feat(appointment): integrate work calendar and stable cargo batches

This commit is contained in:
2026-10-15 16:55:57 +08:00
parent dbb93a06c2
commit a1d5ebdab2
171 changed files with 7898 additions and 396 deletions
@@ -0,0 +1,12 @@
package com.baiyue.firstleg.client;
import com.baiyue.common.core.api.ApiResponse;
import org.springframework.cloud.openfeign.FeignClient;
import org.springframework.web.bind.annotation.*;
@FeignClient(name="lastleg-service",contextId="appointmentWorkRouteClient",url="${LASTLEG_SERVICE_URL:${baiyue.clients.lastleg.url:}}")
public interface AppointmentWorkRouteClient {
record Route(String flow) { }
@GetMapping("/internal/lastleg/appointment-work/containers/{id}/route")
ApiResponse<Route> route(@RequestHeader("X-Internal-Service-Token") String token,@PathVariable("id") long id);
}
@@ -0,0 +1,36 @@
package com.baiyue.firstleg.portschedule.controller;
import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.firstleg.portschedule.dto.AppointmentWorkSourceDtos.*;
import com.baiyue.firstleg.portschedule.service.AppointmentWorkSourceService;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.web.bind.annotation.*;
import org.springframework.http.HttpStatus;
import org.springframework.web.server.ResponseStatusException;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.util.List;
@RestController
@RequestMapping("/internal/firstleg/appointment-work")
public class InternalAppointmentWorkSourceController {
private final AppointmentWorkSourceService service;
private final String token;
public InternalAppointmentWorkSourceController(AppointmentWorkSourceService service,
@Value("${baiyue.security.internal-service-token:}") String token) { this.service=service; this.token=token; }
@GetMapping("/containers")
public ApiResponse<SourcePage> discover(@RequestHeader(value="X-Internal-Service-Token",required=false) String supplied,
@RequestParam(required=false) String after,@RequestParam(defaultValue="200") int size) {
authorize(supplied); return ApiResponse.success(service.discover(after,size));
}
@GetMapping("/containers/{id}")
public ApiResponse<ContainerSource> container(@RequestHeader(value="X-Internal-Service-Token",required=false) String supplied,
@PathVariable long id) { authorize(supplied); return ApiResponse.success(service.container(id)); }
@PostMapping("/facts")
public ApiResponse<List<Fact>> facts(@RequestHeader(value="X-Internal-Service-Token",required=false) String supplied,
@RequestBody List<FactRequest> requests) { authorize(supplied); return ApiResponse.success(service.facts(requests)); }
private void authorize(String supplied) {
if(token==null || token.isBlank() || supplied==null || !MessageDigest.isEqual(token.getBytes(StandardCharsets.UTF_8),supplied.getBytes(StandardCharsets.UTF_8)))
throw new ResponseStatusException(HttpStatus.UNAUTHORIZED,"内部身份无效");
}
}
@@ -0,0 +1,29 @@
package com.baiyue.firstleg.portschedule.dto;
import java.time.*;
import java.util.List;
/** Internal wire values. Unzoned source timestamps are never converted using the server zone. */
public final class AppointmentWorkSourceDtos {
private AppointmentWorkSourceDtos() { }
public record Reference(String kind, String key, String businessNo, String label) { }
public record SourceReference(String customerId,String sourceKey,String destinationKey) { }
public record Requirement(String requirementKey, String batchKey, String batchNo, String destinationKind,
String originName, String destinationName, String timezone, List<Reference> references, List<String> replacesRequirementKeys,
List<SourceReference> sources) { }
public record ContainerSource(String containerBookingId, String containerNo, Instant departedAt,
LocalDateTime departedLocalAt, LocalDateTime sailedEvidenceLocalAt, String sourceVersion, String establishedFlow, String timezone, List<Requirement> requirements) { }
public record SourcePage(List<ContainerSource> containers, String nextCursor, boolean complete, String scopeVersion) { }
public record ContainerReference(String containerBookingId, String bookingNo, String containerNo) { }
public record FactRequest(String workItemKey, String type, String flow, String containerBookingId,
String requirementKey, String batchKey, String tripKey, List<String> batchKeys, List<ContainerReference> containers,List<SourceReference> sources) { }
public record Slot(LocalDate date, LocalTime startTime, LocalDate endDate, LocalTime endTime, String timezone) { }
public record Actual(Instant occurredAt, LocalDateTime localOccurredAt, String timezone) { }
public record Target(String kind, String containerBookingId) { }
public record Action(String code, String label, String targetWorkItemKey, Target target) { }
public record Responsibility(String kind, String id, String name) { }
public record Fact(String workItemKey, String fingerprint, String impactToken, boolean planExists, String planStatus,
String executionStatus, Slot target, Slot confirmed, Actual actual, String timezone, String businessNo, String tripKey,
List<String> readPermissions, List<String> actionPermissions, Action action, List<String> blockReasons,
List<String> missingFields, List<Reference> references, Responsibility responsibility) { }
}
@@ -0,0 +1,43 @@
package com.baiyue.firstleg.portschedule.mapper;
import org.apache.ibatis.annotations.*;
import java.time.LocalDateTime;
import java.util.List;
@Mapper
public interface AppointmentWorkSourceMapper {
record Source(long id, String cabinetNo, String containerNo, String departurePortName,
String destinationWarehouseName, LocalDateTime departedLocalAt, LocalDateTime sailedEvidenceLocalAt, int version, long factVersion, long deliveryVersion) { }
record Cargo(long inboundId, long customerId, long orderId, String orderNo, String inboundCode,
String destinationKind, String destinationCode, String destinationName, int packageCount) { }
String SOURCES = """
SELECT b.id,b.cabinet_no,b.container_no,b.departure_port_name,b.destination_warehouse_name,
MIN(CASE WHEN f.standard_node='DEPARTED_ORIGIN_PORT' THEN f.actual_at END) departed_local_at,
MIN(f.actual_at) sailed_evidence_local_at,
b.version,MAX(f.id) fact_version,
(SELECT COUNT(*) FROM fl_direct_delivery_unloading d WHERE d.container_booking_id=b.id) delivery_version
FROM fl_container_booking b JOIN fl_carrier_schedule_node_fact f ON f.container_booking_id=b.id
WHERE b.record_status='ACTIVE' AND f.actual_at IS NOT NULL
AND f.standard_node IN ('DEPARTED_ORIGIN_PORT','IN_TRANSIT','ARRIVED_DESTINATION_PORT',
'DISCHARGED_FROM_VESSEL','PICKUP_READY','PICKED_UP','EMPTY_RETURNED')
GROUP BY b.id,b.cabinet_no,b.container_no,b.departure_port_name,b.destination_warehouse_name,b.version
ORDER BY b.id
""";
// A complete identity/version manifest also detects deletions or edits between cursor pages.
@Select(SOURCES)
List<Source> sources();
@Select("""
SELECT i.inbound_id, i.account_id customer_id, i.order_id, i.order_no,i.inbound_code,
o.destination_type destination_kind,i.destination_warehouse_code destination_code,
i.destination_warehouse_name destination_name,SUM(i.package_count) package_count
FROM fl_loading_plan p JOIN fl_loading_plan_item i ON i.loading_plan_id=p.id AND i.deleted=0
JOIN fl_inbound_order o ON o.id=i.inbound_id
WHERE p.container_booking_id=#{id} AND p.plan_status='COMPLETED'
GROUP BY i.inbound_id,i.account_id,i.order_id,i.order_no,i.inbound_code,o.destination_type,
i.destination_warehouse_code,i.destination_warehouse_name
ORDER BY i.inbound_id
""")
List<Cargo> cargo(@Param("id") long id);
}
@@ -15,6 +15,15 @@ import java.util.List;
@Mapper
public interface CarrierScheduleQueryMapper extends BaseMapper<CarrierScheduleQueryEntity> {
@Select("""
SELECT container_booking_id FROM fl_carrier_schedule_node_fact
WHERE standard_node='PICKED_UP' AND actual_at IS NOT NULL
GROUP BY container_booking_id
HAVING MAX(actual_at)>=#{from} AND MAX(actual_at)<#{to}
ORDER BY MAX(actual_at),container_booking_id LIMIT 1001
""")
List<Long> selectPickupCalendarContainers(@Param("from") LocalDateTime from,@Param("to") LocalDateTime to);
@Select("""
<script>
SELECT * FROM fl_carrier_schedule_query
@@ -49,6 +49,9 @@ public class CarrierScheduleQueryRepository {
public List<ContainerActualFacts> containerActualFacts(List<Long> containerBookingIds) {
return queries.selectContainerActualFacts(containerBookingIds);
}
public List<Long> pickupCalendarContainers(LocalDateTime from, LocalDateTime to) {
return queries.selectPickupCalendarContainers(from, to);
}
public List<CarrierScheduleQueryEntity> due(LocalDateTime now, int limit) { return queries.selectDue(now, limit); }
public boolean claimLease(long id, String owner, LocalDateTime now, LocalDateTime leaseUntil) {
return queries.claimLease(id, owner, now, leaseUntil) == 1;
@@ -0,0 +1,77 @@
package com.baiyue.firstleg.portschedule.service;
import com.baiyue.firstleg.portschedule.dto.AppointmentWorkSourceDtos.*;
import com.baiyue.firstleg.portschedule.mapper.AppointmentWorkSourceMapper;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.http.HttpStatus;
import org.springframework.web.server.ResponseStatusException;
import java.security.MessageDigest;
import java.util.*;
@Service
public class AppointmentWorkSourceService {
public interface OriginalFacts {
String establishedFlow(long containerBookingId);
Fact read(FactRequest request, ContainerSource source);
}
private final AppointmentWorkSourceMapper mapper;
private final OriginalFacts original;
private final ObjectMapper json;
public AppointmentWorkSourceService(AppointmentWorkSourceMapper mapper,OriginalFacts original,ObjectMapper json) {
this.mapper=mapper; this.original=original; this.json=json;
}
@Transactional(readOnly=true, isolation=org.springframework.transaction.annotation.Isolation.REPEATABLE_READ)
public SourcePage discover(String after,int size) {
if(size<1 || size>200) throw bad("分页大小必须为1至200");
var rows=mapper.sources();
String scope=hash(rows); long last=0;
if(after!=null) {
String[] parts=after.split(":",-1);
if(parts.length!=2) throw bad("来源游标无效");
if(!scope.equals(parts[0])) throw new ResponseStatusException(HttpStatus.CONFLICT,"已开船来源已变化,请重新同步");
last=id(parts[1]);
}
final long cursor=last;
var page=rows.stream().filter(row->row.id()>cursor).limit(size+1L).toList();
boolean complete=page.size()<=size;
var selected=page.stream().limit(size).toList();
return new SourcePage(selected.stream().map(this::source).toList(),complete?null:scope+":"+selected.get(selected.size()-1).id(),complete,scope);
}
public ContainerSource container(long id) {
return mapper.sources().stream().filter(row->row.id()==id).findFirst().map(this::source)
.orElseThrow(()->new ResponseStatusException(HttpStatus.NOT_FOUND,"未找到有效已开船柜次"));
}
private ContainerSource source(AppointmentWorkSourceMapper.Source row) {
var cargo=mapper.cargo(row.id());
var requirements=cargo.stream().map(c->new Requirement("INBOUND:"+c.inboundId(),null,null,
c.destinationKind(),row.departurePortName(),c.destinationName(),null,
List.of(new Reference("CUSTOMER",Long.toString(c.customerId()),null,"所属客户"),
new Reference("ORDER",Long.toString(c.orderId()),c.orderNo(),"原订单"),
new Reference("INBOUND",Long.toString(c.inboundId()),c.inboundCode(),"入仓单"),
new Reference("CONTAINER",Long.toString(row.id()),row.cabinetNo(),"柜次")),List.of(),List.of())).toList();
return new ContainerSource(Long.toString(row.id()),row.containerNo(),null,row.departedLocalAt(),row.sailedEvidenceLocalAt(),
hash(List.of(row,cargo)),original.establishedFlow(row.id()),null,requirements);
}
public List<Fact> facts(List<FactRequest> requests) {
if(requests==null || requests.isEmpty() || requests.size()>200) throw bad("事实请求须包含1至200项");
Set<String> keys=new HashSet<>(); Map<Long,ContainerSource> sources=new HashMap<>();
return requests.stream().map(request->{
if(request==null || request.workItemKey()==null || request.workItemKey().isBlank() || !keys.add(request.workItemKey())
|| !("PICKUP".equals(request.type()) || "EMPTY_RETURN".equals(request.type()) || "DELIVERY".equals(request.type()) && "DIRECT".equals(request.flow())))
throw bad("事项身份或事实类型无效");
long id=id(request.containerBookingId());
return original.read(request,sources.computeIfAbsent(id,this::container));
}).toList();
}
public String hash(Object value) {
try {return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(json.writeValueAsBytes(value)));}
catch(Exception error){throw new IllegalStateException("不能生成来源版本",error);}
}
private static long id(String value) {
try {long id=Long.parseLong(value); if(id>0)return id;} catch(RuntimeException ignored) { }
throw bad("柜次或游标无效");
}
private static ResponseStatusException bad(String message){return new ResponseStatusException(HttpStatus.BAD_REQUEST,message);}
}
@@ -68,6 +68,7 @@ public class CarrierScheduleQueryService {
@Resource private CarrierScheduleAdapterRegistry adapters;
@Resource private CarrierScheduleNodeOutboxService nodeOutbox;
@Resource private ObjectMapper objectMapper;
@Resource private EmptyReturnPrerequisite emptyReturnPrerequisite;
@Value("${baiyue.carrier-schedule.scheduler.zone:Asia/Shanghai}")
private String scheduleZone = "Asia/Shanghai";
private Clock clock = Clock.systemDefaultZone();
@@ -81,6 +82,14 @@ public class CarrierScheduleQueryService {
source.page(), source.size(), source.total(), source.pages());
}
public List<Long> pickupCalendarContainers(LocalDateTime from, LocalDateTime to) {
if (from == null || to == null || !to.isAfter(from)
|| java.time.Duration.between(from, to).compareTo(java.time.Duration.ofDays(42)) > 0) {
throw badRequest("提柜日历查询范围必须为1至42天");
}
return repository.pickupCalendarContainers(from, to);
}
public List<ContainerActualFacts> containerActualFacts(List<Long> containerBookingIds) {
if (containerBookingIds == null || containerBookingIds.isEmpty() || containerBookingIds.size() > 200) {
throw badRequest("containerBookingIds数量必须在1到200之间");
@@ -223,6 +232,7 @@ public class CarrierScheduleQueryService {
CarrierScheduleQueryEntity value = requiredLockedVersion(id, request.expectedVersion());
CarrierScheduleNode node = CarrierScheduleNode.require(request.standardNode());
requireManualActualAdvance(value, node, request.actualAt());
requireEmptyReturnEvidence(value.getContainerBookingId(), node, request.actualAt());
CarrierScheduleQueryEntity before = copy(value);
String providerEventId = sha256("MANUAL|" + id + '|' + key);
CarrierScheduleNodeFactEntity fact = addNode(value, "MANUAL", providerEventId, node.name(), node,
@@ -266,6 +276,7 @@ public class CarrierScheduleQueryService {
CarrierScheduleQueryEntity query = found == null ? null : requiredLocked(found.getId());
String providerEventId = sha256("CONTAINER_MANUAL|" + containerBookingId + '|' + key);
String payloadHash = hash("CONTAINER_MANUAL_NODE", containerBookingId, request);
requireEmptyReturnEvidence(containerBookingId, node, request.actualAt());
CarrierScheduleNodeFactEntity fact = addNode(query == null ? null : query.getId(), containerBookingId,
"MANUAL", providerEventId,
node.name(), node, request.observedAt(), request.estimatedAt(), request.actualAt(),
@@ -331,6 +342,11 @@ public class CarrierScheduleQueryService {
}
}
private void requireEmptyReturnEvidence(long containerBookingId,CarrierScheduleNode node,LocalDateTime actualAt) {
if(node==CarrierScheduleNode.EMPTY_RETURNED && actualAt!=null)
emptyReturnPrerequisite.requireUnloaded(containerBookingId,actualAt);
}
public boolean hasOutboundDepartureFact(long customerId, long oceanBookingId, long containerBookingId) {
CarrierScheduleQueryEntity query = repository.findByBusinessScope(customerId, oceanBookingId,
containerBookingId);
@@ -0,0 +1,9 @@
package com.baiyue.firstleg.portschedule.service;
import java.time.LocalDateTime;
/** Actual unloading evidence owned by its original domain; not a work-item completion flag. */
public interface EmptyReturnPrerequisite {
LocalDateTime completedUnloading(long containerBookingId);
void requireUnloaded(long containerBookingId, LocalDateTime returnedAt);
}
@@ -0,0 +1,30 @@
package com.baiyue.firstleg.terminaldrayage.controller;
import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.common.security.AuthenticatedUser;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import com.baiyue.firstleg.service.FirstlegScopeGuard;
import com.baiyue.firstleg.terminaldrayage.service.DirectDeliveryService;
import jakarta.validation.Valid;
import org.springframework.web.bind.annotation.*;
import java.util.List;
@RestController
@RequestMapping("/api/platform/v1/terminal-drayages/{containerBookingId}/direct-deliveries")
public class PlatformDirectDeliveryController {
private final DirectDeliveryService service;
public PlatformDirectDeliveryController(DirectDeliveryService service){this.service=service;}
@GetMapping
@PreAuthorize("hasAuthority('platform:firstleg:terminal-drayage:list')")
public ApiResponse<List<DirectDeliveryService.View>> list(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable long containerBookingId) {
FirstlegScopeGuard.platform(user); return ApiResponse.success(service.list(containerBookingId));
}
@PostMapping("/{inboundId}")
@PreAuthorize("hasAuthority('platform:firstleg:terminal-drayage:direct-unload:confirm')")
public ApiResponse<DirectDeliveryService.View> record(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable long containerBookingId,
@PathVariable long inboundId,@RequestHeader("Idempotency-Key") String key,@Valid @RequestBody DirectDeliveryService.Request request) {
FirstlegScopeGuard.platform(user);
return ApiResponse.success(service.record(containerBookingId,inboundId,user.userId(),user.username(),key,request));
}
}
@@ -0,0 +1,42 @@
package com.baiyue.firstleg.terminaldrayage.mapper;
import org.apache.ibatis.annotations.*;
import java.time.LocalDateTime;
import java.util.List;
@Mapper
public interface DirectDeliveryMapper {
record Delivery(long inboundId,String inboundCode,String orderNo,String destinationName,int packageCount,
int version,LocalDateTime actualAt,String timezone,String receivedBy,String evidenceNo,String remark,
String idempotencyKey,String requestHash) { }
record Container(long id,String recordStatus,LocalDateTime warehouseUnloadedAt) { }
@Select("SELECT id,record_status,unloading_completed_at warehouse_unloaded_at FROM fl_container_booking WHERE id=#{id} FOR UPDATE")
@Options(flushCache=Options.FlushCachePolicy.TRUE,useCache=false)
Container lockContainer(@Param("id") long id);
@Select("SELECT id,record_status,unloading_completed_at warehouse_unloaded_at FROM fl_container_booking WHERE id=#{id}")
Container container(@Param("id") long id);
@Select("""
SELECT i.inbound_id,MAX(i.inbound_code) inbound_code,MAX(i.order_no) order_no,
MAX(i.destination_warehouse_name) destination_name,SUM(i.package_count) package_count,
COALESCE(d.version,0) version,d.actual_at,d.timezone,d.received_by,d.evidence_no,d.remark,d.idempotency_key,d.request_hash
FROM fl_loading_plan p JOIN fl_loading_plan_item i ON i.loading_plan_id=p.id AND i.deleted=0
LEFT JOIN fl_direct_delivery_unloading d ON d.container_booking_id=p.container_booking_id AND d.inbound_id=i.inbound_id
WHERE p.container_booking_id=#{id} AND p.plan_status='COMPLETED'
GROUP BY i.inbound_id,d.version,d.actual_at,d.timezone,d.received_by,d.evidence_no,d.remark,d.idempotency_key,d.request_hash
ORDER BY i.inbound_id
""")
List<Delivery> deliveries(@Param("id") long id);
@Select("SELECT COUNT(*) FROM fl_direct_delivery_unloading WHERE container_booking_id=#{id}")
int evidenceCount(@Param("id") long id);
@Select("SELECT COUNT(*) FROM fl_direct_delivery_unloading WHERE container_booking_id=#{id} AND idempotency_key=#{key}")
int keyCount(@Param("id") long id,@Param("key") String key);
@Insert("""
INSERT INTO fl_direct_delivery_unloading(container_booking_id,inbound_id,actual_at,timezone,received_by,
evidence_no,remark,version,idempotency_key,request_hash,created_by,created_by_name)
VALUES(#{id},#{inboundId},#{actualAt},#{timezone},#{receivedBy},#{evidenceNo},#{remark},1,#{key},#{hash},#{operatorId},#{operatorName})
""")
int insert(@Param("id") long id,@Param("inboundId") long inboundId,@Param("actualAt") LocalDateTime actualAt,
@Param("timezone") String timezone,@Param("receivedBy") String receivedBy,@Param("evidenceNo") String evidenceNo,
@Param("remark") String remark,@Param("key") String key,@Param("hash") String hash,
@Param("operatorId") long operatorId,@Param("operatorName") String operatorName);
}
@@ -27,6 +27,21 @@ public interface TerminalDrayageMapper extends BaseMapper<TerminalDrayageEntity>
List<com.baiyue.firstleg.terminaldrayage.dto.PickupCalendarEntry> calendar(
@Param("from") LocalDateTime from,@Param("to") LocalDateTime to,@Param("limit") int limit);
@Select("""
<script>
SELECT c.id containerBookingId,c.cabinet_no cabinetNo,c.container_no containerNo,
c.destination_port_name destinationPortName,c.destination_warehouse_code destinationWarehouseCode,
t.fleet_supplier_name fleetSupplierName,t.pickup_appointment_at pickupAppointmentAt,NULL actualPickupAt
FROM fl_container_booking c LEFT JOIN fl_terminal_drayage t ON t.container_booking_id=c.id
WHERE c.record_status='ACTIVE' AND c.id IN
<foreach collection='ids' item='id' open='(' separator=',' close=')'>#{id}</foreach>
AND EXISTS(SELECT 1 FROM fl_outbound_dispatch d WHERE d.container_booking_id=c.id
AND d.confirmed_at IS NOT NULL AND d.reversed_at IS NULL AND d.status='WAITING_FOR_LOADING')
ORDER BY c.id
</script>
""")
List<com.baiyue.firstleg.terminaldrayage.dto.PickupCalendarEntry> calendarByContainers(@Param("ids") List<Long> ids);
@Select("""
SELECT b.id AS containerBookingId,b.container_no AS containerNo,b.ocean_booking_id AS oceanBookingId,
b.record_status AS recordStatus,b.unloading_completed_at AS unloadingCompletedAt
@@ -0,0 +1,100 @@
package com.baiyue.firstleg.terminaldrayage.service;
import com.baiyue.firstleg.portschedule.dto.AppointmentWorkSourceDtos.*;
import com.baiyue.firstleg.portschedule.service.AppointmentWorkSourceService.OriginalFacts;
import com.baiyue.firstleg.portschedule.service.CarrierScheduleQueryService;
import com.baiyue.firstleg.terminaldrayage.mapper.DirectDeliveryMapper;
import com.baiyue.firstleg.terminaldrayage.mapper.TerminalDrayageMapper;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.stereotype.Component;
import org.springframework.http.HttpStatus;
import org.springframework.web.server.ResponseStatusException;
import java.time.*;
import java.security.MessageDigest;
import java.util.*;
/** The terminal domain adapts its facts to a wire port; the work service never imports its persistence. */
@Component
public class AppointmentWorkTerminalFacts implements OriginalFacts {
private static final String READ="platform:firstleg:terminal-drayage:list";
private final TerminalDrayageMapper terminal;
private final DirectDeliveryMapper direct;
private final CarrierScheduleQueryService nodes;
private final UnloadingEvidenceGuard unloading;
private final ObjectMapper json;
public AppointmentWorkTerminalFacts(TerminalDrayageMapper terminal,DirectDeliveryMapper direct,
CarrierScheduleQueryService nodes,UnloadingEvidenceGuard unloading,ObjectMapper json) {
this.terminal=terminal;this.direct=direct;this.nodes=nodes;this.unloading=unloading;this.json=json;
}
public String establishedFlow(long id) {
var container=direct.container(id);
boolean transfer=container!=null && container.warehouseUnloadedAt()!=null;
boolean delivery=direct.evidenceCount(id)>0;
if(transfer && delivery) throw new ResponseStatusException(HttpStatus.CONFLICT,"货柜存在冲突的中转和直送执行事实");
return transfer?"TRANSFER":delivery?"DIRECT":"UNKNOWN";
}
public Fact read(FactRequest request,ContainerSource source) {
long id=Long.parseLong(request.containerBookingId());
var node=nodes.containerActualFacts(List.of(id)).stream().findFirst().orElse(null);
var plan=terminal.find(id);
var references=new ArrayList<Reference>();
source.requirements().forEach(r->r.references().forEach(ref->{if(!references.contains(ref))references.add(ref);}));
var blocks=new ArrayList<String>(); var missing=new ArrayList<String>();
LocalDateTime at=null; String zone=null; Slot confirmed=null; boolean exists=false;
String code=null,label=null,permission=null; Responsibility responsibility=null;
String businessNo=references.stream().filter(r->"CONTAINER".equals(r.kind())).map(Reference::businessNo).filter(Objects::nonNull).findFirst().orElse(null);
String impact="BASELINE";
switch(request.type()) {
case "PICKUP" -> {
at=node==null?null:node.pickedUpAt();
if(plan!=null && plan.getPickupAppointmentAt()!=null) {
var time=plan.getPickupAppointmentAt(); confirmed=new Slot(time.toLocalDate(),time.toLocalTime(),null,null,null); exists=true;
}
if(plan!=null && plan.getFleetSupplierId()!=null)
responsibility=new Responsibility("SUPPLIER",plan.getFleetSupplierId().toString(),plan.getFleetSupplierName());
// The initial arrangement/assignment establishes a baseline. Only later changes invalidate dependants.
var history=terminal.histories(id);
long reschedules=Math.max(0,history.stream().filter(h->"SCHEDULE_PICKUP".equals(h.action())).count()-1);
long reassignments=Math.max(0,history.stream().filter(h->"SELECT_FLEET".equals(h.action())).count()-1);
impact=reschedules+":"+reassignments;
if(at==null) {
if(node==null || node.arrivedDestinationPortAt()==null) blocks.add("实际到港后才能安排提柜");
else if(plan==null || plan.getFleetSupplierId()==null) {code="SAVE_PLAN";label="选择码头车队";permission="platform:firstleg:terminal-drayage:fleet:edit";}
else if(confirmed==null) {code="REQUEST_BOOKING";label="预约提柜";permission="platform:firstleg:terminal-drayage:pickup-appointment:edit";}
else {code="RECORD_EXECUTION";label="记录实际提柜";permission="platform:firstleg:port-schedule-query:manual";}
}
}
case "EMPTY_RETURN" -> {
at=node==null?null:node.emptyReturnedAt();
if(at==null && plan!=null) at=plan.getActualEmptyReturnAt();
if(at==null && unloading.completedUnloading(id)==null) blocks.add("海外仓卸柜或全部直送目的仓实际卸货后才能还空");
else if(at==null) {code="RECORD_EXECUTION";label="确认实际还空";permission="platform:firstleg:terminal-drayage:empty-return:confirm";}
// Return responsibility belongs to the work handoff, not the historical pickup carrier.
}
case "DELIVERY" -> {
var requirement=source.requirements().stream().filter(r->Objects.equals(r.requirementKey(),request.requirementKey())).findFirst()
.orElseThrow(()->new ResponseStatusException(HttpStatus.CONFLICT,"直送需求与原柜来源不一致"));
references.clear(); references.addAll(requirement.references());
var row=direct.deliveries(id).stream().filter(d->("INBOUND:"+d.inboundId()).equals(request.requirementKey())).findFirst()
.orElseThrow(()->new ResponseStatusException(HttpStatus.CONFLICT,"直送需求与实际装柜来源不一致"));
at=row.actualAt();zone=row.timezone();businessNo=row.inboundCode();
if(at==null && (node==null || node.pickedUpAt()==null)) blocks.add("实际提柜后才能记录直送卸货");
else if(at==null) {code="RECORD_EXECUTION";label="登记目的仓实际卸货";permission="platform:firstleg:terminal-drayage:direct-unload:confirm";}
}
default -> throw new ResponseStatusException(HttpStatus.BAD_REQUEST,"不支持的Firstleg事实类型");
}
if((at!=null || confirmed!=null) && zone==null) missing.add("SOURCE_TIMEZONE");
Actual actual=at==null?null:new Actual(zone==null?null:at.atZone(ZoneId.of(zone)).toInstant(),at,zone);
Action action=code==null?null:new Action(code,label,null,new Target("FIRSTLEG_CONTAINER",source.containerBookingId()));
var fact=new Fact(request.workItemKey(),"pending",impact,exists,confirmed==null?"UNPLANNED":"CONFIRMED",
actual!=null?"COMPLETED":"PENDING",null,confirmed,actual,zone,businessNo,null,List.of(READ),
permission==null?List.of():List.of(permission),action,List.copyOf(blocks),List.copyOf(missing),List.copyOf(references),responsibility);
return new Fact(fact.workItemKey(),hash(fact),fact.impactToken(),fact.planExists(),fact.planStatus(),fact.executionStatus(),
fact.target(),fact.confirmed(),fact.actual(),fact.timezone(),fact.businessNo(),fact.tripKey(),fact.readPermissions(),
fact.actionPermissions(),fact.action(),fact.blockReasons(),fact.missingFields(),fact.references(),fact.responsibility());
}
private String hash(Object value) {
try{return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(json.writeValueAsBytes(value)));}
catch(Exception error){throw new IllegalStateException(error);}
}
}
@@ -0,0 +1,22 @@
package com.baiyue.firstleg.terminaldrayage.service;
import com.baiyue.firstleg.client.AppointmentWorkRouteClient;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import org.springframework.http.HttpStatus;
import org.springframework.web.server.ResponseStatusException;
@Service
public class DirectDeliveryRouteGuard {
private final AppointmentWorkRouteClient client;
private final String token;
public DirectDeliveryRouteGuard(AppointmentWorkRouteClient client,@Value("${baiyue.security.internal-service-token:}") String token){this.client=client;this.token=token;}
public void requireDirect(long id) {
if(token==null || token.isBlank()) throw unavailable();
com.baiyue.common.core.api.ApiResponse<AppointmentWorkRouteClient.Route> response;
try {response=client.route(token,id);} catch(RuntimeException error){throw unavailable();}
if(response==null || !response.successful() || response.data()==null) throw unavailable();
if(!"DIRECT".equals(response.data().flow())) throw new ResponseStatusException(HttpStatus.CONFLICT,"请先在预约工作中确认整柜直送路线");
}
private ResponseStatusException unavailable(){return new ResponseStatusException(HttpStatus.SERVICE_UNAVAILABLE,"无法核实已确认路线,请稍后重试");}
}
@@ -0,0 +1,77 @@
package com.baiyue.firstleg.terminaldrayage.service;
import com.baiyue.common.core.api.ResultCode;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.firstleg.portschedule.service.CarrierScheduleQueryService;
import com.baiyue.firstleg.terminaldrayage.mapper.DirectDeliveryMapper;
import com.fasterxml.jackson.databind.ObjectMapper;
import jakarta.validation.constraints.*;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.security.MessageDigest;
import java.time.*;
import java.util.*;
/** Actual direct delivery evidence. It does not call warehouse receipt or create stock. */
@Service
public class DirectDeliveryService {
public record Request(@Min(0) int expectedVersion,@NotNull LocalDateTime actualAt,@NotBlank String timezone,
@NotBlank @Size(max=128) String receivedBy,@NotBlank @Size(max=128) String evidenceNo,@Size(max=1000) String remark) { }
public record View(String requirementKey,String inboundCode,String orderNo,String destinationName,int packageCount,
int version,LocalDateTime actualAt,String timezone,String receivedBy,String evidenceNo,String remark) { }
private final DirectDeliveryMapper mapper;
private final CarrierScheduleQueryService nodes;
private final ObjectMapper json;
private final DirectDeliveryRouteGuard routes;
public DirectDeliveryService(DirectDeliveryMapper mapper,CarrierScheduleQueryService nodes,ObjectMapper json,DirectDeliveryRouteGuard routes) {
this.mapper=mapper;this.nodes=nodes;this.json=json;this.routes=routes;
}
public List<View> list(long id) {
var container=mapper.container(id);
if(container==null || !"ACTIVE".equals(container.recordStatus())) throw new BizException(ResultCode.NOT_FOUND,"货柜不存在");
return mapper.deliveries(id).stream().map(this::view).toList();
}
@Transactional
public View record(long id,long inboundId,long operatorId,String operatorName,String key,Request request) {
if(key==null || key.isBlank() || key.length()>128 || request==null) throw bad("请提供有效幂等键及卸货记录");
var container=mapper.lockContainer(id);
if(container==null || !"ACTIVE".equals(container.recordStatus())) throw new BizException(ResultCode.NOT_FOUND,"货柜不存在");
var current=mapper.deliveries(id).stream().filter(row->row.inboundId()==inboundId).findFirst()
.orElseThrow(()->bad("该入仓来源不在本柜实际装柜清单内"));
String hash=hash(request);
if(key.equals(current.idempotencyKey())) {
if(!hash.equals(current.requestHash())) throw conflict("同一幂等键对应的卸货内容已变化");
return view(current);
}
if(mapper.keyCount(id,key)>0) throw conflict("幂等键已被其他卸货记录使用");
if(current.version()!=request.expectedVersion() || current.actualAt()!=null) throw conflict("实际卸货已确认,请刷新核对");
if(container.warehouseUnloadedAt()!=null) throw conflict("已有海外仓卸柜事实,不能改记为直送");
routes.requireDirect(id);
ZoneId zone;
try {if(!ZoneId.getAvailableZoneIds().contains(request.timezone())) throw new IllegalArgumentException(); zone=ZoneId.of(request.timezone());} catch(RuntimeException e){throw bad("请选择有效目的地时区");}
if(request.actualAt()==null || zone.getRules().getValidOffsets(request.actualAt()).size()!=1)
throw bad("实际卸货时间缺失或处于夏令时歧义区间,请核对");
if(request.actualAt().atZone(zone).toInstant().isAfter(Instant.now())) throw bad("实际卸货时间不能在未来");
if(request.receivedBy()==null || request.receivedBy().isBlank() || request.evidenceNo()==null || request.evidenceNo().isBlank())
throw bad("请填写真实签收人及签收凭据号");
var facts=nodes.containerActualFacts(List.of(id));
var fact=facts.stream().filter(f->f.containerBookingId()==id).findFirst().orElse(null);
if(fact==null || fact.pickedUpAt()==null) throw conflict("实际提柜后才能记录直送卸货");
if(fact.emptyReturnedAt()!=null) throw conflict("货柜已实际还空,不能新增卸货事实");
// Pickup has no source zone. Its local clock cannot order a destination-zone actual;
// require the real pickup fact, without inventing an offset for it.
mapper.insert(id,inboundId,request.actualAt(),zone.getId(),request.receivedBy().trim(),request.evidenceNo().trim(),
request.remark(),key,hash,operatorId,operatorName);
return mapper.deliveries(id).stream().filter(row->row.inboundId()==inboundId).findFirst().map(this::view).orElseThrow();
}
private View view(DirectDeliveryMapper.Delivery row) {
return new View("INBOUND:"+row.inboundId(),row.inboundCode(),row.orderNo(),row.destinationName(),row.packageCount(),
row.version(),row.actualAt(),row.timezone(),row.receivedBy(),row.evidenceNo(),row.remark());
}
private String hash(Object value) {
try{return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(json.writeValueAsBytes(value)));}
catch(Exception e){throw new IllegalStateException(e);}
}
private static BizException bad(String message){return new BizException(ResultCode.BAD_REQUEST,message);}
private static BizException conflict(String message){return new BizException(ResultCode.CONFLICT,message);}
}
@@ -23,11 +23,18 @@ public class TerminalDrayageCalendarService {
@Value("${baiyue.security.internal-service-token}") String token) {this.mapper=mapper;this.warehouses=warehouses;this.shipping=shipping;this.token=token;}
public record Calendar(List<PickupCalendarEntry> records,boolean truncated,int unclassifiedCount,
String scopeBasis,String timeBasis) { }
@org.springframework.transaction.annotation.Transactional(readOnly=true)
public Calendar calendar(LocalDate from,LocalDate to) {
if(from==null || to==null || !to.isAfter(from) || ChronoUnit.DAYS.between(from,to)>42)
throw new ResponseStatusException(HttpStatus.BAD_REQUEST,"日历查询范围必须为1至42天");
var rows=mapper.calendar(from.atStartOfDay(),to.atStartOfDay(),1001);
if(rows.isEmpty()) return result(List.of(),false,0);
var planned=mapper.calendar(from.atStartOfDay(),to.atStartOfDay(),1001);
var actualIds=shipping.pickupCalendarContainers(from.atStartOfDay(),to.atStartOfDay());
var candidates=new LinkedHashMap<Long,PickupCalendarEntry>();
planned.stream().limit(1000).forEach(row->candidates.put(row.containerBookingId(),row));
if(!actualIds.isEmpty()) mapper.calendarByContainers(actualIds.stream().limit(1000).toList())
.forEach(row->candidates.put(row.containerBookingId(),row));
boolean truncated=planned.size()>1000 || actualIds.size()>1000;
if(candidates.isEmpty()) return result(List.of(),truncated,0);
final Map<String,String> countries=new HashMap<>();
try {
var response=warehouses.catalog(token);
@@ -36,7 +43,7 @@ public class TerminalDrayageCalendarService {
countries.put(warehouse.warehouseCode(),warehouse.countryCode());
} catch(RuntimeException error) {throw new ResponseStatusException(HttpStatus.SERVICE_UNAVAILABLE,"提柜目的仓范围暂不可核实",error);}
var records=new ArrayList<PickupCalendarEntry>();int unknown=0;
for(var row:rows.stream().limit(1000).toList()) {
for(var row:candidates.values()) {
var country=countries.get(row.destinationWarehouseCode());
if(country==null || country.isBlank()) unknown++;
else if("US".equals(country)) records.add(row);
@@ -48,8 +55,13 @@ public class TerminalDrayageCalendarService {
if(fact.pickedUpAt()!=null) actual.put(fact.containerBookingId(),fact.pickedUpAt());
}
var enriched=records.stream().map(row->new PickupCalendarEntry(row.containerBookingId(),row.cabinetNo(),row.containerNo(),
row.destinationPortName(),row.destinationWarehouseCode(),row.fleetSupplierName(),row.pickupAppointmentAt(),actual.get(row.containerBookingId()))).toList();
return result(enriched,rows.size()>1000,unknown);
row.destinationPortName(),row.destinationWarehouseCode(),row.fleetSupplierName(),row.pickupAppointmentAt(),actual.get(row.containerBookingId())))
.filter(row->{var at=occurrence(row);return at!=null && !at.isBefore(from.atStartOfDay()) && at.isBefore(to.atStartOfDay());})
.sorted(Comparator.comparing(TerminalDrayageCalendarService::occurrence).thenComparing(PickupCalendarEntry::containerBookingId)).toList();
return result(enriched.stream().limit(1000).toList(),truncated || enriched.size()>1000,unknown);
}
private static java.time.LocalDateTime occurrence(PickupCalendarEntry row) {
return row.actualPickupAt()!=null?row.actualPickupAt():row.pickupAppointmentAt();
}
private Calendar result(List<PickupCalendarEntry> records,boolean truncated,int unknown) {
return new Calendar(List.copyOf(records),truncated,unknown,"US_DESTINATION_WAREHOUSE","SOURCE_LOCAL_UNSPECIFIED");
@@ -48,6 +48,7 @@ public class TerminalDrayageService {
private final CarrierScheduleQueryService carrierSchedules;
private final ObjectMapper objectMapper;
private final TerminalDrayageMailService mails;
private final UnloadingEvidenceGuard unloadingEvidence;
private Clock clock = Clock.systemDefaultZone();
public View detail(long containerBookingId) {
@@ -145,10 +146,10 @@ public class TerminalDrayageService {
TerminalDrayageEntity value = mapper.lock(containerBookingId);
requireVersion(value, request.expectedVersion());
LocalDateTime pickupAt = actualPickupAt(context, value);
if (context.base().unloadingCompletedAt() == null) throw conflict("海外卸柜收货完成后才能确认还柜");
LocalDateTime earliest = pickupAt != null && pickupAt.isAfter(context.base().unloadingCompletedAt())
? pickupAt : context.base().unloadingCompletedAt();
if (request.actualAt().isBefore(earliest)) throw badRequest("实际还柜时间不能早于提柜或卸柜完成时间");
if (completedUnloading(context) == null) throw conflict("海外仓卸柜或全部直送目的仓实际卸货完成后才能确认还柜");
LocalDateTime earliest = context.base().unloadingCompletedAt();
if (pickupAt != null && (earliest == null || pickupAt.isAfter(earliest))) earliest = pickupAt;
if (earliest != null && request.actualAt().isBefore(earliest)) throw badRequest("实际还柜时间不能早于提柜或卸柜完成时间");
if (actualEmptyReturnAt(context, value) != null) throw conflict("实际还柜时间已确认");
advanceNode(context, CarrierScheduleNode.EMPTY_RETURNED, request.actualAt(), action, key,
request.remark(), operatorId, operatorName);
@@ -229,23 +230,25 @@ public class TerminalDrayageService {
}
private void applyAuthoritativeDeadline(WorkContext context, TerminalDrayageEntity value) {
if (context.base().unloadingCompletedAt() != null) {
value.setEstimatedEmptyReturnAt(context.base().unloadingCompletedAt().plusHours(EMPTY_RETURN_WARNING_HOURS));
if (completedUnloading(context) != null) {
value.setEstimatedEmptyReturnAt(returnDeadline(context));
}
}
private View view(WorkContext context, TerminalDrayageEntity value) {
String status = status(context, value);
LocalDateTime deadline = context.base().unloadingCompletedAt() == null ? null
: context.base().unloadingCompletedAt().plusHours(EMPTY_RETURN_WARNING_HOURS);
LocalDateTime deadline = returnDeadline(context);
Duration remaining = deadline == null ? null : context.directCompletion() == null
? Duration.between(LocalDateTime.now(clock), deadline)
: Duration.between(clock.instant(), context.directCompletion().instant().plus(Duration.ofHours(EMPTY_RETURN_WARNING_HOURS)));
LocalDateTime returnedAt = actualEmptyReturnAt(context, value);
String warningStatus;
long overdueHours = 0;
if (returnedAt != null) warningStatus = "RESOLVED";
else if (deadline == null) warningStatus = "NOT_APPLICABLE";
else if (LocalDateTime.now(clock).isAfter(deadline)) {
else if (remaining.isNegative()) {
warningStatus = "OVERDUE";
overdueHours = Math.max(0, Duration.between(deadline, LocalDateTime.now(clock)).toHours());
overdueHours = Math.max(0, remaining.negated().toHours());
} else warningStatus = "PENDING";
Warning warning = new Warning(warningStatus, deadline, overdueHours, overdueHours / 24);
return new View(context.base().containerBookingId(), context.base().containerNo(), context.base().oceanBookingId(), status,
@@ -253,14 +256,14 @@ public class TerminalDrayageService {
value == null ? null : value.getFleetSupplierName(),
value == null ? null : value.getPickupAppointmentAt(),
actualPickupAt(context, value), deadline, returnedAt,
context.base().unloadingCompletedAt(), warning, allowedActions(context, value), context.facts(),
completedUnloading(context), warning, allowedActions(context, value), context.facts(),
value == null ? null : value.getUpdatedAt());
}
private List<String> allowedActions(WorkContext context, TerminalDrayageEntity value) {
List<String> result = new ArrayList<>();
if (value != null && value.getFleetSupplierId() != null && actualPickupAt(context, value) == null
&& actualEmptyReturnAt(context, value) == null && context.base().unloadingCompletedAt() == null) result.add("SEND_PICKUP_EMAIL");
&& actualEmptyReturnAt(context, value) == null && completedUnloading(context) == null) result.add("SEND_PICKUP_EMAIL");
CarrierScheduleNode current = currentNode(context);
if (current != null && current.sequence() >= CarrierScheduleNode.ARRIVED_DESTINATION_PORT.sequence()
&& current.sequence() < CarrierScheduleNode.PICKED_UP.sequence()
@@ -268,14 +271,14 @@ public class TerminalDrayageService {
result.add("SELECT_FLEET");
if (value != null && value.getFleetSupplierId() != null) result.add("SCHEDULE_PICKUP");
}
if (context.base().unloadingCompletedAt() != null
if (completedUnloading(context) != null
&& actualEmptyReturnAt(context, value) == null) result.add("RECORD_EMPTY_RETURN");
return List.copyOf(result);
}
private String status(WorkContext context, TerminalDrayageEntity value) {
if (actualEmptyReturnAt(context, value) != null) return "EMPTY_RETURNED";
if (context.base().unloadingCompletedAt() != null) return "UNLOADED";
if (completedUnloading(context) != null) return "UNLOADED";
if (actualPickupAt(context, value) != null) return "PICKED_UP";
if (value != null && value.getPickupAppointmentAt() != null) return "PICKUP_SCHEDULED";
CarrierScheduleNode current = currentNode(context);
@@ -300,6 +303,14 @@ public class TerminalDrayageService {
return context.facts() == null ? null : node(context.facts().latestActualNode());
}
private LocalDateTime completedUnloading(WorkContext context) {
return context.completedUnloadingAt();
}
private LocalDateTime returnDeadline(WorkContext context) {
if(context.directCompletion()!=null) return context.directCompletion().deadline(EMPTY_RETURN_WARNING_HOURS);
return completedUnloading(context)==null?null:completedUnloading(context).plusHours(EMPTY_RETURN_WARNING_HOURS);
}
private LocalDateTime actualPickupAt(WorkContext context, TerminalDrayageEntity value) {
if (context.facts() != null && context.facts().pickedUpAt() != null) return context.facts().pickedUpAt();
return context.base().unloadingCompletedAt();
@@ -334,10 +345,12 @@ public class TerminalDrayageService {
private WorkContext context(Context base, ContainerActualFacts facts) {
if (base == null || !"ACTIVE".equals(base.recordStatus())) return null;
return new WorkContext(base, facts);
var direct=base.unloadingCompletedAt()==null?unloadingEvidence.directCompletion(base.containerBookingId()):null;
return new WorkContext(base, facts,base.unloadingCompletedAt()!=null?base.unloadingCompletedAt():direct==null?null:direct.localAt(),direct);
}
private record WorkContext(Context base, ContainerActualFacts facts) {}
private record WorkContext(Context base, ContainerActualFacts facts,LocalDateTime completedUnloadingAt,
UnloadingEvidenceGuard.DirectCompletion directCompletion) {}
private TerminalDrayageEntity requiredAggregate(long id) {
TerminalDrayageEntity value = mapper.lock(id);
@@ -0,0 +1,41 @@
package com.baiyue.firstleg.terminaldrayage.service;
import com.baiyue.firstleg.portschedule.service.EmptyReturnPrerequisite;
import com.baiyue.firstleg.terminaldrayage.mapper.DirectDeliveryMapper;
import com.baiyue.common.core.api.ResultCode;
import com.baiyue.common.core.exception.BizException;
import org.springframework.stereotype.Service;
import java.time.*;
@Service
public class UnloadingEvidenceGuard implements EmptyReturnPrerequisite {
private final DirectDeliveryMapper mapper;
public UnloadingEvidenceGuard(DirectDeliveryMapper mapper){this.mapper=mapper;}
public record DirectCompletion(LocalDateTime localAt,String timezone) {
public Instant instant(){return localAt.atZone(ZoneId.of(timezone)).toInstant();}
public LocalDateTime deadline(int hours){return instant().plus(Duration.ofHours(hours)).atZone(ZoneId.of(timezone)).toLocalDateTime();}
}
public DirectCompletion directCompletion(long id) {
var rows=mapper.deliveries(id);
if(rows.isEmpty() || rows.stream().anyMatch(row->row.actualAt()==null)) return null;
return rows.stream().map(row->new DirectCompletion(row.actualAt(),row.timezone()))
.max(java.util.Comparator.comparing(DirectCompletion::instant)).orElse(null);
}
public LocalDateTime completedUnloading(long id) {
var container=mapper.container(id);
if(container==null || !"ACTIVE".equals(container.recordStatus())) return null;
if(container.warehouseUnloadedAt()!=null) return container.warehouseUnloadedAt();
var direct=directCompletion(id);
return direct==null?null:direct.localAt();
}
public void requireUnloaded(long id,LocalDateTime returnedAt) {
var container=mapper.lockContainer(id);
if(container==null) throw new BizException(ResultCode.NOT_FOUND,"货柜不存在");
LocalDateTime completed=completedUnloading(id);
if(completed==null) throw new BizException(ResultCode.CONFLICT,"海外仓卸柜或全部直送目的仓实际卸货完成后才能确认还柜");
// Original return nodes have no zone. Preserve the existing warehouse-local check,
// but do not compare that unzoned value with another destination's wall clock.
if(container.warehouseUnloadedAt()!=null && returnedAt.isBefore(completed))
throw new BizException(ResultCode.BAD_REQUEST,"实际还柜时间不能早于卸货完成时间");
}
}