feat(appointment): integrate work calendar and stable cargo batches
This commit is contained in:
@@ -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);
|
||||
}
|
||||
+36
@@ -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,"内部身份无效");
|
||||
}
|
||||
}
|
||||
+29
@@ -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) { }
|
||||
}
|
||||
+43
@@ -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);
|
||||
}
|
||||
+9
@@ -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
|
||||
|
||||
+3
@@ -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;
|
||||
|
||||
+77
@@ -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);}
|
||||
}
|
||||
+16
@@ -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);
|
||||
|
||||
+9
@@ -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);
|
||||
}
|
||||
+30
@@ -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));
|
||||
}
|
||||
}
|
||||
+42
@@ -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);
|
||||
}
|
||||
+15
@@ -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
|
||||
|
||||
+100
@@ -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);}
|
||||
}
|
||||
}
|
||||
+22
@@ -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,"无法核实已确认路线,请稍后重试");}
|
||||
}
|
||||
+77
@@ -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);}
|
||||
}
|
||||
+17
-5
@@ -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");
|
||||
|
||||
+29
-16
@@ -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);
|
||||
|
||||
+41
@@ -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,"实际还柜时间不能早于卸货完成时间");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user