优化
This commit is contained in:
@@ -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', '平台海运订舱编号');
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
+1
-1
@@ -15,7 +15,7 @@ import org.apache.ibatis.annotations.Update;
|
||||
|
||||
@Mapper
|
||||
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);
|
||||
@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);
|
||||
|
||||
+7
@@ -36,6 +36,7 @@ import java.util.Set;
|
||||
@Service
|
||||
public class OceanBookingService {
|
||||
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(
|
||||
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<OceanBookingPartyEntity> 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()));
|
||||
|
||||
+19
-10
@@ -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) {
|
||||
|
||||
+15
-5
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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承载';
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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
|
||||
|
||||
+10
@@ -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();
|
||||
|
||||
+2
@@ -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")
|
||||
|
||||
+3
-2
@@ -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));
|
||||
|
||||
+5
@@ -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")
|
||||
|
||||
+12
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
+66
-2
@@ -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);
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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<String> types;
|
||||
|
||||
@@ -35,10 +35,12 @@ public class PartnerImportService {
|
||||
private static final String TEMPLATE_VERSION = "1";
|
||||
private static final Set<String> ALLOWED_TYPES = Set.of(
|
||||
"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 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<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) { }
|
||||
}
|
||||
|
||||
+24
-1
@@ -24,6 +24,8 @@ public class PlatformPartnerQueryService {
|
||||
private static final long PUBLIC_TENANT_ID = 0L;
|
||||
private static final Set<String> ALLOWED_QUERY_TYPES = Set.of(
|
||||
"CARRIER", "SHIPPER", "CONSIGNEE");
|
||||
private static final Set<String> 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<String> 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<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;
|
||||
}
|
||||
}
|
||||
|
||||
+2
-1
@@ -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"), "测试备注");
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user