fix: complete October 22 workflow feedback and request failure recovery

This commit is contained in:
2026-10-22 23:06:00 +08:00
parent f701456de1
commit a16a85e14a
85 changed files with 1518 additions and 277 deletions
@@ -211,6 +211,7 @@ public class ContainerCustomsService {
return platformView(planId);
}
/** 全部预录单经客户确认后发送报关行执行,保留版本与发送状态校验。 */
@Transactional
public ContainerView submitToBroker(long planId, long operatorId, String operatorName, String key, ActionRequest request) {
String action = "SUBMIT_CUSTOMS_TO_BROKER_" + planId;
@@ -221,8 +222,8 @@ public class ContainerCustomsService {
ContainerRow container = mapper.lockContainer(planId);
if (container == null || !DECLARING.equals(container.status())) throw conflict("当前柜不在报关中");
if (!Objects.equals(container.version(), request.expectedVersion())) throw concurrent();
if (mapper.taxRefundCount(container.loadingPlanId()) == 0) throw conflict("全买单柜无需发送客户回执给报关行");
if (mapper.unconfirmedReceiptCount(container.id()) != 0) throw conflict("仍有报关行回执未完成客户确认");
if (mapper.taxRefundCount(container.loadingPlanId()) == 0) throw conflict("全买单柜无需发送预录单确认结果给报关行");
if (mapper.unconfirmedReceiptCount(container.id()) != 0) throw conflict("仍有预录单未完成客户确认");
if (mapper.brokerSubmission(container.id()) == null) throw conflict("当前柜尚未完成首次发送报关行资料");
if (mapper.brokerSubmittedAt(container.id()) != null) throw conflict("当前柜已发送给报关行执行");
if (mapper.submitToBroker(container.id(), container.version(), operatorId, operatorName) != 1) throw concurrent();
@@ -231,6 +232,7 @@ public class ContainerCustomsService {
return platformView(planId);
}
/** 上传预录单并保存文件版本,已发送报关行执行后仍禁止替换。 */
@Transactional
public DocumentView uploadCompletion(long documentId, long operatorId, String operatorName, String key,
int expectedVersion, MultipartFile file) {
@@ -246,7 +248,7 @@ public class ContainerCustomsService {
throw conflict("当前客户资料不在报关中冻结版本");
}
if (mapper.brokerSubmittedAt(container.id()) != null) {
throw conflict("客户确认回执已发送给报关行,不可再修改回执");
throw conflict("客户确认的预录单已发送给报关行,不可再修改预录单");
}
if (mapper.brokerSubmission(container.id()) == null) {
throw conflict("当前柜尚未完成首次发送报关行资料");
@@ -300,6 +302,7 @@ public class ContainerCustomsService {
return merchantViewForDocument(tenantId, source);
}
/** 预录单确认并发送报关行执行后,接收真实报关完税文件。 */
@Transactional
public DocumentView uploadFinalDocument(long documentId, long operatorId, String operatorName, String key,
int expectedVersion, MultipartFile file) {
@@ -316,7 +319,7 @@ public class ContainerCustomsService {
}
if (mapper.brokerSubmittedAt(container.id()) == null) throw conflict("客户确认结果尚未发送给报关行");
CompletionRow receipt = mapper.lockCompletion(documentId);
if (receipt == null || !"CONFIRMED".equals(receipt.customerConfirmStatus())) throw conflict("该客户回执尚未确认");
if (receipt == null || !"CONFIRMED".equals(receipt.customerConfirmStatus())) throw conflict("该客户的预录单尚未确认");
FinalRow existing = mapper.lockFinalDocument(documentId);
CustomsFileClient.Metadata metadata = upload(source.tenantId(), RESULT_FILE_TYPE, Long.toString(container.loadingPlanId()), operatorId, file);
long finalId;
@@ -371,7 +374,7 @@ public class ContainerCustomsService {
if (container == null || !(DECLARING.equals(container.status()) || COMPLETED.equals(container.status()))) throw conflict("当前柜尚未进入报关行执行阶段,不能创建正式申报批次");
if (mapper.taxRefundCount(planId) == 0) throw conflict("全买单柜不创建退税正式申报批次");
if (mapper.unconfirmedReceiptCount(container.id()) != 0 || mapper.brokerSubmittedAt(container.id()) == null) {
throw conflict("当前柜尚未完成退税资料、回执确认和报关行执行");
throw conflict("当前柜尚未完成退税资料、预录单确认和报关行执行");
}
if (!Objects.equals(container.version(), request.expectedVersion())) throw concurrent();
long batchId = IdWorker.getId();
@@ -536,14 +539,15 @@ public class ContainerCustomsService {
row.operatorName(), row.createdAt())).toList();
}
/** 按既有客户范围与可见状态下载预录单,沿用文件真实名称。 */
public Download downloadCompletion(long tenantId, long documentId, boolean platform) {
DocumentRow source = requiredDocument(documentId);
ContainerRow container = mapper.containerById(source.customsContainerId());
if (!platform && (source.tenantId() != tenantId || container == null || !Set.of(DECLARING, COMPLETED).contains(container.status()))) {
throw notFound("报关行回执不存在");
throw notFound("预录单不存在");
}
CompletionRow completion = mapper.completion(documentId);
if (completion == null) throw notFound("报关行回执不存在");
if (completion == null) throw notFound("预录单不存在");
return download(source.tenantId(), RECEIPT_FILE_TYPE, Long.toString(container.loadingPlanId()), completion.fileId(), completion.fileName());
}
@@ -126,6 +126,17 @@ public class PlatformOceanBookingController {
@PutMapping("/{oceanBookingId}") @PreAuthorize("hasAuthority('platform:firstleg:ocean-booking:edit')")
@BusinessAudit(event="OCEAN_BOOKING_UPDATE_DRAFT", resourceType="OCEAN_BOOKING", resourceId="#oceanBookingId", description="编辑海运订舱草稿")
public ApiResponse<Detail> updateDraft(@AuthenticationPrincipal AuthenticatedUser user, @PathVariable @Positive long oceanBookingId, @RequestHeader("Idempotency-Key") String key, @Valid @RequestBody UpdateRequest request) { validateOptionalCustomer(user, request.data().customerId()); return ApiResponse.success(service.updateDraft(oceanBookingId, user.userId(), user.username(), key, request)); }
/** 放舱后单独维护预计到港,其他运输资料仍冻结。 */
@PutMapping("/{oceanBookingId}/estimated-arrival")
@PreAuthorize("hasAuthority('platform:firstleg:ocean-booking:edit')")
@BusinessAudit(event="OCEAN_BOOKING_UPDATE_ESTIMATED_ARRIVAL", resourceType="OCEAN_BOOKING", resourceId="#oceanBookingId", description="维护订舱预计到港")
public ApiResponse<Detail> updateEstimatedArrival(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable @Positive long oceanBookingId, @RequestHeader("Idempotency-Key") String key,
@Valid @RequestBody EstimatedArrivalRequest request) {
FirstlegScopeGuard.platform(user);
return ApiResponse.success(service.updateEstimatedArrival(oceanBookingId, user.userId(), user.username(), key, request));
}
/** 客服在放舱后补录实货量,沿用订舱编辑权限并记录审计。 */
@PutMapping("/{oceanBookingId}/actual-cargo")
@PreAuthorize("hasAuthority('platform:firstleg:ocean-booking:edit')")
@@ -95,6 +95,10 @@ public final class OceanBookingDtos {
@jakarta.validation.constraints.DecimalMin(value = "0", inclusive = false)
@jakarta.validation.constraints.Digits(integer = 12, fraction = 3) BigDecimal actualVolumeCbm) {}
/** 放舱后仅维护预计到港,不接收其他运输资料。 */
public record EstimatedArrivalRequest(@NotNull @Min(0) Integer expectedVersion,
@NotNull LocalDateTime estimatedArrivalAt) {}
/** 放舱后的受控实货量补录,不接收运输资料。 */
public record ActualCargoRequest(@NotNull @Min(0) Integer expectedVersion,
@Min(1) @Max(999999999) Integer actualPackageCount,
@@ -27,6 +27,11 @@ public interface OceanBookingMapper extends BaseMapper<OceanBookingEntity> {
@Param("operatorId") long operatorId, @Param("operatorName") String operatorName);
@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},estimated_sailing_at=#{value.estimatedSailingAt},estimated_arrival_at=#{value.estimatedArrivalAt},remark=#{value.remark},freight_term=#{value.freightTerm},service_term=#{value.serviceTerm},package_unit=#{value.packageUnit},cargo_description=#{value.cargoDescription},marks_and_nos=#{value.marksAndNos},declared_package_count=#{value.declaredPackageCount},declared_gross_weight_kg=#{value.declaredGrossWeightKg},declared_volume_cbm=#{value.declaredVolumeCbm},actual_package_count=#{value.actualPackageCount},actual_gross_weight_kg=#{value.actualGrossWeightKg},actual_volume_cbm=#{value.actualVolumeCbm},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);
/** 已放舱预计到港窄更新,保留状态、版本和操作审计。 */
@Update("UPDATE fl_ocean_booking SET estimated_arrival_at=#{estimatedArrivalAt},updated_by=#{operatorId},updated_by_name=#{operatorName},updated_at=CURRENT_TIMESTAMP(3),version=version+1 WHERE id=#{id} AND status='RELEASED' AND version=#{version}")
int updateEstimatedArrival(@Param("id") long id, @Param("estimatedArrivalAt") java.time.LocalDateTime estimatedArrivalAt,
@Param("version") int version, @Param("operatorId") long operatorId, @Param("operatorName") String operatorName);
/** 已放舱补实只更新白名单字段,保留乐观锁及状态门禁。 */
@Update("""
UPDATE fl_ocean_booking SET actual_package_count=#{value.actualPackageCount},
@@ -54,6 +54,11 @@ public class OceanBookingRepository {
public int touchDraft(long id, int version, long operatorId, String operatorName) {
return bookings.touchDraft(id, version, operatorId, operatorName);
}
/** 按订舱版本仅更新已放舱预计到港,不重写其他运输或货量资料。 */
public int updateEstimatedArrival(long id, java.time.LocalDateTime estimatedArrivalAt, int version, long operatorId, String operatorName) {
return bookings.updateEstimatedArrival(id, estimatedArrivalAt, version, operatorId, operatorName);
}
/** 只更新已放舱订舱的实际货量,运输快照保持冻结。 */
public int updateActualCargo(OceanBookingEntity value, int version, long operatorId, String operatorName) {
return bookings.updateActualCargo(value, version, operatorId, operatorName);
@@ -145,6 +145,24 @@ public class OceanBookingService {
addHistory(id, OceanBookingStatus.DRAFT.name(), OceanBookingStatus.DRAFT.name(), action, null, before, after, operatorId, operatorName);
return complete(id, operatorId, action, key, detail(after));
}
/** 放舱后单独维护预计到港,按版本和幂等键保存并审计前后快照。 */
@Transactional
public Detail updateEstimatedArrival(long id, long operatorId, String name, String key, EstimatedArrivalRequest request) {
String action = "UPDATE_ESTIMATED_ARRIVAL";
Detail replay = claimOrReplay(id, operatorId, action, key, hash(action, id, request));
if (replay != null) return replay;
OceanBookingEntity before = requiredVersionForUpdate(id, request.expectedVersion());
requireStatus(before, OceanBookingStatus.RELEASED);
if (request.estimatedArrivalAt() == null) throw badRequest("预计到港时间不能为空");
if (before.getEstimatedSailingAt() != null && request.estimatedArrivalAt().isBefore(before.getEstimatedSailingAt())) {
throw badRequest("预计到港时间不能早于预计开船时间");
}
if (repository.updateEstimatedArrival(id, request.estimatedArrivalAt(), request.expectedVersion(), operatorId, name) != 1) throw concurrent();
OceanBookingEntity saved = required(id);
addHistory(id, before.getStatus(), saved.getStatus(), action, "维护预计到港", before, saved, operatorId, name);
return complete(id, operatorId, action, key, detail(saved));
}
/** 放舱后仅补录实货量,版本、幂等结果和修改前后快照在同一事务保存。 */
@Transactional
public Detail updateActualCargo(long id, long operatorId, String name, String key, ActualCargoRequest request) {
@@ -82,15 +82,19 @@ public class ContainerService {
return value;
}
/** 首次关联已放舱订舱,仅规范化柜号并保留关联资格与并发校验。 */
@Transactional
public ContainerBookingEntity linkOceanBooking(long id, long operatorId, String operatorName, OceanBookingLinkRequest request) {
ContainerBookingEntity container = required(id); requireVersion(container, request.expectedVersion()); requireFirstLinkMutable(container);
if (container.getOceanBookingId() != null) throw conflict("模拟柜已关联真实订舱柜");
String existingContainerNo = trim(container.getContainerNo());
String requestedContainerNo = trim(request.containerNo());
String containerNo = requestedContainerNo == null
? existingContainerNo
: Iso6346ContainerNumber.require(requestedContainerNo);
// 本关联入口不按 ISO 长度、结构或校验位阻断,仍统一空白与大小写以核对唯一性。
if (requestedContainerNo != null) {
requestedContainerNo = trim(requestedContainerNo.replaceAll("[\\p{javaWhitespace}\\p{Z}]", "")
.toUpperCase(java.util.Locale.ROOT));
}
String containerNo = requestedContainerNo == null ? existingContainerNo : requestedContainerNo;
if (existingContainerNo != null && !existingContainerNo.equals(containerNo)) {
throw conflict("输入的真实集装箱号与当前柜次已录入柜号不一致,请先在预装运输信息中纠正");
}
@@ -493,10 +493,15 @@ public class LoadingListService {
return repository.preloadDetail(requirePlan(planId));
}
/** 保留关联入口已接受的柜号,运输入口首次录入或纠错仍执行原有校验。 */
private static String targetContainerNo(AggregateRow before, String requested) {
if (requested != null) return Iso6346ContainerNumber.require(requested);
return StringUtils.hasText(before.getContainerNo())
? Iso6346ContainerNumber.require(before.getContainerNo()) : null;
if (requested == null) return StringUtils.hasText(before.getContainerNo()) ? before.getContainerNo() : null;
String normalized = requested.replaceAll("[\\p{javaWhitespace}\\p{Z}]", "")
.toUpperCase(java.util.Locale.ROOT);
if (StringUtils.hasText(normalized) && normalized.equals(before.getContainerNo())) {
return before.getContainerNo();
}
return Iso6346ContainerNumber.require(requested);
}
private static String transportHash(long planId, int version, String containerNo,
@@ -140,6 +140,7 @@ public class LoadingPickService {
smartLoadingRepository.items(plan.getId()));
}
/** 确认预装前验证作业资格和真实柜号,格式问题必须在实物作业前纠正。 */
public List<Link> requirePreloadConfirmationReady(long loadingPlanId) {
inboundHoldGuard.requirePlanNotHeld(loadingPlanId);
List<Link> links = loadingModelBridge.requireAllocatedLinksForConfirmation(loadingPlanId);
@@ -151,6 +152,7 @@ public class LoadingPickService {
if (booking == null || !StringUtils.hasText(booking.getContainerNo())) {
throw conflict("确认预装前必须录入真实集装箱号");
}
requireContainerNumberBeforeStarting(booking.getContainerNo());
var allocation = oceanBookingRepository.allocationByContainer(link.containerBookingId());
if (allocation == null) throw conflict("确认预装前必须关联真实订舱柜");
var oceanBooking = oceanBookingRepository.find(allocation.getOceanBookingId());
@@ -164,12 +166,14 @@ public class LoadingPickService {
return links;
}
/** 仅在柜号已满足完成事件要求时创建 PDA 任务,已有任务的重复同步保持原语义。 */
@Transactional
public void begin(long planId, String containerNo, long operatorId, String operatorName,
List<LoadingPlanItemEntity> planItems) {
inboundHoldGuard.requirePlanNotHeld(planId);
LoadingPickTaskEntity task = repository.taskByPlan(planId);
if (task == null) {
requireContainerNumberBeforeStarting(containerNo);
task = new LoadingPickTaskEntity();
task.setLoadingPlanId(planId);
var booking = smartLoadingRepository.booking(smartLoadingRepository.plan(planId).getContainerBookingId());
@@ -610,6 +614,16 @@ public class LoadingPickService {
/** 本进程事务提交后的唤醒信号,权威命令和恢复事实仍保存在数据库。 */
public record CompletionAccepted(long commandId, long acceptedAtNanos) {}
/** 在仍可纠错的预装阶段提示格式错误,避免领取或实物作业后卡在完成门禁。 */
private static void requireContainerNumberBeforeStarting(String containerNo) {
try {
Iso6346ContainerNumber.require(containerNo);
} catch (BizException exception) {
throw conflict("请先在预装运输信息中纠正真实集装箱号后再确认预装或启动PDA,需符合ISO 6346");
}
}
/** 完成前复核真实柜号,以生成一致的跨服务装柜完成事件。 */
private void requireCompletionContainerNumber(long taskId) {
ContainerBookingEntity booking = repository.activeBookingByTask(taskId);
if (booking == null || !StringUtils.hasText(booking.getContainerNo())) {
@@ -232,7 +232,7 @@ DROP TRIGGER IF EXISTS trg_ocean_booking_before_update;
DELIMITER $$
CREATE TRIGGER trg_ocean_booking_before_update BEFORE UPDATE ON fl_ocean_booking FOR EACH ROW
BEGIN
-- 已放舱只允许实货量及其审计元数据变化,运输和预报快照保持冻结。
-- 已放舱只允许预计到港、实货量及审计元数据变化,其余运输和预报快照保持冻结。
IF OLD.status = 'RELEASED' AND NOT (
(NEW.id <=> OLD.id)
AND (NEW.booking_no <=> OLD.booking_no)
@@ -257,7 +257,6 @@ BEGIN
AND (NEW.destination_port_name <=> OLD.destination_port_name)
AND (NEW.place_of_delivery <=> OLD.place_of_delivery)
AND (NEW.estimated_sailing_at <=> OLD.estimated_sailing_at)
AND (NEW.estimated_arrival_at <=> OLD.estimated_arrival_at)
AND (NEW.remark <=> OLD.remark)
AND (NEW.freight_term <=> OLD.freight_term)
AND (NEW.service_term <=> OLD.service_term)
@@ -272,7 +271,7 @@ BEGIN
AND (NEW.created_at <=> OLD.created_at)
AND NEW.version = OLD.version + 1
) THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='已放舱运输资料不可修改,仅允许补录实货量';
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='已放舱仅允许维护预计到港及补录实货量';
END IF;
IF OLD.status <> NEW.status AND NOT (
(OLD.status='DRAFT' AND NEW.status='RELEASED')) THEN
@@ -35,7 +35,7 @@ CREATE TABLE IF NOT EXISTS fl_document_sequence (
CREATE TABLE IF NOT EXISTS fl_container_booking (
id BIGINT UNSIGNED NOT NULL COMMENT '货柜预订ID(雪花ID)',
container_no VARCHAR(64) NULL COMMENT '国内柜次真实集装箱号权威快照;ISO 6346标准格式;同一订舱内唯一,不同提单允许复用;订舱关联可选写入、提柜后可原子补录、预装纠错命令可修改',
container_no VARCHAR(64) NULL COMMENT '国内柜次真实集装箱号权威快照;订舱关联不限制ISO格式;同一订舱内唯一,不同提单允许复用;订舱关联可选写入、提柜后可原子补录、预装纠错命令可修改',
cabinet_no VARCHAR(64) NOT NULL COMMENT '平台唯一内部柜号',
source_type VARCHAR(24) NOT NULL DEFAULT 'SIMULATION' COMMENT '记录来源:SIMULATION内部模拟柜、OCEAN_BOOKING订舱真实柜',
record_status VARCHAR(16) NOT NULL DEFAULT 'ACTIVE' COMMENT '记录状态:ACTIVE可操作、MERGED已合并历史',
@@ -100,9 +100,7 @@ CREATE TABLE IF NOT EXISTS fl_container_booking (
CONSTRAINT chk_container_booking_record_status CHECK (record_status IN ('ACTIVE','MERGED')),
CONSTRAINT chk_container_booking_capacity CHECK (capacity_cbm IS NULL OR capacity_cbm > 0),
CONSTRAINT chk_container_booking_weight CHECK (max_weight_kg IS NULL OR max_weight_kg > 0),
CONSTRAINT chk_container_booking_pickup_code CHECK (pickup_code IS NULL OR pickup_code REGEXP '^[0-9]{1,64}$'),
CONSTRAINT chk_container_booking_iso6346 CHECK
(container_no IS NULL OR container_no REGEXP '^[A-Z]{3}[UJZ][0-9]{7}$')
CONSTRAINT chk_container_booking_pickup_code CHECK (pickup_code IS NULL OR pickup_code REGEXP '^[0-9]{1,64}$')
) ENGINE=InnoDB COMMENT='平台货柜预订资料;预装状态唯一来源为装柜计划';
CREATE TABLE IF NOT EXISTS fl_container_event_inbox (
@@ -228,11 +226,6 @@ BEGIN
ALTER TABLE fl_container_booking ADD CONSTRAINT chk_container_booking_record_status
CHECK (record_status IN ('ACTIVE','MERGED'));
END IF;
IF NOT EXISTS (SELECT 1 FROM information_schema.table_constraints WHERE constraint_schema=DATABASE()
AND table_name='fl_container_booking' AND constraint_name='chk_container_booking_iso6346') THEN
ALTER TABLE fl_container_booking ADD CONSTRAINT chk_container_booking_iso6346
CHECK (container_no IS NULL OR container_no REGEXP '^[A-Z]{3}[UJZ][0-9]{7}$');
END IF;
IF NOT EXISTS (SELECT 1 FROM information_schema.table_constraints WHERE constraint_schema=DATABASE()
AND table_name='fl_container_booking' AND constraint_name='chk_container_booking_pickup_code') THEN
ALTER TABLE fl_container_booking ADD CONSTRAINT chk_container_booking_pickup_code
@@ -118,9 +118,9 @@ paths:
x-permission: platform:firstleg:customs:list
parameters: [{$ref: '#/components/parameters/DocumentId'}]
responses: {'200': {$ref: '#/components/responses/BinaryResponse'}}
/api/platform/v1/customs/loading-plans/documents/{documentId}/receipt/download:
/api/platform/v1/customs/loading-plans/documents/{documentId}/completion/download:
get:
summary: 平台下载报关行回执
summary: 平台下载预录单
x-permission: platform:firstleg:customs:list
parameters: [{$ref: '#/components/parameters/DocumentId'}]
responses: {'200': {$ref: '#/components/responses/BinaryResponse'}}
@@ -607,7 +607,7 @@ paths:
/api/platform/v1/containers/{id}/ocean-booking-link:
post:
summary: 将内部模拟柜关联到已发布订舱需求
description: 需要 platform:firstleg:container:edit;请求指定 RELEASED oceanBookingId 和未满配 requirementId,containerNo 可空。首次关联允许 WAIT_LOADING 或已有预排货的 EDITING 模拟柜,保留原 containerBookingId/cabinetNo/loadingPlanId/items,原子写入订舱、运输和范围快照并创建 allocation;提柜后由拖车链调用柜次服务原子补录真实柜号。若关联请求已提供 containerNo,服务端仍规范化并校验 ISO 6346 结构、设备类别和校验位,并保证同一订舱内唯一;不同提单允许复用同一集装箱号。存在跨仓、跨渠道、柜型规格不一致、超量、同提单重复柜号、PDA、有效装柜证据、业务单证或 LOADING/COMPLETED 等事实时拒绝。
description: 需要 platform:firstleg:container:edit;请求指定 RELEASED oceanBookingId 和未满配 requirementId,containerNo 可空。首次关联允许 WAIT_LOADING 或已有预排货的 EDITING 模拟柜,保留原 containerBookingId/cabinetNo/loadingPlanId/items,原子写入订舱、运输和范围快照并创建 allocation;提柜后由拖车链调用柜次服务原子补录真实柜号。若关联请求已提供 containerNo,服务端仅移除空白并转大写,不校验 ISO 6346 长度、结构或校验位,并保证同一订舱内唯一;不同提单允许复用同一集装箱号。关联接受的柜号在运输信息未修改柜号时不重复执行ISO校验;确认预装和创建PDA任务前仍校验ISO格式,并提示在预装运输信息中纠错,防止实物作业后才发现无法完成。存在跨仓、跨渠道、柜型规格不一致、超量、同提单重复柜号、PDA、有效装柜证据、业务单证或 LOADING/COMPLETED 等事实时拒绝。
parameters: [{$ref: '#/components/parameters/ContainerBookingId'}]
requestBody: {required: true, content: {application/json: {schema: {$ref: '#/components/schemas/ContainerOceanBookingLinkRequest'}}}}
responses: {'200': {description: 关联后的同一模拟柜 containerBookingId/cabinetNo;列表仍只有这一条 ACTIVE 操作身份}}
@@ -2947,7 +2947,7 @@ components:
properties:
oceanBookingId: {type: integer, format: int64, minimum: 1, description: RELEASED订舱ID}
requirementId: {type: integer, format: int64, minimum: 1, description: 未满配的订舱柜型需求ID}
containerNo: {type: string, nullable: true, maxLength: 64, description: 可选;港口提柜后可由拖车链另行原子补录。非空时服务端移除格式空白、转大写并校验ISO 6346结构、设备类别、校验位和同一订舱内唯一性}
containerNo: {type: string, nullable: true, maxLength: 64, description: 可选;港口提柜后可由拖车链另行原子补录。关联时移除格式空白并转大写,不校验ISO长度、结构或校验位,仍保证同一订舱内唯一;确认预装及创建PDA任务前需纠正为符合ISO格式的真实柜号}
expectedVersion: {type: integer, minimum: 0}
ContainerFeeAdjustRequest:
type: object
@@ -50,7 +50,7 @@ paths:
summary: 修改预装运输信息
description: >-
可在 EDITING 或 LOADING 阶段首次补录真实集装箱号;已有柜号的纠正仅限仍为 EDITING、PDA任务尚未领取且无任何物理作业事实时,并必须提供原因和当前版本;
containerNo 为 null 表示保持当前值,非空时服务端移除格式空白、转大写并校验ISO 6346结构、设备类别和校验位,空白字符串非法且禁止清空。
containerNo 为 null 或规范化后等于当前值时保留关联入口已接受的柜号,不重复校验格式;首次录入或纠正为不同值时服务端移除格式空白、转大写并校验ISO 6346结构、设备类别和校验位,空白字符串非法且禁止清空。
x-permission: platform:firstleg:preload:edit
parameters:
- {$ref: '#/components/parameters/PlanId'}
@@ -29,6 +29,14 @@ paths:
parameters: [{$ref: '#/components/parameters/OceanBookingId'}, {$ref: '#/components/parameters/IdempotencyKey'}]
requestBody: {required: true, content: {application/json: {schema: {$ref: '#/components/schemas/ActionRequest'}}}}
responses: {'200': {description: Deleted}}
/api/platform/v1/ocean-bookings/{oceanBookingId}/estimated-arrival:
put:
summary: 放舱后维护预计到港
description: 仅允许RELEASED订舱按当前版本与幂等键维护estimatedArrivalAt,不得清空或早于预计开船时间;其他运输、预报、关系方、柜型与附件仍冻结,保存前后快照及操作人。不修改实际到港或计划到仓。
x-permission: platform:firstleg:ocean-booking:edit
parameters: [{$ref: '#/components/parameters/OceanBookingId'}, {$ref: '#/components/parameters/IdempotencyKey'}]
requestBody: {required: true, content: {application/json: {schema: {$ref: '#/components/schemas/EstimatedArrivalRequest'}}}}
responses: {'200': {description: 已保存的RELEASED订舱详情,包含当前版本和预计到港}}
/api/platform/v1/ocean-bookings/{oceanBookingId}/actual-cargo:
put:
summary: Supplement manual actual cargo on a RELEASED booking
@@ -217,6 +225,13 @@ components:
containerRequirements: {type: array, nullable: true, items: {$ref: '#/components/schemas/ContainerRequirementRequest'}}
PartyRequest: {type: object, required: [partyType, partyName], properties: {partyType: {type: string, enum: [SHIPPER, CONSIGNEE, NOTIFY_PARTY]}, partyName: {type: string}, address: {type: string, nullable: true}, contactName: {type: string, nullable: true}, contactPhone: {type: string, nullable: true}, contactEmail: {type: string, format: email, maxLength: 255, nullable: true}, taxNo: {type: string, maxLength: 128, nullable: true}}}
ContainerRequirementRequest: {type: object, required: [containerType, containerKind, quantity, capacityCbm, maxWeightKg], properties: {containerType: {type: string, enum: [40GP, 40HQ, 45HQ], default: 40HQ}, containerKind: {type: string}, quantity: {type: integer, minimum: 1}, capacityCbm: {type: number, exclusiveMinimum: true, minimum: 0, description: 统一实装上限;40GP=58、40HQ=68、45HQ=86}, maxWeightKg: {type: number, enum: [19500], description: 统一货重上限}}}
EstimatedArrivalRequest:
type: object
additionalProperties: false
required: [expectedVersion, estimatedArrivalAt]
properties:
expectedVersion: {type: integer, minimum: 0}
estimatedArrivalAt: {type: string, format: date-time}
ActualCargoRequest:
type: object
additionalProperties: false
@@ -88,7 +88,7 @@ class ContainerControllerContractTest {
}
@Test
@DisplayName("OpenAPI声明平台级货柜路径和冻结规则")
@DisplayName("OpenAPI声明平台级货柜路径、关联柜号格式放行及冻结规则")
void documentsContainerContract() throws Exception {
String openapi = new ClassPathResource("openapi/firstleg-v1.yaml")
.getContentAsString(StandardCharsets.UTF_8);
@@ -106,7 +106,8 @@ class ContainerControllerContractTest {
.contains("未满配 requirementId")
.contains("containerNo 可空")
.contains("提柜后由拖车链调用柜次服务原子补录真实柜号")
.contains("校验 ISO 6346 结构、设备类别和校验位,并保证同一订舱内唯一")
.contains("不校验 ISO 6346 长度、结构或校验位,并保证同一订舱内唯一")
.contains("确认预装和创建PDA任务前仍校验ISO格式")
.doesNotContain("/api/platform/v1/containers/{id}/transport:")
.doesNotContain("ContainerOceanBookingUnlinkRequest")
.doesNotContain("ContainerTransportUpdateRequest")
@@ -4,6 +4,7 @@ import com.baiyue.firstleg.customs.container.controller.*;
import org.junit.jupiter.api.Test;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.core.io.ClassPathResource;
import org.yaml.snakeyaml.Yaml;
@@ -12,6 +13,22 @@ import java.lang.reflect.Method;
import static org.assertj.core.api.Assertions.assertThat;
class ContainerCustomsControllerContractTest {
/** 下载契约必须跟随实际 Controller,避免前端和文档共同固化不存在的地址。 */
@Test void platformPreEntryDownloadMatchesActualControllerAndOpenApi() throws Exception {
Method download = java.util.Arrays.stream(PlatformContainerCustomsController.class.getDeclaredMethods())
.filter(method -> method.getName().equals("downloadCompletion")).findFirst().orElseThrow();
String root = PlatformContainerCustomsController.class.getAnnotation(RequestMapping.class).value()[0];
String path = root + download.getAnnotation(GetMapping.class).value()[0];
String source = new ClassPathResource("openapi/customs-v1.yaml").getContentAsString(java.nio.charset.StandardCharsets.UTF_8);
java.util.Map<?, ?> specification = new Yaml().load(source);
java.util.Map<?, ?> paths = (java.util.Map<?, ?>) specification.get("paths");
assertThat(path).isEqualTo("/api/platform/v1/customs/loading-plans/documents/{documentId}/completion/download");
assertThat(paths.containsKey(path)).isTrue();
assertThat(paths.containsKey(root + "/documents/{documentId}/receipt/download")).isFalse();
assertThat(download.getAnnotation(PreAuthorize.class).value()).contains("platform:firstleg:customs:list");
}
@Test void platformUsesListForReadsAndManageForWrites() {
assertThat(PlatformContainerCustomsController.class.getAnnotation(RequestMapping.class).value())
.containsExactly("/api/platform/v1/customs/loading-plans");
@@ -442,6 +442,7 @@ class ContainerCustomsServiceTest {
verifyNoInteractions(files);
}
/** 上传被拒绝时使用预录单名称,并保留报关行执行后的替换门禁。 */
@Test void receiptCannotBeReplacedAfterBrokerSubmission(){
var source=document(1L);when(mapper.document(1L)).thenReturn(source);
when(mapper.claimCommand(anyLong(),eq(5L),eq("UPLOAD_COMPLETION_1"),eq("broker-sent-key"),anyString())).thenReturn(1);
@@ -450,7 +451,10 @@ class ContainerCustomsServiceTest {
when(mapper.brokerSubmittedAt(2L)).thenReturn(java.time.LocalDateTime.now());
var file=new MockMultipartFile("file","result.bin","application/octet-stream",new byte[]{1});
assertThatThrownBy(()->service.uploadCompletion(1L,5L,"admin","broker-sent-key",0,file))
.isInstanceOf(BizException.class).hasMessageContaining("不可再修改回执");
.isInstanceOfSatisfying(BizException.class, error -> {
assertThat(error.getResultCode()).isEqualTo(ResultCode.CONFLICT);
assertThat(error).hasMessage("客户确认的预录单已发送给报关行,不可再修改预录单");
});
verifyNoInteractions(files);
}
@@ -512,11 +516,43 @@ class ContainerCustomsServiceTest {
verify(mapper,never()).complete(anyLong(),anyInt(),anyLong(),anyString());
}
/** 下载不可见或缺失的预录单时使用正式名称,不改变客户范围校验。 */
@Test void merchantCannotDownloadCompletionBeforeWholeContainerCompletes(){
var source=document(1L);when(mapper.document(1L)).thenReturn(source);
when(mapper.containerById(2L)).thenReturn(container(ContainerCustomsService.DECLARING,3));
assertThatThrownBy(()->service.downloadCompletion(7L,1L,false)).isInstanceOf(BizException.class)
.hasMessageContaining("报关行回执不存在");
.hasMessageContaining("预录单不存在");
verifyNoInteractions(files);
}
/** 平台下载的当前预录单不存在时仍返回原业务错误码和正式名称。 */
@Test void platformMissingPreEntryDocumentUsesCurrentName() {
when(mapper.document(1L)).thenReturn(document(1L));
when(mapper.completion(1L)).thenReturn(null);
assertThatThrownBy(() -> service.downloadCompletion(0L, 1L, true))
.isInstanceOfSatisfying(BizException.class, error -> {
assertThat(error.getResultCode()).isEqualTo(ResultCode.NOT_FOUND);
assertThat(error).hasMessage("预录单不存在");
});
verifyNoInteractions(files);
}
/** 发送前置校验仅调整用户可见名称,不改变全买单或客户确认门禁。 */
@ParameterizedTest
@CsvSource({"0,0,全买单柜无需发送预录单确认结果给报关行", "1,1,仍有预录单未完成客户确认"})
void brokerSubmissionErrorsUsePreEntryDocumentName(int taxRefundCount, int unconfirmedCount, String message) {
when(mapper.claimCommand(anyLong(), eq(5L), eq("SUBMIT_CUSTOMS_TO_BROKER_9"), eq("pre-entry-name"), anyString())).thenReturn(1);
when(mapper.lockContainer(9L)).thenReturn(container(ContainerCustomsService.DECLARING, 3));
when(mapper.taxRefundCount(9L)).thenReturn(taxRefundCount);
when(mapper.unconfirmedReceiptCount(2L)).thenReturn(unconfirmedCount);
assertThatThrownBy(() -> service.submitToBroker(9L, 5L, "admin", "pre-entry-name", new ActionRequest(3, true, null)))
.isInstanceOfSatisfying(BizException.class, error -> {
assertThat(error.getResultCode()).isEqualTo(ResultCode.CONFLICT);
assertThat(error).hasMessage(message);
});
verify(mapper, never()).submitToBroker(anyLong(), anyInt(), anyLong(), anyString());
verifyNoInteractions(files);
}
@@ -14,6 +14,37 @@ import java.nio.file.Path;
import static org.assertj.core.api.Assertions.assertThat;
class PlatformOceanBookingControllerContractTest {
/** 预计到港命令仅接收版本与时间,沿用编辑权限及独立审计。 */
@Test void estimatedArrivalCommandHasNarrowDtoAndEditPermission() throws Exception {
var endpoint = method("updateEstimatedArrival");
assertThat(endpoint.getAnnotation(org.springframework.web.bind.annotation.PutMapping.class).value()).containsExactly("/{oceanBookingId}/estimated-arrival");
assertThat(endpoint.getAnnotation(PreAuthorize.class).value()).isEqualTo("hasAuthority('platform:firstleg:ocean-booking:edit')");
assertThat(endpoint.getAnnotation(BusinessAudit.class)).isNotNull();
assertThat(Arrays.stream(com.baiyue.firstleg.oceanbooking.dto.OceanBookingDtos.EstimatedArrivalRequest.class.getRecordComponents())
.map(java.lang.reflect.RecordComponent::getName)).containsExactly("expectedVersion", "estimatedArrivalAt");
assertThat(Files.readString(Path.of("src/main/resources/openapi/ocean-bookings-v1.yaml")))
.contains("/api/platform/v1/ocean-bookings/{oceanBookingId}/estimated-arrival:", "#/components/schemas/EstimatedArrivalRequest");
}
/** 真实方法权限拦截器拒绝只有读取权限的账号,命令不会进入服务层。 */
@Test void estimatedArrivalRejectsReadOnlyAccountBeforeServiceCall() {
var service = org.mockito.Mockito.mock(com.baiyue.firstleg.oceanbooking.service.OceanBookingService.class);
var target = new PlatformOceanBookingController();
org.springframework.test.util.ReflectionTestUtils.setField(target, "service", service);
var factory = new org.springframework.aop.framework.ProxyFactory(target);
factory.setProxyTargetClass(true);
factory.addAdvisor(org.springframework.security.authorization.method.AuthorizationManagerBeforeMethodInterceptor.preAuthorize());
var secured = (PlatformOceanBookingController) factory.getProxy();
var context = org.springframework.security.core.context.SecurityContextHolder.getContext();
context.setAuthentication(new org.springframework.security.authentication.UsernamePasswordAuthenticationToken("reader", "",
java.util.List.of(new org.springframework.security.core.authority.SimpleGrantedAuthority("platform:firstleg:ocean-booking:list"))));
try {
org.assertj.core.api.Assertions.assertThatThrownBy(() -> secured.updateEstimatedArrival(null, 1L, "eta-denied",
new com.baiyue.firstleg.oceanbooking.dto.OceanBookingDtos.EstimatedArrivalRequest(3, java.time.LocalDateTime.now())))
.isInstanceOf(org.springframework.security.access.AccessDeniedException.class);
org.mockito.Mockito.verifyNoInteractions(service);
} finally { org.springframework.security.core.context.SecurityContextHolder.clearContext(); }
}
@Test
void usesDedicatedResourceAndPermissions() {
assertThat(PlatformOceanBookingController.class.getAnnotation(RequestMapping.class).value())
@@ -14,6 +14,13 @@ import java.util.Arrays;
import static org.assertj.core.api.Assertions.assertThat;
class OceanBookingMapperContractTest {
/** 放舱后预计到港只更新该字段及版本审计,不允许带出其他冻结资料。 */
@Test void estimatedArrivalUpdateIsNarrowAndVersioned() throws Exception {
String sql = String.join("\n", OceanBookingMapper.class.getDeclaredMethod("updateEstimatedArrival", long.class,
java.time.LocalDateTime.class, int.class, long.class, String.class).getAnnotation(Update.class).value());
assertThat(sql).contains("estimated_arrival_at=#{estimatedArrivalAt}", "status='RELEASED'", "version=#{version}", "version=version+1")
.doesNotContain("vessel_name=", "voyage_no=", "estimated_sailing_at=", "actual_", "declared_", "warehouse_id=");
}
@Test
void listUsesPlatformAggregateFilters() throws Exception {
String sql = String.join("\n", OceanBookingMapper.class.getDeclaredMethod("selectPage",
@@ -10,6 +10,14 @@ import java.nio.file.Path;
import static org.assertj.core.api.Assertions.assertThat;
class OceanBookingSqlContractTest {
/** 两份触发器基线只放开预计到港,继续冻结船名、开船、预报和其他运输资料。 */
@Test void releasedFreezeAllowsEstimatedArrivalOnly() throws Exception {
for (String source : new String[] {sql(), Files.readString(Path.of("../deploy/sql/头程数据库统一增量-MySQL8.sql"))}) {
assertThat(source).doesNotContain("NEW.estimated_arrival_at <=> OLD.estimated_arrival_at")
.contains("NEW.vessel_name <=> OLD.vessel_name", "NEW.estimated_sailing_at <=> OLD.estimated_sailing_at",
"NEW.declared_package_count <=> OLD.declared_package_count", "NEW.version = OLD.version + 1");
}
}
@Test
void definesDedicatedAggregateWithoutChangingContainerSemantics() throws Exception {
String sql = sql();
@@ -399,6 +399,57 @@ class OceanBookingServiceTest {
verify(repository, never()).transition(anyLong(), anyString(), anyString(), anyInt(), anyLong(), anyString());
}
/** 预计到港独立更新、回显并保留修改前后审计,不能触及其他冻结字段。 */
@Test void maintainsReleasedEstimatedArrivalWithVersionAndAudit() {
var before = completeBooking("RELEASED", 3);
var after = objectMapper.convertValue(before, OceanBookingEntity.class);
var arrival = before.getEstimatedSailingAt().plusDays(20);
after.setEstimatedArrivalAt(arrival); after.setVersion(4);
when(repository.find(1L)).thenReturn(before, after);
when(repository.updateEstimatedArrival(1L, arrival, 3, 9L, "admin")).thenReturn(1);
var result = service.updateEstimatedArrival(1L, 9L, "admin", "eta-save", new EstimatedArrivalRequest(3, arrival));
assertThat(result.oceanBooking().getEstimatedArrivalAt()).isEqualTo(arrival);
assertThat(result.oceanBooking().getVersion()).isEqualTo(4);
assertThat(result.oceanBooking().getVesselName()).isEqualTo(before.getVesselName());
var history = ArgumentCaptor.forClass(com.baiyue.firstleg.oceanbooking.entity.OceanBookingStatusHistoryEntity.class);
verify(repository).insert(history.capture());
assertThat(history.getValue().getAction()).isEqualTo("UPDATE_ESTIMATED_ARRIVAL");
assertThat(history.getValue().getBeforeSnapshot()).contains("estimatedArrivalAt");
assertThat(history.getValue().getAfterSnapshot()).contains("estimatedArrivalAt");
verify(repository, never()).updateDraft(any(), anyInt(), anyLong(), any());
verify(repository, never()).replaceRequirements(anyLong(), any());
}
/** 草稿、旧版本、空值及逆序时间均不能走放舱后的预计到港命令。 */
@Test void rejectsInvalidEstimatedArrivalCommands() {
var value = completeBooking("DRAFT", 3);
when(repository.find(1L)).thenReturn(value);
var arrival = value.getEstimatedSailingAt().plusDays(20);
assertThatThrownBy(() -> service.updateEstimatedArrival(1L, 9L, "admin", "eta-draft", new EstimatedArrivalRequest(3, arrival))).isInstanceOf(BizException.class);
value.setStatus("RELEASED");
assertThatThrownBy(() -> service.updateEstimatedArrival(1L, 9L, "admin", "eta-stale", new EstimatedArrivalRequest(2, arrival))).isInstanceOf(BizException.class);
assertThatThrownBy(() -> service.updateEstimatedArrival(1L, 9L, "admin", "eta-empty", new EstimatedArrivalRequest(3, null))).hasMessageContaining("不能为空");
assertThatThrownBy(() -> service.updateEstimatedArrival(1L, 9L, "admin", "eta-early", new EstimatedArrivalRequest(3, value.getEstimatedSailingAt().minusSeconds(1)))).hasMessageContaining("不能早于");
when(repository.updateEstimatedArrival(anyLong(), any(), anyInt(), anyLong(), any())).thenReturn(0);
assertThatThrownBy(() -> service.updateEstimatedArrival(1L, 9L, "admin", "eta-conflict", new EstimatedArrivalRequest(3, arrival))).isInstanceOf(BizException.class);
}
/** 同一预计到港命令重放复用原结果,不重复保存或追加审计。 */
@Test void estimatedArrivalReplayDoesNotWriteAgain() throws Exception {
var request = new EstimatedArrivalRequest(3, LocalDateTime.of(2026, 10, 25, 10, 0));
var replay = new Detail(completeBooking("RELEASED", 4), List.of(), List.of(), List.of(), List.of(), List.of());
var operation = new OceanBookingOperationEntity();
operation.setRequestHash(hash("UPDATE_ESTIMATED_ARRIVAL", 1L, request));
operation.setStatus("SUCCESS"); operation.setResultJson(objectMapper.writeValueAsString(replay));
when(repository.claimOperation(1L, 9L, "UPDATE_ESTIMATED_ARRIVAL", "eta-replay", operation.getRequestHash())).thenReturn(false);
when(repository.operation(9L, "UPDATE_ESTIMATED_ARRIVAL", "eta-replay")).thenReturn(operation);
assertThat(service.updateEstimatedArrival(1L, 9L, "admin", "eta-replay", request).oceanBooking().getVersion()).isEqualTo(4);
verify(repository, never()).updateEstimatedArrival(anyLong(), any(), anyInt(), anyLong(), any());
verify(repository, never()).insert(any(com.baiyue.firstleg.oceanbooking.entity.OceanBookingStatusHistoryEntity.class));
}
@Test
void supplementsReleasedActualCargoWithVersionAndBeforeAfterAudit() {
OceanBookingEntity before = completeBooking("RELEASED", 3);
@@ -8,12 +8,23 @@ import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Update;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.ValueSource;
import com.baomidou.mybatisplus.core.MybatisConfiguration;
import com.baomidou.mybatisplus.extension.spring.MybatisSqlSessionFactoryBean;
import com.baiyue.firstleg.oceanbooking.entity.OceanBookingEntity;
import org.h2.jdbcx.JdbcDataSource;
import org.mybatis.spring.SqlSessionTemplate;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.dao.DuplicateKeyException;
import org.springframework.test.util.ReflectionTestUtils;
import java.util.Arrays;
import java.nio.file.Files;
import java.nio.file.Path;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
@DisplayName("货柜数据库映射契约")
class ContainerBookingMapperContractTest {
@@ -106,7 +117,7 @@ class ContainerBookingMapperContractTest {
}
@Test
@DisplayName("初始化与统一增量锁定真实柜号权威、订舱内唯一和ISO结构约束")
@DisplayName("初始化与统一增量保留柜号权威及订舱内唯一,不限制关联柜号ISO格式")
void databaseContractsFreezeAuthoritativeContainerNumber() throws Exception {
String module = Files.readString(Path.of("src/main/resources/db/phase3-smart-loading-schema.sql"));
String unified = Files.readString(Path.of("../deploy/sql/头程数据库统一增量-MySQL8.sql"));
@@ -118,8 +129,41 @@ class ContainerBookingMapperContractTest {
assertThat(source.toLowerCase())
.contains("unique key uk_container_booking_ocean_container (ocean_booking_id,container_no)")
.contains("key idx_container_booking_container_no (container_no,updated_at,id)")
.contains("constraint chk_container_booking_iso6346")
.contains("^[a-z]{3}[ujz][0-9]{7}$");
.doesNotContain("chk_container_booking_iso6346", "^[a-z]{3}[ujz][0-9]{7}$");
}
}
/** 从当前建表基线创建隔离数据库,真实执行关联 Mapper 并验证唯一键和版本。 */
@ParameterizedTest
@ValueSource(strings = {"MSCU6639871", "ABC", "ABCU123456789012", "LOCAL-2026-001"})
void linksNonIsoContainerNumbersThroughActualSchemaAndMapper(String containerNo) throws Exception {
var dataSource = new JdbcDataSource();
dataSource.setURL("jdbc:h2:mem:container-link-" + java.util.UUID.randomUUID() + ";MODE=MySQL;DB_CLOSE_DELAY=-1");
var jdbc = new JdbcTemplate(dataSource);
String source = Files.readString(Path.of("src/main/resources/db/phase3-smart-loading-schema.sql"));
int start = source.indexOf("CREATE TABLE IF NOT EXISTS fl_container_booking (");
jdbc.execute(source.substring(start, source.indexOf(';', source.indexOf(") ENGINE=InnoDB", start)) + 1));
for (long id = 1; id <= 3; id++) {
jdbc.update("INSERT INTO fl_container_booking(id,cabinet_no,warehouse_id,warehouse_name,container_type,created_by,updated_by) VALUES(?,?,8,'测试仓','40HQ',9,9)",
id, "D" + id);
}
var configuration = new MybatisConfiguration();
configuration.setMapUnderscoreToCamelCase(true);
configuration.addMapper(ContainerBookingMapper.class);
var factory = new MybatisSqlSessionFactoryBean();
factory.setDataSource(dataSource);
factory.setConfiguration(configuration);
var session = new SqlSessionTemplate(factory.getObject());
var repository = new ContainerRepository();
ReflectionTestUtils.setField(repository, "mapper", session.getMapper(ContainerBookingMapper.class));
var booking = new OceanBookingEntity();
assertThat(repository.linkReleasedBooking(1L, 21L, 31L, containerNo, booking, 0, 9L, "操作员")).isEqualTo(1);
assertThat(jdbc.queryForObject("SELECT container_no FROM fl_container_booking WHERE id=1", String.class)).isEqualTo(containerNo);
assertThat(jdbc.queryForObject("SELECT version FROM fl_container_booking WHERE id=1", Integer.class)).isEqualTo(1);
assertThat(repository.linkReleasedBooking(1L, 21L, 31L, containerNo, booking, 0, 9L, "操作员")).isZero();
assertThatThrownBy(() -> repository.linkReleasedBooking(2L, 21L, 31L, containerNo, booking, 0, 9L, "操作员"))
.isInstanceOf(DuplicateKeyException.class);
assertThat(repository.linkReleasedBooking(3L, 22L, 32L, containerNo, booking, 0, 9L, "操作员")).isEqualTo(1);
}
}
@@ -329,17 +329,21 @@ class ContainerServiceTest {
verify(repository, never()).assignRealContainerNo(anyLong(), anyString(), anyInt(), anyLong(), anyString());
}
@Test
@DisplayName("首次关联拒绝错误ISO 6346校验位")
void rejectsInvalidContainerNumberCheckDigit() {
/** 关联入口允许非 ISO 柜号,继续按原版本和关联范围保存。 */
@ParameterizedTest
@ValueSource(strings = {"MSCU6639871", "ABC", "ABCU123456789012", "LOCAL-2026-001"})
@DisplayName("首次关联不因柜号长度结构或校验位阻断")
void linksWithoutIsoContainerNumberValidation(String containerNo) {
stubLinkContext();
when(repository.linkReleasedBooking(eq(11L), eq(21L), eq(31L), eq(containerNo),
any(OceanBookingEntity.class), eq(2), eq(9L), eq("admin"))).thenReturn(1);
assertThatThrownBy(() -> service.linkOceanBooking(11L, 9L, "admin",
new OceanBookingLinkRequest(21L, 31L, "MSCU6639871", 2)))
.isInstanceOf(BizException.class).hasMessageContaining("校验位");
service.linkOceanBooking(11L, 9L, "admin", new OceanBookingLinkRequest(21L, 31L, containerNo, 2));
verify(repository, never()).linkReleasedBooking(anyLong(), anyLong(), anyLong(), anyString(),
any(), anyInt(), anyLong(), anyString());
verify(repository).containerNoExists(21L, containerNo, 11L);
verify(repository).linkReleasedBooking(eq(11L), eq(21L), eq(31L), eq(containerNo),
any(OceanBookingEntity.class), eq(2), eq(9L), eq("admin"));
verify(oceanBookings).insert(any(OceanBookingContainerAllocationEntity.class));
}
@Test
@@ -26,6 +26,9 @@ import com.fasterxml.jackson.databind.ObjectMapper;
import jakarta.annotation.Resource;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.NullSource;
import org.junit.jupiter.params.provider.ValueSource;
import org.mockito.ArgumentCaptor;
import org.springframework.boot.test.context.TestConfiguration;
import org.springframework.boot.test.mock.mockito.MockBean;
@@ -147,10 +150,42 @@ class LoadingListServiceTest {
verifyNoInteractions(loadingPickService);
}
/** 只更新航次时,关联已接受的非 ISO 柜号不应被重新校验或改写。 */
@ParameterizedTest
@NullSource
@ValueSource(strings = {"LOCAL-1", " local-1 "})
void preservesLinkedNonIsoNumberWhenOnlyVoyageChanges(String requested) {
AggregateRow before = row("EDITING", 3);
before.setContainerNo("LOCAL-1");
before.setVoyageNo("001E");
when(repository.aggregate(10L)).thenReturn(before);
when(repository.updateTransport(10L, 3, "LOCAL-1", "023E", 9L, "operator")).thenReturn(1);
service.updateTransport(10L, 100L, 9L, "operator", "preserve-linked-number",
new TransportUpdateRequest(requested, "023E", 3, null));
verify(repository).updateTransport(10L, 3, "LOCAL-1", "023E", 9L, "operator");
verify(loadingPickService).beginIfReady(30L, 9L, "operator");
}
/** 非关联入口新录入或纠正柜号仍使用原有规则,不放宽到所有录入场景。 */
@Test
void stillRejectsNewNonIsoNumberInTransportCorrection() {
AggregateRow before = row("EDITING", 3);
before.setContainerNo("LOCAL-1");
when(repository.aggregate(10L)).thenReturn(before);
assertThatThrownBy(() -> service.updateTransport(10L, 100L, 9L, "operator", "invalid-correction",
new TransportUpdateRequest("OTHER-2", null, 3, "纠错")))
.isInstanceOf(BizException.class).hasMessageContaining("ISO 6346");
verify(repository, never()).updateTransport(anyLong(), anyInt(), any(), any(), anyLong(), any());
}
/** 关联入口接受的非 ISO 柜号,在 PDA 领取前仍可按版本和原因纠正为真实柜号。 */
@Test
void pushedEditingPreloadCanCorrectExistingContainerNumberBeforePdaClaim() {
AggregateRow before = row("EDITING", 3);
before.setContainerNo("MSCU6639870");
before.setContainerNo("LOCAL-1");
AggregateRow after = row("EDITING", 4);
after.setContainerNo("CSQU3054383");
when(repository.aggregate(10L)).thenReturn(before, after);
@@ -227,7 +227,7 @@ class LoadingPickServiceTest {
Link link = new Link(1L, 1L, 50L, 2L, "PENDING", 1,
10L, 40L, "MSCU-001", "EDITING", 3, 2, null);
var booking = new com.baiyue.firstleg.entity.ContainerBookingEntity();
booking.setId(40L); booking.setContainerNo("MSCU-001");
booking.setId(40L); booking.setContainerNo("MSCU6639870");
var allocation = new OceanBookingContainerAllocationEntity();
allocation.setOceanBookingId(90L);
var ocean = new OceanBookingEntity();
@@ -962,6 +962,57 @@ class LoadingPickServiceTest {
verify(repository, never()).acceptCompletion(anyLong(), anyInt());
}
/** 关联放行的非 ISO 柜号必须在可纠错阶段明确反馈,纠正后可继续确认。 */
@ParameterizedTest
@ValueSource(strings = {"MSCU6639871", "ABC", "ABCU123456789012", "LOCAL-2026-001"})
void rejectsNonIsoNumberBeforeConfirmationAndAllowsCorrection(String containerNo) {
Link link = new Link(1L, 1L, 50L, 2L, "PENDING", 1,
10L, 40L, containerNo, "EDITING", 3, 2, null);
var booking = new ContainerBookingEntity();
booking.setId(40L);
booking.setContainerNo(containerNo);
when(loadingModelBridge.requireAllocatedLinksForConfirmation(10L)).thenReturn(List.of(link));
when(smartLoadingRepository.booking(40L)).thenReturn(booking);
assertThatThrownBy(() -> service.requirePreloadConfirmationReady(10L))
.isInstanceOf(BizException.class).hasMessageContaining("预装运输信息");
verify(repository, never()).insert(any(LoadingPickTaskEntity.class));
verify(completionCommands, never()).insert(any());
booking.setContainerNo("MSCU6639870");
var allocation = new OceanBookingContainerAllocationEntity();
allocation.setOceanBookingId(90L);
var ocean = new OceanBookingEntity();
ocean.setId(90L);
ocean.setStatus("RELEASED");
when(oceanBookingRepository.allocationByContainer(40L)).thenReturn(allocation);
when(oceanBookingRepository.find(90L)).thenReturn(ocean);
assertThat(service.requirePreloadConfirmationReady(10L)).containsExactly(link);
}
/** 任务创建入口同样先校验格式,不能绕过确认门禁进入实物作业。 */
@ParameterizedTest
@ValueSource(strings = {"MSCU6639871", "ABC", "ABCU123456789012", "LOCAL-2026-001"})
void rejectsNonIsoNumberBeforeCreatingPdaTask(String containerNo) {
assertThatThrownBy(() -> service.begin(10L, containerNo, 9L, "operator", List.of()))
.isInstanceOf(BizException.class).hasMessageContaining("预装运输信息");
verify(repository, never()).insert(any(LoadingPickTaskEntity.class));
verify(repository, never()).insert(any(LoadingPickItemEntity.class));
}
/** 完成前的最终校验仍保留,不能让异常柜号生成无法投递的装柜完成事件。 */
@Test
void completionStillRequiresIsoNumberForCrossServiceFacts() {
repository.activeBookingByTask(20L).setContainerNo("MSCU6639871");
when(repository.taskForUpdate(20L)).thenReturn(task());
assertThatThrownBy(() -> service.complete(20L,
new CompleteLoadingRequest(0, "invalid-final-container", "checked"), 9L, "operator"))
.isInstanceOf(BizException.class).hasMessageContaining("ISO 6346");
verify(completionCommands, never()).insert(any());
verify(repository, never()).acceptCompletion(anyLong(), anyInt());
}
@Test
void differentOperationIdIsRejectedImmediatelyWithConflict() {
// different operationId is a canonical A0409 CONFLICT while the durable command is active.