feat(firstleg): confirm domestic departure by container

This commit is contained in:
2026-08-26 12:05:42 +08:00
parent 9ea2aa46ec
commit 8eeb52ba77
11 changed files with 583 additions and 292 deletions
@@ -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> detail(@AuthenticationPrincipal AuthenticatedUser user, @Valid Query query) {
FirstlegScopeGuard.platform(user); return ApiResponse.success(service.detail(query));
public ApiResponse<Detail> 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<GateResult> checkGate(@AuthenticationPrincipal AuthenticatedUser user, @Valid Query query) {
FirstlegScopeGuard.platform(user); return ApiResponse.success(service.checkGate(query));
public ApiResponse<ContainerGateResult> 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<Detail> 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<Detail> 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));
}
}
@@ -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<GateItem> 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<InboundGate> 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<InboundBinding> inbounds,
ContainerGateResult gate) {}
public record TimelineItem(String module, String action, String fromStatus, String toStatus,
Long operatorId, String operatorName, String remark, LocalDateTime occurredAt) {}
@@ -14,6 +14,11 @@ public interface OutboundDispatchMapper extends BaseMapper<OutboundDispatchEntit
OutboundDispatchEntity find(@Param("tenantId") long tenantId, @Param("inboundId") long inboundId,
@Param("containerBookingId") long containerBookingId);
@Select("SELECT * FROM fl_outbound_dispatch WHERE tenant_id=#{tenantId} AND inbound_id=#{inboundId} "
+ "AND container_booking_id=#{containerBookingId} FOR UPDATE")
OutboundDispatchEntity findForUpdate(@Param("tenantId") long tenantId, @Param("inboundId") long inboundId,
@Param("containerBookingId") long containerBookingId);
@Update("UPDATE fl_outbound_dispatch SET status='WAITING_FOR_LOADING',confirmed_by=#{operatorId},"
+ "confirmed_by_name=#{operatorName},confirmed_at=CURRENT_TIMESTAMP(3),reversed_by=NULL,reversed_by_name=NULL,"
+ "reversed_at=NULL,remark=#{remark},version=version+1,updated_at=CURRENT_TIMESTAMP(3) "
@@ -11,7 +11,6 @@ import java.util.List;
@Mapper
public interface OutboundDispatchReadMapper {
@Select("""
<script>
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
<if test="orderId != null">AND inbound.order_id=#{orderId}</if>
<if test="inboundId != null">AND inbound.id=#{inboundId}</if>
<if test="containerBookingId != null">AND booking.id=#{containerBookingId}</if>
AND booking.id=#{containerBookingId}
ORDER BY inbound.id,booking.id
</script>
""")
List<Binding> bindings(@Param("orderId") Long orderId, @Param("inboundId") Long inboundId,
@Param("containerBookingId") Long containerBookingId);
List<Binding> 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<Binding> 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) {}
}
@@ -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<OutboundDispatchReadMapper.Binding> bindings(Query query) {
return reads.bindings(query.orderId(), query.inboundId(), query.containerBookingId());
public List<OutboundDispatchReadMapper.Binding> bindings(long containerId) { return reads.bindings(containerId); }
public List<OutboundDispatchReadMapper.Binding> 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);
@@ -51,93 +51,132 @@ public class OutboundDispatchService {
}
public List<Aggregate> 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<OutboundDispatchReadMapper.Binding> bindings = bindings(containerBookingId);
Aggregate aggregate = aggregate(bindings);
List<TimelineItem> 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<OutboundDispatchReadMapper.Binding> bindings = lockedBindings(containerBookingId);
Aggregate before = aggregate(bindings);
if (!before.gate().passed()) throw conflict("柜内全部入库绑定尚未通过国内出仓门禁");
List<OutboundDispatchEntity> 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<OutboundDispatchReadMapper.Binding> bindings = lockedBindings(containerBookingId);
List<OutboundDispatchEntity> 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<OutboundDispatchReadMapper.Binding> 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<InboundGate> inboundGates = new ArrayList<>();
List<InboundBinding> inboundDtos = new ArrayList<>();
List<OutboundDispatchEntity> 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<GateItem> 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<OutboundDispatchReadMapper.Binding> 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<OutboundDispatchReadMapper.Binding> 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<OutboundDispatchReadMapper.Binding> lockedBindings(long containerBookingId) {
return normalize(repository.lockBindings(containerBookingId));
}
private static List<OutboundDispatchReadMapper.Binding> normalize(List<OutboundDispatchReadMapper.Binding> values) {
if (values == null || values.isEmpty()) {
throw new BizException(ResultCode.NOT_FOUND, "未找到当前货柜的真实入库绑定");
}
LinkedHashMap<Long, OutboundDispatchReadMapper.Binding> unique = new LinkedHashMap<>();
for (OutboundDispatchReadMapper.Binding value : values) unique.putIfAbsent(value.inboundId(), value);
return List.copyOf(unique.values());
}
private static OutboundDispatchReadMapper.Binding validateContainer(List<OutboundDispatchReadMapper.Binding> 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<OutboundDispatchEntity> 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<OutboundDispatchEntity> 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) {}
}
@@ -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}
@@ -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
@@ -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<String> 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<OutboundDispatchReadMapper.Binding> 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 {
}
};
}
}
@@ -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"); }
@@ -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<String, String> 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();
}
}