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)); FirstlegScopeGuard.platform(user); return ApiResponse.success(service.list(query));
} }
@GetMapping("/detail") @GetMapping("/{containerBookingId}")
@PreAuthorize("hasAuthority('platform:firstleg:outbound-dispatch:view')") @PreAuthorize("hasAuthority('platform:firstleg:outbound-dispatch:view')")
public ApiResponse<Detail> detail(@AuthenticationPrincipal AuthenticatedUser user, @Valid Query query) { public ApiResponse<Detail> detail(@AuthenticationPrincipal AuthenticatedUser user,
FirstlegScopeGuard.platform(user); return ApiResponse.success(service.detail(query)); @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')") @PreAuthorize("hasAuthority('platform:firstleg:outbound-dispatch:check')")
public ApiResponse<GateResult> checkGate(@AuthenticationPrincipal AuthenticatedUser user, @Valid Query query) { public ApiResponse<ContainerGateResult> checkGate(@AuthenticationPrincipal AuthenticatedUser user,
FirstlegScopeGuard.platform(user); return ApiResponse.success(service.checkGate(query)); @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')") @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, public ApiResponse<Detail> confirm(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable @jakarta.validation.constraints.Positive long containerBookingId,
@RequestHeader("Idempotency-Key") String key, @RequestHeader("Idempotency-Key") String key,
@Valid @RequestBody ActionRequest request) { @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')") @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, public ApiResponse<Detail> reverse(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable @jakarta.validation.constraints.Positive long containerBookingId,
@RequestHeader("Idempotency-Key") String key, @RequestHeader("Idempotency-Key") String key,
@Valid @RequestBody ActionRequest request) { @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.Positive;
import jakarta.validation.constraints.PositiveOrZero; import jakarta.validation.constraints.PositiveOrZero;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Size; import jakarta.validation.constraints.Size;
import java.time.LocalDateTime; import java.time.LocalDateTime;
@@ -10,21 +11,27 @@ import java.util.List;
public final class OutboundDispatchDtos { public final class OutboundDispatchDtos {
private 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, public record ActionRequest(@NotNull @PositiveOrZero Integer expectedVersion,
@PositiveOrZero int expectedVersion, @Size(max = 500) String remark) {} @Size(max = 500) String remark) {}
public record GateItem(String code, String name, boolean passed, String actual, String requirement) {} 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 GateResult(boolean passed, List<GateItem> items, LocalDateTime checkedAt) {}
public record Aggregate(Long orderId, String orderNo, Long inboundId, String inboundCode, Long tenantId, public record InboundBinding(Long orderId, String orderNo, Long inboundId, String inboundCode,
Long customerId, String customerName, String inboundStatus, Long tenantId, Long customerId, String customerName,
Long containerBookingId, String cabinetNo, String containerNo, 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 loadingPlanId, String loadingPlanStatus,
Long pickTaskId, String pickTaskStatus, Long oceanBookingId, String oceanBookingStatus, Long oceanBookingId, String oceanBookingStatus,
Long truckAppointmentId, String truckAppointmentStatus, 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, public record TimelineItem(String module, String action, String fromStatus, String toStatus,
Long operatorId, String operatorName, String remark, LocalDateTime occurredAt) {} 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, OutboundDispatchEntity find(@Param("tenantId") long tenantId, @Param("inboundId") long inboundId,
@Param("containerBookingId") long containerBookingId); @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}," @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," + "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) " + "reversed_at=NULL,remark=#{remark},version=version+1,updated_at=CURRENT_TIMESTAMP(3) "
@@ -11,7 +11,6 @@ import java.util.List;
@Mapper @Mapper
public interface OutboundDispatchReadMapper { public interface OutboundDispatchReadMapper {
@Select(""" @Select("""
<script>
SELECT DISTINCT inbound.order_id orderId,inbound.order_no orderNo,inbound.id inboundId, 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.inbound_code inboundCode,inbound.tenant_id tenantId,inbound.customer_id customerId,
inbound.customer_name customerName,inbound.process_status inboundStatus, 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_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 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 WHERE item.deleted=0
<if test="orderId != null">AND inbound.order_id=#{orderId}</if> AND booking.id=#{containerBookingId}
<if test="inboundId != null">AND inbound.id=#{inboundId}</if>
<if test="containerBookingId != null">AND booking.id=#{containerBookingId}</if>
ORDER BY inbound.id,booking.id ORDER BY inbound.id,booking.id
</script>
""") """)
List<Binding> bindings(@Param("orderId") Long orderId, @Param("inboundId") Long inboundId, List<Binding> bindings(@Param("containerBookingId") long containerBookingId);
@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} " @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") + "ORDER BY started_at DESC,id DESC LIMIT 1")
IdStatus pick(@Param("planId") long planId); IdStatus pick(@Param("planId") long planId);
@Select(""" @Select("""
SELECT booking.id,booking.status SELECT booking.id,booking.status,booking.customer_id customerId
FROM fl_ocean_booking_container_allocation allocation FROM fl_ocean_booking_container_allocation allocation
JOIN fl_ocean_booking booking ON booking.id=allocation.ocean_booking_id 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 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("""
SELECT id,status FROM fl_truck_appointment SELECT id,status FROM fl_truck_appointment
WHERE container_booking_id=#{containerBookingId} AND loading_plan_id=#{loadingPlanId} WHERE container_booking_id=#{containerBookingId} AND loading_plan_id=#{loadingPlanId}
AND customer_id=#{tenantId}
ORDER BY updated_at DESC,id DESC LIMIT 1 ORDER BY updated_at DESC,id DESC LIMIT 1
""") """)
IdStatus truck(@Param("containerBookingId") long containerBookingId, @Param("loadingPlanId") long loadingPlanId, IdStatus truck(@Param("containerBookingId") long containerBookingId, @Param("loadingPlanId") long loadingPlanId);
@Param("tenantId") long tenantId);
@Select(""" @Select("""
SELECT COUNT(DISTINCT CASE WHEN status='ACTIVE' AND file_id IS NOT NULL AND evidence_type IN 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, Long customerId, String customerName, String inboundStatus, Long containerBookingId,
String cabinetNo, String containerNo, Long loadingPlanId, String loadingPlanStatus) {} String cabinetNo, String containerNo, Long loadingPlanId, String loadingPlanStatus) {}
record IdStatus(Long id, String status) {} record IdStatus(Long id, String status) {}
record OceanBinding(Long id, String status, Long customerId) {}
record Evidence(Integer evidenceCount, Integer validSeal) {} record Evidence(Integer evidenceCount, Integer validSeal) {}
} }
@@ -1,7 +1,6 @@
package com.baiyue.firstleg.outbound.dispatch.repository; package com.baiyue.firstleg.outbound.dispatch.repository;
import com.baomidou.mybatisplus.core.toolkit.IdWorker; 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.dto.OutboundDispatchDtos.TimelineItem;
import com.baiyue.firstleg.outbound.dispatch.entity.OutboundDispatchEntity; import com.baiyue.firstleg.outbound.dispatch.entity.OutboundDispatchEntity;
import com.baiyue.firstleg.outbound.dispatch.entity.OutboundDispatchHistoryEntity; import com.baiyue.firstleg.outbound.dispatch.entity.OutboundDispatchHistoryEntity;
@@ -22,15 +21,14 @@ public class OutboundDispatchRepository {
@Resource private OutboundDispatchOperationMapper operations; @Resource private OutboundDispatchOperationMapper operations;
@Resource private OutboundDispatchReadMapper reads; @Resource private OutboundDispatchReadMapper reads;
public List<OutboundDispatchReadMapper.Binding> bindings(Query query) { public List<OutboundDispatchReadMapper.Binding> bindings(long containerId) { return reads.bindings(containerId); }
return reads.bindings(query.orderId(), query.inboundId(), query.containerBookingId()); public List<OutboundDispatchReadMapper.Binding> lockBindings(long containerId) {
return reads.bindingsForUpdate(containerId);
} }
public OutboundDispatchReadMapper.IdStatus pick(long planId) { return reads.pick(planId); } public OutboundDispatchReadMapper.IdStatus pick(long planId) { return reads.pick(planId); }
public OutboundDispatchReadMapper.IdStatus ocean(long containerId, long tenantId) { public OutboundDispatchReadMapper.OceanBinding ocean(long containerId) { return reads.ocean(containerId); }
return reads.ocean(containerId, tenantId); public OutboundDispatchReadMapper.IdStatus truck(long containerId, long planId) {
} return reads.truck(containerId, planId);
public OutboundDispatchReadMapper.IdStatus truck(long containerId, long planId, long tenantId) {
return reads.truck(containerId, planId, tenantId);
} }
public OutboundDispatchReadMapper.Evidence evidence(long tenantId, long inboundId) { public OutboundDispatchReadMapper.Evidence evidence(long tenantId, long inboundId) {
return reads.evidence(tenantId, inboundId); return reads.evidence(tenantId, inboundId);
@@ -38,6 +36,9 @@ public class OutboundDispatchRepository {
public OutboundDispatchEntity dispatch(long tenantId, long inboundId, long containerId) { public OutboundDispatchEntity dispatch(long tenantId, long inboundId, long containerId) {
return dispatches.find(tenantId, inboundId, 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 void insert(OutboundDispatchEntity value) { dispatches.insert(value); }
public int confirm(long id, int version, long operatorId, String operatorName, String remark) { public int confirm(long id, int version, long operatorId, String operatorName, String remark) {
return dispatches.confirm(id, version, operatorId, operatorName, remark); return dispatches.confirm(id, version, operatorId, operatorName, remark);
@@ -51,93 +51,132 @@ public class OutboundDispatchService {
} }
public List<Aggregate> list(Query query) { public List<Aggregate> list(Query query) {
requireSelector(query); return List.of(aggregate(bindings(query.containerBookingId())));
return repository.bindings(query).stream().map(this::aggregate).toList();
} }
public Detail detail(Query query) { public Detail detail(long containerBookingId) {
OutboundDispatchReadMapper.Binding binding = single(query); List<OutboundDispatchReadMapper.Binding> bindings = bindings(containerBookingId);
Aggregate aggregate = aggregate(binding); Aggregate aggregate = aggregate(bindings);
return new Detail(aggregate, repository.timeline(binding, aggregate.oceanBookingId(), aggregate.truckAppointmentId())); 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 @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); validateKey(key);
Query query = new Query(null, request.inboundId(), request.containerBookingId()); String hash = hash("CONFIRM", containerBookingId, request);
OutboundDispatchReadMapper.Binding binding = single(query); Detail replay = replay(containerBookingId, operatorId, "CONFIRM_DISPATCH", key, hash);
String hash = hash("CONFIRM", request);
Detail replay = replay(operatorId, "CONFIRM_DISPATCH", key, hash, query);
if (replay != null) return replay; if (replay != null) return replay;
Aggregate before = aggregate(binding); List<OutboundDispatchReadMapper.Binding> bindings = lockedBindings(containerBookingId);
if (!before.gate().passed()) throw conflict("国内出仓门禁未通过"); Aggregate before = aggregate(bindings);
OutboundDispatchEntity state = state(binding); if (!before.gate().passed()) throw conflict("柜内全部入库绑定尚未通过国内出仓门禁");
if (!Objects.equals(state.getVersion(), request.expectedVersion())) throw concurrent(); List<OutboundDispatchEntity> states = bindings.stream().map(this::stateLocked).toList();
if (OutboundDispatchStatus.WAITING_FOR_LOADING.name().equals(state.getStatus())) { StateView visible = visibleState(states);
throw conflict("当前出仓单已确认派车"); 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) { for (OutboundDispatchEntity state : states) {
throw concurrent(); 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); OutboundDispatchStatus.WAITING_FOR_LOADING.name(), request.remark(), operatorId, operatorName);
OutboundDispatchReadMapper.OceanBinding ocean = repository.ocean(containerBookingId);
if (carrierSchedules != null) { if (carrierSchedules != null) {
carrierSchedules.ensureFromOutboundConfirm(binding.tenantId(), before.oceanBookingId(), if (ocean == null || ocean.id() == null || ocean.customerId() == null) {
binding.containerBookingId(), operatorId, operatorName); throw conflict("当前货柜未关联有效订舱,无法创建船司查询任务");
}
carrierSchedules.ensureFromOutboundConfirm(ocean.customerId(), ocean.id(), containerBookingId,
operatorId, operatorName);
} }
if (repository.completeOperation(state.getId(), operatorId, "CONFIRM_DISPATCH", key) != 1) throw concurrent(); if (repository.completeOperation(representative.getId(), operatorId, "CONFIRM_DISPATCH", key) != 1) {
return detail(query); throw concurrent();
}
return detail(containerBookingId);
} }
@Transactional @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); validateKey(key);
Query query = new Query(null, request.inboundId(), request.containerBookingId()); String hash = hash("REVERSE", containerBookingId, request);
OutboundDispatchReadMapper.Binding binding = single(query); Detail replay = replay(containerBookingId, operatorId, "REVERSE_DISPATCH", key, hash);
String hash = hash("REVERSE", request);
Detail replay = replay(operatorId, "REVERSE_DISPATCH", key, hash, query);
if (replay != null) return replay; if (replay != null) return replay;
OutboundDispatchEntity state = repository.dispatch(binding.tenantId(), binding.inboundId(), binding.containerBookingId()); List<OutboundDispatchReadMapper.Binding> bindings = lockedBindings(containerBookingId);
if (state == null || !OutboundDispatchStatus.WAITING_FOR_LOADING.name().equals(state.getStatus())) { List<OutboundDispatchEntity> states = bindings.stream().map(this::existingStateLocked).toList();
throw conflict("当前出仓单不在等待装船状态"); StateView visible = visibleState(states);
if (!OutboundDispatchStatus.WAITING_FOR_LOADING.name().equals(visible.status())) {
throw conflict("当前货柜不在已确认离仓状态");
} }
if (!Objects.equals(state.getVersion(), request.expectedVersion())) throw concurrent(); if (!Objects.equals(visible.version(), request.expectedVersion())) throw concurrent();
OutboundDispatchReadMapper.IdStatus ocean = repository.ocean(binding.containerBookingId(), binding.tenantId()); OutboundDispatchReadMapper.OceanBinding ocean = repository.ocean(containerBookingId);
if (carrierSchedules != null && ocean != null && carrierSchedules.hasOutboundDepartureFact( if (carrierSchedules != null) {
binding.tenantId(), ocean.id(), binding.containerBookingId())) { if (ocean == null || ocean.id() == null || ocean.customerId() == null) {
throw conflict("已产生实际DEPARTED_WAREHOUSE船节点,禁止撤销国内出仓"); 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(); throw concurrent();
} }
history(state, "REVERSE_DISPATCH", OutboundDispatchStatus.WAITING_FOR_LOADING.name(), return detail(containerBookingId);
OutboundDispatchStatus.READY_FOR_DISPATCH.name(), request.remark(), operatorId, operatorName);
if (repository.completeOperation(state.getId(), operatorId, "REVERSE_DISPATCH", key) != 1) throw concurrent();
return detail(query);
} }
private Aggregate aggregate(OutboundDispatchReadMapper.Binding binding) { private Aggregate aggregate(List<OutboundDispatchReadMapper.Binding> bindings) {
var pick = repository.pick(binding.loadingPlanId()); OutboundDispatchReadMapper.Binding first = validateContainer(bindings);
var ocean = repository.ocean(binding.containerBookingId(), binding.tenantId()); var pick = repository.pick(first.loadingPlanId());
var truck = repository.truck(binding.containerBookingId(), binding.loadingPlanId(), binding.tenantId()); var ocean = repository.ocean(first.containerBookingId());
var evidence = repository.evidence(binding.tenantId(), binding.inboundId()); var truck = repository.truck(first.containerBookingId(), first.loadingPlanId());
var dispatch = repository.dispatch(binding.tenantId(), binding.inboundId(), binding.containerBookingId()); List<InboundGate> inboundGates = new ArrayList<>();
GateResult gate = gate(binding, pick, ocean, truck, evidence); List<InboundBinding> inboundDtos = new ArrayList<>();
return new Aggregate(binding.orderId(), binding.orderNo(), binding.inboundId(), binding.inboundCode(), List<OutboundDispatchEntity> states = new ArrayList<>();
binding.tenantId(), binding.customerId(), binding.customerName(), binding.inboundStatus(), for (OutboundDispatchReadMapper.Binding binding : bindings) {
binding.containerBookingId(), binding.cabinetNo(), binding.containerNo(), var evidence = repository.evidence(binding.tenantId(), binding.inboundId());
binding.loadingPlanId(), binding.loadingPlanStatus(), GateResult gate = gate(binding, pick, ocean, truck, evidence);
id(pick), status(pick), id(ocean), status(ocean), id(truck), status(truck), inboundGates.add(new InboundGate(binding.inboundId(), binding.inboundCode(), gate));
dispatch == null ? OutboundDispatchStatus.READY_FOR_DISPATCH.name() : dispatch.getStatus(), inboundDtos.add(new InboundBinding(binding.orderId(), binding.orderNo(), binding.inboundId(),
dispatch == null ? 0 : dispatch.getVersion(), gate); 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, private GateResult gate(OutboundDispatchReadMapper.Binding binding, OutboundDispatchReadMapper.IdStatus pick,
OutboundDispatchReadMapper.IdStatus ocean, OutboundDispatchReadMapper.IdStatus truck, OutboundDispatchReadMapper.OceanBinding ocean, OutboundDispatchReadMapper.IdStatus truck,
OutboundDispatchReadMapper.Evidence evidence) { OutboundDispatchReadMapper.Evidence evidence) {
List<GateItem> items = new ArrayList<>(); List<GateItem> items = new ArrayList<>();
items.add(item("INBOUND_LOADED", "入库单装柜完成", "LOADED".equals(binding.inboundStatus()), 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()); 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; if (repository.claim(operatorId, operation, key, hash)) return null;
var existing = repository.operation(operatorId, operation, key); var existing = repository.operation(operatorId, operation, key);
if (existing == null || !Objects.equals(existing.getRequestHash(), hash)) { if (existing == null || !Objects.equals(existing.getRequestHash(), hash)) {
throw conflict("Idempotency-Key已用于不同请求"); throw conflict("Idempotency-Key已用于不同请求");
} }
if (!"SUCCESS".equals(existing.getStatus())) throw conflict("相同请求正在处理中"); if (!"SUCCESS".equals(existing.getStatus())) throw conflict("相同请求正在处理中");
return detail(query); return detail(containerBookingId);
} }
private OutboundDispatchEntity state(OutboundDispatchReadMapper.Binding binding) { private OutboundDispatchEntity stateLocked(OutboundDispatchReadMapper.Binding binding) {
OutboundDispatchEntity state = repository.dispatch(binding.tenantId(), binding.inboundId(), binding.containerBookingId()); OutboundDispatchEntity state = repository.dispatchLocked(binding.tenantId(), binding.inboundId(),
binding.containerBookingId());
if (state != null) return state; if (state != null) return state;
state = new OutboundDispatchEntity(); state = new OutboundDispatchEntity();
state.setId(IdWorker.getId()); state.setTenantId(binding.tenantId()); state.setOrderId(binding.orderId()); state.setId(IdWorker.getId());
state.setInboundId(binding.inboundId()); state.setContainerBookingId(binding.containerBookingId()); state.setTenantId(binding.tenantId());
state.setStatus(OutboundDispatchStatus.READY_FOR_DISPATCH.name()); state.setVersion(0); state.setOrderId(binding.orderId());
try { repository.insert(state); } state.setInboundId(binding.inboundId());
catch (DuplicateKeyException ex) { state.setContainerBookingId(binding.containerBookingId());
state = repository.dispatch(binding.tenantId(), binding.inboundId(), 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; if (state == null) throw ex;
} }
return state; 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, private void history(OutboundDispatchEntity state, String action, String from, String to, String remark,
long operatorId, String operatorName) { long operatorId, String operatorName) {
OutboundDispatchHistoryEntity history = new OutboundDispatchHistoryEntity(); OutboundDispatchHistoryEntity history = new OutboundDispatchHistoryEntity();
history.setId(IdWorker.getId()); history.setDispatchId(state.getId()); history.setTenantId(state.getTenantId()); history.setId(IdWorker.getId());
history.setInboundId(state.getInboundId()); history.setContainerBookingId(state.getContainerBookingId()); history.setDispatchId(state.getId());
history.setAction(action); history.setFromStatus(from); history.setToStatus(to); history.setRemark(remark); history.setTenantId(state.getTenantId());
history.setOperatorId(operatorId); history.setOperatorName(operatorName); repository.history(history); 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) { private List<OutboundDispatchReadMapper.Binding> bindings(long containerBookingId) {
requireSelector(query); return normalize(repository.bindings(containerBookingId));
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 static void requireSelector(Query query) { private List<OutboundDispatchReadMapper.Binding> lockedBindings(long containerBookingId) {
if (query == null || query.orderId() == null && query.inboundId() == null && query.containerBookingId() == null) { return normalize(repository.lockBindings(containerBookingId));
throw new BizException(ResultCode.BAD_REQUEST, "orderId、inboundId、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) { private static void validateKey(String key) {
if (key == null || key.isBlank() || key.length() > 128) { if (key == null || key.isBlank() || key.length() > 128) {
throw new BizException(ResultCode.BAD_REQUEST, "Idempotency-Key不能为空且长度不能超过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() + "|" private static String hash(String action, long containerBookingId, ActionRequest request) {
+ request.expectedVersion() + "|" + Objects.toString(request.remark(), ""); String source = action + "|" + containerBookingId + "|" + request.expectedVersion() + "|"
+ Objects.toString(request.remark(), "");
try { try {
byte[] digest = MessageDigest.getInstance("SHA-256").digest(source.getBytes(StandardCharsets.UTF_8)); byte[] digest = MessageDigest.getInstance("SHA-256").digest(source.getBytes(StandardCharsets.UTF_8));
return HexFormat.of().formatHex(digest); 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.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.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) { private static GateItem item(String code, String name, boolean pass, String actual, String requirement) {
return new GateItem(code, name, pass, actual, 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 conflict(String message) { return new BizException(ResultCode.CONFLICT, message); }
private static BizException concurrent() { return conflict("数据已被其他人修改,请刷新后重试"); } private static BizException concurrent() { return conflict("数据已被其他人修改,请刷新后重试"); }
private record StateView(String status, Integer version) {}
} }
@@ -1,85 +1,116 @@
openapi: 3.0.3 openapi: 3.0.3
info: info:
title: Phase 3 Domestic Outbound Dispatch API title: Phase 3 Container-level Domestic Outbound Dispatch API
version: 1.0.0 version: 2.0.0
paths: paths:
/api/platform/v1/first-leg/outbound-dispatches: /api/platform/v1/first-leg/outbound-dispatches:
get: 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: parameters:
- {$ref: '#/components/parameters/OrderId'} - {$ref: '#/components/parameters/ContainerBookingIdQuery'}
- {$ref: '#/components/parameters/InboundId'}
- {$ref: '#/components/parameters/ContainerBookingId'}
responses: responses:
'200': '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: content:
application/json: application/json:
schema: schema:
type: object type: object
properties: properties:
data: {type: array, items: {$ref: '#/components/schemas/Aggregate'}} 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: 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: parameters:
- {$ref: '#/components/parameters/OrderId'} - {$ref: '#/components/parameters/ContainerBookingIdPath'}
- {$ref: '#/components/parameters/InboundId'}
- {$ref: '#/components/parameters/ContainerBookingId'}
responses: responses:
'200': '200':
description: Business envelope containing aggregate detail description: Business envelope containing all inbound bindings and one visible dispatch version
content: content:
application/json: application/json:
schema: schema:
type: object type: object
properties: properties:
data: data: {$ref: '#/components/schemas/Detail'}
type: object /api/platform/v1/first-leg/outbound-dispatches/{containerBookingId}/gate:
properties:
aggregate: {$ref: '#/components/schemas/Aggregate'}
/api/platform/v1/first-leg/outbound-dispatches/check-gate:
get: 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: parameters:
- {$ref: '#/components/parameters/OrderId'} - {$ref: '#/components/parameters/ContainerBookingIdPath'}
- {$ref: '#/components/parameters/InboundId'}
- {$ref: '#/components/parameters/ContainerBookingId'}
responses: responses:
'200': {description: Business envelope containing every gate item} '200': {description: Business envelope containing the container gate and per-inbound gate results}
/api/platform/v1/first-leg/outbound-dispatches/confirm-dispatch: /api/platform/v1/first-leg/outbound-dispatches/{containerBookingId}/confirm:
post: 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: parameters:
- {$ref: '#/components/parameters/ContainerBookingIdPath'}
- {$ref: '#/components/parameters/IdempotencyKey'} - {$ref: '#/components/parameters/IdempotencyKey'}
requestBody: requestBody:
required: true required: true
content: content:
application/json: {schema: {$ref: '#/components/schemas/ActionRequest'}} application/json: {schema: {$ref: '#/components/schemas/ActionRequest'}}
responses: responses:
'200': {description: Business envelope; gate or version failures use business error code} '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/reverse-dispatch: /api/platform/v1/first-leg/outbound-dispatches/{containerBookingId}/reverse:
post: post:
summary: Reverse WAITING_FOR_LOADING only before maritime tracking starts 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 READY_FOR_DISPATCH cannot coexist with maritime tracking. 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: parameters:
- {$ref: '#/components/parameters/ContainerBookingIdPath'}
- {$ref: '#/components/parameters/IdempotencyKey'} - {$ref: '#/components/parameters/IdempotencyKey'}
requestBody: requestBody:
required: true required: true
content: content:
application/json: {schema: {$ref: '#/components/schemas/ActionRequest'}} application/json: {schema: {$ref: '#/components/schemas/ActionRequest'}}
responses: responses:
'200': {description: Business envelope containing reverted aggregate} '200': {description: Business envelope containing the reverted container aggregate}
components: components:
parameters: parameters:
OrderId: {name: orderId, in: query, schema: {type: integer, format: int64, minimum: 1}} ContainerBookingIdQuery:
InboundId: {name: inboundId, in: query, schema: {type: integer, format: int64, minimum: 1}} name: containerBookingId
ContainerBookingId: {name: containerBookingId, in: query, schema: {type: integer, format: int64, minimum: 1}} in: query
IdempotencyKey: {name: Idempotency-Key, in: header, required: true, schema: {type: string, maxLength: 128}} 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: schemas:
Aggregate: Aggregate:
type: object 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: properties:
orderId: {type: integer, format: int64} orderId: {type: integer, format: int64}
orderNo: {type: string} orderNo: {type: string}
@@ -89,25 +120,35 @@ components:
customerId: {type: integer, format: int64, nullable: true} customerId: {type: integer, format: int64, nullable: true}
customerName: {type: string, nullable: true} customerName: {type: string, nullable: true}
inboundStatus: {type: string} 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} pickTaskId: {type: integer, format: int64, nullable: true}
pickTaskStatus: {type: string, nullable: true} pickTaskStatus: {type: string, nullable: true}
oceanBookingId: {type: integer, format: int64, nullable: true} ContainerGateResult:
oceanBookingStatus: {type: string, nullable: true} type: object
truckAppointmentId: {type: integer, format: int64, nullable: true} required: [passed, inbounds, checkedAt]
truckAppointmentStatus: {type: string, nullable: true} properties:
dispatchStatus: {type: string} passed: {type: boolean}
version: {type: integer, minimum: 0} inbounds:
gate: {type: object} 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: ActionRequest:
type: object type: object
required: [inboundId, containerBookingId, expectedVersion] required: [expectedVersion]
properties: properties:
inboundId: {type: integer, format: int64, minimum: 1}
containerBookingId: {type: integer, format: int64, minimum: 1}
expectedVersion: {type: integer, minimum: 0} expectedVersion: {type: integer, minimum: 0}
remark: {type: string, maxLength: 500} remark: {type: string, maxLength: 500}
@@ -10,9 +10,25 @@ class OutboundDispatchReadMapperContractTest {
@Test @Test
void bindingProjectsStableCabinetAndRealContainerNumbersSeparately() throws Exception { void bindingProjectsStableCabinetAndRealContainerNumbersSeparately() throws Exception {
String sql = String.join("\n", OutboundDispatchReadMapper.class String sql = String.join("\n", OutboundDispatchReadMapper.class
.getMethod("bindings", Long.class, Long.class, Long.class) .getMethod("bindings", long.class)
.getAnnotation(Select.class).value()); .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 @Test
@@ -36,14 +52,19 @@ class OutboundDispatchReadMapperContractTest {
} }
@Test @Test
void oceanAndTruckQueriesUseTenantIsolationKey() throws Exception { void oceanAndTruckQueriesAreResolvedOnceFromTheContainerRoot() throws Exception {
String oceanSql = OutboundDispatchReadMapper.class.getMethod("ocean", long.class, long.class) String oceanSql = OutboundDispatchReadMapper.class.getMethod("ocean", long.class)
.getAnnotation(Select.class).value()[0]; .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]; .getAnnotation(Select.class).value()[0];
assertThat(oceanSql).contains("booking.customer_id=#{tenantId}").doesNotContain("#{customerId}"); assertThat(oceanSql)
assertThat(truckSql).contains("customer_id=#{tenantId}").doesNotContain("#{customerId}"); .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 @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.repository.OutboundDispatchRepository;
import com.baiyue.firstleg.outbound.dispatch.service.OutboundDispatchService; import com.baiyue.firstleg.outbound.dispatch.service.OutboundDispatchService;
import com.baiyue.firstleg.portschedule.service.CarrierScheduleQueryService; import com.baiyue.firstleg.portschedule.service.CarrierScheduleQueryService;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import java.util.List; 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.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.assertj.core.api.Assertions.assertThatThrownBy;
@@ -19,159 +21,191 @@ import static org.mockito.ArgumentMatchers.*;
import static org.mockito.Mockito.*; import static org.mockito.Mockito.*;
class OutboundDispatchServiceTest { 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 @Test
void gateFailsClosedWhenCustomsAndScheduleModulesAreUnavailable() { void gateAggregatesEveryRealInboundAndFailsClosedWhenOneBindingFails() {
OutboundDispatchService service = new OutboundDispatchService(repository, List.of()); OutboundDispatchService service = serviceWithoutCarrier();
stubReadyChain(); 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.passed()).isFalse();
assertThat(result.items()).filteredOn(item -> !item.passed()) assertThat(result.inbounds()).hasSize(2);
.extracting(GateItem::code).containsExactlyInAnyOrder("CUSTOMS_CLEARANCE", "SAILING_SCHEDULE"); assertThat(result.inbounds().get(1).gate().items())
.filteredOn(item -> "LOADING_EVIDENCE_COMPLETE".equals(item.code()))
.extracting(GateItem::passed).containsExactly(false);
} }
@Test @Test
void aggregateUsesTenantKeyWhenCustomerEntityIdDiffers() { void detailReturnsOneVersionAndAllInboundBindings() {
OutboundDispatchService service = new OutboundDispatchService(repository, List.of()); OutboundDispatchService service = serviceWithoutCarrier();
stubReadyChain(); 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); assertThat(detail.aggregate().inbounds()).extracting(InboundBinding::inboundId).containsExactly(11L, 12L);
verify(repository).truck(22L, 44L, 7L); assertThat(detail.aggregate().dispatchStatus()).isEqualTo("WAITING_FOR_LOADING");
verify(repository, never()).ocean(22L, 8L); assertThat(detail.aggregate().version()).isEqualTo(3);
verify(repository, never()).truck(22L, 44L, 8L);
} }
@Test @Test
void confirmAdvancesDispatchWithoutMutatingLoadedInbound() { void confirmLocksAndAdvancesAllBindingsButWritesOneHistoryAndOneShippingFact() {
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() {
CarrierScheduleQueryService carrierSchedules = mock(CarrierScheduleQueryService.class); CarrierScheduleQueryService carrierSchedules = mock(CarrierScheduleQueryService.class);
OutboundDispatchService service = new OutboundDispatchService(repository, OutboundDispatchService service = service(carrierSchedules);
List.of(passing("CUSTOMS_CLEARANCE"), passing("SAILING_SCHEDULE")), carrierSchedules); stubContainer();
stubReadyChain(); when(repository.lockBindings(22L)).thenReturn(bindings());
OutboundDispatchEntity state = new OutboundDispatchEntity(); OutboundDispatchEntity first = state(31L, 11L, "READY_FOR_DISPATCH", 0);
state.setId(33L); state.setTenantId(7L); state.setInboundId(11L); OutboundDispatchEntity second = state(32L, 12L, "READY_FOR_DISPATCH", 0);
state.setContainerBookingId(22L); state.setStatus("READY_FOR_DISPATCH"); state.setVersion(0); when(repository.dispatchLocked(7L, 11L, 22L)).thenReturn(first);
when(repository.dispatch(7L, 11L, 22L)).thenReturn(state); when(repository.dispatchLocked(7L, 12L, 22L)).thenReturn(second);
when(repository.claim(eq(9L), eq("CONFIRM_DISPATCH"), eq("confirm-carrier"), anyString())).thenReturn(true); when(repository.claim(eq(9L), eq("CONFIRM_DISPATCH"), eq("confirm-container"), anyString())).thenReturn(true);
when(repository.confirm(33L, 0, 9L, "admin", null)).thenReturn(1); when(repository.confirm(anyLong(), eq(0), eq(9L), eq("admin"), eq("ok"))).thenReturn(1);
when(repository.completeOperation(33L, 9L, "CONFIRM_DISPATCH", "confirm-carrier")).thenReturn(1); when(repository.completeOperation(31L, 9L, "CONFIRM_DISPATCH", "confirm-container")).thenReturn(1);
when(repository.timeline(any(), any(), any())).thenReturn(List.of());
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 @Test
void confirmNeverWritesStateWhenAnyRealGateFails() { void confirmDoesNotWriteAnyRowWhenOneInboundGateFails() {
OutboundDispatchService service = new OutboundDispatchService(repository, OutboundDispatchService service = serviceWithoutCarrier();
List.of(passing("CUSTOMS_CLEARANCE"), passing("SAILING_SCHEDULE"))); stubContainer();
stubReadyChain(); when(repository.lockBindings(22L)).thenReturn(bindings());
when(repository.pick(44L)).thenReturn(new OutboundDispatchReadMapper.IdStatus(55L, "LOADING")); when(repository.evidence(7L, 12L)).thenReturn(new OutboundDispatchReadMapper.Evidence(4, 1));
when(repository.claim(eq(9L), eq("CONFIRM_DISPATCH"), eq("confirm-2"), anyString())).thenReturn(true); when(repository.claim(eq(9L), eq("CONFIRM_DISPATCH"), eq("blocked"), anyString())).thenReturn(true);
assertThatThrownBy(() -> service.confirm(9L, "admin", "confirm-2", assertThatThrownBy(() -> service.confirm(22L, 9L, "admin", "blocked", new ActionRequest(0, null)))
new ActionRequest(11L, 22L, 0, null))).isInstanceOf(BizException.class).hasMessageContaining("门禁"); .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()); verify(repository, never()).confirm(anyLong(), anyInt(), anyLong(), anyString(), any());
} }
@Test @Test
void reverseIsRejectedAfterActualDepartedWarehouseFactExists() { void actualDepartedWarehouseFactBlocksContainerReverseBeforeAnyRowChanges() {
CarrierScheduleQueryService carrierSchedules = mock(CarrierScheduleQueryService.class); CarrierScheduleQueryService carrierSchedules = mock(CarrierScheduleQueryService.class);
OutboundDispatchService service = new OutboundDispatchService(repository, List.of(), carrierSchedules); OutboundDispatchService service = service(carrierSchedules);
stubReadyChain(); stubContainer();
OutboundDispatchEntity state = new OutboundDispatchEntity(); when(repository.lockBindings(22L)).thenReturn(bindings());
state.setId(33L); state.setTenantId(7L); state.setInboundId(11L); state.setContainerBookingId(22L); when(repository.dispatchLocked(7L, 11L, 22L)).thenReturn(state(31L, 11L, "WAITING_FOR_LOADING", 1));
state.setStatus("WAITING_FOR_LOADING"); state.setVersion(1); when(repository.dispatchLocked(7L, 12L, 22L)).thenReturn(state(32L, 12L, "WAITING_FOR_LOADING", 1));
when(repository.dispatch(7L, 11L, 22L)).thenReturn(state);
when(repository.claim(eq(9L), eq("REVERSE_DISPATCH"), eq("reverse-blocked"), anyString())).thenReturn(true); when(repository.claim(eq(9L), eq("REVERSE_DISPATCH"), eq("reverse-blocked"), anyString())).thenReturn(true);
when(carrierSchedules.hasOutboundDepartureFact(7L, 66L, 22L)).thenReturn(true); when(carrierSchedules.hasOutboundDepartureFact(7L, 66L, 22L)).thenReturn(true);
assertThatThrownBy(() -> service.reverse(9L, "admin", "reverse-blocked", assertThatThrownBy(() -> service.reverse(22L, 9L, "admin", "reverse-blocked",
new ActionRequest(11L, 22L, 1, "误操作"))) new ActionRequest(1, "误操作"))).isInstanceOf(BizException.class)
.isInstanceOf(BizException.class).hasMessageContaining("DEPARTED_WAREHOUSE"); .hasMessageContaining("DEPARTED_WAREHOUSE");
verify(repository, never()).reverse(anyLong(), anyInt(), anyLong(), anyString(), any()); verify(repository, never()).reverse(anyLong(), anyInt(), anyLong(), anyString(), any());
} }
@Test @Test
void selectorsThatMatchMultipleContainerBindingsRequireDisambiguation() { void sameIdempotencyKeyAndPayloadReplaysContainerResultWithoutMutation() {
OutboundDispatchService service = new OutboundDispatchService(repository, List.of()); OutboundDispatchService service = serviceWithoutCarrier();
when(repository.bindings(any())).thenReturn(List.of(binding(), binding())); stubContainer();
assertThatThrownBy(() -> service.detail(new Query(10L, null, null))) AtomicReference<String> requestHash = new AtomicReference<>();
.isInstanceOf(BizException.class).hasMessageContaining("多个出仓聚合"); 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 @Test
void staleDispatchVersionIsRejectedWithoutPartialInboundAdvance() { void reusedIdempotencyKeyWithDifferentPayloadIsRejected() {
OutboundDispatchService service = new OutboundDispatchService(repository, OutboundDispatchService service = serviceWithoutCarrier();
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();
when(repository.claim(eq(9L), eq("CONFIRM_DISPATCH"), eq("reused"), anyString())).thenReturn(false); when(repository.claim(eq(9L), eq("CONFIRM_DISPATCH"), eq("reused"), anyString())).thenReturn(false);
OutboundDispatchOperationEntity existing = new OutboundDispatchOperationEntity(); 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); when(repository.operation(9L, "CONFIRM_DISPATCH", "reused")).thenReturn(existing);
assertThatThrownBy(() -> service.confirm(9L, "admin", "reused", assertThatThrownBy(() -> service.confirm(22L, 9L, "admin", "reused", new ActionRequest(0, null)))
new ActionRequest(11L, 22L, 0, null))).isInstanceOf(BizException.class) .isInstanceOf(BizException.class).hasMessageContaining("用于不同请求");
.hasMessageContaining("用于不同请求");
} }
private void stubReadyChain() { private OutboundDispatchService serviceWithoutCarrier() {
when(repository.bindings(any())).thenReturn(List.of(binding())); 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.pick(44L)).thenReturn(new OutboundDispatchReadMapper.IdStatus(55L, "COMPLETED"));
when(repository.ocean(22L, 7L)).thenReturn(new OutboundDispatchReadMapper.IdStatus(66L, "RELEASED")); when(repository.ocean(22L)).thenReturn(new OutboundDispatchReadMapper.OceanBinding(66L, "RELEASED", 7L));
when(repository.truck(22L, 44L, 7L)).thenReturn(new OutboundDispatchReadMapper.IdStatus(77L, "DEPARTED")); 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, 11L)).thenReturn(new OutboundDispatchReadMapper.Evidence(5, 1));
when(repository.evidence(7L, 12L)).thenReturn(new OutboundDispatchReadMapper.Evidence(5, 1));
} }
private static OutboundDispatchReadMapper.Binding binding() { private static List<OutboundDispatchReadMapper.Binding> bindings() {
return new OutboundDispatchReadMapper.Binding(10L, "O-10", 11L, "I-11", 7L, 8L, "客户A", 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"); "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) { private static OutboundDispatchGateContributor passing(String code) {
return new OutboundDispatchGateContributor() { return new OutboundDispatchGateContributor() {
@Override public String code() { return code; } @Override public String code() { return code; }
@@ -180,4 +214,5 @@ class OutboundDispatchServiceTest {
} }
}; };
} }
} }
@@ -38,17 +38,19 @@ class OutboundDispatchSqlContractTest {
} }
@Test @Test
void openApiPublishesSelectorsGateAndBothVersionedActions() throws Exception { void openApiPublishesOnlyContainerLevelGateDetailAndVersionedActions() throws Exception {
String openApi = resource("openapi/phase3-outbound-dispatch-v1.yaml"); String openApi = resource("openapi/phase3-outbound-dispatch-v1.yaml");
assertThat(openApi) assertThat(openApi)
.contains("outbound-dispatches:") .contains("outbound-dispatches:")
.contains("outbound-dispatches/detail:") .contains("outbound-dispatches/{containerBookingId}:")
.contains("outbound-dispatches/check-gate:") .contains("outbound-dispatches/{containerBookingId}/gate:")
.contains("outbound-dispatches/confirm-dispatch:") .contains("outbound-dispatches/{containerBookingId}/confirm:")
.contains("outbound-dispatches/reverse-dispatch:") .contains("outbound-dispatches/{containerBookingId}/reverse:")
.contains("every real inbound binding", "No partial success is possible")
.contains("Once an actual DEPARTED_WAREHOUSE node exists") .contains("Once an actual DEPARTED_WAREHOUSE node exists")
.contains("expectedVersion") .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"); } 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();
}
}