diff --git a/deploy/sql/README.md b/deploy/sql/README.md index d552636ee..b17f7ea9a 100644 --- a/deploy/sql/README.md +++ b/deploy/sql/README.md @@ -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`,服务启动不执行这些文件。 diff --git a/deploy/sql/海外区域统一增量-MySQL8.sql b/deploy/sql/海外区域统一增量-MySQL8.sql index 677e05e0b..02edb3802 100644 --- a/deploy/sql/海外区域统一增量-MySQL8.sql +++ b/deploy/sql/海外区域统一增量-MySQL8.sql @@ -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 '约定金额,非已入账应收或实收', diff --git a/lastleg-service/AGENTS.md b/lastleg-service/AGENTS.md index 9bcf01a0b..d2f0489c0 100644 --- a/lastleg-service/AGENTS.md +++ b/lastleg-service/AGENTS.md @@ -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/`。 diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/entity/DeliveryOrder.java b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/entity/DeliveryOrder.java index 2197f002c..a8f275028 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/entity/DeliveryOrder.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/entity/DeliveryOrder.java @@ -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; diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/mapper/DeliveryOperationMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/mapper/DeliveryOperationMapper.java index cbb6e2f31..f6ccb1afa 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/mapper/DeliveryOperationMapper.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/mapper/DeliveryOperationMapper.java @@ -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 { + + @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 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") diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/model/DeliveryCargoQuantities.java b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/model/DeliveryCargoQuantities.java new file mode 100644 index 000000000..51e4673db --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/model/DeliveryCargoQuantities.java @@ -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 receipts) { + if (order.getPackageCount() == null || order.getPackageCount() <= 0) throw invalid("派送缺少实发数量"); + var ids = new HashSet(); + 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); } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/model/DeliveryReturnReceiptFact.java b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/model/DeliveryReturnReceiptFact.java new file mode 100644 index 000000000..917bbe869 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/model/DeliveryReturnReceiptFact.java @@ -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) { } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/model/DeliveryStatusPolicy.java b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/model/DeliveryStatusPolicy.java index 1b6b01368..6ffb102b6 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/model/DeliveryStatusPolicy.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/model/DeliveryStatusPolicy.java @@ -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(); }; } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/service/DeliveryCompletedOutboxService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/service/DeliveryCompletedOutboxService.java index 9d6dc5565..954dd8d36 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/service/DeliveryCompletedOutboxService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/service/DeliveryCompletedOutboxService.java @@ -33,9 +33,13 @@ public class DeliveryCompletedOutboxService { @Resource private LastlegEventOutboxMapper outbox; @Resource private ObjectMapper objectMapper; - public void publish(DeliveryOrder delivery, List 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 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 bySourceInbound = new LinkedHashMap<>(); for (LastlegOrder order : orders == null ? List.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) { diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/service/DeliveryDocumentService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/service/DeliveryDocumentService.java index d888a6b5f..295c44f16 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/service/DeliveryDocumentService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/service/DeliveryDocumentService.java @@ -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 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) { diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/fleet/DispatchFleetAdapter.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/fleet/DispatchFleetAdapter.java index 0c5a97102..fdabb5351 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/fleet/DispatchFleetAdapter.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/fleet/DispatchFleetAdapter.java @@ -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.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 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()); } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/exceptionorder/entity/ExceptionOrder.java b/lastleg-service/src/main/java/com/baiyue/lastleg/exceptionorder/entity/ExceptionOrder.java index 653b11372..48dde881a 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/exceptionorder/entity/ExceptionOrder.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/exceptionorder/entity/ExceptionOrder.java @@ -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; diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/exceptionorder/entity/ExceptionReturnItem.java b/lastleg-service/src/main/java/com/baiyue/lastleg/exceptionorder/entity/ExceptionReturnItem.java index 24bf07152..6de63567f 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/exceptionorder/entity/ExceptionReturnItem.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/exceptionorder/entity/ExceptionReturnItem.java @@ -19,5 +19,6 @@ public class ExceptionReturnItem { private String skuCode; private Long maxReturnPackages; private Long approvedReturnPackages; + private Long receivedPackages; private LocalDateTime createdAt; } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/exceptionorder/mapper/ExceptionOrderMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/exceptionorder/mapper/ExceptionOrderMapper.java index be96b379c..32659e2b6 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/exceptionorder/mapper/ExceptionOrderMapper.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/exceptionorder/mapper/ExceptionOrderMapper.java @@ -9,7 +9,7 @@ import org.apache.ibatis.annotations.Select; @Mapper public interface ExceptionOrderMapper extends BaseMapper { @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); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/exceptionorder/mapper/ExceptionReturnItemMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/exceptionorder/mapper/ExceptionReturnItemMapper.java index 0b29a66e7..c7cf170dd 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/exceptionorder/mapper/ExceptionReturnItemMapper.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/exceptionorder/mapper/ExceptionReturnItemMapper.java @@ -13,4 +13,16 @@ public interface ExceptionReturnItemMapper extends BaseMapper 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); } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/exceptionorder/service/ExceptionOrderService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/exceptionorder/service/ExceptionOrderService.java index 4bb787b6c..d6da45553 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/exceptionorder/service/ExceptionOrderService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/exceptionorder/service/ExceptionOrderService.java @@ -130,7 +130,8 @@ public class ExceptionOrderService { public List 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 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 stored = List.of(); if ("RETURN".equals(order.getExceptionType()) && "OVERSEAS_WAREHOUSE".equals(order.getReturnDirection())) { - List 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 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 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 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 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 allowedActions(ExceptionOrder order) { return switch (Objects.requireNonNullElse(order.getStatus(), "")) { case "PENDING" -> List.of("START_PROCESSING", "CANCEL_EXCEPTION"); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/exceptionorder/service/ReturnReceiptPolicy.java b/lastleg-service/src/main/java/com/baiyue/lastleg/exceptionorder/service/ReturnReceiptPolicy.java new file mode 100644 index 000000000..e8336d790 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/exceptionorder/service/ReturnReceiptPolicy.java @@ -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 quantities(List sources, List receipts) { + Map 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 items) { + return !items.isEmpty() && items.stream().allMatch(item -> received(item) == item.getApprovedReturnPackages()); + } + private static ResponseStatusException conflict(String message) { return new ResponseStatusException(HttpStatus.CONFLICT, message); } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/driver/FleetWarehouseClient.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/driver/FleetWarehouseClient.java index 7b53de839..82770b304 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/driver/FleetWarehouseClient.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/driver/FleetWarehouseClient.java @@ -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> activeWarehouses(@RequestHeader("X-Internal-Service-Token") String token); + @GetMapping("/internal/warehouses/0/{warehouseId}/active") + ApiResponse 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
addresses) { } } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/exception/FleetHandoverPdaQuery.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/exception/FleetHandoverPdaQuery.java index 2c6cdd9af..2e0a9966f 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/exception/FleetHandoverPdaQuery.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/exception/FleetHandoverPdaQuery.java @@ -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; diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/exception/FleetHandoverPolicy.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/exception/FleetHandoverPolicy.java index 8be21f895..bef114894 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/exception/FleetHandoverPolicy.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/exception/FleetHandoverPolicy.java @@ -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) { } + public record ReturnedCargo(String itemId, String palletId, int packages, Instant receivedAt) { } public record Pallet(String palletId, Load measuredLoad) { } public record Inspection(List remainingCargo, List completedDeliveries, Set remainingPalletIds, Load occupied) { } @@ -48,6 +49,7 @@ public final class FleetHandoverPolicy { throw invalid("同一实托包含不同卸货目的地,请先核对原始装载"); } Map byDestination = new HashMap<>(); + Map returned = new HashMap<>(); Set deliveryKeys = new HashSet<>(); List 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 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 palletIds = new TreeSet<>(); remaining.forEach(cargo -> palletIds.add(cargo.palletId())); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/quote/FleetQuoteCandidateModels.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/quote/FleetQuoteCandidateModels.java new file mode 100644 index 000000000..4f3efa6c9 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/quote/FleetQuoteCandidateModels.java @@ -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) { } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/quote/FleetQuoteCandidateRepository.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/quote/FleetQuoteCandidateRepository.java new file mode 100644 index 000000000..48feedf7a --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/quote/FleetQuoteCandidateRepository.java @@ -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 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 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); + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/quote/FleetQuoteCandidateService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/quote/FleetQuoteCandidateService.java new file mode 100644 index 000000000..b0a31a79b --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/quote/FleetQuoteCandidateService.java @@ -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 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(); + 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);} +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/quote/PlatformFleetQuoteCandidateController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/quote/PlatformFleetQuoteCandidateController.java new file mode 100644 index 000000000..dffeffd21 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/quote/PlatformFleetQuoteCandidateController.java @@ -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> candidates(@Valid FleetQuoteCandidateModels.Query query){return ApiResponse.success(service.candidates(query));} +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetBookingEntity.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetBookingEntity.java index e01951ed1..fc212727d 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetBookingEntity.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetBookingEntity.java @@ -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; diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetBookingModels.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetBookingModels.java index d6e150d30..1016e380a 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetBookingModels.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetBookingModels.java @@ -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 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 records) { } } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetBookingQuoteContext.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetBookingQuoteContext.java new file mode 100644 index 000000000..0682f0f7a --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetBookingQuoteContext.java @@ -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(); + 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, "起运仓资料暂不可用,请重试"); } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetBookingReservations.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetBookingReservations.java index d05314d41..8f534866d 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetBookingReservations.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetBookingReservations.java @@ -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 segments(FleetTripEntity trip, FleetExecutionModels.PlannedLoad own, Long excludedId, FleetBookingEntity addition) { var stops=stops(trip); var loads=new ArrayList(); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetBookingService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetBookingService.java index b53a8bcce..5efcb2111 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetBookingService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetBookingService.java @@ -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 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("请选择本车已有卸货站,外接货首版从本起运仓装车"); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetCompletionModels.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetCompletionModels.java index 7893b8bdf..e3b6ef2f8 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetCompletionModels.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetCompletionModels.java @@ -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 outcomes, String blockReason) { } public record Inspection(String tripNo, long recordVersion, boolean canComplete, String blockReason, List outcomes) { } public record Complete(@NotNull @Min(0) Long recordVersion, @NotBlank @Size(min = 8, max = 128) String idempotencyKey, diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetTripCompletionService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetTripCompletionService.java index d545d3b1b..580f9122a 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetTripCompletionService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetTripCompletionService.java @@ -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); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/PlatformFleetBookingController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/PlatformFleetBookingController.java index 239d31065..0bb897829 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/PlatformFleetBookingController.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/PlatformFleetBookingController.java @@ -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> customers(@RequestParam(required=false) String keyword){return ApiResponse.success(customers.options(keyword));} @GetMapping("/{tripNo}/bookings") @PreAuthorize("hasAuthority('platform:fleet:trip:list')") public ApiResponse 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> 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 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')") diff --git a/lastleg-service/src/main/resources/db/lastleg.sql b/lastleg-service/src/main/resources/db/lastleg.sql index f9fcca611..441e97d42 100644 --- a/lastleg-service/src/main/resources/db/lastleg.sql +++ b/lastleg-service/src/main/resources/db/lastleg.sql @@ -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='异常退回原出库库存冻结明细'; diff --git a/lastleg-service/src/main/resources/openapi/delivery-documents-v1.yaml b/lastleg-service/src/main/resources/openapi/delivery-documents-v1.yaml index fa84f738a..bf3b1b5fc 100644 --- a/lastleg-service/src/main/resources/openapi/delivery-documents-v1.yaml +++ b/lastleg-service/src/main/resources/openapi/delivery-documents-v1.yaml @@ -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: [] }] diff --git a/lastleg-service/src/main/resources/openapi/fleet-handovers-v1.yaml b/lastleg-service/src/main/resources/openapi/fleet-handovers-v1.yaml index c2bf97b16..a232bc1e1 100644 --- a/lastleg-service/src/main/resources/openapi/fleet-handovers-v1.yaml +++ b/lastleg-service/src/main/resources/openapi/fleet-handovers-v1.yaml @@ -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: diff --git a/lastleg-service/src/main/resources/openapi/fleet-quote-candidates-v1.yaml b/lastleg-service/src/main/resources/openapi/fleet-quote-candidates-v1.yaml new file mode 100644 index 000000000..7b866c833 --- /dev/null +++ b/lastleg-service/src/main/resources/openapi/fleet-quote-candidates-v1.yaml @@ -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;接入方负责写权限、幂等重放优先返回原结果和实际成交快照持久化。 diff --git a/lastleg-service/src/main/resources/openapi/fleet-trips-v1.yaml b/lastleg-service/src/main/resources/openapi/fleet-trips-v1.yaml index bb1460d92..860c605cd 100644 --- a/lastleg-service/src/main/resources/openapi/fleet-trips-v1.yaml +++ b/lastleg-service/src/main/resources/openapi/fleet-trips-v1.yaml @@ -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'} diff --git a/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml b/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml index c91b76114..7a0c3751c 100644 --- a/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml +++ b/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml @@ -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' } } } } diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/model/DeliveryCargoQuantitiesTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/model/DeliveryCargoQuantitiesTest.java new file mode 100644 index 000000000..607d842cf --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/model/DeliveryCargoQuantitiesTest.java @@ -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); } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/model/DeliveryStatusPolicyTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/model/DeliveryStatusPolicyTest.java index 94c949b2b..341485da5 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/model/DeliveryStatusPolicyTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/model/DeliveryStatusPolicyTest.java @@ -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(); } diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/service/DeliveryCompletedOutboxServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/service/DeliveryCompletedOutboxServiceTest.java index 679162bc0..8b5778182 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/service/DeliveryCompletedOutboxServiceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/service/DeliveryCompletedOutboxServiceTest.java @@ -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"); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/service/DeliveryDocumentServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/service/DeliveryDocumentServiceTest.java index fc320bcee..d0057df50 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/service/DeliveryDocumentServiceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/service/DeliveryDocumentServiceTest.java @@ -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 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)); } } diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/service/DeliveryReturnEvidencePersistenceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/service/DeliveryReturnEvidencePersistenceTest.java new file mode 100644 index 000000000..7104318a5 --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/service/DeliveryReturnEvidencePersistenceTest.java @@ -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(); + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/exceptionorder/service/ExceptionOrderDispatchReturnSourceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/exceptionorder/service/ExceptionOrderDispatchReturnSourceTest.java index 2d2063fbb..4e7ef7760 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/exceptionorder/service/ExceptionOrderDispatchReturnSourceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/exceptionorder/service/ExceptionOrderDispatchReturnSourceTest.java @@ -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); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/exceptionorder/service/ExceptionOrderLifecycleTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/exceptionorder/service/ExceptionOrderLifecycleTest.java index 5e828e45f..efd67f94a 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/exceptionorder/service/ExceptionOrderLifecycleTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/exceptionorder/service/ExceptionOrderLifecycleTest.java @@ -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 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); } diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/exceptionorder/service/ExceptionReturnConcurrencyTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/exceptionorder/service/ExceptionReturnConcurrencyTest.java new file mode 100644 index 000000000..98373940b --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/exceptionorder/service/ExceptionReturnConcurrencyTest.java @@ -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(); } + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/exceptionorder/service/ReturnReceiptPolicyTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/exceptionorder/service/ReturnReceiptPolicyTest.java new file mode 100644 index 000000000..233937ce7 --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/exceptionorder/service/ReturnReceiptPolicyTest.java @@ -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"); } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/exception/FleetHandoverPdaQueryTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/exception/FleetHandoverPdaQueryTest.java index bb95bc67f..a3671f2cc 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/exception/FleetHandoverPdaQueryTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/exception/FleetHandoverPdaQueryTest.java @@ -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))))); } diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/exception/FleetHandoverPolicyTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/exception/FleetHandoverPolicyTest.java index 27e0035ef..e49e87141 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/exception/FleetHandoverPolicyTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/exception/FleetHandoverPolicyTest.java @@ -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)); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/exception/FleetHandoverTransactionTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/exception/FleetHandoverTransactionTest.java index 29049855f..45c32b142 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/exception/FleetHandoverTransactionTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/exception/FleetHandoverTransactionTest.java @@ -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(), diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/exception/FleetTransitEvidenceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/exception/FleetTransitEvidenceTest.java index 141128e05..589f9155e 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/exception/FleetTransitEvidenceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/exception/FleetTransitEvidenceTest.java @@ -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); + } } diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/quote/FleetQuoteCandidatePersistenceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/quote/FleetQuoteCandidatePersistenceTest.java new file mode 100644 index 000000000..cae6c84bf --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/quote/FleetQuoteCandidatePersistenceTest.java @@ -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("重新选择"); + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/quote/FleetQuoteCandidateServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/quote/FleetQuoteCandidateServiceTest.java new file mode 100644 index 000000000..9951b2ab2 --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/quote/FleetQuoteCandidateServiceTest.java @@ -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); + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/trip/FleetBookingQuoteContextTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/trip/FleetBookingQuoteContextTest.java new file mode 100644 index 000000000..c8c0ba8c2 --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/trip/FleetBookingQuoteContextTest.java @@ -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 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"); + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/trip/FleetTripApiContractTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/trip/FleetTripApiContractTest.java index a1c84d7e0..74ab60280 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/trip/FleetTripApiContractTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/trip/FleetTripApiContractTest.java @@ -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 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)); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/trip/FleetTripAuthorizationTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/trip/FleetTripAuthorizationTest.java index 014c19c1d..0a4c54309 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/trip/FleetTripAuthorizationTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/trip/FleetTripAuthorizationTest.java @@ -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")))); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/trip/FleetTripPersistenceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/trip/FleetTripPersistenceTest.java index 9f2b847da..f9f2d93bf 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/trip/FleetTripPersistenceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/trip/FleetTripPersistenceTest.java @@ -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() { diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/schema/Phase5ExceptionVisitSchemaContractTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/schema/Phase5ExceptionVisitSchemaContractTest.java index 084c01f6c..004302dfe 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/schema/Phase5ExceptionVisitSchemaContractTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/schema/Phase5ExceptionVisitSchemaContractTest.java @@ -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='派送单维度客户回访记录'"); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/schema/Phase5OverseasUpgradeSqlContractTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/schema/Phase5OverseasUpgradeSqlContractTest.java index f27e39a69..b4082dc01 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/schema/Phase5OverseasUpgradeSqlContractTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/schema/Phase5OverseasUpgradeSqlContractTest.java @@ -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"); diff --git a/scripts/admin-test-env-modules/initialization-baseline-contract.test.mjs b/scripts/admin-test-env-modules/initialization-baseline-contract.test.mjs index c3a120b00..2bd6e285a 100644 --- a/scripts/admin-test-env-modules/initialization-baseline-contract.test.mjs +++ b/scripts/admin-test-env-modules/initialization-baseline-contract.test.mjs @@ -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)