From 4d7ea28a61d11a868a7fc5bae0006f6af84c2399 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Tue, 1 Sep 2026 15:18:18 +0800 Subject: [PATCH 1/7] feat(firstleg): capture container number at pickup --- .../firstleg/dto/TruckAppointmentDtos.java | 2 +- .../mapper/TruckAppointmentMapper.java | 1 + .../TruckAppointmentRepository.java | 2 +- .../service/TruckAppointmentService.java | 7 +- .../openapi/truck-appointments-v1.yaml | 9 +- ...ruckAppointmentControllerContractTest.java | 2 +- .../TruckAppointmentMapperContractTest.java | 3 + .../service/TruckAppointmentServiceTest.java | 205 ++++++++++++++++-- 8 files changed, 202 insertions(+), 29 deletions(-) diff --git a/firstleg/src/main/java/com/baiyue/firstleg/dto/TruckAppointmentDtos.java b/firstleg/src/main/java/com/baiyue/firstleg/dto/TruckAppointmentDtos.java index 43e6ae875..62782b7ba 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/dto/TruckAppointmentDtos.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/dto/TruckAppointmentDtos.java @@ -3,6 +3,6 @@ import jakarta.validation.constraints.*; import java.math.BigDecimal; import jav public final class TruckAppointmentDtos { private TruckAppointmentDtos(){} public record Query(@Size(max=64) String keyword, com.baiyue.firstleg.domain.TruckAppointmentStatus status, @Positive Long containerBookingId, @Min(1) long page, @Min(1) @Max(200) long size){ public Query{if(page==0)page=1;if(size==0)size=20;} } public record SaveRequest(@Positive Long outboundOrderId,@NotNull @Positive Long oceanBookingId,@NotNull @Positive Long containerBookingId,@Positive Long loadingPlanId,@Positive Long warehouseId,@NotNull @Positive Long carrierId,@Size(max=128) String carrierName,@Size(max=128) String billOfLadingNo,@Size(max=64) String fleetContact,@Size(max=32) String fleetPhone,@Size(max=64) String driverName,@Size(max=32) String driverPhone,@Size(max=32) String driverIdCard,@Size(max=32) String vehicleNo,@Size(max=128) String storeName,@Size(max=64) String storeContact,@Size(max=32) String storePhone,@Size(max=255) String storeAddress,@Size(max=64) String storeCity,@Size(max=16) String storeZipcode,@PositiveOrZero Integer packageCount,@DecimalMin("0") BigDecimal grossWeightKg,@DecimalMin("0") BigDecimal volumeCbm,@PositiveOrZero Integer palletCount,@Size(max=255) String poNo,@NotNull LocalDateTime plannedLoadingAt,@PositiveOrZero Integer transitMinutes,@DecimalMin("0") BigDecimal payableAmount,@Pattern(regexp="RMB|USD") String currency,@Size(max=1000) String remark,@NotNull @PositiveOrZero Integer expectedVersion){} - public record ActionRequest(@NotNull @PositiveOrZero Integer expectedVersion,@Size(max=500) String reason){} + public record ActionRequest(@NotNull @PositiveOrZero Integer expectedVersion,@Size(max=500) String reason,@Size(max=32) String containerNo){} public record AttachmentRequest(@NotNull @Positive Long fileId,@NotBlank @Pattern(regexp="DISPATCH_ORDER|DRIVER_DOCUMENT|VEHICLE_DOCUMENT|OTHER") String attachmentType,@Size(max=255) String fileName,@Size(max=128) String fileHash){ } } diff --git a/firstleg/src/main/java/com/baiyue/firstleg/mapper/TruckAppointmentMapper.java b/firstleg/src/main/java/com/baiyue/firstleg/mapper/TruckAppointmentMapper.java index b2139d8f5..68757f92d 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/mapper/TruckAppointmentMapper.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/mapper/TruckAppointmentMapper.java @@ -6,6 +6,7 @@ import com.baomidou.mybatisplus.core.mapper.BaseMapper; import com.baomidou.myba @Select("") Page page(Page p,@Param("q") Query q); @Update("UPDATE fl_truck_appointment SET status=#{toStatus}, actual_arrived_warehouse_at=CASE WHEN #{toStatus}='ARRIVED_WAREHOUSE' THEN CURRENT_TIMESTAMP(3) ELSE actual_arrived_warehouse_at END, actual_loading_at=CASE WHEN #{toStatus}='LOADING' THEN CURRENT_TIMESTAMP(3) ELSE actual_loading_at END, actual_departed_at=CASE WHEN #{toStatus}='DEPARTED' THEN CURRENT_TIMESTAMP(3) ELSE actual_departed_at END, revision=revision+1, version=version+1, updated_at=CURRENT_TIMESTAMP(3) WHERE id=#{id} AND status=#{fromStatus} AND version=#{version}") int transition(@Param("id") long id,@Param("fromStatus") String from,@Param("toStatus") String to,@Param("version") int version); @Update("UPDATE fl_truck_appointment SET status='DISPATCHED',bill_of_lading_no=#{billOfLadingNo},revision=revision+1,version=version+1,updated_at=CURRENT_TIMESTAMP(3) WHERE id=#{id} AND status=#{fromStatus} AND version=#{version}") int dispatch(@Param("id") long id,@Param("fromStatus") String fromStatus,@Param("billOfLadingNo") String billOfLadingNo,@Param("version") int version); + @Update("UPDATE fl_container_booking SET container_no=#{containerNo},updated_by=#{operatorId},updated_by_name=#{operatorName},updated_at=CURRENT_TIMESTAMP(3),version=version+1 WHERE id=#{containerBookingId} AND record_status='ACTIVE' AND container_no IS NULL") int bindContainerNoForPickup(@Param("containerBookingId") long containerBookingId,@Param("containerNo") String containerNo,@Param("operatorId") long operatorId,@Param("operatorName") String operatorName); @Update("UPDATE fl_truck_appointment SET status='COMPLETED',actual_loading_at=COALESCE(actual_loading_at,CURRENT_TIMESTAMP(3)),revision=revision+1,version=version+1,updated_at=CURRENT_TIMESTAMP(3) WHERE id=#{id} AND status=#{fromStatus} AND version=#{version}") int completeLoadingFromPda(@Param("id") long id,@Param("fromStatus") String fromStatus,@Param("version") int version); @Update("UPDATE fl_truck_appointment SET planned_loading_at=#{at}, revision=revision+1, version=version+1, status='RESCHEDULED', remark=#{reason}, updated_at=CURRENT_TIMESTAMP(3) WHERE id=#{id} AND version=#{version}") int reschedule(@Param("id") long id,@Param("at") java.time.LocalDateTime at,@Param("reason") String reason,@Param("version") int version); @Update("UPDATE fl_truck_appointment SET outbound_order_id=#{e.outboundOrderId},ocean_booking_id=#{e.oceanBookingId},container_booking_id=#{e.containerBookingId},loading_plan_id=#{e.loadingPlanId},warehouse_id=#{e.warehouseId},carrier_id=#{e.carrierId},carrier_name=#{e.carrierName},bill_of_lading_no=#{e.billOfLadingNo},fleet_contact=#{e.fleetContact},fleet_phone=#{e.fleetPhone},driver_name=#{e.driverName},driver_phone=#{e.driverPhone},driver_id_card=#{e.driverIdCard},vehicle_no=#{e.vehicleNo},store_name=#{e.storeName},store_contact=#{e.storeContact},store_phone=#{e.storePhone},store_address=#{e.storeAddress},store_city=#{e.storeCity},store_zipcode=#{e.storeZipcode},package_count=#{e.packageCount},gross_weight_kg=#{e.grossWeightKg},volume_cbm=#{e.volumeCbm},pallet_count=#{e.palletCount},po_no=#{e.poNo},planned_loading_at=#{e.plannedLoadingAt},transit_minutes=#{e.transitMinutes},payable_amount=#{e.payableAmount},currency=#{e.currency},remark=#{e.remark},version=version+1,revision=revision+1,updated_at=CURRENT_TIMESTAMP(3) WHERE id=#{e.id} AND version=#{version}") int updateEdit(@Param("e") TruckAppointmentEntity e,@Param("version") int version); diff --git a/firstleg/src/main/java/com/baiyue/firstleg/repository/TruckAppointmentRepository.java b/firstleg/src/main/java/com/baiyue/firstleg/repository/TruckAppointmentRepository.java index 4bbcce2df..a1ce74211 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/repository/TruckAppointmentRepository.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/repository/TruckAppointmentRepository.java @@ -1,7 +1,7 @@ package com.baiyue.firstleg.repository; import com.baomidou.mybatisplus.extension.plugins.pagination.Page; import com.baiyue.common.core.api.PageResult; import com.baiyue.firstleg.dto.TruckAppointmentDtos.Query; import com.baiyue.firstleg.entity.*; import com.baiyue.firstleg.mapper.*; import jakarta.annotation.Resource; import org.springframework.stereotype.Repository; @Repository public class TruckAppointmentRepository { @Resource TruckAppointmentMapper mapper; @Resource TruckAppointmentHistoryMapper history; @Resource TruckAppointmentAttachmentMapper attachments; @Resource TruckAppointmentOperationMapper operations; - public PageResult page(Query q){return PageResult.from(mapper.page(new Page<>(q.page(),q.size()),q));} public TruckAppointmentEntity find(long id){return mapper.selectById(id);} public void insert(TruckAppointmentEntity e){mapper.insert(e);} public int transition(long id,String from,String to,int v){return mapper.transition(id,from,to,v);} public int dispatch(long id,String from,String billOfLadingNo,int v){return mapper.dispatch(id,from,billOfLadingNo,v);} public int reschedule(long id,java.time.LocalDateTime at,String reason,int v){return mapper.reschedule(id,at,reason,v);} public void history(TruckAppointmentHistoryEntity e){history.insert(e);} public void operation(TruckAppointmentOperationEntity e){operations.insert(e);} public void attachment(TruckAppointmentAttachmentEntity e){attachments.insert(e);} + public PageResult page(Query q){return PageResult.from(mapper.page(new Page<>(q.page(),q.size()),q));} public TruckAppointmentEntity find(long id){return mapper.selectById(id);} public void insert(TruckAppointmentEntity e){mapper.insert(e);} public int transition(long id,String from,String to,int v){return mapper.transition(id,from,to,v);} public int dispatch(long id,String from,String billOfLadingNo,int v){return mapper.dispatch(id,from,billOfLadingNo,v);} public int bindContainerNoForPickup(long containerBookingId,String containerNo,long operatorId,String operatorName){return mapper.bindContainerNoForPickup(containerBookingId,containerNo,operatorId,operatorName);} public int reschedule(long id,java.time.LocalDateTime at,String reason,int v){return mapper.reschedule(id,at,reason,v);} public void history(TruckAppointmentHistoryEntity e){history.insert(e);} public void operation(TruckAppointmentOperationEntity e){operations.insert(e);} public void attachment(TruckAppointmentAttachmentEntity e){attachments.insert(e);} public boolean claimOperation(long operationId,long appointmentId,String action,String key,String hash){return operations.claim(operationId,appointmentId,action,key,hash)==1;} public TruckAppointmentOperationEntity operation(String key){return operations.find(key);} public int completeOperation(long appointmentId,String action,String key,String response){return operations.complete(appointmentId,action,key,response);} public int updateEdit(TruckAppointmentEntity e,int v){return mapper.updateEdit(e,v);} public java.util.List histories(long id){return history.selectList(new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper().eq(TruckAppointmentHistoryEntity::getAppointmentId,id).orderByAsc(TruckAppointmentHistoryEntity::getCreatedAt));} public java.util.List attachments(long id){return attachments.selectList(new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper().eq(TruckAppointmentAttachmentEntity::getAppointmentId,id).orderByAsc(TruckAppointmentAttachmentEntity::getUploadedAt));} public int deleteAttachment(long appointmentId,long attachmentId){return attachments.delete(new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper().eq(TruckAppointmentAttachmentEntity::getAppointmentId,appointmentId).eq(TruckAppointmentAttachmentEntity::getId,attachmentId));} public TruckAppointmentEntity firstActiveForUpdate(long containerBookingId){return mapper.firstActiveForUpdate(containerBookingId);} public java.util.List activeForUpdate(long containerBookingId){return mapper.activeForUpdate(containerBookingId);} public int completeLoadingFromPda(long id,String fromStatus,int version){return mapper.completeLoadingFromPda(id,fromStatus,version);} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/TruckAppointmentService.java b/firstleg/src/main/java/com/baiyue/firstleg/service/TruckAppointmentService.java index bd2fe0d6f..a3cb9b9e4 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/TruckAppointmentService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/TruckAppointmentService.java @@ -54,8 +54,8 @@ public class TruckAppointmentService { @Transactional public void deleteAttachment(long id,long attachmentId){required(id);if(repo.deleteAttachment(id,attachmentId)!=1)throw new BizException(ResultCode.NOT_FOUND,"附件引用不存在");} @Transactional public TruckAppointmentEntity create(long uid,String name,SaveRequest r){if(r.expectedVersion()==null||r.expectedVersion()!=0)throw bad("新增 expectedVersion 必须为0");validateRequired(r);TruckAppointmentEntity active=repo.firstActiveForUpdate(r.containerBookingId());if(active!=null)throw existingAppointmentConflict(active);AssociationContext links=validateAssociations(r);var carrier=suppliers.trucking(r.carrierId(),r.carrierName());TruckAppointmentEntity e=new TruckAppointmentEntity();e.setId(IdWorker.getId());e.setAppointmentNo(documentNumbers.next("TA"));e.setStatus(TruckAppointmentStatus.DRAFT);e.setRevision(0);e.setVersion(0);e.setCreatedAt(LocalDateTime.now());e.setUpdatedAt(e.getCreatedAt());copy(e,r,links,carrier.name());try{repo.insert(e);}catch(DuplicateKeyException exception){if(exception.getMessage()!=null&&exception.getMessage().contains("uk_truck_active_container"))throw existingAppointmentConflict(repo.firstActiveForUpdate(r.containerBookingId()));throw exception;}record(e,null,e.getStatus().name(),"CREATE",null,uid,name);return enrich(e);} @Transactional public TruckAppointmentEntity edit(long id,long uid,String name,SaveRequest r){validateRequired(r);TruckAppointmentEntity e=required(id);checkVersion(e,r.expectedVersion());if(!(e.getStatus()==TruckAppointmentStatus.DRAFT||e.getStatus()==TruckAppointmentStatus.PENDING_DISPATCH||e.getStatus()==TruckAppointmentStatus.RESCHEDULED))throw conflict("当前状态不可编辑");AssociationContext links=validateAssociations(r);var carrier=suppliers.trucking(r.carrierId(),r.carrierName());copy(e,r,links,carrier.name());if(repo.updateEdit(e,r.expectedVersion())!=1)throw conflict("版本已变化,请刷新后重试");record(e,e.getStatus().name(),e.getStatus().name(),"EDIT",null,uid,name);return required(id);} - @Transactional public TruckAppointmentEntity action(long id,String action,ActionRequest r,String idem,String clientHash,long uid,String name){String requestHash=hash(action,id,r);verifyClientHash(clientHash,requestHash);TruckAppointmentEntity replay=claimOrReplay(id,action,idem,requestHash);if(replay!=null)return replay;TruckAppointmentEntity e=required(id); checkVersion(e,r.expectedVersion()); if("RESCHEDULE".equals(action))throw conflict("改约请使用专用接口"); TruckAppointmentStatus from=e.getStatus(),to=next(from,action); String dispatchBill="DISPATCH".equals(action)?requireDispatchReady(e):null; int changed="DISPATCH".equals(action)?repo.dispatch(id,from.name(),dispatchBill,e.getVersion()):repo.transition(id,from.name(),to.name(),e.getVersion()); if(changed!=1)throw conflict("状态或版本已变化,请刷新后重试"); TruckAppointmentEntity after=required(id);record(after,from.name(),to.name(),action,r.reason(),uid,name);return completeOperation(id,action,idem,after);} - @Transactional public TruckAppointmentEntity reschedule(long id,LocalDateTime at,ActionRequest r,String idem,String clientHash,long uid,String name){String action="RESCHEDULE";String requestHash=hash(action,id,new ReschedulePayload(at,r));verifyClientHash(clientHash,requestHash);TruckAppointmentEntity replay=claimOrReplay(id,action,idem,requestHash);if(replay!=null)return replay;TruckAppointmentEntity e=required(id);checkVersion(e,r.expectedVersion()); if(e.getStatus()==TruckAppointmentStatus.COMPLETED||e.getStatus()==TruckAppointmentStatus.CANCELLED)throw conflict("当前状态不可改约"); String from=e.getStatus().name(); if(repo.reschedule(id,at,r.reason(),e.getVersion())!=1)throw conflict("状态或版本已变化,请刷新后重试");TruckAppointmentEntity after=required(id);record(after,from,"RESCHEDULED",action,r.reason(),uid,name);return completeOperation(id,action,idem,after);} + @Transactional public TruckAppointmentEntity action(long id,String action,ActionRequest r,String idem,String clientHash,long uid,String name){String requestHash=hash(action,id,r);verifyClientHash(clientHash,requestHash);TruckAppointmentEntity replay=claimOrReplay(id,action,idem,requestHash);if(replay!=null)return replay;TruckAppointmentEntity e=required(id);checkVersion(e,r.expectedVersion());if("RESCHEDULE".equals(action))throw conflict("改约请使用专用接口");if(!"PICKUP".equals(action)&&StringUtils.hasText(r.containerNo()))throw bad("containerNo仅PICKUP动作可填写");TruckAppointmentStatus from=e.getStatus(),to=next(from,action);String dispatchBill="DISPATCH".equals(action)?requireDispatchReady(e):null;if("PICKUP".equals(action))bindContainerNoForPickup(e,r.containerNo(),uid,name);int changed="DISPATCH".equals(action)?repo.dispatch(id,from.name(),dispatchBill,e.getVersion()):repo.transition(id,from.name(),to.name(),e.getVersion());if(changed!=1)throw conflict("状态或版本已变化,请刷新后重试");TruckAppointmentEntity after=required(id);record(after,from.name(),to.name(),action,r.reason(),uid,name);return completeOperation(id,action,idem,after);} + @Transactional public TruckAppointmentEntity reschedule(long id,LocalDateTime at,ActionRequest r,String idem,String clientHash,long uid,String name){if(StringUtils.hasText(r.containerNo()))throw bad("containerNo仅PICKUP动作可填写");String action="RESCHEDULE";String requestHash=hash(action,id,new ReschedulePayload(at,r));verifyClientHash(clientHash,requestHash);TruckAppointmentEntity replay=claimOrReplay(id,action,idem,requestHash);if(replay!=null)return replay;TruckAppointmentEntity e=required(id);checkVersion(e,r.expectedVersion()); if(e.getStatus()==TruckAppointmentStatus.COMPLETED||e.getStatus()==TruckAppointmentStatus.CANCELLED)throw conflict("当前状态不可改约"); String from=e.getStatus().name(); if(repo.reschedule(id,at,r.reason(),e.getVersion())!=1)throw conflict("状态或版本已变化,请刷新后重试");TruckAppointmentEntity after=required(id);record(after,from,"RESCHEDULED",action,r.reason(),uid,name);return completeOperation(id,action,idem,after);} @Transactional(propagation=Propagation.REQUIRES_NEW) public void completeLoadingFromPda(long containerBookingId,long taskId,long uid,String name){java.util.List active=repo.activeForUpdate(containerBookingId);if(active.isEmpty())return;if(active.size()!=1)throw conflict("同一柜次存在多条有效拖车预约,跳过自动完成");TruckAppointmentEntity appointment=active.get(0);String from=appointment.getStatus().name();if(repo.completeLoadingFromPda(appointment.getId(),from,appointment.getVersion())!=1)throw conflict("拖车预约已被并发修改,跳过自动完成");TruckAppointmentEntity after=required(appointment.getId());record(after,from,TruckAppointmentStatus.COMPLETED.name(),"PDA_COMPLETE_LOADING","PDA task "+taskId+" 确认装柜完成",uid,name);} private AssociationContext validateAssociations(SaveRequest r){ OceanBookingEntity booking=oceanBookings.find(r.oceanBookingId()); @@ -70,7 +70,6 @@ public class TruckAppointmentService { long containerId=container.getId(); boolean allocated=oceanBookings.allocations(booking.getId()).stream().anyMatch(a->Objects.equals(a.getContainerBookingId(),containerId)); if(!allocated)throw bad("containerBookingId未分配至该订舱"); - Iso6346ContainerNumber.require(container.getContainerNo()); oceanBookingDocuments.requireActiveValidDocument(booking); if(r.loadingPlanId()!=null){LoadingPlanEntity plan=loadingPlans.planByBooking(container.getId());if(plan==null||!Objects.equals(r.loadingPlanId(),plan.getId()))throw bad("loadingPlanId与柜次当前装柜计划不一致");} return new AssociationContext(booking,container,warehouseId,billOfLadingNo); @@ -95,12 +94,12 @@ public class TruckAppointmentService { String bill=releasedBookingBill(booking,trim(appointment.getBillOfLadingNo())); ContainerBookingEntity container=containers.find(appointment.getContainerBookingId()); if(container==null||!"ACTIVE".equals(container.getRecordStatus())||!Objects.equals(container.getOceanBookingId(),booking.getId()))throw conflict("关联柜次已失效或不属于当前订舱"); - Iso6346ContainerNumber.require(container.getContainerNo()); boolean allocated=oceanBookings.allocations(booking.getId()).stream().anyMatch(a->Objects.equals(a.getContainerBookingId(),container.getId())); if(!allocated)throw conflict("关联柜次未分配至当前订舱"); oceanBookingDocuments.requireActiveValidDocument(booking); return bill; } + private void bindContainerNoForPickup(TruckAppointmentEntity appointment,String requested,long operatorId,String operatorName){if(appointment.getContainerBookingId()==null)throw conflict("拖车预约缺少柜次关联");ContainerBookingEntity container=containers.findActiveForUpdate(appointment.getContainerBookingId());if(container==null||!Objects.equals(container.getOceanBookingId(),appointment.getOceanBookingId()))throw conflict("关联柜次已失效或不属于当前订舱");String existing=trim(container.getContainerNo());String supplied=trim(requested);if(StringUtils.hasText(existing)){String normalizedExisting=Iso6346ContainerNumber.require(existing);if(StringUtils.hasText(supplied)&&!normalizedExisting.equals(Iso6346ContainerNumber.require(supplied)))throw conflict("PICKUP提交的真实集装箱号与柜次已有号码不一致,请通过预装纠错修改");return;}if(!StringUtils.hasText(supplied))return;String normalized=Iso6346ContainerNumber.require(supplied);if(containers.containerNoExists(normalized,container.getId()))throw conflict("真实集装箱号已被其他柜次使用");try{if(repo.bindContainerNoForPickup(container.getId(),normalized,operatorId,operatorName)!=1)throw conflict("柜次真实集装箱号已被并发修改,请刷新后重试");}catch(DuplicateKeyException exception){throw conflict("真实集装箱号已被其他柜次使用");}} private static String releasedBookingBill(OceanBookingEntity booking,String requested){String authoritative=trim(booking.getBillOfLadingNo());if(!StringUtils.hasText(authoritative))throw conflict("关联已放舱订舱缺少提单号");if(StringUtils.hasText(requested)&&!authoritative.equals(requested))throw bad("billOfLadingNo与关联订舱不一致");return authoritative;} private static String attachmentType(String value){String normalized=trim(value);if(!ATTACHMENT_TYPES.contains(normalized))throw bad("不支持的拖车附件类型");return normalized;} private static String currency(String value){if(!StringUtils.hasText(value))return "RMB";String normalized=value.trim();if(!CURRENCIES.contains(normalized))throw bad("currency仅支持RMB或USD");return normalized;} diff --git a/firstleg/src/main/resources/openapi/truck-appointments-v1.yaml b/firstleg/src/main/resources/openapi/truck-appointments-v1.yaml index 57c9df103..e2e7d03a1 100644 --- a/firstleg/src/main/resources/openapi/truck-appointments-v1.yaml +++ b/firstleg/src/main/resources/openapi/truck-appointments-v1.yaml @@ -2,7 +2,7 @@ openapi: 3.0.3 info: title: Truck Appointment API version: 1.3.0 - description: Every truck appointment must select one RELEASED ocean booking and one ACTIVE real-numbered container already allocated to that booking. The bill-of-lading number is frozen from the booking, and the booking must own at least one active BOOKING_CONFIRMATION or BILL_OF_LADING file under OCEAN_BOOKING_DOCUMENT. Booking and bill files are not owned by truck appointments. After a PDA loading transaction commits, the server best-effort completes the unique active appointment explicitly linked to that container in an isolated transaction; absence, duplicates or concurrent truck edits never roll back loading completion. + description: Every truck appointment must select one RELEASED ocean booking and its allocated ACTIVE internal container identity. A real ISO 6346 container number is not required for create, edit, submit or dispatch. PICKUP may omit it and still advances normally; when supplied, PICKUP atomically records it if still missing or reuses the existing valid number. The bill-of-lading number is frozen from the booking, and the booking must own at least one active BOOKING_CONFIRMATION or BILL_OF_LADING file under OCEAN_BOOKING_DOCUMENT. Booking and bill files are not owned by truck appointments. After a PDA loading transaction commits, the server best-effort completes the unique active appointment explicitly linked to that container in an isolated transaction; absence, duplicates or concurrent truck edits never roll back loading completion. paths: /api/platform/v1/truck-appointments: get: @@ -20,7 +20,7 @@ paths: properties: records: {type: array, items: {$ref: '#/components/schemas/DetailStableReferences'}} post: - summary: Create a truck appointment for a released booking and its allocated real container + summary: Create a truck appointment for a released booking and its allocated internal container description: If the real container already has a non-terminal appointment, the server returns A0409 with the existing appointment number and instructs the operator to continue editing. This duplicate guard runs before booking-document validation; the database unique key provides the same response for concurrent creates. requestBody: required: true @@ -53,7 +53,7 @@ paths: - {$ref: '#/components/parameters/RequestHash'} post: summary: Apply an idempotent appointment lifecycle action - description: ARRIVE, LOAD and DEPART persist their actual event timestamps atomically with the status transition. + description: PICKUP accepts optional containerNo. Omitting it never blocks the transition to PICKUP_IN_PROGRESS; when supplied, the server atomically records it on the linked internal container if missing. An existing valid ISO 6346 number may be omitted or resubmitted unchanged; a different value is rejected and must use the preload correction flow. Other actions reject containerNo. ARRIVE, LOAD and DEPART persist their actual event timestamps atomically with the status transition. requestBody: required: true content: @@ -176,13 +176,14 @@ components: properties: expectedVersion: {type: integer, minimum: 0} reason: {type: string, maxLength: 500} + containerNo: {type: string, maxLength: 32, nullable: true, description: Optional and only valid for PICKUP. Omission does not block PICKUP; when supplied it is normalized and validated as unique ISO 6346 before being written atomically. A different value from an existing number is rejected and must use preload correction.} SaveRequest: type: object required: [oceanBookingId, containerBookingId, carrierId, plannedLoadingAt, expectedVersion] properties: outboundOrderId: {type: integer, format: int64, minimum: 1, nullable: true} oceanBookingId: {type: integer, format: int64, minimum: 1, description: Required RELEASED booking; must own an active BOOKING_CONFIRMATION or BILL_OF_LADING file.} - containerBookingId: {type: integer, format: int64, minimum: 1, description: Required ACTIVE container already allocated to oceanBookingId and carrying a valid unique ISO 6346 container number.} + containerBookingId: {type: integer, format: int64, minimum: 1, description: Required ACTIVE internal container already allocated to oceanBookingId. Its real ISO 6346 number may remain empty until PICKUP.} loadingPlanId: {type: integer, format: int64, minimum: 1, nullable: true, description: Optional loading-plan association. It may be supplied only with containerBookingId and must be that container's current plan.} warehouseId: {type: integer, format: int64, minimum: 1, nullable: true, description: Optional warehouse scope. Linked booking/container warehouse facts must agree; when omitted the linked fact is frozen if available.} carrierId: {type: integer, format: int64, minimum: 1, description: Required ACTIVE Warehouse supplier ID validated as TRUCKING.} diff --git a/firstleg/src/test/java/com/baiyue/firstleg/controller/TruckAppointmentControllerContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/controller/TruckAppointmentControllerContractTest.java index 4ffcde1c2..37153971b 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/controller/TruckAppointmentControllerContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/controller/TruckAppointmentControllerContractTest.java @@ -31,7 +31,7 @@ class TruckAppointmentControllerContractTest { } @Test void openApiDocumentsLifecycleIdempotencyAndActualTimes() throws Exception {String s=new ClassPathResource("openapi/truck-appointments-v1.yaml").getContentAsString(StandardCharsets.UTF_8);assertThat(s).contains("/api/platform/v1/truck-appointments/{id}/{action}:","/api/platform/v1/truck-appointments/{id}/reschedule:","name: Idempotency-Key","required: true","actual event timestamps");} @Test void openApiDocumentsActionableDuplicateCreateConflict() throws Exception {String s=new ClassPathResource("openapi/truck-appointments-v1.yaml").getContentAsString(StandardCharsets.UTF_8);assertThat(s).contains("A0409","existing appointment number","continue editing","before booking-document validation","database unique key");} - @Test void openApiRequiresReleasedBookingAndAllocatedRealContainerWithoutCustomerScope() throws Exception {String s=new ClassPathResource("openapi/truck-appointments-v1.yaml").getContentAsString(StandardCharsets.UTF_8);assertThat(s).contains("required: [oceanBookingId, containerBookingId, carrierId, plannedLoadingAt, expectedVersion]","RELEASED ocean booking","ACTIVE real-numbered container","OCEAN_BOOKING_DOCUMENT","valid unique ISO 6346 container number","schema: {$ref: '#/components/schemas/DetailEnvelope'}","required: [code, message, data, timestamp]","allOf: [{$ref: '#/components/schemas/DetailStableReferences'}]","nullable: true").doesNotContain("customerId", "customerName", "Filter by customer");} + @Test void openApiRequiresReleasedBookingAndAllocatedInternalContainerWithoutCustomerScope() throws Exception {String s=new ClassPathResource("openapi/truck-appointments-v1.yaml").getContentAsString(StandardCharsets.UTF_8);assertThat(s).contains("required: [oceanBookingId, containerBookingId, carrierId, plannedLoadingAt, expectedVersion]","RELEASED ocean booking","allocated ACTIVE internal container identity","real ISO 6346 container number is not required for create, edit, submit or dispatch","PICKUP may omit it and still advances normally","Omitting it never blocks the transition to PICKUP_IN_PROGRESS","must use the preload correction flow","Other actions reject containerNo","OCEAN_BOOKING_DOCUMENT","schema: {$ref: '#/components/schemas/DetailEnvelope'}","required: [code, message, data, timestamp]","allOf: [{$ref: '#/components/schemas/DetailStableReferences'}]","nullable: true").doesNotContain("customerId", "customerName", "Filter by customer");} @Test void openApiKeepsOnlyNonBookingTruckAttachments() throws Exception {String s=new ClassPathResource("openapi/truck-appointments-v1.yaml").getContentAsString(StandardCharsets.UTF_8);assertThat(s).contains("AttachmentRequest:","required: [fileId, attachmentType]","required: [attachmentType, file]","TRUCK_APPOINTMENT_ATTACHMENT","Deletes the appointment reference only","DetailStableReferences:","oceanBookingNo:","shippingCompanyName:","bookingAgentName:","cabinetNo:","billOfLadingNo:").doesNotContain("enum: [BILL_OF_LADING, DISPATCH_ORDER");} @Test void openApiExposesOnlyPayableWithFixedCurrencies() throws Exception {String s=new ClassPathResource("openapi/truck-appointments-v1.yaml").getContentAsString(StandardCharsets.UTF_8);assertThat(s).contains("payableAmount:","enum: [RMB, USD]","default: RMB").doesNotContain("receivableAmount");} @Test void methodsHavePlatformPermissionsAndAuthenticatedScope() { diff --git a/firstleg/src/test/java/com/baiyue/firstleg/repository/TruckAppointmentMapperContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/repository/TruckAppointmentMapperContractTest.java index d373d1acb..3c979281c 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/repository/TruckAppointmentMapperContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/repository/TruckAppointmentMapperContractTest.java @@ -20,6 +20,9 @@ class TruckAppointmentMapperContractTest { assertThat(transition).contains("status=#{toStatus}", "status=#{fromStatus}", "version=#{version}", "revision=revision+1", "version=version+1"); String dispatch = TruckAppointmentMapper.class.getMethod("dispatch", long.class, String.class, String.class, int.class).getAnnotation(Update.class).value()[0]; assertThat(dispatch).contains("status='DISPATCHED'", "bill_of_lading_no=#{billOfLadingNo}", "status=#{fromStatus}", "version=#{version}", "revision=revision+1"); + String pickupContainer = TruckAppointmentMapper.class.getMethod("bindContainerNoForPickup", long.class, String.class, long.class, String.class).getAnnotation(Update.class).value()[0]; + assertThat(pickupContainer).contains("UPDATE fl_container_booking", "container_no=#{containerNo}", + "id=#{containerBookingId}", "record_status='ACTIVE'", "container_no IS NULL", "version=version+1"); String active = TruckAppointmentMapper.class.getMethod("activeForUpdate", long.class).getAnnotation(Select.class).value()[0]; assertThat(active).contains("container_booking_id=#{containerBookingId}", "FOR UPDATE") .contains("'COMPLETED','CANCELLED','EXCEPTION'"); diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/TruckAppointmentServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/TruckAppointmentServiceTest.java index b55a1d46c..aa8cd7066 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/service/TruckAppointmentServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/TruckAppointmentServiceTest.java @@ -25,6 +25,7 @@ import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.mockito.ArgumentCaptor; import org.mockito.InjectMocks; +import org.mockito.InOrder; import org.mockito.Mock; import org.mockito.MockitoAnnotations; import org.mockito.Spy; @@ -272,15 +273,21 @@ class TruckAppointmentServiceTest { verify(repository, never()).insert(any()); } - @Test void createRejectsContainerWithoutRealIsoNumberAndMissingBookingDocument() { + @Test void createAcceptsAllocatedInternalContainerWithoutRealNumber() { SaveRequest request = validRequest(); ContainerBookingEntity missingNo = realContainer(); missingNo.setContainerNo(null); when(containers.find(88L)).thenReturn(missingNo); - assertThatThrownBy(() -> service.create(7L, "admin", request)) - .isInstanceOf(BizException.class).hasMessageContaining("集装箱号"); - verify(repository, never()).insert(any()); - when(containers.find(88L)).thenReturn(realContainer()); + service.create(7L, "admin", request); + + verify(repository).insert(any(TruckAppointmentEntity.class)); + verify(oceanBookingDocuments).requireActiveValidDocument(any(OceanBookingEntity.class)); + } + + @Test void createStillRequiresBookingDocumentBeforeRealNumberIsAssigned() { + SaveRequest request = validRequest(); + ContainerBookingEntity missingNo = realContainer(); missingNo.setContainerNo(null); + when(containers.find(88L)).thenReturn(missingNo); doThrow(new BizException(com.baiyue.common.core.api.ResultCode.CONFLICT, "订舱缺少有效订舱确认或提单文件")) .when(oceanBookingDocuments).requireActiveValidDocument(any()); @@ -385,7 +392,7 @@ class TruckAppointmentServiceTest { when(repository.transition(1L, "DRAFT", "PENDING_DISPATCH", 0)).thenReturn(1); when(repository.completeOperation(eq(1L), eq("SUBMIT"), eq("idem-1"), anyString())).thenReturn(1); - var result = service.action(1L, "SUBMIT", new ActionRequest(0, "提交"), "idem-1", null, 7L, "admin"); + var result = service.action(1L, "SUBMIT", new ActionRequest(0, "提交", null), "idem-1", null, 7L, "admin"); assertThat(result.getStatus()).isEqualTo(TruckAppointmentStatus.PENDING_DISPATCH); assertThat(result.getVersion()).isEqualTo(1); @@ -399,12 +406,12 @@ class TruckAppointmentServiceTest { TruckAppointmentEntity completed = entity(TruckAppointmentStatus.PENDING_DISPATCH, 1); TruckAppointmentOperationEntity operation = new TruckAppointmentOperationEntity(); operation.setAppointmentId(1L); operation.setAction("SUBMIT"); operation.setIdempotencyKey("idem-1"); - String hash = requestHash("SUBMIT", 1L, new ActionRequest(0, "提交")); + String hash = requestHash("SUBMIT", 1L, new ActionRequest(0, "提交", null)); operation.setRequestHash(hash); operation.setResponseSnapshot(objectMapper.writeValueAsString(completed)); when(repository.claimOperation(anyLong(), eq(1L), eq("SUBMIT"), eq("idem-1"), eq(hash))).thenReturn(false); when(repository.operation("idem-1")).thenReturn(operation); - var replay = service.action(1L, "SUBMIT", new ActionRequest(0, "提交"), "idem-1", hash, 7L, "admin"); + var replay = service.action(1L, "SUBMIT", new ActionRequest(0, "提交", null), "idem-1", hash, 7L, "admin"); assertThat(replay.getStatus()).isEqualTo(TruckAppointmentStatus.PENDING_DISPATCH); verify(repository, never()).transition(anyLong(), anyString(), anyString(), anyInt()); @@ -412,7 +419,7 @@ class TruckAppointmentServiceTest { } @Test void concurrentSameKeyBeforeFirstCompletionReturnsRetryableConflictWithoutDrift() throws Exception { - var request = new ActionRequest(0, "并发提交"); + var request = new ActionRequest(0, "并发提交", null); String hash = requestHash("SUBMIT", 1L, request); TruckAppointmentOperationEntity processing = new TruckAppointmentOperationEntity(); processing.setAppointmentId(1L); processing.setAction("SUBMIT"); @@ -437,7 +444,7 @@ class TruckAppointmentServiceTest { .thenReturn(false); when(repository.operation("reused-key")).thenReturn(existing); - assertThatThrownBy(() -> service.action(1L, "SUBMIT", new ActionRequest(0, "载荷A"), + assertThatThrownBy(() -> service.action(1L, "SUBMIT", new ActionRequest(0, "载荷A", null), "reused-key", null, 7L, "admin")) .isInstanceOf(BizException.class).hasMessageContaining("不同请求"); @@ -453,7 +460,7 @@ class TruckAppointmentServiceTest { when(repository.claimOperation(anyLong(), eq(1L), eq("DISPATCH"), eq("dispatch-no-bill"), anyString())).thenReturn(true); when(repository.find(1L)).thenReturn(missingBill); - assertThatThrownBy(() -> service.action(1L, "DISPATCH", new ActionRequest(1, null), + assertThatThrownBy(() -> service.action(1L, "DISPATCH", new ActionRequest(1, null, null), "dispatch-no-bill", null, 7L, "admin")) .isInstanceOf(BizException.class).hasMessageContaining("提单号"); @@ -465,26 +472,28 @@ class TruckAppointmentServiceTest { doThrow(new BizException(com.baiyue.common.core.api.ResultCode.CONFLICT,"订舱缺少有效订舱确认或提单文件")) .when(oceanBookingDocuments).requireActiveValidDocument(any()); - assertThatThrownBy(() -> service.action(1L, "DISPATCH", new ActionRequest(1, null), + assertThatThrownBy(() -> service.action(1L, "DISPATCH", new ActionRequest(1, null, null), "dispatch-no-file", null, 7L, "admin")) .isInstanceOf(BizException.class).hasMessageContaining("有效订舱确认或提单文件"); verify(repository, never()).dispatch(anyLong(), anyString(), anyString(), anyInt()); } - @Test void dispatchFreezesReleasedBookingBillAndTransitionsOnceWithActiveAttachment() { + @Test void dispatchFreezesReleasedBookingBillWithoutRequiringRealContainerNumber() { TruckAppointmentEntity before = entity(TruckAppointmentStatus.PENDING_DISPATCH, 1); before.setOceanBookingId(99L); before.setContainerBookingId(88L); before.setBillOfLadingNo(null); TruckAppointmentEntity after = entity(TruckAppointmentStatus.DISPATCHED, 2); after.setOceanBookingId(99L); after.setContainerBookingId(88L); after.setBillOfLadingNo("BL-BOOKING-1"); OceanBookingEntity booking = new OceanBookingEntity(); booking.setId(99L); booking.setStatus("RELEASED"); booking.setBillOfLadingNo("BL-BOOKING-1"); + ContainerBookingEntity internalContainer = realContainer(); internalContainer.setContainerNo(null); when(repository.claimOperation(anyLong(), eq(1L), eq("DISPATCH"), eq("dispatch-ok"), anyString())).thenReturn(true); when(repository.find(1L)).thenReturn(before, after); when(oceanBookings.find(99L)).thenReturn(booking); + when(containers.find(88L)).thenReturn(internalContainer); when(repository.dispatch(1L, "PENDING_DISPATCH", "BL-BOOKING-1", 1)).thenReturn(1); when(repository.completeOperation(eq(1L), eq("DISPATCH"), eq("dispatch-ok"), anyString())).thenReturn(1); - TruckAppointmentEntity result = service.action(1L, "DISPATCH", new ActionRequest(1, null), + TruckAppointmentEntity result = service.action(1L, "DISPATCH", new ActionRequest(1, null, null), "dispatch-ok", null, 7L, "admin"); assertThat(result.getStatus()).isEqualTo(TruckAppointmentStatus.DISPATCHED); @@ -494,8 +503,168 @@ class TruckAppointmentServiceTest { verify(repository).history(any(TruckAppointmentHistoryEntity.class)); } + @Test void pickupWithoutContainerNumberStillAdvancesWithoutBinding() { + TruckAppointmentEntity before = entity(TruckAppointmentStatus.DISPATCHED, 2); + before.setOceanBookingId(99L); before.setContainerBookingId(88L); + TruckAppointmentEntity after = entity(TruckAppointmentStatus.PICKUP_IN_PROGRESS, 3); + after.setOceanBookingId(99L); after.setContainerBookingId(88L); + ContainerBookingEntity container = realContainer(); container.setContainerNo(null); + when(repository.find(1L)).thenReturn(before, after); + when(repository.claimOperation(anyLong(), eq(1L), eq("PICKUP"), eq("pickup-without-number"), anyString())).thenReturn(true); + when(containers.findActiveForUpdate(88L)).thenReturn(container); + when(repository.transition(1L, "DISPATCHED", "PICKUP_IN_PROGRESS", 2)).thenReturn(1); + when(repository.completeOperation(eq(1L), eq("PICKUP"), eq("pickup-without-number"), anyString())).thenReturn(1); + + TruckAppointmentEntity result = service.action(1L, "PICKUP", new ActionRequest(2, "提柜", null), + "pickup-without-number", null, 7L, "admin"); + + assertThat(result.getStatus()).isEqualTo(TruckAppointmentStatus.PICKUP_IN_PROGRESS); + verify(repository, never()).bindContainerNoForPickup(anyLong(), anyString(), anyLong(), anyString()); + verify(repository).transition(1L, "DISPATCHED", "PICKUP_IN_PROGRESS", 2); + verify(repository).history(any(TruckAppointmentHistoryEntity.class)); + } + + @Test void pickupRejectsSuppliedInvalidContainerNumberWithoutDrift() { + TruckAppointmentEntity before = entity(TruckAppointmentStatus.DISPATCHED, 2); + before.setOceanBookingId(99L); before.setContainerBookingId(88L); + ContainerBookingEntity container = realContainer(); container.setContainerNo(null); + when(repository.find(1L)).thenReturn(before); + when(repository.claimOperation(anyLong(), eq(1L), eq("PICKUP"), eq("pickup-invalid"), anyString())).thenReturn(true); + when(containers.findActiveForUpdate(88L)).thenReturn(container); + + assertThatThrownBy(() -> service.action(1L, "PICKUP", new ActionRequest(2, "提柜", "MSCU6639871"), + "pickup-invalid", null, 7L, "admin")) + .isInstanceOf(BizException.class).hasMessageContaining("ISO 6346"); + + verify(repository, never()).bindContainerNoForPickup(anyLong(), anyString(), anyLong(), anyString()); + verify(repository, never()).transition(anyLong(), anyString(), anyString(), anyInt()); + verify(repository, never()).history(any()); + } + + @Test void pickupNormalizesAndAtomicallyBindsContainerNumberBeforeTransition() { + TruckAppointmentEntity before = entity(TruckAppointmentStatus.DISPATCHED, 2); + before.setOceanBookingId(99L); before.setContainerBookingId(88L); + TruckAppointmentEntity after = entity(TruckAppointmentStatus.PICKUP_IN_PROGRESS, 3); + after.setOceanBookingId(99L); after.setContainerBookingId(88L); + ContainerBookingEntity container = realContainer(); container.setContainerNo(null); + when(repository.find(1L)).thenReturn(before, after); + when(repository.claimOperation(anyLong(), eq(1L), eq("PICKUP"), eq("pickup-bind"), anyString())).thenReturn(true); + when(containers.findActiveForUpdate(88L)).thenReturn(container); + when(containers.containerNoExists("MSCU6639870", 88L)).thenReturn(false); + when(repository.bindContainerNoForPickup(88L, "MSCU6639870", 7L, "admin")).thenReturn(1); + when(repository.transition(1L, "DISPATCHED", "PICKUP_IN_PROGRESS", 2)).thenReturn(1); + when(repository.completeOperation(eq(1L), eq("PICKUP"), eq("pickup-bind"), anyString())).thenReturn(1); + + TruckAppointmentEntity result = service.action(1L, "PICKUP", + new ActionRequest(2, "司机已提柜", " ms cu 663987 0 "), "pickup-bind", null, 7L, "admin"); + + assertThat(result.getStatus()).isEqualTo(TruckAppointmentStatus.PICKUP_IN_PROGRESS); + InOrder writes = inOrder(repository); + writes.verify(repository).bindContainerNoForPickup(88L, "MSCU6639870", 7L, "admin"); + writes.verify(repository).transition(1L, "DISPATCHED", "PICKUP_IN_PROGRESS", 2); + writes.verify(repository).history(any(TruckAppointmentHistoryEntity.class)); + } + + @Test void pickupReusesExistingValidContainerNumberWithoutRewritingIt() { + TruckAppointmentEntity before = entity(TruckAppointmentStatus.DISPATCHED, 2); + before.setOceanBookingId(99L); before.setContainerBookingId(88L); + TruckAppointmentEntity after = entity(TruckAppointmentStatus.PICKUP_IN_PROGRESS, 3); + after.setOceanBookingId(99L); after.setContainerBookingId(88L); + when(repository.find(1L)).thenReturn(before, after); + when(repository.claimOperation(anyLong(), eq(1L), eq("PICKUP"), eq("pickup-reuse"), anyString())).thenReturn(true); + when(containers.findActiveForUpdate(88L)).thenReturn(realContainer()); + when(repository.transition(1L, "DISPATCHED", "PICKUP_IN_PROGRESS", 2)).thenReturn(1); + when(repository.completeOperation(eq(1L), eq("PICKUP"), eq("pickup-reuse"), anyString())).thenReturn(1); + + service.action(1L, "PICKUP", new ActionRequest(2, "复用已有柜号", null), + "pickup-reuse", null, 7L, "admin"); + + verify(repository, never()).bindContainerNoForPickup(anyLong(), anyString(), anyLong(), anyString()); + verify(repository).transition(1L, "DISPATCHED", "PICKUP_IN_PROGRESS", 2); + } + + @Test void pickupIdempotentReplayDoesNotLockOrRewriteContainerAndChangedNumberConflicts() throws Exception { + ActionRequest request = new ActionRequest(2, "提柜", "MSCU6639870"); + TruckAppointmentEntity completed = entity(TruckAppointmentStatus.PICKUP_IN_PROGRESS, 3); + completed.setOceanBookingId(99L); completed.setContainerBookingId(88L); + TruckAppointmentOperationEntity operation = new TruckAppointmentOperationEntity(); + operation.setAppointmentId(1L); operation.setAction("PICKUP"); operation.setIdempotencyKey("pickup-replay"); + operation.setRequestHash(requestHash("PICKUP", 1L, request)); + operation.setResponseSnapshot(objectMapper.writeValueAsString(completed)); + when(repository.claimOperation(anyLong(), eq(1L), eq("PICKUP"), eq("pickup-replay"), anyString())) + .thenReturn(false); + when(repository.operation("pickup-replay")).thenReturn(operation); + + TruckAppointmentEntity replay = service.action(1L, "PICKUP", request, + "pickup-replay", operation.getRequestHash(), 7L, "admin"); + assertThat(replay.getStatus()).isEqualTo(TruckAppointmentStatus.PICKUP_IN_PROGRESS); + assertThatThrownBy(() -> service.action(1L, "PICKUP", + new ActionRequest(2, "提柜", "TSTU1503306"), "pickup-replay", null, 7L, "admin")) + .isInstanceOf(BizException.class).hasMessageContaining("不同请求"); + + verify(containers, never()).findActiveForUpdate(anyLong()); + verify(repository, never()).bindContainerNoForPickup(anyLong(), anyString(), anyLong(), anyString()); + verify(repository, never()).transition(anyLong(), anyString(), anyString(), anyInt()); + } + + @Test void pickupRejectsMismatchedOrDuplicateContainerNumberBeforeStatusDrift() { + TruckAppointmentEntity before = entity(TruckAppointmentStatus.DISPATCHED, 2); + before.setOceanBookingId(99L); before.setContainerBookingId(88L); + when(repository.find(1L)).thenReturn(before); + when(repository.claimOperation(anyLong(), eq(1L), eq("PICKUP"), anyString(), anyString())).thenReturn(true); + when(containers.findActiveForUpdate(88L)).thenReturn(realContainer()); + + assertThatThrownBy(() -> service.action(1L, "PICKUP", + new ActionRequest(2, "错误柜号", "TSTU1503306"), "pickup-mismatch", null, 7L, "admin")) + .isInstanceOf(BizException.class).hasMessageContaining("不一致").hasMessageContaining("预装纠错"); + + ContainerBookingEntity unnumbered = realContainer(); unnumbered.setContainerNo(null); + when(containers.findActiveForUpdate(88L)).thenReturn(unnumbered); + when(containers.containerNoExists("TSTU1503306", 88L)).thenReturn(true); + assertThatThrownBy(() -> service.action(1L, "PICKUP", + new ActionRequest(2, "重复柜号", "TSTU1503306"), "pickup-duplicate", null, 7L, "admin")) + .isInstanceOf(BizException.class).hasMessageContaining("其他柜次使用"); + + verify(repository, never()).transition(anyLong(), anyString(), anyString(), anyInt()); + verify(repository, never()).history(any()); + } + + @Test void concurrentPickupContainerNumberCollisionRollsBackBeforeStatusTransition() { + TruckAppointmentEntity before = entity(TruckAppointmentStatus.DISPATCHED, 2); + before.setOceanBookingId(99L); before.setContainerBookingId(88L); + ContainerBookingEntity container = realContainer(); container.setContainerNo(null); + when(repository.find(1L)).thenReturn(before); + when(repository.claimOperation(anyLong(), eq(1L), eq("PICKUP"), eq("pickup-concurrent"), anyString())).thenReturn(true); + when(containers.findActiveForUpdate(88L)).thenReturn(container); + when(containers.containerNoExists("MSCU6639870", 88L)).thenReturn(false); + doThrow(new DuplicateKeyException("uk_container_booking_container_no")) + .when(repository).bindContainerNoForPickup(88L, "MSCU6639870", 7L, "admin"); + + assertThatThrownBy(() -> service.action(1L, "PICKUP", + new ActionRequest(2, "并发提柜", "MSCU6639870"), "pickup-concurrent", null, 7L, "admin")) + .isInstanceOf(BizException.class).hasMessageContaining("其他柜次使用"); + + verify(repository, never()).transition(anyLong(), anyString(), anyString(), anyInt()); + verify(repository, never()).history(any()); + verify(repository, never()).completeOperation(anyLong(), anyString(), anyString(), anyString()); + } + + @Test void nonPickupActionsRejectContainerNumberWithoutWritingIt() { + TruckAppointmentEntity before = entity(TruckAppointmentStatus.DRAFT, 0); + when(repository.find(1L)).thenReturn(before); + when(repository.claimOperation(anyLong(), eq(1L), eq("SUBMIT"), eq("submit-with-container"), anyString())).thenReturn(true); + + assertThatThrownBy(() -> service.action(1L, "SUBMIT", + new ActionRequest(0, "错误携带柜号", "MSCU6639870"), "submit-with-container", null, 7L, "admin")) + .isInstanceOf(BizException.class).hasMessageContaining("仅PICKUP动作"); + + verify(repository, never()).bindContainerNoForPickup(anyLong(), anyString(), anyLong(), anyString()); + verify(repository, never()).transition(anyLong(), anyString(), anyString(), anyInt()); + verify(repository, never()).history(any()); + } + @Test void actionRejectsMissingKeyAndMismatchedClientHash() { - var request = new ActionRequest(0, "提交"); + var request = new ActionRequest(0, "提交", null); assertThatThrownBy(() -> service.action(1L, "SUBMIT", request, null, null, 7L, "admin")) .isInstanceOf(BizException.class).hasMessageContaining("Idempotency-Key"); assertThatThrownBy(() -> service.action(1L, "SUBMIT", request, "idem-1", "wrong", 7L, "admin")) @@ -507,11 +676,11 @@ class TruckAppointmentServiceTest { TruckAppointmentEntity e = entity(TruckAppointmentStatus.DRAFT, 0); when(repository.find(1L)).thenReturn(e); when(repository.claimOperation(anyLong(), eq(1L), eq("RESCHEDULE"), eq("idem-action"), anyString())).thenReturn(true); - assertThatThrownBy(() -> service.action(1L, "RESCHEDULE", new ActionRequest(0, null), "idem-action", null, 7L, "admin")) + assertThatThrownBy(() -> service.action(1L, "RESCHEDULE", new ActionRequest(0, null, null), "idem-action", null, 7L, "admin")) .isInstanceOf(BizException.class).hasMessageContaining("专用接口"); e.setStatus(TruckAppointmentStatus.COMPLETED); when(repository.claimOperation(anyLong(), eq(1L), eq("RESCHEDULE"), eq("idem-reschedule"), anyString())).thenReturn(true); - assertThatThrownBy(() -> service.reschedule(1L, LocalDateTime.now(), new ActionRequest(0, "改约"), "idem-reschedule", null, 7L, "admin")) + assertThatThrownBy(() -> service.reschedule(1L, LocalDateTime.now(), new ActionRequest(0, "改约", null), "idem-reschedule", null, 7L, "admin")) .isInstanceOf(BizException.class).hasMessageContaining("不可改约"); verify(repository, never()).reschedule(anyLong(), any(), anyString(), anyInt()); } @@ -524,7 +693,7 @@ class TruckAppointmentServiceTest { when(repository.reschedule(eq(1L), any(), eq("改约"), eq(2))).thenReturn(1); when(repository.completeOperation(eq(1L), eq("RESCHEDULE"), eq("idem-reschedule"), anyString())).thenReturn(1); var at = LocalDateTime.of(2026, 8, 12, 10, 0); - var result = service.reschedule(1L, at, new ActionRequest(2, "改约"), "idem-reschedule", null, 7L, "admin"); + var result = service.reschedule(1L, at, new ActionRequest(2, "改约", null), "idem-reschedule", null, 7L, "admin"); assertThat(result.getStatus()).isEqualTo(TruckAppointmentStatus.RESCHEDULED); assertThat(result.getVersion()).isEqualTo(3); ArgumentCaptor history = ArgumentCaptor.forClass(TruckAppointmentHistoryEntity.class); From d1225845ec7ce176d07f9e75826191503c365144 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Tue, 1 Sep 2026 15:25:15 +0800 Subject: [PATCH 2/7] =?UTF-8?q?=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../PlatformLoadingListController.java | 4 +- .../baiyue/firstleg/dto/LoadingListDtos.java | 2 +- .../mapper/LoadingListQueryMapper.java | 1 - .../repository/LoadingListRepository.java | 3 +- .../firstleg/service/LoadingListService.java | 21 +++++---- .../firstleg/service/LoadingPickService.java | 23 +++++++--- .../resources/openapi/loading-lists-v1.yaml | 12 +++--- .../LoadingListControllerContractTest.java | 4 +- .../LoadingListQueryMapperContractTest.java | 2 +- .../repository/LoadingListRepositoryTest.java | 26 +++++++++++ .../service/LoadingListServiceTest.java | 33 +++++++++++--- .../service/LoadingPickServiceTest.java | 43 ++++++++++++++----- 12 files changed, 129 insertions(+), 45 deletions(-) diff --git a/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformLoadingListController.java b/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformLoadingListController.java index fe72ab844..89f643dfc 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformLoadingListController.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformLoadingListController.java @@ -72,7 +72,7 @@ public class PlatformLoadingListController { return ApiResponse.success(service.lifecycle(planId, warehouseId)); } - @Operation(summary = "修改装柜运输字段", description = "已推送且仍为 EDITING 的预装清单可补录或修改集装箱号与航次;LOADING 阶段按稳定柜次身份规则维护,不能清空已创建 PDA 任务的柜号。") + @Operation(summary = "修改装柜运输字段", description = "已推送且仍为 EDITING、PDA尚未领取或产生物理作业事实时,可首次补录或纠正集装箱号并维护航次;已有柜号的纠正必须提供原因和版本,柜号不能清空。") @PatchMapping("/loading-plans/{planId}/transport") @PreAuthorize("hasAuthority('platform:firstleg:preload:edit')") public ApiResponse updateTransport( @@ -86,7 +86,7 @@ public class PlatformLoadingListController { planId, warehouseId, user.userId(), user.username(), idempotencyKey, request)); } - @Operation(summary = "确认整份预装计划", description = "按 loadingPlanId 原子确认全部关联入库预装票据;要求已推送、真实集装箱号已录入、关联订舱已放舱。成功后唯一创建待领取 PDA 任务,claim 后才进入 LOADING。") + @Operation(summary = "确认整份预装计划", description = "按 loadingPlanId 原子确认全部关联入库预装票据;要求已推送且关联订舱已放舱,真实集装箱号可在PDA确认装柜完成前补录。成功后唯一创建待领取 PDA 任务,claim 后才进入 LOADING。") @PostMapping("/loading-plans/{planId}/confirm") @PreAuthorize("hasAuthority('platform:firstleg:preload:edit')") public ApiResponse confirmPreload( diff --git a/firstleg/src/main/java/com/baiyue/firstleg/dto/LoadingListDtos.java b/firstleg/src/main/java/com/baiyue/firstleg/dto/LoadingListDtos.java index 1ba217f2b..1e7ef50eb 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/dto/LoadingListDtos.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/dto/LoadingListDtos.java @@ -51,7 +51,7 @@ public final class LoadingListDtos { @Size(max = 64) String containerNo, @Size(max = 64) String voyageNo, @NotNull @Min(0) Integer expectedVersion, - @NotBlank @Size(max = 500) String operationReason + @Size(max = 500) String operationReason ) { } diff --git a/firstleg/src/main/java/com/baiyue/firstleg/mapper/LoadingListQueryMapper.java b/firstleg/src/main/java/com/baiyue/firstleg/mapper/LoadingListQueryMapper.java index abfdaa551..1bb4f1a1c 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/mapper/LoadingListQueryMapper.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/mapper/LoadingListQueryMapper.java @@ -250,7 +250,6 @@ public interface LoadingListQueryMapper { b.updated_by = #{operatorId}, b.updated_by_name = #{operatorName}, b.updated_at = CURRENT_TIMESTAMP(3), b.version = b.version + 1 WHERE p.id = #{planId} AND p.plan_status='EDITING' AND p.preload_pushed_at IS NOT NULL - AND #{containerNo} IS NOT NULL AND NOT EXISTS (SELECT 1 FROM fl_loading_pick_task task JOIN fl_loading_pick_assignment assignment ON assignment.pick_task_id=task.id WHERE task.loading_plan_id=p.id) diff --git a/firstleg/src/main/java/com/baiyue/firstleg/repository/LoadingListRepository.java b/firstleg/src/main/java/com/baiyue/firstleg/repository/LoadingListRepository.java index 6afdf2102..27f5333d6 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/repository/LoadingListRepository.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/repository/LoadingListRepository.java @@ -151,13 +151,12 @@ public class LoadingListRepository { java.util.ArrayList blockers = new java.util.ArrayList<>(); if (!pushed) blockers.add("PRELOAD_NOT_PUSHED"); if (!editing) blockers.add("LOADING_PLAN_NOT_EDITING"); - if (!containerReady) blockers.add("CONTAINER_NO_MISSING"); if (linkCount == 0) blockers.add("PRELOAD_LINK_MISSING"); if (!oceanLinked) blockers.add("OCEAN_BOOKING_NOT_LINKED"); else if (!released) blockers.add("OCEAN_BOOKING_NOT_RELEASED"); if (taskCreated) blockers.add("PDA_TASK_ALREADY_CREATED"); boolean allConfirmed = linkCount > 0 && confirmedCount == linkCount; - boolean prerequisitesReady = pushed && editing && containerReady && linkCount > 0 && released; + boolean prerequisitesReady = pushed && editing && linkCount > 0 && released; boolean canConfirm = prerequisitesReady && !taskCreated && !allConfirmed; if (allConfirmed) blockers.add("PRELOAD_ALREADY_CONFIRMED"); String confirmationStatus = allConfirmed ? "CONFIRMED" diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingListService.java b/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingListService.java index 86970d5fe..bf2838354 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingListService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingListService.java @@ -423,7 +423,6 @@ public class LoadingListService { AggregateRow before = requirePlan(planId); requireWarehouse(before, warehouseId); String reason = trim(request.operationReason()); - if (!StringUtils.hasText(reason)) throw badRequest("纠错原因不能为空"); String operationId = operationId("TRANSPORT", planId, idempotencyKey); HistoryRow replay = repository.history(operationId); if (replay != null) { @@ -434,15 +433,21 @@ public class LoadingListService { return repository.preloadDetail(before); } - requireTransportCorrectionMutable(before); + requireTransportMutable(before); String containerNo = targetContainerNo(before, request.containerNo()); String voyageNo = nullableTrim(request.voyageNo()); + boolean correctingContainerNo = StringUtils.hasText(before.getContainerNo()) + && !java.util.Objects.equals(before.getContainerNo(), containerNo); + if (correctingContainerNo && !StringUtils.hasText(reason)) { + throw badRequest("纠正已有真实集装箱号时纠错原因不能为空"); + } String requestHash = transportHash(planId, request.expectedVersion(), containerNo, voyageNo, reason); if (java.util.Objects.equals(before.getContainerNo(), containerNo) && java.util.Objects.equals(before.getVoyageNo(), voyageNo)) { return repository.preloadDetail(before); } - if (containerRepository.containerNoExists(containerNo, before.getContainerBookingId())) { + if (containerNo != null + && containerRepository.containerNoExists(containerNo, before.getContainerBookingId())) { throw conflict("集装箱号已存在"); } try { @@ -464,8 +469,9 @@ public class LoadingListService { } private static String targetContainerNo(AggregateRow before, String requested) { - return requested == null ? Iso6346ContainerNumber.require(before.getContainerNo()) - : Iso6346ContainerNumber.require(requested); + if (requested != null) return Iso6346ContainerNumber.require(requested); + return StringUtils.hasText(before.getContainerNo()) + ? Iso6346ContainerNumber.require(before.getContainerNo()) : null; } private static String transportHash(long planId, int version, String containerNo, @@ -577,14 +583,11 @@ public class LoadingListService { } } - private void requireTransportCorrectionMutable(AggregateRow row) { + private void requireTransportMutable(AggregateRow row) { if (!"EDITING".equals(row.getPlanStatus())) { throw conflict("仅已推送且PDA尚未开始的预装计划可纠错真实集装箱号"); } if (row.getPreloadPushedAt() == null) throw conflict("仅已推送的预装清单可维护运输字段"); - if (!StringUtils.hasText(row.getContainerNo())) { - throw conflict("真实集装箱号只能在关联已放舱订舱时首次写入"); - } if (repository.hasPdaWorkStarted(row.getPlanId())) { throw conflict("PDA任务已领取、已开始作业或已有物理作业事实,不能修改真实集装箱号"); } diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingPickService.java b/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingPickService.java index eb203f7dd..a285dabe2 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingPickService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingPickService.java @@ -23,6 +23,7 @@ import com.baiyue.firstleg.dto.LoadingPickDtos.InventoryResource; import com.baiyue.firstleg.dto.LoadingPickDtos.InboundInventory; import com.baiyue.firstleg.dto.LoadingPickDtos.InventorySnapshot; import com.baiyue.firstleg.dto.LoadingPickDtos.PendingTask; +import com.baiyue.firstleg.domain.Iso6346ContainerNumber; import com.baiyue.firstleg.entity.LoadingPickAssignmentEntity; import com.baiyue.firstleg.entity.LoadingPickHistoryEntity; import com.baiyue.firstleg.entity.LoadingPickItemEntity; @@ -113,11 +114,11 @@ public class LoadingPickService { @Value("${baiyue.security.internal-service-token:test-internal-service-token}") private String internalToken; - /** Opens the field task only after planning, transport identity and ocean release have converged. */ + /** Opens the field task after planning and ocean release; the real container number may be filled later. */ @Transactional public void beginIfReady(long containerBookingId, long operatorId, String operatorName) { var booking = smartLoadingRepository.booking(containerBookingId); - if (booking == null || !StringUtils.hasText(booking.getContainerNo())) return; + if (booking == null) return; var plan = smartLoadingRepository.planByBooking(containerBookingId); if (plan == null || plan.getPreloadPushedAt() == null || !"EDITING".equals(plan.getPlanStatus())) return; var links = loadingModelBridge.byLoadingPlan(plan.getId()); @@ -137,10 +138,6 @@ public class LoadingPickService { if (!Set.of("PENDING", "CONFIRMING").contains(link.preloadStatus())) { throw conflict("预装清单已确认或当前状态不允许确认"); } - var booking = smartLoadingRepository.booking(link.containerBookingId()); - if (booking == null || !StringUtils.hasText(booking.getContainerNo())) { - throw conflict("确认预装前必须录入真实集装箱号"); - } var allocation = oceanBookingRepository.allocationByContainer(link.containerBookingId()); if (allocation == null) throw conflict("确认预装前必须关联真实订舱柜"); var oceanBooking = oceanBookingRepository.find(allocation.getOceanBookingId()); @@ -462,6 +459,7 @@ public class LoadingPickService { requireSameCompletionCommand(replay, taskId, requestHash, operatorId); if ("FAILED".equals(replay.getCommandStatus())) { LoadingPickTaskEntity failed = requiredTaskForUpdate(taskId); + requireCompletionContainerNumber(taskId); if (!"COMPLETION_FAILED".equals(failed.getTaskStatus()) || !completionCommands.resume(replay.getId()) || repository.resumeCompletion(taskId) != 1) { throw conflict("装柜完成命令状态已变化,请刷新后重试"); @@ -485,6 +483,7 @@ public class LoadingPickService { if (!request.expectedTaskVersion().equals(task.getVersion())) { throw conflict("PDA任务版本已变化,请刷新后复核"); } + requireCompletionContainerNumber(taskId); if (!repository.allTaskItemsResolved(taskId)) { throw conflict("存在尚未处理的PDA拣货明细,不能确认装柜完成"); } @@ -514,6 +513,18 @@ public class LoadingPickService { return view(requiredTask(taskId), null); } + private void requireCompletionContainerNumber(long taskId) { + ContainerBookingEntity booking = repository.activeBookingByTask(taskId); + if (booking == null || !StringUtils.hasText(booking.getContainerNo())) { + throw conflict("PDA确认装柜完成前必须录入真实集装箱号"); + } + try { + Iso6346ContainerNumber.require(booking.getContainerNo()); + } catch (BizException exception) { + throw conflict("PDA确认装柜完成前必须录入符合ISO 6346的真实集装箱号"); + } + } + /** Called only by the completion worker, after its durable lease has committed. */ public void processCompletion(LoadingCompletionCommandEntity command, String worker) { long taskId = command.getPickTaskId(); diff --git a/firstleg/src/main/resources/openapi/loading-lists-v1.yaml b/firstleg/src/main/resources/openapi/loading-lists-v1.yaml index 92937c0b0..139bfb523 100644 --- a/firstleg/src/main/resources/openapi/loading-lists-v1.yaml +++ b/firstleg/src/main/resources/openapi/loading-lists-v1.yaml @@ -49,8 +49,8 @@ paths: patch: summary: 修改预装运输信息 description: >- - 唯一纠错入口。仅已推送且仍为 EDITING、真实柜号已由 ocean-booking-link 首次写入、PDA任务尚未领取且无任何物理作业事实时允许; - containerNo 为空表示保持当前值,非空时服务端移除格式空白、转大写并校验ISO 6346结构、设备类别和校验位。禁止清空,LOADING/COMPLETED全部拒绝。 + 仅已推送且仍为 EDITING、PDA任务尚未领取且无任何物理作业事实时允许。可首次补录真实集装箱号;已有柜号的纠正必须提供原因和当前版本; + containerNo 为 null 表示保持当前值,非空时服务端移除格式空白、转大写并校验ISO 6346结构、设备类别和校验位,空白字符串非法且禁止清空。 x-permission: platform:firstleg:preload:edit parameters: - {$ref: '#/components/parameters/PlanId'} @@ -66,7 +66,7 @@ paths: /api/platform/v1/first-leg/loading-plans/{planId}/confirm: post: summary: 原子确认整份预装计划 - description: 以 loadingPlanId 为唯一范围,原子确认全部已分配入库预装票据。要求计划已 push 且仍为 EDITING、真实集装箱号已录入、稳定 bridge 完整、真实订舱已关联并为 RELEASED;任一票不满足均不写入。成功后唯一创建待领取 PDA task,claim 后才推进 LOADING。同一 Idempotency-Key 与相同请求精确重放原响应,不同 Key 重复确认返回业务冲突。 + description: 以 loadingPlanId 为唯一范围,原子确认全部已分配入库预装票据。要求计划已 push 且仍为 EDITING、稳定 bridge 完整、真实订舱已关联并为 RELEASED;真实集装箱号可在PDA确认装柜完成前补录。任一票不满足均不写入。成功后唯一创建待领取 PDA task,claim 后才推进 LOADING。同一 Idempotency-Key 与相同请求精确重放原响应,不同 Key 重复确认返回业务冲突。 x-permission: platform:firstleg:preload:edit parameters: [{$ref: '#/components/parameters/PlanId'}, {$ref: '#/components/parameters/IdempotencyKey'}] requestBody: @@ -313,11 +313,11 @@ components: pdaTaskStatus: {type: string, nullable: true, description: PDA task 当前状态} confirmationStatus: {type: string, enum: [PENDING, PARTIALLY_CONFIRMED, CONFIRMED], description: 整份计划的预装确认状态} canConfirm: {type: boolean, description: 当前是否可调用计划级确认接口} - readyForPda: {type: boolean, description: PDA 创建前置条件是否全部满足;task 已创建后仍为 true} + readyForPda: {type: boolean, description: PDA 创建前置条件是否全部满足;不依赖真实集装箱号,task 已创建后仍为 true} blockingReasons: type: array description: 阻止计划确认的稳定原因码 - items: {type: string, enum: [PRELOAD_NOT_PUSHED, LOADING_PLAN_NOT_EDITING, CONTAINER_NO_MISSING, PRELOAD_LINK_MISSING, OCEAN_BOOKING_NOT_LINKED, OCEAN_BOOKING_NOT_RELEASED, PDA_TASK_ALREADY_CREATED, PRELOAD_ALREADY_CONFIRMED]} + items: {type: string, enum: [PRELOAD_NOT_PUSHED, LOADING_PLAN_NOT_EDITING, PRELOAD_LINK_MISSING, OCEAN_BOOKING_NOT_LINKED, OCEAN_BOOKING_NOT_RELEASED, PDA_TASK_ALREADY_CREATED, PRELOAD_ALREADY_CONFIRMED]} ConfirmPreloadRequest: type: object required: [expectedVersion, operationReason] @@ -428,4 +428,4 @@ components: containerNo: {type: string, maxLength: 64, nullable: true, description: 新真实集装箱号;null表示保持当前值,空白字符串非法,禁止清空} voyageNo: {type: string, maxLength: 64, nullable: true, description: 航次} expectedVersion: {type: integer, format: int32, minimum: 0, description: 当前乐观锁版本} - operationReason: {type: string, minLength: 1, maxLength: 500, description: 必填纠错原因} + operationReason: {type: string, minLength: 1, maxLength: 500, nullable: true, description: 纠正已有真实集装箱号时必填;首次补录或保持当前柜号时可省略} diff --git a/firstleg/src/test/java/com/baiyue/firstleg/controller/LoadingListControllerContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/controller/LoadingListControllerContractTest.java index 3260d4bc5..c0461e7f5 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/controller/LoadingListControllerContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/controller/LoadingListControllerContractTest.java @@ -100,10 +100,10 @@ class LoadingListControllerContractTest { } @Test - void transportCorrectionRequiresReasonAndKeepsNullContainerAsUnchanged() { + void transportCorrectionReasonIsRuntimeConditionalAndNullContainerKeepsCurrentValue() { assertThat(component(TransportUpdateRequest.class, "operationReason").getAccessor() .getAnnotation(NotBlank.class)) - .isNotNull(); + .isNull(); assertThat(component(TransportUpdateRequest.class, "containerNo").getAccessor() .getAnnotation(NotBlank.class)) .isNull(); diff --git a/firstleg/src/test/java/com/baiyue/firstleg/repository/LoadingListQueryMapperContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/repository/LoadingListQueryMapperContractTest.java index d8a657a27..ba1f02508 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/repository/LoadingListQueryMapperContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/repository/LoadingListQueryMapperContractTest.java @@ -94,7 +94,7 @@ class LoadingListQueryMapperContractTest { .contains("SET b.container_no = #{containerNo}, b.voyage_no = #{voyageNo}") .contains("p.plan_status='EDITING'") .contains("preload_pushed_at IS NOT NULL") - .contains("#{containerNo} IS NOT NULL") + .doesNotContain("#{containerNo} IS NOT NULL") .contains("fl_loading_pick_task", "fl_loading_pick_assignment") .doesNotContain("DELETE") .doesNotContain("fl_loading_plan_item"); diff --git a/firstleg/src/test/java/com/baiyue/firstleg/repository/LoadingListRepositoryTest.java b/firstleg/src/test/java/com/baiyue/firstleg/repository/LoadingListRepositoryTest.java index c2d7bc9db..aa7a047c6 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/repository/LoadingListRepositoryTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/repository/LoadingListRepositoryTest.java @@ -72,4 +72,30 @@ class LoadingListRepositoryTest { assertThat(confirmed.planId()).isEqualTo(41L); assertThat(confirmed.containerBookingId()).isEqualTo(61L); } + + @Test + void missingContainerNumberDoesNotBlockPreloadConfirmationOrPdaReadiness() { + AggregateRow row = new AggregateRow(); + row.setPlanId(41L); + row.setContainerBookingId(61L); + row.setPlanNo("LP-41"); + row.setPlanStatus("EDITING"); + row.setVersion(1); + row.setPreloadPushedAt(java.time.LocalDateTime.now()); + row.setPreloadLinkCount(2L); + row.setConfirmedPreloadCount(0L); + row.setOceanBookingId(81L); + row.setOceanBookingStatus("RELEASED"); + when(mapper.selectItems(41L)).thenReturn(List.of()); + when(mapper.selectHistories(41L)).thenReturn(List.of()); + + var pending = repository.preloadDetail(row).summary().readiness(); + assertThat(pending.containerNoReady()).isFalse(); + assertThat(pending.canConfirm()).isTrue(); + assertThat(pending.blockingReasons()).doesNotContain("CONTAINER_NO_MISSING"); + + row.setConfirmedPreloadCount(2L); + var confirmed = repository.preloadDetail(row).summary().readiness(); + assertThat(confirmed.readyForPda()).isTrue(); + } } diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingListServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingListServiceTest.java index db2e06853..31bf5cfcb 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingListServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingListServiceTest.java @@ -208,20 +208,43 @@ class LoadingListServiceTest { } @Test - void loadingPlanAndMissingInitialOceanBookingNumberBothRejectCorrection() { + void loadingPlanRejectsTransportUpdateButPushedEditingPlanAllowsFirstContainerFill() { AggregateRow loading = row("LOADING", 3); loading.setPreloadPushedAt(java.time.LocalDateTime.now()); loading.setContainerNo("MSCU6639870"); AggregateRow missing = row("EDITING", 3); missing.setPreloadPushedAt(java.time.LocalDateTime.now()); - when(repository.aggregate(10L)).thenReturn(loading, missing); + AggregateRow afterFill = row("EDITING", 4); + afterFill.setPreloadPushedAt(missing.getPreloadPushedAt()); + afterFill.setContainerNo("CSQU3054383"); + when(repository.aggregate(10L)).thenReturn(loading, missing, afterFill); + when(repository.updateTransport(10L, 3, "CSQU3054383", null, 9L, "operator")) + .thenReturn(1); + when(repository.preloadDetail(afterFill)).thenReturn( + new com.baiyue.firstleg.dto.LoadingListDtos.PreloadDetail(null, List.of(), List.of())); assertThatThrownBy(() -> service.updateTransport(10L, 100L, 9L, "operator", "loading-key", new TransportUpdateRequest("CSQU3054383", null, 3, "作业后纠错"))) .isInstanceOf(BizException.class).hasMessageContaining("PDA尚未开始"); - assertThatThrownBy(() -> service.updateTransport(10L, 100L, 9L, "operator", "missing-key", - new TransportUpdateRequest("CSQU3054383", null, 3, "首次补录"))) - .isInstanceOf(BizException.class).hasMessageContaining("首次写入"); + service.updateTransport(10L, 100L, 9L, "operator", "missing-key", + new TransportUpdateRequest("CSQU3054383", null, 3, null)); + + verify(repository).updateTransport(10L, 3, "CSQU3054383", null, 9L, "operator"); + verify(loadingPickService).beginIfReady(30L, 9L, "operator"); + } + + @Test + void correctingExistingContainerNumberStillRequiresReason() { + AggregateRow before = row("EDITING", 3); + before.setPreloadPushedAt(java.time.LocalDateTime.now()); + before.setContainerNo("MSCU6639870"); + when(repository.aggregate(10L)).thenReturn(before); + + assertThatThrownBy(() -> service.updateTransport(10L, 100L, 9L, "operator", "reason-key", + new TransportUpdateRequest("CSQU3054383", null, 3, null))) + .isInstanceOf(BizException.class).hasMessageContaining("纠错原因"); + + verify(repository, never()).updateTransport(anyLong(), anyInt(), any(), any(), anyLong(), any()); } @Test diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingPickServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingPickServiceTest.java index 86557fd2e..38c953f01 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingPickServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingPickServiceTest.java @@ -113,7 +113,7 @@ class LoadingPickServiceTest { }).when(completionTransaction).executeWithoutResult(any()); ContainerBookingEntity booking = new ContainerBookingEntity(); booking.setId(99L); - booking.setContainerNo("CSLU1234567"); + booking.setContainerNo("MSCU6639870"); booking.setWarehouseId(101L); booking.setSourceType("SIMULATION"); booking.setRecordStatus("ACTIVE"); @@ -124,9 +124,9 @@ class LoadingPickServiceTest { } @Test - void opensTaskOnlyAfterReleasedBookingAndRealContainerNumber() { + void opensTaskAfterReleasedBookingWithoutWaitingForRealContainerNumber() { var booking = new com.baiyue.firstleg.entity.ContainerBookingEntity(); - booking.setId(40L); booking.setContainerNo("MSCU-001"); + booking.setId(40L); var plan = new com.baiyue.firstleg.entity.LoadingPlanEntity(); plan.setId(10L); plan.setContainerBookingId(40L); plan.setPlanStatus("EDITING"); plan.setPreloadPushedAt(java.time.LocalDateTime.now()); @@ -136,7 +136,7 @@ class LoadingPickServiceTest { when(smartLoadingRepository.planByBooking(40L)).thenReturn(plan); when(smartLoadingRepository.plan(10L)).thenReturn(plan); when(loadingModelBridge.byLoadingPlan(10L)).thenReturn(List.of( - new Link(1L, 1L, 50L, 2L, "CONFIRMED", 1, 10L, 40L, "MSCU-001", "EDITING", 1, 1, null))); + new Link(1L, 1L, 50L, 2L, "CONFIRMED", 1, 10L, 40L, null, "EDITING", 1, 1, null))); when(oceanBookingRepository.allocationByContainer(40L)).thenReturn(allocation); when(oceanBookingRepository.find(90L)).thenReturn(ocean); when(repository.taskByPlan(10L)).thenReturn(null); @@ -191,11 +191,11 @@ class LoadingPickServiceTest { } @Test - void planConfirmationGateRequiresRealContainerAndReleasedBooking() { + void planConfirmationGateRequiresReleasedBookingButNotRealContainerNumber() { 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); var allocation = new OceanBookingContainerAllocationEntity(); allocation.setOceanBookingId(90L); var ocean = new OceanBookingEntity(); @@ -209,17 +209,21 @@ class LoadingPickServiceTest { } @Test - void planConfirmationGateRejectsMissingRealContainerNumber() { + void planConfirmationGateAllowsMissingRealContainerNumber() { Link link = new Link(1L, 1L, 50L, 2L, "PENDING", 1, 10L, 40L, null, "EDITING", 3, 2, null); var booking = new com.baiyue.firstleg.entity.ContainerBookingEntity(); booking.setId(40L); + var allocation = new OceanBookingContainerAllocationEntity(); + allocation.setOceanBookingId(90L); + var ocean = new OceanBookingEntity(); + ocean.setId(90L); ocean.setStatus("RELEASED"); when(loadingModelBridge.requireAllocatedLinksForConfirmation(10L)).thenReturn(List.of(link)); when(smartLoadingRepository.booking(40L)).thenReturn(booking); + when(oceanBookingRepository.allocationByContainer(40L)).thenReturn(allocation); + when(oceanBookingRepository.find(90L)).thenReturn(ocean); - assertThatThrownBy(() -> service.requirePreloadConfirmationReady(10L)) - .isInstanceOf(BizException.class).hasMessageContaining("真实集装箱号"); - verify(oceanBookingRepository, never()).allocationByContainer(anyLong()); + assertThat(service.requirePreloadConfirmationReady(10L)).containsExactly(link); } @Test @@ -775,6 +779,25 @@ class LoadingPickServiceTest { verify(loadingListService, never()).finishFromPda(anyLong(), anyLong(), anyLong(), anyString()); } + @Test + void completionRejectsMissingRealContainerNumberBeforeCreatingCommand() { + LoadingPickTaskEntity loading = task(); + ContainerBookingEntity booking = new ContainerBookingEntity(); + booking.setId(99L); + booking.setWarehouseId(101L); + booking.setRecordStatus("ACTIVE"); + when(repository.taskForUpdate(20L)).thenReturn(loading); + when(repository.activeBookingByTask(20L)).thenReturn(booking); + + assertThatThrownBy(() -> service.complete(20L, + new CompleteLoadingRequest(0, "complete-missing-container", "checked"), 9L, "operator")) + .isInstanceOf(BizException.class).hasMessageContaining("确认装柜完成前"); + + verify(repository, never()).allTaskItemsResolved(20L); + 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. From 07ffd8ddce91d5acc17739891591f57a4bcb33fb Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Tue, 1 Sep 2026 15:27:34 +0800 Subject: [PATCH 3/7] fix(firstleg): finalize late container gate --- .../java/com/baiyue/firstleg/service/LoadingPickService.java | 1 + .../firstleg/controller/LoadingListControllerContractTest.java | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingPickService.java b/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingPickService.java index a285dabe2..d6b3d9f05 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingPickService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingPickService.java @@ -34,6 +34,7 @@ import com.baiyue.firstleg.entity.LoadingPickTaskEntity; import com.baiyue.firstleg.entity.LoadingPlanItemEntity; import com.baiyue.firstleg.entity.LoadingPlanHistoryEntity; import com.baiyue.firstleg.entity.InboundStatusHistoryEntity; +import com.baiyue.firstleg.entity.ContainerBookingEntity; import com.baiyue.firstleg.loading.mapper.LoadingInboundMapper; import com.baiyue.firstleg.loading.service.LoadingDeliveryService; import com.baiyue.firstleg.loading.coload.model.ColoadGroupModels.GroupSnapshot; diff --git a/firstleg/src/test/java/com/baiyue/firstleg/controller/LoadingListControllerContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/controller/LoadingListControllerContractTest.java index c0461e7f5..25b5a0b42 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/controller/LoadingListControllerContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/controller/LoadingListControllerContractTest.java @@ -69,7 +69,7 @@ class LoadingListControllerContractTest { .contains("beforeSnapshot") .contains("containerNo") .contains("voyageNo") - .contains("唯一纠错入口") + .contains("可首次补录真实集装箱号") .contains("校验ISO 6346结构、设备类别和校验位") .contains("PDA任务尚未领取且无任何物理作业事实") .contains("/loading-plans/{planId}/confirm") From 8b2a964bfb216ca722487af2b3bf2b41c48d05fb Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Tue, 1 Sep 2026 15:38:33 +0800 Subject: [PATCH 4/7] fix(firstleg): allow late initial container fill --- .../PlatformLoadingListController.java | 2 +- .../mapper/LoadingListQueryMapper.java | 30 +++++++++++++++++++ .../repository/LoadingListRepository.java | 8 +++++ .../firstleg/service/LoadingListService.java | 20 +++++++++---- .../resources/openapi/loading-lists-v1.yaml | 2 +- .../LoadingListControllerContractTest.java | 2 +- .../LoadingListQueryMapperContractTest.java | 12 ++++++++ .../service/LoadingListServiceTest.java | 28 +++++++++++++++-- 8 files changed, 93 insertions(+), 11 deletions(-) diff --git a/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformLoadingListController.java b/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformLoadingListController.java index 89f643dfc..59eb7bf0e 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformLoadingListController.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformLoadingListController.java @@ -72,7 +72,7 @@ public class PlatformLoadingListController { return ApiResponse.success(service.lifecycle(planId, warehouseId)); } - @Operation(summary = "修改装柜运输字段", description = "已推送且仍为 EDITING、PDA尚未领取或产生物理作业事实时,可首次补录或纠正集装箱号并维护航次;已有柜号的纠正必须提供原因和版本,柜号不能清空。") + @Operation(summary = "修改装柜运输字段", description = "已推送后可在装柜完成前首次补录集装箱号;已有柜号的纠正仅限 EDITING 且 PDA 尚未领取或产生物理作业事实,并必须提供原因和版本;柜号不能清空。") @PatchMapping("/loading-plans/{planId}/transport") @PreAuthorize("hasAuthority('platform:firstleg:preload:edit')") public ApiResponse updateTransport( diff --git a/firstleg/src/main/java/com/baiyue/firstleg/mapper/LoadingListQueryMapper.java b/firstleg/src/main/java/com/baiyue/firstleg/mapper/LoadingListQueryMapper.java index 1bb4f1a1c..cadc31d22 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/mapper/LoadingListQueryMapper.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/mapper/LoadingListQueryMapper.java @@ -265,6 +265,36 @@ public interface LoadingListQueryMapper { @Param("voyageNo") String voyageNo, @Param("operatorId") long operatorId, @Param("operatorName") String operatorName); + @Update(""" + UPDATE fl_loading_plan p + JOIN fl_container_booking b ON b.id=p.container_booking_id + SET p.updated_by=#{operatorId}, p.updated_by_name=#{operatorName}, + p.updated_at=CURRENT_TIMESTAMP(3), p.version=p.version+1 + WHERE p.id=#{planId} AND p.plan_status IN ('EDITING','LOADING') + AND p.preload_pushed_at IS NOT NULL AND p.version=#{expectedVersion} + AND b.container_no IS NULL + """) + int touchForInitialContainerFill(@Param("planId") long planId, + @Param("expectedVersion") int expectedVersion, + @Param("operatorId") long operatorId, + @Param("operatorName") String operatorName); + + @Update(""" + UPDATE fl_container_booking b + JOIN fl_loading_plan p ON p.container_booking_id=b.id + SET b.container_no=#{containerNo}, b.voyage_no=#{voyageNo}, + b.updated_by=#{operatorId}, b.updated_by_name=#{operatorName}, + b.updated_at=CURRENT_TIMESTAMP(3), b.version=b.version+1 + WHERE p.id=#{planId} AND p.plan_status IN ('EDITING','LOADING') + AND p.preload_pushed_at IS NOT NULL AND b.container_no IS NULL + AND #{containerNo} IS NOT NULL + """) + int fillInitialContainerNo(@Param("planId") long planId, + @Param("containerNo") String containerNo, + @Param("voyageNo") String voyageNo, + @Param("operatorId") long operatorId, + @Param("operatorName") String operatorName); + @Select(""" SELECT CASE WHEN EXISTS (SELECT 1 FROM fl_loading_pick_task task diff --git a/firstleg/src/main/java/com/baiyue/firstleg/repository/LoadingListRepository.java b/firstleg/src/main/java/com/baiyue/firstleg/repository/LoadingListRepository.java index 27f5333d6..b1fbd5d5f 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/repository/LoadingListRepository.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/repository/LoadingListRepository.java @@ -86,6 +86,14 @@ public class LoadingListRepository { return mapper.updateTransport(planId, containerNo, voyageNo, operatorId, operatorName); } + public int fillInitialContainerNo(long planId, int expectedVersion, String containerNo, String voyageNo, + long operatorId, String operatorName) { + if (mapper.touchForInitialContainerFill(planId, expectedVersion, operatorId, operatorName) != 1) { + return 0; + } + return mapper.fillInitialContainerNo(planId, containerNo, voyageNo, operatorId, operatorName); + } + public boolean hasPdaWorkStarted(long planId) { return mapper.hasPdaWorkStarted(planId) > 0; } diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingListService.java b/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingListService.java index bf2838354..ff26f6356 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingListService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingListService.java @@ -433,9 +433,10 @@ public class LoadingListService { return repository.preloadDetail(before); } - requireTransportMutable(before); String containerNo = targetContainerNo(before, request.containerNo()); String voyageNo = nullableTrim(request.voyageNo()); + boolean initialContainerFill = !StringUtils.hasText(before.getContainerNo()) && containerNo != null; + requireTransportMutable(before, initialContainerFill); boolean correctingContainerNo = StringUtils.hasText(before.getContainerNo()) && !java.util.Objects.equals(before.getContainerNo(), containerNo); if (correctingContainerNo && !StringUtils.hasText(reason)) { @@ -451,8 +452,12 @@ public class LoadingListService { throw conflict("集装箱号已存在"); } try { - if (repository.updateTransport(planId, request.expectedVersion(), containerNo, voyageNo, - operatorId, trim(operatorName)) != 1) { + int updated = initialContainerFill + ? repository.fillInitialContainerNo(planId, request.expectedVersion(), containerNo, voyageNo, + operatorId, trim(operatorName)) + : repository.updateTransport(planId, request.expectedVersion(), containerNo, voyageNo, + operatorId, trim(operatorName)); + if (updated != 1) { throw conflict("预装计划、PDA领取或物理作业状态已变化,请刷新后重试"); } } catch (DuplicateKeyException exception) { @@ -583,12 +588,15 @@ public class LoadingListService { } } - private void requireTransportMutable(AggregateRow row) { - if (!"EDITING".equals(row.getPlanStatus())) { + private void requireTransportMutable(AggregateRow row, boolean initialContainerFill) { + if (initialContainerFill && !Set.of("EDITING", "LOADING").contains(row.getPlanStatus())) { + throw conflict("仅装柜完成前允许首次补录真实集装箱号"); + } + if (!initialContainerFill && !"EDITING".equals(row.getPlanStatus())) { throw conflict("仅已推送且PDA尚未开始的预装计划可纠错真实集装箱号"); } if (row.getPreloadPushedAt() == null) throw conflict("仅已推送的预装清单可维护运输字段"); - if (repository.hasPdaWorkStarted(row.getPlanId())) { + if (!initialContainerFill && repository.hasPdaWorkStarted(row.getPlanId())) { throw conflict("PDA任务已领取、已开始作业或已有物理作业事实,不能修改真实集装箱号"); } } diff --git a/firstleg/src/main/resources/openapi/loading-lists-v1.yaml b/firstleg/src/main/resources/openapi/loading-lists-v1.yaml index 139bfb523..e69d52c52 100644 --- a/firstleg/src/main/resources/openapi/loading-lists-v1.yaml +++ b/firstleg/src/main/resources/openapi/loading-lists-v1.yaml @@ -49,7 +49,7 @@ paths: patch: summary: 修改预装运输信息 description: >- - 仅已推送且仍为 EDITING、PDA任务尚未领取且无任何物理作业事实时允许。可首次补录真实集装箱号;已有柜号的纠正必须提供原因和当前版本; + 已推送后可在 EDITING 或 LOADING 阶段首次补录真实集装箱号;已有柜号的纠正仅限仍为 EDITING、PDA任务尚未领取且无任何物理作业事实时,并必须提供原因和当前版本; containerNo 为 null 表示保持当前值,非空时服务端移除格式空白、转大写并校验ISO 6346结构、设备类别和校验位,空白字符串非法且禁止清空。 x-permission: platform:firstleg:preload:edit parameters: diff --git a/firstleg/src/test/java/com/baiyue/firstleg/controller/LoadingListControllerContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/controller/LoadingListControllerContractTest.java index 25b5a0b42..21563a38f 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/controller/LoadingListControllerContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/controller/LoadingListControllerContractTest.java @@ -69,7 +69,7 @@ class LoadingListControllerContractTest { .contains("beforeSnapshot") .contains("containerNo") .contains("voyageNo") - .contains("可首次补录真实集装箱号") + .contains("首次补录真实集装箱号") .contains("校验ISO 6346结构、设备类别和校验位") .contains("PDA任务尚未领取且无任何物理作业事实") .contains("/loading-plans/{planId}/confirm") diff --git a/firstleg/src/test/java/com/baiyue/firstleg/repository/LoadingListQueryMapperContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/repository/LoadingListQueryMapperContractTest.java index ba1f02508..ee7e3fb69 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/repository/LoadingListQueryMapperContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/repository/LoadingListQueryMapperContractTest.java @@ -80,6 +80,8 @@ class LoadingListQueryMapperContractTest { void mutationsShouldUseStatusAndOptimisticVersionConditions() throws Exception { String touch = sql("touchEditable", Update.class); String transport = sql("updateTransport", Update.class); + String touchInitialFill = sql("touchForInitialContainerFill", Update.class); + String initialFill = sql("fillInitialContainerNo", Update.class); String confirmPreload = sql("confirmLinkedPreload", Update.class); String confirmPlan = sql("touchForPreloadConfirmation", Update.class); String advance = sql("advance", Update.class); @@ -98,6 +100,16 @@ class LoadingListQueryMapperContractTest { .contains("fl_loading_pick_task", "fl_loading_pick_assignment") .doesNotContain("DELETE") .doesNotContain("fl_loading_plan_item"); + assertThat(touchInitialFill) + .contains("p.plan_status IN ('EDITING','LOADING')") + .contains("p.version=#{expectedVersion}") + .contains("b.container_no IS NULL") + .doesNotContain("fl_loading_pick_assignment", "item_status"); + assertThat(initialFill) + .contains("p.plan_status IN ('EDITING','LOADING')") + .contains("b.container_no IS NULL") + .contains("#{containerNo} IS NOT NULL") + .doesNotContain("fl_loading_pick_assignment", "item_status"); assertThat(advance) .contains("plan_status = #{fromStatus}") .contains("plan_status = #{toStatus}") diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingListServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingListServiceTest.java index 31bf5cfcb..9a7c18f29 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingListServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingListServiceTest.java @@ -218,7 +218,7 @@ class LoadingListServiceTest { afterFill.setPreloadPushedAt(missing.getPreloadPushedAt()); afterFill.setContainerNo("CSQU3054383"); when(repository.aggregate(10L)).thenReturn(loading, missing, afterFill); - when(repository.updateTransport(10L, 3, "CSQU3054383", null, 9L, "operator")) + when(repository.fillInitialContainerNo(10L, 3, "CSQU3054383", null, 9L, "operator")) .thenReturn(1); when(repository.preloadDetail(afterFill)).thenReturn( new com.baiyue.firstleg.dto.LoadingListDtos.PreloadDetail(null, List.of(), List.of())); @@ -229,10 +229,34 @@ class LoadingListServiceTest { service.updateTransport(10L, 100L, 9L, "operator", "missing-key", new TransportUpdateRequest("CSQU3054383", null, 3, null)); - verify(repository).updateTransport(10L, 3, "CSQU3054383", null, 9L, "operator"); + verify(repository).fillInitialContainerNo(10L, 3, "CSQU3054383", null, 9L, "operator"); verify(loadingPickService).beginIfReady(30L, 9L, "operator"); } + @Test + void loadingPlanWithClaimedAndPickedPdaTaskStillAllowsInitialContainerFill() { + AggregateRow before = row("LOADING", 7); + before.setPreloadPushedAt(java.time.LocalDateTime.now()); + AggregateRow after = row("LOADING", 8); + after.setPreloadPushedAt(before.getPreloadPushedAt()); + after.setContainerNo("CSQU3054383"); + when(repository.aggregate(10L)).thenReturn(before, after); + when(repository.hasPdaWorkStarted(10L)).thenReturn(true); + when(repository.fillInitialContainerNo(10L, 7, "CSQU3054383", "023E", 9L, "operator")) + .thenReturn(1); + when(repository.preloadDetail(after)).thenReturn( + new com.baiyue.firstleg.dto.LoadingListDtos.PreloadDetail(null, List.of(), List.of())); + + service.updateTransport(10L, 100L, 9L, "operator", "late-fill-key", + new TransportUpdateRequest("CSQU3054383", "023E", 7, null)); + + verify(repository).fillInitialContainerNo(10L, 7, "CSQU3054383", "023E", 9L, "operator"); + verify(repository, never()).hasPdaWorkStarted(10L); + verify(repository).appendHistory(eq(10L), anyString(), eq("UPDATE_PRELOAD_TRANSPORT"), + eq("LOADING"), eq("LOADING"), anyString(), anyString(), eq(9L), eq("operator"), + org.mockito.ArgumentMatchers.isNull()); + } + @Test void correctingExistingContainerNumberStillRequiresReason() { AggregateRow before = row("EDITING", 3); From df1ffbc6bc8d8315c50a7831fdf69a087d738177 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Tue, 1 Sep 2026 15:41:36 +0800 Subject: [PATCH 5/7] fix(firstleg): filter truck booking candidates --- .../PlatformTruckAppointmentController.java | 1 + .../firstleg/dto/TruckAppointmentDtos.java | 2 + .../mapper/TruckAppointmentMapper.java | 27 +++++++++- .../TruckAppointmentRepository.java | 4 +- .../service/TruckAppointmentService.java | 4 +- .../openapi/truck-appointments-v1.yaml | 49 +++++++++++++++++++ ...ruckAppointmentControllerContractTest.java | 5 +- .../TruckAppointmentMapperContractTest.java | 7 +++ .../service/TruckAppointmentServiceTest.java | 28 +++++++++++ 9 files changed, 120 insertions(+), 7 deletions(-) diff --git a/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformTruckAppointmentController.java b/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformTruckAppointmentController.java index 7e8df28e7..c4b2abf2d 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformTruckAppointmentController.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformTruckAppointmentController.java @@ -2,6 +2,7 @@ package com.baiyue.firstleg.controller; import com.baiyue.common.audit.BusinessAudit; import com.baiyue.common.core.api.*; import com.baiyue.common.security.AuthenticatedUser; import com.baiyue.firstleg.dto.TruckAppointmentDtos.*; import com.baiyue.firstleg.entity.TruckAppointmentEntity; import com.baiyue.firstleg.service.*; import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.annotation.Resource; import jakarta.validation.Valid; import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.Pattern; import jakarta.validation.constraints.Positive; import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.validation.annotation.Validated; import org.springframework.web.bind.annotation.*; import java.time.LocalDateTime; @Tag(name="拖车预约") @Validated @RestController @RequestMapping("/api/platform/v1/truck-appointments") public class PlatformTruckAppointmentController { @Resource TruckAppointmentService service; @GetMapping @PreAuthorize("hasAuthority('platform:firstleg:truck-appointment:list')") public ApiResponse> page(@AuthenticationPrincipal AuthenticatedUser u,@Valid Query q){FirstlegScopeGuard.platform(u);return ApiResponse.success(service.page(q));} + @GetMapping("/candidates") @PreAuthorize("hasAuthority('platform:firstleg:truck-appointment:list')") public ApiResponse> candidates(@AuthenticationPrincipal AuthenticatedUser u,@Valid CandidateQuery q){FirstlegScopeGuard.platform(u);return ApiResponse.success(service.candidates(q));} @GetMapping("/{id}") @PreAuthorize("hasAuthority('platform:firstleg:truck-appointment:list')") public ApiResponse detail(@AuthenticationPrincipal AuthenticatedUser u,@PathVariable @Positive long id){FirstlegScopeGuard.platform(u);return ApiResponse.success(service.detail(id));} @GetMapping("/{id}/history") @PreAuthorize("hasAuthority('platform:firstleg:truck-appointment:list')") public ApiResponse> history(@AuthenticationPrincipal AuthenticatedUser u,@PathVariable @Positive long id){FirstlegScopeGuard.platform(u);return ApiResponse.success(service.history(id));} @GetMapping("/{id}/attachments") @PreAuthorize("hasAuthority('platform:firstleg:truck-appointment:list')") public ApiResponse> attachments(@AuthenticationPrincipal AuthenticatedUser u,@PathVariable @Positive long id){FirstlegScopeGuard.platform(u);return ApiResponse.success(service.attachments(id));} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/dto/TruckAppointmentDtos.java b/firstleg/src/main/java/com/baiyue/firstleg/dto/TruckAppointmentDtos.java index 62782b7ba..75c569f10 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/dto/TruckAppointmentDtos.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/dto/TruckAppointmentDtos.java @@ -2,6 +2,8 @@ package com.baiyue.firstleg.dto; import jakarta.validation.constraints.*; import java.math.BigDecimal; import java.time.LocalDateTime; public final class TruckAppointmentDtos { private TruckAppointmentDtos(){} public record Query(@Size(max=64) String keyword, com.baiyue.firstleg.domain.TruckAppointmentStatus status, @Positive Long containerBookingId, @Min(1) long page, @Min(1) @Max(200) long size){ public Query{if(page==0)page=1;if(size==0)size=20;} } + public record CandidateQuery(@Size(max=64) String keyword,@Min(1) long page,@Min(1) @Max(200) long size){public CandidateQuery{if(page==0)page=1;if(size==0)size=20;}} + public record Candidate(Long oceanBookingId,String oceanBookingNo,String billOfLadingNo,Long warehouseId,String warehouseName,String channelCode,String channelName,Long requirementId,String containerType,Integer requirementQuantity,Long containerBookingId,String cabinetNo,String containerNo,Long loadingPlanId,String loadingPlanNo,String loadingPlanStatus,Boolean bookingDocumentReady){} public record SaveRequest(@Positive Long outboundOrderId,@NotNull @Positive Long oceanBookingId,@NotNull @Positive Long containerBookingId,@Positive Long loadingPlanId,@Positive Long warehouseId,@NotNull @Positive Long carrierId,@Size(max=128) String carrierName,@Size(max=128) String billOfLadingNo,@Size(max=64) String fleetContact,@Size(max=32) String fleetPhone,@Size(max=64) String driverName,@Size(max=32) String driverPhone,@Size(max=32) String driverIdCard,@Size(max=32) String vehicleNo,@Size(max=128) String storeName,@Size(max=64) String storeContact,@Size(max=32) String storePhone,@Size(max=255) String storeAddress,@Size(max=64) String storeCity,@Size(max=16) String storeZipcode,@PositiveOrZero Integer packageCount,@DecimalMin("0") BigDecimal grossWeightKg,@DecimalMin("0") BigDecimal volumeCbm,@PositiveOrZero Integer palletCount,@Size(max=255) String poNo,@NotNull LocalDateTime plannedLoadingAt,@PositiveOrZero Integer transitMinutes,@DecimalMin("0") BigDecimal payableAmount,@Pattern(regexp="RMB|USD") String currency,@Size(max=1000) String remark,@NotNull @PositiveOrZero Integer expectedVersion){} public record ActionRequest(@NotNull @PositiveOrZero Integer expectedVersion,@Size(max=500) String reason,@Size(max=32) String containerNo){} public record AttachmentRequest(@NotNull @Positive Long fileId,@NotBlank @Pattern(regexp="DISPATCH_ORDER|DRIVER_DOCUMENT|VEHICLE_DOCUMENT|OTHER") String attachmentType,@Size(max=255) String fileName,@Size(max=128) String fileHash){ } diff --git a/firstleg/src/main/java/com/baiyue/firstleg/mapper/TruckAppointmentMapper.java b/firstleg/src/main/java/com/baiyue/firstleg/mapper/TruckAppointmentMapper.java index 68757f92d..12d1aa57a 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/mapper/TruckAppointmentMapper.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/mapper/TruckAppointmentMapper.java @@ -1,6 +1,31 @@ package com.baiyue.firstleg.mapper; -import com.baomidou.mybatisplus.core.mapper.BaseMapper; import com.baomidou.mybatisplus.extension.plugins.pagination.Page; import com.baiyue.firstleg.dto.TruckAppointmentDtos.Query; import com.baiyue.firstleg.entity.TruckAppointmentEntity; import org.apache.ibatis.annotations.*; +import com.baomidou.mybatisplus.core.mapper.BaseMapper; import com.baomidou.mybatisplus.extension.plugins.pagination.Page; import com.baiyue.firstleg.dto.TruckAppointmentDtos.*; import com.baiyue.firstleg.entity.TruckAppointmentEntity; import org.apache.ibatis.annotations.*; @Mapper public interface TruckAppointmentMapper extends BaseMapper{ + @Select(""" + + """) Page candidates(Page p,@Param("q") CandidateQuery q); @Select("SELECT * FROM fl_truck_appointment WHERE container_booking_id=#{containerBookingId} AND status NOT IN ('COMPLETED','CANCELLED','EXCEPTION') ORDER BY created_at,id LIMIT 1 FOR UPDATE") TruckAppointmentEntity firstActiveForUpdate(@Param("containerBookingId") long containerBookingId); @Select("SELECT * FROM fl_truck_appointment WHERE container_booking_id=#{containerBookingId} AND status NOT IN ('COMPLETED','CANCELLED','EXCEPTION') ORDER BY id FOR UPDATE") java.util.List activeForUpdate(@Param("containerBookingId") long containerBookingId); @Select("") Page page(Page p,@Param("q") Query q); diff --git a/firstleg/src/main/java/com/baiyue/firstleg/repository/TruckAppointmentRepository.java b/firstleg/src/main/java/com/baiyue/firstleg/repository/TruckAppointmentRepository.java index a1ce74211..bdfca4c94 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/repository/TruckAppointmentRepository.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/repository/TruckAppointmentRepository.java @@ -1,7 +1,7 @@ package com.baiyue.firstleg.repository; -import com.baomidou.mybatisplus.extension.plugins.pagination.Page; import com.baiyue.common.core.api.PageResult; import com.baiyue.firstleg.dto.TruckAppointmentDtos.Query; import com.baiyue.firstleg.entity.*; import com.baiyue.firstleg.mapper.*; import jakarta.annotation.Resource; import org.springframework.stereotype.Repository; +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; import com.baiyue.common.core.api.PageResult; import com.baiyue.firstleg.dto.TruckAppointmentDtos.*; import com.baiyue.firstleg.entity.*; import com.baiyue.firstleg.mapper.*; import jakarta.annotation.Resource; import org.springframework.stereotype.Repository; @Repository public class TruckAppointmentRepository { @Resource TruckAppointmentMapper mapper; @Resource TruckAppointmentHistoryMapper history; @Resource TruckAppointmentAttachmentMapper attachments; @Resource TruckAppointmentOperationMapper operations; - public PageResult page(Query q){return PageResult.from(mapper.page(new Page<>(q.page(),q.size()),q));} public TruckAppointmentEntity find(long id){return mapper.selectById(id);} public void insert(TruckAppointmentEntity e){mapper.insert(e);} public int transition(long id,String from,String to,int v){return mapper.transition(id,from,to,v);} public int dispatch(long id,String from,String billOfLadingNo,int v){return mapper.dispatch(id,from,billOfLadingNo,v);} public int bindContainerNoForPickup(long containerBookingId,String containerNo,long operatorId,String operatorName){return mapper.bindContainerNoForPickup(containerBookingId,containerNo,operatorId,operatorName);} public int reschedule(long id,java.time.LocalDateTime at,String reason,int v){return mapper.reschedule(id,at,reason,v);} public void history(TruckAppointmentHistoryEntity e){history.insert(e);} public void operation(TruckAppointmentOperationEntity e){operations.insert(e);} public void attachment(TruckAppointmentAttachmentEntity e){attachments.insert(e);} + public PageResult page(Query q){return PageResult.from(mapper.page(new Page<>(q.page(),q.size()),q));} public PageResult candidates(CandidateQuery q){return PageResult.from(mapper.candidates(new Page<>(q.page(),q.size()),q));} public TruckAppointmentEntity find(long id){return mapper.selectById(id);} public void insert(TruckAppointmentEntity e){mapper.insert(e);} public int transition(long id,String from,String to,int v){return mapper.transition(id,from,to,v);} public int dispatch(long id,String from,String billOfLadingNo,int v){return mapper.dispatch(id,from,billOfLadingNo,v);} public int bindContainerNoForPickup(long containerBookingId,String containerNo,long operatorId,String operatorName){return mapper.bindContainerNoForPickup(containerBookingId,containerNo,operatorId,operatorName);} public int reschedule(long id,java.time.LocalDateTime at,String reason,int v){return mapper.reschedule(id,at,reason,v);} public void history(TruckAppointmentHistoryEntity e){history.insert(e);} public void operation(TruckAppointmentOperationEntity e){operations.insert(e);} public void attachment(TruckAppointmentAttachmentEntity e){attachments.insert(e);} public boolean claimOperation(long operationId,long appointmentId,String action,String key,String hash){return operations.claim(operationId,appointmentId,action,key,hash)==1;} public TruckAppointmentOperationEntity operation(String key){return operations.find(key);} public int completeOperation(long appointmentId,String action,String key,String response){return operations.complete(appointmentId,action,key,response);} public int updateEdit(TruckAppointmentEntity e,int v){return mapper.updateEdit(e,v);} public java.util.List histories(long id){return history.selectList(new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper().eq(TruckAppointmentHistoryEntity::getAppointmentId,id).orderByAsc(TruckAppointmentHistoryEntity::getCreatedAt));} public java.util.List attachments(long id){return attachments.selectList(new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper().eq(TruckAppointmentAttachmentEntity::getAppointmentId,id).orderByAsc(TruckAppointmentAttachmentEntity::getUploadedAt));} public int deleteAttachment(long appointmentId,long attachmentId){return attachments.delete(new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper().eq(TruckAppointmentAttachmentEntity::getAppointmentId,appointmentId).eq(TruckAppointmentAttachmentEntity::getId,attachmentId));} public TruckAppointmentEntity firstActiveForUpdate(long containerBookingId){return mapper.firstActiveForUpdate(containerBookingId);} public java.util.List activeForUpdate(long containerBookingId){return mapper.activeForUpdate(containerBookingId);} public int completeLoadingFromPda(long id,String fromStatus,int version){return mapper.completeLoadingFromPda(id,fromStatus,version);} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/TruckAppointmentService.java b/firstleg/src/main/java/com/baiyue/firstleg/service/TruckAppointmentService.java index a3cb9b9e4..a19c2fec3 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/TruckAppointmentService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/TruckAppointmentService.java @@ -47,7 +47,7 @@ public class TruckAppointmentService { @Resource FirstlegDocumentNumberGenerator documentNumbers; @Resource ObjectMapper objectMapper; @Value("${baiyue.security.internal-service-token}") String internalToken; - public PageResult page(Query q){return repo.page(q);} public TruckAppointmentEntity detail(long id){return enrich(required(id));} + public PageResult page(Query q){return repo.page(q);} public PageResult candidates(CandidateQuery q){return repo.candidates(q);} public TruckAppointmentEntity detail(long id){return enrich(required(id));} public java.util.List history(long id){required(id);return repo.histories(id);} public java.util.List attachments(long id){required(id);return repo.attachments(id);} @Transactional public TruckAppointmentAttachmentEntity upload(long id,String type,MultipartFile file,long uid){String attachmentType=attachmentType(type);required(id);try{var response=files.upload(internalToken,uid,0L,"TRUCK_APPOINTMENT_ATTACHMENT",Long.toString(id),file);if(response==null||!response.successful()||response.data()==null)throw bad("附件上传失败");var m=response.data();TruckAppointmentAttachmentEntity a=new TruckAppointmentAttachmentEntity();a.setId(IdWorker.getId());a.setAppointmentId(id);a.setFileId(m.fileId());a.setAttachmentType(attachmentType);a.setFileName(m.fileName());a.setFileHash(m.sha256());a.setVisibility("PLATFORM");a.setUploadedBy(uid);a.setUploadedAt(LocalDateTime.now());repo.attachment(a);return a;}catch(FeignException ex){throw new BizException(ResultCode.SERVICE_UNAVAILABLE,"文件服务暂不可用");}} @Transactional public TruckAppointmentAttachmentEntity attach(long id,AttachmentRequest r,long uid){String attachmentType=attachmentType(r.attachmentType());required(id);TruckAppointmentFileClient.Metadata metadata;try{var response=files.metadata(internalToken,0L,"TRUCK_APPOINTMENT_ATTACHMENT",Long.toString(id),r.fileId());if(response==null||!response.successful()||response.data()==null)throw bad("附件不存在或归属不匹配");metadata=response.data();}catch(FeignException ex){throw new BizException(ResultCode.SERVICE_UNAVAILABLE,"文件服务暂不可用");} TruckAppointmentAttachmentEntity a=new TruckAppointmentAttachmentEntity();a.setId(IdWorker.getId());a.setAppointmentId(id);a.setFileId(metadata.fileId());a.setAttachmentType(attachmentType);a.setFileName(metadata.fileName());a.setFileHash(metadata.sha256());a.setVisibility("PLATFORM");a.setUploadedBy(uid);a.setUploadedAt(LocalDateTime.now());repo.attachment(a);return a;} @@ -71,7 +71,7 @@ public class TruckAppointmentService { boolean allocated=oceanBookings.allocations(booking.getId()).stream().anyMatch(a->Objects.equals(a.getContainerBookingId(),containerId)); if(!allocated)throw bad("containerBookingId未分配至该订舱"); oceanBookingDocuments.requireActiveValidDocument(booking); - if(r.loadingPlanId()!=null){LoadingPlanEntity plan=loadingPlans.planByBooking(container.getId());if(plan==null||!Objects.equals(r.loadingPlanId(),plan.getId()))throw bad("loadingPlanId与柜次当前装柜计划不一致");} + LoadingPlanEntity plan=loadingPlans.planByBooking(container.getId());if(plan!=null&&"COMPLETED".equals(plan.getPlanStatus()))throw conflict("关联柜次已装柜完成,不能新建或修改拖车预约");if(r.loadingPlanId()!=null&&(plan==null||!Objects.equals(r.loadingPlanId(),plan.getId())))throw bad("loadingPlanId与柜次当前装柜计划不一致"); return new AssociationContext(booking,container,warehouseId,billOfLadingNo); } private static void validateRequired(SaveRequest r){if(r.oceanBookingId()==null||r.oceanBookingId()<=0)throw bad("oceanBookingId不能为空");if(r.containerBookingId()==null||r.containerBookingId()<=0)throw bad("containerBookingId不能为空");if(r.carrierId()==null||r.carrierId()<=0)throw bad("carrierId不能为空");if(r.plannedLoadingAt()==null)throw bad("plannedLoadingAt不能为空");if(r.expectedVersion()==null||r.expectedVersion()<0)throw bad("expectedVersion不合法");} diff --git a/firstleg/src/main/resources/openapi/truck-appointments-v1.yaml b/firstleg/src/main/resources/openapi/truck-appointments-v1.yaml index e2e7d03a1..4cea5d827 100644 --- a/firstleg/src/main/resources/openapi/truck-appointments-v1.yaml +++ b/firstleg/src/main/resources/openapi/truck-appointments-v1.yaml @@ -30,6 +30,16 @@ paths: responses: '200': {description: 'Created appointment', content: {application/json: {schema: {$ref: '#/components/schemas/DetailEnvelope'}}}} '409': {description: 'A0409 - this container already has a non-terminal appointment; message includes its appointment number and asks the operator to continue editing'} + /api/platform/v1/truck-appointments/candidates: + get: + summary: Query authoritative create-time ocean-booking and internal-container candidates + description: Requires platform:firstleg:truck-appointment:list. Returns only RELEASED bookings with an active booking-owned confirmation or bill file and allocated ACTIVE internal containers that have no non-terminal truck appointment and whose loading plan is absent or not COMPLETED. A missing containerNo remains eligible. Completed/LOADED containers, occupied containers and bookings without any remaining eligible allocated container are absent. Edit pages must display their current binding from appointment detail rather than injecting it into this create-only candidate result. + parameters: + - {name: keyword, in: query, required: false, schema: {type: string, maxLength: 64}, description: Matches booking number, bill of lading, cabinet number or real container number} + - {name: page, in: query, required: false, schema: {type: integer, format: int64, minimum: 1, default: 1}} + - {name: size, in: query, required: false, schema: {type: integer, format: int64, minimum: 1, maximum: 200, default: 20}} + responses: + '200': {description: Paged flat booking-container candidates, content: {application/json: {schema: {$ref: '#/components/schemas/CandidatePageEnvelope'}}}} /api/platform/v1/truck-appointments/{id}: parameters: [{$ref: '#/components/parameters/AppointmentId'}] get: @@ -138,6 +148,45 @@ components: IdempotencyKey: {name: Idempotency-Key, in: header, required: true, schema: {type: string, maxLength: 128}} RequestHash: {name: X-Request-Hash, in: header, required: false, schema: {type: string, pattern: '^[0-9a-fA-F]{64}$'}} schemas: + CandidatePageEnvelope: + type: object + required: [code, message, data, timestamp] + properties: + code: {type: string, example: '200'} + message: {type: string} + data: + type: object + required: [records, page, size, total, pages] + properties: + records: {type: array, items: {$ref: '#/components/schemas/Candidate'}} + page: {type: integer, format: int64} + size: {type: integer, format: int64} + total: {type: integer, format: int64} + pages: {type: integer, format: int64} + requestId: {type: string, nullable: true} + traceId: {type: string, nullable: true} + timestamp: {type: string, format: date-time} + Candidate: + type: object + required: [oceanBookingId, oceanBookingNo, warehouseId, requirementId, containerType, requirementQuantity, containerBookingId, cabinetNo, bookingDocumentReady] + properties: + oceanBookingId: {type: integer, format: int64} + oceanBookingNo: {type: string} + billOfLadingNo: {type: string, nullable: true} + warehouseId: {type: integer, format: int64} + warehouseName: {type: string, nullable: true} + channelCode: {type: string, nullable: true} + channelName: {type: string, nullable: true} + requirementId: {type: integer, format: int64} + containerType: {type: string} + requirementQuantity: {type: integer, minimum: 1} + containerBookingId: {type: integer, format: int64} + cabinetNo: {type: string} + containerNo: {type: string, nullable: true, description: Optional until PICKUP or preload correction; absence does not make the candidate unavailable} + loadingPlanId: {type: integer, format: int64, nullable: true} + loadingPlanNo: {type: string, nullable: true} + loadingPlanStatus: {type: string, nullable: true} + bookingDocumentReady: {type: boolean, enum: [true]} DetailEnvelope: type: object required: [code, message, data, timestamp] diff --git a/firstleg/src/test/java/com/baiyue/firstleg/controller/TruckAppointmentControllerContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/controller/TruckAppointmentControllerContractTest.java index 37153971b..8df54537a 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/controller/TruckAppointmentControllerContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/controller/TruckAppointmentControllerContractTest.java @@ -18,7 +18,7 @@ import java.util.Map; import static org.assertj.core.api.Assertions.assertThat; class TruckAppointmentControllerContractTest { - @Test void exposesDedicatedEndpointAndPermissions() {String p=PlatformTruckAppointmentController.class.getAnnotation(org.springframework.web.bind.annotation.RequestMapping.class).value()[0];assertThat(p).isEqualTo("/api/platform/v1/truck-appointments"); for(String n:new String[]{"page","detail","history","attachments","attach","upload","deleteAttachment","create","edit","action","reschedule"}) assertThat(java.util.Arrays.stream(PlatformTruckAppointmentController.class.getDeclaredMethods()).anyMatch(m->m.getName().equals(n))).isTrue();} + @Test void exposesDedicatedEndpointAndPermissions() {String p=PlatformTruckAppointmentController.class.getAnnotation(org.springframework.web.bind.annotation.RequestMapping.class).value()[0];assertThat(p).isEqualTo("/api/platform/v1/truck-appointments"); for(String n:new String[]{"page","candidates","detail","history","attachments","attach","upload","deleteAttachment","create","edit","action","reschedule"}) assertThat(java.util.Arrays.stream(PlatformTruckAppointmentController.class.getDeclaredMethods()).anyMatch(m->m.getName().equals(n))).isTrue();} @Test void schemaHasPlatformAggregateWithoutCustomerOrReceivableColumns() throws Exception {String s=new ClassPathResource("db/phase3-truck-appointment-schema.sql").getContentAsString(StandardCharsets.UTF_8);assertThat(s).contains("fl_truck_appointment","fl_truck_appointment_history","fl_truck_appointment_attachment","fl_truck_appointment_operation","bill_of_lading_no VARCHAR(128)","chk_truck_attachment_type","DISPATCH_ORDER","PENDING_DISPATCH","PICKUP_IN_PROGRESS","ARRIVED_WAREHOUSE","RESCHEDULED","actual_arrived_warehouse_at","actual_loading_at","actual_departed_at","CALL phase3_truck_add_column","visibility VARCHAR(24) NOT NULL DEFAULT 'PLATFORM'","active_container_booking_id BIGINT UNSIGNED GENERATED ALWAYS AS","UNIQUE KEY uk_truck_active_container(active_container_booking_id)","chk_truck_appointment_currency CHECK(currency IN ('RMB','USD'))").doesNotContain("customer_id", "customer_name", "idx_truck_appointment_customer", "receivable_amount");} @Test void existingAppointmentTableReceivesAllLateColumns() throws Exception { String moduleSql=new ClassPathResource("db/phase3-truck-appointment-schema.sql").getContentAsString(StandardCharsets.UTF_8); @@ -31,12 +31,13 @@ class TruckAppointmentControllerContractTest { } @Test void openApiDocumentsLifecycleIdempotencyAndActualTimes() throws Exception {String s=new ClassPathResource("openapi/truck-appointments-v1.yaml").getContentAsString(StandardCharsets.UTF_8);assertThat(s).contains("/api/platform/v1/truck-appointments/{id}/{action}:","/api/platform/v1/truck-appointments/{id}/reschedule:","name: Idempotency-Key","required: true","actual event timestamps");} @Test void openApiDocumentsActionableDuplicateCreateConflict() throws Exception {String s=new ClassPathResource("openapi/truck-appointments-v1.yaml").getContentAsString(StandardCharsets.UTF_8);assertThat(s).contains("A0409","existing appointment number","continue editing","before booking-document validation","database unique key");} + @Test void openApiPublishesCreateOnlyAuthoritativeCandidateContract() throws Exception {String s=new ClassPathResource("openapi/truck-appointments-v1.yaml").getContentAsString(StandardCharsets.UTF_8);assertThat(s).contains("/api/platform/v1/truck-appointments/candidates:","Paged flat booking-container candidates","bookingDocumentReady","A missing containerNo remains eligible","Completed/LOADED containers, occupied containers","Edit pages must display their current binding from appointment detail");} @Test void openApiRequiresReleasedBookingAndAllocatedInternalContainerWithoutCustomerScope() throws Exception {String s=new ClassPathResource("openapi/truck-appointments-v1.yaml").getContentAsString(StandardCharsets.UTF_8);assertThat(s).contains("required: [oceanBookingId, containerBookingId, carrierId, plannedLoadingAt, expectedVersion]","RELEASED ocean booking","allocated ACTIVE internal container identity","real ISO 6346 container number is not required for create, edit, submit or dispatch","PICKUP may omit it and still advances normally","Omitting it never blocks the transition to PICKUP_IN_PROGRESS","must use the preload correction flow","Other actions reject containerNo","OCEAN_BOOKING_DOCUMENT","schema: {$ref: '#/components/schemas/DetailEnvelope'}","required: [code, message, data, timestamp]","allOf: [{$ref: '#/components/schemas/DetailStableReferences'}]","nullable: true").doesNotContain("customerId", "customerName", "Filter by customer");} @Test void openApiKeepsOnlyNonBookingTruckAttachments() throws Exception {String s=new ClassPathResource("openapi/truck-appointments-v1.yaml").getContentAsString(StandardCharsets.UTF_8);assertThat(s).contains("AttachmentRequest:","required: [fileId, attachmentType]","required: [attachmentType, file]","TRUCK_APPOINTMENT_ATTACHMENT","Deletes the appointment reference only","DetailStableReferences:","oceanBookingNo:","shippingCompanyName:","bookingAgentName:","cabinetNo:","billOfLadingNo:").doesNotContain("enum: [BILL_OF_LADING, DISPATCH_ORDER");} @Test void openApiExposesOnlyPayableWithFixedCurrencies() throws Exception {String s=new ClassPathResource("openapi/truck-appointments-v1.yaml").getContentAsString(StandardCharsets.UTF_8);assertThat(s).contains("payableAmount:","enum: [RMB, USD]","default: RMB").doesNotContain("receivableAmount");} @Test void methodsHavePlatformPermissionsAndAuthenticatedScope() { Map expected=Map.ofEntries( - Map.entry("page","platform:firstleg:truck-appointment:list"), Map.entry("detail","platform:firstleg:truck-appointment:list"), + Map.entry("page","platform:firstleg:truck-appointment:list"), Map.entry("candidates","platform:firstleg:truck-appointment:list"), Map.entry("detail","platform:firstleg:truck-appointment:list"), Map.entry("history","platform:firstleg:truck-appointment:list"), Map.entry("attachments","platform:firstleg:truck-appointment:list"), Map.entry("attach","platform:firstleg:truck-appointment:edit"), Map.entry("upload","platform:firstleg:truck-appointment:edit"), Map.entry("deleteAttachment","platform:firstleg:truck-appointment:edit"), Map.entry("create","platform:firstleg:truck-appointment:create"), diff --git a/firstleg/src/test/java/com/baiyue/firstleg/repository/TruckAppointmentMapperContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/repository/TruckAppointmentMapperContractTest.java index 3c979281c..a0b5cd5c8 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/repository/TruckAppointmentMapperContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/repository/TruckAppointmentMapperContractTest.java @@ -16,6 +16,13 @@ class TruckAppointmentMapperContractTest { assertThat(page).contains("fl_truck_appointment", "appointment_no", "vehicle_no", "status=#{q.status}", "container_booking_id=#{q.containerBookingId}", "fl_ocean_booking", "fl_container_booking", "record_status='ACTIVE'", "cabinet_no", "container_no", "fl_truck_appointment_attachment", "attachment_count"); assertThat(page).doesNotContain("customer_id", "customerId", "customer_name"); + String candidates = TruckAppointmentMapper.class.getMethod("candidates", com.baomidou.mybatisplus.extension.plugins.pagination.Page.class, com.baiyue.firstleg.dto.TruckAppointmentDtos.CandidateQuery.class).getAnnotation(Select.class).value()[0]; + assertThat(candidates).contains("o.status='RELEASED'", "fl_ocean_booking_container_allocation", + "fl_ocean_booking_container_requirement", "c.record_status='ACTIVE'", "d.valid=1", + "'BOOKING_CONFIRMATION','BILL_OF_LADING'", "p.plan_status<>'COMPLETED'", + "t.status NOT IN ('COMPLETED','CANCELLED','EXCEPTION')", "containerBookingId", + "bookingDocumentReady"); + assertThat(candidates).doesNotContain("c.container_no IS NOT NULL", "currentAppointmentId", "currentContainerBookingId"); String transition = TruckAppointmentMapper.class.getMethod("transition", long.class, String.class, String.class, int.class).getAnnotation(Update.class).value()[0]; assertThat(transition).contains("status=#{toStatus}", "status=#{fromStatus}", "version=#{version}", "revision=revision+1", "version=version+1"); String dispatch = TruckAppointmentMapper.class.getMethod("dispatch", long.class, String.class, String.class, int.class).getAnnotation(Update.class).value()[0]; diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/TruckAppointmentServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/TruckAppointmentServiceTest.java index aa8cd7066..cdcb39a55 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/service/TruckAppointmentServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/TruckAppointmentServiceTest.java @@ -1,9 +1,12 @@ package com.baiyue.firstleg.service; import com.baiyue.common.core.exception.BizException; +import com.baiyue.common.core.api.PageResult; import com.baiyue.firstleg.domain.TruckAppointmentStatus; import com.baiyue.firstleg.dto.TruckAppointmentDtos.ActionRequest; import com.baiyue.firstleg.dto.TruckAppointmentDtos.AttachmentRequest; +import com.baiyue.firstleg.dto.TruckAppointmentDtos.Candidate; +import com.baiyue.firstleg.dto.TruckAppointmentDtos.CandidateQuery; import com.baiyue.firstleg.dto.TruckAppointmentDtos.SaveRequest; import com.baiyue.firstleg.entity.TruckAppointmentEntity; import com.baiyue.firstleg.entity.TruckAppointmentAttachmentEntity; @@ -91,6 +94,18 @@ class TruckAppointmentServiceTest { assertThat(detail.getContainerNo()).isEqualTo("MSCU1234567"); } + @Test void createCandidatesDelegateOneBoundedAuthoritativeQuery() { + Candidate candidate = new Candidate(99L, "OB-1", "BL-1", 18L, "义乌仓", "SEA-US", "美西", + 70L, "45HC", 1, 88L, "CAB-1", null, 77L, "LP-1", "EDITING", true); + when(repository.candidates(any())).thenReturn(new PageResult<>(List.of(candidate), 1, 20, 1, 1)); + + PageResult result = service.candidates(new CandidateQuery(" OB-1 ", 1, 20)); + + assertThat(result.records()).containsExactly(candidate); + verify(repository, times(1)).candidates(any(CandidateQuery.class)); + verifyNoInteractions(oceanBookings, containers, loadingPlans); + } + @Test void createRequiresZeroVersionAndInitialisesDraft() { SaveRequest invalid = request(1); assertThatThrownBy(() -> service.create(7L, "admin", invalid)).isInstanceOf(BizException.class); @@ -384,6 +399,19 @@ class TruckAppointmentServiceTest { verify(repository, never()).insert(any()); } + @Test void createRejectsCompletedLoadingContainerEvenWhenAppointmentIsTerminal() { + SaveRequest request = validRequest(); + LoadingPlanEntity completed = new LoadingPlanEntity(); completed.setId(77L); + completed.setContainerBookingId(88L); completed.setPlanStatus("COMPLETED"); + when(loadingPlans.planByBooking(88L)).thenReturn(completed); + + assertThatThrownBy(() -> service.create(7L, "admin", request)) + .isInstanceOf(BizException.class).hasMessageContaining("已装柜完成"); + + verify(repository, never()).insert(any()); + verify(repository, never()).history(any()); + } + @Test void actionAdvancesMainLifecycleAndWritesIdempotentOperation() { TruckAppointmentEntity before = entity(TruckAppointmentStatus.DRAFT, 0); TruckAppointmentEntity after = entity(TruckAppointmentStatus.PENDING_DISPATCH, 1); From aa828ca1eb3f3497557af7caaed17e70db0453b7 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Tue, 1 Sep 2026 15:43:19 +0800 Subject: [PATCH 6/7] =?UTF-8?q?=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../admin-test-env-modules/phase3-loading.mjs | 83 +++++++++++++++++-- 1 file changed, 77 insertions(+), 6 deletions(-) diff --git a/scripts/admin-test-env-modules/phase3-loading.mjs b/scripts/admin-test-env-modules/phase3-loading.mjs index ed57a829b..7e3535425 100644 --- a/scripts/admin-test-env-modules/phase3-loading.mjs +++ b/scripts/admin-test-env-modules/phase3-loading.mjs @@ -8,6 +8,7 @@ const OPTIONS = { scenario: 'phase3-split-preload-loading', deploymentBlocker: t const MERCHANT_USERNAME = process.env.MERCHANT_TEST_USERNAME || '' const MERCHANT_PASSWORD = process.env.MERCHANT_TEST_PASSWORD || '' const MATSON_SUPPLIER_NAME = 'MATSON 开发联调船司' +const LATE_CONTAINER_GATE_ONLY = process.env.LATE_CONTAINER_GATE_ONLY === '1' function id(value) { return value == null ? '' : String(value) } export function markerSuffix(marker) { @@ -496,19 +497,24 @@ export async function run(context) { const latestContainerResponse = await context.request('GET', `/api/platform/v1/containers/${containerBookingId}`) const latestContainer = context.apiSuccess(MODULE, '合并前读取模拟柜最新版本', latestContainerResponse, '', OPTIONS) const linkResponse = await request(context, 'POST', `/api/platform/v1/containers/${containerBookingId}/ocean-booking-link`, { - oceanBookingId: bookingId, requirementId: id(requirement.id), containerNo, + oceanBookingId: bookingId, requirementId: id(requirement.id), + ...(LATE_CONTAINER_GATE_ONLY ? {} : { containerNo }), expectedVersion: Number(latestContainer?.version), }) - const linked = context.apiSuccess(MODULE, '模拟柜原地关联三期 RELEASED 订舱需求并补真实柜号', linkResponse, '', OPTIONS) - context.assert(MODULE, '订舱关联保持柜次计划与真实柜号身份', linkResponse, + const linked = context.apiSuccess(MODULE, LATE_CONTAINER_GATE_ONLY + ? '无号模拟柜原地关联三期 RELEASED 订舱需求' + : '模拟柜原地关联三期 RELEASED 订舱需求并补真实柜号', linkResponse, '', OPTIONS) + context.assert(MODULE, '订舱关联保持柜次计划与运输身份', linkResponse, id(linked?.id) === containerBookingId && linked?.cabinetNo === simulation.cabinetNo - && linked?.containerNo === containerNo && id(linked?.oceanBookingId) === bookingId, + && (LATE_CONTAINER_GATE_ONLY ? linked?.containerNo == null : linked?.containerNo === containerNo) + && id(linked?.oceanBookingId) === bookingId, `containerBookingId=${containerBookingId}, loadingPlanId=${loadingPlanId}`, OPTIONS) if (id(linked?.id) !== containerBookingId) return context.assert(MODULE, '三期订舱 RELEASED 后保持模拟柜关联身份和运输快照', releaseResponse, bookingConfirmed?.oceanBooking?.status === 'RELEASED' - && id(linked?.oceanBookingId) === bookingId && linked?.containerNo === containerNo, + && id(linked?.oceanBookingId) === bookingId + && (LATE_CONTAINER_GATE_ONLY ? linked?.containerNo == null : linked?.containerNo === containerNo), `status=${bookingConfirmed?.oceanBooking?.status || 'missing'}, containerBookingId=${containerBookingId}`, OPTIONS) const unconfirmedTaskResponse = await context.request('GET', @@ -610,7 +616,8 @@ export async function run(context) { && id(inventory?.loadingPlanId) === loadingPlanId && id(inventory?.containerBookingId) === containerBookingId && Number(inventory?.taskVersion) === Number(claimed?.version) - && id(inventory?.cabinetNo) && inventory?.currentContainerNo === containerNo + && id(inventory?.cabinetNo) + && (LATE_CONTAINER_GATE_ONLY ? inventory?.currentContainerNo == null : inventory?.currentContainerNo === containerNo) && id(inventory?.warehouseId) === warehouseId && Boolean(inventory?.capturedAt) && typeof inventory?.snapshotToken === 'string' && inventory.snapshotToken.length === 64 && Number(inventoryInbound?.availablePackageCount) >= 1 @@ -869,6 +876,70 @@ export async function run(context) { && id(pickedPlan?.pdaTaskId) === canonicalTaskId && pickedPlan?.pdaTaskStatus === 'LOADING', `status=${pickedPlan?.status || 'missing'}, version=${pickedPlan?.version ?? 'missing'}`, OPTIONS) + if (LATE_CONTAINER_GATE_ONLY) { + const missingOperationId = `${context.marker}-late-container-missing-complete`.slice(0, 128) + const missingComplete = await request(context, 'POST', `${pdaBase}/tasks/${canonicalTaskId}/complete`, { + expectedTaskVersion: Number(pickedCanonical?.version), operationId: missingOperationId, + remark: `${context.marker}-missing-container`, + }) + context.addStep(MODULE, '无号拣货完成后 PDA complete 精确返回 A0409', missingComplete, + missingComplete.http === 200 && codeOf(missingComplete) === 'A0409' + && String(missingComplete.body?.message || '').includes('真实集装箱号'), + `${responseEvidence(missingComplete)}, message=${missingComplete.body?.message || 'missing'}`, OPTIONS) + + const transport = await request(context, 'PATCH', + `/api/platform/v1/first-leg/loading-plans/${loadingPlanId}/transport?warehouseId=${encodeURIComponent(warehouseId)}`, { + containerNo, voyageNo: bookingDraft.voyageNo, expectedVersion: Number(pickedPlan?.version), + operationReason: null, + }, { 'Idempotency-Key': `${context.marker}-late-container-fill`.slice(0, 128) }) + const filled = context.apiSuccess(MODULE, 'LOADING 阶段通过预装运输信息首次补录真实柜号', transport, + responseEvidence(transport), OPTIONS) + context.assert(MODULE, '首次补号保持计划与柜次身份且柜号规范化落盘', transport, + id(filled?.summary?.planId) === loadingPlanId + && id(filled?.summary?.containerBookingId) === containerBookingId + && filled?.summary?.status === 'LOADING' && filled?.summary?.containerNo === containerNo, + `planId=${id(filled?.summary?.planId) || 'missing'}, containerBookingId=${id(filled?.summary?.containerBookingId) || 'missing'}, containerNo=${filled?.summary?.containerNo || 'missing'}`, OPTIONS) + + const afterFillTaskResponse = await context.request('GET', taskDetailPath) + const afterFillTask = context.apiSuccess(MODULE, '首次补号后回读原 PDA 任务', afterFillTaskResponse, '', OPTIONS) + context.assert(MODULE, '补号不替换 task/plan/cabinet 稳定身份', afterFillTaskResponse, + id(afterFillTask?.id) === canonicalTaskId && id(afterFillTask?.loadingPlanId) === loadingPlanId + && id(afterFillTask?.containerBookingId) === containerBookingId + && afterFillTask?.currentContainerNo === containerNo, + `taskId=${id(afterFillTask?.id) || 'missing'}, currentContainerNo=${afterFillTask?.currentContainerNo || 'missing'}`, OPTIONS) + + const acceptedOperationId = `${context.marker}-late-container-accepted-complete`.slice(0, 128) + const acceptedComplete = await request(context, 'POST', `${pdaBase}/tasks/${canonicalTaskId}/complete`, { + expectedTaskVersion: Number(afterFillTask?.version), operationId: acceptedOperationId, + remark: `${context.marker}-container-filled`, + }) + context.assert(MODULE, '补号后 complete 已越过柜号门禁并受理异步命令', acceptedComplete, + acceptedComplete.http === 200 && codeOf(acceptedComplete) === '200' + && ['PROCESSING', 'FAILED', 'COMPLETED'].includes(acceptedComplete.body?.data?.workflowStatus), + `${responseEvidence(acceptedComplete)}, workflow=${acceptedComplete.body?.data?.workflowStatus || 'missing'}`, OPTIONS) + + let terminalTask = acceptedComplete.body?.data + let terminalResponse = acceptedComplete + for (let attempt = 0; attempt < 10 && terminalTask?.workflowStatus === 'PROCESSING'; attempt += 1) { + await new Promise((resolve) => setTimeout(resolve, 500)) + terminalResponse = await context.request('GET', taskDetailPath) + if (terminalResponse.http === 200 && codeOf(terminalResponse) === '200') terminalTask = terminalResponse.body?.data + } + context.assert(MODULE, '补号后的下一完成门禁返回明确终态而非柜号冲突', terminalResponse, + terminalTask?.workflowStatus !== 'PROCESSING' + && !String(terminalTask?.completionCommand?.failureMessage || '').includes('集装箱号'), + `workflow=${terminalTask?.workflowStatus || 'missing'}, stage=${terminalTask?.completionCommand?.failureStage || 'none'}, code=${terminalTask?.completionCommand?.failureCode || 'none'}, message=${terminalTask?.completionCommand?.failureMessage || 'none'}`, OPTIONS) + context.createdResources.phase3LateContainerGate = { + marker: context.marker, tenantId, inboundId, containerBookingId, cabinetNo: simulation.cabinetNo, + oceanBookingId: bookingId, loadingPlanId, taskId: canonicalTaskId, containerNo, + missingComplete: responseEvidence(missingComplete), fill: responseEvidence(transport), + acceptedComplete: responseEvidence(acceptedComplete), finalWorkflow: terminalTask?.workflowStatus || null, + nextFailureStage: terminalTask?.completionCommand?.failureStage || null, + nextFailureCode: terminalTask?.completionCommand?.failureCode || null, + } + return + } + const preload = await context.request('GET', `${base}/preload?tenantId=${encodeURIComponent(tenantId)}`) const preloadData = context.apiSuccess(MODULE, '回查已确认预装清单', preload, '', OPTIONS) context.assert(MODULE, 'PDA 最终完成前预装清单仍为 CONFIRMED 且归属稳定', preload, From ca8127ce8388991e5ff4328e270a94775389f8cb Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Tue, 1 Sep 2026 15:53:12 +0800 Subject: [PATCH 7/7] test(firstleg): formalize late container gate acceptance --- scripts/admin-test-env-modules/README.md | 8 +++++++ .../admin-test-env-modules/phase3-loading.mjs | 13 ++++++----- .../phase3-producer-chain-contract.test.mjs | 22 ++++++++++++++++++- 3 files changed, 37 insertions(+), 6 deletions(-) diff --git a/scripts/admin-test-env-modules/README.md b/scripts/admin-test-env-modules/README.md index f46f6459f..57c81e7fb 100644 --- a/scripts/admin-test-env-modules/README.md +++ b/scripts/admin-test-env-modules/README.md @@ -120,6 +120,14 @@ node scripts/admin-test-env-acceptance.mjs --gateway --ove node scripts/admin-test-env-acceptance.mjs --modules merchant-qualification-fixture,orders,inbound-measurement,phase3-container-loading,phase3-loading,phase3-container-overview,phase3-pda-convergence,phase3-evidence-gate,phase3-customs,phase3-loading-finalize ``` +只验证真实集装箱号晚补门禁时,使用正式可选参数 `LATE_CONTAINER_GATE_ONLY=1` 并仍从总入口执行 `phase3-loading`。该模式使用本轮 marker 独立造数,固定验证无号 push、订舱关联、整份预装确认、PDA claim/batch、无号 complete `A0409`、LOADING 阶段首次补号和补号后 complete 受理;随后允许后台命令停在 `CABINET_EVIDENCE/A0409`,以证明柜号门禁已经消失且下一门禁来自未上传的五类柜级证据。该模式不继续完整报关或 LOADED 链,也不得与完整链同时解释为同一终态验收。 + +```powershell +$env:LATE_CONTAINER_GATE_ONLY='1' +node --env-file="$env:USERPROFILE\.codex\chongchong-global-test-acceptance.env" scripts/admin-test-env-acceptance.mjs --modules phase3-loading +Remove-Item Env:LATE_CONTAINER_GATE_ONLY +``` + 该命令会写测试环境,必须另行获得当前对话授权。离线入口合同为 `../full-regression-entry-contract.test.mjs`,生产者与状态合同为 `phase3-producer-chain-contract.test.mjs`。 ## 消息中心模块 diff --git a/scripts/admin-test-env-modules/phase3-loading.mjs b/scripts/admin-test-env-modules/phase3-loading.mjs index 7e3535425..2d4bec66f 100644 --- a/scripts/admin-test-env-modules/phase3-loading.mjs +++ b/scripts/admin-test-env-modules/phase3-loading.mjs @@ -925,17 +925,20 @@ export async function run(context) { terminalResponse = await context.request('GET', taskDetailPath) if (terminalResponse.http === 200 && codeOf(terminalResponse) === '200') terminalTask = terminalResponse.body?.data } - context.assert(MODULE, '补号后的下一完成门禁返回明确终态而非柜号冲突', terminalResponse, - terminalTask?.workflowStatus !== 'PROCESSING' - && !String(terminalTask?.completionCommand?.failureMessage || '').includes('集装箱号'), + const nextStage = terminalTask?.completionCommand?.failureStage || null + const nextCode = terminalTask?.completionCommand?.failureCode || null + const nextMessage = String(terminalTask?.completionCommand?.failureMessage || '') + context.assert(MODULE, '补号后已越过柜号门禁并允许进入柜级证据门禁', terminalResponse, + !nextMessage.includes('集装箱号') + && (terminalTask?.workflowStatus !== 'PROCESSING' + || (nextStage === 'CABINET_EVIDENCE' && nextCode === 'A0409')), `workflow=${terminalTask?.workflowStatus || 'missing'}, stage=${terminalTask?.completionCommand?.failureStage || 'none'}, code=${terminalTask?.completionCommand?.failureCode || 'none'}, message=${terminalTask?.completionCommand?.failureMessage || 'none'}`, OPTIONS) context.createdResources.phase3LateContainerGate = { marker: context.marker, tenantId, inboundId, containerBookingId, cabinetNo: simulation.cabinetNo, oceanBookingId: bookingId, loadingPlanId, taskId: canonicalTaskId, containerNo, missingComplete: responseEvidence(missingComplete), fill: responseEvidence(transport), acceptedComplete: responseEvidence(acceptedComplete), finalWorkflow: terminalTask?.workflowStatus || null, - nextFailureStage: terminalTask?.completionCommand?.failureStage || null, - nextFailureCode: terminalTask?.completionCommand?.failureCode || null, + nextFailureStage: nextStage, nextFailureCode: nextCode, } return } diff --git a/scripts/admin-test-env-modules/phase3-producer-chain-contract.test.mjs b/scripts/admin-test-env-modules/phase3-producer-chain-contract.test.mjs index 8573d0854..de5bff40e 100644 --- a/scripts/admin-test-env-modules/phase3-producer-chain-contract.test.mjs +++ b/scripts/admin-test-env-modules/phase3-producer-chain-contract.test.mjs @@ -123,6 +123,25 @@ test('bridge loading confirms preload before creating and claiming the PDA task' assert.match(value, /bookingConfirmed\?\.oceanBooking\?\.status === 'RELEASED'/) }) +test('late container gate mode proves the final gate without requiring evidence completion', () => { + const value = source('./phase3-loading.mjs') + assert.match(value, /LATE_CONTAINER_GATE_ONLY = process\.env\.LATE_CONTAINER_GATE_ONLY === '1'/) + assertOrder(value, [ + '无号模拟柜原地关联三期 RELEASED 订舱需求', + '计划级原子确认整份预装并触发唯一 PDA 任务', + '领取柜次 PDA 任务', + '原子提交柜次 PDA 整批拣货', + '无号拣货完成后 PDA complete 精确返回 A0409', + 'LOADING 阶段通过预装运输信息首次补录真实柜号', + '补号后 complete 已越过柜号门禁并受理异步命令', + '补号后已越过柜号门禁并允许进入柜级证据门禁', + ]) + assert.match(value, /String\(missingComplete\.body\?\.message \|\| ''\)\.includes\('真实集装箱号'\)/) + assert.match(value, /nextStage === 'CABINET_EVIDENCE' && nextCode === 'A0409'/) + assert.match(value, /!nextMessage\.includes\('集装箱号'\)/) + assert.match(value, /context\.createdResources\.phase3LateContainerGate/) +}) + test('push replay and both conflict modes preserve the complete pre-PDA snapshot', () => { for (const file of ['./phase3-loading.mjs', './phase3-container-loading.mjs']) { const value = source(file) @@ -220,7 +239,8 @@ test('PDA inventory snapshot gates batch and records the only safe mutation gap' ]) assert.match(loading, /inventorySnapshotToken: refreshedInventory\.snapshotToken/) assert.match(loading, /warehouseId, loadingPlanId, containerBookingId,/) - assert.match(loading, /inventory\?\.currentContainerNo === containerNo/) + assert.match(loading, + /LATE_CONTAINER_GATE_ONLY \? inventory\?\.currentContainerNo == null : inventory\?\.currentContainerNo === containerNo/) const batchPayload = loading.slice(loading.indexOf('const batchPayload ='), loading.indexOf('const staleBatch =')) assert.doesNotMatch(batchPayload, /containerNo[,\s]/) assert.doesNotMatch(loading, /\/loading\/picking\/(?:start|complete)/)