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

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