feat(appointments): unify pre-inbound planning and confirmed FBA bookings

This commit is contained in:
2026-10-10 10:38:06 +08:00
parent 24f5ccf7ff
commit c4c1bddeec
54 changed files with 1623 additions and 744 deletions
@@ -14,4 +14,19 @@ public class FleetAppointmentSourceAdapter implements AppointmentExternalSourceG
return sources.resolve(refs.stream().map(r->new FleetPoolSourceResolver.SourceRef(r.customerId(),r.bookingNo())).toList())
.stream().map(s->new Source(s.customerId(),s.bookingNo(),s.tripNo(),s.bookingStatus(),s.warehouseId(),s.destinationKey(),s.lastlegOrderId(),s.packageCount(),s.recordVersion())).toList();
}
@Override
public List<Source> resolveOrders(List<OrderRef> refs) {
if (refs == null || refs.isEmpty()) return List.of();
if (refs.size() > 100 || refs.stream().anyMatch(java.util.Objects::isNull))
throw new IllegalArgumentException("订单来源每次最多100条,且不能包含空项");
return sources.resolveOrders(refs.stream().map(r -> new FleetPoolSourceResolver.OrderRef(r.customerId(), r.orderId())).toList())
.stream().map(s -> new Source(s.customerId(), s.bookingNo(), s.tripNo(), s.bookingStatus(), s.warehouseId(),
s.destinationKey(), s.lastlegOrderId(), s.packageCount(), s.recordVersion())).toList();
}
@Override
public void lockForAppointment(List<SourceRef> refs) {
if (refs == null || refs.size() > 100 || refs.stream().anyMatch(java.util.Objects::isNull))
throw new IllegalArgumentException("预约来源须完整,每次最多100条");
sources.lockForAppointment(refs.stream().map(r -> new FleetPoolSourceResolver.SourceRef(r.customerId(), r.bookingNo())).toList());
}
}
@@ -33,11 +33,14 @@ public class FleetPoolOrderBridge {
private final LastlegOrderNoGenerator numbers;
private final ObjectMapper json;
private final DeliverySourceEvidenceMapper deliveryEvidence;
private final com.baiyue.lastleg.appointmentplanning.AppointmentSourceLinker appointmentSources;
public FleetPoolOrderBridge(FleetPoolOrderMapper links,LocalLooseboardOrderMapper loose,
LocalLooseboardHistoryMapper looseHistory,UsTruckOrderMapper trucks,
UsTruckOrderStatusHistoryMapper truckHistory,LastlegOrderNoGenerator numbers,ObjectMapper json,DeliverySourceEvidenceMapper deliveryEvidence) {
UsTruckOrderStatusHistoryMapper truckHistory,LastlegOrderNoGenerator numbers,ObjectMapper json,DeliverySourceEvidenceMapper deliveryEvidence,
com.baiyue.lastleg.appointmentplanning.AppointmentSourceLinker appointmentSources) {
this.links=links;this.loose=loose;this.looseHistory=looseHistory;this.trucks=trucks;
this.truckHistory=truckHistory;this.numbers=numbers;this.json=json;this.deliveryEvidence=deliveryEvidence;
this.appointmentSources=appointmentSources;
}
@Transactional(readOnly=true)
public Map<Long,String> fulfillmentStages(List<Long> bookingIds) {
@@ -100,6 +103,7 @@ public class FleetPoolOrderBridge {
if(order.getLastlegOrderId()!=null&&!order.getLastlegOrderId().equals(lastlegOrderId))throw conflict("散板商业单已存在另一尾程来源");
order.setLastlegOrderId(lastlegOrderId);order.setVersion(order.getVersion()+1);order.setUpdatedAt(row.getUpdatedAt());loose.updateById(order);
}
appointmentSources.materialized(customerId, bookingNo, lastlegOrderId);
}
@Transactional(propagation=Propagation.MANDATORY)
public void receiving(String customerId,String incomingNo,String operator) {
@@ -7,6 +7,8 @@ import java.util.List;
@Mapper
public interface FleetPoolOrderMapper extends BaseMapper<FleetPoolOrderLink> {
@Select("SELECT customer_id,booking_no FROM ll_fleet_external_booking WHERE booking_no=#{bookingNo} AND offer_id IS NOT NULL FOR UPDATE")
FleetPoolSourceResolver.SourceRef lockAppointmentSource(String bookingNo);
@Select({"<script>SELECT b.customer_id,b.booking_no,b.trip_no,b.status AS booking_status,",
"t.warehouse_id,b.destination_key,b.destination_label,b.incoming_no,",
"l.lastleg_order_id,l.business_order_no,l.fulfillment_stage,b.record_version,",
@@ -16,6 +18,16 @@ public interface FleetPoolOrderMapper extends BaseMapper<FleetPoolOrderLink> {
"WHERE b.offer_id IS NOT NULL AND <foreach collection='sources' item='s' open='(' separator=' OR ' close=')'>",
"(b.customer_id=#{s.customerId} AND b.booking_no=#{s.bookingNo})</foreach></script>"})
List<FleetPoolSourceResolver.SourceRow> resolveSources(@Param("sources") List<FleetPoolSourceResolver.SourceRef> sources);
@Select({"<script>SELECT b.customer_id,b.booking_no,b.trip_no,b.status AS booking_status,",
"t.warehouse_id,b.destination_key,b.destination_label,b.incoming_no,",
"l.lastleg_order_id,l.business_order_no,l.fulfillment_stage,b.record_version,",
"b.cargo_json,b.expected_pallet_count,b.estimated_arrival_at",
"FROM ll_fleet_pool_order_link l JOIN ll_fleet_external_booking b ON b.id=l.booking_id AND b.customer_id=l.customer_id",
"JOIN ll_fleet_trip t ON t.id=b.trip_id",
"JOIN ll_order o ON o.id=l.lastleg_order_id AND o.customer_id=l.customer_id",
"WHERE b.offer_id IS NOT NULL AND <foreach collection='orders' item='o' open='(' separator=' OR ' close=')'>",
"(l.customer_id=#{o.customerId} AND l.lastleg_order_id=#{o.orderId})</foreach></script>"})
List<FleetPoolSourceResolver.SourceRow> resolveOrderSources(@Param("orders") List<FleetPoolSourceResolver.OrderRef> orders);
@Select("SELECT * FROM ll_fleet_pool_order_link WHERE booking_id=#{id} FOR UPDATE")
FleetPoolOrderLink lockBooking(long id);
@Select("SELECT * FROM ll_fleet_pool_order_link WHERE booking_no=#{no} FOR UPDATE")
@@ -43,7 +43,7 @@ public class FleetPoolOrderQueries {
row.getTripNo(),row.getPlannedDepartureAt()==null?null:row.getPlannedDepartureAt().toInstant(java.time.ZoneOffset.UTC),row.getOriginWarehouseName(),row.getDestinationLabel(),
row.getDeliveryMode(),row.getDeliveryInstructions(),row.getFulfillmentStage(),row.getStageOccurredAt()==null?null:row.getStageOccurredAt().atZone(java.time.ZoneId.systemDefault()).toInstant(),
row.getAgreedFreightAmount(),row.getCurrency(),row.getIncomingNo(),row.getDispatchNo(),row.getDeliveryNo(),
signed,unsigned,returned,exception,"此订单由拼车承运实际履约推进,请从来货、排车和签收入口操作");
signed,unsigned,returned,exception,"此订单已关联拼车承运,状态随实际收货、排车和签收更新,无需重复下单。");
}
private static ResponseStatusException conflict(String message){return new ResponseStatusException(HttpStatus.CONFLICT,message);}
}
@@ -8,6 +8,7 @@ import org.springframework.transaction.annotation.Transactional;
@Service
public class FleetPoolSourceResolver {
public record SourceRef(String customerId, String bookingNo) { }
public record OrderRef(String customerId, Long orderId) { }
public record Source(String customerId, String bookingNo, String tripNo, String bookingStatus,
String warehouseId, String destinationKey, String destinationLabel, String incomingNo,
Long lastlegOrderId, String businessOrderNo, String fulfillmentStage, Long recordVersion, Integer packageCount, Integer expectedPalletCount, java.time.LocalDateTime estimatedArrivalAt) { }
@@ -19,12 +20,43 @@ public class FleetPoolSourceResolver {
private final FleetPoolOrderMapper links;
public FleetPoolSourceResolver(FleetPoolOrderMapper links,com.fasterxml.jackson.databind.ObjectMapper json) { this.links = links; this.json=json; }
@Transactional(propagation = org.springframework.transaction.annotation.Propagation.MANDATORY)
public void lockForAppointment(List<SourceRef> sources) {
if (!org.springframework.transaction.support.TransactionSynchronizationManager.isActualTransactionActive())
throw new IllegalStateException("预约来源锁必须在调用方事务内获取");
if (sources == null || sources.size() > 100 || sources.stream().anyMatch(s -> s == null
|| s.customerId() == null || s.customerId().isBlank() || s.bookingNo() == null || s.bookingNo().isBlank()))
throw new IllegalArgumentException("来源须包含客户及登记业务号,每次最多100条");
// Lock the original booking, not the optional commercial link: an inquiry may have no incoming yet.
// One unique-key lookup at a time gives concurrent batches the same lock order.
for (var source : sources.stream().distinct().sorted(java.util.Comparator.comparing(SourceRef::bookingNo)
.thenComparing(SourceRef::customerId)).toList()) {
var locked = links.lockAppointmentSource(source.bookingNo());
if (locked == null || !source.equals(locked))
throw new org.springframework.web.server.ResponseStatusException(org.springframework.http.HttpStatus.CONFLICT,
"预约货源不存在或客户归属不一致,请重新核对");
}
}
@Transactional(readOnly = true)
public List<Source> resolve(List<SourceRef> sources) {
if (sources == null || sources.isEmpty()) return List.of();
if (sources.size() > 100 || sources.stream().anyMatch(s -> s == null || s.customerId() == null || s.customerId().isBlank() || s.bookingNo() == null || s.bookingNo().isBlank()))
throw new IllegalArgumentException("来源须包含客户及登记业务号,每次最多100条");
return links.resolveSources(sources.stream().distinct().toList()).stream().map(row -> {
return project(links.resolveSources(sources.stream().distinct().toList()));
}
@Transactional(readOnly = true)
public List<Source> resolveOrders(List<OrderRef> orders) {
if (orders == null || orders.isEmpty()) return List.of();
if (orders.size() > 100 || orders.stream().anyMatch(o -> o == null || o.customerId() == null
|| o.customerId().isBlank() || o.orderId() == null || o.orderId() <= 0))
throw new IllegalArgumentException("来源须包含客户及有效订单,每次最多100条");
return project(links.resolveOrderSources(orders.stream().distinct().toList()));
}
private List<Source> project(List<SourceRow> rows) {
return rows.stream().map(row -> {
Integer packages;
try {
var value=json.readTree(row.cargoJson()).get("packageCount");