From 68d9216cb84b0bd7574cea5f1bf44aed779168aa Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Mon, 17 Aug 2026 15:55:11 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../mapper/LoadingCompletionGateMapper.java | 9 +--- .../service/LoadingCompletionGateService.java | 3 +- .../service/LoadingDeliveryService.java | 6 +-- .../mapper/TruckAppointmentMapper.java | 2 + .../TruckAppointmentRepository.java | 1 + .../firstleg/service/LoadingPickService.java | 4 ++ .../service/TruckAppointmentService.java | 1 + .../openapi/phase3-loading-delivery-v1.yaml | 2 +- .../openapi/phase3-pda-convergence-v1.yaml | 2 +- .../openapi/truck-appointments-v1.yaml | 1 + .../LoadingCompletionGateSqlContractTest.java | 8 +-- .../service/LoadingDeliveryServiceTest.java | 16 +++--- .../TruckAppointmentMapperContractTest.java | 6 +++ .../service/LoadingPickServiceTest.java | 11 ++-- .../service/TruckAppointmentServiceTest.java | 51 +++++++++++++++++++ scripts/admin-test-env-modules/README.md | 4 +- .../admin-test-env-modules/phase3-customs.mjs | 21 ++------ ...e3-loading-dynamic-final-contract.test.mjs | 29 ++++------- .../phase3-loading-finalize.mjs | 13 ++--- .../phase3-producer-chain-contract.test.mjs | 7 +-- .../full-regression-entry-contract.test.mjs | 9 ++-- 21 files changed, 121 insertions(+), 85 deletions(-) diff --git a/firstleg/src/main/java/com/baiyue/firstleg/loading/mapper/LoadingCompletionGateMapper.java b/firstleg/src/main/java/com/baiyue/firstleg/loading/mapper/LoadingCompletionGateMapper.java index 82bba04b4..62e2ea610 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/loading/mapper/LoadingCompletionGateMapper.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/loading/mapper/LoadingCompletionGateMapper.java @@ -12,20 +12,15 @@ public interface LoadingCompletionGateMapper { COALESCE(SUM(CASE WHEN EXISTS ( SELECT 1 FROM fl_ocean_booking_container_allocation a JOIN fl_ocean_booking b ON b.id=a.ocean_booking_id - WHERE a.container_booking_id=p.container_booking_id AND b.status='RELEASED') THEN 1 ELSE 0 END),0) oceanReady, - COALESCE(SUM(CASE WHEN EXISTS ( - SELECT 1 FROM fl_truck_appointment t - WHERE t.container_booking_id=p.container_booking_id - AND t.status='COMPLETED') THEN 1 ELSE 0 END),0) truckReady + WHERE a.container_booking_id=p.container_booking_id AND b.status='RELEASED') THEN 1 ELSE 0 END),0) oceanReady FROM fl_preload_loading_plan_link l JOIN fl_loading_plan p ON p.id=l.loading_plan_id WHERE l.tenant_id=#{tenantId} AND l.inbound_id=#{inboundId} """) GateSnapshot snapshot(@Param("tenantId") long tenantId, @Param("inboundId") long inboundId); - record GateSnapshot(Integer linkCount, Integer oceanReady, Integer truckReady) { + record GateSnapshot(Integer linkCount, Integer oceanReady) { public boolean allBookingReady() { return count(linkCount) > 0 && count(linkCount) == count(oceanReady); } - public boolean allTruckReady() { return count(linkCount) > 0 && count(linkCount) == count(truckReady); } private static int count(Integer value) { return value == null ? 0 : value; } } } diff --git a/firstleg/src/main/java/com/baiyue/firstleg/loading/service/LoadingCompletionGateService.java b/firstleg/src/main/java/com/baiyue/firstleg/loading/service/LoadingCompletionGateService.java index 21410817b..8aac3146c 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/loading/service/LoadingCompletionGateService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/loading/service/LoadingCompletionGateService.java @@ -11,13 +11,12 @@ import org.springframework.stereotype.Service; public class LoadingCompletionGateService { @Resource private LoadingCompletionGateMapper mapper; - public void requireBookingAndTransport(long tenantId, long inboundId) { + public void requireReleasedBooking(long tenantId, long inboundId) { LoadingCompletionGateMapper.GateSnapshot gate = mapper.snapshot(tenantId, inboundId); if (gate == null || gate.linkCount() == null || gate.linkCount() == 0) { throw blocked("未找到有效柜次关联"); } if (!gate.allBookingReady()) throw blocked("订舱尚未完成(必须为 RELEASED)"); - if (!gate.allTruckReady()) throw blocked("拖车尚未完成(必须为 COMPLETED)"); } private static BizException blocked(String message) { diff --git a/firstleg/src/main/java/com/baiyue/firstleg/loading/service/LoadingDeliveryService.java b/firstleg/src/main/java/com/baiyue/firstleg/loading/service/LoadingDeliveryService.java index 335c8fe38..7198f715c 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/loading/service/LoadingDeliveryService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/loading/service/LoadingDeliveryService.java @@ -112,7 +112,7 @@ public class LoadingDeliveryService { } if (!"LOADING".equals(inbound.processStatus())) throw new BizException(ResultCode.CONFLICT, "当前订单不在装柜中"); loadingModelBridge.requireCompleted(tenantId, inboundId); - completionGate.requireBookingAndTransport(tenantId, inboundId); + completionGate.requireReleasedBooking(tenantId, inboundId); requireCompletedPicking(tenantId, inboundId); requireCompletionEvidence(tenantId, inboundId); if (repository.completeLoading(tenantId, inboundId, operatorId, request.expectedVersion()) != 1) throw concurrent(); @@ -131,7 +131,7 @@ public class LoadingDeliveryService { throw new BizException(ResultCode.CONFLICT, "PDA确认装柜完成前订单必须处于LOADING"); } loadingModelBridge.requireStarted(tenantId, inboundId); - completionGate.requireBookingAndTransport(tenantId, inboundId); + completionGate.requireReleasedBooking(tenantId, inboundId); requireCompletionEvidence(tenantId, inboundId); } @@ -147,7 +147,7 @@ public class LoadingDeliveryService { throw new BizException(ResultCode.CONFLICT, "实装履约分片不在装柜中"); } loadingModelBridge.requireCompleted(tenantId, target.sourceInboundId()); - completionGate.requireBookingAndTransport(tenantId, target.sourceInboundId()); + completionGate.requireReleasedBooking(tenantId, target.sourceInboundId()); requireCompletedPicking(tenantId, target.sourceInboundId()); requireCompletionEvidence(tenantId, target.sourceInboundId()); if (repository.completeLoadedChild(tenantId, target.sourceInboundId(), operatorId, 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 8c0576f3f..b94f76257 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/mapper/TruckAppointmentMapper.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/mapper/TruckAppointmentMapper.java @@ -1,8 +1,10 @@ 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.*; @Mapper public interface TruckAppointmentMapper extends BaseMapper{ + @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); @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='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 ocean_booking_id=#{e.oceanBookingId},container_booking_id=#{e.containerBookingId},loading_plan_id=#{e.loadingPlanId},warehouse_id=#{e.warehouseId},customer_id=#{e.customerId},customer_name=#{e.customerName},carrier_id=#{e.carrierId},carrier_name=#{e.carrierName},vehicle_no=#{e.vehicleNo},driver_name=#{e.driverName},driver_phone=#{e.driverPhone},payable_amount=#{e.payableAmount},receivable_amount=#{e.receivableAmount},planned_loading_at=#{e.plannedLoadingAt},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 2bb033a54..4f140cf76 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/repository/TruckAppointmentRepository.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/repository/TruckAppointmentRepository.java @@ -4,4 +4,5 @@ import com.baomidou.mybatisplus.extension.plugins.pagination.Page; import com.ba 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 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 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/LoadingPickService.java b/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingPickService.java index fac0340c1..d75ba7f67 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingPickService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingPickService.java @@ -83,6 +83,8 @@ public class LoadingPickService { @Resource private LoadingDeliveryService loadingDeliveryService; @Resource + private TruckAppointmentService truckAppointmentService; + @Resource private InboundStatusHistoryMapper inboundHistories; @Resource private PdaPickSettlementService settlementService; @@ -458,6 +460,8 @@ public class LoadingPickService { loadingDeliveryService.requirePdaCompletionReady(owner.tenantId(), owner.inboundId()); inboundSnapshots.put(owner, snapshot); } + truckAppointmentService.completeLoadingFromPda(task.getContainerBookingId(), taskId, + operatorId, operatorName); settlementService.settle(taskId, operatorId, operatorName); if (!repository.allPlanItemsPicked(task.getLoadingPlanId())) { throw conflict("柜次仍有未完成PDA拣货的箱数"); 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 87a162638..b073105d7 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/TruckAppointmentService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/TruckAppointmentService.java @@ -47,6 +47,7 @@ public class TruckAppointmentService { @Transactional public TruckAppointmentEntity edit(long id,long uid,String name,SaveRequest 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("当前状态不可编辑"); BookingLink link=validateBookingLink(r); var carrier=suppliers.trucking(r.carrierId(),r.carrierName()); copy(e,r,link,carrier==null?null: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); if(repo.transition(id,from.name(),to.name(),e.getVersion())!=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 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 BookingLink validateBookingLink(SaveRequest r){if(r.oceanBookingId()==null||r.oceanBookingId()<=0)throw bad("oceanBookingId不能为空"); if(r.containerBookingId()==null||r.containerBookingId()<=0)throw bad("containerBookingId不能为空"); OceanBookingEntity b=oceanBookings.find(r.oceanBookingId()); if(b==null)throw new BizException(ResultCode.NOT_FOUND,"关联订舱不存在"); if(!"RELEASED".equals(b.getStatus()))throw conflict("仅已放舱订舱可创建拖车预约"); if(b.getCustomerId()==null||!StringUtils.hasText(b.getCustomerName()))throw conflict("关联订舱缺少客户快照"); if(r.customerId()!=null&&r.customerId()>0&&!Objects.equals(r.customerId(),b.getCustomerId()))throw bad("customerId与订舱客户不一致"); if(StringUtils.hasText(r.customerName())&&!r.customerName().trim().equals(b.getCustomerName()))throw bad("customerName与订舱客户快照不一致"); ContainerBookingEntity c=containers.find(r.containerBookingId()); if(c==null)throw new BizException(ResultCode.NOT_FOUND,"关联柜体不存在"); boolean allocated=oceanBookings.allocations(r.oceanBookingId()).stream().anyMatch(a->Objects.equals(a.getContainerBookingId(),r.containerBookingId())); if(!allocated)throw bad("containerBookingId未分配至该订舱"); if(r.warehouseId()!=null&&r.warehouseId()>0&&c.getWarehouseId()!=null&&!Objects.equals(r.warehouseId(),c.getWarehouseId()))throw bad("warehouseId与柜体不一致"); LoadingPlanEntity plan=loadingPlans.planByBooking(r.containerBookingId()); if(plan==null)throw conflict("关联柜次尚未建立装柜计划"); if(r.loadingPlanId()!=null&&!Objects.equals(r.loadingPlanId(),plan.getId()))throw bad("loadingPlanId与柜体当前装柜计划不一致"); return new BookingLink(b,plan);} private TruckAppointmentStatus next(TruckAppointmentStatus s,String a){return switch(a){case"SUBMIT"->s==TruckAppointmentStatus.DRAFT?TruckAppointmentStatus.PENDING_DISPATCH:badStatus();case"DISPATCH"->s==TruckAppointmentStatus.PENDING_DISPATCH||s==TruckAppointmentStatus.RESCHEDULED?TruckAppointmentStatus.DISPATCHED:badStatus();case"PICKUP"->s==TruckAppointmentStatus.DISPATCHED?TruckAppointmentStatus.PICKUP_IN_PROGRESS:badStatus();case"ARRIVE"->s==TruckAppointmentStatus.PICKUP_IN_PROGRESS?TruckAppointmentStatus.ARRIVED_WAREHOUSE:badStatus();case"LOAD"->s==TruckAppointmentStatus.ARRIVED_WAREHOUSE?TruckAppointmentStatus.LOADING:badStatus();case"DEPART"->s==TruckAppointmentStatus.LOADING?TruckAppointmentStatus.DEPARTED:badStatus();case"COMPLETE"->s==TruckAppointmentStatus.DEPARTED?TruckAppointmentStatus.COMPLETED:badStatus();case"EXCEPTION"->s!=TruckAppointmentStatus.COMPLETED&&s!=TruckAppointmentStatus.CANCELLED?TruckAppointmentStatus.EXCEPTION:badStatus();case"CANCEL"->s!=TruckAppointmentStatus.COMPLETED?TruckAppointmentStatus.CANCELLED:badStatus();default->badStatus();};} private TruckAppointmentStatus badStatus(){throw conflict("不允许的状态动作");} private TruckAppointmentEntity required(long id){TruckAppointmentEntity e=repo.find(id);if(e==null)throw new BizException(ResultCode.NOT_FOUND,"拖车预约不存在");return e;} private void checkVersion(TruckAppointmentEntity e,int v){if(e.getVersion()!=v)throw conflict("版本已变化,请刷新后重试");} private static BizException conflict(String m){return new BizException(ResultCode.CONFLICT,m);} private static BizException bad(String m){return new BizException(ResultCode.BAD_REQUEST,m);} diff --git a/firstleg/src/main/resources/openapi/phase3-loading-delivery-v1.yaml b/firstleg/src/main/resources/openapi/phase3-loading-delivery-v1.yaml index b4dc44d04..60e832d45 100644 --- a/firstleg/src/main/resources/openapi/phase3-loading-delivery-v1.yaml +++ b/firstleg/src/main/resources/openapi/phase3-loading-delivery-v1.yaml @@ -95,7 +95,7 @@ paths: /api/platform/v1/inbounds/{inboundId}/loading/complete: post: summary: Compatibility endpoint for completing loading after the canonical PDA confirmation - description: Canonical PDA confirmation now atomically completes task, plan and inbound LOADED. This endpoint remains an idempotent server-side completion primitive; RELEASED booking, COMPLETED truck, completed plan/picking and five ACTIVE evidence types remain hard gates. + description: Canonical PDA confirmation atomically completes task, plan and inbound LOADED. This endpoint remains an idempotent server-side completion primitive; RELEASED booking, completed plan/picking and five ACTIVE evidence types remain hard gates. Truck status is not a prerequisite; the canonical PDA action completes the unique active appointment for the stable container booking when one exists. parameters: [{$ref: '#/components/parameters/InboundId'}, {$ref: '#/components/parameters/TenantId'}, {$ref: '#/components/parameters/IdempotencyKey'}] requestBody: required: true diff --git a/firstleg/src/main/resources/openapi/phase3-pda-convergence-v1.yaml b/firstleg/src/main/resources/openapi/phase3-pda-convergence-v1.yaml index 50eeceb2c..18bf7c84f 100644 --- a/firstleg/src/main/resources/openapi/phase3-pda-convergence-v1.yaml +++ b/firstleg/src/main/resources/openapi/phase3-pda-convergence-v1.yaml @@ -106,7 +106,7 @@ paths: /api/platform/v1/first-leg/pda-loading/tasks/{taskId}/complete: post: summary: PDA拣货全部处理后人工确认装柜完成 - description: PDA拣货只锁定数量和仓储资源。本接口先无写入校验全部关联入库单的RELEASED订舱、COMPLETED拖车和五类ACTIVE证据;任何阻断返回A0409且task、plan、inbound仍保持LOADING。全部满足后在同一事务内结算资源、完成task/plan并推进inbound为LOADED。 + description: PDA拣货只锁定数量和仓储资源。本接口先无写入校验全部关联入库单的RELEASED订舱、稳定bridge和五类ACTIVE证据;拖车状态不是前置门禁。若当前稳定containerBookingId没有有效拖车则直接继续;恰有一条有效拖车时由服务端加锁推进为COMPLETED并写历史;多条有效拖车或并发修改返回A0409且全部Firstleg本地状态回滚。随后在同一事务内结算资源、完成task/plan并推进inbound为LOADED。同operationId重放返回冻结结果,不重复推进拖车或写历史。 parameters: [{$ref: '#/components/parameters/TaskId'}] requestBody: required: true diff --git a/firstleg/src/main/resources/openapi/truck-appointments-v1.yaml b/firstleg/src/main/resources/openapi/truck-appointments-v1.yaml index 4836b7a3d..df539a96c 100644 --- a/firstleg/src/main/resources/openapi/truck-appointments-v1.yaml +++ b/firstleg/src/main/resources/openapi/truck-appointments-v1.yaml @@ -2,6 +2,7 @@ openapi: 3.0.3 info: title: Truck Appointment API version: 1.1.0 + description: PDA final complete uses the stable containerBookingId to lock the unique active appointment, transition it to COMPLETED and append operator history. No appointment does not block loading completion; multiple active appointments or an optimistic-lock conflict return A0409. paths: /api/platform/v1/truck-appointments: get: diff --git a/firstleg/src/test/java/com/baiyue/firstleg/loading/mapper/LoadingCompletionGateSqlContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/loading/mapper/LoadingCompletionGateSqlContractTest.java index 822daef09..66e311dab 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/loading/mapper/LoadingCompletionGateSqlContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/loading/mapper/LoadingCompletionGateSqlContractTest.java @@ -7,7 +7,7 @@ import static org.assertj.core.api.Assertions.assertThat; class LoadingCompletionGateSqlContractTest { @Test - void requiresEveryLinkedContainerToPassBookingAndTruckGates() throws Exception { + void requiresEveryLinkedContainerToHaveReleasedBookingWithoutTruckPrerequisite() throws Exception { Select select = LoadingCompletionGateMapper.class .getDeclaredMethod("snapshot", long.class, long.class).getAnnotation(Select.class); String sql = String.join(" ", select.value()).replaceAll("\\s+", " "); @@ -17,12 +17,8 @@ class LoadingCompletionGateSqlContractTest { .contains("JOIN fl_loading_plan p ON p.id=l.loading_plan_id") .contains("fl_ocean_booking_container_allocation") .contains("b.status='RELEASED'") - .contains("fl_truck_appointment") - .contains("t.container_booking_id=p.container_booking_id") - .contains("t.status='COMPLETED'") - .doesNotContain("t.loading_plan_id=p.id") + .doesNotContain("fl_truck_appointment", "truckReady", "t.status='COMPLETED'") .contains("l.tenant_id=#{tenantId} AND l.inbound_id=#{inboundId}") - .doesNotContain("truckReady, FROM") .doesNotContain("fl_customs_case", "fl_customs_receipt", "customsReady") .doesNotContain("<", ">"); } diff --git a/firstleg/src/test/java/com/baiyue/firstleg/loading/service/LoadingDeliveryServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/loading/service/LoadingDeliveryServiceTest.java index 3c98722ce..df015021b 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/loading/service/LoadingDeliveryServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/loading/service/LoadingDeliveryServiceTest.java @@ -98,7 +98,7 @@ class LoadingDeliveryServiceTest { LoadingActionResponse result = service.completeLoading(10, 20, 7, "admin", "k1", new CompleteLoadingRequest(3, null)); assertThat(result.mainStatus()).isEqualTo("LOADED"); verify(loadingModelBridge).requireCompleted(10, 20); - verify(completionGate).requireBookingAndTransport(10, 20); + verify(completionGate).requireReleasedBooking(10, 20); verify(histories).insert(any(InboundStatusHistoryEntity.class)); } @@ -119,22 +119,22 @@ class LoadingDeliveryServiceTest { service.requirePdaCompletionReady(10, 20); verify(loadingModelBridge).requireStarted(10, 20); - verify(completionGate).requireBookingAndTransport(10, 20); + verify(completionGate).requireReleasedBooking(10, 20); verify(loadingModelBridge, never()).requireCompleted(anyLong(), anyLong()); verify(repository, never()).completeLoading(anyLong(), anyLong(), anyLong(), anyInt()); verify(repository, never()).claimOperation(anyLong(), anyLong(), anyLong(), anyString(), anyString(), anyString()); } @Test - void pdaCompletionPreflightReportsTruckBlockerBeforeEvidenceOrWrites() { + void pdaCompletionPreflightReportsBookingBlockerBeforeEvidenceOrWrites() { when(repository.findInbound(10, 20)).thenReturn(new Snapshot(20L, 10L, "LOADING", 3)); doThrow(new com.baiyue.common.core.exception.BizException( com.baiyue.common.core.api.ResultCode.CONFLICT, - "LOADED门禁未通过:拖车尚未完成(必须为 COMPLETED)")) - .when(completionGate).requireBookingAndTransport(10, 20); + "LOADED门禁未通过:订舱尚未完成(必须为 RELEASED)")) + .when(completionGate).requireReleasedBooking(10, 20); assertThatThrownBy(() -> service.requirePdaCompletionReady(10, 20)) - .hasMessageContaining("拖车尚未完成"); + .hasMessageContaining("订舱尚未完成"); verify(repository, never()).listEvidence(anyLong(), anyLong()); verify(repository, never()).completeLoading(anyLong(), anyLong(), anyLong(), anyInt()); @@ -170,7 +170,7 @@ class LoadingDeliveryServiceTest { assertThat(result.mainStatus()).isEqualTo("LOADED"); verify(loadingModelBridge).requireCompleted(10, 20); - verify(completionGate).requireBookingAndTransport(10, 20); + verify(completionGate).requireReleasedBooking(10, 20); verify(repository).completeLoadedChild(10, 20, 7, 3); verify(repository).aggregateRootLoaded(10, 20, 7); verify(histories).insert(argThat((InboundStatusHistoryEntity history) -> @@ -229,7 +229,7 @@ class LoadingDeliveryServiceTest { verify(repository).completeLoadedChild(10, 20, 7, 5); verify(repository).aggregateRootLoaded(10, 10, 7); verify(loadingModelBridge).requireCompleted(10, 20); - verify(completionGate).requireBookingAndTransport(10, 20); + verify(completionGate).requireReleasedBooking(10, 20); verify(histories).insert(any(InboundStatusHistoryEntity.class)); verify(repository).completeOperation(10L, 20L, 7L, "COMPLETE_LOADING", "slice-replay", resultJson.get()); } 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 f46fc9132..881be0d51 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,12 @@ class TruckAppointmentMapperContractTest { assertThat(page).contains("fl_truck_appointment", "appointment_no", "vehicle_no", "status=#{q.status}", "customer_id=#{q.customerId}", "container_booking_id=#{q.containerBookingId}"); 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 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'"); + String pdaComplete = TruckAppointmentMapper.class.getMethod("completeLoadingFromPda", long.class, String.class, int.class).getAnnotation(Update.class).value()[0]; + assertThat(pdaComplete).contains("status='COMPLETED'", "actual_loading_at=COALESCE", "status=#{fromStatus}", + "version=#{version}", "revision=revision+1", "version=version+1"); String reschedule = TruckAppointmentMapper.class.getMethod("reschedule", long.class, java.time.LocalDateTime.class, String.class, int.class).getAnnotation(Update.class).value()[0]; assertThat(reschedule).contains("status='RESCHEDULED'", "version=#{version}", "revision=revision+1"); String edit = TruckAppointmentMapper.class.getMethod("updateEdit", TruckAppointmentEntity.class, int.class).getAnnotation(Update.class).value()[0]; 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 2209168ea..7a67d1948 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingPickServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingPickServiceTest.java @@ -70,6 +70,8 @@ class LoadingPickServiceTest { @MockBean private LoadingDeliveryService loadingDeliveryService; @MockBean + private TruckAppointmentService truckAppointmentService; + @MockBean private InboundStatusHistoryMapper inboundHistories; @MockBean private PdaPickSettlementService settlementService; @@ -509,13 +511,14 @@ class LoadingPickServiceTest { verify(settlementService).settle(20L, 9L, "operator"); verify(repository).syncInboundProjections(20L, 9L, "operator"); verify(loadingDeliveryService).requirePdaCompletionReady(101L, 50L); + verify(truckAppointmentService).completeLoadingFromPda(99L, 20L, 9L, "operator"); verify(loadingDeliveryService).completeLoading(eq(101L), eq(50L), eq(9L), eq("operator"), eq("PDA-complete-1-50"), argThat(value -> value.expectedVersion() == 7 && "checked".equals(value.remark()))); } @Test - void truckBlockerLeavesPdaTaskPlanAndSettlementUntouched() { + void conflictingTruckAppointmentsLeavePdaTaskPlanAndSettlementUntouched() { LoadingPickTaskEntity task = task(); when(repository.taskForUpdate(20L)).thenReturn(task); when(repository.allTaskItemsResolved(20L)).thenReturn(true); @@ -525,12 +528,12 @@ class LoadingPickServiceTest { when(repository.inboundForUpdate(101L, 50L)).thenReturn( new LoadingInboundMapper.Snapshot(50L, 101L, "LOADING", 7)); doThrow(new BizException(com.baiyue.common.core.api.ResultCode.CONFLICT, - "LOADED门禁未通过:拖车尚未完成(必须为 COMPLETED)")) - .when(loadingDeliveryService).requirePdaCompletionReady(101L, 50L); + "同一柜次存在多条有效拖车预约,不能确认装柜完成")) + .when(truckAppointmentService).completeLoadingFromPda(99L, 20L, 9L, "operator"); assertThatThrownBy(() -> service.complete(20L, new CompleteLoadingRequest(0, "complete-blocked", "checked"), 9L, "operator")) - .isInstanceOf(BizException.class).hasMessageContaining("拖车尚未完成"); + .isInstanceOf(BizException.class).hasMessageContaining("多条有效拖车预约"); verify(settlementService, never()).settle(anyLong(), anyLong(), anyString()); verify(repository, never()).approve(anyLong(), anyLong(), anyString(), any()); 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 30a8b69a2..6ddee035f 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/service/TruckAppointmentServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/TruckAppointmentServiceTest.java @@ -27,6 +27,7 @@ import com.fasterxml.jackson.databind.json.JsonMapper; import java.lang.reflect.Constructor; import java.time.LocalDateTime; +import java.util.List; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; @@ -224,6 +225,56 @@ class TruckAppointmentServiceTest { assertThat(history.getValue().getToStatus()).isEqualTo("RESCHEDULED"); } + @Test void pdaCompletionDoesNotRequireTruckAppointment() { + when(repository.activeForUpdate(99L)).thenReturn(List.of()); + + service.completeLoadingFromPda(99L, 20L, 7L, "admin"); + + verify(repository, never()).completeLoadingFromPda(anyLong(), anyString(), anyInt()); + verify(repository, never()).history(any()); + } + + @Test void pdaCompletionLocksAndCompletesTheOnlyActiveTruckWithHistory() { + TruckAppointmentEntity before = entity(TruckAppointmentStatus.LOADING, 4); + TruckAppointmentEntity after = entity(TruckAppointmentStatus.COMPLETED, 5); + when(repository.activeForUpdate(99L)).thenReturn(List.of(before)); + when(repository.completeLoadingFromPda(1L, "LOADING", 4)).thenReturn(1); + when(repository.find(1L)).thenReturn(after); + + service.completeLoadingFromPda(99L, 20L, 7L, "admin"); + + verify(repository).completeLoadingFromPda(1L, "LOADING", 4); + ArgumentCaptor history = ArgumentCaptor.forClass(TruckAppointmentHistoryEntity.class); + verify(repository).history(history.capture()); + assertThat(history.getValue().getFromStatus()).isEqualTo("LOADING"); + assertThat(history.getValue().getToStatus()).isEqualTo("COMPLETED"); + assertThat(history.getValue().getAction()).isEqualTo("PDA_COMPLETE_LOADING"); + assertThat(history.getValue().getOperatorId()).isEqualTo(7L); + assertThat(history.getValue().getReason()).contains("20"); + } + + @Test void pdaCompletionRejectsMultipleActiveTruckAppointmentsWithoutMutation() { + when(repository.activeForUpdate(99L)).thenReturn(List.of( + entity(TruckAppointmentStatus.LOADING, 1), entity(TruckAppointmentStatus.DEPARTED, 2))); + + assertThatThrownBy(() -> service.completeLoadingFromPda(99L, 20L, 7L, "admin")) + .isInstanceOf(BizException.class).hasMessageContaining("多条有效拖车预约"); + + verify(repository, never()).completeLoadingFromPda(anyLong(), anyString(), anyInt()); + verify(repository, never()).history(any()); + } + + @Test void pdaCompletionRejectsConcurrentTruckModificationWithoutHistory() { + TruckAppointmentEntity before = entity(TruckAppointmentStatus.RESCHEDULED, 4); + when(repository.activeForUpdate(99L)).thenReturn(List.of(before)); + when(repository.completeLoadingFromPda(1L, "RESCHEDULED", 4)).thenReturn(0); + + assertThatThrownBy(() -> service.completeLoadingFromPda(99L, 20L, 7L, "admin")) + .isInstanceOf(BizException.class).hasMessageContaining("并发修改"); + + verify(repository, never()).history(any()); + } + private String requestHash(String action, long id, Object request) throws Exception { String canonical = action + '|' + id + '|' + objectMapper.writeValueAsString(request); return java.util.HexFormat.of().formatHex(java.security.MessageDigest.getInstance("SHA-256") diff --git a/scripts/admin-test-env-modules/README.md b/scripts/admin-test-env-modules/README.md index fe984b14a..7b55496b2 100644 --- a/scripts/admin-test-env-modules/README.md +++ b/scripts/admin-test-env-modules/README.md @@ -62,11 +62,11 @@ export async function run(context) { 默认 Admin 全量入口已注册并按同一 `context.marker` 串行执行货柜主链。冻结时序为: -`WAIT_SPLIT -> SIMULATION/ACTIVE 内部模拟柜 -> 智能排货 -> 无集装箱号 push-preload -> exact replay/冲突零漂移 -> 订舱逐柜登记 OCEAN_BOOKING/ACTIVE 真实柜 -> 按 realContainerBookingId 原地合并(来源 MERGED,模拟柜身份存活)-> RELEASED -> 计划级原子确认 -> 唯一 PDA task -> claim 后 LOADING -> 原子批量拣货 -> AWAITING_REVIEW -> 五类证据 -> LOADING 阶段增量入库单/旧 inventory token A0409/刷新后续拣 -> 拖车与报关清关门禁 -> PDA task complete 同事务完成 task/plan/inbound -> LOADED`。 +`WAIT_SPLIT -> SIMULATION/ACTIVE 内部模拟柜 -> 智能排货 -> 无集装箱号 push-preload -> exact replay/冲突零漂移 -> 订舱逐柜登记 OCEAN_BOOKING/ACTIVE 真实柜 -> 按 realContainerBookingId 原地合并(来源 MERGED,模拟柜身份存活)-> RELEASED -> 计划级原子确认 -> 唯一 PDA task -> claim 后 LOADING -> 原子批量拣货 -> AWAITING_REVIEW -> 五类证据 -> LOADING 阶段增量入库单/旧 inventory token A0409/刷新后续拣 -> PDA task complete 联动同柜有效拖车(如有)并同事务完成 task/plan/inbound -> LOADED`。 其中,无号 push 只冻结装柜计划、建立 bridge 并使预装可见,不得创建 PDA task;真实柜号由合并动作从未消费的订舱真实柜带入,合并前、订舱未 `RELEASED` 或整份计划未确认时,PDA 查询必须返回业务码 `A0404`。合并后正常列表只保留模拟柜原地转成的一个 ACTIVE 操作身份,PDA 禁止使用 MERGED 来源 ID。唯一确认入口为 `POST /api/platform/v1/first-leg/loading-plans/{loadingPlanId}/confirm`,请求体只含当前计划 `expectedVersion` 并携带 `Idempotency-Key`;成功必须返回 `confirmationStatus=CONFIRMED`、`readiness.readyForPda=true` 和唯一 `readiness.pdaTaskId`。验收脚本禁止调用旧入库单级 `/preload/confirming`、`/preload/confirm`。 -计划在 `EDITING/LOADING` 可通过正式 assignments/PATCH/DELETE 端点增量;计划、任务和明细版本必须递增,PDA 四元身份不变,旧 `inventorySnapshotToken` 必须返回 `A0409 库存已变化,请刷新后重试`。已拣项不可删或减量,未拣项可删;增量会把全拣的 `AWAITING_REVIEW` 撤回为 `IN_PROGRESS`,刷新 task detail 与 inventory 后续拣才可再次等待最终确认。最终唯一完成入口为 `POST /api/platform/v1/first-leg/pda-loading/tasks/{taskId}/complete`:服务端预检同柜 `RELEASED` 订舱、`COMPLETED` 拖车、五类 ACTIVE 证据和 bridge,缺项返回 A0409 且 task/plan/inbound 零写入;全部满足后同一事务推进 `LOADED`。后续模块必须复用 `phase3Loading` 发布的本轮 `taskId + inboundId + loadingPlanId + containerBookingId`,不得回退历史资源。 +计划在 `EDITING/LOADING` 可通过正式 assignments/PATCH/DELETE 端点增量;计划、任务和明细版本必须递增,PDA 四元身份不变,旧 `inventorySnapshotToken` 必须返回 `A0409 库存已变化,请刷新后重试`。已拣项不可删或减量,未拣项可删;增量会把全拣的 `AWAITING_REVIEW` 撤回为 `IN_PROGRESS`,刷新 task detail 与 inventory 后续拣才可再次等待最终确认。最终唯一完成入口仍为 `POST /api/platform/v1/first-leg/pda-loading/tasks/{taskId}/complete`:保留同柜 `RELEASED` 订舱、bridge 与五类 ACTIVE 证据门禁;拖车缺失或未完成不得阻断,若稳定柜次有关联有效拖车,则该业务动作负责将其推进到“已完成装柜”。当前后端 OpenAPI 仍写“预先 COMPLETED 拖车”,验收 finalize 已 `BLOCKED_CONTRACT` 主动停止,待新响应/历史/回滚字段冻结后接线。后续模块必须复用 `phase3Loading` 发布的本轮 `taskId + inboundId + loadingPlanId + containerBookingId`,不得回退历史资源。 部署后需要定向验证该完整链时,模块顺序固定为: diff --git a/scripts/admin-test-env-modules/phase3-customs.mjs b/scripts/admin-test-env-modules/phase3-customs.mjs index 32c7b9049..c63b81ac2 100644 --- a/scripts/admin-test-env-modules/phase3-customs.mjs +++ b/scripts/admin-test-env-modules/phase3-customs.mjs @@ -85,7 +85,7 @@ export function selectCustomsFlow(createdResources) { return { fixture: null, container: null } } -async function ensureServiceCenterFlow(context, container) { +async function ensureReleasedOceanBooking(context, container) { const containerBookingId = id(container?.containerBookingId) const loadingPlanId = id(container?.loadingPlanId) let ocean = context.createdResources?.oceanBooking @@ -104,22 +104,7 @@ async function ensureServiceCenterFlow(context, container) { return false } - let truck = context.createdResources?.serviceCenterFlow - if (truck?.finalStatus !== 'COMPLETED' - || id(truck?.oceanBookingId) !== id(ocean.id) - || id(truck?.containerBookingId) !== containerBookingId - || id(truck?.loadingPlanId) !== loadingPlanId) { - const serviceCenter = await import(new URL('./service-center-flow.mjs', import.meta.url)) - await serviceCenter.run(context) - truck = context.createdResources?.serviceCenterFlow - } - const ready = truck?.finalStatus === 'COMPLETED' - && id(truck?.oceanBookingId) === id(ocean.id) - && id(truck?.containerBookingId) === containerBookingId - && id(truck?.loadingPlanId) === loadingPlanId - context.addStep(MODULE, '报关前拖车已完成并绑定实际装柜柜次', null, ready, - `truckStatus=${truck?.finalStatus || 'missing'}, truckContainer=${id(truck?.containerBookingId) || 'missing'}, containerBookingId=${containerBookingId || 'missing'}`, OPTIONS) - return ready + return true } export async function run(context) { @@ -137,7 +122,7 @@ export async function run(context) { `tenantId=${tenantId || 'missing'}, parentOrderId=${parentOrderId || 'missing'}, inboundId=${inboundId || 'missing'}, containerBookingId=${containerBookingId || 'missing'}, loadingPlanId=${loadingPlanId || 'missing'}, containerStatus=${container?.finalStatus || 'missing'}`, OPTIONS) return } - if (!await ensureServiceCenterFlow(context, container)) return + if (!await ensureReleasedOceanBooking(context, container)) return const slicesPath = `/api/platform/v1/orders/container-bookings/${encodeURIComponent(containerBookingId)}/fulfillment-slices?tenantId=${encodeURIComponent(tenantId)}` const slicesResponse = await context.request('GET', slicesPath) diff --git a/scripts/admin-test-env-modules/phase3-loading-dynamic-final-contract.test.mjs b/scripts/admin-test-env-modules/phase3-loading-dynamic-final-contract.test.mjs index c240c0cc9..94e627f67 100644 --- a/scripts/admin-test-env-modules/phase3-loading-dynamic-final-contract.test.mjs +++ b/scripts/admin-test-env-modules/phase3-loading-dynamic-final-contract.test.mjs @@ -73,32 +73,25 @@ test('HTTP chain refreshes after stale inventory and keeps one stable PDA identi assert.doesNotMatch(loading, /\/loading\/complete\?tenantId=/) }) -test('downstream modules keep the task pending until the transactional PDA final complete', () => { +test('downstream modules stop before HTTP until the new truck-coupled complete contract freezes', () => { assert.match(convergence, /summary\?\.status === 'LOADING'/) assert.match(convergence, /!operations\.some\(\(item\) => item\.actionType === 'COMPLETE_LOADING'\)/) assert.match(customs, /\['AWAITING_REVIEW', 'LOADED'\]/) + assert.match(customs, /ensureReleasedOceanBooking/) + assert.doesNotMatch(customs, /truck\?\.finalStatus !== 'COMPLETED'/) ordered(finalize, [ - '最终完成前拖车与报关清关门禁属于同一稳定柜次', - '最终完成前四元身份稳定且任务等待人工确认', - '读取最终完成并发前计划即时版本', - 'Admin 增量与 PDA final complete 并发恰好一个事务成功', - '订舱拖车报关齐备后完成装柜', - '统一硬门禁通过后 task 与 workflow 同事务完成且身份不漂移', - '入库单终态 LOADED、版本递增且审计历史存在', - '统一硬门禁通过后五类有效证据仍完整', - 'COMPLETED 后新增计划明细被拒绝', - 'COMPLETED 后调整计划明细被拒绝', - 'COMPLETED 后删除计划明细被拒绝', + '最终装柜前保持 LOADING 且版本有效', + 'PDA complete 联动拖车并原子推进 LOADED', + 'return', + 'const taskPath =', ]) - assert.match(finalize, /`\$\{taskPath\}\/complete`/) - assert.match(finalize, /await Promise\.all\(/) - assert.match(finalize, /completeWon !== adjustWon/) + assert.match(finalize, /BLOCKED_CONTRACT:用户已确认拖车缺失或未完成不再阻断/) + assert.doesNotMatch(finalize, /truck\?\.finalStatus === 'COMPLETED'/) assert.doesNotMatch(finalize, /\/loading\/complete\?tenantId=/) }) -test('PDA final completion OpenAPI freezes zero-write gate semantics', () => { +test('current PDA complete OpenAPI remains a blocker because it still requires pre-completed truck', () => { assert.match(pdaOpenApi, /\/api\/platform\/v1\/first-leg\/pda-loading\/tasks\/\{taskId\}\/complete:/) assert.match(pdaOpenApi, /先无写入校验全部关联入库单的RELEASED订舱、COMPLETED拖车和五类ACTIVE证据/) - assert.match(pdaOpenApi, /任何阻断返回A0409且task、plan、inbound仍保持LOADING/) - assert.match(pdaOpenApi, /全部满足后在同一事务内结算资源、完成task\/plan并推进inbound为LOADED/) + assert.match(finalize, /BLOCKED_CONTRACT/) }) diff --git a/scripts/admin-test-env-modules/phase3-loading-finalize.mjs b/scripts/admin-test-env-modules/phase3-loading-finalize.mjs index 0f7c52c5d..403ee9052 100644 --- a/scripts/admin-test-env-modules/phase3-loading-finalize.mjs +++ b/scripts/admin-test-env-modules/phase3-loading-finalize.mjs @@ -69,14 +69,11 @@ export async function run(context) { if (before?.summary?.processStatus !== 'LOADING' || !Number.isInteger(version)) return const base = `/api/platform/v1/inbounds/${inboundId}` - const truck = context.createdResources?.serviceCenterFlow - const customs = context.createdResources?.phase3Customs - context.assert(MODULE, '最终完成前拖车与报关清关门禁属于同一稳定柜次', beforeResponse, - truck?.marker === context.marker && truck?.finalStatus === 'COMPLETED' - && id(truck?.containerBookingId) === containerBookingId - && customs?.marker === context.marker && customs?.finalStatus === 'CLEARANCE_COMPLETED' - && id(customs?.containerBookingId) === containerBookingId, - `truck=${truck?.finalStatus || 'missing'}, customs=${customs?.finalStatus || 'missing'}, containerBookingId=${containerBookingId}`, OPTIONS) + context.addCoverageGap(MODULE, OPTIONS.scenario, 'PDA complete 联动拖车并原子推进 LOADED', + 'BLOCKED_CONTRACT:用户已确认拖车缺失或未完成不再阻断;等待后端冻结 complete 响应中的拖车投影、LOADING→COMPLETED 历史动作、错柜隔离、重放与失败回滚字段后再执行写 HTTP。当前脚本主动停止,禁止沿用“拖车必须预先 COMPLETED”的旧门禁。', + 'contract') + return + const taskPath = `/api/platform/v1/first-leg/pda-loading/tasks/${encodeURIComponent(taskId)}` const taskBeforeResponse = await context.request('GET', taskPath) const taskBefore = context.apiSuccess(MODULE, '读取最终完成前 PDA 任务即时版本', taskBeforeResponse, '', OPTIONS) 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 f0674d612..ef14abbff 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 @@ -460,15 +460,16 @@ test('phase3 loading fixture forces A0 through reversible public location status assert.match(orders, /receivingWarehouseId: id\(master\.receiving\.id\)/) }) -test('customs provisions released booking and completed truck for the selected loading container', () => { +test('customs provisions released booking without pre-completing truck', () => { const value = source('./phase3-customs.mjs') assertOrder(value, [ 'selectCustomsFlow(context.createdResources)', - 'if (!await ensureServiceCenterFlow(context, container)) return', + 'if (!await ensureReleasedOceanBooking(context, container)) return', 'const slicesPath =', ]) assert.match(value, /oceanBookings\.run\(context\)/) - assert.match(value, /serviceCenter\.run\(context\)/) + assert.doesNotMatch(value, /serviceCenter\.run\(context\)/) + assert.doesNotMatch(value, /truck\?\.finalStatus !== 'COMPLETED'/) assert.match(value, /request\('POST', '\/api\/platform\/v1\/suppliers\?tenantId=0'/) assert.match(value, /category: 'CUSTOMS'/) assert.match(value, /context\.createdResources\.phase3CustomsSupplier/) diff --git a/scripts/full-regression-entry-contract.test.mjs b/scripts/full-regression-entry-contract.test.mjs index b389da1c1..b42a99ade 100644 --- a/scripts/full-regression-entry-contract.test.mjs +++ b/scripts/full-regression-entry-contract.test.mjs @@ -102,7 +102,7 @@ test('test-environment loading chains push without a number and open PDA only af assert.doesNotMatch(containerLoading, /\/preload\/confirming|\/preload\/confirm\?tenantId=/) }) -test('default Admin HTTP entry carries one marker-isolated loading chain through evidence and LOADED', () => { +test('default Admin HTTP entry carries evidence chain but fail-closes before obsolete truck completion semantics', () => { const focused = normalizeModuleOrder([ 'merchant-qualification-fixture', 'orders', 'inbound-measurement', 'phase3-container-loading', 'phase3-loading', 'phase3-container-overview', @@ -130,9 +130,10 @@ test('default Admin HTTP entry carries one marker-isolated loading chain through assert.match(evidenceGate, /taskId[\s\S]*inboundId[\s\S]*loadingPlanId[\s\S]*containerBookingId/) assert.match(customs, /createdResources\?\.phase3Loading[\s\S]*container\?\.finalStatus/) assert.match(finalize, /fixture\?\.marker !== context\.marker/) - assert.match(finalize, /统一硬门禁通过后 task 与 workflow 同事务完成且身份不漂移/) - assert.match(finalize, /统一硬门禁通过后五类有效证据仍完整/) - assert.match(finalize, /统一订单详情投影同一订单为 LOADED[\s\S]*统一订单列表投影同一订单为 LOADED/) + assert.match(finalize, /BLOCKED_CONTRACT:用户已确认拖车缺失或未完成不再阻断/) + assert.ok(finalize.indexOf('PDA complete 联动拖车并原子推进 LOADED') + < finalize.indexOf('const taskPath =')) + assert.doesNotMatch(finalize, /truck\?\.finalStatus === 'COMPLETED'/) }) test('Finance-only HTTP entry expands the completed-container producer prerequisites', () => {