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,"实际还柜时间不能早于卸货完成时间");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
-- Current schema for actual destination unloading; never an overseas warehouse receipt.
|
||||
CREATE TABLE IF NOT EXISTS fl_direct_delivery_unloading (
|
||||
container_booking_id BIGINT UNSIGNED NOT NULL,
|
||||
inbound_id BIGINT UNSIGNED NOT NULL COMMENT 'Immutable physically loaded inbound source',
|
||||
actual_at DATETIME(3) NOT NULL COMMENT 'Actual destination local time',
|
||||
timezone VARCHAR(64) NOT NULL COMMENT 'Explicit destination IANA timezone',
|
||||
received_by VARCHAR(128) NOT NULL COMMENT 'Actual recipient',
|
||||
evidence_no VARCHAR(128) NOT NULL COMMENT 'Actual delivery receipt reference',
|
||||
remark VARCHAR(1000) NULL,
|
||||
version INT NOT NULL DEFAULT 1,
|
||||
idempotency_key VARCHAR(128) NOT NULL,
|
||||
request_hash CHAR(64) NOT NULL,
|
||||
created_by BIGINT UNSIGNED NOT NULL,
|
||||
created_by_name VARCHAR(64) NULL,
|
||||
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||
PRIMARY KEY (container_booking_id,inbound_id),
|
||||
UNIQUE KEY uk_direct_unloading_command (container_booking_id,idempotency_key),
|
||||
CONSTRAINT fk_direct_unloading_container FOREIGN KEY (container_booking_id) REFERENCES fl_container_booking(id)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='直送目的仓真实卸货凭据';
|
||||
@@ -0,0 +1,178 @@
|
||||
openapi: 3.0.3
|
||||
info: {title: Firstleg appointment work authoritative sources, version: 1.0.0}
|
||||
security: [{InternalServiceToken: []}]
|
||||
paths:
|
||||
/internal/firstleg/appointment-work/containers:
|
||||
get:
|
||||
summary: Complete actual-sailed container scope
|
||||
description: Includes missing warehouses, private destinations and stopped tracking tasks. An actual departure or later maritime milestone is required. GET never creates work. Scope-versioned cursors reject changes between pages with 409; consumers restart a full scan. Original local times without a source timezone are never converted to UTC.
|
||||
parameters:
|
||||
- {name: after, in: query, schema: {type: string, maxLength: 128}}
|
||||
- {name: size, in: query, schema: {type: integer, minimum: 1, maximum: 200, default: 200}}
|
||||
responses:
|
||||
'200':
|
||||
description: ApiResponse
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
data: {$ref: '#/components/schemas/SourcePage'}
|
||||
'401': {description: Invalid internal service identity}
|
||||
'409': {description: Scope changed; restart from the first page}
|
||||
/internal/firstleg/appointment-work/containers/{id}:
|
||||
get:
|
||||
summary: Read one authoritative sailed container and physical cargo lineage
|
||||
parameters: [{name: id, in: path, required: true, schema: {type: integer, format: int64, minimum: 1}}]
|
||||
responses:
|
||||
'200':
|
||||
description: ApiResponse
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties: {data: {$ref: '#/components/schemas/ContainerSource'}}
|
||||
'401': {description: Invalid internal service identity}
|
||||
'404': {description: No active actual-sailed source}
|
||||
/internal/firstleg/appointment-work/facts:
|
||||
post:
|
||||
summary: Read pickup, direct unloading and empty-return facts; no writes
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema: {type: array, minItems: 1, maxItems: 200, items: {$ref: '#/components/schemas/FactRequest'}}
|
||||
responses:
|
||||
'200':
|
||||
description: Exactly one result per requested work key, never a partial success
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties: {data: {type: array, items: {$ref: '#/components/schemas/Fact'}}}
|
||||
'400': {description: Invalid type or duplicate identity}
|
||||
'401': {description: Invalid internal service identity}
|
||||
'409': {description: Direct requirement does not match physical cargo}
|
||||
components:
|
||||
securitySchemes:
|
||||
InternalServiceToken: {type: apiKey, in: header, name: X-Internal-Service-Token}
|
||||
schemas:
|
||||
Reference:
|
||||
type: object
|
||||
required: [kind, key, label]
|
||||
properties:
|
||||
kind: {type: string}
|
||||
key: {type: string, description: Internal association only}
|
||||
businessNo: {type: string, nullable: true}
|
||||
label: {type: string}
|
||||
SourceReference:
|
||||
type: object
|
||||
properties:
|
||||
customerId: {type: string}
|
||||
sourceKey: {type: string}
|
||||
destinationKey: {type: string, nullable: true}
|
||||
Requirement:
|
||||
type: object
|
||||
required: [requirementKey, references, replacesRequirementKeys, sources]
|
||||
properties:
|
||||
requirementKey: {type: string, description: 'Immutable INBOUND:<physical inbound id>'}
|
||||
batchKey: {type: string, nullable: true}
|
||||
batchNo: {type: string, nullable: true}
|
||||
destinationKind: {type: string, nullable: true}
|
||||
originName: {type: string, nullable: true}
|
||||
destinationName: {type: string, nullable: true}
|
||||
timezone: {type: string, nullable: true}
|
||||
references: {type: array, items: {$ref: '#/components/schemas/Reference'}}
|
||||
replacesRequirementKeys: {type: array, items: {type: string}}
|
||||
sources: {type: array, items: {$ref: '#/components/schemas/SourceReference'}, description: Empty until Lastleg resolves its canonical source; domestic ORDER references are not tail-leg ORDER keys}
|
||||
ContainerSource:
|
||||
type: object
|
||||
required: [containerBookingId, sourceVersion, establishedFlow, requirements]
|
||||
properties:
|
||||
containerBookingId: {type: string}
|
||||
containerNo: {type: string, nullable: true}
|
||||
departedAt: {type: string, format: date-time, nullable: true}
|
||||
departedLocalAt: {type: string, nullable: true, description: Exact actual departure only; source local timestamp without offset}
|
||||
sailedEvidenceLocalAt: {type: string, nullable: true, description: Actual departure-or-later node proving sailed eligibility; never presented as an actual departure time}
|
||||
sourceVersion: {type: string}
|
||||
establishedFlow: {type: string, enum: [UNKNOWN, DIRECT, TRANSFER]}
|
||||
timezone: {type: string, nullable: true}
|
||||
requirements: {type: array, items: {$ref: '#/components/schemas/Requirement'}}
|
||||
SourcePage:
|
||||
type: object
|
||||
required: [containers, complete, scopeVersion]
|
||||
properties:
|
||||
containers: {type: array, items: {$ref: '#/components/schemas/ContainerSource'}}
|
||||
nextCursor: {type: string, nullable: true}
|
||||
complete: {type: boolean}
|
||||
scopeVersion: {type: string}
|
||||
ContainerReference:
|
||||
type: object
|
||||
properties:
|
||||
containerBookingId: {type: string}
|
||||
bookingNo: {type: string, nullable: true}
|
||||
containerNo: {type: string, nullable: true}
|
||||
FactRequest:
|
||||
type: object
|
||||
required: [workItemKey, type, flow, containerBookingId]
|
||||
properties:
|
||||
workItemKey: {type: string}
|
||||
type: {type: string, enum: [PICKUP, EMPTY_RETURN, DELIVERY]}
|
||||
flow: {type: string, enum: [TRANSFER, DIRECT, UNKNOWN]}
|
||||
containerBookingId: {type: string}
|
||||
requirementKey: {type: string, nullable: true}
|
||||
batchKey: {type: string, nullable: true}
|
||||
tripKey: {type: string, nullable: true}
|
||||
batchKeys: {type: array, items: {type: string}}
|
||||
containers: {type: array, items: {$ref: '#/components/schemas/ContainerReference'}}
|
||||
sources: {type: array, items: {$ref: '#/components/schemas/SourceReference'}}
|
||||
Slot:
|
||||
type: object
|
||||
properties:
|
||||
date: {type: string, format: date}
|
||||
startTime: {type: string, nullable: true}
|
||||
endDate: {type: string, format: date, nullable: true}
|
||||
endTime: {type: string, nullable: true}
|
||||
timezone: {type: string, nullable: true, description: Null for unzoned original plans; source-local date is retained}
|
||||
Actual:
|
||||
type: object
|
||||
description: At least one time is required. Unzoned source facts contain localOccurredAt only.
|
||||
properties:
|
||||
occurredAt: {type: string, format: date-time, nullable: true}
|
||||
localOccurredAt: {type: string, nullable: true}
|
||||
timezone: {type: string, nullable: true}
|
||||
Fact:
|
||||
type: object
|
||||
required: [workItemKey, fingerprint, impactToken, planExists, planStatus, executionStatus, readPermissions, actionPermissions, blockReasons, missingFields, references]
|
||||
properties:
|
||||
workItemKey: {type: string}
|
||||
fingerprint: {type: string}
|
||||
impactToken: {type: string, description: First scheduling and first carrier assignment do not increase semantic impact}
|
||||
planExists: {type: boolean}
|
||||
planStatus: {type: string, enum: [UNPLANNED, TARGET_SET, AWAITING_REPLY, CONFIRMED, NOT_REQUIRED]}
|
||||
executionStatus: {type: string, enum: [PENDING, IN_PROGRESS, COMPLETED]}
|
||||
target: {$ref: '#/components/schemas/Slot'}
|
||||
confirmed: {$ref: '#/components/schemas/Slot'}
|
||||
actual: {$ref: '#/components/schemas/Actual'}
|
||||
timezone: {type: string, nullable: true}
|
||||
businessNo: {type: string, nullable: true}
|
||||
tripKey: {type: string, nullable: true}
|
||||
readPermissions: {type: array, items: {type: string}}
|
||||
actionPermissions: {type: array, items: {type: string}}
|
||||
blockReasons: {type: array, items: {type: string}}
|
||||
missingFields: {type: array, items: {type: string}}
|
||||
references: {type: array, items: {$ref: '#/components/schemas/Reference'}}
|
||||
responsibility:
|
||||
type: object
|
||||
nullable: true
|
||||
properties: {kind: {type: string}, id: {type: string, nullable: true}, name: {type: string}}
|
||||
action:
|
||||
type: object
|
||||
nullable: true
|
||||
properties:
|
||||
code: {type: string}
|
||||
label: {type: string}
|
||||
targetWorkItemKey: {type: string, nullable: true}
|
||||
target:
|
||||
type: object
|
||||
properties: {kind: {type: string, enum: [FIRSTLEG_CONTAINER]}, containerBookingId: {type: string}}
|
||||
@@ -0,0 +1,70 @@
|
||||
openapi: 3.0.3
|
||||
info: {title: Direct destination unloading evidence, version: 1.0.0}
|
||||
paths:
|
||||
/api/platform/v1/terminal-drayages/{containerBookingId}/direct-deliveries:
|
||||
get:
|
||||
summary: Physical loaded inbound sources and immutable direct unloading evidence
|
||||
x-permission: platform:firstleg:terminal-drayage:list
|
||||
parameters: [{$ref: '#/components/parameters/Container'}]
|
||||
responses:
|
||||
'200':
|
||||
description: ApiResponse
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties: {data: {type: array, items: {$ref: '#/components/schemas/View'}}}
|
||||
'403': {description: Requires platform identity and list permission}
|
||||
/api/platform/v1/terminal-drayages/{containerBookingId}/direct-deliveries/{inboundId}:
|
||||
post:
|
||||
summary: Record actual unloading for one complete physically loaded source
|
||||
description: Locks the physical container. Requires Lastleg's persisted confirmed DIRECT route, actual pickup, a real recipient and receipt reference, an unambiguous IANA local time, and no warehouse unloading or empty-return fact. Route source failure returns 503. Exact retries replay; changed retries conflict. Does not create warehouse stock. Every loaded source must complete before manual empty return. Unzoned pickup/return timestamps are not compared to destination wall-clock times; cross-location chronology cannot be proven without the source zone. Direct completion and its 72-hour reminder use the latest evidence Instant.
|
||||
x-permission: platform:firstleg:terminal-drayage:direct-unload:confirm
|
||||
parameters:
|
||||
- {$ref: '#/components/parameters/Container'}
|
||||
- {name: inboundId, in: path, required: true, schema: {type: integer, format: int64, minimum: 1}}
|
||||
- {name: Idempotency-Key, in: header, required: true, schema: {type: string, maxLength: 128}}
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
required: [expectedVersion, actualAt, timezone, receivedBy, evidenceNo]
|
||||
properties:
|
||||
expectedVersion: {type: integer, minimum: 0}
|
||||
actualAt: {type: string, description: Local YYYY-MM-DDTHH:mm:ss without offset}
|
||||
timezone: {type: string, description: Explicit destination IANA zone}
|
||||
receivedBy: {type: string, minLength: 1, maxLength: 128}
|
||||
evidenceNo: {type: string, minLength: 1, maxLength: 128}
|
||||
remark: {type: string, maxLength: 1000, nullable: true}
|
||||
responses:
|
||||
'200':
|
||||
description: ApiResponse
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties: {data: {$ref: '#/components/schemas/View'}}
|
||||
'400': {description: Invalid source, time or evidence}
|
||||
'403': {description: Requires platform identity and independent unloading permission}
|
||||
'409': {description: Invalid business prerequisites, version or replay content}
|
||||
'503': {description: Cannot verify authoritative route}
|
||||
components:
|
||||
parameters:
|
||||
Container: {name: containerBookingId, in: path, required: true, schema: {type: integer, format: int64, minimum: 1}}
|
||||
schemas:
|
||||
View:
|
||||
type: object
|
||||
properties:
|
||||
requirementKey: {type: string, description: 'INBOUND:<id>, association only, not a visible business number'}
|
||||
inboundCode: {type: string, nullable: true}
|
||||
orderNo: {type: string, nullable: true}
|
||||
destinationName: {type: string, nullable: true}
|
||||
packageCount: {type: integer}
|
||||
version: {type: integer, description: Zero until first actual unloading}
|
||||
actualAt: {type: string, nullable: true}
|
||||
timezone: {type: string, nullable: true}
|
||||
receivedBy: {type: string, nullable: true}
|
||||
evidenceNo: {type: string, nullable: true}
|
||||
remark: {type: string, nullable: true}
|
||||
@@ -4,7 +4,7 @@ paths:
|
||||
/api/platform/v1/terminal-drayages/calendar:
|
||||
get:
|
||||
summary: 美国目的仓相关的提柜预约日历
|
||||
description: 平台身份,同时要求预约列表和码头拖车列表权限。按原业务预约提柜日期查询,不将实际提柜或预计到仓替代预约时间。美国范围按启用目的仓国家确认,不代表已确认港口所在国。原字段未提供港口时区,timeBasis明确待核实,禁止当作UTC或套用收货仓时区。最多42天、读取1001条以检测截断;返回最多1000条中的美国范围记录,未知目的仓计入unclassifiedCount,不能将缺失数据当零。
|
||||
description: 平台身份,同时要求预约列表和码头拖车列表权限。已提柜按船节点权威实际提柜日期、尚未提柜按原预约日期查询,改期不移动已执行事实;原预约与实际时间分别返回,不以预计到仓替代。美国范围按启用目的仓国家确认,不代表已确认港口所在国。原字段未提供港口时区,timeBasis明确待核实,禁止当作UTC或套用收货仓时区。最多42天,原预约和船节点实际日期候选分别读取1001条检测截断,经各自所属服务读取并按柜去重;返回最多1000条美国范围记录,未知目的仓计入unclassifiedCount,不能将缺失数据当零。
|
||||
x-permissions: [platform:appointment:list, 'platform:firstleg:terminal-drayage:list']
|
||||
parameters:
|
||||
- {name: from, in: query, required: true, schema: {type: string, format: date}, description: 包含的原业务当地日期}
|
||||
@@ -38,7 +38,7 @@ paths:
|
||||
destinationPortName: {type: string, nullable: true}
|
||||
destinationWarehouseCode: {type: string, nullable: true}
|
||||
fleetSupplierName: {type: string, nullable: true}
|
||||
pickupAppointmentAt: {type: string, description: 原业务当地时间,无时区偏移,不是UTC时间点}
|
||||
pickupAppointmentAt: {type: string, nullable: true, description: 原业务当地预约时间,无时区偏移;仅有实际提柜事实时可为空}
|
||||
actualPickupAt: {type: string, nullable: true}
|
||||
'400': {description: 日期范围非法}
|
||||
'403': {description: 非平台身份或权限不足}
|
||||
@@ -82,8 +82,8 @@ paths:
|
||||
responses: {'200': {$ref: '#/components/responses/ViewResponse'}}
|
||||
/api/platform/v1/terminal-drayages/{containerBookingId}/empty-return:
|
||||
post:
|
||||
summary: 海外卸柜收货完成后确认实际还柜并推进EMPTY_RETURNED节点
|
||||
description: 以海外卸柜完成事实开放还柜;无需已有车队、预约或实际提柜记录。无拖车记录时提交 expectedVersion=0,由命令创建记录,不补造提柜时间。实际还柜时间不得早于卸柜完成时间及已有实际提柜时间。
|
||||
summary: 海外仓卸柜或全部直送目的仓实际卸货完成后确认实际还柜并推进EMPTY_RETURNED节点
|
||||
description: 海外仓卸柜或全部直送目的仓实际卸货完成后开放还柜。无需已有车队或预约;海外仓卸柜可无实际提柜记录。无拖车记录时提交 expectedVersion=0,不补造提柜时间。保留原海外仓卸柜及已有船节点实际提柜的当地时间先后校验;直送目的仓时区不能套用于缺少时区的还柜时间,不比较不同地点的当地钟面值。
|
||||
x-permission: platform:firstleg:terminal-drayage:empty-return:confirm
|
||||
parameters: [{$ref: '#/components/parameters/ContainerBookingId'}, {$ref: '#/components/parameters/IdempotencyKey'}]
|
||||
requestBody: {required: true, content: {application/json: {schema: {$ref: '#/components/schemas/ActualTimeRequest'}}}}
|
||||
@@ -173,7 +173,7 @@ components:
|
||||
required: [status, overdueHours, overdueDays]
|
||||
properties:
|
||||
status: {type: string, enum: [NOT_APPLICABLE, PENDING, OVERDUE, RESOLVED]}
|
||||
deadlineAt: {type: string, format: date-time, nullable: true, description: 卸柜完成时间加72小时}
|
||||
deadlineAt: {type: string, format: date-time, nullable: true, description: 原海外仓卸柜时间加72小时;直送按最晚真实卸货Instant加72小时后保留该目的地当地时间}
|
||||
overdueHours: {type: integer, format: int64, minimum: 0}
|
||||
overdueDays: {type: integer, format: int64, minimum: 0}
|
||||
TerminalDrayageView:
|
||||
@@ -189,7 +189,7 @@ components:
|
||||
fleetSupplierName: {type: string, nullable: true}
|
||||
pickupAppointmentAt: {type: string, format: date-time, nullable: true}
|
||||
actualPickupAt: {type: string, format: date-time, nullable: true, description: 优先使用实际船节点提柜时间;缺失时在海外PDA确认整柜拆柜完成后使用卸柜完成时间}
|
||||
estimatedEmptyReturnAt: {type: string, format: date-time, nullable: true, description: 服务端权威值,卸柜完成时间加72小时}
|
||||
estimatedEmptyReturnAt: {type: string, format: date-time, nullable: true, description: 服务端权威值;直送72小时按绝对时长计算,时区不明的原海外仓事实保留原规则}
|
||||
actualEmptyReturnAt: {type: string, format: date-time, nullable: true}
|
||||
unloadingCompletedAt: {type: string, format: date-time, nullable: true}
|
||||
emptyReturnWarning: {$ref: '#/components/schemas/Warning'}
|
||||
|
||||
Reference in New Issue
Block a user