feat(fleet): bind quote versions and reconcile actual return quantities

This commit is contained in:
2026-10-03 13:32:55 +08:00
parent cb6c54ffe5
commit a6fd0ba3c5
60 changed files with 1363 additions and 75 deletions
+3
View File
@@ -13,6 +13,9 @@
- 两份文件必须在同一测试数据库实例中完成初始化;业务服务仍只访问本服务 schema,经内部接口访问 IAM/Warehouse。已移除事件桥两个 schema 的创建,不对已有数据库执行 DROP。
- 国内 IAM 是全部平台与客户权限的唯一权威,包括海外仓、海外业务、海外主数据及财务权限;这些权限保留在国内种子中。
- 车辆成本、报价、出车实测及在途交接表对应Lastleg模块基线;公司车辆费用三表对应Finance模块基线。公司财务权限源为`iam/src/main/resources/db/fleet-finance-permissions.sql`,已合入国内统一种子,不以模块文件代替完整空库初始化。
- 外接预订的`quote_source_json`保存可选销售报价来源版本快照,实际成交价仍在货物快照及`agreed_amount`中;手填成交价时无报价来源。该字段随Lastleg模块基线与海外统一入口维护,执行状态只见正式部署契约。
- 异常退回的`return_context_json`保存取消时所需的原始业务状态;逐来源`received_packages`累计实际入仓量且不得超过批准退回量。同一派送可以保留多张已取消/已结束的历史退回单,活动退回仍由派送行锁内校验;普通`idx_ll_exception_delivery_return`替代跨全部历史的唯一索引。数量和状态不能通过回填旧单据伪造。
- 派送`ll_delivery_order.signed_packages`保存签收资料确认时冻结的实际签收箱数;NULL表示未确认,不当作全量签收,数量须在0到本次实发箱数之间。后续退回保留原签收历史,不用当前退回量倒改实签。
- 国内 Warehouse 管理包括海外仓在内的公共仓库主档;海外 Warehouse 只保存资源与主档 ID 引用。
- Gateway、Print、Integration 不新增数据库。Compose 继续使用 `SPRING_SQL_INIT_MODE=never`,服务启动不执行这些文件。
+10 -1
View File
@@ -275,6 +275,9 @@ CREATE TABLE ll_delivery_order (
version bigint NOT NULL DEFAULT '0' COMMENT '派送单乐观锁版本',
created_at datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间',
updated_at datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间',
signed_packages INT NULL COMMENT '保存签收资料时冻结的实际签收箱数,不含此前已退回量',
CONSTRAINT chk_delivery_signed_packages CHECK(signed_packages IS NULL OR (signed_packages>=0 AND signed_packages<=package_count)),
PRIMARY KEY (id),
UNIQUE KEY uk_ll_delivery_no (customer_id,delivery_no),
UNIQUE KEY uk_ll_delivery_idempotency (customer_id,idempotency_key),
@@ -684,9 +687,11 @@ CREATE TABLE ll_exception_order (
version bigint NOT NULL DEFAULT '0' COMMENT '异常订单乐观锁版本',
created_at datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间',
updated_at datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间',
return_context_json JSON NULL COMMENT '退回异常创建前的派送及尾程状态快照',
PRIMARY KEY (id),
UNIQUE KEY uk_ll_exception_no (customer_id,exception_no),
UNIQUE KEY uk_ll_exception_delivery_return (customer_id,delivery_order_id,exception_type),
KEY idx_ll_exception_delivery_return(customer_id,delivery_order_id,exception_type),
KEY idx_ll_exception_list (customer_id,status,exception_at),
KEY idx_ll_exception_tail (customer_id,last_mile_order_no),
KEY idx_ll_exception_inbound (customer_id,inbound_code)
@@ -717,6 +722,9 @@ CREATE TABLE ll_exception_return_item (
max_return_packages bigint NOT NULL COMMENT '原出库可退箱数上限',
approved_return_packages bigint NOT NULL COMMENT '批准退回箱数',
created_at datetime(3) NOT NULL COMMENT '创建时间',
received_packages BIGINT NOT NULL DEFAULT 0 COMMENT '已实际退回入仓的累计箱数',
CONSTRAINT chk_exception_return_received CHECK(received_packages>=0 AND received_packages<=approved_return_packages),
PRIMARY KEY (id),
UNIQUE KEY uk_ll_exception_return_source (customer_id,exception_order_id,outbound_pallet_item_id,inventory_position_id),
KEY idx_ll_exception_return_order (customer_id,exception_order_id)
@@ -2735,6 +2743,7 @@ CREATE TABLE ll_fleet_external_booking (
customer_name VARCHAR(255) NOT NULL COMMENT '客户名称快照',
status VARCHAR(16) NOT NULL COMMENT 'DRAFT询价/CONFIRMED占位/RECEIVED确认实收/BOUND关联发运/CANCELLED取消',
cargo_json JSON NOT NULL COMMENT '货物、计划载荷、联系人及约定报价快照',
quote_source_json JSON NULL COMMENT '选用销售报价的可信版本快照,与实际成交金额分开保存',
destination_key VARCHAR(160) NOT NULL COMMENT '已有路线卸货站匹配键',
destination_label VARCHAR(255) NOT NULL COMMENT '约定卸货站名称',
agreed_amount DECIMAL(16,2) NULL COMMENT '约定金额,非已入账应收或实收',
+3 -2
View File
@@ -61,7 +61,7 @@
| 本土散板订单 | `looseboard/controller/PlatformLocalLooseboardController.java` |
| 海外仓报价、月度汇率、车辆主数据 | `masterdata/quote/` |
| 司机档案、证照上传/审核、资格预览与历史 | `fleet/driver/PlatformDriverController.java`、`DriverService.java`;OpenAPI `fleet-drivers-v1.yaml`,表 `ll_fleet_driver` / `ll_fleet_driver_history`;资格规则 `fleet/domain/DriverQualificationPolicy.java` |
| 车辆运维:保养、加油、维修、故障、救援、异常 | `fleet/maintenance/PlatformVehicleCareController.java`、`VehicleCareService.java`;OpenAPI `fleet-vehicle-records-v1.yaml`;逐车及逐类型隔离,保养加油显式单位,事件保留项目/地点/处理人;申报费用须有币种,处理完成不代表付款,当前无财务关联只返回UNLINKED。创建/上传幂等,更正保留版本及历史,不自动变更主档里程、车辆状态或财务 |
| 车辆运维:保养、加油、维修、故障、救援、异常 | `fleet/maintenance/PlatformVehicleCareController.java`、`VehicleCareService.java`;合同`fleet-vehicle-records-v1.yaml`。按车/类型隔离,单位/币种明确,处理不等付款;创建/附件幂等,更正留版本历史,不改主档里程/状态。维修救援经`financefee/fleet/VehicleCareFinanceAdapter`入公司成本,Finance为付款权威,未关联UNLINKED,不生成客户应收 |
| 车次计划、车/司机分配与占用、客户排车关联 | `fleet/trip/PlatformFleetTripController.java`、`FleetTripService.java`;OpenAPI `fleet-trips-v1.yaml`;`FleetDispatchGateway` 由 `dispatch/fleet/DispatchFleetAdapter.java` 实现,`FleetDispatchBindingGuard` 控制子单独立修改/发车;车辆通过 `VehicleRepository` 和 `VehicleAssignmentGuard` 协作,fleet不直读车辆持久化适配器 |
| 调度与理货审核 | `FleetTripService.reviewDispatch`、`FleetTripExecutionService.reviewLoading`;每张客户排车单先调度通过,整车确认实载后理货通过。`FleetDispatchBindingGuard.requirePickingApproved` 在新拣货/完成前核对当前单审核;已成功操作的同键重放保留原结果 |
| 外接询价、确认占位及取消 | `fleet/trip/PlatformFleetBookingController.java`、`FleetBookingService.java`、`FleetBookingReservations.java`;客户从`fleet/customer/`读取IAM有效主档与资格,询价不占位,确认后与自有货计划共同校验逐段容量。约定金额不代表应收/实收,`FleetBookingIncomingGateway`由`transfer/fleet/FleetIncomingAdapter`实现来货登记/实收确认,排车关联通过`FleetDispatchGateway.sourceCargo`核对实收来源 |
@@ -77,6 +77,7 @@
- 车次成本:`fleet/trip/FleetTripCostService.java`、`fleet-trip-costs-v1.yaml`;按DEPART实测及外接实收分摊,不产生客户账单/付款。
- 报价/统计:`fleet/quote/`、`fleet/reporting/`,合同`fleet-quotes-v1.yaml`、`fleet-operations-v1.yaml`。
- 外接选价合同:`fleet-quote-candidates-v1.yaml`、`fleet-trips-v1.yaml`;来源快照与成交价分开,确认承运复验。
- 在途交接:`fleet/exception/`、`fleet-handovers-v1.yaml`;后台确认交接,旧资源分别结束任务,DEPART不重写。
- 车辆履约投影:`fleet/trip/PlatformVehicleFulfillmentController.java` → `VehicleFulfillmentService` / `VehicleFulfillmentMapper`,合同`fleet-vehicle-fulfillment-v1.yaml`。派送同时要求vehicle:list与delivery:list,更换历史要求vehicle:list与trip:list;客户与排车双键关联,不跨库。按不可变DEPART/ASSIGN关联车辆,首次分配不算更换,不按当前车牌猜历史。只读重复读事务分页;不返回内部ID或整份客户快照。
@@ -87,7 +88,7 @@
- 排车转派送允许在 `PENDING_DEPARTURE`、`PICKING`、`READY_TO_DEPART` 阶段幂等生成一张 `WAIT_OUTBOUND` 出库单,并按目的FBA仓生成多张派送单,但必须早于发车;BOL和签收资料均绑定拆分后的单仓派送单,派送费用仍只发布一次。PDA 排车详情由 `DispatchPdaWorkflowService` 返回 `canDepart`、`departBlockReason`,只有车辆信息完整、拣货任务完成且箱数核平、排车为 `READY_TO_DEPART`、关联出库单仍为 `WAIT_OUTBOUND` 时才允许发车,PDA 不得自行推导该门禁。
- 共享车次关联取排车单自身客户与起运仓;排车有效关联唯一,多客户不合并库存归属。`FleetDispatchBindingGuard` 在子单行锁内阻止独立编辑/移托/取消及绕过车次协调单独发车;转派送的新建请求不能覆盖车次车辆司机,已成功转换的重放继续返回既有单据。PDA详情同步返回车次统一发车阻断原因;关联与改派通过 `FleetDispatchGateway` 同事务同步尚未执行的派送快照。已拣货禁止直接解除关联或变更路线;发车前可统一换车/司机或保持路线调整时间,保留拣货事实与到达时间,重验司机任务覆盖及替换车辆对已确认装载的容量,成功后调度审核、理货审核及原装载确认失效;仅修改备注保留审核与装载。已出库/发车不允许此类修改。
- 实物装载按仓库托盘ID去重计量,客户货量与物理占位分别保存;重量/体积为明确确认的实际值,不读取计费材积重回填。路线卸货站的 `destinationKey` 必须匹配原货物目的地,同实托不同目的地先拆托。装载确认保存来源hash、版本、操作人和历史;改计划/改派/关联变化使确认失效。整车发车在同一Lastleg事务中复核所有子单、车辆占用及司机证照,调用原子单出库命令后统一提交 `IN_TRANSIT` 和幂等结果;任一失败全车回滚,`DEPARTING`只在事务内部使用。PDA列表按车次复用发车门禁查询,再按当前客户排车单审核返回canPick/pickBlockReason;客户A通过不能代替客户B通过。重新确认装载或重新调度审核使理货审核待审,审核历史不删除;过期前30天提醒与阻断分别返回。
- 车次货物结案只消费原派送单事实:已签收须有signedAt,已退回须有RETURN_COMPLETED操作记录;拒收、待退回和取消不能冒充完成。完成时间为本车最后一票实际终态时间,不取最近编辑时间;完成不改客户状态、不生成费用、不释放占用。车辆与司机分别结束实际任务并保留地点/时间,车辆状态与资源释放同事务提交;司机证照过期不阻止结束旧任务,但不恢复其新任务资格。原释放请求重放不影响资源后来已分配的新车次。
- 车次结案只消费派送事实:实签须signedAt/signedPackages,实退须原来源入账流水/RETURN_COMPLETED;实签加本趟实退等于原实发且余量为零。拒收、待退回、取消不算完成,时间取最后实际终态。结案不改客户状态/费用、不释放占用;车/司机分别记录实际结束时间地点,车辆状态与释放同事务。过期证照不阻止旧任务结束、不恢复新任务资格;原键重放不影响后续车次。
- 已签收后另发起退货保留原运输的签收事实,后续退货不重开原车次、不被车次结案改写;未签收货物仍按实际退回完成核对。`DeliveryDocumentService.save` 首次保存签收资料触发完成时要求关联出库已OUTBOUNDED,未出库可以准备BOL/装车资料但不能提前完成派送。
- 排车执行与 `delivery/` Controller 和其他尾程业务接口一同注册,不受功能开关控制。平台排车列表与详情仍由 `lastleg.dispatch-query-api.enabled=true` 独立启用;入库单智能排车候选、草稿锁定/解锁/释放和预排创建仍由 `lastleg.smart-dispatch-api.enabled=true` 独立启用。这些排车平台入口均不接收 `customerId`:排车列表跨客户分页,详情按全局排车单号定位后使用记录自身客户范围加载明细;智能排车候选未传计划 ID 时跨客户查询,传入时根据计划 ID 解析范围;托盘移入草稿时立即以操作人和随机草稿标识锁定,移除或关闭时释放,草稿锁超时失效;预排创建根据所选托盘 ID 在服务端解析并校验唯一客户范围,并将当前操作人的草稿锁原子转换为正式排车锁。
- 仓库 ID、类型、状态、地址和能力唯一权威归国内 `warehouse-service`;海外库位、实物托盘和资源锁归 `warehouse-overseas-service/` 且只引用同一国内 `warehouseId`。Lastleg PDA 通过 Warehouse Overseas 内部资源入口校验该国内共享收货仓,不得要求海外数据库重复建仓;浏览器 API 统一经 `gateway/`。
@@ -55,6 +55,7 @@ public class DeliveryOrder {
private LocalDateTime dispatchedAt;
private LocalDateTime expectedArrivalAt;
private LocalDateTime signedAt;
private Integer signedPackages;
private String remark;
private String idempotencyKey;
private String createdBy;
@@ -2,6 +2,7 @@ package com.baiyue.lastleg.delivery.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baiyue.lastleg.delivery.entity.DeliveryOperation;
import com.baiyue.lastleg.delivery.model.DeliveryReturnReceiptFact;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
@@ -10,6 +11,29 @@ import java.util.List;
@Mapper
public interface DeliveryOperationMapper extends BaseMapper<DeliveryOperation> {
@Select("SELECT l.id AS ledger_id, di.id AS dispatch_item_id, oi.warehouse_pallet_id AS pallet_id, " +
"l.delta_packages AS packages, l.occurred_at AS received_at FROM ll_delivery_order d " +
"JOIN ll_exception_order e ON e.customer_id=d.customer_id AND e.delivery_order_id=d.id " +
"JOIN ll_exception_return_item i ON i.customer_id=e.customer_id AND i.exception_order_id=e.id " +
"JOIN ll_outbound_pallet_item oi ON oi.customer_id=d.customer_id AND oi.outbound_order_id=d.outbound_order_id AND oi.id=i.outbound_pallet_item_id " +
"JOIN ll_inventory_ledger l ON l.customer_id=e.customer_id AND l.business_order_no=e.exception_no " +
"AND l.outbound_order_no=d.outbound_no AND l.business_type='RETURN_STOCK_IN' AND l.delta_packages>0 " +
"AND l.source_event_id=CONCAT('RETURN_SOURCE:',i.outbound_pallet_item_id,':',i.inventory_position_id) " +
"LEFT JOIN ll_dispatch_item di ON di.customer_id=d.customer_id AND di.dispatch_order_id=d.dispatch_order_id " +
"AND di.warehouse_pallet_id=oi.warehouse_pallet_id " +
"AND EXISTS (SELECT 1 FROM ll_dispatch_pda_pick p JOIN ll_dispatch_pda_pick_order a " +
"ON a.customer_id=p.customer_id AND a.pick_id=p.id " +
"JOIN ll_dispatch_pda_task t ON t.customer_id=p.customer_id AND t.id=p.task_id " +
"WHERE p.customer_id=d.customer_id AND t.dispatch_order_id=d.dispatch_order_id " +
"AND p.dispatch_item_id=di.id AND p.inventory_position_id=i.inventory_position_id AND a.order_id=e.last_mile_order_id) " +
"WHERE d.customer_id=#{customerId} AND d.id=#{deliveryOrderId} ORDER BY l.occurred_at,l.id")
List<DeliveryReturnReceiptFact> returnReceipts(@Param("customerId") String customerId, @Param("deliveryOrderId") Long deliveryOrderId);
@Select("SELECT COUNT(*) FROM ll_exception_order WHERE customer_id=#{customerId} AND delivery_order_id=#{deliveryOrderId} " +
"AND exception_type='RETURN' AND status IN ('PENDING','PROCESSING')")
long activeReturns(@Param("customerId") String customerId, @Param("deliveryOrderId") Long deliveryOrderId);
@Select("SELECT MAX(created_at) FROM ll_delivery_operation WHERE customer_id=#{customerId} AND delivery_order_id=#{deliveryOrderId} AND operation_node='RETURN_COMPLETED'")
java.time.LocalDateTime returnedAt(@Param("customerId") String customerId, @Param("deliveryOrderId") Long deliveryOrderId);
@Select("SELECT * FROM ll_delivery_operation WHERE customer_id=#{customerId} AND delivery_order_id=#{deliveryOrderId} ORDER BY created_at,id")
@@ -0,0 +1,32 @@
package com.baiyue.lastleg.delivery.model;
import com.baiyue.lastleg.delivery.entity.DeliveryOrder;
import java.util.HashSet;
import java.util.List;
/** Quantity accounting for the original journey; later post-signature returns belong to another movement. */
public record DeliveryCargoQuantities(int shipped, int signed, int returned, int remaining) {
public static DeliveryCargoQuantities from(DeliveryOrder order, List<DeliveryReturnReceiptFact> receipts) {
if (order.getPackageCount() == null || order.getPackageCount() <= 0) throw invalid("派送缺少实发数量");
var ids = new HashSet<Long>();
long received = 0;
for (var receipt : receipts) {
if (receipt.ledgerId() == null || !ids.add(receipt.ledgerId()) || receipt.packages() <= 0 || receipt.receivedAt() == null)
throw invalid("退回实际入账流水缺失或来源映射重复");
received = Math.addExact(received, receipt.packages());
}
int shipped = order.getPackageCount();
if (received > shipped) throw invalid("退回实收超过原实发数量");
if (order.getSignedAt() != null) {
Integer signed = order.getSignedPackages();
long beforeSignature = receipts.stream().filter(receipt -> !receipt.receivedAt().isAfter(order.getSignedAt())).mapToLong(DeliveryReturnReceiptFact::packages).sum();
if (signed == null || signed <= 0 || signed > shipped || beforeSignature < shipped - signed)
throw invalid("签收数量快照缺失或与实际退回不守恒");
// Do not re-open or reclassify signed cargo when another return is later received.
return new DeliveryCargoQuantities(shipped, signed, shipped - signed, 0);
}
if (order.getSignedPackages() != null) throw invalid("签收数量缺少签收时间");
return new DeliveryCargoQuantities(shipped, 0, Math.toIntExact(received), shipped - Math.toIntExact(received));
}
private static IllegalArgumentException invalid(String message) { return new IllegalArgumentException(message); }
}
@@ -0,0 +1,6 @@
package com.baiyue.lastleg.delivery.model;
import java.time.LocalDateTime;
/** Immutable actual receipt, attributed to the original dispatch item and physical pallet. */
public record DeliveryReturnReceiptFact(Long ledgerId, Long dispatchItemId, Long palletId, long packages, LocalDateTime receivedAt) { }
@@ -31,7 +31,7 @@ public final class DeliveryStatusPolicy {
case "WAIT_DELIVERY" -> List.of(
"START_DELIVERY", "REJECT_DELIVERY", "CANCEL_DELIVERY");
case "DELIVERING" -> List.of("REJECT_DELIVERY", "MARK_RETURN");
case "DELIVERED", "REJECTED" -> List.of("MARK_RETURN");
case "DELIVERED", "REJECTED", "RETURN_PENDING" -> List.of("MARK_RETURN");
default -> List.of();
};
}
@@ -33,9 +33,13 @@ public class DeliveryCompletedOutboxService {
@Resource private LastlegEventOutboxMapper outbox;
@Resource private ObjectMapper objectMapper;
public void publish(DeliveryOrder delivery, List<LastlegOrder> orders, Instant completedAt) {
public enum Publication { PUBLISHED, NO_DOMESTIC_SOURCE, UNKNOWN_SIGNATURE_REQUIRES_RECONCILIATION, MIXED_RETURN_REQUIRES_SOURCE_RECONCILIATION }
public Publication publish(DeliveryOrder delivery, List<LastlegOrder> orders, Instant completedAt) {
Objects.requireNonNull(delivery, "delivery");
Objects.requireNonNull(completedAt, "completedAt");
// The domestic consumer marks the whole inbound order DELIVERED (signed), not merely transport closed.
// A mixed return/signature must not publish that full-signature fact.
Map<String, LastlegOrder> bySourceInbound = new LinkedHashMap<>();
for (LastlegOrder order : orders == null ? List.<LastlegOrder>of() : orders) {
// Only orders projected from the domestic first-leg domain need an
@@ -59,7 +63,14 @@ public class DeliveryCompletedOutboxService {
throw new IllegalStateException("同一来源入仓单对应的派送完成快照冲突");
}
}
if (bySourceInbound.isEmpty()) return Publication.NO_DOMESTIC_SOURCE;
if (delivery.getPackageCount() == null || delivery.getPackageCount() <= 0 || delivery.getSignedPackages() == null
|| delivery.getSignedPackages() <= 0 || delivery.getSignedPackages() > delivery.getPackageCount())
return Publication.UNKNOWN_SIGNATURE_REQUIRES_RECONCILIATION;
if (!Objects.equals(delivery.getSignedPackages(), delivery.getPackageCount()))
return Publication.MIXED_RETURN_REQUIRES_SOURCE_RECONCILIATION;
for (LastlegOrder order : bySourceInbound.values()) publishOne(delivery, order, completedAt);
return Publication.PUBLISHED;
}
private void publishOne(DeliveryOrder delivery, LastlegOrder order, Instant completedAt) {
@@ -86,12 +86,18 @@ public class DeliveryDocumentService {
}).toList();
LocalDateTime now = LocalDateTime.now();
boolean receiptSaved = !bindings.getOrDefault(DeliveryDocumentCategory.RECEIPT, List.of()).isEmpty();
boolean autoCompleted = receiptSaved && !"DELIVERED".equals(order.getStatus());
boolean autoCompleted = receiptSaved && order.getSignedAt() == null && !"DELIVERED".equals(order.getStatus());
int signedPackages = 0;
if (autoCompleted) {
if (!DeliveryStatusPolicy.canTransition(order.getStatus(), "DELIVERED")) throw conflict("当前派送状态不允许通过签收文件完成");
if (!DeliveryStatusPolicy.canTransition(order.getStatus(), "DELIVERED") && !"RETURN_PENDING".equals(order.getStatus()))
throw conflict("当前派送状态不允许通过签收文件完成");
// A read avoids reversing outbound -> delivery lock order. An uncommitted departure remains ineligible.
var outbound = outboundOrders.findByNo(customerId, order.getOutboundNo());
if (outbound == null || !"OUTBOUNDED".equals(outbound.getStatus())) throw conflict("请先完成实际出库,再保存签收资料完成派送");
if (operations.activeReturns(customerId, order.getId()) != 0) throw conflict("请先完成或取消正在处理的退回异常,再确认剩余货物签收");
try { signedPackages = DeliveryCargoQuantities.from(order, operations.returnReceipts(customerId, order.getId())).remaining(); }
catch (IllegalArgumentException error) { throw conflict(error.getMessage()); }
if (signedPackages <= 0) throw conflict("本次派送已无待签收货物");
}
order.setBolNo(request.bolNo() == null ? "" : request.bolNo().trim());
order.setDocumentsJson(write(categories));
@@ -100,11 +106,23 @@ public class DeliveryDocumentService {
if (autoCompleted) {
order.setStatus("DELIVERED");
order.setSignedAt(now);
order.setSignedPackages(signedPackages);
}
orders.updateById(order);
if (autoCompleted) {
List<LastlegOrder> completedOrders = completeRelatedOrders(order, now);
deliveryCompletedOutbox.publish(order, completedOrders, now.toInstant(ZoneOffset.UTC));
var publication = deliveryCompletedOutbox.publish(order, completedOrders, now.toInstant(ZoneOffset.UTC));
if (publication == DeliveryCompletedOutboxService.Publication.MIXED_RETURN_REQUIRES_SOURCE_RECONCILIATION
|| publication == DeliveryCompletedOutboxService.Publication.UNKNOWN_SIGNATURE_REQUIRES_RECONCILIATION) {
DeliveryOperation pending = new DeliveryOperation();
pending.setCustomerId(customerId); pending.setDeliveryOrderId(order.getId()); pending.setOperatorId(operatorId);
pending.setOperationNode("FIRSTLEG_COMPLETION_PENDING");
pending.setResult(publication == DeliveryCompletedOutboxService.Publication.UNKNOWN_SIGNATURE_REQUIRES_RECONCILIATION
? "签收数量未确认或无效,国内整票签收事件未发布,须核对真实实签量后继续回写"
: "原实发" + order.getPackageCount() + "箱,本次签收" + order.getSignedPackages()
+ "箱,其余已退回;国内整票签收事件未发布,须按来源入仓单核对签收与退回处置后继续回写");
pending.setCreatedAt(now); operations.insert(pending);
}
customerVisits.ensureForCompletedDelivery(order, now);
}
Detail result = detail(order, false);
@@ -156,7 +174,8 @@ public class DeliveryDocumentService {
// Preserve the outbound leg when receipt completion closes delivery.
// Sub-order status is a composite fact; replacing it with DELIVERY alone
// makes a successfully departed outbound appear unfinished or disappear.
order.setSubOrderStatus("OUTBOUND:OUTBOUNDED;DELIVERY:DELIVERED");
order.setSubOrderStatus(delivery.getSignedPackages().equals(delivery.getPackageCount())
? "OUTBOUND:OUTBOUNDED;DELIVERY:DELIVERED" : "OUTBOUND:OUTBOUNDED;DELIVERY:PARTIALLY_RETURNED");
order.setUpdatedAt(now);
lastlegOrders.updateById(order);
}
@@ -167,7 +186,7 @@ public class DeliveryDocumentService {
operation.setCustomerId(order.getCustomerId()); operation.setDeliveryOrderId(order.getId());
operation.setOperatorId(operatorId); operation.setOperatorName(operatorId);
operation.setOperationNode("RECEIPT_AUTO_COMPLETE");
operation.setResult("签收文件保存成功,派送单自动完成");
operation.setResult("签收文件保存成功,本次实际签收" + order.getSignedPackages() + "箱;原实发" + order.getPackageCount() + "箱");
operation.setCreatedAt(now); operations.insert(operation);
}
private String hash(Object value) {
@@ -5,6 +5,7 @@ import com.baiyue.lastleg.delivery.entity.DeliveryOrder;
import com.baiyue.lastleg.delivery.mapper.DeliveryOrderMapper;
import com.baiyue.lastleg.delivery.mapper.DeliveryOperationMapper;
import com.baiyue.lastleg.delivery.mapper.OutboundOrderMapper;
import com.baiyue.lastleg.delivery.model.DeliveryCargoQuantities;
import com.baiyue.lastleg.dispatch.entity.DispatchOrder;
import com.baiyue.lastleg.dispatch.mapper.DispatchOrderMapper;
import com.baiyue.lastleg.dispatch.mapper.DispatchItemMapper;
@@ -159,17 +160,26 @@ public class DispatchFleetAdapter implements FleetDispatchGateway {
var outbound = lock ? outbounds.lockByDispatch(dispatch.getCustomerId(), dispatch.getId()) : outbounds.findByDispatch(dispatch.getCustomerId(), dispatch.getId());
var rows = outbound == null ? java.util.List.<DeliveryOrder>of() : lock
? deliveries.lockByOutbound(dispatch.getCustomerId(), outbound.getId()) : deliveries.findByOutbound(dispatch.getCustomerId(), outbound.getId());
var outcomes = rows.stream().map(row -> new FleetCompletionModels.Outcome(dispatchNo, row.getDeliveryNo(), dispatch.getCustomerId(),
if (outbound != null && (rows.stream().mapToLong(row -> Objects.requireNonNullElse(row.getPackageCount(), 0)).sum()
!= Objects.requireNonNullElse(dispatch.getShippedPackageCount(), 0) || !Objects.equals(dispatch.getShippedPackageCount(), outbound.getPackageCount())))
return new FleetCompletionModels.DispatchCompletion(dispatchNo, dispatch.getOverseasWarehouseId(), java.util.List.of(), "派送单数量未覆盖本次全部实发货物");
var outcomes = rows.stream().map(row -> {
var quantities = quantities(row, deliveryOperations.returnReceipts(dispatch.getCustomerId(), row.getId()));
return new FleetCompletionModels.Outcome(dispatchNo, row.getDeliveryNo(), dispatch.getCustomerId(),
"PRIVATE".equals(row.getDestinationType()) ? row.getDeliveryAddress() : first(row.getDestinationWarehouseCode(), row.getDestinationWarehouseName()),
originallySigned(row) ? "DELIVERED" : row.getStatus(), Objects.requireNonNullElse(row.getPackageCount(), 0),
originallySigned(row) ? instant(row.getSignedAt()) : "RETURNED".equals(row.getStatus())
? instant(deliveryOperations.returnedAt(dispatch.getCustomerId(), row.getId())) : null)).toList();
? instant(deliveryOperations.returnedAt(dispatch.getCustomerId(), row.getId())) : null,
quantities.signed(), quantities.returned(), quantities.remaining());
}).toList();
String reason = null;
if (!"DEPARTED".equals(dispatch.getStatus()) || outbound == null || !"OUTBOUNDED".equals(outbound.getStatus())) reason = "排车出库尚未完成";
else if (outcomes.isEmpty()) reason = "缺少对应派送单";
else if (outcomes.stream().mapToLong(FleetCompletionModels.Outcome::packages).sum() != Objects.requireNonNullElse(dispatch.getShippedPackageCount(), 0)
|| !Objects.equals(dispatch.getShippedPackageCount(), outbound.getPackageCount())) reason = "派送单数量未覆盖本次全部实发货物";
else if (outcomes.stream().anyMatch(row -> row.packages() <= 0 || !java.util.Set.of("DELIVERED", "RETURNED").contains(row.status()) || row.terminalAt() == null))
else if (outcomes.stream().anyMatch(row -> row.packages() <= 0 || row.remainingPackages() != 0
|| row.signedPackages() + row.returnedPackages() != row.packages()
|| !java.util.Set.of("DELIVERED", "RETURNED").contains(row.status()) || row.terminalAt() == null))
reason = "所有派送单须已签收,或完成真实退回入仓";
return new FleetCompletionModels.DispatchCompletion(dispatchNo, dispatch.getOverseasWarehouseId(), outcomes, reason);
}
@@ -194,17 +204,25 @@ public class DispatchFleetAdapter implements FleetDispatchGateway {
|| !Objects.equals(row.getOutboundOrderId(), outbound.getId())
|| !Objects.equals(row.getOverseasWarehouseId(), dispatch.getOverseasWarehouseId())
|| row.getPackageCount() == null || row.getPackageCount() <= 0 || row.getVersion() == null
|| row.getStatus() == null || !java.util.Set.of("WAIT_DELIVERY", "DELIVERED", "RETURN_PENDING", "RETURNED").contains(row.getStatus()))
|| row.getStatus() == null || !java.util.Set.of("WAIT_DELIVERY", "DELIVERING", "REJECTED", "DELIVERED", "RETURN_PENDING", "RETURNED").contains(row.getStatus()))
throw conflict("派送货物归属或执行状态与原始发运不一致");
Instant terminal = originallySigned(row) ? instant(row.getSignedAt()) : "RETURNED".equals(row.getStatus())
? instant(deliveryOperations.returnedAt(dispatch.getCustomerId(), row.getId())) : null;
if (("DELIVERED".equals(row.getStatus()) || "RETURNED".equals(row.getStatus())) && terminal == null)
throw conflict("派送终态缺少真实签收或退回完成证据");
var receipts = deliveryOperations.returnReceipts(dispatch.getCustomerId(), row.getId());
var quantities = quantities(row, receipts);
if (terminal != null && quantities.remaining() != 0) throw conflict("派送终态数量未覆盖原实发货物");
String key = destination(row.getDestinationType(), row.getDestinationWarehouseCode(), null,
row.getDeliveryAddress(), row.getPostalCode());
if (key == null) throw conflict("派送单缺少真实卸货目的地");
var cargo = new com.baiyue.lastleg.fleet.exception.FleetHandoverPolicy.Delivery(dispatch.getCustomerId(),
dispatchNo, row.getDeliveryNo(), key, row.getPackageCount(), terminal);
dispatchNo, row.getDeliveryNo(), key, row.getPackageCount(), terminal,
originallySigned(row) ? java.util.List.of() : receipts.stream().map(receipt -> {
if (receipt.dispatchItemId() == null || receipt.palletId() == null) throw conflict("实际退回缺少原发运实托明细归属");
return new com.baiyue.lastleg.fleet.exception.FleetHandoverPolicy.ReturnedCargo(
receipt.dispatchItemId().toString(), receipt.palletId().toString(), Math.toIntExact(receipt.packages()), instant(receipt.receivedAt()));
}).toList());
facts.add(new com.baiyue.lastleg.fleet.exception.FleetTransitEvidence.Delivery(cargo, row.getStatus(), row.getVersion(),
new com.baiyue.lastleg.fleet.exception.FleetTransitEvidence.Transport(row.getVehiclePlate(), row.getDriverName(), row.getDriverPhone())));
packages = Math.addExact(packages, row.getPackageCount());
@@ -236,6 +254,10 @@ public class DispatchFleetAdapter implements FleetDispatchGateway {
}
private static Instant instant(LocalDateTime value) { return value == null ? null : value.atZone(ZoneId.systemDefault()).toInstant(); }
private static DeliveryCargoQuantities quantities(DeliveryOrder row, java.util.List<com.baiyue.lastleg.delivery.model.DeliveryReturnReceiptFact> receipts) {
try { return DeliveryCargoQuantities.from(row, receipts); }
catch (IllegalArgumentException error) { throw conflict(error.getMessage()); }
}
private static boolean originallySigned(DeliveryOrder order) {
return order.getSignedAt() != null && java.util.Set.of("DELIVERED", "RETURN_PENDING", "RETURNED").contains(order.getStatus());
}
@@ -31,6 +31,7 @@ public class ExceptionOrder {
private Integer packageCount;
private String returnDirection;
private String returnLogisticsNo;
private String returnContextJson;
private String handlingAction;
private String handlingResult;
private String evidenceFileIdsJson;
@@ -19,5 +19,6 @@ public class ExceptionReturnItem {
private String skuCode;
private Long maxReturnPackages;
private Long approvedReturnPackages;
private Long receivedPackages;
private LocalDateTime createdAt;
}
@@ -9,7 +9,7 @@ import org.apache.ibatis.annotations.Select;
@Mapper
public interface ExceptionOrderMapper extends BaseMapper<ExceptionOrder> {
@Select("SELECT * FROM ll_exception_order WHERE customer_id=#{customerId} AND delivery_order_id=#{deliveryOrderId} " +
"AND exception_type='RETURN' LIMIT 1")
"AND exception_type='RETURN' AND status IN ('PENDING','PROCESSING') LIMIT 1")
ExceptionOrder findDeliveryReturn(@Param("customerId") String customerId,
@Param("deliveryOrderId") Long deliveryOrderId);
@@ -13,4 +13,16 @@ public interface ExceptionReturnItemMapper extends BaseMapper<ExceptionReturnIte
@Select("SELECT * FROM ll_exception_return_item WHERE customer_id=#{customerId} AND exception_order_id=#{exceptionOrderId} ORDER BY id")
List<ExceptionReturnItem> findByException(@Param("customerId") String customerId,
@Param("exceptionOrderId") Long exceptionOrderId);
@Select("SELECT COALESCE(SUM(i.received_packages),0) FROM ll_exception_return_item i " +
"JOIN ll_exception_order e ON e.id=i.exception_order_id AND e.customer_id=i.customer_id " +
"WHERE e.customer_id=#{customerId} AND e.delivery_order_id=#{deliveryOrderId}")
long receivedByDelivery(@Param("customerId") String customerId, @Param("deliveryOrderId") Long deliveryOrderId);
@Select("SELECT COALESCE(SUM(i.received_packages),0) FROM ll_exception_return_item i " +
"JOIN ll_exception_order e ON e.id=i.exception_order_id AND e.customer_id=i.customer_id " +
"WHERE e.customer_id=#{customerId} AND e.delivery_order_id=#{deliveryOrderId} " +
"AND i.outbound_pallet_item_id=#{itemId} AND i.inventory_position_id=#{positionId}")
long receivedBySource(@Param("customerId") String customerId, @Param("deliveryOrderId") Long deliveryOrderId,
@Param("itemId") Long itemId, @Param("positionId") Long positionId);
}
@@ -130,7 +130,8 @@ public class ExceptionOrderService {
public List<ExceptionOrderResponses.Relation> returnCandidates(String customerId, String deliveryNo) {
DeliveryOrder delivery = deliveryOrders.findByNo(customerId, deliveryNo);
if (delivery == null) throw notFound("派送单不存在");
if (!DeliveryStatusPolicy.allowedActions(delivery.getStatus()).contains("MARK_RETURN")) {
if (!DeliveryStatusPolicy.allowedActions(delivery.getStatus()).contains("MARK_RETURN")
&& !"RETURN_PENDING".equals(delivery.getStatus())) {
throw conflict("当前派送状态不允许标记退回");
}
return returnCandidates(delivery);
@@ -157,19 +158,13 @@ public class ExceptionOrderService {
request.skuCode(), null, null, "MANUAL", operatorId, operatorName);
}
@Transactional
@Transactional(isolation = org.springframework.transaction.annotation.Isolation.READ_COMMITTED)
public ExceptionOrderResponses.Created markReturn(String customerId, String operatorId, String operatorName, String deliveryNo,
ExceptionOrderRequests.MarkReturn request) {
String requestHash = hash(request);
ExceptionOrderCommand replay = commands.findByKey(customerId, "MARK_RETURN", request.idempotencyKey().trim());
if (replay != null) return replayCreated(replay, requestHash);
DeliveryOrder delivery = deliveryOrders.lockByNo(customerId, deliveryNo);
if (delivery == null) throw notFound("派送单不存在");
if (!DeliveryStatusPolicy.allowedActions(delivery.getStatus()).contains("MARK_RETURN")) {
throw conflict("当前派送状态不允许标记退回");
}
ExceptionOrder existing = exceptionOrders.findDeliveryReturn(customerId, delivery.getId());
if (existing != null) throw conflict("该派送单已存在退回异常单");
DeliveryOrder delivery = lockForReturn(customerId, deliveryNo);
List<ExceptionOrderResponses.Relation> candidates = returnCandidates(delivery);
ExceptionOrderResponses.Relation selected = candidates.stream()
@@ -198,6 +193,7 @@ public class ExceptionOrderService {
item.setOutboundPalletItemId(source.outboundPalletItemId()); item.setInventoryPositionId(source.inventoryPositionId());
item.setPalletNo(source.palletNo()); item.setSkuCode(source.skuCode());
item.setMaxReturnPackages(source.maxReturnPackages()); item.setApprovedReturnPackages(requestedItem.packageCount());
item.setReceivedPackages(0L);
item.setCreatedAt(LocalDateTime.now()); returnItems.insert(item);
}
@@ -222,6 +218,17 @@ public class ExceptionOrderService {
return created;
}
/** Caller keeps the delivery row locked until return creation commits. */
DeliveryOrder lockForReturn(String customerId, String deliveryNo) {
DeliveryOrder delivery = deliveryOrders.lockByNo(customerId, deliveryNo);
if (delivery == null) throw notFound("派送单不存在");
if (!DeliveryStatusPolicy.allowedActions(delivery.getStatus()).contains("MARK_RETURN"))
throw conflict("当前派送状态不允许标记退回");
if (exceptionOrders.findDeliveryReturn(customerId, delivery.getId()) != null)
throw conflict("该派送单已存在退回异常单");
return delivery;
}
@Transactional
public ExceptionOrderResponses.CommandResult startProcessing(String customerId, String operatorId,
String exceptionNo,
@@ -247,6 +254,7 @@ public class ExceptionOrderService {
ExceptionOrderCommand replay = commands.findByKey(customerId, "CANCEL", request.idempotencyKey().trim());
if (replay != null) return replayResult(replay, requestHash);
ExceptionOrder order = lock(customerId, exceptionNo, request.expectedVersion(), "PENDING");
restoreCancelledReturn(order);
LocalDateTime now = LocalDateTime.now();
order.setStatus("CANCELLED"); order.setHandlingResult(trim(request.reason()));
order.setHandlerId(operatorId); order.setUpdatedAt(now); order.setVersion(version(order) + 1);
@@ -266,29 +274,42 @@ public class ExceptionOrderService {
ExceptionOrder order = lock(customerId, exceptionNo, request.expectedVersion(), "PROCESSING");
request.evidenceFileIds().stream().distinct().forEach(fileId -> evidenceFiles.validate(customerId, order.getId(), fileId));
ReturnedInventoryRestoreResponse restored = null;
List<ExceptionReturnItem> stored = List.of();
if ("RETURN".equals(order.getExceptionType()) && "OVERSEAS_WAREHOUSE".equals(order.getReturnDirection())) {
List<ExceptionReturnItem> stored = returnItems.findByException(customerId, order.getId());
var delivery = deliveryOrders.lockByNo(customerId, order.getDeliveryNo());
if (delivery == null || !"RETURN_PENDING".equals(delivery.getStatus())) throw conflict("关联派送单不是退回处理中状态");
stored = returnItems.findByException(customerId, order.getId());
if (stored.isEmpty()) throw conflict("退回异常单缺少已冻结的原出库库存明细");
if (request.receipts() == null || request.receipts().isEmpty()) throw badRequest("请填写退回实收明细");
Map<Long, Long> received = ReturnReceiptPolicy.quantities(stored, request.receipts());
restored = inventoryCommands.restoreReturnedInventory(customerId, operatorId,
new ReturnedInventoryRestoreRequest(order.getExceptionNo(), order.getOutboundNo(),
order.getOverseasWarehouseId(), order.getInboundOrderId(),
stored.stream().map(item -> new ReturnedInventoryRestoreRequest.SourceItem(
stored.stream().filter(item -> received.containsKey(item.getId())).map(item -> new ReturnedInventoryRestoreRequest.SourceItem(
item.getOutboundPalletItemId(), item.getInventoryPositionId(), item.getSkuCode(),
Objects.requireNonNullElse(item.getApprovedReturnPackages(), 0L))).toList(),
item.getApprovedReturnPackages() - ReturnReceiptPolicy.received(item))).toList(),
request.receipts().stream().map(receipt -> new ReturnedInventoryRestoreRequest.Receipt(
receipt.outboundPalletItemId(), receipt.inventoryPositionId(), receipt.receivedPackages(),
receipt.targetPalletId(), receipt.targetPalletNo(), receipt.targetLocationId(),
receipt.targetLocationCode())).toList(), request.idempotencyKey().trim(),
request.evidenceFileIds().stream().map(String::valueOf).toList()));
if (restored.restoredPackages() != received.values().stream().mapToLong(Long::longValue).sum())
throw conflict("退回库存实际入账数量与本次实收不一致");
for (ExceptionReturnItem item : stored) if (received.containsKey(item.getId())) {
item.setReceivedPackages(ReturnReceiptPolicy.received(item) + received.get(item.getId()));
returnItems.updateById(item);
}
} else if (request.receipts() != null && !request.receipts().isEmpty()) {
throw badRequest("非退回海外仓异常不能提交库存实收明细");
}
LocalDateTime now = restored == null ? LocalDateTime.now() : restored.completedAt();
order.setStatus("COMPLETED"); order.setHandlingResult(trim(request.handlingResult()));
order.setEvidenceFileIdsJson(write(request.evidenceFileIds()));
boolean completed = stored.isEmpty() || ReturnReceiptPolicy.complete(stored);
order.setStatus(completed ? "COMPLETED" : "PROCESSING"); order.setHandlingResult(trim(request.handlingResult()));
List<Long> evidence = new ArrayList<>(readEvidenceFileIds(order.getEvidenceFileIdsJson()));
evidence.addAll(request.evidenceFileIds());
order.setEvidenceFileIdsJson(write(evidence.stream().distinct().toList()));
order.setInventoryAdjustmentId(restored == null ? null : restored.adjustmentId());
order.setHandlerId(operatorId); order.setCompletedAt(now); order.setUpdatedAt(now);
order.setHandlerId(operatorId); order.setCompletedAt(completed ? now : null); order.setUpdatedAt(now);
order.setVersion(version(order) + 1); exceptionOrders.updateById(order);
closeReturnedDelivery(order, operatorId, now);
long restoredPackages = restored == null ? 0 : restored.restoredPackages();
@@ -352,6 +373,11 @@ public class ExceptionOrderService {
value.setOverseasWarehouseName(delivery == null ? relation.overseasWarehouseName() : delivery.getOverseasWarehouseName());
value.setSkuCode(trim(sku)); value.setPackageCount(packages); value.setReturnDirection(trim(direction));
value.setReturnLogisticsNo(trim(logisticsNo));
if (delivery != null) {
LastlegOrder tail = relation.lastMileOrderId() == null ? null : lastMileOrders.selectById(relation.lastMileOrderId());
value.setReturnContextJson(write(new ReturnContext(delivery.getStatus(), tail == null ? null : tail.getId(),
tail == null ? null : tail.getSubOrderStatus(), tail == null ? null : tail.getAbnormal())));
}
if (StringUtils.hasText(direction)) {
value.setHandlingAction("OVERSEAS_WAREHOUSE".equals(direction) ? "退回海外仓" : "CUSTOMER".equals(direction) ? "退回客户" : "其他退货处理");
value.setHandlerId(operatorId); value.setHandlerName(operatorName);
@@ -414,7 +440,9 @@ public class ExceptionOrderService {
DeliveryOrder delivery) {
List<ExceptionOrderResponses.ReturnSource> sources = returnSources(delivery).stream()
.filter(source -> relation.inboundCode() == null || Objects.equals(source.inboundCode(), relation.inboundCode()))
.map(Source::response).toList();
.map(source -> source.withPackages(source.maxReturnPackages() - returnItems.receivedBySource(
delivery.getCustomerId(), delivery.getId(), source.outboundPalletItemId(), source.inventoryPositionId())))
.filter(source -> source.maxReturnPackages() > 0).map(Source::response).toList();
return new ExceptionOrderResponses.Relation(relation.lastMileOrderId(), relation.lastMileOrderNo(),
relation.inboundOrderId(), relation.inboundCode(), relation.customerId(), relation.customerName(),
relation.overseasWarehouseId(), relation.overseasWarehouseName(), relation.deliveryNo(),
@@ -476,7 +504,8 @@ public class ExceptionOrderService {
return returnItems.findByException(order.getCustomerId(), order.getId()).stream()
.map(item -> new ExceptionOrderResponses.ReturnSource(item.getOutboundPalletItemId(),
item.getInventoryPositionId(), item.getPalletNo(), item.getSkuCode(),
Objects.requireNonNullElse(item.getApprovedReturnPackages(), 0L))).toList();
Objects.requireNonNullElse(item.getApprovedReturnPackages(), 0L) - ReturnReceiptPolicy.received(item)))
.filter(item -> item.maxReturnPackages() > 0).toList();
}
private void validateReturnItems(List<ExceptionOrderResponses.ReturnSource> available,
@@ -506,10 +535,15 @@ public class ExceptionOrderService {
private void closeReturnedDelivery(ExceptionOrder order, String operatorId, LocalDateTime now) {
if (!"RETURN".equals(order.getExceptionType()) || !StringUtils.hasText(order.getDeliveryNo())) return;
// Closing a non-warehouse case does not establish physical cargo handoff or inventory receipt.
if (!"OVERSEAS_WAREHOUSE".equals(order.getReturnDirection())) return;
DeliveryOrder delivery = deliveryOrders.lockByNo(order.getCustomerId(), order.getDeliveryNo());
if (delivery == null || !"RETURN_PENDING".equals(delivery.getStatus())) {
throw conflict("关联派送单不是退货处理中状态");
}
long received = returnItems.receivedByDelivery(order.getCustomerId(), delivery.getId());
if (delivery.getPackageCount() == null || received > delivery.getPackageCount()) throw conflict("累计退回量与派送实发数量不一致");
if (received < delivery.getPackageCount()) return;
delivery.setStatus("RETURNED"); delivery.setUpdatedAt(now);
delivery.setVersion(Objects.requireNonNullElse(delivery.getVersion(), 0L) + 1);
deliveryOrders.updateById(delivery);
@@ -526,6 +560,30 @@ public class ExceptionOrderService {
operation.setCreatedAt(now); deliveryOperations.insert(operation);
}
private record ReturnContext(String deliveryStatus, Long tailId, String tailStatus, Boolean abnormal) { }
private void restoreCancelledReturn(ExceptionOrder order) {
if (!"RETURN".equals(order.getExceptionType()) || !StringUtils.hasText(order.getDeliveryNo())) return;
ReturnContext previous;
try { previous = objectMapper.readValue(order.getReturnContextJson(), ReturnContext.class); }
catch (Exception exception) { throw conflict("退回异常缺少原始状态,不能猜测恢复状态"); }
if (previous == null || !Set.of("DELIVERING", "DELIVERED", "REJECTED", "RETURN_PENDING").contains(previous.deliveryStatus()))
throw conflict("退回异常原始状态无效");
DeliveryOrder delivery = deliveryOrders.lockByNo(order.getCustomerId(), order.getDeliveryNo());
if (delivery == null || !"RETURN_PENDING".equals(delivery.getStatus())) throw conflict("关联派送状态已变化,不能取消退回");
if (returnItems.findByException(order.getCustomerId(), order.getId()).stream().anyMatch(item -> ReturnReceiptPolicy.received(item) > 0))
throw conflict("已有实际退回入仓,不能取消退回事实");
delivery.setStatus(previous.deliveryStatus()); delivery.setUpdatedAt(LocalDateTime.now());
delivery.setVersion(Objects.requireNonNullElse(delivery.getVersion(), 0L) + 1); deliveryOrders.updateById(delivery);
if (previous.tailId() != null) {
LastlegOrder tail = lastMileOrders.selectById(previous.tailId());
if (tail == null || !Objects.equals(tail.getCustomerId(), order.getCustomerId())
|| !"DELIVERY:RETURN_PENDING".equals(tail.getSubOrderStatus())) throw conflict("关联尾程状态已变化,不能覆盖");
tail.setSubOrderStatus(previous.tailStatus()); tail.setAbnormal(previous.abnormal());
tail.setUpdatedAt(LocalDateTime.now()); lastMileOrders.updateById(tail);
}
}
private List<String> allowedActions(ExceptionOrder order) {
return switch (Objects.requireNonNullElse(order.getStatus(), "")) {
case "PENDING" -> List.of("START_PROCESSING", "CANCEL_EXCEPTION");
@@ -0,0 +1,37 @@
package com.baiyue.lastleg.exceptionorder.service;
import com.baiyue.lastleg.exceptionorder.dto.ExceptionOrderRequests.ReturnReceipt;
import com.baiyue.lastleg.exceptionorder.entity.ExceptionReturnItem;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import org.springframework.http.HttpStatus;
import org.springframework.web.server.ResponseStatusException;
final class ReturnReceiptPolicy {
private ReturnReceiptPolicy() { }
static Map<Long, Long> quantities(List<ExceptionReturnItem> sources, List<ReturnReceipt> receipts) {
Map<Long, Long> amounts = new LinkedHashMap<>();
if (sources.isEmpty() || receipts == null || receipts.isEmpty()) throw conflict("请填写已冻结来源的退回实收明细");
for (ReturnReceipt receipt : receipts) {
ExceptionReturnItem source = sources.stream().filter(item -> Objects.equals(item.getOutboundPalletItemId(), receipt.outboundPalletItemId())
&& Objects.equals(item.getInventoryPositionId(), receipt.inventoryPositionId())).findFirst()
.orElseThrow(() -> conflict("退回实收明细不属于本异常冻结来源"));
if (receipt.receivedPackages() <= 0) throw conflict("退回实收箱数必须大于零");
amounts.merge(source.getId(), receipt.receivedPackages(), Math::addExact);
}
for (ExceptionReturnItem source : sources) {
long total = Math.addExact(received(source), amounts.getOrDefault(source.getId(), 0L));
if (total > source.getApprovedReturnPackages()) throw conflict("累计实收箱数超过批准退回数量");
}
return amounts;
}
static long received(ExceptionReturnItem item) { return Objects.requireNonNullElse(item.getReceivedPackages(), 0L); }
static boolean complete(List<ExceptionReturnItem> items) {
return !items.isEmpty() && items.stream().allMatch(item -> received(item) == item.getApprovedReturnPackages());
}
private static ResponseStatusException conflict(String message) { return new ResponseStatusException(HttpStatus.CONFLICT, message); }
}
@@ -4,11 +4,17 @@ 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 org.springframework.web.bind.annotation.PathVariable;
import java.util.List;
@FeignClient(name = "warehouse-service", contextId = "fleetWarehouseClient", url = "${baiyue.clients.domestic-warehouse.url:}")
public interface FleetWarehouseClient {
@GetMapping("/internal/warehouses/order-catalog")
ApiResponse<List<Warehouse>> activeWarehouses(@RequestHeader("X-Internal-Service-Token") String token);
@GetMapping("/internal/warehouses/0/{warehouseId}/active")
ApiResponse<Detail> activeDetail(@RequestHeader("X-Internal-Service-Token") String token, @PathVariable("warehouseId") long warehouseId);
record Warehouse(Long id, String warehouseCode, String warehouseName, String warehouseType, String countryCode) { }
record Identity(Long id, Long tenantId, String warehouseType, String status, String timezone) { }
record Address(Long warehouseId, Long tenantId, String city) { }
record Detail(Identity warehouse, List<Address> addresses) { }
}
@@ -28,7 +28,7 @@ public class FleetHandoverPdaQuery {
FleetDispatchGateway dispatches, FleetHandoverRepository handovers, ObjectMapper json) {
this.bindings = bindings; this.trips = trips; this.history = history; this.dispatches = dispatches; this.handovers = handovers; this.json = json;
}
public record Delivery(String deliveryNo, String status, int packages, Instant terminalAt, FleetTransitEvidence.Transport transport) { }
public record Delivery(String deliveryNo, String status, int packages, int remainingPackages, Instant terminalAt, FleetTransitEvidence.Transport transport) { }
public record Handover(String handoverNo, Instant handedOverAt, String location,
FleetTransitEvidence.Transport previous, FleetTransitEvidence.Transport replacement) { }
public record Status(String dispatchNo, String tripNo, String tripStatus, String tripRecordVersion,
@@ -53,6 +53,7 @@ public class FleetHandoverPdaQuery {
if (departed == null || !trip.getTripNo().equals(departed.tripNo()) || !Long.toString(warehouseId).equals(departed.warehouseId())) throw notFound();
var originalTransport = new FleetTransitEvidence.Transport(departed.vehiclePlate(), departed.driverName(), departed.driverPhone());
var rows = evidence.deliveries().stream().map(row -> new Delivery(row.cargo().deliveryNo(), row.status(), row.cargo().packages(),
row.cargo().terminalAt() != null ? 0 : row.cargo().packages() - row.cargo().returnedCargo().stream().mapToInt(FleetHandoverPolicy.ReturnedCargo::packages).sum(),
row.cargo().terminalAt(), row.transport())).toList();
var latest = handovers.latestForCargo(trip.getId(), json.writeValueAsString(Map.of("customerId", binding.getCustomerId(), "dispatchNo", dispatchNo)));
Handover handover = null;
@@ -12,7 +12,8 @@ public final class FleetHandoverPolicy {
String destinationKey, int packages) { }
/** terminalAt is a signed or RETURN_COMPLETED fact, not a mutable updatedAt. */
public record Delivery(String customerId, String dispatchNo, String deliveryNo,
String destinationKey, int packages, Instant terminalAt) { }
String destinationKey, int packages, Instant terminalAt, List<ReturnedCargo> returnedCargo) { }
public record ReturnedCargo(String itemId, String palletId, int packages, Instant receivedAt) { }
public record Pallet(String palletId, Load measuredLoad) { }
public record Inspection(List<Cargo> remainingCargo, List<Delivery> completedDeliveries,
Set<String> remainingPalletIds, Load occupied) { }
@@ -48,6 +49,7 @@ public final class FleetHandoverPolicy {
throw invalid("同一实托包含不同卸货目的地,请先核对原始装载");
}
Map<Destination, Delivery> byDestination = new HashMap<>();
Map<Item, Integer> returned = new HashMap<>();
Set<DeliveryKey> deliveryKeys = new HashSet<>();
List<Delivery> completed = new ArrayList<>();
for (Delivery delivery : deliveries) {
@@ -60,6 +62,16 @@ public final class FleetHandoverPolicy {
throw invalid("派送事实重复或目的地拆分尚未核平");
if (!Objects.equals(shipped.get(key), delivery.packages()))
throw invalid("派送客户、排车、目的地或箱数与原始发运不一致");
if (delivery.returnedCargo() == null) throw invalid("缺少实际退回数量依据");
for (ReturnedCargo receipt : delivery.returnedCargo()) {
Item item = new Item(delivery.customerId(), delivery.dispatchNo(), receipt.itemId());
Cargo source = departureCargo.stream().filter(cargo -> item.equals(new Item(cargo.customerId(), cargo.dispatchNo(), cargo.itemId())))
.findFirst().orElseThrow(() -> invalid("退回来源不属于原始发运明细"));
if (!destination(source).equals(key) || !Objects.equals(source.palletId(), receipt.palletId()) || receipt.packages() <= 0
|| receipt.receivedAt() == null || receipt.receivedAt().isBefore(departedAt) || receipt.receivedAt().isAfter(handedOverAt))
throw invalid("退回来源、数量或实际时间与交接不一致");
if (returned.merge(item, receipt.packages(), Math::addExact) > source.packages()) throw invalid("累计退回超过原发运明细数量");
}
if (delivery.terminalAt() != null) {
if (delivery.terminalAt().isBefore(departedAt) || delivery.terminalAt().isAfter(handedOverAt))
throw invalid("交接时间与已发生的签收或退回事实冲突,请刷新核对");
@@ -76,7 +88,10 @@ public final class FleetHandoverPolicy {
}
if (!measurements.keySet().equals(palletDestinations.keySet())) throw invalid("实测托盘与原始货物不一致");
List<Cargo> remaining = departureCargo.stream()
.filter(cargo -> byDestination.get(destination(cargo)).terminalAt() == null).toList();
.filter(cargo -> byDestination.get(destination(cargo)).terminalAt() == null)
.map(cargo -> new Cargo(cargo.customerId(), cargo.dispatchNo(), cargo.itemId(), cargo.palletId(), cargo.destinationKey(),
cargo.packages() - returned.getOrDefault(new Item(cargo.customerId(), cargo.dispatchNo(), cargo.itemId()), 0)))
.filter(cargo -> cargo.packages() > 0).toList();
if (remaining.isEmpty()) throw invalid("全部货物已完成,请按结案和资源结束流程处理");
Set<String> palletIds = new TreeSet<>();
remaining.forEach(cargo -> palletIds.add(cargo.palletId()));
@@ -0,0 +1,28 @@
package com.baiyue.lastleg.fleet.quote;
import jakarta.validation.constraints.*;
import java.math.BigDecimal;
import java.time.LocalDate;
public final class FleetQuoteCandidateModels {
private FleetQuoteCandidateModels() { }
/** The booking owner resolves this from its actual customer, route, currency and agreed service date. */
public record Context(@NotNull @Positive Long customerId,
@NotBlank @Size(max=128) String originCity, @NotBlank @Size(max=64) String destinationCode,
@NotBlank @Pattern(regexp="CNY|USD") String currency, @NotNull LocalDate effectiveOn,
@NotBlank @Pattern(regexp="ORDER|PALLET") String priceBasis) { }
public record Query(@NotNull @Positive Long customerId,
@NotBlank @Size(max=128) String originCity, @NotBlank @Size(max=64) String destinationCode,
@NotBlank @Pattern(regexp="CNY|USD") String currency, @NotNull LocalDate effectiveOn,
@NotBlank @Pattern(regexp="ORDER|PALLET") String priceBasis,
@Min(1) Integer page,@Min(1) @Max(100) Integer size) {
public Query { page=page==null?1:page;size=size==null?20:size; }
public Context context(){return new Context(customerId,originCity,destinationCode,currency,effectiveOn,priceBasis);}
}
public record QuoteRef(@NotBlank @Size(max=64) String quoteNo,@NotNull @Min(0) Long recordVersion,
@NotBlank @Pattern(regexp="FULL_DELIVERY|LOOSE_DELIVERY") String priceItem) { }
/** Quoted price is a source fact, distinct from the booking owner's negotiated price/quantity/amount. */
public record SourceSnapshot(String quoteNo,long recordVersion,String priceItem,String customerId,String customerName,
String originCity,String destinationCode,BigDecimal distanceMiles,String currency,String priceBasis,
BigDecimal quotedUnitPrice,LocalDate validFrom,LocalDate validTo,LocalDate effectiveOn) { }
}
@@ -0,0 +1,26 @@
package com.baiyue.lastleg.fleet.quote;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.jdbc.core.RowMapper;
import org.springframework.stereotype.Repository;
import java.util.List;
/** Read-only projection of the existing quote library; no second quote persistence model or writes. */
@Repository
public class FleetQuoteCandidateRepository {
record QuoteRow(String quoteNo,String partyName,String status,long version,String details) { }
private static final RowMapper<QuoteRow> ROW=(r,i)->new QuoteRow(r.getString("quote_no"),r.getString("party_name"),r.getString("status"),r.getLong("record_version"),r.getString("details_json"));
private final JdbcTemplate jdbc;
public FleetQuoteCandidateRepository(JdbcTemplate jdbc){this.jdbc=jdbc;}
List<QuoteRow> candidates(FleetQuoteCandidateModels.Context context) {
return jdbc.query("""
SELECT quote_no,party_name,status,record_version,details_json FROM ll_fleet_quote
WHERE kind='SALE' AND status='ACTIVE' AND party_id=? AND LOWER(TRIM(origin_city))=?
AND destination_code=? AND valid_from<=? AND valid_to>=?
ORDER BY valid_from DESC,quote_no DESC LIMIT 5001
""",ROW,context.customerId(),context.originCity(),context.destinationCode(),context.effectiveOn(),context.effectiveOn());
}
QuoteRow lock(String quoteNo) {
return jdbc.query("SELECT quote_no,party_name,status,record_version,details_json FROM ll_fleet_quote WHERE quote_no=? FOR UPDATE",ROW,quoteNo).stream().findFirst().orElse(null);
}
}
@@ -0,0 +1,70 @@
package com.baiyue.lastleg.fleet.quote;
import com.baiyue.common.core.api.PageResult;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.*;
import org.springframework.web.server.ResponseStatusException;
import java.math.BigDecimal;
import java.util.*;
@Service
public class FleetQuoteCandidateService {
private final FleetQuoteCandidateRepository repository;
private final FleetQuoteDirectory directory;
private final ObjectMapper json;
public FleetQuoteCandidateService(FleetQuoteCandidateRepository repository,FleetQuoteDirectory directory,ObjectMapper json){this.repository=repository;this.directory=directory;this.json=json;}
@Transactional(readOnly=true,isolation=Isolation.REPEATABLE_READ)
public PageResult<FleetQuoteCandidateModels.SourceSnapshot> candidates(FleetQuoteCandidateModels.Query query) {
var context=normalize(query.context());directory.name("SALE",context.customerId());
var rows=repository.candidates(context);
if(rows.size()>5000)throw error(HttpStatus.BAD_REQUEST,"当前路线有效报价过多,请先整理报价库");
var candidates=new ArrayList<FleetQuoteCandidateModels.SourceSnapshot>();
for(var row:rows) {
var draft=read(row.details());
if(matches(row,draft,context) && price(draft,context.priceBasis())!=null)candidates.add(snapshot(row,draft,context));
}
long total=candidates.size();int start=(int)Math.min(total,((long)query.page()-1)*query.size());
return new PageResult<>(List.copyOf(candidates.subList(start,Math.min(candidates.size(),start+query.size()))),query.page(),query.size(),total,(total+query.size()-1)/query.size());
}
/** Called only inside the booking owner's write transaction, after its permission and scope checks.
* The quote row lock lasts until that transaction commits; caller persists this returned snapshot.
* No public write endpoint, no booking writes and no negotiated-price calculation live here.
*/
@Transactional(propagation=Propagation.MANDATORY)
public FleetQuoteCandidateModels.SourceSnapshot lockAndResolve(FleetQuoteCandidateModels.Context requested,FleetQuoteCandidateModels.QuoteRef reference) {
var context=normalize(requested);
if(reference==null || reference.quoteNo()==null || reference.quoteNo().isBlank() || reference.recordVersion()==null || reference.recordVersion()<0
|| !item(context.priceBasis()).equals(reference.priceItem()))throw error(HttpStatus.BAD_REQUEST,"报价引用或计价项目无效");
var row=repository.lock(reference.quoteNo());
if(row==null)throw error(HttpStatus.NOT_FOUND,"报价不存在");
var draft=read(row.details());
if(row.version()!=reference.recordVersion() || !matches(row,draft,context))throw error(HttpStatus.CONFLICT,"报价版本、客户、路线、币种或有效期已不匹配,请重新选择");
if(price(draft,context.priceBasis())==null)throw error(HttpStatus.CONFLICT,"所选项目尚未报价,不能作为零价使用");
directory.name("SALE",context.customerId());
return snapshot(row,draft,context);
}
private static FleetQuoteCandidateModels.Context normalize(FleetQuoteCandidateModels.Context context) {
if(context==null || context.customerId()==null || context.customerId()<=0 || context.originCity()==null || context.originCity().isBlank()
|| context.destinationCode()==null || context.destinationCode().isBlank() || context.effectiveOn()==null
|| !Set.of("USD","CNY").contains(Objects.toString(context.currency(),"")) || !Set.of("ORDER","PALLET").contains(Objects.toString(context.priceBasis(),"")))
throw error(HttpStatus.BAD_REQUEST,"请选择明确客户、路线、币种、服务日期及计价单位");
return new FleetQuoteCandidateModels.Context(context.customerId(),context.originCity().trim().toLowerCase(Locale.ROOT),context.destinationCode().trim().toUpperCase(Locale.ROOT),context.currency(),context.effectiveOn(),context.priceBasis());
}
private static boolean matches(FleetQuoteCandidateRepository.QuoteRow row,FleetQuoteModels.Draft d,FleetQuoteCandidateModels.Context c) {
return "ACTIVE".equals(row.status()) && "SALE".equals(d.kind()) && c.customerId().equals(d.partyId())
&& c.originCity().equals(d.originCity().trim().toLowerCase(Locale.ROOT)) && c.destinationCode().equals(d.destinationCode().trim().toUpperCase(Locale.ROOT))
&& c.currency().equals(d.currency()) && !c.effectiveOn().isBefore(d.validFrom()) && !c.effectiveOn().isAfter(d.validTo());
}
private static String item(String basis){return "PALLET".equals(basis)?"LOOSE_DELIVERY":"FULL_DELIVERY";}
private static BigDecimal price(FleetQuoteModels.Draft d,String basis){return "PALLET".equals(basis)?d.looseDeliveryPrice():d.fullDeliveryPrice();}
private static FleetQuoteCandidateModels.SourceSnapshot snapshot(FleetQuoteCandidateRepository.QuoteRow row,FleetQuoteModels.Draft d,FleetQuoteCandidateModels.Context c) {
return new FleetQuoteCandidateModels.SourceSnapshot(row.quoteNo(),row.version(),item(c.priceBasis()),d.partyId().toString(),row.partyName(),d.originCity(),d.destinationCode(),d.distanceMiles(),d.currency(),c.priceBasis(),price(d,c.priceBasis()),d.validFrom(),d.validTo(),c.effectiveOn());
}
private FleetQuoteModels.Draft read(String value){try{return json.readValue(value,FleetQuoteModels.Draft.class);}catch(JsonProcessingException e){throw new IllegalStateException("报价来源快照无法读取",e);}}
private static ResponseStatusException error(HttpStatus status,String message){return new ResponseStatusException(status,message);}
}
@@ -0,0 +1,16 @@
package com.baiyue.lastleg.fleet.quote;
import com.baiyue.common.core.api.*;
import jakarta.validation.Valid;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.*;
@RestController @Validated
@RequestMapping("/api/platform/v1/last-leg/fleet/quotes/sale-candidates")
public class PlatformFleetQuoteCandidateController {
private final FleetQuoteCandidateService service;
public PlatformFleetQuoteCandidateController(FleetQuoteCandidateService service){this.service=service;}
@GetMapping @PreAuthorize("hasAuthority('platform:fleet:quote:list')")
public ApiResponse<PageResult<FleetQuoteCandidateModels.SourceSnapshot>> candidates(@Valid FleetQuoteCandidateModels.Query query){return ApiResponse.success(service.candidates(query));}
}
@@ -16,6 +16,7 @@ public class FleetBookingEntity {
private String customerName;
private String status;
private String cargoJson;
@TableField(updateStrategy=FieldStrategy.ALWAYS) private String quoteSourceJson;
private String destinationKey;
private String destinationLabel;
@TableField(updateStrategy=FieldStrategy.ALWAYS) private BigDecimal agreedAmount;
@@ -1,5 +1,6 @@
package com.baiyue.lastleg.fleet.trip;
import com.baiyue.lastleg.fleet.quote.FleetQuoteCandidateModels;
import jakarta.validation.Valid;
import jakarta.validation.constraints.*;
import java.math.BigDecimal;
@@ -14,8 +15,13 @@ public final class FleetBookingModels {
@Pattern(regexp="PALLET|ORDER") String priceBasis, @DecimalMin("0.00") @Digits(integer=10, fraction=2) BigDecimal unitPrice,
@Size(max=3) String currency, @Size(max=128) String contactName, @Size(max=128) String contactPhone, @Size(max=1000) String remark) { }
public record Create(@NotNull @Min(0) Long tripRecordVersion, @NotNull @Positive Long customerId,
@NotNull @Valid Cargo cargo, @NotBlank @Size(min=8,max=128) String idempotencyKey) { }
public record Update(@NotNull @Min(0) Long tripRecordVersion, @NotNull @Min(0) Long recordVersion, @NotNull @Valid Cargo cargo) { }
@NotNull @Valid Cargo cargo, @NotBlank @Size(min=8,max=128) String idempotencyKey, @Valid FleetQuoteCandidateModels.QuoteRef quoteRef) { }
public record Update(@NotNull @Min(0) Long tripRecordVersion, @NotNull @Min(0) Long recordVersion, @NotNull @Valid Cargo cargo, @Valid FleetQuoteCandidateModels.QuoteRef quoteRef) { }
public record QuoteCandidatesQuery(@NotNull @Positive Long customerId,@NotBlank @Size(max=160) String destinationKey,
@NotBlank @Pattern(regexp="CNY|USD") String currency,@NotBlank @Pattern(regexp="PALLET|ORDER") String priceBasis,
@Min(1) Integer page,@Min(1) @Max(100) Integer size) {
public QuoteCandidatesQuery {page=page==null?1:page;size=size==null?20:size;}
}
public record Command(@NotNull @Min(0) Long tripRecordVersion, @NotNull @Min(0) Long recordVersion,
@NotBlank @Size(min=8,max=128) String idempotencyKey, @Size(max=1000) String reason) { }
public record StartIncoming(@NotNull @Min(0) Long tripRecordVersion,@NotNull @Min(0) Long recordVersion,
@@ -32,7 +38,8 @@ public final class FleetBookingModels {
public record IncomingPreview(long tripRecordVersion,long recordVersion,FleetBookingIncomingGateway.Evidence incoming) { }
public record AcceptedReceipt(FleetBookingIncomingGateway.Evidence source,List<PalletSpaces> pallets,String operatorId,Instant confirmedAt) { }
public record Response(String bookingNo, String tripNo, String customerId, String customerCode, String customerName,
String status, Cargo cargo, String destinationLabel, BigDecimal agreedAmount, long recordVersion, Instant updatedAt, String incomingNo, String dispatchNo, AcceptedReceipt receipt) { }
String status, Cargo cargo, String destinationLabel, BigDecimal agreedAmount, long recordVersion, Instant updatedAt, String incomingNo, String dispatchNo, AcceptedReceipt receipt,
FleetQuoteCandidateModels.SourceSnapshot quoteSource) { }
public record Result(long tripRecordVersion, Response booking, boolean replayed) { }
public record ListResult(long tripRecordVersion, List<Response> records) { }
}
@@ -0,0 +1,62 @@
package com.baiyue.lastleg.fleet.trip;
import com.baiyue.lastleg.fleet.driver.FleetWarehouseClient;
import com.baiyue.lastleg.fleet.quote.FleetQuoteCandidateModels;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Component;
import org.springframework.web.server.ResponseStatusException;
import java.time.DateTimeException;
import java.time.ZoneId;
import java.time.ZoneOffset;
import java.util.Locale;
import java.util.Set;
import java.util.TreeSet;
/** Resolve quote eligibility from the trip and authoritative warehouse facts, never browser hints. */
@Component
public class FleetBookingQuoteContext {
private final FleetWarehouseClient warehouses;
private final FleetBookingReservations reservations;
private final String token;
public FleetBookingQuoteContext(FleetWarehouseClient warehouses, FleetBookingReservations reservations,
@Value("${baiyue.security.internal-service-token:}") String token) {
this.warehouses = warehouses; this.reservations = reservations; this.token = token;
}
public FleetQuoteCandidateModels.Context resolve(FleetTripEntity trip, long customerId,
String destinationKey, String currency, String priceBasis) {
if (customerId <= 0 || trip.getWarehouseId() == null || trip.getPlannedDepartureAt() == null
|| FleetBookingReservations.index(reservations.stops(trip), destinationKey) < 1)
throw conflict("请选择本车已有卸货站和委托客户");
if (destinationKey == null || !destinationKey.startsWith("FBA:") || destinationKey.length() <= 4)
throw conflict("该卸货站没有报价库目的仓码,请手填本次成交价");
if (!Set.of("CNY", "USD").contains(currency == null ? "" : currency)
|| !Set.of("PALLET", "ORDER").contains(priceBasis == null ? "" : priceBasis))
throw conflict("请选择报价币种及计价方式");
FleetWarehouseClient.Detail detail;
try {
var response = warehouses.activeDetail(token, trip.getWarehouseId());
if (response == null || !response.successful() || response.data() == null) throw unavailable();
detail = response.data();
} catch (ResponseStatusException e) { throw e; }
catch (RuntimeException e) { throw unavailable(); }
var warehouse = detail.warehouse();
if (warehouse == null || !trip.getWarehouseId().equals(warehouse.id()) || !Long.valueOf(0).equals(warehouse.tenantId())
|| !"ACTIVE".equals(warehouse.status()) || !Set.of("OVERSEAS", "OVERSEAS_PARTNER").contains(warehouse.warehouseType() == null ? "" : warehouse.warehouseType()))
throw conflict("起运仓资料已变化,请重新核对");
var cities = new TreeSet<String>();
if (detail.addresses() != null) for (var address : detail.addresses()) {
if (address != null && trip.getWarehouseId().equals(address.warehouseId()) && Long.valueOf(0).equals(address.tenantId())
&& address.city() != null && !address.city().isBlank()) cities.add(address.city().trim().toLowerCase(Locale.ROOT));
}
if (cities.size() != 1) throw conflict("起运仓城市尚未明确,请完善仓库资料或手填本次成交价");
ZoneId zone;
try { zone = ZoneId.of(warehouse.timezone() == null ? "" : warehouse.timezone().trim()); }
catch (DateTimeException e) { throw conflict("起运仓时区尚未明确,请完善仓库资料或手填本次成交价"); }
var date = trip.getPlannedDepartureAt().toInstant(ZoneOffset.UTC).atZone(zone).toLocalDate();
return new FleetQuoteCandidateModels.Context(customerId, cities.first(), destinationKey.substring(4), currency, date, priceBasis);
}
private static ResponseStatusException conflict(String message) { return new ResponseStatusException(HttpStatus.CONFLICT, message); }
private static ResponseStatusException unavailable() { return new ResponseStatusException(HttpStatus.SERVICE_UNAVAILABLE, "起运仓资料暂不可用,请重试"); }
}
@@ -1,6 +1,7 @@
package com.baiyue.lastleg.fleet.trip;
import com.baiyue.lastleg.fleet.domain.TripCapacityPolicy;
import com.baiyue.lastleg.fleet.quote.FleetQuoteCandidateModels;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
@@ -21,8 +22,9 @@ public class FleetBookingReservations {
public FleetBookingModels.Cargo cargo(FleetBookingEntity row) { return read(row.getCargoJson(), FleetBookingModels.Cargo.class); }
public FleetBookingModels.Response response(FleetBookingEntity row) {
return new FleetBookingModels.Response(row.getBookingNo(),row.getTripNo(),row.getCustomerId(),row.getCustomerCode(),row.getCustomerName(),
row.getStatus(),cargo(row),row.getDestinationLabel(),row.getAgreedAmount(),row.getRecordVersion(),row.getUpdatedAt().toInstant(ZoneOffset.UTC),row.getIncomingNo(),row.getDispatchNo(),receipt(row));
row.getStatus(),cargo(row),row.getDestinationLabel(),row.getAgreedAmount(),row.getRecordVersion(),row.getUpdatedAt().toInstant(ZoneOffset.UTC),row.getIncomingNo(),row.getDispatchNo(),receipt(row),quoteSource(row));
}
public FleetQuoteCandidateModels.SourceSnapshot quoteSource(FleetBookingEntity row){return row.getQuoteSourceJson()==null?null:read(row.getQuoteSourceJson(),FleetQuoteCandidateModels.SourceSnapshot.class);}
public List<TripCapacityPolicy.Segment> segments(FleetTripEntity trip, FleetExecutionModels.PlannedLoad own,
Long excludedId, FleetBookingEntity addition) {
var stops=stops(trip); var loads=new ArrayList<TripCapacityPolicy.Booking>();
@@ -2,6 +2,10 @@ package com.baiyue.lastleg.fleet.trip;
import com.baomidou.mybatisplus.core.toolkit.IdWorker;
import com.baiyue.lastleg.fleet.customer.FleetCustomerDirectory;
import com.baiyue.common.core.api.PageResult;
import com.baiyue.lastleg.fleet.quote.FleetQuoteCandidateModels;
import com.baiyue.lastleg.fleet.quote.FleetQuoteCandidateService;
import org.springframework.security.core.context.SecurityContextHolder;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.node.ObjectNode;
import org.springframework.http.HttpStatus;
@@ -27,12 +31,21 @@ public class FleetBookingService {
private final FleetCustomerDirectory customers;
private final ObjectMapper json;
private final FleetBookingIncomingGateway incoming;
private final FleetBookingQuoteContext quoteContext;
private final FleetQuoteCandidateService quotes;
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){
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;
}
@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()));}
@Transactional(readOnly=true,isolation=org.springframework.transaction.annotation.Isolation.REPEATABLE_READ)
public PageResult<FleetQuoteCandidateModels.SourceSnapshot> quoteCandidates(String tripNo,FleetBookingModels.QuoteCandidatesQuery query){
var context=quoteContext.resolve(trip(tripNo,false),query.customerId(),query.destinationKey(),query.currency(),query.priceBasis());
return quotes.candidates(new FleetQuoteCandidateModels.Query(context.customerId(),context.originCity(),context.destinationCode(),context.currency(),context.effectiveOn(),context.priceBasis(),query.page(),query.size()));
}
@Transactional
public FleetBookingModels.Result create(String tripNo,String operator,FleetBookingModels.Create request){
var trip=trip(tripNo,true);String hash=hash(request);
@@ -41,7 +54,7 @@ public class FleetBookingService {
requireTrip(trip,request.tripRecordVersion());var customer=customers.required(request.customerId());
var row=new FleetBookingEntity();row.setId(IdWorker.getId());row.setBookingNo("FB-"+row.getId());row.setTripId(trip.getId());row.setTripNo(tripNo);
row.setCustomerId(customer.id());row.setCustomerCode(customer.code());row.setCustomerName(customer.name());row.setStatus("DRAFT");
apply(trip,row,request.cargo());row.setIdempotencyKey(request.idempotencyKey());row.setRequestHash(hash);row.setRecordVersion(0L);
apply(trip,row,request.cargo());applyQuote(trip,row,request.quoteRef());row.setIdempotencyKey(request.idempotencyKey());row.setRequestHash(hash);row.setRecordVersion(0L);
row.setCreatedBy(operator);row.setCreatedAt(now());row.setUpdatedBy(operator);row.setUpdatedAt(row.getCreatedAt());
bookings.insert(row);history.recordExecution(trip,request.tripRecordVersion(),"BOOKING_CREATE","登记外接询价",operator);
return result(trip,row,false);
@@ -50,7 +63,7 @@ public class FleetBookingService {
public FleetBookingModels.Result update(String tripNo,String bookingNo,String operator,FleetBookingModels.Update request){
var trip=trip(tripNo,true);requireTrip(trip,request.tripRecordVersion());var row=booking(trip,bookingNo);requireVersion(row,request.recordVersion());
if(!"DRAFT".equals(row.getStatus()))throw conflict("已确认承运的货物请先取消预订后重新登记,原记录保留");
apply(trip,row,request.cargo());save(trip,row,operator,"BOOKING_EDIT","更新外接询价");return result(trip,row,false);
apply(trip,row,request.cargo());applyQuote(trip,row,request.quoteRef());save(trip,row,operator,"BOOKING_EDIT","更新外接询价");return result(trip,row,false);
}
@Transactional
public FleetBookingModels.Result confirm(String tripNo,String bookingNo,String operator,FleetBookingModels.Command request){
@@ -61,6 +74,8 @@ public class FleetBookingService {
customers.required(Long.parseLong(row.getCustomerId()));var cargo=reservations.cargo(row);
if(row.getAgreedAmount()==null||cargo.currency()==null)throw conflict("确认承运前请填写约定单价和币种");
if(!java.util.Set.of("CNY","USD").contains(cargo.currency()))throw conflict("首版运费入账支持CNY或USD,请调整约定币种");
var source=reservations.quoteSource(row);
if(source!=null)row.setQuoteSourceJson(write(resolveQuote(trip,row,new FleetQuoteCandidateModels.QuoteRef(source.quoteNo(),source.recordVersion(),source.priceItem()))));
var plan=execution.capacityPlan(tripNo);
if(!plan.planValid()||plan.blockReason()!=null)throw conflict("请先核对本车自有货计划容量,再确认外接承运");
reservations.segments(trip,plan.capacityPlan(),null,row);
@@ -141,6 +156,19 @@ public class FleetBookingService {
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("来货单引用不一致");
}
private FleetQuoteCandidateModels.SourceSnapshot resolveQuote(FleetTripEntity trip,FleetBookingEntity row,FleetQuoteCandidateModels.QuoteRef reference){
var cargo=reservations.cargo(row);
return quotes.lockAndResolve(quoteContext.resolve(trip,Long.parseLong(row.getCustomerId()),cargo.destinationKey(),cargo.currency(),cargo.priceBasis()),reference);
}
private void applyQuote(FleetTripEntity trip,FleetBookingEntity row,FleetQuoteCandidateModels.QuoteRef reference){
if(reference!=null || row.getQuoteSourceJson()!=null)requireQuotePermission();
row.setQuoteSourceJson(reference==null?null:write(resolveQuote(trip,row,reference)));
}
private static void requireQuotePermission(){
var authentication=SecurityContextHolder.getContext().getAuthentication();
if(authentication==null || authentication.getAuthorities().stream().noneMatch(a->"platform:fleet:quote:list".equals(a.getAuthority())))
throw new ResponseStatusException(HttpStatus.FORBIDDEN,"引用或清除报价来源需要报价读取权限");
}
private void apply(FleetTripEntity trip,FleetBookingEntity row,FleetBookingModels.Cargo input){
var stops=reservations.stops(trip);int index=FleetBookingReservations.index(stops,input.destinationKey());
if(index<1)throw conflict("请选择本车已有卸货站,外接货首版从本起运仓装车");
@@ -7,7 +7,7 @@ import java.util.List;
public final class FleetCompletionModels {
private FleetCompletionModels() { }
public record Outcome(String dispatchNo, String deliveryNo, String customerId, String destination,
String status, int packages, Instant terminalAt) { }
String status, int packages, Instant terminalAt, int signedPackages, int returnedPackages, int remainingPackages) { }
public record DispatchCompletion(String dispatchNo, String warehouseId, List<Outcome> outcomes, String blockReason) { }
public record Inspection(String tripNo, long recordVersion, boolean canComplete, String blockReason, List<Outcome> outcomes) { }
public record Complete(@NotNull @Min(0) Long recordVersion, @NotBlank @Size(min = 8, max = 128) String idempotencyKey,
@@ -117,7 +117,9 @@ public class FleetTripCompletionService {
if (completion.blockReason() != null) { reason = completion.dispatchNo() + ":" + completion.blockReason(); break; }
}
Instant departure = trip.getDepartedAt().toInstant(ZoneOffset.UTC), now = Instant.now();
if (reason == null && outcomes.stream().anyMatch(outcome -> outcome.terminalAt() == null
if (reason == null && outcomes.stream().anyMatch(outcome -> outcome.packages() <= 0 || outcome.remainingPackages() != 0
|| outcome.signedPackages() < 0 || outcome.returnedPackages() < 0
|| (long) outcome.signedPackages() + outcome.returnedPackages() != outcome.packages() || outcome.terminalAt() == null
|| outcome.terminalAt().isBefore(departure) || outcome.terminalAt().isAfter(now))) reason = "签收或退回时间须在发车之后且不晚于当前时间,请核对原单";
}
return new FleetCompletionModels.Inspection(trip.getTripNo(), trip.getRecordVersion(), reason == null, reason, outcomes);
@@ -1,6 +1,8 @@
package com.baiyue.lastleg.fleet.trip;
import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.common.core.api.PageResult;
import com.baiyue.lastleg.fleet.quote.FleetQuoteCandidateModels;
import com.baiyue.common.security.AuthenticatedUser;
import com.baiyue.lastleg.fleet.customer.FleetCustomerDirectory;
import jakarta.validation.Valid;
@@ -20,6 +22,8 @@ public class PlatformFleetBookingController {
public ApiResponse<List<FleetCustomerDirectory.Customer>> customers(@RequestParam(required=false) String keyword){return ApiResponse.success(customers.options(keyword));}
@GetMapping("/{tripNo}/bookings") @PreAuthorize("hasAuthority('platform:fleet:trip:list')")
public ApiResponse<FleetBookingModels.ListResult> list(@PathVariable String tripNo){return ApiResponse.success(service.list(tripNo));}
@GetMapping("/{tripNo}/bookings/quote-candidates") @PreAuthorize("hasAuthority('platform:fleet:trip:list') and hasAuthority('platform:fleet:quote:list')")
public ApiResponse<PageResult<FleetQuoteCandidateModels.SourceSnapshot>> quoteCandidates(@PathVariable String tripNo,@Valid FleetBookingModels.QuoteCandidatesQuery query){return ApiResponse.success(service.quoteCandidates(tripNo,query));}
@PostMapping("/{tripNo}/bookings") @PreAuthorize("hasAuthority('platform:fleet:trip:booking-create')")
public ApiResponse<FleetBookingModels.Result> create(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String tripNo,@Valid @RequestBody FleetBookingModels.Create request){return ApiResponse.success(service.create(tripNo,operator(user),request));}
@PutMapping("/{tripNo}/bookings/{bookingNo}") @PreAuthorize("hasAuthority('platform:fleet:trip:booking-edit')")
@@ -311,6 +311,9 @@ CREATE TABLE IF NOT EXISTS ll_delivery_order (
version BIGINT NOT NULL DEFAULT 0 COMMENT '派送单乐观锁版本',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间',
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间',
signed_packages INT NULL COMMENT '保存签收资料时冻结的实际签收箱数,不含此前已退回量',
CONSTRAINT chk_delivery_signed_packages CHECK(signed_packages IS NULL OR (signed_packages>=0 AND signed_packages<=package_count)),
PRIMARY KEY (id),
UNIQUE KEY uk_ll_delivery_no (customer_id,delivery_no),
UNIQUE KEY uk_ll_delivery_idempotency (customer_id,idempotency_key),
@@ -1138,6 +1141,7 @@ CREATE TABLE ll_fleet_external_booking (
customer_name VARCHAR(255) NOT NULL COMMENT '客户名称快照',
status VARCHAR(16) NOT NULL COMMENT 'DRAFT询价/CONFIRMED占位/RECEIVED确认实收/BOUND关联发运/CANCELLED取消',
cargo_json JSON NOT NULL COMMENT '货物、计划载荷、联系人及约定报价快照',
quote_source_json JSON NULL COMMENT '选用销售报价的可信版本快照,与实际成交金额分开保存',
destination_key VARCHAR(160) NOT NULL COMMENT '已有路线卸货站匹配键',
destination_label VARCHAR(255) NOT NULL COMMENT '约定卸货站名称',
agreed_amount DECIMAL(16,2) NULL COMMENT '约定金额,非已入账应收或实收',
@@ -2636,8 +2640,10 @@ CREATE TABLE IF NOT EXISTS ll_exception_order (
exception_at DATETIME(3) NOT NULL COMMENT '异常发生时间', completed_at DATETIME(3) NULL COMMENT '异常处理完成时间',
version BIGINT NOT NULL DEFAULT 0 COMMENT '异常订单乐观锁版本', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间',
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间',
return_context_json JSON NULL COMMENT '退回异常创建前的派送及尾程状态快照',
PRIMARY KEY(id), UNIQUE KEY uk_ll_exception_no(customer_id,exception_no),
UNIQUE KEY uk_ll_exception_delivery_return(customer_id,delivery_order_id,exception_type),
KEY idx_ll_exception_delivery_return(customer_id,delivery_order_id,exception_type),
KEY idx_ll_exception_list(customer_id,status,exception_at), KEY idx_ll_exception_tail(customer_id,last_mile_order_no),
KEY idx_ll_exception_inbound(customer_id,inbound_code)
) ENGINE=InnoDB COMMENT='海外仓异常订单';
@@ -2648,6 +2654,9 @@ CREATE TABLE IF NOT EXISTS ll_exception_return_item (
inventory_position_id BIGINT NOT NULL COMMENT '原出库库存位置ID', pallet_no VARCHAR(128) NULL COMMENT '原出库托盘码快照',
sku_code VARCHAR(128) NULL COMMENT '原出库SKU快照', max_return_packages BIGINT NOT NULL COMMENT '原出库可退箱数上限',
approved_return_packages BIGINT NOT NULL COMMENT '批准退回箱数', created_at DATETIME(3) NOT NULL COMMENT '创建时间',
received_packages BIGINT NOT NULL DEFAULT 0 COMMENT '已实际退回入仓的累计箱数',
CONSTRAINT chk_exception_return_received CHECK(received_packages>=0 AND received_packages<=approved_return_packages),
PRIMARY KEY(id), UNIQUE KEY uk_ll_exception_return_source(customer_id,exception_order_id,outbound_pallet_item_id,inventory_position_id),
KEY idx_ll_exception_return_order(customer_id,exception_order_id)
) ENGINE=InnoDB COMMENT='异常退回原出库库存冻结明细';
@@ -6,6 +6,7 @@ info:
平台资料入口的customerId必须为真实派送所属客户;客户只读入口范围只取Token,只公开当前派送单已保存的RECEIPT签收文件,不提供上传、修改、历史版本、BOL或装车照片下载。
GET /delivery-orders 及 GET /delivery-orders/{deliveryNo} 的 order 行新增 bolNo 和 documentSummary,version/allowedActions 保持原契约。
RECEIPT 签收文件只有在关联出库单已OUTBOUNDED、PUT保存进入权威资料快照后才自动完成当前派送单;仅上传未保存不会完成。发车前可保存BOL及装车资料,不能通过提前保存签收文件绕过实际出库。allowedActions 不再返回人工 COMPLETE_DELIVERY。
首次签收冻结signedPackages为原实发箱数减去此前实际退回入账箱数;退回异常仍在处理时不得签收余货。后续资料修改或退货不改写原signedAt和signedPackages。部分退回后的余货签收保留国内回写待处理操作记录,不发送会把整票入仓单标记已签收的OVERSEAS_DELIVERY_COMPLETED事件;该跨服务分支尚未闭环。
START_DELIVERY=DELIVERING(派送中);COMPLETE_DELIVERY=DELIVERED(完成并记录签收时间、生成待回访)。同状态重放不重复副作用。
资料不设必传门禁,不以三类齐全作为派送完成条件。业务失败使用统一 ApiResponse(A0422参数、A0409冲突),通常HTTP200。
security: [{ bearerAuth: [] }]
@@ -37,6 +37,7 @@ paths:
deliveryNo: { type: string }
status: { type: string }
packages: { type: integer }
remainingPackages: { type: integer, minimum: 0, description: 原运输尚未签收且尚未实际退回的箱数 }
terminalAt: { type: string, format: date-time, nullable: true }
transport: { $ref: '#/components/schemas/Transport' }
latestHandover:
@@ -222,6 +223,16 @@ components:
destinationKey: { type: string }
packages: { type: integer }
terminalAt: { type: string, format: date-time, nullable: true }
returnedCargo:
type: array
description: 由实际退回入账流水映射到原发运明细;部分退回按此扣减箱数,未清空实托保留原测量载荷。
items:
type: object
properties:
itemId: { type: string }
palletId: { type: string }
packages: { type: integer, minimum: 1 }
receivedAt: { type: string, format: date-time }
Inspection:
type: object
properties:
@@ -0,0 +1,82 @@
openapi: 3.0.3
info:
title: 外接销售报价候选与来源快照
version: 1.0.0
description: >-
候选只读取有效销售报价,不创建承运、库存或费用。客户、起运城市、目的仓码、币种、计价单位和日期明确匹配;
空价排除,零价保留,重叠有效期不自动选价。PALLET展示LOOSE_DELIVERY,ORDER展示FULL_DELIVERY价项;
返回整车派送价项不表示已作为拼载成交价采用,必须明确选择报价号、版本及价项。
日期由调用方根据真实车次计划起运时间和明确业务时区产生,不猜测机器或浏览器时区。
security:
- bearerAuth: []
paths:
/api/platform/v1/last-leg/fleet/quotes/sale-candidates:
get:
description: platform:fleet:quote:list;同路线原始候选超过5000条明确拒绝,不能截断后声称无报价。
parameters:
- { name: customerId, in: query, required: true, schema: { type: integer, format: int64, minimum: 1 } }
- { name: originCity, in: query, required: true, schema: { type: string, minLength: 1, maxLength: 128 } }
- { name: destinationCode, in: query, required: true, schema: { type: string, minLength: 1, maxLength: 64 } }
- { name: currency, in: query, required: true, schema: { type: string, enum: [CNY, USD] } }
- { name: effectiveOn, in: query, required: true, schema: { type: string, format: date } }
- { name: priceBasis, in: query, required: true, schema: { type: string, enum: [ORDER, PALLET] } }
- { 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: 成功业务码为200;失败保留业务码及requestId/traceId,不以空页冒充目录可用。
content:
application/json:
schema:
type: object
properties:
code: { type: string }
message: { type: string }
requestId: { type: string }
traceId: { type: string }
data:
type: object
required: [records, page, size, total, pages]
properties:
records: { type: array, items: { $ref: '#/components/schemas/SourceSnapshot' } }
page: { type: integer, minimum: 1 }
size: { type: integer, minimum: 1 }
total: { type: integer, minimum: 0 }
pages: { type: integer, minimum: 0 }
components:
securitySchemes:
bearerAuth: { type: http, scheme: bearer, bearerFormat: JWT }
schemas:
QuoteRef:
type: object
required: [quoteNo, recordVersion, priceItem]
properties:
quoteNo: { type: string, minLength: 1, maxLength: 64 }
recordVersion: { type: integer, format: int64, minimum: 0 }
priceItem: { type: string, enum: [FULL_DELIVERY, LOOSE_DELIVERY] }
SourceSnapshot:
type: object
required: [quoteNo, recordVersion, priceItem, customerId, customerName, originCity, destinationCode, distanceMiles, currency, priceBasis, quotedUnitPrice, validFrom, validTo, effectiveOn]
properties:
quoteNo: { type: string }
recordVersion: { type: integer, format: int64, minimum: 0 }
priceItem: { type: string, enum: [FULL_DELIVERY, LOOSE_DELIVERY] }
customerId: { type: string }
customerName: { type: string, description: 报价版本中的客户名称快照 }
originCity: { type: string }
destinationCode: { type: string }
distanceMiles: { type: number, minimum: 0 }
currency: { type: string, enum: [CNY, USD] }
priceBasis: { type: string, enum: [ORDER, PALLET] }
quotedUnitPrice: { type: number, minimum: 0, description: 正式报价单价;不是承运方最终成交单价或实际应收 }
validFrom: { type: string, format: date }
validTo: { type: string, format: date, description: 包含当天 }
effectiveOn: { type: string, format: date }
x-booking-integration:
method: FleetQuoteCandidateService.lockAndResolve(Context requested, QuoteRef reference)
transaction: MANDATORY;由承运写事务调用。SELECT FOR UPDATE锁住原报价直到外层提交,禁止事务外解析后再单独保存。
context: customerId/originCity/destinationCode/currency/effectiveOn/priceBasis;必须由承运服务按客户、真实路线、计划起运时间和明确业务时区核验,不能原样信任浏览器条件。
validation: 重新核对SALE、ACTIVE、精确版本、客户、路线、币种、日期及明确选择的价项。未知价拒绝,零价允许;采购、自提、停用或陈旧报价不能引用。
snapshot: 保存返回的SourceSnapshot,禁止使用浏览器提交的来源快照。来源单价与承运实际成交单价、计价量、金额分别保存,后续报价改价不覆盖历史。
manual: 未引用时不调用解析方法,保留手填成交价能力;引用报价不自动生成应收、成本或付款。
ownership: 提供端不修改FleetBooking、共享UI或SQL;接入方负责写权限、幂等重放优先返回原结果和实际成交快照持久化。
@@ -65,6 +65,37 @@ paths:
description: '权限:platform:fleet:trip:list;读取IAM当前客户主档;不使用采购历史或允许输入不存在的客户。'
parameters: [{name: keyword, in: query, schema: {type: string}}]
responses: {'200': {description: 统一响应data为客户id/code/name/contactName/contactPhone数组}}
/api/platform/v1/last-leg/fleet/trips/{tripNo}/bookings/quote-candidates:
parameters: [{$ref: '#/components/parameters/TripNo'}]
get:
summary: 按本车真实起运仓及当地计划日期查询客户销售报价
description: '同时要求platform:fleet:trip:list与platform:fleet:quote:list。城市来自有效公共海外仓的唯一明确地址城市,日期为计划起运UTC时间转换到仓库明确时区;不接收浏览器城市或日期。目的地须为本车既有FBA卸货站;私人地址、缺失或歧义城市/时区明确拒绝查询,仍可手工询价。'
parameters:
- {name: customerId, in: query, required: true, schema: {type: integer, format: int64, minimum: 1}}
- {name: destinationKey, in: query, required: true, schema: {type: string, maxLength: 160}}
- {name: currency, in: query, required: true, schema: {type: string, enum: [CNY, USD]}}
- {name: priceBasis, in: query, required: true, schema: {type: string, enum: [PALLET, ORDER]}}
- {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为报价来源候选分页;无价不列入,零价保留
content:
application/json:
schema:
allOf:
- {$ref: '#/components/schemas/Envelope'}
- properties:
data:
type: object
properties:
records: {type: array, items: {$ref: 'fleet-quote-candidates-v1.yaml#/components/schemas/SourceSnapshot'}}
page: {type: integer}
size: {type: integer}
total: {type: integer}
pages: {type: integer}
'403': {description: 缺少任一读取权限}
'409': {description: 无法确定本车报价查询上下文}
/api/platform/v1/last-leg/fleet/trips/{tripNo}/bookings:
parameters: [{$ref: '#/components/parameters/TripNo'}]
get:
@@ -379,6 +410,7 @@ components:
properties:
tripRecordVersion: {type: integer, format: int64, minimum: 0}
customerId: {type: string, pattern: '^[1-9][0-9]*$'}
quoteRef: {$ref: '#/components/schemas/BookingQuoteRef'}
cargo: {$ref: '#/components/schemas/BookingCargo'}
idempotencyKey: {type: string, minLength: 8, maxLength: 128}
BookingUpdate:
@@ -387,7 +419,13 @@ components:
properties:
tripRecordVersion: {type: integer, format: int64, minimum: 0}
recordVersion: {type: integer, format: int64, minimum: 0}
quoteRef: {$ref: '#/components/schemas/BookingQuoteRef'}
cargo: {$ref: '#/components/schemas/BookingCargo'}
BookingQuoteRef:
type: object
nullable: true
description: '选填;null或省略为手工询价。引用或清除已有来源另要求platform:fleet:quote:list。服务端在承运写事务内锁定并核对精确版本、SALE/ACTIVE、客户、路线、币种、仓库当地计划日期及价项;只保存可信来源,不接收浏览器快照。PALLET选LOOSE_DELIVERY,ORDER选FULL_DELIVERY。'
allOf: [{$ref: 'fleet-quote-candidates-v1.yaml#/components/schemas/QuoteRef'}]
BookingCommand:
type: object
required: [tripRecordVersion, recordVersion, idempotencyKey]
@@ -406,6 +444,11 @@ components:
customerName: {type: string}
status: {type: string, enum: [DRAFT, CONFIRMED, RECEIVED, BOUND, CANCELLED]}
cargo: {$ref: '#/components/schemas/BookingCargo'}
quoteSource:
type: object
nullable: true
description: '可信报价来源,独立于cargo.unitPrice及agreedAmount。确认承运重新核对报价及当前计划日期;确认后的报价变更或实收议价不覆盖已保存来源。'
allOf: [{$ref: 'fleet-quote-candidates-v1.yaml#/components/schemas/SourceSnapshot'}]
destinationLabel: {type: string}
agreedAmount: {type: number, nullable: true, description: 约定运费,非财务应收或实收}
incomingNo: {type: string, nullable: true}
@@ -421,8 +464,8 @@ components:
recordVersion: {type: integer, format: int64}
updatedAt: {type: string, format: date-time}
Outcome:
description: packages保留原实发量;signedPackages为不可变实签量,returnedPackages只计原运输实际退回,remainingPackages必须为零且三者守恒才可结案。签收后的另行退货不重开原运输。
type: object
description: 本车原运输的结案依据;已签收后另发起退货仍保留本次运输DELIVERED及原signedAt,不修改后续退货单据状态。
properties:
dispatchNo: {type: string}
deliveryNo: {type: string}
@@ -431,6 +474,9 @@ components:
status: {type: string}
packages: {type: integer}
terminalAt: {type: string, format: date-time, nullable: true}
signedPackages: {type: integer, minimum: 0}
returnedPackages: {type: integer, minimum: 0}
remainingPackages: {type: integer, minimum: 0}
CompletionEnvelope:
allOf:
- {$ref: '#/components/schemas/Envelope'}
@@ -898,7 +898,7 @@ paths:
/api/platform/v1/last-leg/exception-orders/{exceptionNo}/complete:
post:
summary: 完成异常处理并原子恢复退回库存
description: 权限 platform:overseas-warehouse:exception-order:process;仅 PROCESSING→COMPLETED。退回海外仓必须提交证据和实收目标,库存 RETURN_STOCK_IN 成功后才完成异常单和派送退回。
description: 权限 platform:overseas-warehouse:exception-order:process;退回海外仓支持分批实收,每批使用新幂等键及当前版本,累计不得超过批准量。库存 RETURN_STOCK_IN 成功后累计实收,未收齐保留 PROCESSING,批准量收齐才转 COMPLETED。详情 returnSources 只返回剩余可收数量。派送仅在累计实际退回覆盖整单实发数量时写 RETURNED 和 RETURN_COMPLETED;非海外仓异常结案不证明货物交接完成。原 signedAt 保留,车次及资源不由工单关闭释放。
security: [{ bearerAuth: [] }]
parameters: [{ name: exceptionNo, in: path, required: true, schema: { type: string } }]
requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/ExceptionComplete' } } } }
@@ -906,7 +906,7 @@ paths:
/api/platform/v1/last-leg/exception-orders/{exceptionNo}/cancel:
post:
summary: 取消未处理异常订单
description: 权限 platform:overseas-warehouse:exception-order:process;仅 PENDING→CANCELLED。
description: 权限 platform:overseas-warehouse:exception-order:process;仅 PENDING→CANCELLED,已有实收禁止取消。退回单按创建时冻结的派送及关联尾程状态原子恢复,保留原签收事实;缺少冻结状态或当前状态已变化时拒绝猜测恢复。
security: [{ bearerAuth: [] }]
parameters: [{ name: exceptionNo, in: path, required: true, schema: { type: string } }]
requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/ExceptionCancel' } } } }
@@ -927,7 +927,7 @@ paths:
/api/platform/v1/last-leg/delivery-orders/{deliveryNo}/mark-return:
post:
summary: 将派送单标记为退回入仓
description: 权限 platform:overseas-warehouse:delivery:return;items 必须来自 return-inbound-options,服务端冻结源出库明细与批准退回上限。
description: 权限 platform:overseas-warehouse:delivery:return;items 必须来自 return-inbound-options,服务端扣除历史实际退回量后冻结源出库明细与批准退回上限。同一派送同时只允许一个 PENDING/PROCESSING 退回异常,取消或完成后可为剩余货物新建;RETURN_PENDING 不允许绕过尚未完成的退回异常。
security: [{ bearerAuth: [] }]
parameters: [{ name: deliveryNo, in: path, required: true, schema: { type: string } }]
requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/DeliveryMarkReturn' } } } }
@@ -0,0 +1,34 @@
package com.baiyue.lastleg.delivery.model;
import com.baiyue.lastleg.delivery.entity.DeliveryOrder;
import java.time.LocalDateTime;
import java.util.List;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.*;
class DeliveryCargoQuantitiesTest {
private final LocalDateTime now = LocalDateTime.of(2026, 10, 3, 12, 0);
@Test void unsignedQuantityRemainsOpenUntilAllCargoHasActualTerminalFacts() {
var row = new DeliveryOrder(); row.setPackageCount(10);
assertThat(DeliveryCargoQuantities.from(row, List.of(receipt(1, 4, now))))
.isEqualTo(new DeliveryCargoQuantities(10, 0, 4, 6));
row.setSignedAt(now.plusMinutes(1)); row.setSignedPackages(6);
assertThat(DeliveryCargoQuantities.from(row, List.of(receipt(1, 4, now), receipt(2, 2, now.plusMinutes(2)))))
.isEqualTo(new DeliveryCargoQuantities(10, 6, 4, 0));
}
@Test void missingSignatureQuantityCannotDefaultToTheWholeShipment() {
var row = new DeliveryOrder(); row.setPackageCount(10); row.setSignedAt(now);
assertThatThrownBy(() -> DeliveryCargoQuantities.from(row, List.of())).hasMessageContaining("签收数量");
}
@Test void duplicatedSourcesOrOverReturnCannotCloseCargo() {
var row = new DeliveryOrder(); row.setPackageCount(10);
assertThatThrownBy(() -> DeliveryCargoQuantities.from(row, List.of(receipt(1, 4, now), receipt(1, 4, now))))
.hasMessageContaining("重复");
assertThatThrownBy(() -> DeliveryCargoQuantities.from(row, List.of(receipt(1, 11, now)))).hasMessageContaining("超过");
}
@Test void laterReturnCannotFillAnUnprovenGapInAnEarlierSignature() {
var row = new DeliveryOrder(); row.setPackageCount(10); row.setSignedAt(now); row.setSignedPackages(6);
assertThatThrownBy(() -> DeliveryCargoQuantities.from(row, List.of(receipt(1, 4, now.plusMinutes(1)))))
.hasMessageContaining("守恒");
}
private DeliveryReturnReceiptFact receipt(long id, long packages, LocalDateTime time) { return new DeliveryReturnReceiptFact(id, 2L, 3L, packages, time); }
}
@@ -13,7 +13,7 @@ class DeliveryStatusPolicyTest {
.containsExactly("REJECT_DELIVERY", "MARK_RETURN");
assertThat(DeliveryStatusPolicy.allowedActions("DELIVERED")).containsExactly("MARK_RETURN");
assertThat(DeliveryStatusPolicy.isEditableTarget("DELIVERED")).isFalse();
assertThat(DeliveryStatusPolicy.allowedActions("RETURN_PENDING")).isEmpty();
assertThat(DeliveryStatusPolicy.allowedActions("RETURN_PENDING")).containsExactly("MARK_RETURN");
assertThat(DeliveryStatusPolicy.allowedActions("CANCELLED")).isEmpty();
}
@@ -31,6 +31,7 @@ class DeliveryCompletedOutboxServiceTest {
ReflectionTestUtils.setField(service, "objectMapper", json);
DeliveryOrder delivery = new DeliveryOrder();
delivery.setId(1L); delivery.setDeliveryNo("DL-1"); delivery.setVersion(2L);
delivery.setPackageCount(10); delivery.setSignedPackages(10);
service.publish(delivery, List.of(order(11, "101", "201", "IN-1", "ONT8"),
order(12, "102", "202", "IN-2", "ACY2")), Instant.parse("2026-09-15T08:00:00Z"));
@@ -100,6 +101,19 @@ class DeliveryCompletedOutboxServiceTest {
verifyNoInteractions(mapper);
}
@Test void unknownOrPartialSignatureCannotPublishAWholeDomesticInboundSignature() {
var mapper = mock(LastlegEventOutboxMapper.class);
var service = new DeliveryCompletedOutboxService(); ReflectionTestUtils.setField(service, "outbox", mapper);
var delivery = new DeliveryOrder(); delivery.setPackageCount(10);
var sources = List.of(order(11, "101", "201", "IN-1", "ONT8"));
for (Integer invalid : java.util.Arrays.asList(null, 0, -1, 11)) {
delivery.setSignedPackages(invalid);
assertThat(service.publish(delivery, sources, Instant.now())).isEqualTo(DeliveryCompletedOutboxService.Publication.UNKNOWN_SIGNATURE_REQUIRES_RECONCILIATION);
}
delivery.setSignedPackages(6);
assertThat(service.publish(delivery, sources, Instant.now())).isEqualTo(DeliveryCompletedOutboxService.Publication.MIXED_RETURN_REQUIRES_SOURCE_RECONCILIATION);
verifyNoInteractions(mapper);
}
private LastlegOrder order(long id, String inboundId, String orderId, String inboundCode, String fba) {
LastlegOrder value = new LastlegOrder();
value.setId(id); value.setOrderNo("LM-" + id); value.setCustomerId("88");
@@ -48,7 +48,7 @@ class DeliveryDocumentServiceTest {
ReflectionTestUtils.setField(service,"customerVisits",customerVisits);
order = new DeliveryOrder(); order.setId(10L); order.setCustomerId("100");
order.setDeliveryNo("DL-1"); order.setVersion(0L); order.setStatus("DELIVERED");
order.setOutboundNo("OUT-1");
order.setOutboundNo("OUT-1"); order.setPackageCount(10);
}
@Test void savedCategoryCountCountsCategoriesNotFilesAndSurvivesReload() {
when(orders.lockByNo("100","DL-1")).thenReturn(order);
@@ -215,6 +215,30 @@ class DeliveryDocumentServiceTest {
verify(files,never()).download(anyString(),anyLong(),any(),anyLong());
verifyNoInteractions(revisions,operations);
}
@Test void remainingSignatureFreezesActualQuantityAndPersistsDomesticWritebackBlocker() {
order.setStatus("RETURN_PENDING");
when(orders.lockByNo("100", "DL-1")).thenReturn(order);
when(files.metadata("100", 10, RECEIPT, 13)).thenReturn(file(13));
var outbound = new OutboundOrder(); outbound.setStatus("OUTBOUNDED");
when(outboundOrders.findByNo("100", "OUT-1")).thenReturn(outbound);
when(operations.returnReceipts("100", 10L)).thenReturn(List.of(new com.baiyue.lastleg.delivery.model.DeliveryReturnReceiptFact(1L, 2L, 3L, 4, LocalDateTime.now().minusMinutes(1))));
when(deliveryCompletedOutbox.publish(eq(order), anyList(), any())).thenReturn(DeliveryCompletedOutboxService.Publication.MIXED_RETURN_REQUIRES_SOURCE_RECONCILIATION);
service.save("100", "DL-1", "9", request(0, "residual-signature", List.of(new Binding(RECEIPT, List.of(13L)))));
assertThat(order.getPackageCount()).isEqualTo(10); assertThat(order.getSignedPackages()).isEqualTo(6);
assertThat(order.getSignedAt()).isNotNull();
var captured = ArgumentCaptor.forClass(DeliveryOperation.class);
verify(operations, times(3)).insert(captured.capture());
assertThat(captured.getAllValues()).anyMatch(row -> "FIRSTLEG_COMPLETION_PENDING".equals(row.getOperationNode()) && row.getResult().contains("未发布"));
}
@Test void updatingDocumentsDuringLaterReturnNeverRewritesOriginalSignature() {
order.setStatus("RETURN_PENDING"); order.setSignedAt(LocalDateTime.of(2026, 9, 1, 12, 0)); order.setSignedPackages(10);
when(orders.lockByNo("100", "DL-1")).thenReturn(order);
when(files.metadata("100", 10, RECEIPT, 13)).thenReturn(file(13));
service.save("100", "DL-1", "9", request(0, "later-evidence", List.of(new Binding(RECEIPT, List.of(13L)))));
assertThat(order.getSignedAt()).isEqualTo(LocalDateTime.of(2026, 9, 1, 12, 0));
assertThat(order.getSignedPackages()).isEqualTo(10); assertThat(order.getStatus()).isEqualTo("RETURN_PENDING");
verifyNoInteractions(deliveryCompletedOutbox, customerVisits);
}
private Save request(long version,String key,List<Binding> bindings) { return new Save(version,key,"BOL-1",bindings); }
private File file(long id) { return new File(id,"a.pdf","application/pdf",1,LocalDateTime.of(2026,9,9,8,0)); }
}
@@ -0,0 +1,49 @@
package com.baiyue.lastleg.delivery.service;
import com.baiyue.lastleg.delivery.entity.DeliveryOrder;
import com.baiyue.lastleg.delivery.mapper.DeliveryOperationMapper;
import com.baiyue.lastleg.delivery.model.DeliveryCargoQuantities;
import org.apache.ibatis.annotations.Select;
import org.junit.jupiter.api.Test;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate;
import org.springframework.jdbc.datasource.DriverManagerDataSource;
import java.time.LocalDateTime;
import java.util.Map;
import java.util.UUID;
import static org.assertj.core.api.Assertions.*;
class DeliveryReturnEvidencePersistenceTest {
@Test void twoItemsOnTheSamePalletResolveThroughTheOriginalPickPositionAndOrder() throws Exception {
var data = new DriverManagerDataSource("jdbc:h2:mem:" + UUID.randomUUID() + ";MODE=MySQL;DB_CLOSE_DELAY=-1", "sa", "");
var jdbc = new JdbcTemplate(data);
jdbc.execute("CREATE TABLE ll_delivery_order(id BIGINT,customer_id VARCHAR(20),dispatch_order_id BIGINT,outbound_order_id BIGINT,outbound_no VARCHAR(20))");
jdbc.execute("CREATE TABLE ll_exception_order(id BIGINT,customer_id VARCHAR(20),delivery_order_id BIGINT,exception_no VARCHAR(20),last_mile_order_id BIGINT)");
jdbc.execute("CREATE TABLE ll_exception_return_item(exception_order_id BIGINT,customer_id VARCHAR(20),outbound_pallet_item_id BIGINT,inventory_position_id BIGINT)");
jdbc.execute("CREATE TABLE ll_outbound_pallet_item(id BIGINT,customer_id VARCHAR(20),outbound_order_id BIGINT,warehouse_pallet_id BIGINT)");
jdbc.execute("CREATE TABLE ll_dispatch_item(id BIGINT,customer_id VARCHAR(20),dispatch_order_id BIGINT,warehouse_pallet_id BIGINT)");
jdbc.execute("CREATE TABLE ll_dispatch_pda_pick(id BIGINT,customer_id VARCHAR(20),task_id BIGINT,dispatch_item_id BIGINT,inventory_position_id BIGINT)");
jdbc.execute("CREATE TABLE ll_dispatch_pda_pick_order(customer_id VARCHAR(20),pick_id BIGINT,order_id BIGINT)");
jdbc.execute("CREATE TABLE ll_dispatch_pda_task(id BIGINT,customer_id VARCHAR(20),dispatch_order_id BIGINT)");
jdbc.execute("CREATE TABLE ll_inventory_ledger(id BIGINT,customer_id VARCHAR(20),business_order_no VARCHAR(20),outbound_order_no VARCHAR(20),business_type VARCHAR(40),delta_packages BIGINT,source_event_id VARCHAR(100),occurred_at TIMESTAMP)");
jdbc.update("INSERT INTO ll_delivery_order VALUES(1,'A',10,20,'OUT-1')");
jdbc.update("INSERT INTO ll_exception_order VALUES(2,'A',1,'EX-1',71)");
jdbc.update("INSERT INTO ll_exception_return_item VALUES(2,'A',21,41)");
jdbc.update("INSERT INTO ll_outbound_pallet_item VALUES(21,'A',20,99)");
jdbc.update("INSERT INTO ll_dispatch_item VALUES(31,'A',10,99),(32,'A',10,99)");
jdbc.update("INSERT INTO ll_dispatch_pda_task VALUES(50,'A',10)");
jdbc.update("INSERT INTO ll_dispatch_pda_pick VALUES(51,'A',50,31,41),(52,'A',50,32,42)");
jdbc.update("INSERT INTO ll_dispatch_pda_pick_order VALUES('A',51,71),('A',52,72)");
jdbc.update("INSERT INTO ll_inventory_ledger VALUES(60,'A','EX-1','OUT-1','RETURN_STOCK_IN',4,'RETURN_SOURCE:21:41',CURRENT_TIMESTAMP)");
String sql = String.join(" ", DeliveryOperationMapper.class.getMethod("returnReceipts", String.class, Long.class)
.getAnnotation(Select.class).value()).replace("#{customerId}", ":customerId").replace("#{deliveryOrderId}", ":deliveryOrderId");
var named = new NamedParameterJdbcTemplate(data);
var facts = named.query(sql, Map.of("customerId", "A", "deliveryOrderId", 1L), (row, index) ->
new com.baiyue.lastleg.delivery.model.DeliveryReturnReceiptFact(row.getLong("ledger_id"), row.getLong("dispatch_item_id"), row.getLong("pallet_id"), row.getLong("packages"), row.getObject("received_at", LocalDateTime.class)));
assertThat(facts).hasSize(1); assertThat(facts.get(0).dispatchItemId()).isEqualTo(31);
assertThat(facts.get(0).packages()).isEqualTo(4);
var delivery = new DeliveryOrder(); delivery.setPackageCount(10);
assertThat(DeliveryCargoQuantities.from(delivery, facts).remaining()).isEqualTo(6);
assertThat(named.queryForList(sql, Map.of("customerId", "OTHER", "deliveryOrderId", 1L))).isEmpty();
}
}
@@ -34,6 +34,7 @@ import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class)
class ExceptionOrderDispatchReturnSourceTest {
@Mock private com.baiyue.lastleg.exceptionorder.mapper.ExceptionReturnItemMapper returnItems;
@Mock private DeliveryOrderMapper deliveryOrders;
@Mock private OutboundOrderMapper outboundOrders;
@Mock private OutboundPalletItemMapper outboundPalletItems;
@@ -50,6 +51,7 @@ class ExceptionOrderDispatchReturnSourceTest {
void setUp() {
service = new ExceptionOrderService();
ReflectionTestUtils.setField(service, "deliveryOrders", deliveryOrders);
ReflectionTestUtils.setField(service, "returnItems", returnItems);
ReflectionTestUtils.setField(service, "outboundOrders", outboundOrders);
ReflectionTestUtils.setField(service, "outboundPalletItems", outboundPalletItems);
ReflectionTestUtils.setField(service, "outboundPdaPicks", outboundPdaPicks);
@@ -119,11 +119,13 @@ class ExceptionOrderLifecycleTest {
order.setOutboundNo("OUT-1"); order.setOverseasWarehouseId("WH-1"); order.setInboundOrderId(44L);
order.setDeliveryNo("DL-1");
ExceptionReturnItem source = new ExceptionReturnItem();
source.setId(501L);
source.setOutboundPalletItemId(51L); source.setInventoryPositionId(61L);
source.setSkuCode("SKU-1"); source.setApprovedReturnPackages(2L);
DeliveryOrder delivery = new DeliveryOrder();
delivery.setId(71L); delivery.setCustomerId("1001"); delivery.setDeliveryNo("DL-1");
delivery.setStatus("RETURN_PENDING"); delivery.setVersion(2L);
delivery.setPackageCount(2);
LocalDateTime completedAt = LocalDateTime.of(2026, 9, 8, 18, 30);
when(commands.findByKey("1001", "COMPLETE", "complete-1")).thenReturn(null);
when(exceptionOrders.lockByNo("1001", "EX-1")).thenReturn(order);
@@ -131,6 +133,7 @@ class ExceptionOrderLifecycleTest {
when(inventoryCommands.restoreReturnedInventory(any(), any(), any(ReturnedInventoryRestoreRequest.class)))
.thenReturn(new ReturnedInventoryRestoreResponse(81L, List.of(91L), 0, 2, 2, false, completedAt));
when(deliveryOrders.lockByNo("1001", "DL-1")).thenReturn(delivery);
when(returnItems.receivedByDelivery("1001", 71L)).thenReturn(2L);
when(lastMileOrders.selectList(any())).thenReturn(List.of());
ExceptionOrderRequests.Complete request = new ExceptionOrderRequests.Complete(1, "complete-1", "已退回入仓",
List.of(101L), List.of(new ExceptionOrderRequests.ReturnReceipt(51L, 61L, 2, 201L,
@@ -146,6 +149,82 @@ class ExceptionOrderLifecycleTest {
verify(inventoryCommands).restoreReturnedInventory(any(), any(), any(ReturnedInventoryRestoreRequest.class));
}
@Test
void partialReceiptStaysProcessingAndCannotCompleteTheDelivery() {
var delivery = partialReturn(4, 10, 1);
var result = service.complete("1001", "9001", "EX-1", receipt(1));
assertThat(result.status()).isEqualTo("PROCESSING");
assertThat(result.completedAt()).isNull();
assertThat(result.restoredPackages()).isEqualTo(1);
assertThat(delivery.getStatus()).isEqualTo("RETURN_PENDING");
verify(deliveryOperations, never()).insert(any(com.baiyue.lastleg.delivery.entity.DeliveryOperation.class));
ArgumentCaptor<ExceptionReturnItem> item = ArgumentCaptor.forClass(ExceptionReturnItem.class);
verify(returnItems).updateById(item.capture());
assertThat(item.getValue().getReceivedPackages()).isEqualTo(1);
}
@Test
void fullyReceivingAPartialReturnCaseDoesNotCloseTheRemainingDeliveryCargo() {
var delivery = partialReturn(2, 10, 2);
var signedAt = LocalDateTime.of(2026, 9, 1, 12, 0);
delivery.setSignedAt(signedAt);
var result = service.complete("1001", "9001", "EX-1", receipt(2));
assertThat(result.status()).isEqualTo("COMPLETED");
assertThat(delivery.getStatus()).isEqualTo("RETURN_PENDING");
assertThat(delivery.getSignedAt()).isEqualTo(signedAt);
verify(deliveryOperations, never()).insert(any(com.baiyue.lastleg.delivery.entity.DeliveryOperation.class));
}
@Test
void closingCustomerReturnCaseDoesNotInventCargoHandoff() {
var order = order("PROCESSING", 1);
order.setExceptionType("RETURN"); order.setReturnDirection("CUSTOMER"); order.setDeliveryNo("DL-1");
when(exceptionOrders.lockByNo("1001", "EX-1")).thenReturn(order);
var result = service.complete("1001", "9001", "EX-1", new ExceptionOrderRequests.Complete(1, "other-1", "联系客户", List.of(101L), List.of()));
assertThat(result.status()).isEqualTo("COMPLETED");
verify(deliveryOrders, never()).updateById(any(DeliveryOrder.class));
verify(deliveryOperations, never()).insert(any(com.baiyue.lastleg.delivery.entity.DeliveryOperation.class));
}
@Test
void cancellingPendingReturnRestoresTheFrozenStatusAndPreservesSignature() {
var order = order("PENDING", 1);
order.setExceptionType("RETURN"); order.setDeliveryNo("DL-1");
order.setReturnContextJson("{\"deliveryStatus\":\"DELIVERED\",\"tailId\":null,\"tailStatus\":null,\"abnormal\":null}");
var delivery = new DeliveryOrder(); delivery.setStatus("RETURN_PENDING"); delivery.setVersion(3L);
var signedAt = LocalDateTime.of(2026, 9, 1, 12, 0); delivery.setSignedAt(signedAt);
when(exceptionOrders.lockByNo("1001", "EX-1")).thenReturn(order);
when(deliveryOrders.lockByNo("1001", "DL-1")).thenReturn(delivery);
when(returnItems.findByException("1001", 1L)).thenReturn(List.of());
var result = service.cancel("1001", "9001", "EX-1", new ExceptionOrderRequests.Cancel(1, "cancel-1", "无需退回"));
assertThat(result.status()).isEqualTo("CANCELLED");
assertThat(delivery.getStatus()).isEqualTo("DELIVERED");
assertThat(delivery.getVersion()).isEqualTo(4);
assertThat(delivery.getSignedAt()).isEqualTo(signedAt);
verify(deliveryOperations, never()).insert(any(com.baiyue.lastleg.delivery.entity.DeliveryOperation.class));
}
private DeliveryOrder partialReturn(long approved, int shipped, long actual) {
var order = order("PROCESSING", 1);
order.setExceptionType("RETURN"); order.setReturnDirection("OVERSEAS_WAREHOUSE"); order.setDeliveryNo("DL-1");
order.setOutboundNo("OUT-1"); order.setOverseasWarehouseId("1"); order.setInboundOrderId(44L);
var source = new ExceptionReturnItem(); source.setId(501L); source.setOutboundPalletItemId(51L);
source.setInventoryPositionId(61L); source.setApprovedReturnPackages(approved); source.setReceivedPackages(0L);
var delivery = new DeliveryOrder(); delivery.setId(71L); delivery.setStatus("RETURN_PENDING"); delivery.setPackageCount(shipped);
when(exceptionOrders.lockByNo("1001", "EX-1")).thenReturn(order);
when(returnItems.findByException("1001", 1L)).thenReturn(List.of(source));
when(deliveryOrders.lockByNo("1001", "DL-1")).thenReturn(delivery);
when(returnItems.receivedByDelivery("1001", 71L)).thenReturn(actual);
when(inventoryCommands.restoreReturnedInventory(any(), any(), any(ReturnedInventoryRestoreRequest.class)))
.thenReturn(new ReturnedInventoryRestoreResponse(81L, List.of(91L), 0, actual, actual, false, LocalDateTime.now()));
return delivery;
}
private ExceptionOrderRequests.Complete receipt(long amount) {
return new ExceptionOrderRequests.Complete(1, "batch-1", "本次实际退回", List.of(101L),
List.of(new ExceptionOrderRequests.ReturnReceipt(51L, 61L, amount, 201L, "PLT-201", 301L, "LOC-301")));
}
private String hashFor(Object request) {
return (String) ReflectionTestUtils.invokeMethod(service, "hash", request);
}
@@ -0,0 +1,68 @@
package com.baiyue.lastleg.exceptionorder.service;
import com.baomidou.mybatisplus.core.MybatisConfiguration;
import com.baomidou.mybatisplus.extension.spring.MybatisSqlSessionFactoryBean;
import com.baiyue.lastleg.delivery.mapper.DeliveryOrderMapper;
import com.baiyue.lastleg.exceptionorder.mapper.ExceptionOrderMapper;
import org.mybatis.spring.SqlSessionTemplate;
import org.junit.jupiter.api.Test;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.jdbc.datasource.DriverManagerDataSource;
import org.springframework.jdbc.datasource.DataSourceTransactionManager;
import org.springframework.test.util.ReflectionTestUtils;
import org.springframework.transaction.TransactionDefinition;
import org.springframework.transaction.support.TransactionTemplate;
import java.util.UUID;
import java.util.concurrent.*;
import static org.assertj.core.api.Assertions.*;
class ExceptionReturnConcurrencyTest {
@Test void concurrentCreatorsSerializeOnTheRealDeliveryRowAndCancelledHistoryDoesNotBlock() throws Exception {
assertThat(ExceptionOrderService.class.getMethod("markReturn", String.class, String.class, String.class, String.class,
com.baiyue.lastleg.exceptionorder.dto.ExceptionOrderRequests.MarkReturn.class)
.getAnnotation(org.springframework.transaction.annotation.Transactional.class).isolation())
.isEqualTo(org.springframework.transaction.annotation.Isolation.READ_COMMITTED);
var data = new DriverManagerDataSource("jdbc:h2:mem:" + UUID.randomUUID() + ";MODE=MySQL;DB_CLOSE_DELAY=-1;LOCK_TIMEOUT=5000", "sa", "");
var jdbc = new JdbcTemplate(data);
jdbc.execute("CREATE TABLE ll_delivery_order(id BIGINT PRIMARY KEY,customer_id VARCHAR(50),delivery_no VARCHAR(50),status VARCHAR(40))");
jdbc.execute("CREATE TABLE ll_exception_order(id BIGINT PRIMARY KEY,customer_id VARCHAR(50),delivery_order_id BIGINT,exception_type VARCHAR(30),status VARCHAR(30))");
jdbc.update("INSERT INTO ll_delivery_order VALUES(1,'A','DL-1','DELIVERING')");
jdbc.update("INSERT INTO ll_exception_order VALUES(10,'A',1,'RETURN','CANCELLED')");
var config = new MybatisConfiguration(); config.addMapper(DeliveryOrderMapper.class); config.addMapper(ExceptionOrderMapper.class);
var factory = new MybatisSqlSessionFactoryBean(); factory.setDataSource(data); factory.setConfiguration(config);
var sessions = new SqlSessionTemplate(factory.getObject());
var service = new ExceptionOrderService();
ReflectionTestUtils.setField(service, "deliveryOrders", sessions.getMapper(DeliveryOrderMapper.class));
ReflectionTestUtils.setField(service, "exceptionOrders", sessions.getMapper(ExceptionOrderMapper.class));
var tx = new TransactionTemplate(new DataSourceTransactionManager(data));
tx.setIsolationLevel(TransactionDefinition.ISOLATION_READ_COMMITTED);
var locked = new CountDownLatch(1); var secondStarted = new CountDownLatch(1); var releaseFirst = new CountDownLatch(1);
var executor = Executors.newFixedThreadPool(2);
try {
Future<?> first = executor.submit(() -> tx.executeWithoutResult(status -> {
service.lockForReturn("A", "DL-1");
jdbc.update("INSERT INTO ll_exception_order VALUES(11,'A',1,'RETURN','PENDING')");
locked.countDown();
try { assertThat(releaseFirst.await(5, TimeUnit.SECONDS)).isTrue(); }
catch (InterruptedException error) { throw new IllegalStateException(error); }
}));
Future<?> second = executor.submit(() -> {
try { assertThat(locked.await(3, TimeUnit.SECONDS)).isTrue(); }
catch (InterruptedException error) { throw new IllegalStateException(error); }
assertThatThrownBy(() -> tx.executeWithoutResult(status -> {
// Prime a query before the first transaction commits, just like the idempotency lookup.
jdbc.queryForObject("SELECT COUNT(*) FROM ll_exception_order", Long.class);
secondStarted.countDown(); service.lockForReturn("A", "DL-1");
jdbc.update("INSERT INTO ll_exception_order VALUES(12,'A',1,'RETURN','PENDING')");
})).hasMessageContaining("已存在退回异常单");
});
assertThat(secondStarted.await(3, TimeUnit.SECONDS)).isTrue();
assertThatThrownBy(() -> second.get(200, TimeUnit.MILLISECONDS)).isInstanceOf(TimeoutException.class);
releaseFirst.countDown();
first.get(8, TimeUnit.SECONDS); second.get(8, TimeUnit.SECONDS);
assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM ll_exception_order WHERE status='PENDING'", Long.class)).isEqualTo(1);
jdbc.update("UPDATE ll_exception_order SET status='COMPLETED' WHERE id=11");
tx.executeWithoutResult(status -> assertThat(service.lockForReturn("A", "DL-1").getId()).isEqualTo(1));
} finally { releaseFirst.countDown(); executor.shutdownNow(); }
}
}
@@ -0,0 +1,30 @@
package com.baiyue.lastleg.exceptionorder.service;
import com.baiyue.lastleg.exceptionorder.dto.ExceptionOrderRequests.ReturnReceipt;
import com.baiyue.lastleg.exceptionorder.entity.ExceptionReturnItem;
import java.util.List;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.*;
class ReturnReceiptPolicyTest {
@Test void cumulativeReceiptCannotExceedApprovalAcrossBatches() {
var source = source(); source.setReceivedPackages(3L);
assertThat(ReturnReceiptPolicy.quantities(List.of(source), List.of(receipt(1)))).containsEntry(1L, 1L);
assertThatThrownBy(() -> ReturnReceiptPolicy.quantities(List.of(source), List.of(receipt(2)))).hasMessageContaining("累计实收");
assertThat(ReturnReceiptPolicy.complete(List.of(source))).isFalse();
source.setReceivedPackages(4L); assertThat(ReturnReceiptPolicy.complete(List.of(source))).isTrue();
}
@Test void splitTargetsAreSummedBeforeCheckingTheSourceLimit() {
assertThatThrownBy(() -> ReturnReceiptPolicy.quantities(List.of(source()), List.of(receipt(3), receipt(2))))
.hasMessageContaining("累计实收");
}
@Test void wrongPositionCannotBorrowAnotherSourceApproval() {
var wrong = new ReturnReceipt(10L, 999L, 1, 20L, "P", 30L, "L");
assertThatThrownBy(() -> ReturnReceiptPolicy.quantities(List.of(source()), List.of(wrong))).hasMessageContaining("冻结来源");
}
private ExceptionReturnItem source() {
var value = new ExceptionReturnItem(); value.setId(1L); value.setOutboundPalletItemId(10L);
value.setInventoryPositionId(11L); value.setApprovedReturnPackages(4L); value.setReceivedPackages(0L); return value;
}
private ReturnReceipt receipt(long amount) { return new ReturnReceipt(10L, 11L, amount, 20L, "P", 30L, "L"); }
}
@@ -32,9 +32,9 @@ class FleetHandoverPdaQueryTest {
""");
when(history.selectList(any())).thenReturn(List.of(departed));
when(dispatches.transitEvidence("PC-A", false)).thenReturn(new FleetTransitEvidence("PC-A", "CUSTOMER-A", "92", 30, List.of(
new FleetTransitEvidence.Delivery(new FleetHandoverPolicy.Delivery("CUSTOMER-A", "PC-A", "DL-DONE", "FBA:ONE", 10, Instant.parse("2026-10-03T01:00:00Z")),
new FleetTransitEvidence.Delivery(new FleetHandoverPolicy.Delivery("CUSTOMER-A", "PC-A", "DL-DONE", "FBA:ONE", 10, Instant.parse("2026-10-03T01:00:00Z"), List.of()),
"DELIVERED", 2, new FleetTransitEvidence.Transport("ORIGINAL", "原司机", null)),
new FleetTransitEvidence.Delivery(new FleetHandoverPolicy.Delivery("CUSTOMER-A", "PC-A", "DL-PENDING", "FBA:TWO", 20, null),
new FleetTransitEvidence.Delivery(new FleetHandoverPolicy.Delivery("CUSTOMER-A", "PC-A", "DL-PENDING", "FBA:TWO", 20, null, List.of()),
"WAIT_DELIVERY", 3, new FleetTransitEvidence.Transport("REPLACEMENT", "接替司机", null)))));
}
@@ -41,14 +41,14 @@ class FleetHandoverPolicyTest {
}
@Test void sameDestinationCannotMixCustomerOrDispatchFacts() {
var wrong = new Delivery("A", "PC-B", "D-B", "FBA:ONE", 20, null);
var wrong = new Delivery("A", "PC-B", "D-B", "FBA:ONE", 20, null, List.of());
assertThrows(IllegalArgumentException.class, () -> inspectBoth(delivery(a, null), wrong));
}
@Test void missingDuplicateAndChangedQuantityFactsAreRejected() {
assertThrows(IllegalArgumentException.class, () -> inspectBoth(delivery(a, null)));
assertThrows(IllegalArgumentException.class, () -> inspectBoth(delivery(a, null), delivery(a, null), delivery(b, null)));
var wrong = new Delivery("B", "PC-B", "D-B", "FBA:ONE", 19, null);
var wrong = new Delivery("B", "PC-B", "D-B", "FBA:ONE", 19, null, List.of());
assertThrows(IllegalArgumentException.class, () -> inspectBoth(delivery(a, null), wrong));
}
@@ -90,7 +90,7 @@ class FleetHandoverPolicyTest {
}
private static Delivery delivery(Cargo cargo, Instant terminalAt) {
return new Delivery(cargo.customerId(), cargo.dispatchNo(), "D-" + cargo.customerId(),
cargo.destinationKey(), cargo.packages(), terminalAt);
cargo.destinationKey(), cargo.packages(), terminalAt, List.of());
}
private static Load load(int pallets, int kg, int cbm) {
return new Load(pallets, BigDecimal.valueOf(kg), BigDecimal.valueOf(cbm));
@@ -70,7 +70,7 @@ class FleetHandoverTransactionTest {
true, "APPROVED", "file", 0, "1", LocalDateTime.now(), qualification, "VALID", null, null));
var inspection = mock(FleetHandoverInspectionService.class);
var cargo = new FleetHandoverPolicy.Cargo("CUSTOMER-A", "PC-A", "item", "pallet", "FBA:ONT8", 10);
var delivery = new FleetHandoverPolicy.Delivery("CUSTOMER-A", "PC-A", "DL-A", "FBA:ONT8", 10, null);
var delivery = new FleetHandoverPolicy.Delivery("CUSTOMER-A", "PC-A", "DL-A", "FBA:ONT8", 10, null, List.of());
var evidence = new FleetTransitEvidence("PC-A", "CUSTOMER-A", "92", 10,
List.of(new FleetTransitEvidence.Delivery(delivery, "WAIT_DELIVERY", 1, new FleetTransitEvidence.Transport("PLATE-11", "司机", "phone"))));
when(inspection.prepare(anyString(), any(), any(), eq(true))).thenAnswer(call -> new FleetHandoverInspectionService.Prepared(state().getRecordVersion(),
@@ -63,10 +63,10 @@ class FleetTransitEvidenceTest {
@Test void priorSignatureRemainsTerminalAfterLaterReturnRequest() {
var signed = LocalDateTime.of(2026, 10, 3, 9, 0);
delivery.setStatus("RETURN_PENDING"); delivery.setSignedAt(signed);
delivery.setStatus("RETURN_PENDING"); delivery.setSignedAt(signed); delivery.setSignedPackages(10);
var fact = adapter.transitEvidence("PC-1", false);
assertEquals(signed.atZone(ZoneId.systemDefault()).toInstant(), fact.deliveries().get(0).cargo().terminalAt());
verifyNoInteractions(operations, workflow);
verify(operations).returnReceipts("CUSTOMER-A", 41L); verifyNoInteractions(workflow);
}
@Test void unsignedReturnRequiresImmutableReturnOperation() {
@@ -74,6 +74,7 @@ class FleetTransitEvidenceTest {
assertThrows(org.springframework.web.server.ResponseStatusException.class, () -> adapter.transitEvidence("PC-1", false));
var returned = LocalDateTime.of(2026, 10, 3, 10, 0);
when(operations.returnedAt("CUSTOMER-A", 41L)).thenReturn(returned);
when(operations.returnReceipts("CUSTOMER-A", 41L)).thenReturn(List.of(new com.baiyue.lastleg.delivery.model.DeliveryReturnReceiptFact(1L, 51L, 61L, 10, returned)));
assertEquals(returned.atZone(ZoneId.systemDefault()).toInstant(),
adapter.transitEvidence("PC-1", false).deliveries().get(0).cargo().terminalAt());
}
@@ -104,7 +105,7 @@ class FleetTransitEvidenceTest {
}
@Test void signedCargoStaysOnOriginalAssignmentAndStaleVersionCannotOverwrite() {
delivery.setStatus("DELIVERED"); delivery.setSignedAt(LocalDateTime.of(2026, 10, 3, 9, 0));
delivery.setStatus("DELIVERED"); delivery.setSignedAt(LocalDateTime.of(2026, 10, 3, 9, 0)); delivery.setSignedPackages(10);
var evidence = adapter.transitEvidence("PC-1", true);
var assignment = new com.baiyue.lastleg.fleet.trip.FleetDispatchGateway.Assignment("NEW-PLATE", "接替司机", "phone");
adapter.handover(evidence, assignment);
@@ -113,4 +114,37 @@ class FleetTransitEvidenceTest {
assertThrows(org.springframework.web.server.ResponseStatusException.class, () -> adapter.handover(evidence, assignment));
verifyNoInteractions(workflow);
}
@Test void partialReturnThenRemainingHandoverThenSignatureConservesTheOriginalShipment() {
var returnedAt = LocalDateTime.of(2026, 10, 3, 9, 0);
delivery.setStatus("RETURN_PENDING");
when(operations.returnReceipts("CUSTOMER-A", 41L)).thenReturn(List.of(new com.baiyue.lastleg.delivery.model.DeliveryReturnReceiptFact(1L, 51L, 61L, 4, returnedAt)));
var evidence = adapter.transitEvidence("PC-1", true);
var departed = returnedAt.minusHours(1).atZone(ZoneId.systemDefault()).toInstant();
var handedOver = returnedAt.plusMinutes(1).atZone(ZoneId.systemDefault()).toInstant();
var load = new com.baiyue.lastleg.fleet.domain.TripCapacityPolicy.Load(1, new java.math.BigDecimal("100"), java.math.BigDecimal.ONE);
var inspected = FleetHandoverPolicy.inspect(departed, handedOver, handedOver,
List.of(new FleetHandoverPolicy.Cargo("CUSTOMER-A", "PC-1", "51", "61", "FBA:ONT8", 10)),
List.of(evidence.deliveries().get(0).cargo()), List.of(new FleetHandoverPolicy.Pallet("61", load)), load);
assertEquals(6, inspected.remainingCargo().get(0).packages());
assertEquals(load, inspected.occupied()); // No invented proportional weight reduction.
assertNotNull(adapter.completion("PC-1", false).blockReason());
when(deliveries.lockByNo("CUSTOMER-A", "DL-1")).thenReturn(delivery);
when(deliveries.update(isNull(), any())).thenReturn(1);
adapter.handover(evidence, new com.baiyue.lastleg.fleet.trip.FleetDispatchGateway.Assignment("NEW", "司机", "phone"));
delivery.setStatus("DELIVERED"); delivery.setSignedAt(returnedAt.plusMinutes(2)); delivery.setSignedPackages(6);
var complete = adapter.completion("PC-1", false);
assertNull(complete.blockReason());
var outcome = complete.outcomes().get(0);
assertEquals(10, outcome.packages()); assertEquals(6, outcome.signedPackages()); assertEquals(4, outcome.returnedPackages());
assertEquals(0, outcome.remainingPackages());
// A later return of signed goods cannot rewrite the original 6 signed + 4 returned journey.
when(operations.returnReceipts("CUSTOMER-A", 41L)).thenReturn(List.of(
new com.baiyue.lastleg.delivery.model.DeliveryReturnReceiptFact(1L, 51L, 61L, 4, returnedAt),
new com.baiyue.lastleg.delivery.model.DeliveryReturnReceiptFact(2L, 51L, 61L, 2, returnedAt.plusMinutes(3))));
delivery.setStatus("RETURN_PENDING");
assertEquals(outcome, adapter.completion("PC-1", false).outcomes().get(0));
assertEquals(10, delivery.getPackageCount());
verifyNoInteractions(workflow);
}
}
@@ -0,0 +1,53 @@
package com.baiyue.lastleg.fleet.quote;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
import org.junit.jupiter.api.*;
import org.springframework.aop.framework.ProxyFactory;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.jdbc.datasource.*;
import org.springframework.transaction.IllegalTransactionStateException;
import org.springframework.transaction.annotation.AnnotationTransactionAttributeSource;
import org.springframework.transaction.interceptor.TransactionInterceptor;
import org.springframework.transaction.support.TransactionTemplate;
import java.math.BigDecimal;
import java.nio.file.*;
import java.time.LocalDate;
import java.util.*;
import java.util.regex.Pattern;
import static org.assertj.core.api.Assertions.*;
import static org.mockito.Mockito.*;
class FleetQuoteCandidatePersistenceTest {
private FleetQuoteCandidateService candidates;private FleetQuoteService quotes;private TransactionTemplate transaction;
private final LocalDate day=LocalDate.of(2026,10,3);
@BeforeEach void setup()throws Exception{
var source=new DriverManagerDataSource("jdbc:h2:mem:quote-candidates-"+UUID.randomUUID()+";MODE=MySQL;DB_CLOSE_DELAY=-1","sa","");
var jdbc=new JdbcTemplate(source);String schema=Files.readString(Path.of("src/main/resources/db/lastleg.sql"));
for(String table:List.of("ll_fleet_quote","ll_fleet_quote_history")){
var match=Pattern.compile("(?s)CREATE TABLE "+table+" \\(.*?;").matcher(schema);assertThat(match.find()).isTrue();jdbc.execute(match.group().replace(" JSON"," LONGTEXT"));
}
var directory=mock(FleetQuoteDirectory.class);when(directory.name(anyString(),anyLong())).thenReturn("测试客户");
var json=new ObjectMapper().registerModule(new JavaTimeModule());var manager=new DataSourceTransactionManager(source);transaction=new TransactionTemplate(manager);
quotes=new FleetQuoteService(new FleetQuoteRepository(jdbc),directory,json);
var proxy=new ProxyFactory(new FleetQuoteCandidateService(new FleetQuoteCandidateRepository(jdbc),directory,json));proxy.setProxyTargetClass(true);
proxy.addAdvice(new TransactionInterceptor(manager,new AnnotationTransactionAttributeSource()));candidates=(FleetQuoteCandidateService)proxy.getProxy();
}
private FleetQuoteModels.Draft draft(String kind,long customer,BigDecimal price){return new FleetQuoteModels.Draft(kind,customer,"LA","ONT8",BigDecimal.TEN,"USD",BigDecimal.ONE,BigDecimal.TEN,price,day,day,null);}
private FleetQuoteModels.Quote create(String key,String kind,long customer,BigDecimal price){return transaction.execute(s->quotes.create("1",new FleetQuoteModels.Create(key,draft(kind,customer,price))));}
private FleetQuoteCandidateModels.Context context(){return new FleetQuoteCandidateModels.Context(1L," LA ","ont8","USD",day,"PALLET");}
@Test void realQueryRestrictsCustomerPurposeAndDateAndDoesNotPromoteUnknownToZero(){
create("candidate-sale","SALE",1,BigDecimal.ZERO);create("candidate-other","SALE",2,BigDecimal.ONE);create("candidate-buy","PURCHASE",1,BigDecimal.ONE);
var result=candidates.candidates(new FleetQuoteCandidateModels.Query(1L," LA ","ont8","USD",day,"PALLET",1,20));
assertThat(result.total()).isEqualTo(1);assertThat(result.records().get(0).quotedUnitPrice()).isEqualByComparingTo("0");
assertThat(candidates.candidates(new FleetQuoteCandidateModels.Query(1L,"LA","ONT8","USD",day.plusDays(1),"PALLET",1,20)).total()).isZero();
}
@Test void sourceResolutionRequiresTheBookingTransactionAndKeepsOriginalVersionAfterLaterPriceChange(){
var quote=create("candidate-source","SALE",1,BigDecimal.ZERO);var ref=new FleetQuoteCandidateModels.QuoteRef(quote.quoteNo(),quote.recordVersion(),"LOOSE_DELIVERY");
assertThatThrownBy(()->candidates.lockAndResolve(context(),ref)).isInstanceOf(IllegalTransactionStateException.class);
var source=transaction.execute(s->candidates.lockAndResolve(context(),ref));
transaction.executeWithoutResult(s->quotes.update(quote.quoteNo(),"1",new FleetQuoteModels.Update(quote.recordVersion(),draft("SALE",1,new BigDecimal("15.00")))));
assertThat(source.quotedUnitPrice()).isEqualByComparingTo("0");assertThat(source.recordVersion()).isEqualTo(quote.recordVersion());
assertThatThrownBy(()->transaction.execute(s->candidates.lockAndResolve(context(),ref))).hasMessageContaining("重新选择");
}
}
@@ -0,0 +1,67 @@
package com.baiyue.lastleg.fleet.quote;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
import org.junit.jupiter.api.Test;
import org.springframework.transaction.annotation.*;
import org.springframework.security.access.prepost.PreAuthorize;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.util.*;
import static org.assertj.core.api.Assertions.*;
import static org.mockito.Mockito.*;
class FleetQuoteCandidateServiceTest {
private final ObjectMapper json=new ObjectMapper().registerModule(new JavaTimeModule());
private final FleetQuoteCandidateRepository repository=mock(FleetQuoteCandidateRepository.class);
private final FleetQuoteDirectory directory=mock(FleetQuoteDirectory.class);
private final FleetQuoteCandidateService service=new FleetQuoteCandidateService(repository,directory,json);
private final LocalDate day=LocalDate.of(2026,10,3);
private FleetQuoteCandidateModels.Context context(){return new FleetQuoteCandidateModels.Context(1L," LA ","ont8","USD",day,"PALLET");}
private FleetQuoteModels.Draft draft(String kind,long party,String currency,BigDecimal loose){return new FleetQuoteModels.Draft(kind,party,"LA","ONT8",BigDecimal.TEN,currency,BigDecimal.TEN,BigDecimal.ONE,loose,day,day,null);}
private FleetQuoteCandidateRepository.QuoteRow row(String number,String status,long version,FleetQuoteModels.Draft d)throws Exception{return new FleetQuoteCandidateRepository.QuoteRow(number,"客户名称快照",status,version,json.writeValueAsString(d));}
private FleetQuoteCandidateModels.Query query(int size){var c=context();return new FleetQuoteCandidateModels.Query(c.customerId(),c.originCity(),c.destinationCode(),c.currency(),c.effectiveOn(),c.priceBasis(),1,size);}
@Test void candidatesKeepZeroAndBothDateBoundariesButExcludeUnknownPurchaseCustomerAndCurrency()throws Exception{
when(repository.candidates(any())).thenReturn(List.of(row("FQ-ZERO","ACTIVE",7,draft("SALE",1,"USD",BigDecimal.ZERO)),
row("FQ-UNKNOWN","ACTIVE",1,draft("SALE",1,"USD",null)),row("FQ-PURCHASE","ACTIVE",1,draft("PURCHASE",1,"USD",BigDecimal.ONE)),
row("FQ-OTHER","ACTIVE",1,draft("SALE",2,"USD",BigDecimal.ONE)),row("FQ-CNY","ACTIVE",1,draft("SALE",1,"CNY",BigDecimal.ONE)),
row("FQ-OFF","INACTIVE",1,draft("SALE",1,"USD",BigDecimal.ONE)),row("FQ-NEXT","ACTIVE",3,draft("SALE",1,"USD",BigDecimal.TEN))));
var result=service.candidates(query(1));assertThat(result.total()).isEqualTo(2);assertThat(result.records()).hasSize(1);
assertThat(result.records().get(0).quotedUnitPrice()).isEqualByComparingTo("0");assertThat(result.records().get(0).recordVersion()).isEqualTo(7);
verify(directory,times(1)).name("SALE",1L);verify(repository).candidates(new FleetQuoteCandidateModels.Context(1L,"la","ONT8","USD",day,"PALLET"));
}
@Test void selectionRejectsStaleVersionsWrongBasisAndUnavailablePrice()throws Exception{
when(repository.lock("FQ")).thenReturn(row("FQ","ACTIVE",8,draft("SALE",1,"USD",BigDecimal.ZERO)));
assertThatThrownBy(()->service.lockAndResolve(context(),new FleetQuoteCandidateModels.QuoteRef("FQ",7L,"LOOSE_DELIVERY"))).hasMessageContaining("重新选择");
assertThatThrownBy(()->service.lockAndResolve(context(),new FleetQuoteCandidateModels.QuoteRef("FQ",8L,"FULL_DELIVERY"))).hasMessageContaining("计价项目");
when(repository.lock("FQ")).thenReturn(row("FQ","ACTIVE",8,draft("SALE",1,"USD",null)));
assertThatThrownBy(()->service.lockAndResolve(context(),new FleetQuoteCandidateModels.QuoteRef("FQ",8L,"LOOSE_DELIVERY"))).hasMessageContaining("尚未报价");
}
@Test void resolvedSnapshotRetainsSourcePriceInsteadOfInventingNegotiatedAmount()throws Exception{
when(repository.lock("FQ")).thenReturn(row("FQ","ACTIVE",8,draft("SALE",1,"USD",BigDecimal.ZERO)));
var result=service.lockAndResolve(context(),new FleetQuoteCandidateModels.QuoteRef("FQ",8L,"LOOSE_DELIVERY"));
assertThat(result.customerId()).isEqualTo("1");assertThat(result.customerName()).isEqualTo("客户名称快照");assertThat(result.quotedUnitPrice()).isEqualByComparingTo("0");
assertThat(result.priceItem()).isEqualTo("LOOSE_DELIVERY");assertThat(result.effectiveOn()).isEqualTo(day);
}
@Test void selectionRejectsChangedRouteCustomerCurrencyDateAndStatus()throws Exception{
var ref=new FleetQuoteCandidateModels.QuoteRef("FQ",8L,"LOOSE_DELIVERY");
when(repository.lock("FQ")).thenReturn(row("FQ","ACTIVE",8,draft("SALE",1,"USD",BigDecimal.ONE)));
for(var c:List.of(new FleetQuoteCandidateModels.Context(2L,"LA","ONT8","USD",day,"PALLET"),new FleetQuoteCandidateModels.Context(1L,"SF","ONT8","USD",day,"PALLET"),
new FleetQuoteCandidateModels.Context(1L,"LA","LAX9","USD",day,"PALLET"),new FleetQuoteCandidateModels.Context(1L,"LA","ONT8","CNY",day,"PALLET"),
new FleetQuoteCandidateModels.Context(1L,"LA","ONT8","USD",day.plusDays(1),"PALLET")))assertThatThrownBy(()->service.lockAndResolve(c,ref)).hasMessageContaining("不匹配");
when(repository.lock("FQ")).thenReturn(row("FQ","INACTIVE",8,draft("SALE",1,"USD",BigDecimal.ONE)));
assertThatThrownBy(()->service.lockAndResolve(context(),ref)).hasMessageContaining("不匹配");
}
@Test void selectionRequiresOwnersTransactionAndReadEndpointRequiresQuotePermission()throws Exception{
var method=FleetQuoteCandidateService.class.getMethod("lockAndResolve",FleetQuoteCandidateModels.Context.class,FleetQuoteCandidateModels.QuoteRef.class);
assertThat(method.getAnnotation(Transactional.class).propagation()).isEqualTo(Propagation.MANDATORY);
var query=PlatformFleetQuoteCandidateController.class.getMethod("candidates",FleetQuoteCandidateModels.Query.class);
assertThat(query.getAnnotation(PreAuthorize.class).value()).isEqualTo("hasAuthority('platform:fleet:quote:list')");
}
@Test void missingExplicitCityOrBusinessDateCannotFallBackToWarehouseNameOrSystemDate(){
var ref=new FleetQuoteCandidateModels.QuoteRef("FQ",0L,"LOOSE_DELIVERY");
assertThatThrownBy(()->service.lockAndResolve(new FleetQuoteCandidateModels.Context(1L," ","ONT8","USD",day,"PALLET"),ref)).hasMessageContaining("明确客户、路线");
assertThatThrownBy(()->service.lockAndResolve(new FleetQuoteCandidateModels.Context(1L,"LA","ONT8","USD",null,"PALLET"),ref)).hasMessageContaining("服务日期");
verifyNoInteractions(repository,directory);
}
}
@@ -0,0 +1,61 @@
package com.baiyue.lastleg.fleet.trip;
import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.lastleg.fleet.driver.FleetWarehouseClient;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import java.time.LocalDateTime;
import java.util.List;
import static org.assertj.core.api.Assertions.*;
import static org.mockito.Mockito.*;
class FleetBookingQuoteContextTest {
private final FleetWarehouseClient client = mock(FleetWarehouseClient.class);
private final FleetBookingReservations reservations = mock(FleetBookingReservations.class);
private final FleetBookingQuoteContext resolver = new FleetBookingQuoteContext(client, reservations, "test-token");
private final FleetTripEntity trip = new FleetTripEntity();
@BeforeEach void prepare() {
trip.setWarehouseId(12L); trip.setWarehouseName("不能当城市的仓名");
trip.setPlannedDepartureAt(LocalDateTime.parse("2026-07-01T07:30:00"));
when(reservations.stops(trip)).thenReturn(List.of(new FleetTripModels.Stop("起点", null, null), new FleetTripModels.Stop("ONT8", null, "FBA:ONT8")));
warehouse(12L, "America/Los_Angeles", List.of(new FleetWarehouseClient.Address(12L, 0L, " Los Angeles ")));
}
void warehouse(long id, String timezone, List<FleetWarehouseClient.Address> addresses) {
when(client.activeDetail("test-token", 12L)).thenReturn(ApiResponse.success(new FleetWarehouseClient.Detail(
new FleetWarehouseClient.Identity(id, 0L, "OVERSEAS", "ACTIVE", timezone), addresses)));
}
@Test void usesWarehouseCityAndDaylightSavingZoneForTheDepartureDate() {
var value = resolver.resolve(trip, 101, "FBA:ONT8", "USD", "PALLET");
assertThat(value.originCity()).isEqualTo("los angeles");
assertThat(value.effectiveOn()).hasToString("2026-07-01");
assertThat(value.destinationCode()).isEqualTo("ONT8");
assertThat(value.customerId()).isEqualTo(101);
verify(client).activeDetail("test-token", 12L);
}
@Test void fixedOffsetIsUsedAsConfiguredWithoutGuessingFromCountry() {
warehouse(12, "UTC-8", List.of(new FleetWarehouseClient.Address(12L, 0L, "Los Angeles")));
assertThat(resolver.resolve(trip, 101, "FBA:ONT8", "USD", "ORDER").effectiveOn()).hasToString("2026-06-30");
}
@Test void rejectsUnmatchedWarehouseAndUnknownZone() {
warehouse(13, "UTC", List.of(new FleetWarehouseClient.Address(12L, 0L, "LA")));
assertThatThrownBy(() -> resolver.resolve(trip, 101, "FBA:ONT8", "USD", "PALLET")).hasMessageContaining("资料已变化");
warehouse(12, "unknown-zone", List.of(new FleetWarehouseClient.Address(12L, 0L, "LA")));
assertThatThrownBy(() -> resolver.resolve(trip, 101, "FBA:ONT8", "USD", "PALLET")).hasMessageContaining("时区尚未明确");
}
@Test void missingOrAmbiguousCityDoesNotFallBackToWarehouseName() {
warehouse(12, "UTC", List.of(new FleetWarehouseClient.Address(13L, 0L, "wrong warehouse")));
assertThatThrownBy(() -> resolver.resolve(trip, 101, "FBA:ONT8", "USD", "PALLET")).hasMessageContaining("城市尚未明确");
warehouse(12, "UTC", List.of(new FleetWarehouseClient.Address(12L, 0L, "LA"), new FleetWarehouseClient.Address(12L, 0L, "SF")));
assertThatThrownBy(() -> resolver.resolve(trip, 101, "FBA:ONT8", "USD", "PALLET")).hasMessageContaining("城市尚未明确");
}
@Test void routeMustBelongToThisTripAndPrivateAddressesAreNotGuessed() {
assertThatThrownBy(() -> resolver.resolve(trip, 101, "FBA:OTHER", "USD", "PALLET")).hasMessageContaining("已有卸货站");
when(reservations.stops(trip)).thenReturn(List.of(new FleetTripModels.Stop("起点", null, null), new FleetTripModels.Stop("私人地址", null, "PRIVATE:hash")));
assertThatThrownBy(() -> resolver.resolve(trip, 101, "PRIVATE:hash", "USD", "PALLET")).hasMessageContaining("手填");
verifyNoInteractions(client);
}
@Test void unavailableDirectoryNeverProducesInventedCriteria() {
when(client.activeDetail("test-token", 12L)).thenThrow(new IllegalStateException("network"));
assertThatThrownBy(() -> resolver.resolve(trip, 101, "FBA:ONT8", "USD", "PALLET")).hasMessageContaining("503");
}
}
@@ -42,7 +42,7 @@ class FleetTripApiContractTest {
count++;
}
}
assertThat(count).isEqualTo(28);
assertThat(count).isEqualTo(29);
verifyRefs(document, document);
}
@@ -63,9 +63,18 @@ class FleetTripApiContractTest {
private static void verifyRefs(Object node, Map<String, Object> root) {
if (node instanceof Map<?, ?> map) {
if (map.get("$ref") instanceof String reference) {
assertThat(reference).startsWith("#/");
Object value = root;
for (String key : reference.substring(2).split("/")) value = ((Map<?, ?>) value).get(key);
String pointer=reference;
if(!reference.startsWith("#/")) {
String[] parts=reference.split("#",2);
assertThat(parts).hasSize(2);
assertThat(parts[0]).isEqualTo("fleet-quote-candidates-v1.yaml");
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); }
pointer="#"+parts[1];
}
assertThat(pointer).startsWith("#/");
for (String key : pointer.substring(2).split("/")) value = ((Map<?, ?>) value).get(key);
assertThat(value).as(reference).isNotNull();
}
map.values().forEach(value -> verifyRefs(value, root));
@@ -12,6 +12,19 @@ import static org.assertj.core.api.Assertions.*;
import static org.mockito.Mockito.*;
class FleetTripAuthorizationTest {
@Test void bookingQuoteCandidatesRequireBothTripAndQuoteReadPermissions() {
try(var context=new AnnotationConfigApplicationContext(Config.class)) {
var controller=context.getBean(PlatformFleetBookingController.class);
var query=new FleetBookingModels.QuoteCandidatesQuery(9L,"FBA:ONT8","USD","PALLET",1,20);
for(var permission:List.of("platform:fleet:trip:list","platform:fleet:quote:list")) {
SecurityContextHolder.getContext().setAuthentication(new UsernamePasswordAuthenticationToken("operator","unused",List.of(new SimpleGrantedAuthority(permission))));
assertThatThrownBy(()->controller.quoteCandidates("TR-TEST",query)).isInstanceOf(AccessDeniedException.class);
}
SecurityContextHolder.getContext().setAuthentication(new UsernamePasswordAuthenticationToken("operator","unused",List.of(new SimpleGrantedAuthority("platform:fleet:trip:list"),new SimpleGrantedAuthority("platform:fleet:quote:list"))));
controller.quoteCandidates("TR-TEST",query);
verify(context.getBean(FleetBookingService.class)).quoteCandidates("TR-TEST",query);
} finally { SecurityContextHolder.clearContext(); }
}
@Test void listPermissionCannotReserveOrReleaseResources() {
try (var context = new AnnotationConfigApplicationContext(Config.class)) {
SecurityContextHolder.getContext().setAuthentication(new UsernamePasswordAuthenticationToken("operator", "unused", List.of(new SimpleGrantedAuthority("platform:fleet:trip:list"))));
@@ -23,6 +23,11 @@ import com.baiyue.lastleg.masterdata.vehicle.application.port.out.VehicleReposit
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.AfterEach;
import com.baiyue.lastleg.fleet.quote.FleetQuoteCandidateModels;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.junit.jupiter.api.Test;
import org.mybatis.spring.SqlSessionTemplate;
import org.springframework.aop.framework.ProxyFactory;
@@ -60,7 +65,20 @@ class FleetTripPersistenceTest {
private FleetTripCompletionService completion;
private FleetBookingService bookingService;
private FleetBookingIncomingGateway incoming;
private FleetBookingQuoteContext quoteContext;
private com.baiyue.lastleg.fleet.quote.FleetQuoteCandidateService quoteCandidates;
private Instant departure, end;
@AfterEach void clearAuthentication() { SecurityContextHolder.clearContext(); }
private FleetQuoteCandidateModels.SourceSnapshot enableQuote() {
SecurityContextHolder.getContext().setAuthentication(new UsernamePasswordAuthenticationToken("1","unused",List.of(new SimpleGrantedAuthority("platform:fleet:quote:list"))));
var context=new FleetQuoteCandidateModels.Context(9L,"Los Angeles","ONT8","USD",LocalDate.of(2026,10,3),"PALLET");
when(quoteContext.resolve(any(),eq(9L),eq("FBA:ONT8"),eq("USD"),eq("PALLET"))).thenReturn(context);
var source=new FleetQuoteCandidateModels.SourceSnapshot("FQ-TEST",2L,"LOOSE_DELIVERY","9","外接客户","Los Angeles","ONT8",new BigDecimal("30"),"USD","PALLET",BigDecimal.ZERO,LocalDate.of(2026,10,1),LocalDate.of(2026,10,31),context.effectiveOn());
when(quoteCandidates.lockAndResolve(eq(context),any())).thenReturn(source);
return source;
}
private static FleetQuoteCandidateModels.QuoteRef quoteReference(){return new FleetQuoteCandidateModels.QuoteRef("FQ-TEST",2L,"LOOSE_DELIVERY");}
@BeforeEach void initialize() throws Exception {
departure = Instant.now().plus(1, ChronoUnit.HOURS).truncatedTo(ChronoUnit.MILLIS);
@@ -70,7 +88,8 @@ class FleetTripPersistenceTest {
String schema = Files.readString(Path.of("src/main/resources/db/lastleg.sql"));
for (String table : List.of("ll_fleet_driver", "ll_fleet_driver_history", "ll_vehicle", "ll_fleet_trip", "ll_fleet_resource_assignment", "ll_fleet_trip_history",
"ll_fleet_external_booking", "ll_fleet_trip_dispatch", "ll_dispatch_order", "ll_dispatch_pda_task", "ll_outbound_order", "ll_delivery_order",
"ll_dispatch_item", "ll_dispatch_pda_pick", "ll_dispatch_pda_operation", "ll_dispatch_pallet_lock", "ll_delivery_operation")) {
"ll_dispatch_item", "ll_dispatch_pda_pick", "ll_dispatch_pda_operation", "ll_dispatch_pallet_lock", "ll_delivery_operation",
"ll_outbound_pallet_item", "ll_exception_order", "ll_exception_return_item", "ll_inventory_ledger", "ll_dispatch_pda_pick_order")) {
var match = Pattern.compile("(?s)CREATE TABLE(?: IF NOT EXISTS)? " + table + " \\(.*?;").matcher(schema);
assertThat(match.find()).as(table).isTrue();
jdbc.execute(match.group().replace(" JSON", " LONGTEXT"));
@@ -119,7 +138,9 @@ class FleetTripPersistenceTest {
when(customerDirectory.required(anyLong())).thenAnswer(call -> new com.baiyue.lastleg.fleet.customer.FleetCustomerDirectory.Customer(
Long.toString(call.getArgument(0,Long.class)), "00001", "外接客户", null, null));
incoming=mock(FleetBookingIncomingGateway.class);
bookingService = transactional(new FleetBookingService(session.getMapper(FleetTripMapper.class), bookingMapper, reservations, service, execution, customerDirectory, json, incoming), FleetBookingService.class);
quoteContext=mock(FleetBookingQuoteContext.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);
completion = transactional(new FleetTripCompletionService(session.getMapper(FleetTripMapper.class), bindingMapper, resources, dispatchGateway, service, vehicles, vehicleService, json), FleetTripCompletionService.class);
}
@@ -313,9 +334,10 @@ class FleetTripPersistenceTest {
}
@Test void externalReceiptChangesReservationOnlyAfterExplicitActualQuantityAndPriceConfirmation() {
var source=enableQuote();
String number=readyTrip();var basis=execution.capacityPlan(number);
execution.confirmCapacityPlan(number,"1",new FleetExecutionModels.Confirm(basis.recordVersion(),basis.manifestHash(),List.of(measurement()),"计划"));
var draft=bookingService.create(number,"1",new FleetBookingModels.Create(service.detail(number).trip().recordVersion(),9L,externalCargo(2),"receipt-create"));
var draft=bookingService.create(number,"1",new FleetBookingModels.Create(service.detail(number).trip().recordVersion(),9L,externalCargo(2),"receipt-create",quoteReference()));
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);
when(incoming.create(eq("1"),any())).thenReturn(awaiting);
@@ -336,6 +358,8 @@ class FleetTripPersistenceTest {
List.of(new FleetBookingModels.PalletSpaces("902",1)),new BigDecimal("55.00"),"accept-receipt"));
assertThat(accepted.booking().status()).isEqualTo("RECEIVED");assertThat(accepted.booking().cargo().packageCount()).isEqualTo(22);
assertThat(accepted.booking().agreedAmount()).isEqualByComparingTo("55.00");
assertThat(accepted.booking().quoteSource()).isEqualTo(source);
verify(quoteCandidates,times(2)).lockAndResolve(any(),eq(quoteReference()));
assertThat(execution.capacityPlan(number).availableSegments().get(0).remaining().palletSpaces()).isEqualTo(28);
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')");
@@ -351,7 +375,7 @@ class FleetTripPersistenceTest {
@Test void externalInquiryDoesNotReserveUntilConfirmedAndCancellationReleasesExactlyOnce() {
String number=readyTrip(); var actual=confirm(number); var basis=execution.capacityPlan(number);
execution.confirmCapacityPlan(number,"1",new FleetExecutionModels.Confirm(basis.recordVersion(),basis.manifestHash(),List.of(measurement()),"计划"));
var create=new FleetBookingModels.Create(service.detail(number).trip().recordVersion(),9L,externalCargo(10),"external-create-one");
var create=new FleetBookingModels.Create(service.detail(number).trip().recordVersion(),9L,externalCargo(10),"external-create-one",null);
var draft=bookingService.create(number,"1",create);
assertThat(bookingService.create(number,"1",create).booking().bookingNo()).isEqualTo(draft.booking().bookingNo());
assertThat(execution.capacityPlan(number).availableSegments().get(0).remaining().palletSpaces()).isEqualTo(29);
@@ -374,9 +398,9 @@ class FleetTripPersistenceTest {
@Test void secondExternalBookingCannotOversellAndOwnPlanCannotConsumeCommittedExternalSpace() {
String number=readyTrip();var basis=execution.capacityPlan(number);
execution.confirmCapacityPlan(number,"1",new FleetExecutionModels.Confirm(basis.recordVersion(),basis.manifestHash(),List.of(measurement()),"计划"));
var first=bookingService.create(number,"1",new FleetBookingModels.Create(service.detail(number).trip().recordVersion(),9L,externalCargo(20),"external-first"));
var first=bookingService.create(number,"1",new FleetBookingModels.Create(service.detail(number).trip().recordVersion(),9L,externalCargo(20),"external-first",null));
bookingService.confirm(number,first.booking().bookingNo(),"1",new FleetBookingModels.Command(first.tripRecordVersion(),0L,"confirm-first",null));
var second=bookingService.create(number,"1",new FleetBookingModels.Create(service.detail(number).trip().recordVersion(),10L,externalCargo(10),"external-second"));
var second=bookingService.create(number,"1",new FleetBookingModels.Create(service.detail(number).trip().recordVersion(),10L,externalCargo(10),"external-second",null));
assertThatThrownBy(()->bookingService.confirm(number,second.booking().bookingNo(),"1",new FleetBookingModels.Command(second.tripRecordVersion(),0L,"confirm-second",null))).hasMessageContaining("超过车辆");
assertThat(bookingService.list(number).records().get(1).status()).isEqualTo("DRAFT");
var excessive=new FleetExecutionModels.Measurement("901",11,new BigDecimal("100"),new BigDecimal("1"),1);
@@ -389,6 +413,44 @@ class FleetTripPersistenceTest {
}
private static FleetBookingModels.Cargo externalCargo(int spaces){return new FleetBookingModels.Cargo("FBA:ONT8","普通货",20,spaces,new BigDecimal("1000"),new BigDecimal("10"),"PALLET",new BigDecimal("50.00"),"USD",null,null,null);}
@Test void bookingSourceIsPersistedSeparatelyFromAgreedPriceAndCanBeExplicitlyCleared() {
var source=enableQuote();String number=assignedTrip();long version=service.detail(number).trip().recordVersion();
var request=new FleetBookingModels.Create(version,9L,externalCargo(2),"quote-create",quoteReference());
var created=bookingService.create(number,"1",request);
assertThat(bookingService.list(number).records().get(0).quoteSource()).isEqualTo(source);
assertThat(created.booking().agreedAmount()).isEqualByComparingTo("100.00");
assertThat(bookingService.create(number,"1",request).replayed()).isTrue();
verify(quoteCandidates,times(1)).lockAndResolve(any(),eq(quoteReference()));
assertThatThrownBy(()->bookingService.create(number,"1",new FleetBookingModels.Create(version,9L,externalCargo(2),"quote-create",null))).hasMessageContaining("同一登记请求");
var cleared=bookingService.update(number,created.booking().bookingNo(),"1",new FleetBookingModels.Update(created.tripRecordVersion(),0L,externalCargo(2),null));
assertThat(cleared.booking().quoteSource()).isNull();
assertThat(bookingService.list(number).records().get(0).quoteSource()).isNull();
assertThat(jdbc.queryForObject("SELECT quote_source_json FROM ll_fleet_external_booking",String.class)).isNull();
}
@Test void referencingOrClearingQuoteRequiresQuoteReadPermissionAndRollsBack() {
String number=assignedTrip();long version=service.detail(number).trip().recordVersion();
assertThatThrownBy(()->bookingService.create(number,"1",new FleetBookingModels.Create(version,9L,externalCargo(2),"denied-quote",quoteReference()))).isInstanceOf(ResponseStatusException.class).hasMessageContaining("报价读取权限");
assertThat(bookingService.list(number).records()).isEmpty();
var source=enableQuote();var created=bookingService.create(number,"1",new FleetBookingModels.Create(version,9L,externalCargo(2),"allowed-quote",quoteReference()));
SecurityContextHolder.clearContext();
assertThatThrownBy(()->bookingService.update(number,created.booking().bookingNo(),"1",new FleetBookingModels.Update(created.tripRecordVersion(),0L,externalCargo(2),null))).hasMessageContaining("报价读取权限");
assertThat(bookingService.list(number).records().get(0).quoteSource()).isEqualTo(source);
assertThat(service.detail(number).trip().recordVersion()).isEqualTo(created.tripRecordVersion());
}
@Test void confirmRevalidatesQuoteAndDoesNotReserveWhenSourceIsStale() {
var source=enableQuote();String number=readyTrip();var basis=execution.capacityPlan(number);
execution.confirmCapacityPlan(number,"1",new FleetExecutionModels.Confirm(basis.recordVersion(),basis.manifestHash(),List.of(measurement()),"计划"));
var created=bookingService.create(number,"1",new FleetBookingModels.Create(service.detail(number).trip().recordVersion(),9L,externalCargo(2),"stale-quote",quoteReference()));
when(quoteCandidates.lockAndResolve(any(),any())).thenThrow(new ResponseStatusException(org.springframework.http.HttpStatus.CONFLICT,"报价版本已变化"));
assertThatThrownBy(()->bookingService.confirm(number,created.booking().bookingNo(),"1",new FleetBookingModels.Command(created.tripRecordVersion(),0L,"confirm-stale",null))).hasMessageContaining("报价版本已变化");
var stored=bookingService.list(number).records().get(0);
assertThat(stored.status()).isEqualTo("DRAFT");assertThat(stored.recordVersion()).isZero();assertThat(stored.quoteSource()).isEqualTo(source);
assertThat(execution.capacityPlan(number).availableSegments().get(0).remaining().palletSpaces()).isEqualTo(29);
verify(quoteContext,times(2)).resolve(any(),eq(9L),eq("FBA:ONT8"),eq("USD"),eq("PALLET"));
}
@Test void capacityCanBePlannedBeforePickingWithoutCreatingActualLoadOrApprovals() {
String number = readyTrip();
jdbc.update("DELETE FROM ll_dispatch_pda_pick");
@@ -725,17 +787,18 @@ class FleetTripPersistenceTest {
jdbc.update("UPDATE ll_delivery_order SET status='CANCELLED' WHERE id=2012");
assertThatThrownBy(() -> completion.complete(number, "1", new FleetCompletionModels.Complete(service.detail(number).trip().recordVersion(), "incomplete-cargo", "核对"))).hasMessageContaining("DP-12");
assertThat(service.detail(number).trip().status()).isEqualTo("IN_TRANSIT");
jdbc.update("UPDATE ll_delivery_order SET status='DELIVERED', package_count=9 WHERE id=2012");
jdbc.update("UPDATE ll_delivery_order SET status='DELIVERED', package_count=9,signed_packages=9 WHERE id=2012");
assertThat(completion.inspection(number).blockReason()).contains("数量未覆盖");
assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM ll_fleet_resource_assignment", Integer.class)).isEqualTo(2);
}
@Test void returnedStatusRequiresImmutableReturnCompletionEvidence() {
String number = departedTrip(); markDeliveriesSigned();
jdbc.update("UPDATE ll_delivery_order SET status='RETURNED',signed_at=NULL WHERE id=2012");
jdbc.update("UPDATE ll_delivery_order SET status='RETURNED',signed_at=NULL,signed_packages=NULL WHERE id=2012");
assertThat(completion.inspection(number).canComplete()).isFalse();
LocalDateTime returnedAt = LocalDateTime.now().truncatedTo(ChronoUnit.MILLIS);
jdbc.update("INSERT INTO ll_delivery_operation(id,customer_id,delivery_order_id,operation_node,result,created_at) VALUES(1,'CLIENT-B',2012,'RETURN_COMPLETED','退回已完成',?)", returnedAt);
returnedCargo(10, returnedAt);
jdbc.update("UPDATE ll_delivery_order SET updated_at=? WHERE id=2012", returnedAt.plusDays(1));
assertThat(completion.inspection(number).canComplete()).isTrue();
var completed = completion.complete(number, "1", new FleetCompletionModels.Complete(service.detail(number).trip().recordVersion(), "return-completion", "签收及退回已核对")).trip();
@@ -790,7 +853,33 @@ class FleetTripPersistenceTest {
}
private void markDeliveriesSigned() {
jdbc.update("UPDATE ll_outbound_order SET package_count=10");
jdbc.update("UPDATE ll_delivery_order SET package_count=10,status='DELIVERED',signed_at=?", LocalDateTime.now().truncatedTo(ChronoUnit.MILLIS));
jdbc.update("UPDATE ll_delivery_order SET package_count=10,status='DELIVERED',signed_packages=10,signed_at=?", LocalDateTime.now().truncatedTo(ChronoUnit.MILLIS));
}
private void returnedCargo(int quantity, LocalDateTime at) {
jdbc.update("INSERT INTO ll_outbound_pallet_item(id,customer_id,outbound_order_id,warehouse_pallet_id,package_count) VALUES(6001,'CLIENT-B',1012,901,10)");
jdbc.update("INSERT INTO ll_exception_order(id,customer_id,exception_no,exception_type,status,reason,delivery_order_id,last_mile_order_id,exception_at) VALUES(6002,'CLIENT-B','EX-RETURN','RETURN','COMPLETED','真实退回',2012,6102,?)", at);
jdbc.update("INSERT INTO ll_exception_return_item(id,customer_id,exception_order_id,outbound_pallet_item_id,inventory_position_id,max_return_packages,approved_return_packages,received_packages,created_at) VALUES(6003,'CLIENT-B',6002,6001,4012,10,?,?,?)", quantity, quantity, at);
jdbc.update("INSERT INTO ll_dispatch_pda_pick_order(id,customer_id,pick_id,receipt_id,order_id,packages) VALUES(6005,'CLIENT-B',12,6103,6102,10)");
jdbc.update("INSERT INTO ll_inventory_ledger(id,customer_id,customer_name,balance_id,overseas_warehouse_id,inbound_order_id,business_type,business_order_no,outbound_order_no,source_event_id,idempotency_key,before_packages,delta_packages,after_packages,occurred_at) VALUES(6004,'CLIENT-B','客户B',6101,'92',6102,'RETURN_STOCK_IN','EX-RETURN','OUT-12','RETURN_SOURCE:6001:4012','receipt-unique',0,?,?,?)", quantity, quantity, at);
}
@Test void partialReturnCannotReleaseResourcesAndResidualSignatureClosesOnlyConservedQuantities() {
String number = departedTrip(); markDeliveriesSigned();
jdbc.update("UPDATE ll_delivery_order SET status='RETURN_PENDING',signed_at=NULL,signed_packages=NULL WHERE id=2012");
returnedCargo(4, LocalDateTime.now().truncatedTo(ChronoUnit.MILLIS));
assertThat(completion.inspection(number).canComplete()).isFalse();
assertThatThrownBy(() -> completion.release(number, "1", new FleetCompletionModels.Release(service.detail(number).trip().recordVersion(),
"VEHICLE", Instant.now(), "仓库", "IN_SERVICE", "partial-no-release", "仍有余货"))).hasMessageContaining("结案");
var signedAt = LocalDateTime.now().truncatedTo(ChronoUnit.MILLIS);
jdbc.update("UPDATE ll_delivery_order SET status='DELIVERED',signed_at=?,signed_packages=6 WHERE id=2012", signedAt);
var inspection = completion.inspection(number);
assertThat(inspection.canComplete()).isTrue();
var outcome = inspection.outcomes().stream().filter(row -> row.deliveryNo().equals("DL-12")).findFirst().orElseThrow();
assertThat(outcome.packages()).isEqualTo(10); assertThat(outcome.signedPackages()).isEqualTo(6);
assertThat(outcome.returnedPackages()).isEqualTo(4); assertThat(outcome.remainingPackages()).isZero();
completion.complete(number, "1", new FleetCompletionModels.Complete(service.detail(number).trip().recordVersion(), "partial-true-completion", "实退及余货实签核平"));
assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM ll_fleet_resource_assignment", Integer.class)).isEqualTo(2);
}
private String readyTrip() {
@@ -20,11 +20,15 @@ class Phase5ExceptionVisitSchemaContractTest {
assertThat(normalized).contains(
"ll_exception_order(",
"UNIQUEKEYuk_ll_exception_no(customer_id,exception_no)",
"UNIQUEKEYuk_ll_exception_delivery_return(customer_id,delivery_order_id,exception_type)",
"KEYidx_ll_exception_delivery_return(customer_id,delivery_order_id,exception_type)",
"return_context_jsonJSONNULL",
"received_packagesBIGINTNOTNULLDEFAULT0",
"CHECK(received_packages>=0ANDreceived_packages<=approved_return_packages)",
"KEYidx_ll_exception_inbound(customer_id,inbound_code)",
"ll_customer_visit(",
"UNIQUEKEYuk_ll_customer_visit_delivery(customer_id,delivery_order_id)",
"KEYidx_ll_customer_visit_list(customer_id,status,delivery_completed_at)");
assertThat(normalized).doesNotContain("UNIQUEKEYuk_ll_exception_delivery_return");
assertThat(sql).contains(
"COMMENT='海外仓异常订单'",
"COMMENT='派送单维度客户回访记录'");
@@ -42,7 +42,8 @@ class Phase5OverseasUpgradeSqlContractTest {
"customer_id", "outbound_type", "lx_codes", "request_hash");
assertThat(createStatement(overseas, "ll_outbound_pallet_item")).contains("picked_package_count");
assertThat(createStatement(overseas, "ll_delivery_order")).contains(
"customer_id", "overseas_warehouse_name", "waybill_no");
"customer_id", "overseas_warehouse_name", "waybill_no", "signed_packages");
assertThat(normalized).contains("check(signed_packagesisnullor(signed_packages>=0andsigned_packages<=package_count))");
assertThat(createStatement(overseas, "ll_dropship_order_profile")).contains(
"source_pallet_no", "manifest_version", "manifest_package_count",
"manifest_fragment_count", "manifest_hash");
@@ -45,6 +45,9 @@ test('车次直接成本按车次幂等并保留独立币种与不可变更正
})
test('车辆报价与实测记录保留业务去重和不可变版本', () => {
const booking = table(overseas, 'baiyue_lastleg', 'll_fleet_external_booking')
assert.match(booking, /quote_source_json JSON NULL/i)
assert.match(booking, /agreed_amount DECIMAL\(16,2\) NULL/i)
const quote = table(overseas, 'baiyue_lastleg', 'll_fleet_quote')
assert.match(quote, /UNIQUE KEY uk_fleet_quote_no \(quote_no\)/i)
assert.match(quote, /UNIQUE KEY uk_fleet_quote_natural \(natural_key\)/i)