优化
This commit is contained in:
@@ -26,5 +26,7 @@ public final class SmartLoadingDtos { private SmartLoadingDtos(){}
|
||||
public record DeleteRequest(@NotNull @Min(0) Integer expectedPlanVersion,
|
||||
@NotNull @Min(0) Integer expectedItemVersion,@NotBlank @Size(max=128) String operationId) {}
|
||||
public record PushRequest(@NotNull @Min(0) Integer expectedVersion) {}
|
||||
public record Plan(Long id,String planNo,String status,Long bookingId,String containerNo,String voyageNo,Integer version,List<LoadingPlanItemEntity> items) {}
|
||||
public record Plan(Long id,String planNo,String status,Long bookingId,String containerNo,String voyageNo,
|
||||
Integer version,Boolean canAssign,Boolean canEdit,String blockedReason,String finalCompletionStatus,
|
||||
Long pdaTaskId,String pdaTaskStatus,Integer pdaTaskVersion,List<LoadingPlanItemEntity> items) {}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@ 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 customerId, @Positive Long containerBookingId, @Min(1) long page, @Min(1) @Max(200) long size){ public Query{if(page==0)page=1;if(size==0)size=20;} }
|
||||
public record SaveRequest(@Positive Long outboundOrderId,@Positive Long oceanBookingId,@Positive Long containerBookingId,@Positive Long loadingPlanId,@Positive Long warehouseId,@Positive Long customerId,@Size(max=128) String customerName,@Positive Long carrierId,@Size(max=128) String carrierName,@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,@DecimalMin("0") BigDecimal receivableAmount,@Size(max=8) String currency,@Size(max=1000) String remark,@NotNull @PositiveOrZero Integer expectedVersion){}
|
||||
public record SaveRequest(@Positive Long outboundOrderId,@NotNull @Positive Long oceanBookingId,@NotNull @Positive Long containerBookingId,@Positive Long loadingPlanId,@Positive Long warehouseId,@Positive Long customerId,@Size(max=128) String customerName,@Positive Long carrierId,@Size(max=128) String carrierName,@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,@DecimalMin("0") BigDecimal receivableAmount,@Size(max=8) 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,@Size(max=64) String attachmentType,@Size(max=255) String fileName,@Size(max=128) String fileHash){ }
|
||||
}
|
||||
|
||||
@@ -10,6 +10,11 @@ public class LoadingPlanItemEntity {
|
||||
private String fbaTrackingNo; private String shipmentNo; private String shippingMark; private Integer loadSequence; private String positionSnapshot;
|
||||
private String internalRemark; private String customerRemark; private String warehouseRemark; private LocalDateTime frozenAt; private Long frozenBy;
|
||||
private Long removedBy; private String removedByName; private LocalDateTime removedAt; @TableLogic private Boolean deleted;
|
||||
@TableField(exist=false) private Long activeInboundDetailId; private Long createdBy; private String createdByName; private LocalDateTime createdAt;
|
||||
@TableField(exist=false) private Long activeInboundDetailId;
|
||||
@TableField(exist=false) private Integer pickedPackageCount;
|
||||
@TableField(exist=false) private Integer minPackageCount;
|
||||
@TableField(exist=false) private Boolean canAdjust;
|
||||
@TableField(exist=false) private Boolean canRemove;
|
||||
private Long createdBy; private String createdByName; private LocalDateTime createdAt;
|
||||
private Long updatedBy; private String updatedByName; private LocalDateTime updatedAt; @Version private Integer version;
|
||||
}
|
||||
|
||||
+12
@@ -39,6 +39,18 @@ public interface LoadingModelBridgeMapper extends BaseMapper<PreloadLoadingPlanL
|
||||
@Param("inboundId") long inboundId, @Param("expectedVersion") int expectedVersion,
|
||||
@Param("operatorId") long operatorId, @Param("operatorName") String operatorName);
|
||||
|
||||
@Update("UPDATE fl_preload_plan SET status='CONFIRMED',confirmed_by=#{operatorId},"
|
||||
+ "confirmed_by_name=#{operatorName},confirmed_at=NOW(),updated_by=#{operatorId},"
|
||||
+ "updated_by_name=#{operatorName},version=version+1 WHERE id=#{preloadPlanId} "
|
||||
+ "AND tenant_id=#{tenantId} AND inbound_id=#{inboundId} AND status='PENDING' "
|
||||
+ "AND version=#{expectedVersion}")
|
||||
int confirmIncrementalPreload(@Param("preloadPlanId") long preloadPlanId,
|
||||
@Param("tenantId") long tenantId,
|
||||
@Param("inboundId") long inboundId,
|
||||
@Param("expectedVersion") int expectedVersion,
|
||||
@Param("operatorId") long operatorId,
|
||||
@Param("operatorName") String operatorName);
|
||||
|
||||
@Update("UPDATE fl_loading_plan SET updated_by=#{operatorId},updated_by_name=#{operatorName},"
|
||||
+ "version=version+1 WHERE id=#{loadingPlanId} AND plan_status='EDITING' "
|
||||
+ "AND version=#{expectedVersion}")
|
||||
|
||||
+8
@@ -42,11 +42,19 @@ public class LoadingModelBridgeRepository {
|
||||
|
||||
public void insert(PreloadLoadingPlanLinkEntity value) { mapper.insert(value); }
|
||||
|
||||
public void delete(PreloadLoadingPlanLinkEntity value) { mapper.deleteById(value); }
|
||||
|
||||
public int touchPreload(long preloadPlanId, long tenantId, long inboundId, int version,
|
||||
long operatorId, String operatorName) {
|
||||
return mapper.touchPreload(preloadPlanId, tenantId, inboundId, version, operatorId, operatorName);
|
||||
}
|
||||
|
||||
public int confirmIncrementalPreload(long preloadPlanId, long tenantId, long inboundId, int version,
|
||||
long operatorId, String operatorName) {
|
||||
return mapper.confirmIncrementalPreload(preloadPlanId, tenantId, inboundId, version,
|
||||
operatorId, operatorName);
|
||||
}
|
||||
|
||||
public int touchLoadingPlan(long loadingPlanId, int version, long operatorId, String operatorName) {
|
||||
return mapper.touchLoadingPlan(loadingPlanId, version, operatorId, operatorName);
|
||||
}
|
||||
|
||||
+51
@@ -109,6 +109,57 @@ public class LoadingModelBridgeService {
|
||||
return repository.byLoadingPlan(loadingPlanId);
|
||||
}
|
||||
|
||||
/** Adds a newly allocated inbound to an already pushed EDITING/LOADING plan. */
|
||||
@Transactional
|
||||
public void bindIncrementalAllocation(long loadingPlanId, long tenantId, long inboundId,
|
||||
long operatorId, String operatorName, String operationId) {
|
||||
if (repository.byPair(tenantId, inboundId, loadingPlanId) != null) return;
|
||||
var plan = repository.loadingPlan(loadingPlanId);
|
||||
if (plan == null) throw new BizException(ResultCode.NOT_FOUND, "柜次装柜计划不存在");
|
||||
if (plan.preloadPushedAt() == null) return;
|
||||
if (!Set.of("EDITING", "LOADING").contains(plan.status())) {
|
||||
throw conflict("PDA任务正在最终确认或已经完成,不能新增预装关联");
|
||||
}
|
||||
if (repository.allocatedPackages(tenantId, inboundId, loadingPlanId) <= 0) {
|
||||
throw conflict("柜次计划尚未分配当前入库单货物");
|
||||
}
|
||||
var preload = repository.preload(tenantId, inboundId);
|
||||
if (preload == null) throw new BizException(ResultCode.NOT_FOUND, "入库单预装计划不存在");
|
||||
if (!Set.of("PENDING", "CONFIRMED").contains(preload.status())) {
|
||||
throw conflict("新增入库单预装状态不允许进入当前柜次");
|
||||
}
|
||||
PreloadLoadingPlanLinkEntity link = new PreloadLoadingPlanLinkEntity();
|
||||
link.setTenantId(tenantId); link.setInboundId(inboundId); link.setPreloadPlanId(preload.id());
|
||||
link.setLoadingPlanId(loadingPlanId);
|
||||
link.setIdempotencyKey("INCREMENTAL:" + hash(operationId + "|" + tenantId + "|" + inboundId
|
||||
+ "|" + loadingPlanId).substring(0, 52));
|
||||
link.setRequestHash(hash("INCREMENTAL|" + tenantId + "|" + inboundId + "|" + loadingPlanId));
|
||||
link.setCreatedBy(operatorId); link.setCreatedByName(operatorName);
|
||||
try {
|
||||
repository.insert(link);
|
||||
} catch (DuplicateKeyException exception) {
|
||||
if (repository.byPair(tenantId, inboundId, loadingPlanId) == null) {
|
||||
throw conflict("新增入库单预装关联并发冲突,请刷新后重试");
|
||||
}
|
||||
}
|
||||
// A running PDA task has already passed plan-level confirmation. Adding an inbound is the
|
||||
// explicit Admin approval for that new allocation, so its fresh PENDING preload joins the
|
||||
// same confirmed plan atomically instead of leaving an unreachable pending aggregate.
|
||||
if ("LOADING".equals(plan.status()) && "PENDING".equals(preload.status())
|
||||
&& repository.confirmIncrementalPreload(preload.id(), tenantId, inboundId,
|
||||
preload.version(), operatorId, operatorName) != 1) {
|
||||
throw conflict("新增入库单预装确认并发冲突,请刷新后重试");
|
||||
}
|
||||
}
|
||||
|
||||
/** Removes only a now-empty allocation link; picked-item protection is enforced by the PDA task lock. */
|
||||
@Transactional
|
||||
public void removeIfUnallocated(long loadingPlanId, long tenantId, long inboundId) {
|
||||
if (repository.allocatedPackages(tenantId, inboundId, loadingPlanId) > 0) return;
|
||||
PreloadLoadingPlanLinkEntity link = repository.byPair(tenantId, inboundId, loadingPlanId);
|
||||
if (link != null) repository.delete(link);
|
||||
}
|
||||
|
||||
public List<Link> byInbound(long tenantId, long inboundId) { return repository.byInbound(tenantId, inboundId); }
|
||||
public List<Link> byLoadingPlan(long loadingPlanId) { return repository.byLoadingPlan(loadingPlanId); }
|
||||
|
||||
|
||||
+1
-1
@@ -15,7 +15,7 @@ public interface LoadingCompletionGateMapper {
|
||||
WHERE a.container_booking_id=p.container_booking_id AND b.status='RELEASED') THEN 1 ELSE 0 END),0) oceanReady,
|
||||
COALESCE(SUM(CASE WHEN EXISTS (
|
||||
SELECT 1 FROM fl_truck_appointment t
|
||||
WHERE t.container_booking_id=p.container_booking_id AND t.loading_plan_id=p.id
|
||||
WHERE t.container_booking_id=p.container_booking_id
|
||||
AND t.status='COMPLETED') THEN 1 ELSE 0 END),0) truckReady
|
||||
FROM fl_preload_loading_plan_link l
|
||||
JOIN fl_loading_plan p ON p.id=l.loading_plan_id
|
||||
|
||||
@@ -11,6 +11,10 @@ public interface LoadingInboundMapper {
|
||||
+ "FROM fl_inbound_order WHERE tenant_id=#{tenantId} AND id=#{inboundId} AND deleted=0")
|
||||
Snapshot find(@Param("tenantId") long tenantId, @Param("inboundId") long inboundId);
|
||||
|
||||
@Select("SELECT id, tenant_id AS tenantId, process_status AS processStatus, version "
|
||||
+ "FROM fl_inbound_order WHERE tenant_id=#{tenantId} AND id=#{inboundId} AND deleted=0 FOR UPDATE")
|
||||
Snapshot findForUpdate(@Param("tenantId") long tenantId, @Param("inboundId") long inboundId);
|
||||
|
||||
@Update("UPDATE fl_inbound_order SET process_status='LOADING', updated_by=#{operatorId}, "
|
||||
+ "version=version+1 WHERE tenant_id=#{tenantId} AND id=#{inboundId} AND deleted=0 "
|
||||
+ "AND process_status='WAIT_SPLIT' AND version=#{expectedVersion}")
|
||||
|
||||
+14
@@ -121,6 +121,20 @@ public class LoadingDeliveryService {
|
||||
new LoadingActionResponse("LOADED", evidenceResponse(tenantId, inboundId)));
|
||||
}
|
||||
|
||||
/**
|
||||
* Read-only preflight used before PDA settlement. The current plan may still be LOADING here;
|
||||
* every external/business hard gate must pass before the PDA task is mutated.
|
||||
*/
|
||||
public void requirePdaCompletionReady(long tenantId, long inboundId) {
|
||||
Snapshot inbound = requireInbound(tenantId, inboundId);
|
||||
if (!"LOADING".equals(inbound.processStatus())) {
|
||||
throw new BizException(ResultCode.CONFLICT, "PDA确认装柜完成前订单必须处于LOADING");
|
||||
}
|
||||
loadingModelBridge.requireStarted(tenantId, inboundId);
|
||||
completionGate.requireBookingAndTransport(tenantId, inboundId);
|
||||
requireCompletionEvidence(tenantId, inboundId);
|
||||
}
|
||||
|
||||
private LoadingActionResponse completeFulfillmentSlice(long tenantId, long evidenceInboundId,
|
||||
long operatorId, String operatorName, String key,
|
||||
CompleteLoadingRequest request,
|
||||
|
||||
@@ -20,16 +20,4 @@ import com.baomidou.mybatisplus.core.mapper.BaseMapper; import com.baiyue.firstl
|
||||
WHERE id=#{planId} AND plan_status='EDITING' AND preload_pushed_at IS NOT NULL
|
||||
""")
|
||||
int startPicking(@Param("planId") long planId,@Param("operatorId") long operatorId,@Param("operatorName") String operatorName);
|
||||
@Update("""
|
||||
UPDATE fl_loading_plan plan SET plan_status='EDITING',updated_by=#{operatorId},updated_by_name=#{operatorName},
|
||||
updated_at=CURRENT_TIMESTAMP(3),version=version+1
|
||||
WHERE plan.id=#{planId} AND plan.plan_status='LOADING' AND plan.version=#{expectedVersion}
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM fl_loading_pick_item pick_item
|
||||
JOIN fl_loading_pick_task pick_task ON pick_task.id=pick_item.pick_task_id
|
||||
WHERE pick_task.loading_plan_id=plan.id AND pick_item.picked_package_count>0
|
||||
)
|
||||
""")
|
||||
int recycleUnpicked(@Param("planId") long planId,@Param("expectedVersion") int expectedVersion,
|
||||
@Param("operatorId") long operatorId,@Param("operatorName") String operatorName);
|
||||
}
|
||||
|
||||
@@ -4,5 +4,5 @@ import com.baomidou.mybatisplus.core.mapper.BaseMapper; import com.baomidou.myba
|
||||
@Select("<script>SELECT * FROM fl_truck_appointment WHERE (#{q.keyword} IS NULL OR appointment_no LIKE CONCAT('%',#{q.keyword},'%') OR vehicle_no LIKE CONCAT('%',#{q.keyword},'%') OR carrier_name LIKE CONCAT('%',#{q.keyword},'%')) AND (#{q.status} IS NULL OR status=#{q.status}) AND (#{q.customerId} IS NULL OR customer_id=#{q.customerId}) AND (#{q.containerBookingId} IS NULL OR container_booking_id=#{q.containerBookingId}) ORDER BY updated_at DESC,id DESC</script>") Page<TruckAppointmentEntity> page(Page<TruckAppointmentEntity> p,@Param("q") Query q);
|
||||
@Update("UPDATE fl_truck_appointment SET status=#{toStatus}, actual_arrived_warehouse_at=CASE WHEN #{toStatus}='ARRIVED_WAREHOUSE' THEN CURRENT_TIMESTAMP(3) ELSE actual_arrived_warehouse_at END, actual_loading_at=CASE WHEN #{toStatus}='LOADING' THEN CURRENT_TIMESTAMP(3) ELSE actual_loading_at END, actual_departed_at=CASE WHEN #{toStatus}='DEPARTED' THEN CURRENT_TIMESTAMP(3) ELSE actual_departed_at END, revision=revision+1, version=version+1, updated_at=CURRENT_TIMESTAMP(3) WHERE id=#{id} AND status=#{fromStatus} AND version=#{version}") int transition(@Param("id") long id,@Param("fromStatus") String from,@Param("toStatus") String to,@Param("version") int version);
|
||||
@Update("UPDATE fl_truck_appointment SET 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 vehicle_no=#{e.vehicleNo},driver_name=#{e.driverName},driver_phone=#{e.driverPhone},payable_amount=#{e.payableAmount},receivable_amount=#{e.receivableAmount},planned_loading_at=#{e.plannedLoadingAt},remark=#{e.remark},version=version+1,revision=revision+1,updated_at=CURRENT_TIMESTAMP(3) WHERE id=#{e.id} AND version=#{version}") int updateEdit(@Param("e") TruckAppointmentEntity e,@Param("version") int version);
|
||||
@Update("UPDATE fl_truck_appointment SET ocean_booking_id=#{e.oceanBookingId},container_booking_id=#{e.containerBookingId},loading_plan_id=#{e.loadingPlanId},warehouse_id=#{e.warehouseId},customer_id=#{e.customerId},customer_name=#{e.customerName},carrier_id=#{e.carrierId},carrier_name=#{e.carrierName},vehicle_no=#{e.vehicleNo},driver_name=#{e.driverName},driver_phone=#{e.driverPhone},payable_amount=#{e.payableAmount},receivable_amount=#{e.receivableAmount},planned_loading_at=#{e.plannedLoadingAt},remark=#{e.remark},version=version+1,revision=revision+1,updated_at=CURRENT_TIMESTAMP(3) WHERE id=#{e.id} AND version=#{version}") int updateEdit(@Param("e") TruckAppointmentEntity e,@Param("version") int version);
|
||||
}
|
||||
|
||||
@@ -225,6 +225,10 @@ public class LoadingPickRepository {
|
||||
return inbounds.find(tenantId, inboundId);
|
||||
}
|
||||
|
||||
public LoadingInboundMapper.Snapshot inboundForUpdate(long tenantId, long inboundId) {
|
||||
return inbounds.findForUpdate(tenantId, inboundId);
|
||||
}
|
||||
|
||||
public int beginInboundLoading(long tenantId, long inboundId, long operatorId, int expectedVersion) {
|
||||
return inbounds.beginLoading(tenantId, inboundId, operatorId, expectedVersion);
|
||||
}
|
||||
|
||||
@@ -75,7 +75,4 @@ public class SmartLoadingRepository {
|
||||
public int startPicking(long planId, long operatorId, String operatorName) {
|
||||
return plans.startPicking(planId, operatorId, operatorName);
|
||||
}
|
||||
public int recycleUnpicked(long planId, int expectedVersion, long operatorId, String operatorName) {
|
||||
return plans.recycleUnpicked(planId, expectedVersion, operatorId, operatorName);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -32,10 +32,13 @@ import com.baiyue.firstleg.entity.LoadingPlanItemEntity;
|
||||
import com.baiyue.firstleg.entity.LoadingPlanHistoryEntity;
|
||||
import com.baiyue.firstleg.entity.InboundStatusHistoryEntity;
|
||||
import com.baiyue.firstleg.loading.mapper.LoadingInboundMapper;
|
||||
import com.baiyue.firstleg.loading.service.LoadingDeliveryService;
|
||||
import com.baiyue.firstleg.loading.dto.LoadingRequests;
|
||||
import com.baiyue.firstleg.loading.bridge.service.LoadingModelBridgeService;
|
||||
import com.baiyue.firstleg.loading.bridge.dto.LoadingModelBridgeDtos.Link;
|
||||
import com.baiyue.firstleg.mapper.InboundStatusHistoryMapper;
|
||||
import com.baiyue.firstleg.mapper.LoadingPickItemMapper.CartonLabelRow;
|
||||
import com.baiyue.firstleg.mapper.LoadingPickItemMapper;
|
||||
import com.baiyue.firstleg.repository.LoadingPickRepository;
|
||||
import com.baiyue.firstleg.repository.SmartLoadingRepository;
|
||||
import com.baiyue.firstleg.oceanbooking.repository.OceanBookingRepository;
|
||||
@@ -56,6 +59,7 @@ import java.util.ArrayList;
|
||||
import java.util.Comparator;
|
||||
import java.util.Map;
|
||||
import java.util.HashMap;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Objects;
|
||||
import java.util.Set;
|
||||
import java.util.stream.Collectors;
|
||||
@@ -77,6 +81,8 @@ public class LoadingPickService {
|
||||
@Resource
|
||||
private LoadingListService loadingListService;
|
||||
@Resource
|
||||
private LoadingDeliveryService loadingDeliveryService;
|
||||
@Resource
|
||||
private InboundStatusHistoryMapper inboundHistories;
|
||||
@Resource
|
||||
private PdaPickSettlementService settlementService;
|
||||
@@ -159,11 +165,38 @@ public class LoadingPickService {
|
||||
@Transactional
|
||||
public void sync(long planId, LoadingPlanItemEntity planItem) {
|
||||
LoadingPickTaskEntity task = repository.taskByPlan(planId);
|
||||
if (task != null && "LOADING".equals(task.getTaskStatus())) {
|
||||
syncItem(task.getId(), planItem);
|
||||
if (task == null) return;
|
||||
task = repository.taskForUpdate(task.getId());
|
||||
requireMutableTask(task);
|
||||
syncItem(task.getId(), planItem);
|
||||
if (repository.touchTask(task.getId(), task.getVersion()) != 1) {
|
||||
throw conflict("PDA任务版本已变化,装柜明细同步失败");
|
||||
}
|
||||
}
|
||||
|
||||
/** Shared lock used by Admin plan mutations and the final PDA completion transaction. */
|
||||
@Transactional
|
||||
public void lockMutable(long planId) {
|
||||
LoadingPickTaskEntity task = repository.taskByPlan(planId);
|
||||
if (task == null) return;
|
||||
requireMutableTask(repository.taskForUpdate(task.getId()));
|
||||
}
|
||||
|
||||
/** Read model used by the Admin plan response; mutations still lock the task row separately. */
|
||||
public MutationSnapshot mutationSnapshot(long planId) {
|
||||
LoadingPickTaskEntity task = repository.taskByPlan(planId);
|
||||
if (task == null) return new MutationSnapshot(null, null, null, Map.of());
|
||||
Map<Long, Integer> pickedByPlanItem = repository.items(task.getId()).stream()
|
||||
.filter(item -> item.getLoadingPlanItemId() != null)
|
||||
.collect(Collectors.toMap(LoadingPickItemEntity::getLoadingPlanItemId,
|
||||
item -> count(item.getPickedPackageCount()), Math::max));
|
||||
return new MutationSnapshot(task.getId(), task.getTaskStatus(), task.getVersion(), pickedByPlanItem);
|
||||
}
|
||||
|
||||
public record MutationSnapshot(Long taskId, String taskStatus, Integer taskVersion,
|
||||
Map<Long, Integer> pickedByPlanItem) {
|
||||
}
|
||||
|
||||
public Task task(String cabinetNo, Long loadingPlanId, long warehouseId) {
|
||||
int selectors = (StringUtils.hasText(cabinetNo) ? 1 : 0)
|
||||
+ (loadingPlanId != null ? 1 : 0);
|
||||
@@ -412,6 +445,19 @@ public class LoadingPickService {
|
||||
throw conflict("存在尚未处理的PDA拣货明细,不能确认装柜完成");
|
||||
}
|
||||
if (repository.review(taskId) != null) throw conflict("PDA任务已经确认装柜完成");
|
||||
List<LoadingPickItemMapper.InboundOwner> owners = repository.inboundOwners(taskId).stream()
|
||||
.filter(owner -> owner.tenantId() != null && owner.inboundId() != null)
|
||||
.sorted(Comparator.comparing(LoadingPickItemMapper.InboundOwner::tenantId)
|
||||
.thenComparing(LoadingPickItemMapper.InboundOwner::inboundId))
|
||||
.toList();
|
||||
if (owners.isEmpty()) throw conflict("PDA任务未关联有效入库单");
|
||||
Map<LoadingPickItemMapper.InboundOwner, LoadingInboundMapper.Snapshot> inboundSnapshots = new LinkedHashMap<>();
|
||||
for (LoadingPickItemMapper.InboundOwner owner : owners) {
|
||||
LoadingInboundMapper.Snapshot snapshot = repository.inboundForUpdate(owner.tenantId(), owner.inboundId());
|
||||
if (snapshot == null) throw conflict("PDA任务关联入库单不存在");
|
||||
loadingDeliveryService.requirePdaCompletionReady(owner.tenantId(), owner.inboundId());
|
||||
inboundSnapshots.put(owner, snapshot);
|
||||
}
|
||||
settlementService.settle(taskId, operatorId, operatorName);
|
||||
if (!repository.allPlanItemsPicked(task.getLoadingPlanId())) {
|
||||
throw conflict("柜次仍有未完成PDA拣货的箱数");
|
||||
@@ -422,6 +468,13 @@ public class LoadingPickService {
|
||||
insertOperation(taskId, operationId, requestHash, "COMPLETE_LOADING", operatorId, operatorName);
|
||||
loadingListService.finishFromPda(task.getLoadingPlanId(), taskId, operatorId, operatorName);
|
||||
repository.syncInboundProjections(taskId, operatorId, operatorName);
|
||||
for (Map.Entry<LoadingPickItemMapper.InboundOwner, LoadingInboundMapper.Snapshot> entry
|
||||
: inboundSnapshots.entrySet()) {
|
||||
LoadingPickItemMapper.InboundOwner owner = entry.getKey();
|
||||
loadingDeliveryService.completeLoading(owner.tenantId(), owner.inboundId(), operatorId,
|
||||
operatorName, "PDA-" + operationId + '-' + owner.inboundId(),
|
||||
new LoadingRequests.CompleteLoadingRequest(entry.getValue().version(), remark));
|
||||
}
|
||||
return view(requiredTask(taskId), null);
|
||||
}
|
||||
|
||||
@@ -438,10 +491,21 @@ public class LoadingPickService {
|
||||
if (item == null) {
|
||||
return;
|
||||
}
|
||||
LoadingPickTaskEntity task = repository.taskForUpdate(item.getPickTaskId());
|
||||
requireMutableTask(task);
|
||||
if (item.getPickedPackageCount() != null && item.getPickedPackageCount() > 0) {
|
||||
throw conflict("已有拣货记录的明细不能删除");
|
||||
}
|
||||
repository.deleteItem(item.getId());
|
||||
if (repository.touchTask(task.getId(), task.getVersion()) != 1) {
|
||||
throw conflict("PDA任务版本已变化,装柜明细删除失败");
|
||||
}
|
||||
}
|
||||
|
||||
private static void requireMutableTask(LoadingPickTaskEntity task) {
|
||||
if (task == null || !"LOADING".equals(task.getTaskStatus())) {
|
||||
throw conflict("PDA任务正在最终确认或已经完成,不能修改预装明细");
|
||||
}
|
||||
}
|
||||
|
||||
private void syncItem(long taskId, LoadingPlanItemEntity planItem) {
|
||||
|
||||
@@ -130,10 +130,12 @@ public class SmartLoadingService {
|
||||
if (repository.update(existing) != 1) throw conflict("装柜草稿已被并发修改");
|
||||
changed = existing;
|
||||
}
|
||||
history(planId, changed.getId(), "ADD_ITEM", "EDITING", "EDITING", operation,
|
||||
history(planId, changed.getId(), "ADD_ITEM", plan.getPlanStatus(), plan.getPlanStatus(), operation,
|
||||
json(values("version", request.expectedVersion())),
|
||||
json(values("requestHash", requestHash, "itemId", changed.getId(),
|
||||
"packageCount", changed.getPackageCount())), operatorId, operatorName);
|
||||
loadingModelBridge.bindIncrementalAllocation(planId, candidate.accountId(), candidate.inboundId(),
|
||||
operatorId, operatorName, operation);
|
||||
loadingPickService.sync(planId, changed);
|
||||
return view(repository.plan(planId), booking);
|
||||
}
|
||||
@@ -163,7 +165,7 @@ public class SmartLoadingService {
|
||||
}
|
||||
}
|
||||
LoadingPlanEntity recommended = repository.plan(planId);
|
||||
history(planId, null, "SMART_RECOMMEND", "EDITING", "EDITING", operation,
|
||||
history(planId, null, "SMART_RECOMMEND", plan.getPlanStatus(), recommended.getPlanStatus(), operation,
|
||||
json(values("version", request.expectedVersion())),
|
||||
json(values("requestHash", requestHash, "version", recommended.getVersion())),
|
||||
operatorId, operatorName);
|
||||
@@ -188,6 +190,7 @@ public class SmartLoadingService {
|
||||
Candidate candidate = repository.candidate(booking.getWarehouseId(), item.getInboundId(), item.getInboundDetailId());
|
||||
if (candidate == null || request.packageCount() > candidate.availablePackageCount() + item.getPackageCount()) throw validation("调整箱数超过可用箱数");
|
||||
requireSameChannel(booking, candidate);
|
||||
requireSameCustomerScope(booking, candidate);
|
||||
OrderCargoClient.CargoBinding cargo = loadCargo(candidate);
|
||||
List<LoadingPlanItemEntity> others = new ArrayList<>(repository.items(planId));
|
||||
others.removeIf(x -> Objects.equals(x.getId(), itemId));
|
||||
@@ -198,7 +201,7 @@ public class SmartLoadingService {
|
||||
item.setVolumeCbm(unit(candidate.unitVolumeCbm()).multiply(BigDecimal.valueOf(request.packageCount()))); applyCargo(item, cargo, request.packageCount());
|
||||
item.setUpdatedBy(operatorId); item.setUpdatedByName(operatorName);
|
||||
if (repository.update(item) != 1) throw conflict("装柜明细已被并发修改");
|
||||
history(planId, itemId, "ADJUST_ITEM", "EDITING", "EDITING", operation,
|
||||
history(planId, itemId, "ADJUST_ITEM", plan.getPlanStatus(), plan.getPlanStatus(), operation,
|
||||
json(values("version", request.expectedPlanVersion(), "packageCount", item.getPackageCount())),
|
||||
json(values("requestHash", requestHash, "packageCount", request.packageCount())),
|
||||
operatorId, operatorName);
|
||||
@@ -221,7 +224,8 @@ public class SmartLoadingService {
|
||||
loadingPickService.remove(itemId);
|
||||
touch(plan, operatorId, operatorName);
|
||||
if (repository.remove(planId, itemId, request.expectedItemVersion(), operatorId, operatorName) != 1) throw conflict("装柜草稿已被并发修改");
|
||||
history(planId, itemId, "REMOVE_ITEM", "EDITING", "EDITING", operation,
|
||||
loadingModelBridge.removeIfUnallocated(planId, item.getAccountId(), item.getInboundId());
|
||||
history(planId, itemId, "REMOVE_ITEM", plan.getPlanStatus(), plan.getPlanStatus(), operation,
|
||||
json(values("version", request.expectedPlanVersion(), "packageCount", item.getPackageCount())),
|
||||
json(values("requestHash", requestHash, "deleted", true)), operatorId, operatorName);
|
||||
return view(repository.plan(planId), requireBooking(plan.getContainerBookingId()));
|
||||
@@ -372,7 +376,28 @@ public class SmartLoadingService {
|
||||
int byW=unit(c.unitWeightKg()).signum()==0?c.availablePackageCount():leftW.max(BigDecimal.ZERO).divideToIntegralValue(c.unitWeightKg()).intValue();
|
||||
return Math.max(0,Math.min(c.availablePackageCount(),Math.min(byV,byW)));
|
||||
}
|
||||
private Plan view(LoadingPlanEntity p,ContainerBookingEntity b){return new Plan(p.getId(),p.getPlanNo(),p.getPlanStatus(),p.getContainerBookingId(),b.getContainerNo(),b.getVoyageNo(),p.getVersion(),repository.items(p.getId()));}
|
||||
private Plan view(LoadingPlanEntity p,ContainerBookingEntity b){
|
||||
var task = loadingPickService.mutationSnapshot(p.getId());
|
||||
boolean planMutable = Set.of("EDITING", "LOADING").contains(p.getPlanStatus());
|
||||
boolean taskMutable = task.taskId() == null || "LOADING".equals(task.taskStatus());
|
||||
boolean mutable = planMutable && taskMutable;
|
||||
String blockedReason = mutable ? null : "COMPLETED".equals(p.getPlanStatus())
|
||||
|| "COMPLETED".equals(task.taskStatus()) ? "装柜已最终完成,不能修改预装明细"
|
||||
: "当前计划或PDA任务状态不允许修改预装明细";
|
||||
List<LoadingPlanItemEntity> items = repository.items(p.getId());
|
||||
for (LoadingPlanItemEntity item : items) {
|
||||
int picked = task.pickedByPlanItem().getOrDefault(item.getId(), 0);
|
||||
item.setPickedPackageCount(picked);
|
||||
item.setMinPackageCount(Math.max(1, picked));
|
||||
item.setCanAdjust(mutable);
|
||||
item.setCanRemove(mutable && picked == 0);
|
||||
}
|
||||
String finalStatus = task.taskId() == null ? "NOT_STARTED"
|
||||
: "COMPLETED".equals(task.taskStatus()) ? "COMPLETED" : "IN_PROGRESS";
|
||||
return new Plan(p.getId(),p.getPlanNo(),p.getPlanStatus(),p.getContainerBookingId(),
|
||||
b.getContainerNo(),b.getVoyageNo(),p.getVersion(),mutable,mutable,blockedReason,finalStatus,
|
||||
task.taskId(),task.taskStatus(),task.taskVersion(),items);
|
||||
}
|
||||
private Plan draftView(LoadingPlanEntity plan, ContainerBookingEntity booking) {
|
||||
if (plan == null) throw new BizException(ResultCode.NOT_FOUND, "该货柜尚未创建智能装柜草稿");
|
||||
if (!Set.of("EDITING", "LOADING").contains(plan.getPlanStatus())) {
|
||||
@@ -386,11 +411,7 @@ public class SmartLoadingService {
|
||||
if (!Set.of("EDITING", "LOADING").contains(p.getPlanStatus()) || !Objects.equals(p.getVersion(), version)) {
|
||||
throw conflict("计划已完成或版本冲突");
|
||||
}
|
||||
if ("LOADING".equals(p.getPlanStatus())
|
||||
&& repository.recycleUnpicked(id, version, operatorId, operatorName) == 1) {
|
||||
p.setPlanStatus("EDITING");
|
||||
p.setVersion(version + 1);
|
||||
}
|
||||
loadingPickService.lockMutable(id);
|
||||
return p;
|
||||
}
|
||||
private ContainerBookingEntity requireBooking(long id){ContainerBookingEntity b=repository.booking(id);if(b==null)throw new BizException(ResultCode.NOT_FOUND,"货柜预订不存在");return b;}
|
||||
|
||||
@@ -43,21 +43,21 @@ public class TruckAppointmentService {
|
||||
@Transactional public TruckAppointmentAttachmentEntity upload(long id,String type,MultipartFile file,long uid){TruckAppointmentEntity appointment=required(id);if(appointment.getCustomerId()==null||appointment.getCustomerId()<=0)throw bad("预约缺少客户归属,无法上传附件");try{var response=files.upload(internalToken,uid,appointment.getCustomerId(),"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(type);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){TruckAppointmentEntity appointment=required(id); if(appointment.getCustomerId()==null||appointment.getCustomerId()<=0)throw bad("预约缺少客户归属,无法引用附件"); TruckAppointmentFileClient.Metadata metadata;try{var response=files.metadata(internalToken,appointment.getCustomerId(),"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(r.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()!=0)throw bad("新增 expectedVersion 必须为0"); OceanBookingEntity booking=validateBookingLink(r); var carrier=suppliers.trucking(r.carrierId(),r.carrierName()); TruckAppointmentEntity e=new TruckAppointmentEntity(); e.setId(IdWorker.getId()); e.setAppointmentNo("TA"+e.getId()); e.setStatus(TruckAppointmentStatus.DRAFT); e.setRevision(0); e.setVersion(0); e.setCreatedAt(LocalDateTime.now()); e.setUpdatedAt(e.getCreatedAt()); copy(e,r,booking,carrier==null?null:carrier.name()); repo.insert(e); record(e,null,e.getStatus().name(),"CREATE",null,uid,name); return e;}
|
||||
@Transactional public TruckAppointmentEntity edit(long id,long uid,String name,SaveRequest r){TruckAppointmentEntity e=required(id); checkVersion(e,r.expectedVersion()); if(!(e.getStatus()==TruckAppointmentStatus.DRAFT||e.getStatus()==TruckAppointmentStatus.PENDING_DISPATCH||e.getStatus()==TruckAppointmentStatus.RESCHEDULED))throw conflict("当前状态不可编辑"); OceanBookingEntity booking=validateBookingLink(r); var carrier=suppliers.trucking(r.carrierId(),r.carrierName()); copy(e,r,booking,carrier==null?null:carrier.name()); if(repo.updateEdit(e,r.expectedVersion())!=1)throw conflict("版本已变化,请刷新后重试"); record(e,e.getStatus().name(),e.getStatus().name(),"EDIT",null,uid,name); return required(id);}
|
||||
@Transactional public TruckAppointmentEntity create(long uid,String name,SaveRequest r){if(r.expectedVersion()!=0)throw bad("新增 expectedVersion 必须为0"); BookingLink link=validateBookingLink(r); var carrier=suppliers.trucking(r.carrierId(),r.carrierName()); TruckAppointmentEntity e=new TruckAppointmentEntity(); e.setId(IdWorker.getId()); e.setAppointmentNo("TA"+e.getId()); e.setStatus(TruckAppointmentStatus.DRAFT); e.setRevision(0); e.setVersion(0); e.setCreatedAt(LocalDateTime.now()); e.setUpdatedAt(e.getCreatedAt()); copy(e,r,link,carrier==null?null:carrier.name()); repo.insert(e); record(e,null,e.getStatus().name(),"CREATE",null,uid,name); return e;}
|
||||
@Transactional public TruckAppointmentEntity edit(long id,long uid,String name,SaveRequest r){TruckAppointmentEntity e=required(id); checkVersion(e,r.expectedVersion()); if(!(e.getStatus()==TruckAppointmentStatus.DRAFT||e.getStatus()==TruckAppointmentStatus.PENDING_DISPATCH||e.getStatus()==TruckAppointmentStatus.RESCHEDULED))throw conflict("当前状态不可编辑"); BookingLink link=validateBookingLink(r); var carrier=suppliers.trucking(r.carrierId(),r.carrierName()); copy(e,r,link,carrier==null?null:carrier.name()); if(repo.updateEdit(e,r.expectedVersion())!=1)throw conflict("版本已变化,请刷新后重试"); record(e,e.getStatus().name(),e.getStatus().name(),"EDIT",null,uid,name); return required(id);}
|
||||
@Transactional public TruckAppointmentEntity action(long id,String action,ActionRequest r,String idem,String clientHash,long uid,String name){String requestHash=hash(action,id,r);verifyClientHash(clientHash,requestHash);TruckAppointmentEntity replay=claimOrReplay(id,action,idem,requestHash);if(replay!=null)return replay;TruckAppointmentEntity e=required(id); checkVersion(e,r.expectedVersion()); if("RESCHEDULE".equals(action))throw conflict("改约请使用专用接口"); TruckAppointmentStatus from=e.getStatus(),to=next(from,action); if(repo.transition(id,from.name(),to.name(),e.getVersion())!=1)throw conflict("状态或版本已变化,请刷新后重试"); TruckAppointmentEntity after=required(id);record(after,from.name(),to.name(),action,r.reason(),uid,name);return completeOperation(id,action,idem,after);}
|
||||
@Transactional public TruckAppointmentEntity reschedule(long id,LocalDateTime at,ActionRequest r,String idem,String clientHash,long uid,String name){String action="RESCHEDULE";String requestHash=hash(action,id,new ReschedulePayload(at,r));verifyClientHash(clientHash,requestHash);TruckAppointmentEntity replay=claimOrReplay(id,action,idem,requestHash);if(replay!=null)return replay;TruckAppointmentEntity e=required(id);checkVersion(e,r.expectedVersion()); if(e.getStatus()==TruckAppointmentStatus.COMPLETED||e.getStatus()==TruckAppointmentStatus.CANCELLED)throw conflict("当前状态不可改约"); String from=e.getStatus().name(); if(repo.reschedule(id,at,r.reason(),e.getVersion())!=1)throw conflict("状态或版本已变化,请刷新后重试");TruckAppointmentEntity after=required(id);record(after,from,"RESCHEDULED",action,r.reason(),uid,name);return completeOperation(id,action,idem,after);}
|
||||
private OceanBookingEntity validateBookingLink(SaveRequest r){if(r.oceanBookingId()==null||r.oceanBookingId()<=0)throw bad("oceanBookingId不能为空"); OceanBookingEntity b=oceanBookings.find(r.oceanBookingId()); if(b==null)throw new BizException(ResultCode.NOT_FOUND,"关联订舱不存在"); if(!"RELEASED".equals(b.getStatus()))throw conflict("仅已放舱订舱可创建拖车预约"); if(b.getCustomerId()==null||!StringUtils.hasText(b.getCustomerName()))throw conflict("关联订舱缺少客户快照"); if(r.customerId()!=null&&r.customerId()>0&&!Objects.equals(r.customerId(),b.getCustomerId()))throw bad("customerId与订舱客户不一致"); if(StringUtils.hasText(r.customerName())&&!r.customerName().trim().equals(b.getCustomerName()))throw bad("customerName与订舱客户快照不一致"); if(r.containerBookingId()!=null&&r.containerBookingId()>0){ContainerBookingEntity c=containers.find(r.containerBookingId()); if(c==null)throw new BizException(ResultCode.NOT_FOUND,"关联柜体不存在"); boolean allocated=oceanBookings.allocations(r.oceanBookingId()).stream().anyMatch(a->Objects.equals(a.getContainerBookingId(),r.containerBookingId())); if(!allocated)throw bad("containerBookingId未分配至该订舱"); if(r.warehouseId()!=null&&r.warehouseId()>0&&c.getWarehouseId()!=null&&!Objects.equals(r.warehouseId(),c.getWarehouseId()))throw bad("warehouseId与柜体不一致");} validateLoadingPlanLink(r);return b;}
|
||||
private void validateLoadingPlanLink(SaveRequest r){if(r.loadingPlanId()==null)return;if(r.loadingPlanId()<=0)throw bad("loadingPlanId必须大于0");if(r.containerBookingId()==null||r.containerBookingId()<=0)throw bad("关联装柜计划时containerBookingId不能为空");LoadingPlanEntity plan=loadingPlans.plan(r.loadingPlanId());if(plan==null)throw new BizException(ResultCode.NOT_FOUND,"关联装柜计划不存在");if(!Objects.equals(plan.getContainerBookingId(),r.containerBookingId()))throw bad("loadingPlanId与柜体不一致");}
|
||||
private BookingLink validateBookingLink(SaveRequest r){if(r.oceanBookingId()==null||r.oceanBookingId()<=0)throw bad("oceanBookingId不能为空"); if(r.containerBookingId()==null||r.containerBookingId()<=0)throw bad("containerBookingId不能为空"); OceanBookingEntity b=oceanBookings.find(r.oceanBookingId()); if(b==null)throw new BizException(ResultCode.NOT_FOUND,"关联订舱不存在"); if(!"RELEASED".equals(b.getStatus()))throw conflict("仅已放舱订舱可创建拖车预约"); if(b.getCustomerId()==null||!StringUtils.hasText(b.getCustomerName()))throw conflict("关联订舱缺少客户快照"); if(r.customerId()!=null&&r.customerId()>0&&!Objects.equals(r.customerId(),b.getCustomerId()))throw bad("customerId与订舱客户不一致"); if(StringUtils.hasText(r.customerName())&&!r.customerName().trim().equals(b.getCustomerName()))throw bad("customerName与订舱客户快照不一致"); ContainerBookingEntity c=containers.find(r.containerBookingId()); if(c==null)throw new BizException(ResultCode.NOT_FOUND,"关联柜体不存在"); boolean allocated=oceanBookings.allocations(r.oceanBookingId()).stream().anyMatch(a->Objects.equals(a.getContainerBookingId(),r.containerBookingId())); if(!allocated)throw bad("containerBookingId未分配至该订舱"); if(r.warehouseId()!=null&&r.warehouseId()>0&&c.getWarehouseId()!=null&&!Objects.equals(r.warehouseId(),c.getWarehouseId()))throw bad("warehouseId与柜体不一致"); LoadingPlanEntity plan=loadingPlans.planByBooking(r.containerBookingId()); if(plan==null)throw conflict("关联柜次尚未建立装柜计划"); if(r.loadingPlanId()!=null&&!Objects.equals(r.loadingPlanId(),plan.getId()))throw bad("loadingPlanId与柜体当前装柜计划不一致"); return new BookingLink(b,plan);}
|
||||
private TruckAppointmentStatus next(TruckAppointmentStatus s,String a){return switch(a){case"SUBMIT"->s==TruckAppointmentStatus.DRAFT?TruckAppointmentStatus.PENDING_DISPATCH:badStatus();case"DISPATCH"->s==TruckAppointmentStatus.PENDING_DISPATCH||s==TruckAppointmentStatus.RESCHEDULED?TruckAppointmentStatus.DISPATCHED:badStatus();case"PICKUP"->s==TruckAppointmentStatus.DISPATCHED?TruckAppointmentStatus.PICKUP_IN_PROGRESS:badStatus();case"ARRIVE"->s==TruckAppointmentStatus.PICKUP_IN_PROGRESS?TruckAppointmentStatus.ARRIVED_WAREHOUSE:badStatus();case"LOAD"->s==TruckAppointmentStatus.ARRIVED_WAREHOUSE?TruckAppointmentStatus.LOADING:badStatus();case"DEPART"->s==TruckAppointmentStatus.LOADING?TruckAppointmentStatus.DEPARTED:badStatus();case"COMPLETE"->s==TruckAppointmentStatus.DEPARTED?TruckAppointmentStatus.COMPLETED:badStatus();case"EXCEPTION"->s!=TruckAppointmentStatus.COMPLETED&&s!=TruckAppointmentStatus.CANCELLED?TruckAppointmentStatus.EXCEPTION:badStatus();case"CANCEL"->s!=TruckAppointmentStatus.COMPLETED?TruckAppointmentStatus.CANCELLED:badStatus();default->badStatus();};}
|
||||
private TruckAppointmentStatus badStatus(){throw conflict("不允许的状态动作");} private TruckAppointmentEntity required(long id){TruckAppointmentEntity e=repo.find(id);if(e==null)throw new BizException(ResultCode.NOT_FOUND,"拖车预约不存在");return e;} private void checkVersion(TruckAppointmentEntity e,int v){if(e.getVersion()!=v)throw conflict("版本已变化,请刷新后重试");} private static BizException conflict(String m){return new BizException(ResultCode.CONFLICT,m);} private static BizException bad(String m){return new BizException(ResultCode.BAD_REQUEST,m);}
|
||||
private TruckAppointmentEntity enrich(TruckAppointmentEntity value){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());}}return value;}
|
||||
private void copy(TruckAppointmentEntity e,SaveRequest r,OceanBookingEntity booking,String carrierName){e.setOutboundOrderId(r.outboundOrderId());e.setOceanBookingId(r.oceanBookingId());e.setContainerBookingId(r.containerBookingId());e.setLoadingPlanId(r.loadingPlanId());e.setWarehouseId(r.warehouseId());e.setCustomerId(booking.getCustomerId());e.setCustomerName(booking.getCustomerName());e.setCarrierId(r.carrierId());e.setCarrierName(carrierName);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.setReceivableAmount(r.receivableAmount());e.setCurrency(r.currency()==null?"RMB":r.currency());e.setRemark(r.remark());}
|
||||
private void copy(TruckAppointmentEntity e,SaveRequest r,BookingLink link,String carrierName){OceanBookingEntity booking=link.booking();e.setOutboundOrderId(r.outboundOrderId());e.setOceanBookingId(r.oceanBookingId());e.setContainerBookingId(r.containerBookingId());e.setLoadingPlanId(link.plan().getId());e.setWarehouseId(r.warehouseId());e.setCustomerId(booking.getCustomerId());e.setCustomerName(booking.getCustomerName());e.setCarrierId(r.carrierId());e.setCarrierName(carrierName);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.setReceivableAmount(r.receivableAmount());e.setCurrency(r.currency()==null?"RMB":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);}}
|
||||
private static void verifyClientHash(String clientHash,String serverHash){if(StringUtils.hasText(clientHash)&&!serverHash.equalsIgnoreCase(clientHash.trim()))throw bad("X-Request-Hash与请求内容不一致");}
|
||||
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 record BookingLink(OceanBookingEntity booking,LoadingPlanEntity plan){}
|
||||
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);}
|
||||
}
|
||||
|
||||
@@ -357,6 +357,53 @@ paths:
|
||||
responses:
|
||||
'200': {description: 成功、计划不存在或计划已进入预装流程}
|
||||
'403': {description: 无智能装柜查询权限}
|
||||
/api/platform/v1/first-leg/smart-loading/plans/{planId}/assignments:
|
||||
post:
|
||||
summary: 向预装计划新增入库单明细
|
||||
description: >-
|
||||
EDITING 或 LOADING 且 PDA task 尚未完成时可用。服务端按 planId 锁定同一 PDA task,
|
||||
校验仓库、渠道、客户范围、可用箱数、容积和限重后原子新增/累加明细;LOADING 时同步到原 taskId,
|
||||
递增 planVersion、taskVersion,原 inventorySnapshotToken 随即失效。与 PDA batch/final complete 并发时,
|
||||
锁顺序为 PDA task 行锁 -> plan 乐观锁;仅先取得任务锁且持有匹配版本的一方成功,另一方返回 A0409。
|
||||
PDA 应重新 GET task detail 与 inventory snapshot,Admin 应重新 GET SmartLoadingPlan。新增项沿用原 taskId、
|
||||
loadingPlanId、containerBookingId 和 inbound identity;已全拣任务会因新增 PENDING item 从 AWAITING_REVIEW
|
||||
回到 IN_PROGRESS,已有证据不删除,但 final complete 门禁撤回。
|
||||
parameters:
|
||||
- {name: planId, in: path, required: true, schema: {type: string, pattern: '^[1-9][0-9]*$'}}
|
||||
requestBody:
|
||||
required: true
|
||||
content: {application/json: {schema: {$ref: '#/components/schemas/SmartLoadingAssignRequest'}}}
|
||||
responses:
|
||||
'200': {description: 成功或业务失败,data 为最新 SmartLoadingPlan}
|
||||
/api/platform/v1/first-leg/smart-loading/plans/{planId}/items/{itemId}:
|
||||
patch:
|
||||
summary: 调整预装计划明细箱数
|
||||
description: >-
|
||||
EDITING/LOADING 均可;packageCount 不得小于已拣 pickedPackageCount。成功递增 plan、计划明细和现有 PDA task 版本,
|
||||
原 inventorySnapshotToken 失效。PDA task 已完成或并发版本变化返回 A0409,事务零写入;调用方须刷新
|
||||
SmartLoadingPlan,PDA 在下一次写入前须刷新 task detail 与 inventory snapshot。
|
||||
parameters:
|
||||
- {name: planId, in: path, required: true, schema: {type: string, pattern: '^[1-9][0-9]*$'}}
|
||||
- {name: itemId, in: path, required: true, schema: {type: string, pattern: '^[1-9][0-9]*$'}}
|
||||
requestBody:
|
||||
required: true
|
||||
content: {application/json: {schema: {$ref: '#/components/schemas/SmartLoadingAdjustRequest'}}}
|
||||
responses:
|
||||
'200': {description: 成功或业务失败,data 为最新 SmartLoadingPlan}
|
||||
delete:
|
||||
summary: 删除未拣预装计划明细
|
||||
description: >-
|
||||
仅 pickedPackageCount=0 且计划/PDA task 未最终完成时允许。成功递增 plan 和 task 版本并使原库存快照失效;
|
||||
已拣、COMPLETED 或并发版本变化返回 A0409,事务零写入。PDA final complete 与本操作使用相同 task 行锁,
|
||||
因而只允许一个事务成功;失败方刷新计划、task detail 与 inventory snapshot 后再决定后续操作。
|
||||
parameters:
|
||||
- {name: planId, in: path, required: true, schema: {type: string, pattern: '^[1-9][0-9]*$'}}
|
||||
- {name: itemId, in: path, required: true, schema: {type: string, pattern: '^[1-9][0-9]*$'}}
|
||||
requestBody:
|
||||
required: true
|
||||
content: {application/json: {schema: {$ref: '#/components/schemas/SmartLoadingDeleteRequest'}}}
|
||||
responses:
|
||||
'200': {description: 成功或业务失败,data 为最新 SmartLoadingPlan}
|
||||
/api/platform/v1/first-leg/smart-loading/plans/{planId}/push-preload:
|
||||
post:
|
||||
summary: 推送智能装柜计划到预装清单
|
||||
@@ -1303,6 +1350,67 @@ components:
|
||||
required: [expectedVersion]
|
||||
properties:
|
||||
expectedVersion: {type: integer, minimum: 0}
|
||||
SmartLoadingAssignRequest:
|
||||
type: object
|
||||
required: [inboundId, inboundDetailId, packageCount, expectedVersion, operationId]
|
||||
properties:
|
||||
inboundId: {type: string, pattern: '^[1-9][0-9]*$'}
|
||||
inboundDetailId: {type: string, pattern: '^[1-9][0-9]*$'}
|
||||
packageCount: {type: integer, minimum: 1}
|
||||
expectedVersion: {type: integer, minimum: 0, description: 当前 planVersion}
|
||||
operationId: {type: string, minLength: 1, maxLength: 128}
|
||||
SmartLoadingAdjustRequest:
|
||||
type: object
|
||||
required: [packageCount, expectedPlanVersion, expectedItemVersion, operationId]
|
||||
properties:
|
||||
packageCount: {type: integer, minimum: 1, description: 不得小于响应中的 minPackageCount}
|
||||
expectedPlanVersion: {type: integer, minimum: 0}
|
||||
expectedItemVersion: {type: integer, minimum: 0}
|
||||
operationId: {type: string, minLength: 1, maxLength: 128}
|
||||
SmartLoadingDeleteRequest:
|
||||
type: object
|
||||
required: [expectedPlanVersion, expectedItemVersion, operationId]
|
||||
properties:
|
||||
expectedPlanVersion: {type: integer, minimum: 0}
|
||||
expectedItemVersion: {type: integer, minimum: 0}
|
||||
operationId: {type: string, minLength: 1, maxLength: 128}
|
||||
SmartLoadingPlan:
|
||||
type: object
|
||||
required: [id, planNo, status, bookingId, version, canAssign, canEdit, finalCompletionStatus, items]
|
||||
properties:
|
||||
id: {type: string}
|
||||
planNo: {type: string}
|
||||
status: {type: string, enum: [EDITING, LOADING, COMPLETED]}
|
||||
bookingId: {type: string}
|
||||
containerNo: {type: string, nullable: true}
|
||||
voyageNo: {type: string, nullable: true}
|
||||
version: {type: integer, minimum: 0, description: planVersion,写请求使用此值}
|
||||
canAssign: {type: boolean}
|
||||
canEdit: {type: boolean}
|
||||
blockedReason: {type: string, nullable: true}
|
||||
finalCompletionStatus: {type: string, enum: [NOT_STARTED, IN_PROGRESS, COMPLETED], description: IN_PROGRESS 表示 final complete 门禁尚未通过;增量新增 PENDING item 会保持或恢复此值,COMPLETED 后 canAssign/canEdit 均为 false}
|
||||
pdaTaskId: {type: string, nullable: true}
|
||||
pdaTaskStatus: {type: string, nullable: true, enum: [LOADING, COMPLETED], description: LOADING task 在增量后保持原 taskId;COMPLETED 永久拒绝增量}
|
||||
pdaTaskVersion: {type: integer, minimum: 0, nullable: true, description: 服务端同步后最新 taskVersion;Admin 写操作不提交该值}
|
||||
items:
|
||||
type: array
|
||||
items: {$ref: '#/components/schemas/SmartLoadingPlanItem'}
|
||||
SmartLoadingPlanItem:
|
||||
type: object
|
||||
required: [id, packageCount, version, pickedPackageCount, minPackageCount, canAdjust, canRemove]
|
||||
properties:
|
||||
id: {type: string}
|
||||
inboundId: {type: string}
|
||||
inboundDetailId: {type: string}
|
||||
orderNo: {type: string, nullable: true}
|
||||
inboundCode: {type: string, nullable: true}
|
||||
customerName: {type: string, nullable: true}
|
||||
packageCount: {type: integer, minimum: 1}
|
||||
version: {type: integer, minimum: 0, description: itemVersion,调整/删除写请求使用此值}
|
||||
pickedPackageCount: {type: integer, minimum: 0}
|
||||
minPackageCount: {type: integer, minimum: 1}
|
||||
canAdjust: {type: boolean}
|
||||
canRemove: {type: boolean}
|
||||
ContainerDocumentAttachRequest:
|
||||
type: object
|
||||
required: [fileId, expectedVersion]
|
||||
|
||||
@@ -94,7 +94,8 @@ paths:
|
||||
'200': {description: Business envelope containing task or null}
|
||||
/api/platform/v1/inbounds/{inboundId}/loading/complete:
|
||||
post:
|
||||
summary: Complete loading only after picking and five evidence types each have an active photo
|
||||
summary: Compatibility endpoint for completing loading after the canonical PDA confirmation
|
||||
description: Canonical PDA confirmation now atomically completes task, plan and inbound LOADED. This endpoint remains an idempotent server-side completion primitive; RELEASED booking, COMPLETED truck, completed plan/picking and five ACTIVE evidence types remain hard gates.
|
||||
parameters: [{$ref: '#/components/parameters/InboundId'}, {$ref: '#/components/parameters/TenantId'}, {$ref: '#/components/parameters/IdempotencyKey'}]
|
||||
requestBody:
|
||||
required: true
|
||||
|
||||
@@ -106,7 +106,7 @@ paths:
|
||||
/api/platform/v1/first-leg/pda-loading/tasks/{taskId}/complete:
|
||||
post:
|
||||
summary: PDA拣货全部处理后人工确认装柜完成
|
||||
description: PDA拣货只锁定数量和仓储资源,本接口执行结算、释放资源并生成装柜清单。
|
||||
description: PDA拣货只锁定数量和仓储资源。本接口先无写入校验全部关联入库单的RELEASED订舱、COMPLETED拖车和五类ACTIVE证据;任何阻断返回A0409且task、plan、inbound仍保持LOADING。全部满足后在同一事务内结算资源、完成task/plan并推进inbound为LOADED。
|
||||
parameters: [{$ref: '#/components/parameters/TaskId'}]
|
||||
requestBody:
|
||||
required: true
|
||||
|
||||
@@ -155,11 +155,11 @@ components:
|
||||
reason: {type: string, maxLength: 500}
|
||||
SaveRequest:
|
||||
type: object
|
||||
required: [oceanBookingId, plannedLoadingAt, expectedVersion]
|
||||
required: [oceanBookingId, containerBookingId, plannedLoadingAt, expectedVersion]
|
||||
properties:
|
||||
oceanBookingId: {type: integer, format: int64, minimum: 1}
|
||||
containerBookingId: {type: integer, format: int64, minimum: 1}
|
||||
loadingPlanId: {type: integer, format: int64, minimum: 1}
|
||||
loadingPlanId: {type: integer, format: int64, minimum: 1, nullable: true, description: Optional consistency hint; the server resolves and freezes the current loadingPlanId from containerBookingId and rejects a mismatch.}
|
||||
carrierId: {type: integer, format: int64, minimum: 1, nullable: true, description: Optional 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 and cannot be supplied without carrierId.}
|
||||
plannedLoadingAt: {type: string, format: date-time}
|
||||
|
||||
+19
@@ -34,5 +34,24 @@ class PlatformSmartLoadingControllerContractTest {
|
||||
.contains("领取后才进入 LOADING")
|
||||
.doesNotContain("真实柜号必须已录入");
|
||||
}
|
||||
@Test void openApiFreezesIncrementalMutationAndCapabilityContract() throws Exception {
|
||||
String yaml=java.nio.file.Files.readString(java.nio.file.Path.of("src/main/resources/openapi/firstleg-v1.yaml"));
|
||||
assertThat(yaml)
|
||||
.contains("/api/platform/v1/first-leg/smart-loading/plans/{planId}/assignments:")
|
||||
.contains("/api/platform/v1/first-leg/smart-loading/plans/{planId}/items/{itemId}:")
|
||||
.contains("SmartLoadingAssignRequest:")
|
||||
.contains("SmartLoadingAdjustRequest:")
|
||||
.contains("SmartLoadingDeleteRequest:")
|
||||
.contains("canAssign: {type: boolean}")
|
||||
.contains("canEdit: {type: boolean}")
|
||||
.contains("pickedPackageCount: {type: integer, minimum: 0}")
|
||||
.contains("minPackageCount: {type: integer, minimum: 1}")
|
||||
.contains("canAdjust: {type: boolean}")
|
||||
.contains("canRemove: {type: boolean}")
|
||||
.contains("原 inventorySnapshotToken 随即失效")
|
||||
.contains("PDA task 行锁 -> plan 乐观锁")
|
||||
.contains("回到 IN_PROGRESS")
|
||||
.contains("COMPLETED 永久拒绝增量");
|
||||
}
|
||||
private void authority(String method,String permission,Class<?>...types)throws Exception{Method m=PlatformSmartLoadingController.class.getDeclaredMethod(method,types);assertThat(m.getAnnotation(PreAuthorize.class).value()).isEqualTo("hasAuthority('"+permission+"')");}
|
||||
}
|
||||
|
||||
+2
@@ -18,7 +18,9 @@ class LoadingCompletionGateSqlContractTest {
|
||||
.contains("fl_ocean_booking_container_allocation")
|
||||
.contains("b.status='RELEASED'")
|
||||
.contains("fl_truck_appointment")
|
||||
.contains("t.container_booking_id=p.container_booking_id")
|
||||
.contains("t.status='COMPLETED'")
|
||||
.doesNotContain("t.loading_plan_id=p.id")
|
||||
.contains("l.tenant_id=#{tenantId} AND l.inbound_id=#{inboundId}")
|
||||
.doesNotContain("truckReady, FROM")
|
||||
.doesNotContain("fl_customs_case", "fl_customs_receipt", "customsReady")
|
||||
|
||||
+38
@@ -102,6 +102,44 @@ class LoadingDeliveryServiceTest {
|
||||
verify(histories).insert(any(InboundStatusHistoryEntity.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
void pdaCompletionPreflightChecksStartedPlanTransportAndEvidenceWithoutWriting() {
|
||||
when(repository.findInbound(10, 20)).thenReturn(new Snapshot(20L, 10L, "LOADING", 3));
|
||||
when(repository.listEvidence(10, 20)).thenReturn(List.of(
|
||||
evidence("PHOTO_STEP_1", 1L, null), evidence("PHOTO_STEP_2", 2L, null),
|
||||
evidence("PHOTO_STEP_3", 3L, null), evidence("PHOTO_STEP_4", 4L, null),
|
||||
evidence("SEAL", 5L, "LOCK-001")));
|
||||
when(files.validate(eq("token"), eq(10L), eq(20L), anyLong())).thenAnswer(invocation -> {
|
||||
long fileId = invocation.getArgument(3);
|
||||
String type = fileId == 5L ? "SEAL" : "PHOTO_STEP_" + fileId;
|
||||
return ApiResponse.success(new LoadingEvidenceFileClient.FileMetadata(
|
||||
fileId, type.toLowerCase() + ".png", "LOADING_" + type, "image/png"));
|
||||
});
|
||||
|
||||
service.requirePdaCompletionReady(10, 20);
|
||||
|
||||
verify(loadingModelBridge).requireStarted(10, 20);
|
||||
verify(completionGate).requireBookingAndTransport(10, 20);
|
||||
verify(loadingModelBridge, never()).requireCompleted(anyLong(), anyLong());
|
||||
verify(repository, never()).completeLoading(anyLong(), anyLong(), anyLong(), anyInt());
|
||||
verify(repository, never()).claimOperation(anyLong(), anyLong(), anyLong(), anyString(), anyString(), anyString());
|
||||
}
|
||||
|
||||
@Test
|
||||
void pdaCompletionPreflightReportsTruckBlockerBeforeEvidenceOrWrites() {
|
||||
when(repository.findInbound(10, 20)).thenReturn(new Snapshot(20L, 10L, "LOADING", 3));
|
||||
doThrow(new com.baiyue.common.core.exception.BizException(
|
||||
com.baiyue.common.core.api.ResultCode.CONFLICT,
|
||||
"LOADED门禁未通过:拖车尚未完成(必须为 COMPLETED)"))
|
||||
.when(completionGate).requireBookingAndTransport(10, 20);
|
||||
|
||||
assertThatThrownBy(() -> service.requirePdaCompletionReady(10, 20))
|
||||
.hasMessageContaining("拖车尚未完成");
|
||||
|
||||
verify(repository, never()).listEvidence(anyLong(), anyLong());
|
||||
verify(repository, never()).completeLoading(anyLong(), anyLong(), anyLong(), anyInt());
|
||||
}
|
||||
|
||||
@Test
|
||||
void completesLoadedFulfillmentSliceBeforeAggregatingRoot() {
|
||||
when(repository.claimOperation(anyLong(), anyLong(), anyLong(), anyString(), anyString(), anyString())).thenReturn(true);
|
||||
|
||||
@@ -20,6 +20,7 @@ import com.baiyue.firstleg.entity.LoadingPlanItemEntity;
|
||||
import com.baiyue.firstleg.entity.ContainerBookingEntity;
|
||||
import com.baiyue.firstleg.entity.InboundStatusHistoryEntity;
|
||||
import com.baiyue.firstleg.loading.mapper.LoadingInboundMapper;
|
||||
import com.baiyue.firstleg.loading.service.LoadingDeliveryService;
|
||||
import com.baiyue.firstleg.loading.bridge.dto.LoadingModelBridgeDtos.Link;
|
||||
import com.baiyue.firstleg.loading.bridge.service.LoadingModelBridgeService;
|
||||
import com.baiyue.firstleg.mapper.InboundStatusHistoryMapper;
|
||||
@@ -43,7 +44,10 @@ import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.argThat;
|
||||
import static org.mockito.ArgumentMatchers.anyLong;
|
||||
import static org.mockito.ArgumentMatchers.anyString;
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
import static org.mockito.Mockito.inOrder;
|
||||
import static org.mockito.Mockito.doThrow;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
@@ -64,6 +68,8 @@ class LoadingPickServiceTest {
|
||||
@MockBean
|
||||
private LoadingListService loadingListService;
|
||||
@MockBean
|
||||
private LoadingDeliveryService loadingDeliveryService;
|
||||
@MockBean
|
||||
private InboundStatusHistoryMapper inboundHistories;
|
||||
@MockBean
|
||||
private PdaPickSettlementService settlementService;
|
||||
@@ -491,6 +497,10 @@ class LoadingPickServiceTest {
|
||||
when(repository.allTaskItemsResolved(20L)).thenReturn(true);
|
||||
when(repository.allPlanItemsPicked(10L)).thenReturn(true);
|
||||
when(repository.approve(20L, 9L, "operator", "checked")).thenReturn(1);
|
||||
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));
|
||||
|
||||
service.complete(20L, new CompleteLoadingRequest(0, "complete-1", "checked"), 9L, "operator");
|
||||
|
||||
@@ -498,6 +508,35 @@ class LoadingPickServiceTest {
|
||||
verify(loadingListService).finishFromPda(10L, 20L, 9L, "operator");
|
||||
verify(settlementService).settle(20L, 9L, "operator");
|
||||
verify(repository).syncInboundProjections(20L, 9L, "operator");
|
||||
verify(loadingDeliveryService).requirePdaCompletionReady(101L, 50L);
|
||||
verify(loadingDeliveryService).completeLoading(eq(101L), eq(50L), eq(9L), eq("operator"),
|
||||
eq("PDA-complete-1-50"), argThat(value -> value.expectedVersion() == 7
|
||||
&& "checked".equals(value.remark())));
|
||||
}
|
||||
|
||||
@Test
|
||||
void truckBlockerLeavesPdaTaskPlanAndSettlementUntouched() {
|
||||
LoadingPickTaskEntity task = task();
|
||||
when(repository.taskForUpdate(20L)).thenReturn(task);
|
||||
when(repository.allTaskItemsResolved(20L)).thenReturn(true);
|
||||
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,
|
||||
"LOADED门禁未通过:拖车尚未完成(必须为 COMPLETED)"))
|
||||
.when(loadingDeliveryService).requirePdaCompletionReady(101L, 50L);
|
||||
|
||||
assertThatThrownBy(() -> service.complete(20L,
|
||||
new CompleteLoadingRequest(0, "complete-blocked", "checked"), 9L, "operator"))
|
||||
.isInstanceOf(BizException.class).hasMessageContaining("拖车尚未完成");
|
||||
|
||||
verify(settlementService, never()).settle(anyLong(), anyLong(), anyString());
|
||||
verify(repository, never()).approve(anyLong(), anyLong(), anyString(), any());
|
||||
verify(loadingListService, never()).finishFromPda(anyLong(), anyLong(), anyLong(), anyString());
|
||||
verify(loadingDeliveryService, never()).completeLoading(anyLong(), anyLong(), anyLong(),
|
||||
anyString(), anyString(), any());
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
@@ -34,6 +34,7 @@ import java.nio.charset.StandardCharsets;
|
||||
import java.security.MessageDigest;
|
||||
import java.util.HexFormat;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.catchThrowableOfType;
|
||||
@@ -68,6 +69,8 @@ class SmartLoadingServiceTest {
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
when(loadingPickService.mutationSnapshot(anyLong())).thenReturn(
|
||||
new LoadingPickService.MutationSnapshot(null, null, null, Map.of()));
|
||||
when(cargoClient.detail(anyString(), anyLong(), anyLong(), anyLong())).thenReturn(
|
||||
ApiResponse.success(new OrderCargoClient.CargoBinding(20L, 30L, 1, "货物", 5,
|
||||
new BigDecimal("10"), new BigDecimal("2"))));
|
||||
@@ -95,6 +98,53 @@ class SmartLoadingServiceTest {
|
||||
assertThat(service.draftByBooking(99L).status()).isEqualTo("LOADING");
|
||||
}
|
||||
|
||||
@Test
|
||||
void exposesPickedMinimumAndItemCapabilitiesForRunningTask() {
|
||||
LoadingPlanEntity plan = plan("LOADING", 3);
|
||||
LoadingPlanItemEntity line = item(8, 9, 20, 3);
|
||||
when(repository.booking(99L)).thenReturn(booking());
|
||||
when(repository.plan(1L)).thenReturn(plan);
|
||||
when(repository.items(1L)).thenReturn(List.of(line));
|
||||
when(loadingPickService.mutationSnapshot(1L)).thenReturn(
|
||||
new LoadingPickService.MutationSnapshot(77L, "LOADING", 6, Map.of(8L, 2)));
|
||||
|
||||
Plan result = service.detail(1L);
|
||||
|
||||
assertThat(result.canAssign()).isTrue();
|
||||
assertThat(result.canEdit()).isTrue();
|
||||
assertThat(result.finalCompletionStatus()).isEqualTo("IN_PROGRESS");
|
||||
assertThat(result.pdaTaskId()).isEqualTo(77L);
|
||||
assertThat(result.pdaTaskVersion()).isEqualTo(6);
|
||||
assertThat(result.items()).singleElement().satisfies(item -> {
|
||||
assertThat(item.getPickedPackageCount()).isEqualTo(2);
|
||||
assertThat(item.getMinPackageCount()).isEqualTo(2);
|
||||
assertThat(item.getCanAdjust()).isTrue();
|
||||
assertThat(item.getCanRemove()).isFalse();
|
||||
});
|
||||
}
|
||||
|
||||
@Test
|
||||
void completedTaskDisablesEveryIncrementalCapability() {
|
||||
LoadingPlanEntity plan = plan("COMPLETED", 4);
|
||||
LoadingPlanItemEntity line = item(8, 9, 20, 3);
|
||||
when(repository.booking(99L)).thenReturn(booking());
|
||||
when(repository.plan(1L)).thenReturn(plan);
|
||||
when(repository.items(1L)).thenReturn(List.of(line));
|
||||
when(loadingPickService.mutationSnapshot(1L)).thenReturn(
|
||||
new LoadingPickService.MutationSnapshot(77L, "COMPLETED", 7, Map.of(8L, 3)));
|
||||
|
||||
Plan result = service.detail(1L);
|
||||
|
||||
assertThat(result.canAssign()).isFalse();
|
||||
assertThat(result.canEdit()).isFalse();
|
||||
assertThat(result.blockedReason()).contains("最终完成");
|
||||
assertThat(result.finalCompletionStatus()).isEqualTo("COMPLETED");
|
||||
assertThat(result.items()).singleElement().satisfies(item -> {
|
||||
assertThat(item.getCanAdjust()).isFalse();
|
||||
assertThat(item.getCanRemove()).isFalse();
|
||||
});
|
||||
}
|
||||
|
||||
@Test
|
||||
void doesNotRestoreCompletedPlanInTheLoadingWorkbench() {
|
||||
when(repository.booking(99L)).thenReturn(booking());
|
||||
@@ -227,13 +277,12 @@ class SmartLoadingServiceTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
void recyclesLegacyLoadingPlanBeforeAddingFirstItem() {
|
||||
LoadingPlanEntity stale = plan("LOADING", 5);
|
||||
LoadingPlanEntity after = plan("EDITING", 7);
|
||||
when(repository.plan(1)).thenReturn(stale, after);
|
||||
void addsItemToLoadingPlanWithoutChangingStableTaskOrPlanStatus() {
|
||||
LoadingPlanEntity loading = plan("LOADING", 5);
|
||||
LoadingPlanEntity after = plan("LOADING", 6);
|
||||
when(repository.plan(1)).thenReturn(loading, after);
|
||||
when(repository.booking(99)).thenReturn(booking());
|
||||
when(repository.recycleUnpicked(1, 5, 7, "u")).thenReturn(1);
|
||||
when(repository.touch(1, 6, 7, "u")).thenReturn(1);
|
||||
when(repository.touch(1, 5, 7, "u")).thenReturn(1);
|
||||
when(repository.items(1)).thenReturn(List.of());
|
||||
when(repository.candidate(100, 10, 20)).thenReturn(candidate(10));
|
||||
when(repository.activeItem(1L, 9L, 20L)).thenReturn(null);
|
||||
@@ -241,11 +290,15 @@ class SmartLoadingServiceTest {
|
||||
new OrderCargoClient.CargoBinding(20L, 30L, 1, "货物", 1,
|
||||
BigDecimal.ONE, BigDecimal.ONE, null, null, null, null, null, null)));
|
||||
|
||||
Plan result = service.assign(1, new AssignRequest(10L, 20L, 1, 5, "assign-recycle"), 7, "u");
|
||||
Plan result = service.assign(1, new AssignRequest(10L, 20L, 1, 5, "assign-loading"), 7, "u");
|
||||
|
||||
assertThat(result.status()).isEqualTo("EDITING");
|
||||
verify(repository).recycleUnpicked(1, 5, 7, "u");
|
||||
verify(repository).touch(1, 6, 7, "u");
|
||||
assertThat(result.status()).isEqualTo("LOADING");
|
||||
var order = inOrder(loadingPickService, repository, loadingModelBridge);
|
||||
order.verify(loadingPickService).lockMutable(1L);
|
||||
order.verify(repository).touch(1, 5, 7, "u");
|
||||
order.verify(loadingModelBridge).bindIncrementalAllocation(1L, 9L, 10L,
|
||||
7L, "u", "assign-loading");
|
||||
order.verify(loadingPickService).sync(eq(1L), any(LoadingPlanItemEntity.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
+15
-5
@@ -78,6 +78,7 @@ class TruckAppointmentServiceTest {
|
||||
SaveRequest request = mock(SaveRequest.class);
|
||||
when(request.expectedVersion()).thenReturn(0);
|
||||
when(request.oceanBookingId()).thenReturn(99L);
|
||||
when(request.containerBookingId()).thenReturn(88L);
|
||||
OceanBookingEntity booking = new OceanBookingEntity(); booking.setId(99L); booking.setStatus("CONFIRMED");
|
||||
when(oceanBookings.find(99L)).thenReturn(booking);
|
||||
assertThatThrownBy(() -> service.create(7L, "admin", request)).isInstanceOf(BizException.class).hasMessageContaining("放舱");
|
||||
@@ -88,15 +89,24 @@ class TruckAppointmentServiceTest {
|
||||
SaveRequest request = mock(SaveRequest.class);
|
||||
when(request.expectedVersion()).thenReturn(0);
|
||||
when(request.oceanBookingId()).thenReturn(99L);
|
||||
when(request.containerBookingId()).thenReturn(88L);
|
||||
when(request.customerId()).thenReturn(7L);
|
||||
when(request.loadingPlanId()).thenReturn(null);
|
||||
OceanBookingEntity booking = new OceanBookingEntity(); booking.setId(99L); booking.setStatus("RELEASED"); booking.setCustomerId(7L); booking.setCustomerName("Canonical Customer");
|
||||
when(oceanBookings.find(99L)).thenReturn(booking);
|
||||
ContainerBookingEntity container = new ContainerBookingEntity(); container.setId(88L);
|
||||
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(77L); plan.setContainerBookingId(88L);
|
||||
when(loadingPlans.planByBooking(88L)).thenReturn(plan);
|
||||
service.create(7L, "admin", request);
|
||||
ArgumentCaptor<TruckAppointmentEntity> entity = ArgumentCaptor.forClass(TruckAppointmentEntity.class);
|
||||
verify(repository).insert(entity.capture());
|
||||
assertThat(entity.getValue().getCustomerId()).isEqualTo(7L);
|
||||
assertThat(entity.getValue().getCustomerName()).isEqualTo("Canonical Customer");
|
||||
assertThat(entity.getValue().getContainerBookingId()).isEqualTo(88L);
|
||||
assertThat(entity.getValue().getLoadingPlanId()).isEqualTo(77L);
|
||||
verify(repository).history(any(TruckAppointmentHistoryEntity.class));
|
||||
}
|
||||
|
||||
@@ -113,11 +123,11 @@ class TruckAppointmentServiceTest {
|
||||
var allocation = new OceanBookingContainerAllocationEntity();
|
||||
allocation.setContainerBookingId(88L);
|
||||
when(oceanBookings.allocations(99L)).thenReturn(java.util.List.of(allocation));
|
||||
LoadingPlanEntity plan = new LoadingPlanEntity(); plan.setId(77L); plan.setContainerBookingId(66L);
|
||||
when(loadingPlans.plan(77L)).thenReturn(plan);
|
||||
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("柜体");
|
||||
.isInstanceOf(BizException.class).hasMessageContaining("loadingPlanId").hasMessageContaining("当前装柜计划");
|
||||
verify(repository, never()).insert(any());
|
||||
}
|
||||
|
||||
@@ -134,10 +144,10 @@ class TruckAppointmentServiceTest {
|
||||
var allocation = new OceanBookingContainerAllocationEntity();
|
||||
allocation.setContainerBookingId(88L);
|
||||
when(oceanBookings.allocations(99L)).thenReturn(java.util.List.of(allocation));
|
||||
when(loadingPlans.plan(77L)).thenReturn(null);
|
||||
when(loadingPlans.planByBooking(88L)).thenReturn(null);
|
||||
|
||||
assertThatThrownBy(() -> service.create(7L, "admin", request))
|
||||
.isInstanceOf(BizException.class).hasMessageContaining("装柜计划不存在");
|
||||
.isInstanceOf(BizException.class).hasMessageContaining("尚未建立装柜计划");
|
||||
verify(repository, never()).insert(any());
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user