feat(lastleg): connect appointment followups and fleet intake

This commit is contained in:
2026-10-09 22:04:49 +08:00
parent 1202cafadf
commit 0d75e311d0
54 changed files with 2315 additions and 25 deletions
@@ -6710,6 +6710,8 @@ INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id,
INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5107280,0,CURRENT_TIMESTAMP(3)); INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5107280,0,CURRENT_TIMESTAMP(3));
INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5107281,0,'PLATFORM','BUTTON',5107280,'platform:appointment:delete','删除预约',NULL,'DELETE',741,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5107281,0,'PLATFORM','BUTTON',5107280,'platform:appointment:delete','删除预约',NULL,'DELETE',741,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0);
INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5107281,0,CURRENT_TIMESTAMP(3)); INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5107281,0,CURRENT_TIMESTAMP(3));
INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5107282,0,'PLATFORM','BUTTON',5107280,'platform:appointment:followup','记录预约跟进',NULL,'POST',742,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0);
INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5107282,0,CURRENT_TIMESTAMP(3));
INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5107010,0,CURRENT_TIMESTAMP(3)); INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5107010,0,CURRENT_TIMESTAMP(3));
INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5107011,0,CURRENT_TIMESTAMP(3)); INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5107011,0,CURRENT_TIMESTAMP(3));
INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5107012,0,CURRENT_TIMESTAMP(3)); INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5107012,0,CURRENT_TIMESTAMP(3));
+25 -1
View File
@@ -1479,6 +1479,30 @@ CREATE TABLE ll_order (
KEY idx_ll_order_container_progress (source_container_booking_id,estimated_warehouse_at,created_at,id) KEY idx_ll_order_container_progress (source_container_booking_id,estimated_warehouse_at,created_at,id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='尾程订单主表'; ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='尾程订单主表';
CREATE TABLE ll_appointment_followup (
id BIGINT NOT NULL COMMENT '预约筹划跟进记录ID',
source_key VARCHAR(160) NOT NULL COMMENT '稳定ORDER或BOOKING来源键,不因实收转换而变化',
customer_id VARCHAR(128) NOT NULL COMMENT '服务端核实的来源客户',
source_order_id BIGINT NULL COMMENT '已形成的真实尾程货源ID,尚未生成时为空',
stage VARCHAR(32) NOT NULL COMMENT '跟进对象:DRAYAGE_CONTACT/TERMINAL_APPOINTMENT/WAREHOUSE_RECEIVING/FBA_RECEIVING',
version INT NOT NULL COMMENT '同一货源及跟进对象的记录版本',
contacted_party VARCHAR(200) NULL COMMENT '本次联系对象,不替代承运主档',
result VARCHAR(1000) NOT NULL COMMENT '本次反馈事实,不自动确认预约或推进实物作业',
next_action VARCHAR(500) NULL COMMENT '下一步安排',
followup_date DATE NULL COMMENT '按业务地点当地日期记录的下次跟进日',
operator_id VARCHAR(128) NOT NULL COMMENT '登录平台操作者ID',
operator_name VARCHAR(200) NULL COMMENT '操作人姓名快照',
command_key VARCHAR(64) NOT NULL COMMENT '持久幂等键',
request_hash CHAR(64) NOT NULL COMMENT '含操作者身份的请求SHA256摘要',
created_at DATETIME(3) NOT NULL COMMENT 'UTC记录时间',
PRIMARY KEY (id),
UNIQUE KEY uk_appointment_followup_version (source_key,stage,version),
UNIQUE KEY uk_appointment_followup_command (source_key,stage,command_key),
KEY idx_appointment_followup_date (followup_date,source_order_id),
CONSTRAINT fk_appointment_followup_order FOREIGN KEY (source_order_id) REFERENCES ll_order(id),
CONSTRAINT chk_appointment_followup_stage CHECK (stage IN ('DRAYAGE_CONTACT','TERMINAL_APPOINTMENT','WAREHOUSE_RECEIVING','FBA_RECEIVING'))
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='真实在途货源的预约筹划联系记录,追加留痕';
CREATE TABLE ll_order_command_record ( CREATE TABLE ll_order_command_record (
id bigint NOT NULL COMMENT '尾程订单命令幂等记录ID', id bigint NOT NULL COMMENT '尾程订单命令幂等记录ID',
customer_id varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '客户数据隔离键', customer_id varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '客户数据隔离键',
@@ -3601,4 +3625,4 @@ CREATE TABLE ll_return_carton_count_session (
UNIQUE KEY uk_ll_return_count_session_receipt(receipt_id) UNIQUE KEY uk_ll_return_count_session_receipt(receipt_id)
) ENGINE=InnoDB COMMENT='退货箱内清点单人单任务持久会话'; ) ENGINE=InnoDB COMMENT='退货箱内清点单人单任务持久会话';
SELECT 'PASS' AS initialization_status, 'OVERSEAS' AS region, 160 AS initialized_tables; SELECT 'PASS' AS initialization_status, 'OVERSEAS' AS region, 161 AS initialized_tables;
@@ -20,6 +20,7 @@ INSERT INTO iam_permission
VALUES VALUES
(5107280,0,'PLATFORM','MENU',0,'platform:appointment:list','预约列表','/appointment-calendar','GET',740,'ACTIVE',1,0), (5107280,0,'PLATFORM','MENU',0,'platform:appointment:list','预约列表','/appointment-calendar','GET',740,'ACTIVE',1,0),
(5107281,0,'PLATFORM','BUTTON',5107280,'platform:appointment:delete','删除预约',NULL,'DELETE',741,'ACTIVE',1,0), (5107281,0,'PLATFORM','BUTTON',5107280,'platform:appointment:delete','删除预约',NULL,'DELETE',741,'ACTIVE',1,0),
(5107282,0,'PLATFORM','BUTTON',5107280,'platform:appointment:followup','记录预约跟进',NULL,'POST',742,'ACTIVE',1,0),
(5107000,0,'PLATFORM','MENU',0,'platform:overseas-warehouse:last-mile-order:list','海外尾程订单','/overseas-warehouse/last-mile-orders','GET',700,'ACTIVE',1,0), (5107000,0,'PLATFORM','MENU',0,'platform:overseas-warehouse:last-mile-order:list','海外尾程订单','/overseas-warehouse/last-mile-orders','GET',700,'ACTIVE',1,0),
(5107001,0,'PLATFORM','BUTTON',5107000,'platform:overseas-warehouse:last-mile-order:edit','编辑海外尾程订单',NULL,'PUT',701,'ACTIVE',1,0), (5107001,0,'PLATFORM','BUTTON',5107000,'platform:overseas-warehouse:last-mile-order:edit','编辑海外尾程订单',NULL,'PUT',701,'ACTIVE',1,0),
(5107002,0,'PLATFORM','BUTTON',5107000,'platform:overseas-warehouse:last-mile-order:export','导出海外尾程订单',NULL,'GET',702,'ACTIVE',1,0), (5107002,0,'PLATFORM','BUTTON',5107000,'platform:overseas-warehouse:last-mile-order:export','导出海外尾程订单',NULL,'GET',702,'ACTIVE',1,0),
+2 -2
View File
@@ -11,7 +11,7 @@
业务包相对 `src/main/java/com/baiyue/lastleg/`;`src/`、`docs/`、`scripts/`、`../` 相对服务目录。裸类名在指定包内检索。 业务包相对 `src/main/java/com/baiyue/lastleg/`;`src/`、`docs/`、`scripts/`、`../` 相对服务目录。裸类名在指定包内检索。
拼车需求池:`fleet/trip/FleetCapacityOfferService.java`、`fleet/business/FleetPoolOrderBridge.java`;流程见 `../../docs/海外仓业务/内部拼车需求池首版开发契约.md`。 拼车接货与车次准备:`fleet/trip/`、`fleet/business/`;合同`fleet-preparation-v1.yaml`,流程见 `../../docs/海外仓业务/内部拼车需求池首版开发契约.md`。
事件重试:`controller/EventOperationsController.java`,内部 `/internal/lastleg/event-operations/{entryId}` 查询、`/retry` 写入;仅卸柜、派送、暂扣、资金及费用事件,以 `EVENT_OPERATIONS_TOKEN` 保护。原事件不可改,DEAD→PENDING 与 `event_delivery_retry_audit` 同事务;脚本 `scripts/event-outbox-operations.mjs`。 事件重试:`controller/EventOperationsController.java`,内部 `/internal/lastleg/event-operations/{entryId}` 查询、`/retry` 写入;仅卸柜、派送、暂扣、资金及费用事件,以 `EVENT_OPERATIONS_TOKEN` 保护。原事件不可改,DEAD→PENDING 与 `event_delivery_retry_audit` 同事务;脚本 `scripts/event-outbox-operations.mjs`。
@@ -36,7 +36,7 @@
| 入仓计划分页、柜级预览、转换与详情 | `inboundplan/controller/InboundPlanController.java` | | 入仓计划分页、柜级预览、转换与详情 | `inboundplan/controller/InboundPlanController.java` |
| 自动生成托盘、资源生成失败与重试 | `inboundplan/service/ContainerInboundPlanConversionCoordinator.java` 的 `convertManualAndAutoMaterialize`;详细规则定向查 `docs/business-index.md` 的“人工计划自动生成托盘与资源重试” | | 自动生成托盘、资源生成失败与重试 | `inboundplan/service/ContainerInboundPlanConversionCoordinator.java` 的 `convertManualAndAutoMaterialize`;详细规则定向查 `docs/business-index.md` 的“人工计划自动生成托盘与资源重试” |
| 托盘分配算法 | `inboundplan/service/PalletAllocator.java` | | 托盘分配算法 | `inboundplan/service/PalletAllocator.java` |
| 跨客户同仓预约、日历、增删改查 | `InboundPlanController.java` | | 跨客户同仓预约、日历 | `InboundPlanController.java`;在途/外接货跟进与覆盖见`appointmentplanning/` |
| PDA 入仓、柜号签收、实收、上架与完成 | `inboundplan/pda/controller/ReceivingContainerController.java` → `inboundplan/pda/service/ReceivingContainerService.java`;门禁与状态定向查 `docs/business-index.md` 的“PDA 入仓、实收、上架与完成” | | PDA 入仓、柜号签收、实收、上架与完成 | `inboundplan/pda/controller/ReceivingContainerController.java` → `inboundplan/pda/service/ReceivingContainerService.java`;门禁与状态定向查 `docs/business-index.md` 的“PDA 入仓、实收、上架与完成” |
| PDA 卸柜照片上传、归属校验与预览 | `ReceivingContainerController.java` | | PDA 卸柜照片上传、归属校验与预览 | `ReceivingContainerController.java` |
| PDA 国内装柜照片对照 | `ReceivingContainerController.java` | | PDA 国内装柜照片对照 | `ReceivingContainerController.java` |
@@ -0,0 +1,37 @@
package com.baiyue.lastleg.appointmentplanning;
import lombok.Data;
import java.math.BigDecimal;
import java.time.LocalDateTime;
/** Read-only loaded cargo, independent of inbound-plan materialization. */
@Data
public class AppointmentCargo {
private String sourceKey;
private Long orderId;
private String customerId;
private String customerName;
private String inboundCode;
private String containerBookingId;
private String containerNo;
private String originWarehouseId;
private String originWarehouseCode;
private String originWarehouseName;
private String destinationWarehouseCode;
private Integer packages;
private Integer receivedPackages;
private Integer shippedPackages;
private Integer sourcePalletCount;
private BigDecimal volumeCbm;
private LocalDateTime estimatedArrivalAt;
private LocalDateTime estimatedWarehouseAt;
private Integer orderVersion;
private String standardNode;
private Long shippingVersion;
private LocalDateTime shippingObservedAt;
private String shippingProvider;
private boolean hasInboundPlan;
private boolean hasActiveAppointment;
private boolean detained;
private boolean mixedDestinations;
}
@@ -0,0 +1,45 @@
package com.baiyue.lastleg.appointmentplanning;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
import java.util.List;
@Mapper
public interface AppointmentCargoMapper {
String FILTER = """
FROM ll_order o JOIN ll_container_shipping_gate g ON g.container_booking_id=o.source_container_booking_id
WHERE o.order_type='FIRST_LEG' AND o.destination_type='FBA' AND g.node_sequence >=40
AND o.current_status NOT IN ('CANCELLED','COMPLETED')
AND o.overseas_warehouse_id IN
<foreach collection='origins' item='id' open='(' separator=',' close=')'>#{id}</foreach>
AND o.fba_warehouse_code IN
<foreach collection='destinations' item='code' open='(' separator=',' close=')'>#{code}</foreach>
<if test='warehouseId != null'>AND o.overseas_warehouse_id=#{warehouseId}</if>
<if test='destination != null'>AND o.fba_warehouse_code=#{destination}</if>
<if test='keyword != null'>AND (o.inbound_code LIKE CONCAT('%',#{keyword},'%') OR o.container_no LIKE CONCAT('%',#{keyword},'%') OR o.customer_name LIKE CONCAT('%',#{keyword},'%'))</if>
""";
@Select("<script>SELECT COUNT(*) " + FILTER + "</script>")
long count(@Param("origins") List<String> origins, @Param("destinations") List<String> destinations,
@Param("warehouseId") String warehouseId, @Param("destination") String destination, @Param("keyword") String keyword);
@Select("<script>" + """
SELECT CONCAT('ORDER:',o.id) AS source_key,o.id AS order_id,o.customer_id,o.customer_name,o.inbound_code,
o.source_container_booking_id AS container_booking_id,o.container_no,
o.overseas_warehouse_id AS origin_warehouse_id,o.overseas_warehouse_code AS origin_warehouse_code,
o.overseas_warehouse_name AS origin_warehouse_name,o.fba_warehouse_code AS destination_warehouse_code,
o.package_count AS packages,o.received_package_count AS received_packages,o.shipped_package_count AS shipped_packages,
o.pallet_count AS source_pallet_count,o.volume_cbm,o.estimated_arrival_at,o.estimated_warehouse_at,
o.version AS order_version,g.standard_node,g.aggregate_version AS shipping_version,
g.observed_at AS shipping_observed_at,g.provider_code AS shipping_provider,o.detained,
EXISTS(SELECT 1 FROM ll_inbound_plan p WHERE p.source_order_id=o.id AND p.customer_id=o.customer_id) AS has_inbound_plan,
EXISTS(SELECT 1 FROM ll_inbound_fba_appointment_item i JOIN ll_inbound_fba_appointment a ON a.id=i.appointment_id
WHERE i.order_id=o.id AND i.customer_id=o.customer_id AND a.appointment_status NOT IN ('CANCELLED','FAILED')) AS has_active_appointment,
EXISTS(SELECT 1 FROM ll_order_detail d WHERE d.order_id=o.id AND d.customer_id=o.customer_id
AND d.destination_warehouse_code IS NOT NULL AND d.destination_warehouse_code&lt;&gt;o.fba_warehouse_code) AS mixed_destinations
""" + FILTER + " ORDER BY o.created_at DESC,o.id DESC LIMIT #{size} OFFSET #{offset}</script>")
List<AppointmentCargo> page(@Param("origins") List<String> origins, @Param("destinations") List<String> destinations,
@Param("warehouseId") String warehouseId, @Param("destination") String destination,
@Param("keyword") String keyword, @Param("size") int size, @Param("offset") long offset);
}
@@ -0,0 +1,57 @@
package com.baiyue.lastleg.appointmentplanning;
import com.baiyue.common.core.api.PageResult;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Service;
import org.springframework.web.server.ResponseStatusException;
import java.util.List;
import java.util.Set;
@Service
public class AppointmentCargoService {
private final AppointmentCargoMapper mapper;
private final AppointmentWarehouseClient warehouses;
private final String token;
public AppointmentCargoService(AppointmentCargoMapper mapper, AppointmentWarehouseClient warehouses,
@Value("${baiyue.security.internal-service-token:}") String token) {
this.mapper = mapper; this.warehouses = warehouses; this.token = token;
}
public PageResult<AppointmentCargo> page(int page, int size, String warehouseId, String destination, String keyword) {
if (page < 1 || size < 1 || size > 100) throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "每页数量必须为1至100");
if (keyword != null && keyword.length() > 128) throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "搜索词过长");
var scope = scope();
var origins = scope.origins();
var destinations = scope.destinations();
warehouseId = clean(warehouseId); destination = clean(destination); keyword = clean(keyword);
if (warehouseId != null && !origins.contains(warehouseId) || destination != null && !destinations.contains(destination)) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "请选择已启用的美国接仓点及FBA目的仓");
}
if (origins.isEmpty() || destinations.isEmpty()) return new PageResult<>(List.of(), page, size, 0, 0);
long total = mapper.count(origins, destinations, warehouseId, destination, keyword);
var rows = mapper.page(origins, destinations, warehouseId, destination, keyword, size, (long) (page - 1) * size);
return new PageResult<>(rows, page, size, total, (total + size - 1) / size);
}
private record Scope(List<String> origins, List<String> destinations) { }
private Scope scope() {
final List<AppointmentWarehouseClient.Warehouse> catalog;
try {
var result = warehouses.catalog(token);
if (result == null || !result.successful() || result.data() == null) throw new IllegalStateException();
catalog = result.data();
} catch (RuntimeException error) {
throw new ResponseStatusException(HttpStatus.SERVICE_UNAVAILABLE, "仓库目录暂不可用,无法核实美国货物范围", error);
}
var us = catalog.stream().filter(w -> w != null && w.id() != null && w.id() > 0 && "US".equals(w.countryCode())).toList();
var origins = us.stream().filter(w -> Set.of("OVERSEAS", "OVERSEAS_PARTNER").contains(w.warehouseType() == null ? "" : w.warehouseType()))
.map(w -> w.id().toString()).distinct().toList();
var destinations = us.stream().filter(w -> "FBA".equals(w.warehouseType()) && clean(w.warehouseCode()) != null)
.map(AppointmentWarehouseClient.Warehouse::warehouseCode).distinct().toList();
return new Scope(origins, destinations);
}
private static String clean(String value) { return value == null || value.isBlank() ? null : value.trim(); }
}
@@ -0,0 +1,37 @@
package com.baiyue.lastleg.appointmentplanning;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
import java.time.LocalDateTime;
import java.util.List;
@Mapper
public interface AppointmentCoverageMapper {
record OrderRef(String customerId, Long orderId) { }
record Source(Long orderId, String customerId, String destinationType, String fbaWarehouseCode,
Integer packages, Integer version, LocalDateTime updatedAt, boolean shipmentReferencesMissing) { }
record Fact(Long orderId, String customerId, String appointmentNo, String status,
LocalDateTime appointmentAt, LocalDateTime appointmentEndAt, LocalDateTime updatedAt,
Long lineId, Integer packages, String warehouseCode) { }
@Select({"<script>SELECT o.id AS orderId,o.customer_id AS customerId,o.destination_type AS destinationType,",
"o.fba_warehouse_code AS fbaWarehouseCode,o.package_count AS packages,o.version,o.updated_at AS updatedAt, ",
"(NOT EXISTS(SELECT 1 FROM ll_order_detail d WHERE d.order_id=o.id AND d.customer_id=o.customer_id) OR ",
"EXISTS(SELECT 1 FROM ll_order_detail d WHERE d.order_id=o.id AND d.customer_id=o.customer_id ",
"AND (d.shipment_no IS NULL OR TRIM(d.shipment_no)='') AND (d.fba_tracking_no IS NULL OR TRIM(d.fba_tracking_no)=''))) AS shipmentReferencesMissing ",
"FROM ll_order o WHERE <foreach collection='sources' item='s' open='(' separator=' OR ' close=')'>",
"(o.id=#{s.orderId} AND o.customer_id=#{s.customerId})</foreach></script>"})
List<Source> sources(@Param("sources") List<OrderRef> sources);
@Select({"<script>SELECT i.order_id AS orderId,i.customer_id AS customerId,a.appointment_no AS appointmentNo,",
"a.appointment_status AS status,a.appointment_at AS appointmentAt,a.appointment_end_at AS appointmentEndAt,",
"a.updated_at AS updatedAt,i.line_id AS lineId,l.required_package_count AS packages,l.fba_warehouse_code AS warehouseCode ",
"FROM ll_inbound_fba_appointment_item i JOIN ll_inbound_fba_appointment a ON a.id=i.appointment_id ",
"JOIN ll_inbound_plan_line l ON l.id=i.line_id AND l.plan_id=i.plan_id AND l.order_id=i.order_id AND l.customer_id=i.customer_id ",
"WHERE a.appointment_status NOT IN ('FAILED','CANCELLED') AND ",
"<foreach collection='sources' item='s' open='(' separator=' OR ' close=')'>",
"(i.order_id=#{s.orderId} AND i.customer_id=#{s.customerId})</foreach>",
" ORDER BY a.appointment_at,a.id,i.id</script>"})
List<Fact> appointments(@Param("sources") List<OrderRef> sources);
}
@@ -0,0 +1,101 @@
package com.baiyue.lastleg.appointmentplanning;
import org.springframework.stereotype.Service;
import java.time.LocalDateTime;
import java.util.*;
import java.util.stream.Collectors;
/** Shared read projection. Callers authorize the customer/source before invoking; never confirms bookings. */
@Service
public class AppointmentCoverageService {
public record SourceRef(String customerId, String sourceKey, String destinationKey) { }
public record Appointment(String appointmentNo, String confirmationNo, String status,
LocalDateTime confirmedAt, LocalDateTime confirmedEndAt, String timezone) { }
public record Coverage(String customerId, String sourceKey, String destinationKey, String status,
List<Appointment> appointments, List<String> missingFields,
String sourceVersion, boolean changeReviewRequired) { }
private final AppointmentCoverageMapper mapper;
private final AppointmentExternalSourceGateway external;
public AppointmentCoverageService(AppointmentCoverageMapper mapper, AppointmentExternalSourceGateway external) {
this.mapper=mapper; this.external=external;
}
public List<Coverage> query(List<SourceRef> input) {
if (input==null || input.isEmpty()) return List.of();
if (input.size()>100 || input.stream().anyMatch(s -> s==null || blank(s.customerId()) || blank(s.sourceKey()) || blank(s.destinationKey()))) {
throw new IllegalArgumentException("每次最多核对100个完整的预约来源");
}
var requested=input.stream().distinct().toList();
var externalRefs=requested.stream().filter(s -> s.sourceKey().startsWith("BOOKING:"))
.map(s -> new AppointmentExternalSourceGateway.SourceRef(s.customerId(),s.sourceKey().substring(8))).toList();
var bookings=externalRefs.isEmpty() ? List.<AppointmentExternalSourceGateway.Source>of() : external.resolve(externalRefs);
var bookingMap=bookings.stream().collect(Collectors.toMap(s -> key(s.customerId(),"BOOKING:"+s.bookingNo()),s -> s));
var refs=new LinkedHashMap<SourceRef,AppointmentCoverageMapper.OrderRef>();
for (var source:requested) {
if (source.sourceKey().startsWith("ORDER:")) {
try {
long id=Long.parseLong(source.sourceKey().substring(6));
if (id>0) refs.put(source,new AppointmentCoverageMapper.OrderRef(source.customerId(),id));
} catch (NumberFormatException ignored) { /* Invalid source is returned explicitly below. */ }
} else if (source.sourceKey().startsWith("BOOKING:")) {
var booking=bookingMap.get(key(source.customerId(),source.sourceKey()));
if (booking!=null && booking.orderId()!=null && Objects.equals(source.destinationKey(),booking.destinationKey())) {
refs.put(source,new AppointmentCoverageMapper.OrderRef(source.customerId(),booking.orderId()));
}
}
}
var unique=refs.values().stream().distinct().toList();
if (unique.size()!=refs.size()) throw new IllegalArgumentException("同一货物存在重复来源,请保留原外接登记来源后再核对");
var orders=unique.isEmpty() ? List.<AppointmentCoverageMapper.Source>of() : mapper.sources(unique);
var orderMap=orders.stream().collect(Collectors.toMap(s -> new AppointmentCoverageMapper.OrderRef(s.customerId(),s.orderId()),s -> s));
var facts=unique.isEmpty() ? List.<AppointmentCoverageMapper.Fact>of() : mapper.appointments(unique);
var byOrder=facts.stream().collect(Collectors.groupingBy(f -> new AppointmentCoverageMapper.OrderRef(f.customerId(),f.orderId())));
var output=new ArrayList<Coverage>();
for (var source:requested) {
var booking=bookingMap.get(key(source.customerId(),source.sourceKey()));
if (booking!=null && !source.destinationKey().equals(booking.destinationKey())) {
output.add(empty(source,"DESTINATION_MISMATCH","DESTINATION",version(booking.recordVersion()),true)); continue;
}
if (booking!=null && !Set.of("CONFIRMED","RECEIVED","BOUND").contains(booking.bookingStatus()==null ? "" : booking.bookingStatus())) {
output.add(empty(source,"SOURCE_NOT_READY","CARRIER_CONFIRMATION",version(booking.recordVersion()),false)); continue;
}
var order=orderMap.get(refs.get(source));
if (order==null) {
output.add(empty(source,booking==null ? "SOURCE_NOT_FOUND" : "SOURCE_NOT_MATERIALIZED","REAL_CARGO_MATERIALS",booking==null ? null : version(booking.recordVersion()),false)); continue;
}
if (!"FBA".equals(order.destinationType()) || !source.destinationKey().equals("FBA:"+order.fbaWarehouseCode())) {
output.add(empty(source,"DESTINATION_MISMATCH","DESTINATION",version(order.version()),true)); continue;
}
var relevant=byOrder.getOrDefault(refs.get(source),List.of());
var distinct=new LinkedHashMap<String,Appointment>();
var seen=new HashSet<Long>(); long confirmed=0, covered=0; boolean invalid=false, changed=false;
for (var fact:relevant) {
if (!Objects.equals(order.fbaWarehouseCode(),fact.warehouseCode()) || fact.packages()==null || fact.packages()<=0 || !seen.add(fact.lineId())) { invalid=true; continue; }
covered+=fact.packages();
if ("SUCCESS".equals(fact.status()) && fact.appointmentAt()!=null) confirmed+=fact.packages();
boolean success="SUCCESS".equals(fact.status());
distinct.putIfAbsent(fact.appointmentNo(),new Appointment(fact.appointmentNo(),success ? fact.appointmentNo() : null,fact.status(),success ? fact.appointmentAt() : null,success ? fact.appointmentEndAt() : null,null));
changed|=order.updatedAt()==null || fact.updatedAt()==null || order.updatedAt().isAfter(fact.updatedAt());
}
var missing=new ArrayList<String>();
String status;
if (invalid || order.packages()==null || order.packages()<=0 || covered>order.packages()) { status="REVIEW_REQUIRED"; missing.add("CARGO_QUANTITY_REVIEW"); }
else if (covered==0) { status="UNASSIGNED"; missing.add("RECEIVING_APPOINTMENT"); }
else if (covered<order.packages()) { status="PARTIAL"; missing.add("UNCOVERED_CARGO"); }
else if (confirmed<order.packages()) { status="PENDING"; missing.add("RECEIVING_CONFIRMATION"); }
else { status="CONFIRMED"; }
if (confirmed>0) missing.add("APPOINTMENT_TIMEZONE_UNVERIFIED");
if (order.shipmentReferencesMissing()) missing.add("FBA_SHIPMENT_REFERENCES");
if (changed) missing.add("SOURCE_CHANGED_REVIEW");
output.add(new Coverage(source.customerId(),source.sourceKey(),source.destinationKey(),status,List.copyOf(distinct.values()),List.copyOf(missing),
(booking==null ? "" : "BOOKING:"+version(booking.recordVersion())+"/")+"ORDER:"+version(order.version()),changed || invalid));
}
return List.copyOf(output);
}
private static Coverage empty(SourceRef s,String status,String missing,String version,boolean review) {
return new Coverage(s.customerId(),s.sourceKey(),s.destinationKey(),status,List.of(),List.of(missing),version,review);
}
private static String key(String customerId,String sourceKey) { return customerId+"\n"+sourceKey; }
private static String version(Number version) { return version==null ? null : version.toString(); }
private static boolean blank(String value) { return value==null || value.isBlank(); }
}
@@ -0,0 +1,11 @@
package com.baiyue.lastleg.appointmentplanning;
import java.util.List;
/** Implemented by the external-booking owner; planning must not query Fleet's persistence directly. */
public interface AppointmentExternalSourceGateway {
record SourceRef(String customerId, String bookingNo) { }
record Source(String customerId, String bookingNo, String tripNo, String bookingStatus,
String warehouseId, String destinationKey, Long orderId, Integer packageCount, Long recordVersion) { }
List<Source> resolve(List<SourceRef> sources);
}
@@ -0,0 +1,28 @@
package com.baiyue.lastleg.appointmentplanning;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import java.time.LocalDate;
import java.time.LocalDateTime;
@Data
@TableName("ll_appointment_followup")
public class AppointmentFollowup {
@TableId(type = IdType.INPUT) private Long id;
private Long sourceOrderId;
private String sourceKey;
private String customerId;
private String stage;
private Integer version;
private String contactedParty;
private String result;
private String nextAction;
private LocalDate followupDate;
private String operatorId;
private String operatorName;
private String commandKey;
private String requestHash;
private LocalDateTime createdAt;
}
@@ -0,0 +1,19 @@
package com.baiyue.lastleg.appointmentplanning;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
import java.util.List;
@Mapper
public interface AppointmentFollowupMapper extends BaseMapper<AppointmentFollowup> {
@Select("SELECT * FROM ll_appointment_followup WHERE source_key=#{sourceKey} AND customer_id=#{customerId} AND stage=#{stage} AND command_key=#{key}")
AppointmentFollowup command(@Param("sourceKey") String sourceKey, @Param("customerId") String customerId, @Param("stage") String stage, @Param("key") String key);
@Select("SELECT COALESCE(MAX(version),0) FROM ll_appointment_followup WHERE source_key=#{sourceKey} AND customer_id=#{customerId} AND stage=#{stage}")
int version(@Param("sourceKey") String sourceKey, @Param("customerId") String customerId, @Param("stage") String stage);
@Select("SELECT * FROM ll_appointment_followup WHERE source_key=#{sourceKey} AND customer_id=#{customerId} ORDER BY created_at DESC,id DESC LIMIT #{size} OFFSET #{offset}")
List<AppointmentFollowup> page(@Param("sourceKey") String sourceKey, @Param("customerId") String customerId, @Param("size") int size, @Param("offset") long offset);
@Select("SELECT COUNT(*) FROM ll_appointment_followup WHERE source_key=#{sourceKey} AND customer_id=#{customerId}")
long count(@Param("sourceKey") String sourceKey, @Param("customerId") String customerId);
}
@@ -0,0 +1,83 @@
package com.baiyue.lastleg.appointmentplanning;
import com.baomidou.mybatisplus.core.toolkit.IdWorker;
import com.baiyue.common.core.api.PageResult;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import jakarta.validation.constraints.*;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.server.ResponseStatusException;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.util.HexFormat;
import java.nio.charset.StandardCharsets;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.ZoneOffset;
import java.time.Instant;
import java.util.Set;
@Service
public class AppointmentFollowupService {
private final AppointmentSourceService sources;
private final AppointmentFollowupMapper mapper;
private final ObjectMapper json;
public AppointmentFollowupService(AppointmentSourceService sources, AppointmentFollowupMapper mapper, ObjectMapper json) {
this.sources = sources; this.mapper = mapper; this.json = json;
}
public record Save(@NotBlank @Size(max=32) String stage, @Min(0) int expectedVersion,
@NotBlank @Size(max=64) String idempotencyKey,
@Size(max=200) String contactedParty, @NotBlank @Size(max=1000) String result,
@Size(max=500) String nextAction, LocalDate followupDate) { }
public record Record(String stage, int version, String contactedParty, String result, String nextAction,
LocalDate followupDate, String operatorName, Instant createdAt) { }
public record History(PageResult<Record> history, java.util.Map<String,Integer> versions) { }
private static final Set<String> STAGES = Set.of("DRAYAGE_CONTACT", "TERMINAL_APPOINTMENT", "WAREHOUSE_RECEIVING", "FBA_RECEIVING");
public History history(String sourceKey, String customerId, int page, int size) {
if (page<1 || size<1 || size>100) throw new ResponseStatusException(HttpStatus.BAD_REQUEST,"分页参数无效");
long total = mapper.count(sourceKey,customerId);
if (total == 0) sources.resolve(sourceKey,customerId,null);
var records = mapper.page(sourceKey,customerId,size,(long)(page-1)*size).stream().map(AppointmentFollowupService::view).toList();
var versions = new java.util.LinkedHashMap<String,Integer>();
STAGES.stream().sorted().forEach(stage -> versions.put(stage, mapper.version(sourceKey,customerId,stage)));
return new History(new PageResult<>(records,page,size,total,(total+size-1)/size),versions);
}
@Transactional(isolation = org.springframework.transaction.annotation.Isolation.READ_COMMITTED)
public Record save(String sourceKey, String customerId, Save request, String operatorId, String operatorName) {
if (!STAGES.contains(request.stage())) throw new ResponseStatusException(HttpStatus.BAD_REQUEST,"请选择有效跟进事项");
if (request.followupDate()!=null && (request.nextAction()==null || request.nextAction().isBlank())) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST,"设置跟进日期时请填写下一步安排");
}
final String hash;
try { hash = HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest((customerId+"\n"+operatorId+"\n"+json.writeValueAsString(request)).getBytes(StandardCharsets.UTF_8))); }
catch (JsonProcessingException | NoSuchAlgorithmException error) { throw new IllegalStateException("无法保存跟进请求",error); }
var existing = mapper.command(sourceKey,customerId,request.stage(),request.idempotencyKey());
if (existing!=null) {
if (!hash.equals(existing.getRequestHash())) throw new ResponseStatusException(HttpStatus.CONFLICT,"重复请求内容不一致,请核对跟进记录");
return view(existing);
}
var source = sources.resolve(sourceKey,customerId,null);
if (!source.canFollowup()) throw new ResponseStatusException(HttpStatus.CONFLICT,"当前来源仅可查看,请先核对承运状态");
if (mapper.version(sourceKey,customerId,request.stage())!=request.expectedVersion()) throw new ResponseStatusException(HttpStatus.CONFLICT,"跟进记录已更新,请重新读取后提交");
var value = new AppointmentFollowup(); value.setId(IdWorker.getId()); value.setSourceOrderId(source.orderId()); value.setSourceKey(source.sourceKey()); value.setCustomerId(source.customerId());
value.setStage(request.stage()); value.setVersion(request.expectedVersion()+1); value.setContactedParty(clean(request.contactedParty()));
value.setResult(request.result().trim()); value.setNextAction(clean(request.nextAction())); value.setFollowupDate(request.followupDate());
value.setOperatorId(operatorId); value.setOperatorName(clean(operatorName)); value.setCommandKey(request.idempotencyKey());
value.setRequestHash(hash); value.setCreatedAt(LocalDateTime.now(ZoneOffset.UTC));
try { mapper.insert(value); }
catch (org.springframework.dao.DuplicateKeyException conflict) {
var raced = mapper.command(sourceKey,customerId,request.stage(),request.idempotencyKey());
if (raced!=null && hash.equals(raced.getRequestHash())) return view(raced);
throw new ResponseStatusException(HttpStatus.CONFLICT,"跟进记录已更新或请求内容冲突,请重新读取",conflict);
}
return view(value);
}
private static String clean(String value) { return value==null || value.isBlank() ? null : value.trim(); }
private static Record view(AppointmentFollowup row) {
return new Record(row.getStage(),row.getVersion(),row.getContactedParty(),row.getResult(),row.getNextAction(),row.getFollowupDate(),row.getOperatorName(),row.getCreatedAt().toInstant(ZoneOffset.UTC));
}
}
@@ -0,0 +1,70 @@
package com.baiyue.lastleg.appointmentplanning;
import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.common.core.api.PageResult;
import com.baiyue.common.security.AuthenticatedUser;
import org.springframework.http.HttpStatus;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestBody;
import jakarta.validation.Valid;
import org.springframework.web.server.ResponseStatusException;
@RestController
@RequestMapping("/api/platform/v1/last-leg/appointment-planning")
public class AppointmentPlanningController {
private final AppointmentCargoService service;
private final AppointmentFollowupService followups;
private final AppointmentSourceService sources;
private final AppointmentCoverageService coverage;
public AppointmentPlanningController(AppointmentCargoService service, AppointmentFollowupService followups,
AppointmentSourceService sources, AppointmentCoverageService coverage) {
this.service = service; this.followups = followups; this.sources = sources; this.coverage = coverage;
}
public record Resolved(AppointmentSourceService.Source source, AppointmentCoverageService.Coverage coverage) { }
@GetMapping("/sources/resolve")
@PreAuthorize("hasAuthority('platform:appointment:list') and hasAuthority('platform:overseas-warehouse:last-mile-order:list')")
public ApiResponse<Resolved> resolve(@AuthenticationPrincipal AuthenticatedUser user,
@RequestParam String sourceKey, @RequestParam String customerId, @RequestParam(required=false) String destinationKey) {
requirePlatform(user);
var source = sources.resolve(sourceKey,customerId,destinationKey);
var result = coverage.query(java.util.List.of(new AppointmentCoverageService.SourceRef(source.customerId(),source.sourceKey(),source.destinationKey())));
return ApiResponse.success(new Resolved(source,result.get(0)));
}
@GetMapping("/cargo")
@PreAuthorize("hasAuthority('platform:appointment:list') and hasAuthority('platform:overseas-warehouse:last-mile-order:list')")
public ApiResponse<PageResult<AppointmentCargo>> cargo(@AuthenticationPrincipal AuthenticatedUser user,
@RequestParam(defaultValue = "1") int page, @RequestParam(defaultValue = "20") int size,
@RequestParam(required = false) String warehouseId, @RequestParam(required = false) String destination,
@RequestParam(required = false) String keyword) {
requirePlatform(user);
return ApiResponse.success(service.page(page, size, warehouseId, destination, keyword));
}
@GetMapping("/sources/{sourceKey}/follow-ups")
@PreAuthorize("hasAuthority('platform:appointment:list') and hasAuthority('platform:overseas-warehouse:last-mile-order:list')")
public ApiResponse<AppointmentFollowupService.History> history(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable String sourceKey, @RequestParam String customerId, @RequestParam(defaultValue="1") int page, @RequestParam(defaultValue="20") int size) {
requirePlatform(user); return ApiResponse.success(followups.history(sourceKey,customerId,page,size));
}
@PostMapping("/sources/{sourceKey}/follow-ups")
@PreAuthorize("hasAuthority('platform:appointment:list') and hasAuthority('platform:overseas-warehouse:last-mile-order:list') and hasAuthority('platform:appointment:followup')")
public ApiResponse<AppointmentFollowupService.Record> followup(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable String sourceKey, @RequestParam String customerId, @Valid @RequestBody AppointmentFollowupService.Save request) {
requirePlatform(user);
return ApiResponse.success(followups.save(sourceKey,customerId,request,user.userId().toString(),user.displayName()==null ? user.username() : user.displayName()));
}
private static void requirePlatform(AuthenticatedUser user) {
if (user == null || user.userId()==null) throw new ResponseStatusException(HttpStatus.UNAUTHORIZED);
if (!"PLATFORM".equals(user.appCode()) || !Long.valueOf(0).equals(user.tenantId())) throw new ResponseStatusException(HttpStatus.FORBIDDEN);
}
}
@@ -0,0 +1,83 @@
package com.baiyue.lastleg.appointmentplanning;
import java.time.Instant;
import java.util.HashSet;
import java.util.List;
import java.util.Objects;
import java.util.Set;
/** Stage-specific planning rules. Planning never creates stock, vehicles or carrier confirmations. */
public final class AppointmentPlanningPolicy {
private AppointmentPlanningPolicy() { }
public record Cargo(String sourceKey, String customerId, String originWarehouseId,
String destinationKey, String originCountry, String destinationCountry,
Integer packages, Instant readyFrom, Instant readyTo,
boolean eligibleForPlanning, String blockReason) { }
public record Group(String originWarehouseId, String destinationKey, long packages,
int sourceCount, int customerCount, Instant readyFrom, Instant readyTo,
boolean readinessIncomplete) { }
public record Forecast(String sourceKey, String sourceVersion, Instant eta, Instant readyAt) { }
public record Impact(String sourceKey, String reason, Instant previousEta, Instant latestEta,
Instant confirmedAt, boolean reviewRequired) { }
public static Group group(List<Cargo> cargo) {
if (cargo == null || cargo.isEmpty()) throw new IllegalArgumentException("请选择需要安排的真实货物");
if (cargo.size() > 500) throw new IllegalArgumentException("每批最多选择500条来源货物");
Set<String> sources = new HashSet<>();
Set<String> customers = new HashSet<>();
String origin = null, destination = null;
long packages = 0;
Instant from = null, to = null;
boolean incomplete = false;
for (Cargo row : cargo) {
if (row == null || blank(row.sourceKey()) || blank(row.customerId())) {
throw new IllegalArgumentException("货物缺少稳定来源或客户归属");
}
if (!sources.add(row.sourceKey())) throw new IllegalArgumentException("同一来源货物不能重复选择");
if (!row.eligibleForPlanning()) throw new IllegalArgumentException(
blank(row.blockReason()) ? "所选货物暂不具备筹划资格" : row.blockReason());
if (!"US".equals(row.originCountry()) || !"US".equals(row.destinationCountry())) {
throw new IllegalArgumentException("请选择已确认美国起运点及目的地的货物");
}
if (blank(row.originWarehouseId()) || blank(row.destinationKey())) {
throw new IllegalArgumentException("货物缺少实际接仓点或目的地");
}
if (origin == null) { origin = row.originWarehouseId(); destination = row.destinationKey(); }
if (!origin.equals(row.originWarehouseId()) || !destination.equals(row.destinationKey())) {
throw new IllegalArgumentException("同批货物必须具有相同实际起运仓和目的地");
}
if (row.packages() == null || row.packages() <= 0) throw new IllegalArgumentException("货物箱数待核实,不能填默认数量");
packages = Math.addExact(packages, row.packages().longValue());
customers.add(row.customerId());
if (row.readyFrom() == null || row.readyTo() == null) incomplete = true;
if (row.readyFrom() != null && row.readyTo() != null && row.readyTo().isBefore(row.readyFrom())) {
throw new IllegalArgumentException("货物预计备妥窗口无效");
}
if (row.readyFrom() != null && (from == null || row.readyFrom().isAfter(from))) from = row.readyFrom();
if (row.readyTo() != null && (to == null || row.readyTo().isBefore(to))) to = row.readyTo();
}
if (from != null && to != null && to.isBefore(from)) throw new IllegalArgumentException("所选货物预计备妥窗口不相容,请分别安排");
return new Group(origin, destination, packages, sources.size(), customers.size(), from, to, incomplete);
}
/** A new forecast only requests review; it never changes an externally confirmed appointment. */
public static Impact compare(Forecast acknowledged, Forecast latest, Instant confirmedAt) {
if (acknowledged == null || latest == null || blank(latest.sourceKey())
|| !Objects.equals(acknowledged.sourceKey(), latest.sourceKey())) {
throw new IllegalArgumentException("只能比较同一真实货源的预测");
}
boolean changed = !Objects.equals(acknowledged.eta(), latest.eta())
|| !Objects.equals(acknowledged.readyAt(), latest.readyAt());
String reason = "UNCHANGED";
if (changed) {
reason = latest.eta() == null ? "FORECAST_MISSING" : "FORECAST_CHANGED";
if (confirmedAt != null && latest.readyAt() != null && latest.readyAt().isAfter(confirmedAt)) {
reason = "READINESS_AFTER_APPOINTMENT";
}
}
return new Impact(latest.sourceKey(), reason, acknowledged.eta(), latest.eta(), confirmedAt, changed);
}
private static boolean blank(String value) { return value == null || value.isBlank(); }
}
@@ -0,0 +1,86 @@
package com.baiyue.lastleg.appointmentplanning;
import com.baiyue.lastleg.mapper.LastlegOrderMapper;
import com.baiyue.lastleg.shipping.mapper.ContainerShippingGateMapper;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Service;
import org.springframework.web.server.ResponseStatusException;
import java.util.List;
import java.util.Objects;
import java.util.Set;
@Service
public class AppointmentSourceService {
public record Source(String sourceKey, String customerId, Long orderId, String inboundCode, String bookingNo,
String containerNo, String originWarehouseId, String originWarehouseName,
String destinationKey, String destinationWarehouseCode, Integer packages,
String sourceVersion, boolean canFollowup, List<String> blockReasons) { }
private final LastlegOrderMapper orders;
private final ContainerShippingGateMapper shipping;
private final AppointmentExternalSourceGateway external;
private final AppointmentWarehouseClient warehouses;
private final String token;
public AppointmentSourceService(LastlegOrderMapper orders, ContainerShippingGateMapper shipping,
AppointmentExternalSourceGateway external, AppointmentWarehouseClient warehouses,
@Value("${baiyue.security.internal-service-token:}") String token) {
this.orders=orders; this.shipping=shipping; this.external=external; this.warehouses=warehouses; this.token=token;
}
public Source resolve(String sourceKey,String customerId,String destinationKey) {
if (blank(sourceKey) || sourceKey.length()>160 || blank(customerId) || customerId.length()>128) throw bad("请选择完整的真实货源");
com.baiyue.lastleg.entity.LastlegOrder order=null;
String bookingNo=null,origin,destination,version;
Integer packages;
boolean followup=true;
List<String> reasons=List.of();
if (sourceKey.startsWith("ORDER:")) {
long id;
try { id=Long.parseLong(sourceKey.substring(6)); if (id<=0 || !sourceKey.equals("ORDER:"+id)) throw new NumberFormatException(); }
catch (NumberFormatException error) { throw bad("货源标识无效"); }
order=orders.selectById(id);
if (order==null || !customerId.equals(order.getCustomerId())) throw missing();
if (!"FIRST_LEG".equals(order.getOrderType())) throw bad("请从原外接登记继续跟进,避免重复建立货源");
var gate=order.getSourceContainerBookingId()==null ? null : shipping.selectById(order.getSourceContainerBookingId());
if (gate==null || gate.getNodeSequence()==null || gate.getNodeSequence()<40) throw bad("货物尚无已开船或后续实际船节点");
origin=order.getOverseasWarehouseId(); destination="FBA:"+order.getFbaWarehouseCode();
if (!"FBA".equals(order.getDestinationType())) throw bad("当前货源不是FBA送仓货物");
packages=order.getPackageCount(); version=order.getVersion()==null ? null : "ORDER:"+order.getVersion();
} else if (sourceKey.startsWith("BOOKING:")) {
bookingNo=sourceKey.substring(8);
if (blank(bookingNo)) throw bad("外接登记号不能为空");
var matches=external.resolve(List.of(new AppointmentExternalSourceGateway.SourceRef(customerId,bookingNo)));
if (matches.size()!=1) throw missing();
var source=matches.get(0);
if (!Objects.equals(source.customerId(),customerId) || !Objects.equals(source.bookingNo(),bookingNo)) throw missing();
origin=source.warehouseId(); destination=source.destinationKey(); packages=source.packageCount();
version=source.recordVersion()==null ? null : "BOOKING:"+source.recordVersion();
followup=Set.of("CONFIRMED","RECEIVED","BOUND").contains(source.bookingStatus()==null ? "" : source.bookingStatus());
if (!followup) reasons=List.of("当前登记尚未确认承运或已取消,仅可查看跟进记录");
if (source.orderId()!=null) {
order=orders.selectById(source.orderId());
if (order==null || !customerId.equals(order.getCustomerId())) throw missing();
if (!Objects.equals(order.getOverseasWarehouseId(),origin)) throw bad("来货的接仓点与原登记不一致,请先核对");
if (!"FBA".equals(order.getDestinationType()) || !Objects.equals(destination,"FBA:"+order.getFbaWarehouseCode())) throw bad("来货目的仓与原登记不一致,请先核对");
packages=order.getPackageCount();
}
} else throw bad("不支持的货源类型");
if (order!=null && Set.of("CANCELLED","COMPLETED").contains(order.getCurrentStatus()==null ? "" : order.getCurrentStatus())) {
followup=false; reasons=List.of("该货物已取消或完成,仅可查看跟进记录");
}
if (!blank(destinationKey) && !Objects.equals(destination,destinationKey)) throw bad("目的地已变化,请返回原业务重新核对");
if (destination==null || !destination.startsWith("FBA:")) throw bad("当前仅支持FBA收货跟进来源");
var catalog=catalog(); final String originId=origin; final String code=destination.substring(4);
var warehouse=catalog.stream().filter(w -> w.id()!=null && w.id().toString().equals(originId) && "US".equals(w.countryCode())
&& Set.of("OVERSEAS","OVERSEAS_PARTNER").contains(w.warehouseType()==null ? "" : w.warehouseType())).findFirst().orElseThrow(() -> bad("接仓点不在启用的美国仓目录中"));
if (catalog.stream().noneMatch(w -> "FBA".equals(w.warehouseType()) && "US".equals(w.countryCode()) && code.equals(w.warehouseCode()))) throw bad("目的仓不在启用的美国FBA目录中");
return new Source(sourceKey,customerId,order==null ? null : order.getId(),order==null ? null : order.getInboundCode(),bookingNo,
order==null ? null : order.getContainerNo(),origin,warehouse.warehouseName(),destination,code,packages,version,followup,reasons);
}
private List<AppointmentWarehouseClient.Warehouse> catalog() {
try { var result=warehouses.catalog(token); if (result==null || !result.successful() || result.data()==null) throw new IllegalStateException(); return result.data().stream().filter(Objects::nonNull).toList(); }
catch (RuntimeException error) { throw new ResponseStatusException(HttpStatus.SERVICE_UNAVAILABLE,"仓库目录暂不可用,请稍后重新核对",error); }
}
private static boolean blank(String value) { return value==null || value.isBlank(); }
private static ResponseStatusException bad(String message) { return new ResponseStatusException(HttpStatus.BAD_REQUEST,message); }
private static ResponseStatusException missing() { return new ResponseStatusException(HttpStatus.NOT_FOUND,"未找到当前客户的货物来源"); }
}
@@ -0,0 +1,42 @@
package com.baiyue.lastleg.appointmentplanning;
import java.time.DateTimeException;
import java.time.Instant;
import java.time.LocalDateTime;
import java.time.ZoneId;
import java.time.ZoneOffset;
import java.util.List;
/** Resolves a location's appointment window without using the operator's system time zone. */
public final class AppointmentTimePolicy {
private AppointmentTimePolicy() { }
public record Window(Instant start, Instant end, String timezone) { }
public static Window resolve(LocalDateTime start, LocalDateTime end, String timezone,
ZoneOffset startOffset, ZoneOffset endOffset) {
if (start == null) throw new IllegalArgumentException("请填写预约开始时间");
if (timezone == null || timezone.isBlank()) throw new IllegalArgumentException("请先补齐预约地点时区");
final ZoneId zone;
try { zone = ZoneId.of(timezone); }
catch (DateTimeException error) { throw new IllegalArgumentException("预约地点时区无效", error); }
if (zone instanceof ZoneOffset) throw new IllegalArgumentException("预约地点需要地区时区,不能仅填写固定时差");
Instant first = instant(start, zone, startOffset);
Instant last = end == null ? null : instant(end, zone, endOffset);
if (end == null && endOffset != null) throw new IllegalArgumentException("未填写结束时间,不能指定结束时差");
if (last != null && !last.isAfter(first)) throw new IllegalArgumentException("预约结束时间必须晚于开始时间");
return new Window(first, last, zone.getId());
}
private static Instant instant(LocalDateTime value, ZoneId zone, ZoneOffset selected) {
List<ZoneOffset> offsets = zone.getRules().getValidOffsets(value);
if (offsets.isEmpty()) throw new IllegalArgumentException("该当地时间因夏令时切换不存在,请重新选择");
if (selected == null && offsets.size() > 1) {
throw new IllegalArgumentException("该当地时间在夏令时切换期间出现两次,请明确时差");
}
if (selected != null && !offsets.contains(selected)) {
throw new IllegalArgumentException("所选时差与预约地点及日期不符");
}
return value.toInstant(selected == null ? offsets.get(0) : selected);
}
}
@@ -0,0 +1,14 @@
package com.baiyue.lastleg.appointmentplanning;
import com.baiyue.common.core.api.ApiResponse;
import org.springframework.cloud.openfeign.FeignClient;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestHeader;
import java.util.List;
@FeignClient(name = "warehouse-service", contextId = "appointmentWarehouseClient", url = "${baiyue.clients.domestic-warehouse.url:}")
public interface AppointmentWarehouseClient {
@GetMapping("/internal/warehouses/order-catalog")
ApiResponse<List<Warehouse>> catalog(@RequestHeader("X-Internal-Service-Token") String token);
record Warehouse(Long id, String warehouseCode, String warehouseName, String warehouseType, String countryCode) { }
}
@@ -0,0 +1,58 @@
package com.baiyue.lastleg.dispatch.fleet;
import com.baiyue.common.core.api.PageResult;
import com.baiyue.lastleg.dispatch.dto.DispatchRequests;
import com.baiyue.lastleg.dispatch.service.DispatchService;
import com.baiyue.lastleg.fleet.trip.FleetBookingIncomingGateway;
import com.baiyue.lastleg.fleet.trip.FleetDispatchGateway;
import com.baiyue.lastleg.fleet.trip.FleetDispatchPlanningGateway;
import org.springframework.context.annotation.Lazy;
import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Propagation;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.server.ResponseStatusException;
import org.springframework.http.HttpStatus;
import java.util.*;
@Component
@Transactional(propagation=Propagation.MANDATORY)
public class FleetDispatchPlanningAdapter implements FleetDispatchPlanningGateway {
private final FleetDispatchPlanningMapper mapper;
private final DispatchService dispatches;
private final FleetDispatchGateway cargo;
public FleetDispatchPlanningAdapter(FleetDispatchPlanningMapper mapper,@Lazy DispatchService dispatches,FleetDispatchGateway cargo) {
this.mapper=mapper;this.dispatches=dispatches;this.cargo=cargo;
}
public PageResult<Candidate> candidates(String tripNo,String warehouseId,String keyword,int page,int size) {
var result=mapper.candidates(new com.baomidou.mybatisplus.extension.plugins.pagination.Page<>(page,size),tripNo,warehouseId,keyword);
return new PageResult<>(result.getRecords(),result.getCurrent(),result.getSize(),result.getTotal(),result.getPages());
}
public String resolveIncoming(String customerId,FleetBookingIncomingGateway.Evidence receipt,String operator) {
if(receipt==null||receipt.inboundCode()==null||!"READY_FOR_DISPATCH".equals(receipt.status())||receipt.pallets().isEmpty())
throw conflict("请先完成真实收货和实收核对");
var existing=mapper.sourceDispatches(customerId,receipt.inboundCode());
if(existing.size()>1)throw conflict("本次来货已有多张排车安排,请调度核查:"+String.join("、",existing));
String number;
if(existing.isEmpty()) {
var ids=mapper.receivedPlanPallets(customerId,receipt.inboundCode());
if(ids.isEmpty())throw conflict("实收来源没有可用于排车的真实托盘");
number=dispatches.preArrange(customerId,operator,new DispatchRequests.PreArrange(ids,null,null,null,null)).dispatchNo();
} else number=existing.get(0);
validateIncomingDispatch(number,customerId,receipt);
return number;
}
public void validateIncomingDispatch(String number,String customerId,FleetBookingIncomingGateway.Evidence receipt) {
var snapshot=cargo.lockForPlanning(number);
if(!customerId.equals(snapshot.customerId())||!receipt.warehouseId().equals(snapshot.warehouseId()))throw conflict("排车客户或起运仓与来货不一致");
var expected=new TreeMap<String,Integer>();
for(var p:receipt.pallets())expected.merge(p.warehousePalletId(),p.packages(),Math::addExact);
var selected=new TreeMap<String,Integer>();
for(var item:cargo.sourceCargo(number)) {
if(!Objects.equals(receipt.inboundCode(),item.inboundCode())||!Objects.equals(receipt.destinationKey(),item.destinationKey())||item.warehousePalletId()==null)
throw conflict("候选排车包含其他来源或目的地,请调度核查 "+number);
selected.merge(item.warehousePalletId(),item.packages(),Math::addExact);
}
if(!expected.equals(selected))throw conflict("候选排车未完整覆盖本票全部实收货物,请调度核查 "+number);
}
private static ResponseStatusException conflict(String message){return new ResponseStatusException(HttpStatus.CONFLICT,message);}
}
@@ -0,0 +1,30 @@
package com.baiyue.lastleg.dispatch.fleet;
import com.baomidou.mybatisplus.core.metadata.IPage;
import com.baiyue.lastleg.fleet.trip.FleetDispatchPlanningGateway;
import org.apache.ibatis.annotations.*;
import java.util.List;
@Mapper
public interface FleetDispatchPlanningMapper {
@Select({"<script>SELECT d.dispatch_no,d.customer_id,d.overseas_warehouse_id AS warehouse_id,d.package_count,d.route",
"FROM ll_dispatch_order d WHERE d.overseas_warehouse_id=#{warehouseId} AND d.status='PENDING_DEPARTURE'",
"AND d.package_count>0 AND COALESCE(d.picked_package_count,0)=0 AND COALESCE(d.shipped_package_count,0)=0",
"AND NOT EXISTS(SELECT 1 FROM ll_fleet_trip_dispatch b WHERE b.dispatch_id=d.id)",
"AND NOT EXISTS(SELECT 1 FROM ll_dispatch_item di JOIN ll_fleet_external_booking eb ON eb.incoming_no=di.inbound_code",
"WHERE di.dispatch_order_id=d.id AND (eb.trip_no!=#{tripNo} OR eb.status!='RECEIVED'))",
"AND NOT EXISTS(SELECT 1 FROM ll_dispatch_pda_task t WHERE t.dispatch_order_id=d.id AND t.status!='PDA_PENDING')",
"AND NOT EXISTS(SELECT 1 FROM ll_outbound_order o WHERE o.dispatch_order_id=d.id AND o.status!='WAIT_OUTBOUND')",
"<if test='keyword != null and keyword != &quot;&quot;'>AND (d.dispatch_no LIKE CONCAT('%',#{keyword},'%') OR d.route LIKE CONCAT('%',#{keyword},'%'))</if>",
"ORDER BY d.updated_at DESC,d.id DESC</script>"})
IPage<FleetDispatchPlanningGateway.Candidate> candidates(IPage<?> page,@Param("tripNo") String tripNo,@Param("warehouseId") String warehouseId,@Param("keyword") String keyword);
@Select("SELECT DISTINCT d.dispatch_no FROM ll_dispatch_order d JOIN ll_dispatch_item i ON i.dispatch_order_id=d.id AND i.customer_id=d.customer_id " +
"WHERE d.customer_id=#{customerId} AND i.inbound_code=#{inboundCode} AND d.status!='CANCELLED' ORDER BY d.dispatch_no")
List<String> sourceDispatches(@Param("customerId") String customerId,@Param("inboundCode") String inboundCode);
@Select("SELECT DISTINCT p.id FROM ll_inbound_plan_pallet p JOIN ll_inbound_plan ip ON ip.id=p.plan_id " +
"JOIN ll_inbound_plan_pallet_allocation a ON a.pallet_id=p.id JOIN ll_inbound_plan_line l ON l.id=a.line_id " +
"WHERE ip.customer_id=#{customerId} AND l.inbound_code=#{inboundCode} AND p.warehouse_pallet_id IS NOT NULL ORDER BY p.id")
List<Long> receivedPlanPallets(@Param("customerId") String customerId,@Param("inboundCode") String inboundCode);
}
@@ -0,0 +1,17 @@
package com.baiyue.lastleg.fleet.business;
import com.baiyue.lastleg.appointmentplanning.AppointmentExternalSourceGateway;
import org.springframework.stereotype.Component;
import java.util.List;
/** Appointment resolves aliases through Fleet's read boundary, never by copying cargo. */
@Component
public class FleetAppointmentSourceAdapter implements AppointmentExternalSourceGateway {
private final FleetPoolSourceResolver sources;
public FleetAppointmentSourceAdapter(FleetPoolSourceResolver sources){this.sources=sources;}
@Override
public List<Source> resolve(List<SourceRef> refs) {
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();
}
}
@@ -39,6 +39,13 @@ public class FleetPoolOrderBridge {
this.links=links;this.loose=loose;this.looseHistory=looseHistory;this.trucks=trucks; 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.truckHistory=truckHistory;this.numbers=numbers;this.json=json;this.deliveryEvidence=deliveryEvidence;
} }
@Transactional(readOnly=true)
public Map<Long,String> fulfillmentStages(List<Long> bookingIds) {
if(bookingIds.isEmpty())return Map.of();
var result=new HashMap<Long,String>();
for(var link:links.bookings(bookingIds))result.put(link.getBookingId(),link.getFulfillmentStage());
return Map.copyOf(result);
}
@Transactional(propagation=Propagation.MANDATORY) @Transactional(propagation=Propagation.MANDATORY)
public BusinessOrderRef confirm(ConfirmedBooking in,String operator) { public BusinessOrderRef confirm(ConfirmedBooking in,String operator) {
validate(in); validate(in);
@@ -7,6 +7,15 @@ import java.util.List;
@Mapper @Mapper
public interface FleetPoolOrderMapper extends BaseMapper<FleetPoolOrderLink> { public interface FleetPoolOrderMapper extends BaseMapper<FleetPoolOrderLink> {
@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_external_booking b JOIN ll_fleet_trip t ON t.id=b.trip_id",
"LEFT JOIN ll_fleet_pool_order_link l ON l.booking_id=b.id AND l.customer_id=b.customer_id",
"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("SELECT * FROM ll_fleet_pool_order_link WHERE booking_id=#{id} FOR UPDATE") @Select("SELECT * FROM ll_fleet_pool_order_link WHERE booking_id=#{id} FOR UPDATE")
FleetPoolOrderLink lockBooking(long id); FleetPoolOrderLink lockBooking(long id);
@Select("SELECT * FROM ll_fleet_pool_order_link WHERE booking_no=#{no} FOR UPDATE") @Select("SELECT * FROM ll_fleet_pool_order_link WHERE booking_no=#{no} FOR UPDATE")
@@ -0,0 +1,37 @@
package com.baiyue.lastleg.fleet.business;
import java.util.List;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
/** Internal read boundary for appointment planning; caller must authorize every customer/source. */
@Service
public class FleetPoolSourceResolver {
public record SourceRef(String customerId, String bookingNo) { }
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) { }
public record SourceRow(String customerId, String bookingNo, String tripNo, String bookingStatus,
String warehouseId, String destinationKey, String destinationLabel, String incomingNo,
Long lastlegOrderId, String businessOrderNo, String fulfillmentStage, Long recordVersion,
String cargoJson, Integer expectedPalletCount, java.time.LocalDateTime estimatedArrivalAt) { }
private final com.fasterxml.jackson.databind.ObjectMapper json;
private final FleetPoolOrderMapper links;
public FleetPoolSourceResolver(FleetPoolOrderMapper links,com.fasterxml.jackson.databind.ObjectMapper json) { this.links = links; this.json=json; }
@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 -> {
Integer packages;
try {
var value=json.readTree(row.cargoJson()).get("packageCount");
if(value==null||value.isNull()) packages=null;
else { if(!value.isIntegralNumber()||!value.canConvertToInt())throw new IllegalStateException("预报箱数格式无效"); packages=value.intValue(); }
} catch(java.io.IOException e){throw new IllegalStateException("货物预报读取失败",e);}
return new Source(row.customerId(),row.bookingNo(),row.tripNo(),row.bookingStatus(),row.warehouseId(),row.destinationKey(),row.destinationLabel(),row.incomingNo(),row.lastlegOrderId(),row.businessOrderNo(),row.fulfillmentStage(),row.recordVersion(),packages,row.expectedPalletCount(),row.estimatedArrivalAt());
}).toList();
}
}
@@ -11,6 +11,8 @@ public interface FleetBookingIncomingGateway {
record Pallet(String warehousePalletId,String palletNo,int packages,BigDecimal weightKg,BigDecimal volumeCbm) { } record Pallet(String warehousePalletId,String palletNo,int packages,BigDecimal weightKg,BigDecimal volumeCbm) { }
record Evidence(String incomingNo,String inboundCode,String warehouseId,String destinationKey,String status, record Evidence(String incomingNo,String inboundCode,String warehouseId,String destinationKey,String status,
String receiptHash,List<Pallet> pallets,int receivedPackages) { } String receiptHash,List<Pallet> pallets,int receivedPackages) { }
record Source(String customerId,String incomingNo) { }
java.util.Map<Source,String> statuses(List<Source> sources);
Evidence create(String operator, Incoming incoming); Evidence create(String operator, Incoming incoming);
Evidence inspect(String customerId,String incomingNo,boolean lock); Evidence inspect(String customerId,String incomingNo,boolean lock);
Evidence confirm(String customerId,String incomingNo,String operator,String key); Evidence confirm(String customerId,String incomingNo,String operator,String key);
@@ -35,12 +35,17 @@ public class FleetBookingService {
private final FleetBookingQuoteContext quoteContext; private final FleetBookingQuoteContext quoteContext;
private final FleetQuoteCandidateService quotes; private final FleetQuoteCandidateService quotes;
@jakarta.annotation.Resource private FleetPoolOrderBridge poolOrders; @jakarta.annotation.Resource private FleetPoolOrderBridge poolOrders;
@jakarta.annotation.Resource private FleetDispatchPlanningGateway planning;
@jakarta.annotation.Resource private FleetCapacityOfferCommandMapper commands;
public FleetBookingService(FleetTripMapper trips,FleetBookingMapper bookings,FleetBookingReservations reservations, public FleetBookingService(FleetTripMapper trips,FleetBookingMapper bookings,FleetBookingReservations reservations,
FleetTripService history,FleetTripExecutionService execution,FleetCustomerDirectory customers,ObjectMapper json,FleetBookingIncomingGateway incoming, FleetTripService history,FleetTripExecutionService execution,FleetCustomerDirectory customers,ObjectMapper json,FleetBookingIncomingGateway incoming,
FleetBookingQuoteContext quoteContext,FleetQuoteCandidateService quotes){ FleetBookingQuoteContext quoteContext,FleetQuoteCandidateService quotes){
this.trips=trips;this.bookings=bookings;this.reservations=reservations;this.history=history;this.execution=execution;this.customers=customers;this.json=json;this.incoming=incoming; this.trips=trips;this.bookings=bookings;this.reservations=reservations;this.history=history;this.execution=execution;this.customers=customers;this.json=json;this.incoming=incoming;
this.quoteContext=quoteContext;this.quotes=quotes; this.quoteContext=quoteContext;this.quotes=quotes;
} }
public java.util.Map<FleetBookingIncomingGateway.Source,String> incomingStatuses(java.util.List<FleetBookingEntity> rows) {
return incoming.statuses(rows.stream().filter(r->r.getIncomingNo()!=null).map(r->new FleetBookingIncomingGateway.Source(r.getCustomerId(),r.getIncomingNo())).distinct().toList());
}
@Transactional(readOnly=true,isolation=org.springframework.transaction.annotation.Isolation.REPEATABLE_READ) @Transactional(readOnly=true,isolation=org.springframework.transaction.annotation.Isolation.REPEATABLE_READ)
public FleetBookingModels.ListResult list(String tripNo){var trip=trip(tripNo,false);return new FleetBookingModels.ListResult(trip.getRecordVersion(),reservations.responses(trip.getId()));} public FleetBookingModels.ListResult list(String tripNo){var trip=trip(tripNo,false);return new FleetBookingModels.ListResult(trip.getRecordVersion(),reservations.responses(trip.getId()));}
@Transactional(readOnly=true,isolation=org.springframework.transaction.annotation.Isolation.REPEATABLE_READ) @Transactional(readOnly=true,isolation=org.springframework.transaction.annotation.Isolation.REPEATABLE_READ)
@@ -125,9 +130,12 @@ public class FleetBookingService {
requireTrip(trip,request.tripRecordVersion());requireVersion(row,request.recordVersion()); requireTrip(trip,request.tripRecordVersion());requireVersion(row,request.recordVersion());
if(!"CONFIRMED".equals(row.getStatus())||row.getIncomingNo()!=null)throw conflict("仅已确认且尚未登记来货的预订允许此操作"); if(!"CONFIRMED".equals(row.getStatus())||row.getIncomingNo()!=null)throw conflict("仅已确认且尚未登记来货的预订允许此操作");
customers.required(Long.parseLong(row.getCustomerId())); customers.required(Long.parseLong(row.getCustomerId()));
var arrival=request.estimatedArrivalAt()==null?trip.getPlannedDepartureAt().toInstant(ZoneOffset.UTC).minusSeconds(7200):request.estimatedArrivalAt(); var arrival=request.estimatedArrivalAt()!=null?request.estimatedArrivalAt():row.getEstimatedArrivalAt()==null?null:row.getEstimatedArrivalAt().toInstant(ZoneOffset.UTC);
if(arrival==null)throw conflict("请确认预计到仓时间,不自动推算到仓安排");
if(arrival.isAfter(trip.getPlannedDepartureAt().toInstant(ZoneOffset.UTC)))throw conflict("预计到仓不得晚于计划发车时间");
var instructions=trim(request.deliveryInstructions())!=null?request.deliveryInstructions():reservations.cargo(row).remark();
var evidence=incoming.create(operator,new FleetBookingIncomingGateway.Incoming(row.getCustomerId(),trip.getWarehouseId().toString(),trip.getWarehouseName(),row.getBookingNo(),reservations.cargo(row), var evidence=incoming.create(operator,new FleetBookingIncomingGateway.Incoming(row.getCustomerId(),trip.getWarehouseId().toString(),trip.getWarehouseName(),row.getBookingNo(),reservations.cargo(row),
LocalDateTime.ofInstant(arrival,java.time.ZoneId.systemDefault()),request.deliveryInstructions(),request.privateAddress(),request.postalCode(),request.recipientName(),request.recipientPhone())); LocalDateTime.ofInstant(arrival,java.time.ZoneId.systemDefault()),instructions,request.privateAddress(),request.postalCode(),request.recipientName(),request.recipientPhone()));
requireEvidence(trip,row,evidence); requireEvidence(trip,row,evidence);
row.setIncomingNo(evidence.incomingNo());row.setIncomingKey(request.idempotencyKey());row.setIncomingHash(hash); row.setIncomingNo(evidence.incomingNo());row.setIncomingKey(request.idempotencyKey());row.setIncomingHash(hash);
save(trip,row,operator,"BOOKING_INCOMING","登记外接来货 "+evidence.incomingNo());return result(trip,row,false); save(trip,row,operator,"BOOKING_INCOMING","登记外接来货 "+evidence.incomingNo());return result(trip,row,false);
@@ -169,12 +177,48 @@ public class FleetBookingService {
if(Objects.equals(row.getBindingKey(),request.idempotencyKey())){if(!hash.equals(row.getBindingHash()))throw conflict("关联发运请求内容不一致");return result(trip,row,true);} if(Objects.equals(row.getBindingKey(),request.idempotencyKey())){if(!hash.equals(row.getBindingHash()))throw conflict("关联发运请求内容不一致");return result(trip,row,true);}
requireTrip(trip,request.tripRecordVersion());requireVersion(row,request.recordVersion()); requireTrip(trip,request.tripRecordVersion());requireVersion(row,request.recordVersion());
if(!"RECEIVED".equals(row.getStatus()))throw conflict("请先确认外接实收货量和运费"); if(!"RECEIVED".equals(row.getStatus()))throw conflict("请先确认外接实收货量和运费");
if(row.getOfferId()!=null) {
var accepted=reservations.receipt(row);
var evidence=incoming.inspect(row.getCustomerId(),row.getIncomingNo(),true);requireEvidence(trip,row,evidence);
if(accepted==null||!Objects.equals(accepted.source().receiptHash(),evidence.receiptHash()))throw conflict("实收来源已变化,请重新核对");
planning.validateIncomingDispatch(request.dispatchNo(),row.getCustomerId(),evidence);
}
row.setBindingKey(request.idempotencyKey());row.setBindingHash(hash);bookings.updateById(row); row.setBindingKey(request.idempotencyKey());row.setBindingHash(hash);bookings.updateById(row);
var linked=history.bindDispatch(tripNo,operator,new FleetTripModels.BindingCommand(request.tripRecordVersion(),request.dispatchNo(),"关联外接预订 "+bookingNo)); var linked=history.bindDispatch(tripNo,operator,new FleetTripModels.BindingCommand(request.tripRecordVersion(),request.dispatchNo(),"关联外接预订 "+bookingNo));
var result=booking(trip,bookingNo); var result=booking(trip,bookingNo);
if(!"BOUND".equals(result.getStatus())||!Objects.equals(result.getDispatchNo(),request.dispatchNo().trim()))throw conflict("所选排车未包含此外接来货单的实际货物"); if(!"BOUND".equals(result.getStatus())||!Objects.equals(result.getDispatchNo(),request.dispatchNo().trim()))throw conflict("所选排车未包含此外接来货单的实际货物");
return new FleetBookingModels.Result(linked.trip().recordVersion(),reservations.response(result),false); return new FleetBookingModels.Result(linked.trip().recordVersion(),reservations.response(result),false);
} }
@Transactional
public FleetBookingModels.Result arrangeDispatch(String tripNo,String bookingNo,String operator,FleetBookingModels.Command request) {
var trip=trip(tripNo,true);String hash=hash(request);
var previous=commands.find(trip.getId(),operator,"BOOKING_ARRANGE",bookingNo,request.idempotencyKey());
if(previous!=null) {
if(!hash.equals(previous.getRequestHash()))throw conflict("排入本车请求内容不一致");
try {var saved=json.readValue(previous.getResultJson(),FleetBookingModels.Result.class);return new FleetBookingModels.Result(saved.tripRecordVersion(),saved.booking(),true);}
catch(java.io.IOException e){throw new IllegalStateException("原排入结果读取失败",e);}
}
String resourceBlock=execution.currentVehicleBlockReason(trip,true);var row=booking(trip,bookingNo);
requireTrip(trip,request.tripRecordVersion());requireVersion(row,request.recordVersion());
if(!"RECEIVED".equals(row.getStatus()))throw conflict("请先确认全部实收货量和运费");
var accepted=reservations.receipt(row);
var evidence=incoming.inspect(row.getCustomerId(),row.getIncomingNo(),true);requireEvidence(trip,row,evidence);
if(accepted==null||!Objects.equals(accepted.source().receiptHash(),evidence.receiptHash()))throw conflict("实收来源已变化,请重新核对");
if(resourceBlock!=null)throw conflict(resourceBlock);
var plan=execution.capacityPlan(tripNo);
if(!plan.planValid()||plan.blockReason()!=null)throw conflict("本车计划容量待核对,暂不能排入");
reservations.segments(trip,plan.capacityPlan(),row.getId(),row);
String dispatchNo=planning.resolveIncoming(row.getCustomerId(),evidence,operator);
var linked=history.bindDispatch(tripNo,operator,new FleetTripModels.BindingCommand(trip.getRecordVersion(),dispatchNo,"实收核对后排入本车 "+bookingNo));
row=booking(trip,bookingNo);
if(!"BOUND".equals(row.getStatus())||!Objects.equals(row.getDispatchNo(),dispatchNo))throw conflict("未完整关联本票实收来源");
row.setBindingKey(request.idempotencyKey());row.setBindingHash(hash);bookings.updateById(row);
var result=new FleetBookingModels.Result(linked.trip().recordVersion(),reservations.response(row),false);
var command=new FleetCapacityOfferCommand();command.setId(IdWorker.getId());command.setTripId(trip.getId());command.setOperatorId(operator);
command.setAction("BOOKING_ARRANGE");command.setScopeNo(bookingNo);command.setIdempotencyKey(request.idempotencyKey());command.setRequestHash(hash);
command.setResultJson(write(result));command.setCreatedAt(now());commands.insert(command);
return result;
}
private static void requireEvidence(FleetTripEntity trip,FleetBookingEntity row,FleetBookingIncomingGateway.Evidence evidence){ private static void requireEvidence(FleetTripEntity trip,FleetBookingEntity row,FleetBookingIncomingGateway.Evidence evidence){
if(evidence==null||evidence.incomingNo()==null||!Objects.equals(trip.getWarehouseId().toString(),evidence.warehouseId())||!Objects.equals(row.getDestinationKey(),evidence.destinationKey()))throw conflict("来货单仓库或目的地与外接承运约定不一致"); if(evidence==null||evidence.incomingNo()==null||!Objects.equals(trip.getWarehouseId().toString(),evidence.warehouseId())||!Objects.equals(row.getDestinationKey(),evidence.destinationKey()))throw conflict("来货单仓库或目的地与外接承运约定不一致");
if(row.getIncomingNo()!=null&&!Objects.equals(row.getIncomingNo(),evidence.incomingNo()))throw conflict("来货单引用不一致"); if(row.getIncomingNo()!=null&&!Objects.equals(row.getIncomingNo(),evidence.incomingNo()))throw conflict("来货单引用不一致");
@@ -21,7 +21,7 @@ public final class FleetCapacityOfferModels {
} }
public record InquiryQuery(@Min(1) Integer page,@Min(1) @Max(100) Integer size, public record InquiryQuery(@Min(1) Integer page,@Min(1) @Max(100) Integer size,
@Size(max=128) String keyword,@Size(max=64) String offerNo,@Size(max=32) String status, @Size(max=128) String keyword,@Size(max=64) String offerNo,@Size(max=32) String status,
@Pattern(regexp="MINE|ALL") String scope) { @Pattern(regexp="MINE|ALL|TODO") String scope) {
public InquiryQuery {page=page==null?1:page;size=size==null?20:size;scope=scope==null?"MINE":scope;} public InquiryQuery {page=page==null?1:page;size=size==null?20:size;scope=scope==null?"MINE":scope;}
} }
public record Write(@NotBlank @Size(max=64) String tripNo,@NotNull @Min(0) Long tripRecordVersion, public record Write(@NotBlank @Size(max=64) String tripNo,@NotNull @Min(0) Long tripRecordVersion,
@@ -50,12 +50,15 @@ public final class FleetCapacityOfferModels {
String contactName,String contactPhone,String remark,List<Destination> destinations,List<Segment> segments, String contactName,String contactPhone,String remark,List<Destination> destinations,List<Segment> segments,
boolean capacityKnown,boolean canCreateInquiry,boolean canConfirm,String blockReason,Actions actions) { } boolean capacityKnown,boolean canCreateInquiry,boolean canConfirm,String blockReason,Actions actions) { }
public record InquiryActions(boolean canEdit,boolean canConfirm,boolean canCancel,String blockReason) { } public record InquiryActions(boolean canEdit,boolean canConfirm,boolean canCancel,String blockReason) { }
public record Progress(String stage,String label,String reservation,String owner,String primaryAction,
String message,List<String> missingFields) { }
public record Inquiry(String bookingNo,String offerNo,String tripNo,String status,long recordVersion, public record Inquiry(String bookingNo,String offerNo,String tripNo,String status,long recordVersion,
long offerRecordVersion,long tripRecordVersion,String salesOwnerUserId,String customerId,String customerCode, long offerRecordVersion,long tripRecordVersion,String salesOwnerUserId,String customerId,String customerCode,
String customerName,String businessType,String deliveryMode,Instant estimatedArrivalAt,Integer expectedPalletCount, String customerName,String businessType,String deliveryMode,Instant estimatedArrivalAt,Integer expectedPalletCount,
FleetBookingModels.Cargo cargo,String destinationLabel,BigDecimal agreedAmount, FleetBookingModels.Cargo cargo,String destinationLabel,BigDecimal agreedAmount,
FleetQuoteCandidateModels.SourceSnapshot quoteSource,String businessOrderNo,String incomingNo,String dispatchNo, FleetQuoteCandidateModels.SourceSnapshot quoteSource,String businessOrderNo,String incomingNo,String dispatchNo,
Instant updatedAt,InquiryActions actions) { } Instant updatedAt,InquiryActions actions,Progress progress,com.baiyue.lastleg.appointmentplanning.AppointmentCoverageService.Coverage appointmentCoverage) { }
public record Fulfillment(FleetBookingModels.Response booking,FleetBookingModels.IncomingPreview preview,Instant plannedDepartureAt) { }
public record Detail(Offer offer,PageResult<Inquiry> inquiries) { } public record Detail(Offer offer,PageResult<Inquiry> inquiries) { }
public record OfferResult(Offer offer,boolean replayed) { } public record OfferResult(Offer offer,boolean replayed) { }
public record InquiryResult(Inquiry inquiry,Offer offer,boolean replayed) { } public record InquiryResult(Inquiry inquiry,Offer offer,boolean replayed) { }
@@ -37,6 +37,7 @@ public class FleetCapacityOfferService {
private final FleetPoolOrderBridge orders; private final FleetPoolOrderBridge orders;
private final ObjectMapper json; private final ObjectMapper json;
private final Clock clock; private final Clock clock;
@jakarta.annotation.Resource private com.baiyue.lastleg.appointmentplanning.AppointmentCoverageService appointmentCoverage;
@Autowired @Autowired
public FleetCapacityOfferService(FleetCapacityOfferMapper offers,FleetCapacityOfferCommandMapper commands, public FleetCapacityOfferService(FleetCapacityOfferMapper offers,FleetCapacityOfferCommandMapper commands,
FleetTripMapper trips,FleetBookingMapper bookings,FleetBookingService bookingService,FleetBookingReservations reservations, FleetTripMapper trips,FleetBookingMapper bookings,FleetBookingService bookingService,FleetBookingReservations reservations,
@@ -67,23 +68,52 @@ public class FleetCapacityOfferService {
} }
@Transactional(readOnly=true,isolation=Isolation.REPEATABLE_READ) @Transactional(readOnly=true,isolation=Isolation.REPEATABLE_READ)
public PageResult<FleetCapacityOfferModels.Inquiry> inquiries(FleetCapacityOfferModels.InquiryQuery query){ public PageResult<FleetCapacityOfferModels.Inquiry> inquiries(FleetCapacityOfferModels.InquiryQuery query){
readPermission();if("ALL".equals(query.scope())&&!manage())throw forbidden(); readPermission();if(!"MINE".equals(query.scope())&&!manage())throw forbidden();
var filter=new LambdaQueryWrapper<FleetBookingEntity>().isNotNull(FleetBookingEntity::getOfferId); var filter=new LambdaQueryWrapper<FleetBookingEntity>().isNotNull(FleetBookingEntity::getOfferId);
if(!"ALL".equals(query.scope()))filter.eq(FleetBookingEntity::getSalesOwnerUserId,operator()); if("MINE".equals(query.scope()))filter.eq(FleetBookingEntity::getSalesOwnerUserId,operator());
if(query.offerNo()!=null){var row=offer(query.offerNo(),false);requireVisible(row);filter.eq(FleetBookingEntity::getOfferId,row.getId());} if(query.offerNo()!=null){var row=offer(query.offerNo(),false);requireVisible(row);filter.eq(FleetBookingEntity::getOfferId,row.getId());}
if(nonblank(query.status()))filter.eq(FleetBookingEntity::getStatus,query.status()); if(nonblank(query.status()))filter.eq(FleetBookingEntity::getStatus,query.status());
if(nonblank(query.keyword()))filter.and(q->q.like(FleetBookingEntity::getBookingNo,query.keyword()).or().like(FleetBookingEntity::getCustomerName,query.keyword()).or().like(FleetBookingEntity::getCustomerCode,query.keyword())); if(nonblank(query.keyword()))filter.and(q->q.like(FleetBookingEntity::getBookingNo,query.keyword()).or().like(FleetBookingEntity::getCustomerName,query.keyword()).or().like(FleetBookingEntity::getCustomerCode,query.keyword()).or().like(FleetBookingEntity::getTripNo,query.keyword()));
boolean todo="TODO".equals(query.scope());
if(todo)filter.in(FleetBookingEntity::getStatus,"DRAFT","CONFIRMED","RECEIVED");
filter.orderByDesc(FleetBookingEntity::getUpdatedAt,FleetBookingEntity::getId); filter.orderByDesc(FleetBookingEntity::getUpdatedAt,FleetBookingEntity::getId);
var result=bookings.selectPage(new com.baomidou.mybatisplus.extension.plugins.pagination.Page<>(query.page(),query.size()),filter); var result=bookings.selectPage(new com.baomidou.mybatisplus.extension.plugins.pagination.Page<>(todo?1:query.page(),todo?100:query.size()),filter);
if(todo&&result.getTotal()>100)throw bad("待办实时核对最多100条,请按客户或登记号缩小范围");
var incomingStatuses=bookingService.incomingStatuses(result.getRecords());
var coverage=coverageByBooking(result.getRecords());
var stages=orders.fulfillmentStages(result.getRecords().stream().map(FleetBookingEntity::getId).toList());
var numbers=orders.businessOrderNumbers(result.getRecords().stream().map(FleetBookingEntity::getId).toList()); var numbers=orders.businessOrderNumbers(result.getRecords().stream().map(FleetBookingEntity::getId).toList());
var projections=new HashMap<Long,FleetCapacityOfferModels.Offer>(); var projections=new HashMap<Long,FleetCapacityOfferModels.Offer>();
var values=result.getRecords().stream().map(row->inquiry(row,numbers.get(row.getId()),projections.computeIfAbsent(row.getOfferId(),key->{var offer=offers.selectById(key);return project(offer,trip(offer.getTripNo(),false));}))).toList(); var values=result.getRecords().stream().map(row->inquiry(row,numbers.get(row.getId()),projections.computeIfAbsent(row.getOfferId(),key->{var offer=offers.selectById(key);return project(offer,trip(offer.getTripNo(),false));}),incomingStatuses.get(new FleetBookingIncomingGateway.Source(row.getCustomerId(),row.getIncomingNo())),stages.get(row.getId()),coverage.get(row.getBookingNo()))).filter(v->!todo||"调度".equals(v.progress().owner())).toList();
if(todo)return page(values,query.page(),query.size());
return new PageResult<>(values,result.getCurrent(),result.getSize(),result.getTotal(),result.getPages()); return new PageResult<>(values,result.getCurrent(),result.getSize(),result.getTotal(),result.getPages());
} }
@Transactional(readOnly=true,isolation=Isolation.REPEATABLE_READ) @Transactional(readOnly=true,isolation=Isolation.REPEATABLE_READ)
public FleetCapacityOfferModels.Inquiry inquiry(String number){ public FleetCapacityOfferModels.Inquiry inquiry(String number){
readPermission();var row=inquiryRow(number);own(row);return inquiry(row,orders.businessOrderNumbers(List.of(row.getId())).get(row.getId())); readPermission();var row=inquiryRow(number);own(row);return inquiry(row,orders.businessOrderNumbers(List.of(row.getId())).get(row.getId()));
} }
@Transactional(readOnly=true,isolation=Isolation.REPEATABLE_READ)
public FleetCapacityOfferModels.Fulfillment fulfillment(String number) {
readPermission();var row=inquiryRow(number);own(row);
var trip=trip(row.getTripNo(),false);
return new FleetCapacityOfferModels.Fulfillment(reservations.response(row),
bookingService.incoming(row.getTripNo(),number),instant(trip.getPlannedDepartureAt()));
}
@Transactional
public FleetBookingModels.Result arrangeIncoming(String number,FleetBookingModels.StartIncoming request) {
requireManage();var row=inquiryRow(number);
return bookingService.startIncoming(row.getTripNo(),number,operator(),request);
}
@Transactional
public FleetBookingModels.Result acceptReceipt(String number,FleetBookingModels.AcceptReceipt request) {
requireManage();var row=inquiryRow(number);
return bookingService.acceptReceipt(row.getTripNo(),number,operator(),request);
}
@Transactional
public FleetBookingModels.Result arrangeDispatch(String number,FleetBookingModels.Command request) {
requireManage();var row=inquiryRow(number);
return bookingService.arrangeDispatch(row.getTripNo(),number,operator(),request);
}
@Transactional(readOnly=true) @Transactional(readOnly=true)
public List<FleetCapacityOfferModels.CustomerOption> customerOptions(String keyword){ public List<FleetCapacityOfferModels.CustomerOption> customerOptions(String keyword){
writePermission("inquiry-create"); writePermission("inquiry-create");
@@ -165,6 +195,16 @@ public class FleetCapacityOfferService {
var result=inquiryResult(row,booking);saveCommand(trip,"INQUIRY_EDIT",number,request.idempotencyKey(),request,result);return result; var result=inquiryResult(row,booking);saveCommand(trip,"INQUIRY_EDIT",number,request.idempotencyKey(),request,result);return result;
} }
@Transactional @Transactional
public FleetCapacityOfferModels.InquiryResult saveAndConfirm(String number,FleetCapacityOfferModels.InquiryWrite request) {
requireManage();var initial=inquiryRow(number);var trip=trip(initial.getTripNo(),true);
var replay=replay(trip,"SAVE_CONFIRM",number,request.idempotencyKey(),request,FleetCapacityOfferModels.InquiryResult.class);
if(replay!=null)return replayed(replay);
var saved=updateInquiry(number,request);
var result=confirm(number,new FleetCapacityOfferModels.InquiryCommand(saved.offer().tripRecordVersion(),saved.offer().recordVersion(),saved.inquiry().recordVersion(),request.idempotencyKey(),"核对资料并确认承运"));
saveCommand(trip,"SAVE_CONFIRM",number,request.idempotencyKey(),request,result);
return result;
}
@Transactional
public FleetCapacityOfferModels.InquiryResult confirm(String number,FleetCapacityOfferModels.InquiryCommand request){ public FleetCapacityOfferModels.InquiryResult confirm(String number,FleetCapacityOfferModels.InquiryCommand request){
requireManage();var initial=inquiryRow(number);var trip=trip(initial.getTripNo(),true); requireManage();var initial=inquiryRow(number);var trip=trip(initial.getTripNo(),true);
String vehicleBlock=execution.currentVehicleBlockReason(trip,true);var row=offers.lock(offers.selectById(initial.getOfferId()).getOfferNo());var booking=bookings.lock(trip.getId(),number); String vehicleBlock=execution.currentVehicleBlockReason(trip,true);var row=offers.lock(offers.selectById(initial.getOfferId()).getOfferNo());var booking=bookings.lock(trip.getId(),number);
@@ -172,7 +212,10 @@ public class FleetCapacityOfferService {
versions(trip,row,request.tripRecordVersion(),request.offerRecordVersion());requireInquiryOpen(row,trip); versions(trip,row,request.tripRecordVersion(),request.offerRecordVersion());requireInquiryOpen(row,trip);
if(vehicleBlock!=null)throw conflict(vehicleBlock); if(vehicleBlock!=null)throw conflict(vehicleBlock);
var offer=project(row,trip);if(!offer.canConfirm())throw conflict(offer.blockReason()); var offer=project(row,trip);if(!offer.canConfirm())throw conflict(offer.blockReason());
var cargo=reservations.cargo(booking);FleetBookingService.requireCompleteCargo(cargo); var cargo=reservations.cargo(booking);
var missing=FleetInquiryProgress.missing(booking,cargo,offer);
if(!missing.isEmpty())throw conflict("确认前还需:"+String.join("、",missing));
FleetBookingService.requireCompleteCargo(cargo);
if(!nonblank(cargo.contactName())||!nonblank(cargo.contactPhone())||!nonblank(cargo.cargoName()))throw conflict("成交前请补齐货名和联系信息"); if(!nonblank(cargo.contactName())||!nonblank(cargo.contactPhone())||!nonblank(cargo.cargoName()))throw conflict("成交前请补齐货名和联系信息");
if("US_TRUCK".equals(booking.getBusinessType())&&(booking.getExpectedPalletCount()==null||booking.getExpectedPalletCount()<1))throw conflict("本土卡车成交前请填写预计实托数,不能用托位替代"); if("US_TRUCK".equals(booking.getBusinessType())&&(booking.getExpectedPalletCount()==null||booking.getExpectedPalletCount()<1))throw conflict("本土卡车成交前请填写预计实托数,不能用托位替代");
if(booking.getEstimatedArrivalAt()==null||booking.getEstimatedArrivalAt().isAfter(row.getCutoffAt()))throw conflict("请填写不晚于交货截止的预计到仓时间"); if(booking.getEstimatedArrivalAt()==null||booking.getEstimatedArrivalAt().isAfter(row.getCutoffAt()))throw conflict("请填写不晚于交货截止的预计到仓时间");
@@ -197,18 +240,32 @@ public class FleetCapacityOfferService {
var result=inquiryResult(row,bookings.lock(trip.getId(),number));saveCommand(trip,"CANCEL",number,request.idempotencyKey(),request,result);return result; var result=inquiryResult(row,bookings.lock(trip.getId(),number));saveCommand(trip,"CANCEL",number,request.idempotencyKey(),request,result);return result;
} }
private Map<String,com.baiyue.lastleg.appointmentplanning.AppointmentCoverageService.Coverage> coverageByBooking(List<FleetBookingEntity> rows) {
var refs=rows.stream().filter(r->r.getDestinationKey()!=null&&r.getDestinationKey().startsWith("FBA:"))
.map(r->new com.baiyue.lastleg.appointmentplanning.AppointmentCoverageService.SourceRef(r.getCustomerId(),"BOOKING:"+r.getBookingNo(),r.getDestinationKey())).toList();
var result=new HashMap<String,com.baiyue.lastleg.appointmentplanning.AppointmentCoverageService.Coverage>();
if(refs.isEmpty())return result;
for(var c:appointmentCoverage.query(refs))result.put(c.sourceKey().substring("BOOKING:".length()),c);
return result;
}
private FleetCapacityOfferModels.InquiryResult inquiryResult(FleetCapacityOfferEntity row,FleetBookingEntity booking){ private FleetCapacityOfferModels.InquiryResult inquiryResult(FleetCapacityOfferEntity row,FleetBookingEntity booking){
return new FleetCapacityOfferModels.InquiryResult(inquiry(booking,orders.businessOrderNumbers(List.of(booking.getId())).get(booking.getId())),project(row,trip(row.getTripNo(),false)),false); return new FleetCapacityOfferModels.InquiryResult(inquiry(booking,orders.businessOrderNumbers(List.of(booking.getId())).get(booking.getId())),project(row,trip(row.getTripNo(),false)),false);
} }
private FleetCapacityOfferModels.InquiryResult replayed(FleetCapacityOfferModels.InquiryResult value){return new FleetCapacityOfferModels.InquiryResult(value.inquiry(),value.offer(),true);} private FleetCapacityOfferModels.InquiryResult replayed(FleetCapacityOfferModels.InquiryResult value){return new FleetCapacityOfferModels.InquiryResult(value.inquiry(),value.offer(),true);}
private FleetCapacityOfferModels.Inquiry inquiry(FleetBookingEntity booking,String businessNo){ private FleetCapacityOfferModels.Inquiry inquiry(FleetBookingEntity booking,String businessNo){
var row=offers.selectById(booking.getOfferId());return inquiry(booking,businessNo,project(row,trip(booking.getTripNo(),false))); var row=offers.selectById(booking.getOfferId());return inquiry(booking,businessNo,project(row,trip(booking.getTripNo(),false)),bookingService.incomingStatuses(List.of(booking)).get(new FleetBookingIncomingGateway.Source(booking.getCustomerId(),booking.getIncomingNo())),orders.fulfillmentStages(List.of(booking.getId())).get(booking.getId()),coverageByBooking(List.of(booking)).get(booking.getBookingNo()));
} }
private FleetCapacityOfferModels.Inquiry inquiry(FleetBookingEntity booking,String businessNo,FleetCapacityOfferModels.Offer offer){ private FleetCapacityOfferModels.Inquiry inquiry(FleetBookingEntity booking,String businessNo,FleetCapacityOfferModels.Offer offer,String incomingStatus,String fulfillmentStage,com.baiyue.lastleg.appointmentplanning.AppointmentCoverageService.Coverage coverage){
boolean owned=manage()||Objects.equals(operator(),booking.getSalesOwnerUserId());boolean draft="DRAFT".equals(booking.getStatus()); boolean owned=manage()||Objects.equals(operator(),booking.getSalesOwnerUserId());boolean draft="DRAFT".equals(booking.getStatus());
boolean canCancel=owned&&(draft||manage()&&"CONFIRMED".equals(booking.getStatus())&&booking.getIncomingNo()==null); boolean canCancel=owned&&(draft||manage()&&"CONFIRMED".equals(booking.getStatus())&&booking.getIncomingNo()==null);
var cargo=reservations.cargo(booking);
var missing=FleetInquiryProgress.missing(booking,cargo,offer);
String block=draft?FleetInquiryProgress.capacityBlock(cargo,offer):null;
boolean canEdit=owned&&draft&&offer.canCreateInquiry()&&(manage()||has(LIST)&&has(LIST.replace(":list",":inquiry-edit")));
boolean canConfirm=manage()&&draft&&offer.canConfirm()&&missing.isEmpty()&&block==null;
return new FleetCapacityOfferModels.Inquiry(booking.getBookingNo(),offer.offerNo(),booking.getTripNo(),booking.getStatus(),booking.getRecordVersion(),offer.recordVersion(),offer.tripRecordVersion(),booking.getSalesOwnerUserId(),booking.getCustomerId(),booking.getCustomerCode(),booking.getCustomerName(),booking.getBusinessType(),booking.getDeliveryMode(),instant(booking.getEstimatedArrivalAt()),booking.getExpectedPalletCount(),reservations.cargo(booking),booking.getDestinationLabel(),booking.getAgreedAmount(),reservations.quoteSource(booking),businessNo,booking.getIncomingNo(),booking.getDispatchNo(),instant(booking.getUpdatedAt()), return new FleetCapacityOfferModels.Inquiry(booking.getBookingNo(),offer.offerNo(),booking.getTripNo(),booking.getStatus(),booking.getRecordVersion(),offer.recordVersion(),offer.tripRecordVersion(),booking.getSalesOwnerUserId(),booking.getCustomerId(),booking.getCustomerCode(),booking.getCustomerName(),booking.getBusinessType(),booking.getDeliveryMode(),instant(booking.getEstimatedArrivalAt()),booking.getExpectedPalletCount(),reservations.cargo(booking),booking.getDestinationLabel(),booking.getAgreedAmount(),reservations.quoteSource(booking),businessNo,booking.getIncomingNo(),booking.getDispatchNo(),instant(booking.getUpdatedAt()),
new FleetCapacityOfferModels.InquiryActions(owned&&draft&&offer.canCreateInquiry()&&(manage()||has(LIST)&&has(LIST.replace(":list",":inquiry-edit"))),manage()&&draft&&offer.canConfirm(),canCancel&&(manage()||has(LIST)&&has(LIST.replace(":list",":inquiry-cancel"))),offer.blockReason())); new FleetCapacityOfferModels.InquiryActions(canEdit,canConfirm,canCancel&&(manage()||has(LIST)&&has(LIST.replace(":list",":inquiry-cancel"))),block),
FleetInquiryProgress.project(booking,missing,block,canEdit,canConfirm,incomingStatus,fulfillmentStage),coverage);
} }
private FleetCapacityOfferModels.Offer project(FleetCapacityOfferEntity row,FleetTripEntity trip){ private FleetCapacityOfferModels.Offer project(FleetCapacityOfferEntity row,FleetTripEntity trip){
String block=openBlock(row,trip,clock.instant());var planning=execution.capacityPlan(trip.getTripNo()); String block=openBlock(row,trip,clock.instant());var planning=execution.capacityPlan(trip.getTripNo());
@@ -0,0 +1,11 @@
package com.baiyue.lastleg.fleet.trip;
import com.baiyue.common.core.api.PageResult;
/** Dispatch owns candidate eligibility and creation; fleet orchestrates the trip transaction. */
public interface FleetDispatchPlanningGateway {
record Candidate(String dispatchNo,String customerId,String warehouseId,int packageCount,String route) { }
PageResult<Candidate> candidates(String tripNo,String warehouseId,String keyword,int page,int size);
void validateIncomingDispatch(String dispatchNo,String customerId,FleetBookingIncomingGateway.Evidence receipt);
String resolveIncoming(String customerId,FleetBookingIncomingGateway.Evidence receipt,String operator);
}
@@ -0,0 +1,72 @@
package com.baiyue.lastleg.fleet.trip;
import java.math.BigDecimal;
import java.util.ArrayList;
import java.util.List;
/** Derived handling guidance, never a second writable booking state machine. */
final class FleetInquiryProgress {
private FleetInquiryProgress() { }
static List<String> missing(FleetBookingEntity booking, FleetBookingModels.Cargo cargo,
FleetCapacityOfferModels.Offer offer) {
var fields = new ArrayList<String>();
if (cargo.packageCount() == null || cargo.packageCount() < 1) fields.add("预计箱数");
if (cargo.palletSpaces() == null || cargo.palletSpaces() < 1) fields.add("占用标准托位");
if (!positive(cargo.weightKg())) fields.add("预计重量");
if (!positive(cargo.volumeCbm())) fields.add("预计体积");
if ("US_TRUCK".equals(booking.getBusinessType()) &&
(booking.getExpectedPalletCount() == null || booking.getExpectedPalletCount() < 1)) fields.add("预计实托数");
if (cargo.unitPrice() == null || cargo.unitPrice().signum() < 0) fields.add("约定价格");
if (!List.of("CNY", "USD").contains(cargo.currency() == null ? "" : cargo.currency())) fields.add("币种(CNY或USD)");
if (blank(cargo.cargoName())) fields.add("货物名称");
if (blank(cargo.contactName())) fields.add("联系人");
if (blank(cargo.contactPhone())) fields.add("联系方式");
if (booking.getEstimatedArrivalAt() == null) fields.add("预计到仓时间");
else if (booking.getEstimatedArrivalAt().toInstant(java.time.ZoneOffset.UTC).isAfter(offer.cutoffAt())) fields.add("预计到仓时间(不得晚于最晚送仓)");
return List.copyOf(fields);
}
static String capacityBlock(FleetBookingModels.Cargo cargo, FleetCapacityOfferModels.Offer offer) {
if (!offer.canConfirm()) return offer.blockReason();
var destination = offer.destinations().stream().filter(d -> d.destinationKey().equals(cargo.destinationKey())).findFirst();
if (destination.isEmpty()) return "卸货站已变化,请调度核对路线";
var remaining = destination.get().remaining();
if (remaining == null) return "余位待调度核对";
if (cargo.palletSpaces() != null && cargo.palletSpaces() > remaining.palletSpaces()
|| cargo.weightKg() != null && cargo.weightKg().compareTo(remaining.weightKg()) > 0
|| cargo.volumeCbm() != null && cargo.volumeCbm().compareTo(remaining.volumeCbm()) > 0)
return "本票预报超过所选目的地余位,请调度核对";
return null;
}
static FleetCapacityOfferModels.Progress project(FleetBookingEntity booking, List<String> missing,
String block, boolean canEdit, boolean canConfirm, String incomingStatus, String fulfillmentStage) {
if ("IN_TRANSIT".equals(fulfillmentStage)) return progress("IN_TRANSIT", "已发车", "运输中", "派送", "VIEW", "按实际派送进度跟进", List.of());
if ("COMPLETED".equals(fulfillmentStage)) return progress("COMPLETED", "已完成", "运输已完成", "无待办", "VIEW", "实际货物已全部签收", List.of());
return switch (booking.getStatus()) {
case "DRAFT" -> {
if (!canEdit && !canConfirm && block != null)
yield progress("BLOCKED", "登记暂不可处理", "尚未保位", "调度", "VIEW", block, missing);
if (!missing.isEmpty()) yield progress("NEEDS_DETAILS", "待补资料", "尚未保位", "销售", canEdit ? "EDIT" : "VIEW", "确认前还需:" + String.join("、", missing), missing);
if (block != null) yield progress("RECHECK", "待调度核对", "尚未保位", "调度", "VIEW", block, missing);
yield progress("AWAITING_CONFIRMATION", "待调度确认", "尚未保位", "调度", canConfirm ? "CONFIRM" : "VIEW", "保存后调度可见,确认承运成功后才保位", missing);
}
case "CONFIRMED" -> booking.getIncomingNo() == null
? progress("AWAITING_INCOMING", "尚未安排来货", "已保留货位", "调度", "INCOMING", "确定来货安排后再生成来货单", List.of())
: incomingStatus != null && List.of("WAIT_CUSTOMER_CONFIRMATION","READY_FOR_DISPATCH").contains(incomingStatus)
? progress("AWAITING_RECEIPT_CHECK", "实收完成待核量", "已保留货位", "调度", "RECEIPT", "核对预报与实收差异、容量及约定运费", List.of())
: progress("RECEIVING", "已安排来货", "已保留货位", "仓库", "VIEW", incomingStatus == null ? "收货进度暂不可确认,请打开本票进度核对" : "等待仓库实际收货,销售跟进客户送仓", List.of());
case "RECEIVED" -> progress("READY_TO_BIND", "实收已核对", "已保留货位", "调度", "BIND", "确认排入原车次,仍需后续审核和实际作业", List.of());
case "BOUND" -> progress("BOUND", "已排入本车", "已转为本车货物", "审核 / 仓库", "VIEW", "排入不等于已装车或发车,请查看关联排车作业", List.of());
default -> progress("CANCELLED", "已取消", "未保留货位", "无待办", "VIEW", "原登记保留供追溯", List.of());
};
}
private static FleetCapacityOfferModels.Progress progress(String stage, String label, String reservation,
String owner, String action, String message, List<String> missing) {
return new FleetCapacityOfferModels.Progress(stage, label, reservation, owner, action, message, missing);
}
private static boolean blank(String value) { return value == null || value.isBlank(); }
private static boolean positive(BigDecimal value) { return value != null && value.signum() > 0; }
}
@@ -0,0 +1,81 @@
package com.baiyue.lastleg.fleet.trip;
import com.baomidou.mybatisplus.core.toolkit.IdWorker;
import com.fasterxml.jackson.databind.ObjectMapper;
import jakarta.validation.Valid;
import jakarta.validation.constraints.*;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.http.HttpStatus;
import org.springframework.web.server.ResponseStatusException;
import org.springframework.security.core.context.SecurityContextHolder;
import java.util.*;
import java.time.LocalDateTime;
import java.time.ZoneOffset;
/** Shared by dispatch and appointment consumers; all business writes commit together. */
@Service
public class FleetTripPreparationService {
public record Prepare(@Size(max=64) String tripNo,@Min(0) Long recordVersion,
@Valid FleetTripModels.Create plan,@Positive Long vehicleId,@Positive Long driverId,
@NotEmpty @Size(max=100) List<@NotBlank @Size(max=64) String> dispatchNos,
@NotBlank @Size(min=8,max=128) String idempotencyKey) { }
private final FleetTripService service;
private final FleetTripMapper trips;
private final FleetCapacityOfferCommandMapper commands;
private final ObjectMapper json;
public FleetTripPreparationService(FleetTripService service,FleetTripMapper trips,FleetCapacityOfferCommandMapper commands,ObjectMapper json) {
this.service=service;this.trips=trips;this.commands=commands;this.json=json;
}
@Transactional
public FleetTripModels.Detail prepare(String operator,Prepare request) {
boolean create=request.tripNo()==null||request.tripNo().isBlank();
if(create) { permission("platform:fleet:trip:create");permission("platform:fleet:trip:assign"); }
permission("platform:fleet:trip:edit");
if(new HashSet<>(request.dispatchNos()).size()!=request.dispatchNos().size())throw conflict("排车单不能重复选择");
if(create && (request.plan()==null||request.vehicleId()==null||request.driverId()==null))throw conflict("新建车次请填写计划、车辆和司机");
if(!create && (request.plan()!=null||request.vehicleId()!=null||request.driverId()!=null||request.recordVersion()==null))throw conflict("加入已有车次请提交车次版本,不重复修改资源或计划");
var initial=create?trips.findCreated(operator,request.idempotencyKey()):trips.findByNo(request.tripNo());
if(!create&&initial==null)throw conflict("所选车次不存在");
String payload=write(request);
if(initial!=null) {
var locked=trips.lockByNo(initial.getTripNo());
var previous=commands.find(locked.getId(),operator,"PREPARE_TRIP",locked.getTripNo(),request.idempotencyKey());
if(previous!=null) {
if(!Objects.equals(previous.getRequestHash(),hash(payload)))throw conflict("同一安排请求不能更换内容");
try{return json.readValue(previous.getResultJson(),FleetTripModels.Detail.class);}catch(Exception e){throw new IllegalStateException(e);}
}
if(create)throw conflict("该新建请求已被其他操作使用,请核对原车次");
}
FleetTripModels.Detail detail;
if(create) {
var p=request.plan();
detail=service.create(operator,new FleetTripModels.Create(p.warehouseId(),p.plannedDepartureAt(),p.expectedDriverEndAt(),p.stops(),p.remark(),request.idempotencyKey()));
// Concurrent same-key create may have committed while waiting for its unique constraint.
var locked=trips.lockByNo(detail.trip().tripNo());
var previous=commands.find(locked.getId(),operator,"PREPARE_TRIP",locked.getTripNo(),request.idempotencyKey());
if(previous!=null) {
if(!Objects.equals(previous.getRequestHash(),hash(payload)))throw conflict("同一安排请求不能更换内容");
try{return json.readValue(previous.getResultJson(),FleetTripModels.Detail.class);}catch(Exception e){throw new IllegalStateException(e);}
}
detail=service.assign(detail.trip().tripNo(),operator,new FleetTripModels.Assign(detail.trip().recordVersion(),request.vehicleId(),request.driverId(),"安排车次时分配资源"));
} else {
detail=service.detail(request.tripNo());
if(detail.trip().recordVersion()!=request.recordVersion())throw conflict("车次已变化,请重新核对");
}
for(String number:request.dispatchNos().stream().sorted().toList())
detail=service.bindDispatch(detail.trip().tripNo(),operator,new FleetTripModels.BindingCommand(detail.trip().recordVersion(),number,"勾选待运货物安排车次"));
var row=trips.findByNo(detail.trip().tripNo());
var command=new FleetCapacityOfferCommand();command.setId(IdWorker.getId());command.setTripId(row.getId());command.setOperatorId(operator);
command.setAction("PREPARE_TRIP");command.setScopeNo(row.getTripNo());command.setIdempotencyKey(request.idempotencyKey());command.setRequestHash(hash(payload));
command.setResultJson(write(detail));command.setCreatedAt(LocalDateTime.now(ZoneOffset.UTC));commands.insert(command);
return detail;
}
private static void permission(String name) {
var auth=SecurityContextHolder.getContext().getAuthentication();
if(auth==null||auth.getAuthorities().stream().noneMatch(a->name.equals(a.getAuthority())))throw new ResponseStatusException(HttpStatus.FORBIDDEN,"缺少安排车次所需权限");
}
private String write(Object value){try{return json.writeValueAsString(value);}catch(Exception e){throw new IllegalStateException(e);}}
private static String hash(String value){try{return HexFormat.of().formatHex(java.security.MessageDigest.getInstance("SHA-256").digest(value.getBytes(java.nio.charset.StandardCharsets.UTF_8)));}catch(Exception e){throw new IllegalStateException(e);}}
private static ResponseStatusException conflict(String message){return new ResponseStatusException(HttpStatus.CONFLICT,message);}
}
@@ -190,6 +190,15 @@ public class FleetTripService {
return detail(trip); return detail(trip);
} }
@jakarta.annotation.Resource private FleetDispatchPlanningGateway planning;
@Transactional(readOnly=true,isolation=org.springframework.transaction.annotation.Isolation.REPEATABLE_READ)
public com.baiyue.common.core.api.PageResult<FleetDispatchPlanningGateway.Candidate> dispatchCandidates(String tripNo,String keyword,int page,int size) {
var trip=trips.findByNo(tripNo);
if(trip==null)throw new org.springframework.web.server.ResponseStatusException(org.springframework.http.HttpStatus.NOT_FOUND,"车次不存在");
if(!"PLANNED".equals(trip.getStatus()))throw conflict("仅计划中的车次可以添加待运货物");
return planning.candidates(tripNo,trip.getWarehouseId().toString(),keyword,page,size);
}
@Transactional @Transactional
public FleetTripModels.Detail bindDispatch(String tripNo, String operator, FleetTripModels.BindingCommand request) { public FleetTripModels.Detail bindDispatch(String tripNo, String operator, FleetTripModels.BindingCommand request) {
var trip = planned(tripNo, request.recordVersion()); var trip = planned(tripNo, request.recordVersion());
@@ -45,4 +45,16 @@ public class PlatformFleetCapacityOfferController {
public ApiResponse<List<FleetCapacityOfferModels.CustomerOption>> customers(@RequestParam(required=false) String keyword){return ApiResponse.success(service.customerOptions(keyword));} public ApiResponse<List<FleetCapacityOfferModels.CustomerOption>> customers(@RequestParam(required=false) String keyword){return ApiResponse.success(service.customerOptions(keyword));}
@GetMapping("/{offerNo}/quote-candidates") @PreAuthorize("hasAuthority('platform:fleet:quote:list') and hasAnyAuthority('platform:fleet:capacity-offer:list','platform:fleet:capacity-offer:manage')") @GetMapping("/{offerNo}/quote-candidates") @PreAuthorize("hasAuthority('platform:fleet:quote:list') and hasAnyAuthority('platform:fleet:capacity-offer:list','platform:fleet:capacity-offer:manage')")
public ApiResponse<PageResult<FleetQuoteCandidateModels.SourceSnapshot>> quoteCandidates(@PathVariable String offerNo,@Valid FleetBookingModels.QuoteCandidatesQuery query){return ApiResponse.success(service.quoteCandidates(offerNo,query));} public ApiResponse<PageResult<FleetQuoteCandidateModels.SourceSnapshot>> quoteCandidates(@PathVariable String offerNo,@Valid FleetBookingModels.QuoteCandidatesQuery query){return ApiResponse.success(service.quoteCandidates(offerNo,query));}
@GetMapping("/inquiries/{bookingNo}/fulfillment") @PreAuthorize("hasAnyAuthority('platform:fleet:capacity-offer:list','platform:fleet:capacity-offer:manage')")
public ApiResponse<FleetCapacityOfferModels.Fulfillment> fulfillment(@PathVariable String bookingNo){return ApiResponse.success(service.fulfillment(bookingNo));}
@PostMapping("/inquiries/{bookingNo}/incoming") @PreAuthorize("hasAuthority('platform:fleet:capacity-offer:manage') and hasAuthority('platform:fleet:trip:booking-edit')")
public ApiResponse<FleetBookingModels.Result> incoming(@PathVariable String bookingNo,@Valid @RequestBody FleetBookingModels.StartIncoming request){return ApiResponse.success(service.arrangeIncoming(bookingNo,request));}
@PostMapping("/inquiries/{bookingNo}/receipt-confirmation") @PreAuthorize("hasAuthority('platform:fleet:capacity-offer:manage') and hasAuthority('platform:fleet:trip:booking-confirm')")
public ApiResponse<FleetBookingModels.Result> receipt(@PathVariable String bookingNo,@Valid @RequestBody FleetBookingModels.AcceptReceipt request){return ApiResponse.success(service.acceptReceipt(bookingNo,request));}
@PostMapping("/inquiries/{bookingNo}/arrange-dispatch") @PreAuthorize("hasAuthority('platform:fleet:capacity-offer:manage') and hasAuthority('platform:fleet:trip:booking-edit') and hasAuthority('platform:fleet:trip:edit') and hasAuthority('platform:overseas-warehouse:inbound-order:smart-dispatch')")
public ApiResponse<FleetBookingModels.Result> arrange(@PathVariable String bookingNo,@Valid @RequestBody FleetBookingModels.Command request){return ApiResponse.success(service.arrangeDispatch(bookingNo,request));}
@PostMapping("/inquiries/{bookingNo}/save-and-confirm") @PreAuthorize("hasAuthority('platform:fleet:capacity-offer:manage')")
public ApiResponse<FleetCapacityOfferModels.InquiryResult> saveAndConfirm(@PathVariable String bookingNo,@Valid @RequestBody FleetCapacityOfferModels.InquiryWrite request){return ApiResponse.success(service.saveAndConfirm(bookingNo,request));}
} }
@@ -17,6 +17,13 @@ import org.springframework.web.server.ResponseStatusException;
@Validated @Validated
@RequestMapping("/api/platform/v1/last-leg/fleet/trips") @RequestMapping("/api/platform/v1/last-leg/fleet/trips")
public class PlatformFleetTripController { public class PlatformFleetTripController {
@jakarta.annotation.Resource private FleetTripPreparationService preparation;
@PostMapping("/prepare")
@PreAuthorize("hasAuthority('platform:fleet:trip:edit')")
public ApiResponse<FleetTripModels.Detail> prepare(@AuthenticationPrincipal AuthenticatedUser user,
@Valid @RequestBody FleetTripPreparationService.Prepare request) {
return ApiResponse.success(preparation.prepare(operator(user),request));
}
private final FleetTripService service; private final FleetTripService service;
private final FleetTripExecutionService execution; private final FleetTripExecutionService execution;
private final FleetTripCompletionService completion; private final FleetTripCompletionService completion;
@@ -50,6 +57,13 @@ public class PlatformFleetTripController {
public ApiResponse<FleetTripModels.Detail> cancel(@AuthenticationPrincipal AuthenticatedUser user, @PathVariable String tripNo, @Valid @RequestBody FleetTripModels.Cancel request) { public ApiResponse<FleetTripModels.Detail> cancel(@AuthenticationPrincipal AuthenticatedUser user, @PathVariable String tripNo, @Valid @RequestBody FleetTripModels.Cancel request) {
return ApiResponse.success(service.cancel(tripNo, operator(user), request)); return ApiResponse.success(service.cancel(tripNo, operator(user), request));
} }
@GetMapping("/{tripNo}/dispatch-candidates")
@PreAuthorize("hasAuthority('platform:fleet:trip:list')")
public ApiResponse<PageResult<FleetDispatchPlanningGateway.Candidate>> dispatchCandidates(@PathVariable String tripNo,
@RequestParam(required=false) String keyword,@RequestParam(defaultValue="1") @Min(1) int page,
@RequestParam(defaultValue="20") @Min(1) @Max(100) int size) {
return ApiResponse.success(service.dispatchCandidates(tripNo,keyword,page,size));
}
@PostMapping("/{tripNo}/dispatches/bind") @PostMapping("/{tripNo}/dispatches/bind")
@PreAuthorize("hasAuthority('platform:fleet:trip:edit')") @PreAuthorize("hasAuthority('platform:fleet:trip:edit')")
public ApiResponse<FleetTripModels.Detail> bind(@AuthenticationPrincipal AuthenticatedUser user, @PathVariable String tripNo, public ApiResponse<FleetTripModels.Detail> bind(@AuthenticationPrincipal AuthenticatedUser user, @PathVariable String tripNo,
@@ -24,6 +24,16 @@ public class FleetIncomingAdapter implements FleetBookingIncomingGateway {
private final ObjectMapper json; private final ObjectMapper json;
public FleetIncomingAdapter(WarehouseTransferService service,WarehouseTransferOrderMapper orders,ObjectMapper json){this.service=service;this.orders=orders;this.json=json;} public FleetIncomingAdapter(WarehouseTransferService service,WarehouseTransferOrderMapper orders,ObjectMapper json){this.service=service;this.orders=orders;this.json=json;}
@Override @Transactional(propagation=Propagation.MANDATORY) @Override @Transactional(propagation=Propagation.MANDATORY)
public Map<Source,String> statuses(List<Source> sources) {
if(sources.isEmpty())return Map.of();
if(sources.size()>100)throw conflict("每次最多读取100条来货进度");
var query=new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper<com.baiyue.lastleg.transfer.entity.WarehouseTransferOrder>();
query.and(q->{for(var source:sources)q.or(c->c.eq(com.baiyue.lastleg.transfer.entity.WarehouseTransferOrder::getCustomerId,source.customerId()).eq(com.baiyue.lastleg.transfer.entity.WarehouseTransferOrder::getTransferNo,source.incomingNo()));});
var result=new HashMap<Source,String>();
for(var row:orders.selectList(query))result.put(new Source(row.getCustomerId(),row.getTransferNo()),row.getStatus());
return Map.copyOf(result);
}
@Override @Transactional(propagation=Propagation.MANDATORY)
public Evidence create(String operator,Incoming input){ public Evidence create(String operator,Incoming input){
boolean fba=input.cargo().destinationKey().startsWith("FBA:"); boolean fba=input.cargo().destinationKey().startsWith("FBA:");
String code=fba?input.cargo().destinationKey().substring(4):null; String code=fba?input.cargo().destinationKey().substring(4):null;
@@ -1624,6 +1624,30 @@ CREATE TABLE IF NOT EXISTS ll_pda_inventory_move (
KEY idx_ll_pda_inventory_move_pallet(overseas_warehouse_id,pallet_id,created_at) KEY idx_ll_pda_inventory_move_pallet(overseas_warehouse_id,pallet_id,created_at)
) ENGINE=InnoDB COMMENT='海外PDA同仓整托移库记录'; ) ENGINE=InnoDB COMMENT='海外PDA同仓整托移库记录';
CREATE TABLE IF NOT EXISTS ll_appointment_followup (
id BIGINT NOT NULL COMMENT '预约筹划跟进记录ID',
source_key VARCHAR(160) NOT NULL COMMENT '稳定ORDER或BOOKING来源键,不因实收转换而变化',
customer_id VARCHAR(128) NOT NULL COMMENT '服务端核实的来源客户',
source_order_id BIGINT NULL COMMENT '已形成的真实尾程货源ID,尚未生成时为空',
stage VARCHAR(32) NOT NULL COMMENT '跟进对象:DRAYAGE_CONTACT/TERMINAL_APPOINTMENT/WAREHOUSE_RECEIVING/FBA_RECEIVING',
version INT NOT NULL COMMENT '同一货源及跟进对象的记录版本',
contacted_party VARCHAR(200) NULL COMMENT '本次联系对象,不替代承运主档',
result VARCHAR(1000) NOT NULL COMMENT '本次反馈事实,不自动确认预约或推进实物作业',
next_action VARCHAR(500) NULL COMMENT '下一步安排',
followup_date DATE NULL COMMENT '按业务地点当地日期记录的下次跟进日',
operator_id VARCHAR(128) NOT NULL COMMENT '登录平台操作者ID',
operator_name VARCHAR(200) NULL COMMENT '操作人姓名快照',
command_key VARCHAR(64) NOT NULL COMMENT '持久幂等键',
request_hash CHAR(64) NOT NULL COMMENT '含操作者身份的请求SHA256摘要',
created_at DATETIME(3) NOT NULL COMMENT 'UTC记录时间',
PRIMARY KEY (id),
UNIQUE KEY uk_appointment_followup_version (source_key,stage,version),
UNIQUE KEY uk_appointment_followup_command (source_key,stage,command_key),
KEY idx_appointment_followup_date (followup_date,source_order_id),
CONSTRAINT fk_appointment_followup_order FOREIGN KEY (source_order_id) REFERENCES ll_order(id),
CONSTRAINT chk_appointment_followup_stage CHECK (stage IN ('DRAYAGE_CONTACT','TERMINAL_APPOINTMENT','WAREHOUSE_RECEIVING','FBA_RECEIVING'))
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='真实在途货源的预约筹划联系记录,追加留痕';
CREATE TABLE IF NOT EXISTS ll_inbound_fba_appointment ( CREATE TABLE IF NOT EXISTS ll_inbound_fba_appointment (
id BIGINT NOT NULL COMMENT '主键ID', id BIGINT NOT NULL COMMENT '主键ID',
appointment_no VARCHAR(64) NOT NULL COMMENT '预约单号', appointment_no VARCHAR(64) NOT NULL COMMENT '预约单号',
@@ -0,0 +1,230 @@
openapi: 3.0.3
info:
title: 美国预约筹划货源
version: 1.0.0
description: 读取真实已开船头程货物,不要求预先创建入仓计划,不生成库存、车次或对外确认。
security:
- bearerAuth: []
paths:
/api/platform/v1/last-leg/appointment-planning/cargo:
get:
operationId: appointmentPlanningCargo
summary: 分页读取美国FBA筹划货源
description: >-
仅平台身份;同时要求platform:appointment:list与platform:overseas-warehouse:last-mile-order:list。
从公共仓库目录核对美国接仓点与美国FBA目的仓;目录失败返回错误,不冒充空结果。
当前读取FIRST_LEG货物与已确认开船或更晚的实际船节点,未建计划可见;已有计划不重复计数。
预计时间来自尾程订单保存的来源快照,不代表实时船司ETA;shippingObservedAt仅是实际节点观察时间。
sourcePalletCount是头程预报托数,不是海外实测托位。其他货源由后续正式合同接入,不能从本接口推断已支持。
parameters:
- {name: page, in: query, schema: {type: integer, minimum: 1, default: 1}}
- {name: size, in: query, schema: {type: integer, minimum: 1, maximum: 100, default: 20}}
- {name: warehouseId, in: query, schema: {type: string}, description: 美国接仓点主档ID,仅用于关联}
- {name: destination, in: query, schema: {type: string}, description: 美国FBA仓编码}
- {name: keyword, in: query, schema: {type: string, maxLength: 128}, description: 入仓码、柜号或客户名称}
responses:
'200':
description: 业务响应,必须核对code
content:
application/json:
schema:
type: object
properties:
code: {type: string}
message: {type: string}
data:
type: object
properties:
records: {type: array, items: {$ref: '#/components/schemas/Cargo'}}
page: {type: integer}
size: {type: integer}
total: {type: integer}
pages: {type: integer}
'400': {description: 查询参数或美国范围无效}
'403': {description: 无来源读取权限或非平台身份}
'503': {description: 仓库目录暂不可用,不能证明美国货源范围}
/api/platform/v1/last-leg/appointment-planning/sources/resolve:
get:
operationId: resolveAppointmentPlanningSource
summary: 核对深链来源并读取预约覆盖
description: >-
仅平台;同时要求platform:appointment:list与platform:overseas-warehouse:last-mile-order:list。
依据真实客户、货源与美国仓目录核对来源,URL参数不能授予预约资格。
ORDER仅接受已开船头程FBA货;BOOKING保留原登记身份,确认承运后尚未生成入仓单也能跟进。
此查询不建单、不确认预约。当前覆盖读取已有FBA预约,尚未生成货物时返回SOURCE_NOT_MATERIALIZED。
parameters:
- {name: sourceKey, in: query, required: true, schema: {type: string, maxLength: 160}, description: 'ORDER:<内部关联ID>或BOOKING:<登记号>,非用户展示编号'}
- {name: customerId, in: query, required: true, schema: {type: string, maxLength: 128}}
- {name: destinationKey, in: query, schema: {type: string}, description: 'FBA:<仓编码>,传入后必须与真实来源一致'}
responses:
'200':
description: code=200时data包含已核实来源和预约覆盖
content:
application/json:
schema:
type: object
properties:
code: {type: string}
message: {type: string}
data:
type: object
properties:
source: {$ref: '#/components/schemas/PlanningSource'}
coverage: {$ref: '#/components/schemas/Coverage'}
'400': {description: 非美国FBA来源、尚未开船或目的地变化}
'403': {description: 无读取权限或非平台身份}
'404': {description: 客户与来源不匹配或来源不存在}
'503': {description: 仓库目录不可用}
/api/platform/v1/last-leg/appointment-planning/sources/{sourceKey}/follow-ups:
parameters:
- {name: sourceKey, in: path, required: true, schema: {type: string, maxLength: 160}, description: 'ORDER或BOOKING稳定来源键,路径须URL编码'}
- {name: customerId, in: query, required: true, schema: {type: string, maxLength: 128}}
get:
summary: 分页读取分事项跟进历史与最新版本
description: 仅平台;同时要求platform:appointment:list与platform:overseas-warehouse:last-mile-order:list。已有跟进历史在货物完成后仍可读取。
parameters:
- {name: page, in: query, schema: {type: integer, minimum: 1, default: 1}}
- {name: size, in: query, schema: {type: integer, minimum: 1, maximum: 100, default: 20}}
responses:
'200':
description: code=200时data含history分页和按stage排列的versions;createdAt为UTC时间点,followupDate为业务地点当地日期。
content:
application/json:
schema:
type: object
properties:
code: {type: string}
message: {type: string}
data:
type: object
properties:
history:
type: object
properties:
records: {type: array, items: {$ref: '#/components/schemas/FollowupRecord'}}
page: {type: integer}
size: {type: integer}
total: {type: integer}
pages: {type: integer}
versions: {type: object, additionalProperties: {type: integer, minimum: 0}, description: 四个事项各自的最新版本}
'403': {description: 无读取权限或非平台身份}
'404': {description: 无历史且来源不具备美国筹划资格}
post:
summary: 追加人工联系结果与下次跟进
description: >-
仅平台;除读取权限外要求platform:appointment:followup。
跟进人来自登录主体,不接受客户端操作者ID。核实来源后按stage版本追加,以持久化唯一约束防止并发覆盖;幂等键持久化,同键同操作者同内容返回原记录,异内容拒绝。
不要求已创建入仓计划、外部预约号或司机,不更新正式预约、库存和车次。设置followupDate时nextAction必填。
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [stage, expectedVersion, idempotencyKey, result]
properties:
stage: {type: string, enum: [DRAYAGE_CONTACT, TERMINAL_APPOINTMENT, WAREHOUSE_RECEIVING, FBA_RECEIVING]}
expectedVersion: {type: integer, minimum: 0, description: 初次为0,后续取history.versions中对应事项版本}
idempotencyKey: {type: string, maxLength: 64}
contactedParty: {type: string, maxLength: 200, nullable: true}
result: {type: string, minLength: 1, maxLength: 1000}
nextAction: {type: string, maxLength: 500, nullable: true}
followupDate: {type: string, format: date, nullable: true}
responses:
'200':
description: data为原记录或新增记录
content:
application/json:
schema:
type: object
properties:
code: {type: string}
message: {type: string}
data: {$ref: '#/components/schemas/FollowupRecord'}
'400': {description: 无效事项或缺少下一步安排}
'403': {description: 无写权限或非平台身份}
'404': {description: 来源不存在或不可筹划}
'409': {description: 版本冲突或同键异内容}
components:
securitySchemes:
bearerAuth: {type: http, scheme: bearer, bearerFormat: JWT}
schemas:
FollowupRecord:
type: object
properties:
stage: {type: string, enum: [DRAYAGE_CONTACT, TERMINAL_APPOINTMENT, WAREHOUSE_RECEIVING, FBA_RECEIVING]}
version: {type: integer, minimum: 1}
contactedParty: {type: string, nullable: true}
result: {type: string}
nextAction: {type: string, nullable: true}
followupDate: {type: string, format: date, nullable: true}
operatorName: {type: string, nullable: true}
createdAt: {type: string, format: date-time, description: UTC时间点}
PlanningSource:
type: object
properties:
sourceKey: {type: string}
customerId: {type: string}
orderId: {type: string, nullable: true}
inboundCode: {type: string, nullable: true}
bookingNo: {type: string, nullable: true}
containerNo: {type: string, nullable: true}
originWarehouseId: {type: string}
originWarehouseName: {type: string}
destinationKey: {type: string}
destinationWarehouseCode: {type: string}
packages: {type: integer, nullable: true, description: 未知数量保持空值,不能替换为0}
sourceVersion: {type: string, nullable: true}
canFollowup: {type: boolean}
blockReasons: {type: array, items: {type: string}}
Coverage:
type: object
properties:
customerId: {type: string}
sourceKey: {type: string}
destinationKey: {type: string}
status: {type: string, enum: [SOURCE_NOT_FOUND, SOURCE_NOT_MATERIALIZED, SOURCE_NOT_READY, DESTINATION_MISMATCH, UNASSIGNED, PARTIAL, PENDING, CONFIRMED, REVIEW_REQUIRED]}
sourceVersion: {type: string, nullable: true}
changeReviewRequired: {type: boolean}
missingFields: {type: array, items: {type: string}, description: 缺项或待核实原因代码,客户端需显示业务文案}
appointments:
type: array
items:
type: object
properties:
appointmentNo: {type: string}
confirmationNo: {type: string, nullable: true}
status: {type: string}
confirmedAt: {type: string, nullable: true}
confirmedEndAt: {type: string, nullable: true}
timezone: {type: string, nullable: true, description: 旧预约未记录时区时为空并返回缺项,不能按浏览器时区推定}
Cargo:
type: object
properties:
sourceKey: {type: string, description: 稳定ORDER来源键,不因生成入仓计划而变化;非展示编号}
orderId: {type: string}
customerId: {type: string}
customerName: {type: string, nullable: true}
inboundCode: {type: string, nullable: true}
containerBookingId: {type: string}
containerNo: {type: string, nullable: true}
originWarehouseId: {type: string}
originWarehouseCode: {type: string}
originWarehouseName: {type: string}
destinationWarehouseCode: {type: string}
packages: {type: integer, nullable: true}
receivedPackages: {type: integer, nullable: true}
shippedPackages: {type: integer, nullable: true}
sourcePalletCount: {type: integer, nullable: true}
volumeCbm: {type: number, nullable: true}
estimatedArrivalAt: {type: string, nullable: true, description: 来源预计到港快照,保留原业务当地时间;不得按浏览器时区转换}
estimatedWarehouseAt: {type: string, nullable: true, description: 已保存预计到仓,不因本查询改变}
orderVersion: {type: integer}
standardNode: {type: string}
shippingVersion: {type: string, nullable: true}
shippingObservedAt: {type: string, nullable: true}
shippingProvider: {type: string, nullable: true}
hasInboundPlan: {type: boolean}
hasActiveAppointment: {type: boolean}
detained: {type: boolean}
mixedDestinations: {type: boolean, description: 存在其他目的地明细时,不能按整票默认为同仓货物}
@@ -376,7 +376,7 @@ paths:
/api/platform/v1/last-leg/fleet/capacity-offers/inquiries: /api/platform/v1/last-leg/fleet/capacity-offers/inquiries:
get: get:
summary: 分页查询本人或获准的全部意向 summary: 分页查询本人或获准的全部意向
description: 权限:platform:fleet:capacity-offer:list 或 platform:fleet:capacity-offer:manage。ALL只允许manage;销售负责人由登录身份过滤,历史不依赖发布仍OPEN。 description: 权限:platform:fleet:capacity-offer:list 或 platform:fleet:capacity-offer:manage。ALL和TODO只允许manage;TODO按服务端负责角色筛选调度待办,基础候选超过100条要求按客户或登记号缩小范围,过滤后精确分页,不按当前页伪造总数;销售负责人由登录身份过滤,历史不依赖发布仍OPEN。
responses: responses:
'200': '200':
description: 统一业务响应;code=200成功,冲突或资格拒绝不得当成功 description: 统一业务响应;code=200成功,冲突或资格拒绝不得当成功
@@ -440,6 +440,7 @@ paths:
enum: enum:
- MINE - MINE
- ALL - ALL
- TODO
default: MINE default: MINE
/api/platform/v1/last-leg/fleet/capacity-offers/inquiries/{bookingNo}: /api/platform/v1/last-leg/fleet/capacity-offers/inquiries/{bookingNo}:
get: get:
@@ -985,8 +986,41 @@ components:
actions: actions:
$ref: '#/components/schemas/OfferActions' $ref: '#/components/schemas/OfferActions'
Inquiry: Inquiry:
description: 登记返回服务端资料缺项与处理进度;canConfirm同时核对本票完整性和目的地三维余位,最终写命令仍复验。
type: object type: object
properties: properties:
appointmentCoverage:
type: object
nullable: true
description: FBA来源通过AppointmentCoverageService按客户和BOOKING稳定来源批量读取;无订单保留待形成货件资料,私人目的地为null。不把保位当收货预约,不推算未知时区。
properties:
status: {type: string}
sourceVersion: {type: string, nullable: true}
changeReviewRequired: {type: boolean}
missingFields: {type: array, items: {type: string}}
appointments:
type: array
items:
type: object
properties:
appointmentNo: {type: string}
confirmationNo: {type: string, nullable: true}
status: {type: string}
confirmedAt: {type: string, nullable: true}
confirmedEndAt: {type: string, nullable: true}
timezone: {type: string, nullable: true}
progress:
type: object
description: 只读派生处理提示,不是可写状态;负责人及动作按资料完整性、承运/来货/实收事实返回;保存不保位。
required: [stage, label, reservation, owner, primaryAction, message, missingFields]
properties:
stage: {type: string}
label: {type: string}
reservation: {type: string}
owner: {type: string}
primaryAction: {type: string, enum: [VIEW, EDIT, CONFIRM, INCOMING, RECEIPT, BIND]}
message: {type: string}
missingFields: {type: array, items: {type: string}}
bookingNo: bookingNo:
type: string type: string
offerNo: offerNo:
@@ -0,0 +1,103 @@
openapi: 3.0.3
info:
title: 共用车次准备与拼车连续处理
version: 1.0.0
security:
- bearerAuth: []
paths:
/api/platform/v1/last-leg/fleet/trips/prepare:
post:
summary: 原子新建或加入车次并关联所选排车单
description: '权限:platform:fleet:trip:edit;新建另需platform:fleet:trip:create和platform:fleet:trip:assign。新建、资源分配和全部关联在同一事务,失败回滚;幂等键同载荷重放原结果,异载荷拒绝。同仓、未执行、未绑定其他车次;不确认容量、不审批、不发车。'
requestBody:
required: true
content:
application/json:
schema: {$ref: '#/components/schemas/Prepare'}
responses:
'200': {description: '统一ApiResponse,data为FleetTripModels.Detail;code=200才成功'}
/api/platform/v1/last-leg/fleet/trips/{tripNo}/dispatch-candidates:
parameters: [{$ref: './fleet-trips-v1.yaml#/components/parameters/TripNo'}]
get:
summary: 本仓未关联且未开始执行的排车候选
description: '权限:platform:fleet:trip:list。数据库筛选后分页,绑定时重新锁定校验。候选不代表预约、装载或审核已完成。'
parameters:
- {name: keyword, in: query, schema: {type: string}}
- {name: page, in: query, schema: {type: integer, minimum: 1, default: 1}}
- {name: size, in: query, schema: {type: integer, minimum: 1, maximum: 100, default: 20}}
responses:
'200': {description: '统一分页data:records含dispatchNo、customerId、warehouseId、packageCount、route及total/page/size/pages;内部身份不作为用户业务编号'}
/api/platform/v1/last-leg/fleet/capacity-offers/inquiries/{bookingNo}/save-and-confirm:
parameters: [{$ref: './fleet-trips-v1.yaml#/components/parameters/BookingNo'}]
post:
summary: 同窗补齐登记资料并原子确认承运
description: '权限:platform:fleet:capacity-offer:manage。复用InquiryWrite,更新资料、创建唯一商业订单及占位同事务;任何阶段失败全部回滚,不把保存当成交。同键同载荷返回原结果。'
requestBody:
required: true
content:
application/json:
schema: {$ref: './fleet-capacity-offers-v1.yaml#/components/schemas/InquiryWrite'}
responses:
'200': {description: '统一ApiResponse,data为InquiryResult'}
/api/platform/v1/last-leg/fleet/capacity-offers/inquiries/{bookingNo}/fulfillment:
parameters: [{$ref: './fleet-trips-v1.yaml#/components/parameters/BookingNo'}]
get:
summary: 从原登记查看本票履约进度
description: '权限:platform:fleet:capacity-offer:list或platform:fleet:capacity-offer:manage。销售仅本人;返回本票booking、preview及plannedDepartureAt,不返回整车司机或他人货物。preview含tripRecordVersion、recordVersion、incoming,未安排时incoming为null。'
responses:
'200': {description: '统一ApiResponse,data为Fulfillment'}
/api/platform/v1/last-leg/fleet/capacity-offers/inquiries/{bookingNo}/incoming:
parameters: [{$ref: './fleet-trips-v1.yaml#/components/parameters/BookingNo'}]
post:
summary: 从原登记明确安排来货
description: '权限:platform:fleet:capacity-offer:manage和platform:fleet:trip:booking-edit。复用版本及幂等门禁,未填时间时沿用登记预计到仓,仍缺失则拒绝,不自动减两小时猜测。承运确认不自动调用此命令。'
requestBody:
required: true
content:
application/json:
schema: {$ref: './fleet-trips-v1.yaml#/components/schemas/BookingIncoming'}
responses:
'200': {$ref: './fleet-trips-v1.yaml#/components/responses/BookingResult'}
/api/platform/v1/last-leg/fleet/capacity-offers/inquiries/{bookingNo}/receipt-confirmation:
parameters: [{$ref: './fleet-trips-v1.yaml#/components/parameters/BookingNo'}]
post:
summary: 原登记核对真实实收与约定运费
description: '权限:platform:fleet:capacity-offer:manage和platform:fleet:trip:booking-confirm。沿实际receiptHash/托盘和三维容量校验;不以预报覆盖实收。'
requestBody:
required: true
content:
application/json:
schema: {$ref: './fleet-trips-v1.yaml#/components/schemas/BookingAcceptReceipt'}
responses:
'200': {$ref: './fleet-trips-v1.yaml#/components/responses/BookingResult'}
/api/platform/v1/last-leg/fleet/capacity-offers/inquiries/{bookingNo}/arrange-dispatch:
parameters: [{$ref: './fleet-trips-v1.yaml#/components/parameters/BookingNo'}]
post:
summary: 实收后复用或生成排车并原子排入本车
description: '权限:platform:fleet:capacity-offer:manage、platform:fleet:trip:booking-edit、platform:fleet:trip:edit和platform:overseas-warehouse:inbound-order:smart-dispatch。锁原车次和本票来货,先校验实收快照、车辆、有效容量;来源有多张排车或不完整时拒绝,不另建。无排车时复用真实预排命令;任何校验或绑定失败整体回滚。同键重放不重复建单。成功将预留替换为BOUND,旧计划失效待核对,仍需审核和现场作业。'
requestBody:
required: true
content:
application/json:
schema: {$ref: './fleet-trips-v1.yaml#/components/schemas/BookingCommand'}
responses:
'200': {$ref: './fleet-trips-v1.yaml#/components/responses/BookingResult'}
components:
securitySchemes:
bearerAuth: {type: http, scheme: bearer, bearerFormat: JWT}
schemas:
Prepare:
type: object
required: [dispatchNos, idempotencyKey]
properties:
tripNo: {type: string, nullable: true, maxLength: 64}
recordVersion: {type: integer, format: int64, nullable: true, minimum: 0}
plan:
allOf:
- {$ref: './fleet-trips-v1.yaml#/components/schemas/Create'}
nullable: true
description: 仅新建提交,实际创建幂等键使用外层idempotencyKey。
vehicleId: {type: string, nullable: true}
driverId: {type: string, nullable: true}
dispatchNos: {type: array, minItems: 1, maxItems: 100, uniqueItems: true, items: {type: string, maxLength: 64}}
idempotencyKey: {type: string, minLength: 8, maxLength: 128}
@@ -0,0 +1,45 @@
package com.baiyue.lastleg.appointmentplanning;
import org.apache.ibatis.mapping.Environment;
import org.apache.ibatis.session.Configuration;
import org.apache.ibatis.session.SqlSessionFactoryBuilder;
import org.apache.ibatis.transaction.jdbc.JdbcTransactionFactory;
import org.h2.jdbcx.JdbcDataSource;
import org.junit.jupiter.api.Test;
import java.util.List;
import java.util.UUID;
import static org.junit.jupiter.api.Assertions.*;
class AppointmentCargoMapperTest {
@Test void unplannedSailedCargoIsVisibleAndCreatingPlansDoesNotDuplicateTheSource() throws Exception {
var ds = new JdbcDataSource(); ds.setURL("jdbc:h2:mem:appointment-"+ UUID.randomUUID()+";MODE=MySQL;DB_CLOSE_DELAY=-1");
try (var c=ds.getConnection(); var s=c.createStatement()) {
s.execute("CREATE TABLE ll_order(id BIGINT,customer_id VARCHAR,customer_name VARCHAR,inbound_code VARCHAR,source_container_booking_id VARCHAR,container_no VARCHAR,overseas_warehouse_id VARCHAR,overseas_warehouse_code VARCHAR,overseas_warehouse_name VARCHAR,fba_warehouse_code VARCHAR,package_count INT,received_package_count INT,shipped_package_count INT,pallet_count INT,volume_cbm DECIMAL,estimated_arrival_at TIMESTAMP,estimated_warehouse_at TIMESTAMP,version INT,detained BOOLEAN,order_type VARCHAR,destination_type VARCHAR,current_status VARCHAR,created_at TIMESTAMP)");
s.execute("CREATE TABLE ll_container_shipping_gate(container_booking_id VARCHAR,node_sequence INT,standard_node VARCHAR,aggregate_version BIGINT,observed_at TIMESTAMP,provider_code VARCHAR)");
s.execute("CREATE TABLE ll_inbound_plan(id BIGINT,source_order_id BIGINT,customer_id VARCHAR)");
s.execute("CREATE TABLE ll_inbound_fba_appointment_item(order_id BIGINT,customer_id VARCHAR,appointment_id BIGINT)");
s.execute("CREATE TABLE ll_inbound_fba_appointment(id BIGINT,appointment_status VARCHAR)");
s.execute("CREATE TABLE ll_order_detail(order_id BIGINT,customer_id VARCHAR,destination_warehouse_code VARCHAR)");
s.execute("INSERT INTO ll_order(id,customer_id,inbound_code,source_container_booking_id,overseas_warehouse_id,fba_warehouse_code,package_count,order_type,destination_type,current_status,created_at) VALUES(1,'A','IN-A','C1','100','ONT8',10,'FIRST_LEG','FBA','WAIT_INBOUND',CURRENT_TIMESTAMP),(2,'B','IN-B','C2','100','ONT8',8,'FIRST_LEG','FBA','WAIT_INBOUND',CURRENT_TIMESTAMP)");
s.execute("INSERT INTO ll_container_shipping_gate(container_booking_id,node_sequence) VALUES('C1',40),('C2',30)");
}
var config=new Configuration(new Environment("test",new JdbcTransactionFactory(),ds));
config.setMapUnderscoreToCamelCase(true); config.addMapper(AppointmentCargoMapper.class);
try (var session=new SqlSessionFactoryBuilder().build(config).openSession(true)) {
var mapper=session.getMapper(AppointmentCargoMapper.class);
assertEquals(1,mapper.count(List.of("100"),List.of("ONT8"),null,null,null));
var first=mapper.page(List.of("100"),List.of("ONT8"),null,null,null,20,0);
assertEquals("ORDER:1",first.get(0).getSourceKey()); assertFalse(first.get(0).isHasInboundPlan());
try (var s=session.getConnection().createStatement()) {
s.execute("INSERT INTO ll_inbound_plan VALUES(11,1,'A'),(12,1,'A')");
s.execute("INSERT INTO ll_inbound_fba_appointment VALUES(20,'SUCCESS')");
s.execute("INSERT INTO ll_inbound_fba_appointment_item VALUES(1,'A',20)");
}
session.clearCache();
var after=mapper.page(List.of("100"),List.of("ONT8"),null,null,null,20,0);
assertEquals(1,after.size()); assertTrue(after.get(0).isHasInboundPlan()); assertTrue(after.get(0).isHasActiveAppointment());
assertEquals(10,after.get(0).getPackages()); assertEquals(1,mapper.count(List.of("100"),List.of("ONT8"),null,null,null));
assertEquals(0,mapper.count(List.of("200"),List.of("ONT8"),null,null,null));
}
}
}
@@ -0,0 +1,44 @@
package com.baiyue.lastleg.appointmentplanning;
import com.baiyue.common.core.api.ApiResponse;
import org.junit.jupiter.api.Test;
import org.springframework.web.server.ResponseStatusException;
import java.util.List;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.*;
class AppointmentCargoServiceTest {
@Test void authenticUsDirectoryScopesTheDatabaseQueryAndPreservesServerPagination() {
var mapper = mock(AppointmentCargoMapper.class);
var warehouses = mock(AppointmentWarehouseClient.class);
when(warehouses.catalog("token")).thenReturn(ApiResponse.success(List.of(
new AppointmentWarehouseClient.Warehouse(1L,"US-WH","美国仓","OVERSEAS","US"),
new AppointmentWarehouseClient.Warehouse(2L,"CA-WH","加拿大仓","OVERSEAS","CA"),
new AppointmentWarehouseClient.Warehouse(3L,"ONT8","ONT8","FBA","US"),
new AppointmentWarehouseClient.Warehouse(4L,"YYZ1","YYZ1","FBA","CA"))));
var row = new AppointmentCargo(); row.setOrderId(99L); row.setSourceKey("ORDER:99"); row.setHasInboundPlan(false);
when(mapper.count(List.of("1"),List.of("ONT8"),null,null,"WT")).thenReturn(21L);
when(mapper.page(List.of("1"),List.of("ONT8"),null,null,"WT",20,20)).thenReturn(List.of(row));
var result = new AppointmentCargoService(mapper,warehouses,"token").page(2,20,null,null," WT ");
assertEquals(21,result.total()); assertEquals(2,result.pages()); assertEquals(1,result.records().size());
assertFalse(result.records().get(0).isHasInboundPlan());
verify(warehouses,times(1)).catalog("token");
}
@Test void directoryFailureIsNotReportedAsAnEmptyAmericanCargoList() {
var mapper = mock(AppointmentCargoMapper.class); var warehouses = mock(AppointmentWarehouseClient.class);
when(warehouses.catalog("token")).thenThrow(new IllegalStateException("offline"));
var error = assertThrows(ResponseStatusException.class, () -> new AppointmentCargoService(mapper,warehouses,"token").page(1,20,null,null,null));
assertEquals(503,error.getStatusCode().value()); verifyNoInteractions(mapper);
}
@Test void invalidPaginationAndNonUsFiltersNeverQueryCargo() {
var mapper = mock(AppointmentCargoMapper.class); var warehouses = mock(AppointmentWarehouseClient.class);
var service = new AppointmentCargoService(mapper,warehouses,"token");
assertThrows(ResponseStatusException.class, () -> service.page(0,20,null,null,null));
verifyNoInteractions(warehouses);
when(warehouses.catalog("token")).thenReturn(ApiResponse.success(List.of()));
assertThrows(ResponseStatusException.class, () -> service.page(1,20,"unknown",null,null));
verifyNoInteractions(mapper);
}
}
@@ -0,0 +1,41 @@
package com.baiyue.lastleg.appointmentplanning;
import org.apache.ibatis.mapping.Environment;
import org.apache.ibatis.session.Configuration;
import org.apache.ibatis.session.SqlSessionFactoryBuilder;
import org.apache.ibatis.transaction.jdbc.JdbcTransactionFactory;
import org.h2.jdbcx.JdbcDataSource;
import org.junit.jupiter.api.Test;
import java.util.List;
import java.util.UUID;
import static org.junit.jupiter.api.Assertions.*;
class AppointmentCoverageMapperTest {
@Test void readsOnlyMatchingCustomerAndPlanLinesAndExcludesCancelledAppointments() throws Exception {
var ds=new JdbcDataSource(); ds.setURL("jdbc:h2:mem:coverage-"+UUID.randomUUID()+";MODE=MySQL;DB_CLOSE_DELAY=-1");
try (var c=ds.getConnection();var s=c.createStatement()) {
s.execute("CREATE TABLE ll_order(id BIGINT,customer_id VARCHAR(128),destination_type VARCHAR(32),fba_warehouse_code VARCHAR(128),package_count INT,version INT,updated_at TIMESTAMP)");
s.execute("CREATE TABLE ll_order_detail(order_id BIGINT,customer_id VARCHAR(128),shipment_no VARCHAR(128),fba_tracking_no VARCHAR(128))");
s.execute("CREATE TABLE ll_inbound_plan_line(id BIGINT,plan_id BIGINT,order_id BIGINT,customer_id VARCHAR(128),required_package_count INT,fba_warehouse_code VARCHAR(128))");
s.execute("CREATE TABLE ll_inbound_fba_appointment(id BIGINT,appointment_no VARCHAR(128),appointment_status VARCHAR(32),appointment_at TIMESTAMP,appointment_end_at TIMESTAMP,updated_at TIMESTAMP)");
s.execute("CREATE TABLE ll_inbound_fba_appointment_item(id BIGINT,appointment_id BIGINT,order_id BIGINT,customer_id VARCHAR(128),line_id BIGINT,plan_id BIGINT)");
s.execute("INSERT INTO ll_order VALUES(1,'A','FBA','ONT8',20,1,CURRENT_TIMESTAMP),(2,'B','FBA','ONT8',8,1,CURRENT_TIMESTAMP)");
s.execute("INSERT INTO ll_order_detail VALUES(1,'A','SHIP-A',NULL),(2,'B',NULL,NULL)");
s.execute("INSERT INTO ll_inbound_plan_line VALUES(10,100,1,'A',20,'ONT8'),(20,200,2,'B',8,'ONT8')");
s.execute("INSERT INTO ll_inbound_fba_appointment VALUES(1000,'APP-1','SUCCESS',CURRENT_TIMESTAMP,NULL,CURRENT_TIMESTAMP),(2000,'CANCELLED-1','CANCELLED',CURRENT_TIMESTAMP,NULL,CURRENT_TIMESTAMP)");
s.execute("INSERT INTO ll_inbound_fba_appointment_item VALUES(1,1000,1,'A',10,100),(2,1000,2,'B',20,200),(3,2000,1,'A',10,100),(4,1000,1,'A',20,200)");
}
var config=new Configuration(new Environment("test",new JdbcTransactionFactory(),ds));
config.setMapUnderscoreToCamelCase(true); config.addMapper(AppointmentCoverageMapper.class);
try (var session=new SqlSessionFactoryBuilder().build(config).openSession(true)) {
var mapper=session.getMapper(AppointmentCoverageMapper.class);
var refs=List.of(new AppointmentCoverageMapper.OrderRef("A",1L));
var source=mapper.sources(refs).get(0); assertFalse(source.shipmentReferencesMissing()); assertEquals(20,source.packages());
var facts=mapper.appointments(refs); assertEquals(1,facts.size()); assertEquals("APP-1",facts.get(0).appointmentNo()); assertEquals(20,facts.get(0).packages());
assertTrue(mapper.sources(List.of(new AppointmentCoverageMapper.OrderRef("B",1L))).isEmpty());
assertTrue(mapper.sources(List.of(new AppointmentCoverageMapper.OrderRef("B",2L))).get(0).shipmentReferencesMissing());
try (var s=session.getConnection().createStatement()) { s.execute("INSERT INTO ll_order_detail VALUES(1,'A',' ',NULL)"); }
session.clearCache(); assertTrue(mapper.sources(refs).get(0).shipmentReferencesMissing());
}
}
}
@@ -0,0 +1,65 @@
package com.baiyue.lastleg.appointmentplanning;
import org.junit.jupiter.api.Test;
import java.time.LocalDateTime;
import java.util.List;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.*;
import static org.mockito.Mockito.*;
class AppointmentCoverageServiceTest {
private static final LocalDateTime BEFORE=LocalDateTime.of(2026,10,9,1,0), AFTER=BEFORE.plusHours(1);
private static AppointmentCoverageMapper.Source order() {
return new AppointmentCoverageMapper.Source(1L,"A","FBA","ONT8",20,2,BEFORE,false);
}
private static AppointmentCoverageMapper.Fact fact(long line,int packages,String status) {
return new AppointmentCoverageMapper.Fact(1L,"A","FBA-CONFIRM-1",status,AFTER.plusDays(1),null,AFTER,line,packages,"ONT8");
}
@Test void completeQuantitySharesOneAppointmentButDoesNotInventItsTimezone() {
var mapper=mock(AppointmentCoverageMapper.class); var gateway=mock(AppointmentExternalSourceGateway.class);
when(mapper.sources(anyList())).thenReturn(List.of(order()));
when(mapper.appointments(anyList())).thenReturn(List.of(fact(10,10,"SUCCESS"),fact(11,10,"SUCCESS")));
var result=new AppointmentCoverageService(mapper,gateway).query(List.of(new AppointmentCoverageService.SourceRef("A","ORDER:1","FBA:ONT8"))).get(0);
assertEquals("CONFIRMED",result.status()); assertEquals(1,result.appointments().size());
assertNull(result.appointments().get(0).timezone()); assertTrue(result.missingFields().contains("APPOINTMENT_TIMEZONE_UNVERIFIED"));
assertFalse(result.changeReviewRequired()); verifyNoInteractions(gateway);
}
@Test void partialPendingAndDuplicateClaimsNeverAppearAsCompleteCoverage() {
var mapper=mock(AppointmentCoverageMapper.class); when(mapper.sources(anyList())).thenReturn(List.of(order()));
var service=new AppointmentCoverageService(mapper,mock(AppointmentExternalSourceGateway.class));
var refs=List.of(new AppointmentCoverageService.SourceRef("A","ORDER:1","FBA:ONT8"));
when(mapper.appointments(anyList())).thenReturn(List.of(fact(10,10,"SUCCESS")));
assertEquals("PARTIAL",service.query(refs).get(0).status());
when(mapper.appointments(anyList())).thenReturn(List.of(fact(10,20,"PENDING")));
assertEquals("PENDING",service.query(refs).get(0).status());
when(mapper.appointments(anyList())).thenReturn(List.of(fact(10,10,"SUCCESS"),fact(10,10,"SUCCESS")));
assertEquals("REVIEW_REQUIRED",service.query(refs).get(0).status());
}
@Test void externalBookingKeepsItsIdentityBeforeAndAfterRealCargoExists() {
var mapper=mock(AppointmentCoverageMapper.class); var gateway=mock(AppointmentExternalSourceGateway.class);
var refs=List.of(new AppointmentCoverageService.SourceRef("A","BOOKING:B-1","FBA:ONT8"));
var service=new AppointmentCoverageService(mapper,gateway);
when(gateway.resolve(anyList())).thenReturn(List.of(new AppointmentExternalSourceGateway.Source("A","B-1","TR-1","CONFIRMED","100","FBA:ONT8",null,20,1L)));
assertEquals("SOURCE_NOT_MATERIALIZED",service.query(refs).get(0).status()); verifyNoInteractions(mapper);
when(gateway.resolve(anyList())).thenReturn(List.of(new AppointmentExternalSourceGateway.Source("A","B-1","TR-1","BOUND","100","FBA:ONT8",1L,20,2L)));
when(mapper.sources(anyList())).thenReturn(List.of(order())); when(mapper.appointments(anyList())).thenReturn(List.of(fact(10,20,"SUCCESS")));
var result=service.query(refs).get(0); assertEquals("BOOKING:B-1",result.sourceKey()); assertEquals("CONFIRMED",result.status());
assertThrows(IllegalArgumentException.class, () -> service.query(List.of(refs.get(0),new AppointmentCoverageService.SourceRef("A","ORDER:1","FBA:ONT8"))));
}
@Test void anotherCustomerAndChangedDestinationCannotBorrowAppointmentCoverage() {
var mapper=mock(AppointmentCoverageMapper.class); var gateway=mock(AppointmentExternalSourceGateway.class);
when(mapper.sources(anyList())).thenReturn(List.of(order())); when(mapper.appointments(anyList())).thenReturn(List.of(fact(10,20,"SUCCESS")));
var service=new AppointmentCoverageService(mapper,gateway);
assertEquals("SOURCE_NOT_FOUND",service.query(List.of(new AppointmentCoverageService.SourceRef("B","ORDER:1","FBA:ONT8"))).get(0).status());
assertEquals("DESTINATION_MISMATCH",service.query(List.of(new AppointmentCoverageService.SourceRef("A","ORDER:1","FBA:LAX9"))).get(0).status());
}
@Test void changedSourcesAndMissingShipmentReferencesAreExplicitWithoutChangingConfirmedTime() {
var mapper=mock(AppointmentCoverageMapper.class);
when(mapper.sources(anyList())).thenReturn(List.of(new AppointmentCoverageMapper.Source(1L,"A","FBA","ONT8",20,3,AFTER.plusHours(1),true)));
when(mapper.appointments(anyList())).thenReturn(List.of(fact(10,20,"SUCCESS")));
var result=new AppointmentCoverageService(mapper,mock(AppointmentExternalSourceGateway.class)).query(List.of(new AppointmentCoverageService.SourceRef("A","ORDER:1","FBA:ONT8"))).get(0);
assertTrue(result.changeReviewRequired()); assertTrue(result.missingFields().containsAll(List.of("SOURCE_CHANGED_REVIEW","FBA_SHIPMENT_REFERENCES")));
assertEquals(AFTER.plusDays(1),result.appointments().get(0).confirmedAt());
verify(mapper,times(1)).sources(anyList()); verify(mapper,times(1)).appointments(anyList()); verifyNoMoreInteractions(mapper);
}
}
@@ -0,0 +1,45 @@
package com.baiyue.lastleg.appointmentplanning;
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.nio.charset.StandardCharsets;
import java.time.LocalDateTime;
import java.util.UUID;
import static org.junit.jupiter.api.Assertions.*;
class AppointmentFollowupPersistenceTest {
@Test void baselineAndMapperPersistSeparateStagesAndEnforceCommandAndVersionUniqueness() throws Exception {
String sql;
try (var input=getClass().getResourceAsStream("/db/lastleg.sql")) {
assertNotNull(input); var baseline=new String(input.readAllBytes(),StandardCharsets.UTF_8);
int start=baseline.indexOf("CREATE TABLE IF NOT EXISTS ll_appointment_followup (");
assertTrue(start>=0); sql=baseline.substring(start,baseline.indexOf(';',start)+1);
}
var ds=new JdbcDataSource(); ds.setURL("jdbc:h2:mem:followup-"+ UUID.randomUUID()+";MODE=MySQL;DB_CLOSE_DELAY=-1");
try (var c=ds.getConnection();var s=c.createStatement()) {
s.execute("CREATE TABLE ll_order(id BIGINT PRIMARY KEY)"); s.execute("INSERT INTO ll_order VALUES(1)"); s.execute(sql);
}
var config=new MybatisConfiguration(); config.setEnvironment(new Environment("test",new JdbcTransactionFactory(),ds));
config.setMapUnderscoreToCamelCase(true); config.addMapper(AppointmentFollowupMapper.class);
try (var session=new MybatisSqlSessionFactoryBuilder().build(config).openSession(true)) {
var mapper=session.getMapper(AppointmentFollowupMapper.class);
var first=record(11L,"DRAYAGE_CONTACT",1,"key-a"); mapper.insert(first);
mapper.insert(record(12L,"FBA_RECEIVING",1,"key-a"));
assertEquals(1,mapper.version("ORDER:1","A","DRAYAGE_CONTACT")); assertEquals(1,mapper.version("ORDER:1","A","FBA_RECEIVING"));
assertEquals(2,mapper.count("ORDER:1","A")); assertEquals("联系结果",mapper.command("ORDER:1","A","DRAYAGE_CONTACT","key-a").getResult());
assertEquals(1,mapper.page("ORDER:1","A",1,0).size());
assertThrows(RuntimeException.class, () -> mapper.insert(record(13L,"DRAYAGE_CONTACT",2,"key-a")));
assertThrows(RuntimeException.class, () -> mapper.insert(record(14L,"DRAYAGE_CONTACT",1,"key-b")));
assertEquals(2,mapper.count("ORDER:1","A"));
}
}
private static AppointmentFollowup record(long id,String stage,int version,String key) {
var row=new AppointmentFollowup(); row.setId(id); row.setSourceOrderId(1L); row.setSourceKey("ORDER:1"); row.setCustomerId("A"); row.setStage(stage); row.setVersion(version);
row.setResult("联系结果"); row.setOperatorId("operator"); row.setCommandKey(key); row.setRequestHash("a".repeat(64));
row.setCreatedAt(LocalDateTime.of(2026,10,9,12,0)); return row;
}
}
@@ -0,0 +1,59 @@
package com.baiyue.lastleg.appointmentplanning;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.Test;
import org.springframework.web.server.ResponseStatusException;
import java.time.LocalDate;
import java.util.HashMap;
import java.util.List;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.*;
import static org.mockito.Mockito.*;
class AppointmentFollowupServiceTest {
@Test void contactsBeforePlanningAreAppendOnlyAndOldCommandsReplayTheirOriginalResult() {
var cargo=mock(AppointmentSourceService.class); var mapper=mock(AppointmentFollowupMapper.class);
var saved=new HashMap<String,AppointmentFollowup>(); var version=new int[]{0};
when(cargo.resolve("ORDER:1","A",null)).thenReturn(new AppointmentSourceService.Source("ORDER:1","A",1L,"IN-A",null,"C1","100","美国仓","FBA:ONT8","ONT8",10,"ORDER:0",true,List.of()));
when(mapper.command(eq("ORDER:1"),eq("A"),eq("DRAYAGE_CONTACT"),anyString())).thenAnswer(call -> saved.get(call.getArgument(3)));
when(mapper.version("ORDER:1","A","DRAYAGE_CONTACT")).thenAnswer(call -> version[0]);
when(mapper.insert(any(AppointmentFollowup.class))).thenAnswer(call -> {
AppointmentFollowup row=call.getArgument(0); saved.put(row.getCommandKey(),row); version[0]=row.getVersion(); return 1;
});
var service=new AppointmentFollowupService(cargo,mapper,new ObjectMapper().findAndRegisterModules());
var first=new AppointmentFollowupService.Save("DRAYAGE_CONTACT",0,"first","车队A","已联系,等待回复","明日再联系", LocalDate.of(2026,10,15));
var result=service.save("ORDER:1","A",first,"operator","运营甲");
service.save("ORDER:1","A",new AppointmentFollowupService.Save("DRAYAGE_CONTACT",1,"second","车队A","尚未给出正式时段",null,null),"operator","运营甲");
var replay=service.save("ORDER:1","A",first,"operator","运营甲");
assertEquals(result,replay); assertEquals(1,replay.version()); assertEquals(2,saved.size());
assertEquals("运营甲",result.operatorName()); assertEquals("已联系,等待回复",result.result());
verify(mapper,times(2)).insert(any(AppointmentFollowup.class));
verify(cargo,times(2)).resolve("ORDER:1","A",null);
var conflict=assertThrows(ResponseStatusException.class, () -> service.save("ORDER:1","A",
new AppointmentFollowupService.Save("DRAYAGE_CONTACT",0,"first",null,"不同结果",null,null),"operator","运营甲"));
assertEquals(409,conflict.getStatusCode().value());
assertThrows(ResponseStatusException.class, () -> service.save("ORDER:1","A",first,"another-operator","运营乙"));
assertThrows(ResponseStatusException.class, () -> service.save("ORDER:1","A",
new AppointmentFollowupService.Save("DRAYAGE_CONTACT",0,"stale",null,"并发旧版本",null,null),"operator","运营甲"));
assertEquals(2,saved.size());
}
@Test void followupDateRequiresAnActionAndInvalidStagesDoNotWrite() {
var cargo=mock(AppointmentSourceService.class); var mapper=mock(AppointmentFollowupMapper.class);
var service=new AppointmentFollowupService(cargo,mapper,new ObjectMapper());
assertThrows(ResponseStatusException.class, () -> service.save("ORDER:1","A",
new AppointmentFollowupService.Save("FBA_RECEIVING",0,"key",null,"等待材料",null,LocalDate.of(2026,10,15)),"operator","运营"));
assertThrows(ResponseStatusException.class, () -> service.save("ORDER:1","A",
new AppointmentFollowupService.Save("CONFIRM_ALL",0,"key",null,"不能混用",null,null),"operator","运营"));
verifyNoInteractions(cargo,mapper);
}
@Test void existingHistoryRemainsReadableWhenTheCargoHasLeftTheActivePlanningList() {
var cargo=mock(AppointmentSourceService.class); var mapper=mock(AppointmentFollowupMapper.class);
when(mapper.count("ORDER:1","A")).thenReturn(21L); when(mapper.page("ORDER:1","A",20,20)).thenReturn(List.of());
var service=new AppointmentFollowupService(cargo,mapper,new ObjectMapper());
var history=service.history("ORDER:1","A",2,20);
assertEquals(21,history.history().total()); assertEquals(2,history.history().pages());
verifyNoInteractions(cargo);
}
}
@@ -0,0 +1,56 @@
package com.baiyue.lastleg.appointmentplanning;
import com.baiyue.common.security.AuthenticatedUser;
import org.junit.jupiter.api.Test;
import org.springframework.context.annotation.*;
import org.springframework.security.access.AccessDeniedException;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.web.server.ResponseStatusException;
import java.util.List;
import java.util.Set;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.*;
class AppointmentPlanningAuthorizationTest {
private static final String LIST="platform:appointment:list", CARGO="platform:overseas-warehouse:last-mile-order:list", WRITE="platform:appointment:followup";
@Test void readRequiresBothSourcePermissionsAndCannotWrite() {
try (var context=new AnnotationConfigApplicationContext(Config.class)) {
var controller=context.getBean(AppointmentPlanningController.class);
var user=user(0L,"PLATFORM");
for (String permission:List.of(LIST,CARGO)) {
login(permission);
assertThrows(AccessDeniedException.class,() -> controller.cargo(user,1,20,null,null,null));
assertThrows(AccessDeniedException.class,() -> controller.history(user,"ORDER:1","A",1,20));
}
login(LIST,CARGO);
controller.cargo(user,1,20,null,null,null);
verify(context.getBean(AppointmentCargoService.class)).page(1,20,null,null,null);
assertThrows(AccessDeniedException.class,() -> controller.followup(user,"ORDER:1","A",null));
verifyNoInteractions(context.getBean(AppointmentFollowupService.class));
} finally { SecurityContextHolder.clearContext(); }
}
@Test void permissionDoesNotAllowClientIdentityAndOperatorComesFromLogin() {
try (var context=new AnnotationConfigApplicationContext(Config.class)) {
var controller=context.getBean(AppointmentPlanningController.class);
login(LIST,CARGO,WRITE);
var request=new AppointmentFollowupService.Save("FBA_RECEIVING",0,"key",null,"已联系",null,null);
assertThrows(ResponseStatusException.class,() -> controller.followup(user(1L,"CLIENT"),"BOOKING:B-1","A",request));
assertThrows(ResponseStatusException.class,() -> controller.cargo(null,1,20,null,null,null));
controller.followup(user(0L,"PLATFORM"),"BOOKING:B-1","A",request);
verify(context.getBean(AppointmentFollowupService.class)).save("BOOKING:B-1","A",request,"7","operator");
} finally { SecurityContextHolder.clearContext(); }
}
private static AuthenticatedUser user(Long customer,String app) { return new AuthenticatedUser(7L,customer,0L,8L,"operator",app,"WEB",Set.of()); }
private static void login(String... permissions) { SecurityContextHolder.getContext().setAuthentication(new UsernamePasswordAuthenticationToken("operator","unused",java.util.Arrays.stream(permissions).map(SimpleGrantedAuthority::new).toList())); }
@Configuration @EnableMethodSecurity
static class Config {
@Bean AppointmentCargoService cargo() { return mock(AppointmentCargoService.class); }
@Bean AppointmentFollowupService followups() { return mock(AppointmentFollowupService.class); }
@Bean AppointmentSourceService source() { return mock(AppointmentSourceService.class); }
@Bean AppointmentCoverageService coverage() { return mock(AppointmentCoverageService.class); }
@Bean AppointmentPlanningController controller(AppointmentCargoService cargo,AppointmentFollowupService followups,AppointmentSourceService source,AppointmentCoverageService coverage) { return new AppointmentPlanningController(cargo,followups,source,coverage); }
}
}
@@ -0,0 +1,86 @@
package com.baiyue.lastleg.appointmentplanning;
import org.junit.jupiter.api.Test;
import java.time.Instant;
import java.time.LocalDateTime;
import java.time.ZoneOffset;
import java.util.List;
import static org.junit.jupiter.api.Assertions.*;
class AppointmentPlanningPolicyTest {
private static Instant at(int hour) { return Instant.parse("2026-10-15T" + String.format("%02d", hour) + ":00:00Z"); }
private static AppointmentPlanningPolicy.Cargo cargo(String source, String customer, String origin, String destination,
Integer packages, Instant from, Instant to) {
return new AppointmentPlanningPolicy.Cargo(source, customer, origin, destination, "US", "US", packages, from, to, true, null);
}
@Test void twoCustomersCanPlanOneDestinationWithoutAPlanNumberDriverOrExternalConfirmation() {
var result = AppointmentPlanningPolicy.group(List.of(
cargo("ORDER:1", "A", "WH-1", "FBA:ONT8", 10, at(1), at(5)),
cargo("BOOKING:B-1", "B", "WH-1", "FBA:ONT8", 8, at(3), at(8))));
assertEquals(18, result.packages()); assertEquals(2, result.customerCount());
assertEquals(at(3), result.readyFrom()); assertEquals(at(5), result.readyTo());
assertFalse(result.readinessIncomplete());
}
@Test void unknownReadinessIsPreservedInsteadOfInventingAnArrivalOffset() {
var result = AppointmentPlanningPolicy.group(List.of(cargo("ORDER:1", "A", "WH-1", "FBA:ONT8", 10, null, null)));
assertTrue(result.readinessIncomplete()); assertNull(result.readyFrom()); assertNull(result.readyTo());
}
@Test void differentOriginsDestinationsAndDisjointWindowsAreNotSilentlyMerged() {
var first = cargo("ORDER:1", "A", "WH-1", "FBA:ONT8", 10, at(1), at(3));
assertThrows(IllegalArgumentException.class, () -> AppointmentPlanningPolicy.group(List.of(first,
cargo("ORDER:2", "B", "WH-2", "FBA:ONT8", 8, at(1), at(3)))));
assertThrows(IllegalArgumentException.class, () -> AppointmentPlanningPolicy.group(List.of(first,
cargo("ORDER:2", "B", "WH-1", "FBA:LAX9", 8, at(1), at(3)))));
assertThrows(IllegalArgumentException.class, () -> AppointmentPlanningPolicy.group(List.of(first,
cargo("ORDER:2", "B", "WH-1", "FBA:ONT8", 8, at(5), at(8)))));
}
@Test void sourceIdentityMustNotBeCountedAgainAfterMaterialization() {
var first = cargo("BOOKING:B-1", "A", "WH-1", "FBA:ONT8", 10, null, null);
assertThrows(IllegalArgumentException.class, () -> AppointmentPlanningPolicy.group(List.of(first, first)));
assertThrows(IllegalArgumentException.class, () -> AppointmentPlanningPolicy.group(List.of(
cargo("ORDER:1", "A", "WH-1", "FBA:ONT8", null, null, null))));
}
@Test void nonUsAndBlockedSourcesCannotBeTreatedAsEligibleAmericanCargo() {
assertThrows(IllegalArgumentException.class, () -> AppointmentPlanningPolicy.group(List.of(
new AppointmentPlanningPolicy.Cargo("ORDER:1", "A", "WH-1", "FBA:YYZ", "US", "CA", 1, null, null, true, null))));
var error = assertThrows(IllegalArgumentException.class, () -> AppointmentPlanningPolicy.group(List.of(
new AppointmentPlanningPolicy.Cargo("ORDER:1", "A", "WH-1", "FBA:ONT8", "US", "US", 1, null, null, false, "货物已被有效安排占用"))));
assertEquals("货物已被有效安排占用", error.getMessage());
}
@Test void changedReadinessIdentifiesImpactWithoutOverwritingTheConfirmedTime() {
var before = new AppointmentPlanningPolicy.Forecast("ORDER:1", "1", at(1), at(3));
var after = new AppointmentPlanningPolicy.Forecast("ORDER:1", "2", at(4), at(8));
var impact = AppointmentPlanningPolicy.compare(before, after, at(6));
assertTrue(impact.reviewRequired()); assertEquals("READINESS_AFTER_APPOINTMENT", impact.reason());
assertEquals(at(6), impact.confirmedAt()); assertEquals(at(1), impact.previousEta()); assertEquals(at(4), impact.latestEta());
assertFalse(AppointmentPlanningPolicy.compare(after, after, at(6)).reviewRequired());
}
@Test void lostForecastNeedsReviewAndUnrelatedSourcesCannotBeCompared() {
var before = new AppointmentPlanningPolicy.Forecast("ORDER:1", "1", at(1), at(3));
assertEquals("FORECAST_MISSING", AppointmentPlanningPolicy.compare(before,
new AppointmentPlanningPolicy.Forecast("ORDER:1", "2", null, null), at(6)).reason());
assertThrows(IllegalArgumentException.class, () -> AppointmentPlanningPolicy.compare(before,
new AppointmentPlanningPolicy.Forecast("ORDER:2", "2", at(2), at(3)), at(6)));
}
@Test void losAngelesTimesResolveToTheLocationZoneAndKeepAnUnknownEndUnknown() {
var result = AppointmentTimePolicy.resolve(LocalDateTime.parse("2026-10-15T09:00:00"), null, "America/Los_Angeles", null, null);
assertEquals(Instant.parse("2026-10-15T16:00:00Z"), result.start()); assertNull(result.end());
assertThrows(IllegalArgumentException.class, () -> AppointmentTimePolicy.resolve(LocalDateTime.parse("2026-10-15T09:00:00"), null, null, null, null));
}
@Test void daylightSavingGapIsRejectedAndOverlapRequiresAnExplicitValidOffset() {
assertThrows(IllegalArgumentException.class, () -> AppointmentTimePolicy.resolve(LocalDateTime.parse("2026-03-08T02:30:00"), null, "America/Los_Angeles", null, null));
var overlap = LocalDateTime.parse("2026-11-01T01:30:00");
assertThrows(IllegalArgumentException.class, () -> AppointmentTimePolicy.resolve(overlap, null, "America/Los_Angeles", null, null));
assertEquals(Instant.parse("2026-11-01T09:30:00Z"), AppointmentTimePolicy.resolve(overlap, null, "America/Los_Angeles", ZoneOffset.ofHours(-8), null).start());
assertThrows(IllegalArgumentException.class, () -> AppointmentTimePolicy.resolve(overlap, null, "America/Los_Angeles", ZoneOffset.ofHours(8), null));
}
}
@@ -0,0 +1,40 @@
package com.baiyue.lastleg.appointmentplanning;
import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.lastleg.entity.LastlegOrder;
import com.baiyue.lastleg.mapper.LastlegOrderMapper;
import com.baiyue.lastleg.shipping.mapper.ContainerShippingGateMapper;
import org.junit.jupiter.api.Test;
import org.springframework.web.server.ResponseStatusException;
import java.util.List;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.*;
import static org.mockito.Mockito.*;
class AppointmentSourceServiceTest {
@Test void confirmedExternalBookingCanBeFollowedWithoutCreatingAnOrderAndKeepsItsKeyAfterMaterialization() {
var orders=mock(LastlegOrderMapper.class); var gateway=mock(AppointmentExternalSourceGateway.class); var warehouse=mock(AppointmentWarehouseClient.class);
when(warehouse.catalog("token")).thenReturn(ApiResponse.success(List.of(
new AppointmentWarehouseClient.Warehouse(100L,"US-WH","美国仓","OVERSEAS","US"),
new AppointmentWarehouseClient.Warehouse(200L,"ONT8","ONT8","FBA","US"))));
var service=new AppointmentSourceService(orders,mock(ContainerShippingGateMapper.class),gateway,warehouse,"token");
when(gateway.resolve(anyList())).thenReturn(List.of(new AppointmentExternalSourceGateway.Source("A","B-1","TR-1","CONFIRMED","100","FBA:ONT8",null,null,1L)));
var source=service.resolve("BOOKING:B-1","A","FBA:ONT8"); assertTrue(source.canFollowup()); assertNull(source.orderId()); assertNull(source.packages()); verifyNoInteractions(orders);
var order=new LastlegOrder(); order.setId(1L); order.setCustomerId("A"); order.setOverseasWarehouseId("100"); order.setPackageCount(12); order.setInboundCode("IN-A"); order.setDestinationType("FBA"); order.setFbaWarehouseCode("ONT8");
when(orders.selectById(1L)).thenReturn(order);
when(gateway.resolve(anyList())).thenReturn(List.of(new AppointmentExternalSourceGateway.Source("A","B-1","TR-1","RECEIVED","100","FBA:ONT8",1L,10,2L)));
var received=service.resolve("BOOKING:B-1","A","FBA:ONT8"); assertEquals("BOOKING:B-1",received.sourceKey()); assertEquals(12,received.packages()); assertEquals("IN-A",received.inboundCode());
assertThrows(ResponseStatusException.class, () -> service.resolve("BOOKING:B-1","A","FBA:LAX9"));
assertThrows(ResponseStatusException.class, () -> service.resolve("BOOKING:B-1","OTHER","FBA:ONT8"));
order.setCurrentStatus("CANCELLED"); assertFalse(service.resolve("BOOKING:B-1","A","FBA:ONT8").canFollowup());
order.setFbaWarehouseCode("LAX9"); assertThrows(ResponseStatusException.class, () -> service.resolve("BOOKING:B-1","A","FBA:ONT8"));
}
@Test void draftBookingCannotBePresentedAsConfirmedCarrierCapacity() {
var gateway=mock(AppointmentExternalSourceGateway.class); var warehouse=mock(AppointmentWarehouseClient.class);
when(warehouse.catalog("token")).thenReturn(ApiResponse.success(List.of(
new AppointmentWarehouseClient.Warehouse(100L,"US-WH","美国仓","OVERSEAS","US"),new AppointmentWarehouseClient.Warehouse(200L,"ONT8","ONT8","FBA","US"))));
when(gateway.resolve(anyList())).thenReturn(List.of(new AppointmentExternalSourceGateway.Source("A","B-1","TR-1","DRAFT","100","FBA:ONT8",null,null,0L)));
var source=new AppointmentSourceService(mock(LastlegOrderMapper.class),mock(ContainerShippingGateMapper.class),gateway,warehouse,"token").resolve("BOOKING:B-1","A","FBA:ONT8");
assertFalse(source.canFollowup()); assertFalse(source.blockReasons().isEmpty());
}
}
@@ -0,0 +1,32 @@
package com.baiyue.lastleg.dispatch.fleet;
import com.baiyue.lastleg.dispatch.service.DispatchService;
import com.baiyue.lastleg.fleet.trip.*;
import org.junit.jupiter.api.Test;
import java.math.BigDecimal;
import java.util.List;
import static org.assertj.core.api.Assertions.*;
import static org.mockito.Mockito.*;
class FleetDispatchPlanningAdapterTest {
private final FleetDispatchPlanningMapper mapper=mock(FleetDispatchPlanningMapper.class);
private final DispatchService service=mock(DispatchService.class);
private final FleetDispatchGateway gateway=mock(FleetDispatchGateway.class);
private final FleetDispatchPlanningAdapter adapter=new FleetDispatchPlanningAdapter(mapper,service,gateway);
private FleetBookingIncomingGateway.Evidence receipt(){return new FleetBookingIncomingGateway.Evidence("WT-1","IN-1","92","FBA:ACY2","READY_FOR_DISPATCH","hash",List.of(new FleetBookingIncomingGateway.Pallet("1","P-1",10,BigDecimal.TEN,BigDecimal.ONE)),10);}
@Test void refusesAmbiguousSourcesWithoutCreatingAnotherDispatch() {
when(mapper.sourceDispatches("9","IN-1")).thenReturn(List.of("DS-1","DS-2"));
assertThatThrownBy(()->adapter.resolveIncoming("9",receipt(),"1")).hasMessageContaining("多张排车");
verifyNoInteractions(service);
}
@Test void reusesOnlyAnExactExistingShipment() {
when(mapper.sourceDispatches("9","IN-1")).thenReturn(List.of("DS-1"));
when(gateway.lockForPlanning("DS-1")).thenReturn(new FleetDispatchGateway.Snapshot(1,"DS-1","9","92",10));
when(gateway.sourceCargo("DS-1")).thenReturn(List.of(new FleetDispatchGateway.SourceCargo("IN-1","1","P-1",10,"FBA:ACY2")));
assertThat(adapter.resolveIncoming("9",receipt(),"1")).isEqualTo("DS-1");verifyNoInteractions(service);
when(gateway.sourceCargo("DS-1")).thenReturn(List.of(new FleetDispatchGateway.SourceCargo("IN-1","1","P-1",9,"FBA:ACY2")));
assertThatThrownBy(()->adapter.resolveIncoming("9",receipt(),"1")).hasMessageContaining("全部实收");
when(gateway.sourceCargo("DS-1")).thenReturn(List.of(new FleetDispatchGateway.SourceCargo("IN-other","1","P-1",10,"FBA:ACY2")));
assertThatThrownBy(()->adapter.resolveIncoming("9",receipt(),"1")).hasMessageContaining("其他来源");
}
}
@@ -0,0 +1,51 @@
package com.baiyue.lastleg.dispatch.fleet;
import com.baomidou.mybatisplus.core.MybatisConfiguration;
import com.baomidou.mybatisplus.extension.spring.MybatisSqlSessionFactoryBean;
import com.baomidou.mybatisplus.extension.plugins.MybatisPlusInterceptor;
import com.baomidou.mybatisplus.extension.plugins.inner.PaginationInnerInterceptor;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.baiyue.lastleg.fleet.business.*;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.Test;
import org.mybatis.spring.SqlSessionTemplate;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.jdbc.datasource.DriverManagerDataSource;
import java.util.List;
import java.util.UUID;
import static org.assertj.core.api.Assertions.*;
class FleetPlanningReadPersistenceTest {
@Test void candidatesPaginateOnlyEligibleSameWarehouseUnboundCargoAndSourcesPreserveUnknowns() throws Exception {
var ds=new DriverManagerDataSource("jdbc:h2:mem:"+UUID.randomUUID()+";MODE=MySQL;DB_CLOSE_DELAY=-1","sa","");
var jdbc=new JdbcTemplate(ds);
jdbc.execute("CREATE TABLE ll_dispatch_order(id BIGINT PRIMARY KEY,dispatch_no VARCHAR(64),customer_id VARCHAR(64),overseas_warehouse_id VARCHAR(64),package_count INT,status VARCHAR(32),picked_package_count INT,shipped_package_count INT,updated_at TIMESTAMP,route VARCHAR(500))");
jdbc.execute("CREATE TABLE ll_dispatch_item(dispatch_order_id BIGINT,customer_id VARCHAR(64),inbound_code VARCHAR(64))");
jdbc.execute("CREATE TABLE ll_fleet_trip_dispatch(dispatch_id BIGINT)");
jdbc.execute("CREATE TABLE ll_dispatch_pda_task(dispatch_order_id BIGINT,status VARCHAR(32))");
jdbc.execute("CREATE TABLE ll_outbound_order(dispatch_order_id BIGINT,status VARCHAR(32))");
jdbc.execute("CREATE TABLE ll_fleet_trip(id BIGINT PRIMARY KEY,warehouse_id BIGINT)");
jdbc.execute("CREATE TABLE ll_fleet_external_booking(id BIGINT,trip_id BIGINT,customer_id VARCHAR(64),booking_no VARCHAR(64),trip_no VARCHAR(64),status VARCHAR(32),offer_id BIGINT,destination_key VARCHAR(160),destination_label VARCHAR(100),incoming_no VARCHAR(64),record_version BIGINT,cargo_json VARCHAR(2000),expected_pallet_count INT,estimated_arrival_at TIMESTAMP)");
jdbc.execute("CREATE TABLE ll_fleet_pool_order_link(booking_id BIGINT,customer_id VARCHAR(64),lastleg_order_id BIGINT,business_order_no VARCHAR(64),fulfillment_stage VARCHAR(32))");
for(int i=1;i<=6;i++)jdbc.update("INSERT INTO ll_dispatch_order(id,dispatch_no,customer_id,overseas_warehouse_id,package_count,status,picked_package_count,shipped_package_count,updated_at) VALUES(?,?,?,'92',10,'PENDING_DEPARTURE',0,0,CURRENT_TIMESTAMP)",i,"DS-"+i,"9");
jdbc.update("INSERT INTO ll_fleet_trip_dispatch VALUES(2)");
jdbc.update("UPDATE ll_dispatch_order SET overseas_warehouse_id='93' WHERE id=3");
jdbc.update("INSERT INTO ll_dispatch_pda_task VALUES(4,'PICKING')");
jdbc.update("INSERT INTO ll_dispatch_item VALUES(5,'9','IN-5'),(6,'9','IN-6')");
jdbc.update("INSERT INTO ll_fleet_trip VALUES(1,92)");
jdbc.update("INSERT INTO ll_fleet_external_booking(id,trip_id,customer_id,booking_no,trip_no,status,offer_id,destination_key,destination_label,incoming_no,record_version,cargo_json) VALUES(5,1,'9','FB-5','TR-OTHER','RECEIVED',1,'FBA:ACY2','ACY2','IN-5',1,'{\"packageCount\":null}'),(6,1,'9','FB-6','TR-1','RECEIVED',1,'FBA:ACY2','ACY2','IN-6',2,'{\"packageCount\":10}')");
var configuration=new MybatisConfiguration();configuration.addMapper(FleetDispatchPlanningMapper.class);configuration.addMapper(FleetPoolOrderMapper.class);
var interceptor=new MybatisPlusInterceptor();interceptor.addInnerInterceptor(new PaginationInnerInterceptor(com.baomidou.mybatisplus.annotation.DbType.H2));configuration.addInterceptor(interceptor);
var factory=new MybatisSqlSessionFactoryBean();factory.setDataSource(ds);factory.setConfiguration(configuration);
var session=new SqlSessionTemplate(factory.getObject());var mapper=session.getMapper(FleetDispatchPlanningMapper.class);
var page=mapper.candidates(new Page<>(1,1),"TR-1","92",null);
assertThat(page.getTotal()).isEqualTo(2);assertThat(page.getRecords()).hasSize(1);
assertThat(mapper.candidates(new Page<>(1,100),"TR-1","92","DS-1").getRecords()).extracting(r->r.dispatchNo()).containsExactly("DS-1");
var resolver=new FleetPoolSourceResolver(session.getMapper(FleetPoolOrderMapper.class),new ObjectMapper());
var sources=resolver.resolve(List.of(new FleetPoolSourceResolver.SourceRef("9","FB-5"),new FleetPoolSourceResolver.SourceRef("9","FB-6"),new FleetPoolSourceResolver.SourceRef("OTHER","FB-6")));
assertThat(sources).hasSize(2);
assertThat(sources.stream().filter(s->s.bookingNo().equals("FB-5")).findFirst().orElseThrow().packageCount()).isNull();
assertThat(sources.stream().filter(s->s.bookingNo().equals("FB-6")).findFirst().orElseThrow().packageCount()).isEqualTo(10);
assertThat(sources).allMatch(s->s.lastlegOrderId()==null&&s.expectedPalletCount()==null);
}
}
@@ -0,0 +1,47 @@
package com.baiyue.lastleg.fleet.trip;
import com.baiyue.lastleg.fleet.domain.TripCapacityPolicy;
import org.junit.jupiter.api.Test;
import java.math.BigDecimal;
import java.time.*;
import java.util.List;
import static org.assertj.core.api.Assertions.*;
class FleetInquiryProgressTest {
private final Instant cutoff=Instant.parse("2026-10-20T10:00:00Z");
private FleetCapacityOfferModels.Offer offer() {
return new FleetCapacityOfferModels.Offer("FO-1","TR-1","OPEN",1,1,"1","仓库",cutoff,cutoff,null,null,null,
List.of(new FleetCapacityOfferModels.Destination("FBA:ACY2","ACY2",1,new TripCapacityPolicy.Load(4,new BigDecimal("1000"),new BigDecimal("10")))),List.of(),true,true,true,null,null);
}
private FleetBookingModels.Cargo cargo(Integer spaces) {
return new FleetBookingModels.Cargo("FBA:ACY2","货物",1,spaces,new BigDecimal("100"),BigDecimal.ONE,"PALLET",BigDecimal.ZERO,"USD","客户联系人","联系信息",null);
}
private FleetBookingEntity booking(String status) {
var row=new FleetBookingEntity();row.setStatus(status);row.setBusinessType("LOCAL_LOOSEBOARD");row.setEstimatedArrivalAt(LocalDateTime.ofInstant(cutoff.minusSeconds(60),ZoneOffset.UTC));return row;
}
@Test void unknownQuantityIsMissingAndTruckNeedsPhysicalPalletCount() {
var row=booking("DRAFT");row.setBusinessType("US_TRUCK");
assertThat(FleetInquiryProgress.missing(row,cargo(null),offer())).containsExactly("占用标准托位","预计实托数");
row.setExpectedPalletCount(1);
assertThat(FleetInquiryProgress.missing(row,cargo(1),offer())).isEmpty();
}
@Test void capacityIsCheckedAgainstThisDestinationAndThisShipment() {
assertThat(FleetInquiryProgress.capacityBlock(cargo(5),offer())).contains("超过");
assertThat(FleetInquiryProgress.capacityBlock(cargo(4),offer())).isNull();
}
@Test void expiryDoesNotTellSalesToEditAnUnsavableDraft() {
var p=FleetInquiryProgress.project(booking("DRAFT"),List.of("预计重量"),"已截止",false,false,null,null);
assertThat(p.primaryAction()).isEqualTo("VIEW");assertThat(p.reservation()).isEqualTo("尚未保位");
}
@Test void receivingIsNotDispatchWorkUntilActualMeasurementCompletes() {
var row=booking("CONFIRMED");row.setIncomingNo("WT-1");
var waiting=FleetInquiryProgress.project(row,List.of(),null,false,false,"WAIT_PDA_RECEIVING",null);
assertThat(waiting.owner()).isEqualTo("仓库");assertThat(waiting.primaryAction()).isEqualTo("VIEW");
var received=FleetInquiryProgress.project(row,List.of(),null,false,false,"WAIT_CUSTOMER_CONFIRMATION",null);
assertThat(received.owner()).isEqualTo("调度");assertThat(received.primaryAction()).isEqualTo("RECEIPT");
}
@Test void bindingIsNotDepartureAndOnlyActualFulfillmentAdvancesTracking() {
assertThat(FleetInquiryProgress.project(booking("BOUND"),List.of(),null,false,false,null,"PENDING_DISPATCH").label()).isEqualTo("已排入本车");
assertThat(FleetInquiryProgress.project(booking("BOUND"),List.of(),null,false,false,null,"IN_TRANSIT").label()).isEqualTo("已发车");
}
}
@@ -13,11 +13,15 @@ import static org.assertj.core.api.Assertions.*;
class FleetTripApiContractTest { class FleetTripApiContractTest {
@Test void allTripOperationsHaveOpenApiAndRealPermissions() throws Exception { @Test void allTripOperationsHaveOpenApiAndRealPermissions() throws Exception {
Map<String, Object> document = new Yaml().load(Files.readString(Path.of("src/main/resources/openapi/fleet-trips-v1.yaml"))); Map<String, Object> document = new Yaml().load(Files.readString(Path.of("src/main/resources/openapi/fleet-trips-v1.yaml")));
Map<?, ?> paths = (Map<?, ?>) document.get("paths"); Map<String, Object> preparation = new Yaml().load(Files.readString(Path.of("src/main/resources/openapi/fleet-preparation-v1.yaml")));
Map<String, Object> pool = new Yaml().load(Files.readString(Path.of("src/main/resources/openapi/fleet-capacity-offers-v1.yaml")));
var paths = new java.util.HashMap<Object,Object>();
for(var spec:java.util.List.of(document,preparation,pool))paths.putAll((Map<?,?>)spec.get("paths"));
String module = Files.readString(Path.of("../iam/src/main/resources/db/lastleg-permissions.sql")); String module = Files.readString(Path.of("../iam/src/main/resources/db/lastleg-permissions.sql"));
module += Files.readString(Path.of("../iam/src/main/resources/db/fleet-capacity-offer-permissions.sql"));
String deployed = Files.readString(Path.of("../deploy/sql/头程数据库统一增量-MySQL8.sql")); String deployed = Files.readString(Path.of("../deploy/sql/头程数据库统一增量-MySQL8.sql"));
int count = 0; int count = 0;
for (var controller : java.util.List.of(PlatformFleetTripController.class, PlatformFleetBookingController.class)) { for (var controller : java.util.List.of(PlatformFleetTripController.class, PlatformFleetBookingController.class, PlatformFleetCapacityOfferController.class)) {
String base = controller.getAnnotation(RequestMapping.class).value()[0]; String base = controller.getAnnotation(RequestMapping.class).value()[0];
for (var method : controller.getDeclaredMethods()) { for (var method : controller.getDeclaredMethods()) {
String verb = null; String[] suffix = null; String verb = null; String[] suffix = null;
@@ -42,8 +46,10 @@ class FleetTripApiContractTest {
count++; count++;
} }
} }
assertThat(count).isEqualTo(29); assertThat(count).isEqualTo(52);
verifyRefs(document, document); verifyRefs(document, document);
verifyRefs(preparation, preparation);
verifyRefs(pool, pool);
} }
@Test void schemasMatchAndUseUniqueResourceClaimsAndVersionedHistory() throws Exception { @Test void schemasMatchAndUseUniqueResourceClaimsAndVersionedHistory() throws Exception {
@@ -68,7 +74,7 @@ class FleetTripApiContractTest {
if(!reference.startsWith("#/")) { if(!reference.startsWith("#/")) {
String[] parts=reference.split("#",2); String[] parts=reference.split("#",2);
assertThat(parts).hasSize(2); assertThat(parts).hasSize(2);
assertThat(parts[0]).isEqualTo("fleet-quote-candidates-v1.yaml"); assertThat(Path.of(parts[0]).getFileName().toString()).isIn("fleet-quote-candidates-v1.yaml","fleet-trips-v1.yaml","fleet-capacity-offers-v1.yaml");
try { value=new Yaml().load(Files.readString(Path.of("src/main/resources/openapi",parts[0]))); } try { value=new Yaml().load(Files.readString(Path.of("src/main/resources/openapi",parts[0]))); }
catch(java.io.IOException error) { throw new java.io.UncheckedIOException(error); } catch(java.io.IOException error) { throw new java.io.UncheckedIOException(error); }
pointer="#"+parts[1]; pointer="#"+parts[1];
@@ -36,6 +36,7 @@ class FleetTripAuthorizationTest {
assertThatThrownBy(() -> controller.cancel(null, "TR-TEST", null)).isInstanceOf(AccessDeniedException.class); assertThatThrownBy(() -> controller.cancel(null, "TR-TEST", null)).isInstanceOf(AccessDeniedException.class);
assertThatThrownBy(() -> controller.plan(null, "TR-TEST", null)).isInstanceOf(AccessDeniedException.class); assertThatThrownBy(() -> controller.plan(null, "TR-TEST", null)).isInstanceOf(AccessDeniedException.class);
assertThatThrownBy(() -> controller.bind(null, "TR-TEST", null)).isInstanceOf(AccessDeniedException.class); assertThatThrownBy(() -> controller.bind(null, "TR-TEST", null)).isInstanceOf(AccessDeniedException.class);
assertThatThrownBy(() -> controller.prepare(null,null)).isInstanceOf(AccessDeniedException.class);
assertThatThrownBy(() -> controller.unbind(null, "TR-TEST", null)).isInstanceOf(AccessDeniedException.class); assertThatThrownBy(() -> controller.unbind(null, "TR-TEST", null)).isInstanceOf(AccessDeniedException.class);
assertThatThrownBy(() -> controller.confirmLoading(null, "TR-TEST", null)).isInstanceOf(AccessDeniedException.class); assertThatThrownBy(() -> controller.confirmLoading(null, "TR-TEST", null)).isInstanceOf(AccessDeniedException.class);
assertThatThrownBy(() -> controller.reviewDispatch(null, "TR-TEST", "DP-1", null)).isInstanceOf(AccessDeniedException.class); assertThatThrownBy(() -> controller.reviewDispatch(null, "TR-TEST", "DP-1", null)).isInstanceOf(AccessDeniedException.class);
@@ -55,8 +56,25 @@ class FleetTripAuthorizationTest {
verifyNoMoreInteractions(service); verifyNoMoreInteractions(service);
} finally { SecurityContextHolder.clearContext(); } } finally { SecurityContextHolder.clearContext(); }
} }
@Test void poolReadDoesNotGrantIncomingReceiptOrDispatchCommands() {
try(var context=new AnnotationConfigApplicationContext(Config.class)) {
SecurityContextHolder.getContext().setAuthentication(new UsernamePasswordAuthenticationToken("sales","unused",List.of(new SimpleGrantedAuthority(FleetCapacityOfferService.LIST))));
var pool=context.getBean(PlatformFleetCapacityOfferController.class);
pool.fulfillment("FB-1");verify(context.getBean(FleetCapacityOfferService.class)).fulfillment("FB-1");
assertThatThrownBy(()->pool.incoming("FB-1",null)).isInstanceOf(AccessDeniedException.class);
assertThatThrownBy(()->pool.receipt("FB-1",null)).isInstanceOf(AccessDeniedException.class);
assertThatThrownBy(()->pool.arrange("FB-1",null)).isInstanceOf(AccessDeniedException.class);
assertThatThrownBy(()->pool.saveAndConfirm("FB-1",null)).isInstanceOf(AccessDeniedException.class);
} finally {SecurityContextHolder.clearContext();}
}
@Configuration @EnableMethodSecurity @Configuration @EnableMethodSecurity
static class Config { static class Config {
@Bean FleetCapacityOfferCommandMapper commands() { return mock(FleetCapacityOfferCommandMapper.class); }
@Bean FleetDispatchPlanningGateway planning() { return mock(FleetDispatchPlanningGateway.class); }
@Bean FleetTripPreparationService preparation() { return mock(FleetTripPreparationService.class); }
@Bean com.baiyue.lastleg.appointmentplanning.AppointmentCoverageService appointmentCoverage() { return mock(com.baiyue.lastleg.appointmentplanning.AppointmentCoverageService.class); }
@Bean FleetCapacityOfferService capacityOffers() { return mock(FleetCapacityOfferService.class); }
@Bean PlatformFleetCapacityOfferController poolController(FleetCapacityOfferService service) { return new PlatformFleetCapacityOfferController(service); }
@Bean com.baiyue.lastleg.fleet.business.FleetPoolOrderBridge poolOrders() { return mock(com.baiyue.lastleg.fleet.business.FleetPoolOrderBridge.class); } @Bean com.baiyue.lastleg.fleet.business.FleetPoolOrderBridge poolOrders() { return mock(com.baiyue.lastleg.fleet.business.FleetPoolOrderBridge.class); }
@Bean FleetBookingService bookingService() { return mock(FleetBookingService.class); } @Bean FleetBookingService bookingService() { return mock(FleetBookingService.class); }
@Bean com.baiyue.lastleg.fleet.customer.FleetCustomerDirectory customers() { return mock(com.baiyue.lastleg.fleet.customer.FleetCustomerDirectory.class); } @Bean com.baiyue.lastleg.fleet.customer.FleetCustomerDirectory customers() { return mock(com.baiyue.lastleg.fleet.customer.FleetCustomerDirectory.class); }
@@ -56,6 +56,8 @@ import static org.mockito.Mockito.*;
class FleetTripPersistenceTest { class FleetTripPersistenceTest {
private JdbcTemplate jdbc; private JdbcTemplate jdbc;
private FleetTripService service; private FleetTripService service;
private FleetTripPreparationService preparation;
private FleetDispatchPlanningGateway planning;
private DriverService drivers; private DriverService drivers;
private VehicleRepository vehicles; private VehicleRepository vehicles;
private VehicleApplicationService vehicleService; private VehicleApplicationService vehicleService;
@@ -141,6 +143,9 @@ class FleetTripPersistenceTest {
transactional(workflow, DispatchPdaWorkflowService.class), session.getMapper(DeliveryOperationMapper.class)), DispatchFleetAdapter.class); transactional(workflow, DispatchPdaWorkflowService.class), session.getMapper(DeliveryOperationMapper.class)), DispatchFleetAdapter.class);
service = transactional(new FleetTripService(session.getMapper(FleetTripMapper.class), resources, session.getMapper(FleetTripHistoryMapper.class), vehicles, drivers, directory, json, service = transactional(new FleetTripService(session.getMapper(FleetTripMapper.class), resources, session.getMapper(FleetTripHistoryMapper.class), vehicles, drivers, directory, json,
bindingMapper, dispatchGateway, reservations), FleetTripService.class); bindingMapper, dispatchGateway, reservations), FleetTripService.class);
preparation = transactional(new FleetTripPreparationService(service,session.getMapper(FleetTripMapper.class),session.getMapper(FleetCapacityOfferCommandMapper.class),json),FleetTripPreparationService.class);
planning=mock(FleetDispatchPlanningGateway.class);
ReflectionTestUtils.setField(service,"planning",planning);
execution = transactional(new FleetTripExecutionService(session.getMapper(FleetTripMapper.class), bindingMapper, resources, dispatchGateway, service, drivers, vehicles, json, reservations), FleetTripExecutionService.class); execution = transactional(new FleetTripExecutionService(session.getMapper(FleetTripMapper.class), bindingMapper, resources, dispatchGateway, service, drivers, vehicles, json, reservations), FleetTripExecutionService.class);
var customerDirectory = mock(com.baiyue.lastleg.fleet.customer.FleetCustomerDirectory.class); var customerDirectory = mock(com.baiyue.lastleg.fleet.customer.FleetCustomerDirectory.class);
when(customerDirectory.required(anyLong())).thenAnswer(call -> new com.baiyue.lastleg.fleet.customer.FleetCustomerDirectory.Customer( when(customerDirectory.required(anyLong())).thenAnswer(call -> new com.baiyue.lastleg.fleet.customer.FleetCustomerDirectory.Customer(
@@ -150,11 +155,47 @@ class FleetTripPersistenceTest {
quoteCandidates=mock(com.baiyue.lastleg.fleet.quote.FleetQuoteCandidateService.class); quoteCandidates=mock(com.baiyue.lastleg.fleet.quote.FleetQuoteCandidateService.class);
bookingService = transactional(new FleetBookingService(session.getMapper(FleetTripMapper.class), bookingMapper, reservations, service, execution, customerDirectory, json, incoming, quoteContext, quoteCandidates), FleetBookingService.class); bookingService = transactional(new FleetBookingService(session.getMapper(FleetTripMapper.class), bookingMapper, reservations, service, execution, customerDirectory, json, incoming, quoteContext, quoteCandidates), FleetBookingService.class);
ReflectionTestUtils.setField(bookingService,"poolOrders",poolOrders); ReflectionTestUtils.setField(bookingService,"poolOrders",poolOrders);
ReflectionTestUtils.setField(bookingService,"planning",planning);
ReflectionTestUtils.setField(bookingService,"commands",session.getMapper(FleetCapacityOfferCommandMapper.class));
capacityOffers=transactional(new FleetCapacityOfferService(session.getMapper(FleetCapacityOfferMapper.class),session.getMapper(FleetCapacityOfferCommandMapper.class), capacityOffers=transactional(new FleetCapacityOfferService(session.getMapper(FleetCapacityOfferMapper.class),session.getMapper(FleetCapacityOfferCommandMapper.class),
session.getMapper(FleetTripMapper.class),bookingMapper,bookingService,reservations,execution,customerDirectory,poolOrders,json),FleetCapacityOfferService.class); session.getMapper(FleetTripMapper.class),bookingMapper,bookingService,reservations,execution,customerDirectory,poolOrders,json),FleetCapacityOfferService.class);
ReflectionTestUtils.setField(capacityOffers,"appointmentCoverage",mock(com.baiyue.lastleg.appointmentplanning.AppointmentCoverageService.class));
completion = transactional(new FleetTripCompletionService(session.getMapper(FleetTripMapper.class), bindingMapper, resources, dispatchGateway, service, vehicles, vehicleService, json), FleetTripCompletionService.class); completion = transactional(new FleetTripCompletionService(session.getMapper(FleetTripMapper.class), bindingMapper, resources, dispatchGateway, service, vehicles, vehicleService, json), FleetTripCompletionService.class);
} }
@Test void preparationRollsBackNewTripAndResourcesIfAnyBindingFailsThenReplaysSuccess() {
SecurityContextHolder.getContext().setAuthentication(new UsernamePasswordAuthenticationToken("1","unused",List.of("platform:fleet:trip:create","platform:fleet:trip:assign","platform:fleet:trip:edit").stream().map(SimpleGrantedAuthority::new).toList()));
long vehicle=vehicle(true), driver=driver(true);dispatch(21,"A","92");dispatch(22,"B","93");
var request=new FleetTripPreparationService.Prepare(null,null,createRequest("prepare-atomic"),vehicle,driver,List.of("DP-21","DP-22"),"prepare-atomic");
assertThatThrownBy(()->preparation.prepare("1",request)).hasMessageContaining("同一起运仓");
assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM ll_fleet_trip",Integer.class)).isZero();
assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM ll_fleet_trip_dispatch",Integer.class)).isZero();
assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM ll_fleet_resource_assignment",Integer.class)).isZero();
jdbc.update("UPDATE ll_dispatch_order SET overseas_warehouse_id='92' WHERE id=22");
jdbc.update("UPDATE ll_dispatch_pda_task SET warehouse_id='92' WHERE dispatch_order_id=22");
var saved=preparation.prepare("1",request);
assertThat(saved.dispatches()).hasSize(2);
assertThat(preparation.prepare("1",request)).isEqualTo(saved);
assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM ll_fleet_trip",Integer.class)).isEqualTo(1);
assertThatThrownBy(()->preparation.prepare("1",new FleetTripPreparationService.Prepare(null,null,request.plan(),vehicle,driver,List.of("DP-21"),request.idempotencyKey()))).hasMessageContaining("不能更换内容");
}
@Test void sameWindowSaveAndConfirmIsAtomicAndReplaysOriginalResult() {
var offer=publishedOffer();var draft=capacityOffers.createInquiry(offer.offerNo(),poolDraft(offer,2,"prepare-register"));
var input=poolDraft(draft.offer(),2,"same-window-confirm");
var update=new FleetCapacityOfferModels.InquiryWrite(input.tripRecordVersion(),input.offerRecordVersion(),draft.inquiry().recordVersion(),input.customerId(),input.businessType(),input.deliveryMode(),input.estimatedArrivalAt(),input.expectedPalletCount(),input.cargo(),input.quoteRef(),input.idempotencyKey());
doThrow(new IllegalStateException("bridge failed")).when(poolOrders).confirm(any(),anyString());
assertThatThrownBy(()->capacityOffers.saveAndConfirm(draft.inquiry().bookingNo(),update)).hasMessageContaining("bridge failed");
assertThat(capacityOffers.inquiry(draft.inquiry().bookingNo()).recordVersion()).isEqualTo(draft.inquiry().recordVersion());
assertThat(capacityOffers.inquiry(draft.inquiry().bookingNo()).status()).isEqualTo("DRAFT");
reset(poolOrders);
var confirmed=capacityOffers.saveAndConfirm(draft.inquiry().bookingNo(),update);
assertThat(confirmed.inquiry().status()).isEqualTo("CONFIRMED");
assertThat(confirmed.inquiry().progress().reservation()).isEqualTo("已保留货位");
assertThat(capacityOffers.saveAndConfirm(draft.inquiry().bookingNo(),update).replayed()).isTrue();
verify(poolOrders,times(1)).confirm(any(),anyString());
}
@Test void poolSalesAreIsolatedByUserIdAndCannotConfirmOrReadAllInquiries() { @Test void poolSalesAreIsolatedByUserIdAndCannotConfirmOrReadAllInquiries() {
var offer=publishedOffer();poolLogin(101,false); var offer=publishedOffer();poolLogin(101,false);
var first=capacityOffers.createInquiry(offer.offerNo(),poolDraft(offer,2,"owner-first")); var first=capacityOffers.createInquiry(offer.offerNo(),poolDraft(offer,2,"owner-first"));
@@ -443,7 +484,8 @@ class FleetTripPersistenceTest {
} finally { executor.shutdownNow(); } } finally { executor.shutdownNow(); }
} }
@Test void externalReceiptChangesReservationOnlyAfterExplicitActualQuantityAndPriceConfirmation() { @org.junit.jupiter.params.ParameterizedTest @org.junit.jupiter.params.provider.ValueSource(booleans={false,true})
void externalReceiptChangesReservationOnlyAfterExplicitActualQuantityAndPriceConfirmation(boolean automatic) {
var source=enableQuote(); var source=enableQuote();
String number=readyTrip();var basis=execution.capacityPlan(number); String number=readyTrip();var basis=execution.capacityPlan(number);
execution.confirmCapacityPlan(number,"1",new FleetExecutionModels.Confirm(basis.recordVersion(),basis.manifestHash(),List.of(measurement()),"计划")); execution.confirmCapacityPlan(number,"1",new FleetExecutionModels.Confirm(basis.recordVersion(),basis.manifestHash(),List.of(measurement()),"计划"));
@@ -451,7 +493,7 @@ class FleetTripPersistenceTest {
var confirmed=bookingService.confirm(number,draft.booking().bookingNo(),"1",new FleetBookingModels.Command(draft.tripRecordVersion(),0L,"receipt-reserve",null)); var confirmed=bookingService.confirm(number,draft.booking().bookingNo(),"1",new FleetBookingModels.Command(draft.tripRecordVersion(),0L,"receipt-reserve",null));
var awaiting=new FleetBookingIncomingGateway.Evidence("WT-EXT","WT-EXT","92","FBA:ONT8","WAIT_PDA_RECEIVING","empty",List.of(),0); var awaiting=new FleetBookingIncomingGateway.Evidence("WT-EXT","WT-EXT","92","FBA:ONT8","WAIT_PDA_RECEIVING","empty",List.of(),0);
when(incoming.create(eq("1"),any())).thenReturn(awaiting); when(incoming.create(eq("1"),any())).thenReturn(awaiting);
var request=new FleetBookingModels.StartIncoming(confirmed.tripRecordVersion(),confirmed.booking().recordVersion(),null,null,null,null,null,null,"incoming-start"); var request=new FleetBookingModels.StartIncoming(confirmed.tripRecordVersion(),confirmed.booking().recordVersion(),departure.minusSeconds(120),null,null,null,null,null,"incoming-start");
var started=bookingService.startIncoming(number,draft.booking().bookingNo(),"1",request); var started=bookingService.startIncoming(number,draft.booking().bookingNo(),"1",request);
assertThat(bookingService.startIncoming(number,draft.booking().bookingNo(),"1",request).replayed()).isTrue(); assertThat(bookingService.startIncoming(number,draft.booking().bookingNo(),"1",request).replayed()).isTrue();
verify(incoming,times(1)).create(eq("1"),any()); verify(incoming,times(1)).create(eq("1"),any());
@@ -473,11 +515,20 @@ class FleetTripPersistenceTest {
assertThat(execution.capacityPlan(number).availableSegments().get(0).remaining().palletSpaces()).isEqualTo(28); assertThat(execution.capacityPlan(number).availableSegments().get(0).remaining().palletSpaces()).isEqualTo(28);
dispatch(13,"9","92"); dispatch(13,"9","92");
jdbc.update("INSERT INTO ll_dispatch_item(id,dispatch_order_id,customer_id,warehouse_pallet_id,pallet_no,package_count,source_stage,destination_warehouse_code,overseas_warehouse_id,inbound_code) VALUES(3013,13,'9',902,'PALLET-902',22,'RECEIVED','ONT8','92','WT-EXT')"); jdbc.update("INSERT INTO ll_dispatch_item(id,dispatch_order_id,customer_id,warehouse_pallet_id,pallet_no,package_count,source_stage,destination_warehouse_code,overseas_warehouse_id,inbound_code) VALUES(3013,13,'9',902,'PALLET-902',22,'RECEIVED','ONT8','92','WT-EXT')");
var linked=bookingService.bindDispatch(number,draft.booking().bookingNo(),"1",new FleetBookingModels.BindDispatch(accepted.tripRecordVersion(),accepted.booking().recordVersion(),"DP-13","link-external")); when(incoming.inspect("9","WT-EXT",true)).thenReturn(new FleetBookingIncomingGateway.Evidence(actual.incomingNo(),actual.inboundCode(),actual.warehouseId(),actual.destinationKey(),"READY_FOR_DISPATCH",actual.receiptHash(),actual.pallets(),actual.receivedPackages()));
when(planning.resolveIncoming(eq("9"),any(),eq("1"))).thenReturn("DP-13");
var arrange=new FleetBookingModels.Command(accepted.tripRecordVersion(),accepted.booking().recordVersion(),"auto-link-external",null);
var linked=automatic?bookingService.arrangeDispatch(number,draft.booking().bookingNo(),"1",arrange):bookingService.bindDispatch(number,draft.booking().bookingNo(),"1",new FleetBookingModels.BindDispatch(accepted.tripRecordVersion(),accepted.booking().recordVersion(),"DP-13","link-external"));
if(automatic){assertThat(bookingService.arrangeDispatch(number,draft.booking().bookingNo(),"1",arrange).replayed()).isTrue();verify(planning,times(1)).resolveIncoming(eq("9"),any(),eq("1"));}
assertThat(linked.booking().status()).isEqualTo("BOUND");assertThat(linked.booking().dispatchNo()).isEqualTo("DP-13"); assertThat(linked.booking().status()).isEqualTo("BOUND");assertThat(linked.booking().dispatchNo()).isEqualTo("DP-13");
assertThat(execution.capacityPlan(number).planValid()).isFalse(); assertThat(execution.capacityPlan(number).planValid()).isFalse();
assertThat(service.detail(number).dispatches()).hasSize(3); assertThat(service.detail(number).dispatches()).hasSize(3);
service.unbindDispatch(number,"1",new FleetTripModels.BindingCommand(linked.tripRecordVersion(),"DP-13","撤销未开工关联")); var recheck=execution.capacityPlan(number);
var planned=execution.confirmCapacityPlan(number,"1",new FleetExecutionModels.Confirm(recheck.recordVersion(),recheck.manifestHash(),List.of(measurement(),new FleetExecutionModels.Measurement("902",1,new BigDecimal("150"),new BigDecimal("1.5"),1)),"关联实收后核对"));
assertThat(planned.capacityPlan().segments().get(0).occupied().palletSpaces()).isEqualTo(2);
assertThat(planned.availableSegments().get(0).remaining().palletSpaces()).isEqualTo(28);
if(automatic){var replay=bookingService.arrangeDispatch(number,draft.booking().bookingNo(),"1",arrange);assertThat(replay.tripRecordVersion()).isEqualTo(linked.tripRecordVersion());assertThat(replay.booking()).isEqualTo(linked.booking());}
service.unbindDispatch(number,"1",new FleetTripModels.BindingCommand(planned.recordVersion(),"DP-13","撤销未开工关联"));
assertThat(bookingService.list(number).records().get(0).status()).isEqualTo("RECEIVED"); assertThat(bookingService.list(number).records().get(0).status()).isEqualTo("RECEIVED");
assertThat(bookingService.list(number).records().get(0).dispatchNo()).isNull(); assertThat(bookingService.list(number).records().get(0).dispatchNo()).isNull();
} }