Merge remote-tracking branch 'origin/test' into dev

This commit is contained in:
2026-09-01 15:59:15 +08:00
26 changed files with 726 additions and 172 deletions
@@ -72,7 +72,7 @@ public class PlatformLoadingListController {
return ApiResponse.success(service.lifecycle(planId, warehouseId));
}
@Operation(summary = "修改装柜运输字段", description = "已推送且仍为 EDITING 的预装清单可补录或修改集装箱号与航次;LOADING 阶段按稳定柜次身份规则维护,不能清空已创建 PDA 任务的柜号。")
@Operation(summary = "修改装柜运输字段", description = "已推送后可在装柜完成前首次补录集装箱号;已有柜号的纠正仅限 EDITING 且 PDA 尚未领取或产生物理作业事实,并必须提供原因和版本;柜号不能清空。")
@PatchMapping("/loading-plans/{planId}/transport")
@PreAuthorize("hasAuthority('platform:firstleg:preload:edit')")
public ApiResponse<PreloadDetail> updateTransport(
@@ -86,7 +86,7 @@ public class PlatformLoadingListController {
planId, warehouseId, user.userId(), user.username(), idempotencyKey, request));
}
@Operation(summary = "确认整份预装计划", description = "按 loadingPlanId 原子确认全部关联入库预装票据;要求已推送、真实集装箱号已录入、关联订舱已放舱。成功后唯一创建待领取 PDA 任务,claim 后才进入 LOADING。")
@Operation(summary = "确认整份预装计划", description = "按 loadingPlanId 原子确认全部关联入库预装票据;要求已推送且关联订舱已放舱,真实集装箱号可在PDA确认装柜完成前补录。成功后唯一创建待领取 PDA 任务,claim 后才进入 LOADING。")
@PostMapping("/loading-plans/{planId}/confirm")
@PreAuthorize("hasAuthority('platform:firstleg:preload:edit')")
public ApiResponse<PreloadConfirmationResponse> confirmPreload(
@@ -2,6 +2,7 @@ package com.baiyue.firstleg.controller;
import com.baiyue.common.audit.BusinessAudit; import com.baiyue.common.core.api.*; import com.baiyue.common.security.AuthenticatedUser; import com.baiyue.firstleg.dto.TruckAppointmentDtos.*; import com.baiyue.firstleg.entity.TruckAppointmentEntity; import com.baiyue.firstleg.service.*; import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.annotation.Resource; import jakarta.validation.Valid; import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.Pattern; import jakarta.validation.constraints.Positive; import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.validation.annotation.Validated; import org.springframework.web.bind.annotation.*; import java.time.LocalDateTime;
@Tag(name="拖车预约") @Validated @RestController @RequestMapping("/api/platform/v1/truck-appointments") public class PlatformTruckAppointmentController { @Resource TruckAppointmentService service;
@GetMapping @PreAuthorize("hasAuthority('platform:firstleg:truck-appointment:list')") public ApiResponse<PageResult<TruckAppointmentEntity>> page(@AuthenticationPrincipal AuthenticatedUser u,@Valid Query q){FirstlegScopeGuard.platform(u);return ApiResponse.success(service.page(q));}
@GetMapping("/candidates") @PreAuthorize("hasAuthority('platform:firstleg:truck-appointment:list')") public ApiResponse<PageResult<Candidate>> candidates(@AuthenticationPrincipal AuthenticatedUser u,@Valid CandidateQuery q){FirstlegScopeGuard.platform(u);return ApiResponse.success(service.candidates(q));}
@GetMapping("/{id}") @PreAuthorize("hasAuthority('platform:firstleg:truck-appointment:list')") public ApiResponse<TruckAppointmentEntity> detail(@AuthenticationPrincipal AuthenticatedUser u,@PathVariable @Positive long id){FirstlegScopeGuard.platform(u);return ApiResponse.success(service.detail(id));}
@GetMapping("/{id}/history") @PreAuthorize("hasAuthority('platform:firstleg:truck-appointment:list')") public ApiResponse<java.util.List<com.baiyue.firstleg.entity.TruckAppointmentHistoryEntity>> history(@AuthenticationPrincipal AuthenticatedUser u,@PathVariable @Positive long id){FirstlegScopeGuard.platform(u);return ApiResponse.success(service.history(id));}
@GetMapping("/{id}/attachments") @PreAuthorize("hasAuthority('platform:firstleg:truck-appointment:list')") public ApiResponse<java.util.List<com.baiyue.firstleg.entity.TruckAppointmentAttachmentEntity>> attachments(@AuthenticationPrincipal AuthenticatedUser u,@PathVariable @Positive long id){FirstlegScopeGuard.platform(u);return ApiResponse.success(service.attachments(id));}
@@ -51,7 +51,7 @@ public final class LoadingListDtos {
@Size(max = 64) String containerNo,
@Size(max = 64) String voyageNo,
@NotNull @Min(0) Integer expectedVersion,
@NotBlank @Size(max = 500) String operationReason
@Size(max = 500) String operationReason
) {
}
@@ -2,7 +2,9 @@ package com.baiyue.firstleg.dto;
import jakarta.validation.constraints.*; import java.math.BigDecimal; import java.time.LocalDateTime;
public final class TruckAppointmentDtos { private TruckAppointmentDtos(){}
public record Query(@Size(max=64) String keyword, com.baiyue.firstleg.domain.TruckAppointmentStatus status, @Positive Long containerBookingId, @Min(1) long page, @Min(1) @Max(200) long size){ public Query{if(page==0)page=1;if(size==0)size=20;} }
public record CandidateQuery(@Size(max=64) String keyword,@Min(1) long page,@Min(1) @Max(200) long size){public CandidateQuery{if(page==0)page=1;if(size==0)size=20;}}
public record Candidate(Long oceanBookingId,String oceanBookingNo,String billOfLadingNo,Long warehouseId,String warehouseName,String channelCode,String channelName,Long requirementId,String containerType,Integer requirementQuantity,Long containerBookingId,String cabinetNo,String containerNo,Long loadingPlanId,String loadingPlanNo,String loadingPlanStatus,Boolean bookingDocumentReady){}
public record SaveRequest(@Positive Long outboundOrderId,@NotNull @Positive Long oceanBookingId,@NotNull @Positive Long containerBookingId,@Positive Long loadingPlanId,@Positive Long warehouseId,@NotNull @Positive Long carrierId,@Size(max=128) String carrierName,@Size(max=128) String billOfLadingNo,@Size(max=64) String fleetContact,@Size(max=32) String fleetPhone,@Size(max=64) String driverName,@Size(max=32) String driverPhone,@Size(max=32) String driverIdCard,@Size(max=32) String vehicleNo,@Size(max=128) String storeName,@Size(max=64) String storeContact,@Size(max=32) String storePhone,@Size(max=255) String storeAddress,@Size(max=64) String storeCity,@Size(max=16) String storeZipcode,@PositiveOrZero Integer packageCount,@DecimalMin("0") BigDecimal grossWeightKg,@DecimalMin("0") BigDecimal volumeCbm,@PositiveOrZero Integer palletCount,@Size(max=255) String poNo,@NotNull LocalDateTime plannedLoadingAt,@PositiveOrZero Integer transitMinutes,@DecimalMin("0") BigDecimal payableAmount,@Pattern(regexp="RMB|USD") String currency,@Size(max=1000) String remark,@NotNull @PositiveOrZero Integer expectedVersion){}
public record ActionRequest(@NotNull @PositiveOrZero Integer expectedVersion,@Size(max=500) String reason){}
public record ActionRequest(@NotNull @PositiveOrZero Integer expectedVersion,@Size(max=500) String reason,@Size(max=32) String containerNo){}
public record AttachmentRequest(@NotNull @Positive Long fileId,@NotBlank @Pattern(regexp="DISPATCH_ORDER|DRIVER_DOCUMENT|VEHICLE_DOCUMENT|OTHER") String attachmentType,@Size(max=255) String fileName,@Size(max=128) String fileHash){ }
}
@@ -250,7 +250,6 @@ public interface LoadingListQueryMapper {
b.updated_by = #{operatorId}, b.updated_by_name = #{operatorName},
b.updated_at = CURRENT_TIMESTAMP(3), b.version = b.version + 1
WHERE p.id = #{planId} AND p.plan_status='EDITING' AND p.preload_pushed_at IS NOT NULL
AND #{containerNo} IS NOT NULL
AND NOT EXISTS (SELECT 1 FROM fl_loading_pick_task task
JOIN fl_loading_pick_assignment assignment ON assignment.pick_task_id=task.id
WHERE task.loading_plan_id=p.id)
@@ -266,6 +265,36 @@ public interface LoadingListQueryMapper {
@Param("voyageNo") String voyageNo, @Param("operatorId") long operatorId,
@Param("operatorName") String operatorName);
@Update("""
UPDATE fl_loading_plan p
JOIN fl_container_booking b ON b.id=p.container_booking_id
SET p.updated_by=#{operatorId}, p.updated_by_name=#{operatorName},
p.updated_at=CURRENT_TIMESTAMP(3), p.version=p.version+1
WHERE p.id=#{planId} AND p.plan_status IN ('EDITING','LOADING')
AND p.preload_pushed_at IS NOT NULL AND p.version=#{expectedVersion}
AND b.container_no IS NULL
""")
int touchForInitialContainerFill(@Param("planId") long planId,
@Param("expectedVersion") int expectedVersion,
@Param("operatorId") long operatorId,
@Param("operatorName") String operatorName);
@Update("""
UPDATE fl_container_booking b
JOIN fl_loading_plan p ON p.container_booking_id=b.id
SET b.container_no=#{containerNo}, b.voyage_no=#{voyageNo},
b.updated_by=#{operatorId}, b.updated_by_name=#{operatorName},
b.updated_at=CURRENT_TIMESTAMP(3), b.version=b.version+1
WHERE p.id=#{planId} AND p.plan_status IN ('EDITING','LOADING')
AND p.preload_pushed_at IS NOT NULL AND b.container_no IS NULL
AND #{containerNo} IS NOT NULL
""")
int fillInitialContainerNo(@Param("planId") long planId,
@Param("containerNo") String containerNo,
@Param("voyageNo") String voyageNo,
@Param("operatorId") long operatorId,
@Param("operatorName") String operatorName);
@Select("""
SELECT CASE WHEN
EXISTS (SELECT 1 FROM fl_loading_pick_task task
@@ -1,11 +1,37 @@
package com.baiyue.firstleg.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper; import com.baomidou.mybatisplus.extension.plugins.pagination.Page; import com.baiyue.firstleg.dto.TruckAppointmentDtos.Query; import com.baiyue.firstleg.entity.TruckAppointmentEntity; import org.apache.ibatis.annotations.*;
import com.baomidou.mybatisplus.core.mapper.BaseMapper; import com.baomidou.mybatisplus.extension.plugins.pagination.Page; import com.baiyue.firstleg.dto.TruckAppointmentDtos.*; import com.baiyue.firstleg.entity.TruckAppointmentEntity; import org.apache.ibatis.annotations.*;
@Mapper public interface TruckAppointmentMapper extends BaseMapper<TruckAppointmentEntity>{
@Select("""
<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
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 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
</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);
@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);
@@ -86,6 +86,14 @@ public class LoadingListRepository {
return mapper.updateTransport(planId, containerNo, voyageNo, operatorId, operatorName);
}
public int fillInitialContainerNo(long planId, int expectedVersion, String containerNo, String voyageNo,
long operatorId, String operatorName) {
if (mapper.touchForInitialContainerFill(planId, expectedVersion, operatorId, operatorName) != 1) {
return 0;
}
return mapper.fillInitialContainerNo(planId, containerNo, voyageNo, operatorId, operatorName);
}
public boolean hasPdaWorkStarted(long planId) {
return mapper.hasPdaWorkStarted(planId) > 0;
}
@@ -151,13 +159,12 @@ public class LoadingListRepository {
java.util.ArrayList<String> blockers = new java.util.ArrayList<>();
if (!pushed) blockers.add("PRELOAD_NOT_PUSHED");
if (!editing) blockers.add("LOADING_PLAN_NOT_EDITING");
if (!containerReady) blockers.add("CONTAINER_NO_MISSING");
if (linkCount == 0) blockers.add("PRELOAD_LINK_MISSING");
if (!oceanLinked) blockers.add("OCEAN_BOOKING_NOT_LINKED");
else if (!released) blockers.add("OCEAN_BOOKING_NOT_RELEASED");
if (taskCreated) blockers.add("PDA_TASK_ALREADY_CREATED");
boolean allConfirmed = linkCount > 0 && confirmedCount == linkCount;
boolean prerequisitesReady = pushed && editing && containerReady && linkCount > 0 && released;
boolean prerequisitesReady = pushed && editing && linkCount > 0 && released;
boolean canConfirm = prerequisitesReady && !taskCreated && !allConfirmed;
if (allConfirmed) blockers.add("PRELOAD_ALREADY_CONFIRMED");
String confirmationStatus = allConfirmed ? "CONFIRMED"
@@ -1,7 +1,7 @@
package com.baiyue.firstleg.repository;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page; import com.baiyue.common.core.api.PageResult; import com.baiyue.firstleg.dto.TruckAppointmentDtos.Query; import com.baiyue.firstleg.entity.*; import com.baiyue.firstleg.mapper.*; import jakarta.annotation.Resource; import org.springframework.stereotype.Repository;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page; import com.baiyue.common.core.api.PageResult; import com.baiyue.firstleg.dto.TruckAppointmentDtos.*; import com.baiyue.firstleg.entity.*; import com.baiyue.firstleg.mapper.*; import jakarta.annotation.Resource; import org.springframework.stereotype.Repository;
@Repository public class TruckAppointmentRepository { @Resource TruckAppointmentMapper mapper; @Resource TruckAppointmentHistoryMapper history; @Resource TruckAppointmentAttachmentMapper attachments; @Resource TruckAppointmentOperationMapper operations;
public PageResult<TruckAppointmentEntity> page(Query q){return PageResult.from(mapper.page(new Page<>(q.page(),q.size()),q));} public TruckAppointmentEntity find(long id){return mapper.selectById(id);} public void insert(TruckAppointmentEntity e){mapper.insert(e);} public int transition(long id,String from,String to,int v){return mapper.transition(id,from,to,v);} public int dispatch(long id,String from,String billOfLadingNo,int v){return mapper.dispatch(id,from,billOfLadingNo,v);} public int reschedule(long id,java.time.LocalDateTime at,String reason,int v){return mapper.reschedule(id,at,reason,v);} public void history(TruckAppointmentHistoryEntity e){history.insert(e);} public void operation(TruckAppointmentOperationEntity e){operations.insert(e);} public void attachment(TruckAppointmentAttachmentEntity e){attachments.insert(e);}
public PageResult<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 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);}
@@ -423,7 +423,6 @@ public class LoadingListService {
AggregateRow before = requirePlan(planId);
requireWarehouse(before, warehouseId);
String reason = trim(request.operationReason());
if (!StringUtils.hasText(reason)) throw badRequest("纠错原因不能为空");
String operationId = operationId("TRANSPORT", planId, idempotencyKey);
HistoryRow replay = repository.history(operationId);
if (replay != null) {
@@ -434,20 +433,31 @@ public class LoadingListService {
return repository.preloadDetail(before);
}
requireTransportCorrectionMutable(before);
String containerNo = targetContainerNo(before, request.containerNo());
String voyageNo = nullableTrim(request.voyageNo());
boolean initialContainerFill = !StringUtils.hasText(before.getContainerNo()) && containerNo != null;
requireTransportMutable(before, initialContainerFill);
boolean correctingContainerNo = StringUtils.hasText(before.getContainerNo())
&& !java.util.Objects.equals(before.getContainerNo(), containerNo);
if (correctingContainerNo && !StringUtils.hasText(reason)) {
throw badRequest("纠正已有真实集装箱号时纠错原因不能为空");
}
String requestHash = transportHash(planId, request.expectedVersion(), containerNo, voyageNo, reason);
if (java.util.Objects.equals(before.getContainerNo(), containerNo)
&& java.util.Objects.equals(before.getVoyageNo(), voyageNo)) {
return repository.preloadDetail(before);
}
if (containerRepository.containerNoExists(containerNo, before.getContainerBookingId())) {
if (containerNo != null
&& containerRepository.containerNoExists(containerNo, before.getContainerBookingId())) {
throw conflict("集装箱号已存在");
}
try {
if (repository.updateTransport(planId, request.expectedVersion(), containerNo, voyageNo,
operatorId, trim(operatorName)) != 1) {
int updated = initialContainerFill
? repository.fillInitialContainerNo(planId, request.expectedVersion(), containerNo, voyageNo,
operatorId, trim(operatorName))
: repository.updateTransport(planId, request.expectedVersion(), containerNo, voyageNo,
operatorId, trim(operatorName));
if (updated != 1) {
throw conflict("预装计划、PDA领取或物理作业状态已变化,请刷新后重试");
}
} catch (DuplicateKeyException exception) {
@@ -464,8 +474,9 @@ public class LoadingListService {
}
private static String targetContainerNo(AggregateRow before, String requested) {
return requested == null ? Iso6346ContainerNumber.require(before.getContainerNo())
: Iso6346ContainerNumber.require(requested);
if (requested != null) return Iso6346ContainerNumber.require(requested);
return StringUtils.hasText(before.getContainerNo())
? Iso6346ContainerNumber.require(before.getContainerNo()) : null;
}
private static String transportHash(long planId, int version, String containerNo,
@@ -577,15 +588,15 @@ public class LoadingListService {
}
}
private void requireTransportCorrectionMutable(AggregateRow row) {
if (!"EDITING".equals(row.getPlanStatus())) {
private void requireTransportMutable(AggregateRow row, boolean initialContainerFill) {
if (initialContainerFill && !Set.of("EDITING", "LOADING").contains(row.getPlanStatus())) {
throw conflict("仅装柜完成前允许首次补录真实集装箱号");
}
if (!initialContainerFill && !"EDITING".equals(row.getPlanStatus())) {
throw conflict("仅已推送且PDA尚未开始的预装计划可纠错真实集装箱号");
}
if (row.getPreloadPushedAt() == null) throw conflict("仅已推送的预装清单可维护运输字段");
if (!StringUtils.hasText(row.getContainerNo())) {
throw conflict("真实集装箱号只能在关联已放舱订舱时首次写入");
}
if (repository.hasPdaWorkStarted(row.getPlanId())) {
if (!initialContainerFill && repository.hasPdaWorkStarted(row.getPlanId())) {
throw conflict("PDA任务已领取、已开始作业或已有物理作业事实,不能修改真实集装箱号");
}
}
@@ -23,6 +23,7 @@ import com.baiyue.firstleg.dto.LoadingPickDtos.InventoryResource;
import com.baiyue.firstleg.dto.LoadingPickDtos.InboundInventory;
import com.baiyue.firstleg.dto.LoadingPickDtos.InventorySnapshot;
import com.baiyue.firstleg.dto.LoadingPickDtos.PendingTask;
import com.baiyue.firstleg.domain.Iso6346ContainerNumber;
import com.baiyue.firstleg.entity.LoadingPickAssignmentEntity;
import com.baiyue.firstleg.entity.LoadingPickHistoryEntity;
import com.baiyue.firstleg.entity.LoadingPickItemEntity;
@@ -33,6 +34,7 @@ import com.baiyue.firstleg.entity.LoadingPickTaskEntity;
import com.baiyue.firstleg.entity.LoadingPlanItemEntity;
import com.baiyue.firstleg.entity.LoadingPlanHistoryEntity;
import com.baiyue.firstleg.entity.InboundStatusHistoryEntity;
import com.baiyue.firstleg.entity.ContainerBookingEntity;
import com.baiyue.firstleg.loading.mapper.LoadingInboundMapper;
import com.baiyue.firstleg.loading.service.LoadingDeliveryService;
import com.baiyue.firstleg.loading.coload.model.ColoadGroupModels.GroupSnapshot;
@@ -113,11 +115,11 @@ public class LoadingPickService {
@Value("${baiyue.security.internal-service-token:test-internal-service-token}")
private String internalToken;
/** Opens the field task only after planning, transport identity and ocean release have converged. */
/** Opens the field task after planning and ocean release; the real container number may be filled later. */
@Transactional
public void beginIfReady(long containerBookingId, long operatorId, String operatorName) {
var booking = smartLoadingRepository.booking(containerBookingId);
if (booking == null || !StringUtils.hasText(booking.getContainerNo())) return;
if (booking == null) return;
var plan = smartLoadingRepository.planByBooking(containerBookingId);
if (plan == null || plan.getPreloadPushedAt() == null || !"EDITING".equals(plan.getPlanStatus())) return;
var links = loadingModelBridge.byLoadingPlan(plan.getId());
@@ -137,10 +139,6 @@ public class LoadingPickService {
if (!Set.of("PENDING", "CONFIRMING").contains(link.preloadStatus())) {
throw conflict("预装清单已确认或当前状态不允许确认");
}
var booking = smartLoadingRepository.booking(link.containerBookingId());
if (booking == null || !StringUtils.hasText(booking.getContainerNo())) {
throw conflict("确认预装前必须录入真实集装箱号");
}
var allocation = oceanBookingRepository.allocationByContainer(link.containerBookingId());
if (allocation == null) throw conflict("确认预装前必须关联真实订舱柜");
var oceanBooking = oceanBookingRepository.find(allocation.getOceanBookingId());
@@ -462,6 +460,7 @@ public class LoadingPickService {
requireSameCompletionCommand(replay, taskId, requestHash, operatorId);
if ("FAILED".equals(replay.getCommandStatus())) {
LoadingPickTaskEntity failed = requiredTaskForUpdate(taskId);
requireCompletionContainerNumber(taskId);
if (!"COMPLETION_FAILED".equals(failed.getTaskStatus())
|| !completionCommands.resume(replay.getId()) || repository.resumeCompletion(taskId) != 1) {
throw conflict("装柜完成命令状态已变化,请刷新后重试");
@@ -485,6 +484,7 @@ public class LoadingPickService {
if (!request.expectedTaskVersion().equals(task.getVersion())) {
throw conflict("PDA任务版本已变化,请刷新后复核");
}
requireCompletionContainerNumber(taskId);
if (!repository.allTaskItemsResolved(taskId)) {
throw conflict("存在尚未处理的PDA拣货明细,不能确认装柜完成");
}
@@ -514,6 +514,18 @@ public class LoadingPickService {
return view(requiredTask(taskId), null);
}
private void requireCompletionContainerNumber(long taskId) {
ContainerBookingEntity booking = repository.activeBookingByTask(taskId);
if (booking == null || !StringUtils.hasText(booking.getContainerNo())) {
throw conflict("PDA确认装柜完成前必须录入真实集装箱号");
}
try {
Iso6346ContainerNumber.require(booking.getContainerNo());
} catch (BizException exception) {
throw conflict("PDA确认装柜完成前必须录入符合ISO 6346的真实集装箱号");
}
}
/** Called only by the completion worker, after its durable lease has committed. */
public void processCompletion(LoadingCompletionCommandEntity command, String worker) {
long taskId = command.getPickTaskId();
@@ -47,15 +47,15 @@ public class TruckAppointmentService {
@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 TruckAppointmentEntity detail(long id){return enrich(required(id));}
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 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("改约请使用专用接口"); TruckAppointmentStatus from=e.getStatus(),to=next(from,action); String dispatchBill="DISPATCH".equals(action)?requireDispatchReady(e):null; int changed="DISPATCH".equals(action)?repo.dispatch(id,from.name(),dispatchBill,e.getVersion()):repo.transition(id,from.name(),to.name(),e.getVersion()); if(changed!=1)throw conflict("状态或版本已变化,请刷新后重试"); TruckAppointmentEntity after=required(id);record(after,from.name(),to.name(),action,r.reason(),uid,name);return completeOperation(id,action,idem,after);}
@Transactional public TruckAppointmentEntity reschedule(long id,LocalDateTime at,ActionRequest r,String idem,String clientHash,long uid,String name){String action="RESCHEDULE";String requestHash=hash(action,id,new ReschedulePayload(at,r));verifyClientHash(clientHash,requestHash);TruckAppointmentEntity replay=claimOrReplay(id,action,idem,requestHash);if(replay!=null)return replay;TruckAppointmentEntity e=required(id);checkVersion(e,r.expectedVersion()); if(e.getStatus()==TruckAppointmentStatus.COMPLETED||e.getStatus()==TruckAppointmentStatus.CANCELLED)throw conflict("当前状态不可改约"); String from=e.getStatus().name(); if(repo.reschedule(id,at,r.reason(),e.getVersion())!=1)throw conflict("状态或版本已变化,请刷新后重试");TruckAppointmentEntity after=required(id);record(after,from,"RESCHEDULED",action,r.reason(),uid,name);return completeOperation(id,action,idem,after);}
@Transactional public TruckAppointmentEntity action(long id,String action,ActionRequest r,String idem,String clientHash,long uid,String name){String requestHash=hash(action,id,r);verifyClientHash(clientHash,requestHash);TruckAppointmentEntity replay=claimOrReplay(id,action,idem,requestHash);if(replay!=null)return replay;TruckAppointmentEntity e=required(id);checkVersion(e,r.expectedVersion());if("RESCHEDULE".equals(action))throw conflict("改约请使用专用接口");if(!"PICKUP".equals(action)&&StringUtils.hasText(r.containerNo()))throw bad("containerNo仅PICKUP动作可填写");TruckAppointmentStatus from=e.getStatus(),to=next(from,action);String dispatchBill="DISPATCH".equals(action)?requireDispatchReady(e):null;if("PICKUP".equals(action))bindContainerNoForPickup(e,r.containerNo(),uid,name);int changed="DISPATCH".equals(action)?repo.dispatch(id,from.name(),dispatchBill,e.getVersion()):repo.transition(id,from.name(),to.name(),e.getVersion());if(changed!=1)throw conflict("状态或版本已变化,请刷新后重试");TruckAppointmentEntity after=required(id);record(after,from.name(),to.name(),action,r.reason(),uid,name);return completeOperation(id,action,idem,after);}
@Transactional public TruckAppointmentEntity reschedule(long id,LocalDateTime at,ActionRequest r,String idem,String clientHash,long uid,String name){if(StringUtils.hasText(r.containerNo()))throw bad("containerNo仅PICKUP动作可填写");String action="RESCHEDULE";String requestHash=hash(action,id,new ReschedulePayload(at,r));verifyClientHash(clientHash,requestHash);TruckAppointmentEntity replay=claimOrReplay(id,action,idem,requestHash);if(replay!=null)return replay;TruckAppointmentEntity e=required(id);checkVersion(e,r.expectedVersion()); if(e.getStatus()==TruckAppointmentStatus.COMPLETED||e.getStatus()==TruckAppointmentStatus.CANCELLED)throw conflict("当前状态不可改约"); String from=e.getStatus().name(); if(repo.reschedule(id,at,r.reason(),e.getVersion())!=1)throw conflict("状态或版本已变化,请刷新后重试");TruckAppointmentEntity after=required(id);record(after,from,"RESCHEDULED",action,r.reason(),uid,name);return completeOperation(id,action,idem,after);}
@Transactional(propagation=Propagation.REQUIRES_NEW) public void completeLoadingFromPda(long containerBookingId,long taskId,long uid,String name){java.util.List<TruckAppointmentEntity> active=repo.activeForUpdate(containerBookingId);if(active.isEmpty())return;if(active.size()!=1)throw conflict("同一柜次存在多条有效拖车预约,跳过自动完成");TruckAppointmentEntity appointment=active.get(0);String from=appointment.getStatus().name();if(repo.completeLoadingFromPda(appointment.getId(),from,appointment.getVersion())!=1)throw conflict("拖车预约已被并发修改,跳过自动完成");TruckAppointmentEntity after=required(appointment.getId());record(after,from,TruckAppointmentStatus.COMPLETED.name(),"PDA_COMPLETE_LOADING","PDA task "+taskId+" 确认装柜完成",uid,name);}
private AssociationContext validateAssociations(SaveRequest r){
OceanBookingEntity booking=oceanBookings.find(r.oceanBookingId());
@@ -70,9 +70,8 @@ public class TruckAppointmentService {
long containerId=container.getId();
boolean allocated=oceanBookings.allocations(booking.getId()).stream().anyMatch(a->Objects.equals(a.getContainerBookingId(),containerId));
if(!allocated)throw bad("containerBookingId未分配至该订舱");
Iso6346ContainerNumber.require(container.getContainerNo());
oceanBookingDocuments.requireActiveValidDocument(booking);
if(r.loadingPlanId()!=null){LoadingPlanEntity plan=loadingPlans.planByBooking(container.getId());if(plan==null||!Objects.equals(r.loadingPlanId(),plan.getId()))throw bad("loadingPlanId与柜次当前装柜计划不一致");}
LoadingPlanEntity plan=loadingPlans.planByBooking(container.getId());if(plan!=null&&"COMPLETED".equals(plan.getPlanStatus()))throw conflict("关联柜次已装柜完成,不能新建或修改拖车预约");if(r.loadingPlanId()!=null&&(plan==null||!Objects.equals(r.loadingPlanId(),plan.getId())))throw bad("loadingPlanId与柜次当前装柜计划不一致");
return new AssociationContext(booking,container,warehouseId,billOfLadingNo);
}
private static void validateRequired(SaveRequest r){if(r.oceanBookingId()==null||r.oceanBookingId()<=0)throw bad("oceanBookingId不能为空");if(r.containerBookingId()==null||r.containerBookingId()<=0)throw bad("containerBookingId不能为空");if(r.carrierId()==null||r.carrierId()<=0)throw bad("carrierId不能为空");if(r.plannedLoadingAt()==null)throw bad("plannedLoadingAt不能为空");if(r.expectedVersion()==null||r.expectedVersion()<0)throw bad("expectedVersion不合法");}
@@ -95,12 +94,12 @@ public class TruckAppointmentService {
String bill=releasedBookingBill(booking,trim(appointment.getBillOfLadingNo()));
ContainerBookingEntity container=containers.find(appointment.getContainerBookingId());
if(container==null||!"ACTIVE".equals(container.getRecordStatus())||!Objects.equals(container.getOceanBookingId(),booking.getId()))throw conflict("关联柜次已失效或不属于当前订舱");
Iso6346ContainerNumber.require(container.getContainerNo());
boolean allocated=oceanBookings.allocations(booking.getId()).stream().anyMatch(a->Objects.equals(a.getContainerBookingId(),container.getId()));
if(!allocated)throw conflict("关联柜次未分配至当前订舱");
oceanBookingDocuments.requireActiveValidDocument(booking);
return bill;
}
private void bindContainerNoForPickup(TruckAppointmentEntity appointment,String requested,long operatorId,String operatorName){if(appointment.getContainerBookingId()==null)throw conflict("拖车预约缺少柜次关联");ContainerBookingEntity container=containers.findActiveForUpdate(appointment.getContainerBookingId());if(container==null||!Objects.equals(container.getOceanBookingId(),appointment.getOceanBookingId()))throw conflict("关联柜次已失效或不属于当前订舱");String existing=trim(container.getContainerNo());String supplied=trim(requested);if(StringUtils.hasText(existing)){String normalizedExisting=Iso6346ContainerNumber.require(existing);if(StringUtils.hasText(supplied)&&!normalizedExisting.equals(Iso6346ContainerNumber.require(supplied)))throw conflict("PICKUP提交的真实集装箱号与柜次已有号码不一致,请通过预装纠错修改");return;}if(!StringUtils.hasText(supplied))return;String normalized=Iso6346ContainerNumber.require(supplied);if(containers.containerNoExists(normalized,container.getId()))throw conflict("真实集装箱号已被其他柜次使用");try{if(repo.bindContainerNoForPickup(container.getId(),normalized,operatorId,operatorName)!=1)throw conflict("柜次真实集装箱号已被并发修改,请刷新后重试");}catch(DuplicateKeyException exception){throw conflict("真实集装箱号已被其他柜次使用");}}
private static String releasedBookingBill(OceanBookingEntity booking,String requested){String authoritative=trim(booking.getBillOfLadingNo());if(!StringUtils.hasText(authoritative))throw conflict("关联已放舱订舱缺少提单号");if(StringUtils.hasText(requested)&&!authoritative.equals(requested))throw bad("billOfLadingNo与关联订舱不一致");return authoritative;}
private static String attachmentType(String value){String normalized=trim(value);if(!ATTACHMENT_TYPES.contains(normalized))throw bad("不支持的拖车附件类型");return normalized;}
private static String currency(String value){if(!StringUtils.hasText(value))return "RMB";String normalized=value.trim();if(!CURRENCIES.contains(normalized))throw bad("currency仅支持RMB或USD");return normalized;}