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) {}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user