diff --git a/firstleg/src/main/java/com/baiyue/firstleg/outbound/dispatch/controller/PlatformOutboundDispatchController.java b/firstleg/src/main/java/com/baiyue/firstleg/outbound/dispatch/controller/PlatformOutboundDispatchController.java index 36f8e7bd7..73e73b70c 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/outbound/dispatch/controller/PlatformOutboundDispatchController.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/outbound/dispatch/controller/PlatformOutboundDispatchController.java @@ -28,33 +28,39 @@ public class PlatformOutboundDispatchController { FirstlegScopeGuard.platform(user); return ApiResponse.success(service.list(query)); } - @GetMapping("/detail") + @GetMapping("/{containerBookingId}") @PreAuthorize("hasAuthority('platform:firstleg:outbound-dispatch:view')") - public ApiResponse detail(@AuthenticationPrincipal AuthenticatedUser user, @Valid Query query) { - FirstlegScopeGuard.platform(user); return ApiResponse.success(service.detail(query)); + public ApiResponse detail(@AuthenticationPrincipal AuthenticatedUser user, + @PathVariable @jakarta.validation.constraints.Positive long containerBookingId) { + FirstlegScopeGuard.platform(user); return ApiResponse.success(service.detail(containerBookingId)); } - @GetMapping("/check-gate") + @GetMapping("/{containerBookingId}/gate") @PreAuthorize("hasAuthority('platform:firstleg:outbound-dispatch:check')") - public ApiResponse checkGate(@AuthenticationPrincipal AuthenticatedUser user, @Valid Query query) { - FirstlegScopeGuard.platform(user); return ApiResponse.success(service.checkGate(query)); + public ApiResponse checkGate(@AuthenticationPrincipal AuthenticatedUser user, + @PathVariable @jakarta.validation.constraints.Positive long containerBookingId) { + FirstlegScopeGuard.platform(user); return ApiResponse.success(service.checkGate(containerBookingId)); } - @PostMapping("/confirm-dispatch") + @PostMapping("/{containerBookingId}/confirm") @PreAuthorize("hasAuthority('platform:firstleg:outbound-dispatch:confirm')") - @BusinessAudit(event="OUTBOUND_CONFIRM_DISPATCH",resourceType="INBOUND",resourceId="#request.inboundId",description="确认国内出仓") + @BusinessAudit(event="OUTBOUND_CONFIRM_DISPATCH",resourceType="CONTAINER_BOOKING",resourceId="#containerBookingId",description="柜级确认国内出仓") public ApiResponse confirm(@AuthenticationPrincipal AuthenticatedUser user, + @PathVariable @jakarta.validation.constraints.Positive long containerBookingId, @RequestHeader("Idempotency-Key") String key, @Valid @RequestBody ActionRequest request) { - FirstlegScopeGuard.platform(user); return ApiResponse.success(service.confirm(user.userId(), user.username(), key, request)); + FirstlegScopeGuard.platform(user); return ApiResponse.success(service.confirm( + containerBookingId, user.userId(), user.username(), key, request)); } - @PostMapping("/reverse-dispatch") + @PostMapping("/{containerBookingId}/reverse") @PreAuthorize("hasAuthority('platform:firstleg:outbound-dispatch:reverse')") - @BusinessAudit(event="OUTBOUND_REVERSE_DISPATCH",resourceType="INBOUND",resourceId="#request.inboundId",description="撤销国内出仓") + @BusinessAudit(event="OUTBOUND_REVERSE_DISPATCH",resourceType="CONTAINER_BOOKING",resourceId="#containerBookingId",description="柜级撤销国内出仓") public ApiResponse reverse(@AuthenticationPrincipal AuthenticatedUser user, + @PathVariable @jakarta.validation.constraints.Positive long containerBookingId, @RequestHeader("Idempotency-Key") String key, @Valid @RequestBody ActionRequest request) { - FirstlegScopeGuard.platform(user); return ApiResponse.success(service.reverse(user.userId(), user.username(), key, request)); + FirstlegScopeGuard.platform(user); return ApiResponse.success(service.reverse( + containerBookingId, user.userId(), user.username(), key, request)); } } diff --git a/firstleg/src/main/java/com/baiyue/firstleg/outbound/dispatch/dto/OutboundDispatchDtos.java b/firstleg/src/main/java/com/baiyue/firstleg/outbound/dispatch/dto/OutboundDispatchDtos.java index 38c848ca6..b7c8dc1b6 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/outbound/dispatch/dto/OutboundDispatchDtos.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/outbound/dispatch/dto/OutboundDispatchDtos.java @@ -2,6 +2,7 @@ package com.baiyue.firstleg.outbound.dispatch.dto; import jakarta.validation.constraints.Positive; import jakarta.validation.constraints.PositiveOrZero; +import jakarta.validation.constraints.NotNull; import jakarta.validation.constraints.Size; import java.time.LocalDateTime; @@ -10,21 +11,27 @@ import java.util.List; public final class OutboundDispatchDtos { private OutboundDispatchDtos() {} - public record Query(@Positive Long orderId, @Positive Long inboundId, @Positive Long containerBookingId) {} + public record Query(@NotNull @Positive Long containerBookingId) {} - public record ActionRequest(@Positive Long inboundId, @Positive Long containerBookingId, - @PositiveOrZero int expectedVersion, @Size(max = 500) String remark) {} + public record ActionRequest(@NotNull @PositiveOrZero Integer expectedVersion, + @Size(max = 500) String remark) {} public record GateItem(String code, String name, boolean passed, String actual, String requirement) {} public record GateResult(boolean passed, List items, LocalDateTime checkedAt) {} - public record Aggregate(Long orderId, String orderNo, Long inboundId, String inboundCode, Long tenantId, - Long customerId, String customerName, String inboundStatus, - Long containerBookingId, String cabinetNo, String containerNo, + public record InboundBinding(Long orderId, String orderNo, Long inboundId, String inboundCode, + Long tenantId, Long customerId, String customerName, + String inboundStatus, Long pickTaskId, String pickTaskStatus) {} + + public record InboundGate(Long inboundId, String inboundCode, GateResult gate) {} + public record ContainerGateResult(boolean passed, List inbounds, LocalDateTime checkedAt) {} + + public record Aggregate(Long containerBookingId, String cabinetNo, String containerNo, Long loadingPlanId, String loadingPlanStatus, - Long pickTaskId, String pickTaskStatus, Long oceanBookingId, String oceanBookingStatus, + Long oceanBookingId, String oceanBookingStatus, Long truckAppointmentId, String truckAppointmentStatus, - String dispatchStatus, Integer version, GateResult gate) {} + String dispatchStatus, Integer version, List inbounds, + ContainerGateResult gate) {} public record TimelineItem(String module, String action, String fromStatus, String toStatus, Long operatorId, String operatorName, String remark, LocalDateTime occurredAt) {} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/outbound/dispatch/mapper/OutboundDispatchMapper.java b/firstleg/src/main/java/com/baiyue/firstleg/outbound/dispatch/mapper/OutboundDispatchMapper.java index 09fb7a8a7..9bbcf97e8 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/outbound/dispatch/mapper/OutboundDispatchMapper.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/outbound/dispatch/mapper/OutboundDispatchMapper.java @@ -14,6 +14,11 @@ public interface OutboundDispatchMapper extends BaseMapper SELECT DISTINCT inbound.order_id orderId,inbound.order_no orderNo,inbound.id inboundId, inbound.inbound_code inboundCode,inbound.tenant_id tenantId,inbound.customer_id customerId, inbound.customer_name customerName,inbound.process_status inboundStatus, @@ -22,36 +21,46 @@ public interface OutboundDispatchReadMapper { JOIN fl_container_booking booking ON booking.id=plan.container_booking_id JOIN fl_inbound_order inbound ON inbound.id=item.inbound_id AND inbound.tenant_id=item.account_id AND inbound.deleted=0 WHERE item.deleted=0 - AND inbound.order_id=#{orderId} - AND inbound.id=#{inboundId} - AND booking.id=#{containerBookingId} + AND booking.id=#{containerBookingId} ORDER BY inbound.id,booking.id - """) - List bindings(@Param("orderId") Long orderId, @Param("inboundId") Long inboundId, - @Param("containerBookingId") Long containerBookingId); + List bindings(@Param("containerBookingId") long containerBookingId); + + @Select(""" + SELECT inbound.order_id orderId,inbound.order_no orderNo,inbound.id inboundId, + inbound.inbound_code inboundCode,inbound.tenant_id tenantId,inbound.customer_id customerId, + inbound.customer_name customerName,inbound.process_status inboundStatus, + booking.id containerBookingId,booking.cabinet_no cabinetNo,booking.container_no containerNo, + plan.id loadingPlanId,plan.plan_status loadingPlanStatus + FROM fl_loading_plan_item item + JOIN fl_loading_plan plan ON plan.id=item.loading_plan_id + JOIN fl_container_booking booking ON booking.id=plan.container_booking_id + JOIN fl_inbound_order inbound ON inbound.id=item.inbound_id AND inbound.tenant_id=item.account_id AND inbound.deleted=0 + WHERE item.deleted=0 AND booking.id=#{containerBookingId} + ORDER BY inbound.id,booking.id + FOR UPDATE + """) + List bindingsForUpdate(@Param("containerBookingId") long containerBookingId); @Select("SELECT id,task_status status FROM fl_loading_pick_task WHERE loading_plan_id=#{planId} " + "ORDER BY started_at DESC,id DESC LIMIT 1") IdStatus pick(@Param("planId") long planId); @Select(""" - SELECT booking.id,booking.status + SELECT booking.id,booking.status,booking.customer_id customerId FROM fl_ocean_booking_container_allocation allocation JOIN fl_ocean_booking booking ON booking.id=allocation.ocean_booking_id - WHERE allocation.container_booking_id=#{containerBookingId} AND booking.customer_id=#{tenantId} + WHERE allocation.container_booking_id=#{containerBookingId} ORDER BY booking.updated_at DESC,booking.id DESC LIMIT 1 """) - IdStatus ocean(@Param("containerBookingId") long containerBookingId, @Param("tenantId") long tenantId); + OceanBinding ocean(@Param("containerBookingId") long containerBookingId); @Select(""" SELECT id,status FROM fl_truck_appointment WHERE container_booking_id=#{containerBookingId} AND loading_plan_id=#{loadingPlanId} - AND customer_id=#{tenantId} ORDER BY updated_at DESC,id DESC LIMIT 1 """) - IdStatus truck(@Param("containerBookingId") long containerBookingId, @Param("loadingPlanId") long loadingPlanId, - @Param("tenantId") long tenantId); + IdStatus truck(@Param("containerBookingId") long containerBookingId, @Param("loadingPlanId") long loadingPlanId); @Select(""" SELECT COUNT(DISTINCT CASE WHEN status='ACTIVE' AND file_id IS NOT NULL AND evidence_type IN @@ -122,5 +131,6 @@ public interface OutboundDispatchReadMapper { Long customerId, String customerName, String inboundStatus, Long containerBookingId, String cabinetNo, String containerNo, Long loadingPlanId, String loadingPlanStatus) {} record IdStatus(Long id, String status) {} + record OceanBinding(Long id, String status, Long customerId) {} record Evidence(Integer evidenceCount, Integer validSeal) {} } diff --git a/firstleg/src/main/java/com/baiyue/firstleg/outbound/dispatch/repository/OutboundDispatchRepository.java b/firstleg/src/main/java/com/baiyue/firstleg/outbound/dispatch/repository/OutboundDispatchRepository.java index a0f87004a..3ad889820 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/outbound/dispatch/repository/OutboundDispatchRepository.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/outbound/dispatch/repository/OutboundDispatchRepository.java @@ -1,7 +1,6 @@ package com.baiyue.firstleg.outbound.dispatch.repository; import com.baomidou.mybatisplus.core.toolkit.IdWorker; -import com.baiyue.firstleg.outbound.dispatch.dto.OutboundDispatchDtos.Query; import com.baiyue.firstleg.outbound.dispatch.dto.OutboundDispatchDtos.TimelineItem; import com.baiyue.firstleg.outbound.dispatch.entity.OutboundDispatchEntity; import com.baiyue.firstleg.outbound.dispatch.entity.OutboundDispatchHistoryEntity; @@ -22,15 +21,14 @@ public class OutboundDispatchRepository { @Resource private OutboundDispatchOperationMapper operations; @Resource private OutboundDispatchReadMapper reads; - public List bindings(Query query) { - return reads.bindings(query.orderId(), query.inboundId(), query.containerBookingId()); + public List bindings(long containerId) { return reads.bindings(containerId); } + public List lockBindings(long containerId) { + return reads.bindingsForUpdate(containerId); } public OutboundDispatchReadMapper.IdStatus pick(long planId) { return reads.pick(planId); } - public OutboundDispatchReadMapper.IdStatus ocean(long containerId, long tenantId) { - return reads.ocean(containerId, tenantId); - } - public OutboundDispatchReadMapper.IdStatus truck(long containerId, long planId, long tenantId) { - return reads.truck(containerId, planId, tenantId); + public OutboundDispatchReadMapper.OceanBinding ocean(long containerId) { return reads.ocean(containerId); } + public OutboundDispatchReadMapper.IdStatus truck(long containerId, long planId) { + return reads.truck(containerId, planId); } public OutboundDispatchReadMapper.Evidence evidence(long tenantId, long inboundId) { return reads.evidence(tenantId, inboundId); @@ -38,6 +36,9 @@ public class OutboundDispatchRepository { public OutboundDispatchEntity dispatch(long tenantId, long inboundId, long containerId) { return dispatches.find(tenantId, inboundId, containerId); } + public OutboundDispatchEntity dispatchLocked(long tenantId, long inboundId, long containerId) { + return dispatches.findForUpdate(tenantId, inboundId, containerId); + } public void insert(OutboundDispatchEntity value) { dispatches.insert(value); } public int confirm(long id, int version, long operatorId, String operatorName, String remark) { return dispatches.confirm(id, version, operatorId, operatorName, remark); diff --git a/firstleg/src/main/java/com/baiyue/firstleg/outbound/dispatch/service/OutboundDispatchService.java b/firstleg/src/main/java/com/baiyue/firstleg/outbound/dispatch/service/OutboundDispatchService.java index fad3762c1..4192ec0b2 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/outbound/dispatch/service/OutboundDispatchService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/outbound/dispatch/service/OutboundDispatchService.java @@ -51,93 +51,132 @@ public class OutboundDispatchService { } public List list(Query query) { - requireSelector(query); - return repository.bindings(query).stream().map(this::aggregate).toList(); + return List.of(aggregate(bindings(query.containerBookingId()))); } - public Detail detail(Query query) { - OutboundDispatchReadMapper.Binding binding = single(query); - Aggregate aggregate = aggregate(binding); - return new Detail(aggregate, repository.timeline(binding, aggregate.oceanBookingId(), aggregate.truckAppointmentId())); + public Detail detail(long containerBookingId) { + List bindings = bindings(containerBookingId); + Aggregate aggregate = aggregate(bindings); + List timeline = bindings.stream() + .flatMap(binding -> repository.timeline(binding, aggregate.oceanBookingId(), aggregate.truckAppointmentId()).stream()) + .distinct() + .sorted(Comparator.comparing(TimelineItem::occurredAt, + Comparator.nullsLast(Comparator.naturalOrder())).thenComparing(TimelineItem::module)) + .toList(); + return new Detail(aggregate, timeline); } - public GateResult checkGate(Query query) { return aggregate(single(query)).gate(); } + public ContainerGateResult checkGate(long containerBookingId) { + return aggregate(bindings(containerBookingId)).gate(); + } @Transactional - public Detail confirm(long operatorId, String operatorName, String key, ActionRequest request) { + public Detail confirm(long containerBookingId, long operatorId, String operatorName, + String key, ActionRequest request) { validateKey(key); - Query query = new Query(null, request.inboundId(), request.containerBookingId()); - OutboundDispatchReadMapper.Binding binding = single(query); - String hash = hash("CONFIRM", request); - Detail replay = replay(operatorId, "CONFIRM_DISPATCH", key, hash, query); + String hash = hash("CONFIRM", containerBookingId, request); + Detail replay = replay(containerBookingId, operatorId, "CONFIRM_DISPATCH", key, hash); if (replay != null) return replay; - Aggregate before = aggregate(binding); - if (!before.gate().passed()) throw conflict("国内出仓门禁未通过"); - OutboundDispatchEntity state = state(binding); - if (!Objects.equals(state.getVersion(), request.expectedVersion())) throw concurrent(); - if (OutboundDispatchStatus.WAITING_FOR_LOADING.name().equals(state.getStatus())) { - throw conflict("当前出仓单已确认派车"); + List bindings = lockedBindings(containerBookingId); + Aggregate before = aggregate(bindings); + if (!before.gate().passed()) throw conflict("柜内全部入库绑定尚未通过国内出仓门禁"); + List states = bindings.stream().map(this::stateLocked).toList(); + StateView visible = visibleState(states); + if (!Objects.equals(visible.version(), request.expectedVersion())) throw concurrent(); + if (OutboundDispatchStatus.WAITING_FOR_LOADING.name().equals(visible.status())) { + throw conflict("当前货柜已确认离仓"); } - if (repository.confirm(state.getId(), state.getVersion(), operatorId, operatorName, request.remark()) != 1) { - throw concurrent(); + for (OutboundDispatchEntity state : states) { + if (repository.confirm(state.getId(), state.getVersion(), operatorId, operatorName, request.remark()) != 1) { + throw concurrent(); + } } - history(state, "CONFIRM_DISPATCH", OutboundDispatchStatus.READY_FOR_DISPATCH.name(), + OutboundDispatchEntity representative = states.get(0); + history(representative, "CONFIRM_DISPATCH", OutboundDispatchStatus.READY_FOR_DISPATCH.name(), OutboundDispatchStatus.WAITING_FOR_LOADING.name(), request.remark(), operatorId, operatorName); + + OutboundDispatchReadMapper.OceanBinding ocean = repository.ocean(containerBookingId); if (carrierSchedules != null) { - carrierSchedules.ensureFromOutboundConfirm(binding.tenantId(), before.oceanBookingId(), - binding.containerBookingId(), operatorId, operatorName); + if (ocean == null || ocean.id() == null || ocean.customerId() == null) { + throw conflict("当前货柜未关联有效订舱,无法创建船司查询任务"); + } + carrierSchedules.ensureFromOutboundConfirm(ocean.customerId(), ocean.id(), containerBookingId, + operatorId, operatorName); } - if (repository.completeOperation(state.getId(), operatorId, "CONFIRM_DISPATCH", key) != 1) throw concurrent(); - return detail(query); + if (repository.completeOperation(representative.getId(), operatorId, "CONFIRM_DISPATCH", key) != 1) { + throw concurrent(); + } + return detail(containerBookingId); } @Transactional - public Detail reverse(long operatorId, String operatorName, String key, ActionRequest request) { + public Detail reverse(long containerBookingId, long operatorId, String operatorName, + String key, ActionRequest request) { validateKey(key); - Query query = new Query(null, request.inboundId(), request.containerBookingId()); - OutboundDispatchReadMapper.Binding binding = single(query); - String hash = hash("REVERSE", request); - Detail replay = replay(operatorId, "REVERSE_DISPATCH", key, hash, query); + String hash = hash("REVERSE", containerBookingId, request); + Detail replay = replay(containerBookingId, operatorId, "REVERSE_DISPATCH", key, hash); if (replay != null) return replay; - OutboundDispatchEntity state = repository.dispatch(binding.tenantId(), binding.inboundId(), binding.containerBookingId()); - if (state == null || !OutboundDispatchStatus.WAITING_FOR_LOADING.name().equals(state.getStatus())) { - throw conflict("当前出仓单不在等待装船状态"); + List bindings = lockedBindings(containerBookingId); + List states = bindings.stream().map(this::existingStateLocked).toList(); + StateView visible = visibleState(states); + if (!OutboundDispatchStatus.WAITING_FOR_LOADING.name().equals(visible.status())) { + throw conflict("当前货柜不在已确认离仓状态"); } - if (!Objects.equals(state.getVersion(), request.expectedVersion())) throw concurrent(); - OutboundDispatchReadMapper.IdStatus ocean = repository.ocean(binding.containerBookingId(), binding.tenantId()); - if (carrierSchedules != null && ocean != null && carrierSchedules.hasOutboundDepartureFact( - binding.tenantId(), ocean.id(), binding.containerBookingId())) { - throw conflict("已产生实际DEPARTED_WAREHOUSE船节点,禁止撤销国内出仓"); + if (!Objects.equals(visible.version(), request.expectedVersion())) throw concurrent(); + OutboundDispatchReadMapper.OceanBinding ocean = repository.ocean(containerBookingId); + if (carrierSchedules != null) { + if (ocean == null || ocean.id() == null || ocean.customerId() == null) { + throw conflict("无法核验货柜后续船节点,禁止撤销国内出仓"); + } + if (carrierSchedules.hasOutboundDepartureFact(ocean.customerId(), ocean.id(), containerBookingId)) { + throw conflict("已产生实际DEPARTED_WAREHOUSE船节点,禁止撤销国内出仓"); + } } - if (repository.reverse(state.getId(), state.getVersion(), operatorId, operatorName, request.remark()) != 1) { + for (OutboundDispatchEntity state : states) { + if (repository.reverse(state.getId(), state.getVersion(), operatorId, operatorName, request.remark()) != 1) { + throw concurrent(); + } + } + OutboundDispatchEntity representative = states.get(0); + history(representative, "REVERSE_DISPATCH", OutboundDispatchStatus.WAITING_FOR_LOADING.name(), + OutboundDispatchStatus.READY_FOR_DISPATCH.name(), request.remark(), operatorId, operatorName); + if (repository.completeOperation(representative.getId(), operatorId, "REVERSE_DISPATCH", key) != 1) { throw concurrent(); } - history(state, "REVERSE_DISPATCH", OutboundDispatchStatus.WAITING_FOR_LOADING.name(), - OutboundDispatchStatus.READY_FOR_DISPATCH.name(), request.remark(), operatorId, operatorName); - if (repository.completeOperation(state.getId(), operatorId, "REVERSE_DISPATCH", key) != 1) throw concurrent(); - return detail(query); + return detail(containerBookingId); } - private Aggregate aggregate(OutboundDispatchReadMapper.Binding binding) { - var pick = repository.pick(binding.loadingPlanId()); - var ocean = repository.ocean(binding.containerBookingId(), binding.tenantId()); - var truck = repository.truck(binding.containerBookingId(), binding.loadingPlanId(), binding.tenantId()); - var evidence = repository.evidence(binding.tenantId(), binding.inboundId()); - var dispatch = repository.dispatch(binding.tenantId(), binding.inboundId(), binding.containerBookingId()); - GateResult gate = gate(binding, pick, ocean, truck, evidence); - return new Aggregate(binding.orderId(), binding.orderNo(), binding.inboundId(), binding.inboundCode(), - binding.tenantId(), binding.customerId(), binding.customerName(), binding.inboundStatus(), - binding.containerBookingId(), binding.cabinetNo(), binding.containerNo(), - binding.loadingPlanId(), binding.loadingPlanStatus(), - id(pick), status(pick), id(ocean), status(ocean), id(truck), status(truck), - dispatch == null ? OutboundDispatchStatus.READY_FOR_DISPATCH.name() : dispatch.getStatus(), - dispatch == null ? 0 : dispatch.getVersion(), gate); + private Aggregate aggregate(List bindings) { + OutboundDispatchReadMapper.Binding first = validateContainer(bindings); + var pick = repository.pick(first.loadingPlanId()); + var ocean = repository.ocean(first.containerBookingId()); + var truck = repository.truck(first.containerBookingId(), first.loadingPlanId()); + List inboundGates = new ArrayList<>(); + List inboundDtos = new ArrayList<>(); + List states = new ArrayList<>(); + for (OutboundDispatchReadMapper.Binding binding : bindings) { + var evidence = repository.evidence(binding.tenantId(), binding.inboundId()); + GateResult gate = gate(binding, pick, ocean, truck, evidence); + inboundGates.add(new InboundGate(binding.inboundId(), binding.inboundCode(), gate)); + inboundDtos.add(new InboundBinding(binding.orderId(), binding.orderNo(), binding.inboundId(), + binding.inboundCode(), binding.tenantId(), binding.customerId(), binding.customerName(), + binding.inboundStatus(), id(pick), status(pick))); + OutboundDispatchEntity state = repository.dispatch(binding.tenantId(), binding.inboundId(), + binding.containerBookingId()); + if (state != null) states.add(state); + } + StateView visible = visibleStateForRead(states, bindings.size()); + ContainerGateResult containerGate = new ContainerGateResult( + inboundGates.stream().allMatch(value -> value.gate().passed()), List.copyOf(inboundGates), LocalDateTime.now()); + return new Aggregate(first.containerBookingId(), first.cabinetNo(), first.containerNo(), + first.loadingPlanId(), first.loadingPlanStatus(), id(ocean), status(ocean), id(truck), status(truck), + visible.status(), visible.version(), List.copyOf(inboundDtos), containerGate); } private GateResult gate(OutboundDispatchReadMapper.Binding binding, OutboundDispatchReadMapper.IdStatus pick, - OutboundDispatchReadMapper.IdStatus ocean, OutboundDispatchReadMapper.IdStatus truck, + OutboundDispatchReadMapper.OceanBinding ocean, OutboundDispatchReadMapper.IdStatus truck, OutboundDispatchReadMapper.Evidence evidence) { List items = new ArrayList<>(); items.add(item("INBOUND_LOADED", "入库单装柜完成", "LOADED".equals(binding.inboundStatus()), @@ -174,68 +213,125 @@ public class OutboundDispatchService { return new GateResult(items.stream().allMatch(GateItem::passed), List.copyOf(items), LocalDateTime.now()); } - private Detail replay(long operatorId, String operation, String key, String hash, Query query) { + private Detail replay(long containerBookingId, long operatorId, String operation, String key, String hash) { if (repository.claim(operatorId, operation, key, hash)) return null; var existing = repository.operation(operatorId, operation, key); if (existing == null || !Objects.equals(existing.getRequestHash(), hash)) { throw conflict("Idempotency-Key已用于不同请求"); } if (!"SUCCESS".equals(existing.getStatus())) throw conflict("相同请求正在处理中"); - return detail(query); + return detail(containerBookingId); } - private OutboundDispatchEntity state(OutboundDispatchReadMapper.Binding binding) { - OutboundDispatchEntity state = repository.dispatch(binding.tenantId(), binding.inboundId(), binding.containerBookingId()); + private OutboundDispatchEntity stateLocked(OutboundDispatchReadMapper.Binding binding) { + OutboundDispatchEntity state = repository.dispatchLocked(binding.tenantId(), binding.inboundId(), + binding.containerBookingId()); if (state != null) return state; state = new OutboundDispatchEntity(); - state.setId(IdWorker.getId()); state.setTenantId(binding.tenantId()); state.setOrderId(binding.orderId()); - state.setInboundId(binding.inboundId()); state.setContainerBookingId(binding.containerBookingId()); - state.setStatus(OutboundDispatchStatus.READY_FOR_DISPATCH.name()); state.setVersion(0); - try { repository.insert(state); } - catch (DuplicateKeyException ex) { - state = repository.dispatch(binding.tenantId(), binding.inboundId(), binding.containerBookingId()); + state.setId(IdWorker.getId()); + state.setTenantId(binding.tenantId()); + state.setOrderId(binding.orderId()); + state.setInboundId(binding.inboundId()); + state.setContainerBookingId(binding.containerBookingId()); + state.setStatus(OutboundDispatchStatus.READY_FOR_DISPATCH.name()); + state.setVersion(0); + try { + repository.insert(state); + } catch (DuplicateKeyException ex) { + state = repository.dispatchLocked(binding.tenantId(), binding.inboundId(), binding.containerBookingId()); if (state == null) throw ex; } return state; } + private OutboundDispatchEntity existingStateLocked(OutboundDispatchReadMapper.Binding binding) { + OutboundDispatchEntity state = repository.dispatchLocked(binding.tenantId(), binding.inboundId(), + binding.containerBookingId()); + if (state == null) throw conflict("当前货柜尚未确认离仓"); + return state; + } + private void history(OutboundDispatchEntity state, String action, String from, String to, String remark, long operatorId, String operatorName) { OutboundDispatchHistoryEntity history = new OutboundDispatchHistoryEntity(); - history.setId(IdWorker.getId()); history.setDispatchId(state.getId()); history.setTenantId(state.getTenantId()); - history.setInboundId(state.getInboundId()); history.setContainerBookingId(state.getContainerBookingId()); - history.setAction(action); history.setFromStatus(from); history.setToStatus(to); history.setRemark(remark); - history.setOperatorId(operatorId); history.setOperatorName(operatorName); repository.history(history); + history.setId(IdWorker.getId()); + history.setDispatchId(state.getId()); + history.setTenantId(state.getTenantId()); + history.setInboundId(state.getInboundId()); + history.setContainerBookingId(state.getContainerBookingId()); + history.setAction(action); + history.setFromStatus(from); + history.setToStatus(to); + history.setRemark(remark); + history.setOperatorId(operatorId); + history.setOperatorName(operatorName); + repository.history(history); } - private OutboundDispatchReadMapper.Binding single(Query query) { - requireSelector(query); - List bindings = repository.bindings(query); - if (bindings.isEmpty()) throw new BizException(ResultCode.NOT_FOUND, "未找到匹配的入库单与柜次关联"); - if (bindings.size() != 1) throw new BizException(ResultCode.BAD_REQUEST, "查询条件匹配多个出仓聚合,请同时指定inboundId和containerBookingId"); - return bindings.get(0); + private List bindings(long containerBookingId) { + return normalize(repository.bindings(containerBookingId)); } - private static void requireSelector(Query query) { - if (query == null || query.orderId() == null && query.inboundId() == null && query.containerBookingId() == null) { - throw new BizException(ResultCode.BAD_REQUEST, "orderId、inboundId、containerBookingId至少提供一个"); + private List lockedBindings(long containerBookingId) { + return normalize(repository.lockBindings(containerBookingId)); + } + + private static List normalize(List values) { + if (values == null || values.isEmpty()) { + throw new BizException(ResultCode.NOT_FOUND, "未找到当前货柜的真实入库绑定"); } + LinkedHashMap unique = new LinkedHashMap<>(); + for (OutboundDispatchReadMapper.Binding value : values) unique.putIfAbsent(value.inboundId(), value); + return List.copyOf(unique.values()); } + + private static OutboundDispatchReadMapper.Binding validateContainer(List bindings) { + OutboundDispatchReadMapper.Binding first = bindings.get(0); + boolean inconsistent = bindings.stream().anyMatch(binding -> + !Objects.equals(first.containerBookingId(), binding.containerBookingId()) + || !Objects.equals(first.loadingPlanId(), binding.loadingPlanId())); + if (inconsistent) throw conflict("货柜入库绑定存在跨柜次数据冲突"); + return first; + } + + private static StateView visibleStateForRead(List states, int bindingCount) { + if (states.isEmpty()) return new StateView(OutboundDispatchStatus.READY_FOR_DISPATCH.name(), 0); + if (states.size() != bindingCount) throw conflict("货柜出仓状态不完整,请联系管理员处理"); + return visibleState(states); + } + + private static StateView visibleState(List states) { + if (states.isEmpty()) throw conflict("货柜出仓状态不存在"); + String status = states.get(0).getStatus(); + Integer version = states.get(0).getVersion(); + if (states.stream().anyMatch(value -> !Objects.equals(status, value.getStatus()) + || !Objects.equals(version, value.getVersion()))) { + throw conflict("货柜出仓状态或版本不一致,请联系管理员处理"); + } + return new StateView(status, version); + } + private static void validateKey(String key) { if (key == null || key.isBlank() || key.length() > 128) { throw new BizException(ResultCode.BAD_REQUEST, "Idempotency-Key不能为空且长度不能超过128"); } } - private static String hash(String action, ActionRequest request) { - String source = action + "|" + request.inboundId() + "|" + request.containerBookingId() + "|" - + request.expectedVersion() + "|" + Objects.toString(request.remark(), ""); + + private static String hash(String action, long containerBookingId, ActionRequest request) { + String source = action + "|" + containerBookingId + "|" + request.expectedVersion() + "|" + + Objects.toString(request.remark(), ""); try { byte[] digest = MessageDigest.getInstance("SHA-256").digest(source.getBytes(StandardCharsets.UTF_8)); return HexFormat.of().formatHex(digest); - } catch (Exception ex) { throw new IllegalStateException(ex); } + } catch (Exception ex) { + throw new IllegalStateException(ex); + } } + private static Long id(OutboundDispatchReadMapper.IdStatus value) { return value == null ? null : value.id(); } + private static Long id(OutboundDispatchReadMapper.OceanBinding value) { return value == null ? null : value.id(); } private static String status(OutboundDispatchReadMapper.IdStatus value) { return value == null ? null : value.status(); } + private static String status(OutboundDispatchReadMapper.OceanBinding value) { return value == null ? null : value.status(); } private static GateItem item(String code, String name, boolean pass, String actual, String requirement) { return new GateItem(code, name, pass, actual, requirement); } @@ -244,4 +340,6 @@ public class OutboundDispatchService { } private static BizException conflict(String message) { return new BizException(ResultCode.CONFLICT, message); } private static BizException concurrent() { return conflict("数据已被其他人修改,请刷新后重试"); } + + private record StateView(String status, Integer version) {} } diff --git a/firstleg/src/main/resources/openapi/phase3-outbound-dispatch-v1.yaml b/firstleg/src/main/resources/openapi/phase3-outbound-dispatch-v1.yaml index 8553a2767..b590dfc68 100644 --- a/firstleg/src/main/resources/openapi/phase3-outbound-dispatch-v1.yaml +++ b/firstleg/src/main/resources/openapi/phase3-outbound-dispatch-v1.yaml @@ -1,85 +1,116 @@ openapi: 3.0.3 info: - title: Phase 3 Domestic Outbound Dispatch API - version: 1.0.0 + title: Phase 3 Container-level Domestic Outbound Dispatch API + version: 2.0.0 paths: /api/platform/v1/first-leg/outbound-dispatches: get: - summary: Query outbound aggregates by order, inbound or container + summary: Query the outbound aggregate for one container booking + description: Returns one container-level aggregate containing every real inbound binding. No per-inbound partial outbound aggregate is exposed. + x-permission: platform:firstleg:outbound-dispatch:list parameters: - - {$ref: '#/components/parameters/OrderId'} - - {$ref: '#/components/parameters/InboundId'} - - {$ref: '#/components/parameters/ContainerBookingId'} + - {$ref: '#/components/parameters/ContainerBookingIdQuery'} responses: '200': - description: Business envelope containing matching aggregates, each with stable cabinetNo and real containerNo + description: Business envelope containing a single matching aggregate in a list content: application/json: schema: type: object properties: data: {type: array, items: {$ref: '#/components/schemas/Aggregate'}} - /api/platform/v1/first-leg/outbound-dispatches/detail: + /api/platform/v1/first-leg/outbound-dispatches/{containerBookingId}: get: - summary: Query one aggregate and its unified timeline + summary: Query one container-level aggregate and unified timeline + x-permission: platform:firstleg:outbound-dispatch:view parameters: - - {$ref: '#/components/parameters/OrderId'} - - {$ref: '#/components/parameters/InboundId'} - - {$ref: '#/components/parameters/ContainerBookingId'} + - {$ref: '#/components/parameters/ContainerBookingIdPath'} responses: '200': - description: Business envelope containing aggregate detail + description: Business envelope containing all inbound bindings and one visible dispatch version content: application/json: schema: type: object properties: - data: - type: object - properties: - aggregate: {$ref: '#/components/schemas/Aggregate'} - /api/platform/v1/first-leg/outbound-dispatches/check-gate: + data: {$ref: '#/components/schemas/Detail'} + /api/platform/v1/first-leg/outbound-dispatches/{containerBookingId}/gate: get: - summary: Evaluate the fail-closed domestic outbound gate + summary: Evaluate every inbound binding in the fail-closed container outbound gate + x-permission: platform:firstleg:outbound-dispatch:check parameters: - - {$ref: '#/components/parameters/OrderId'} - - {$ref: '#/components/parameters/InboundId'} - - {$ref: '#/components/parameters/ContainerBookingId'} + - {$ref: '#/components/parameters/ContainerBookingIdPath'} responses: - '200': {description: Business envelope containing every gate item} - /api/platform/v1/first-leg/outbound-dispatches/confirm-dispatch: + '200': {description: Business envelope containing the container gate and per-inbound gate results} + /api/platform/v1/first-leg/outbound-dispatches/{containerBookingId}/confirm: post: - summary: Confirm domestic dispatch and enter WAITING_FOR_LOADING + summary: Atomically confirm domestic departure for the entire container + description: Locks every real inbound binding, requires every gate to pass, advances all technical rows in one transaction, records one visible history, and creates the CarrierScheduleQuery/DEPARTED_WAREHOUSE fact once. No partial success is possible. + x-permission: platform:firstleg:outbound-dispatch:confirm parameters: + - {$ref: '#/components/parameters/ContainerBookingIdPath'} - {$ref: '#/components/parameters/IdempotencyKey'} requestBody: required: true content: application/json: {schema: {$ref: '#/components/schemas/ActionRequest'}} responses: - '200': {description: Business envelope; gate or version failures use business error code} - /api/platform/v1/first-leg/outbound-dispatches/reverse-dispatch: + '200': {description: Same key and same payload replays the single container-level result; stale version or changed payload returns A0409} + /api/platform/v1/first-leg/outbound-dispatches/{containerBookingId}/reverse: post: - summary: Reverse WAITING_FOR_LOADING only before maritime tracking starts - description: Once an actual DEPARTED_WAREHOUSE node exists, reversal is rejected with business code A0409 so READY_FOR_DISPATCH cannot coexist with maritime tracking. + summary: Atomically reverse the entire container before any actual shipping node exists + description: Once an actual DEPARTED_WAREHOUSE node exists, reversal is rejected with business code A0409 so an outbound fact can never coexist with READY_FOR_DISPATCH. + x-permission: platform:firstleg:outbound-dispatch:reverse parameters: + - {$ref: '#/components/parameters/ContainerBookingIdPath'} - {$ref: '#/components/parameters/IdempotencyKey'} requestBody: required: true content: application/json: {schema: {$ref: '#/components/schemas/ActionRequest'}} responses: - '200': {description: Business envelope containing reverted aggregate} + '200': {description: Business envelope containing the reverted container aggregate} components: parameters: - OrderId: {name: orderId, in: query, schema: {type: integer, format: int64, minimum: 1}} - InboundId: {name: inboundId, in: query, schema: {type: integer, format: int64, minimum: 1}} - ContainerBookingId: {name: containerBookingId, in: query, schema: {type: integer, format: int64, minimum: 1}} - IdempotencyKey: {name: Idempotency-Key, in: header, required: true, schema: {type: string, maxLength: 128}} + ContainerBookingIdQuery: + name: containerBookingId + in: query + required: true + schema: {type: integer, format: int64, minimum: 1} + ContainerBookingIdPath: + name: containerBookingId + in: path + required: true + schema: {type: integer, format: int64, minimum: 1} + IdempotencyKey: + name: Idempotency-Key + in: header + required: true + schema: {type: string, maxLength: 128} schemas: Aggregate: type: object - required: [orderId, orderNo, inboundId, inboundCode, tenantId, inboundStatus, containerBookingId, cabinetNo, loadingPlanId, loadingPlanStatus, dispatchStatus, version, gate] + required: [containerBookingId, cabinetNo, loadingPlanId, loadingPlanStatus, dispatchStatus, version, inbounds, gate] + properties: + containerBookingId: {type: integer, format: int64} + cabinetNo: {type: string, description: Stable cabinet number} + containerNo: {type: string, nullable: true, description: Real container number} + loadingPlanId: {type: integer, format: int64} + loadingPlanStatus: {type: string} + oceanBookingId: {type: integer, format: int64, nullable: true} + oceanBookingStatus: {type: string, nullable: true} + truckAppointmentId: {type: integer, format: int64, nullable: true} + truckAppointmentStatus: {type: string, nullable: true} + dispatchStatus: {type: string, description: One visible container-level status} + version: {type: integer, minimum: 0, description: One optimistic-lock version shared by all technical inbound rows} + inbounds: + type: array + minItems: 1 + items: {$ref: '#/components/schemas/InboundBinding'} + gate: {$ref: '#/components/schemas/ContainerGateResult'} + InboundBinding: + type: object + required: [orderId, orderNo, inboundId, inboundCode, tenantId, inboundStatus] properties: orderId: {type: integer, format: int64} orderNo: {type: string} @@ -89,25 +120,35 @@ components: customerId: {type: integer, format: int64, nullable: true} customerName: {type: string, nullable: true} inboundStatus: {type: string} - containerBookingId: {type: integer, format: int64} - cabinetNo: {type: string, description: 稳定柜次号} - containerNo: {type: string, nullable: true, description: 真实集装箱号} - loadingPlanId: {type: integer, format: int64} - loadingPlanStatus: {type: string} pickTaskId: {type: integer, format: int64, nullable: true} pickTaskStatus: {type: string, nullable: true} - oceanBookingId: {type: integer, format: int64, nullable: true} - oceanBookingStatus: {type: string, nullable: true} - truckAppointmentId: {type: integer, format: int64, nullable: true} - truckAppointmentStatus: {type: string, nullable: true} - dispatchStatus: {type: string} - version: {type: integer, minimum: 0} - gate: {type: object} + ContainerGateResult: + type: object + required: [passed, inbounds, checkedAt] + properties: + passed: {type: boolean} + inbounds: + type: array + items: + type: object + required: [inboundId, inboundCode, gate] + properties: + inboundId: {type: integer, format: int64} + inboundCode: {type: string} + gate: {type: object} + checkedAt: {type: string, format: date-time} + Detail: + type: object + required: [aggregate, timeline] + properties: + aggregate: {$ref: '#/components/schemas/Aggregate'} + timeline: + type: array + description: Unified immutable facts; a container confirm or reverse contributes one visible history item. + items: {type: object} ActionRequest: type: object - required: [inboundId, containerBookingId, expectedVersion] + required: [expectedVersion] properties: - inboundId: {type: integer, format: int64, minimum: 1} - containerBookingId: {type: integer, format: int64, minimum: 1} expectedVersion: {type: integer, minimum: 0} remark: {type: string, maxLength: 500} diff --git a/firstleg/src/test/java/com/baiyue/firstleg/outbound/dispatch/OutboundDispatchReadMapperContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/outbound/dispatch/OutboundDispatchReadMapperContractTest.java index d4f2eebfd..2a15df4bf 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/outbound/dispatch/OutboundDispatchReadMapperContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/outbound/dispatch/OutboundDispatchReadMapperContractTest.java @@ -10,9 +10,25 @@ class OutboundDispatchReadMapperContractTest { @Test void bindingProjectsStableCabinetAndRealContainerNumbersSeparately() throws Exception { String sql = String.join("\n", OutboundDispatchReadMapper.class - .getMethod("bindings", Long.class, Long.class, Long.class) + .getMethod("bindings", long.class) .getAnnotation(Select.class).value()); - assertThat(sql).contains("booking.cabinet_no cabinetNo", "booking.container_no containerNo"); + assertThat(sql) + .contains("booking.cabinet_no cabinetNo", "booking.container_no containerNo") + .contains("booking.id=#{containerBookingId}") + .doesNotContain("#{orderId}", "#{inboundId}"); + } + + @Test + void actionQueryLocksEveryRealInboundBindingForTheContainer() throws Exception { + String sql = String.join("\n", OutboundDispatchReadMapper.class + .getMethod("bindingsForUpdate", long.class) + .getAnnotation(Select.class).value()); + + assertThat(sql) + .contains("booking.id=#{containerBookingId}") + .contains("item.deleted=0", "inbound.deleted=0") + .contains("FOR UPDATE") + .doesNotContain("LIMIT 1"); } @Test @@ -36,14 +52,19 @@ class OutboundDispatchReadMapperContractTest { } @Test - void oceanAndTruckQueriesUseTenantIsolationKey() throws Exception { - String oceanSql = OutboundDispatchReadMapper.class.getMethod("ocean", long.class, long.class) + void oceanAndTruckQueriesAreResolvedOnceFromTheContainerRoot() throws Exception { + String oceanSql = OutboundDispatchReadMapper.class.getMethod("ocean", long.class) .getAnnotation(Select.class).value()[0]; - String truckSql = OutboundDispatchReadMapper.class.getMethod("truck", long.class, long.class, long.class) + String truckSql = OutboundDispatchReadMapper.class.getMethod("truck", long.class, long.class) .getAnnotation(Select.class).value()[0]; - assertThat(oceanSql).contains("booking.customer_id=#{tenantId}").doesNotContain("#{customerId}"); - assertThat(truckSql).contains("customer_id=#{tenantId}").doesNotContain("#{customerId}"); + assertThat(oceanSql) + .contains("allocation.container_booking_id=#{containerBookingId}") + .contains("booking.customer_id customerId") + .doesNotContain("#{tenantId}"); + assertThat(truckSql) + .contains("container_booking_id=#{containerBookingId}", "loading_plan_id=#{loadingPlanId}") + .doesNotContain("#{tenantId}"); } @Test diff --git a/firstleg/src/test/java/com/baiyue/firstleg/outbound/dispatch/OutboundDispatchServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/outbound/dispatch/OutboundDispatchServiceTest.java index 8c4bf583f..e85d69d17 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/outbound/dispatch/OutboundDispatchServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/outbound/dispatch/OutboundDispatchServiceTest.java @@ -9,9 +9,11 @@ import com.baiyue.firstleg.outbound.dispatch.mapper.OutboundDispatchReadMapper; import com.baiyue.firstleg.outbound.dispatch.repository.OutboundDispatchRepository; import com.baiyue.firstleg.outbound.dispatch.service.OutboundDispatchService; import com.baiyue.firstleg.portschedule.service.CarrierScheduleQueryService; +import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import java.util.List; +import java.util.concurrent.atomic.AtomicReference; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; @@ -19,159 +21,191 @@ import static org.mockito.ArgumentMatchers.*; import static org.mockito.Mockito.*; class OutboundDispatchServiceTest { - private final OutboundDispatchRepository repository = mock(OutboundDispatchRepository.class); + private OutboundDispatchRepository repository; + + @BeforeEach + void setUp() { + repository = mock(OutboundDispatchRepository.class); + when(repository.timeline(any(), any(), any())).thenReturn(List.of()); + } @Test - void gateFailsClosedWhenCustomsAndScheduleModulesAreUnavailable() { - OutboundDispatchService service = new OutboundDispatchService(repository, List.of()); - stubReadyChain(); + void gateAggregatesEveryRealInboundAndFailsClosedWhenOneBindingFails() { + OutboundDispatchService service = serviceWithoutCarrier(); + stubContainer(); + when(repository.evidence(7L, 12L)).thenReturn(new OutboundDispatchReadMapper.Evidence(4, 1)); - GateResult result = service.checkGate(new Query(null, 11L, 22L)); + ContainerGateResult result = service.checkGate(22L); assertThat(result.passed()).isFalse(); - assertThat(result.items()).filteredOn(item -> !item.passed()) - .extracting(GateItem::code).containsExactlyInAnyOrder("CUSTOMS_CLEARANCE", "SAILING_SCHEDULE"); + assertThat(result.inbounds()).hasSize(2); + assertThat(result.inbounds().get(1).gate().items()) + .filteredOn(item -> "LOADING_EVIDENCE_COMPLETE".equals(item.code())) + .extracting(GateItem::passed).containsExactly(false); } @Test - void aggregateUsesTenantKeyWhenCustomerEntityIdDiffers() { - OutboundDispatchService service = new OutboundDispatchService(repository, List.of()); - stubReadyChain(); + void detailReturnsOneVersionAndAllInboundBindings() { + OutboundDispatchService service = serviceWithoutCarrier(); + stubContainer(); + OutboundDispatchEntity first = state(31L, 11L, "WAITING_FOR_LOADING", 3); + OutboundDispatchEntity second = state(32L, 12L, "WAITING_FOR_LOADING", 3); + when(repository.dispatch(7L, 11L, 22L)).thenReturn(first); + when(repository.dispatch(7L, 12L, 22L)).thenReturn(second); - service.checkGate(new Query(null, 11L, 22L)); + Detail detail = service.detail(22L); - verify(repository).ocean(22L, 7L); - verify(repository).truck(22L, 44L, 7L); - verify(repository, never()).ocean(22L, 8L); - verify(repository, never()).truck(22L, 44L, 8L); + assertThat(detail.aggregate().inbounds()).extracting(InboundBinding::inboundId).containsExactly(11L, 12L); + assertThat(detail.aggregate().dispatchStatus()).isEqualTo("WAITING_FOR_LOADING"); + assertThat(detail.aggregate().version()).isEqualTo(3); } @Test - void confirmAdvancesDispatchWithoutMutatingLoadedInbound() { - OutboundDispatchService service = new OutboundDispatchService(repository, - List.of(passing("CUSTOMS_CLEARANCE"), passing("SAILING_SCHEDULE"))); - stubReadyChain(); - OutboundDispatchEntity state = new OutboundDispatchEntity(); - state.setId(33L); state.setTenantId(7L); state.setOrderId(10L); state.setInboundId(11L); - state.setContainerBookingId(22L); state.setStatus("READY_FOR_DISPATCH"); state.setVersion(0); - when(repository.dispatch(7L, 11L, 22L)).thenReturn(state); - when(repository.claim(eq(9L), eq("CONFIRM_DISPATCH"), eq("confirm-1"), anyString())).thenReturn(true); - when(repository.confirm(33L, 0, 9L, "admin", "ok")).thenReturn(1); - when(repository.completeOperation(33L, 9L, "CONFIRM_DISPATCH", "confirm-1")).thenReturn(1); - when(repository.timeline(any(), any(), any())).thenReturn(List.of()); - - service.confirm(9L, "admin", "confirm-1", new ActionRequest(11L, 22L, 0, "ok")); - - verify(repository).confirm(33L, 0, 9L, "admin", "ok"); - verify(repository).history(argThat(history -> "CONFIRM_DISPATCH".equals(history.getAction()) - && "WAITING_FOR_LOADING".equals(history.getToStatus()))); - verify(repository).completeOperation(33L, 9L, "CONFIRM_DISPATCH", "confirm-1"); - } - - @Test - void confirmCreatesCanonicalCarrierScheduleTaskInTheSameTransaction() { + void confirmLocksAndAdvancesAllBindingsButWritesOneHistoryAndOneShippingFact() { CarrierScheduleQueryService carrierSchedules = mock(CarrierScheduleQueryService.class); - OutboundDispatchService service = new OutboundDispatchService(repository, - List.of(passing("CUSTOMS_CLEARANCE"), passing("SAILING_SCHEDULE")), carrierSchedules); - stubReadyChain(); - OutboundDispatchEntity state = new OutboundDispatchEntity(); - state.setId(33L); state.setTenantId(7L); state.setInboundId(11L); - state.setContainerBookingId(22L); state.setStatus("READY_FOR_DISPATCH"); state.setVersion(0); - when(repository.dispatch(7L, 11L, 22L)).thenReturn(state); - when(repository.claim(eq(9L), eq("CONFIRM_DISPATCH"), eq("confirm-carrier"), anyString())).thenReturn(true); - when(repository.confirm(33L, 0, 9L, "admin", null)).thenReturn(1); - when(repository.completeOperation(33L, 9L, "CONFIRM_DISPATCH", "confirm-carrier")).thenReturn(1); - when(repository.timeline(any(), any(), any())).thenReturn(List.of()); + OutboundDispatchService service = service(carrierSchedules); + stubContainer(); + when(repository.lockBindings(22L)).thenReturn(bindings()); + OutboundDispatchEntity first = state(31L, 11L, "READY_FOR_DISPATCH", 0); + OutboundDispatchEntity second = state(32L, 12L, "READY_FOR_DISPATCH", 0); + when(repository.dispatchLocked(7L, 11L, 22L)).thenReturn(first); + when(repository.dispatchLocked(7L, 12L, 22L)).thenReturn(second); + when(repository.claim(eq(9L), eq("CONFIRM_DISPATCH"), eq("confirm-container"), anyString())).thenReturn(true); + when(repository.confirm(anyLong(), eq(0), eq(9L), eq("admin"), eq("ok"))).thenReturn(1); + when(repository.completeOperation(31L, 9L, "CONFIRM_DISPATCH", "confirm-container")).thenReturn(1); - service.confirm(9L, "admin", "confirm-carrier", new ActionRequest(11L, 22L, 0, null)); + service.confirm(22L, 9L, "admin", "confirm-container", new ActionRequest(0, "ok")); - verify(carrierSchedules).ensureFromOutboundConfirm(7L, 66L, 22L, 9L, "admin"); + verify(repository).lockBindings(22L); + verify(repository).confirm(31L, 0, 9L, "admin", "ok"); + verify(repository).confirm(32L, 0, 9L, "admin", "ok"); + verify(repository, times(1)).history(argThat(value -> "CONFIRM_DISPATCH".equals(value.getAction()) + && value.getContainerBookingId().equals(22L))); + verify(carrierSchedules, times(1)).ensureFromOutboundConfirm(7L, 66L, 22L, 9L, "admin"); } @Test - void confirmNeverWritesStateWhenAnyRealGateFails() { - OutboundDispatchService service = new OutboundDispatchService(repository, - List.of(passing("CUSTOMS_CLEARANCE"), passing("SAILING_SCHEDULE"))); - stubReadyChain(); - when(repository.pick(44L)).thenReturn(new OutboundDispatchReadMapper.IdStatus(55L, "LOADING")); - when(repository.claim(eq(9L), eq("CONFIRM_DISPATCH"), eq("confirm-2"), anyString())).thenReturn(true); + void confirmDoesNotWriteAnyRowWhenOneInboundGateFails() { + OutboundDispatchService service = serviceWithoutCarrier(); + stubContainer(); + when(repository.lockBindings(22L)).thenReturn(bindings()); + when(repository.evidence(7L, 12L)).thenReturn(new OutboundDispatchReadMapper.Evidence(4, 1)); + when(repository.claim(eq(9L), eq("CONFIRM_DISPATCH"), eq("blocked"), anyString())).thenReturn(true); - assertThatThrownBy(() -> service.confirm(9L, "admin", "confirm-2", - new ActionRequest(11L, 22L, 0, null))).isInstanceOf(BizException.class).hasMessageContaining("门禁"); + assertThatThrownBy(() -> service.confirm(22L, 9L, "admin", "blocked", new ActionRequest(0, null))) + .isInstanceOf(BizException.class).hasMessageContaining("全部入库绑定"); + + verify(repository, never()).confirm(anyLong(), anyInt(), anyLong(), anyString(), any()); + verify(repository, never()).history(any()); + } + + @Test + void differingTechnicalRowVersionsAreRejectedAsContainerConflict() { + OutboundDispatchService service = serviceWithoutCarrier(); + stubContainer(); + when(repository.lockBindings(22L)).thenReturn(bindings()); + when(repository.dispatchLocked(7L, 11L, 22L)).thenReturn(state(31L, 11L, "READY_FOR_DISPATCH", 0)); + when(repository.dispatchLocked(7L, 12L, 22L)).thenReturn(state(32L, 12L, "READY_FOR_DISPATCH", 1)); + when(repository.claim(eq(9L), eq("CONFIRM_DISPATCH"), eq("mixed"), anyString())).thenReturn(true); + + assertThatThrownBy(() -> service.confirm(22L, 9L, "admin", "mixed", new ActionRequest(0, null))) + .isInstanceOf(BizException.class).hasMessageContaining("版本不一致"); verify(repository, never()).confirm(anyLong(), anyInt(), anyLong(), anyString(), any()); } @Test - void reverseIsRejectedAfterActualDepartedWarehouseFactExists() { + void actualDepartedWarehouseFactBlocksContainerReverseBeforeAnyRowChanges() { CarrierScheduleQueryService carrierSchedules = mock(CarrierScheduleQueryService.class); - OutboundDispatchService service = new OutboundDispatchService(repository, List.of(), carrierSchedules); - stubReadyChain(); - OutboundDispatchEntity state = new OutboundDispatchEntity(); - state.setId(33L); state.setTenantId(7L); state.setInboundId(11L); state.setContainerBookingId(22L); - state.setStatus("WAITING_FOR_LOADING"); state.setVersion(1); - when(repository.dispatch(7L, 11L, 22L)).thenReturn(state); + OutboundDispatchService service = service(carrierSchedules); + stubContainer(); + when(repository.lockBindings(22L)).thenReturn(bindings()); + when(repository.dispatchLocked(7L, 11L, 22L)).thenReturn(state(31L, 11L, "WAITING_FOR_LOADING", 1)); + when(repository.dispatchLocked(7L, 12L, 22L)).thenReturn(state(32L, 12L, "WAITING_FOR_LOADING", 1)); when(repository.claim(eq(9L), eq("REVERSE_DISPATCH"), eq("reverse-blocked"), anyString())).thenReturn(true); when(carrierSchedules.hasOutboundDepartureFact(7L, 66L, 22L)).thenReturn(true); - assertThatThrownBy(() -> service.reverse(9L, "admin", "reverse-blocked", - new ActionRequest(11L, 22L, 1, "误操作"))) - .isInstanceOf(BizException.class).hasMessageContaining("DEPARTED_WAREHOUSE"); + assertThatThrownBy(() -> service.reverse(22L, 9L, "admin", "reverse-blocked", + new ActionRequest(1, "误操作"))).isInstanceOf(BizException.class) + .hasMessageContaining("DEPARTED_WAREHOUSE"); verify(repository, never()).reverse(anyLong(), anyInt(), anyLong(), anyString(), any()); } @Test - void selectorsThatMatchMultipleContainerBindingsRequireDisambiguation() { - OutboundDispatchService service = new OutboundDispatchService(repository, List.of()); - when(repository.bindings(any())).thenReturn(List.of(binding(), binding())); - assertThatThrownBy(() -> service.detail(new Query(10L, null, null))) - .isInstanceOf(BizException.class).hasMessageContaining("多个出仓聚合"); + void sameIdempotencyKeyAndPayloadReplaysContainerResultWithoutMutation() { + OutboundDispatchService service = serviceWithoutCarrier(); + stubContainer(); + AtomicReference requestHash = new AtomicReference<>(); + when(repository.claim(eq(9L), eq("CONFIRM_DISPATCH"), eq("replay"), anyString())).thenAnswer(invocation -> { + requestHash.set(invocation.getArgument(3)); + return false; + }); + OutboundDispatchOperationEntity existing = new OutboundDispatchOperationEntity(); + existing.setStatus("SUCCESS"); + when(repository.operation(9L, "CONFIRM_DISPATCH", "replay")).thenAnswer(invocation -> { + existing.setRequestHash(requestHash.get()); + return existing; + }); + + Detail replay = service.confirm(22L, 9L, "admin", "replay", new ActionRequest(0, null)); + + assertThat(replay.aggregate().containerBookingId()).isEqualTo(22L); + verify(repository, never()).confirm(anyLong(), anyInt(), anyLong(), anyString(), any()); } @Test - void staleDispatchVersionIsRejectedWithoutPartialInboundAdvance() { - OutboundDispatchService service = new OutboundDispatchService(repository, - List.of(passing("CUSTOMS_CLEARANCE"), passing("SAILING_SCHEDULE"))); - stubReadyChain(); - OutboundDispatchEntity state = new OutboundDispatchEntity(); - state.setId(33L); state.setTenantId(7L); state.setInboundId(11L); state.setContainerBookingId(22L); - state.setStatus("READY_FOR_DISPATCH"); state.setVersion(2); - when(repository.dispatch(7L, 11L, 22L)).thenReturn(state); - when(repository.claim(eq(9L), eq("CONFIRM_DISPATCH"), eq("stale"), anyString())).thenReturn(true); - - assertThatThrownBy(() -> service.confirm(9L, "admin", "stale", - new ActionRequest(11L, 22L, 1, null))).isInstanceOf(BizException.class).hasMessageContaining("刷新"); - } - - @Test - void reusedIdempotencyKeyWithDifferentHashIsRejected() { - OutboundDispatchService service = new OutboundDispatchService(repository, - List.of(passing("CUSTOMS_CLEARANCE"), passing("SAILING_SCHEDULE"))); - stubReadyChain(); + void reusedIdempotencyKeyWithDifferentPayloadIsRejected() { + OutboundDispatchService service = serviceWithoutCarrier(); when(repository.claim(eq(9L), eq("CONFIRM_DISPATCH"), eq("reused"), anyString())).thenReturn(false); OutboundDispatchOperationEntity existing = new OutboundDispatchOperationEntity(); - existing.setRequestHash("different"); existing.setStatus("SUCCESS"); + existing.setRequestHash("different"); + existing.setStatus("SUCCESS"); when(repository.operation(9L, "CONFIRM_DISPATCH", "reused")).thenReturn(existing); - assertThatThrownBy(() -> service.confirm(9L, "admin", "reused", - new ActionRequest(11L, 22L, 0, null))).isInstanceOf(BizException.class) - .hasMessageContaining("用于不同请求"); + assertThatThrownBy(() -> service.confirm(22L, 9L, "admin", "reused", new ActionRequest(0, null))) + .isInstanceOf(BizException.class).hasMessageContaining("用于不同请求"); } - private void stubReadyChain() { - when(repository.bindings(any())).thenReturn(List.of(binding())); + private OutboundDispatchService serviceWithoutCarrier() { + return service(null); + } + + private OutboundDispatchService service(CarrierScheduleQueryService carrierSchedules) { + return new OutboundDispatchService(repository, + List.of(passing("CUSTOMS_CLEARANCE"), passing("SAILING_SCHEDULE")), carrierSchedules); + } + + private void stubContainer() { + when(repository.bindings(22L)).thenReturn(bindings()); when(repository.pick(44L)).thenReturn(new OutboundDispatchReadMapper.IdStatus(55L, "COMPLETED")); - when(repository.ocean(22L, 7L)).thenReturn(new OutboundDispatchReadMapper.IdStatus(66L, "RELEASED")); - when(repository.truck(22L, 44L, 7L)).thenReturn(new OutboundDispatchReadMapper.IdStatus(77L, "DEPARTED")); + when(repository.ocean(22L)).thenReturn(new OutboundDispatchReadMapper.OceanBinding(66L, "RELEASED", 7L)); + when(repository.truck(22L, 44L)).thenReturn(new OutboundDispatchReadMapper.IdStatus(77L, "DEPARTED")); when(repository.evidence(7L, 11L)).thenReturn(new OutboundDispatchReadMapper.Evidence(5, 1)); + when(repository.evidence(7L, 12L)).thenReturn(new OutboundDispatchReadMapper.Evidence(5, 1)); } - private static OutboundDispatchReadMapper.Binding binding() { - return new OutboundDispatchReadMapper.Binding(10L, "O-10", 11L, "I-11", 7L, 8L, "客户A", + private static List bindings() { + return List.of(binding(11L, "I-11"), binding(12L, "I-12")); + } + + private static OutboundDispatchReadMapper.Binding binding(long inboundId, String inboundCode) { + return new OutboundDispatchReadMapper.Binding(10L, "O-10", inboundId, inboundCode, 7L, 8L, "客户A", "LOADED", 22L, "CAB-22", "C-22", 44L, "COMPLETED"); } + private static OutboundDispatchEntity state(long id, long inboundId, String status, int version) { + OutboundDispatchEntity value = new OutboundDispatchEntity(); + value.setId(id); + value.setTenantId(7L); + value.setOrderId(10L); + value.setInboundId(inboundId); + value.setContainerBookingId(22L); + value.setStatus(status); + value.setVersion(version); + return value; + } + private static OutboundDispatchGateContributor passing(String code) { return new OutboundDispatchGateContributor() { @Override public String code() { return code; } @@ -180,4 +214,5 @@ class OutboundDispatchServiceTest { } }; } + } diff --git a/firstleg/src/test/java/com/baiyue/firstleg/outbound/dispatch/OutboundDispatchSqlContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/outbound/dispatch/OutboundDispatchSqlContractTest.java index e587f38f1..2bba4fc07 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/outbound/dispatch/OutboundDispatchSqlContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/outbound/dispatch/OutboundDispatchSqlContractTest.java @@ -38,17 +38,19 @@ class OutboundDispatchSqlContractTest { } @Test - void openApiPublishesSelectorsGateAndBothVersionedActions() throws Exception { + void openApiPublishesOnlyContainerLevelGateDetailAndVersionedActions() throws Exception { String openApi = resource("openapi/phase3-outbound-dispatch-v1.yaml"); assertThat(openApi) .contains("outbound-dispatches:") - .contains("outbound-dispatches/detail:") - .contains("outbound-dispatches/check-gate:") - .contains("outbound-dispatches/confirm-dispatch:") - .contains("outbound-dispatches/reverse-dispatch:") + .contains("outbound-dispatches/{containerBookingId}:") + .contains("outbound-dispatches/{containerBookingId}/gate:") + .contains("outbound-dispatches/{containerBookingId}/confirm:") + .contains("outbound-dispatches/{containerBookingId}/reverse:") + .contains("every real inbound binding", "No partial success is possible") .contains("Once an actual DEPARTED_WAREHOUSE node exists") .contains("expectedVersion") - .contains("Idempotency-Key"); + .contains("Idempotency-Key") + .doesNotContain("confirm-dispatch", "reverse-dispatch", "check-gate", "inboundId, containerBookingId"); } private static String migration() throws Exception { return resource("db/phase3-outbound-dispatch-schema.sql"); } diff --git a/firstleg/src/test/java/com/baiyue/firstleg/outbound/dispatch/PlatformOutboundDispatchControllerContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/outbound/dispatch/PlatformOutboundDispatchControllerContractTest.java new file mode 100644 index 000000000..83af6945e --- /dev/null +++ b/firstleg/src/test/java/com/baiyue/firstleg/outbound/dispatch/PlatformOutboundDispatchControllerContractTest.java @@ -0,0 +1,65 @@ +package com.baiyue.firstleg.outbound.dispatch; + +import com.baiyue.common.audit.BusinessAudit; +import com.baiyue.firstleg.outbound.dispatch.controller.PlatformOutboundDispatchController; +import com.baiyue.firstleg.outbound.dispatch.dto.OutboundDispatchDtos.ActionRequest; +import org.junit.jupiter.api.Test; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.*; + +import java.lang.reflect.Method; +import java.util.Arrays; +import java.util.List; +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; + +class PlatformOutboundDispatchControllerContractTest { + @Test + void exposesOnlyContainerLevelResourcePathsAndExistingPermissions() { + assertThat(PlatformOutboundDispatchController.class.getAnnotation(RequestMapping.class).value()) + .containsExactly("/api/platform/v1/first-leg/outbound-dispatches"); + Map permissions = Map.of( + "list", "platform:firstleg:outbound-dispatch:list", + "detail", "platform:firstleg:outbound-dispatch:view", + "checkGate", "platform:firstleg:outbound-dispatch:check", + "confirm", "platform:firstleg:outbound-dispatch:confirm", + "reverse", "platform:firstleg:outbound-dispatch:reverse"); + permissions.forEach((name, permission) -> assertThat(method(name).getAnnotation(PreAuthorize.class).value()) + .isEqualTo("hasAuthority('" + permission + "')")); + + assertThat(method("detail").getAnnotation(GetMapping.class).value()).containsExactly("/{containerBookingId}"); + assertThat(method("checkGate").getAnnotation(GetMapping.class).value()) + .containsExactly("/{containerBookingId}/gate"); + assertThat(method("confirm").getAnnotation(PostMapping.class).value()) + .containsExactly("/{containerBookingId}/confirm"); + assertThat(method("reverse").getAnnotation(PostMapping.class).value()) + .containsExactly("/{containerBookingId}/reverse"); + } + + @Test + void actionBodyContainsOnlyContainerVersionAndRemarkAndAuditsContainerRoot() { + assertThat(Arrays.stream(ActionRequest.class.getRecordComponents()).map(value -> value.getName())) + .containsExactly("expectedVersion", "remark") + .doesNotContain("inboundId", "containerBookingId"); + for (String name : List.of("confirm", "reverse")) { + BusinessAudit audit = method(name).getAnnotation(BusinessAudit.class); + assertThat(audit.resourceType()).isEqualTo("CONTAINER_BOOKING"); + assertThat(audit.resourceId()).isEqualTo("#containerBookingId"); + } + } + + @Test + void everyNonListOperationUsesOnlyContainerBookingIdPathTarget() { + for (String name : List.of("detail", "checkGate", "confirm", "reverse")) { + assertThat(Arrays.stream(method(name).getParameters()) + .filter(value -> value.isAnnotationPresent(PathVariable.class)) + .map(value -> value.getName())).containsExactly("containerBookingId"); + } + } + + private static Method method(String name) { + return Arrays.stream(PlatformOutboundDispatchController.class.getDeclaredMethods()) + .filter(value -> value.getName().equals(name)).findFirst().orElseThrow(); + } +}