This commit is contained in:
2026-08-26 14:48:43 +08:00
parent 0f4524c795
commit bf960f10d1
22 changed files with 237 additions and 40 deletions
@@ -9029,6 +9029,7 @@ CREATE TABLE IF NOT EXISTS fl_ocean_booking (
bill_of_lading_no VARCHAR(128) NULL COMMENT '提单号', bill_of_lading_no VARCHAR(128) NULL COMMENT '提单号',
shipping_company_id BIGINT UNSIGNED NULL COMMENT '船公司逻辑ID快照', shipping_company_id BIGINT UNSIGNED NULL COMMENT '船公司逻辑ID快照',
shipping_company_name VARCHAR(128) NULL COMMENT '船公司名称快照', 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_id BIGINT UNSIGNED NULL COMMENT '订舱代理逻辑ID快照',
booking_agent_name VARCHAR(128) NULL COMMENT '订舱代理名称快照', booking_agent_name VARCHAR(128) NULL COMMENT '订舱代理名称快照',
vessel_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_customer CHECK (customer_id > 0),
CONSTRAINT chk_ocean_booking_status CHECK (status IN CONSTRAINT chk_ocean_booking_status CHECK (status IN
('DRAFT','RELEASED')), ('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 CONSTRAINT chk_ocean_booking_schedule CHECK
(estimated_sailing_at IS NULL OR estimated_arrival_at IS NULL OR estimated_arrival_at >= estimated_sailing_at) (estimated_sailing_at IS NULL OR estimated_arrival_at IS NULL OR estimated_arrival_at >= estimated_sailing_at)
) ENGINE=InnoDB COMMENT='独立海运订舱聚合;柜次仍由fl_container_booking承载'; ) 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. -- CREATE TABLE IF NOT EXISTS does not reconcile a database initialized before customer became optional.
ALTER TABLE fl_ocean_booking ALTER TABLE fl_ocean_booking
MODIFY COLUMN customer_id BIGINT UNSIGNED NULL COMMENT '可选客户ID快照', 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', ocean_booking_id BIGINT UNSIGNED NOT NULL COMMENT '关联海运订舱ID',
container_booking_id BIGINT UNSIGNED 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', 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_type VARCHAR(32) NOT NULL COMMENT '官网跟踪类型:BOOKING_NO订舱号、BILL_OF_LADING提单号、CONTAINER_NO箱号',
tracking_no VARCHAR(128) NOT NULL COMMENT '提交至船公司官网的跟踪号码', tracking_no VARCHAR(128) NOT NULL COMMENT '提交至船公司官网的跟踪号码',
official_url VARCHAR(500) 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', status VARCHAR(24) NOT NULL COMMENT '任务操作状态:READY、MANUAL_REQUIRED、STOPPED',
trigger_source VARCHAR(32) NOT NULL DEFAULT 'MANUAL' COMMENT '任务触发来源:OUTBOUND_CONFIRM或MANUAL', trigger_source VARCHAR(32) NOT NULL DEFAULT 'MANUAL' COMMENT '任务触发来源:OUTBOUND_CONFIRM或MANUAL',
latest_standard_node VARCHAR(32) NULL COMMENT '最近实际标准船节点;预计事实不推进', 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 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_customer CHECK (customer_id > 0),
CONSTRAINT chk_carrier_schedule_query_carrier CHECK 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 CONSTRAINT chk_carrier_schedule_query_tracking CHECK
(tracking_type IN ('BOOKING_NO','BILL_OF_LADING','CONTAINER_NO')), (tracking_type IN ('BOOKING_NO','BILL_OF_LADING','CONTAINER_NO')),
CONSTRAINT chk_carrier_schedule_query_access CHECK 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 CONSTRAINT chk_carrier_schedule_query_status CHECK
(status IN ('READY','MANUAL_REQUIRED','STOPPED')), (status IN ('READY','MANUAL_REQUIRED','STOPPED')),
CONSTRAINT chk_carrier_schedule_query_node CHECK 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 与唯一索引。统一收敛到当前 -- CREATE TABLE IF NOT EXISTS 不会替换历史 CHECK 与唯一索引。统一收敛到当前
-- READY/MANUAL_REQUIRED/STOPPED、五种访问方式、十个实际节点和每业务范围唯一任务。 -- READY/MANUAL_REQUIRED/STOPPED、五种访问方式、十个实际节点和每业务范围唯一任务。
ALTER TABLE fl_carrier_schedule_query 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_access,
DROP CHECK chk_carrier_schedule_query_status; DROP CHECK chk_carrier_schedule_query_status;
DROP PROCEDURE IF EXISTS drop_legacy_carrier_schedule_node_check; DROP PROCEDURE IF EXISTS drop_legacy_carrier_schedule_node_check;
@@ -10722,8 +10737,10 @@ DELIMITER ;
CALL drop_legacy_carrier_schedule_node_check(); CALL drop_legacy_carrier_schedule_node_check();
DROP PROCEDURE IF EXISTS drop_legacy_carrier_schedule_node_check; DROP PROCEDURE IF EXISTS drop_legacy_carrier_schedule_node_check;
ALTER TABLE fl_carrier_schedule_query 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 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 ADD CONSTRAINT chk_carrier_schedule_query_status CHECK
(status IN ('READY','MANUAL_REQUIRED','STOPPED')), (status IN ('READY','MANUAL_REQUIRED','STOPPED')),
ADD CONSTRAINT chk_carrier_schedule_query_node CHECK 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', 'weight_kg', '设备实测重量kg');
CALL queue_column_comment('baiyue_firstleg', 'fl_mojia_measurement_receipt', 'width_cm', '设备实测宽度cm'); 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', '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_id', '订舱代理逻辑ID快照');
CALL queue_column_comment('baiyue_firstleg', 'fl_ocean_booking', 'booking_agent_name', '订舱代理名称快照'); CALL queue_column_comment('baiyue_firstleg', 'fl_ocean_booking', 'booking_agent_name', '订舱代理名称快照');
CALL queue_column_comment('baiyue_firstleg', 'fl_ocean_booking', 'booking_no', '平台海运订舱编号'); CALL queue_column_comment('baiyue_firstleg', 'fl_ocean_booking', 'booking_no', '平台海运订舱编号');
@@ -60,6 +60,7 @@ public final class OceanBookingDtos {
@Size(max = 128) String billOfLadingNo, @Size(max = 128) String billOfLadingNo,
@Positive Long shippingCompanyId, @Positive Long shippingCompanyId,
@Size(max = 128) String shippingCompanyName, @Size(max = 128) String shippingCompanyName,
@Size(max = 16) String carrierCode,
@Positive Long bookingAgentId, @Positive Long bookingAgentId,
@Size(max = 128) String bookingAgentName, @Size(max = 128) String bookingAgentName,
@Size(max = 128) String vesselName, @Size(max = 128) String vesselName,
@@ -27,6 +27,7 @@ public class OceanBookingEntity {
private String billOfLadingNo; private String billOfLadingNo;
private Long shippingCompanyId; private Long shippingCompanyId;
private String shippingCompanyName; private String shippingCompanyName;
private String carrierCode;
private Long bookingAgentId; private Long bookingAgentId;
private String bookingAgentName; private String bookingAgentName;
private String vesselName; private String vesselName;
@@ -15,7 +15,7 @@ import org.apache.ibatis.annotations.Update;
@Mapper @Mapper
public interface OceanBookingMapper extends BaseMapper<OceanBookingEntity> { public interface OceanBookingMapper extends BaseMapper<OceanBookingEntity> {
@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); 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}") @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); int deleteDraft(@Param("id") long id, @Param("version") int version);
@@ -36,6 +36,7 @@ import java.util.Set;
@Service @Service
public class OceanBookingService { public class OceanBookingService {
private static final Set<String> PARTY_TYPES = Set.of("SHIPPER", "CONSIGNEE", "NOTIFY_PARTY"); private static final Set<String> PARTY_TYPES = Set.of("SHIPPER", "CONSIGNEE", "NOTIFY_PARTY");
private static final Set<String> CARRIER_CODES = Set.of("MATSON", "SML", "ZIM", "COSCO", "EMC", "HEDE", "OTHER");
private static final Set<OceanBookingStatus> OPERATIONAL_LIST_STATUSES = Set.of( private static final Set<OceanBookingStatus> OPERATIONAL_LIST_STATUSES = Set.of(
OceanBookingStatus.DRAFT, OceanBookingStatus.RELEASED); OceanBookingStatus.DRAFT, OceanBookingStatus.RELEASED);
@Resource private OceanBookingRepository repository; @Resource private OceanBookingRepository repository;
@@ -196,6 +197,10 @@ public class OceanBookingService {
ContainerSpecificationCatalog.requireMatch(container.containerType(), container.capacityCbm(), container.maxWeightKg()); 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 validateRange(LocalDateTime from, LocalDateTime to) { if (from != null && to != null && to.isBefore(from)) throw badRequest("预计到港时间不能早于预计开船时间"); }
private static void validateRelease(OceanBookingEntity booking, List<OceanBookingPartyEntity> parties, private static void validateRelease(OceanBookingEntity booking, List<OceanBookingPartyEntity> parties,
@@ -206,6 +211,7 @@ public class OceanBookingService {
|| !StringUtils.hasText(booking.getVoyageNo()) || !StringUtils.hasText(booking.getServiceTerm()) || !StringUtils.hasText(booking.getVoyageNo()) || !StringUtils.hasText(booking.getServiceTerm())
|| !StringUtils.hasText(booking.getFreightTerm()) || booking.getEstimatedSailingAt() == null || !StringUtils.hasText(booking.getFreightTerm()) || booking.getEstimatedSailingAt() == null
|| booking.getEstimatedArrivalAt() == null || booking.getShippingCompanyId() == null || booking.getEstimatedArrivalAt() == null || booking.getShippingCompanyId() == null
|| !StringUtils.hasText(booking.getCarrierCode())
|| booking.getBookingAgentId() == null || parties == null || parties.size() != 3 || booking.getBookingAgentId() == null || parties == null || parties.size() != 3
|| requirements == null || requirements.isEmpty()) throw badRequest("订舱资料尚未填写完整,无法发布"); || requirements == null || requirements.isEmpty()) throw badRequest("订舱资料尚未填写完整,无法发布");
validateRange(booking.getEstimatedSailingAt(), booking.getEstimatedArrivalAt()); validateRange(booking.getEstimatedSailingAt(), booking.getEstimatedArrivalAt());
@@ -252,6 +258,7 @@ public class OceanBookingService {
v.setWarehouseId(d.warehouseId()); v.setWarehouseName(resolved.warehouseName()); v.setWarehouseId(d.warehouseId()); v.setWarehouseName(resolved.warehouseName());
v.setBillOfLadingNo(trim(d.billOfLadingNo())); v.setBillOfLadingNo(trim(d.billOfLadingNo()));
v.setShippingCompanyId(d.shippingCompanyId()); v.setShippingCompanyName(trim(d.shippingCompanyName())); 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.setBookingAgentId(d.bookingAgentId()); v.setBookingAgentName(resolved.bookingAgentName());
v.setVesselName(trim(d.vesselName())); v.setVoyageNo(trim(d.voyageNo())); v.setVesselName(trim(d.vesselName())); v.setVoyageNo(trim(d.voyageNo()));
v.setDeparturePortCode(trim(d.departurePortCode())); v.setDeparturePortName(trim(d.departurePortName())); v.setDeparturePortCode(trim(d.departurePortCode())); v.setDeparturePortName(trim(d.departurePortName()));
@@ -36,23 +36,26 @@ public class CarrierScheduleAdapterRegistry {
public PreparedAttempt prepare(String carrierCode, String trackingType, String trackingNo) { public PreparedAttempt prepare(String carrierCode, String trackingType, String trackingNo) {
Definition definition = required(carrierCode); Definition definition = required(carrierCode);
boolean automated = automated(definition);
return new PreparedAttempt(definition.code(), definition.accessMode(), return new PreparedAttempt(definition.code(), definition.accessMode(),
officialUrl(definition, trackingType, trackingNo), automated(definition) ? "READY" : "MANUAL_REQUIRED", officialUrl(definition, trackingType, trackingNo), automated ? "READY" : "MANUAL_REQUIRED",
definition.coverageGapCode(), definition.manualReason()); 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) { public String normalize(String carrierCode) {
return required(carrierCode).code(); 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) { private static Definition required(String carrierCode) {
String normalized = carrierCode == null ? "" : carrierCode.trim().toUpperCase(Locale.ROOT); String normalized = carrierCode == null ? "" : carrierCode.trim().toUpperCase(Locale.ROOT);
Definition value = DEFINITIONS.get(normalized); 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, private record Definition(String code, String name, String officialUrl, String accessMode,
String coverageGapCode, String manualReason) { String coverageGapCode, String manualReason) {
CarrierDescriptor descriptor(boolean automated) { CarrierDescriptor descriptor(boolean automated) {
@@ -95,7 +95,6 @@ public class CarrierScheduleQueryService {
ContainerBookingEntity container = portSchedules.containerBooking(containerBookingId); ContainerBookingEntity container = portSchedules.containerBooking(containerBookingId);
if (booking == null || container == null) throw new BizException(ResultCode.NOT_FOUND, "出仓订舱或柜次不存在"); if (booking == null || container == null) throw new BizException(ResultCode.NOT_FOUND, "出仓订舱或柜次不存在");
validateLinks(customerId, oceanBookingId, containerBookingId); validateLinks(customerId, oceanBookingId, containerBookingId);
String carrierCode = adapters.resolve(booking.getShippingCompanyName());
String trackingType; String trackingType;
String trackingNo; String trackingNo;
if (StringUtils.hasText(container.getContainerNo())) { if (StringUtils.hasText(container.getContainerNo())) {
@@ -107,18 +106,20 @@ public class CarrierScheduleQueryService {
} else { } else {
throw badRequest("确认国内出仓前必须具备柜号、提单号或订舱号之一"); throw badRequest("确认国内出仓前必须具备柜号、提单号或订舱号之一");
} }
PreparedAttempt strategy = adapters.prepare(carrierCode, trackingType, trackingNo); PreparedAttempt strategy = adapters.prepareOutbound(booking.getCarrierCode(), trackingType, trackingNo);
LocalDateTime now = now(); LocalDateTime now = now();
CarrierScheduleQueryEntity value = new CarrierScheduleQueryEntity(); CarrierScheduleQueryEntity value = new CarrierScheduleQueryEntity();
value.setId(IdWorker.getId()); value.setQueryNo("CSQ" + value.getId()); value.setId(IdWorker.getId()); value.setQueryNo("CSQ" + value.getId());
value.setCustomerId(customerId); value.setOceanBookingId(oceanBookingId); 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.setTrackingType(trackingType); value.setTrackingNo(trackingNo);
value.setOfficialUrl(strategy.officialUrl()); value.setAccessMode(strategy.accessMode()); 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.setLatestStandardNode(CarrierScheduleNode.DEPARTED_WAREHOUSE.name());
value.setLatestNodeObservedAt(now); value.setWindowStartedAt(now); value.setAttemptCount(0); 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.setCreatedBy(operatorId); value.setCreatedByName(operatorName);
value.setUpdatedBy(operatorId); value.setUpdatedByName(operatorName); value.setVersion(0); value.setUpdatedBy(operatorId); value.setUpdatedByName(operatorName); value.setVersion(0);
try { repository.insert(value); } try { repository.insert(value); }
@@ -137,6 +138,15 @@ public class CarrierScheduleQueryService {
return value; 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 @Transactional
public Detail applyProviderObservation(long id, ProviderObservationRequest request) { public Detail applyProviderObservation(long id, ProviderObservationRequest request) {
CarrierScheduleQueryEntity value = requiredLocked(id); CarrierScheduleQueryEntity value = requiredLocked(id);
@@ -8,11 +8,11 @@ CREATE TABLE IF NOT EXISTS fl_carrier_schedule_query (
ocean_booking_id BIGINT UNSIGNED NOT NULL COMMENT '关联海运订舱ID', ocean_booking_id BIGINT UNSIGNED NOT NULL COMMENT '关联海运订舱ID',
container_booking_id BIGINT UNSIGNED 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', 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_type VARCHAR(32) NOT NULL COMMENT '官网跟踪类型:BOOKING_NO订舱号、BILL_OF_LADING提单号、CONTAINER_NO箱号',
tracking_no VARCHAR(128) NOT NULL COMMENT '提交至船公司官网的跟踪号码', tracking_no VARCHAR(128) NOT NULL COMMENT '提交至船公司官网的跟踪号码',
official_url VARCHAR(500) 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', status VARCHAR(24) NOT NULL COMMENT '任务操作状态:READY、MANUAL_REQUIRED、STOPPED',
trigger_source VARCHAR(32) NOT NULL DEFAULT 'MANUAL' COMMENT '任务触发来源:OUTBOUND_CONFIRM或MANUAL', trigger_source VARCHAR(32) NOT NULL DEFAULT 'MANUAL' COMMENT '任务触发来源:OUTBOUND_CONFIRM或MANUAL',
latest_standard_node VARCHAR(32) NULL COMMENT '最近实际标准船节点;预计事实不推进', 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 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_customer CHECK (customer_id > 0),
CONSTRAINT chk_carrier_schedule_query_carrier CHECK 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 CONSTRAINT chk_carrier_schedule_query_tracking CHECK
(tracking_type IN ('BOOKING_NO','BILL_OF_LADING','CONTAINER_NO')), (tracking_type IN ('BOOKING_NO','BILL_OF_LADING','CONTAINER_NO')),
CONSTRAINT chk_carrier_schedule_query_access CHECK 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 CONSTRAINT chk_carrier_schedule_query_status CHECK
(status IN ('READY','MANUAL_REQUIRED','STOPPED')), (status IN ('READY','MANUAL_REQUIRED','STOPPED')),
CONSTRAINT chk_carrier_schedule_query_node CHECK CONSTRAINT chk_carrier_schedule_query_node CHECK
@@ -14,6 +14,7 @@ CREATE TABLE IF NOT EXISTS fl_ocean_booking (
bill_of_lading_no VARCHAR(128) NULL COMMENT '提单号', bill_of_lading_no VARCHAR(128) NULL COMMENT '提单号',
shipping_company_id BIGINT UNSIGNED NULL COMMENT '船公司逻辑ID快照', shipping_company_id BIGINT UNSIGNED NULL COMMENT '船公司逻辑ID快照',
shipping_company_name VARCHAR(128) NULL COMMENT '船公司名称快照', 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_id BIGINT UNSIGNED NULL COMMENT '订舱代理逻辑ID快照',
booking_agent_name VARCHAR(128) NULL COMMENT '订舱代理名称快照', booking_agent_name VARCHAR(128) NULL COMMENT '订舱代理名称快照',
vessel_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_customer CHECK (customer_id > 0),
CONSTRAINT chk_ocean_booking_status CHECK (status IN CONSTRAINT chk_ocean_booking_status CHECK (status IN
('DRAFT','RELEASED')), ('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 CONSTRAINT chk_ocean_booking_schedule CHECK
(estimated_sailing_at IS NULL OR estimated_arrival_at IS NULL OR estimated_arrival_at >= estimated_sailing_at) (estimated_sailing_at IS NULL OR estimated_arrival_at IS NULL OR estimated_arrival_at >= estimated_sailing_at)
) ENGINE=InnoDB COMMENT='独立海运订舱聚合;柜次仍由fl_container_booking承载'; ) ENGINE=InnoDB COMMENT='独立海运订舱聚合;柜次仍由fl_container_booking承载';
@@ -90,6 +90,10 @@ components:
IdempotencyKey: {name: Idempotency-Key, in: header, required: true, schema: {type: string, minLength: 1, maxLength: 128}} IdempotencyKey: {name: Idempotency-Key, in: header, required: true, schema: {type: string, minLength: 1, maxLength: 128}}
schemas: schemas:
CarrierCode: {type: string, enum: [MATSON, SML, ZIM, COSCO, EMC, HEDE]} 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]} TrackingType: {type: string, enum: [BOOKING_NO, BILL_OF_LADING, CONTAINER_NO]}
QueryStatus: {type: string, enum: [READY, MANUAL_REQUIRED, STOPPED]} QueryStatus: {type: string, enum: [READY, MANUAL_REQUIRED, STOPPED]}
StandardNode: StandardNode:
@@ -137,11 +141,15 @@ components:
customerId: {type: integer, format: int64} customerId: {type: integer, format: int64}
oceanBookingId: {type: integer, format: int64} oceanBookingId: {type: integer, format: int64}
containerBookingId: {type: integer, format: int64, nullable: true} containerBookingId: {type: integer, format: int64, nullable: true}
carrierCode: {$ref: '#/components/schemas/CarrierCode'} carrierCode: {$ref: '#/components/schemas/TaskCarrierCode'}
trackingType: {$ref: '#/components/schemas/TrackingType'} trackingType: {$ref: '#/components/schemas/TrackingType'}
trackingNo: {type: string} trackingNo: {type: string}
officialUrl: {type: string, nullable: true} 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'} status: {$ref: '#/components/schemas/QueryStatus'}
triggerSource: {type: string} triggerSource: {type: string}
latestActualNode: {allOf: [{$ref: '#/components/schemas/StandardNode'}], nullable: true} latestActualNode: {allOf: [{$ref: '#/components/schemas/StandardNode'}], nullable: true}
@@ -150,7 +158,10 @@ components:
sourceUpdatedAt: {type: string, format: date-time, nullable: true} sourceUpdatedAt: {type: string, format: date-time, nullable: true}
expiresAt: {type: string, format: date-time, nullable: true} expiresAt: {type: string, format: date-time, nullable: true}
resultPortScheduleId: {type: integer, format: int64, 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} lastErrorCode: {type: string, nullable: true}
lastErrorMessage: {type: string, nullable: true} lastErrorMessage: {type: string, nullable: true}
stoppedAt: {type: string, format: date-time, nullable: true} stoppedAt: {type: string, format: date-time, nullable: true}
@@ -80,6 +80,11 @@ components:
billOfLadingNo: {type: string, nullable: true} billOfLadingNo: {type: string, nullable: true}
shippingCompanyId: {type: integer, format: int64, nullable: true} shippingCompanyId: {type: integer, format: int64, nullable: true}
shippingCompanyName: {type: string, 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: bookingAgentId:
type: integer type: integer
format: int64 format: int64
@@ -86,6 +86,16 @@ class PlatformOceanBookingControllerContractTest {
.contains("Sum of all container requirement quantities; independent of allocation, linking or unlinking."); .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) { private static Method method(String name) {
return Arrays.stream(PlatformOceanBookingController.class.getDeclaredMethods()) return Arrays.stream(PlatformOceanBookingController.class.getDeclaredMethods())
.filter(method -> method.getName().equals(name)).findFirst().orElseThrow(); .filter(method -> method.getName().equals(name)).findFirst().orElseThrow();
@@ -24,6 +24,8 @@ class OceanBookingSqlContractTest {
.contains("MODIFY COLUMN customer_name VARCHAR(128) NULL COMMENT '可选客户名称快照'") .contains("MODIFY COLUMN customer_name VARCHAR(128) NULL COMMENT '可选客户名称快照'")
.contains("service_term VARCHAR(32) NULL COMMENT '运输服务条款快照;发布前必填'") .contains("service_term VARCHAR(32) NULL COMMENT '运输服务条款快照;发布前必填'")
.contains("freight_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("container_booking_id BIGINT UNSIGNED NOT NULL COMMENT '明确引用现有柜次ID'")
.contains("REFERENCES fl_container_booking(id)") .contains("REFERENCES fl_container_booking(id)")
.contains("REFERENCES fl_ocean_booking(id) ON DELETE CASCADE") .contains("REFERENCES fl_ocean_booking(id) ON DELETE CASCADE")
@@ -259,12 +259,12 @@ class OceanBookingServiceTest {
} }
private static DraftData partialDraft(String remark) { 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); null, null, null, null, null, null, null, null, null, null, null, remark, null, null);
} }
private static DraftData completeDraft() { 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", "洛杉矶", 81L, null, "VESSEL", "V001", "CNSZX", "深圳", "USLAX", "洛杉矶",
"LAX", "CY-CY", "PREPAID", LocalDateTime.of(2026, 9, 1, 0, 0), "LAX", "CY-CY", "PREPAID", LocalDateTime.of(2026, 9, 1, 0, 0),
LocalDateTime.of(2026, 9, 20, 0, 0), null, LocalDateTime.of(2026, 9, 20, 0, 0), null,
@@ -279,6 +279,7 @@ class OceanBookingServiceTest {
OceanBookingEntity value = booking(status, version); OceanBookingEntity value = booking(status, version);
value.setCustomerId(100L); value.setCustomerName("客户"); value.setChannelCode("SEA-US"); value.setCustomerId(100L); value.setCustomerName("客户"); value.setChannelCode("SEA-US");
value.setWarehouseId(701L); value.setBillOfLadingNo("BL-001"); value.setShippingCompanyId(71L); value.setWarehouseId(701L); value.setBillOfLadingNo("BL-001"); value.setShippingCompanyId(71L);
value.setCarrierCode("MATSON");
value.setBookingAgentId(81L); value.setVesselName("VESSEL"); value.setVoyageNo("V001"); value.setBookingAgentId(81L); value.setVesselName("VESSEL"); value.setVoyageNo("V001");
value.setServiceTerm("CY-CY"); value.setFreightTerm("PREPAID"); value.setServiceTerm("CY-CY"); value.setFreightTerm("PREPAID");
value.setEstimatedSailingAt(LocalDateTime.of(2026, 9, 1, 0, 0)); value.setEstimatedSailingAt(LocalDateTime.of(2026, 9, 1, 0, 0));
@@ -63,6 +63,8 @@ class CarrierScheduleQuerySqlContractTest {
.contains("'EXCEPTION_OBSERVED','PROVIDER_QUERY','STOP'") .contains("'EXCEPTION_OBSERVED','PROVIDER_QUERY','STOP'")
.contains("provider_event_id") .contains("provider_event_id")
.contains("'PUBLIC_FORM','PUBLIC_WEB_PROXY','PUBLIC_HTML','HCAPTCHA','OFFICIAL_API'") .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("lease_owner")
.contains("consecutive_failures") .contains("consecutive_failures")
.doesNotContain("status IN ('READY','MANUAL_REQUIRED','ARRIVED'"); .doesNotContain("status IN ('READY','MANUAL_REQUIRED','ARRIVED'");
@@ -102,11 +104,14 @@ class CarrierScheduleQuerySqlContractTest {
.contains("ADD COLUMN next_allowed_at DATETIME(3) NULL") .contains("ADD COLUMN next_allowed_at DATETIME(3) NULL")
.contains("ADD KEY idx_carrier_schedule_query_due (status,next_allowed_at,id)") .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_access")
.contains("DROP CHECK chk_carrier_schedule_query_carrier")
.contains("DROP CHECK chk_carrier_schedule_query_status") .contains("DROP CHECK chk_carrier_schedule_query_status")
.contains("DROP INDEX uk_carrier_schedule_query_scope") .contains("DROP INDEX uk_carrier_schedule_query_scope")
.contains("ADD UNIQUE KEY uk_carrier_schedule_query_scope") .contains("ADD UNIQUE KEY uk_carrier_schedule_query_scope")
.contains("(customer_id,ocean_booking_id,container_scope_id)") .contains("(customer_id,ocean_booking_id,container_scope_id)")
.contains("'PUBLIC_FORM','PUBLIC_WEB_PROXY','PUBLIC_HTML','HCAPTCHA','OFFICIAL_API'") .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("status IN ('READY','MANUAL_REQUIRED','STOPPED')")
.contains("ARRIVED_DESTINATION_PORT") .contains("ARRIVED_DESTINATION_PORT")
.contains("EMPTY_RETURNED") .contains("EMPTY_RETURNED")
@@ -39,4 +39,16 @@ class CarrierScheduleAdapterRegistryTest {
assertThatThrownBy(() -> registry.prepare("UNKNOWN", "BOOKING_NO", "X")) assertThatThrownBy(() -> registry.prepare("UNKNOWN", "BOOKING_NO", "X"))
.hasMessageContaining("仅支持MATSON"); .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");
}
} }
@@ -47,12 +47,14 @@ class CarrierScheduleQueryServiceTest {
@Mock private PortScheduleRepository portSchedules; @Mock private PortScheduleRepository portSchedules;
@Mock private CarrierScheduleNodeOutboxService nodeOutbox; @Mock private CarrierScheduleNodeOutboxService nodeOutbox;
@InjectMocks private CarrierScheduleQueryService service; @InjectMocks private CarrierScheduleQueryService service;
private CarrierScheduleAdapterRegistry adapters;
@BeforeEach @BeforeEach
void setUp() throws Exception { void setUp() throws Exception {
MockitoAnnotations.openMocks(this); MockitoAnnotations.openMocks(this);
set("objectMapper", new ObjectMapper().findAndRegisterModules()); 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)); 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.claimOperation(any(), anyLong(), anyString(), anyString(), anyString())).thenReturn(true);
when(repository.completeOperation(anyLong(), anyLong(), anyString(), anyString())).thenReturn(1); when(repository.completeOperation(anyLong(), anyLong(), anyString(), anyString())).thenReturn(1);
@@ -85,7 +87,7 @@ class CarrierScheduleQueryServiceTest {
@Test @Test
void outboundConfirmationCreatesTaskWithContainerTrackingAndDepartureNode() { void outboundConfirmationCreatesTaskWithContainerTrackingAndDepartureNode() {
OceanBookingEntity booking = new OceanBookingEntity(); booking.setCustomerId(10001L); 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(); var container = new com.baiyue.firstleg.entity.ContainerBookingEntity();
container.setContainerNo("CSNU1234567"); container.setContainerNo("CSNU1234567");
when(portSchedules.oceanBooking(10L)).thenReturn(booking); when(portSchedules.oceanBooking(10L)).thenReturn(booking);
@@ -96,12 +98,74 @@ class CarrierScheduleQueryServiceTest {
assertThat(result.getTriggerSource()).isEqualTo("OUTBOUND_CONFIRM"); assertThat(result.getTriggerSource()).isEqualTo("OUTBOUND_CONFIRM");
assertThat(result.getCarrierCode()).isEqualTo("COSCO"); 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.getTrackingType()).isEqualTo("CONTAINER_NO");
assertThat(result.getLatestStandardNode()).isEqualTo("DEPARTED_WAREHOUSE"); assertThat(result.getLatestStandardNode()).isEqualTo("DEPARTED_WAREHOUSE");
assertThat(result.getNextAllowedAt()).isEqualTo(LocalDateTime.of(2026, 8, 10, 12, 30)); assertThat(result.getNextAllowedAt()).isEqualTo(LocalDateTime.of(2026, 8, 10, 12, 30));
verify(repository).insertIfAbsent(any(com.baiyue.firstleg.portschedule.entity.CarrierScheduleNodeFactEntity.class)); 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 @Test
void providerActualObservationAdvancesBusinessNodeWithoutChangingTaskStatus() { void providerActualObservationAdvancesBusinessNodeWithoutChangingTaskStatus() {
CarrierScheduleQueryEntity task = task(0, 0); CarrierScheduleQueryEntity task = task(0, 0);
@@ -71,6 +71,9 @@ public final class MasterDataQueries {
@NotBlank @Size(max = 128) String partnerName, @NotBlank @Size(max = 128) String partnerName,
@Schema(description = "单值单位类型", allowableValues = {"CARRIER", "SHIPPER", "CONSIGNEE"}) @Schema(description = "单值单位类型", allowableValues = {"CARRIER", "SHIPPER", "CONSIGNEE"})
@Pattern(regexp = "^(CARRIER|SHIPPER|CONSIGNEE)$") String partnerType, @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 = "张三") @Schema(description = "联系人", example = "张三")
@NotBlank @Size(max = 64) String contactName, @NotBlank @Size(max = 64) String contactName,
@Schema(description = "联系电话", example = "13800138000") @Schema(description = "联系电话", example = "13800138000")
@@ -28,6 +28,9 @@ public class PartnerEntity extends BaseEntity {
private String partnerName; private String partnerName;
@Schema(description = "类型", example = "CARRIER", allowableValues = {"CARRIER", "SHIPPER", "CONSIGNEE"}) @Schema(description = "类型", example = "CARRIER", allowableValues = {"CARRIER", "SHIPPER", "CONSIGNEE"})
private String partnerType; private String partnerType;
@Schema(description = "船司标准编码;船公司必填,其他类型必须为空", example = "MATSON",
allowableValues = {"MATSON", "SML", "ZIM", "COSCO", "EMC", "HEDE", "OTHER"})
private String carrierCode;
@TableField(exist = false) @TableField(exist = false)
@Schema(description = "单位类型集合", allowableValues = {"CARRIER", "SHIPPER", "CONSIGNEE"}) @Schema(description = "单位类型集合", allowableValues = {"CARRIER", "SHIPPER", "CONSIGNEE"})
private List<String> types; private List<String> types;
@@ -35,10 +35,12 @@ public class PartnerImportService {
private static final String TEMPLATE_VERSION = "1"; private static final String TEMPLATE_VERSION = "1";
private static final Set<String> ALLOWED_TYPES = Set.of( private static final Set<String> ALLOWED_TYPES = Set.of(
"CARRIER", "SHIPPER", "CONSIGNEE"); "CARRIER", "SHIPPER", "CONSIGNEE");
private static final Set<String> 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 Pattern EMAIL = Pattern.compile("^[^\\s@]+@[^\\s@]+\\.[^\\s@]+$");
private static final String[] HEADERS = { private static final String[] HEADERS = {
"单位编码*", "单位名称*", "单位类型*", "联系人*", "联系电话*", "联系邮箱", "单位编码*", "单位名称*", "单位类型*", "联系人*", "联系电话*", "联系邮箱",
"国家二字码*", "省/州", "城市*", "区/县", "详细地址*", "邮政编码", "状态*", "备注" "船司标准编码", "国家二字码*", "省/州", "城市*", "区/县", "详细地址*", "邮政编码", "状态*", "备注"
}; };
@Resource @Resource
@@ -107,9 +109,9 @@ public class PartnerImportService {
ImportRow item = new ImportRow(index + 1, ImportRow item = new ImportRow(index + 1,
value(row, 0, formatter), value(row, 1, formatter), parseTypes(value(row, 2, formatter)), 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, 3, formatter), value(row, 4, formatter), value(row, 5, formatter),
value(row, 6, formatter).toUpperCase(Locale.ROOT), value(row, 7, 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, 8, formatter), value(row, 9, formatter), value(row, 10, formatter), value(row, 11, formatter),
value(row, 11, formatter), value(row, 12, formatter).toUpperCase(Locale.ROOT), value(row, 13, 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); validate(tenantId, item, rows, errors);
rows.add(item); rows.add(item);
} }
@@ -134,6 +136,11 @@ public class PartnerImportService {
if (row.types.isEmpty()) errors.add(error(row, "单位类型", "至少填写一个类型")); if (row.types.isEmpty()) errors.add(error(row, "单位类型", "至少填写一个类型"));
row.types.stream().filter(type -> !ALLOWED_TYPES.contains(type)) row.types.stream().filter(type -> !ALLOWED_TYPES.contains(type))
.forEach(type -> errors.add(error(row, "单位类型", "不支持类型 " + 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.contactName, 64);
required(row, errors, "联系电话", row.contactPhone, 32); required(row, errors, "联系电话", row.contactPhone, 32);
if (row.email.length() > 128 || (!row.email.isBlank() && !EMAIL.matcher(row.email).matches())) { 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.setPartnerCode(row.code);
entity.setPartnerName(row.name); entity.setPartnerName(row.name);
entity.setTypes(row.types); 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.setContactName(row.contactName);
entity.setContactPhone(row.contactPhone); entity.setContactPhone(row.contactPhone);
entity.setContactEmail(nullable(row.email)); entity.setContactEmail(nullable(row.email));
@@ -216,6 +224,6 @@ public class PartnerImportService {
} }
private record ImportRow(int row, String code, String name, List<String> types, String contactName, private record ImportRow(int row, String code, String name, List<String> 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) { } String district, String address, String postalCode, String status, String remark) { }
} }
@@ -24,6 +24,8 @@ public class PlatformPartnerQueryService {
private static final long PUBLIC_TENANT_ID = 0L; private static final long PUBLIC_TENANT_ID = 0L;
private static final Set<String> ALLOWED_QUERY_TYPES = Set.of( private static final Set<String> ALLOWED_QUERY_TYPES = Set.of(
"CARRIER", "SHIPPER", "CONSIGNEE"); "CARRIER", "SHIPPER", "CONSIGNEE");
private static final Set<String> ALLOWED_CARRIER_CODES = Set.of(
"MATSON", "SML", "ZIM", "COSCO", "EMC", "HEDE", "OTHER");
@Resource @Resource
private PartnerRepository repository; private PartnerRepository repository;
@@ -121,10 +123,12 @@ public class PlatformPartnerQueryService {
private static void copy(PartnerEntity entity, PartnerSaveRequest request) { private static void copy(PartnerEntity entity, PartnerSaveRequest request) {
List<String> types = normalizeTypes(request.types(), request.partnerType()); List<String> types = normalizeTypes(request.types(), request.partnerType());
String carrierCode = normalizeCarrierCode(types, request.carrierCode());
entity.setPartnerCode(request.partnerCode()); entity.setPartnerCode(request.partnerCode());
entity.setPartnerName(request.partnerName()); entity.setPartnerName(request.partnerName());
entity.setTypes(types); entity.setTypes(types);
entity.setPartnerType(legacyType(types)); entity.setPartnerType(legacyType(types));
entity.setCarrierCode(carrierCode);
entity.setContactName(request.contactName()); entity.setContactName(request.contactName());
entity.setContactPhone(request.contactPhone()); entity.setContactPhone(request.contactPhone());
entity.setContactEmail(request.contactEmail()); entity.setContactEmail(request.contactEmail());
@@ -154,6 +158,25 @@ public class PlatformPartnerQueryService {
} }
private static String legacyType(List<String> types) { private static String legacyType(List<String> types) {
return types.get(0); return types.contains("CARRIER") ? "CARRIER" : types.get(0);
}
static String normalizeCarrierCode(List<String> 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;
} }
} }
@@ -79,6 +79,7 @@ class PlatformMasterDataManagementServiceTest {
assertThat(entity.getValue().getPartnerCode()).isEqualTo("CUS-001"); assertThat(entity.getValue().getPartnerCode()).isEqualTo("CUS-001");
assertThat(entity.getValue().getPartnerName()).isEqualTo("示例客户"); assertThat(entity.getValue().getPartnerName()).isEqualTo("示例客户");
assertThat(entity.getValue().getTypes()).containsExactly("CARRIER", "SHIPPER"); assertThat(entity.getValue().getTypes()).containsExactly("CARRIER", "SHIPPER");
assertThat(entity.getValue().getCarrierCode()).isEqualTo("OTHER");
assertThat(entity.getValue().getRemark()).isEqualTo("测试备注"); assertThat(entity.getValue().getRemark()).isEqualTo("测试备注");
} }
@@ -210,7 +211,7 @@ class PlatformMasterDataManagementServiceTest {
} }
private static PartnerSaveRequest partnerRequest() { 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", "test@example.com", "CN", "广东省", "深圳市", "南山区", "示例地址", "518000", "ACTIVE",
java.util.List.of("CARRIER", "SHIPPER"), "测试备注"); java.util.List.of("CARRIER", "SHIPPER"), "测试备注");
} }