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'}
|
||||
|
||||
+32
@@ -0,0 +1,32 @@
|
||||
package com.baiyue.firstleg.portschedule.mapper;
|
||||
|
||||
import com.baomidou.mybatisplus.core.MybatisConfiguration;
|
||||
import com.baomidou.mybatisplus.core.MybatisSqlSessionFactoryBuilder;
|
||||
import org.apache.ibatis.mapping.Environment;
|
||||
import org.apache.ibatis.transaction.jdbc.JdbcTransactionFactory;
|
||||
import org.h2.jdbcx.JdbcDataSource;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import java.util.UUID;
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
class AppointmentWorkSourceMapperTest {
|
||||
@Test void actualSailedScopeHasNoWarehouseDestinationOrOpenQueryFilter() throws Exception {
|
||||
var ds=new JdbcDataSource();ds.setURL("jdbc:h2:mem:"+UUID.randomUUID()+";MODE=MySQL;DB_CLOSE_DELAY=-1");
|
||||
try(var c=ds.getConnection();var s=c.createStatement()) {
|
||||
s.execute("CREATE TABLE fl_container_booking(id BIGINT,cabinet_no VARCHAR,container_no VARCHAR,departure_port_name VARCHAR,destination_warehouse_name VARCHAR,version INT,record_status VARCHAR)");
|
||||
s.execute("CREATE TABLE fl_carrier_schedule_node_fact(id BIGINT,container_booking_id BIGINT,standard_node VARCHAR,actual_at TIMESTAMP)");
|
||||
s.execute("CREATE TABLE fl_direct_delivery_unloading(container_booking_id BIGINT)");
|
||||
s.execute("INSERT INTO fl_container_booking VALUES(1,'D1',NULL,'上海',NULL,1,'ACTIVE'),(2,'D2','C2','上海','私人仓',1,'ACTIVE'),(3,'D3','C3','上海',NULL,1,'ACTIVE'),(4,'D4','C4','上海',NULL,1,'MERGED')");
|
||||
s.execute("INSERT INTO fl_carrier_schedule_node_fact VALUES(10,1,'DEPARTED_ORIGIN_PORT','2026-10-01'),(20,2,'EMPTY_RETURNED','2026-10-03'),(30,3,'LOADED_ON_VESSEL','2026-10-04'),(40,4,'DEPARTED_ORIGIN_PORT','2026-10-01'),(50,3,'DEPARTED_ORIGIN_PORT',NULL)");
|
||||
s.execute("INSERT INTO fl_direct_delivery_unloading VALUES(2)");
|
||||
}
|
||||
var config=new MybatisConfiguration();config.setMapUnderscoreToCamelCase(true);
|
||||
config.setEnvironment(new Environment("test",new JdbcTransactionFactory(),ds));config.addMapper(AppointmentWorkSourceMapper.class);
|
||||
try(var session=new MybatisSqlSessionFactoryBuilder().build(config).openSession()) {
|
||||
var rows=session.getMapper(AppointmentWorkSourceMapper.class).sources();
|
||||
assertEquals(2,rows.size());assertEquals(1,rows.get(0).id());assertEquals(2,rows.get(1).id());
|
||||
assertNull(rows.get(0).destinationWarehouseName());assertEquals(1,rows.get(1).deliveryVersion());
|
||||
assertNull(rows.get(1).departedLocalAt());assertNotNull(rows.get(1).sailedEvidenceLocalAt());
|
||||
}
|
||||
}
|
||||
}
|
||||
+34
@@ -0,0 +1,34 @@
|
||||
package com.baiyue.firstleg.portschedule.mapper;
|
||||
|
||||
import com.baomidou.mybatisplus.core.MybatisConfiguration;
|
||||
import com.baomidou.mybatisplus.core.MybatisSqlSessionFactoryBuilder;
|
||||
import org.apache.ibatis.mapping.Environment;
|
||||
import org.apache.ibatis.transaction.jdbc.JdbcTransactionFactory;
|
||||
import org.h2.jdbcx.JdbcDataSource;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
class PickupActualCalendarMapperTest {
|
||||
@Test void selectsCanonicalActualPickupOnceInAHalfOpenRangeAndDetectsTruncation() throws Exception {
|
||||
var ds=new JdbcDataSource();ds.setURL("jdbc:h2:mem:"+UUID.randomUUID()+";MODE=MySQL;DB_CLOSE_DELAY=-1");
|
||||
try(var connection=ds.getConnection();var sql=connection.createStatement()) {
|
||||
sql.execute("CREATE TABLE fl_carrier_schedule_node_fact(container_booking_id BIGINT,standard_node VARCHAR,actual_at TIMESTAMP)");
|
||||
sql.execute("INSERT INTO fl_carrier_schedule_node_fact VALUES(1,'PICKED_UP','2026-09-30 23:59:59'),(2,'PICKED_UP','2026-10-01 00:00:00'),(3,'PICKED_UP','2026-10-31 23:59:59'),(4,'PICKED_UP','2026-11-01 00:00:00'),(5,'ARRIVED_DESTINATION_PORT','2026-10-10'),(6,'PICKED_UP',NULL)");
|
||||
sql.execute("INSERT INTO fl_carrier_schedule_node_fact VALUES(2,'PICKED_UP','2026-10-01 00:00:00'),(4,'PICKED_UP','2026-10-10')");
|
||||
}
|
||||
var config=new MybatisConfiguration();config.setEnvironment(new Environment("test",new JdbcTransactionFactory(),ds));config.addMapper(CarrierScheduleQueryMapper.class);
|
||||
try(var session=new MybatisSqlSessionFactoryBuilder().build(config).openSession()) {
|
||||
var mapper=session.getMapper(CarrierScheduleQueryMapper.class);
|
||||
var from=LocalDateTime.parse("2026-10-01T00:00:00");var to=LocalDateTime.parse("2026-11-01T00:00:00");
|
||||
assertEquals(List.of(2L,3L),mapper.selectPickupCalendarContainers(from,to));
|
||||
try(var connection=ds.getConnection();var sql=connection.prepareStatement("INSERT INTO fl_carrier_schedule_node_fact VALUES(?,'PICKED_UP','2026-10-15')")) {
|
||||
for(long id=10;id<1012;id++){sql.setLong(1,id);sql.addBatch();}sql.executeBatch();
|
||||
}
|
||||
session.clearCache();
|
||||
assertEquals(1001,mapper.selectPickupCalendarContainers(from,to).size());
|
||||
}
|
||||
}
|
||||
}
|
||||
+45
@@ -0,0 +1,45 @@
|
||||
package com.baiyue.firstleg.portschedule.service;
|
||||
|
||||
import com.baiyue.firstleg.portschedule.mapper.AppointmentWorkSourceMapper;
|
||||
import com.baiyue.firstleg.portschedule.controller.InternalAppointmentWorkSourceController;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
import static org.mockito.Mockito.*;
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
class AppointmentWorkSourceServiceTest {
|
||||
private final AppointmentWorkSourceMapper mapper=mock(AppointmentWorkSourceMapper.class);
|
||||
private final AppointmentWorkSourceService.OriginalFacts facts=mock(AppointmentWorkSourceService.OriginalFacts.class);
|
||||
private final AppointmentWorkSourceService service=new AppointmentWorkSourceService(mapper,facts,new ObjectMapper().findAndRegisterModules());
|
||||
private AppointmentWorkSourceMapper.Source source(long id,int version) {
|
||||
return new AppointmentWorkSourceMapper.Source(id,"D000"+id,null,"上海",null,LocalDateTime.parse("2026-10-01T12:00:00"),LocalDateTime.parse("2026-10-01T12:00:00"),version,10L,0L);
|
||||
}
|
||||
@Test void completeCursorManifestIncludesUnboundSourcesAndRejectsScopeDrift() {
|
||||
when(mapper.sources()).thenReturn(List.of(source(1,1),source(2,1)));
|
||||
when(mapper.cargo(anyLong())).thenReturn(List.of()); when(facts.establishedFlow(anyLong())).thenReturn("UNKNOWN");
|
||||
var first=service.discover(null,1);
|
||||
assertFalse(first.complete()); assertNotNull(first.nextCursor());
|
||||
assertNull(first.containers().get(0).departedAt());
|
||||
assertEquals(LocalDateTime.parse("2026-10-01T12:00:00"),first.containers().get(0).departedLocalAt());
|
||||
var next=service.discover(first.nextCursor(),1);
|
||||
assertTrue(next.complete());assertEquals("2",next.containers().get(0).containerBookingId());
|
||||
when(mapper.sources()).thenReturn(List.of(source(1,2),source(2,1)));
|
||||
assertEquals(409,assertThrows(ResponseStatusException.class,()->service.discover(first.nextCursor(),1)).getStatusCode().value());
|
||||
}
|
||||
@Test void sourceKeepsCustomerAndPhysicalInboundLineageWithoutDisplayingIdsAsNumbers() {
|
||||
when(mapper.sources()).thenReturn(List.of(source(1,1)));
|
||||
when(mapper.cargo(1)).thenReturn(List.of(new AppointmentWorkSourceMapper.Cargo(8,9,10,"ORDER-10","IN-8","PRIVATE","P","客户仓",12)));
|
||||
when(facts.establishedFlow(1)).thenReturn("DIRECT");
|
||||
var source=service.container(1);var requirement=source.requirements().get(0);
|
||||
assertEquals("INBOUND:8",requirement.requirementKey());assertEquals("PRIVATE",requirement.destinationKind());
|
||||
assertTrue(requirement.references().stream().anyMatch(r->r.kind().equals("CUSTOMER") && r.key().equals("9") && r.businessNo()==null));
|
||||
}
|
||||
@Test void internalControllerFailsClosedBeforeReadingSources() {
|
||||
var guarded=new InternalAppointmentWorkSourceController(service,"secret");
|
||||
assertEquals(401,assertThrows(ResponseStatusException.class,()->guarded.discover(null,null,20)).getStatusCode().value());
|
||||
verifyNoInteractions(mapper);
|
||||
}
|
||||
}
|
||||
+14
@@ -53,6 +53,7 @@ class CarrierScheduleQueryServiceTest {
|
||||
@Mock private CarrierScheduleQueryRepository repository;
|
||||
@Mock private PortScheduleRepository portSchedules;
|
||||
@Mock private CarrierScheduleNodeOutboxService nodeOutbox;
|
||||
@Mock private EmptyReturnPrerequisite emptyReturnPrerequisite;
|
||||
@InjectMocks private CarrierScheduleQueryService service;
|
||||
private CarrierScheduleAdapterRegistry adapters;
|
||||
|
||||
@@ -84,6 +85,19 @@ class CarrierScheduleQueryServiceTest {
|
||||
verify(repository).containerActualFacts(List.of(20L, 21L));
|
||||
}
|
||||
|
||||
@Test
|
||||
void containerManualEmptyReturnCannotBypassRealUnloadingEvidence() {
|
||||
org.mockito.Mockito.doThrow(new BizException(com.baiyue.common.core.api.ResultCode.CONFLICT,"未卸货"))
|
||||
.when(emptyReturnPrerequisite).requireUnloaded(20L,NOW);
|
||||
assertThatThrownBy(()->service.addContainerManualNode(20L,7L,"admin","empty-no-unload",
|
||||
new com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.ContainerManualNodeRequest(
|
||||
"EMPTY_RETURNED",NOW,null,NOW,null,null,"还柜")))
|
||||
.isInstanceOf(BizException.class).hasMessage("未卸货");
|
||||
verifyNoNodePublication();
|
||||
}
|
||||
|
||||
private void verifyNoNodePublication() { org.mockito.Mockito.verifyNoInteractions(nodeOutbox); }
|
||||
|
||||
@Test
|
||||
void rejectsInvalidActualFactBatchBeforeRepositoryAccess() {
|
||||
assertThatThrownBy(() -> service.containerActualFacts(null))
|
||||
|
||||
+1
-1
@@ -80,7 +80,7 @@ class Phase3FinalUnifiedSqlContractTest {
|
||||
@Test
|
||||
void initializationPassIsUniqueAndFinal() throws Exception {
|
||||
String sql = sql();
|
||||
int expectedTables = 226;
|
||||
int expectedTables = 227;
|
||||
long actualTables = java.util.regex.Pattern.compile("(?m)^CREATE TABLE\\s+")
|
||||
.matcher(sql).results().count();
|
||||
assertThat(actualTables).isEqualTo(expectedTables);
|
||||
|
||||
+36
@@ -0,0 +1,36 @@
|
||||
package com.baiyue.firstleg.terminaldrayage.mapper;
|
||||
|
||||
import com.baomidou.mybatisplus.core.MybatisConfiguration;
|
||||
import com.baomidou.mybatisplus.core.MybatisSqlSessionFactoryBuilder;
|
||||
import org.apache.ibatis.mapping.Environment;
|
||||
import org.apache.ibatis.transaction.jdbc.JdbcTransactionFactory;
|
||||
import org.h2.jdbcx.JdbcDataSource;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.UUID;
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
class DirectDeliveryMapperTest {
|
||||
@Test void physicalSourceAggregationAndImmutableEvidenceSurviveActualPersistence() throws Exception {
|
||||
var ds=new JdbcDataSource();ds.setURL("jdbc:h2:mem:"+UUID.randomUUID()+";MODE=MySQL;DB_CLOSE_DELAY=-1");
|
||||
try(var c=ds.getConnection();var s=c.createStatement()) {
|
||||
s.execute("CREATE TABLE fl_container_booking(id BIGINT PRIMARY KEY,record_status VARCHAR,unloading_completed_at TIMESTAMP)");
|
||||
s.execute("CREATE TABLE fl_loading_plan(id BIGINT,container_booking_id BIGINT,plan_status VARCHAR)");
|
||||
s.execute("CREATE TABLE fl_loading_plan_item(loading_plan_id BIGINT,inbound_id BIGINT,inbound_code VARCHAR,order_no VARCHAR,destination_warehouse_name VARCHAR,package_count INT,deleted INT)");
|
||||
s.execute("CREATE TABLE fl_direct_delivery_unloading(container_booking_id BIGINT,inbound_id BIGINT,actual_at TIMESTAMP,timezone VARCHAR,received_by VARCHAR,evidence_no VARCHAR,remark VARCHAR,version INT,idempotency_key VARCHAR,request_hash VARCHAR,created_by BIGINT,created_by_name VARCHAR,PRIMARY KEY(container_booking_id,inbound_id),UNIQUE(container_booking_id,idempotency_key))");
|
||||
s.execute("INSERT INTO fl_container_booking VALUES(1,'ACTIVE',NULL)");s.execute("INSERT INTO fl_loading_plan VALUES(10,1,'COMPLETED')");
|
||||
s.execute("INSERT INTO fl_loading_plan_item VALUES(10,20,'I20','O20','D',3,0),(10,20,'I20','O20','D',4,0),(10,21,'I21','O21','D',5,0),(10,22,'I22','O22','D',100,1)");
|
||||
}
|
||||
var config=new MybatisConfiguration();config.setMapUnderscoreToCamelCase(true);config.setEnvironment(new Environment("test",new JdbcTransactionFactory(),ds));config.addMapper(DirectDeliveryMapper.class);
|
||||
try(var session=new MybatisSqlSessionFactoryBuilder().build(config).openSession()) {
|
||||
var mapper=session.getMapper(DirectDeliveryMapper.class);assertEquals(1,mapper.lockContainer(1).id());
|
||||
assertEquals(2,mapper.deliveries(1).size());assertEquals(7,mapper.deliveries(1).get(0).packageCount());
|
||||
var at=LocalDateTime.parse("2026-01-01T10:30:00");
|
||||
mapper.insert(1,20,at,"America/Los_Angeles","签收人","POD20",null,"key","hash",7,"admin");
|
||||
assertEquals(1,mapper.evidenceCount(1));assertEquals(1,mapper.keyCount(1,"key"));
|
||||
assertEquals(at,mapper.deliveries(1).get(0).actualAt());assertNull(mapper.deliveries(1).get(1).actualAt());
|
||||
assertThrows(RuntimeException.class,()->mapper.insert(1,20,at,"America/Los_Angeles","签收人","POD20",null,"another","hash",7,"admin"));
|
||||
session.rollback();
|
||||
}
|
||||
}
|
||||
}
|
||||
+30
@@ -0,0 +1,30 @@
|
||||
package com.baiyue.firstleg.terminaldrayage.mapper;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import java.nio.file.*;
|
||||
import java.util.regex.Pattern;
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
class DirectDeliverySchemaContractTest {
|
||||
@Test void immutableDirectEvidenceAndIndependentWritePermissionsReachDeploymentBaseline() throws Exception {
|
||||
String source=Files.readString(Path.of("src/main/resources/db/phase3-direct-delivery-schema.sql"));
|
||||
String release=Files.readString(Path.of("../deploy/sql/头程数据库统一增量-MySQL8.sql"));
|
||||
assertEquals(definition(source),definition(release));
|
||||
assertTrue(source.contains("PRIMARY KEY (container_booking_id,inbound_id)"));
|
||||
assertTrue(source.contains("UNIQUE KEY uk_direct_unloading_command"));
|
||||
String permissions=Files.readString(Path.of("../iam/src/main/resources/db/appointment-work-permissions.sql"));
|
||||
for(String code:java.util.List.of("platform:appointment:manage-work","platform:firstleg:terminal-drayage:direct-unload:confirm")) {
|
||||
assertTrue(release.contains("'"+code+"'"));assertTrue(permissions.contains("'"+code+"'"));
|
||||
}
|
||||
assertTrue(release.contains("VALUES (4000001,5107283,0,CURRENT_TIMESTAMP(3))"));
|
||||
assertTrue(release.contains("VALUES (4000001,5102213,0,CURRENT_TIMESTAMP(3))"));
|
||||
assertTrue(permissions.indexOf("CALL check_appointment_work_permission_identity")<permissions.indexOf("INSERT INTO iam_permission"));
|
||||
assertTrue(permissions.contains("platform:firstleg:terminal-drayage:direct-unload:confirm"));
|
||||
assertTrue(permissions.contains("platform:appointment:manage-work"));
|
||||
}
|
||||
private String definition(String sql) {
|
||||
var match=Pattern.compile("CREATE TABLE(?: IF NOT EXISTS)? fl_direct_delivery_unloading \\([\\s\\S]*?\\) ENGINE=InnoDB[^\\r\\n]*;").matcher(sql);
|
||||
assertTrue(match.find());String result=match.group().replace("CREATE TABLE IF NOT EXISTS","CREATE TABLE").replace("\r\n","\n");
|
||||
assertFalse(match.find());return result;
|
||||
}
|
||||
}
|
||||
+9
@@ -29,6 +29,15 @@ class TerminalDrayageCalendarMapperTest {
|
||||
var rows=session.getMapper(TerminalDrayageMapper.class).calendar(LocalDateTime.parse("2026-10-01T00:00:00"),LocalDateTime.parse("2026-11-01T00:00:00"),1001);
|
||||
assertEquals(List.of(1L,2L),rows.stream().map(r->r.containerBookingId()).toList());assertNull(rows.get(0).actualPickupAt());
|
||||
assertEquals("D0001", rows.get(0).cabinetNo());
|
||||
try(var c=ds.getConnection();var s=c.createStatement()) {
|
||||
s.execute("UPDATE fl_terminal_drayage SET pickup_appointment_at='2026-12-01' WHERE container_booking_id=3");
|
||||
s.execute("DELETE FROM fl_terminal_drayage WHERE container_booking_id=2");
|
||||
}
|
||||
session.clearCache();
|
||||
rows=session.getMapper(TerminalDrayageMapper.class).calendarByContainers(List.of(1L,2L,3L,4L,5L));
|
||||
assertEquals(List.of(1L,2L,3L),rows.stream().map(r->r.containerBookingId()).toList());
|
||||
assertNull(rows.get(1).pickupAppointmentAt());
|
||||
assertEquals(LocalDateTime.parse("2026-12-01T00:00:00"),rows.get(2).pickupAppointmentAt());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+55
@@ -0,0 +1,55 @@
|
||||
package com.baiyue.firstleg.terminaldrayage.service;
|
||||
|
||||
import com.baiyue.firstleg.portschedule.dto.AppointmentWorkSourceDtos.*;
|
||||
import com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.ContainerActualFacts;
|
||||
import com.baiyue.firstleg.portschedule.service.CarrierScheduleQueryService;
|
||||
import com.baiyue.firstleg.terminaldrayage.mapper.*;
|
||||
import com.baiyue.firstleg.terminaldrayage.entity.TerminalDrayageEntity;
|
||||
import com.baiyue.firstleg.terminaldrayage.dto.TerminalDrayageDtos.History;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
import static org.mockito.Mockito.*;
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
class AppointmentWorkTerminalFactsTest {
|
||||
private final TerminalDrayageMapper terminal=mock(TerminalDrayageMapper.class);
|
||||
private final DirectDeliveryMapper direct=mock(DirectDeliveryMapper.class);
|
||||
private final CarrierScheduleQueryService nodes=mock(CarrierScheduleQueryService.class);
|
||||
private final UnloadingEvidenceGuard guard=mock(UnloadingEvidenceGuard.class);
|
||||
private final AppointmentWorkTerminalFacts adapter=new AppointmentWorkTerminalFacts(terminal,direct,nodes,guard,new ObjectMapper().findAndRegisterModules());
|
||||
private final LocalDateTime at=LocalDateTime.parse("2026-01-01T12:30:00");
|
||||
private ContainerSource source(){return new ContainerSource("1","C1",null,at,at,"v","UNKNOWN",null,List.of());}
|
||||
private FactRequest request(String type){return new FactRequest("W1",type,"DIRECT","1",null,null,null,List.of(),List.of(),List.of());}
|
||||
private History history(String action){return new History(action,"A","B",1,2,null,7L,"admin",at);}
|
||||
@Test void initialPickupPlanRetainsLocalDateAndDoesNotInventZoneOrReview() {
|
||||
var plan=new TerminalDrayageEntity();plan.setPickupAppointmentAt(at);plan.setFleetSupplierId(4L);plan.setFleetSupplierName("车队");
|
||||
when(terminal.find(1)).thenReturn(plan);
|
||||
when(terminal.histories(1)).thenReturn(List.of(history("SELECT_FLEET"),history("SCHEDULE_PICKUP")));
|
||||
var fact=adapter.read(request("PICKUP"),source());
|
||||
assertEquals("0:0",fact.impactToken());assertEquals(at.toLocalDate(),fact.confirmed().date());
|
||||
assertNull(fact.timezone());assertTrue(fact.missingFields().contains("SOURCE_TIMEZONE"));
|
||||
when(terminal.histories(1)).thenReturn(List.of(history("SELECT_FLEET"),history("SCHEDULE_PICKUP"),history("SCHEDULE_PICKUP")));
|
||||
assertEquals("1:0",adapter.read(request("PICKUP"),source()).impactToken());
|
||||
}
|
||||
@Test void emptyReturnActualIsCompleteEvenWhenOriginalTimezoneIsUnknownAndDoesNotCopyPickupResponsibility() {
|
||||
var plan=new TerminalDrayageEntity();plan.setFleetSupplierId(4L);plan.setFleetSupplierName("提柜承运人");
|
||||
when(terminal.find(1)).thenReturn(plan);
|
||||
when(nodes.containerActualFacts(List.of(1L))).thenReturn(List.of(new ContainerActualFacts(1L,2L,3,"STOPPED","EMPTY_RETURNED",at,null,null,null,null,at.minusDays(1),at)));
|
||||
var fact=adapter.read(request("EMPTY_RETURN"),source());
|
||||
assertEquals("COMPLETED",fact.executionStatus());assertNull(fact.actual().occurredAt());assertEquals(at,fact.actual().localOccurredAt());
|
||||
assertNull(fact.responsibility());assertNull(fact.action());assertNull(fact.businessNo());
|
||||
}
|
||||
@Test void directDeliveryReferencesOnlyItsOwnPhysicalSourceAndCustomer() {
|
||||
var own=List.of(new Reference("CUSTOMER","7",null,"客户A"),new Reference("INBOUND","2","IN2","入仓单"));
|
||||
var other=List.of(new Reference("CUSTOMER","8",null,"客户B"),new Reference("INBOUND","3","IN3","入仓单"));
|
||||
var source=new ContainerSource("1","C1",null,at,at,"v","DIRECT",null,List.of(
|
||||
new Requirement("INBOUND:2",null,null,"PRIVATE","港","A",null,own,List.of(),List.of()),
|
||||
new Requirement("INBOUND:3",null,null,"PRIVATE","港","B",null,other,List.of(),List.of())));
|
||||
when(direct.deliveries(1)).thenReturn(List.of(new DirectDeliveryMapper.Delivery(2,"IN2","O2","A",4,1,at,"America/Los_Angeles","a","P2",null,"k","h")));
|
||||
var request=new FactRequest("W1","DELIVERY","DIRECT","1","INBOUND:2",null,null,List.of(),List.of(),List.of());
|
||||
var fact=adapter.read(request,source);
|
||||
assertEquals(own,fact.references());assertEquals("IN2",fact.businessNo());assertEquals("COMPLETED",fact.executionStatus());
|
||||
}
|
||||
}
|
||||
+2
-2
@@ -132,7 +132,7 @@ class ContainerWorkbenchServiceTest {
|
||||
var mapper = mock(com.baiyue.firstleg.terminaldrayage.mapper.TerminalDrayageMapper.class);
|
||||
var json = new com.fasterxml.jackson.databind.ObjectMapper().findAndRegisterModules();
|
||||
var realDrayages = new TerminalDrayageService(mapper,
|
||||
mock(com.baiyue.firstleg.service.WarehouseSupplierSnapshotService.class), carrierSchedules, json, mock(TerminalDrayageMailService.class));
|
||||
mock(com.baiyue.firstleg.service.WarehouseSupplierSnapshotService.class), carrierSchedules, json, mock(TerminalDrayageMailService.class), mock(UnloadingEvidenceGuard.class));
|
||||
ReflectionTestUtils.setField(service, "terminalDrayages", realDrayages);
|
||||
ContainerBookingEntity container = container();
|
||||
container.setUnloadingCompletedAt(LocalDateTime.of(2026, 9, 5, 8, 0));
|
||||
@@ -176,7 +176,7 @@ class ContainerWorkbenchServiceTest {
|
||||
var mapper = mock(com.baiyue.firstleg.terminaldrayage.mapper.TerminalDrayageMapper.class);
|
||||
var realDrayages = new TerminalDrayageService(mapper,
|
||||
mock(com.baiyue.firstleg.service.WarehouseSupplierSnapshotService.class), carrierSchedules,
|
||||
new com.fasterxml.jackson.databind.ObjectMapper().findAndRegisterModules(), mock(TerminalDrayageMailService.class));
|
||||
new com.fasterxml.jackson.databind.ObjectMapper().findAndRegisterModules(), mock(TerminalDrayageMailService.class), mock(UnloadingEvidenceGuard.class));
|
||||
ReflectionTestUtils.setField(service, "terminalDrayages", realDrayages);
|
||||
ContainerBookingEntity container = container();
|
||||
LocalDateTime unloadedAt = LocalDateTime.of(2026, 9, 5, 8, 0);
|
||||
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
package com.baiyue.firstleg.terminaldrayage.service;
|
||||
|
||||
import com.baiyue.firstleg.client.AppointmentWorkRouteClient;
|
||||
import com.baiyue.common.core.api.ApiResponse;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
import static org.mockito.Mockito.*;
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
class DirectDeliveryRouteGuardTest {
|
||||
@Test void onlyConfirmedDirectRouteCanExecuteAndRemoteFailureIsNotUnknownSuccess() {
|
||||
var client=mock(AppointmentWorkRouteClient.class);var guard=new DirectDeliveryRouteGuard(client,"token");
|
||||
for(String flow:new String[]{"UNKNOWN","TRANSFER"}) {
|
||||
when(client.route("token",1)).thenReturn(ApiResponse.success(new AppointmentWorkRouteClient.Route(flow)));
|
||||
assertEquals(409,assertThrows(ResponseStatusException.class,()->guard.requireDirect(1)).getStatusCode().value());
|
||||
}
|
||||
when(client.route("token",1)).thenReturn(ApiResponse.success(new AppointmentWorkRouteClient.Route("DIRECT")));
|
||||
assertDoesNotThrow(()->guard.requireDirect(1));
|
||||
when(client.route("token",1)).thenThrow(new IllegalStateException("offline"));
|
||||
assertEquals(503,assertThrows(ResponseStatusException.class,()->guard.requireDirect(1)).getStatusCode().value());
|
||||
}
|
||||
}
|
||||
+50
@@ -0,0 +1,50 @@
|
||||
package com.baiyue.firstleg.terminaldrayage.service;
|
||||
|
||||
import com.baiyue.firstleg.terminaldrayage.mapper.DirectDeliveryMapper;
|
||||
import com.baiyue.firstleg.portschedule.service.CarrierScheduleQueryService;
|
||||
import com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.ContainerActualFacts;
|
||||
import com.baiyue.common.core.exception.BizException;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
import static org.mockito.Mockito.*;
|
||||
import static org.mockito.ArgumentMatchers.*;
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
class DirectDeliveryServiceTest {
|
||||
private final DirectDeliveryMapper mapper=mock(DirectDeliveryMapper.class);
|
||||
private final CarrierScheduleQueryService nodes=mock(CarrierScheduleQueryService.class);
|
||||
private final DirectDeliveryService service=new DirectDeliveryService(mapper,nodes,new ObjectMapper().findAndRegisterModules(),mock(DirectDeliveryRouteGuard.class));
|
||||
private final LocalDateTime at=LocalDateTime.parse("2026-01-03T12:00:00");
|
||||
private DirectDeliveryMapper.Delivery pending(){return new DirectDeliveryMapper.Delivery(2,"IN2","O2","客户仓",4,0,null,null,null,null,null,null,null);}
|
||||
private DirectDeliveryService.Request request(){return new DirectDeliveryService.Request(0,at,"America/Los_Angeles","收货人","POD-2","直送签收");}
|
||||
@Test void recordsOnlyActualLoadedSourceAfterPickupAndReplaysWithoutDuplicateWrite() {
|
||||
when(mapper.lockContainer(1)).thenReturn(new DirectDeliveryMapper.Container(1,"ACTIVE",null));
|
||||
when(mapper.deliveries(1)).thenReturn(List.of(pending()));
|
||||
// Unknown pickup zone: a numerically later source-local clock is not evidence
|
||||
// that the explicitly Los Angeles destination actual occurred first.
|
||||
when(nodes.containerActualFacts(List.of(1L))).thenReturn(List.of(new ContainerActualFacts(1L,2L,3,"READY","PICKED_UP",at.plusHours(1),null,null,null,null,at.plusHours(1),null)));
|
||||
doAnswer(inv->{
|
||||
when(mapper.deliveries(1)).thenReturn(List.of(new DirectDeliveryMapper.Delivery(2,"IN2","O2","客户仓",4,1,at,"America/Los_Angeles","收货人","POD-2","直送签收","k",inv.getArgument(8))));
|
||||
return 1;
|
||||
}).when(mapper).insert(anyLong(),anyLong(),any(),anyString(),anyString(),anyString(),any(),anyString(),anyString(),anyLong(),anyString());
|
||||
assertEquals(1,service.record(1,2,7,"admin","k",request()).version());
|
||||
assertEquals(1,service.record(1,2,7,"admin","k",request()).version());
|
||||
verify(mapper,times(1)).insert(eq(1L),eq(2L),eq(at),eq("America/Los_Angeles"),eq("收货人"),eq("POD-2"),any(),eq("k"),anyString(),eq(7L),eq("admin"));
|
||||
}
|
||||
@Test void refusesForeignSourceAndWarehouseReceiptWithoutWriting() {
|
||||
when(mapper.lockContainer(1)).thenReturn(new DirectDeliveryMapper.Container(1,"ACTIVE",at.minusDays(1)));
|
||||
when(mapper.deliveries(1)).thenReturn(List.of(pending()));
|
||||
assertThrows(BizException.class,()->service.record(1,99,7,"a","k",request()));
|
||||
assertThrows(BizException.class,()->service.record(1,2,7,"a","k",request()));
|
||||
verifyNoInteractions(nodes);
|
||||
verify(mapper,never()).insert(anyLong(),anyLong(),any(),anyString(),anyString(),anyString(),any(),anyString(),anyString(),anyLong(),anyString());
|
||||
}
|
||||
@Test void refusesAmbiguousLocalTimeInsteadOfGuessingAnOffset() {
|
||||
when(mapper.lockContainer(1)).thenReturn(new DirectDeliveryMapper.Container(1,"ACTIVE",null));
|
||||
when(mapper.deliveries(1)).thenReturn(List.of(pending()));
|
||||
var ambiguous=new DirectDeliveryService.Request(0,LocalDateTime.parse("2025-11-02T01:30:00"),"America/Los_Angeles","收货人","POD",null);
|
||||
assertThrows(BizException.class,()->service.record(1,2,7,"a","k",ambiguous));verifyNoInteractions(nodes);
|
||||
}
|
||||
}
|
||||
+28
-1
@@ -46,8 +46,35 @@ class TerminalDrayageCalendarServiceTest {
|
||||
when(mapper.calendar(any(),any(),anyInt())).thenReturn(List.of());assertTrue(service.calendar(from,to).records().isEmpty());verifyNoInteractions(warehouses);
|
||||
}
|
||||
@Test void reportsTruncationInsteadOfClaimingCompleteResults() {
|
||||
when(mapper.calendar(any(),any(),anyInt())).thenReturn(Collections.nCopies(1001,row(1,"US-W")));
|
||||
when(mapper.calendar(any(),any(),anyInt())).thenReturn(java.util.stream.LongStream.rangeClosed(1,1001).mapToObj(id->row(id,"US-W")).toList());
|
||||
when(warehouses.catalog("test")).thenReturn(ApiResponse.success(List.of(new WarehouseMasterDataClient.CatalogWarehouse(1L,"US-W","US","OVERSEAS","US"))));
|
||||
var result=service.calendar(from,to);assertTrue(result.truncated());assertEquals(1000,result.records().size());
|
||||
}
|
||||
@Test void mergesActualMonthWithoutMovingExecutedWorkWhenPlansChangeOrDuplicatingTheContainer() {
|
||||
when(mapper.calendar(any(),any(),anyInt())).thenReturn(List.of(row(1,"US-W"),row(2,"US-W")));
|
||||
when(shipping.pickupCalendarContainers(any(),any())).thenReturn(List.of(2L,3L));
|
||||
var movedPlan=new PickupCalendarEntry(3L,"D0003","CONT3","Port","US-W","Fleet",to.plusDays(20).atStartOfDay(),null);
|
||||
when(mapper.calendarByContainers(List.of(2L,3L))).thenReturn(List.of(row(2,"US-W"),movedPlan));
|
||||
when(warehouses.catalog("test")).thenReturn(ApiResponse.success(List.of(new WarehouseMasterDataClient.CatalogWarehouse(1L,"US-W","US","OVERSEAS","US"))));
|
||||
when(shipping.containerActualFacts(List.of(1L,2L,3L))).thenReturn(List.of(
|
||||
fact(1L,to.atStartOfDay()),fact(2L,from.atStartOfDay()),fact(3L,from.plusDays(1).atStartOfDay())));
|
||||
var result=service.calendar(from,to);
|
||||
assertEquals(List.of(2L,3L),result.records().stream().map(PickupCalendarEntry::containerBookingId).toList());
|
||||
assertEquals(to.plusDays(20).atStartOfDay(),result.records().get(1).pickupAppointmentAt());
|
||||
assertEquals(from.plusDays(1).atStartOfDay(),result.records().get(1).actualPickupAt());
|
||||
assertFalse(result.truncated());
|
||||
}
|
||||
private com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.ContainerActualFacts fact(long id,java.time.LocalDateTime at) {
|
||||
return new com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.ContainerActualFacts(id,null,null,null,null,null,null,null,null,null,at,null);
|
||||
}
|
||||
@Test void includesAnActualPickupWithoutInventingAnAppointment() {
|
||||
when(shipping.pickupCalendarContainers(any(),any())).thenReturn(List.of(3L));
|
||||
when(mapper.calendarByContainers(List.of(3L))).thenReturn(List.of(new PickupCalendarEntry(3L,"D0003","CONT3","Port","US-W",null,null,null)));
|
||||
when(warehouses.catalog("test")).thenReturn(ApiResponse.success(List.of(new WarehouseMasterDataClient.CatalogWarehouse(1L,"US-W","US","OVERSEAS","US"))));
|
||||
when(shipping.containerActualFacts(List.of(3L))).thenReturn(List.of(fact(3L,from.atStartOfDay())));
|
||||
var result=service.calendar(from,to);
|
||||
assertEquals(1,result.records().size());
|
||||
assertNull(result.records().get(0).pickupAppointmentAt());
|
||||
assertEquals(from.atStartOfDay(),result.records().get(0).actualPickupAt());
|
||||
}
|
||||
}
|
||||
|
||||
+17
-2
@@ -47,6 +47,7 @@ class TerminalDrayageServiceTest {
|
||||
private CarrierScheduleQueryService carrierSchedules;
|
||||
private TerminalDrayageService service;
|
||||
private TerminalDrayageMailService mails;
|
||||
private UnloadingEvidenceGuard unloadingEvidence;
|
||||
private Deque<ContainerActualFacts> facts;
|
||||
|
||||
@BeforeEach
|
||||
@@ -60,7 +61,8 @@ class TerminalDrayageServiceTest {
|
||||
return List.of(facts.size() > 1 ? facts.removeFirst() : facts.peekFirst());
|
||||
});
|
||||
mails = mock(TerminalDrayageMailService.class);
|
||||
service = new TerminalDrayageService(mapper, suppliers, carrierSchedules, new ObjectMapper().findAndRegisterModules(), mails);
|
||||
unloadingEvidence = mock(UnloadingEvidenceGuard.class);
|
||||
service = new TerminalDrayageService(mapper, suppliers, carrierSchedules, new ObjectMapper().findAndRegisterModules(), mails, unloadingEvidence);
|
||||
service.setClock(Clock.fixed(Instant.parse("2026-09-05T02:00:00Z"), ZoneOffset.UTC));
|
||||
}
|
||||
|
||||
@@ -115,7 +117,7 @@ class TerminalDrayageServiceTest {
|
||||
assertThatThrownBy(() -> service.recordEmptyReturn(11L, 7L, "admin", "return-1",
|
||||
new ActualTimeRequest(3, LocalDateTime.of(2026, 9, 4, 10, 0), null)))
|
||||
.isInstanceOf(BizException.class)
|
||||
.hasMessage("海外卸柜收货完成后才能确认还柜");
|
||||
.hasMessage("海外仓卸柜或全部直送目的仓实际卸货完成后才能确认还柜");
|
||||
verify(carrierSchedules, never()).addContainerManualNode(anyLong(), anyLong(), anyString(), anyString(), any());
|
||||
}
|
||||
|
||||
@@ -138,6 +140,19 @@ class TerminalDrayageServiceTest {
|
||||
assertThat(service.detail(11L).emptyReturnWarning().overdueHours()).isZero();
|
||||
}
|
||||
|
||||
@Test
|
||||
void directReturnWarningUsesDestinationInstantInsteadOfServerWallClock() {
|
||||
when(mapper.context(11L)).thenReturn(context("PICKED_UP", null));
|
||||
when(mapper.find(11L)).thenReturn(row(3));
|
||||
when(unloadingEvidence.directCompletion(11L)).thenReturn(new UnloadingEvidenceGuard.DirectCompletion(
|
||||
LocalDateTime.parse("2026-09-01T22:00:00"), "America/Los_Angeles"));
|
||||
var result=service.detail(11L);
|
||||
assertThat(result.estimatedEmptyReturnAt()).isEqualTo(LocalDateTime.parse("2026-09-04T22:00:00"));
|
||||
assertThat(result.emptyReturnWarning().status()).isEqualTo("PENDING");
|
||||
service.setClock(Clock.fixed(Instant.parse("2026-09-05T06:00:00Z"), ZoneOffset.ofHours(9)));
|
||||
assertThat(service.detail(11L).emptyReturnWarning().overdueHours()).isEqualTo(1);
|
||||
}
|
||||
|
||||
@Test
|
||||
void offersEmptyReturnAfterUnloadWithoutDrayageAggregateOrPickupFact() {
|
||||
LocalDateTime unloadedAt = LocalDateTime.of(2026, 9, 3, 0, 0);
|
||||
|
||||
+41
@@ -0,0 +1,41 @@
|
||||
package com.baiyue.firstleg.terminaldrayage.service;
|
||||
|
||||
import com.baiyue.firstleg.terminaldrayage.mapper.DirectDeliveryMapper;
|
||||
import com.baiyue.common.core.exception.BizException;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
import static org.mockito.Mockito.*;
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
class UnloadingEvidenceGuardTest {
|
||||
@Test void requiresEveryPhysicalInboundSourceAndAcceptsLatestActualEvidence() {
|
||||
var mapper=mock(DirectDeliveryMapper.class);var guard=new UnloadingEvidenceGuard(mapper);
|
||||
var container=new DirectDeliveryMapper.Container(1,"ACTIVE",null);
|
||||
when(mapper.container(1)).thenReturn(container);when(mapper.lockContainer(1)).thenReturn(container);
|
||||
var at=LocalDateTime.parse("2026-10-10T12:00:00");
|
||||
var first=new DirectDeliveryMapper.Delivery(2,"IN2","O2","D",4,1,at,"America/Los_Angeles","张三","POD2",null,"k","h");
|
||||
var pending=new DirectDeliveryMapper.Delivery(3,"IN3","O3","D",6,0,null,null,null,null,null,null,null);
|
||||
when(mapper.deliveries(1)).thenReturn(List.of(first,pending));
|
||||
assertNull(guard.completedUnloading(1));assertThrows(BizException.class,()->guard.requireUnloaded(1,at.plusDays(1)));
|
||||
var second=new DirectDeliveryMapper.Delivery(3,"IN3","O3","D",6,1,at.plusHours(1),"America/Los_Angeles","李四","POD3",null,"k3","h3");
|
||||
when(mapper.deliveries(1)).thenReturn(List.of(first,second));
|
||||
assertEquals(at.plusHours(1),guard.completedUnloading(1));
|
||||
assertDoesNotThrow(()->guard.requireUnloaded(1,at)); // Return has no source zone.
|
||||
assertDoesNotThrow(()->guard.requireUnloaded(1,at.plusDays(1)));
|
||||
var warehouse=new DirectDeliveryMapper.Container(1,"ACTIVE",at.plusHours(1));
|
||||
when(mapper.container(1)).thenReturn(warehouse);when(mapper.lockContainer(1)).thenReturn(warehouse);
|
||||
assertThrows(BizException.class,()->guard.requireUnloaded(1,at));
|
||||
}
|
||||
@Test void comparesDestinationEvidenceByInstantAndMeasuresSeventyTwoHoursAcrossDst() {
|
||||
var mapper=mock(DirectDeliveryMapper.class);var guard=new UnloadingEvidenceGuard(mapper);
|
||||
var ny=LocalDateTime.parse("2026-10-10T12:00:00");
|
||||
var la=LocalDateTime.parse("2026-10-10T10:00:00");
|
||||
when(mapper.deliveries(1)).thenReturn(List.of(
|
||||
new DirectDeliveryMapper.Delivery(2,"IN2","O2","D",4,1,ny,"America/New_York","a","P2",null,"k2","h2"),
|
||||
new DirectDeliveryMapper.Delivery(3,"IN3","O3","D",6,1,la,"America/Los_Angeles","a","P3",null,"k3","h3")));
|
||||
assertEquals(la,guard.directCompletion(1).localAt());
|
||||
var beforeFallback=new UnloadingEvidenceGuard.DirectCompletion(LocalDateTime.parse("2026-10-31T12:00:00"),"America/Los_Angeles");
|
||||
assertEquals(LocalDateTime.parse("2026-11-03T11:00:00"),beforeFallback.deadline(72));
|
||||
}
|
||||
}
|
||||
@@ -85,12 +85,56 @@
|
||||
],
|
||||
"sliceSpecificRules": {
|
||||
"customer-statistics": {
|
||||
"allowedOutgoing": ["shared-technical"],
|
||||
"allowedOutgoing": [
|
||||
"shared-technical"
|
||||
],
|
||||
"allowedIncoming": []
|
||||
},
|
||||
"pricebook-query": {
|
||||
"allowedOutgoing": [],
|
||||
"allowedIncoming": ["booking-customs-trucking", "measurement-pricing", "split-loading"]
|
||||
"allowedIncoming": [
|
||||
"booking-customs-trucking",
|
||||
"measurement-pricing",
|
||||
"split-loading"
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"reviewedClassDependencies": [
|
||||
{
|
||||
"source": "com.baiyue.firstleg.controller.InternalOverseasContainerEventController",
|
||||
"target": "com.baiyue.firstleg.service.OverseasInboundOrderDetainedConsumer",
|
||||
"reason": "海外事件HTTP适配器按事件类型派发到暂扣消费者;入仓数据仍由消费者维护。"
|
||||
},
|
||||
{
|
||||
"source": "com.baiyue.firstleg.loading.service.LoadingDeliveryService",
|
||||
"target": "com.baiyue.firstleg.service.FbaPricingService",
|
||||
"reason": "装柜实物完成时按真实分配刷新费用,调用既有计价应用服务,不读取计价持久化。"
|
||||
},
|
||||
{
|
||||
"source": "com.baiyue.firstleg.service.LoadingListService",
|
||||
"target": "com.baiyue.firstleg.domain.PriceBookChannelCatalog",
|
||||
"reason": "分柜按当前有效渠道目录校验,复用纯领域目录,不访问价格簿实现。"
|
||||
},
|
||||
{
|
||||
"source": "com.baiyue.firstleg.service.LoadingListService",
|
||||
"target": "com.baiyue.firstleg.service.FbaPricingService",
|
||||
"reason": "装柜分配变动触发既有计价服务按客户重算,费用冻结门槛保留在原服务。"
|
||||
},
|
||||
{
|
||||
"source": "com.baiyue.firstleg.service.SmartLoadingService",
|
||||
"target": "com.baiyue.firstleg.service.FbaPricingService",
|
||||
"reason": "智能排柜分配后调用原计价服务守恒重算,不跨域修改费用表。"
|
||||
}
|
||||
],
|
||||
"reviewedPackageCycles": [
|
||||
{
|
||||
"packages": [
|
||||
"com.baiyue.firstleg.loading.bridge.service",
|
||||
"com.baiyue.firstleg.loading.service",
|
||||
"com.baiyue.firstleg.oceanbooking.service",
|
||||
"com.baiyue.firstleg.service"
|
||||
],
|
||||
"reason": "既有全局service包混合了入仓暂扣、装柜费用与订舱文档的应用编排。本轮不拆分这些无关既有服务;仅冻结当前四包SCC,新增节点或第二环继续失败。价格簿查询切片仍必须独立无环。"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user