This commit is contained in:
2026-08-17 15:55:11 +08:00
parent 5f79616623
commit 68d9216cb8
21 changed files with 121 additions and 85 deletions
@@ -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; }
}
}
@@ -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) {
@@ -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,
@@ -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<TruckAppointmentEntity>{
@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<TruckAppointmentEntity> activeForUpdate(@Param("containerBookingId") long containerBookingId);
@Select("<script>SELECT * FROM fl_truck_appointment WHERE (#{q.keyword} IS NULL OR appointment_no LIKE CONCAT('%',#{q.keyword},'%') OR vehicle_no LIKE CONCAT('%',#{q.keyword},'%') OR carrier_name LIKE CONCAT('%',#{q.keyword},'%')) AND (#{q.status} IS NULL OR status=#{q.status}) AND (#{q.customerId} IS NULL OR customer_id=#{q.customerId}) AND (#{q.containerBookingId} IS NULL OR container_booking_id=#{q.containerBookingId}) ORDER BY updated_at DESC,id DESC</script>") Page<TruckAppointmentEntity> page(Page<TruckAppointmentEntity> 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);
}
@@ -4,4 +4,5 @@ import com.baomidou.mybatisplus.extension.plugins.pagination.Page; import com.ba
public PageResult<TruckAppointmentEntity> 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<TruckAppointmentHistoryEntity> histories(long id){return history.selectList(new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper<TruckAppointmentHistoryEntity>().eq(TruckAppointmentHistoryEntity::getAppointmentId,id).orderByAsc(TruckAppointmentHistoryEntity::getCreatedAt));} public java.util.List<TruckAppointmentAttachmentEntity> attachments(long id){return attachments.selectList(new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper<TruckAppointmentAttachmentEntity>().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<TruckAppointmentAttachmentEntity>().eq(TruckAppointmentAttachmentEntity::getAppointmentId,appointmentId).eq(TruckAppointmentAttachmentEntity::getId,attachmentId));}
public java.util.List<TruckAppointmentEntity> activeForUpdate(long containerBookingId){return mapper.activeForUpdate(containerBookingId);} public int completeLoadingFromPda(long id,String fromStatus,int version){return mapper.completeLoadingFromPda(id,fromStatus,version);}
}
@@ -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拣货的箱数");
@@ -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<TruckAppointmentEntity> 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);}
@@ -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
@@ -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
@@ -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:
@@ -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("&lt;", "&gt;");
}
@@ -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());
}
@@ -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];
@@ -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());
@@ -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<TruckAppointmentHistoryEntity> 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")