feat(firstleg): confirm domestic departure by container
This commit is contained in:
+18
-12
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
+15
-8
@@ -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) {}
|
||||
|
||||
+5
@@ -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) "
|
||||
|
||||
+23
-13
@@ -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) {}
|
||||
}
|
||||
|
||||
+9
-8
@@ -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);
|
||||
|
||||
+183
-85
@@ -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}
|
||||
|
||||
+28
-7
@@ -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
|
||||
|
||||
+138
-103
@@ -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 {
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
+8
-6
@@ -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"); }
|
||||
|
||||
+65
@@ -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();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user