fix(firstleg): scope truck appointments by booking

This commit is contained in:
2026-09-01 16:21:27 +08:00
parent ca8127ce83
commit ecc83ed64a
20 changed files with 176 additions and 591 deletions
@@ -1,10 +1,10 @@
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 Query(@Size(max=64) String keyword, com.baiyue.firstleg.domain.TruckAppointmentStatus status, @Positive Long oceanBookingId, @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 Candidate(Long oceanBookingId,String oceanBookingNo,String billOfLadingNo,Long warehouseId,String warehouseName,String channelCode,String channelName,Boolean bookingDocumentReady){}
public record SaveRequest(@Positive Long outboundOrderId,@NotNull @Positive Long oceanBookingId,@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 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){ }
}
@@ -9,6 +9,7 @@ import lombok.Setter;
import java.math.BigDecimal;
import java.time.LocalDateTime;
import com.baiyue.firstleg.domain.TruckAppointmentStatus;
import com.fasterxml.jackson.annotation.JsonIgnore;
@Getter @Setter @TableName("fl_truck_appointment")
public class TruckAppointmentEntity {
@@ -16,8 +17,8 @@ public class TruckAppointmentEntity {
private String appointmentNo;
private Long outboundOrderId;
private Long oceanBookingId;
private Long containerBookingId;
private Long loadingPlanId;
@JsonIgnore private Long containerBookingId;
@JsonIgnore private Long loadingPlanId;
private Long warehouseId;
private Long carrierId;
private String carrierName;
@@ -58,7 +59,7 @@ public class TruckAppointmentEntity {
@TableField(exist = false) private String shippingCompanyName;
@TableField(exist = false) private Long bookingAgentId;
@TableField(exist = false) private String bookingAgentName;
@TableField(exist = false) private String cabinetNo;
@TableField(exist = false) private String containerNo;
@JsonIgnore @TableField(exist = false) private String cabinetNo;
@JsonIgnore @TableField(exist = false) private String containerNo;
@TableField(exist = false) private Long attachmentCount;
}
@@ -5,34 +5,22 @@ import com.baomidou.mybatisplus.core.mapper.BaseMapper; import com.baomidou.myba
<script>
SELECT o.id oceanBookingId,o.booking_no oceanBookingNo,o.bill_of_lading_no billOfLadingNo,
o.warehouse_id warehouseId,o.warehouse_name warehouseName,o.channel_code channelCode,
o.channel_name channelName,r.id requirementId,r.container_type containerType,
r.quantity requirementQuantity,c.id containerBookingId,c.cabinet_no cabinetNo,
c.container_no containerNo,p.id loadingPlanId,p.plan_no loadingPlanNo,
p.plan_status loadingPlanStatus,TRUE bookingDocumentReady
FROM fl_ocean_booking_container_allocation a
JOIN fl_ocean_booking o ON o.id=a.ocean_booking_id AND o.status='RELEASED'
JOIN fl_ocean_booking_container_requirement r ON r.id=a.requirement_id AND r.ocean_booking_id=o.id
JOIN fl_container_booking c ON c.id=a.container_booking_id AND c.ocean_booking_id=o.id AND c.record_status='ACTIVE'
LEFT JOIN fl_loading_plan p ON p.container_booking_id=c.id
o.channel_name channelName,TRUE bookingDocumentReady
FROM fl_ocean_booking o
WHERE EXISTS (SELECT 1 FROM fl_ocean_booking_document d WHERE d.ocean_booking_id=o.id
AND d.valid=1 AND d.document_type IN ('BOOKING_CONFIRMATION','BILL_OF_LADING'))
AND (p.id IS NULL OR p.plan_status&lt;&gt;'COMPLETED')
AND NOT EXISTS (SELECT 1 FROM fl_truck_appointment t WHERE t.container_booking_id=c.id
AND o.status='RELEASED' AND o.bill_of_lading_no IS NOT NULL AND o.bill_of_lading_no&lt;&gt;''
AND NOT EXISTS (SELECT 1 FROM fl_truck_appointment t WHERE t.ocean_booking_id=o.id
AND t.status NOT IN ('COMPLETED','CANCELLED','EXCEPTION'))
AND (#{q.keyword} IS NULL OR o.booking_no LIKE CONCAT('%',#{q.keyword},'%')
OR o.bill_of_lading_no LIKE CONCAT('%',#{q.keyword},'%')
OR c.cabinet_no LIKE CONCAT('%',#{q.keyword},'%')
OR c.container_no LIKE CONCAT('%',#{q.keyword},'%'))
ORDER BY o.updated_at DESC,o.id DESC,c.updated_at DESC,c.id DESC
OR o.bill_of_lading_no LIKE CONCAT('%',#{q.keyword},'%'))
ORDER BY o.updated_at DESC,o.id DESC
</script>
""") Page<Candidate> candidates(Page<Candidate> 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<TruckAppointmentEntity> activeForUpdate(@Param("containerBookingId") long containerBookingId);
@Select("<script>SELECT a.*,o.booking_no ocean_booking_no,COALESCE(o.warehouse_name,c.warehouse_name) warehouse_name,o.shipping_company_id,o.shipping_company_name,o.booking_agent_id,o.booking_agent_name,c.cabinet_no,c.container_no,(SELECT COUNT(*) FROM fl_truck_appointment_attachment x WHERE x.appointment_id=a.id) attachment_count FROM fl_truck_appointment a LEFT JOIN fl_ocean_booking o ON o.id=a.ocean_booking_id LEFT JOIN fl_container_booking c ON c.id=a.container_booking_id AND c.record_status='ACTIVE' WHERE (#{q.keyword} IS NULL OR a.appointment_no LIKE CONCAT('%',#{q.keyword},'%') OR a.vehicle_no LIKE CONCAT('%',#{q.keyword},'%') OR a.carrier_name LIKE CONCAT('%',#{q.keyword},'%')) AND (#{q.status} IS NULL OR a.status=#{q.status}) AND (#{q.containerBookingId} IS NULL OR a.container_booking_id=#{q.containerBookingId}) ORDER BY a.updated_at DESC,a.id DESC</script>") Page<TruckAppointmentEntity> page(Page<TruckAppointmentEntity> p,@Param("q") Query q);
@Select("SELECT * FROM fl_truck_appointment WHERE ocean_booking_id=#{oceanBookingId} AND status NOT IN ('COMPLETED','CANCELLED','EXCEPTION') ORDER BY created_at,id LIMIT 1 FOR UPDATE") TruckAppointmentEntity firstActiveForUpdate(@Param("oceanBookingId") long oceanBookingId);
@Select("<script>SELECT a.*,o.booking_no ocean_booking_no,o.warehouse_name warehouse_name,o.shipping_company_id,o.shipping_company_name,o.booking_agent_id,o.booking_agent_name,(SELECT COUNT(*) FROM fl_truck_appointment_attachment x WHERE x.appointment_id=a.id) attachment_count FROM fl_truck_appointment a LEFT JOIN fl_ocean_booking o ON o.id=a.ocean_booking_id WHERE (#{q.keyword} IS NULL OR a.appointment_no LIKE CONCAT('%',#{q.keyword},'%') OR o.booking_no LIKE CONCAT('%',#{q.keyword},'%') OR a.vehicle_no LIKE CONCAT('%',#{q.keyword},'%') OR a.carrier_name LIKE CONCAT('%',#{q.keyword},'%')) AND (#{q.status} IS NULL OR a.status=#{q.status}) AND (#{q.oceanBookingId} IS NULL OR a.ocean_booking_id=#{q.oceanBookingId}) ORDER BY a.updated_at DESC,a.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='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);
@Update("UPDATE fl_truck_appointment SET outbound_order_id=#{e.outboundOrderId},ocean_booking_id=#{e.oceanBookingId},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);
}
@@ -56,11 +56,14 @@ public interface OutboundDispatchReadMapper {
OceanBinding ocean(@Param("containerBookingId") long containerBookingId);
@Select("""
SELECT id,status FROM fl_truck_appointment
WHERE container_booking_id=#{containerBookingId} AND loading_plan_id=#{loadingPlanId}
ORDER BY updated_at DESC,id DESC LIMIT 1
SELECT appointment.id,appointment.status
FROM fl_truck_appointment appointment
JOIN fl_ocean_booking_container_allocation allocation
ON allocation.ocean_booking_id=appointment.ocean_booking_id
WHERE allocation.container_booking_id=#{containerBookingId}
ORDER BY appointment.updated_at DESC,appointment.id DESC LIMIT 1
""")
IdStatus truck(@Param("containerBookingId") long containerBookingId, @Param("loadingPlanId") long loadingPlanId);
IdStatus truck(@Param("containerBookingId") long containerBookingId);
@Select("""
SELECT COUNT(DISTINCT CASE WHEN evidence.status='ACTIVE' AND evidence.file_id IS NOT NULL
@@ -27,8 +27,8 @@ public class OutboundDispatchRepository {
}
public OutboundDispatchReadMapper.IdStatus pick(long planId) { return reads.pick(planId); }
public OutboundDispatchReadMapper.OceanBinding ocean(long containerId) { return reads.ocean(containerId); }
public OutboundDispatchReadMapper.IdStatus truck(long containerId, long planId) {
return reads.truck(containerId, planId);
public OutboundDispatchReadMapper.IdStatus truck(long containerId) {
return reads.truck(containerId);
}
public OutboundDispatchReadMapper.Evidence evidence(long containerId, long loadingPlanId) {
return reads.evidence(containerId, loadingPlanId);
@@ -151,7 +151,7 @@ public class OutboundDispatchService {
OutboundDispatchReadMapper.Binding first = validateContainer(bindings);
var pick = repository.pick(first.loadingPlanId());
var ocean = repository.ocean(first.containerBookingId());
var truck = repository.truck(first.containerBookingId(), first.loadingPlanId());
var truck = repository.truck(first.containerBookingId());
var evidence = repository.evidence(first.containerBookingId(), first.loadingPlanId());
List<GateItem> containerContributorGates = contributorGates(first, ocean);
List<InboundGate> inboundGates = new ArrayList<>();
@@ -1,8 +1,8 @@
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.*; 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<TruckAppointmentEntity> page(Query q){return PageResult.from(mapper.page(new Page<>(q.page(),q.size()),q));} public PageResult<Candidate> 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 PageResult<TruckAppointmentEntity> page(Query q){return PageResult.from(mapper.page(new Page<>(q.page(),q.size()),q));} public PageResult<Candidate> 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 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 TruckAppointmentEntity firstActiveForUpdate(long containerBookingId){return mapper.firstActiveForUpdate(containerBookingId);} 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);}
public TruckAppointmentEntity firstActiveForUpdate(long oceanBookingId){return mapper.firstActiveForUpdate(oceanBookingId);}
}
@@ -54,8 +54,6 @@ import feign.FeignException;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.dao.DuplicateKeyException;
import org.springframework.stereotype.Service;
import org.springframework.transaction.support.TransactionSynchronization;
import org.springframework.transaction.support.TransactionSynchronizationManager;
import org.springframework.transaction.support.TransactionTemplate;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.util.StringUtils;
@@ -105,8 +103,6 @@ public class LoadingPickService {
@Resource
private ColoadGroupAssignmentPort coloadGroups;
@Resource
private TruckAppointmentService truckAppointmentService;
@Resource
private InboundStatusHistoryMapper inboundHistories;
@Resource
private PdaPickSettlementService settlementService;
@@ -581,7 +577,6 @@ public class LoadingPickService {
}
}
completionTransaction.executeWithoutResult(ignored -> finalizeCompletion(command, worker, task, taskGroups));
completeTruckAfterCommit(task.getContainerBookingId(), taskId, command.getOperatorId(), command.getOperatorName());
}
/** Final Firstleg convergence only; all remote owner settlement calls have completed before this short transaction. */
@@ -701,27 +696,6 @@ public class LoadingPickService {
}
}
private void completeTruckAfterCommit(long containerBookingId, long taskId,
long operatorId, String operatorName) {
Runnable completion = () -> {
try {
truckAppointmentService.completeLoadingFromPda(containerBookingId, taskId,
operatorId, operatorName);
} catch (RuntimeException exception) {
log.warn("PDA loading completed but linked truck appointment was not auto-completed: "
+ "containerBookingId={}, taskId={}, reason={}", containerBookingId, taskId,
exception.getMessage());
}
};
if (TransactionSynchronizationManager.isSynchronizationActive()) {
TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() {
@Override public void afterCommit() { completion.run(); }
});
} else {
completion.run();
}
}
public List<Operation> operations(long taskId) {
requiredTask(taskId);
return repository.operations(taskId).stream().map(item -> new Operation(item.getId(), item.getPickTaskId(),
@@ -5,22 +5,18 @@ import com.baiyue.common.core.api.PageResult;
import com.baiyue.common.core.api.ResultCode;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.firstleg.domain.TruckAppointmentStatus;
import com.baiyue.firstleg.domain.Iso6346ContainerNumber;
import com.baiyue.firstleg.dto.TruckAppointmentDtos.*;
import com.baiyue.firstleg.entity.*;
import com.baiyue.firstleg.oceanbooking.entity.OceanBookingEntity;
import com.baiyue.firstleg.oceanbooking.repository.OceanBookingRepository;
import com.baiyue.firstleg.oceanbooking.service.OceanBookingDocumentService;
import com.baiyue.firstleg.repository.TruckAppointmentRepository;
import com.baiyue.firstleg.repository.SmartLoadingRepository;
import com.baiyue.firstleg.repository.ContainerRepository;
import com.baiyue.firstleg.client.TruckAppointmentFileClient;
import jakarta.annotation.Resource;
import feign.FeignException;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.transaction.annotation.Propagation;
import org.springframework.dao.DuplicateKeyException;
import org.springframework.util.StringUtils;
import com.fasterxml.jackson.core.JsonProcessingException;
@@ -40,46 +36,35 @@ public class TruckAppointmentService {
@Resource TruckAppointmentRepository repo;
@Resource OceanBookingRepository oceanBookings;
@Resource OceanBookingDocumentService oceanBookingDocuments;
@Resource SmartLoadingRepository loadingPlans;
@Resource ContainerRepository containers;
@Resource TruckAppointmentFileClient files;
@Resource WarehouseSupplierSnapshotService suppliers;
@Resource FirstlegDocumentNumberGenerator documentNumbers;
@Resource ObjectMapper objectMapper;
@Value("${baiyue.security.internal-service-token}") String internalToken;
public PageResult<TruckAppointmentEntity> page(Query q){return repo.page(q);} public PageResult<Candidate> candidates(CandidateQuery q){return repo.candidates(q);} public TruckAppointmentEntity detail(long id){return enrich(required(id));}
public PageResult<TruckAppointmentEntity> page(Query q){PageResult<TruckAppointmentEntity> page=repo.page(q);return new PageResult<>(page.records().stream().map(this::enrich).toList(),page.page(),page.size(),page.total(),page.pages());} public PageResult<Candidate> candidates(CandidateQuery q){return repo.candidates(q);} public TruckAppointmentEntity detail(long id){return enrich(required(id));}
public java.util.List<TruckAppointmentHistoryEntity> history(long id){required(id);return repo.histories(id);} public java.util.List<TruckAppointmentAttachmentEntity> 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;}
@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("改约请使用专用接口");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<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);}
@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.oceanBookingId());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_ocean_booking"))throw existingAppointmentConflict(repo.firstActiveForUpdate(r.oceanBookingId()));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("当前状态不可编辑");TruckAppointmentEntity active=repo.firstActiveForUpdate(r.oceanBookingId());if(active!=null&&!Objects.equals(active.getId(),e.getId()))throw existingAppointmentConflict(active);AssociationContext links=validateAssociations(r);var carrier=suppliers.trucking(r.carrierId(),r.carrierName());copy(e,r,links,carrier.name());try{if(repo.updateEdit(e,r.expectedVersion())!=1)throw conflict("版本已变化,请刷新后重试");}catch(DuplicateKeyException exception){if(exception.getMessage()!=null&&exception.getMessage().contains("uk_truck_active_ocean_booking"))throw existingAppointmentConflict(repo.firstActiveForUpdate(r.oceanBookingId()));throw exception;}record(e,e.getStatus().name(),e.getStatus().name(),"EDIT",null,uid,name);return enrich(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=enrich(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=enrich(required(id));record(after,from,"RESCHEDULED",action,r.reason(),uid,name);return completeOperation(id,action,idem,after);}
private AssociationContext validateAssociations(SaveRequest r){
OceanBookingEntity booking=oceanBookings.find(r.oceanBookingId());
if(booking==null)throw new BizException(ResultCode.NOT_FOUND,"关联订舱不存在");
if(!"RELEASED".equals(booking.getStatus()))throw conflict("拖车预约只能选择已放舱订舱");
Long warehouseId=mergeWarehouse(r.warehouseId(),booking.getWarehouseId(),"订舱");
String billOfLadingNo=releasedBookingBill(booking,trim(r.billOfLadingNo()));
ContainerBookingEntity container=containers.find(r.containerBookingId());
if(container==null||!"ACTIVE".equals(container.getRecordStatus()))throw new BizException(ResultCode.NOT_FOUND,"关联有效柜次不存在");
warehouseId=mergeWarehouse(warehouseId,container.getWarehouseId(),"柜次");
if(!Objects.equals(container.getOceanBookingId(),booking.getId()))throw bad("柜次未关联至所选订舱");
long containerId=container.getId();
boolean allocated=oceanBookings.allocations(booking.getId()).stream().anyMatch(a->Objects.equals(a.getContainerBookingId(),containerId));
if(!allocated)throw bad("containerBookingId未分配至该订舱");
oceanBookingDocuments.requireActiveValidDocument(booking);
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);
return new AssociationContext(booking,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不合法");}
private static void validateRequired(SaveRequest r){if(r.oceanBookingId()==null||r.oceanBookingId()<=0)throw bad("oceanBookingId不能为空");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不合法");}
private static Long mergeWarehouse(Long requested,Long linked,String source){if(requested!=null&&linked!=null&&!Objects.equals(requested,linked))throw bad("warehouseId与"+source+"不一致");return requested!=null?requested:linked;}
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);}
private TruckAppointmentEntity enrich(TruckAppointmentEntity value){if(value.getOceanBookingId()!=null){OceanBookingEntity booking=oceanBookings.find(value.getOceanBookingId());if(booking!=null){value.setOceanBookingNo(booking.getBookingNo());value.setWarehouseName(booking.getWarehouseName());value.setShippingCompanyId(booking.getShippingCompanyId());value.setShippingCompanyName(booking.getShippingCompanyName());value.setBookingAgentId(booking.getBookingAgentId());value.setBookingAgentName(booking.getBookingAgentName());}}if(value.getContainerBookingId()!=null){ContainerBookingEntity container=containers.find(value.getContainerBookingId());if(container!=null){value.setCabinetNo(container.getCabinetNo());value.setContainerNo(container.getContainerNo());if(!StringUtils.hasText(value.getWarehouseName()))value.setWarehouseName(container.getWarehouseName());}}value.setAttachmentCount((long)repo.attachments(value.getId()).size());return value;}
private void copy(TruckAppointmentEntity e,SaveRequest r,AssociationContext links,String carrierName){e.setOutboundOrderId(r.outboundOrderId());e.setOceanBookingId(r.oceanBookingId());e.setContainerBookingId(r.containerBookingId());e.setLoadingPlanId(r.loadingPlanId());e.setWarehouseId(links.warehouseId());e.setCarrierId(r.carrierId());e.setCarrierName(carrierName);e.setBillOfLadingNo(links.billOfLadingNo());e.setFleetContact(r.fleetContact());e.setFleetPhone(r.fleetPhone());e.setDriverName(r.driverName());e.setDriverPhone(r.driverPhone());e.setDriverIdCard(r.driverIdCard());e.setVehicleNo(r.vehicleNo());e.setStoreName(r.storeName());e.setStoreContact(r.storeContact());e.setStorePhone(r.storePhone());e.setStoreAddress(r.storeAddress());e.setStoreCity(r.storeCity());e.setStoreZipcode(r.storeZipcode());e.setPackageCount(r.packageCount());e.setGrossWeightKg(r.grossWeightKg());e.setVolumeCbm(r.volumeCbm());e.setPalletCount(r.palletCount());e.setPoNo(r.poNo());e.setPlannedLoadingAt(r.plannedLoadingAt());e.setTransitMinutes(r.transitMinutes());e.setPayableAmount(r.payableAmount());e.setCurrency(currency(r.currency()));e.setRemark(r.remark());}
private TruckAppointmentEntity enrich(TruckAppointmentEntity value){if(value.getOceanBookingId()!=null){OceanBookingEntity booking=oceanBookings.find(value.getOceanBookingId());if(booking!=null){value.setOceanBookingNo(booking.getBookingNo());value.setWarehouseName(booking.getWarehouseName());value.setShippingCompanyId(booking.getShippingCompanyId());value.setShippingCompanyName(booking.getShippingCompanyName());value.setBookingAgentId(booking.getBookingAgentId());value.setBookingAgentName(booking.getBookingAgentName());}}value.setAttachmentCount((long)repo.attachments(value.getId()).size());return value;}
private void copy(TruckAppointmentEntity e,SaveRequest r,AssociationContext links,String carrierName){e.setOutboundOrderId(r.outboundOrderId());e.setOceanBookingId(r.oceanBookingId());e.setContainerBookingId(null);e.setLoadingPlanId(null);e.setWarehouseId(links.warehouseId());e.setCarrierId(r.carrierId());e.setCarrierName(carrierName);e.setBillOfLadingNo(links.billOfLadingNo());e.setFleetContact(r.fleetContact());e.setFleetPhone(r.fleetPhone());e.setDriverName(r.driverName());e.setDriverPhone(r.driverPhone());e.setDriverIdCard(r.driverIdCard());e.setVehicleNo(r.vehicleNo());e.setStoreName(r.storeName());e.setStoreContact(r.storeContact());e.setStorePhone(r.storePhone());e.setStoreAddress(r.storeAddress());e.setStoreCity(r.storeCity());e.setStoreZipcode(r.storeZipcode());e.setPackageCount(r.packageCount());e.setGrossWeightKg(r.grossWeightKg());e.setVolumeCbm(r.volumeCbm());e.setPalletCount(r.palletCount());e.setPoNo(r.poNo());e.setPlannedLoadingAt(r.plannedLoadingAt());e.setTransitMinutes(r.transitMinutes());e.setPayableAmount(r.payableAmount());e.setCurrency(currency(r.currency()));e.setRemark(r.remark());}
private TruckAppointmentEntity claimOrReplay(long id,String action,String key,String hash){validateKey(key);String normalized=key.trim();if(repo.claimOperation(IdWorker.getId(),id,action,normalized,hash))return null;TruckAppointmentOperationEntity existing=repo.operation(normalized);if(existing==null||!Objects.equals(existing.getAppointmentId(),id)||!action.equals(existing.getAction())||!hash.equals(existing.getRequestHash()))throw conflict("Idempotency-Key已用于不同请求");if(!StringUtils.hasText(existing.getResponseSnapshot()))throw conflict("相同请求正在处理中,请稍后重试");try{return objectMapper.readValue(existing.getResponseSnapshot(),TruckAppointmentEntity.class);}catch(JsonProcessingException ex){throw new IllegalStateException("无法读取拖车预约幂等结果",ex);}}
private TruckAppointmentEntity completeOperation(long id,String action,String key,TruckAppointmentEntity result){try{String normalized=key.trim();if(repo.completeOperation(id,action,normalized,objectMapper.writeValueAsString(result))!=1)throw conflict("拖车预约幂等请求完成状态冲突");return result;}catch(JsonProcessingException ex){throw new IllegalStateException("无法保存拖车预约幂等结果",ex);}}
private String hash(String action,long id,Object request){try{return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest((action+'|'+id+'|'+objectMapper.writeValueAsString(request)).getBytes(StandardCharsets.UTF_8)));}catch(Exception ex){throw new IllegalStateException("无法计算拖车预约请求摘要",ex);}}
@@ -87,24 +72,19 @@ public class TruckAppointmentService {
private static void validateKey(String key){if(!StringUtils.hasText(key)||key.trim().length()>128)throw bad("Idempotency-Key不能为空且不能超过128个字符");}
private record ReschedulePayload(LocalDateTime plannedLoadingAt,ActionRequest request){}
private String requireDispatchReady(TruckAppointmentEntity appointment){
if(appointment.getOceanBookingId()==null||appointment.getContainerBookingId()==null)throw conflict("拖车预约缺少订舱或柜次关联");
if(appointment.getOceanBookingId()==null)throw conflict("拖车预约缺少订舱关联");
OceanBookingEntity booking=oceanBookings.find(appointment.getOceanBookingId());
if(booking==null)throw new BizException(ResultCode.NOT_FOUND,"关联订舱不存在");
if(!"RELEASED".equals(booking.getStatus()))throw conflict("关联订舱尚未放舱");
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("关联柜次已失效或不属于当前订舱");
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;}
private static BizException existingAppointmentConflict(TruckAppointmentEntity existing){String appointmentNo=existing==null?null:trim(existing.getAppointmentNo());return conflict(StringUtils.hasText(appointmentNo)?"该柜已存在拖车预约 "+appointmentNo+",请继续编辑":"该柜已存在拖车预约,请刷新后继续编辑");}
private static BizException existingAppointmentConflict(TruckAppointmentEntity existing){String appointmentNo=existing==null?null:trim(existing.getAppointmentNo());return conflict(StringUtils.hasText(appointmentNo)?"该订舱已存在拖车预约 "+appointmentNo+",请继续编辑":"该订舱已存在拖车预约,请刷新后继续编辑");}
private static String trim(String value){return StringUtils.hasText(value)?value.trim():null;}
private record AssociationContext(OceanBookingEntity booking,ContainerBookingEntity container,Long warehouseId,String billOfLadingNo){}
private record AssociationContext(OceanBookingEntity booking,Long warehouseId,String billOfLadingNo){}
private void record(TruckAppointmentEntity e,String from,String to,String action,String reason,long uid,String name){TruckAppointmentHistoryEntity h=new TruckAppointmentHistoryEntity();h.setId(IdWorker.getId());h.setAppointmentId(e.getId());h.setRevision(e.getRevision());h.setAction(action);h.setFromStatus(from);h.setToStatus(to);h.setReason(reason);h.setOperatorId(uid);h.setOperatorName(name);h.setCreatedAt(LocalDateTime.now());repo.history(h);}
}
@@ -4,8 +4,8 @@ CREATE TABLE IF NOT EXISTS fl_truck_appointment (
appointment_no VARCHAR(40) NOT NULL COMMENT '平台拖车预约编号',
outbound_order_id BIGINT UNSIGNED NULL COMMENT '关联头程订单逻辑ID;不跨库建外键',
ocean_booking_id BIGINT UNSIGNED NULL COMMENT '关联海运订舱逻辑ID',
container_booking_id BIGINT UNSIGNED NULL COMMENT '关联柜次逻辑ID;拖车完成供装柜门禁校验',
loading_plan_id BIGINT UNSIGNED NULL COMMENT '关联装柜计划逻辑ID',
container_booking_id BIGINT UNSIGNED NULL COMMENT '停用保留列;订舱级拖车单不写入、不读取',
loading_plan_id BIGINT UNSIGNED NULL COMMENT '停用保留列;订舱级拖车单不写入、不读取',
warehouse_id BIGINT UNSIGNED NULL COMMENT '预约到仓的国内收货仓逻辑ID',
carrier_id BIGINT UNSIGNED NULL COMMENT '拖车服务商逻辑ID',
carrier_name VARCHAR(128) NULL COMMENT '拖车服务商名称快照',
@@ -35,14 +35,14 @@ CREATE TABLE IF NOT EXISTS fl_truck_appointment (
payable_amount DECIMAL(18,2) NULL COMMENT '应付拖车服务商金额;币种由currency指定',
currency VARCHAR(8) NOT NULL DEFAULT 'RMB' COMMENT '应付金额币种;仅RMB或USD',
status VARCHAR(32) NOT NULL DEFAULT 'DRAFT' COMMENT '拖车预约状态:DRAFT草稿、PENDING_DISPATCH待派车、DISPATCHED已派车、PICKUP_IN_PROGRESS提货中、ARRIVED_WAREHOUSE已到仓、LOADING装柜中、DEPARTED已离仓、COMPLETED已完成、RESCHEDULED已改约、EXCEPTION异常、CANCELLED已取消',
active_container_booking_id BIGINT UNSIGNED GENERATED ALWAYS AS (CASE WHEN status NOT IN ('COMPLETED','CANCELLED','EXCEPTION') THEN container_booking_id ELSE NULL END) STORED COMMENT '非终态预约的柜次唯一键',
active_ocean_booking_id BIGINT UNSIGNED GENERATED ALWAYS AS (CASE WHEN status NOT IN ('COMPLETED','CANCELLED','EXCEPTION') THEN ocean_booking_id ELSE NULL END) STORED COMMENT '非终态预约的订舱唯一键',
revision INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '拖车预约业务修订号;每次状态或改约递增',
version INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '乐观锁版本号',
remark VARCHAR(1000) NULL COMMENT '平台内部备注或异常说明',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '拖车预约创建时间',
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '拖车预约最后更新时间',
PRIMARY KEY(id), UNIQUE KEY uk_truck_appointment_no(appointment_no), UNIQUE KEY uk_truck_active_container(active_container_booking_id),
KEY idx_truck_appointment_status(status,updated_at), KEY idx_truck_appointment_container(container_booking_id),
PRIMARY KEY(id), UNIQUE KEY uk_truck_appointment_no(appointment_no), UNIQUE KEY uk_truck_active_ocean_booking(active_ocean_booking_id),
KEY idx_truck_appointment_status(status,updated_at), KEY idx_truck_appointment_ocean(ocean_booking_id),
CONSTRAINT chk_truck_appointment_status CHECK(status IN ('DRAFT','PENDING_DISPATCH','DISPATCHED','PICKUP_IN_PROGRESS','ARRIVED_WAREHOUSE','LOADING','DEPARTED','COMPLETED','RESCHEDULED','EXCEPTION','CANCELLED')),
CONSTRAINT chk_truck_appointment_currency CHECK(currency IN ('RMB','USD'))
) ENGINE=InnoDB COMMENT='三期拖车预约派车聚合';
@@ -100,16 +100,26 @@ CALL phase3_truck_add_column(
'DATETIME(3) NULL COMMENT ''车辆实际离仓时间;DEPARTED后供LOADED门禁使用'' AFTER actual_loading_at'
);
CALL phase3_truck_add_column(
'active_container_booking_id',
'BIGINT UNSIGNED GENERATED ALWAYS AS (CASE WHEN status NOT IN (''COMPLETED'',''CANCELLED'',''EXCEPTION'') THEN container_booking_id ELSE NULL END) STORED COMMENT ''非终态预约的柜次唯一键'' AFTER status'
'active_ocean_booking_id',
'BIGINT UNSIGNED GENERATED ALWAYS AS (CASE WHEN status NOT IN (''COMPLETED'',''CANCELLED'',''EXCEPTION'') THEN ocean_booking_id ELSE NULL END) STORED COMMENT ''非终态预约的订舱唯一键'' AFTER status'
);
DROP PROCEDURE IF EXISTS phase3_truck_add_column;
SET @phase3_truck_active_index = IF(
SET @phase3_truck_drop_old_active_index = IF(
EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE()
AND table_name='fl_truck_appointment' AND index_name='uk_truck_active_container' AND non_unique=0),
'ALTER TABLE fl_truck_appointment DROP INDEX uk_truck_active_container',
'SELECT 1'
);
PREPARE phase3_truck_active_stmt FROM @phase3_truck_drop_old_active_index;
EXECUTE phase3_truck_active_stmt;
DEALLOCATE PREPARE phase3_truck_active_stmt;
SET @phase3_truck_active_index = IF(
EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE()
AND table_name='fl_truck_appointment' AND index_name='uk_truck_active_ocean_booking' AND non_unique=0),
'SELECT 1',
'ALTER TABLE fl_truck_appointment ADD UNIQUE KEY uk_truck_active_container(active_container_booking_id)'
'ALTER TABLE fl_truck_appointment ADD UNIQUE KEY uk_truck_active_ocean_booking(active_ocean_booking_id)'
);
PREPARE phase3_truck_active_stmt FROM @phase3_truck_active_index;
EXECUTE phase3_truck_active_stmt;
@@ -1,15 +1,15 @@
openapi: 3.0.3
info:
title: Truck Appointment API
version: 1.3.0
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.
version: 2.0.0
description: A truck appointment belongs to one RELEASED ocean booking, independently of when or how that booking's internal containers are linked. Each booking may have at most one non-terminal appointment. The bill-of-lading number and warehouse are frozen from the booking, which must own at least one active BOOKING_CONFIRMATION or BILL_OF_LADING file. Container identities and real container numbers remain in the container/loading modules and are not part of this API. PDA loading completion does not change a booking-level truck appointment.
paths:
/api/platform/v1/truck-appointments:
get:
summary: Query truck appointments
responses:
'200':
description: Paged truck appointments with stable booking/container snapshots and attachment count
description: Paged booking-level truck appointments with stable booking snapshots and attachment count
content:
application/json:
schema:
@@ -20,8 +20,8 @@ paths:
properties:
records: {type: array, items: {$ref: '#/components/schemas/DetailStableReferences'}}
post:
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.
summary: Create a truck appointment for a released booking
description: If the booking already has a non-terminal appointment, the server returns A0409 with the existing appointment number and instructs the operator to continue editing. The database booking-level unique key provides the same guarantee for concurrent creates.
requestBody:
required: true
content:
@@ -29,17 +29,17 @@ paths:
schema: {$ref: '#/components/schemas/SaveRequest'}
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'}
'409': {description: 'A0409 - this booking 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.
summary: Query authoritative create-time ocean-booking candidates
description: Requires platform:firstleg:truck-appointment:list. Returns one row per RELEASED booking with a bill of lading and an active booking-owned confirmation or bill file, excluding bookings that already have a non-terminal truck appointment. Container allocation and loading status do not affect eligibility. Edit pages display their current booking from appointment detail rather than injecting it into this create-only 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: keyword, in: query, required: false, schema: {type: string, maxLength: 64}, description: Matches booking number or bill of lading}
- {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'}}}}
'200': {description: Paged booking candidates, content: {application/json: {schema: {$ref: '#/components/schemas/CandidatePageEnvelope'}}}}
/api/platform/v1/truck-appointments/{id}:
parameters: [{$ref: '#/components/parameters/AppointmentId'}]
get:
@@ -63,7 +63,7 @@ paths:
- {$ref: '#/components/parameters/RequestHash'}
post:
summary: Apply an idempotent appointment lifecycle action
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.
description: All actions transition the booking-level appointment as one aggregate. PICKUP does not accept or write a container number. ARRIVE, LOAD and DEPART persist their actual event timestamps atomically with the status transition.
requestBody:
required: true
content:
@@ -168,7 +168,7 @@ components:
timestamp: {type: string, format: date-time}
Candidate:
type: object
required: [oceanBookingId, oceanBookingNo, warehouseId, requirementId, containerType, requirementQuantity, containerBookingId, cabinetNo, bookingDocumentReady]
required: [oceanBookingId, oceanBookingNo, warehouseId, bookingDocumentReady]
properties:
oceanBookingId: {type: integer, format: int64}
oceanBookingNo: {type: string}
@@ -177,15 +177,6 @@ components:
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
@@ -225,16 +216,13 @@ 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]
required: [oceanBookingId, 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 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.}
oceanBookingId: {type: integer, format: int64, minimum: 1, description: Required RELEASED booking; must have a bill of lading and own an active BOOKING_CONFIRMATION or BILL_OF_LADING file.}
warehouseId: {type: integer, format: int64, minimum: 1, nullable: true, description: Optional consistency hint. The persisted warehouse is frozen from the selected booking.}
carrierId: {type: integer, format: int64, minimum: 1, description: Required ACTIVE Warehouse supplier ID validated as TRUCKING.}
carrierName: {type: string, maxLength: 128, nullable: true, description: Optional consistency hint only; the persisted snapshot comes from Warehouse supplier master data.}
billOfLadingNo: {type: string, maxLength: 128, nullable: true, description: Optional consistency hint; the persisted snapshot is always frozen from the selected RELEASED booking.}
@@ -245,15 +233,11 @@ components:
DetailStableReferences:
type: object
required: [appointmentNo, carrierId, carrierName, attachmentCount]
description: Stable IDs plus server-resolved saved resource names returned by appointment detail. Ocean booking and container associations are always present.
description: Stable booking and supplier references returned by appointment detail. Container identities are intentionally absent.
properties:
appointmentNo: {type: string, pattern: '^TA-[0-9]{6}-[0-9]{4}$', example: TA-260818-0001, description: 派车单号;按北京时间创建日逐日编号}
oceanBookingId: {type: integer, format: int64, nullable: true}
oceanBookingNo: {type: string, nullable: true, pattern: '^OB-[0-9]{6}-[0-9]{4}$', example: OB-260818-0001}
containerBookingId: {type: integer, format: int64, nullable: true}
cabinetNo: {type: string, nullable: true, pattern: '^CAB-[0-9]{6}-[0-9]{4}$', example: CAB-260818-0001}
containerNo: {type: string, nullable: true}
loadingPlanId: {type: integer, format: int64, nullable: true}
warehouseId: {type: integer, format: int64, nullable: true}
warehouseName: {type: string, nullable: true}
shippingCompanyId: {type: integer, format: int64, nullable: true}
@@ -19,7 +19,7 @@ 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","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 schemaHasBookingLevelUniqueAggregateWithoutCustomerScope() throws Exception {String s=new ClassPathResource("db/phase3-truck-appointment-schema.sql").getContentAsString(StandardCharsets.UTF_8);assertThat(s).contains("fl_truck_appointment","bill_of_lading_no VARCHAR(128)","active_ocean_booking_id BIGINT UNSIGNED GENERATED ALWAYS AS","UNIQUE KEY uk_truck_active_ocean_booking(active_ocean_booking_id)","ALTER TABLE fl_truck_appointment DROP INDEX uk_truck_active_container","chk_truck_appointment_currency CHECK(currency IN ('RMB','USD'))").doesNotContain("customer_id", "customer_name", "receivable_amount");}
@Test void existingAppointmentTableReceivesAllLateColumns() throws Exception {
String moduleSql=new ClassPathResource("db/phase3-truck-appointment-schema.sql").getContentAsString(StandardCharsets.UTF_8);
Path direct=Path.of("deploy","sql","头程数据库统一增量-MySQL8.sql");
@@ -30,10 +30,10 @@ 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 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","database booking-level unique key");}
@Test void openApiPublishesBookingLevelCandidateContract() 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 booking candidates","bookingDocumentReady","Container allocation and loading status do not affect eligibility").doesNotContain("requirementId:","containerBookingId:","loadingPlanId:");}
@Test void openApiRequiresReleasedBookingWithoutContainerWriteProtocol() throws Exception {String s=new ClassPathResource("openapi/truck-appointments-v1.yaml").getContentAsString(StandardCharsets.UTF_8);assertThat(s).contains("required: [oceanBookingId, carrierId, plannedLoadingAt, expectedVersion]","RELEASED ocean booking","PICKUP does not accept or write a container number","schema: {$ref: '#/components/schemas/DetailEnvelope'}").doesNotContain("containerBookingId:","loadingPlanId:","containerNo:","customerId", "customerName");}
@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:","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<String,String> expected=Map.ofEntries(
@@ -69,7 +69,7 @@ class OutboundDispatchServiceTest {
void confirmAllowsMissingTruckAppointmentWhenEveryAuthoritativeGatePasses() {
OutboundDispatchService service = serviceWithoutCarrier();
stubContainer();
when(repository.truck(22L, 44L)).thenReturn(null);
when(repository.truck(22L)).thenReturn(null);
stubConfirmMutation("no-truck");
Detail result = service.confirm(22L, 9L, "admin", "no-truck", new ActionRequest(0, null));
@@ -83,7 +83,7 @@ class OutboundDispatchServiceTest {
void confirmAllowsPendingTruckAppointmentWhenEveryAuthoritativeGatePasses() {
OutboundDispatchService service = serviceWithoutCarrier();
stubContainer();
when(repository.truck(22L, 44L)).thenReturn(new OutboundDispatchReadMapper.IdStatus(77L, "PENDING"));
when(repository.truck(22L)).thenReturn(new OutboundDispatchReadMapper.IdStatus(77L, "PENDING"));
stubConfirmMutation("pending-truck");
Detail result = service.confirm(22L, 9L, "admin", "pending-truck", new ActionRequest(0, null));
@@ -247,7 +247,7 @@ class OutboundDispatchServiceTest {
when(repository.bindings(22L)).thenReturn(values);
when(repository.pick(44L)).thenReturn(new OutboundDispatchReadMapper.IdStatus(55L, "COMPLETED"));
when(repository.ocean(22L)).thenReturn(new OutboundDispatchReadMapper.OceanBinding(66L, "RELEASED", 7L));
when(repository.truck(22L, 44L)).thenReturn(new OutboundDispatchReadMapper.IdStatus(77L, "DEPARTED"));
when(repository.truck(22L)).thenReturn(new OutboundDispatchReadMapper.IdStatus(77L, "DEPARTED"));
when(repository.evidence(22L, 44L)).thenReturn(new OutboundDispatchReadMapper.Evidence(5, 1));
}
@@ -13,39 +13,30 @@ class TruckAppointmentMapperContractTest {
@Test void mapsToPhase3TableAndUsesOptimisticGuards() throws Exception {
assertThat(TruckAppointmentEntity.class.getAnnotation(TableName.class).value()).isEqualTo("fl_truck_appointment");
String page = TruckAppointmentMapper.class.getMethod("page", com.baomidou.mybatisplus.extension.plugins.pagination.Page.class, com.baiyue.firstleg.dto.TruckAppointmentDtos.Query.class).getAnnotation(Select.class).value()[0];
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).contains("fl_truck_appointment", "appointment_no", "vehicle_no", "status=#{q.status}", "ocean_booking_id=#{q.oceanBookingId}",
"fl_ocean_booking", "fl_truck_appointment_attachment", "attachment_count")
.doesNotContain("fl_container_booking", "cabinet_no", "container_no");
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&lt;&gt;'COMPLETED'",
"t.status NOT IN ('COMPLETED','CANCELLED','EXCEPTION')", "containerBookingId",
"bookingDocumentReady");
assertThat(candidates).doesNotContain("c.container_no IS NOT NULL", "currentAppointmentId", "currentContainerBookingId");
assertThat(candidates).contains("FROM fl_ocean_booking o", "o.status='RELEASED'", "d.valid=1",
"'BOOKING_CONFIRMATION','BILL_OF_LADING'", "t.ocean_booking_id=o.id",
"t.status NOT IN ('COMPLETED','CANCELLED','EXCEPTION')", "bookingDocumentReady");
assertThat(candidates).doesNotContain("fl_ocean_booking_container_allocation", "fl_container_booking",
"loadingPlanId", "containerBookingId", "cabinetNo", "containerNo");
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];
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'");
String firstActive = TruckAppointmentMapper.class.getMethod("firstActiveForUpdate", long.class).getAnnotation(Select.class).value()[0];
assertThat(firstActive).contains("container_booking_id=#{containerBookingId}", "ORDER BY created_at,id", "LIMIT 1 FOR UPDATE")
assertThat(firstActive).contains("ocean_booking_id=#{oceanBookingId}", "ORDER BY created_at,id", "LIMIT 1 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];
assertThat(edit).contains("ocean_booking_id=#{e.oceanBookingId}", "container_booking_id=#{e.containerBookingId}",
"loading_plan_id=#{e.loadingPlanId}", "carrier_id=#{e.carrierId}",
assertThat(edit).contains("ocean_booking_id=#{e.oceanBookingId}", "carrier_id=#{e.carrierId}",
"planned_loading_at=#{e.plannedLoadingAt}", "bill_of_lading_no=#{e.billOfLadingNo}", "WHERE id=#{e.id} AND version=#{version}",
"version=version+1", "revision=revision+1");
assertThat(edit).doesNotContain("container_booking_id", "loading_plan_id");
assertThat(edit).doesNotContain("customer_id", "customer_name", "customerId", "customerName");
assertThat(edit).doesNotContain("receivable_amount", "receivableAmount");
}
@@ -93,8 +93,6 @@ class LoadingPickServiceTest {
@MockBean
private ColoadGroupAssignmentPort coloadGroups;
@MockBean
private TruckAppointmentService truckAppointmentService;
@MockBean
private InboundStatusHistoryMapper inboundHistories;
@MockBean
private PdaPickSettlementService settlementService;
@@ -594,7 +592,6 @@ class LoadingPickServiceTest {
verify(repository).syncInboundProjections(20L, 9L, "operator");
verify(loadingDeliveryService).requirePdaCompletionReady(101L, 50L);
verify(loadingDeliveryService).requirePdaCompletionReady(101L, 60L);
verify(truckAppointmentService).completeLoadingFromPda(99L, 20L, 9L, "operator");
verify(loadingDeliveryService).completeAfterPdaSettlement(101L, 50L, 9L, "operator",
"PDA-complete-1-50", "checked");
verify(loadingDeliveryService).completeAfterPdaSettlement(101L, 60L, 9L, "operator",
@@ -607,37 +604,6 @@ class LoadingPickServiceTest {
finalization.verify(loadingListService).publishCompletedEvent(10L);
}
@Test
@org.junit.jupiter.api.Disabled("Replaced by durable asynchronous completion command contract")
void conflictingTruckAppointmentsDoNotBlockPdaCompletion() {
LoadingPickTaskEntity task = task();
LoadingPickItemEntity item = item();
when(repository.taskForUpdate(20L)).thenReturn(task);
when(repository.task(20L)).thenReturn(task);
when(repository.items(20L)).thenReturn(List.of(item));
when(repository.allTaskItemsResolved(20L)).thenReturn(true);
when(repository.allPlanItemsPicked(10L)).thenReturn(true);
when(repository.approve(20L, 9L, "operator", "checked")).thenReturn(1);
when(repository.review(20L)).thenReturn(null);
var owner = new LoadingPickItemMapper.InboundOwner(101L, 50L);
when(repository.inboundOwners(20L)).thenReturn(List.of(owner));
when(repository.inboundForUpdate(101L, 50L)).thenReturn(
new LoadingInboundMapper.Snapshot(50L, 101L, "LOADING", 7));
doThrow(new BizException(com.baiyue.common.core.api.ResultCode.CONFLICT,
"同一柜次存在多条有效拖车预约,跳过自动完成"))
.when(truckAppointmentService).completeLoadingFromPda(99L, 20L, 9L, "operator");
service.complete(20L,
new CompleteLoadingRequest(0, "complete-not-blocked", "checked"), 9L, "operator");
verify(settlementService).settle(20L, 9L, "operator");
verify(repository).approve(20L, 9L, "operator", "checked");
verify(loadingListService).finishFromPda(10L, 20L, 9L, "operator");
verify(loadingDeliveryService).completeAfterPdaSettlement(101L, 50L, 9L, "operator",
"PDA-complete-not-blocked-50", "checked");
verify(loadingListService).publishCompletedEvent(10L);
}
@Test
@org.junit.jupiter.api.Disabled("Replaced by durable asynchronous completion command contract")
void doesNotPublishCompletedEventWhenInboundFinalizationFails() {
@@ -12,14 +12,9 @@ import com.baiyue.firstleg.entity.TruckAppointmentEntity;
import com.baiyue.firstleg.entity.TruckAppointmentAttachmentEntity;
import com.baiyue.firstleg.entity.TruckAppointmentHistoryEntity;
import com.baiyue.firstleg.entity.TruckAppointmentOperationEntity;
import com.baiyue.firstleg.entity.LoadingPlanEntity;
import com.baiyue.firstleg.entity.ContainerBookingEntity;
import com.baiyue.firstleg.repository.TruckAppointmentRepository;
import com.baiyue.firstleg.repository.SmartLoadingRepository;
import com.baiyue.firstleg.repository.ContainerRepository;
import com.baiyue.firstleg.oceanbooking.repository.OceanBookingRepository;
import com.baiyue.firstleg.oceanbooking.entity.OceanBookingEntity;
import com.baiyue.firstleg.oceanbooking.entity.OceanBookingContainerAllocationEntity;
import com.baiyue.firstleg.oceanbooking.service.OceanBookingDocumentService;
import com.baiyue.firstleg.client.TruckAppointmentFileClient;
import com.baiyue.firstleg.client.WarehouseSupplierClient;
@@ -28,7 +23,6 @@ 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;
@@ -48,8 +42,6 @@ import static org.mockito.Mockito.*;
class TruckAppointmentServiceTest {
@Mock TruckAppointmentRepository repository;
@Mock OceanBookingRepository oceanBookings;
@Mock SmartLoadingRepository loadingPlans;
@Mock ContainerRepository containers;
@Mock WarehouseSupplierSnapshotService suppliers;
@Mock TruckAppointmentFileClient files;
@Mock OceanBookingDocumentService oceanBookingDocuments;
@@ -64,46 +56,36 @@ class TruckAppointmentServiceTest {
new WarehouseSupplierClient.Snapshot(8L, "Canonical Carrier", "TRUCKING", "ACTIVE", java.util.List.of()));
lenient().when(repository.attachments(anyLong())).thenReturn(List.of());
OceanBookingEntity booking = releasedBooking();
ContainerBookingEntity container = realContainer();
OceanBookingContainerAllocationEntity allocation = new OceanBookingContainerAllocationEntity();
allocation.setContainerBookingId(88L);
lenient().when(oceanBookings.find(99L)).thenReturn(booking);
lenient().when(containers.find(88L)).thenReturn(container);
lenient().when(oceanBookings.allocations(99L)).thenReturn(List.of(allocation));
}
@Test void detailResolvesStableNamesAndIdentifiersFromLinkedResources() {
@Test void detailResolvesStableNamesFromLinkedBooking() {
TruckAppointmentEntity appointment = new TruckAppointmentEntity();
appointment.setId(51L); appointment.setOceanBookingId(61L); appointment.setContainerBookingId(71L);
appointment.setId(51L); appointment.setOceanBookingId(61L);
when(repository.find(51L)).thenReturn(appointment);
OceanBookingEntity booking = new OceanBookingEntity(); booking.setId(61L); booking.setBookingNo("OB61");
booking.setWarehouseName("Shenzhen Transit"); booking.setShippingCompanyId(81L);
booking.setShippingCompanyName("Carrier Line"); booking.setBookingAgentId(91L); booking.setBookingAgentName("Agent Co");
when(oceanBookings.find(61L)).thenReturn(booking);
ContainerBookingEntity container = new ContainerBookingEntity(); container.setId(71L);
container.setCabinetNo("CAB-71"); container.setContainerNo("MSCU1234567");
when(containers.find(71L)).thenReturn(container);
TruckAppointmentEntity detail = service.detail(51L);
assertThat(detail.getOceanBookingNo()).isEqualTo("OB61");
assertThat(detail.getWarehouseName()).isEqualTo("Shenzhen Transit");
assertThat(detail.getShippingCompanyName()).isEqualTo("Carrier Line");
assertThat(detail.getBookingAgentName()).isEqualTo("Agent Co");
assertThat(detail.getCabinetNo()).isEqualTo("CAB-71");
assertThat(detail.getContainerNo()).isEqualTo("MSCU1234567");
assertThat(detail.getContainerBookingId()).isNull();
assertThat(detail.getLoadingPlanId()).isNull();
}
@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);
Candidate candidate = new Candidate(99L, "OB-1", "BL-1", 18L, "义乌仓", "SEA-US", "美西", true);
when(repository.candidates(any())).thenReturn(new PageResult<>(List.of(candidate), 1, 20, 1, 1));
PageResult<Candidate> 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);
verifyNoInteractions(oceanBookings);
}
@Test void createRequiresZeroVersionAndInitialisesDraft() {
@@ -112,14 +94,12 @@ class TruckAppointmentServiceTest {
verifyNoInteractions(repository);
}
@Test void createRejectsMissingBookingAndContainer() {
@Test void createRejectsMissingBooking() {
SaveRequest request = mock(SaveRequest.class);
when(request.expectedVersion()).thenReturn(0);
when(request.carrierId()).thenReturn(8L);
when(request.plannedLoadingAt()).thenReturn(LocalDateTime.of(2026, 8, 17, 10, 0));
when(request.oceanBookingId()).thenReturn(null);
when(request.containerBookingId()).thenReturn(null);
when(request.loadingPlanId()).thenReturn(null);
when(request.warehouseId()).thenReturn(null);
assertThatThrownBy(() -> service.create(7L, "admin", request))
@@ -139,14 +119,14 @@ class TruckAppointmentServiceTest {
verifyNoInteractions(files);
}
@Test void createRejectsAnotherActiveAppointmentForTheSameRealContainerWithoutWriting() {
@Test void createRejectsAnotherActiveAppointmentForTheSameBookingWithoutWriting() {
SaveRequest request = validRequest();
TruckAppointmentEntity existing = entity(TruckAppointmentStatus.DRAFT, 0);
existing.setAppointmentNo("TA-260901-0003");
when(repository.firstActiveForUpdate(88L)).thenReturn(existing);
when(repository.firstActiveForUpdate(99L)).thenReturn(existing);
assertThatThrownBy(() -> service.create(7L, "admin", request))
.isInstanceOf(BizException.class).hasMessageContaining("该柜已存在拖车预约 TA-260901-0003,请继续编辑");
.isInstanceOf(BizException.class).hasMessageContaining("该订舱已存在拖车预约 TA-260901-0003,请继续编辑");
verifyNoInteractions(oceanBookingDocuments);
verify(repository, never()).insert(any());
@@ -159,14 +139,14 @@ class TruckAppointmentServiceTest {
SaveRequest request = validRequest();
TruckAppointmentEntity concurrent = entity(TruckAppointmentStatus.DRAFT, 0);
concurrent.setAppointmentNo("TA-260901-0003");
when(repository.firstActiveForUpdate(88L)).thenReturn(null, concurrent);
doThrow(new DuplicateKeyException("Duplicate entry for key 'uk_truck_active_container'"))
when(repository.firstActiveForUpdate(99L)).thenReturn(null, concurrent);
doThrow(new DuplicateKeyException("Duplicate entry for key 'uk_truck_active_ocean_booking'"))
.when(repository).insert(any());
assertThatThrownBy(() -> service.create(7L, "admin", request))
.isInstanceOf(BizException.class).hasMessageContaining("该柜已存在拖车预约 TA-260901-0003,请继续编辑");
.isInstanceOf(BizException.class).hasMessageContaining("该订舱已存在拖车预约 TA-260901-0003,请继续编辑");
verify(repository, times(2)).firstActiveForUpdate(88L);
verify(repository, times(2)).firstActiveForUpdate(99L);
verify(repository, never()).history(any());
assertThat(concurrent.getStatus()).isEqualTo(TruckAppointmentStatus.DRAFT);
assertThat(concurrent.getVersion()).isZero();
@@ -261,8 +241,6 @@ class TruckAppointmentServiceTest {
when(request.oceanBookingId()).thenReturn(99L);
when(request.carrierId()).thenReturn(8L);
when(request.plannedLoadingAt()).thenReturn(LocalDateTime.of(2026, 8, 17, 10, 0));
when(request.containerBookingId()).thenReturn(88L);
when(request.loadingPlanId()).thenReturn(null);
when(request.warehouseId()).thenReturn(null);
OceanBookingEntity booking = new OceanBookingEntity(); booking.setId(99L); booking.setStatus("CONFIRMED"); booking.setWarehouseId(18L);
when(oceanBookings.find(99L)).thenReturn(booking);
@@ -271,38 +249,8 @@ class TruckAppointmentServiceTest {
verify(repository, never()).insert(any());
}
@Test void createWithBookingAndContainerRequiresTheirAllocation() {
@Test void createStillRequiresBookingDocumentWithoutAnyContainerAssociation() {
SaveRequest request = validRequest();
when(request.oceanBookingId()).thenReturn(99L);
when(request.containerBookingId()).thenReturn(88L);
OceanBookingEntity booking = new OceanBookingEntity();
booking.setId(99L); booking.setStatus("RELEASED"); booking.setWarehouseId(18L); booking.setBillOfLadingNo("BL-1");
ContainerBookingEntity container = new ContainerBookingEntity();
container.setId(88L); container.setRecordStatus("ACTIVE"); container.setWarehouseId(18L); container.setOceanBookingId(99L); container.setContainerNo("MSCU6639870");
when(oceanBookings.find(99L)).thenReturn(booking);
when(containers.find(88L)).thenReturn(container);
when(oceanBookings.allocations(99L)).thenReturn(List.of());
assertThatThrownBy(() -> service.create(7L, "admin", request))
.isInstanceOf(BizException.class).hasMessageContaining("未分配至该订舱");
verify(repository, never()).insert(any());
}
@Test void createAcceptsAllocatedInternalContainerWithoutRealNumber() {
SaveRequest request = validRequest();
ContainerBookingEntity missingNo = realContainer(); missingNo.setContainerNo(null);
when(containers.find(88L)).thenReturn(missingNo);
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());
@@ -311,22 +259,16 @@ class TruckAppointmentServiceTest {
verify(repository, never()).insert(any());
}
@Test void editCanSwitchOnlyToAnotherMatchedReleasedBookingAndContainer() {
@Test void editCanSwitchToAnotherUnoccupiedReleasedBooking() {
TruckAppointmentEntity existing = entity(TruckAppointmentStatus.DRAFT, 3);
existing.setOceanBookingId(99L); existing.setContainerBookingId(88L);
existing.setOceanBookingId(99L);
when(repository.find(1L)).thenReturn(existing, existing);
SaveRequest request = validRequest();
when(request.expectedVersion()).thenReturn(3);
when(request.oceanBookingId()).thenReturn(199L);
when(request.containerBookingId()).thenReturn(188L);
OceanBookingEntity booking = releasedBooking(); booking.setId(199L); booking.setBillOfLadingNo("BL-NEW");
ContainerBookingEntity container = realContainer(); container.setId(188L); container.setOceanBookingId(199L);
container.setContainerNo("MSCU6639870");
OceanBookingContainerAllocationEntity allocation = new OceanBookingContainerAllocationEntity();
allocation.setContainerBookingId(188L);
when(oceanBookings.find(199L)).thenReturn(booking);
when(containers.find(188L)).thenReturn(container);
when(oceanBookings.allocations(199L)).thenReturn(List.of(allocation));
when(repository.firstActiveForUpdate(199L)).thenReturn(null);
when(repository.updateEdit(any(), eq(3))).thenReturn(1);
service.edit(1L, 7L, "admin", request);
@@ -334,82 +276,9 @@ class TruckAppointmentServiceTest {
ArgumentCaptor<TruckAppointmentEntity> updated = ArgumentCaptor.forClass(TruckAppointmentEntity.class);
verify(repository).updateEdit(updated.capture(), eq(3));
assertThat(updated.getValue().getOceanBookingId()).isEqualTo(199L);
assertThat(updated.getValue().getContainerBookingId()).isEqualTo(188L);
assertThat(updated.getValue().getContainerBookingId()).isNull();
assertThat(updated.getValue().getLoadingPlanId()).isNull();
assertThat(updated.getValue().getBillOfLadingNo()).isEqualTo("BL-NEW");
container.setOceanBookingId(99L);
assertThatThrownBy(() -> service.edit(1L, 7L, "admin", request))
.isInstanceOf(BizException.class).hasMessageContaining("未关联至所选订舱");
}
@Test void createRejectsMissingContainerBeforeLoadingPlanValidation() {
SaveRequest request = validRequest();
when(request.loadingPlanId()).thenReturn(77L);
when(request.containerBookingId()).thenReturn(null);
assertThatThrownBy(() -> service.create(7L, "admin", request))
.isInstanceOf(BizException.class).hasMessageContaining("containerBookingId");
verify(repository, never()).insert(any());
}
@Test void createRejectsLoadingPlanFromAnotherContainer() {
SaveRequest request = mock(SaveRequest.class);
when(request.expectedVersion()).thenReturn(0);
when(request.oceanBookingId()).thenReturn(99L);
when(request.containerBookingId()).thenReturn(88L);
when(request.loadingPlanId()).thenReturn(77L);
when(request.carrierId()).thenReturn(8L);
when(request.plannedLoadingAt()).thenReturn(LocalDateTime.of(2026, 8, 17, 10, 0));
when(request.warehouseId()).thenReturn(null);
OceanBookingEntity booking = releasedBooking();
when(oceanBookings.find(99L)).thenReturn(booking);
ContainerBookingEntity container = realContainer();
when(containers.find(88L)).thenReturn(container);
var allocation = new OceanBookingContainerAllocationEntity();
allocation.setContainerBookingId(88L);
when(oceanBookings.allocations(99L)).thenReturn(java.util.List.of(allocation));
LoadingPlanEntity plan = new LoadingPlanEntity(); plan.setId(78L); plan.setContainerBookingId(88L);
when(loadingPlans.planByBooking(88L)).thenReturn(plan);
assertThatThrownBy(() -> service.create(7L, "admin", request))
.isInstanceOf(BizException.class).hasMessageContaining("loadingPlanId").hasMessageContaining("当前装柜计划");
verify(repository, never()).insert(any());
}
@Test void createRejectsUnknownLoadingPlan() {
SaveRequest request = mock(SaveRequest.class);
when(request.expectedVersion()).thenReturn(0);
when(request.oceanBookingId()).thenReturn(99L);
when(request.containerBookingId()).thenReturn(88L);
when(request.loadingPlanId()).thenReturn(77L);
when(request.carrierId()).thenReturn(8L);
when(request.plannedLoadingAt()).thenReturn(LocalDateTime.of(2026, 8, 17, 10, 0));
when(request.warehouseId()).thenReturn(null);
OceanBookingEntity booking = releasedBooking();
when(oceanBookings.find(99L)).thenReturn(booking);
ContainerBookingEntity container = realContainer();
when(containers.find(88L)).thenReturn(container);
var allocation = new OceanBookingContainerAllocationEntity();
allocation.setContainerBookingId(88L);
when(oceanBookings.allocations(99L)).thenReturn(java.util.List.of(allocation));
when(loadingPlans.planByBooking(88L)).thenReturn(null);
assertThatThrownBy(() -> service.create(7L, "admin", request))
.isInstanceOf(BizException.class).hasMessageContaining("loadingPlanId").hasMessageContaining("当前装柜计划");
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() {
@@ -420,7 +289,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, "提交", null), "idem-1", null, 7L, "admin");
var result = service.action(1L, "SUBMIT", new ActionRequest(0, "提交"), "idem-1", null, 7L, "admin");
assertThat(result.getStatus()).isEqualTo(TruckAppointmentStatus.PENDING_DISPATCH);
assertThat(result.getVersion()).isEqualTo(1);
@@ -434,12 +303,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, "提交", null));
String hash = requestHash("SUBMIT", 1L, new ActionRequest(0, "提交"));
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, "提交", null), "idem-1", hash, 7L, "admin");
var replay = service.action(1L, "SUBMIT", new ActionRequest(0, "提交"), "idem-1", hash, 7L, "admin");
assertThat(replay.getStatus()).isEqualTo(TruckAppointmentStatus.PENDING_DISPATCH);
verify(repository, never()).transition(anyLong(), anyString(), anyString(), anyInt());
@@ -447,7 +316,7 @@ class TruckAppointmentServiceTest {
}
@Test void concurrentSameKeyBeforeFirstCompletionReturnsRetryableConflictWithoutDrift() throws Exception {
var request = new ActionRequest(0, "并发提交", null);
var request = new ActionRequest(0, "并发提交");
String hash = requestHash("SUBMIT", 1L, request);
TruckAppointmentOperationEntity processing = new TruckAppointmentOperationEntity();
processing.setAppointmentId(1L); processing.setAction("SUBMIT");
@@ -472,7 +341,7 @@ class TruckAppointmentServiceTest {
.thenReturn(false);
when(repository.operation("reused-key")).thenReturn(existing);
assertThatThrownBy(() -> service.action(1L, "SUBMIT", new ActionRequest(0, "载荷A", null),
assertThatThrownBy(() -> service.action(1L, "SUBMIT", new ActionRequest(0, "载荷A"),
"reused-key", null, 7L, "admin"))
.isInstanceOf(BizException.class).hasMessageContaining("不同请求");
@@ -488,7 +357,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, null),
assertThatThrownBy(() -> service.action(1L, "DISPATCH", new ActionRequest(1, null),
"dispatch-no-bill", null, 7L, "admin"))
.isInstanceOf(BizException.class).hasMessageContaining("提单号");
@@ -500,7 +369,7 @@ 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, null),
assertThatThrownBy(() -> service.action(1L, "DISPATCH", new ActionRequest(1, null),
"dispatch-no-file", null, 7L, "admin"))
.isInstanceOf(BizException.class).hasMessageContaining("有效订舱确认或提单文件");
verify(repository, never()).dispatch(anyLong(), anyString(), anyString(), anyInt());
@@ -513,15 +382,13 @@ class TruckAppointmentServiceTest {
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, null),
TruckAppointmentEntity result = service.action(1L, "DISPATCH", new ActionRequest(1, null),
"dispatch-ok", null, 7L, "admin");
assertThat(result.getStatus()).isEqualTo(TruckAppointmentStatus.DISPATCHED);
@@ -533,166 +400,24 @@ class TruckAppointmentServiceTest {
@Test void pickupWithoutContainerNumberStillAdvancesWithoutBinding() {
TruckAppointmentEntity before = entity(TruckAppointmentStatus.DISPATCHED, 2);
before.setOceanBookingId(99L); before.setContainerBookingId(88L);
before.setOceanBookingId(99L);
TruckAppointmentEntity after = entity(TruckAppointmentStatus.PICKUP_IN_PROGRESS, 3);
after.setOceanBookingId(99L); after.setContainerBookingId(88L);
ContainerBookingEntity container = realContainer(); container.setContainerNo(null);
after.setOceanBookingId(99L);
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),
TruckAppointmentEntity result = service.action(1L, "PICKUP", new ActionRequest(2, "提柜"),
"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, "提交", null);
var request = new ActionRequest(0, "提交");
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"))
@@ -704,11 +429,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, null), "idem-action", null, 7L, "admin"))
assertThatThrownBy(() -> service.action(1L, "RESCHEDULE", new ActionRequest(0, 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, "改约", null), "idem-reschedule", null, 7L, "admin"))
assertThatThrownBy(() -> service.reschedule(1L, LocalDateTime.now(), new ActionRequest(0, "改约"), "idem-reschedule", null, 7L, "admin"))
.isInstanceOf(BizException.class).hasMessageContaining("不可改约");
verify(repository, never()).reschedule(anyLong(), any(), anyString(), anyInt());
}
@@ -721,7 +446,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, "改约", null), "idem-reschedule", null, 7L, "admin");
var result = service.reschedule(1L, at, new ActionRequest(2, "改约"), "idem-reschedule", null, 7L, "admin");
assertThat(result.getStatus()).isEqualTo(TruckAppointmentStatus.RESCHEDULED);
assertThat(result.getVersion()).isEqualTo(3);
ArgumentCaptor<TruckAppointmentHistoryEntity> history = ArgumentCaptor.forClass(TruckAppointmentHistoryEntity.class);
@@ -730,56 +455,6 @@ 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")
@@ -804,8 +479,6 @@ class TruckAppointmentServiceTest {
when(request.carrierId()).thenReturn(8L);
when(request.plannedLoadingAt()).thenReturn(LocalDateTime.of(2026, 8, 17, 10, 0));
when(request.oceanBookingId()).thenReturn(99L);
when(request.containerBookingId()).thenReturn(88L);
when(request.loadingPlanId()).thenReturn(null);
when(request.warehouseId()).thenReturn(null);
return request;
}
@@ -817,10 +490,4 @@ class TruckAppointmentServiceTest {
return booking;
}
private static ContainerBookingEntity realContainer() {
ContainerBookingEntity container = new ContainerBookingEntity();
container.setId(88L); container.setRecordStatus("ACTIVE");
container.setWarehouseId(18L); container.setOceanBookingId(99L); container.setContainerNo("MSCU6639870");
return container;
}
}