Compare commits

..
Author SHA1 Message Date
ouyangxq a563f63857 feat(appointments): expose destination warehouse options 2026-10-19 17:02:25 +08:00
ouyangxq eb167347f6 test(transfer): prepare source snapshot before foreign relabel ownership check 2026-10-19 15:19:09 +08:00
ouyangxq c8608d50ba merge: retain current overseas inventory fixes with looseboard demand 2026-10-19 15:11:45 +08:00
ouyangxq dff040b509 feat(looseboard): require route contacts and pickup delivery times 2026-10-19 15:11:15 +08:00
liuyihe 26a203b4eb 提交 2026-10-19 15:00:25 +08:00
ouyangxq 21128b895f feat(trucking): refine truck demand contacts vehicle and service times 2026-10-19 14:34:33 +08:00
ouyangxq fafcf75014 fix(appointments): exclude pickup trips from delivery calendar 2026-10-19 12:21:57 +08:00
ouyangxq aa195cef66 feat(appointments): add four-type calendar sources and align release contracts 2026-10-19 12:15:43 +08:00
liuyihe fe1b2a6a46 提交 2026-10-19 11:54:29 +08:00
liuyihe e8f6bb32dc 提交 2026-10-19 10:23:09 +08:00
ouyangxq 45fbd6eff8 fix(lastleg): reconfirm received bookings after releasing all own cargo 2026-10-18 19:53:35 +08:00
ouyangxq 2c330bf9df test(lastleg): derive merchant relabel scope from profile receipt 2026-10-18 19:29:35 +08:00
ouyangxq 4bef8577fa test(lastleg): verify dispatch repair with current-run HTTP evidence 2026-10-18 19:21:12 +08:00
ouyangxq db4354bda8 fix(lastleg): align return receipt fixtures and schema totals 2026-10-18 18:45:47 +08:00
ouyangxq aa33514ec6 fix(lastleg): repair dispatch cancellation and return execution contracts 2026-10-18 16:34:14 +08:00
ouyangxq 66a0beb653 test(lastleg): mark diagnostic fixture mapper for registration contract 2026-10-18 11:49:47 +08:00
ouyangxq 5cd108b39a chore(lastleg): instrument appointment latency from restored 662dead baseline 2026-10-18 11:43:10 +08:00
ouyangxq b0acbfbb34 perf(lastleg): batch appointment requirement generation reads 2026-10-17 12:54:39 +08:00
ouyangxq 662dead053 chore(lastleg): diagnose work fact synchronization phases 2026-10-17 11:37:57 +08:00
ouyangxq 2971d6f7f0 perf(lastleg): reuse warehouse directory within sync generation 2026-10-17 09:57:54 +08:00
ouyangxq 81e4123b10 perf(lastleg): limit transport reconciliation facts and trace sync phases 2026-10-17 09:11:00 +08:00
ouyangxq 732cf99007 perf(lastleg): reuse work items within synchronization batch 2026-10-17 08:30:11 +08:00
ouyangxq 5fd74e9c8c perf(lastleg): batch receiving candidates and trace slow work invalidation 2026-10-17 07:49:56 +08:00
ouyangxq af97cd17d0 Merge production prebuild test fixes into frozen release 2026-10-16 18:44:42 +08:00
ouyangxq 5f5133bd68 test(lastleg): separate stable carton marks from return numbers 2026-10-16 18:44:32 +08:00
ouyangxq 339c70bb56 test(lastleg): isolate SQL semantic tests in bounded CI 2026-10-16 18:41:25 +08:00
ouyangxq e4bf573146 test: integrate production preflight contract correction 2026-10-16 18:14:49 +08:00
ouyangxq 4348296daf test(lastleg): validate shared appointment contract references 2026-10-16 18:14:37 +08:00
liuyihe 9ea03eb540 提交 2026-10-16 17:43:07 +08:00
liuyihe c9186c37a5 提交 2026-10-16 16:56:33 +08:00
liuyihe 6211d4ae37 提交 2026-10-16 16:53:32 +08:00
ouyangxq fee0268391 fix(appointment): link customer work to original batch requirement 2026-10-16 16:38:33 +08:00
ouyangxq 38a1f1079a Merge remote-tracking branch 'origin/test' into test 2026-10-16 16:21:51 +08:00
ouyangxq 58685945b1 fix(appointment): exclude cancelled batches without active allocations 2026-10-16 16:21:51 +08:00
211 changed files with 8897 additions and 918 deletions
+7
View File
@@ -285,9 +285,14 @@ services:
image: ${IMAGE_REPOSITORY}/lastleg-service:${IMAGE_TAG}
container_name: firstleg-${DEPLOY_ENV:-test}-lastleg
expose: ["9303"]
# 仅限制尾程服务,避免 JVM 异常时拖垮测试服务器.
mem_limit: 3g
memswap_limit: 3g
cpus: "2.0"
environment:
<<: *common-environment
SERVER_PORT: 9303
JAVA_TOOL_OPTIONS: "-Xms512m -Xmx1536m -XX:+ExitOnOutOfMemoryError -Dfile.encoding=UTF-8"
SELFOP_EXECUTION_ENABLED: ${SELFOP_EXECUTION_ENABLED:-false}
SELFOP_COLLECTION_MODE: ${SELFOP_COLLECTION_MODE:-OFF}
SELFOP_SOURCE_ACCOUNT: ${SELFOP_SOURCE_ACCOUNT:-lingxing-primary}
@@ -333,3 +338,5 @@ networks:
infra-network:
external: true
name: ${INFRA_NETWORK:-firstleg-${DEPLOY_ENV:-test}-infra-network}
+4 -2
View File
@@ -1,5 +1,7 @@
# 单测试环境数据库空库初始化
本土散板简化下单的当前结构同步于 Lastleg 源基线及海外统一空库基线:移除散板柜型与旧要求日期,增加商品名称、申报预计托数及当地取货/送达时间,移除运输方式,增加起运地及目的地各自的联系人和联系电话;四项联系信息、取货及送货时间均必填;打盘与运输安排允许未知。预计托数不代表实收。字段语义唯一见工作区 `docs/海外仓业务/README.md`,不回填测试存量,不提供自动重置或启动时 SQL;需与匹配 Lastleg、Admin 和 Client 一起发布。本说明不表示 SQL 已执行。
`migrations/overseas/baiyue_lastleg/V23__align_return_movement_collation.sql` 将退货进销存 UNION 涉及的八张表、22 个文本字段及表默认规则显式统一为 `utf8mb4_unicode_ci`,同步 Lastleg 两份源基线与海外统一空库基线。执行前核对字段长度、默认值、可空性及目标规则下唯一索引无冲突;只修改规则,不回填或删除数据,不重放空库基线。DDL 非事务,失败时停止并复查实际已完成项;可按最终定义重跑。脚本存在不表示测试库已执行,执行后用原始计数/分页查询验证,无需发布查询转换代码。
`migrations/overseas/baiyue_lastleg/V22__selfop_warehouse_daily_sales.sql` 新增自营仓库每日汇总表及销量版本 `warehouse_daily_ready` 标记。与匹配 Lastleg 一起发布;汇总随 SKU 日事实批次原子构建,完整性验证后方可发布。已有未构建汇总的销量版本须重新构建发布,不回填旧版本、不回退旧查询;空版本同样必须有就绪证据。源基线与海外空库基线同步。
@@ -45,7 +47,7 @@ V13 收货覆盖由 `lastleg-service/src/main/resources/db/appointment-receiving
| 区域 | 完整入口 | 服务 schema | 表数 |
|---|---|---|---:|
| 国内 | `头程数据库统一增量-MySQL8.sql` | `baiyue_auth`、`baiyue_iam`、`baiyue_order`、`baiyue_warehouse`、`baiyue_firstleg`、`baiyue_file`、`baiyue_audit`、`baiyue_finance` | 227 |
| 海外 | `海外区域统一增量-MySQL8.sql` | `baiyue_lastleg`、`baiyue_lastlag_warehouse` | 214 |
| 海外 | `海外区域统一增量-MySQL8.sql` | `baiyue_lastleg`、`baiyue_lastlag_warehouse` | 220 |
- 所有服务位于同一测试环境;两份文件只分组组织服务 schema,不代表两个部署区域。`baiyue_lastlag_warehouse` 保留当前正式的 `lastlag` 拼写。
@@ -68,7 +70,7 @@ V13 收货覆盖由 `lastleg-service/src/main/resources/db/appointment-receiving
5. DDL 隐式提交,不可整体回滚。失败后停止并保留错误;经另行授权准备空库后从头执行,不针对半初始化库追加迁移或从失败行续跑。
6. Firstleg 测量项与拆分项互为外键:建空表期间暂设会话 `foreign_key_checks=0`,随后立即恢复 `1`,再插入种子。失败时关闭连接。两边外键完整保留在 CREATE TABLE 内。
成功时只在最末输出一行 `initialization_status=PASS`,并带 `region=CN/OVERSEAS`、`initialized_tables=227/214`。必须同时确认客户端退出码 0;忽略错误后出现的 PASS 不算成功。
成功时只在最末输出一行 `initialization_status=PASS`,并带 `region=CN/OVERSEAS`、`initialized_tables=227/215`。必须同时确认客户端退出码 0;忽略错误后出现的 PASS 不算成功。
## 平台管理员密码
@@ -0,0 +1,2 @@
-- 对应数据库:baiyue_lastleg;可重复执行,不修改原箱号、SKU预报件数和实测数据。
ALTER TABLE ll_warehouse_transfer_carton MODIFY COLUMN transfer_item_id BIGINT NULL COMMENT '实际箱内首项SKU明细ID,预报补箱可为空';
+109 -17
View File
@@ -754,13 +754,17 @@ CREATE TABLE ll_exception_return_item (
customer_id varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '客户数据隔离键',
exception_order_id bigint NOT NULL COMMENT '异常订单ID',
outbound_pallet_item_id bigint NOT NULL COMMENT '原出库托盘明细ID',
inventory_position_id bigint NOT NULL COMMENT '原出库库存位置ID',
inventory_position_id bigint DEFAULT NULL COMMENT '普通原出库库存位置;与退货原箱互斥',
return_carton_id bigint DEFAULT NULL COMMENT '原生退货箱标识;与普通库存位置互斥',
return_carton_mark varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '原生退货箱业务箱码快照',
pallet_no varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '原出库托盘码快照',
sku_code varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '原出库SKU快照',
max_return_packages bigint NOT NULL COMMENT '原出库可退箱数上限',
approved_return_packages bigint NOT NULL COMMENT '批准退回箱数',
created_at datetime(3) NOT NULL COMMENT '创建时间',
received_packages BIGINT NOT NULL DEFAULT 0 COMMENT '已实际退回入仓的累计箱数',
CONSTRAINT chk_exception_return_source CHECK((inventory_position_id IS NOT NULL AND return_carton_id IS NULL) OR (inventory_position_id IS NULL AND return_carton_id IS NOT NULL)),
UNIQUE KEY uk_ll_exception_return_carton(customer_id,exception_order_id,outbound_pallet_item_id,return_carton_id),
CONSTRAINT chk_exception_return_received CHECK(received_packages>=0 AND received_packages<=approved_return_packages),
PRIMARY KEY (id),
@@ -768,6 +772,38 @@ CREATE TABLE ll_exception_return_item (
KEY idx_ll_exception_return_order (customer_id,exception_order_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='异常退回原出库库存冻结明细';
CREATE TABLE ll_return_carton_stock_receipt (
id BIGINT NOT NULL COMMENT '原箱回仓凭据明细标识',
customer_id VARCHAR(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '客户数据隔离键',
receipt_no VARCHAR(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '原箱回仓实收业务号',
exception_order_id BIGINT NOT NULL COMMENT '本次异常退回订单',
exception_return_item_id BIGINT NOT NULL COMMENT '本次冻结的原箱来源明细',
delivery_order_id BIGINT NOT NULL COMMENT '原运输派送单',
source_pick_id BIGINT NOT NULL COMMENT '原箱实际出库拣货凭据',
source_outbound_no VARCHAR(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '原出库单业务号',
source_dispatch_item_id BIGINT NOT NULL COMMENT '原排车货物明细',
source_outbound_pallet_item_id BIGINT NOT NULL COMMENT '原出库托盘明细',
return_carton_id BIGINT NOT NULL COMMENT '真实退货原箱标识',
carton_mark VARCHAR(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '原箱业务箱码快照',
overseas_warehouse_id VARCHAR(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '实际回仓海外仓标识',
target_pallet_id BIGINT NOT NULL COMMENT '实际回仓目标实物托盘',
target_pallet_no VARCHAR(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '实际目标托盘业务码',
target_location_id BIGINT NOT NULL COMMENT '实际回仓目标库位',
target_location_code VARCHAR(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '实际目标库位业务码',
sku_code VARCHAR(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '原出库快照中的商品编码',
product_name_zh VARCHAR(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL COMMENT '原出库快照中的中文品名',
quantity BIGINT NOT NULL COMMENT '本次原SKU实际恢复件数',
packages BIGINT NOT NULL COMMENT '本箱仅首条SKU记录计一箱其余为零',
evidence_file_ids_json JSON NOT NULL COMMENT '本次实收证据文件引用',
received_by VARCHAR(128) NOT NULL COMMENT '本次原箱实收确认操作人',
received_at DATETIME(3) NOT NULL COMMENT '实际原箱回仓入账时间',
PRIMARY KEY(id),
UNIQUE KEY uk_return_carton_receipt_source(customer_id,source_pick_id,sku_code),
KEY idx_return_carton_receipt_exception(customer_id,exception_order_id,received_at),
KEY idx_return_carton_receipt_delivery(customer_id,delivery_order_id),
CONSTRAINT chk_return_carton_receipt_quantity CHECK(quantity>0 AND packages IN (0,1))
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='原生退货箱按原出库SKU实际回仓的不可变凭据';
CREATE TABLE ll_finance_fee_command (
id bigint NOT NULL COMMENT '幂等命令ID',
operator_id varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '操作人ID',
@@ -1451,22 +1487,21 @@ CREATE TABLE ll_local_looseboard_order (
customer_id varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '客户租户ID',
lastleg_order_id bigint DEFAULT NULL COMMENT '关联尾程主订单ID',
order_no varchar(64) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '本土散板订单号',
transport_mode varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '运输方式:拼车或优先拼载',
pallet_required varchar(8) COLLATE utf8mb4_unicode_ci NULL DEFAULT NULL COMMENT '是否需要打盘:YES或NO;拼车来源未提供时为空',
transport_type varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '运输类型',
transport_type VARCHAR(32) NULL DEFAULT NULL COMMENT '接单运输安排;下单不预设',
origin varchar(255) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '起运地',
origin_address varchar(500) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '起运详细地址',
destination varchar(255) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '目的地',
destination_address varchar(500) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '目的详细地址、码头或仓号',
container_type varchar(32) COLLATE utf8mb4_unicode_ci NULL DEFAULT NULL COMMENT '柜型;拼车来源未提供时为空,不推断为整柜',
product_name VARCHAR(255) NULL COMMENT '商品名称', estimated_pallet_count INT NULL COMMENT '客户申报预计托数,不代表实际收货',
package_count int NOT NULL DEFAULT '0' COMMENT '货物件数',
total_weight_kg decimal(18,3) NOT NULL DEFAULT '0.000' COMMENT '总重量(千克)',
total_volume_cbm decimal(18,6) NOT NULL DEFAULT '0.000000' COMMENT '总体积(立方米)',
budget_amount decimal(18,2) DEFAULT NULL COMMENT '预算总价',
currency char(3) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'USD' COMMENT '预算币种',
contact_name varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '联系人姓名',
contact_info varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '联系人联系方式',
requested_date date DEFAULT NULL COMMENT '要求服务日期',
origin_contact_name VARCHAR(128) NULL COMMENT '起运地联系人', origin_contact_phone VARCHAR(128) NULL COMMENT '起运地联系电话',
destination_contact_name VARCHAR(128) NULL COMMENT '目的地联系人', destination_contact_phone VARCHAR(128) NULL COMMENT '目的地联系电话',
pickup_at DATETIME(3) NULL COMMENT '取货时间(当地时间,不转换时区)', delivery_at DATETIME(3) NULL COMMENT '送货时间(当地时间,不转换时区)',
status varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'PENDING_ACCEPTANCE' COMMENT '本土散板订单状态',
remark varchar(1000) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '备注',
idempotency_key varchar(255) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '创建幂等键',
@@ -1480,7 +1515,7 @@ CREATE TABLE ll_local_looseboard_order (
UNIQUE KEY uk_ll_local_looseboard_key (customer_id,idempotency_key),
UNIQUE KEY uk_ll_local_looseboard_lastleg (lastleg_order_id),
KEY idx_ll_local_looseboard_list (customer_id,status,created_at),
KEY idx_ll_local_looseboard_date (customer_id,requested_date,created_at)
KEY idx_ll_local_looseboard_date (customer_id,pickup_at,created_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='本土散板订单';
CREATE TABLE ll_monthly_exchange_rate (
@@ -2689,13 +2724,12 @@ CREATE TABLE ll_us_truck_order (
id bigint NOT NULL COMMENT '主键ID',
customer_id varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '客户租户ID',
order_no varchar(64) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '订单号',
transport_type varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '运输类型',
vehicle_type VARCHAR(32) NULL COMMENT '车辆类型:PALLET_15为15拖,PALLET_30为30拖;拼车未提供时为空',
origin varchar(255) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '起运地',
origin_address varchar(500) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '起运地址',
destination varchar(255) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '目的地',
destination_address varchar(500) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '目的地址',
container_type varchar(32) COLLATE utf8mb4_unicode_ci NULL DEFAULT NULL COMMENT '柜型;拼车来源未提供时为空,不推断为整柜',
pallet_count int NOT NULL DEFAULT '1' COMMENT '客户申报托盘数',
pallet_count INT NOT NULL DEFAULT 0 COMMENT '确认托盘数;独立下单首次实收确定,拼车来源取真实预报',
package_count int DEFAULT NULL COMMENT '可选箱数,零或空值均有效',
received_pallet_count int NOT NULL DEFAULT '0' COMMENT 'PDA实收托盘数',
allocated_pallet_count int NOT NULL DEFAULT '0' COMMENT '已分配到合车行程的托盘数',
@@ -2703,9 +2737,12 @@ CREATE TABLE ll_us_truck_order (
total_volume_cbm decimal(18,6) NOT NULL DEFAULT '0.000000' COMMENT '总体积(立方米)',
budget_amount decimal(18,2) DEFAULT NULL COMMENT '预算金额',
currency varchar(3) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'USD' COMMENT '币种',
contact_name varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '联系人姓名',
contact_info varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '联系人联系方式',
requested_date date NOT NULL COMMENT '要求服务日期',
origin_contact_name VARCHAR(128) NULL COMMENT '起运地联系人',
origin_contact_phone VARCHAR(128) NULL COMMENT '起运地联系电话',
destination_contact_name VARCHAR(128) NULL COMMENT '目的地联系人',
destination_contact_phone VARCHAR(128) NULL COMMENT '目的地联系电话',
pickup_at DATETIME NULL COMMENT '取货时间,按填写的日期时分保存,不转换时区',
delivery_at DATETIME NULL COMMENT '送货时间,按填写的日期时分保存;拼车来源未提供时为空',
status varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '美国卡车订单状态',
remark varchar(1000) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '备注',
idempotency_key varchar(255) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '业务幂等键',
@@ -2718,7 +2755,7 @@ CREATE TABLE ll_us_truck_order (
UNIQUE KEY uk_ll_us_truck_order_no (customer_id,order_no),
UNIQUE KEY uk_ll_us_truck_order_key (customer_id,idempotency_key),
KEY idx_ll_us_truck_order_list (customer_id,status,created_at),
KEY idx_ll_us_truck_order_date (customer_id,requested_date,created_at)
KEY idx_ll_us_truck_order_date (customer_id,pickup_at,created_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='美国卡车运输订单';
CREATE TABLE ll_us_truck_order_operation (
@@ -2892,6 +2929,7 @@ CREATE TABLE ll_fleet_vehicle_record_history (
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='车辆运维记录不可变历史';
CREATE TABLE ll_fleet_trip (
trip_purpose VARCHAR(16) NOT NULL DEFAULT 'DELIVERY' COMMENT 'DELIVERY出库派送 PICKUP上门取货入仓',
id BIGINT NOT NULL COMMENT '内部主键',
trip_no VARCHAR(32) NOT NULL COMMENT '车次业务编号',
idempotency_key VARCHAR(128) NOT NULL COMMENT '创建请求幂等键',
@@ -3270,7 +3308,7 @@ CREATE TABLE ll_warehouse_transfer_carton (
sku_items_json JSON NULL COMMENT '实收箱内SKU及逐项件数明细',
id bigint NOT NULL COMMENT '预报箱主键',
transfer_order_id bigint NOT NULL COMMENT '转运单ID',
transfer_item_id bigint NOT NULL COMMENT '转运申报明细ID',
transfer_item_id bigint DEFAULT NULL COMMENT '转运申报明细ID',
customer_id varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '客户租户ID',
carton_mark varchar(255) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '系统生成的一箱一码箱唛',
customer_carton_mark varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '客户原箱唛,用于换标现场绑定',
@@ -4621,4 +4659,58 @@ CREATE TABLE ll_compact_number_clock (
PRIMARY KEY (number_key)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='海外业务短单号分配时钟';
SELECT 'PASS' AS initialization_status, 'OVERSEAS' AS region, 214 AS initialized_tables;
-- 仓储转运上门取货表归尾程服务,仅用于空库初始化。
USE baiyue_lastleg;
CREATE TABLE ll_fleet_pickup_plan (
trip_no VARCHAR(64) NOT NULL COMMENT '现有车辆车次号',
request_hash VARCHAR(64) NOT NULL COMMENT '创建来源与计划摘要',
PRIMARY KEY(trip_no)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='仓储转运取货计划';
CREATE TABLE ll_fleet_pickup_task (
transfer_order_id BIGINT NOT NULL COMMENT '原仓储转运单ID,一票唯一',
customer_id VARCHAR(64) NOT NULL COMMENT '客户隔离键',
transfer_no VARCHAR(64) NOT NULL COMMENT '原仓储转运单号',
trip_no VARCHAR(64) NULL COMMENT '当前取货车次,解除后为空',
status VARCHAR(32) NOT NULL COMMENT 'WAITING待安排 ASSIGNED已安排 PICKED已取货 ARRIVED已送仓',
actual_packages INT NULL COMMENT '司机实提箱数,非仓库实收',
picked_at DATETIME NULL COMMENT '实际确认取货UTC时间',
arrived_at DATETIME NULL COMMENT '实际确认送仓UTC时间',
evidence_json LONGTEXT NULL COMMENT '本票已确认凭证元数据',
remark VARCHAR(1000) NULL COMMENT '取货备注',
updated_at DATETIME NOT NULL COMMENT '最近修改UTC时间',
PRIMARY KEY(transfer_order_id),
UNIQUE KEY uk_pickup_source(customer_id,transfer_no),
KEY idx_pickup_trip(trip_no,status)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='仓储转运逐票取货执行';
CREATE TABLE ll_fleet_pickup_command (
trip_no VARCHAR(64) NOT NULL COMMENT '取货车次号',
request_key VARCHAR(128) NOT NULL COMMENT '成功命令幂等键',
request_hash VARCHAR(64) NOT NULL COMMENT '命令动作与内容摘要',
PRIMARY KEY(trip_no,request_key)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='取货命令幂等记录';
CREATE TABLE ll_fleet_pickup_upload (
transfer_order_id BIGINT NOT NULL COMMENT '凭证所属原单',
trip_no VARCHAR(64) NOT NULL COMMENT '凭证所属车次',
file_id BIGINT NOT NULL COMMENT '平台归属文件ID',
PRIMARY KEY(file_id),
KEY idx_pickup_upload_source(transfer_order_id,trip_no)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='取货上传凭证精确单据归属';
CREATE TABLE ll_fleet_pickup_history (
id BIGINT NOT NULL AUTO_INCREMENT COMMENT '操作流水',
customer_id VARCHAR(64) NOT NULL COMMENT '客户隔离键',
transfer_no VARCHAR(64) NOT NULL COMMENT '原转运单号',
trip_no VARCHAR(64) NOT NULL COMMENT '当时取货车次号',
action VARCHAR(32) NOT NULL COMMENT '安排解除取货到仓动作',
operator_id VARCHAR(64) NOT NULL COMMENT '操作人',
occurred_at DATETIME NOT NULL COMMENT '操作UTC时间',
detail VARCHAR(2000) NULL COMMENT '操作说明',
PRIMARY KEY(id),
KEY idx_pickup_history_source(customer_id,transfer_no,id),
KEY idx_pickup_history_trip(trip_no,id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='取货不可变操作历史';
SELECT 'PASS' AS initialization_status, 'OVERSEAS' AS region, 220 AS initialized_tables;
Binary file not shown.

After

Width:  |  Height:  |  Size: 236 KiB

+90
View File
@@ -0,0 +1,90 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1800" height="1280" viewBox="0 0 1800 1280">
<defs>
<marker id="arrow" markerWidth="10" markerHeight="10" refX="9" refY="3" orient="auto"><path d="M0,0 L10,3 L0,6 Z" fill="#335c81"/></marker>
<marker id="greenArrow" markerWidth="10" markerHeight="10" refX="9" refY="3" orient="auto"><path d="M0,0 L10,3 L0,6 Z" fill="#18865b"/></marker>
<marker id="redArrow" markerWidth="10" markerHeight="10" refX="9" refY="3" orient="auto"><path d="M0,0 L10,3 L0,6 Z" fill="#c94d4d"/></marker>
<style>
.title{font:700 34px 'Microsoft YaHei','Noto Sans SC',sans-serif;fill:#17324d}
.sub{font:400 17px 'Microsoft YaHei','Noto Sans SC',sans-serif;fill:#547086}
.lane{font:700 20px 'Microsoft YaHei','Noto Sans SC',sans-serif;fill:#17324d}
.boxTitle{font:700 19px 'Microsoft YaHei','Noto Sans SC',sans-serif;fill:#17324d}
.boxText{font:400 16px 'Microsoft YaHei','Noto Sans SC',sans-serif;fill:#36566d}
.small{font:400 14px 'Microsoft YaHei','Noto Sans SC',sans-serif;fill:#547086}
.assert{font:500 16px 'Microsoft YaHei','Noto Sans SC',sans-serif;fill:#23445a}
.line{stroke:#335c81;stroke-width:3;fill:none;marker-end:url(#arrow)}
.gline{stroke:#18865b;stroke-width:3;fill:none;marker-end:url(#greenArrow)}
.rline{stroke:#c94d4d;stroke-width:3;fill:none;marker-end:url(#redArrow)}
</style>
</defs>
<rect width="1800" height="1280" fill="#f6f9fc"/>
<text x="70" y="62" class="title">海外业务|库存变动测试总流程图</text>
<text x="70" y="94" class="sub">测试看库存:先看库存状态,再看数量、位置、流水和幂等;灰色节点只改状态/资源,不直接改可用库存。</text>
<g transform="translate(70,125)">
<rect width="1660" height="58" rx="12" fill="#e9f1f8"/>
<circle cx="28" cy="29" r="10" fill="#18865b"/><text x="50" y="35" class="small">绿色:库存增加/恢复</text>
<circle cx="250" cy="29" r="10" fill="#c94d4d"/><text x="272" y="35" class="small">红色:库存扣减</text>
<circle cx="450" cy="29" r="10" fill="#e19a28"/><text x="472" y="35" class="small">橙色:冻结/释放</text>
<circle cx="690" cy="29" r="10" fill="#7657a8"/><text x="712" y="35" class="small">紫色:SKU/箱唛变化</text>
<circle cx="960" cy="29" r="10" fill="#78909c"/><text x="982" y="35" class="small">灰色:状态/资源变化</text>
<text x="1260" y="35" class="small">所有写操作:租户隔离 + 版本/锁 + 幂等键</text>
</g>
<g transform="translate(70,220)">
<rect width="1660" height="196" rx="16" fill="#edf5ee" stroke="#b7d9c2" stroke-width="2"/>
<text x="24" y="34" class="lane">A. 库存来源与入仓</text>
<rect x="24" y="58" width="240" height="96" rx="12" fill="#fff" stroke="#6ea984" stroke-width="2"/>
<text x="44" y="88" class="boxTitle">头程装柜完成事件</text><text x="44" y="116" class="boxText">生成尾程单 / 冻结明细</text><text x="44" y="140" class="small">在途:不可用库存</text>
<path d="M264 106 H330" class="line"/>
<rect x="330" y="58" width="250" height="96" rx="12" fill="#fff" stroke="#6ea984" stroke-width="2"/>
<text x="350" y="88" class="boxTitle">平台转换入仓计划</text><text x="350" y="116" class="boxText">按入仓单 / 推荐托数建计划</text><text x="350" y="140" class="small">仍未形成可用库存</text>
<path d="M580 106 H646" class="line"/>
<rect x="646" y="58" width="290" height="96" rx="12" fill="#fff" stroke="#18865b" stroke-width="3"/>
<text x="666" y="88" class="boxTitle">PDA 签收 → 实收 → 上架</text><text x="666" y="116" class="boxText">完成后入仓库存入账</text><text x="666" y="140" class="small">AVAILABLE + 位置 + 库存流水</text>
<path d="M936 106 H1002" class="gline"/>
<rect x="1002" y="58" width="280" height="96" rx="12" fill="#e6f6ed" stroke="#18865b" stroke-width="2"/>
<text x="1022" y="88" class="boxTitle">可用库存池</text><text x="1022" y="116" class="boxText">客户 × 海外仓 × 目的仓</text><text x="1022" y="140" class="small">入仓单汇总;托盘只做作业维度</text>
<rect x="1316" y="58" width="310" height="96" rx="12" fill="#f0f2f4" stroke="#9baab4" stroke-width="2"/>
<text x="1336" y="88" class="boxTitle">门禁与失败</text><text x="1336" y="116" class="boxText">照片、仓绑定、客户/柜号</text><text x="1336" y="140" class="small">任一失败:库存与状态整体回滚</text>
</g>
<g transform="translate(70,450)">
<rect width="1660" height="222" rx="16" fill="#fff8ec" stroke="#efd39a" stroke-width="2"/>
<text x="24" y="34" class="lane">B. 从可用库存出发的四类作业</text>
<rect x="24" y="60" width="360" height="126" rx="12" fill="#fff" stroke="#dfa43c" stroke-width="2"/>
<text x="44" y="88" class="boxTitle">排车 / 一件代发拣货</text><text x="44" y="116" class="boxText">AVAILABLE → 冻结 → PICKED</text><text x="44" y="144" class="boxText">发车/出库 → 扣减</text><text x="44" y="170" class="small">整托扫描也按入仓单组成核平</text>
<rect x="430" y="60" width="360" height="126" rx="12" fill="#fff" stroke="#7657a8" stroke-width="2"/>
<text x="450" y="88" class="boxTitle">换标</text><text x="450" y="116" class="boxText">冻结 hold_packages</text><text x="450" y="144" class="boxText">旧 SKU 转出 → 新 SKU 转入</text><text x="450" y="170" class="small">箱唛/托盘绑定变化,箱数守恒</text>
<rect x="836" y="60" width="360" height="126" rx="12" fill="#fff" stroke="#dfa43c" stroke-width="2"/>
<text x="856" y="88" class="boxTitle">仓储转运</text><text x="856" y="116" class="boxText">来源仓冻结/扣减</text><text x="856" y="144" class="boxText">目标仓收货 → 增加</text><text x="856" y="170" class="small">可联动换标;来源/目标核对</text>
<rect x="1242" y="60" width="384" height="126" rx="12" fill="#fff" stroke="#c94d4d" stroke-width="2"/>
<text x="1262" y="88" class="boxTitle">废弃</text><text x="1262" y="116" class="boxText">申请仅锁箱:DISCARD_PENDING</text><text x="1262" y="144" class="boxText">PDA 实际确认 → 扣减 → DISCARDED</text><text x="1262" y="170" class="small">驳回释放;平台结案不再扣一次</text>
</g>
<g transform="translate(70,706)">
<rect width="1660" height="222" rx="16" fill="#eef3fa" stroke="#b7cae2" stroke-width="2"/>
<text x="24" y="34" class="lane">C. 出库后的结果与恢复</text>
<rect x="24" y="60" width="350" height="126" rx="12" fill="#fff" stroke="#c94d4d" stroke-width="2"/>
<text x="44" y="88" class="boxTitle">OUTBOUNDED / 派送中</text><text x="44" y="116" class="boxText">库存已扣减,保留出库事实</text><text x="44" y="144" class="boxText">签收:不再二次扣库存</text><text x="44" y="170" class="small">派送费用与库存动作分开核对</text>
<path d="M374 123 H446" class="line"/>
<rect x="446" y="60" width="380" height="126" rx="12" fill="#fff" stroke="#18865b" stroke-width="3"/>
<text x="466" y="88" class="boxTitle">异常退回入仓</text><text x="466" y="116" class="boxText">只能选原出库拣货事实</text><text x="466" y="144" class="boxText">RETURN_STOCK_IN → 目标托盘/库位</text><text x="466" y="170" class="small">库存恢复 + 异常完成 + 派送 RETURNED</text>
<rect x="900" y="60" width="330" height="126" rx="12" fill="#fff" stroke="#78909c" stroke-width="2"/>
<text x="920" y="88" class="boxTitle">盘点调整</text><text x="920" y="116" class="boxText">实盘数量 − 账面数量 = 差异</text><text x="920" y="144" class="boxText">按正/负生成调整流水</text><text x="920" y="170" class="small">无差异不造流水;需 adjust 权限</text>
<rect x="1270" y="60" width="356" height="126" rx="12" fill="#fff" stroke="#78909c" stroke-width="2"/>
<text x="1290" y="88" class="boxTitle">移库 / 拆合托 / 换绑</text><text x="1290" y="116" class="boxText">同仓位置或托盘组成变化</text><text x="1290" y="144" class="boxText">数量净额 = 0</text><text x="1290" y="170" class="small">记录位置流水;资源锁防并发</text>
</g>
<path d="M1142 416 V450" class="gline"/><path d="M120 638 V706" class="rline"/><path d="M610 638 V706" class="gline"/>
<path d="M1020 638 V706" class="line"/><path d="M1435 638 V706" class="rline"/>
<g transform="translate(70,965)">
<rect width="1660" height="250" rx="16" fill="#ffffff" stroke="#d5e0e8" stroke-width="2"/>
<text x="24" y="36" class="lane">D. 测试统一断言(每个库存场景都要检查)</text>
<text x="42" y="78" class="assert">① 数量守恒:客户 / 海外仓 / 目的仓 / 入仓单 / 托盘组成逐层核平</text>
<text x="42" y="114" class="assert">② 三账一致:库存余额 = 位置明细汇总 = 库存流水结果</text>
<text x="42" y="150" class="assert">③ 幂等并发:重复事件、重复请求、并发锁定不得产生第二笔变动</text>
<text x="42" y="186" class="assert">④ 原子回滚:权限、门禁、资源锁、跨服务失败时,库存/单据/流水一起回滚</text>
<text x="42" y="222" class="assert">⑤ 隔离校验:客户、仓、来源入仓单、原出库事实不可伪造或串用</text>
</g>
</svg>

After

Width:  |  Height:  |  Size: 9.2 KiB

@@ -0,0 +1,40 @@
# 海外业务库存变动测试整理
范围依据:`lastleg-service/AGENTS.md`、`docs/backend-business-index.md` 及当前海外仓库存、入仓、出库、换标、转运、废弃、异常退回和 PDA 作业入口。
## 先统一库存口径
- **可用库存**:按客户、海外仓、目的仓、入仓单汇总箱数;SKU、箱唛和托盘是明细快照/作业维度。
- **冻结库存**:拣货、换标、废弃、转运等处理中暂时不可重复使用;冻结不等于已经扣减。
- **库存流水**:每一次入账、冻结、释放、扣减、恢复、SKU 转换和位置变化都应能按业务单号、幂等键追溯。
- **托盘不是库存归属**:合托后一个物理托盘可以包含多个入仓单,出库、换标和退回必须按托盘组成明细核平。
## 测试场景矩阵
| 场景 | 触发与前置 | 库存变动 | 测试重点 |
|---|---|---|---|
| 头程接柜/海外入仓 | 装柜完成事件 → 生成尾程单/入仓计划;PDA 签收、实收、上架、完成 | 在途冻结 → 入仓后 `AVAILABLE` 增加;写库存位置和流水 | 柜号/客户/入仓单隔离;卸柜照片门禁;重复事件不重复入账;失败整事务回滚 |
| 普通排车出库/派送 | 计划已入仓;排车审核、拣货、箱数核平、发车 | `AVAILABLE` → 冻结/已拣;原子出库后可用库存扣减 | 整托/部分托按组成明细核平;未审核、数量不平、车辆门禁时不得出库;同键重放幂等 |
| 一件代发 | 已完成入库且有可用箱唛/SKU;创建 `WAIT_PICK` 出库单 | SKU/箱唛库存冻结 → PDA 拣货 → `OUTBOUNDED` 扣减 | 候选只能来自真实可用库存;一单不得跨客户/跨仓;整托扫描任一明细失败必须整体回滚 |
| 客户换标 | 历史入仓库存可用;客户申请、PDA 扫旧 SKU/箱唛、完工、客户确认 | 旧 SKU/箱唛冻结;换标完成后旧库存转出、新 SKU/箱唛转入;库存总箱数守恒 | 同箱多 SKU 不重复冻结;只移动本单 `hold_packages`;已生成出库单禁止再次拆托;照片/复核/客户确认门禁 |
| 仓储转运 | 来源库存可用;创建转运、测量、换标(可选)、出库、目标仓收货 | 来源仓冻结/扣减 → 目标仓入账;换标时 SKU 转换 | 来源客户、入仓单和箱数核对;部分转运数量守恒;目标仓与来源仓不能串;重复完成不重复入账 |
| 货物废弃 | 一件代发/库存货物申请废弃;审批后由绑定海外 PDA 实际确认 | `AVAILABLE` → `DISCARD_PENDING`;实际确认后 SKU、余额、位置扣减并记 `DISCARDED` | 申请阶段只锁箱不扣库存;驳回释放;批准不是实际扣减;实际数量与逐箱流水一致;失败整批回滚 |
| 异常退回入仓 | 只能选择原出库拣货事实;异常单受理并完成退回 | 原出库来源核验 → `RETURN_STOCK_IN` 恢复到真实目标托盘/库位 | 不能伪造出库来源、借用其他客户主单;退回数量与原实发核平;异常单完成与派送 `RETURNED` 同事务 |
| PDA 盘点调整 | 绑定海外仓;盘点锁定库存位置并提交实盘数量 | 按差异产生增加/减少调整流水;无差异不产生虚假变动 | 需要 `inventory:adjust`;并发版本校验;差异正负方向正确;盘点重复提交幂等 |
| PDA 移库/托盘拆合/换绑 | 同仓有效托盘/库位;资源锁成功 | 数量净额不变,仅位置/托盘组成变化,写位置流水 | 不跨仓;托盘组成明细保留;资源锁释放;出库/换标占用时禁止绕过协调单独移动 |
## 不应误判为库存变动
- 目的仓修改、暂扣/解除、报价、排车预排、外接预约、车辆/司机资料:只改变状态、资格、资源占用或费用快照。
- 海外采购样品履约:按当前契约走 Admin 台账,**不接入 PDA,也不写 WMS 可用库存**。
- 退货申请/废弃申请/转派送建单:申请或建单阶段只形成冻结/候选/出库单,必须到实际收货、实际扣减或实际出库节点才确认数量变化。
## 每条库存用例的统一断言
1. 变动前后按客户、海外仓、目的仓、入仓单、托盘组成核对数量守恒。
2. 查询余额、位置明细和库存流水三处结果一致,且业务单号/幂等键可追溯。
3. 重复请求、重复事件、并发操作不得新增第二笔库存变动。
4. 任一校验、权限、资源锁或跨服务依赖失败时,库存、单据状态和流水一起回滚。
5. 客户越权、跨仓、跨客户、伪造来源入仓单/出库单均应拒绝。
流程图:`overseas-inventory-mutation-flow.svg` / `overseas-inventory-mutation-flow.png`
@@ -0,0 +1,28 @@
package com.baiyue.firstleg.terminaldrayage.controller;
import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.common.security.AuthenticatedUser;
import com.baiyue.firstleg.service.FirstlegScopeGuard;
import com.baiyue.firstleg.terminaldrayage.service.ContainerCalendarSourceService;
import org.springframework.format.annotation.DateTimeFormat;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.*;
import java.time.LocalDate;
@RestController
@RequestMapping("/api/platform/v1/terminal-drayages")
public class ContainerCalendarSourceController {
private final ContainerCalendarSourceService service;
/** 装配只读日历业务服务。 */
public ContainerCalendarSourceController(ContainerCalendarSourceService service) { this.service = service; }
/** 预约列表可读基础柜信息,还柜事实继续核验原业务读取权限。 */
@GetMapping("/calendar-sources")
@PreAuthorize("hasAuthority('platform:appointment:list')")
public ApiResponse<ContainerCalendarSourceService.Result> calendar(@AuthenticationPrincipal AuthenticatedUser user,
@RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate from,
@RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate to) {
FirstlegScopeGuard.platform(user);
return ApiResponse.success(service.calendar(from, to, user.permissions()));
}
}
@@ -0,0 +1,8 @@
package com.baiyue.firstleg.terminaldrayage.dto;
import java.time.LocalDateTime;
/** 四类日历使用的柜级来源,日期均保留原业务当地值。 */
public record ContainerCalendarSource(Long containerBookingId, String cabinetNo, String containerNo,
String vesselName, String voyageNo, String destinationPortName, String destinationWarehouseCode,
LocalDateTime arrivalAt, String arrivalType) { }
@@ -0,0 +1,44 @@
package com.baiyue.firstleg.terminaldrayage.mapper;
import com.baiyue.firstleg.terminaldrayage.dto.ContainerCalendarSource;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
import java.time.LocalDateTime;
import java.util.List;
@Mapper
public interface ContainerCalendarSourceMapper {
/** 同时选出到港、缺日期和可能还柜的有效出仓柜,最终日期由业务服务复核。 */
@Select("""
SELECT c.id containerBookingId,c.cabinet_no cabinetNo,c.container_no containerNo,
c.vessel_name vesselName,c.voyage_no voyageNo,c.destination_port_name destinationPortName,
c.destination_warehouse_code destinationWarehouseCode,
COALESCE(n.actual_at,c.actual_port_arrival_at,c.estimated_port_arrival_at) arrivalAt,
CASE WHEN COALESCE(n.actual_at,c.actual_port_arrival_at) IS NOT NULL THEN 'ACTUAL' ELSE 'ESTIMATED' END arrivalType
FROM fl_container_booking c
LEFT JOIN fl_terminal_drayage t ON t.container_booking_id=c.id
LEFT JOIN fl_carrier_schedule_node_fact n ON n.id=(
SELECT f.id FROM fl_carrier_schedule_node_fact f WHERE f.container_booking_id=c.id
AND f.standard_node='ARRIVED_DESTINATION_PORT' AND f.actual_at IS NOT NULL
ORDER BY f.observed_at DESC,f.id DESC LIMIT 1)
WHERE c.record_status='ACTIVE'
AND EXISTS(SELECT 1 FROM fl_outbound_dispatch d WHERE d.container_booking_id=c.id
AND d.confirmed_at IS NOT NULL AND d.reversed_at IS NULL AND d.status='WAITING_FOR_LOADING')
AND (
COALESCE(n.actual_at,c.actual_port_arrival_at,c.estimated_port_arrival_at) IS NULL
OR (COALESCE(n.actual_at,c.actual_port_arrival_at,c.estimated_port_arrival_at)>=#{arrivalFrom}
AND COALESCE(n.actual_at,c.actual_port_arrival_at,c.estimated_port_arrival_at)<#{to})
OR (t.actual_empty_return_at>=#{from} AND t.actual_empty_return_at<#{to})
OR (c.unloading_completed_at>=#{returnFrom} AND c.unloading_completed_at<#{to})
OR EXISTS(SELECT 1 FROM fl_carrier_schedule_node_fact r WHERE r.container_booking_id=c.id
AND r.standard_node='EMPTY_RETURNED' AND r.actual_at>=#{from} AND r.actual_at<#{to})
OR EXISTS(SELECT 1 FROM fl_direct_delivery_unloading u WHERE u.container_booking_id=c.id
AND u.actual_at>=#{returnFrom} AND u.actual_at<#{to})
)
ORDER BY c.id LIMIT 2001
""")
List<ContainerCalendarSource> sources(@Param("from") LocalDateTime from,
@Param("arrivalFrom") LocalDateTime arrivalFrom, @Param("returnFrom") LocalDateTime returnFrom,
@Param("to") LocalDateTime to);
}
@@ -0,0 +1,19 @@
package com.baiyue.firstleg.terminaldrayage.repository;
import com.baiyue.firstleg.terminaldrayage.dto.ContainerCalendarSource;
import com.baiyue.firstleg.terminaldrayage.mapper.ContainerCalendarSourceMapper;
import org.springframework.stereotype.Repository;
import java.time.LocalDate;
import java.util.List;
@Repository
public class ContainerCalendarSourceRepository {
private final ContainerCalendarSourceMapper mapper;
/** 使用本服务 Mapper 读取日历所需柜级来源。 */
public ContainerCalendarSourceRepository(ContainerCalendarSourceMapper mapper) { this.mapper = mapper; }
/** 到港前移两天覆盖默认到仓,直送还柜候选额外留一天覆盖夏令时。 */
public List<ContainerCalendarSource> sources(LocalDate from, LocalDate to) {
return mapper.sources(from.atStartOfDay(), from.minusDays(2).atStartOfDay(),
from.minusDays(4).atStartOfDay(), to.atStartOfDay());
}
}
@@ -0,0 +1,46 @@
package com.baiyue.firstleg.terminaldrayage.service;
import com.baiyue.firstleg.terminaldrayage.dto.ContainerCalendarSource;
import com.baiyue.firstleg.terminaldrayage.dto.TerminalDrayageDtos.View;
import com.baiyue.firstleg.terminaldrayage.repository.ContainerCalendarSourceRepository;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Service;
import java.time.LocalDate;
import java.time.temporal.ChronoUnit;
import java.util.*;
import org.springframework.web.server.ResponseStatusException;
@Service
public class ContainerCalendarSourceService {
public record Result(List<ContainerCalendarSource> containers, List<View> emptyReturns,
boolean emptyReturnsReadable) { }
private final ContainerCalendarSourceRepository repository;
private final TerminalDrayageService drayages;
/** 复用真实还柜业务的截止时间和完成事实,不在日历另算还柜状态。 */
public ContainerCalendarSourceService(ContainerCalendarSourceRepository repository, TerminalDrayageService drayages) {
this.repository = repository; this.drayages = drayages;
}
/** 按日期范围读取到港、默认到仓来源及可见的还柜记录。 */
public Result calendar(LocalDate from, LocalDate to, Set<String> permissions) {
if (from == null || to == null || !to.isAfter(from) || ChronoUnit.DAYS.between(from, to) > 42)
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "日历查询范围必须为1至42天");
var sources = repository.sources(from, to);
if (sources.size() > 2000)
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "日历来源超过2000柜,请缩小范围或补齐未定日期");
boolean readable = permissions != null && permissions.contains("platform:firstleg:terminal-drayage:list");
var returns = new ArrayList<View>();
if (readable) {
var ids = sources.stream().map(ContainerCalendarSource::containerBookingId).toList();
for (int start = 0; start < ids.size(); start += 200) {
for (var row : drayages.authorize(drayages.views(ids.subList(start, Math.min(start + 200, ids.size()))), permissions)) {
var at = row.actualEmptyReturnAt() != null ? row.actualEmptyReturnAt() : row.emptyReturnWarning().deadlineAt();
if (at != null && !at.toLocalDate().isBefore(from) && at.toLocalDate().isBefore(to)) returns.add(row);
}
}
}
// 未授权还柜读取时,也不能通过仅因还柜日期命中的旧柜候选推断受保护事实。
var visibleSources = readable ? sources : sources.stream().filter(row -> row.arrivalAt() == null
|| !row.arrivalAt().toLocalDate().isBefore(from.minusDays(2)) && row.arrivalAt().toLocalDate().isBefore(to)).toList();
return new Result(List.copyOf(visibleSources), List.copyOf(returns), readable);
}
}
@@ -0,0 +1,55 @@
openapi: 3.0.3
info: {title: 四类日历柜级来源, version: 1.0.0}
security: [{bearerAuth: []}]
paths:
/api/platform/v1/terminal-drayages/calendar-sources:
get:
summary: 查询日历到港、默认到仓来源和还柜事实
description: >-
仅平台,platform:appointment:list。返回已确认国内出仓、未撤销且有效的柜次。
到港取最新已观测实际到港节点、柜次实际到港、预计到港,按此顺序选一个日期。
到港候选前移两天覆盖计划到仓默认日期;缺到港日期的柜次同时返回,不能伪造日期。
还柜候选含原截止日期与真实完成日期,最终由原码头拖车业务复核,完成后只按实际还柜日期归日历。
没有platform:firstleg:terminal-drayage:list时emptyReturns为空且emptyReturnsReadable=false;不把缺权限当没有安排。
最多2000个候选,超出返回400;来源读取失败返回错误,不以空列表代替。
x-permissions: [platform:appointment:list]
parameters:
- {name: from, in: query, required: true, schema: {type: string, format: date}, description: 包含的原业务当地日期}
- {name: to, in: query, required: true, schema: {type: string, format: date}, description: 不包含的原业务当地日期,范围最多42天}
responses:
'200':
description: 成功业务响应,code为字符串200
content:
application/json:
schema:
type: object
properties:
code: {type: string}
data:
type: object
required: [containers, emptyReturns, emptyReturnsReadable]
properties:
emptyReturnsReadable: {type: boolean}
containers:
type: array
items:
type: object
properties:
containerBookingId: {type: string}
cabinetNo: {type: string, nullable: true}
containerNo: {type: string, nullable: true}
vesselName: {type: string, nullable: true}
voyageNo: {type: string, nullable: true}
destinationPortName: {type: string, nullable: true}
destinationWarehouseCode: {type: string, nullable: true}
arrivalAt: {type: string, format: date-time, nullable: true}
arrivalType: {type: string, enum: [ACTUAL, ESTIMATED]}
emptyReturns:
type: array
description: 沿terminal-drayage-v1的View结构,allowedActions按当前权限过滤;含actualEmptyReturnAt及emptyReturnWarning.deadlineAt
items: {$ref: './terminal-drayage-v1.yaml#/components/schemas/TerminalDrayageView'}
'400': {description: 范围无效或候选过多}
'403': {description: 非平台或没有预约读取权限}
components:
securitySchemes:
bearerAuth: {type: http, scheme: bearer, bearerFormat: JWT}
@@ -0,0 +1,44 @@
package com.baiyue.firstleg.terminaldrayage.mapper;
import com.baomidou.mybatisplus.core.MybatisConfiguration;
import com.baomidou.mybatisplus.core.MybatisSqlSessionFactoryBuilder;
import org.apache.ibatis.mapping.Environment;
import org.apache.ibatis.transaction.jdbc.JdbcTransactionFactory;
import org.h2.jdbcx.JdbcDataSource;
import org.junit.jupiter.api.Test;
import java.time.LocalDateTime;
import static org.junit.jupiter.api.Assertions.*;
class ContainerCalendarSourceMapperTest {
/** 使用实际 Mapper 核对跨月默认日期候选、缺日期与真实出仓边界。 */
@Test
void selectsShiftedArrivalUndatedAndOlderReturnCandidatesWithoutInactiveOrUndepartedContainers() throws Exception {
var ds = new JdbcDataSource();
ds.setURL("jdbc:h2:mem:four-type-calendar;MODE=MySQL;DB_CLOSE_DELAY=-1");
try (var connection = ds.getConnection(); var sql = connection.createStatement()) {
sql.execute("CREATE TABLE fl_container_booking(id BIGINT,cabinet_no VARCHAR,container_no VARCHAR," +
"vessel_name VARCHAR,voyage_no VARCHAR,destination_port_name VARCHAR,destination_warehouse_code VARCHAR," +
"actual_port_arrival_at TIMESTAMP,estimated_port_arrival_at TIMESTAMP,unloading_completed_at TIMESTAMP,record_status VARCHAR)");
sql.execute("CREATE TABLE fl_terminal_drayage(container_booking_id BIGINT,actual_empty_return_at TIMESTAMP)");
sql.execute("CREATE TABLE fl_carrier_schedule_node_fact(id BIGINT,container_booking_id BIGINT,standard_node VARCHAR,actual_at TIMESTAMP,observed_at TIMESTAMP)");
sql.execute("CREATE TABLE fl_direct_delivery_unloading(container_booking_id BIGINT,actual_at TIMESTAMP)");
sql.execute("CREATE TABLE fl_outbound_dispatch(container_booking_id BIGINT,confirmed_at TIMESTAMP,reversed_at TIMESTAMP,status VARCHAR)");
sql.execute("INSERT INTO fl_container_booking(id,cabinet_no,estimated_port_arrival_at,record_status) VALUES " +
"(1,'D1','2026-10-30 12:00:00','ACTIVE'),(2,'D2','2026-11-10 12:00:00','ACTIVE')," +
"(3,'D3',NULL,'ACTIVE'),(4,'D4','2026-01-01 12:00:00','ACTIVE')," +
"(5,'D5','2026-11-10 12:00:00','CANCELLED'),(6,'D6','2026-11-10 12:00:00','ACTIVE')");
sql.execute("INSERT INTO fl_outbound_dispatch SELECT id,'2026-01-01 00:00:00',NULL,'WAITING_FOR_LOADING' FROM fl_container_booking WHERE id<>6");
sql.execute("INSERT INTO fl_terminal_drayage VALUES(4,'2026-11-05 12:00:00')");
sql.execute("INSERT INTO fl_carrier_schedule_node_fact VALUES(1,2,'ARRIVED_DESTINATION_PORT','2026-10-25 12:00:00','2026-10-26 12:00:00')");
}
var config = new MybatisConfiguration();
config.setEnvironment(new Environment("test", new JdbcTransactionFactory(), ds)); config.addMapper(ContainerCalendarSourceMapper.class);
try (var session = new MybatisSqlSessionFactoryBuilder().build(config).openSession()) {
var from = LocalDateTime.parse("2026-11-01T00:00:00");
var rows = session.getMapper(ContainerCalendarSourceMapper.class).sources(from, from.minusDays(2), from.minusDays(4), from.plusDays(30));
assertEquals(java.util.List.of(1L, 3L, 4L), rows.stream().map(row -> row.containerBookingId()).toList());
assertEquals("2026-10-30T12:00", rows.get(0).arrivalAt().toString());
assertNull(rows.get(1).arrivalAt());
}
}
}
@@ -0,0 +1,45 @@
package com.baiyue.firstleg.terminaldrayage.service;
import com.baiyue.firstleg.terminaldrayage.dto.ContainerCalendarSource;
import com.baiyue.firstleg.terminaldrayage.dto.TerminalDrayageDtos.*;
import com.baiyue.firstleg.terminaldrayage.repository.ContainerCalendarSourceRepository;
import org.junit.jupiter.api.Test;
import org.springframework.web.server.ResponseStatusException;
import java.time.*;
import java.util.*;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.*;
class ContainerCalendarSourceServiceTest {
private final ContainerCalendarSourceRepository repository = mock(ContainerCalendarSourceRepository.class);
private final TerminalDrayageService drayages = mock(TerminalDrayageService.class);
private final ContainerCalendarSourceService service = new ContainerCalendarSourceService(repository, drayages);
private final LocalDate from = LocalDate.of(2026, 11, 1), to = from.plusDays(30);
/** 缺还柜读取权限时保留范围说明,不读取受保护事实。 */
@Test void keepsMissingReturnPermissionExplicitWithoutReadingProtectedFacts() {
when(repository.sources(from, to)).thenReturn(List.of(
new ContainerCalendarSource(1L,"D1",null,null,null,null,null,null,"ESTIMATED"),
new ContainerCalendarSource(2L,"D2",null,null,null,null,null,from.minusMonths(2).atStartOfDay(),"ACTUAL")));
var result = service.calendar(from, to, Set.of("platform:appointment:list"));
assertEquals(1, result.containers().size()); assertFalse(result.emptyReturnsReadable());
verifyNoInteractions(drayages);
}
/** 实际还柜日期优先于截止日期,范围外完成事实不回落到原提醒日。 */
@Test void usesOriginalDeadlineButActualReturnSupersedesTheDeadlineDate() {
when(repository.sources(from, to)).thenReturn(List.of(new ContainerCalendarSource(1L,"D1",null,null,null,null,null,null,"ESTIMATED")));
var row = mock(View.class);
when(row.actualEmptyReturnAt()).thenReturn(to.atStartOfDay());
when(row.emptyReturnWarning()).thenReturn(new Warning("RESOLVED", from.atStartOfDay(),0,0));
when(drayages.views(List.of(1L))).thenReturn(List.of(row));
when(drayages.authorize(anyList(), anySet())).thenAnswer(call -> call.getArgument(0));
assertTrue(service.calendar(from,to,Set.of("platform:firstleg:terminal-drayage:list")).emptyReturns().isEmpty());
when(row.actualEmptyReturnAt()).thenReturn(null);
assertEquals(List.of(row), service.calendar(from,to,Set.of("platform:firstleg:terminal-drayage:list")).emptyReturns());
}
/** 非法日期范围在访问持久化层前拒绝。 */
@Test void rejectsInvalidRangesBeforeDatabaseAccess() {
assertThrows(ResponseStatusException.class, () -> service.calendar(from,from,Set.of()));
assertThrows(ResponseStatusException.class, () -> service.calendar(from,from.plusDays(43),Set.of()));
verifyNoInteractions(repository);
}
}
@@ -0,0 +1,15 @@
# 仓储转运上门取货与车辆排车
客户选择仓库上门取货并提交,平台审核通过后,原转运单进入车辆排车的“待取货”。一个取货车次可取多名客户的多票货,目的海外仓必须相同。客户自送、未审核、库存转运和测量已完成的订单不进入候选。
业务入口为 `fleet/pickup/PlatformFleetPickupController`,按 Controller → Service → Repository → Mapper 持久化,复用原车次编号、车辆司机分配和资源占用。`tripPurpose=PICKUP` 区别于原 `DELIVERY`,取货车次不关联出库排车、不发布拼车余位、不执行原出库装载和扣库存。
车次按 `PLANNED → IN_TRANSIT → COMPLETED` 执行。逐票取货任务为 `ASSIGNED → PICKED → ARRIVED`:发车复验车辆司机资格和实际占用;取货必须填写实提箱数并上传本票凭证;到仓后才开放原 PDA 清点测量。实提箱数独立保存,不覆盖客户预报箱数、SKU件数和箱唛。全车送仓可结束运输,车辆与司机任务分别沿原入口释放,不需要等仓内测量。
计划阶段取消车次会保留操作历史、释放资源和原单关联,使原单重新可安排;发车后禁止取消。原车次分配入口支持发车前改派。所有取货命令在车次行锁、版本校验和持久幂等摘要下执行;来源安排同时锁原单,避免一单多车。前端遇到结果未知保留原请求和请求键,阻止刷新卸载或改派后重新生成命令。
平台待取货使用服务端分页;客户详情、平台尾程详情按原转运单查询同一运输进度。凭证只允许当前单已确认引用,本车其他客户文件不能借用或下载。文件数据归 File 服务,Lastleg仅持有文件归属和引用。
公开接口与请求字段见 `src/main/resources/openapi/fleet-transfer-pickup-v1.yaml`。数据库增量为 `../../scripts/db/fleet-transfer-pickup-20261017.sql`,目标 `baiyue_lastleg`,可重复执行,无业务 DML。
本机验收入口 `../../scripts/local-transfer-pickup-acceptance.mjs` 创建带 TEST 标记的新业务记录并保存报告。覆盖待取货、未审核、自送、异仓拒绝、共车、取消改派、资源冲突、同键重放与异载荷拒绝、跨客户文件隔离、未送仓禁止PDA和送仓后完整测量。合成图片仅验证上传与文件归属,不证明物理取货。本机构建、单测和HTTP验收与远端部署、真实页面视觉验收分别记录。
+25
View File
@@ -25,4 +25,29 @@
<dependency><groupId>com.h2database</groupId><artifactId>h2</artifactId><scope>test</scope></dependency>
</dependencies>
<build><plugins><plugin><groupId>org.springframework.boot</groupId><artifactId>spring-boot-maven-plugin</artifactId></plugin></plugins></build>
<profiles>
<profile>
<id>ci-bounded</id>
<build><plugins><plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-surefire-plugin</artifactId>
<executions>
<execution>
<id>default-test</id>
<configuration><excludes><exclude>**/*SqlSemanticTest.java</exclude></excludes></configuration>
</execution>
<!-- H2复杂查询规划与普通单测分进程,仍沿用父级1GB上限并执行全部断言。 -->
<execution>
<id>sql-semantic-tests</id>
<phase>test</phase>
<goals><goal>test</goal></goals>
<configuration>
<includes><include>**/*SqlSemanticTest.java</include></includes>
<reuseForks>false</reuseForks>
</configuration>
</execution>
</executions>
</plugin></plugins></build>
</profile>
</profiles>
</project>
@@ -35,6 +35,11 @@ public class AppointmentCargoService {
return new PageResult<>(rows, page, size, total, (total + size - 1) / size);
}
/** 查询下拉框与货物分页共用权威目录范围,不从当前页猜测目的仓。 */
public List<String> destinations() {
return scope().destinations().stream().distinct().sorted().toList();
}
private record Scope(List<String> origins, List<String> destinations) { }
private Scope scope() {
final List<AppointmentWarehouseClient.Warehouse> catalog;
@@ -53,6 +53,14 @@ public class AppointmentPlanningController {
return ApiResponse.success(new Resolved(source,result.get(0)));
}
/** 目的仓选项沿用货物查询权限,只返回同范围的美国FBA仓码。 */
@GetMapping("/cargo/destinations")
@PreAuthorize("hasAuthority('platform:appointment:list') and hasAuthority('platform:overseas-warehouse:last-mile-order:list')")
public ApiResponse<java.util.List<String>> destinations(@AuthenticationPrincipal AuthenticatedUser user) {
requirePlatform(user);
return ApiResponse.success(service.destinations());
}
@GetMapping("/cargo")
@PreAuthorize("hasAuthority('platform:appointment:list') and hasAuthority('platform:overseas-warehouse:last-mile-order:list')")
public ApiResponse<PageResult<AppointmentCargo>> cargo(@AuthenticationPrincipal AuthenticatedUser user,
@@ -58,18 +58,36 @@ public class AppointmentSourceService {
return response.data().warehouse().timezone();
} catch(RuntimeException error) { throw new ResponseStatusException(HttpStatus.SERVICE_UNAVAILABLE,"目的仓时区读取失败,请稍后核对",error); }
}
/** One catalog request and batched source reads, including mixed first-leg and external bookings. */
/** 普通批量读取创建独立目录作用域,同时读取头程和外接登记来源。 */
public List<Source> resolveBatch(List<AppointmentCoverageService.SourceRef> requested) {
return resolveBatch(requested,true).stream().map(Resolution::requireSource).toList();
return openReadScope().resolveBatch(requested);
}
/** 创建显式单次读取作用域;仅成功的目录结果可在本次生成中复用。 */
public ReadScope openReadScope() { return new ReadScope(); }
public final class ReadScope {
private List<AppointmentWarehouseClient.Warehouse> directory;
/** 只能由所属来源服务创建,不允许传入未经校验的目录。 */
private ReadScope() { }
/** 每次仍读取原订单和权限事实,仅目录共享;不保存来源解析结果。 */
public List<Source> resolveBatch(List<AppointmentCoverageService.SourceRef> requested) {
return AppointmentSourceService.this.resolveBatch(requested,true,this).stream().map(Resolution::requireSource).toList();
}
/** 读取失败不赋值,下次显式调用仍按原目录接口重读。 */
private List<AppointmentWarehouseClient.Warehouse> catalog() {
if(directory==null) directory=AppointmentSourceService.this.catalog();
return directory;
}
}
/** Read-only inspection keeps each source's prerequisite failure separate; infrastructure errors still escape. */
public record Resolution(AppointmentCoverageService.SourceRef reference,Source source,ResponseStatusException error) {
public Source requireSource() { if(error!=null) throw error; return source; }
}
/** 每次检查独立读取目录,保留逐来源前置条件失败与基础设施异常边界。 */
public List<Resolution> inspectBatch(List<AppointmentCoverageService.SourceRef> requested) {
return resolveBatch(requested,false);
return resolveBatch(requested,false,openReadScope());
}
private List<Resolution> resolveBatch(List<AppointmentCoverageService.SourceRef> requested,boolean strict) {
/** 保留原批量校验与错误边界,目录只从调用方的局部作用域读取。 */
private List<Resolution> resolveBatch(List<AppointmentCoverageService.SourceRef> requested,boolean strict,ReadScope scope) {
if(requested==null || requested.isEmpty() || requested.size()>100) throw bad("每批请选择1至100票真实货源");
var keys=new LinkedHashSet<String>(); var orderIds=new LinkedHashSet<Long>();
var bookingRefs=new java.util.ArrayList<AppointmentExternalSourceGateway.SourceRef>();
@@ -96,7 +114,7 @@ public class AppointmentSourceService {
var destinations=transferRefs.isEmpty()?Map.<Long,AppointmentCoverageMapper.Source>of():destinationFacts.sources(transferRefs).stream().collect(Collectors.toMap(AppointmentCoverageMapper.Source::orderId,Function.identity()));
var containerIds=orderRows.stream().filter(o->"FIRST_LEG".equals(o.getOrderType())).map(com.baiyue.lastleg.entity.LastlegOrder::getSourceContainerBookingId).filter(Objects::nonNull).distinct().toList();
var gates=containerIds.isEmpty()?Map.<String,Boolean>of():shipping.selectBatchIds(containerIds).stream().collect(Collectors.toMap(g->g.getContainerBookingId(),g->g.getNodeSequence()!=null && g.getNodeSequence()>=40));
var catalog=catalog();
var catalog=scope.catalog();
var result=requested.stream().map(ref->{
try {
if(ref.sourceKey().startsWith("ORDER:") && externalOrders.contains(new AppointmentExternalSourceGateway.OrderRef(ref.customerId(),orderId(ref.sourceKey()))))
@@ -4,13 +4,15 @@ import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Propagation;
import org.springframework.transaction.annotation.Transactional;
/** Original business commands invalidate the manifest atomically with their business write. */
/** 原业务写入与工作清单失效保持在同一事务,不延后或绕过版本门禁。 */
@Component
public class AppointmentWorkChanges {
private final AppointmentWorkMapper mapper;
public AppointmentWorkChanges(AppointmentWorkMapper mapper) { this.mapper=mapper; }
private final AppointmentWorkStore store;
/** 复用工作事项持久层,统一记录守卫更新耗时。 */
public AppointmentWorkChanges(AppointmentWorkStore store) { this.store=store; }
/** 只标记需求变化,保留调用方原事务和异常语义。 */
@Transactional(propagation=Propagation.MANDATORY)
public void requirementsChanged() {
if(mapper.invalidateRequirements()!=1) throw AppointmentWorkPolicy.unavailable("工作事项同步守卫未初始化");
store.invalidateRequirements();
}
}
@@ -4,12 +4,14 @@ import org.springframework.beans.factory.ObjectProvider;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
import java.util.*;
import static com.baiyue.lastleg.appointmentwork.diagnostics.AppointmentWorkDiagnostics.*;
import static com.baiyue.lastleg.appointmentwork.AppointmentWorkModels.*;
import static com.baiyue.lastleg.appointmentwork.AppointmentWorkFactGateway.*;
import static com.baiyue.lastleg.appointmentwork.AppointmentWorkPolicy.*;
@Component
public class AppointmentWorkFactAdapter implements AppointmentWorkFactGateway {
private static final org.slf4j.Logger LOG=org.slf4j.LoggerFactory.getLogger(AppointmentWorkFactAdapter.class);
private final AppointmentWorkFirstlegClient firstleg;
private final List<BusinessProvider> providers;
private final ObjectProvider<OwnerDirectory> directory;
@@ -38,7 +40,10 @@ public class AppointmentWorkFactAdapter implements AppointmentWorkFactGateway {
return response.data();
} catch(RuntimeException error) { throw unavailable("柜次权威事实不可用",error); }
}
/** 原顺序读取全部事实;慢调用和失败仅附加无业务内容的局部统计。 */
public Map<String,Fact> facts(List<AppointmentWorkState> states) {
var timing=new FactTiming();boolean success=false;
try {
var result=new LinkedHashMap<String,Fact>();
var remote=new ArrayList<FactRequest>();
var groups=new LinkedHashMap<BusinessProvider,List<FactRequest>>();
@@ -53,27 +58,86 @@ public class AppointmentWorkFactAdapter implements AppointmentWorkFactGateway {
}
}
for(int start=0; start<remote.size(); start+=200) {
var requests=remote.subList(start,Math.min(start+200,remote.size())); requireToken();
try { var response=firstleg.facts(token,requests);
var requests=remote.subList(start,Math.min(start+200,remote.size())); timing.stage="FIRSTLEG"; requireToken();
try { var response=timing.measure(timing.firstleg,requests.size(),()->firstleg.facts(token,requests));
if(response==null || !response.successful()) throw unavailable("提柜及还空事实读取失败");
append(result,requests,response.data());
run(Stage.FACTS_VALIDATE,()->append(result,requests,response.data()));
} catch(RuntimeException error) { throw unavailable("Firstleg工作事实不可用",error); }
}
groups.forEach((provider,requests)->{
for(int start=0; start<requests.size(); start+=200) {
var batch=requests.subList(start,Math.min(start+200,requests.size()));
try { append(result,batch,provider.read(batch)); }
var statistics=timing.provider(provider);
try { var response=timing.measure(statistics,batch.size(),()->provider.read(batch));run(Stage.FACTS_VALIDATE,()->append(result,batch,response)); }
catch(RuntimeException error) { throw unavailable("预约/收货/运输事实不可用",error); }
}
});
return result;
success=true;return result;
} finally { timing.finish(success); }
}
public List<Requirement> requirements(ContainerSource source,Flow effectiveFlow) {
var provider=requirements.getIfAvailable();
if(provider==null) throw unavailable("原预约需求与批次清单适配尚未接入,不能生成不完整工作清单");
var result=provider.requirements(source,effectiveFlow);
if(result==null || result.isEmpty()) throw unavailable("原预约需求清单缺失,不能以空清单完成路线生成");
return List.copyOf(result);
/** 一次 facts 调用的计时,不保存原来源或返回值,不跨调用缓存。 */
private static final class FactTiming {
private final Span prepare=span(Stage.FACTS_PREPARE);
private final long started=System.nanoTime();
private final CallStatistics firstleg=new CallStatistics();
private final Map<String,CallStatistics> providers=new LinkedHashMap<>();
private String stage="PREPARE";
/** 固定诊断桶不使用代理名、业务参数或异常文本。 */
private CallStatistics provider(BusinessProvider provider) {
String name=org.springframework.util.ClassUtils.getUserClass(provider).getSimpleName();
name=switch(name) {
case "AppointmentWorkReceivingProvider","AppointmentWorkDeliveryProvider","AppointmentWorkTransportProvider","AppointmentWorkDirectTransportProvider" -> name;
default -> "OTHER";
};
stage=name;
return providers.computeIfAbsent(name,ignored->new CallStatistics());
}
/** 只统计真正尝试的提供端调用;校验及组装耗时保留在 totalMs 中。 */
private <T> T measure(CallStatistics statistics,int requests,java.util.function.Supplier<T> operation) {
prepare.close();statistics.calls++;statistics.requests+=requests;long begin=System.nanoTime();
try(var span=span(switch(stage){case "FIRSTLEG"->Stage.FACTS_FIRSTLEG;case "AppointmentWorkReceivingProvider"->Stage.FACTS_RECEIVING;
case "AppointmentWorkDeliveryProvider"->Stage.FACTS_DELIVERY;case "AppointmentWorkTransportProvider"->Stage.FACTS_TRANSPORT;
case "AppointmentWorkDirectTransportProvider"->Stage.FACTS_DIRECT_TRANSPORT;default->Stage.FACTS_OTHER;})){
try{return operation.get();}catch(RuntimeException|Error error){span.failed();throw error;}
}finally{statistics.elapsed+=System.nanoTime()-begin;}
}
/** 成功慢调用或任意失败只输出一次汇总,日志故障不得遮蔽原异常。 */
private void finish(boolean success) {
if(!success)prepare.failed();prepare.close();
long totalMs=java.util.concurrent.TimeUnit.NANOSECONDS.toMillis(System.nanoTime()-started);
if(success && totalMs<250) return;
try {
var summaries=new LinkedHashMap<String,String>();
providers.forEach((name,value)->summaries.put(name,value.summary()));
LOG.warn("Appointment work facts diagnostic: totalMs={}, firstlegCalls={}, firstlegRequests={}, firstlegMs={}, providers={}, failedStage={}, requestId={}, traceId={}, scopeId={}",
totalMs,firstleg.calls,firstleg.requests,firstleg.millis(),summaries,success?"NONE":stage,org.slf4j.MDC.get("requestId"),org.slf4j.MDC.get("traceId"),currentScopeId());
} catch(Throwable diagnosticFailure) { /* 仅忽略诊断故障,原事实错误仍沿原路径抛出。 */ }
}
}
/** 仅包含数量和单调时钟耗时的累加器。 */
private static final class CallStatistics {
private int calls,requests;private long elapsed;
/** 输出整毫秒,未计入网络之后的事实完整性校验。 */
private long millis() { return java.util.concurrent.TimeUnit.NANOSECONDS.toMillis(elapsed); }
/** 固定字段格式便于日志汇总,不包含任何请求或响应内容。 */
private String summary() { return "calls="+calls+",requests="+requests+",ms="+millis(); }
}
/** 每次生成建立独立读取器;没有实际需求读取时不引入原业务依赖。 */
public RequirementReader openRequirements() {
return new RequirementReader() {
private RequirementReader reader;
/** 首次实际读取才取得原域作用域,保留每份需求的完整性校验。 */
public List<Requirement> read(ContainerSource source,Flow effectiveFlow) {
if(reader==null) {
var provider=requirements.getIfAvailable();
if(provider==null) throw unavailable("原预约需求与批次清单适配尚未接入,不能生成不完整工作清单");
reader=provider.openRequirements();
}
var result=reader.read(source,effectiveFlow);
if(result==null || result.isEmpty()) throw unavailable("原预约需求清单缺失,不能以空清单完成路线生成");
return List.copyOf(result);
}
};
}
static void append(Map<String,Fact> target,List<FactRequest> requests,List<Fact> facts) {
if(facts==null || facts.size()!=requests.size()) throw unavailable("工作事实响应不完整");
@@ -27,8 +27,8 @@ public interface AppointmentWorkFactGateway {
}
SourcePage discover(String after, int size);
ContainerSource container(String containerBookingId);
/** Original appointment core supplies the complete current obligation/batch manifest, including explicit replacement keys. */
List<Requirement> requirements(ContainerSource source,Flow effectiveFlow);
/** 建立本次原预约需求读取作用域,保持完整批次及明确替代关系。 */
RequirementReader openRequirements();
Map<String, Fact> facts(List<AppointmentWorkState> states);
Owner owner(String id, Actor actor);
Responsibility supplier(String id);
@@ -41,5 +41,7 @@ public interface AppointmentWorkFactGateway {
/** IAM-backed active platform operators; display names supplied by callers are never trusted. */
interface OwnerDirectory { Owner activePlatformOwner(String id); }
interface SupplierDirectory { Responsibility activeSupplier(String id); }
interface RequirementProvider { List<Requirement> requirements(ContainerSource source,Flow effectiveFlow); }
/** 单次生成显式复用目录,不跨请求保存来源事实。 */
interface RequirementReader { List<Requirement> read(ContainerSource source,Flow effectiveFlow); }
interface RequirementProvider { RequirementReader openRequirements(); }
}
@@ -9,6 +9,7 @@ import java.util.*;
import java.util.function.Function;
import java.util.function.Predicate;
import java.util.stream.Collectors;
import static com.baiyue.lastleg.appointmentwork.diagnostics.AppointmentWorkDiagnostics.*;
import static com.baiyue.lastleg.appointmentwork.AppointmentWorkModels.*;
import static com.baiyue.lastleg.appointmentwork.AppointmentWorkFactGateway.*;
import static com.baiyue.lastleg.appointmentwork.AppointmentWorkPolicy.*;
@@ -26,10 +27,16 @@ public class AppointmentWorkQueryService {
Snapshot snapshot(Actor actor) {
return snapshot(actor,s->true,true);
}
/** 按原顺序构造完整快照,计时只分离读取、筛选、事实及投影。 */
private Snapshot snapshot(Actor actor,Predicate<AppointmentWorkState> selected,boolean includeNext) {
observeTransaction();var timing=span(Stage.QUERY_GUARD);
try {
String scopeVersion=store.requireInitialized();
timing.close();timing=span(Stage.QUERY_DISCOVER);
if(!Objects.equals(scopeVersion,facts.discover(null,1).scopeVersion())) throw conflict("已开船工作范围变化,请先完成服务端同步");
timing.close();timing=span(Stage.QUERY_STORE);
var all=store.all();
timing.close();timing=span(Stage.QUERY_SELECT);
var stateByKey=all.stream().collect(Collectors.toMap(AppointmentWorkState::getWorkItemKey,Function.identity()));
var selectedKeys=all.stream().filter(s->!s.isRetired() && selected.test(s)).map(AppointmentWorkState::getWorkItemKey).collect(Collectors.toSet());
var needed=new HashSet<>(selectedKeys);
@@ -41,12 +48,16 @@ public class AppointmentWorkQueryService {
}
}
var states=all.stream().filter(s->!s.isRetired() && needed.contains(s.getWorkItemKey())).toList();
timing.close();timing=span(Stage.QUERY_FACTS);
var current=facts.facts(states);
timing.close();timing=span(Stage.QUERY_PROJECT);
var visible=states.stream().filter(s->canRead(s,current.get(s.getWorkItemKey()),actor)).toList();
var items=visible.stream().map(s->project(s,current.get(s.getWorkItemKey()),actor,clock)).toList();
var byKey=items.stream().collect(Collectors.toMap(WorkItem::workItemKey,Function.identity()));
timing.close();timing=span(Stage.QUERY_NEXT);
return new Snapshot(all,current,items.stream().filter(i->selectedKeys.contains(i.workItemKey()))
.map(i->includeNext?withNext(i,next(i,byKey,stateByKey,new HashSet<>())):i).toList());
} catch(RuntimeException|Error error){timing.failed();throw error;} finally{timing.close();}
}
private Action next(WorkItem item,Map<String,WorkItem> items,Map<String,AppointmentWorkState> states,Set<String> visited) {
if(!visited.add(item.workItemKey()) || item.reviewRequired()) return item.nextAction();
@@ -73,23 +84,34 @@ public class AppointmentWorkQueryService {
}
return item.nextAction();
}
/** 完整快照之后单独统计汇总耗时,不改变待办筛选语义。 */
public Summary summary(Actor actor) {
var items=snapshot(actor,s->true,false).items().stream().filter(i->i.executionStatus()!=ExecutionStatus.COMPLETED).toList();
var snapshotItems=snapshot(actor,s->true,false).items();
return measure(Stage.QUERY_FINAL,()->{
var items=snapshotItems.stream().filter(i->i.executionStatus()!=ExecutionStatus.COMPLETED).toList();
return new Summary(items.size(),items.stream().filter(i->followToday(i,clock)).count(),items.stream().filter(i->!i.overdueReasons().isEmpty()).count(),items.stream().filter(i->i.calendarDate()==null).count(),items.stream().filter(WorkItem::reviewRequired).count());
});
}
/** 日历最终分组保持原顺序,单独记录快照后处理。 */
public CalendarResult calendar(LocalDate from,LocalDate to,Flow flow,Set<Type> types,Actor actor) {
if(from==null || to==null || to.isBefore(from) || ChronoUnit.DAYS.between(from,to)>=62) throw bad("日历范围须为1至62天");
if(types!=null && types.contains(Type.ROUTE)) throw bad("路线待确认需求请从全局待办查看");
var items=snapshot(actor,s->s.getType()!=Type.ROUTE && matches(s,flow,types,null),false).items().stream().filter(i->i.type()!=Type.ROUTE && i.calendarDate()!=null && !i.calendarDate().isBefore(from) && !i.calendarDate().isAfter(to))
var snapshotItems=snapshot(actor,s->s.getType()!=Type.ROUTE && matches(s,flow,types,null),false).items();
return measure(Stage.QUERY_FINAL,()->{
var items=snapshotItems.stream().filter(i->i.type()!=Type.ROUTE && i.calendarDate()!=null && !i.calendarDate().isBefore(from) && !i.calendarDate().isAfter(to))
.filter(i->flow==null || i.flow()==flow).filter(i->types==null || types.contains(i.type())).toList();
var groups=items.stream().collect(Collectors.groupingBy(WorkItem::calendarDate,TreeMap::new,Collectors.toList()));
var days=groups.entrySet().stream().map(e->new DayAggregate(e.getKey(),e.getValue().size(),e.getValue().stream().collect(Collectors.groupingBy(WorkItem::type,()->new EnumMap<>(Type.class),Collectors.counting())),counts(e.getValue()))).toList();
return new CalendarResult(from,to,items.size(),counts(items),days);
});
}
/** 保留事实核对之后的最终过滤排序分页,仅拆出其耗时。 */
public PageResult<WorkItem> items(View view,int page,int size,LocalDate date,Flow flow,Set<Type> types,String containerId,Bucket bucket,String keyword,Actor actor) {
if(page<1 || size<1 || size>100 || keyword!=null && keyword.length()>128) throw bad("分页或搜索条件无效");
var word=keyword==null?"":keyword.trim().toLowerCase(Locale.ROOT);
var items=snapshot(actor,s->matches(s,flow,types,containerId) && candidate(s,view),true).items().stream()
var snapshotItems=snapshot(actor,s->matches(s,flow,types,containerId) && candidate(s,view),true).items();
return measure(Stage.QUERY_FINAL,()->{
var items=snapshotItems.stream()
.filter(i->date!=null || i.executionStatus()!=ExecutionStatus.COMPLETED)
.filter(i->date==null || date.equals(i.calendarDate()))
.filter(i->flow==null || flow==i.flow()).filter(i->types==null || types.contains(i.type()))
@@ -100,6 +122,7 @@ public class AppointmentWorkQueryService {
.sorted(Comparator.comparing(WorkItem::followupDate,Comparator.nullsLast(Comparator.naturalOrder())).thenComparing(WorkItem::workItemKey)).toList();
long offset=(long)(page-1)*size; int start=(int)Math.min(offset,items.size());
return new PageResult<>(items.subList(start,Math.min(start+size,items.size())),page,size,items.size(),(items.size()+size-1L)/size);
});
}
public BatchWorkResolution resolveBatch(String batchKey,String customerId,Actor actor) {
if(batchKey==null || !batchKey.matches("(?:FBA_APPOINTMENT|DISPATCH):[1-9][0-9]*")) throw bad("请提供原业务返回的完整批次键");
@@ -117,10 +140,12 @@ public class AppointmentWorkQueryService {
var selected=candidates.get(0);
return new BatchWorkResolution(batchKey,selected.customerId(),detail(selected.workItemKey(),actor),candidates);
}
/** 保留详情完整快照与退休事实读取,历史查询单独观察。 */
public WorkDetail detail(String key,Actor actor) {
var snapshot=snapshot(actor); var state=store.get(key);
var snapshot=snapshot(actor); var state=measure(Stage.DETAIL_STORE,()->store.get(key));
var fact=snapshot.facts().get(key);
if(state.isRetired() && state.getType()!=Type.ROUTE) fact=facts.facts(List.of(state)).get(key);
if(state.isRetired() && state.getType()!=Type.ROUTE) fact=measure(Stage.DETAIL_FACTS,()->facts.facts(List.of(state))).get(key);
try(var timing=span(Stage.DETAIL_PROJECT)) {
if(!canRead(state,fact,actor)) throw forbidden("无权读取关联来源");
var item=state.isRetired()?project(state,fact,actor,clock):snapshot.items().stream().filter(i->i.workItemKey().equals(key)).findFirst().orElseThrow(()->missing("事项不存在"));
var relatedKeys=new HashSet<>(state.getPrerequisites()); relatedKeys.addAll(state.getReplacementKeys());
@@ -128,9 +153,10 @@ public class AppointmentWorkQueryService {
relatedKeys.addAll(effectiveKeys(relatedKeys,statesByKey));
snapshot.states().stream().filter(s->effectiveKeys(s.getPrerequisites(),statesByKey).contains(key)).map(AppointmentWorkState::getWorkItemKey).forEach(relatedKeys::add);
var refs=new ArrayList<>(state.getReferences()); if(fact!=null) refs.addAll(fact.references());
return new WorkDetail(item,snapshot.items().stream().filter(i->relatedKeys.contains(i.workItemKey())).toList(),refs.stream().distinct().toList(),store.history(key),
return new WorkDetail(item,snapshot.items().stream().filter(i->relatedKeys.contains(i.workItemKey())).toList(),refs.stream().distinct().toList(),measure(Stage.DETAIL_HISTORY,()->store.history(key)),
state.isReviewRequired()?new Review(state.getReviewVersion(),List.of(Objects.toString(state.getReviewReason(),"关联安排变化"))):null,
requirementOwned(state,fact)?new RequirementDetails(state.isBookingRequired(),Objects.toString(state.getReceivingRequirements(),""),state.getRequirementReason()):null,state.getReturnHandoff());
}
}
WorkItem current(AppointmentWorkState state,Fact fact,Actor actor) { return project(state,fact,actor,clock); }
private boolean matches(AppointmentWorkState state,Flow flow,Set<Type> types,String containerId) {
@@ -1,23 +1,59 @@
package com.baiyue.lastleg.appointmentwork;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.slf4j.MDC;
import org.springframework.stereotype.Component;
import java.time.*;
import java.util.*;
import static com.baiyue.lastleg.appointmentwork.diagnostics.AppointmentWorkDiagnostics.*;
import static com.baiyue.lastleg.appointmentwork.AppointmentWorkModels.*;
import static com.baiyue.lastleg.appointmentwork.AppointmentWorkPolicy.*;
@Component
public class AppointmentWorkStore {
private static final Logger LOG = LoggerFactory.getLogger(AppointmentWorkStore.class);
public static final int MAX_ITEMS=10000;
private final AppointmentWorkMapper mapper;
private final ObjectMapper json;
public AppointmentWorkStore(AppointmentWorkMapper mapper,ObjectMapper json) { this.mapper=mapper; this.json=json; }
public AppointmentWorkMapper.Guard lock() {
var guard=mapper.lockGuard(); if(guard==null) throw unavailable("工作事项DDL尚未集成"); return guard;
/** 只测量守卫更新语句,不改变锁、事务或版本,用于区分慢写是否卡在该语句。 */
public void invalidateRequirements() {
long started = System.nanoTime();
try {
if (measure(Stage.GUARD_UPDATE,mapper::invalidateRequirements) != 1) throw unavailable("工作事项同步守卫未初始化");
} finally {
long elapsedMs = java.util.concurrent.TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - started);
if (elapsedMs >= 250) {
// 语句耗时可能包含等锁或连接等待,不能仅凭日志认定具体锁根因;不记录请求业务载荷。
try { LOG.warn("Appointment work guard update slow: elapsedMs={}, requestId={}, traceId={}, scopeId={}",
elapsedMs, MDC.get("requestId"), MDC.get("traceId"), currentScopeId());
} catch(Throwable ignored) { /* 诊断输出失败不得覆盖原守卫结果或异常。 */ }
}
}
}
/** 测量守卫读取加锁耗时,保留原有锁和失败行为,覆盖尚未进入更新语句的慢命令。 */
public AppointmentWorkMapper.Guard lock() {
long started = System.nanoTime();
try {
observeTransaction();
var guard = measure(Stage.GUARD_LOCK,mapper::lockGuard);
if (guard == null) throw unavailable("工作事项DDL尚未集成");
return guard;
} finally {
long elapsedMs = java.util.concurrent.TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - started);
if (elapsedMs >= 250) {
// 耗时包含连接及语句执行,单条日志不能区分具体等待原因,不记录业务载荷。
try { LOG.warn("Appointment work guard lock slow: elapsedMs={}, requestId={}, traceId={}, scopeId={}",
elapsedMs, MDC.get("requestId"), MDC.get("traceId"), currentScopeId());
} catch(Throwable ignored) { /* 诊断输出失败不得覆盖原守卫结果或异常。 */ }
}
}
}
/** 完整性守卫保持原语义,读取耗时与事务完成只作诊断。 */
public String requireInitialized() {
var guard=mapper.guard();
observeTransaction();var guard=measure(Stage.GUARD_READ,mapper::guard);
if(guard==null || !guard.isInitialized()) throw unavailable("完整工作范围尚未完成同步,不能显示零项");
if(guard.getRequirementVersion()!=guard.getSynchronizedRequirementVersion()) throw conflict("预约批次或运输关联已变化,服务端正在协调完整工作清单");
return guard.getScopeVersion();
@@ -2,45 +2,58 @@ package com.baiyue.lastleg.appointmentwork;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.time.*;
import java.util.*;
import java.util.function.Function;
import java.util.stream.Collectors;
import static com.baiyue.lastleg.appointmentwork.diagnostics.AppointmentWorkDiagnostics.*;
import static com.baiyue.lastleg.appointmentwork.AppointmentWorkModels.*;
import static com.baiyue.lastleg.appointmentwork.AppointmentWorkFactGateway.*;
import static com.baiyue.lastleg.appointmentwork.AppointmentWorkPolicy.*;
@Service
public class AppointmentWorkSyncService {
private static final Logger LOG=LoggerFactory.getLogger(AppointmentWorkSyncService.class);
private static final Actor SYSTEM=new Actor("appointment-work-sync","服务端工作范围同步",Set.of(LIST,ORDER_READ));
private final AppointmentWorkStore store;
private final AppointmentWorkFactGateway gateway;
public AppointmentWorkSyncService(AppointmentWorkStore store,AppointmentWorkFactGateway gateway) { this.store=store; this.gateway=gateway; }
/** Called by trusted server orchestration, never by GET or by opening a particular container. */
/** 服务端按完整范围同步;同一守卫事务内复用工作集,GET 不触发写入。 */
@Transactional
public SyncResult synchronize(SyncRequest request) {
if(request==null || request.size()<1 || request.size()>100) throw bad("同步批次须为1至100柜");
observeTransaction();var timing=new SyncTiming();boolean success=false;
try {
var guard=store.lock();
if(request.afterContainerBookingId()!=null && !Objects.equals(guard.getScanCursor(),request.afterContainerBookingId())) throw conflict("来源同步游标已变化,请从首批重新核对");
timing.next(SyncPhase.DISCOVER);
var page=gateway.discover(request.afterContainerBookingId(),request.size());
timing.pageContainers=page.containers().size();timing.complete=page.complete();
if(request.afterContainerBookingId()==null) { guard.setScanScopeVersion(page.scopeVersion()); guard.setScanRequirementVersion(guard.getRequirementVersion()); }
else if(!Objects.equals(guard.getScanScopeVersion(),page.scopeVersion())) throw conflict("来源范围扫描中变化,请从首批重试");
else if(guard.getScanRequirementVersion()!=guard.getRequirementVersion()) throw conflict("预约需求扫描中变化,请从首批重试");
timing.next(SyncPhase.GENERATE);
// 保留全部历史与稳定键顺序;逐柜更新立即进入本次工作集,不跨事务缓存。
var known=workingSet();
var requirementReader=gateway.openRequirements();
int created=0; var ids=new HashSet<String>();
for(var source:page.containers()) {
validateSource(source);
if(!ids.add(source.containerBookingId())) throw unavailable("来源响应重复柜次");
var existing=store.container(source.containerBookingId());
var existing=known.values().stream().filter(s->source.containerBookingId().equals(s.getContainerBookingId())
|| s.getContainers().stream().anyMatch(c->source.containerBookingId().equals(c.containerBookingId()))).toList();
if(existing.isEmpty()) {
if(source.establishedFlow()==Flow.UNKNOWN) {
var route=create(source,Type.ROUTE,null,Flow.UNKNOWN); route.setFollowupDate(LocalDate.now(route.getTimezone()==null?REMINDER_ZONE:zone(route.getTimezone()))); store.insert(route); created++;
} else created+=generate(source,source.establishedFlow(),existing,SYSTEM).size();
var route=create(source,Type.ROUTE,null,Flow.UNKNOWN); route.setFollowupDate(LocalDate.now(route.getTimezone()==null?REMINDER_ZONE:zone(route.getTimezone()))); store.insert(route); known.put(route.getWorkItemKey(),route); created++;
} else created+=generate(source,source.establishedFlow(),existing,SYSTEM,known,requirementReader).size();
} else {
var activeFlow=existing.stream().filter(s->s.getType()!=Type.ROUTE && !s.isRetired()).map(AppointmentWorkState::getFlow).findFirst().orElse(null);
if(activeFlow!=null) created+=generate(source,activeFlow,existing,SYSTEM).size();
if(activeFlow!=null) created+=generate(source,activeFlow,existing,SYSTEM,known,requirementReader).size();
else for(var route:existing) if(route.getType()==Type.ROUTE && !route.isRetired()) {
if(source.establishedFlow()!=Flow.UNKNOWN) {
var generated=generate(source,source.establishedFlow(),existing,SYSTEM); created+=generated.size();
var generated=generate(source,source.establishedFlow(),existing,SYSTEM,known,requirementReader); created+=generated.size();
route.setFlow(source.establishedFlow()); route.setRetired(true); route.setRetirementReason("ROUTE_CONFIRMED"); route.setReplacementKeys(generated); store.save(route);
} else if(!Objects.equals(route.getSourceVersion(),source.sourceVersion())) {
route.setSourceVersion(source.sourceVersion()); route.setContainerNo(source.containerNo()); route.setTimezone(source.timezone()); store.save(route);
@@ -48,18 +61,66 @@ public class AppointmentWorkSyncService {
}
}
}
// An existing row is never deleted because an order left the active source page.
// Empty return and other unfinished work continue to read their original authority.
// 原来源离开当前页也保留历史事项,未完成工作仍在第二轮按原业务完整核对。
timing.next(SyncPhase.TRANSPORT_FACTS);
var all=new ArrayList<>(store.all());
var facts=gateway.facts(all.stream().filter(s->s.getType()!=Type.ROUTE && s.getTripKey()==null && !"BATCH_REPLACED".equals(s.getRetirementReason())).toList());
// 首轮仅归并运输关系;保留已关联车次的退役成员,才能发现解绑并恢复原运输事项。
var transportItems=all.stream().filter(s->s.getType()==Type.TRANSPORT && s.getTripKey()==null && !"BATCH_REPLACED".equals(s.getRetirementReason())).toList();
timing.transportFactsCount=transportItems.size();
var facts=gateway.facts(transportItems);
timing.next(SyncPhase.RECONCILE);
reconcileTransport(all,facts);
timing.next(SyncPhase.ACTIVE_STORE);
all=new ArrayList<>(store.all());
facts=gateway.facts(all.stream().filter(s->!s.isRetired()).toList());
var activeItems=all.stream().filter(s->!s.isRetired()).toList();
timing.activeFactsCount=activeItems.size();
timing.next(SyncPhase.ACTIVE_FACTS);
facts=gateway.facts(activeItems);
timing.next(SyncPhase.OBSERVE_CHECKPOINT);
observe(all,facts,SYSTEM);
guard.setScanCursor(page.complete()?null:page.nextCursor());
if(page.complete()) { guard.setInitialized(true); guard.setScopeVersion(page.scopeVersion()); guard.setSynchronizedRequirementVersion(guard.getRequirementVersion()); guard.setCompletedAt(LocalDateTime.now(ZoneOffset.UTC)); }
store.checkpoint(guard);
success=true;
return new SyncResult(page.containers().size(),created,page.nextCursor(),page.complete());
} finally { timing.finish(success); }
}
/** 固定阶段名用于慢同步定位,不包含来源、游标或异常业务内容。 */
private enum SyncPhase { GUARD, DISCOVER, GENERATE, TRANSPORT_FACTS, RECONCILE, ACTIVE_STORE, ACTIVE_FACTS, OBSERVE_CHECKPOINT }
/** 仅统计本次方法执行,不含事务代理提交,不能当作精确锁持有时间。 */
private static final class SyncTiming {
private final Span body=span(Stage.SYNC_BODY);
private Span diagnosticPhase=span(Stage.SYNC_GUARD);
private final long started=System.nanoTime();
private final long[] elapsed=new long[SyncPhase.values().length];
private long phaseStarted=started;
private SyncPhase phase=SyncPhase.GUARD;
private int pageContainers=-1,transportFactsCount=-1,activeFactsCount=-1;
private boolean complete;
/** 完成前一阶段后开始下一阶段;事实阶段包含紧邻的数据库回读。 */
private void next(SyncPhase next) {
long now=System.nanoTime();elapsed[phase.ordinal()]+=now-phaseStarted;phaseStarted=now;phase=next;
diagnosticPhase.close();diagnosticPhase=span(switch(next){
case GUARD->Stage.SYNC_GUARD;case DISCOVER->Stage.SYNC_DISCOVER;case GENERATE->Stage.SYNC_GENERATE;
case TRANSPORT_FACTS->Stage.SYNC_TRANSPORT_FACTS;case RECONCILE->Stage.SYNC_RECONCILE;
case ACTIVE_STORE->Stage.SYNC_ACTIVE_STORE;case ACTIVE_FACTS->Stage.SYNC_ACTIVE_FACTS;case OBSERVE_CHECKPOINT->Stage.SYNC_CHECKPOINT;});
}
/** 成功和失败共用一条慢调用摘要,未到达的计数保持为负一。 */
private void finish(boolean success) {
long now=System.nanoTime();elapsed[phase.ordinal()]+=now-phaseStarted;
if(!success){diagnosticPhase.failed();body.failed();}diagnosticPhase.close();body.close();
long totalMs=java.util.concurrent.TimeUnit.NANOSECONDS.toMillis(now-started);
if(totalMs<250) return;
try {
// 旧 activeFactsMs 仍包含工作表读取、解析及适配;新增明细不能改变旧指标口径。
long activeMs=java.util.concurrent.TimeUnit.NANOSECONDS.toMillis(elapsed[SyncPhase.ACTIVE_STORE.ordinal()]+elapsed[SyncPhase.ACTIVE_FACTS.ordinal()]);
LOG.warn("Appointment work sync slow: totalMs={}, guardMs={}, discoverMs={}, generateMs={}, transportFactsMs={}, reconcileMs={}, activeFactsMs={}, activeStoreMs={}, activeAdapterMs={}, observeCheckpointMs={}, pageContainers={}, transportFactsCount={}, activeFactsCount={}, complete={}, failedStage={}, requestId={}, traceId={}, scopeId={}",
totalMs,millis(SyncPhase.GUARD),millis(SyncPhase.DISCOVER),millis(SyncPhase.GENERATE),millis(SyncPhase.TRANSPORT_FACTS),millis(SyncPhase.RECONCILE),activeMs,millis(SyncPhase.ACTIVE_STORE),millis(SyncPhase.ACTIVE_FACTS),millis(SyncPhase.OBSERVE_CHECKPOINT),
pageContainers,transportFactsCount,activeFactsCount,complete,success?"NONE":phase.name(),org.slf4j.MDC.get("requestId"),org.slf4j.MDC.get("traceId"),currentScopeId());
} catch(Throwable diagnosticFailure) { /* 诊断失败不能替换原业务异常或阻断提交。 */ }
}
/** 阶段仅输出整数毫秒,不携带业务标识。 */
private long millis(SyncPhase value) { return java.util.concurrent.TimeUnit.NANOSECONDS.toMillis(elapsed[value.ordinal()]); }
}
static void validateSource(ContainerSource source) {
if(source==null || source.containerBookingId()==null || source.containerBookingId().isBlank() || source.departedAt()==null && source.departedLocalAt()==null && source.sailedEvidenceLocalAt()==null || source.sourceVersion()==null || source.establishedFlow()==null || source.requirements()==null)
@@ -76,15 +137,25 @@ public class AppointmentWorkSyncService {
if(requirement.timezone()!=null) zone(requirement.timezone());
}
}
/** Caller holds work guard and validates the route against current original facts. */
/** 命令入口仍在调用方守卫事务中即时加载工作集。 */
List<String> generate(ContainerSource source,Flow flow,List<AppointmentWorkState> existing,Actor actor) {
return generate(source,flow,existing,actor,workingSet(),gateway.openRequirements());
}
/** 与持久层全量读取保持稳定键顺序,并保留退役事项及原读取上限。 */
private Map<String,AppointmentWorkState> workingSet() {
var known=new TreeMap<String,AppointmentWorkState>();
store.all().forEach(state->known.put(state.getWorkItemKey(),state));
return known;
}
/** 同页各柜共享最新状态,新增、跨柜关联和退役仍逐项持久化。 */
private List<String> generate(ContainerSource source,Flow flow,List<AppointmentWorkState> existing,Actor actor,
Map<String,AppointmentWorkState> known,RequirementReader requirementReader) {
validateSource(source);
if(flow==Flow.UNKNOWN) throw conflict("真实送货路线尚未明确");
var requirements=gateway.requirements(source,flow);
var requirements=requirementReader.read(source,flow);
source=new ContainerSource(source.containerBookingId(),source.containerNo(),source.departedAt(),source.departedLocalAt(),source.sailedEvidenceLocalAt(),source.sourceVersion(),source.establishedFlow(),source.timezone(),requirements);
validateSource(source);
if(requirements.isEmpty()) throw conflict("真实送货需求尚未明确,不能生成空清单");
var known=store.all().stream().collect(Collectors.toMap(AppointmentWorkState::getWorkItemKey,Function.identity(),(a,b)->a));
var created=new ArrayList<AppointmentWorkState>();
for(var type:flow==Flow.TRANSFER?List.of(Type.PICKUP,Type.WAREHOUSE_RECEIPT,Type.EMPTY_RETURN):List.of(Type.PICKUP,Type.EMPTY_RETURN)) add(source,type,null,flow,known,created);
for(var requirement:source.requirements()) {
@@ -0,0 +1,35 @@
package com.baiyue.lastleg.appointmentwork.diagnostics;
import com.zaxxer.hikari.HikariDataSource;
import org.aopalliance.intercept.MethodInterceptor;
import org.springframework.aop.framework.ProxyFactory;
import org.springframework.aop.support.AopUtils;
import org.springframework.beans.factory.config.BeanPostProcessor;
import javax.sql.DataSource;
import static com.baiyue.lastleg.appointmentwork.diagnostics.AppointmentWorkDiagnostics.*;
/** 仅观察原Hikari getConnection;同池同连接,unwrap保持容器bean self契约而非隐藏target身份。 */
final class AppointmentWorkDataSourcePostProcessor implements BeanPostProcessor {
/** 不识别或已有代理的数据源保持原对象,明确记录未覆盖而不主动探测连接。 */
@Override public Object postProcessAfterInitialization(Object bean,String name) {
if(!(bean instanceof DataSource))return bean;
if(!(bean instanceof HikariDataSource) || AopUtils.isAopProxy(bean)) {
warnUnavailable();return bean;
}
try {
var factory=new ProxyFactory(bean);factory.setProxyTargetClass(true);
factory.addAdvice((MethodInterceptor)invocation->{
if(!invocation.getMethod().getName().equals("getConnection") || !active())return invocation.proceed();
try(var span=span(Stage.CONNECTION_ACQUIRE)) {
try{return invocation.proceed();}catch(Throwable error){span.failed();throw error;}
}
});
return factory.getProxy(bean.getClass().getClassLoader());
} catch(RuntimeException failure) { warnUnavailable();return bean; }
}
/** 不输出 Bean 名、数据源属性或异常内容。 */
private static void warnUnavailable() {
try{org.slf4j.LoggerFactory.getLogger(AppointmentWorkDataSourcePostProcessor.class).warn("Appointment connection diagnostic unavailable: unsupported or already proxied datasource");}
catch(Throwable ignored){ /* 日志故障不改变 Bean 注册。 */ }
}
}
@@ -0,0 +1,39 @@
package com.baiyue.lastleg.appointmentwork.diagnostics;
import jakarta.servlet.*;
import jakarta.servlet.http.*;
import org.springframework.core.Ordered;
import org.springframework.core.annotation.Order;
import org.springframework.stereotype.Component;
import org.springframework.web.filter.OncePerRequestFilter;
import java.io.IOException;
import static com.baiyue.lastleg.appointmentwork.diagnostics.AppointmentWorkDiagnostics.*;
/** 仅测量服务端同步 filter dispatch,不代表客户端总耗时或异步响应完成时间。 */
@Component
@Order(Ordered.HIGHEST_PRECEDENCE+10)
public class AppointmentWorkDiagnosticFilter extends OncePerRequestFilter {
private static final String BASE="/api/platform/v1/last-leg/appointment-work";
/** 仅识别固定模板;路径参数、query string和载荷不进入上下文。 */
static Route route(String method,String path){
if("GET".equals(method)){
if((BASE+"/summary").equals(path))return Route.SUMMARY;
if((BASE+"/calendar").equals(path))return Route.CALENDAR;
if((BASE+"/items").equals(path))return Route.ITEMS;
if((BASE+"/items/resolve").equals(path))return Route.RESOLVE;
if(path!=null && path.matches(BASE+"/items/[^/]+"))return Route.DETAIL;
}
if("POST".equals(method) && path!=null && path.matches(BASE+"/items/[^/]+/commands"))return Route.COMMAND;
return null;
}
/** 不采集其他端点,也不为诊断增加权限判断。 */
@Override protected boolean shouldNotFilter(HttpServletRequest request){return route(request.getMethod(),request.getRequestURI())==null;}
/** 创建及清理线程作用域;不覆写上游相关标识、状态、响应或异常。 */
@Override protected void doFilterInternal(HttpServletRequest request,HttpServletResponse response,FilterChain chain)throws ServletException,IOException{
Scope scope=null;
try{scope=open(Origin.HTTP,route(request.getMethod(),request.getRequestURI()));}catch(RuntimeException ignored){ /* 无诊断仍执行原请求。 */ }
try{chain.doFilter(request,response);}
catch(ServletException|IOException|RuntimeException|Error error){if(scope!=null)scope.failed();throw error;}
finally{if(scope!=null){try{scope.status(response.getStatus());}catch(Throwable ignored){ /* 响应诊断读取失败不替换原异常。 */ }finally{scope.close();}}}
}
}
@@ -0,0 +1,128 @@
package com.baiyue.lastleg.appointmentwork.diagnostics;
import org.slf4j.LoggerFactory;
import org.slf4j.MDC;
import java.util.*;
import java.util.function.LongSupplier;
import java.util.function.Supplier;
/** 仅在当前线程的预约调用内累加固定计时桶,不保存业务参数或跨请求状态。 */
public final class AppointmentWorkDiagnostics {
public enum Origin { HTTP, QUERY, COMMAND, SYNC }
public enum Route { INTERNAL, SUMMARY, CALENDAR, ITEMS, RESOLVE, DETAIL, COMMAND }
public enum Stage {
TRANSACTION_ADVISOR, QUERY_GUARD, QUERY_DISCOVER, QUERY_STORE, QUERY_SELECT, QUERY_FACTS,
QUERY_PROJECT, QUERY_NEXT, QUERY_FINAL, DETAIL_STORE, DETAIL_FACTS, DETAIL_PROJECT, DETAIL_HISTORY,
GUARD_LOCK, GUARD_READ, GUARD_UPDATE, FACTS_PREPARE, FACTS_FIRSTLEG, FACTS_RECEIVING,
FACTS_DELIVERY, FACTS_TRANSPORT, FACTS_DIRECT_TRANSPORT, FACTS_OTHER, FACTS_VALIDATE,
DELIVERY_SOURCE_MAP, DELIVERY_CARGO, DELIVERY_SNAPSHOTS, DELIVERY_ROWS, DELIVERY_RECEIVING,
DELIVERY_IMPACTS, DELIVERY_ASSEMBLY, SYNC_BODY, SYNC_GUARD, SYNC_DISCOVER, SYNC_GENERATE,
SYNC_TRANSPORT_FACTS, SYNC_RECONCILE, SYNC_ACTIVE_STORE, SYNC_ACTIVE_FACTS, SYNC_CHECKPOINT, CONNECTION_ACQUIRE,
DB_EXECUTOR_QUERY, DB_EXECUTOR_UPDATE, DB_PREPARE, DB_STATEMENT_QUERY, DB_STATEMENT_UPDATE, DB_RESULT_MAPPING
}
public enum StatementBucket { NONE, GUARD_LOCK, GUARD_READ, GUARD_UPDATE, WORK_STATE, WORK_HISTORY, DELIVERY, RECEIVING, OTHER }
public record Metric(long calls,long inclusiveNanos,long selfNanos,long failures,long firstOffsetNanos,long lastOffsetNanos) { }
public record Snapshot(String scopeId,String parentScopeId,Origin origin,Route route,String requestId,String traceId,
long elapsedNanos,boolean failed,int status,Map<String,Metric> metrics,boolean transactionObserved,int completionStatus,long completionOffsetNanos,long startedNanos) { }
private static final org.slf4j.Logger LOG=LoggerFactory.getLogger(AppointmentWorkDiagnostics.class);
private static final ThreadLocal<Scope> CURRENT=new ThreadLocal<>();
private static final Span NO_SPAN=new Span(null,null,null,0);
/** 诊断工具无需实例化。 */
private AppointmentWorkDiagnostics() { }
/** 新建独立作用域;关闭时恢复调用方原作用域,不改变 MDC。 */
public static Scope open(Origin origin,Route route) { return new Scope(origin,route,System::nanoTime); }
/** 返回纯诊断标识,缺少作用域时明确为空,不生成业务关联键。 */
public static String currentScopeId() { var scope=CURRENT.get();return scope==null?null:scope.id; }
/** 无作用域时所有阶段计时均为空操作。 */
public static boolean active() { return CURRENT.get()!=null; }
/** 只监听既有事务完成,不开启事务或改变其同步、隔离级别及连接属性。 */
public static void observeTransaction() {
try {
var scope=CURRENT.get();
if(scope==null || scope.transactionObserved || !org.springframework.transaction.support.TransactionSynchronizationManager.isSynchronizationActive()
|| !org.springframework.transaction.support.TransactionSynchronizationManager.isActualTransactionActive())return;
org.springframework.transaction.support.TransactionSynchronizationManager.registerSynchronization(new org.springframework.transaction.support.TransactionSynchronization(){
/** 回调发生在原事务完成后,仅记录相对时刻;不声称这是数据库精确释放锁时刻。 */
@Override public void afterCompletion(int status){try{scope.completionStatus=status;scope.completionOffsetNanos=Math.max(0,scope.clock.getAsLong()-scope.started);}catch(Throwable ignored){}}
});
scope.transactionObserved=true;
}catch(Throwable ignored){ /* 诊断监听失败不能改变事务。 */ }
}
/** 将可信相关标识限制为诊断字符集;任意文本不进入日志。 */
private static String correlation(String value) { return value!=null && value.matches("[A-Za-z0-9_-]{1,80}")?value:null; }
/** 固定阶段开始计时;最多保留64层,避免异常递归造成诊断状态膨胀。 */
public static Span span(Stage stage) { return span(stage,StatementBucket.NONE); }
/** 数据库阶段只接受固定语句桶,不记录 SQL、参数或动态 Mapper 名称。 */
public static Span span(Stage stage,StatementBucket bucket) {
try {
var scope=CURRENT.get();if(scope==null || scope.stack.size()>=64)return NO_SPAN;
var span=new Span(scope,stage,bucket,scope.clock.getAsLong());scope.stack.push(span);return span;
} catch(RuntimeException ignored) { return NO_SPAN; }
}
/** 保留返回对象和原异常;测量只包围原有调用一次。 */
public static <T> T measure(Stage stage,Supplier<T> operation) {
try(var span=span(stage)) { try{return operation.get();}catch(RuntimeException|Error error){span.failed();throw error;} }
}
/** 无返回值调用沿相同路径执行,不增加重试或业务判断。 */
public static void run(Stage stage,Runnable operation) { measure(stage,()->{operation.run();return null;}); }
/** 读取当前统计仅用于固定诊断摘要和隔离验证。 */
static Snapshot currentSnapshot() { var scope=CURRENT.get();return scope==null?null:scope.snapshot(); }
public static final class Scope implements AutoCloseable {
private final Scope previous;
private final String id=UUID.randomUUID().toString();
private final Origin origin;private final Route route;private final LongSupplier clock;
private final String requestId,traceId;private final long started;
private final Deque<Span> stack=new ArrayDeque<>();
private final Map<String,long[]> totals=new LinkedHashMap<>();
private boolean failed,closed,transactionObserved;private int status,completionStatus=-1;private long completionOffsetNanos=-1;
/** 包内时钟注入仅供确定性验证,不修改全局时钟。 */
Scope(Origin origin,Route route,LongSupplier clock) {
this.previous=CURRENT.get();this.origin=origin;this.route=route;this.clock=clock;this.started=clock.getAsLong();
this.requestId=correlation(MDC.get("requestId"));this.traceId=correlation(MDC.get("traceId"));CURRENT.set(this);
}
/** 只标记失败,不保存异常类型以外的业务内容。 */
public void failed() { failed=true; }
/** HTTP状态由容器响应读取,业务code不从载荷解析。 */
public void status(int status) { this.status=status; }
/** 快照为累计已结束阶段;self仅扣同scope直接子段,独立子scope由parentScopeId关联且不可叠加。 */
public Snapshot snapshot() {
var metrics=new LinkedHashMap<String,Metric>();totals.forEach((key,v)->metrics.put(key,new Metric(v[0],v[1],v[2],v[3],v[4],v[5])));
return new Snapshot(id,previous==null?null:previous.id,origin,route,requestId,traceId,Math.max(0,clock.getAsLong()-started),failed,status,Collections.unmodifiableMap(metrics),transactionObserved,completionStatus,completionOffsetNanos,started);
}
/** 只有慢调用或失败输出汇总;清理优先,日志异常不得覆盖原业务异常。 */
@Override public void close() {
if(closed)return;closed=true;
try {
if(CURRENT.get()==this){if(previous==null)CURRENT.remove();else CURRENT.set(previous);}
var s=snapshot();if(!s.failed() && s.status()<500 && s.elapsedNanos()<250_000_000L)return;
// startedNanos及offset只可在同一JVM进程比较,不是数据库锁持有者或跨进程时钟。
LOG.warn("Appointment attribution: scopeId={}, parentScopeId={}, origin={}, route={}, requestId={}, traceId={}, startedNanos={}, elapsedNs={}, failed={}, httpStatus={}, connectionCoverage={}, transactionObserved={}, completionStatus={}, completionOffsetNs={}, boundary=SERVER_DISPATCH_OR_ADVISOR, stages={}",
s.scopeId(),s.parentScopeId(),s.origin(),s.route(),s.requestId(),s.traceId(),s.startedNanos(),s.elapsedNanos(),s.failed(),s.status(),
s.metrics().containsKey(Stage.CONNECTION_ACQUIRE.name())?"OBSERVED":"NOT_OBSERVED",s.transactionObserved(),s.completionStatus(),s.completionOffsetNanos(),s.metrics());
} catch(Throwable ignored) { /* 诊断设施故障不影响返回、原异常或线程清理。 */ }
}
}
public static final class Span implements AutoCloseable {
private final Scope scope;private final Stage stage;private final StatementBucket bucket;private final long started;
private long children;private boolean failed,closed;
/** 保存常量阶段,不引用任何业务对象。 */
private Span(Scope scope,Stage stage,StatementBucket bucket,long started){this.scope=scope;this.stage=stage;this.bucket=bucket;this.started=started;}
/** 失败计数只作为耗时解释,不改变原异常。 */
public void failed(){if(scope!=null){failed=true;scope.failed=true;}}
/** 每个阶段只结束一次;父段self扣除子段inclusive,不能把两种口径相加。 */
@Override public void close(){
if(scope==null || closed)return;closed=true;
try{
long elapsed=Math.max(0,scope.clock.getAsLong()-started);
if(scope.stack.peek()!=this)return;
scope.stack.pop();if(!scope.stack.isEmpty())scope.stack.peek().children+=elapsed;
var key=stage.name()+(bucket==StatementBucket.NONE?"":":"+bucket.name());
var v=scope.totals.computeIfAbsent(key,ignored->new long[]{0,0,0,0,Math.max(0,started-scope.started),0});v[0]++;v[1]+=elapsed;v[2]+=Math.max(0,elapsed-children);if(failed)v[3]++;
v[4]=Math.min(v[4],Math.max(0,started-scope.started));v[5]=Math.max(v[5],Math.max(0,started+elapsed-scope.started));
}catch(Throwable ignored){ /* 诊断计数故障不能替换原结果。 */ }
}
}
}
@@ -0,0 +1,47 @@
package com.baiyue.lastleg.appointmentwork.diagnostics;
import com.baiyue.lastleg.appointmentwork.*;
import org.aopalliance.intercept.MethodInterceptor;
import org.springframework.aop.Advisor;
import org.springframework.aop.support.DefaultPointcutAdvisor;
import org.springframework.aop.support.StaticMethodMatcherPointcut;
import org.springframework.beans.factory.config.BeanDefinition;
import org.springframework.beans.factory.config.BeanPostProcessor;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Role;
import org.springframework.core.Ordered;
import java.lang.reflect.Method;
import java.util.Set;
import static com.baiyue.lastleg.appointmentwork.diagnostics.AppointmentWorkDiagnostics.*;
/** 使用现有 Spring 代理设施观察事务边界,不新增 AspectJ 或改变事务属性。 */
@Configuration(proxyBeanMethods=false)
public class AppointmentWorkDiagnosticsConfiguration {
/** 基础设施角色确保事务自动代理器纳入此 Advisor;顺序早于默认事务 Advisor。 */
@Bean @Role(BeanDefinition.ROLE_INFRASTRUCTURE)
public Advisor appointmentWorkDiagnosticAdvisor() {
var pointcut=new StaticMethodMatcherPointcut() {
/** 仅匹配预约直接入口,不拦截其他模块或内部辅助方法。 */
@Override public boolean matches(Method method,Class<?> targetClass) {
return AppointmentWorkSyncService.class.isAssignableFrom(targetClass) && method.getName().equals("synchronize")
|| AppointmentWorkCommandService.class.isAssignableFrom(targetClass) && method.getName().equals("execute")
|| AppointmentWorkQueryService.class.isAssignableFrom(targetClass) && Set.of("summary","calendar","items","resolveBatch","detail").contains(method.getName());
}
};
MethodInterceptor interceptor=invocation->{
boolean sync=invocation.getThis() instanceof AppointmentWorkSyncService;
Origin origin=sync?Origin.SYNC:invocation.getThis() instanceof AppointmentWorkCommandService?Origin.COMMAND:Origin.QUERY;
Scope owned=null;
try { if(sync || !active())owned=open(origin,Route.INTERNAL); } catch(RuntimeException ignored) { /* 无法计时仍执行原方法。 */ }
try(var span=span(Stage.TRANSACTION_ADVISOR)) {
try{return invocation.proceed();}catch(Throwable error){span.failed();if(owned!=null)owned.failed();throw error;}
} finally { if(owned!=null)owned.close(); }
};
var advisor=new DefaultPointcutAdvisor(pointcut,interceptor);advisor.setOrder(Ordered.HIGHEST_PRECEDENCE+100);return advisor;
}
/** 静态 Bean 避免提早实例化业务依赖,只对既有 Hikari 实例增加委托代理。 */
@Bean public static BeanPostProcessor appointmentWorkDataSourceDiagnostics() { return new AppointmentWorkDataSourcePostProcessor(); }
/** MyBatis 按现有插件入口注册,不注册 Mapper、执行 SQL 或改变缓存配置。 */
@Bean public AppointmentWorkMyBatisDiagnostics appointmentWorkMyBatisDiagnostics() { return new AppointmentWorkMyBatisDiagnostics(); }
}
@@ -0,0 +1,51 @@
package com.baiyue.lastleg.appointmentwork.diagnostics;
import org.apache.ibatis.executor.Executor;
import org.apache.ibatis.executor.resultset.ResultSetHandler;
import org.apache.ibatis.executor.statement.StatementHandler;
import org.apache.ibatis.mapping.BoundSql;
import org.apache.ibatis.mapping.MappedStatement;
import org.apache.ibatis.cache.CacheKey;
import org.apache.ibatis.plugin.*;
import org.apache.ibatis.session.*;
import java.sql.*;
import static com.baiyue.lastleg.appointmentwork.diagnostics.AppointmentWorkDiagnostics.*;
/** query 为含映射的 inclusive 调用;结果读取另记子段,不推断为纯服务器锁等待。 */
@Intercepts({
@Signature(type=Executor.class,method="query",args={MappedStatement.class,Object.class,RowBounds.class,ResultHandler.class}),
@Signature(type=Executor.class,method="query",args={MappedStatement.class,Object.class,RowBounds.class,ResultHandler.class,CacheKey.class,BoundSql.class}),
@Signature(type=Executor.class,method="update",args={MappedStatement.class,Object.class}),
@Signature(type=StatementHandler.class,method="prepare",args={Connection.class,Integer.class}),
@Signature(type=StatementHandler.class,method="query",args={Statement.class,ResultHandler.class}),
@Signature(type=StatementHandler.class,method="update",args={Statement.class}),
@Signature(type=ResultSetHandler.class,method="handleResultSets",args={Statement.class})
})
public final class AppointmentWorkMyBatisDiagnostics implements Interceptor {
private static final ThreadLocal<StatementBucket> BUCKET=new ThreadLocal<>();
/** 所有调用只 proceed 一次,异常原样返回;固定桶上下文在 finally 恢复。 */
@Override public Object intercept(Invocation invocation)throws Throwable {
if(!active())return invocation.proceed();
var previous=BUCKET.get();boolean executor=invocation.getTarget() instanceof Executor;
var bucket=previous==null?StatementBucket.OTHER:previous;
if(executor){bucket=bucket(((MappedStatement)invocation.getArgs()[0]).getId());BUCKET.set(bucket);}
Stage stage=executor?(invocation.getMethod().getName().equals("query")?Stage.DB_EXECUTOR_QUERY:Stage.DB_EXECUTOR_UPDATE)
:invocation.getTarget() instanceof ResultSetHandler?Stage.DB_RESULT_MAPPING
:switch(invocation.getMethod().getName()){case "prepare"->Stage.DB_PREPARE;case "query"->Stage.DB_STATEMENT_QUERY;default->Stage.DB_STATEMENT_UPDATE;};
try(var span=span(stage,bucket)){
try{return invocation.proceed();}catch(Throwable error){span.failed();throw error;}
}finally{if(executor){if(previous==null)BUCKET.remove();else BUCKET.set(previous);}}
}
/** 只有明确白名单语句有独立桶,其他名字一律合入 OTHER,绝不打印原 ID。 */
private static StatementBucket bucket(String id){
return switch(id){
case "com.baiyue.lastleg.appointmentwork.AppointmentWorkMapper.lockGuard"->StatementBucket.GUARD_LOCK;
case "com.baiyue.lastleg.appointmentwork.AppointmentWorkMapper.guard"->StatementBucket.GUARD_READ;
case "com.baiyue.lastleg.appointmentwork.AppointmentWorkMapper.invalidateRequirements","com.baiyue.lastleg.appointmentwork.AppointmentWorkMapper.saveGuard"->StatementBucket.GUARD_UPDATE;
case "com.baiyue.lastleg.appointmentwork.AppointmentWorkMapper.all","com.baiyue.lastleg.appointmentwork.AppointmentWorkMapper.get"->StatementBucket.WORK_STATE;
case "com.baiyue.lastleg.appointmentwork.AppointmentWorkMapper.selectHistory"->StatementBucket.WORK_HISTORY;
case "com.baiyue.lastleg.inboundplan.work.AppointmentWorkDeliveryMapper.batches","com.baiyue.lastleg.inboundplan.work.AppointmentWorkDeliveryMapper.customer","com.baiyue.lastleg.inboundplan.work.AppointmentWorkDeliveryMapper.privateBatch"->StatementBucket.DELIVERY;
default->StatementBucket.OTHER;
};
}
}
@@ -0,0 +1,29 @@
package com.baiyue.lastleg.calendar;
import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.common.security.AuthenticatedUser;
import com.baiyue.lastleg.security.OverseasPermissions;
import jakarta.validation.Valid;
import org.springframework.http.HttpStatus;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.server.ResponseStatusException;
import java.util.List;
@RestController
@RequestMapping("/api/platform/v1/last-leg/appointment-calendar")
public class WarehouseCalendarController {
private final WarehouseCalendarService service;
/** 装配整柜到仓日历读取服务。 */
public WarehouseCalendarController(WarehouseCalendarService service) { this.service = service; }
/** 使用预约和原订单读取权限,写资格继续使用原订单编辑权限。 */
@PostMapping("/warehouse-arrivals/query")
@PreAuthorize("hasAuthority('platform:appointment:list') and hasAuthority('platform:overseas-warehouse:last-mile-order:list')")
public ApiResponse<List<WarehouseCalendarModels.Entry>> calendar(@AuthenticationPrincipal AuthenticatedUser user,
@Valid @RequestBody WarehouseCalendarModels.Query query) {
if (user == null || !"PLATFORM".equals(user.appCode()) || user.tenantId() == null || user.tenantId() != 0L)
throw new ResponseStatusException(HttpStatus.FORBIDDEN, "仅平台可读预约日历");
return ApiResponse.success(service.calendar(query, user.permissions().contains(OverseasPermissions.LAST_MILE_ORDER_EDIT)));
}
}
@@ -0,0 +1,42 @@
package com.baiyue.lastleg.calendar;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
import java.time.LocalDateTime;
import java.util.List;
@Mapper
public interface WarehouseCalendarMapper {
/** 同柜全部客户合并为一条;人工时间和已建入库计划的时间不能被到港推算覆盖。 */
@Select("""
<script>
SELECT * FROM (
SELECT o.source_container_booking_id sourceContainerBookingId,MAX(o.container_no) containerNo,
CASE WHEN COUNT(DISTINCT o.overseas_warehouse_id)=1 THEN MAX(o.overseas_warehouse_name) END warehouseName,
CASE WHEN COUNT(DISTINCT o.overseas_warehouse_id)=1 THEN MAX(o.overseas_warehouse_code) END warehouseCode,
CASE WHEN COUNT(o.estimated_warehouse_at)=COUNT(*) AND COUNT(DISTINCT o.estimated_warehouse_at)=1
AND (MAX(o.arrival_time_version)>0 OR EXISTS(SELECT 1 FROM ll_inbound_plan p
JOIN ll_order po ON po.id=p.source_order_id AND po.customer_id=p.customer_id
WHERE po.source_container_booking_id=o.source_container_booking_id))
THEN MAX(o.estimated_warehouse_at) END plannedAt,
(SELECT MIN(s.receiving_at) FROM ll_receiving_container_session s
WHERE s.source_container_booking_id=o.source_container_booking_id) actualAt,
CASE WHEN COUNT(DISTINCT o.arrival_time_version)=1 THEN MAX(o.arrival_time_version) ELSE -1 END version,
(SELECT COUNT(*) FROM ll_inbound_plan p JOIN ll_order po ON po.id=p.source_order_id AND po.customer_id=p.customer_id
WHERE po.source_container_booking_id=o.source_container_booking_id) planCount,
FALSE canEdit
FROM ll_order o WHERE o.source_container_booking_id IS NOT NULL
GROUP BY o.source_container_booking_id
) c WHERE
(c.plannedAt &gt;= #{from} AND c.plannedAt &lt; #{to})
<if test="ids != null and !ids.isEmpty()">
OR c.sourceContainerBookingId IN
<foreach collection="ids" item="id" open="(" separator="," close=")">#{id}</foreach>
</if>
ORDER BY c.sourceContainerBookingId LIMIT 2001
</script>
""")
List<WarehouseCalendarModels.Entry> calendar(@Param("from") LocalDateTime from,
@Param("to") LocalDateTime to, @Param("ids") List<String> ids);
}
@@ -0,0 +1,20 @@
package com.baiyue.lastleg.calendar;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Pattern;
import jakarta.validation.constraints.Size;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.util.List;
/** 只读日历沿用整柜预计到仓版本,不保存独立计划台账。 */
public final class WarehouseCalendarModels {
/** 禁止构造工具类。 */
private WarehouseCalendarModels() { }
public record Query(@NotNull LocalDate from, @NotNull LocalDate to,
@NotNull @Size(max = 2000) List<@NotBlank @Pattern(regexp = "[1-9][0-9]*") String> sourceContainerBookingIds) { }
public record Entry(String sourceContainerBookingId, String containerNo, String warehouseName,
String warehouseCode, LocalDateTime plannedAt, LocalDateTime actualAt, int version,
long planCount, boolean canEdit) { }
}
@@ -0,0 +1,15 @@
package com.baiyue.lastleg.calendar;
import org.springframework.stereotype.Repository;
import java.util.List;
@Repository
public class WarehouseCalendarRepository {
private final WarehouseCalendarMapper mapper;
/** 只通过本服务 Mapper 查询柜级到仓事实。 */
public WarehouseCalendarRepository(WarehouseCalendarMapper mapper) { this.mapper = mapper; }
/** 同时覆盖跨月人工改期的柜与本次到港来源柜。 */
public List<WarehouseCalendarModels.Entry> calendar(WarehouseCalendarModels.Query query) {
return mapper.calendar(query.from().atStartOfDay(), query.to().atStartOfDay(), query.sourceContainerBookingIds());
}
}
@@ -0,0 +1,27 @@
package com.baiyue.lastleg.calendar;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Service;
import org.springframework.web.server.ResponseStatusException;
import java.time.temporal.ChronoUnit;
import java.util.HashSet;
import java.util.List;
@Service
public class WarehouseCalendarService {
private final WarehouseCalendarRepository repository;
/** 装配日历只读仓储查询。 */
public WarehouseCalendarService(WarehouseCalendarRepository repository) { this.repository = repository; }
/** 明确权限、版本与计划冻结资格;不以默认计划改写真实到仓时间。 */
public List<WarehouseCalendarModels.Entry> calendar(WarehouseCalendarModels.Query query, boolean canEdit) {
if (query.from() == null || query.to() == null || !query.to().isAfter(query.from())
|| ChronoUnit.DAYS.between(query.from(), query.to()) > 42
|| new HashSet<>(query.sourceContainerBookingIds()).size() != query.sourceContainerBookingIds().size())
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "日历范围须为1至42天,来源柜不能重复");
var rows = repository.calendar(query);
if (rows.size() > 2000) throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "到仓记录过多,请缩小日期范围");
return rows.stream().map(row -> new WarehouseCalendarModels.Entry(row.sourceContainerBookingId(), row.containerNo(),
row.warehouseName(), row.warehouseCode(), row.plannedAt(), row.actualAt(), row.version(), row.planCount(),
canEdit && row.version() >= 0 && row.planCount() == 0 && row.actualAt() == null)).toList();
}
}
@@ -52,6 +52,7 @@ public class CustomerVisitService {
@Resource private LastlegInboundOrderMapper inboundOrders;
@Resource private CustomerVisitFileGateway files;
@Resource private ObjectMapper objectMapper;
@Resource private com.baiyue.lastleg.delivery.repository.DeliveryExecutionRepository deliveryExecution;
public CustomerVisitResponses.Page page(String scopedCustomerId, CustomerVisitRequests.Query query) {
String customerId = customerScope(scopedCustomerId, query.customerId());
@@ -76,6 +77,7 @@ public class CustomerVisitService {
return detail(visit);
}
/** 人工派送动作必须在真实出库后执行,取消统一走出库聚合命令。 */
@Transactional
public CustomerVisitResponses.Completed updateDeliveryStatus(String customerId, String operatorId,
String deliveryNo,
@@ -84,8 +86,14 @@ public class CustomerVisitService {
if (!DeliveryStatusPolicy.isEditableTarget(target)) {
throw badRequest("不支持的派送状态");
}
DeliveryOrder delivery = deliveryOrders.lockByNo(customerId, deliveryNo);
var observed = deliveryExecution.findDelivery(customerId, deliveryNo);
if (observed == null) throw notFound("派送单不存在");
// 不反向申请排车锁;按出库→派送顺序与取消、发车串行。
var outbound = deliveryExecution.lockOutbound(customerId, observed.getOutboundNo());
DeliveryOrder delivery = deliveryExecution.lockDelivery(customerId, deliveryNo);
if (delivery == null) throw notFound("派送单不存在");
if (!com.baiyue.lastleg.delivery.service.DeliveryExecutionGuard.matches(delivery, outbound))
throw conflict("派送尚未实际出库,不能开始派送或拒签");
if (target.equals(delivery.getStatus())) {
CustomerVisit existing = visits.findByDelivery(customerId, delivery.getId());
return new CustomerVisitResponses.Completed(existing == null ? null : existing.getId(), deliveryNo,
@@ -99,7 +107,7 @@ public class CustomerVisitService {
if ("DELIVERING".equals(target) && delivery.getDispatchedAt() == null) delivery.setDispatchedAt(now);
delivery.setUpdatedAt(now);
delivery.setVersion(version(delivery) + 1);
deliveryOrders.updateById(delivery);
deliveryExecution.save(delivery);
String result = "派送状态由 " + from + " 修改为 " + target;
if (StringUtils.hasText(request.remark())) result += ",备注:" + trim(request.remark());
recordOperation(delivery, operatorId, "UPDATE_DELIVERY_STATUS", result, now);
@@ -12,6 +12,7 @@ import java.util.List;
@Mapper
public interface DeliveryOperationMapper extends BaseMapper<DeliveryOperation> {
/** 普通库存流水与原箱回仓凭据共同证明实际退回,每个原箱只计一次箱数。 */
@Select("SELECT l.id AS ledger_id, di.id AS dispatch_item_id, oi.warehouse_pallet_id AS pallet_id, " +
"l.delta_packages AS packages, l.occurred_at AS received_at FROM ll_delivery_order d " +
"JOIN ll_exception_order e ON e.customer_id=d.customer_id AND e.delivery_order_id=d.id " +
@@ -27,7 +28,10 @@ public interface DeliveryOperationMapper extends BaseMapper<DeliveryOperation> {
"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")
"WHERE d.customer_id=#{customerId} AND d.id=#{deliveryOrderId} UNION ALL SELECT MIN(r.id),r.source_dispatch_item_id,oi.warehouse_pallet_id,SUM(r.packages),r.received_at " +
"FROM ll_return_carton_stock_receipt r JOIN ll_outbound_pallet_item oi ON oi.id=r.source_outbound_pallet_item_id AND oi.customer_id=r.customer_id " +
"WHERE r.customer_id=#{customerId} AND r.delivery_order_id=#{deliveryOrderId} " +
"GROUP BY r.receipt_no,r.source_dispatch_item_id,oi.warehouse_pallet_id,r.received_at ORDER BY received_at,ledger_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} " +
@@ -4,7 +4,7 @@ import com.baiyue.lastleg.delivery.dto.DeliveryFeeCoverageModels;
import org.apache.ibatis.annotations.*;
import java.util.List;
/** Reconstruct every selected inbound source, including shared pallets and already picked quantities. */
/** 还原整托实际入仓组成及本次已拣数量,不按展示计划过滤合托来源。 */
@Mapper
public interface DispatchFeeCargoMapper {
@Select("""
@@ -22,13 +22,10 @@ public interface DispatchFeeCargoMapper {
GROUP BY pk.dispatch_item_id,pk.inventory_position_id) picked
ON picked.dispatch_item_id=di.id AND picked.inventory_position_id=p.id
WHERE di.dispatch_order_id=#{dispatchId} AND di.customer_id=#{customerId}
AND (di.plan_pallet_id IS NULL OR EXISTS (
SELECT 1 FROM ll_inbound_plan_pallet_allocation a
JOIN ll_inbound_plan_line line ON line.id=a.line_id AND line.customer_id=di.customer_id
WHERE a.pallet_id=di.plan_pallet_id AND line.order_id=b.inbound_order_id))
GROUP BY di.id,di.warehouse_pallet_id,di.pallet_no,b.inbound_order_id,o.source_region,o.source_inbound_id,b.inbound_code,di.inbound_code
HAVING SUM(p.available_packages+COALESCE(picked.packages,0))>0
ORDER BY di.id,b.inbound_order_id
""")
/** 整托候选箱数来自物理库存,因此组成也必须包含该托所有实际来源。 */
List<DeliveryFeeCoverageModels.Cargo> list(@Param("customerId") String customerId, @Param("dispatchId") long dispatchId);
}
@@ -6,9 +6,9 @@ import java.util.Set;
public final class DeliveryStatusPolicy {
private static final Set<String> EDITABLE_TARGETS = Set.of(
"WAIT_DELIVERY", "DELIVERING", "REJECTED", "CANCELLED");
"WAIT_DELIVERY", "DELIVERING", "REJECTED");
private static final Map<String, Set<String>> TRANSITIONS = Map.of(
"WAIT_DELIVERY", Set.of("DELIVERING", "DELIVERED", "REJECTED", "CANCELLED"),
"WAIT_DELIVERY", Set.of("DELIVERING", "DELIVERED", "REJECTED"),
"DELIVERING", Set.of("DELIVERED", "REJECTED"),
"DELIVERED", Set.of(),
"REJECTED", Set.of(),
@@ -16,23 +16,32 @@ public final class DeliveryStatusPolicy {
"RETURNED", Set.of(),
"CANCELLED", Set.of());
/** 状态规则为纯策略,实际出库由命令及查询层提供。 */
private DeliveryStatusPolicy() { }
/** 人工只允许开始派送或拒签,取消必须由出库聚合命令处理。 */
public static boolean isEditableTarget(String status) {
return EDITABLE_TARGETS.contains(status);
}
/** 校验状态机,签收终态仍由签收资料命令触发。 */
public static boolean canTransition(String from, String target) {
return TRANSITIONS.getOrDefault(from, Set.of()).contains(target);
}
/** 返回状态本身允许的动作;执行动作还须满足实际出库事实。 */
public static List<String> allowedActions(String status) {
return switch (status == null ? "" : status) {
case "WAIT_DELIVERY" -> List.of(
"START_DELIVERY", "REJECT_DELIVERY", "CANCEL_DELIVERY");
"START_DELIVERY", "REJECT_DELIVERY");
case "DELIVERING" -> List.of("REJECT_DELIVERY", "MARK_RETURN");
case "DELIVERED", "REJECTED", "RETURN_PENDING" -> List.of("MARK_RETURN");
default -> List.of();
};
}
/** 未实际出库不得开始派送、拒签或创建运输退回。 */
public static List<String> allowedActions(String status, boolean outboundCompleted) {
return outboundCompleted ? allowedActions(status) : List.of();
}
}
@@ -0,0 +1,52 @@
package com.baiyue.lastleg.delivery.repository;
import com.baiyue.lastleg.delivery.entity.DeliveryOrder;
import com.baiyue.lastleg.delivery.entity.OutboundOrder;
import com.baiyue.lastleg.delivery.mapper.DeliveryOrderMapper;
import com.baiyue.lastleg.delivery.mapper.OutboundOrderMapper;
import org.springframework.stereotype.Repository;
import java.util.List;
/** 派送执行门禁的数据入口,保留取消单据历史。 */
@Repository
public class DeliveryExecutionRepository {
private final OutboundOrderMapper outbounds;
private final DeliveryOrderMapper deliveries;
/** 装配同库的出库及派送访问。 */
public DeliveryExecutionRepository(OutboundOrderMapper outbounds, DeliveryOrderMapper deliveries) {
this.outbounds = outbounds;
this.deliveries = deliveries;
}
/** 按客户和业务编号读取原派送,供命令确定出库锁顺序。 */
public DeliveryOrder findDelivery(String customerId, String deliveryNo) {
return deliveries.findByNo(customerId, deliveryNo);
}
/** 先锁出库,再锁派送,与取消和发车保持一致。 */
public OutboundOrder lockOutbound(String customerId, String outboundNo) {
return outbounds.lockByNo(customerId, outboundNo);
}
/** 锁定客户范围内的派送,锁后由业务层复核关联。 */
public DeliveryOrder lockDelivery(String customerId, String deliveryNo) {
return deliveries.lockByNo(customerId, deliveryNo);
}
/** 读取客户范围内的出库事实,用于动作展示。 */
public OutboundOrder findOutbound(String customerId, String outboundNo) {
return outbounds.findByNo(customerId, outboundNo);
}
/** 发车时锁定全部派送,取消与执行只能产生一个合法结果。 */
public List<DeliveryOrder> deliveries(OutboundOrder outbound, boolean lock) {
return lock ? deliveries.lockByOutbound(outbound.getCustomerId(), outbound.getId())
: deliveries.findByOutbound(outbound.getCustomerId(), outbound.getId());
}
/** 保存已校验版本的派送动作。 */
public void save(DeliveryOrder delivery) {
deliveries.updateById(delivery);
}
}
@@ -0,0 +1,18 @@
package com.baiyue.lastleg.delivery.repository;
import com.baiyue.lastleg.delivery.dto.DeliveryFeeCoverageModels;
import com.baiyue.lastleg.delivery.mapper.DispatchFeeCargoMapper;
import org.springframework.stereotype.Repository;
import java.util.List;
/** 复用库存组成及本次拣货来源,不把展示入仓码当作整托归属。 */
@Repository
public class DispatchCargoSourceRepository {
private final DispatchFeeCargoMapper sources;
/** 装配实际货物组成查询。 */
public DispatchCargoSourceRepository(DispatchFeeCargoMapper sources) { this.sources = sources; }
/** 按客户和当前排车读取逐入仓来源数量。 */
public List<DeliveryFeeCoverageModels.Cargo> sources(String customerId, long dispatchId) {
return sources.list(customerId, dispatchId);
}
}
@@ -0,0 +1,53 @@
package com.baiyue.lastleg.delivery.service;
import com.baiyue.lastleg.delivery.entity.DeliveryOrder;
import com.baiyue.lastleg.delivery.entity.OutboundOrder;
import com.baiyue.lastleg.delivery.repository.DeliveryExecutionRepository;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Service;
import org.springframework.web.server.ResponseStatusException;
import java.util.Objects;
/** 统一派送执行事实,防止状态编辑绕过出库及车次事务。 */
@Service
public class DeliveryExecutionGuard {
private final DeliveryExecutionRepository repository;
/** 装配执行事实的数据入口。 */
public DeliveryExecutionGuard(DeliveryExecutionRepository repository) { this.repository = repository; }
/** 展示动作只认同客户、同出库单的实际出库事实。 */
public boolean outboundCompleted(DeliveryOrder delivery) {
return matches(delivery, repository.findOutbound(delivery.getCustomerId(), delivery.getOutboundNo()));
}
/** 发车前要求派送覆盖完整、均未执行,锁定检查与出库提交同事务。 */
public String departureBlockReason(OutboundOrder outbound, boolean lock) {
var rows = repository.deliveries(outbound, lock);
if (rows.isEmpty()) return "缺少对应派送单,请先核对转派送结果";
if (rows.stream().anyMatch(row -> !Objects.equals(row.getCustomerId(), outbound.getCustomerId())
|| !Objects.equals(row.getOutboundOrderId(), outbound.getId())
|| !Objects.equals(row.getOutboundNo(), outbound.getOutboundNo())
|| !"WAIT_DELIVERY".equals(row.getStatus()) || row.getDispatchedAt() != null || row.getSignedAt() != null
|| row.getPackageCount() == null || row.getPackageCount() <= 0))
return "关联派送单已取消、已执行或来源不一致,不能发车";
if (rows.stream().mapToLong(DeliveryOrder::getPackageCount).sum()
!= Objects.requireNonNullElse(outbound.getPackageCount(), 0))
return "关联派送数量未完整覆盖出库货物";
return null;
}
/** 真正发车时再次加锁校验,不能依赖列表查询结果。 */
public void requireDeparture(OutboundOrder outbound) {
String reason = departureBlockReason(outbound, true);
if (reason != null) throw new ResponseStatusException(HttpStatus.CONFLICT, reason);
}
/** 共享实际出库判据,不能只凭派送状态或发车时间推断。 */
public static boolean matches(DeliveryOrder delivery, OutboundOrder outbound) {
return outbound != null && "OUTBOUNDED".equals(outbound.getStatus())
&& Objects.equals(delivery.getCustomerId(), outbound.getCustomerId())
&& Objects.equals(delivery.getOutboundOrderId(), outbound.getId())
&& Objects.equals(delivery.getOutboundNo(), outbound.getOutboundNo());
}
}
@@ -78,14 +78,15 @@ public class DeliveryFeeCoverageService {
return "实物库存及本次已拣数量与排车箱数不一致,请先核对货物范围";
return null;
}
public record Decision(Set<String> includedServices, DeliveryFeeCoverageModels.Evidence evidence, Set<Long> externalItems) {
public record Decision(Set<String> includedServices, DeliveryFeeCoverageModels.Evidence evidence, Set<String> externalInboundCodes) {
public static Decision none() { return new Decision(Set.of(), null, Set.of()); }
public List<DeliveryFeeCoverageModels.Cargo> uncovered(String service) {
if (evidence == null) return List.of();
return evidence.cargo().stream().filter(row -> !covers(row, service, evidence.references(), externalItems)).toList();
return evidence.cargo().stream().filter(row -> !covers(row, service, evidence.references(), externalInboundCodes)).toList();
}
}
public Decision confirm(String dispatchNo, DeliveryRequests.Transfer request, String operator, Set<Long> externalItems) {
/** 外接覆盖按实际入仓来源判定,不能免除同托普通货的派送费。 */
public Decision confirm(String dispatchNo, DeliveryRequests.Transfer request, String operator, Set<String> externalInboundCodes) {
if (request.feeCoverage() == null || request.feeCoverage().isEmpty()) return Decision.none();
var preview = preview(dispatchNo);
if (!preview.sourceComplete()) throw conflict(preview.blockReason());
@@ -106,13 +107,13 @@ public class DeliveryFeeCoverageService {
}
var included = new HashSet<String>();
for (String service : List.of("DELIVERY_FEE", "LOADING_FEE", "APPOINTMENT_FEE"))
if (preview.cargo().stream().allMatch(row -> covers(row, service, references, externalItems))) included.add(service);
if (preview.cargo().stream().allMatch(row -> covers(row, service, references, externalInboundCodes))) included.add(service);
return new Decision(Set.copyOf(included), new DeliveryFeeCoverageModels.Evidence(preview.cargoHash(), preview.cargo(),
List.copyOf(references), operator, Instant.now()), Set.copyOf(externalItems));
List.copyOf(references), operator, Instant.now()), Set.copyOf(externalInboundCodes));
}
private static boolean covers(DeliveryFeeCoverageModels.Cargo cargo, String service,
List<DeliveryFeeCoverageModels.Reference> references, Set<Long> externalItems) {
if ("DELIVERY_FEE".equals(service) && externalItems.contains(cargo.dispatchItemId())) return true;
List<DeliveryFeeCoverageModels.Reference> references, Set<String> externalInboundCodes) {
if ("DELIVERY_FEE".equals(service) && externalInboundCodes.contains(cargo.inboundCode())) return true;
return "FIRSTLEG".equals(cargo.sourceRegion()) && references.stream().anyMatch(reference ->
reference.source().sourceInboundId().toString().equals(cargo.sourceInboundId()) && reference.services().contains(service));
}
@@ -132,6 +132,8 @@ public class DeliveryTransferService {
@Resource private FleetDispatchBindingGuard fleetBindings;
@Resource private FleetBookingFreight bookingFreight;
@Resource private DeliveryFeeCoverageService feeCoverage;
@Resource private DeliveryExecutionGuard deliveryExecution;
@Resource private DispatchCargoSourceQuery cargoSources;
@Transactional(readOnly = true, isolation = org.springframework.transaction.annotation.Isolation.REPEATABLE_READ)
public FleetBookingFreight.Preview externalFreight(String dispatchNo) {
@@ -140,13 +142,10 @@ public class DeliveryTransferService {
return externalFreight(dispatch, dispatchItems.findByDispatchOrder(dispatch.getCustomerId(), dispatch.getId()));
}
/** 外接运费与车次绑定使用相同的实际来源和数量。 */
private FleetBookingFreight.Preview externalFreight(DispatchOrder dispatch, List<DispatchItem> items) {
return bookingFreight.preview(dispatch.getId(), dispatch.getDispatchNo(), dispatch.getCustomerId(), items.stream()
.map(item -> new FleetDispatchGateway.SourceCargo(item.getInboundCode(),
item.getWarehousePalletId() == null ? null : item.getWarehousePalletId().toString(), item.getPalletNo(),
Objects.requireNonNullElse(item.getPackageCount(), 0), FleetDispatchGateway.destinationKey(item.getDestinationType(),
item.getDestinationWarehouseCode(), item.getFbaWarehouseCode(), item.getDeliveryAddress(), item.getPostalCode())))
.toList());
return bookingFreight.preview(dispatch.getId(), dispatch.getDispatchNo(), dispatch.getCustomerId(),
cargoSources.sources(dispatch, items));
}
@Transactional
@@ -169,6 +168,7 @@ public class DeliveryTransferService {
return transferResolved(dispatch, operatorId, request);
}
/** 排车锁内创建完整目的地派送,历史重放不生成新单或重复发布费用。 */
private DeliveryResponses.TransferResult transferResolved(DispatchOrder dispatch, String operatorId,
DeliveryRequests.Transfer request) {
String customerId = dispatch.getCustomerId();
@@ -197,10 +197,9 @@ public class DeliveryTransferService {
if ((!freight.quotes().isEmpty() || StringUtils.hasText(request.externalFreightHash()))
&& !Objects.equals(freight.snapshotHash(), request.externalFreightHash()))
throw conflict("外接实收或约定运费已变化,请重新打开转派送核对");
var quotedIncomingNos = freight.quotes().stream().map(FleetBookingFreight.Quote::incomingNo).collect(Collectors.toSet());
var externalItems = items.stream().filter(item -> quotedIncomingNos.contains(item.getInboundCode())).map(DispatchItem::getId).collect(Collectors.toSet());
var externalInboundCodes = freight.quotes().stream().map(FleetBookingFreight.Quote::inboundCode).collect(Collectors.toSet());
var coverage = request.feeCoverage() == null || request.feeCoverage().isEmpty() ? DeliveryFeeCoverageService.Decision.none()
: feeCoverage.confirm(dispatch.getDispatchNo(), request, operatorId, externalItems);
: feeCoverage.confirm(dispatch.getDispatchNo(), request, operatorId, externalInboundCodes);
validateTransferFees(request, freight.requiresDeliveryFee(), coverage.includedServices());
if (!freight.priorInboundCodes().isEmpty()) feeSources.requireNoActiveDeliveryReceivables(customerId, freight.priorInboundCodes());
@@ -303,8 +302,10 @@ public class DeliveryTransferService {
deliveries.add(delivery);
}
for (var quote : freight.quotes()) {
var source = items.stream().filter(item -> quote.incomingNo().equals(item.getInboundCode())).findFirst().orElseThrow();
var destination = deliveries.stream().filter(item -> deliveryGroupKey(source).equals(item.getDeliveryGroupKey())).findFirst().orElseThrow();
var destination = deliveries.stream().filter(item -> Objects.equals(quote.destinationKey(), FleetDispatchGateway.destinationKey(
item.getDestinationType(), item.getDestinationWarehouseCode(), item.getDestinationWarehouseCode(),
item.getDeliveryAddress(), item.getPostalCode()))).findFirst()
.orElseThrow(() -> conflict("外接约定目的地未生成对应派送单"));
recordOperation(customerId, destination.getId(), operatorId, "CONFIRM_EXTERNAL_FREIGHT",
"外接预订 " + quote.bookingNo() + ";实收确认版本 " + quote.recordVersion() + ";约定运费 "
+ quote.currency() + " " + quote.amount().toPlainString(), now);
@@ -320,7 +321,8 @@ public class DeliveryTransferService {
}
DeliveryOrder feeDelivery = deliveries.get(0);
List<DeliveryRequests.FeeItem> feeInputs = request.fees() == null ? List.of() : request.fees();
var quotedIncoming = freight.quotes().stream().map(FleetBookingFreight.Quote::incomingNo).collect(Collectors.toSet());
var ordinaryCargo = freight.requiresDeliveryFee() ? cargoSources.sources(dispatch, items).stream()
.filter(source -> !externalInboundCodes.contains(source.inboundCode())).toList() : List.<FleetDispatchGateway.SourceCargo>of();
for (DeliveryRequests.FeeItem input : feeInputs) {
String feeCode = input.feeType().trim().toUpperCase(Locale.ROOT);
var uncovered = coverage.uncovered(feeCode);
@@ -328,11 +330,19 @@ public class DeliveryTransferService {
var chargeItems = coverage.evidence() != null && "RECEIVABLE".equals(input.direction())
? items.stream().filter(item -> uncoveredItemIds.contains(item.getId())).toList()
: "RECEIVABLE".equals(input.direction()) && "DELIVERY_FEE".equals(feeCode) && freight.requiresDeliveryFee()
? items.stream().filter(item -> !quotedIncoming.contains(item.getInboundCode())).toList() : items;
? items.stream().filter(item -> ordinaryCargo.stream().anyMatch(source -> Objects.equals(source.warehousePalletId(),
item.getWarehousePalletId().toString()) && Objects.equals(source.destinationKey(), FleetDispatchGateway.destinationKey(
item.getDestinationType(), item.getDestinationWarehouseCode(), item.getFbaWarehouseCode(), item.getDeliveryAddress(), item.getPostalCode())))).toList() : items;
var feeDestination = "DELIVERY_FEE".equalsIgnoreCase(input.feeType()) && !chargeItems.isEmpty()
? deliveries.stream().filter(item -> deliveryGroupKey(chargeItems.get(0)).equals(item.getDeliveryGroupKey())).findFirst().orElseThrow()
: feeDelivery;
LastlegOrder firstOrder = firstTailOrder(customerId, chargeItems);
// 合托后按真实普通来源归属费用;多票时只绑定派送,不借展示入仓码挂到外接货。
if ("RECEIVABLE".equals(input.direction()) && "DELIVERY_FEE".equals(feeCode) && !externalInboundCodes.isEmpty()) {
var ordinaryOrders = cargoSources.ordinaryOrderIds(dispatch, externalInboundCodes);
firstOrder = ordinaryOrders.size() == 1 ? tailOrders.selectById(ordinaryOrders.iterator().next()) : null;
if (firstOrder != null && !customerId.equals(firstOrder.getCustomerId())) throw conflict("新增费用来源客户不一致");
}
if (coverage.evidence() != null && "RECEIVABLE".equals(input.direction())) {
var uncoveredOrderIds = uncovered.stream().map(com.baiyue.lastleg.delivery.dto.DeliveryFeeCoverageModels.Cargo::orderId).filter(Objects::nonNull).distinct().toList();
firstOrder = uncoveredOrderIds.size() == 1 ? tailOrders.selectById(uncoveredOrderIds.get(0)) : null;
@@ -354,6 +364,7 @@ public class DeliveryTransferService {
return result(outbound, deliveries, false);
}
/** 取消后的同载荷重放仍返回原取消单据,绝不作为新建成功。 */
private DeliveryResponses.TransferResult replayTransfer(OutboundOrder outbound, String requestHash) {
if (!requestHash.equals(outbound.getRequestHash()))
throw conflict("转派送请求与原已保存内容不一致,请查看原单据;费用调整须通过正式费用更正流程");
@@ -1637,7 +1648,8 @@ public class DeliveryTransferService {
allocateMetric(candidate.getSourceVolumeWeightKg(), candidate.getSourcePackageCount(), available, 3),
allocateMetric(candidate.getSourceVolumeCbm(), candidate.getSourcePackageCount(), available, 4));
}
private DeliveryResponses.DeliveryRow deliveryRow(DeliveryOrder d) { return new DeliveryResponses.DeliveryRow(d.getId(), d.getCustomerId(), d.getCustomerName(), d.getDeliveryNo(), d.getOutboundNo(), d.getDispatchNo(), d.getOverseasWarehouseId(), d.getOverseasWarehouseName(), d.getStatus(), d.getWaybillNo(), d.getDeliveryMethod(), d.getCarrierName(), d.getPickupMethod(), d.getLoadingType(), d.getVehicleType(), d.getVehicleSize(), d.getVehiclePlate(), d.getDriverName(), d.getDriverPhone(), d.getAppointmentAccount(), d.getSellerName(), value(d.getPalletCount()), value(d.getPackageCount()), d.getSignedPackages(), d.getTotalWeightKg(), d.getTotalVolumeCbm(), d.getPlannedDeliveryAt(), d.getDispatchedAt(), d.getExpectedArrivalAt(), d.getSignedAt(), d.getRemark(), d.getCreatedAt(), value(d.getVersion()), DeliveryStatusPolicy.allowedActions(d.getStatus()), d.getBolNo(), DeliveryDocumentSnapshot.summary(d.getDocumentsJson()), d.getDestinationWarehouseId(), d.getDestinationWarehouseCode(), d.getDestinationWarehouseName(), d.getDestinationType(), d.getDeliveryAddress(), d.getContactName(), d.getContactPhone(), d.getPostalCode()); }
/** 派送动作由状态和实际出库共同决定,列表与详情复用同一资格。 */
private DeliveryResponses.DeliveryRow deliveryRow(DeliveryOrder d) { return new DeliveryResponses.DeliveryRow(d.getId(), d.getCustomerId(), d.getCustomerName(), d.getDeliveryNo(), d.getOutboundNo(), d.getDispatchNo(), d.getOverseasWarehouseId(), d.getOverseasWarehouseName(), d.getStatus(), d.getWaybillNo(), d.getDeliveryMethod(), d.getCarrierName(), d.getPickupMethod(), d.getLoadingType(), d.getVehicleType(), d.getVehicleSize(), d.getVehiclePlate(), d.getDriverName(), d.getDriverPhone(), d.getAppointmentAccount(), d.getSellerName(), value(d.getPalletCount()), value(d.getPackageCount()), d.getSignedPackages(), d.getTotalWeightKg(), d.getTotalVolumeCbm(), d.getPlannedDeliveryAt(), d.getDispatchedAt(), d.getExpectedArrivalAt(), d.getSignedAt(), d.getRemark(), d.getCreatedAt(), value(d.getVersion()), DeliveryStatusPolicy.allowedActions(d.getStatus(), deliveryExecution.outboundCompleted(d)), d.getBolNo(), DeliveryDocumentSnapshot.summary(d.getDocumentsJson()), d.getDestinationWarehouseId(), d.getDestinationWarehouseCode(), d.getDestinationWarehouseName(), d.getDestinationType(), d.getDeliveryAddress(), d.getContactName(), d.getContactPhone(), d.getPostalCode()); }
private DeliveryResponses.DeliveryFeeRow feeRow(OverseasFeeSource f) { return new DeliveryResponses.DeliveryFeeRow(f.getId(), f.getDirection(), f.getFeeItemCode(), f.getOriginalCurrency(), f.getOriginalAmount(), f.getReviewReason(), f.getReviewStatus(), f.getSourceStatus(), f.getVoidStatus()); }
private DeliveryResponses.DeliveryOperationRow operationRow(DeliveryOperation o) { return new DeliveryResponses.DeliveryOperationRow(o.getId(), o.getCreatedAt(), o.getOperationNode(), firstText(o.getOperatorName(), o.getOperatorId()), o.getResult()); }
private void recordOperation(String customerId, Long deliveryId, String operatorId, String node, String result, LocalDateTime now) { DeliveryOperation operation = new DeliveryOperation(); operation.setCustomerId(customerId); operation.setDeliveryOrderId(deliveryId); operation.setOperationNode(node); operation.setOperatorId(operatorId); operation.setOperatorName(operatorId); operation.setResult(result); operation.setCreatedAt(now); deliveryOperations.insert(operation); }
@@ -0,0 +1,57 @@
package com.baiyue.lastleg.delivery.service;
import com.baiyue.lastleg.dispatch.entity.DispatchItem;
import com.baiyue.lastleg.dispatch.entity.DispatchOrder;
import com.baiyue.lastleg.delivery.repository.DispatchCargoSourceRepository;
import com.baiyue.lastleg.fleet.trip.FleetDispatchGateway;
import org.springframework.stereotype.Service;
import org.springframework.http.HttpStatus;
import org.springframework.web.server.ResponseStatusException;
import java.util.*;
/** 外接绑定和约定运费共同消费同一份逐来源货物。 */
@Service
public class DispatchCargoSourceQuery {
private final DispatchCargoSourceRepository repository;
/** 装配当前库存与本次拣货的真实来源入口。 */
public DispatchCargoSourceQuery(DispatchCargoSourceRepository repository) { this.repository = repository; }
/** 新增普通派送应收只绑定真实未约定来源;混合多票时保留派送归属。 */
public Set<Long> ordinaryOrderIds(DispatchOrder dispatch, Set<String> externalInboundCodes) {
return repository.sources(dispatch.getCustomerId(), dispatch.getId()).stream()
.filter(row -> !externalInboundCodes.contains(row.inboundCode()))
.map(row -> row.orderId()).filter(Objects::nonNull).collect(java.util.stream.Collectors.toSet());
}
/** 每条普通排车明细核平实际组成;箱级换标和退货保留其专用来源快照。 */
public List<FleetDispatchGateway.SourceCargo> sources(DispatchOrder dispatch, List<DispatchItem> items) {
if (items.isEmpty()) return List.of();
var rows = repository.sources(dispatch.getCustomerId(), dispatch.getId());
var result = new ArrayList<FleetDispatchGateway.SourceCargo>();
for (var item : items) {
String destination = FleetDispatchGateway.destinationKey(item.getDestinationType(),
item.getDestinationWarehouseCode(), item.getFbaWarehouseCode(), item.getDeliveryAddress(), item.getPostalCode());
// 实收重派按不可变实收记录选货,箱级换标按箱来源选货;都不是普通整托候选。
if (item.getReturnReceiptId() != null || Set.of("RELABEL", "RETURN_RELABEL").contains(Objects.toString(item.getSourceStage(), ""))) {
result.add(new FleetDispatchGateway.SourceCargo(item.getInboundCode(),
item.getWarehousePalletId() == null ? null : item.getWarehousePalletId().toString(),
item.getPalletNo(), Objects.requireNonNullElse(item.getPackageCount(), 0), destination));
continue;
}
var selected = rows.stream().filter(row -> Objects.equals(row.dispatchItemId(), item.getId())).toList();
if (selected.isEmpty() || item.getPackageCount() == null || selected.stream().mapToLong(row -> row.packages()).sum() != item.getPackageCount())
throw conflict("实际货物组成与排车箱数不一致,请核对收货、托盘及本次拣货来源");
for (var row : selected) {
if (row.inboundCode() == null || row.inboundCode().isBlank() || row.packages() <= 0
|| !Objects.equals(row.warehousePalletId(), item.getWarehousePalletId()))
throw conflict("货物缺少真实入仓来源或实物托盘,不能识别为普通货跳过外接核对");
result.add(new FleetDispatchGateway.SourceCargo(row.inboundCode(), row.warehousePalletId().toString(),
row.palletNo(), Math.toIntExact(row.packages()), destination));
}
}
return List.copyOf(result);
}
/** 来源不足直接阻断,不回退展示字段。 */
private static ResponseStatusException conflict(String reason) { return new ResponseStatusException(HttpStatus.CONFLICT, reason); }
}
@@ -13,6 +13,15 @@ import java.util.List;
@Mapper
public interface CargoDiscardInventoryMapper {
// 箱唛状态可能保留入库快照;废弃必须同时存在当前客户、同托盘的真实可用库存。
String AVAILABLE_STOCK = " AND EXISTS (SELECT 1 FROM ll_dropship_order_profile dp " +
"JOIN ll_inventory_position pos ON pos.id=dp.inventory_position_id AND pos.customer_id=dp.customer_id " +
"AND pos.pallet_id=dp.warehouse_pallet_id " +
"JOIN ll_inventory_balance bal ON bal.id=pos.balance_id AND bal.customer_id=pos.customer_id " +
"WHERE dp.customer_id=c.customer_id AND dp.order_id=c.order_id AND dp.id=c.profile_id " +
"AND dp.status='ACTIVE' AND pos.status='OCCUPIED' AND pos.available_packages>0 " +
"AND bal.available_packages>0) ";
@Select("SELECT p.* FROM ll_inventory_position p WHERE p.customer_id=#{customerId} AND p.id=#{positionId} FOR UPDATE")
com.baiyue.lastleg.inventory.entity.InventoryPosition lockGenericPosition(@Param("customerId") String customerId, @Param("positionId") Long positionId);
@@ -50,10 +59,12 @@ public interface CargoDiscardInventoryMapper {
int changeGenericCartonStatus(@Param("customerId") String customerId, @Param("cartonId") Long cartonId,
@Param("fromStatus") String fromStatus, @Param("toStatus") String toStatus);
@Select("SELECT c.* FROM ll_dropship_carton c WHERE c.customer_id=#{customerId} AND c.order_id=#{orderId} AND c.status='AVAILABLE' ORDER BY c.profile_id,c.id")
/** 仅列出仍有实际可用库存的箱子,排除已出库托盘的残留箱唛。 */
@Select("SELECT c.* FROM ll_dropship_carton c WHERE c.customer_id=#{customerId} AND c.order_id=#{orderId} AND c.status='AVAILABLE' " + AVAILABLE_STOCK + " ORDER BY c.profile_id,c.id")
List<DropshipCarton> availableOrderCartons(@Param("customerId") String customerId, @Param("orderId") Long orderId);
@Select("<script>SELECT c.* FROM ll_dropship_carton c WHERE c.customer_id=#{customerId} AND c.order_id=#{orderId} " +
/** 提交选箱时再次核对实际可用库存,避免旧页面绕过候选过滤。 */
@Select("<script>SELECT c.* FROM ll_dropship_carton c WHERE c.customer_id=#{customerId} AND c.order_id=#{orderId} " + AVAILABLE_STOCK +
"AND c.status='AVAILABLE' AND c.id IN <foreach collection='cartonIds' item='id' open='(' separator=',' close=')'>#{id}</foreach> " +
"AND EXISTS(SELECT 1 FROM ll_dropship_carton_sku cs WHERE cs.customer_id=c.customer_id AND cs.carton_id=c.id AND cs.sku_code=#{skuCode}) ORDER BY c.profile_id,c.id FOR UPDATE</script>")
List<DropshipCarton> lockSelected(@Param("customerId") String customerId, @Param("orderId") Long orderId,
@@ -72,6 +83,7 @@ public interface CargoDiscardInventoryMapper {
@Select("SELECT DISTINCT carton_mark FROM ll_inventory_ledger WHERE customer_id=#{customerId} AND position_id=#{positionId} " +
"AND business_type IN ('STOCK_IN','RETURN_STOCK_IN') AND carton_mark IS NOT NULL AND carton_mark<>'' ORDER BY carton_mark")
List<String> looseCartonMarks(@Param("customerId") String customerId, @Param("positionId") Long positionId);
/** 按当前可用仓位筛选入库单,不能只依赖箱唛的入库状态。 */
@Select("<script>SELECT o.id order_id,o.inbound_code,o.inbound_order_no,o.overseas_warehouse_id," +
"o.overseas_warehouse_name,o.created_at,p.id profile_id,s.id sku_inventory_id,s.sku_code,s.product_name," +
"COUNT(c.id) available_packages FROM ll_order o " +
@@ -80,33 +92,37 @@ public interface CargoDiscardInventoryMapper {
"JOIN ll_dropship_carton c ON c.customer_id=o.customer_id AND c.order_id=o.id " +
"AND c.profile_id=p.id AND c.status='AVAILABLE' " +
"JOIN ll_dropship_carton_sku cs ON cs.customer_id=c.customer_id AND cs.carton_id=c.id AND cs.sku_code=s.sku_code " +
"WHERE o.customer_id=#{customerId} AND o.order_type='ONE_CLICK_DROPSHIP' " +
"WHERE o.customer_id=#{customerId} AND o.order_type='ONE_CLICK_DROPSHIP' " + AVAILABLE_STOCK +
"<if test='inboundCode != null and inboundCode != \"\"'>AND o.inbound_code LIKE CONCAT('%',#{inboundCode},'%') </if>" +
"GROUP BY o.id,o.inbound_code,o.inbound_order_no,o.overseas_warehouse_id,o.overseas_warehouse_name,o.created_at," +
"p.id,s.id,s.sku_code,s.product_name HAVING COUNT(c.id)&gt;0 ORDER BY o.created_at DESC,o.id DESC,p.id,s.sku_code LIMIT 500</script>")
List<CargoDiscardCandidateRow> candidates(@Param("customerId") String customerId,
@Param("inboundCode") String inboundCode);
@Select("SELECT COUNT(*) FROM ll_dropship_carton c WHERE c.customer_id=#{customerId} AND c.order_id=#{orderId} " +
/** SKU 可选数量仅统计实际仍在可用仓位的箱子。 */
@Select("SELECT COUNT(*) FROM ll_dropship_carton c WHERE c.customer_id=#{customerId} AND c.order_id=#{orderId} " + AVAILABLE_STOCK +
"AND c.status='AVAILABLE' AND EXISTS (SELECT 1 FROM ll_dropship_carton_sku cs " +
"WHERE cs.customer_id=c.customer_id AND cs.carton_id=c.id AND cs.sku_code=#{skuCode})")
long countAvailable(@Param("customerId") String customerId, @Param("orderId") Long orderId,
@Param("skuCode") String skuCode);
@Select("SELECT COUNT(*) FROM ll_dropship_carton c WHERE c.customer_id=#{customerId} AND c.order_id=#{orderId} " +
/** 托盘 SKU 数量与候选及提交使用相同库存门禁。 */
@Select("SELECT COUNT(*) FROM ll_dropship_carton c WHERE c.customer_id=#{customerId} AND c.order_id=#{orderId} " + AVAILABLE_STOCK +
"AND c.profile_id=#{profileId} AND c.status='AVAILABLE' AND EXISTS (SELECT 1 FROM ll_dropship_carton_sku cs " +
"WHERE cs.customer_id=c.customer_id AND cs.carton_id=c.id AND cs.sku_code=#{skuCode})")
long countAvailableProfile(@Param("customerId") String customerId, @Param("orderId") Long orderId,
@Param("profileId") Long profileId, @Param("skuCode") String skuCode);
@Select("SELECT c.* FROM ll_dropship_carton c WHERE c.customer_id=#{customerId} AND c.order_id=#{orderId} " +
/** 全部废弃锁箱时排除已出库或无实际可用库存的托盘。 */
@Select("SELECT c.* FROM ll_dropship_carton c WHERE c.customer_id=#{customerId} AND c.order_id=#{orderId} " + AVAILABLE_STOCK +
"AND c.status='AVAILABLE' AND EXISTS (SELECT 1 FROM ll_dropship_carton_sku cs " +
"WHERE cs.customer_id=c.customer_id AND cs.carton_id=c.id AND cs.sku_code=#{skuCode}) " +
"ORDER BY c.profile_id,c.carton_sequence,c.id LIMIT #{limit} FOR UPDATE")
List<DropshipCarton> lockAvailable(@Param("customerId") String customerId, @Param("orderId") Long orderId,
@Param("skuCode") String skuCode, @Param("limit") int limit);
@Select("SELECT COUNT(*) FROM ll_dropship_carton WHERE customer_id=#{customerId} AND order_id=#{orderId} AND status='AVAILABLE'")
/** 入库单按实际可用托盘统计物理箱数,不重复累计混装 SKU。 */
@Select("SELECT COUNT(*) FROM ll_dropship_carton c WHERE c.customer_id=#{customerId} AND c.order_id=#{orderId} AND c.status='AVAILABLE' " + AVAILABLE_STOCK)
long countAvailableOrder(@Param("customerId") String customerId, @Param("orderId") Long orderId);
@Update("UPDATE ll_dropship_carton SET status=#{toStatus},version=version+1,updated_at=NOW(3) " +
@@ -13,7 +13,13 @@ public record DispatchResponse(Long id, String dispatchNo, String vehiclePlate,
int shippedPackageCount, int remainingPackageCount, String partialCompletionReason,
LocalDateTime createdAt,
List<Item> items, List<BolLine> bolLines, com.baiyue.lastleg.fleet.trip.FleetDispatchWorkflowQuery.Workflow workflow,
List<com.baiyue.lastleg.appointmentwork.AppointmentWorkDispatchPort.BatchReference> appointmentBatches) {
List<com.baiyue.lastleg.appointmentwork.AppointmentWorkDispatchPort.BatchReference> appointmentBatches,
Actions actions) {
public record Actions(boolean canEdit, String editBlockReason,
boolean canCancel, String cancelBlockReason,
boolean canRemovePallet, String removePalletBlockReason,
boolean canTransfer, String transferBlockReason,
String outboundNo, String outboundStatus) { }
public record Item(Long id, Long planPalletId, Long warehousePalletId, String palletNo,
String overseasWarehouseId, String overseasWarehouseName,
String orderNo, String orderRefs, String inboundCode, String destinationWarehouseCode,
@@ -37,6 +37,10 @@ import java.util.Objects;
@Component
@Transactional(propagation = Propagation.MANDATORY)
public class DispatchFleetAdapter implements FleetDispatchGateway {
@jakarta.annotation.Resource
private com.baiyue.lastleg.delivery.service.DeliveryExecutionGuard deliveryExecution;
@jakarta.annotation.Resource
private com.baiyue.lastleg.delivery.service.DispatchCargoSourceQuery cargoSources;
private final DispatchOrderMapper orders;
private final DispatchPdaTaskMapper tasks;
private final OutboundOrderMapper outbounds;
@@ -137,6 +141,7 @@ public class DispatchFleetAdapter implements FleetDispatchGateway {
else if (!picked.keySet().equals(selected.stream().map(com.baiyue.lastleg.dispatch.entity.DispatchItem::getId).collect(java.util.stream.Collectors.toSet()))) reason = "拣货记录包含不属于当前发运的货物";
else if (outbound == null || !"WAIT_OUTBOUND".equals(outbound.getStatus())) reason = "请先转派送并生成待出库单";
else if (cargo.stream().anyMatch(item -> item.warehousePalletId() == null || item.palletNo() == null || item.destinationKey() == null)) reason = "实物托盘或目的地未确认";
if (reason == null) reason = deliveryExecution.departureBlockReason(outbound, lock);
return new FleetExecutionModels.Manifest(dispatchNo, dispatch.getOverseasWarehouseId(), dispatch.getVehiclePlate(), dispatch.getDriverName(), dispatch.getDriverPhone(), cargo, reason);
}
@@ -151,12 +156,11 @@ public class DispatchFleetAdapter implements FleetDispatchGateway {
private static String destination(String type, String code, String fba, String address, String postalCode) {
return FleetDispatchGateway.destinationKey(type,code,fba,address,postalCode);
}
/** 用逐入仓组成核对外接承诺,不使用整托展示来源。 */
@Override public java.util.List<SourceCargo> sourceCargo(String dispatchNo) {
var dispatch=orders.findGlobalByDispatchNo(dispatchNo);
if(dispatch==null)throw new ResponseStatusException(HttpStatus.NOT_FOUND,"排车单不存在");
return items.findByDispatchOrder(dispatch.getCustomerId(),dispatch.getId()).stream().map(item->new SourceCargo(item.getInboundCode(),
item.getWarehousePalletId()==null?null:item.getWarehousePalletId().toString(),item.getPalletNo(),Objects.requireNonNullElse(item.getPackageCount(),0),
destination(item.getDestinationType(),item.getDestinationWarehouseCode(),item.getFbaWarehouseCode(),item.getDeliveryAddress(),item.getPostalCode()))).toList();
return cargoSources.sources(dispatch, items.findByDispatchOrder(dispatch.getCustomerId(),dispatch.getId()));
}
@Override
public FleetCompletionModels.DispatchCompletion completion(String dispatchNo, boolean lock) {
@@ -38,6 +38,7 @@ import java.util.stream.Collectors;
@Service
public class DispatchPdaWorkflowService {
@Resource private com.baiyue.lastleg.delivery.service.DeliveryExecutionGuard deliveryExecution;
@Resource private com.baiyue.lastleg.fleet.business.FleetPoolOrderBridge fleetPool;
@Resource private FleetDispatchBindingGuard fleetBindings;
@Resource private DispatchOrderMapper orders;
@@ -238,6 +239,7 @@ public class DispatchPdaWorkflowService {
if (outbound == null || !"WAIT_OUTBOUND".equals(outbound.getStatus())) {
throw conflict("Dispatch must be transferred to a pending outbound order before departure");
}
deliveryExecution.requireDeparture(outbound);
DispatchPdaTask task = tasks.lockByDispatch(customerId, order.getId());
if (task == null || !"COMPLETED".equals(task.getStatus())
|| !"READY_TO_DEPART".equals(order.getStatus())) {
@@ -0,0 +1,69 @@
package com.baiyue.lastleg.dispatch.repository;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baiyue.lastleg.dispatch.entity.*;
import com.baiyue.lastleg.dispatch.mapper.*;
import com.baiyue.lastleg.dispatch.model.DispatchPalletCandidate;
import com.baiyue.lastleg.dispatch.pda.entity.DispatchPdaTask;
import com.baiyue.lastleg.dispatch.pda.mapper.DispatchPdaTaskMapper;
import com.baiyue.lastleg.delivery.entity.OutboundOrder;
import com.baiyue.lastleg.delivery.mapper.OutboundOrderMapper;
import com.baiyue.lastleg.relabel.entity.RelabelApplication;
import com.baiyue.lastleg.relabel.mapper.RelabelApplicationMapper;
import java.util.List;
/** 排车变更的数据操作,事务及状态规则由排车服务维护。 */
public class DispatchMutationRepository {
private final DispatchOrderMapper orders;
private final DispatchItemMapper items;
private final DispatchPalletLockMapper locks;
private final OutboundOrderMapper outbounds;
private final DispatchCandidateMapper candidates;
private final RelabelApplicationMapper relabels;
private final DispatchPdaTaskMapper tasks;
/** 复用现有 Mapper 装配,供原排车服务分步收口变更路径。 */
public DispatchMutationRepository(DispatchOrderMapper orders, DispatchItemMapper items, DispatchPalletLockMapper locks,
OutboundOrderMapper outbounds, DispatchCandidateMapper candidates, RelabelApplicationMapper relabels, DispatchPdaTaskMapper tasks) {
this.orders=orders; this.items=items; this.locks=locks; this.outbounds=outbounds;
this.candidates=candidates; this.relabels=relabels; this.tasks=tasks;
}
/** 按业务号锁定唯一排车。 */
public DispatchOrder lockOrder(String dispatchNo) { return orders.lockGlobalByDispatchNo(dispatchNo); }
/** 保存排车的业务变更。 */
public void saveOrder(DispatchOrder order) { orders.updateById(order); }
/** 创建已校验客户和完整货量的新排车。 */
public void createOrder(DispatchOrder order) { orders.insert(order); }
/** 读取当前客户排车的原始明细。 */
public List<DispatchItem> items(DispatchOrder order) { return items.findByDispatchOrder(order.getCustomerId(), order.getId()); }
/** 锁定本排车的指定托盘明细。 */
public DispatchItem lockItem(DispatchOrder order, Long itemId) { return items.lockById(order.getCustomerId(), order.getId(), itemId); }
/** 保存新换标排车明细。 */
public void createItem(DispatchItem item) { items.insert(item); }
/** 保存退货换标来源快照。 */
public void saveItem(DispatchItem item) { items.updateById(item); }
/** 删除尚未执行的排车托盘,历史单据由取消命令保留。 */
public void removeItem(Long itemId) { items.deleteById(itemId); }
/** 读取该排车自己的活动占用,不触碰后续新排车。 */
public List<DispatchPalletLock> activeLocks(DispatchOrder order) {
return locks.selectList(new LambdaQueryWrapper<DispatchPalletLock>()
.eq(DispatchPalletLock::getCustomerId, order.getCustomerId()).eq(DispatchPalletLock::getDispatchOrderId, order.getId())
.eq(DispatchPalletLock::getStatus, "ACTIVE"));
}
/** 保存单条原排车占用的释放事实。 */
public void saveLock(DispatchPalletLock lock) { locks.updateById(lock); }
/** 读取或锁定历史关联出库;业务层区分活动与取消。 */
public OutboundOrder outbound(DispatchOrder order, boolean lock) {
return lock ? outbounds.lockByDispatch(order.getCustomerId(), order.getId()) : outbounds.findByDispatch(order.getCustomerId(), order.getId());
}
/** 锁定换标申请,串行完整候选检查与整单状态推进。 */
public RelabelApplication lockRelabel(String customer, String number) { return relabels.lockNo(customer, number); }
/** 读取锁定申请下的全部普通换标候选。 */
public List<DispatchPalletCandidate> relabelCandidates(String customer, String number) { return candidates.relabelAvailable(customer, null, number); }
/** 整单申请只允许一次转为有效排车。 */
public int markRelabel(String customer, String number) { return candidates.markRelabelPreDispatched(customer, number); }
/** 取消未执行排车后恢复原申请候选。 */
public void restoreRelabel(String customer, String number) { candidates.restoreRelabelPreDispatch(customer, number); }
/** 创建与新排车同事务的 PDA 任务。 */
public void createTask(DispatchPdaTask task) { tasks.insert(task); }
}
@@ -601,9 +601,10 @@ public class DispatchService {
return response(responseOrder, assignedItems);
}
/** 在申请锁内校验全托并原子建立整单排车。 */
@Transactional
private DispatchResponse preArrangeRelabel(String customerId, String operatorId, DispatchRequests.PreArrange request) {
RelabelApplication application = relabelApplications.lockNo(customerId, request.relabelNo());
RelabelApplication application = mutations().lockRelabel(customerId, request.relabelNo());
if (application == null || !RelabelApplicationService.READY_FOR_OUTBOUND.equals(application.getStatus())) {
throw new ResponseStatusException(HttpStatus.CONFLICT, "换标尚未完成或客户尚未确认,无法进行排车");
}
@@ -613,12 +614,15 @@ public class DispatchService {
relabelBindingGuard.requireBound(application);
relabelMeasurements.requireMeasured(application);
List<DispatchPalletCandidate> selected = java.util.stream.Stream.concat(
measuredRelabelCandidates(candidates.relabelAvailable(customerId, null, request.relabelNo())).stream(),
measuredRelabelCandidates(mutations().relabelCandidates(customerId, request.relabelNo())).stream(),
returnRelabelStock.candidates(customerId, null, request.relabelNo()).stream()).toList();
Set<Long> requested = request.planPalletIds().stream().map(Long::valueOf).collect(Collectors.toSet());
if (selected.stream().anyMatch(DispatchPalletCandidate::returnCartonSource)
&& !requested.equals(selected.stream().map(DispatchPalletCandidate::warehousePalletId).collect(Collectors.toSet())))
throw new ResponseStatusException(HttpStatus.CONFLICT, "退货换标排车须选择本申请全部已绑定托盘");
// 整单履约状态只能由完整托盘集合推进,普通换标与退货换标遵守同一门禁。
Set<Long> requested = new HashSet<>(request.planPalletIds());
if (requested.contains(null) || requested.size() != request.planPalletIds().size())
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "换标托盘不得为空或重复选择");
if (selected.isEmpty() || !requested.equals(selected.stream()
.map(DispatchPalletCandidate::warehousePalletId).collect(Collectors.toSet())))
throw new ResponseStatusException(HttpStatus.CONFLICT, "换标排车须一次选择本申请全部已绑定托盘,请刷新候选后完整选择");
selected = selected.stream().filter(c -> requested.contains(c.warehousePalletId())).toList();
if (selected.isEmpty() || selected.stream().map(DispatchPalletCandidate::warehousePalletId).distinct().count() != requested.size()) throw new ResponseStatusException(HttpStatus.CONFLICT, "换标订单托盘候选已变化,请刷新后重试");
String warehouse = selected.get(0).overseasWarehouseId();
@@ -626,17 +630,17 @@ public class DispatchService {
LocalDateTime now = LocalDateTime.now();
DispatchOrder dispatch = new DispatchOrder(); dispatch.setId(IdWorker.getId()); dispatch.setCustomerId(customerId);
dispatch.setDispatchNo(orderNoGenerator.next("DISPATCH", warehouse, dispatch.getId())); dispatch.setOverseasWarehouseId(warehouse); dispatch.setOverseasWarehouseName(selected.get(0).overseasWarehouseName());
dispatch.setRoute(candidateRoute(selected)); dispatch.setFbaAppointmentNo(trimToNull(request.fbaAppointmentNo())); dispatch.setFbaAppointmentStatus(defaultStatus(request.fbaAppointmentStatus())); dispatch.setVehicleAppointmentStatus(defaultStatus(request.vehicleAppointmentStatus())); dispatch.setStatus(PENDING_DEPARTURE); dispatch.setItemCount(selected.size()); dispatch.setPalletCount(selected.size()); dispatch.setPackageCount(selected.stream().map(DispatchPalletCandidate::packageCount).filter(Objects::nonNull).mapToInt(Integer::intValue).sum()); dispatch.setPickedPackageCount(0); dispatch.setShippedPackageCount(0); dispatch.setCreatedBy(trimToNull(operatorId)); dispatch.setCreatedAt(now); dispatch.setUpdatedAt(now); orders.insert(dispatch);
for (DispatchPalletCandidate candidate : selected) { DispatchItem item = new DispatchItem(); item.setId(IdWorker.getId()); item.setDispatchOrderId(dispatch.getId()); item.setCustomerId(customerId); item.setWarehousePalletId(candidate.warehousePalletId()); item.setOrderId(candidate.orderId()); item.setOverseasWarehouseId(candidate.overseasWarehouseId()); item.setOverseasWarehouseName(candidate.overseasWarehouseName()); item.setOrderNo(candidate.orderNo()); item.setOrderRefs(candidate.orderRefs()); item.setInboundCode(candidate.inboundCode()); item.setPalletNo(candidate.palletNo()); item.setDestinationWarehouseCode(candidate.destinationWarehouseCode()); item.setDestinationWarehouseName(candidate.destinationWarehouseName()); item.setFbaWarehouseCode(candidate.fbaWarehouseCode()); item.setPackageCount(Objects.requireNonNullElse(candidate.packageCount(), 0)); item.setGrossWeightKg(candidate.grossWeightKg()); item.setVolumeWeightKg(Objects.requireNonNullElse(candidate.volumeWeightKg(), BigDecimal.ZERO)); item.setVolumeCbm(Objects.requireNonNullElse(candidate.volumeCbm(), BigDecimal.ZERO)); item.setSourceStage("RELABEL"); item.setCartonMarks(candidate.relabelCartonMarks()); item.setSourceBusinessType("RELABEL_ORDER"); item.setSourceBusinessNo(request.relabelNo()); item.setCreatedAt(now); items.insert(item); }
DispatchPdaTask task = new DispatchPdaTask(); task.setId(IdWorker.getId()); task.setCustomerId(customerId); task.setDispatchOrderId(dispatch.getId()); task.setTaskNo("DPDA" + task.getId()); task.setStatus("PDA_PENDING"); task.setWarehouseId(warehouse); task.setPushedAt(now); task.setCreatedAt(now); task.setUpdatedAt(now); pdaTasks.insert(task);
for (DispatchItem item : items.findByDispatchOrder(customerId, dispatch.getId())) {
dispatch.setRoute(candidateRoute(selected)); dispatch.setFbaAppointmentNo(trimToNull(request.fbaAppointmentNo())); dispatch.setFbaAppointmentStatus(defaultStatus(request.fbaAppointmentStatus())); dispatch.setVehicleAppointmentStatus(defaultStatus(request.vehicleAppointmentStatus())); dispatch.setStatus(PENDING_DEPARTURE); dispatch.setItemCount(selected.size()); dispatch.setPalletCount(selected.size()); dispatch.setPackageCount(selected.stream().map(DispatchPalletCandidate::packageCount).filter(Objects::nonNull).mapToInt(Integer::intValue).sum()); dispatch.setPickedPackageCount(0); dispatch.setShippedPackageCount(0); dispatch.setCreatedBy(trimToNull(operatorId)); dispatch.setCreatedAt(now); dispatch.setUpdatedAt(now); mutations().createOrder(dispatch);
for (DispatchPalletCandidate candidate : selected) { DispatchItem item = new DispatchItem(); item.setId(IdWorker.getId()); item.setDispatchOrderId(dispatch.getId()); item.setCustomerId(customerId); item.setWarehousePalletId(candidate.warehousePalletId()); item.setOrderId(candidate.orderId()); item.setOverseasWarehouseId(candidate.overseasWarehouseId()); item.setOverseasWarehouseName(candidate.overseasWarehouseName()); item.setOrderNo(candidate.orderNo()); item.setOrderRefs(candidate.orderRefs()); item.setInboundCode(candidate.inboundCode()); item.setPalletNo(candidate.palletNo()); item.setDestinationWarehouseCode(candidate.destinationWarehouseCode()); item.setDestinationWarehouseName(candidate.destinationWarehouseName()); item.setFbaWarehouseCode(candidate.fbaWarehouseCode()); item.setPackageCount(Objects.requireNonNullElse(candidate.packageCount(), 0)); item.setGrossWeightKg(candidate.grossWeightKg()); item.setVolumeWeightKg(Objects.requireNonNullElse(candidate.volumeWeightKg(), BigDecimal.ZERO)); item.setVolumeCbm(Objects.requireNonNullElse(candidate.volumeCbm(), BigDecimal.ZERO)); item.setSourceStage("RELABEL"); item.setCartonMarks(candidate.relabelCartonMarks()); item.setSourceBusinessType("RELABEL_ORDER"); item.setSourceBusinessNo(request.relabelNo()); item.setCreatedAt(now); mutations().createItem(item); }
DispatchPdaTask task = new DispatchPdaTask(); task.setId(IdWorker.getId()); task.setCustomerId(customerId); task.setDispatchOrderId(dispatch.getId()); task.setTaskNo("DPDA" + task.getId()); task.setStatus("PDA_PENDING"); task.setWarehouseId(warehouse); task.setPushedAt(now); task.setCreatedAt(now); task.setUpdatedAt(now); mutations().createTask(task);
for (DispatchItem item : mutations().items(dispatch)) {
if (selected.stream().anyMatch(c -> c.returnCartonSource() && Objects.equals(c.warehousePalletId(), item.getWarehousePalletId())
&& Objects.equals(c.relabelCartonMarks(), item.getCartonMarks()))) {
item.setSourceStage("RETURN_RELABEL"); items.updateById(item); returnRelabelStock.reserve(item);
item.setSourceStage("RETURN_RELABEL"); mutations().saveItem(item); returnRelabelStock.reserve(item);
}
}
if (candidates.markRelabelPreDispatched(customerId, request.relabelNo()) != 1) throw new ResponseStatusException(HttpStatus.CONFLICT, "换标订单已被其他排车占用");
return response(dispatch, items.findByDispatchOrder(customerId, dispatch.getId()));
if (mutations().markRelabel(customerId, request.relabelNo()) != 1) throw new ResponseStatusException(HttpStatus.CONFLICT, "换标订单已被其他排车占用");
return response(dispatch, mutations().items(dispatch));
}
private void assertNoDetainedOrders(String customerId, List<DispatchPalletCandidate> selected) {
@@ -704,17 +708,14 @@ public class DispatchService {
bolLines.findByDispatchOrder(dispatch.getCustomerId(), dispatch.getId()));
}
/** 仅编辑未执行且无有效出库或车次占用的排车。 */
@Transactional
public DispatchResponse update(String dispatchNo, DispatchRequests.Update request) {
DispatchOrder dispatch = orders.lockGlobalByDispatchNo(trimToNull(dispatchNo));
DispatchOrder dispatch = mutations().lockOrder(trimToNull(dispatchNo));
if (dispatch == null) throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Dispatch order not found");
fleetBindings.requireUnbound(dispatch.getId());
if (!PENDING_DEPARTURE.equals(dispatch.getStatus())) {
throw new ResponseStatusException(HttpStatus.CONFLICT,
"Only a pending dispatch can be edited");
}
requirePalletEditingAllowed(dispatch);
String customerId = dispatch.getCustomerId();
List<DispatchItem> assignedItems = items.findByDispatchOrder(customerId, dispatch.getId());
List<DispatchItem> assignedItems = mutations().items(dispatch);
dispatch.setVehiclePlate(trimToNull(request.vehiclePlate()));
dispatch.setDriverName(trimToNull(request.driverName()));
dispatch.setDriverPhone(trimToNull(request.driverPhone()));
@@ -726,62 +727,65 @@ public class DispatchService {
dispatch.setVehicleAppointmentStatus(defaultStatus(request.vehicleAppointmentStatus()));
dispatch.setEstimatedArrivalAt(request.estimatedArrivalAt());
dispatch.setUpdatedAt(LocalDateTime.now());
orders.updateById(dispatch);
mutations().saveOrder(dispatch);
return response(dispatch, assignedItems);
}
/** 移除普通来源托盘,整单换标只允许整单取消重排。 */
@Transactional
public DispatchResponse removeItem(String dispatchNo, Long itemId) {
DispatchOrder dispatch = orders.lockGlobalByDispatchNo(trimToNull(dispatchNo));
DispatchOrder dispatch = mutations().lockOrder(trimToNull(dispatchNo));
if (dispatch == null) throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Dispatch order not found");
requirePalletEditingAllowed(dispatch);
DispatchItem item = items.lockById(dispatch.getCustomerId(), dispatch.getId(), itemId);
DispatchItem item = mutations().lockItem(dispatch, itemId);
if (item == null) throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Dispatch pallet not found");
if ("RETURN_RELABEL".equals(item.getSourceStage()))
throw new ResponseStatusException(HttpStatus.CONFLICT, "退货换标按整单排车,请取消整张排车单后重新排车");
if ("RELABEL_ORDER".equals(item.getSourceBusinessType()))
throw new ResponseStatusException(HttpStatus.CONFLICT, "换标按整单排车,请取消整张排车单后重新排车");
List<DispatchPalletLock> activeLocks = locks.selectList(new LambdaQueryWrapper<DispatchPalletLock>()
.eq(DispatchPalletLock::getCustomerId, dispatch.getCustomerId())
.eq(DispatchPalletLock::getDispatchOrderId, dispatch.getId())
.eq(DispatchPalletLock::getStatus, "ACTIVE"));
List<DispatchPalletLock> activeLocks = mutations().activeLocks(dispatch);
List<DispatchPalletLock> matchingLocks = activeLocks.stream().filter(lock -> Objects.equals(lock.getPlanPalletId(), item.getPlanPalletId()) && Objects.equals(lock.getReturnReceiptId(), item.getReturnReceiptId())
|| (item.getWarehousePalletId() != null && Objects.equals(lock.getWarehousePalletId(), item.getWarehousePalletId())))
.toList();
return removeLockedItem(dispatch, item, matchingLocks);
}
/** 同事务释放当前托盘锁、预约分配并重算剩余排车货量。 */
private DispatchResponse removeLockedItem(DispatchOrder dispatch, DispatchItem item,
List<DispatchPalletLock> matchingLocks) {
LocalDateTime now = LocalDateTime.now();
matchingLocks.forEach(lock -> releaseLock(lock, now));
items.deleteById(item.getId());
mutations().removeItem(item.getId());
appointmentAllocations.removeItem(dispatch.getId(),item.getId());
List<DispatchItem> remaining = items.findByDispatchOrder(dispatch.getCustomerId(), dispatch.getId());
List<DispatchItem> remaining = mutations().items(dispatch);
dispatch.setItemCount(remaining.size());
dispatch.setPalletCount(remaining.size());
dispatch.setPackageCount(remaining.stream().map(DispatchItem::getPackageCount).filter(Objects::nonNull)
.mapToInt(Integer::intValue).sum());
dispatch.setRoute(itemRoute(remaining));
dispatch.setUpdatedAt(now);
orders.updateById(dispatch);
mutations().saveOrder(dispatch);
return response(dispatch, remaining);
}
/** 取消历史不占活动出库,已拣货或车次绑定仍禁止修改。 */
private void requirePalletEditingAllowed(DispatchOrder dispatch) {
fleetBindings.requireUnbound(dispatch.getId());
if (!PENDING_DEPARTURE.equals(dispatch.getStatus())
|| Objects.requireNonNullElse(dispatch.getPickedPackageCount(), 0) > 0) {
|| Objects.requireNonNullElse(dispatch.getPickedPackageCount(), 0) > 0
|| Objects.requireNonNullElse(dispatch.getShippedPackageCount(), 0) > 0) {
throw new ResponseStatusException(HttpStatus.CONFLICT, "Only an unpicked pending dispatch can edit pallets");
}
if (outboundOrders.findByDispatch(dispatch.getCustomerId(), dispatch.getId()) != null) {
var outbound = mutations().outbound(dispatch, false);
if (outbound != null && !CANCELLED.equals(outbound.getStatus())) {
throw new ResponseStatusException(HttpStatus.CONFLICT, "已生成出库单,不能再修改排车托盘");
}
}
/** 只取消原未执行排车,取消出库历史保留且重复取消无副作用。 */
@Transactional
public void cancel(String dispatchNo) {
DispatchOrder dispatch = orders.lockGlobalByDispatchNo(trimToNull(dispatchNo));
DispatchOrder dispatch = mutations().lockOrder(trimToNull(dispatchNo));
if (dispatch == null) throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Dispatch order not found");
fleetBindings.requireUnbound(dispatch.getId());
if (CANCELLED.equals(dispatch.getStatus())) return;
@@ -789,32 +793,32 @@ public class DispatchService {
throw new ResponseStatusException(HttpStatus.CONFLICT, "Only a pending dispatch can be cancelled");
}
String customerId = dispatch.getCustomerId();
if (Objects.requireNonNullElse(dispatch.getPickedPackageCount(), 0) > 0)
if (Objects.requireNonNullElse(dispatch.getPickedPackageCount(), 0) > 0
|| Objects.requireNonNullElse(dispatch.getShippedPackageCount(), 0) > 0)
throw new ResponseStatusException(HttpStatus.CONFLICT, "排车已开始拣货,不能取消并释放已出库箱");
if (outboundOrders.findByDispatch(customerId, dispatch.getId()) != null)
var outbound = mutations().outbound(dispatch, true);
if (outbound != null && !CANCELLED.equals(outbound.getStatus()))
throw new ResponseStatusException(HttpStatus.CONFLICT, "已生成出库单,请先取消对应出库单再取消排车");
LocalDateTime now = LocalDateTime.now();
dispatch.setStatus(CANCELLED);
workChanges.requirementsChanged();
dispatch.setUpdatedAt(now);
orders.updateById(dispatch);
for (DispatchItem item : items.findByDispatchOrder(customerId, dispatch.getId())) {
mutations().saveOrder(dispatch);
for (DispatchItem item : mutations().items(dispatch)) {
// 预约分配的有效占用按排车 CANCELLED 状态排除,原分配保留作历史证据。
if ("RETURN_RELABEL".equals(item.getSourceStage())) returnRelabelStock.release(item);
}
items.findByDispatchOrder(customerId, dispatch.getId()).stream()
mutations().items(dispatch).stream()
.filter(item -> "RELABEL_ORDER".equals(item.getSourceBusinessType()))
.map(DispatchItem::getSourceBusinessNo).filter(StringUtils::hasText).distinct()
.forEach(relabelNo -> candidates.restoreRelabelPreDispatch(customerId, relabelNo));
List<DispatchPalletLock> activeLocks = locks.selectList(new LambdaQueryWrapper<DispatchPalletLock>()
.eq(DispatchPalletLock::getCustomerId, customerId)
.eq(DispatchPalletLock::getDispatchOrderId, dispatch.getId())
.eq(DispatchPalletLock::getStatus, "ACTIVE"));
.forEach(relabelNo -> mutations().restoreRelabel(customerId, relabelNo));
List<DispatchPalletLock> activeLocks = mutations().activeLocks(dispatch);
for (DispatchPalletLock lock : activeLocks) {
lock.setStatus("RELEASED");
lock.setActiveKey(null);
lock.setReleasedAt(now);
lock.setUpdatedAt(now);
locks.updateById(lock);
mutations().saveLock(lock);
}
}
@@ -861,7 +865,36 @@ public class DispatchService {
item.getDestinationWarehouseCode(), item.getDestinationWarehouseName(), item.getFbaWarehouseCode(),
Objects.requireNonNullElse(item.getPackageCount(), 0), item.getGrossWeightKg(),
item.getVolumeWeightKg(), item.getVolumeCbm(), item.getSourceStage())).toList(),
bolLineList == null ? List.of() : bolLineList, workflow,appointmentBatches);
bolLineList == null ? List.of() : bolLineList, workflow,appointmentBatches, actions(order, itemList, workflow));
}
/** 返回与写命令相同的操作资格;权限仍由入口独立校验。 */
private DispatchResponse.Actions actions(DispatchOrder order, List<DispatchItem> itemList,
com.baiyue.lastleg.fleet.trip.FleetDispatchWorkflowQuery.Workflow workflow) {
if (workflow == null) workflow = fleetWorkflow.read(List.of(order.getDispatchNo())).get(order.getDispatchNo());
var outbound = mutations().outbound(order, false);
boolean activeOutbound = outbound != null && !CANCELLED.equals(outbound.getStatus());
String pendingReason = !PENDING_DEPARTURE.equals(order.getStatus()) ? "仅未执行的待发车排车可操作"
: Objects.requireNonNullElse(order.getPickedPackageCount(), 0) > 0
|| Objects.requireNonNullElse(order.getShippedPackageCount(), 0) > 0 ? "排车已开始拣货或发车" : null;
String mutationReason = workflow != null ? "请先在车次 " + workflow.tripNo() + " 中解除关联" : pendingReason;
String editReason = mutationReason != null ? mutationReason : activeOutbound ? "请先取消对应出库单" : null;
String removeReason = editReason != null ? editReason : itemList.stream()
.anyMatch(item -> "RELABEL_ORDER".equals(item.getSourceBusinessType())) ? "换标按整单排车,请取消整张排车后重排" : null;
String transferReason = outbound != null ? (CANCELLED.equals(outbound.getStatus())
? "原出库已取消,请取消原排车后通过新预排重新安排" : "已转派送,请查看原出库及派送单")
: !Set.of("PENDING_DEPARTURE", "PICKING", "READY_TO_DEPART").contains(Objects.toString(order.getStatus(), ""))
? "当前状态不能转派送" : itemList.isEmpty() || itemList.stream().anyMatch(item -> item.getWarehousePalletId() == null)
? "请先完成收货并核对实物托盘" : null;
return new DispatchResponse.Actions(editReason == null, editReason, editReason == null, editReason,
removeReason == null, removeReason, transferReason == null, transferReason,
outbound == null ? null : outbound.getOutboundNo(), outbound == null ? null : outbound.getStatus());
}
/** 为本次变更路径集中组装数据访问,保持既有 Mapper 注入与事务连接。 */
private com.baiyue.lastleg.dispatch.repository.DispatchMutationRepository mutations() {
return new com.baiyue.lastleg.dispatch.repository.DispatchMutationRepository(
orders, items, locks, outboundOrders, candidates, relabelApplications, pdaTasks);
}
private static String candidateRoute(List<DispatchPalletCandidate> selected) {
@@ -26,16 +26,16 @@ public final class ExceptionOrderRequests {
@Min(1) Integer packageCount,
@Size(max = 128) String skuCode) {}
public record MarkReturn(@NotNull @Min(1) Long inboundOrderId,
public record MarkReturn(@NotNull @Min(1) Long lastMileOrderId,
@NotBlank @Size(max = 1000) String reason,
@NotBlank @Size(max = 32) String returnDirection,
@Size(max = 128) String returnLogisticsNo,
@NotNull @Size(min = 1, max = 200) List<@NotNull ReturnItem> items,
@NotNull @Size(min = 1, max = 200) List<@NotNull @jakarta.validation.Valid ReturnItem> items,
@NotBlank @Size(max = 255) String idempotencyKey) {}
public record ReturnItem(@NotNull @Min(1) Long outboundPalletItemId,
@NotNull @Min(1) Long inventoryPositionId,
@Min(1) long packageCount) {}
@Min(1) Long inventoryPositionId,
@Min(1) long packageCount, @Min(1) Long returnCartonId) {}
public record StartProcessing(@Min(0) long expectedVersion,
@NotBlank @Size(max = 255) String idempotencyKey,
@@ -45,15 +45,16 @@ public final class ExceptionOrderRequests {
@NotBlank @Size(max = 255) String idempotencyKey,
@NotBlank @Size(max = 2000) String handlingResult,
@NotNull @Size(min = 1, max = 20) List<@NotNull @Min(1) Long> evidenceFileIds,
@Size(max = 200) List<@NotNull ReturnReceipt> receipts) {}
@Size(max = 200) List<@NotNull @jakarta.validation.Valid ReturnReceipt> receipts) {}
public record ReturnReceipt(@NotNull @Min(1) Long outboundPalletItemId,
@NotNull @Min(1) Long inventoryPositionId,
@Min(1) Long inventoryPositionId,
@Min(1) long receivedPackages,
@NotNull @Min(1) Long targetPalletId,
@NotBlank @Size(max = 128) String targetPalletNo,
@NotNull @Min(1) Long targetLocationId,
@NotBlank @Size(max = 128) String targetLocationCode) {}
@NotBlank @Size(max = 128) String targetLocationCode,
@Min(1) Long returnCartonId) {}
public record Cancel(@Min(0) long expectedVersion,
@NotBlank @Size(max = 255) String idempotencyKey,
@@ -26,7 +26,8 @@ public final class ExceptionOrderResponses {
List<ReturnSource> returnSources) {}
public record ReturnSource(Long outboundPalletItemId, Long inventoryPositionId,
String palletNo, String skuCode, long maxReturnPackages) {}
String palletNo, String skuCode, long maxReturnPackages,
Long returnCartonId, String cartonMark) {}
public record Detail(Row order, List<ReturnSource> returnSources, List<ReturnReceipt> returnReceipts) {}
public record ReturnReceipt(String receiptNo, long receivedPackages, LocalDateTime receivedAt,
List<Redispatch> redispatches) {}
@@ -15,6 +15,8 @@ public class ExceptionReturnItem {
private Long exceptionOrderId;
private Long outboundPalletItemId;
private Long inventoryPositionId;
private Long returnCartonId;
private String returnCartonMark;
private String palletNo;
private String skuCode;
private Long maxReturnPackages;
@@ -0,0 +1,35 @@
package com.baiyue.lastleg.exceptionorder.entity;
import com.baomidou.mybatisplus.annotation.*;
import lombok.Data;
import java.time.LocalDateTime;
/** 原箱回仓的不可变逐SKU凭据,箱数仅计一次。 */
@Data
@TableName("ll_return_carton_stock_receipt")
public class ReturnCartonStockReceipt {
@TableId(type=IdType.INPUT) private Long id;
private String customerId;
private String receiptNo;
private Long exceptionOrderId;
private Long exceptionReturnItemId;
private Long deliveryOrderId;
private Long sourcePickId;
private String sourceOutboundNo;
private Long sourceDispatchItemId;
private Long sourceOutboundPalletItemId;
private Long returnCartonId;
private String cartonMark;
private String overseasWarehouseId;
private Long targetPalletId;
private String targetPalletNo;
private Long targetLocationId;
private String targetLocationCode;
private String skuCode;
private String productNameZh;
private Long quantity;
private Long packages;
private String evidenceFileIdsJson;
private String receivedBy;
private LocalDateTime receivedAt;
}
@@ -10,6 +10,13 @@ import java.util.List;
@Mapper
public interface ExceptionReturnItemMapper extends BaseMapper<ExceptionReturnItem> {
/** 原箱回仓按本次派送和原出库明细分别累计,不能借用其他轮次。 */
@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.return_carton_id=#{cartonId} AND i.inventory_position_id IS NULL")
long receivedByCartonSource(@Param("customerId") String customerId, @Param("deliveryOrderId") Long deliveryOrderId,
@Param("itemId") Long itemId, @Param("cartonId") Long cartonId);
@Select("SELECT * FROM ll_exception_return_item WHERE customer_id=#{customerId} AND exception_order_id=#{exceptionOrderId} ORDER BY id")
List<ExceptionReturnItem> findByException(@Param("customerId") String customerId,
@Param("exceptionOrderId") Long exceptionOrderId);
@@ -0,0 +1,34 @@
package com.baiyue.lastleg.exceptionorder.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baiyue.lastleg.exceptionorder.entity.ReturnCartonStockReceipt;
import org.apache.ibatis.annotations.*;
import java.util.List;
/** 原箱的出库归属证明和回仓凭据。 */
@Mapper
public interface ReturnCartonStockReceiptMapper extends BaseMapper<ReturnCartonStockReceipt> {
record Shipment(Long pickId, Long dispatchItemId, String dispatchNo,Long productQuantity) { }
/** 原箱必须真实出库且逐箱归属于当前异常、派送、客户及原尾程订单。 */
@Select("SELECT p.id pick_id,di.id dispatch_item_id,d.dispatch_no,p.product_quantity FROM ll_exception_order e " +
"JOIN ll_exception_return_item frozen ON frozen.exception_order_id=e.id AND frozen.customer_id=e.customer_id " +
"AND frozen.outbound_pallet_item_id=#{itemId} AND frozen.return_carton_id=#{cartonId} AND frozen.inventory_position_id IS NULL AND frozen.approved_return_packages=1 AND frozen.received_packages=0 " +
"JOIN ll_delivery_order delivery ON delivery.id=e.delivery_order_id AND delivery.customer_id=e.customer_id " +
"JOIN ll_outbound_order o ON o.id=delivery.outbound_order_id AND o.customer_id=e.customer_id AND o.outbound_no=e.outbound_no AND o.status='OUTBOUNDED' " +
"JOIN ll_outbound_pallet_item oi ON oi.outbound_order_id=o.id AND oi.customer_id=e.customer_id AND oi.id=#{itemId} " +
"JOIN ll_dispatch_order d ON d.id=o.dispatch_order_id AND d.customer_id=e.customer_id " +
"JOIN ll_dispatch_item di ON di.dispatch_order_id=d.id AND di.customer_id=e.customer_id AND di.warehouse_pallet_id=oi.warehouse_pallet_id AND di.source_stage='RETURN_RELABEL' " +
"AND di.inbound_code=oi.inbound_code AND COALESCE(di.destination_warehouse_code,'')=COALESCE(oi.destination_warehouse_code,'') " +
"JOIN ll_dispatch_pda_task t ON t.dispatch_order_id=d.id AND t.customer_id=e.customer_id " +
"JOIN ll_dispatch_pda_pick p ON p.task_id=t.id AND p.customer_id=e.customer_id AND p.dispatch_item_id=di.id AND p.return_carton_id=#{cartonId} AND p.inventory_position_id IS NULL AND p.packages=1 " +
"JOIN ll_dispatch_pda_pick_order a ON a.pick_id=p.id AND a.customer_id=e.customer_id AND a.order_id=e.last_mile_order_id AND a.packages=1 " +
"WHERE e.id=#{exceptionId} AND e.customer_id=#{customerId} AND e.status='PROCESSING' AND e.return_direction='OVERSEAS_WAREHOUSE'")
Shipment shipment(@Param("customerId") String customerId,@Param("exceptionId") Long exceptionId,
@Param("itemId") Long itemId,@Param("cartonId") Long cartonId);
/** 原出库每个SKU只允许恢复一次,独立于可变箱状态。 */
@Select("SELECT * FROM ll_return_carton_stock_receipt WHERE customer_id=#{customerId} AND source_pick_id=#{pickId}")
List<ReturnCartonStockReceipt> byPick(@Param("customerId") String customerId,@Param("pickId") Long pickId);
/** 异常详情按真实回仓凭据展示,重复请求不生成新记录。 */
@Select("SELECT * FROM ll_return_carton_stock_receipt WHERE customer_id=#{customerId} AND exception_order_id=#{exceptionId} ORDER BY received_at,id")
List<ReturnCartonStockReceipt> byException(@Param("customerId") String customerId,@Param("exceptionId") Long exceptionId);
}
@@ -0,0 +1,51 @@
package com.baiyue.lastleg.exceptionorder.repository;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
import com.baiyue.lastleg.exceptionorder.mapper.ReturnCartonStockReceiptMapper;
import com.baiyue.lastleg.exceptionorder.entity.ReturnCartonStockReceipt;
import com.baiyue.lastleg.returnreceipt.entity.*;
import com.baiyue.lastleg.returnreceipt.mapper.*;
import org.springframework.stereotype.Repository;
import java.time.LocalDateTime;
import java.util.List;
/** 原箱回仓只写原生退货账本,不构造普通库存仓位。 */
@Repository
public class ReturnCartonReturnRepository {
private final ReturnCartonStockReceiptMapper receipts;
private final ReturnInventoryCartonMapper cartons;
private final ReturnInventoryCartonItemMapper items;
private final ReturnInventoryMovementMapper movements;
private final ReturnInventoryBalanceMapper balances;
/** 装配同库原生库存及不可变出库证据。 */
public ReturnCartonReturnRepository(ReturnCartonStockReceiptMapper receipts,ReturnInventoryCartonMapper cartons,
ReturnInventoryCartonItemMapper items,ReturnInventoryMovementMapper movements,ReturnInventoryBalanceMapper balances) {
this.receipts=receipts;this.cartons=cartons;this.items=items;this.movements=movements;this.balances=balances;
}
/** 读取与原箱对应的唯一实际出库。 */
public ReturnCartonStockReceiptMapper.Shipment shipment(String customer,Long exceptionId,Long itemId,Long cartonId) { return receipts.shipment(customer,exceptionId,itemId,cartonId); }
/** 在同客户内锁定真实原箱。 */
public ReturnInventoryCarton lockCarton(String customer,Long id) { return cartons.selectOne(new LambdaQueryWrapper<ReturnInventoryCarton>().eq(ReturnInventoryCarton::getCustomerId,customer).eq(ReturnInventoryCarton::getId,id).last("FOR UPDATE")); }
/** 锁定原箱SKU内容,供与不可变出库快照核平。 */
public List<ReturnInventoryCartonItem> contents(Long id) { return items.lockCarton(id); }
/** 读取原出库逐SKU扣减事实。 */
public List<ReturnInventoryOutboundMovement> shipped(String customer,Long pickId) { return movements.selectList(new LambdaQueryWrapper<ReturnInventoryOutboundMovement>().eq(ReturnInventoryOutboundMovement::getCustomerId,customer).eq(ReturnInventoryOutboundMovement::getOutboundPickId,pickId).orderByAsc(ReturnInventoryOutboundMovement::getSkuCode)); }
/** 已恢复的原出库不能再次入账。 */
public boolean alreadyReceived(String customer,Long pickId) { return !receipts.byPick(customer,pickId).isEmpty(); }
/** 恢复原SKU的件数和原箱占位,不借用其他客户余额。 */
public void restore(ReturnInventoryOutboundMovement shipment) {
if(balances.adjust(com.baomidou.mybatisplus.core.toolkit.IdWorker.getId(),shipment.getCustomerId(),shipment.getOverseasWarehouseId(),shipment.getSkuCode(),shipment.getProductNameZh(),shipment.getQuantity(),shipment.getQuantity(),shipment.getCartonsBefore()-shipment.getCartonsAfter())<1)
throw new IllegalStateException("原箱SKU余额恢复失败");
}
/** 保存不可变回仓行,唯一键防止重复恢复原出库。 */
public void receipt(ReturnCartonStockReceipt receipt) { receipts.insert(receipt); }
/** 实际回仓后开放原箱,清除已完成换标的预留引用,保留原始退货归属。 */
public int available(ReturnInventoryCarton carton,String pallet,String location,LocalDateTime now) {
return cartons.update(null,new LambdaUpdateWrapper<ReturnInventoryCarton>().eq(ReturnInventoryCarton::getId,carton.getId()).eq(ReturnInventoryCarton::getCustomerId,carton.getCustomerId()).eq(ReturnInventoryCarton::getStatus,"OUTBOUNDED")
.set(ReturnInventoryCarton::getStatus,"AVAILABLE").set(ReturnInventoryCarton::getPalletCode,pallet).set(ReturnInventoryCarton::getLocationCode,location).set(ReturnInventoryCarton::getPutawayAt,now)
.set(ReturnInventoryCarton::getRelabelApplicationId,null).set(ReturnInventoryCarton::getRelabelNo,null).set(ReturnInventoryCarton::getRelabelStatus,null).set(ReturnInventoryCarton::getUpdatedAt,now));
}
/** 查询原生退回历史。 */
public List<ReturnCartonStockReceipt> history(String customer,Long exceptionId) { return receipts.byException(customer,exceptionId); }
}
@@ -0,0 +1,19 @@
package com.baiyue.lastleg.exceptionorder.repository;
import com.baiyue.lastleg.returnreceipt.entity.ReturnInventoryCarton;
import com.baiyue.lastleg.returnreceipt.mapper.ReturnInventoryCartonMapper;
/** 异常候选只读取得原箱业务快照,不写原生库存。 */
public class ReturnCartonSourceRepository {
private final ReturnInventoryCartonMapper cartons;
private final com.baiyue.lastleg.exceptionorder.mapper.ExceptionReturnItemMapper returns;
/** 复用异常来源查询中的原箱数据入口。 */
public ReturnCartonSourceRepository(ReturnInventoryCartonMapper cartons,com.baiyue.lastleg.exceptionorder.mapper.ExceptionReturnItemMapper returns){this.cartons=cartons;this.returns=returns;}
/** 按不可变拣货引用取得原箱,再由服务核对客户和仓库。 */
public ReturnInventoryCarton find(Long cartonId){return cartons.selectById(cartonId);}
/** 两类来源分别累计真实实收,禁止用空仓位合并不同原箱。 */
public long received(String customer,Long deliveryId,Long itemId,Long positionId,Long cartonId){
return cartonId==null?returns.receivedBySource(customer,deliveryId,itemId,positionId)
:returns.receivedByCartonSource(customer,deliveryId,itemId,cartonId);
}
}
@@ -83,6 +83,8 @@ public class ExceptionOrderService {
@Resource private InventoryCommandService inventoryCommands;
@Resource private ExceptionEvidenceFileGateway evidenceFiles;
@Resource private ObjectMapper objectMapper;
@Resource private com.baiyue.lastleg.returnreceipt.mapper.ReturnInventoryCartonMapper returnCartons;
@Resource private ReturnCartonReturnService nativeReturns;
@Resource private com.baiyue.lastleg.inventory.mapper.ReturnStockReceiptMapper returnReceipts;
public PageResult<ExceptionOrderResponses.Row> page(String scopedCustomerId, ExceptionOrderRequests.Query query) {
@@ -116,7 +118,18 @@ public class ExceptionOrderService {
ExceptionOrder order = exceptionOrders.findByNo(customerId, exceptionNo);
if (order == null) throw notFound("异常订单不存在");
return new ExceptionOrderResponses.Detail(row(order), storedReturnSources(order),
receiptHistory(returnReceipts.history(customerId, order.getId())));
returnHistory(order));
}
/** 普通仓位与原箱回仓都只展示实际入账凭据。 */
private List<ExceptionOrderResponses.ReturnReceipt> returnHistory(ExceptionOrder order) {
var history=new ArrayList<>(receiptHistory(returnReceipts.history(order.getCustomerId(),order.getId())));
if(returnItems.findByException(order.getCustomerId(),order.getId()).stream().anyMatch(item->item.getReturnCartonId()!=null)) {
var grouped=nativeReturns.history(order.getCustomerId(),order.getId()).stream().collect(java.util.stream.Collectors.groupingBy(
com.baiyue.lastleg.exceptionorder.entity.ReturnCartonStockReceipt::getReceiptNo,LinkedHashMap::new,java.util.stream.Collectors.toList()));
grouped.forEach((number,lines)->history.add(new ExceptionOrderResponses.ReturnReceipt(number,lines.stream().mapToLong(line->line.getPackages()).sum(),lines.get(0).getReceivedAt(),List.of())));
}
return List.copyOf(history);
}
private List<ExceptionOrderResponses.ReturnReceipt> receiptHistory(
@@ -183,10 +196,19 @@ public class ExceptionOrderService {
.eq(LastlegOrder::getDeliveryOrderNo, deliveryNo)
.orderByAsc(LastlegOrder::getOrderNo)).stream()
.map(order -> withReturnSources(
relation(customerId, customerId, order.getInboundCode(), order.getOrderNo()), delivery))
returnRelation(order), delivery))
.distinct().toList();
}
/** 以派送关联的真实尾程主单构建退回候选,换标主单无需普通入仓关系。 */
private ExceptionOrderResponses.Relation returnRelation(LastlegOrder tail) {
LastlegInboundOrder inbound = optionalInbound(tail.getCustomerId(), tail.getInboundCode());
return new ExceptionOrderResponses.Relation(tail.getId(), tail.getOrderNo(),
inbound == null ? null : inbound.getId(), tail.getInboundCode(), tail.getCustomerId(), tail.getCustomerName(),
tail.getOverseasWarehouseId(), tail.getOverseasWarehouseName(), tail.getDeliveryOrderNo(),
tail.getOutboundOrderNo(), tail.getPackageCount(), List.of());
}
@Transactional
public ExceptionOrderResponses.Created create(String scopedCustomerId, String operatorId, String operatorName,
ExceptionOrderRequests.Create request) {
@@ -196,6 +218,7 @@ public class ExceptionOrderService {
request.skuCode(), null, null, "MANUAL", operatorId, operatorName);
}
/** 按原出库冻结普通仓位或原箱来源,整箱引用与数量在同一派送锁内复核。 */
@Transactional(isolation = org.springframework.transaction.annotation.Isolation.READ_COMMITTED)
public ExceptionOrderResponses.Created markReturn(String customerId, String operatorId, String operatorName, String deliveryNo,
ExceptionOrderRequests.MarkReturn request) {
@@ -206,8 +229,8 @@ public class ExceptionOrderService {
List<ExceptionOrderResponses.Relation> candidates = returnCandidates(delivery);
ExceptionOrderResponses.Relation selected = candidates.stream()
.filter(candidate -> Objects.equals(candidate.inboundOrderId(), request.inboundOrderId()))
.findFirst().orElseThrow(() -> conflict("所选入仓单不属于该派送单"));
.filter(candidate -> Objects.equals(candidate.lastMileOrderId(), request.lastMileOrderId()))
.findFirst().orElseThrow(() -> conflict("所选尾程主单不属于该派送单"));
List<ExceptionOrderResponses.ReturnSource> available = selected.returnSources();
List<ExceptionOrderRequests.ReturnItem> requested = request.items();
validateReturnItems(available, requested);
@@ -229,6 +252,7 @@ public class ExceptionOrderService {
ExceptionReturnItem item = new ExceptionReturnItem();
item.setId(IdWorker.getId()); item.setCustomerId(customerId); item.setExceptionOrderId(created.id());
item.setOutboundPalletItemId(source.outboundPalletItemId()); item.setInventoryPositionId(source.inventoryPositionId());
item.setReturnCartonId(source.returnCartonId()); item.setReturnCartonMark(source.cartonMark());
item.setPalletNo(source.palletNo()); item.setSkuCode(source.skuCode());
item.setMaxReturnPackages(source.maxReturnPackages()); item.setApprovedReturnPackages(requestedItem.packageCount());
item.setReceivedPackages(0L);
@@ -302,6 +326,7 @@ public class ExceptionOrderService {
return result;
}
/** 一次实收的普通库存、原箱账本及完成状态同事务提交,失败全部回滚。 */
@Transactional
public ExceptionOrderResponses.CommandResult complete(String customerId, String operatorId,
String exceptionNo,
@@ -312,6 +337,7 @@ public class ExceptionOrderService {
ExceptionOrder order = lock(customerId, exceptionNo, request.expectedVersion(), "PROCESSING");
request.evidenceFileIds().stream().distinct().forEach(fileId -> evidenceFiles.validate(customerId, order.getId(), fileId));
ReturnedInventoryRestoreResponse restored = null;
long nativeRestored=0;
List<ExceptionReturnItem> stored = List.of();
if ("RETURN".equals(order.getExceptionType()) && "OVERSEAS_WAREHOUSE".equals(order.getReturnDirection())) {
var delivery = deliveryOrders.lockByNo(customerId, order.getDeliveryNo());
@@ -320,18 +346,21 @@ public class ExceptionOrderService {
if (stored.isEmpty()) throw conflict("退回异常单缺少已冻结的原出库库存明细");
if (request.receipts() == null || request.receipts().isEmpty()) throw badRequest("请填写退回实收明细");
Map<Long, Long> received = ReturnReceiptPolicy.quantities(stored, request.receipts());
restored = inventoryCommands.restoreReturnedInventory(customerId, operatorId,
var ordinaryReceipts=request.receipts().stream().filter(receipt->receipt.returnCartonId()==null).toList();
if(!ordinaryReceipts.isEmpty()) restored = inventoryCommands.restoreReturnedInventory(customerId, operatorId,
new ReturnedInventoryRestoreRequest(order.getExceptionNo(), order.getOutboundNo(),
order.getOverseasWarehouseId(), order.getInboundOrderId(),
stored.stream().filter(item -> received.containsKey(item.getId())).map(item -> new ReturnedInventoryRestoreRequest.SourceItem(
stored.stream().filter(item -> item.getReturnCartonId()==null && received.containsKey(item.getId())).map(item -> new ReturnedInventoryRestoreRequest.SourceItem(
item.getOutboundPalletItemId(), item.getInventoryPositionId(), item.getSkuCode(),
item.getApprovedReturnPackages() - ReturnReceiptPolicy.received(item))).toList(),
request.receipts().stream().map(receipt -> new ReturnedInventoryRestoreRequest.Receipt(
ordinaryReceipts.stream().map(receipt -> new ReturnedInventoryRestoreRequest.Receipt(
receipt.outboundPalletItemId(), receipt.inventoryPositionId(), receipt.receivedPackages(),
receipt.targetPalletId(), receipt.targetPalletNo(), receipt.targetLocationId(),
receipt.targetLocationCode())).toList(), request.idempotencyKey().trim(),
request.evidenceFileIds().stream().map(String::valueOf).toList()));
if (restored.restoredPackages() != received.values().stream().mapToLong(Long::longValue).sum())
if(request.receipts().stream().anyMatch(receipt->receipt.returnCartonId()!=null))
nativeRestored=nativeReturns.restore(order,stored,request.receipts(),request.evidenceFileIds(),operatorId);
if ((restored==null?0:restored.restoredPackages())+nativeRestored != 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()));
@@ -350,7 +379,7 @@ public class ExceptionOrderService {
order.setHandlerId(operatorId); order.setCompletedAt(completed ? now : null); order.setUpdatedAt(now);
order.setVersion(version(order) + 1); exceptionOrders.updateById(order);
closeReturnedDelivery(order, operatorId, now);
long restoredPackages = restored == null ? 0 : restored.restoredPackages();
long restoredPackages = (restored == null ? 0 : restored.restoredPackages())+nativeRestored;
ExceptionOrderResponses.CommandResult result = commandResult(order, restoredPackages, false);
recordCommand(customerId, order.getId(), "COMPLETE", request.idempotencyKey(), requestHash, result);
return result;
@@ -474,12 +503,15 @@ public class ExceptionOrderService {
readEvidenceFileIds(value.getEvidenceFileIdsJson()), allowedActions(value));
}
/** 原出库数量减去已实际回仓量,历史原箱与普通仓位分别核平。 */
private ExceptionOrderResponses.Relation withReturnSources(ExceptionOrderResponses.Relation relation,
DeliveryOrder delivery) {
List<ExceptionOrderResponses.ReturnSource> sources = returnSources(delivery).stream()
.filter(source -> relation.inboundCode() == null || Objects.equals(source.inboundCode(), relation.inboundCode()))
.map(source -> source.withPackages(source.maxReturnPackages() - returnItems.receivedBySource(
delivery.getCustomerId(), delivery.getId(), source.outboundPalletItemId(), source.inventoryPositionId())))
.filter(source -> source.lastMileOrderId() != null
? Objects.equals(source.lastMileOrderId(), relation.lastMileOrderId())
: StringUtils.hasText(relation.inboundCode()) && Objects.equals(source.inboundCode(), relation.inboundCode()))
.map(source -> source.withPackages(source.maxReturnPackages() - new com.baiyue.lastleg.exceptionorder.repository.ReturnCartonSourceRepository(returnCartons,returnItems).received(
delivery.getCustomerId(),delivery.getId(),source.outboundPalletItemId(),source.inventoryPositionId(),source.returnCartonId())))
.filter(source -> source.maxReturnPackages() > 0).map(Source::response).toList();
return new ExceptionOrderResponses.Relation(relation.lastMileOrderId(), relation.lastMileOrderNo(),
relation.inboundOrderId(), relation.inboundCode(), relation.customerId(), relation.customerName(),
@@ -487,6 +519,7 @@ public class ExceptionOrderService {
relation.outboundNo(), relation.availablePackages(), sources);
}
/** 合并实际出库拣货证据,来源类型始终保留为普通仓位或原箱。 */
private List<Source> returnSources(DeliveryOrder delivery) {
OutboundOrder outbound = outboundOrders.findByNo(delivery.getCustomerId(), delivery.getOutboundNo());
if (outbound == null) throw conflict("派送单缺少原出库单据");
@@ -499,13 +532,14 @@ public class ExceptionOrderService {
if (item == null || pick.getInventoryPositionId() == null) continue;
String key = item.getId() + ":" + pick.getInventoryPositionId();
aggregated.merge(key, new Source(item.getId(), pick.getInventoryPositionId(), item.getPalletNo(),
item.getSkuCode(), item.getInboundCode(), Objects.requireNonNullElse(pick.getPackages(), 0L)),
item.getSkuCode(), item.getInboundCode(), Objects.requireNonNullElse(pick.getPackages(), 0L), null, null, null),
(left, right) -> left.withPackages(left.maxReturnPackages() + right.maxReturnPackages()));
}
appendDispatchPickSources(delivery, outbound, itemById, aggregated);
return new ArrayList<>(aggregated.values());
}
/** 按实际拣货和原订单分摊归属恢复来源,原箱不伪造库存仓位。 */
private void appendDispatchPickSources(DeliveryOrder delivery, OutboundOrder outbound,
Map<Long, OutboundPalletItem> outboundItemById,
Map<String, Source> aggregated) {
@@ -518,7 +552,7 @@ public class ExceptionOrderService {
.forEach(item -> dispatchItemById.put(item.getId(), item));
for (DispatchPdaPick pick : dispatchPdaPicks.listTask(customerId, task.getId())) {
DispatchItem dispatchItem = dispatchItemById.get(pick.getDispatchItemId());
if (dispatchItem == null || pick.getInventoryPositionId() == null) continue;
if (dispatchItem == null || (pick.getInventoryPositionId() == null && pick.getReturnCartonId() == null)) continue;
List<DispatchPdaPickOrder> attributions = dispatchPdaPickOrders.listPick(customerId, pick.getId());
for (DispatchPdaPickOrder attribution : attributions) {
LastlegOrder tail = lastMileOrders.selectById(attribution.getOrderId());
@@ -533,39 +567,53 @@ public class ExceptionOrderService {
if (matchingItems.size() > 1) throw conflict("排车明细对应的原出库托盘项不唯一,拒绝退回来源");
if (matchingItems.isEmpty()) continue;
OutboundPalletItem outboundItem = matchingItems.get(0);
// A mixed pallet has one physical outbound item; each source is identified by the actual pick attribution.
String key = outboundItem.getId() + ":" + pick.getInventoryPositionId() + ":" + tail.getInboundCode();
// 同一实托可包含多票;逐票仓位或原箱以实际拣货归属分别确定。
String key = outboundItem.getId() + ":" + pick.getInventoryPositionId() + ":" + pick.getReturnCartonId() + ":" + tail.getId();
var carton = pick.getReturnCartonId() == null ? null : new com.baiyue.lastleg.exceptionorder.repository.ReturnCartonSourceRepository(returnCartons,returnItems).find(pick.getReturnCartonId());
if (pick.getReturnCartonId() != null && (carton == null || !customerId.equals(carton.getCustomerId())
|| !Objects.equals(dispatchItem.getOverseasWarehouseId(), carton.getOverseasWarehouseId())
|| !"RETURN_RELABEL".equals(dispatchItem.getSourceStage())))
throw conflict("原箱出库来源与客户、仓库或换标申请不一致");
long packages = Objects.requireNonNullElse(attribution.getPackages(), 0L);
if(pick.getReturnCartonId()!=null&&(packages!=1||!Objects.equals(pick.getPackages(),1L)))throw conflict("原箱出库数量凭据不一致");
aggregated.merge(key, new Source(outboundItem.getId(), pick.getInventoryPositionId(),
outboundItem.getPalletNo(), outboundItem.getSkuCode(), tail.getInboundCode(), packages),
outboundItem.getPalletNo(), outboundItem.getSkuCode(), tail.getInboundCode(), packages,
pick.getReturnCartonId(), carton == null ? null : carton.getCartonMark(), tail.getId()),
(left, right) -> left.withPackages(left.maxReturnPackages() + right.maxReturnPackages()));
}
}
}
/** 详情仅返回冻结来源的剩余可收量及原箱业务码。 */
private List<ExceptionOrderResponses.ReturnSource> storedReturnSources(ExceptionOrder order) {
return returnItems.findByException(order.getCustomerId(), order.getId()).stream()
.map(item -> new ExceptionOrderResponses.ReturnSource(item.getOutboundPalletItemId(),
item.getInventoryPositionId(), item.getPalletNo(), item.getSkuCode(),
Objects.requireNonNullElse(item.getApprovedReturnPackages(), 0L) - ReturnReceiptPolicy.received(item)))
Objects.requireNonNullElse(item.getApprovedReturnPackages(), 0L) - ReturnReceiptPolicy.received(item),
item.getReturnCartonId(), item.getReturnCartonMark()))
.filter(item -> item.maxReturnPackages() > 0).toList();
}
/** 申请的来源必须唯一、数量有效且不超过本派送原出库事实。 */
private void validateReturnItems(List<ExceptionOrderResponses.ReturnSource> available,
List<ExceptionOrderRequests.ReturnItem> requested) {
Set<String> unique = new java.util.HashSet<>();
for (ExceptionOrderRequests.ReturnItem item : requested) {
String key = item.outboundPalletItemId() + ":" + item.inventoryPositionId();
ReturnReceiptPolicy.requireSource(item.inventoryPositionId(), item.returnCartonId());
if(item.packageCount()<1||item.returnCartonId()!=null&&item.packageCount()!=1)throw badRequest("退回箱数必须为正数,退货原箱必须按一整箱申请");
String key = item.outboundPalletItemId() + ":" + item.inventoryPositionId() + ":" + item.returnCartonId();
if (!unique.add(key)) throw badRequest("退回库存明细不能重复");
ExceptionOrderResponses.ReturnSource source = available.stream().filter(value -> sameSource(value, item))
.findFirst().orElseThrow(() -> conflict("所选退回库存不属于该派送单和入仓单"));
.findFirst().orElseThrow(() -> conflict("所选退回库存不属于该派送单和尾程主单"));
if (item.packageCount() > source.maxReturnPackages()) throw conflict("退回箱数超过原出库箱数");
}
}
/** 出库项与两类互斥来源共同确定一条真实退回明细。 */
private boolean sameSource(ExceptionOrderResponses.ReturnSource source, ExceptionOrderRequests.ReturnItem item) {
return Objects.equals(source.outboundPalletItemId(), item.outboundPalletItemId())
&& Objects.equals(source.inventoryPositionId(), item.inventoryPositionId());
&& Objects.equals(source.inventoryPositionId(), item.inventoryPositionId())
&& Objects.equals(source.returnCartonId(), item.returnCartonId());
}
private ExceptionOrder lock(String customerId, String exceptionNo, long expectedVersion, String expectedStatus) {
@@ -691,11 +739,13 @@ public class ExceptionOrderService {
private static long version(ExceptionOrder order) { return Objects.requireNonNullElse(order.getVersion(), 0L); }
private record Source(Long outboundPalletItemId, Long inventoryPositionId, String palletNo,
String skuCode, String inboundCode, long maxReturnPackages) {
Source withPackages(long packages) { return new Source(outboundPalletItemId, inventoryPositionId, palletNo, skuCode, inboundCode, packages); }
String skuCode, String inboundCode, long maxReturnPackages, Long returnCartonId, String cartonMark, Long lastMileOrderId) {
/** 保留原始来源身份,仅修订剩余可退数量。 */
Source withPackages(long packages) { return new Source(outboundPalletItemId, inventoryPositionId, palletNo, skuCode, inboundCode, packages, returnCartonId, cartonMark, lastMileOrderId); }
/** 以原箱业务码展示,内部来源标识仅用于提交核对。 */
ExceptionOrderResponses.ReturnSource response() {
return new ExceptionOrderResponses.ReturnSource(outboundPalletItemId, inventoryPositionId,
palletNo, skuCode, maxReturnPackages);
palletNo, skuCode, maxReturnPackages, returnCartonId, cartonMark);
}
}
@@ -0,0 +1,78 @@
package com.baiyue.lastleg.exceptionorder.service;
import com.baomidou.mybatisplus.core.toolkit.IdWorker;
import com.baiyue.lastleg.exceptionorder.dto.ExceptionOrderRequests;
import com.baiyue.lastleg.exceptionorder.entity.*;
import com.baiyue.lastleg.exceptionorder.repository.ReturnCartonReturnRepository;
import com.baiyue.lastleg.inboundplan.pda.gateway.WarehouseResourceGateway;
import com.baiyue.lastleg.palletops.gateway.PalletPhysicalGateway;
import com.baiyue.lastleg.returnreceipt.entity.ReturnInventoryOutboundMovement;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.*;
import org.springframework.http.HttpStatus;
import org.springframework.web.server.ResponseStatusException;
import java.time.LocalDateTime;
import java.util.*;
/** 异常实收事务内按原出库证据恢复原箱,永不创建普通库存仓位。 */
@Service
public class ReturnCartonReturnService {
private final ReturnCartonReturnRepository repository;
private final PalletPhysicalGateway pallets;
private final WarehouseResourceGateway warehouse;
private final ObjectMapper json;
private final com.baiyue.lastleg.service.LastlegOrderNoGenerator numbers;
/** 装配真实原箱数据入口和仓储资源校验。 */
public ReturnCartonReturnService(ReturnCartonReturnRepository repository,PalletPhysicalGateway pallets,WarehouseResourceGateway warehouse,ObjectMapper json,com.baiyue.lastleg.service.LastlegOrderNoGenerator numbers) {
this.repository=repository;this.pallets=pallets;this.warehouse=warehouse;this.json=json;this.numbers=numbers;
}
/** 同一异常完成命令持有异常、派送行锁;任一原箱失败时全部库存和凭据回滚。 */
@Transactional(propagation=Propagation.MANDATORY)
public long restore(ExceptionOrder order,List<ExceptionReturnItem> frozen,List<ExceptionOrderRequests.ReturnReceipt> inputs,List<Long> evidence,String operator) {
if(evidence==null||evidence.isEmpty())throw conflict("原箱实收必须保留有效证据文件");
long packages=0;
for (var input:inputs.stream().filter(i->i.returnCartonId()!=null).sorted(Comparator.comparing(ExceptionOrderRequests.ReturnReceipt::returnCartonId)).toList()) {
var source=frozen.stream().filter(i->Objects.equals(i.getOutboundPalletItemId(),input.outboundPalletItemId())&&Objects.equals(i.getReturnCartonId(),input.returnCartonId())).findFirst().orElseThrow(()->conflict("退回原箱不属于本异常冻结来源"));
if(input.receivedPackages()!=1||source.getApprovedReturnPackages()!=1||ReturnReceiptPolicy.received(source)!=0||input.inventoryPositionId()!=null)throw conflict("原箱须按整箱实收且只能入账一次");
var proof=repository.shipment(order.getCustomerId(),order.getId(),source.getOutboundPalletItemId(),source.getReturnCartonId());
if(proof==null)throw conflict("原箱缺少本派送实际出库及原订单归属凭据");
var carton=repository.lockCarton(order.getCustomerId(),source.getReturnCartonId());
if(carton==null||!Objects.equals(carton.getOverseasWarehouseId(),order.getOverseasWarehouseId())||!"OUTBOUNDED".equals(carton.getStatus())||repository.alreadyReceived(order.getCustomerId(),proof.pickId()))throw conflict("原箱已回仓或客户、仓库、出库状态不匹配");
var shipped=repository.shipped(order.getCustomerId(),proof.pickId());
var expected=new TreeMap<String,Long>();
for(var movement:shipped) {
if(!Objects.equals(movement.getCartonId(),carton.getId())||!Objects.equals(movement.getOverseasWarehouseId(),order.getOverseasWarehouseId())||!Objects.equals(movement.getOutboundNo(),proof.dispatchNo())||movement.getQuantity()==null||movement.getQuantity()<1)throw conflict("原箱出库SKU事实不完整或归属不一致");
expected.merge(movement.getSkuCode(),movement.getQuantity(),Math::addExact);
}
var current=new TreeMap<String,Long>();
for(var item:repository.contents(carton.getId())) {
if(!Objects.equals(item.getCustomerId(),order.getCustomerId())||Objects.requireNonNullElse(item.getPackingReservedQuantity(),0L)>0)throw conflict("原箱内容客户或预留状态不一致");
current.merge(item.getSkuCode(),item.getQuantity(),Math::addExact);
}
long shippedQuantity=expected.values().stream().mapToLong(Long::longValue).sum();
if(expected.isEmpty()||!expected.equals(current)||shippedQuantity!=Objects.requireNonNullElse(carton.getTotalQuantity(),0L)
||!Objects.equals(proof.productQuantity(),shippedQuantity))throw conflict("原箱件数与不可变出库SKU快照不一致");
if(input.targetPalletId()==null||input.targetPalletId()<1||input.targetLocationId()==null||input.targetLocationId()<1||input.targetPalletNo()==null||input.targetPalletNo().isBlank()||input.targetLocationCode()==null||input.targetLocationCode().isBlank())throw conflict("请填写真实回仓目标托盘和库位");
var pallet=pallets.resolve(input.targetPalletNo());
var location=warehouse.resolveLocation(Long.parseLong(order.getOverseasWarehouseId()),input.targetLocationCode());
if(pallet==null||pallet.palletId()!=input.targetPalletId()||!String.valueOf(pallet.warehouseId()).equals(order.getOverseasWarehouseId())||!Set.of("IDLE","OCCUPIED").contains(pallet.status())||location==null||location.locationId()!=input.targetLocationId()||!Objects.equals(pallet.locationId(),location.locationId())||!Set.of("ACTIVE","IDLE","OCCUPIED").contains(location.status())||"VIRTUAL".equalsIgnoreCase(location.locationType()))throw conflict("目标托盘、库位或海外仓不匹配,不能确认原箱回仓");
LocalDateTime now=LocalDateTime.now();String receiptNo=numbers.next("RETURN_RECEIPT",order.getOverseasWarehouseId(),IdWorker.getId());boolean first=true;
for(var movement:shipped) {
repository.restore(movement);
var receipt=new ReturnCartonStockReceipt();receipt.setId(IdWorker.getId());receipt.setReceiptNo(receiptNo);receipt.setCustomerId(order.getCustomerId());
receipt.setExceptionOrderId(order.getId());receipt.setExceptionReturnItemId(source.getId());receipt.setDeliveryOrderId(order.getDeliveryOrderId());receipt.setSourcePickId(proof.pickId());receipt.setSourceOutboundNo(order.getOutboundNo());receipt.setSourceDispatchItemId(proof.dispatchItemId());receipt.setSourceOutboundPalletItemId(source.getOutboundPalletItemId());receipt.setReturnCartonId(carton.getId());receipt.setCartonMark(carton.getCartonMark());receipt.setOverseasWarehouseId(order.getOverseasWarehouseId());receipt.setTargetPalletId(input.targetPalletId());receipt.setTargetPalletNo(pallet.palletCode());receipt.setTargetLocationId(input.targetLocationId());receipt.setTargetLocationCode(location.locationCode());receipt.setSkuCode(movement.getSkuCode());receipt.setProductNameZh(movement.getProductNameZh());receipt.setQuantity(movement.getQuantity());receipt.setPackages(first?1L:0L);receipt.setReceivedAt(now);
receipt.setReceivedBy(operator);
try{receipt.setEvidenceFileIdsJson(json.writeValueAsString(evidence));}catch(com.fasterxml.jackson.core.JsonProcessingException error){throw new IllegalStateException(error);}
repository.receipt(receipt);first=false;
}
if(repository.available(carton,pallet.palletCode(),location.locationCode(),now)!=1)throw conflict("原箱回仓状态已变化");
packages++;
}
return packages;
}
/** 只展示已落账的原箱回仓凭据。 */
public List<ReturnCartonStockReceipt> history(String customer,Long exceptionId){return repository.history(customer,exceptionId);}
/** 拒绝不完整来源或实收,不保留部分入账。 */
private static ResponseStatusException conflict(String reason){return new ResponseStatusException(HttpStatus.CONFLICT,reason);}
}
@@ -12,12 +12,15 @@ 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) {
requireSource(receipt.inventoryPositionId(), receipt.returnCartonId());
ExceptionReturnItem source = sources.stream().filter(item -> Objects.equals(item.getOutboundPalletItemId(), receipt.outboundPalletItemId())
&& Objects.equals(item.getInventoryPositionId(), receipt.inventoryPositionId())).findFirst()
&& Objects.equals(item.getInventoryPositionId(), receipt.inventoryPositionId())
&& Objects.equals(item.getReturnCartonId(), receipt.returnCartonId())).findFirst()
.orElseThrow(() -> conflict("退回实收明细不属于本异常冻结来源"));
if (receipt.receivedPackages() <= 0) throw conflict("退回实收箱数必须大于零");
amounts.merge(source.getId(), receipt.receivedPackages(), Math::addExact);
@@ -29,6 +32,12 @@ final class ReturnReceiptPolicy {
return amounts;
}
/** 普通仓位与原箱引用恰有一个,零值和负值不能作为来源。 */
static void requireSource(Long positionId, Long cartonId) {
if ((positionId == null) == (cartonId == null) || positionId != null && positionId < 1 || cartonId != null && cartonId < 1)
throw conflict("库存仓位与退货原箱必须且只能填写一个有效来源");
}
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());
@@ -3,9 +3,7 @@ package com.baiyue.lastleg.fleet.business;
import com.baomidou.mybatisplus.core.toolkit.IdWorker;
import com.baiyue.lastleg.looseboard.entity.*;
import com.baiyue.lastleg.looseboard.mapper.*;
import com.baiyue.lastleg.trucking.entity.*;
import com.baiyue.lastleg.trucking.mapper.*;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.http.HttpStatus;
@@ -22,34 +20,32 @@ import static com.baiyue.lastleg.fleet.business.FleetPoolOrderModels.*;
/** Same-database bridge. Never creates inventory, publishes fees, or locks a parent trip. */
@Service
public class FleetPoolOrderBridge {
private final FleetPoolOrderMapper links;
private final LocalLooseboardOrderMapper loose;
private final LocalLooseboardHistoryMapper looseHistory;
private final UsTruckOrderMapper trucks;
private final UsTruckOrderStatusHistoryMapper truckHistory;
private final FleetPoolOrderRepository repository;
private final FleetPoolOrderNumberGenerator numbers;
private final ObjectMapper json;
private final FleetPoolDeliveryEvidence deliveryEvidence;
private final com.baiyue.lastleg.appointmentplanning.AppointmentSourceLinker appointmentSources;
public FleetPoolOrderBridge(FleetPoolOrderMapper links,LocalLooseboardOrderMapper loose,
LocalLooseboardHistoryMapper looseHistory,UsTruckOrderMapper trucks,
UsTruckOrderStatusHistoryMapper truckHistory,FleetPoolOrderNumberGenerator numbers,ObjectMapper json,FleetPoolDeliveryEvidence deliveryEvidence,
/** 注入同库商业委托仓储及履约协作服务。 */
public FleetPoolOrderBridge(FleetPoolOrderRepository repository,
FleetPoolOrderNumberGenerator numbers,ObjectMapper json,FleetPoolDeliveryEvidence deliveryEvidence,
com.baiyue.lastleg.appointmentplanning.AppointmentSourceLinker appointmentSources) {
this.links=links;this.loose=loose;this.looseHistory=looseHistory;this.trucks=trucks;
this.truckHistory=truckHistory;this.numbers=numbers;this.json=json;this.deliveryEvidence=deliveryEvidence;
this.repository=repository;
this.numbers=numbers;this.json=json;this.deliveryEvidence=deliveryEvidence;
this.appointmentSources=appointmentSources;
}
/** 批量读取预订对应的当前商业履约阶段。 */
@Transactional(readOnly=true)
public Map<Long,String> fulfillmentStages(List<Long> bookingIds) {
if(bookingIds.isEmpty())return Map.of();
var result=new HashMap<Long,String>();
for(var link:links.bookings(bookingIds))result.put(link.getBookingId(),link.getFulfillmentStage());
for(var link:repository.bookings(bookingIds))result.put(link.getBookingId(),link.getFulfillmentStage());
return Map.copyOf(result);
}
/** 在承运确认事务内创建唯一商业订单,重放时核对来源哈希。 */
@Transactional(propagation=Propagation.MANDATORY)
public BusinessOrderRef confirm(ConfirmedBooking in,String operator) {
validate(in);
String hash=hash(in); var prior=links.lockBooking(in.bookingId());
String hash=hash(in); var prior=repository.lockBooking(in.bookingId());
if(prior!=null){if(!hash.equals(prior.getCreationRequestHash()))throw conflict("同一承运来源不能创建不同商业订单");return ref(prior);}
var now=LocalDateTime.now();var row=new FleetPoolOrderLink();
row.setId(IdWorker.getId());row.setBookingId(in.bookingId());row.setBookingNo(in.bookingNo());
@@ -62,51 +58,55 @@ public class FleetPoolOrderBridge {
String transport=in.destinationKey().startsWith("FBA:")?"WAREHOUSE_TO_WAREHOUSE":"WAREHOUSE_TO_DOOR";
if("LOCAL_LOOSEBOARD".equals(in.businessType())) {
var order=new LocalLooseboardOrder();order.setId(row.getBusinessOrderId());order.setCustomerId(in.customerId());order.setOrderNo(row.getBusinessOrderNo());
order.setTransportMode("SHARED_TRUCK");order.setTransportType(transport);
// A pallet count/space is not a palletizing service request or a container specification.
order.setPalletRequired(null);order.setContainerType(null);
order.setTransportType(transport);
// 货名与预计托数沿真实询价保存;不从数量推断打盘服务,也不预设取送时间。
order.setPalletRequired(null);order.setProductName(in.cargoName());order.setEstimatedPalletCount(in.expectedPalletCount());
order.setOrigin(in.warehouseName());order.setDestination(in.destinationLabel());order.setPackageCount(in.packageCount());
order.setTotalWeightKg(in.weightKg());order.setTotalVolumeCbm(in.volumeCbm());order.setCurrency(in.currency());
order.setContactName(in.contactName());order.setContactInfo(in.contactPhone());order.setRequestedDate(in.plannedDepartureAt().toLocalDate());
order.setOriginContactName(in.contactName());order.setOriginContactPhone(in.contactPhone());
order.setPickupAt(in.plannedDepartureAt());
order.setStatus(row.getFulfillmentStage());order.setRemark(in.remark());order.setIdempotencyKey("FLEET_POOL:"+in.bookingNo());
order.setRequestHash(hash);order.setCreatedBy(operator);order.setVersion(0L);order.setCreatedAt(now);order.setUpdatedAt(now);
loose.insert(order); // No placeholder ll_order: incoming owns the one physical tail order.
repository.insertLoose(order); // No placeholder ll_order: incoming owns the one physical tail order.
} else {
var order=new UsTruckOrder();order.setId(row.getBusinessOrderId());order.setCustomerId(in.customerId());order.setOrderNo(row.getBusinessOrderNo());
order.setTransportType(transport);order.setOrigin(in.warehouseName());order.setDestination(in.destinationLabel());
order.setContainerType(null); // Pool inquiry does not collect a container specification.
order.setOrigin(in.warehouseName());order.setDestination(in.destinationLabel());
order.setPalletCount(in.expectedPalletCount());order.setPackageCount(in.packageCount());order.setReceivedPalletCount(0);order.setAllocatedPalletCount(0);
order.setTotalWeightKg(in.weightKg());order.setTotalVolumeCbm(in.volumeCbm());order.setCurrency(in.currency());
order.setContactName(in.contactName());order.setContactInfo(in.contactPhone());order.setRequestedDate(in.plannedDepartureAt().toLocalDate());
// 来源未提供的车型、目的联系人与送货时间保持未知。
order.setOriginContactName(in.contactName());order.setOriginContactPhone(in.contactPhone());order.setPickupAt(in.plannedDepartureAt());
order.setStatus(row.getFulfillmentStage());order.setRemark(in.remark());order.setIdempotencyKey("FLEET_POOL:"+in.bookingNo());
order.setRequestHash(hash);order.setCreatedBy(operator);order.setVersion(0L);order.setCreatedAt(now);order.setUpdatedAt(now);
trucks.insert(order); // No simulated ll_us_truck_order_pallet rows.
repository.insertTruck(order); // No simulated ll_us_truck_order_pallet rows.
}
links.insert(row);history(row,null,row.getFulfillmentStage(),operator,"确认承运并建立商业委托",now);
repository.insertLink(row);history(row,null,row.getFulfillmentStage(),operator,"确认承运并建立商业委托",now);
return ref(row);
}
/** 锁定预订并关联唯一真实来货,不创建第二份实物来源。 */
@Transactional(propagation=Propagation.MANDATORY)
public void bindIncoming(String bookingNo,String customerId,String incomingNo,long lastlegOrderId,
String warehouseId,String destinationKey,String operator) {
var row=links.lockBookingNo(bookingNo);
if(row==null){if(links.poolBooking(bookingNo)>0)throw conflict("池来源预订缺少唯一商业订单关联");return;}
var row=repository.lockBookingNo(bookingNo);
if(row==null){if(repository.poolBooking(bookingNo)>0)throw conflict("池来源预订缺少唯一商业订单关联");return;}
owned(row,customerId);
if(!Objects.equals(row.getWarehouseId(),warehouseId)||!Objects.equals(row.getDestinationKey(),destinationKey))throw conflict("来货仓库或目的地与池来源商业委托不一致");
if(row.getIncomingNo()!=null){if(!row.getIncomingNo().equals(incomingNo)||!Objects.equals(row.getLastlegOrderId(),lastlegOrderId))throw conflict("商业委托已绑定其他来货");return;}
if(!"WAITING_INBOUND".equals(row.getFulfillmentStage()))throw conflict("商业委托当前不能登记来货");
row.setIncomingNo(incomingNo);row.setLastlegOrderId(lastlegOrderId);touch(row,LocalDateTime.now());links.updateById(row);
row.setIncomingNo(incomingNo);row.setLastlegOrderId(lastlegOrderId);touch(row,LocalDateTime.now());repository.updateLink(row);
if("LOCAL_LOOSEBOARD".equals(row.getBusinessType())) {
var order=loose.lockNo(customerId,row.getBusinessOrderNo());requireOrder(order!=null);
var order=repository.lockLoose(customerId,row.getBusinessOrderNo());requireOrder(order!=null);
if(order.getLastlegOrderId()!=null&&!order.getLastlegOrderId().equals(lastlegOrderId))throw conflict("散板商业单已存在另一尾程来源");
order.setLastlegOrderId(lastlegOrderId);order.setVersion(order.getVersion()+1);order.setUpdatedAt(row.getUpdatedAt());loose.updateById(order);
order.setLastlegOrderId(lastlegOrderId);order.setVersion(order.getVersion()+1);order.setUpdatedAt(row.getUpdatedAt());repository.updateLoose(order);
}
appointmentSources.materialized(customerId, bookingNo, lastlegOrderId);
}
/** 按真实来货开始事件推进收货状态。 */
@Transactional(propagation=Propagation.MANDATORY)
public void receiving(String customerId,String incomingNo,String operator) {
var row=links.lockIncoming(customerId,incomingNo);if(row==null)return;
var row=repository.lockIncoming(customerId,incomingNo);if(row==null)return;
if("WAITING_INBOUND".equals(row.getFulfillmentStage()))move(row,"RECEIVING",operator,"PDA开始实际收货 "+incomingNo,LocalDateTime.now());
}
/** 沿已确认实托及实占托位保存实际收货。 */
@Transactional(propagation=Propagation.MANDATORY)
public void acceptedReceipt(long bookingId,Receipt evidence,int palletSpaces,String operator) {
var row=requiredBooking(bookingId);
@@ -123,6 +123,7 @@ public class FleetPoolOrderBridge {
row.setActualPackageCount(packages);row.setActualPalletCount(unique.size());row.setActualPalletSpaces(palletSpaces);
move(row,"WAITING_CONSOLIDATION",operator,"实收核量确认 "+evidence.incomingNo(),LocalDateTime.now());
}
/** 沿真实排车关联推进商业履约。 */
@Transactional(propagation=Propagation.MANDATORY)
public void boundDispatch(long bookingId,String customerId,String dispatchNo,String operator) {
var row=requiredBooking(bookingId);owned(row,customerId);
@@ -130,6 +131,7 @@ public class FleetPoolOrderBridge {
if(!"WAITING_CONSOLIDATION".equals(row.getFulfillmentStage())||dispatchNo==null||dispatchNo.isBlank())throw conflict("商业单仅实收核量后可关联发运");
row.setDispatchNo(dispatchNo);move(row,"PENDING_DISPATCH",operator,"关联实际排车 "+dispatchNo,LocalDateTime.now());
}
/** 校验商业履约边界或组装真实业务引用。 */
@Transactional(propagation=Propagation.MANDATORY)
public void unboundDispatch(long bookingId,String customerId,String dispatchNo,String operator) {
var row=requiredBooking(bookingId);owned(row,customerId);
@@ -137,6 +139,7 @@ public class FleetPoolOrderBridge {
if(!Objects.equals(row.getDispatchNo(),dispatchNo)||!"PENDING_DISPATCH".equals(row.getFulfillmentStage()))throw conflict("商业单当前不能解除发运关联");
row.setDispatchNo(null);move(row,"WAITING_CONSOLIDATION",operator,"解除未执行排车 "+dispatchNo,LocalDateTime.now());
}
/** 校验商业履约边界或组装真实业务引用。 */
@Transactional(propagation=Propagation.MANDATORY)
public void cancelBeforeIncoming(long bookingId,String customerId,String operator,String reason) {
var row=requiredBooking(bookingId);owned(row,customerId);
@@ -144,19 +147,21 @@ public class FleetPoolOrderBridge {
if(row.getIncomingNo()!=null||!"WAITING_INBOUND".equals(row.getFulfillmentStage()))throw conflict("池来源已登记来货,首期不支持取消或跨车改配");
move(row,"CANCELLED",operator,reason,LocalDateTime.now());
}
/** 沿真实整车发车事实推进运输状态。 */
@Transactional(propagation=Propagation.MANDATORY)
public void departed(String customerId,String dispatchNo,Map<Long,Integer> sourcePackages,String operator,LocalDateTime at) {
for(var row:links.lockDispatch(customerId,dispatchNo)) {
for(var row:repository.lockDispatch(customerId,dispatchNo)) {
if(row.getActualPackageCount()==null||!Objects.equals(sourcePackages.get(row.getLastlegOrderId()),row.getActualPackageCount()))throw conflict("商业单实际出库来源或完整货量不匹配");
if("IN_TRANSIT".equals(row.getFulfillmentStage()))continue;
if(!"PENDING_DISPATCH".equals(row.getFulfillmentStage()))throw conflict("商业单未关联实际发运");
move(row,"IN_TRANSIT",operator,"整车实际出库 "+dispatchNo,at);
}
}
/** 核对全部签收证据后推进商业完成。 */
@Transactional(propagation=Propagation.MANDATORY)
public void signed(SignedDelivery delivery,Map<Long,Long> actualSourcePackages,String operator,LocalDateTime at) {
if(delivery.dispatchNo()==null)return;
for(var row:links.lockDispatch(delivery.customerId(),delivery.dispatchNo())) {
for(var row:repository.lockDispatch(delivery.customerId(),delivery.dispatchNo())) {
if(!actualSourcePackages.containsKey(row.getLastlegOrderId()))continue;
if(row.getActualPackageCount()==null||actualSourcePackages.get(row.getLastlegOrderId())!=row.getActualPackageCount().longValue())throw conflict("商业单签收实际来源数量不完整");
if("COMPLETED".equals(row.getFulfillmentStage()))continue;
@@ -165,12 +170,14 @@ public class FleetPoolOrderBridge {
move(row,"COMPLETED",operator,"真实全部签收 "+delivery.deliveryNo(),at);
}
}
/** 校验商业履约边界或组装真实业务引用。 */
private boolean wholeSourceSigned(FleetPoolOrderLink row) {
return deliveryEvidence.fullySigned(row.getCustomerId(),row.getIncomingNo(),row.getLastlegOrderId(),row.getActualPackageCount());
}
/** 批量读取预订的业务编号。 */
public Map<Long,String> businessOrderNumbers(Collection<Long> ids) {
if(ids==null||ids.isEmpty())return Map.of();var result=new HashMap<Long,String>();
links.bookings(ids).forEach(row->result.put(row.getBookingId(),row.getBusinessOrderNo()));return result;
repository.bookings(ids).forEach(row->result.put(row.getBookingId(),row.getBusinessOrderNo()));return result;
}
static void validate(ConfirmedBooking in) {
if(in==null||in.bookingId()<=0||in.businessType()==null||!Set.of("LOCAL_LOOSEBOARD","US_TRUCK").contains(in.businessType())
@@ -183,37 +190,47 @@ public class FleetPoolOrderBridge {
||("US_TRUCK".equals(in.businessType())&&(in.expectedPalletCount()==null||in.expectedPalletCount()<1)))
throw conflict("池来源商业委托缺少完整预报、独立实托数或运输身份");
}
/** 在同一事务更新商业阶段与不可变历史。 */
private void move(FleetPoolOrderLink row,String stage,String operator,String reason,LocalDateTime at) {
String from=row.getFulfillmentStage();if(stage.equals(from))return;
row.setFulfillmentStage(stage);row.setStageOccurredAt(at);touch(row,at);links.updateById(row);
row.setFulfillmentStage(stage);row.setStageOccurredAt(at);touch(row,at);repository.updateLink(row);
if("LOCAL_LOOSEBOARD".equals(row.getBusinessType())) {
var order=loose.lockNo(row.getCustomerId(),row.getBusinessOrderNo());requireOrder(order!=null);
order.setStatus(stage);order.setVersion(order.getVersion()+1);order.setUpdatedAt(at);loose.updateById(order);
var order=repository.lockLoose(row.getCustomerId(),row.getBusinessOrderNo());requireOrder(order!=null);
order.setStatus(stage);order.setVersion(order.getVersion()+1);order.setUpdatedAt(at);repository.updateLoose(order);
} else {
var order=trucks.lockNo(row.getCustomerId(),row.getBusinessOrderNo());requireOrder(order!=null);
var order=repository.lockTruck(row.getCustomerId(),row.getBusinessOrderNo());requireOrder(order!=null);
order.setStatus(stage);order.setVersion(order.getVersion()+1);order.setUpdatedAt(at);
if(row.getActualPalletCount()!=null)order.setReceivedPalletCount(row.getActualPalletCount());
if(row.getActualPalletCount()!=null)order.setAllocatedPalletCount(row.getDispatchNo()==null?0:row.getActualPalletCount());
trucks.updateById(order);
repository.updateTruck(order);
}
history(row,from,stage,operator,reason,at);
}
/** 保存来源业务号和履约事件的不可变记录。 */
private void history(FleetPoolOrderLink row,String from,String to,String operator,String reason,LocalDateTime at) {
String key="FLEET_POOL:"+row.getBookingNo()+":"+to;
if("LOCAL_LOOSEBOARD".equals(row.getBusinessType())) {
var h=new LocalLooseboardHistory();h.setId(IdWorker.getId());h.setOrderId(row.getBusinessOrderId());h.setCustomerId(row.getCustomerId());
h.setOperationType("FLEET_FULFILLMENT");h.setFromStatus(from);h.setToStatus(to);h.setOperatorId(operator);h.setIdempotencyKey(key);h.setRemark(reason);h.setCreatedAt(at);looseHistory.insert(h);
h.setOperationType("FLEET_FULFILLMENT");h.setFromStatus(from);h.setToStatus(to);h.setOperatorId(operator);h.setIdempotencyKey(key);h.setRemark(reason);h.setCreatedAt(at);repository.insertLooseHistory(h);
} else {
var h=new UsTruckOrderStatusHistory();h.setId(IdWorker.getId());h.setOrderId(row.getBusinessOrderId());h.setCustomerId(row.getCustomerId());
h.setOperationType("FLEET_FULFILLMENT");h.setFromStatus(from);h.setToStatus(to);h.setOperatorId(operator);h.setIdempotencyKey(key);h.setRemark(reason);h.setCreatedAt(at);truckHistory.insert(h);
h.setOperationType("FLEET_FULFILLMENT");h.setFromStatus(from);h.setToStatus(to);h.setOperatorId(operator);h.setIdempotencyKey(key);h.setRemark(reason);h.setCreatedAt(at);repository.insertTruckHistory(h);
}
}
/** 校验商业履约边界或组装真实业务引用。 */
private String hash(Object input){try{return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(json.writeValueAsString(input).getBytes(StandardCharsets.UTF_8)));}catch(Exception e){throw new IllegalStateException(e);}}
private FleetPoolOrderLink requiredBooking(long bookingId){var row=links.lockBooking(bookingId);if(row==null)throw conflict("池来源预订缺少唯一商业订单关联");return row;}
/** 锁定并读取必需的商业委托。 */
private FleetPoolOrderLink requiredBooking(long bookingId){var row=repository.lockBooking(bookingId);if(row==null)throw conflict("池来源预订缺少唯一商业订单关联");return row;}
/** 校验商业履约边界或组装真实业务引用。 */
private static void touch(FleetPoolOrderLink row,LocalDateTime at){row.setRecordVersion(row.getRecordVersion()+1);row.setUpdatedAt(at);}
/** 校验商业履约边界或组装真实业务引用。 */
private static BusinessOrderRef ref(FleetPoolOrderLink row){return new BusinessOrderRef(row.getBusinessType(),row.getBusinessOrderNo());}
/** 校验商业履约边界或组装真实业务引用。 */
private static void owned(FleetPoolOrderLink row,String customer){if(!Objects.equals(row.getCustomerId(),customer))throw conflict("商业委托客户归属不一致");}
/** 校验商业履约边界或组装真实业务引用。 */
private static boolean blank(String value){return value==null||value.isBlank();}
/** 校验商业履约边界或组装真实业务引用。 */
private static void requireOrder(boolean ok){if(!ok)throw conflict("池来源商业订单不存在");}
/** 校验商业履约边界或组装真实业务引用。 */
private static ResponseStatusException conflict(String message){return new ResponseStatusException(HttpStatus.CONFLICT,message);}
}
@@ -0,0 +1,56 @@
package com.baiyue.lastleg.fleet.business;
import com.baiyue.lastleg.looseboard.entity.*;
import com.baiyue.lastleg.looseboard.mapper.*;
import com.baiyue.lastleg.trucking.entity.*;
import com.baiyue.lastleg.trucking.mapper.*;
import org.springframework.stereotype.Repository;
import java.util.List;
import java.util.Collection;
@Repository
public class FleetPoolOrderRepository {
private final FleetPoolOrderMapper links;
private final LocalLooseboardOrderMapper loose;
private final LocalLooseboardHistoryMapper looseHistory;
private final UsTruckOrderMapper trucks;
private final UsTruckOrderStatusHistoryMapper truckHistory;
/** 注入同库商业委托及历史 Mapper,事务由服务统一管理。 */
public FleetPoolOrderRepository(FleetPoolOrderMapper links, LocalLooseboardOrderMapper loose,
LocalLooseboardHistoryMapper looseHistory, UsTruckOrderMapper trucks, UsTruckOrderStatusHistoryMapper truckHistory) {
this.links=links;this.loose=loose;this.looseHistory=looseHistory;this.trucks=trucks;this.truckHistory=truckHistory;
}
/** 批量读取预订关联。 */
public List<FleetPoolOrderLink> bookings(Collection<Long> ids) { return links.bookings(ids); }
/** 锁定预订关联。 */
public FleetPoolOrderLink lockBooking(long id) { return links.lockBooking(id); }
/** 按预订业务号锁定关联。 */
public FleetPoolOrderLink lockBookingNo(String number) { return links.lockBookingNo(number); }
/** 查询预订是否属于运力池。 */
public int poolBooking(String number) { return links.poolBooking(number); }
/** 按客户和来货号锁定关联。 */
public FleetPoolOrderLink lockIncoming(String customer, String number) { return links.lockIncoming(customer, number); }
/** 按客户和排车号锁定关联。 */
public List<FleetPoolOrderLink> lockDispatch(String customer, String number) { return links.lockDispatch(customer, number); }
/** 保存唯一商业关联。 */
public void insertLink(FleetPoolOrderLink row) { links.insert(row); }
/** 更新已锁定商业关联。 */
public void updateLink(FleetPoolOrderLink row) { links.updateById(row); }
/** 保存散板商业主单。 */
public void insertLoose(LocalLooseboardOrder order) { loose.insert(order); }
/** 锁定同客户散板主单。 */
public LocalLooseboardOrder lockLoose(String customer, String number) { return loose.lockNo(customer, number); }
/** 保存已锁定散板事实。 */
public void updateLoose(LocalLooseboardOrder order) { loose.updateById(order); }
/** 保存卡车商业主单。 */
public void insertTruck(UsTruckOrder order) { trucks.insert(order); }
/** 锁定同客户卡车主单。 */
public UsTruckOrder lockTruck(String customer, String number) { return trucks.lockNo(customer, number); }
/** 保存已锁定卡车事实。 */
public void updateTruck(UsTruckOrder order) { trucks.updateById(order); }
/** 保存散板履约历史。 */
public void insertLooseHistory(LocalLooseboardHistory history) { looseHistory.insert(history); }
/** 保存卡车履约历史。 */
public void insertTruckHistory(UsTruckOrderStatusHistory history) { truckHistory.insert(history); }
}
@@ -0,0 +1,72 @@
package com.baiyue.lastleg.fleet.pickup;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baiyue.lastleg.transfer.entity.WarehouseTransferOrder;
import org.apache.ibatis.annotations.*;
import java.util.List;
/** 取货持久化只访问 Lastleg 自有库,客户与原业务号始终成对使用。 */
@Mapper
public interface FleetPickupMapper extends BaseMapper<FleetPickupTask> {
String ELIGIBLE = " FROM ll_warehouse_transfer_order w LEFT JOIN ll_fleet_pickup_task p ON p.transfer_order_id=w.id "
+ "WHERE w.delivery_method='WAREHOUSE_PICKUP' AND w.transfer_no=w.inbound_code "
+ "AND w.status='WAIT_PDA_RECEIVING' AND (p.transfer_order_id IS NULL OR p.trip_no IS NULL) "
+ "AND (#{keyword} IS NULL OR w.transfer_no LIKE CONCAT('%',#{keyword},'%') OR w.delivery_address LIKE CONCAT('%',#{keyword},'%')) ";
/** 分页读取已经审核、尚未安排的上门取货需求。 */
@Select("SELECT w.customer_id,w.transfer_no,w.lastleg_order_no,w.operating_warehouse_id AS warehouse_id,"
+ "w.delivery_address AS pickup_address,w.pickup_contact_name AS contact_name,w.pickup_contact_phone AS contact_phone,"
+ "w.package_count,w.estimated_arrival_at AS expected_arrival_at,NULL AS warehouse_code,NULL AS warehouse_name" + ELIGIBLE + "ORDER BY w.created_at,w.id LIMIT #{limit} OFFSET #{offset}")
List<FleetPickupModels.Candidate> candidates(@Param("keyword") String keyword,@Param("limit") int limit,@Param("offset") int offset);
/** 与分页使用同一资格条件,避免浏览器用当前页推总数。 */
@Select("SELECT COUNT(*)" + ELIGIBLE)
long countCandidates(@Param("keyword") String keyword);
/** 锁定源订单,串行化补箱、清点与取货任务关联。 */
@Select("SELECT * FROM ll_warehouse_transfer_order WHERE customer_id=#{customer} AND transfer_no=#{number} FOR UPDATE")
WarehouseTransferOrder lockSource(@Param("customer") String customer,@Param("number") String number);
/** 读取明确客户下的源订单,不接收来自客户端的客户范围推断。 */
@Select("SELECT * FROM ll_warehouse_transfer_order WHERE customer_id=#{customer} AND transfer_no=#{number}")
WarehouseTransferOrder source(@Param("customer") String customer,@Param("number") String number);
/** PDA 作业仍按已绑定海外仓锁定原单。 */
@Select("SELECT * FROM ll_warehouse_transfer_order WHERE operating_warehouse_id=#{warehouse} AND transfer_no=#{number} FOR UPDATE")
WarehouseTransferOrder lockPdaSource(@Param("warehouse") String warehouse,@Param("number") String number);
/** 读取取货任务,原订单锁负责同票并发互斥。 */
@Select("SELECT * FROM ll_fleet_pickup_task WHERE transfer_order_id=#{id}")
FleetPickupTask task(@Param("id") long id);
/** 同票安排在原单锁后使用当前读,不受早先查询快照影响。 */
@Select("SELECT * FROM ll_fleet_pickup_task WHERE transfer_order_id=#{id} FOR UPDATE")
FleetPickupTask lockTask(@Param("id") long id);
/** 一车多票按原订单编号稳定排序。 */
@Select("SELECT * FROM ll_fleet_pickup_task WHERE trip_no=#{tripNo} ORDER BY transfer_order_id")
List<FleetPickupTask> tasks(@Param("tripNo") String tripNo);
/** 解除计划中的取货关联,保留历史后允许再次安排。 */
@Update("UPDATE ll_fleet_pickup_task SET trip_no=NULL,status='WAITING',updated_at=CURRENT_TIMESTAMP WHERE trip_no=#{tripNo} AND status='ASSIGNED'")
int unassign(@Param("tripNo") String tripNo);
/** 不可变操作历史覆盖安排、解除、取货、到仓及结案。 */
@Insert("INSERT INTO ll_fleet_pickup_history(customer_id,transfer_no,trip_no,action,operator_id,occurred_at,detail) VALUES(#{customer},#{number},#{trip},#{action},#{operator},CURRENT_TIMESTAMP,#{detail})")
int history(@Param("customer") String customer,@Param("number") String number,@Param("trip") String trip,
@Param("action") String action,@Param("operator") String operator,@Param("detail") String detail);
/** 关联取货后禁止再把同一票登记为另一条拼车来货。 */
@Select("SELECT COUNT(*) FROM ll_fleet_pool_order_link p JOIN ll_fleet_external_booking b ON b.id=p.booking_id WHERE p.customer_id=#{customer} AND p.incoming_no=#{number} AND b.status!='CANCELLED'")
long poolLinks(@Param("customer") String customer,@Param("number") String number);
/** 创建请求保存完整来源摘要,原单后续变化不影响未知结果重试。 */
@Insert("INSERT INTO ll_fleet_pickup_plan(trip_no,request_hash) VALUES(#{trip},#{hash})")
int insertPlan(@Param("trip") String trip,@Param("hash") String hash);
/** 查询已提交的取货计划请求。 */
@Select("SELECT request_hash FROM ll_fleet_pickup_plan WHERE trip_no=#{trip}")
String planHash(@Param("trip") String trip);
/** 车次行锁内核对同键同内容。 */
@Select("SELECT request_hash FROM ll_fleet_pickup_command WHERE trip_no=#{trip} AND request_key=#{key}")
String commandHash(@Param("trip") String trip,@Param("key") String key);
/** 命令结果与取货业务变更同事务提交。 */
@Insert("INSERT INTO ll_fleet_pickup_command(trip_no,request_key,request_hash) VALUES(#{trip},#{key},#{hash})")
int command(@Param("trip") String trip,@Param("key") String key,@Param("hash") String hash);
/** 上传阶段保存精确单据归属,避免同车不同客户的凭证串票。 */
@Insert("INSERT INTO ll_fleet_pickup_upload(transfer_order_id,trip_no,file_id) VALUES(#{source},#{trip},#{file})")
int upload(@Param("source") long source,@Param("trip") String trip,@Param("file") long file);
/** 只能引用本票本车次已上传的文件。 */
@Select("SELECT COUNT(*) FROM ll_fleet_pickup_upload WHERE transfer_order_id=#{source} AND trip_no=#{trip} AND file_id=#{file}")
int ownsUpload(@Param("source") long source,@Param("trip") String trip,@Param("file") long file);
/** 发车时核对车辆司机仍被本车次实际占用。 */
@Select("SELECT COUNT(*) FROM ll_fleet_resource_assignment WHERE trip_id=#{trip} AND ((resource_kind='VEHICLE' AND resource_id=#{vehicle}) OR (resource_kind='DRIVER' AND resource_id=#{driver}))")
int resources(@Param("trip") long trip,@Param("vehicle") long vehicle,@Param("driver") long driver);
}
@@ -0,0 +1,40 @@
package com.baiyue.lastleg.fleet.pickup;
import jakarta.validation.Valid;
import jakarta.validation.constraints.*;
import java.time.Instant;
import java.time.LocalDateTime;
import java.util.List;
/** 入仓取货的请求与投影,不把取货箱数当成仓库实收。 */
public final class FleetPickupModels {
/** 工具类不允许实例化。 */
private FleetPickupModels() { }
public record Source(@NotBlank String customerId, @NotBlank String transferNo) { }
public record Query(@Min(1) Integer page, @Min(1) @Max(100) Integer size, @Size(max=128) String keyword) {
/** 缺省分页只读取当前工作列表。 */
public Query { page = page == null ? 1 : page; size = size == null ? 20 : size; }
}
public record Candidate(String customerId, String transferNo, String lastlegOrderNo, String warehouseId,
String pickupAddress, String contactName, String contactPhone, int packageCount,
LocalDateTime expectedArrivalAt, String warehouseCode, String warehouseName) { }
public record Create(@NotNull @Size(min=1,max=100) List<@Valid Source> sources,
@NotNull Instant plannedDepartureAt, @NotNull Instant expectedDriverEndAt,
@Size(max=1000) String remark,
@NotBlank @Size(min=8,max=128) String idempotencyKey) { }
public record Command(@NotNull @Min(0) Long recordVersion,
@NotBlank @Size(min=8,max=128) String idempotencyKey,
@NotBlank @Size(max=1000) String reason) { }
public record Pickup(@NotNull @Min(0) Long recordVersion,
@NotBlank @Size(min=8,max=128) String idempotencyKey,
@Min(1) @Max(5000) int actualPackages,
@NotNull @Size(min=1,max=10) List<@NotNull @Positive Long> evidenceFileIds,
@Size(max=1000) String remark) { }
public record Task(String customerId, String transferNo, String lastlegOrderNo, String warehouseId,
String pickupAddress, String contactName, String contactPhone, int packageCount,
String status, String tripNo, String vehiclePlate, String driverName, String driverPhone,
Instant plannedDepartureAt, Integer actualPackages, Instant pickedAt, Instant arrivedAt,
String remark, List<Evidence> evidence) { }
public record Evidence(String fileId, String fileName, String contentType, long sizeBytes) { }
public record History(String action, String tripNo, String operatorId, Instant occurredAt, String detail) { }
}
@@ -0,0 +1,58 @@
package com.baiyue.lastleg.fleet.pickup;
import com.baiyue.lastleg.fleet.trip.FleetTripEntity;
import com.baiyue.lastleg.fleet.trip.FleetTripMapper;
import com.baiyue.lastleg.transfer.entity.WarehouseTransferOrder;
import org.springframework.stereotype.Repository;
import java.util.List;
/** 封装取货单与同库车次的持久化,不承载状态流转。 */
@Repository
public class FleetPickupRepository {
private final FleetPickupMapper mapper;
private final FleetTripMapper trips;
/** 注入所属域的 Mapper。 */
public FleetPickupRepository(FleetPickupMapper mapper,FleetTripMapper trips) { this.mapper=mapper;this.trips=trips; }
/** 查询当前分页候选。 */
public List<FleetPickupModels.Candidate> candidates(String keyword,int limit,int offset) { return mapper.candidates(keyword,limit,offset); }
/** 查询匹配需求总数。 */
public long count(String keyword) { return mapper.countCandidates(keyword); }
/** 读取或锁定客户原单。 */
public WarehouseTransferOrder source(String customer,String number,boolean lock) { return lock?mapper.lockSource(customer,number):mapper.source(customer,number); }
/** 按 PDA 仓库范围锁定原单。 */
public WarehouseTransferOrder pdaSource(String warehouse,String number) { return mapper.lockPdaSource(warehouse,number); }
/** 读取原单唯一取货记录。 */
public FleetPickupTask task(long id) { return mapper.task(id); }
/** 读取车次全部取货成员。 */
public List<FleetPickupTask> tasks(String number) { return mapper.tasks(number); }
/** 写入首次安排。 */
public int insert(FleetPickupTask task) { return mapper.insert(task); }
/** 保存已有取货执行记录。 */
public int update(FleetPickupTask task) { return mapper.updateById(task); }
/** 读取或锁定共享车次。 */
public FleetTripEntity trip(String number,boolean lock) { return lock?trips.lockByNo(number):trips.findByNo(number); }
/** 按原幂等键查询已创建车次。 */
public FleetTripEntity created(String operator,String key) { return trips.findCreated(operator,key); }
/** 创建资格必须以锁定后的当前记录为准。 */
public FleetPickupTask lockTask(long id) { return mapper.lockTask(id); }
/** 释放未开始的成员关联。 */
public int unassign(String number) { return mapper.unassign(number); }
/** 保存不可变业务操作记录。 */
public void history(FleetPickupTask task,String action,String operator,String detail) { mapper.history(task.getCustomerId(),task.getTransferNo(),task.getTripNo(),action,operator,detail); }
/** 核对旧拼车承运关系,防止同票走两条运输链。 */
public boolean hasPoolLink(String customer,String number) { return mapper.poolLinks(customer,number)>0; }
/** 写入取货计划来源摘要。 */
public void insertPlan(String trip,String hash) { mapper.insertPlan(trip,hash); }
/** 读取计划原请求摘要。 */
public String planHash(String trip) { return mapper.planHash(trip); }
/** 读取命令的幂等摘要。 */
public String commandHash(String trip,String key) { return mapper.commandHash(trip,key); }
/** 保存已成功执行的命令。 */
public void command(String trip,String key,String hash) { mapper.command(trip,key,hash); }
/** 保存本票的上传归属。 */
public void upload(long source,String trip,long file) { mapper.upload(source,trip,file); }
/** 核对凭证是否属于同票同车次。 */
public boolean ownsUpload(long source,String trip,long file) { return mapper.ownsUpload(source,trip,file)==1; }
/** 两种资源须同时属于当前车次。 */
public boolean ownsResources(long trip,long vehicle,long driver) { return mapper.resources(trip,vehicle,driver)==2; }
}
@@ -0,0 +1,269 @@
package com.baiyue.lastleg.fleet.pickup;
import com.baiyue.common.core.api.PageResult;
import com.baiyue.lastleg.fleet.driver.DriverService;
import com.baiyue.lastleg.fleet.driver.FleetWarehouseDirectory;
import com.baiyue.lastleg.fleet.reporting.TripOperationsEvidenceGateway;
import com.baiyue.lastleg.fleet.trip.*;
import com.baiyue.lastleg.masterdata.vehicle.application.port.out.VehicleRepository;
import com.baiyue.lastleg.transfer.entity.WarehouseTransferOrder;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.multipart.MultipartFile;
import org.springframework.web.server.ResponseStatusException;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.time.*;
import java.util.*;
/** 入仓取货只记录运输事实,不创建第二份订单、不写库存和测量完成状态。 */
@Service
public class FleetPickupService {
private final FleetPickupRepository repository;
private final FleetTripService trips;
private final FleetWarehouseDirectory warehouses;
private final DriverService drivers;
private final VehicleRepository vehicles;
private final TripOperationsEvidenceGateway files;
private final ObjectMapper json;
/** 共用现有车次、司机资格、车辆占用及鉴权文件能力。 */
public FleetPickupService(FleetPickupRepository repository,FleetTripService trips,FleetWarehouseDirectory warehouses,
DriverService drivers,VehicleRepository vehicles,TripOperationsEvidenceGateway files,ObjectMapper json) {
this.repository=repository;this.trips=trips;this.warehouses=warehouses;this.drivers=drivers;
this.vehicles=vehicles;this.files=files;this.json=json;
}
/** 审核通过的原单自动出现在候选中,客户自送和库存转运不参与。 */
@Transactional(readOnly=true,isolation=org.springframework.transaction.annotation.Isolation.REPEATABLE_READ)
public PageResult<FleetPickupModels.Candidate> candidates(FleetPickupModels.Query query) {
String keyword=blank(query.keyword());long count=repository.count(keyword);
var rows=repository.candidates(keyword,query.size(),Math.multiplyExact(query.page()-1,query.size()));
var directory=warehouses.options("OVERSEAS").stream().collect(java.util.stream.Collectors.toMap(com.baiyue.lastleg.fleet.driver.DriverModels.WarehouseOption::id,v->v));
var enriched=rows.stream().map(row->{var warehouse=directory.get(row.warehouseId());
return new FleetPickupModels.Candidate(row.customerId(),row.transferNo(),row.lastlegOrderNo(),row.warehouseId(),row.pickupAddress(),row.contactName(),row.contactPhone(),row.packageCount(),row.expectedArrivalAt(),warehouse==null?null:warehouse.code(),warehouse==null?"仓库已停用":warehouse.name());}).toList();
return new PageResult<>(enriched,
query.page(),query.size(),count,(count+query.size()-1)/query.size());
}
/** 锁定源订单创建同仓多点取货车次,任一来源失败时整车回滚。 */
@Transactional(isolation=org.springframework.transaction.annotation.Isolation.READ_COMMITTED)
public FleetTripModels.Detail create(String operator,FleetPickupModels.Create request) {
String requestHash=hash(write(request));
var prior=repository.created(operator,request.idempotencyKey());
if(prior!=null) {
if(!"PICKUP".equals(prior.getTripPurpose()) || !requestHash.equals(repository.planHash(prior.getTripNo())))
throw conflict("同一安排请求不能更换来源或时间");
return trips.detail(prior.getTripNo());
}
var sources=request.sources().stream().sorted(Comparator.comparing(FleetPickupModels.Source::customerId)
.thenComparing(FleetPickupModels.Source::transferNo)).toList();
if(new HashSet<>(sources).size()!=sources.size())throw bad("同一取货单不能重复选择");
var orders=new ArrayList<WarehouseTransferOrder>();
for(var source:sources) {
var order=source(source.customerId(),source.transferNo(),true);eligible(order);
// 相同请求并发等待原单锁后,允许返回第一次已经成功的车次。
var committed=repository.created(operator,request.idempotencyKey());
if(committed!=null) {
if(!"PICKUP".equals(committed.getTripPurpose()) || !requestHash.equals(repository.planHash(committed.getTripNo())))throw conflict("同一安排请求不能更换来源或时间");
return trips.detail(committed.getTripNo());
}
var task=repository.lockTask(order.getId());
if(task!=null && task.getTripNo()!=null)throw conflict("转运单已安排车次:"+task.getTripNo());
if(repository.hasPoolLink(source.customerId(),source.transferNo()))throw conflict("该单已有拼车承运安排,不能重复安排取货");
orders.add(order);
}
String warehouseId=orders.get(0).getOperatingWarehouseId();
if(orders.stream().anyMatch(order->!warehouseId.equals(order.getOperatingWarehouseId())))throw bad("同车取货必须送往同一海外仓");
var warehouse=warehouses.required(Long.valueOf(warehouseId),"OVERSEAS");
// 站点按原订单稳定生成,最后一站固定为客户选择的收货仓。
var stops=new ArrayList<FleetTripModels.Stop>();
for(var order:orders)stops.add(new FleetTripModels.Stop(order.getTransferNo(),order.getDeliveryAddress(),null));
stops.add(new FleetTripModels.Stop(warehouse.name(),null,"PICKUP_WAREHOUSE:"+warehouseId));
var created=trips.createPickup(operator,new FleetTripModels.Create(Long.valueOf(warehouseId),request.plannedDepartureAt(),
request.expectedDriverEndAt(),stops,request.remark(),request.idempotencyKey()));
var trip=repository.trip(created.trip().tripNo(),true);
repository.insertPlan(trip.getTripNo(),requestHash);
for(var order:orders) {
var task=repository.task(order.getId());boolean fresh=task==null;
if(fresh) { task=new FleetPickupTask();task.setTransferOrderId(order.getId());task.setCustomerId(order.getCustomerId());task.setTransferNo(order.getTransferNo()); }
task.setTripNo(trip.getTripNo());task.setStatus("ASSIGNED");task.setUpdatedAt(now());
if(fresh)repository.insert(task);else repository.update(task);
repository.history(task,"ASSIGN",operator,"安排仓储转运上门取货");
}
trips.recordExecution(trip,trip.getRecordVersion(),"PICKUP_PLAN","关联原转运单取货",operator);
return trips.detail(trip.getTripNo());
}
/** 按车次读取取货成员,只有专用用途的车次能进入此链路。 */
@Transactional(readOnly=true,isolation=org.springframework.transaction.annotation.Isolation.REPEATABLE_READ)
public List<FleetPickupModels.Task> tasks(String tripNo) {
pickupTrip(tripNo,false);
return repository.tasks(tripNo).stream().map(task->project(source(task.getCustomerId(),task.getTransferNo(),false),task)).toList();
}
/** 客户和平台详情读取同一取货进度,但只返回当前票的凭证。 */
@Transactional(readOnly=true)
public FleetPickupModels.Task progress(String customer,String number) {
var source=source(customer,number,false);
if(!"WAREHOUSE_PICKUP".equals(source.getDeliveryMethod()) || !source.getTransferNo().equals(source.getInboundCode()))return null;
return project(source,repository.task(source.getId()));
}
/** PDA 开始作业前校验已送仓,防止把司机取货冒充仓库实收。 */
public WarehouseTransferOrder requireMeasuringSource(String warehouse,String number) {
var source=repository.pdaSource(warehouse,number);
if(source==null)throw missing("仓储转运单不存在");
if(!"WAIT_PDA_RECEIVING".equals(source.getStatus()))throw conflict("当前转运单不允许测量");
requireArrived(source);
return source;
}
/** 已有原单行锁的清点、照片和完成入口复用相同到仓门禁。 */
public void requireArrived(WarehouseTransferOrder source) {
if("WAREHOUSE_PICKUP".equals(source.getDeliveryMethod()) && source.getTransferNo().equals(source.getInboundCode())) {
var task=repository.task(source.getId());
if(task==null || !"ARRIVED".equals(task.getStatus()))throw conflict("该单为仓库上门取货,请先完成车辆取货送仓");
}
}
/** 分配后复验车辆和司机,发车不触发任何出库扣库存。 */
@Transactional
public FleetTripModels.Detail depart(String number,String operator,FleetPickupModels.Command request) {
var trip=pickupTrip(number,true);String hash=commandHash("DEPART",request);
if(replayed(trip,request.idempotencyKey(),hash))return trips.detail(number);
version(trip,request.recordVersion());
if(!"PLANNED".equals(trip.getStatus()) || repository.tasks(number).isEmpty())throw conflict("仅有取货任务的计划车次可以发车");
requireResources(trip);
for(var task:repository.tasks(number))eligible(source(task.getCustomerId(),task.getTransferNo(),true));
trip.setStatus("IN_TRANSIT");trip.setDepartedAt(now());
trips.recordExecution(trip,request.recordVersion(),"PICKUP_DEPART",request.reason(),operator);
repository.command(number,request.idempotencyKey(),hash);return trips.detail(number);
}
/** 取货凭证上传只绑定当前票所属车次,上传本身不推进取货状态。 */
@Transactional
public FleetPickupModels.Evidence upload(String number,String customer,String transferNo,String operator,MultipartFile file) {
var trip=pickupTrip(number,true);var task=member(trip,customer,transferNo);
if(!Set.of("PLANNED","IN_TRANSIT").contains(trip.getStatus()))throw conflict("当前车次不能上传取货凭证");
if(file==null || file.isEmpty() || file.getSize()>10L*1024*1024)throw bad("请上传不超过10MB的取货凭证");
var value=files.upload(trip.getId(),operator,file);
repository.upload(task.getTransferOrderId(),number,Long.parseLong(value.fileId()));
return new FleetPickupModels.Evidence(String.valueOf(value.fileId()),value.fileName(),value.contentType(),value.sizeBytes());
}
/** 逐票确认实提箱数与凭证,数量可以与预报不同,留给仓库独立实收。 */
@Transactional
public List<FleetPickupModels.Task> picked(String number,String customer,String transferNo,String operator,FleetPickupModels.Pickup request) {
var trip=pickupTrip(number,true);String hash=commandHash("PICKED:"+customer+":"+transferNo,request);
if(replayed(trip,request.idempotencyKey(),hash))return tasks(number);
version(trip,request.recordVersion());var task=member(trip,customer,transferNo);
if(!"IN_TRANSIT".equals(trip.getStatus()) || !"ASSIGNED".equals(task.getStatus()))throw conflict("请先发车,且该单尚未确认取货");
eligible(source(customer,transferNo,true));
var evidence=request.evidenceFileIds().stream().distinct().map(id->{
if(!repository.ownsUpload(task.getTransferOrderId(),number,id))throw bad("取货凭证不属于当前订单或车次");
var file=files.metadata(trip.getId(),id);
return new FleetPickupModels.Evidence(String.valueOf(file.fileId()),file.fileName(),file.contentType(),file.sizeBytes());
}).toList();
task.setStatus("PICKED");task.setActualPackages(request.actualPackages());task.setPickedAt(now());
task.setEvidenceJson(write(evidence));task.setRemark(blank(request.remark()));task.setUpdatedAt(now());repository.update(task);
repository.history(task,"PICKED",operator,"实提"+request.actualPackages()+"箱;"+Objects.toString(task.getRemark(),""));
trips.recordExecution(trip,request.recordVersion(),"PICKUP_PICKED","确认原单取货",operator);
repository.command(number,request.idempotencyKey(),hash);return tasks(number);
}
/** 到仓确认只开放 PDA 清点,不覆盖预报箱数和 SKU 件数。 */
@Transactional
public List<FleetPickupModels.Task> arrive(String number,String customer,String transferNo,String operator,FleetPickupModels.Command request) {
var trip=pickupTrip(number,true);String hash=commandHash("ARRIVED:"+customer+":"+transferNo,request);
if(replayed(trip,request.idempotencyKey(),hash))return tasks(number);
version(trip,request.recordVersion());var task=member(trip,customer,transferNo);
if(!"IN_TRANSIT".equals(trip.getStatus()) || !"PICKED".equals(task.getStatus()))throw conflict("请先确认该单已取货");
source(customer,transferNo,true);task.setStatus("ARRIVED");task.setArrivedAt(now());task.setUpdatedAt(now());repository.update(task);
repository.history(task,"ARRIVED",operator,request.reason());
trips.recordExecution(trip,request.recordVersion(),"PICKUP_ARRIVED",request.reason(),operator);
repository.command(number,request.idempotencyKey(),hash);return tasks(number);
}
/** 全部送仓后结案运输,车辆和司机仍沿原有入口分别结束任务。 */
@Transactional
public FleetTripModels.Detail complete(String number,String operator,FleetPickupModels.Command request) {
var trip=pickupTrip(number,true);String hash=commandHash("COMPLETE",request);
if(replayed(trip,request.idempotencyKey(),hash))return trips.detail(number);
version(trip,request.recordVersion());var tasks=repository.tasks(number);
if(!"IN_TRANSIT".equals(trip.getStatus()) || tasks.isEmpty() || tasks.stream().anyMatch(task->!"ARRIVED".equals(task.getStatus())))
throw conflict("请先确认本车全部取货订单已送达海外仓");
var completedAt=tasks.stream().map(FleetPickupTask::getArrivedAt).max(Comparator.naturalOrder()).orElseThrow();
trip.setStatus("COMPLETED");trip.setCompletedAt(completedAt);
// 这里保存运输结束凭据;不虚构派送签收单,也不等待仓库测量完成。
trip.setCompletionJson(write(new FleetCompletionModels.Completed(request.idempotencyKey(),hash,completedAt.toInstant(ZoneOffset.UTC),Instant.now(),operator,List.of())));
trips.recordExecution(trip,request.recordVersion(),"PICKUP_COMPLETE",request.reason(),operator);
repository.command(number,request.idempotencyKey(),hash);return trips.detail(number);
}
/** 读取已确认凭证时必须核对具体客户、订单和文件引用,不因共车暴露其他客户照片。 */
public byte[] evidence(String customer,String transferNo,long fileId) {
var order=source(customer,transferNo,false);var task=repository.task(order.getId());
if(task==null || evidence(task).stream().noneMatch(file->file.fileId().equals(String.valueOf(fileId))))throw missing("当前订单没有该取货凭证");
var trip=pickupTrip(task.getTripNo(),false);return files.download(trip.getId(),fileId);
}
/** 组装运输进度,车次资源修改后自然回显最新分配。 */
private FleetPickupModels.Task project(WarehouseTransferOrder source,FleetPickupTask task) {
var trip=task==null || task.getTripNo()==null?null:repository.trip(task.getTripNo(),false);
String status=task==null || task.getTripNo()==null?("PENDING_REVIEW".equals(source.getStatus()) || "DRAFT".equals(source.getStatus())?"PENDING_REVIEW":"WAITING"):task.getStatus();
return new FleetPickupModels.Task(source.getCustomerId(),source.getTransferNo(),source.getLastlegOrderNo(),source.getOperatingWarehouseId(),
source.getDeliveryAddress(),source.getPickupContactName(),source.getPickupContactPhone(),source.getPackageCount(),status,
trip==null?null:trip.getTripNo(),trip==null?null:trip.getVehiclePlate(),trip==null?null:trip.getDriverName(),trip==null?null:trip.getDriverPhone(),
trip==null?null:instant(trip.getPlannedDepartureAt()),task==null?null:task.getActualPackages(),task==null?null:instant(task.getPickedAt()),
task==null?null:instant(task.getArrivedAt()),task==null?null:task.getRemark(),task==null?List.of():evidence(task));
}
/** 解析任务已确认文件清单。 */
private List<FleetPickupModels.Evidence> evidence(FleetPickupTask task) { return task.getEvidenceJson()==null?List.of():read(task.getEvidenceJson(),new TypeReference<>(){}); }
/** 当前车次成员由服务端复核,客户 ID 不能单独授权操作。 */
private FleetPickupTask member(FleetTripEntity trip,String customer,String number) {
var source=source(customer,number,false);var task=repository.task(source.getId());
if(task==null || !trip.getTripNo().equals(task.getTripNo()))throw missing("当前车次没有该取货订单");return task;
}
/** 按客户与单号读取原订单。 */
private WarehouseTransferOrder source(String customer,String number,boolean lock) {
var source=repository.source(customer,number,lock);if(source==null)throw missing("仓储转运单不存在");return source;
}
/** 原业务资格每次执行复核,禁止未审、自送、库存转运和已完成测量单进入。 */
private static void eligible(WarehouseTransferOrder order) {
if(!"WAREHOUSE_PICKUP".equals(order.getDeliveryMethod()) || !order.getTransferNo().equals(order.getInboundCode())
|| !"WAIT_PDA_RECEIVING".equals(order.getStatus()))throw conflict("该单不是已审核待收货的仓库上门取货单");
if(blank(order.getDeliveryAddress())==null || blank(order.getPickupContactName())==null || blank(order.getPickupContactPhone())==null)
throw bad("请补齐原转运单的取货地址、联系人和电话");
}
/** 取货用途与派送用途不允许互相调用执行命令。 */
private FleetTripEntity pickupTrip(String number,boolean lock) {
var trip=repository.trip(number,lock);if(trip==null)throw missing("车次不存在");
if(!"PICKUP".equals(trip.getTripPurpose()))throw conflict("当前车次不是入仓取货车次");return trip;
}
/** 发车复验资源资格,避免分配后车辆停用或证照过期。 */
private void requireResources(FleetTripEntity trip) {
if(trip.getVehicleId()==null || trip.getDriverId()==null)throw conflict("请先分配车辆和司机");
if(!repository.ownsResources(trip.getId(),trip.getVehicleId(),trip.getDriverId()))throw conflict("车辆或司机占用与当前车次不一致,请重新核对分配");
var vehicle=vehicles.lockById(trip.getVehicleId()).orElseThrow(()->conflict("车辆不存在"));
if(!"OVERSEAS".equals(vehicle.region()) || !"IN_SERVICE".equals(vehicle.status()))throw conflict("当前车辆不可出车");
var driver=drivers.requireDispatchable(trip.getDriverId(),instant(trip.getExpectedDriverEndAt()));
if(!"OVERSEAS".equals(driver.details().region()))throw conflict("请选择海外司机");
}
/** 已成功命令同键同内容重放,禁止同键更换动作或订单。 */
private boolean replayed(FleetTripEntity trip,String key,String hash) {
String previous=repository.commandHash(trip.getTripNo(),key);
if(previous!=null && !previous.equals(hash))throw conflict("同一请求标识不能提交不同的取货内容");return previous!=null;
}
/** 当前版本防止调度和现场操作相互覆盖。 */
private static void version(FleetTripEntity trip,long version) { if(trip.getRecordVersion()!=version)throw conflict("车次已变化,请刷新后重试"); }
/** 动作与请求一起摘要,隔离共用请求键。 */
private String commandHash(String action,Object request) { return hash(action+":"+write(request)); }
/** 固定序列化持久化命令和凭证。 */
private String write(Object value) { try{return json.writeValueAsString(value);}catch(Exception error){throw new IllegalStateException("取货数据序列化失败",error);} }
/** 解析服务器保存的凭证数据。 */
private <T>T read(String value,TypeReference<T> type) { try{return json.readValue(value,type);}catch(Exception error){throw new IllegalStateException("取货凭证读取失败",error);} }
/** 创建不可逆请求摘要,不在日志记录完整请求。 */
private static String hash(String value) { try{return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(value.getBytes(StandardCharsets.UTF_8)));}catch(Exception error){throw new IllegalStateException(error);} }
/** 统一以 UTC 保存车次履约时刻。 */
private static LocalDateTime now() { return LocalDateTime.now(ZoneOffset.UTC).withNano(0); }
/** 转换可空的数据库时刻。 */
private static Instant instant(LocalDateTime value) { return value==null?null:value.toInstant(ZoneOffset.UTC); }
/** 空白输入统一为空。 */
private static String blank(String value) { return value==null || value.isBlank()?null:value.trim(); }
/** 返回明确的业务冲突。 */
private static ResponseStatusException conflict(String message) { return new ResponseStatusException(HttpStatus.CONFLICT,message); }
/** 返回参数错误。 */
private static ResponseStatusException bad(String message) { return new ResponseStatusException(HttpStatus.BAD_REQUEST,message); }
/** 隐藏不属于当前业务范围的资源。 */
private static ResponseStatusException missing(String message) { return new ResponseStatusException(HttpStatus.NOT_FOUND,message); }
}
@@ -0,0 +1,22 @@
package com.baiyue.lastleg.fleet.pickup;
import com.baomidou.mybatisplus.annotation.*;
import lombok.Data;
import java.time.LocalDateTime;
/** 每票转运单唯一一条取货执行记录,改派历史另行保存。 */
@Data
@TableName("ll_fleet_pickup_task")
public class FleetPickupTask {
@TableId(type=IdType.INPUT) private Long transferOrderId;
private String customerId;
private String transferNo;
@TableField(updateStrategy=FieldStrategy.ALWAYS) private String tripNo;
private String status;
private Integer actualPackages;
private LocalDateTime pickedAt;
private LocalDateTime arrivedAt;
private String evidenceJson;
private String remark;
private LocalDateTime updatedAt;
}
@@ -0,0 +1,81 @@
package com.baiyue.lastleg.fleet.pickup;
import com.baiyue.common.core.api.*;
import com.baiyue.common.security.AuthenticatedUser;
import com.baiyue.lastleg.fleet.trip.FleetTripModels;
import jakarta.validation.Valid;
import org.springframework.http.*;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile;
import org.springframework.web.server.ResponseStatusException;
import java.util.List;
/** 取货调度复用车次既有独立权限,所有入口必须为平台主体。 */
@Validated
@RestController
@RequestMapping("/api/platform/v1/last-leg/fleet/pickups")
public class PlatformFleetPickupController {
private final FleetPickupService service;
/** 注入取货业务编排。 */
public PlatformFleetPickupController(FleetPickupService service) { this.service=service; }
/** 查询自动汇入的待取货需求。 */
@GetMapping
@PreAuthorize("hasAuthority('platform:fleet:trip:list')")
public ApiResponse<PageResult<FleetPickupModels.Candidate>> candidates(@AuthenticationPrincipal AuthenticatedUser user,@Valid FleetPickupModels.Query query) {
operator(user);return ApiResponse.success(service.candidates(query));
}
/** 创建一仓多点取货车次,不重复创建尾程单。 */
@PostMapping
@PreAuthorize("hasAuthority('platform:fleet:trip:create')")
public ApiResponse<FleetTripModels.Detail> create(@AuthenticationPrincipal AuthenticatedUser user,@Valid @RequestBody FleetPickupModels.Create request) {
return ApiResponse.success(service.create(operator(user),request));
}
/** 查看车次取货成员。 */
@GetMapping("/{tripNo}/tasks")
@PreAuthorize("hasAuthority('platform:fleet:trip:list')")
public ApiResponse<List<FleetPickupModels.Task>> tasks(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String tripNo) {
operator(user);return ApiResponse.success(service.tasks(tripNo));
}
/** 取货车次发车不进入仓内出库链。 */
@PostMapping("/{tripNo}/depart")
@PreAuthorize("hasAuthority('platform:fleet:trip:assign')")
public ApiResponse<FleetTripModels.Detail> depart(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String tripNo,@Valid @RequestBody FleetPickupModels.Command request) {
return ApiResponse.success(service.depart(tripNo,operator(user),request));
}
/** 记录原订单的取货凭证上传归属。 */
@PostMapping(value="/{tripNo}/tasks/{customerId}/{transferNo}/files",consumes=MediaType.MULTIPART_FORM_DATA_VALUE)
@PreAuthorize("hasAuthority('platform:fleet:trip:edit')")
public ApiResponse<FleetPickupModels.Evidence> upload(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String tripNo,
@PathVariable String customerId,@PathVariable String transferNo,@RequestPart MultipartFile file) {
return ApiResponse.success(service.upload(tripNo,customerId,transferNo,operator(user),file));
}
/** 逐票确认实提,不覆盖客户预报。 */
@PostMapping("/{tripNo}/tasks/{customerId}/{transferNo}/picked")
@PreAuthorize("hasAuthority('platform:fleet:trip:edit')")
public ApiResponse<List<FleetPickupModels.Task>> picked(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String tripNo,
@PathVariable String customerId,@PathVariable String transferNo,@Valid @RequestBody FleetPickupModels.Pickup request) {
return ApiResponse.success(service.picked(tripNo,customerId,transferNo,operator(user),request));
}
/** 确认送仓后开放 PDA 清点。 */
@PostMapping("/{tripNo}/tasks/{customerId}/{transferNo}/arrive")
@PreAuthorize("hasAuthority('platform:fleet:trip:edit')")
public ApiResponse<List<FleetPickupModels.Task>> arrive(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String tripNo,
@PathVariable String customerId,@PathVariable String transferNo,@Valid @RequestBody FleetPickupModels.Command request) {
return ApiResponse.success(service.arrive(tripNo,customerId,transferNo,operator(user),request));
}
/** 全车送仓后仅结案本次取货运输。 */
@PostMapping("/{tripNo}/complete")
@PreAuthorize("hasAuthority('platform:fleet:trip:complete')")
public ApiResponse<FleetTripModels.Detail> complete(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String tripNo,@Valid @RequestBody FleetPickupModels.Command request) {
return ApiResponse.success(service.complete(tripNo,operator(user),request));
}
/** 平台调度入口不接受客户会话借同名权限越权。 */
private static String operator(AuthenticatedUser user) {
if(user==null || user.userId()==null)throw new ResponseStatusException(HttpStatus.UNAUTHORIZED,"请重新登录");
if(!"PLATFORM".equals(user.appCode()) || !Long.valueOf(0).equals(user.tenantId()))throw new ResponseStatusException(HttpStatus.FORBIDDEN,"仅平台可以安排取货");
return user.userId().toString();
}
}
@@ -0,0 +1,55 @@
package com.baiyue.lastleg.fleet.pickup;
import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.common.security.AuthenticatedUser;
import com.baiyue.lastleg.security.OverseasPermissions;
import org.springframework.http.*;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.server.ResponseStatusException;
/** 原转运单详情读取运输投影,客户 ID 始终从认证上下文取得。 */
@RestController
public class TransferPickupProgressController {
private final FleetPickupService service;
/** 注入只读进度能力。 */
public TransferPickupProgressController(FleetPickupService service) { this.service=service; }
/** 客户只能读取本企业订单的取货进度。 */
@GetMapping("/api/merchant/v1/last-leg/warehouse-transfers/{transferNo}/pickup-progress")
@PreAuthorize("hasAuthority('"+OverseasPermissions.MERCHANT_TRANSFER_LIST+"')")
public ApiResponse<FleetPickupModels.Task> merchant(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String transferNo) {
return ApiResponse.success(service.progress(customer(user),transferNo));
}
/** 平台详情以明确客户和业务号读取,不暴露整车其他货物。 */
@GetMapping("/api/platform/v1/last-leg/warehouse-transfers/{transferNo}/pickup-progress")
@PreAuthorize("hasAuthority('"+OverseasPermissions.LAST_MILE_ORDER_LIST+"')")
public ApiResponse<FleetPickupModels.Task> platform(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String transferNo,@RequestParam String customerId) {
platform(user);return ApiResponse.success(service.progress(customerId,transferNo));
}
/** 客户凭证只允许读取当前票确认过的引用。 */
@GetMapping("/api/merchant/v1/last-leg/warehouse-transfers/{transferNo}/pickup-evidence/{fileId}")
@PreAuthorize("hasAuthority('"+OverseasPermissions.MERCHANT_TRANSFER_LIST+"')")
public ResponseEntity<byte[]> merchantEvidence(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String transferNo,@PathVariable long fileId) {
return download(customer(user),transferNo,fileId);
}
/** 平台调度或尾程详情沿本票授权下载凭证。 */
@GetMapping("/api/platform/v1/last-leg/warehouse-transfers/{transferNo}/pickup-evidence/{fileId}")
@PreAuthorize("hasAnyAuthority('platform:fleet:trip:list','"+OverseasPermissions.LAST_MILE_ORDER_LIST+"')")
public ResponseEntity<byte[]> platformEvidence(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String transferNo,@PathVariable long fileId,@RequestParam String customerId) {
platform(user);return download(customerId,transferNo,fileId);
}
/** 文件不进入浏览器公共缓存,文件名由调用方已授权元数据提供。 */
private ResponseEntity<byte[]> download(String customer,String number,long file) {
return ResponseEntity.ok().cacheControl(CacheControl.noStore()).contentType(MediaType.APPLICATION_OCTET_STREAM)
.header("X-Content-Type-Options","nosniff").body(service.evidence(customer,number,file));
}
/** 认证客户范围禁止从请求参数取得。 */
private static String customer(AuthenticatedUser user) {
if(user==null || user.tenantId()==null || user.tenantId()<=0)throw new ResponseStatusException(HttpStatus.FORBIDDEN,"请使用客户账号");return user.tenantId().toString();
}
/** 平台读取必须验证零作用域和应用身份。 */
private static void platform(AuthenticatedUser user) {
if(user==null || !Long.valueOf(0).equals(user.tenantId()) || !"PLATFORM".equals(user.appCode()))throw new ResponseStatusException(HttpStatus.FORBIDDEN,"仅平台可以查询");
}
}
@@ -0,0 +1,12 @@
package com.baiyue.lastleg.fleet.trip;
/** 原承运登记的真实入仓来源查询入口。 */
public class FleetBookingCargoRepository {
private final FleetBookingMapper bookings;
/** 复用车次事务中的承运登记Mapper。 */
public FleetBookingCargoRepository(FleetBookingMapper bookings){this.bookings=bookings;}
/** 按客户与真实入仓码定位承运,历史取消记录交由业务层分别处理。 */
public FleetBookingEntity forInboundSource(String customerId,String inboundCode){return bookings.forInboundSource(customerId,inboundCode);}
/** 在车次事务内读取全部承运预留,统一计算区段容量。 */
public java.util.List<FleetBookingEntity> listForTrip(long tripId){return bookings.list(tripId);}
}
@@ -26,7 +26,7 @@ public class FleetBookingFreight {
public record Preview(String snapshotHash, boolean requiresDeliveryFee, List<Quote> quotes,
@com.fasterxml.jackson.annotation.JsonIgnore List<String> priorInboundCodes) { }
/** Caller serializes transfer with the dispatch lock. Never lock the parent trip here. */
/** 转派送持有排车锁,按真实入仓组成复核约定运费,不反向申请父车次锁。 */
public Preview preview(long dispatchId, String dispatchNo, String customerId,
List<FleetDispatchGateway.SourceCargo> sources) {
var quotes = new ArrayList<Quote>();
@@ -38,7 +38,7 @@ public class FleetBookingFreight {
else grouped.computeIfAbsent(source.inboundCode(), key -> new ArrayList<>()).add(source);
}
for (var entry : grouped.entrySet()) {
var booking = bookings.incomingForFreight(entry.getKey());
var booking = new FleetBookingCargoRepository(bookings).forInboundSource(customerId, entry.getKey());
if (booking == null) { requiresFee = true; continue; }
if (!customerId.equals(booking.getCustomerId())) throw conflict("来货来源与排车客户不一致");
var accepted = reservations.receipt(booking);
@@ -62,7 +62,7 @@ public class FleetBookingFreight {
}
var confirmed = new TreeMap<String, Integer>();
for (var pallet : accepted.source().pallets()) confirmed.merge(pallet.warehousePalletId(), pallet.packages(), Math::addExact);
if (!actual.equals(confirmed)) throw conflict("外接发运数量与已确认实收不一致");
if (!actual.equals(confirmed)) throw conflict("外接发运托盘或数量与已确认实收不一致;请先解除排车关联,在外接预订重新确认实际收货与运费后再绑定");
var cargo = reservations.cargo(booking);
BigDecimal quantity = "PALLET".equals(cargo.priceBasis()) ? BigDecimal.valueOf(cargo.palletSpaces()) : BigDecimal.ONE;
if (booking.getAgreedAmount() == null || booking.getAgreedAmount().signum() < 0 || cargo.unitPrice() == null
@@ -6,12 +6,13 @@ import java.util.List;
@Mapper
public interface FleetBookingMapper extends BaseMapper<FleetBookingEntity> {
/** 按实际入仓来源定位原承运登记,取消记录保留供费用核对。 */
@Select("SELECT booking.* FROM ll_fleet_external_booking booking JOIN ll_warehouse_transfer_order incoming " +
"ON incoming.customer_id=booking.customer_id AND incoming.transfer_no=booking.incoming_no " +
"WHERE booking.customer_id=#{customerId} AND incoming.inbound_code=#{inboundCode}")
FleetBookingEntity forInboundSource(@Param("customerId") String customerId, @Param("inboundCode") String inboundCode);
@Select("SELECT * FROM ll_fleet_external_booking WHERE booking_no=#{number}")
FleetBookingEntity byNo(@Param("number") String number);
@Select("SELECT * FROM ll_fleet_external_booking WHERE incoming_no=#{incomingNo} AND status <> 'CANCELLED'")
FleetBookingEntity incoming(@Param("incomingNo") String incomingNo);
@Select("SELECT * FROM ll_fleet_external_booking WHERE incoming_no=#{incomingNo}")
FleetBookingEntity incomingForFreight(@Param("incomingNo") String incomingNo);
@Select("SELECT * FROM ll_fleet_external_booking WHERE trip_id=#{tripId} ORDER BY id")
List<FleetBookingEntity> list(@Param("tripId") long tripId);
@Select("SELECT * FROM ll_fleet_external_booking WHERE trip_id=#{tripId} AND created_by=#{operator} AND idempotency_key=#{key}")
@@ -28,7 +28,9 @@ public final class FleetBookingModels {
Instant estimatedArrivalAt,@Size(max=1000) String deliveryInstructions,@Size(max=1000) String privateAddress,
@Size(max=32) String postalCode,@Size(max=128) String recipientName,@Size(max=64) String recipientPhone,
@NotBlank @Size(min=8,max=128) String idempotencyKey) { }
public record PalletSpaces(@NotBlank String warehousePalletId,@Min(1) int palletSpaces) { }
public record PalletSpaces(@NotBlank String warehousePalletId,@Min(1) int palletSpaces,
@DecimalMin("0.001") @Digits(integer=9,fraction=3) BigDecimal weightKg,
@DecimalMin("0.001") @Digits(integer=9,fraction=3) BigDecimal volumeCbm) { }
public record AcceptReceipt(@NotNull @Min(0) Long tripRecordVersion,@NotNull @Min(0) Long recordVersion,
@NotBlank String receiptHash,@NotEmpty List<@Valid PalletSpaces> pallets,
@NotNull @DecimalMin("0.00") @Digits(integer=10,fraction=2) BigDecimal unitPrice,
@@ -28,12 +28,27 @@ public class FleetBookingReservations {
row.getOfferId()==null?null:offers.selectById(row.getOfferId()).getOfferNo(),row.getExpectedPalletCount());
}
public FleetQuoteCandidateModels.SourceSnapshot quoteSource(FleetBookingEntity row){return row.getQuoteSourceJson()==null?null:read(row.getQuoteSourceJson(),FleetQuoteCandidateModels.SourceSnapshot.class);}
/** 使用当前自有货计划与全部未绑定预留计算区段,不重复计入待替换承运。 */
public List<TripCapacityPolicy.Segment> segments(FleetTripEntity trip, FleetExecutionModels.PlannedLoad own,
Long excludedId, FleetBookingEntity addition) {
return segments(trip,own.pallets(),excludedId,addition);
}
/** 已实收承运解除唯一排车后,自有货为零仍可按本票新实测和其他预留重新核容。 */
public void requireReceiptCapacity(FleetTripEntity trip,FleetExecutionModels.Planning own,FleetBookingEntity row) {
boolean releasedReceipt=own.cargo().isEmpty() && "RECEIVED".equals(row.getStatus()) && receipt(row)!=null;
if(!releasedReceipt && (!own.planValid() || own.blockReason()!=null))
throw conflict("请先重新核对自有货容量计划,再确认外接实收");
// 空自有货只用于已有实收的重新确认;不创建计划、不允许首次实收跳过自有货计划。
List<FleetExecutionModels.Measurement> pallets=releasedReceipt?List.of():own.capacityPlan().pallets();
segments(trip,pallets,row.getId(),row);
}
/** 所有承诺使用同一容量计算,空自有货也必须保留其他承运占位及车辆上限。 */
private List<TripCapacityPolicy.Segment> segments(FleetTripEntity trip,List<FleetExecutionModels.Measurement> own,
Long excludedId,FleetBookingEntity addition) {
var stops=stops(trip); var loads=new ArrayList<TripCapacityPolicy.Booking>();
for (var pallet:own.pallets()) loads.add(new TripCapacityPolicy.Booking("PALLET:"+pallet.warehousePalletId(),0,pallet.deliveryIndex(),
for (var pallet:own) loads.add(new TripCapacityPolicy.Booking("PALLET:"+pallet.warehousePalletId(),0,pallet.deliveryIndex(),
new TripCapacityPolicy.Load(pallet.palletSpaces(),pallet.weightKg(),pallet.volumeCbm())));
for(var row:bookings.list(trip.getId())) if(unbound(row) && !Objects.equals(row.getId(),excludedId)) add(loads,stops,row);
for(var row:new FleetBookingCargoRepository(bookings).listForTrip(trip.getId())) if(unbound(row) && !Objects.equals(row.getId(),excludedId)) add(loads,stops,row);
if(addition!=null) add(loads,stops,addition);
try { return TripCapacityPolicy.calculate(stops.size(),new TripCapacityPolicy.Load(trip.getMaxPalletSpaces(),trip.getMaxLoadKg(),trip.getMaxVolumeCbm()),loads); }
catch(IllegalArgumentException|ArithmeticException error){throw conflict(error.getMessage());}
@@ -48,10 +63,11 @@ public class FleetBookingReservations {
}
public FleetBookingModels.AcceptedReceipt receipt(FleetBookingEntity row){return row.getReceiptJson()==null?null:read(row.getReceiptJson(),FleetBookingModels.AcceptedReceipt.class);}
private static boolean unbound(FleetBookingEntity row){return Set.of("CONFIRMED","RECEIVED").contains(row.getStatus());}
/** 按实际入仓来源绑定整票实收,合托不能掩盖外接承诺。 */
public void bindIncoming(FleetTripEntity trip,FleetDispatchGateway.Snapshot dispatch,List<FleetDispatchGateway.SourceCargo> sources,String operator){
var bySource=sources.stream().filter(row->row.inboundCode()!=null).collect(java.util.stream.Collectors.groupingBy(FleetDispatchGateway.SourceCargo::inboundCode));
for(var entry:bySource.entrySet()){
var row=bookings.incoming(entry.getKey());if(row==null)continue;
var row=new FleetBookingCargoRepository(bookings).forInboundSource(dispatch.customerId(),entry.getKey());if(row==null||"CANCELLED".equals(row.getStatus()))continue;
if(!Objects.equals(row.getTripId(),trip.getId()))throw conflict("此外接货已承诺给其他车次,请先调整原预订");
if(!Objects.equals(row.getCustomerId(),dispatch.customerId())||!"RECEIVED".equals(row.getStatus()))throw conflict("外接货须先确认实收货量及运费,再关联排车");
var accepted=receipt(row);if(accepted==null)throw conflict("外接货缺少实收确认");
@@ -62,7 +78,7 @@ public class FleetBookingReservations {
if(item.warehousePalletId()==null||!Objects.equals(item.destinationKey(),row.getDestinationKey()))throw conflict("排车货物托盘或目的地与外接实收不一致");
selected.merge(item.warehousePalletId(),item.packages(),Math::addExact);
}
if(!selected.equals(expected))throw conflict("当前排车未完整包含此外接预订的实收货物");
if(!selected.equals(expected))throw conflict("当前托盘与外接实收不一致;请在外接预订重新确认实际收货与运费后再关联排车");
row.setStatus("BOUND");row.setDispatchNo(dispatch.dispatchNo());touch(row,operator);
if(row.getOfferId()!=null)poolOrders.boundDispatch(row.getId(),row.getCustomerId(),dispatch.dispatchNo(),operator);
}
@@ -143,12 +143,13 @@ public class FleetBookingService {
save(trip,row,operator,"BOOKING_INCOMING","登记外接来货 "+evidence.incomingNo());return result(trip,row,false);
}
@Transactional
/** 首次或解除排车后的重新核对都要求当前实收摘要、版本、完整实托和容量一致。 */
public FleetBookingModels.Result acceptReceipt(String tripNo,String bookingNo,String operator,FleetBookingModels.AcceptReceipt request){
var trip=trip(tripNo,true);String vehicleBlock=execution.currentVehicleBlockReason(trip,true);var row=booking(trip,bookingNo);String hash=hash(request);
if(Objects.equals(row.getReceiptKey(),request.idempotencyKey())){if(!hash.equals(row.getReceiptHash()))throw conflict("实收确认请求内容不一致");return result(trip,row,true);}
if(vehicleBlock!=null)throw conflict(vehicleBlock);
requireTrip(trip,request.tripRecordVersion());requireVersion(row,request.recordVersion());
if(!"CONFIRMED".equals(row.getStatus())||row.getIncomingNo()==null)throw conflict("请先登记来货并完成仓库实际收货");
if(!java.util.Set.of("CONFIRMED","RECEIVED").contains(row.getStatus())||row.getIncomingNo()==null)throw conflict("请先登记来货并完成仓库实际收货;已绑定排车须先解除关联后重新核对");
var evidence=incoming.inspect(row.getCustomerId(),row.getIncomingNo(),true);requireEvidence(trip,row,evidence);
if(!java.util.Set.of("WAIT_CUSTOMER_CONFIRMATION","READY_FOR_DISPATCH").contains(evidence.status())||evidence.pallets().isEmpty())throw conflict("仓库尚未完成实收及测量");
if(!Objects.equals(evidence.receiptHash(),request.receiptHash()))throw conflict("实收数据已变化,请刷新后重新核对");
@@ -156,19 +157,30 @@ public class FleetBookingService {
for(var entry:request.pallets())if(entry.palletSpaces()<1||spaces.put(entry.warehousePalletId(),entry.palletSpaces())!=null)throw conflict("请为每个实际托盘填写一次占用托位");
var physical=evidence.pallets().stream().map(FleetBookingIncomingGateway.Pallet::warehousePalletId).collect(java.util.stream.Collectors.toSet());
if(!spaces.keySet().equals(physical)||physical.contains(null))throw conflict("托位确认须与实际收货托盘一致");
// 旧测量仅适用于同实托同箱数;组成变化后必须明确输入新的该票实测值。
var measured=new java.util.ArrayList<FleetBookingIncomingGateway.Pallet>();
for(var pallet:evidence.pallets()) {
var input=request.pallets().stream().filter(p->Objects.equals(p.warehousePalletId(),pallet.warehousePalletId())).findFirst().orElseThrow();
var kg=pallet.weightKg()==null?input.weightKg():pallet.weightKg();
var cbm=pallet.volumeCbm()==null?input.volumeCbm():pallet.volumeCbm();
if(pallet.weightKg()!=null&&input.weightKg()!=null&&pallet.weightKg().compareTo(input.weightKg())!=0
||pallet.volumeCbm()!=null&&input.volumeCbm()!=null&&pallet.volumeCbm().compareTo(input.volumeCbm())!=0)
throw conflict("未变化托盘须使用仓储转运的原实测值,不能覆盖原收货证据");
measured.add(new FleetBookingIncomingGateway.Pallet(pallet.warehousePalletId(),pallet.palletNo(),pallet.packages(),kg,cbm));
}
BigDecimal weight=BigDecimal.ZERO,volume=BigDecimal.ZERO;int count=0;
for(var pallet:evidence.pallets()){
for(var pallet:measured){
if(pallet.packages()<=0||pallet.weightKg()==null||pallet.volumeCbm()==null||pallet.weightKg().signum()<=0||pallet.volumeCbm().signum()<=0)throw conflict("实际托盘缺少有效箱数、重量或体积");
weight=weight.add(pallet.weightKg());volume=volume.add(pallet.volumeCbm());count=Math.addExact(count,pallet.packages());
}
int totalSpaces=spaces.values().stream().reduce(0,Math::addExact);var old=reservations.cargo(row);
apply(trip,row,new FleetBookingModels.Cargo(old.destinationKey(),old.cargoName(),count,totalSpaces,weight,volume,old.priceBasis(),request.unitPrice(),old.currency(),old.contactName(),old.contactPhone(),old.remark()));
var plan=execution.capacityPlan(tripNo);
if(!plan.planValid()||plan.blockReason()!=null)throw conflict("请先重新核对自有货容量计划,再确认外接实收");
reservations.segments(trip,plan.capacityPlan(),row.getId(),row);
reservations.requireReceiptCapacity(trip,plan,row);
var confirmed=incoming.confirm(row.getCustomerId(),row.getIncomingNo(),operator,"FLEET:"+bookingNo+":RECEIPT");
if(!"READY_FOR_DISPATCH".equals(confirmed.status())||!Objects.equals(confirmed.receiptHash(),evidence.receiptHash()))throw conflict("来货实收确认未完成或内容发生变化,请重新核对");
row.setReceiptJson(write(new FleetBookingModels.AcceptedReceipt(confirmed,java.util.List.copyOf(request.pallets()),operator,java.time.Instant.now())));
var measuredReceipt=new FleetBookingIncomingGateway.Evidence(confirmed.incomingNo(),confirmed.inboundCode(),confirmed.warehouseId(),confirmed.destinationKey(),confirmed.status(),confirmed.receiptHash(),java.util.List.copyOf(measured),confirmed.receivedPackages());
row.setReceiptJson(write(new FleetBookingModels.AcceptedReceipt(measuredReceipt,java.util.List.copyOf(request.pallets()),operator,java.time.Instant.now())));
row.setStatus("RECEIVED");row.setReceiptKey(request.idempotencyKey());row.setReceiptHash(hash);
if(row.getOfferId()!=null)poolOrders.acceptedReceipt(row.getId(),new Receipt(
confirmed.incomingNo(),confirmed.warehouseId(),confirmed.destinationKey(),confirmed.status(),
@@ -272,7 +284,8 @@ public class FleetBookingService {
private FleetBookingModels.Result result(FleetTripEntity trip,FleetBookingEntity row,boolean replayed){return new FleetBookingModels.Result(trip.getRecordVersion(),reservations.response(row),replayed);}
private FleetTripEntity trip(String number,boolean lock){var value=lock?trips.lockByNo(number):trips.findByNo(number);if(value==null)throw new ResponseStatusException(HttpStatus.NOT_FOUND,"车次不存在");return value;}
private FleetBookingEntity booking(FleetTripEntity trip,String number){var row=bookings.lock(trip.getId(),number);if(row==null)throw new ResponseStatusException(HttpStatus.NOT_FOUND,"当前车次无此外接预订");return row;}
private static void requireTrip(FleetTripEntity trip,long version){if(!"PLANNED".equals(trip.getStatus()))throw conflict("仅计划中的车次允许管理外接预订");if(trip.getRecordVersion()!=version)throw conflict("车次已被修改,请刷新");}
/** 外接预订只服务出库派送车次,并校验当前版本。 */
private static void requireTrip(FleetTripEntity trip,long version){if("PICKUP".equals(trip.getTripPurpose()))throw conflict("取货车次不能承接出库拼车预订");if(!"PLANNED".equals(trip.getStatus()))throw conflict("仅计划中的车次允许管理外接预订");if(trip.getRecordVersion()!=version)throw conflict("车次已被修改,请刷新");}
private static void requireVersion(FleetBookingEntity row,long version){if(row.getRecordVersion()!=version)throw conflict("外接预订已被修改,请刷新");}
private String write(Object value){try{return json.writeValueAsString(value);}catch(Exception error){throw new IllegalStateException(error);}}
private String hash(Object value){try{ObjectNode node=json.valueToTree(value);node.remove("idempotencyKey");node.remove("tripRecordVersion");return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(write(node).getBytes(StandardCharsets.UTF_8)));}catch(Exception error){throw new IllegalStateException(error);}}
@@ -295,9 +295,12 @@ public class FleetCapacityOfferService {
}
static TripCapacityPolicy.Load min(TripCapacityPolicy.Load a,TripCapacityPolicy.Load b){return new TripCapacityPolicy.Load(Math.min(a.palletSpaces(),b.palletSpaces()),a.weightKg().min(b.weightKg()),a.volumeCbm().min(b.volumeCbm()));}
private static boolean positive(TripCapacityPolicy.Load value){return value!=null&&value.palletSpaces()>0&&value.weightKg().signum()>0&&value.volumeCbm().signum()>0;}
static boolean cutoffValid(FleetTripEntity trip,LocalDateTime cutoff,Instant now){return "PLANNED".equals(trip.getStatus())&&cutoff!=null&&trip.getPlannedDepartureAt()!=null&&now.isBefore(instant(cutoff))&&!cutoff.isAfter(trip.getPlannedDepartureAt());}
/** 余位交货截止只适用于尚未发车的派送车次,取货车不接受出库拼车。 */
static boolean cutoffValid(FleetTripEntity trip,LocalDateTime cutoff,Instant now){return !"PICKUP".equals(trip.getTripPurpose())&&"PLANNED".equals(trip.getStatus())&&cutoff!=null&&trip.getPlannedDepartureAt()!=null&&now.isBefore(instant(cutoff))&&!cutoff.isAfter(trip.getPlannedDepartureAt());}
/** 返回不可接单的具体原因,防止使用已有发布绕过用途及时间门禁。 */
static String openBlock(FleetCapacityOfferEntity row,FleetTripEntity trip,Instant now){
if(!"OPEN".equals(row.getStatus()))return "发布未开放接单";
if("PICKUP".equals(trip.getTripPurpose()))return "取货车次不发布出库拼车余位";
if(!"PLANNED".equals(trip.getStatus()))return "车次已发车、结案或取消,不再接受新意向";
if(!cutoffValid(trip,row.getCutoffAt(),now))return "交货截止已过或不符合当前发车时间";return null;
}
@@ -5,6 +5,11 @@ import java.util.List;
@Mapper
public interface FleetResourceMapper {
/** 一次读取当前候选的实际占用,不按车次计划状态推断空闲。 */
@Select({"<script>SELECT resource_kind,resource_id,trip_id,trip_no FROM ll_fleet_resource_assignment",
"WHERE resource_kind=#{kind} AND resource_id IN",
"<foreach collection='ids' item='id' open='(' separator=',' close=')'>#{id}</foreach></script>"})
List<Assignment> candidates(@Param("kind") String kind, @Param("ids") List<Long> ids);
@Select("SELECT resource_kind,resource_id,trip_id,trip_no FROM ll_fleet_resource_assignment WHERE resource_kind=#{kind} AND resource_id=#{id}")
Assignment find(@Param("kind") String kind, @Param("id") long id);
@Select("SELECT resource_kind,resource_id,trip_id,trip_no FROM ll_fleet_resource_assignment WHERE trip_id=#{tripId} ORDER BY resource_kind,resource_id")
@@ -15,6 +15,8 @@ public class FleetTripEntity {
private String idempotencyKey;
private String requestHash;
private String status;
/** 区分出库派送与上门取货入仓。 */
private String tripPurpose = "DELIVERY";
private Long warehouseId;
private String warehouseCode;
private String warehouseName;
@@ -327,7 +327,9 @@ public class FleetTripExecutionService {
private static void requireWarehouse(FleetTripEntity trip, String warehouseId) {
if (!trip.getWarehouseId().toString().equals(warehouseId)) throw new ResponseStatusException(HttpStatus.NOT_FOUND, "当前绑定仓无此车次");
}
/** 出库装载只能用于派送车次,取货必须沿专用运输链执行。 */
private static void requireVersionAndPlan(FleetTripEntity trip, long version) {
if ("PICKUP".equals(trip.getTripPurpose())) throw conflict("取货车次请使用取货任务操作");
if (trip.getRecordVersion() != version) throw conflict("车次已被修改,请刷新后重新确认");
if (!"PLANNED".equals(trip.getStatus())) throw conflict("仅计划中的车次允许此操作");
}
@@ -7,12 +7,14 @@ import org.apache.ibatis.annotations.Select;
@Mapper
public interface FleetTripMapper extends BaseMapper<FleetTripEntity> {
/** 预约日历只读海外仓出库派送,取货入仓车次不属于排车类型。 */
@Select("""
SELECT trip_no,status,warehouse_id,warehouse_code,warehouse_name,planned_departure_at,
expected_driver_end_at,departed_at,completed_at,stops_json
FROM ll_fleet_trip
WHERE (departed_at IS NOT NULL AND departed_at>=#{from} AND departed_at<#{to})
OR (departed_at IS NULL AND planned_departure_at>=#{from} AND planned_departure_at<#{to})
WHERE trip_purpose='DELIVERY' AND (
(departed_at IS NOT NULL AND departed_at>=#{from} AND departed_at<#{to})
OR (departed_at IS NULL AND planned_departure_at>=#{from} AND planned_departure_at<#{to}))
ORDER BY COALESCE(departed_at,planned_departure_at),trip_no LIMIT 2001
""")
java.util.List<FleetTripEntity> calendar(@Param("from") java.time.LocalDateTime from,@Param("to") java.time.LocalDateTime to);
@@ -8,6 +8,11 @@ import java.time.Instant;
import java.util.List;
public final class FleetTripModels {
/** 分配候选只查询本次下拉框的资源,限制批量大小。 */
public record ResourceQuery(@NotBlank @Pattern(regexp="VEHICLE|DRIVER") String kind,
@NotEmpty @Size(max=100) List<@NotNull @Positive Long> resourceIds) { }
/** 可用仅表示未被别车占用,证照、区域和容量仍由分配命令复验。 */
public record ResourceOccupancy(String resourceId, String occupiedTripNo, boolean available) { }
private FleetTripModels() { }
public record Query(@Min(1) Integer page, @Min(1) @Max(200) Integer size, @Size(max = 128) String keyword,
String status, @Positive Long warehouseId) {
@@ -41,7 +46,7 @@ public final class FleetTripModels {
Integer maxPalletSpaces, BigDecimal maxLoadKg, BigDecimal maxVolumeCbm,
Instant plannedDepartureAt, Instant expectedDriverEndAt, Instant departedAt, Instant completedAt,
List<Stop> stops, String remark, long recordVersion, Instant updatedAt,
FleetCompletionModels.ReleaseInfo vehicleRelease, FleetCompletionModels.ReleaseInfo driverRelease) { }
FleetCompletionModels.ReleaseInfo vehicleRelease, FleetCompletionModels.ReleaseInfo driverRelease, String tripPurpose) { }
public record Detail(Response trip, DriverQualificationPolicy.Qualification driverQualification, List<DispatchBinding> dispatches, ReviewState loadingReview,
List<PreviousDispatch> previousDispatches,String appointmentHistoryUnavailableReason) { }
public record AuditSnapshot(Response trip, List<DispatchBinding> dispatches, FleetExecutionModels.ConfirmedLoad confirmedLoad,
@@ -0,0 +1,59 @@
package com.baiyue.lastleg.fleet.trip;
import com.baomidou.mybatisplus.core.conditions.Wrapper;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import org.springframework.stereotype.Repository;
import java.util.List;
/** 车次主流程的数据访问集中经过 Repository,事务和状态决策留在 Service。 */
@Repository
public class FleetTripRepository {
private final FleetTripMapper trips;
private final FleetTripDispatchMapper bindings;
private final FleetTripHistoryMapper histories;
private final FleetResourceMapper resources;
/** 注入车次、成员、历史及资源 Mapper。 */
public FleetTripRepository(FleetTripMapper trips,FleetResourceMapper resources,FleetTripHistoryMapper histories,FleetTripDispatchMapper bindings) {
this.trips=trips;this.resources=resources;this.histories=histories;this.bindings=bindings;
}
/** 按筛选分页查询车次。 */
public Page<FleetTripEntity> pageTrips(Page<FleetTripEntity> page,Wrapper<FleetTripEntity> filter) { return trips.selectPage(page,filter); }
/** 查找原创建请求。 */
public FleetTripEntity findCreated(String operator,String key) { return trips.findCreated(operator,key); }
/** 锁定原创建请求处理唯一键并发。 */
public FleetTripEntity lockCreated(String operator,String key) { return trips.lockCreated(operator,key); }
/** 新增车次记录。 */
public int insertTrip(FleetTripEntity row) { return trips.insert(row); }
/** 按业务号查询车次。 */
public FleetTripEntity findTrip(String number) { return trips.findByNo(number); }
/** 按业务号加锁读取车次。 */
public FleetTripEntity lockTrip(String number) { return trips.lockByNo(number); }
/** 按版本条件原子保存车次。 */
public int updateTrip(FleetTripEntity row,Wrapper<FleetTripEntity> filter) { return trips.update(row,filter); }
/** 读取车次当前排车成员。 */
public List<FleetTripDispatch> listBindings(long tripId) { return bindings.listByTrip(tripId); }
/** 锁定单据当前关联。 */
public FleetTripDispatch lockBinding(long id) { return bindings.lockByDispatch(id); }
/** 保存成员审核。 */
public int updateBinding(FleetTripDispatch row) { return bindings.updateById(row); }
/** 新增车次成员。 */
public int insertBinding(FleetTripDispatch row) { return bindings.insert(row); }
/** 删除尚未执行的成员关联。 */
public int deleteBinding(long id) { return bindings.deleteById(id); }
/** 使当前车次成员的旧审核失效。 */
public int updateBindings(Wrapper<FleetTripDispatch> filter) { return bindings.update(null,filter); }
/** 分页读取不可变车次历史。 */
public Page<FleetTripHistory> pageHistory(Page<FleetTripHistory> page,Wrapper<FleetTripHistory> filter) { return histories.selectPage(page,filter); }
/** 读取原成员绑定历史。 */
public List<FleetTripHistory> bindingHistory(long id) { return histories.bindingHistory(id); }
/** 保存当前操作快照。 */
public int insertHistory(FleetTripHistory row) { return histories.insert(row); }
/** 查询资源实时占用。 */
public FleetResourceMapper.Assignment resource(String kind,long id) { return resources.find(kind,id); }
/** 批量读取候选资源占用,避免列表逐项查询。 */
public List<FleetResourceMapper.Assignment> resourceOccupancy(String kind,List<Long> ids) { return resources.candidates(kind,ids); }
/** 唯一键防止车辆或司机同时承接两趟任务。 */
public int reserve(String kind,long id,long tripId,String number) { return resources.reserve(kind,id,tripId,number); }
/** 仅释放属于原车次的占用。 */
public int release(String kind,long id,long tripId) { return resources.release(kind,id,tripId); }
}
@@ -29,10 +29,8 @@ import java.util.*;
public class FleetTripService {
@jakarta.annotation.Resource private com.baiyue.lastleg.fleet.trip.work.AppointmentWorkTripChanges workChanges;
@jakarta.annotation.Resource private com.baiyue.lastleg.appointmentwork.AppointmentWorkDispatchPort appointmentAllocations;
private final FleetTripMapper trips;
private final FleetResourceMapper resources;
private final FleetTripHistoryMapper histories;
private final FleetTripDispatchMapper bindings;
private final FleetTripRepository repository;
@jakarta.annotation.Resource private com.baiyue.lastleg.fleet.pickup.FleetPickupRepository pickups;
private final FleetDispatchGateway dispatches;
private final VehicleRepository vehicles;
private final DriverService drivers;
@@ -41,20 +39,23 @@ public class FleetTripService {
private final Clock clock;
private final FleetBookingReservations bookingReservations;
/** 注入车次持久化、资源资格和跨域业务协作依赖。 */
@Autowired
public FleetTripService(FleetTripMapper trips, FleetResourceMapper resources, FleetTripHistoryMapper histories,
public FleetTripService(FleetTripRepository repository,
VehicleRepository vehicles, DriverService drivers, FleetWarehouseDirectory warehouses, ObjectMapper json,
FleetTripDispatchMapper bindings, FleetDispatchGateway dispatches, FleetBookingReservations bookingReservations) {
this(trips, resources, histories, vehicles, drivers, warehouses, json, bindings, dispatches, bookingReservations, Clock.systemUTC());
FleetDispatchGateway dispatches, FleetBookingReservations bookingReservations) {
this(repository, vehicles, drivers, warehouses, json, dispatches, bookingReservations, Clock.systemUTC());
}
FleetTripService(FleetTripMapper trips, FleetResourceMapper resources, FleetTripHistoryMapper histories,
/** 注入可控时钟,供车次规则和并发持久化验收统一时间。 */
FleetTripService(FleetTripRepository repository,
VehicleRepository vehicles, DriverService drivers, FleetWarehouseDirectory warehouses, ObjectMapper json,
FleetTripDispatchMapper bindings, FleetDispatchGateway dispatches, FleetBookingReservations bookingReservations, Clock clock) {
this.trips = trips; this.resources = resources; this.histories = histories; this.vehicles = vehicles;
FleetDispatchGateway dispatches, FleetBookingReservations bookingReservations, Clock clock) {
this.repository = repository; this.vehicles = vehicles;
this.drivers = drivers; this.warehouses = warehouses; this.json = json; this.clock = clock;
this.bindings = bindings; this.dispatches = dispatches; this.bookingReservations = bookingReservations;
this.dispatches = dispatches; this.bookingReservations = bookingReservations;
}
/** 按平台筛选条件分页查询车次并补齐仓库与资源信息。 */
public PageResult<FleetTripModels.Response> page(FleetTripModels.Query query) {
if (query.status() != null && !query.status().isBlank() && !Set.of("PLANNED", "IN_TRANSIT", "COMPLETED", "CANCELLED").contains(query.status()))
throw bad("车次状态无效");
@@ -64,20 +65,50 @@ public class FleetTripService {
if (query.keyword() != null && !query.keyword().isBlank()) filter.and(part -> part.like(FleetTripEntity::getTripNo, query.keyword().trim())
.or().like(FleetTripEntity::getVehiclePlate, query.keyword().trim()).or().like(FleetTripEntity::getDriverName, query.keyword().trim()));
filter.orderByDesc(FleetTripEntity::getUpdatedAt, FleetTripEntity::getId);
var page = trips.selectPage(new Page<>(query.page(), query.size()), filter);
var page = repository.pageTrips(new Page<>(query.page(), query.size()), filter);
return new PageResult<>(page.getRecords().stream().map(this::response).toList(), page.getCurrent(), page.getSize(), page.getTotal(), page.getPages());
}
/** 按全局车次号读取计划、关联单据及审核状态。 */
@Transactional(readOnly = true, isolation = Isolation.REPEATABLE_READ)
public FleetTripModels.Detail detail(String tripNo) {
FleetTripEntity trip = required(tripNo);
return detail(trip);
}
/** 下拉候选批量展示实际占用,本车资源仍可选;分配时必须再次锁定复验。 */
@Transactional(readOnly = true)
public List<FleetTripModels.ResourceOccupancy> resourceOccupancy(String tripNo,FleetTripModels.ResourceQuery query) {
var trip=repository.findTrip(tripNo);
if(trip==null)throw new ResponseStatusException(HttpStatus.NOT_FOUND,"车次不存在");
if(query.kind()==null || !Set.of("VEHICLE","DRIVER").contains(query.kind()) || query.resourceIds()==null
|| query.resourceIds().isEmpty() || query.resourceIds().size()>100
|| query.resourceIds().stream().anyMatch(id->id==null || id<=0))throw bad("请选择有效的车辆或司机候选");
var ids=query.resourceIds().stream().distinct().toList();
var occupied=new HashMap<Long,FleetResourceMapper.Assignment>();
for(var row:repository.resourceOccupancy(query.kind(),ids))occupied.put(row.resourceId(),row);
return ids.stream().map(id->{
var row=occupied.get(id);
return new FleetTripModels.ResourceOccupancy(id.toString(),row==null?null:row.tripNo(),row==null || row.tripId()==trip.getId());
}).toList();
}
/** 创建派送用途车次,保留原有请求幂等语义。 */
@Transactional
public FleetTripModels.Detail create(String operator, FleetTripModels.Create request) {
String hash = hash(write(request));
var existing = trips.findCreated(operator, request.idempotencyKey());
return createPlan(operator, request, "DELIVERY");
}
/** 取货车次首次历史即标识取货用途,复用统一的资源和计划规则。 */
@Transactional
public FleetTripModels.Detail createPickup(String operator, FleetTripModels.Create request) {
return createPlan(operator, request, "PICKUP");
}
/** 两类车次共用建档逻辑,幂等摘要包含用途以防串用。 */
private FleetTripModels.Detail createPlan(String operator, FleetTripModels.Create request, String purpose) {
String hash = hash(("PICKUP".equals(purpose) ? "PICKUP:" : "") + write(request));
var existing = repository.findCreated(operator, request.idempotencyKey());
if (existing != null) return replay(existing, hash);
validatePlan(request.plannedDepartureAt(), request.expectedDriverEndAt(), request.stops());
var warehouse = warehouses.required(request.warehouseId(), "OVERSEAS");
@@ -85,15 +116,15 @@ public class FleetTripService {
trip.setId(IdWorker.getId());
trip.setTripNo("TR-" + UUID.randomUUID().toString().replace("-", "").substring(0, 16).toUpperCase(Locale.ROOT));
trip.setIdempotencyKey(request.idempotencyKey()); trip.setRequestHash(hash);
trip.setStatus("PLANNED"); trip.setWarehouseId(Long.valueOf(warehouse.id()));
trip.setTripPurpose(purpose); trip.setStatus("PLANNED"); trip.setWarehouseId(Long.valueOf(warehouse.id()));
trip.setWarehouseCode(warehouse.code()); trip.setWarehouseName(warehouse.name());
trip.setPlannedDepartureAt(utc(request.plannedDepartureAt())); trip.setExpectedDriverEndAt(utc(request.expectedDriverEndAt()));
trip.setStopsJson(write(request.stops())); trip.setRemark(trim(request.remark()));
trip.setRecordVersion(0L); trip.setCreatedBy(operator); trip.setUpdatedBy(operator);
trip.setCreatedAt(now()); trip.setUpdatedAt(trip.getCreatedAt());
try { if (trips.insert(trip) != 1) throw conflict("车次创建失败"); }
try { if (repository.insertTrip(trip) != 1) throw conflict("车次创建失败"); }
catch (DuplicateKeyException duplicate) {
var concurrent = trips.lockCreated(operator, request.idempotencyKey());
var concurrent = repository.lockCreated(operator, request.idempotencyKey());
if (concurrent == null) throw conflict("车次编号冲突,请重试");
return replay(concurrent, hash);
}
@@ -101,11 +132,12 @@ public class FleetTripService {
return detail(trip);
}
/** 锁定车次及资源,复核司机证照和车辆容量后原子改派。 */
@Transactional
public FleetTripModels.Detail assign(String tripNo, String operator, FleetTripModels.Assign request) {
FleetTripEntity trip = planned(tripNo, request.recordVersion());
requireReason(request.reason());
// All assigners serialize first on the vehicle, then on the driver. Resource uniqueness is the final safeguard.
// 分配始终先锁车辆再锁司机,唯一占用记录作为并发分配的最终约束。
var vehicle = vehicles.lockById(request.vehicleId()).orElseThrow(() -> bad("车辆档案不存在"));
if (!"OVERSEAS".equals(vehicle.region()) || !"IN_SERVICE".equals(vehicle.status())) throw bad("请选择在役海外车辆");
if (vehicle.maxPalletSpaces() == null || vehicle.maxLoadKg() == null || vehicle.maxVolumeCbm() == null
@@ -129,7 +161,7 @@ public class FleetTripService {
trip.setVehicleId(vehicle.id()); trip.setVehiclePlate(vehicle.plateNumber());
trip.setMaxPalletSpaces(vehicle.maxPalletSpaces()); trip.setMaxLoadKg(vehicle.maxLoadKg()); trip.setMaxVolumeCbm(vehicle.maxVolumeCbm());
trip.setDriverId(Long.valueOf(driver.id())); trip.setDriverName(driver.details().name()); trip.setDriverPhone(driver.details().phone());
for (var binding : bindings.listByTrip(trip.getId())) {
for (var binding : repository.listBindings(trip.getId())) {
var dispatch = dispatches.lockForAssignment(binding.getDispatchNo());
if (!trip.getWarehouseId().toString().equals(dispatch.warehouseId())) throw conflict("排车单与车次起运仓不一致");
dispatches.assign(dispatch, assignment(trip));
@@ -140,6 +172,7 @@ public class FleetTripService {
return new FleetTripModels.Detail(response(trip), driver.qualification(), currentBindings, FleetTripReview.read(json, trip.getLoadingReviewJson()),history.dispatches(),history.unavailableReason());
}
/** 修改未发车计划;取货车次保留原单取货点和目的仓。 */
@Transactional
public FleetTripModels.Detail updatePlan(String tripNo, String operator, FleetTripModels.Plan request) {
FleetTripEntity trip = planned(tripNo, request.recordVersion());
@@ -148,9 +181,10 @@ public class FleetTripService {
bookingReservations.requireRoute(trip, request.stops());
if (trip.getDriverId() != null) drivers.requireDispatchable(trip.getDriverId(), request.expectedDriverEndAt());
boolean unchangedRoute = response(trip).stops().equals(request.stops());
if ("PICKUP".equals(trip.getTripPurpose()) && !unchangedRoute) throw conflict("取货路线由原单地址和入仓仓库确定,不能修改为出库路线");
boolean unchangedPlan = unchangedRoute && trip.getPlannedDepartureAt().equals(utc(request.plannedDepartureAt()))
&& trip.getExpectedDriverEndAt().equals(utc(request.expectedDriverEndAt()));
for (var binding : bindings.listByTrip(trip.getId())) {
for (var binding : repository.listBindings(trip.getId())) {
if (unchangedRoute) dispatches.lockForAssignment(binding.getDispatchNo());
else dispatches.lockForPlanning(binding.getDispatchNo());
}
@@ -160,6 +194,7 @@ public class FleetTripService {
return detail(trip);
}
/** 逐张审核客户排车单,不以同车其他客户审核代替本单。 */
@Transactional
public FleetTripModels.Detail reviewDispatch(String tripNo, String dispatchNo, String operator, FleetTripModels.Review request) {
var trip = planned(tripNo, request.recordVersion());
@@ -172,20 +207,29 @@ public class FleetTripService {
if (!"OVERSEAS".equals(driver.details().region())) throw conflict("请选择海外司机");
}
var dispatch = dispatches.lockForAssignment(dispatchNo);
var binding = bindings.lockByDispatch(dispatch.dispatchId());
var binding = repository.lockBinding(dispatch.dispatchId());
if (binding == null || !Objects.equals(binding.getTripId(), trip.getId())) throw conflict("排车单未关联当前车次");
binding.setReviewJson(write(new FleetTripModels.ReviewState(request.decision(), reason, operator, clock.instant(), request.recordVersion())));
if (bindings.updateById(binding) != 1) throw conflict("排车单审核保存失败");
if (repository.updateBinding(binding) != 1) throw conflict("排车单审核保存失败");
trip.setLoadingReviewJson(null);
persist(trip, request.recordVersion(), "DISPATCH_REVIEW", reason, operator);
return detail(trip);
}
/** 取消未执行车次,释放资源并将取货原单退回待安排。 */
@Transactional
public FleetTripModels.Detail cancel(String tripNo, String operator, FleetTripModels.Cancel request) {
FleetTripEntity trip = planned(tripNo, request.recordVersion());
requireReason(request.reason());
if (!bindings.listByTrip(trip.getId()).isEmpty()) throw conflict("请先解除客户排车单关联,再取消车次");
if ("PICKUP".equals(trip.getTripPurpose())) {
// 取消车次与释放原单关联同事务,失败时全部回滚。
for (var task : pickups.tasks(tripNo)) {
if (!"ASSIGNED".equals(task.getStatus())) throw conflict("已开始取货,不能取消车次");
pickups.history(task,"UNASSIGN",operator,request.reason());
}
pickups.unassign(tripNo);
}
if (!repository.listBindings(trip.getId()).isEmpty()) throw conflict("请先解除客户排车单关联,再取消车次");
if (bookingReservations.hasConfirmed(trip.getId())) throw conflict("请先取消已确认的外接预订,再取消车次");
if (trip.getVehicleId() != null) release("VEHICLE", trip.getVehicleId(), trip.getId());
if (trip.getDriverId() != null) release("DRIVER", trip.getDriverId(), trip.getId());
@@ -195,29 +239,32 @@ public class FleetTripService {
}
@jakarta.annotation.Resource private FleetDispatchPlanningGateway planning;
/** 读取当前车次可关联的客户排车候选。 */
@Transactional(readOnly=true,isolation=org.springframework.transaction.annotation.Isolation.REPEATABLE_READ)
public com.baiyue.common.core.api.PageResult<FleetDispatchPlanningGateway.Candidate> dispatchCandidates(String tripNo,String keyword,int page,int size) {
var trip=trips.findByNo(tripNo);
var trip=repository.findTrip(tripNo);
if(trip==null)throw new org.springframework.web.server.ResponseStatusException(org.springframework.http.HttpStatus.NOT_FOUND,"车次不存在");
if(!"PLANNED".equals(trip.getStatus()))throw conflict("仅计划中的车次可以添加待运货物");
return planning.candidates(tripNo,trip.getWarehouseId().toString(),keyword,page,size);
}
/** 关联同仓客户排车单并同步车辆司机,禁止取货车次混用。 */
@Transactional
public FleetTripModels.Detail bindDispatch(String tripNo, String operator, FleetTripModels.BindingCommand request) {
var trip = planned(tripNo, request.recordVersion());
if ("PICKUP".equals(trip.getTripPurpose())) throw conflict("取货车次不能关联出库排车单");
requireReason(request.reason());
if (trip.getVehicleId() == null || trip.getDriverId() == null) throw conflict("请先分配车辆和司机");
var dispatch = dispatches.lockForPlanning(request.dispatchNo().trim());
appointmentAllocations.requireTripBinding(dispatch.dispatchNo(),trip.getId());
if (!trip.getWarehouseId().toString().equals(dispatch.warehouseId())) throw bad("排车单与车次必须使用同一起运仓");
var bound = bindings.lockByDispatch(dispatch.dispatchId());
var bound = repository.lockBinding(dispatch.dispatchId());
if (bound != null) throw conflict("排车单已关联车次 " + bound.getTripNo());
var binding = new FleetTripDispatch();
binding.setDispatchId(dispatch.dispatchId()); binding.setDispatchNo(dispatch.dispatchNo()); binding.setCustomerId(dispatch.customerId());
binding.setTripId(trip.getId()); binding.setTripNo(tripNo); binding.setPackageCount(dispatch.packageCount());
binding.setBoundBy(operator); binding.setBoundAt(now());
try { if (bindings.insert(binding) != 1) throw conflict("排车单关联失败"); }
try { if (repository.insertBinding(binding) != 1) throw conflict("排车单关联失败"); }
catch (DuplicateKeyException duplicate) { throw conflict("排车单已关联其他车次,请刷新后重试"); }
bookingReservations.bindIncoming(trip, dispatch, dispatches.sourceCargo(dispatch.dispatchNo()), operator);
var route = matchedStops(response(trip).stops(), dispatches.manifest(dispatch.dispatchNo(), false).cargo());
@@ -250,74 +297,85 @@ public class FleetTripService {
return List.copyOf(result);
}
/** 解除尚未执行的排车关联并使原装载审核失效。 */
@Transactional
public FleetTripModels.Detail unbindDispatch(String tripNo, String operator, FleetTripModels.BindingCommand request) {
var trip = planned(tripNo, request.recordVersion());
requireReason(request.reason());
var dispatch = dispatches.lockForPlanning(request.dispatchNo().trim());
var binding = bindings.lockByDispatch(dispatch.dispatchId());
var binding = repository.lockBinding(dispatch.dispatchId());
if (binding == null || !Objects.equals(binding.getTripId(), trip.getId())) throw conflict("排车单未关联当前车次");
dispatches.assign(dispatch, new FleetDispatchGateway.Assignment(null, null, null));
if (bindings.deleteById(binding.getDispatchId()) != 1) throw conflict("排车单关联已变化,请刷新后重试");
if (repository.deleteBinding(binding.getDispatchId()) != 1) throw conflict("排车单关联已变化,请刷新后重试");
bookingReservations.unbindIncoming(trip, dispatch.dispatchNo(), operator);
persist(trip, request.recordVersion(), "UNBIND_DISPATCH", request.reason(), operator);
return detail(trip);
}
/** 分页读取不可变车次历史,用于追溯计划和执行变更。 */
public PageResult<FleetTripModels.History> history(String tripNo, int page, int size) {
var trip = required(tripNo);
var result = histories.selectPage(new Page<>(page, size), new LambdaQueryWrapper<FleetTripHistory>()
var result = repository.pageHistory(new Page<>(page, size), new LambdaQueryWrapper<FleetTripHistory>()
.eq(FleetTripHistory::getTripId, trip.getId()).orderByDesc(FleetTripHistory::getRecordVersion));
return new PageResult<>(result.getRecords().stream().map(event -> new FleetTripModels.History(event.getRecordVersion(), event.getAction(),
event.getReason(), event.getOperatorId(), instant(event.getOccurredAt()), read(event.getSnapshotJson(), FleetTripModels.AuditSnapshot.class))).toList(),
result.getCurrent(), result.getSize(), result.getTotal(), result.getPages());
}
/** 在唯一资源占用记录内锁定车辆或司机,阻止并发重复分配。 */
private void reserve(String kind, long resourceId, FleetTripEntity trip) {
var current = resources.find(kind, resourceId);
var current = repository.resource(kind, resourceId);
if (current != null) {
if (current.tripId() == trip.getId()) return;
throw conflict(("VEHICLE".equals(kind) ? "车辆" : "司机") + "已被车次 " + current.tripNo() + " 占用");
}
try { if (resources.reserve(kind, resourceId, trip.getId(), trip.getTripNo()) != 1) throw conflict("车次资源预留失败"); }
try { if (repository.reserve(kind, resourceId, trip.getId(), trip.getTripNo()) != 1) throw conflict("车次资源预留失败"); }
catch (DuplicateKeyException duplicate) { throw conflict("车辆或司机已被其他车次占用,请刷新后重试"); }
}
/** 仅释放属于本车次的资源占用。 */
private void release(String kind, long resourceId, long tripId) {
if (resources.release(kind, resourceId, tripId) != 1) throw conflict("车次资源占用不一致,操作未完成");
if (repository.release(kind, resourceId, tripId) != 1) throw conflict("车次资源占用不一致,操作未完成");
}
/** 锁定读取车次,不存在时返回明确的业务错误。 */
private FleetTripEntity required(String number) {
var trip = trips.findByNo(number);
var trip = repository.findTrip(number);
if (trip == null) throw new ResponseStatusException(HttpStatus.NOT_FOUND, "车次不存在");
return trip;
}
/** 校验车次版本及未发车状态,防止陈旧页面覆盖新安排。 */
private FleetTripEntity planned(String number, long version) {
var trip = trips.lockByNo(number);
var trip = repository.lockTrip(number);
if (trip == null) throw new ResponseStatusException(HttpStatus.NOT_FOUND, "车次不存在");
if (trip.getRecordVersion() != version) throw conflict("车次已被修改,请刷新后重试");
if (!"PLANNED".equals(trip.getStatus())) throw conflict("仅计划中的车次允许此操作");
return trip;
}
/** 复核创建请求摘要,同键不同内容不允许复用。 */
private FleetTripModels.Detail replay(FleetTripEntity trip, String hash) {
if (!Objects.equals(trip.getRequestHash(), hash)) throw conflict("创建请求标识已用于不同车次内容");
return detail(trip);
}
/** 组合车次、排车关联与预约历史,形成一致的详情快照。 */
private FleetTripModels.Detail detail(FleetTripEntity trip) {
var currentBindings=bindingResponses(trip);
var history=previousDispatches(trip,currentBindings);
return new FleetTripModels.Detail(response(trip), trip.getDriverId() == null ? null
: drivers.detail(trip.getDriverId(), instant(trip.getExpectedDriverEndAt())).qualification(), currentBindings, FleetTripReview.read(json, trip.getLoadingReviewJson()),history.dispatches(),history.unavailableReason());
}
/** 将客户隔离的排车关联转换为详情展示模型。 */
private List<FleetTripModels.DispatchBinding> bindingResponses(FleetTripEntity trip) {
var current=bindings.listByTrip(trip.getId());
var current=repository.listBindings(trip.getId());
var references=appointmentAllocations.references(current.stream().map(FleetTripDispatch::getDispatchId).toList());
return current.stream().map(binding -> new FleetTripModels.DispatchBinding(binding.getDispatchNo(),
binding.getCustomerId(), binding.getPackageCount(), binding.getBoundBy(), instant(binding.getBoundAt()), FleetTripReview.read(json, binding.getReviewJson()),references.getOrDefault(binding.getDispatchId(),List.of()))).toList();
}
/** 组装当前车次关联单据和预约追溯信息。 */
private record AppointmentHistory(List<FleetTripModels.PreviousDispatch> dispatches,String unavailableReason) { }
/** 读取预约工作批次的历史排车引用,保留历史不可用原因。 */
private AppointmentHistory previousDispatches(FleetTripEntity trip,List<FleetTripModels.DispatchBinding> current) {
var history=histories.bindingHistory(trip.getId());
var history=repository.bindingHistory(trip.getId());
if(history.size()>1000) return new AppointmentHistory(List.of(),"原车绑定历史超过完整读取上限,请分页核对车次历史");
String unavailableReason=null;
var seen=new HashSet<List<String>>();
@@ -338,13 +396,16 @@ public class FleetTripService {
}
return new AppointmentHistory(List.copyOf(previous),unavailableReason);
}
/** 构造稳定预约批次关联键,避免将同名记录误合并。 */
private static List<String> referenceKey(com.baiyue.lastleg.appointmentwork.AppointmentWorkDispatchPort.BatchReference batch,
com.baiyue.lastleg.appointmentwork.AppointmentWorkDispatchPort.BatchSource source) {
return List.of(batch.batchKey(),batch.customerId(),source.sourceKey(),source.destinationKey());
}
/** 生成跨域同步所需的车辆司机分配快照。 */
private static FleetDispatchGateway.Assignment assignment(FleetTripEntity trip) {
return new FleetDispatchGateway.Assignment(trip.getVehiclePlate(), trip.getDriverName(), trip.getDriverPhone());
}
/** 组装车次计划、用途、资源及释放记录的对外响应。 */
private FleetTripModels.Response response(FleetTripEntity trip) {
List<FleetTripModels.Stop> stops;
try { stops = json.readValue(trip.getStopsJson(), new TypeReference<>() { }); }
@@ -353,8 +414,9 @@ public class FleetTripService {
text(trip.getVehicleId()), trip.getVehiclePlate(), text(trip.getDriverId()), trip.getDriverName(), trip.getDriverPhone(),
trip.getMaxPalletSpaces(), trip.getMaxLoadKg(), trip.getMaxVolumeCbm(), instant(trip.getPlannedDepartureAt()), instant(trip.getExpectedDriverEndAt()),
instant(trip.getDepartedAt()), instant(trip.getCompletedAt()), List.copyOf(stops), trip.getRemark(), trip.getRecordVersion(), instant(trip.getUpdatedAt()),
releaseInfo(trip.getVehicleReleaseJson()), releaseInfo(trip.getDriverReleaseJson()));
releaseInfo(trip.getVehicleReleaseJson()), releaseInfo(trip.getDriverReleaseJson()), trip.getTripPurpose());
}
/** 校验发车和任务结束时间以及路线站点完整性。 */
private void validatePlan(Instant departure, Instant end, List<FleetTripModels.Stop> stops) {
if (departure == null || end == null || !end.isAfter(departure) || !end.isAfter(clock.instant())) throw bad("司机预计任务结束时间须晚于计划发车及当前时间");
try {
@@ -363,17 +425,19 @@ public class FleetTripService {
if (stops == null || stops.size() < 2 || stops.size() > 200 || stops.stream().anyMatch(stop -> stop == null || trim(stop.name()) == null))
throw bad("车次须包含起点和目的地,最多200个站点");
}
/** 按乐观版本保存车次变更并记录不可变操作快照。 */
private void persist(FleetTripEntity trip, long version, String action, String reason, String operator) {
if (Set.of("ASSIGN", "PLAN", "BIND_DISPATCH", "UNBIND_DISPATCH", "CANCEL").contains(action)) trip.setPlannedLoadJson(null);
if (Set.of("ASSIGN", "PLAN", "BIND_DISPATCH", "UNBIND_DISPATCH", "CANCEL").contains(action)) trip.setLoadJson(null);
if (Set.of("ASSIGN", "PLAN", "BIND_DISPATCH", "UNBIND_DISPATCH", "CANCEL", "CONFIRM_LOAD").contains(action)) trip.setLoadingReviewJson(null);
if (Set.of("ASSIGN", "PLAN").contains(action)) bindings.update(null, new LambdaUpdateWrapper<FleetTripDispatch>()
if (Set.of("ASSIGN", "PLAN").contains(action)) repository.updateBindings(new LambdaUpdateWrapper<FleetTripDispatch>()
.eq(FleetTripDispatch::getTripId, trip.getId()).set(FleetTripDispatch::getReviewJson, null));
trip.setRecordVersion(version + 1); trip.setUpdatedAt(now()); trip.setUpdatedBy(operator);
if (trips.update(trip, new LambdaUpdateWrapper<FleetTripEntity>().eq(FleetTripEntity::getId, trip.getId()).eq(FleetTripEntity::getRecordVersion, version)) != 1)
if (repository.updateTrip(trip, new LambdaUpdateWrapper<FleetTripEntity>().eq(FleetTripEntity::getId, trip.getId()).eq(FleetTripEntity::getRecordVersion, version)) != 1)
throw conflict("车次已被修改,请刷新后重试");
record(trip, action, reason, operator);
}
/** 为当前版本保存操作人、原因和完整车次历史。 */
private void record(FleetTripEntity trip, String action, String reason, String operator) {
workChanges.changed(trip);
var event = new FleetTripHistory(); event.setId(IdWorker.getId()); event.setTripId(trip.getId()); event.setRecordVersion(trip.getRecordVersion());
@@ -382,26 +446,39 @@ public class FleetTripService {
: read(trip.getLoadJson(), FleetExecutionModels.ConfirmedLoad.class), trip.getCompletionJson() == null ? null
: read(trip.getCompletionJson(), FleetCompletionModels.Completed.class), FleetTripReview.read(json, trip.getLoadingReviewJson()),
trip.getPlannedLoadJson() == null ? null : read(trip.getPlannedLoadJson(), FleetExecutionModels.PlannedLoad.class), bookingReservations.responses(trip.getId()))));
if (histories.insert(event) != 1) throw conflict("车次历史保存失败");
if (repository.insertHistory(event) != 1) throw conflict("车次历史保存失败");
}
/** 将车次快照序列化为持久化 JSON。 */
private String write(Object value) { try { return json.writeValueAsString(value); } catch (JsonProcessingException e) { throw new IllegalStateException("车次序列化失败", e); } }
/** 解析可空的资源任务结束记录。 */
private FleetCompletionModels.ReleaseInfo releaseInfo(String value) {
if (value == null) return null;
var released = read(value, FleetCompletionModels.Released.class);
return new FleetCompletionModels.ReleaseInfo(released.actualEndAt(), released.endLocation(), released.vehicleStatus());
}
/** 在调用方已有事务内保存执行事实及对应历史。 */
@Transactional(propagation = org.springframework.transaction.annotation.Propagation.MANDATORY)
public void recordExecution(FleetTripEntity trip, long version, String action, String reason, String operator) {
persist(trip, version, action, reason, operator);
}
/** 按指定模型解析历史 JSON,损坏记录明确报错。 */
private <T> T read(String value, Class<T> type) { try { return json.readValue(value, type); } catch (JsonProcessingException e) { throw new IllegalStateException("车次历史读取失败", e); } }
/** 为幂等请求生成不可逆内容摘要。 */
private static String hash(String value) { try { return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(value.getBytes(StandardCharsets.UTF_8))); } catch (NoSuchAlgorithmException e) { throw new IllegalStateException(e); } }
/** 使用统一时钟获取 UTC 业务时间。 */
private LocalDateTime now() { return utc(clock.instant()); }
/** 将统一时刻转换为数据库 UTC 时间。 */
private static LocalDateTime utc(Instant value) { return LocalDateTime.ofInstant(value, ZoneOffset.UTC); }
/** 将可空的数据库时间转换为统一时刻。 */
private static Instant instant(LocalDateTime value) { return value == null ? null : value.toInstant(ZoneOffset.UTC); }
/** 将可空的长整型标识转换为字符串,避免前端精度丢失。 */
private static String text(Long value) { return value == null ? null : value.toString(); }
/** 将空白备注归一为空值。 */
private static String trim(String value) { return value == null || value.isBlank() ? null : value.trim(); }
/** 关键变更必须填写操作原因,便于审计追溯。 */
private static void requireReason(String reason) { if (trim(reason) == null) throw bad("请填写操作原因"); }
/** 创建参数不满足业务要求的错误。 */
private static ResponseStatusException bad(String message) { return new ResponseStatusException(HttpStatus.BAD_REQUEST, message); }
/** 创建状态或版本冲突的业务错误。 */
private static ResponseStatusException conflict(String message) { return new ResponseStatusException(HttpStatus.CONFLICT, message); }
}
@@ -57,6 +57,17 @@ public class PlatformFleetTripController {
public ApiResponse<FleetTripModels.Detail> plan(@AuthenticationPrincipal AuthenticatedUser user, @PathVariable String tripNo, @Valid @RequestBody FleetTripModels.Plan request) {
return ApiResponse.success(service.updatePlan(tripNo, operator(user), request));
}
/** 仅平台查看本次候选的实时占用,沿车次列表权限,不产生资源预留。 */
@GetMapping("/{tripNo}/resource-occupancy")
@PreAuthorize("hasAuthority('platform:fleet:trip:list')")
public ApiResponse<java.util.List<FleetTripModels.ResourceOccupancy>> resourceOccupancy(
@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String tripNo,@Valid FleetTripModels.ResourceQuery query) {
if(user==null || !"PLATFORM".equals(user.appCode()) || !Long.valueOf(0).equals(user.tenantId()))
throw new ResponseStatusException(HttpStatus.FORBIDDEN,"仅平台可以查询车次资源占用");
return ApiResponse.success(service.resourceOccupancy(tripNo,query));
}
/** 分配前重新锁定并核对车辆司机,候选空闲不代表已预留。 */
@PostMapping("/{tripNo}/assignment")
@PreAuthorize("hasAuthority('platform:fleet:trip:assign')")
public ApiResponse<FleetTripModels.Detail> assign(@AuthenticationPrincipal AuthenticatedUser user, @PathVariable String tripNo, @Valid @RequestBody FleetTripModels.Assign request) {
@@ -0,0 +1,35 @@
package com.baiyue.lastleg.inboundplan.pda.mapper;
import com.baiyue.lastleg.inboundplan.mapper.ContainerPlanConversionCommandMapper;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
import java.util.List;
/** 候选列表的批量资源门禁与实收汇总,不改变原收货命令。 */
@Mapper
public interface ReceivingCandidateMapper {
/** 柜次和柜号由数据库按原门禁的比较语义匹配,避免 Java 大小写比较缩小候选。 */
@Select("SELECT o.id AS order_id,o.source_container_booking_id AS booking_id FROM ll_order o " +
"JOIN ll_container_shipping_gate g ON g.container_booking_id=o.source_container_booking_id " +
"AND g.container_no=o.container_no WHERE g.status='RECEIVING_AVAILABLE'")
List<AvailableOrder> availableOrders();
record AvailableOrder(Long orderId, String bookingId) { }
/** 保留原转换命令的客户和计划精确匹配,一批读取未确认计划。 */
@Select("<script>SELECT p.id FROM ll_inbound_plan p JOIN ll_order o ON o.customer_id=p.customer_id " +
"AND o.id=p.source_order_id WHERE p.id IN " +
"<foreach collection='ids' item='id' open='(' separator=',' close=')'>#{id}</foreach> AND " +
ContainerPlanConversionCommandMapper.UNCONFIRMED_PLAN + "</script>")
List<Long> unconfirmedPlans(@Param("ids") List<Long> ids);
/** 实收始终按客户、计划及计划行三重身份汇总。 */
@Select("<script>SELECT customer_id,plan_id,line_id,COALESCE(SUM(picked_package_count),0) AS received " +
"FROM ll_inbound_plan_pallet_allocation WHERE line_id IN " +
"<foreach collection='ids' item='id' open='(' separator=',' close=')'>#{id}</foreach> " +
"GROUP BY customer_id,plan_id,line_id</script>")
List<Received> received(@Param("ids") List<Long> ids);
record Received(String customerId, Long planId, Long lineId, int received) { }
}
@@ -0,0 +1,76 @@
package com.baiyue.lastleg.inboundplan.pda.repository;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baiyue.lastleg.entity.LastlegOrder;
import com.baiyue.lastleg.mapper.LastlegOrderMapper;
import com.baiyue.lastleg.inboundplan.entity.InboundPlan;
import com.baiyue.lastleg.inboundplan.entity.InboundPlanLine;
import com.baiyue.lastleg.inboundplan.mapper.InboundPlanMapper;
import com.baiyue.lastleg.inboundplan.mapper.InboundPlanLineMapper;
import com.baiyue.lastleg.inboundplan.pda.mapper.ReceivingCandidateMapper;
import com.baiyue.lastleg.inboundplan.pda.mapper.ReceivingContainerSessionMapper;
import com.baiyue.lastleg.inboundplan.pda.entity.ReceivingContainerSession;
import org.springframework.stereotype.Repository;
import java.util.*;
import java.util.function.Function;
import java.util.stream.Collectors;
/** 一次请求内批量读取候选所需事实,避免按柜、计划和货件逐条查询。 */
@Repository
public class ReceivingCandidateRepository {
private static final int CHUNK_SIZE = 500;
private final LastlegOrderMapper orders;
private final InboundPlanMapper plans;
private final InboundPlanLineMapper lines;
private final ReceivingCandidateMapper candidates;
private final ReceivingContainerSessionMapper sessions;
/** 仅依赖当前收货业务的本库 Mapper。 */
public ReceivingCandidateRepository(LastlegOrderMapper orders, InboundPlanMapper plans,
InboundPlanLineMapper lines, ReceivingCandidateMapper candidates, ReceivingContainerSessionMapper sessions) {
this.orders=orders; this.plans=plans; this.lines=lines; this.candidates=candidates; this.sessions=sessions;
}
/** 先读取当前账号会话,供服务在加载候选前核对仓库归属。 */
public ReceivingContainerSession currentSession(String activeOperatorKey) {
return sessions.findActiveByOperator(activeOperatorKey);
}
/** 读取柜号占用事实,由服务排除其他操作账号正在处理的柜。 */
public List<ReceivingContainerSession> activeSessions() {
return sessions.findActiveContainers();
}
public record LineKey(String customerId, Long planId, Long lineId) { }
public record Facts(List<LastlegOrder> orders, List<InboundPlan> plans, List<InboundPlanLine> lines,
Set<Long> availableOrderIds, Set<Long> unconfirmedPlans, Map<LineKey,Integer> received) { }
/** 先缩小到可收货柜,保留其全部客户订单,不能按当前仓提前裁掉柜内冲突记录。 */
public Facts load() {
var available=candidates.availableOrders();
var bookingIds=available.stream().map(ReceivingCandidateMapper.AvailableOrder::bookingId).distinct().toList();
var orderRows=batch(bookingIds, ids->orders.selectList(new LambdaQueryWrapper<LastlegOrder>()
.in(LastlegOrder::getSourceContainerBookingId,ids).isNotNull(LastlegOrder::getContainerNo)))
.stream().collect(Collectors.toMap(LastlegOrder::getId,Function.identity(),(left,right)->left))
.values().stream().sorted(Comparator.comparing(LastlegOrder::getSourceContainerBookingId)
.thenComparing(LastlegOrder::getId)).toList();
var lineRows=batch(orderRows.stream().map(LastlegOrder::getId).toList(), ids->lines.selectList(
new LambdaQueryWrapper<InboundPlanLine>().in(InboundPlanLine::getOrderId,ids)))
.stream().sorted(Comparator.comparing(InboundPlanLine::getId)).toList();
var planIds=lineRows.stream().map(InboundPlanLine::getPlanId).filter(Objects::nonNull).distinct().toList();
var planRows=batch(planIds, ids->plans.selectList(new LambdaQueryWrapper<InboundPlan>().in(InboundPlan::getId,ids)));
var unconfirmed=new HashSet<>(batch(planIds,candidates::unconfirmedPlans));
var received=batch(lineRows.stream().map(InboundPlanLine::getId).toList(),candidates::received).stream()
.collect(Collectors.toMap(r->new LineKey(r.customerId(),r.planId(),r.lineId()),ReceivingCandidateMapper.Received::received));
return new Facts(orderRows,planRows,lineRows,available.stream().map(ReceivingCandidateMapper.AvailableOrder::orderId)
.collect(Collectors.toSet()),unconfirmed,received);
}
/** 固定大小分块,空范围不执行 SQL,也不截断候选。 */
private static <I,O> List<O> batch(List<I> ids,Function<List<I>,List<O>> query) {
var result=new ArrayList<O>();
for(int start=0;start<ids.size();start+=CHUNK_SIZE)
result.addAll(query.apply(ids.subList(start,Math.min(ids.size(),start+CHUNK_SIZE))));
return result;
}
}
@@ -1,6 +1,7 @@
package com.baiyue.lastleg.inboundplan.pda.service;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baiyue.lastleg.inboundplan.pda.repository.ReceivingCandidateRepository;
import com.baomidou.mybatisplus.core.toolkit.IdWorker;
import com.baiyue.lastleg.entity.LastlegOrder;
import com.baiyue.lastleg.entity.LastlegOrderDetail;
@@ -82,6 +83,7 @@ public class ReceivingContainerService {
private static final String PALLET_CAPACITY_MESSAGE =
"一个托盘最多只能收66箱,请拆分到其他托盘";
@Resource private ReceivingCandidateRepository candidateRepository;
@Resource private InboundPlanMapper plans;
@Resource private InboundPlanLineMapper lines;
@Resource private InboundPlanPalletMapper pallets;
@@ -119,24 +121,31 @@ public class ReceivingContainerService {
Long.toString(value.warehouseId()), value.warehouseCode(), value.warehouseName());
}
/** 批量读取候选事实,柜内客户、仓库一致性和排序仍按原规则判断。 */
public List<ReceivingContainerResponses.Candidate> candidates(String operatorKey, long overseasWarehouseId) {
ReceivingContainerSession current = sessions.findActiveByOperator(activeOperatorKey(operatorKey));
ReceivingContainerSession current = candidateRepository.currentSession(activeOperatorKey(operatorKey));
requireSessionWarehouse(current, overseasWarehouseId);
List<LastlegOrder> orderFacts = orders.selectList(new LambdaQueryWrapper<LastlegOrder>()
.isNotNull(LastlegOrder::getSourceContainerBookingId)
.isNotNull(LastlegOrder::getContainerNo)
.orderByAsc(LastlegOrder::getSourceContainerBookingId, LastlegOrder::getId));
List<InboundPlan> planFacts = plans.selectList(new LambdaQueryWrapper<InboundPlan>().orderByAsc(InboundPlan::getId));
List<InboundPlanLine> lineFacts = lines.selectList(new LambdaQueryWrapper<InboundPlanLine>().orderByAsc(InboundPlanLine::getId));
var facts = candidateRepository.load();
List<LastlegOrder> orderFacts = facts.orders();
Map<Long, List<InboundPlanLine>> linesByOrder = facts.lines().stream()
.collect(Collectors.groupingBy(InboundPlanLine::getOrderId));
Map<Long, InboundPlan> plansById = facts.plans().stream()
.collect(Collectors.toMap(InboundPlan::getId, Function.identity()));
Map<String, List<LastlegOrder>> grouped = orderFacts.stream()
.filter(order -> trim(order.getSourceContainerBookingId()) != null && trim(order.getContainerNo()) != null)
.collect(Collectors.groupingBy(LastlegOrder::getSourceContainerBookingId, LinkedHashMap::new, Collectors.toList()));
String operatorLockKey = activeOperatorKey(operatorKey);
Set<String> reservedByOthers = sessions.findActiveContainers().stream()
Set<String> reservedByOthers = candidateRepository.activeSessions().stream()
.filter(session -> !operatorLockKey.equals(session.getActiveOperatorKey()))
.map(ReceivingContainerSession::getActiveContainerKey).filter(Objects::nonNull).collect(Collectors.toSet());
return grouped.entrySet().stream()
.map(entry -> readiness(entry.getKey(), entry.getValue(), planFacts, lineFacts, overseasWarehouseId))
.map(entry -> {
var localLines = entry.getValue().stream().flatMap(order ->
linesByOrder.getOrDefault(order.getId(), List.of()).stream()).toList();
var localPlans = localLines.stream().map(line -> plansById.get(line.getPlanId()))
.filter(Objects::nonNull).distinct().toList();
return readiness(entry.getKey(), entry.getValue(), localPlans, localLines, overseasWarehouseId, facts);
})
.filter(Objects::nonNull)
.filter(ContainerReadiness::shippingReady)
.filter(value -> !value.allPlansCompleted())
@@ -679,9 +688,17 @@ public class ReceivingContainerService {
}
}
/** 写命令和详情继续即时复验原资源与船节点。 */
private ContainerReadiness readiness(String sourceContainerBookingId, List<LastlegOrder> orderFacts,
List<InboundPlan> planFacts, List<InboundPlanLine> lineFacts,
long overseasWarehouseId) {
return readiness(sourceContainerBookingId, orderFacts, planFacts, lineFacts, overseasWarehouseId, null);
}
/** 候选使用请求内批量事实;其他入口保留即时校验,避免改变写命令并发门禁。 */
private ContainerReadiness readiness(String sourceContainerBookingId, List<LastlegOrder> orderFacts,
List<InboundPlan> planFacts, List<InboundPlanLine> lineFacts,
long overseasWarehouseId, ReceivingCandidateRepository.Facts facts) {
Set<Long> orderIds = orderFacts.stream().map(LastlegOrder::getId).collect(Collectors.toSet());
Map<Long, List<InboundPlanLine>> linesByOrder = lineFacts.stream()
.filter(line -> orderIds.contains(line.getOrderId()))
@@ -718,9 +735,12 @@ public class ReceivingContainerService {
boolean allPlansCompleted = !matchedPlans.isEmpty()
&& matchedPlans.stream().allMatch(plan -> List.of("COMPLETED", "PENDING_RECONCILIATION")
.contains(plan.getStatus()));
boolean shippingReady = shippingGates.receivingAvailable(sourceContainerBookingId, containerNo) == 1;
boolean shippingReady = facts == null
? shippingGates.receivingAvailable(sourceContainerBookingId, containerNo) == 1
: facts.availableOrderIds().contains(orderFacts.get(0).getId());
boolean resourcesConfirmed = matchedPlans.stream().noneMatch(plan ->
plans.countUnconfirmedResourcePlans(plan.getCustomerId(), plan.getId()) > 0);
facts == null ? plans.countUnconfirmedResourcePlans(plan.getCustomerId(), plan.getId()) > 0
: facts.unconfirmedPlans().contains(plan.getId()));
boolean operable = allPlansReady && resourcesConfirmed && shippingReady && warehouseSnapshotConsistent;
int pending = Math.max(0, totalOrders - plannedOrders);
String blockReason = operable ? null : (!warehouseSnapshotConsistent
@@ -740,7 +760,9 @@ public class ReceivingContainerService {
List<InboundRef> matchedRefs = lineFacts.stream().filter(line -> orderIds.contains(line.getOrderId()))
.map(line -> new InboundRef(plansById.get(line.getPlanId()), line, ordersById.get(line.getOrderId())))
.filter(ref -> ref.plan() != null && ref.order() != null).toList();
List<ReceivingContainerResponses.Inbound> inboundValues = matchedRefs.stream().map(this::inbound).toList();
List<ReceivingContainerResponses.Inbound> inboundValues = matchedRefs.stream().map(ref -> facts == null
? inbound(ref) : inbound(ref, facts.received().getOrDefault(new ReceivingCandidateRepository.LineKey(
ref.order().getCustomerId(), ref.plan().getId(), ref.line().getId()), 0))).toList();
List<String> destinationCodes = inboundValues.stream().map(this::destinationCode)
.filter(Objects::nonNull).distinct().sorted().toList();
Totals totals = new Totals(inboundValues.size(), destinationCodes,
@@ -1152,12 +1174,18 @@ public class ReceivingContainerService {
return sessions.findActiveByContainer(activeContainerKey(containerNo));
}
/** 单柜详情和写命令即时读取本货件实收量。 */
private ReceivingContainerResponses.Inbound inbound(InboundRef ref) {
int received = allocations.selectList(new LambdaQueryWrapper<InboundPlanPalletAllocation>()
.eq(InboundPlanPalletAllocation::getCustomerId, ref.order().getCustomerId())
.eq(InboundPlanPalletAllocation::getPlanId, ref.plan().getId())
.eq(InboundPlanPalletAllocation::getLineId, ref.line().getId())).stream()
.mapToInt(value -> value(value.getPickedPackageCount())).sum();
return inbound(ref, received);
}
/** 共享相同展示投影,候选可使用批量汇总的实收量。 */
private ReceivingContainerResponses.Inbound inbound(InboundRef ref, int received) {
int total = value(ref.line().getRequiredPackageCount());
return new ReceivingContainerResponses.Inbound(ref.line().getInboundCode(), destinationType(ref.line(), ref.order()),
ref.plan().getOverseasWarehouseId() == null ? ref.order().getOverseasWarehouseId()
@@ -8,6 +8,7 @@ import com.baiyue.lastleg.inboundplan.receiving.FbaReceivingStore;
import com.baiyue.lastleg.inboundplan.receiving.FbaReceivingContracts.BatchReceiving;
import com.baiyue.lastleg.security.OverseasPermissions;
import org.springframework.stereotype.Component;
import static com.baiyue.lastleg.appointmentwork.diagnostics.AppointmentWorkDiagnostics.*;
import java.util.*;
import static com.baiyue.lastleg.appointmentwork.AppointmentWorkFactGateway.*;
import static com.baiyue.lastleg.appointmentwork.AppointmentWorkModels.*;
@@ -26,11 +27,12 @@ public class AppointmentWorkDeliveryProvider implements AppointmentWorkFactGatew
this.appointments=appointments; this.cargo=cargo; this.commitments=commitments; this.deliveries=deliveries; this.sourceMapping=sourceMapping;this.receiving=receiving;
}
public boolean supports(Type type,Flow flow) { return type==Type.DELIVERY && flow==Flow.TRANSFER; }
/** 按原批次顺序读取,仅对固定步骤附加耗时,不更改来源或失败语义。 */
public List<Fact> read(List<FactRequest> requests) {
var mapped=new HashMap<String,List<AppointmentWorkSourceMapper.Source>>();
var privateSources=new HashMap<String,List<AppointmentWorkSourceMapper.Source>>();
var containers=requests.stream().filter(r->r.batchKey()==null && r.requirementKey()!=null && r.requirementKey().startsWith("INBOUND:")).map(FactRequest::containerBookingId).distinct().toList();
if(!containers.isEmpty()) sourceMapping.containerSourcesBatch(containers).forEach(s->mapped.computeIfAbsent(s.containerBookingId(),ignored->new ArrayList<>()).add(s.source()));
if(!containers.isEmpty()) measure(Stage.DELIVERY_SOURCE_MAP,()->sourceMapping.containerSourcesBatch(containers)).forEach(s->mapped.computeIfAbsent(s.containerBookingId(),ignored->new ArrayList<>()).add(s.source()));
for(var request:requests) if(request.batchKey()==null && request.requirementKey()!=null && request.requirementKey().startsWith("INBOUND:")) {
var sources=mapped.getOrDefault(request.containerBookingId(),List.of());
if(sources.size()>1000) throw unavailable("柜次尾程来源超过完整读取上限");
@@ -40,20 +42,20 @@ public class AppointmentWorkDeliveryProvider implements AppointmentWorkFactGatew
var refs=requests.stream().filter(r->r.batchKey()==null && !privateSources.containsKey(r.workItemKey())).flatMap(r->r.sources().stream())
.map(s->new AppointmentCoverageService.SourceRef(s.customerId(),s.sourceKey(),s.destinationKey())).distinct().toList();
var inspections=new HashMap<AppointmentCoverageService.SourceRef,AppointmentCargoVerificationService.Inspection>();
if(!refs.isEmpty()) cargo.inspectBatch(refs).forEach(i->inspections.put(i.reference(),i));
if(!refs.isEmpty()) measure(Stage.DELIVERY_CARGO,()->cargo.inspectBatch(refs)).forEach(i->inspections.put(i.reference(),i));
var batches=requests.stream().map(FactRequest::batchKey).filter(k->k!=null && !k.startsWith("DISPATCH:")).distinct().toList();
var snapshots=new HashMap<String,FbaAppointmentResponse>();
var deliveryRows=new HashMap<String,List<AppointmentWorkDeliveryMapper.Delivery>>();
Map<String,String> impacts=Map.of();
var receivingSnapshots=new HashMap<String,BatchReceiving>();
if(!batches.isEmpty()) {
appointments.batchSnapshots(batches).forEach(a->snapshots.put(a.batchKey(),a));
deliveries.batches(batches).forEach(d->deliveryRows.computeIfAbsent(d.batchKey(),ignored->new ArrayList<>()).add(d.delivery()));
receiving.snapshots(batches).forEach(s->receivingSnapshots.put(s.batchKey(),s));
impacts=commitments.impactTokens(requests.stream().filter(r->r.batchKey()!=null && r.batchKey().startsWith("FBA_APPOINTMENT:")).map(r->customerRequirement(r.batchKey(),deliveryCustomer(r.sources()))).distinct().toList());
measure(Stage.DELIVERY_SNAPSHOTS,()->appointments.batchSnapshots(batches)).forEach(a->snapshots.put(a.batchKey(),a));
measure(Stage.DELIVERY_ROWS,()->deliveries.batches(batches)).forEach(d->deliveryRows.computeIfAbsent(d.batchKey(),ignored->new ArrayList<>()).add(d.delivery()));
measure(Stage.DELIVERY_RECEIVING,()->receiving.snapshots(batches)).forEach(s->receivingSnapshots.put(s.batchKey(),s));
impacts=measure(Stage.DELIVERY_IMPACTS,()->commitments.impactTokens(requests.stream().filter(r->r.batchKey()!=null && r.batchKey().startsWith("FBA_APPOINTMENT:")).map(r->customerRequirement(r.batchKey(),deliveryCustomer(r.sources()))).distinct().toList()));
}
var context=new BatchFacts(snapshots,deliveryRows,impacts,receivingSnapshots);
return requests.stream().map(r->read(r,privateSources.get(r.workItemKey()),inspections,context)).toList();
return measure(Stage.DELIVERY_ASSEMBLY,()->requests.stream().map(r->read(r,privateSources.get(r.workItemKey()),inspections,context)).toList());
}
private record BatchFacts(Map<String,FbaAppointmentResponse> appointments,Map<String,List<AppointmentWorkDeliveryMapper.Delivery>> deliveries,Map<String,String> impacts,Map<String,BatchReceiving> receiving) { }
private Fact read(FactRequest request,List<AppointmentWorkSourceMapper.Source> privateSources,Map<AppointmentCoverageService.SourceRef,AppointmentCargoVerificationService.Inspection> inspections,BatchFacts context) {
@@ -20,7 +20,22 @@ public class AppointmentWorkRequirementProvider implements AppointmentWorkFactGa
public AppointmentWorkRequirementProvider(AppointmentSourceService sources,AppointmentCargoVerificationService cargo,InboundFbaAppointmentService appointments,AppointmentWorkSourceMapper sourceMapping) {
this.sources=sources; this.cargo=cargo; this.appointments=appointments; this.sourceMapping=sourceMapping;
}
public List<Requirement> requirements(ContainerSource container,Flow effectiveFlow) {
/** 同次生成仅共享按需读取的目录;原业务数据继续逐需求核实。 */
public RequirementReader openRequirements() {
return new RequirementReader() {
private AppointmentSourceService.ReadScope scope;
/** 直送和未建立尾程来源的需求不创建目录读取依赖。 */
public List<Requirement> read(ContainerSource container,Flow flow) {
return requirements(container,flow,refs->{
if(scope==null) scope=sources.openReadScope();
return scope.resolveBatch(refs);
});
}
};
}
/** 按当前实物分配生成客户需求,保留已撤销批次身份以退役旧事项。 */
private List<Requirement> requirements(ContainerSource container,Flow effectiveFlow,
java.util.function.Function<List<AppointmentCoverageService.SourceRef>,List<AppointmentSourceService.Source>> resolver) {
var original=container.requirements().stream().map(r->"PRIVATE".equals(r.destinationKind())?new Requirement(r.requirementKey(),r.batchKey(),r.batchNo(),"CUSTOMER",r.originName(),r.destinationName(),r.timezone(),r.references(),r.replacesRequirementKeys(),r.sources()):r).toList();
if(effectiveFlow==Flow.DIRECT) return original;
if(effectiveFlow!=Flow.TRANSFER) throw conflict("需求清单必须明确有效作业路线");
@@ -37,7 +52,7 @@ public class AppointmentWorkRequirementProvider implements AppointmentWorkFactGa
if(!"FBA".equals(requirement.destinationKind())) { result.put(requirement.requirementKey(),requirement); continue; }
var refs=canonical(sourceOrders.stream().map(s->new SourceReference(s.customerId(),"ORDER:"+s.id(),null)).toList());
List<AppointmentSourceService.Source> resolved;
try { resolved=sources.resolveBatch(refs); }
try { resolved=resolver.apply(refs); }
catch(org.springframework.web.server.ResponseStatusException error) {
if(!AppointmentWorkSourceAvailability.unresolved(error)) throw error;
// Keep the complete physical obligation and its canonical source identities. The fact
@@ -53,9 +68,14 @@ public class AppointmentWorkRequirementProvider implements AppointmentWorkFactGa
// Unknown remaining quantity is still work, never a zero or an absent requirement.
boolean remainder=resolved.stream().map(cargo::inspect).anyMatch(c->c.availablePackages()==null || c.availablePackages()>0);
var replaced=history.stream().filter(a->!active(a)).map(FbaAppointmentResponse::batchKey).toList();
if(remainder) result.put(requirement.requirementKey(),new Requirement(requirement.requirementKey(),null,null,requirement.destinationKind(),
requirement.originName(),requirement.destinationName(),requirement.timezone(),requirement.references(),replaced,
complete.stream().map(r->new SourceReference(r.customerId(),r.sourceKey(),r.destinationKey())).toList()));
if(remainder) {
var restored=new LinkedHashSet<>(replaced);
// 回到未分配需求时,同时承接原批次运输事项和按客户拆分的送仓事项。
complete.forEach(ref->replaced.forEach(key->restored.add(customerRequirement(key,ref.customerId()))));
result.put(requirement.requirementKey(),new Requirement(requirement.requirementKey(),null,null,requirement.destinationKind(),
requirement.originName(),requirement.destinationName(),requirement.timezone(),requirement.references(),List.copyOf(restored),
complete.stream().map(r->new SourceReference(r.customerId(),r.sourceKey(),r.destinationKey())).toList()));
}
for(var batch:active) {
var replacementKeys=new LinkedHashSet<>(replaced); replacementKeys.add(requirement.requirementKey());
history.stream().filter(a->a.replacementBatchKeys()!=null && a.replacementBatchKeys().contains(batch.batchKey())).map(FbaAppointmentResponse::batchKey).forEach(replacementKeys::add);
@@ -78,6 +98,8 @@ public class AppointmentWorkRequirementProvider implements AppointmentWorkFactGa
customers.forEach((customer,refs)->{
var replacements=new LinkedHashSet<>(requirement.replacesRequirementKeys());
requirement.replacesRequirementKeys().stream().filter(k->k.startsWith("FBA_APPOINTMENT:")).map(k->customerRequirement(k,customer)).forEach(replacements::add);
// 客户事项承接同一实物批次的整体需求;批次身份未变化也须有明确拆分去向。
replacements.add(requirement.batchKey());
scoped.add(new Requirement(customerRequirement(requirement.batchKey(),customer),requirement.batchKey(),requirement.batchNo(),requirement.destinationKind(),
requirement.originName(),requirement.destinationName(),requirement.timezone(),requirement.references(),List.copyOf(replacements),List.copyOf(refs)));
});
@@ -118,9 +140,11 @@ public class AppointmentWorkRequirementProvider implements AppointmentWorkFactGa
if(result.size()!=input.size()) throw unavailable("来源键必须明确为ORDER或BOOKING");
return result.stream().distinct().toList();
}
/** 已取消且没有当前实物分配的记录只属历史;取消但仍有货物的批次继续产生需求。 */
private static boolean active(FbaAppointmentResponse batch) {
// Cancelling a booking clears its schedule, not its physical allocation. Only an
// authoritative replacement retires the batch and its delivery/vehicle obligations.
return batch.replacementBatchKeys()==null || batch.replacementBatchKeys().isEmpty();
if(batch.replacementBatchKeys()!=null && !batch.replacementBatchKeys().isEmpty()) return false;
// 客户来源取当前活动分配;空取消记录不能制造无货送仓事项,也不能阻断整份清单。
// 剩余货量仍由 cargo.inspect 核算,有货的 CANCELLED/FAILED 批次不因预约状态退役。
return !("CANCELLED".equals(batch.appointmentStatus()) && batch.items().isEmpty());
}
}
@@ -10,49 +10,72 @@ import jakarta.validation.constraints.Size;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.time.LocalDateTime;
import jakarta.validation.constraints.AssertTrue;
import com.fasterxml.jackson.annotation.JsonIgnore;
public final class LocalLooseboardRequests {
/** 仅作为散板请求或响应模型的命名空间。 */
private LocalLooseboardRequests() { }
public record Create(
@NotBlank @Pattern(regexp = "SHARED_TRUCK|PRIORITY_CONSOLIDATION") String transportMode,
@NotBlank @Pattern(regexp = "YES|NO") String palletRequired,
@NotBlank @Pattern(regexp = "PORT_PICKUP|WAREHOUSE_TO_WAREHOUSE|WAREHOUSE_TO_DOOR") String transportType,
@NotBlank @Size(max = 255) String origin,
@Size(max = 500) String originAddress,
@NotBlank @Size(max = 255) String destination,
@Size(max = 500) String destinationAddress,
@Size(max = 32) String containerType,
@Size(max = 255) String productName,
@NotNull @Min(0) Integer estimatedPalletCount,
@NotNull @Min(0) Integer packageCount,
@NotNull @DecimalMin("0.00") BigDecimal totalWeightKg,
@NotNull @DecimalMin("0.00") BigDecimal totalVolumeCbm,
@DecimalMin("0.00") BigDecimal budgetAmount,
@Size(min = 3, max = 3) String currency,
@Size(max = 128) String contactName,
@Size(max = 128) String contactInfo,
LocalDate requestedDate,
@Pattern(regexp = "USD|CNY") String currency,
@NotBlank @Size(max = 128) String originContactName,
@NotBlank @Size(max = 128) String originContactPhone,
@NotBlank @Size(max = 128) String destinationContactName,
@NotBlank @Size(max = 128) String destinationContactPhone,
@NotNull LocalDateTime pickupAt,
@NotNull LocalDateTime deliveryAt,
@Size(max = 1000) String remark,
@NotBlank @Size(max = 255) String idempotencyKey) { }
@NotBlank @Size(max = 255) String idempotencyKey) {
/** 取送时间均须填写,送货不能早于取货。 */
@JsonIgnore @AssertTrue(message = "送货时间不能早于取货时间")
public boolean isScheduleValid() {
return validSchedule(pickupAt, deliveryAt);
}
}
public record Update(
@NotNull @Min(0) Long version,
@NotBlank @Pattern(regexp = "SHARED_TRUCK|PRIORITY_CONSOLIDATION") String transportMode,
@NotBlank @Pattern(regexp = "YES|NO") String palletRequired,
@NotBlank @Pattern(regexp = "PORT_PICKUP|WAREHOUSE_TO_WAREHOUSE|WAREHOUSE_TO_DOOR") String transportType,
@NotBlank @Size(max = 255) String origin,
@Size(max = 500) String originAddress,
@NotBlank @Size(max = 255) String destination,
@Size(max = 500) String destinationAddress,
@Size(max = 32) String containerType,
@Size(max = 255) String productName,
@NotNull @Min(0) Integer estimatedPalletCount,
@NotNull @Min(0) Integer packageCount,
@NotNull @DecimalMin("0.00") BigDecimal totalWeightKg,
@NotNull @DecimalMin("0.00") BigDecimal totalVolumeCbm,
@DecimalMin("0.00") BigDecimal budgetAmount,
@Size(min = 3, max = 3) String currency,
@Size(max = 128) String contactName,
@Size(max = 128) String contactInfo,
LocalDate requestedDate,
@Size(max = 1000) String remark) { }
@Pattern(regexp = "USD|CNY") String currency,
@NotBlank @Size(max = 128) String originContactName,
@NotBlank @Size(max = 128) String originContactPhone,
@NotBlank @Size(max = 128) String destinationContactName,
@NotBlank @Size(max = 128) String destinationContactPhone,
@NotNull LocalDateTime pickupAt,
@NotNull LocalDateTime deliveryAt,
@Size(max = 1000) String remark) {
/** 取送时间均须填写,送货不能早于取货。 */
@JsonIgnore @AssertTrue(message = "送货时间不能早于取货时间")
public boolean isScheduleValid() {
return validSchedule(pickupAt, deliveryAt);
}
}
/** 统一校验平台及客户新增、编辑的需求时间。 */
public static boolean validSchedule(LocalDateTime pickup, LocalDateTime delivery) {
return pickup != null && delivery != null && !delivery.isBefore(pickup);
}
public record Query(
@Min(1) long page,
@@ -61,7 +84,6 @@ public final class LocalLooseboardRequests {
String orderNo,
String routeKeyword,
String status,
String transportMode,
LocalDate requestedFrom,
LocalDate requestedTo) { }
@@ -71,7 +93,6 @@ public final class LocalLooseboardRequests {
String orderNo,
String routeKeyword,
String status,
String transportMode,
LocalDate requestedFrom,
LocalDate requestedTo) { }
@@ -1,19 +1,19 @@
package com.baiyue.lastleg.looseboard.dto;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.util.List;
public final class LocalLooseboardResponses {
/** 仅作为散板请求或响应模型的命名空间。 */
private LocalLooseboardResponses() { }
/** Pool orders retain null containerType/palletRequired when the inquiry did not specify these facts. */
/** 需求与内部安排分别返回;未知联系人、商品及时间不补造。 */
public record Summary(
Long id, String customerId, String orderNo, String transportMode, String palletRequired,
String transportType, String origin, String destination, String containerType, int packageCount,
Long id, String customerId, String orderNo, String palletRequired,
String transportType, String origin, String destination, String productName, Integer estimatedPalletCount, int packageCount,
BigDecimal totalWeightKg, BigDecimal totalVolumeCbm, BigDecimal budgetAmount, String currency,
String contactName, String contactInfo, LocalDate requestedDate, String status, long version,
String originContactName, String originContactPhone, String destinationContactName, String destinationContactPhone, LocalDateTime pickupAt, LocalDateTime deliveryAt, String status, long version,
LocalDateTime createdAt, LocalDateTime updatedAt, com.baiyue.lastleg.fleet.business.FleetPoolOrderModels.Arrangement fleetArrangement) { }
public record History(
@@ -1,12 +1,13 @@
package com.baiyue.lastleg.looseboard.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.FieldStrategy;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.time.LocalDateTime;
@Data
@@ -16,24 +17,27 @@ public class LocalLooseboardOrder {
private String customerId;
private Long lastlegOrderId;
private String orderNo;
private String transportMode;
private String palletRequired;
private String transportType;
private String origin;
private String originAddress;
@TableField(updateStrategy = FieldStrategy.ALWAYS) private String originAddress;
private String destination;
private String destinationAddress;
private String containerType;
@TableField(updateStrategy = FieldStrategy.ALWAYS) private String destinationAddress;
@TableField(updateStrategy = FieldStrategy.ALWAYS) private String productName;
private Integer estimatedPalletCount;
private Integer packageCount;
private BigDecimal totalWeightKg;
private BigDecimal totalVolumeCbm;
private BigDecimal budgetAmount;
@TableField(updateStrategy = FieldStrategy.ALWAYS) private BigDecimal budgetAmount;
private String currency;
private String contactName;
private String contactInfo;
private LocalDate requestedDate;
private String originContactName;
private String originContactPhone;
private String destinationContactName;
private String destinationContactPhone;
@TableField(updateStrategy = FieldStrategy.ALWAYS) private LocalDateTime pickupAt;
@TableField(updateStrategy = FieldStrategy.ALWAYS) private LocalDateTime deliveryAt;
private String status;
private String remark;
@TableField(updateStrategy = FieldStrategy.ALWAYS) private String remark;
private String idempotencyKey;
private String requestHash;
private String createdBy;
@@ -0,0 +1,67 @@
package com.baiyue.lastleg.looseboard.repository;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.baiyue.lastleg.looseboard.entity.*;
import com.baiyue.lastleg.looseboard.dto.LocalLooseboardRequests;
import org.springframework.util.StringUtils;
import com.baiyue.lastleg.looseboard.mapper.*;
import com.baiyue.lastleg.entity.LastlegOrder;
import com.baiyue.lastleg.entity.LastlegOrderStatusHistory;
import com.baiyue.lastleg.mapper.LastlegOrderMapper;
import com.baiyue.lastleg.mapper.LastlegOrderStatusHistoryMapper;
import jakarta.annotation.Resource;
import org.springframework.stereotype.Repository;
import java.util.List;
@Repository
public class LocalLooseboardRepository {
@Resource private LocalLooseboardOrderMapper orders;
@Resource private LocalLooseboardHistoryMapper histories;
@Resource private LocalLooseboardOperationMapper operations;
@Resource private LastlegOrderMapper lastlegOrders;
@Resource private LastlegOrderStatusHistoryMapper lastlegHistories;
/** 按客户和创建幂等键读取订单。 */
public LocalLooseboardOrder findKey(String customer, String key) { return orders.findKey(customer, key); }
/** 按客户和业务单号读取订单。 */
public LocalLooseboardOrder findNo(String customer, String number) { return orders.findNo(customer, number); }
/** 锁定同客户订单保护编辑及状态推进。 */
public LocalLooseboardOrder lockNo(String customer, String number) { return orders.lockNo(customer, number); }
/** 在客户范围内组合路线、状态与取货日期筛选并返回分页需求。 */
public Page<LocalLooseboardOrder> page(LocalLooseboardRequests.Query query) {
LambdaQueryWrapper<LocalLooseboardOrder> wrapper = new LambdaQueryWrapper<LocalLooseboardOrder>()
.eq(StringUtils.hasText(query.customerId()), LocalLooseboardOrder::getCustomerId, trim(query.customerId()))
.like(StringUtils.hasText(query.orderNo()), LocalLooseboardOrder::getOrderNo, trim(query.orderNo()))
.and(StringUtils.hasText(query.routeKeyword()), value -> value
.like(LocalLooseboardOrder::getOrigin, trim(query.routeKeyword()))
.or().like(LocalLooseboardOrder::getDestination, trim(query.routeKeyword())))
.eq(StringUtils.hasText(query.status()), LocalLooseboardOrder::getStatus,
StringUtils.hasText(query.status()) ? query.status().trim().toUpperCase() : null)
.ge(query.requestedFrom() != null, LocalLooseboardOrder::getPickupAt, query.requestedFrom() == null ? null : query.requestedFrom().atStartOfDay())
.lt(query.requestedTo() != null, LocalLooseboardOrder::getPickupAt, query.requestedTo() == null ? null : query.requestedTo().plusDays(1).atStartOfDay())
.orderByDesc(LocalLooseboardOrder::getCreatedAt).orderByDesc(LocalLooseboardOrder::getId);
return orders.selectPage(Page.of(query.page(), query.size()), wrapper);
}
/** 规范查询文字,缺失值不参与筛选。 */
private static String trim(String value) { return value == null || value.isBlank() ? null : value.trim(); }
/** 保存散板需求。 */
public void insertOrder(LocalLooseboardOrder order) { orders.insert(order); }
/** 更新已锁定需求。 */
public void updateOrder(LocalLooseboardOrder order) { orders.updateById(order); }
/** 读取同客户订单历史。 */
public List<LocalLooseboardHistory> histories(String customer, Long id) { return histories.listOrder(customer, id); }
/** 保存不可变需求历史。 */
public void insertHistory(LocalLooseboardHistory history) { histories.insert(history); }
/** 读取状态命令幂等记录。 */
public LocalLooseboardOperation findOperation(String customer, Long id, String key) { return operations.findKey(customer, id, key); }
/** 保存命令幂等记录。 */
public void insertOperation(LocalLooseboardOperation operation) { operations.insert(operation); }
/** 保存唯一尾程来源。 */
public void insertTailOrder(LastlegOrder order) { lastlegOrders.insert(order); }
/** 读取关联尾程来源。 */
public LastlegOrder tailOrder(Long id) { return lastlegOrders.selectById(id); }
/** 更新关联尾程事实。 */
public void updateTailOrder(LastlegOrder order) { lastlegOrders.updateById(order); }
/** 保存尾程状态历史。 */
public void insertTailHistory(LastlegOrderStatusHistory history) { lastlegHistories.insert(history); }
}
@@ -1,23 +1,17 @@
package com.baiyue.lastleg.looseboard.service;
import com.baiyue.common.core.container.ContainerSpecificationCatalog;
import com.baiyue.lastleg.looseboard.repository.LocalLooseboardRepository;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.toolkit.IdWorker;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.baiyue.common.core.api.PageResult;
import com.baiyue.lastleg.entity.LastlegOrder;
import com.baiyue.lastleg.entity.LastlegOrderStatusHistory;
import com.baiyue.lastleg.mapper.LastlegOrderMapper;
import com.baiyue.lastleg.mapper.LastlegOrderStatusHistoryMapper;
import com.baiyue.lastleg.looseboard.dto.LocalLooseboardRequests;
import com.baiyue.lastleg.looseboard.dto.LocalLooseboardResponses;
import com.baiyue.lastleg.looseboard.entity.LocalLooseboardHistory;
import com.baiyue.lastleg.looseboard.entity.LocalLooseboardOrder;
import com.baiyue.lastleg.looseboard.entity.LocalLooseboardOperation;
import com.baiyue.lastleg.looseboard.mapper.LocalLooseboardHistoryMapper;
import com.baiyue.lastleg.looseboard.mapper.LocalLooseboardOrderMapper;
import com.baiyue.lastleg.looseboard.mapper.LocalLooseboardOperationMapper;
import com.baiyue.lastleg.service.LastlegOrderNoGenerator;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
@@ -48,36 +42,32 @@ public class LocalLooseboardService {
public static final String COMPLETED = "COMPLETED";
public static final String CANCELLED = "CANCELLED";
private static final Set<String> STATUSES = Set.of(PENDING_ACCEPTANCE, DISPATCHED, IN_TRANSIT, COMPLETED, CANCELLED, "WAITING_INBOUND", "RECEIVING", "WAITING_CONSOLIDATION", "PENDING_DISPATCH");
private static final Set<String> MODES = Set.of("SHARED_TRUCK", "PRIORITY_CONSOLIDATION");
private static final Map<String, Set<String>> TRANSITIONS = Map.of(
PENDING_ACCEPTANCE, Set.of(DISPATCHED, CANCELLED),
DISPATCHED, Set.of(IN_TRANSIT, CANCELLED),
IN_TRANSIT, Set.of(COMPLETED));
@Resource private LocalLooseboardOrderMapper orders;
@Resource private LocalLooseboardHistoryMapper histories;
@Resource private LocalLooseboardOperationMapper operations;
@Resource private LastlegOrderMapper lastlegOrders;
@Resource private LastlegOrderStatusHistoryMapper lastlegHistories;
@Resource private LocalLooseboardRepository repository;
@Resource private LastlegOrderNoGenerator lastlegOrderNoGenerator;
@Resource private ObjectMapper objectMapper;
/** 在同一事务保存客户需求、唯一尾程来源及创建历史,创建键重复时核对请求哈希。 */
@Transactional
public LocalLooseboardResponses.Detail create(String customerId, String operatorId,
LocalLooseboardRequests.Create request) {
String key = request.idempotencyKey().trim();
String requestHash = hash(request);
LocalLooseboardOrder replay = orders.findKey(customerId, key);
LocalLooseboardOrder replay = repository.findKey(customerId, key);
if (replay != null) return replay(customerId, replay, requestHash);
LocalDateTime now = LocalDateTime.now();
LocalLooseboardOrder order = new LocalLooseboardOrder();
order.setId(IdWorker.getId());
order.setCustomerId(customerId);
order.setOrderNo(lastlegOrderNoGenerator.next("LOOSEBOARD", request.destination(), order.getId()));
apply(order, request.transportMode(), request.palletRequired(), request.transportType(), request.origin(),
request.originAddress(), request.destination(), request.destinationAddress(), request.containerType(),
apply(order, request.origin(),
request.originAddress(), request.destination(), request.destinationAddress(), request.productName(), request.estimatedPalletCount(),
request.packageCount(), request.totalWeightKg(), request.totalVolumeCbm(), request.budgetAmount(),
request.currency(), request.contactName(), request.contactInfo(), request.requestedDate(), request.remark());
request.currency(), request.originContactName(), request.originContactPhone(), request.destinationContactName(), request.destinationContactPhone(), request.pickupAt(), request.deliveryAt(), request.remark());
order.setStatus(PENDING_ACCEPTANCE);
order.setIdempotencyKey(key);
order.setRequestHash(requestHash);
@@ -88,9 +78,9 @@ public class LocalLooseboardService {
LastlegOrder lastlegOrder = createLastlegOrder(order, requestHash, now);
order.setLastlegOrderId(lastlegOrder.getId());
try {
orders.insert(order);
repository.insertOrder(order);
} catch (DuplicateKeyException exception) {
LocalLooseboardOrder concurrent = orders.findKey(customerId, key);
LocalLooseboardOrder concurrent = repository.findKey(customerId, key);
if (concurrent != null) return replay(customerId, concurrent, requestHash);
throw exception;
}
@@ -101,44 +91,34 @@ public class LocalLooseboardService {
return detail(customerId, order.getOrderNo());
}
/** 按客户、状态及取货日期分页查询需求,并批量装配承运摘要。 */
@Transactional(readOnly = true, isolation = org.springframework.transaction.annotation.Isolation.REPEATABLE_READ)
public PageResult<LocalLooseboardResponses.Summary> page(LocalLooseboardRequests.Query query) {
validateQuery(query);
LambdaQueryWrapper<LocalLooseboardOrder> wrapper = new LambdaQueryWrapper<LocalLooseboardOrder>()
.eq(StringUtils.hasText(query.customerId()), LocalLooseboardOrder::getCustomerId, trim(query.customerId()))
.like(StringUtils.hasText(query.orderNo()), LocalLooseboardOrder::getOrderNo, trim(query.orderNo()))
.and(StringUtils.hasText(query.routeKeyword()), value -> value
.like(LocalLooseboardOrder::getOrigin, trim(query.routeKeyword()))
.or().like(LocalLooseboardOrder::getDestination, trim(query.routeKeyword())))
.eq(StringUtils.hasText(query.status()), LocalLooseboardOrder::getStatus,
StringUtils.hasText(query.status()) ? norm(query.status()) : null)
.eq(StringUtils.hasText(query.transportMode()), LocalLooseboardOrder::getTransportMode,
StringUtils.hasText(query.transportMode()) ? norm(query.transportMode()) : null)
.ge(query.requestedFrom() != null, LocalLooseboardOrder::getRequestedDate, query.requestedFrom())
.le(query.requestedTo() != null, LocalLooseboardOrder::getRequestedDate, query.requestedTo())
.orderByDesc(LocalLooseboardOrder::getCreatedAt).orderByDesc(LocalLooseboardOrder::getId);
Page<LocalLooseboardOrder> result = orders.selectPage(Page.of(query.page(), query.size()), wrapper);
Page<LocalLooseboardOrder> result = repository.page(query);
var arrangements = fleetPool.arrangements(result.getRecords().stream().map(this::poolKey).toList());
return new PageResult<>(result.getRecords().stream().map(o -> summary(o, arrangements.get(poolKey(o)))).toList(), result.getCurrent(),
result.getSize(), result.getTotal(), result.getPages());
}
/** Customer-scoped query that ignores any client-supplied customer filter. */
/** 客户查询强制使用认证客户范围,沿同一需求筛选合同分页。 */
@Transactional(readOnly = true, isolation = org.springframework.transaction.annotation.Isolation.REPEATABLE_READ)
public PageResult<LocalLooseboardResponses.Summary> page(String customerId,
LocalLooseboardRequests.MerchantQuery query) {
return page(new LocalLooseboardRequests.Query(query.page(), query.size(), customerId,
query.orderNo(), query.routeKeyword(), query.status(), query.transportMode(),
query.orderNo(), query.routeKeyword(), query.status(),
query.requestedFrom(), query.requestedTo()));
}
/** 按客户与业务单号读取需求及历史。 */
@Transactional(readOnly = true, isolation = org.springframework.transaction.annotation.Isolation.REPEATABLE_READ)
public LocalLooseboardResponses.Detail detail(String customerId, String orderNo) {
LocalLooseboardOrder order = orders.findNo(customerId, orderNo);
LocalLooseboardOrder order = repository.findNo(customerId, orderNo);
if (order == null) throw notFound();
return response(order);
}
/** 锁定待接单需求并校验版本,保存变更前后快照。 */
@Transactional
public LocalLooseboardResponses.Detail update(String customerId, String orderNo, String operatorId,
LocalLooseboardRequests.Update request) {
@@ -148,27 +128,28 @@ public class LocalLooseboardService {
throw conflict("Only pending-acceptance looseboard orders can be edited");
}
Map<String, Object> before = snapshot(order);
apply(order, request.transportMode(), request.palletRequired(), request.transportType(), request.origin(),
request.originAddress(), request.destination(), request.destinationAddress(), request.containerType(),
apply(order, request.origin(),
request.originAddress(), request.destination(), request.destinationAddress(), request.productName(), request.estimatedPalletCount(),
request.packageCount(), request.totalWeightKg(), request.totalVolumeCbm(), request.budgetAmount(),
request.currency(), request.contactName(), request.contactInfo(), request.requestedDate(), request.remark());
request.currency(), request.originContactName(), request.originContactPhone(), request.destinationContactName(), request.destinationContactPhone(), request.pickupAt(), request.deliveryAt(), request.remark());
Map<String, Object> after = snapshot(order);
if (before.equals(after)) return response(order);
LocalDateTime now = LocalDateTime.now();
touch(order, now);
orders.updateById(order);
repository.updateOrder(order);
history(order, "UPDATE", order.getStatus(), order.getStatus(), operatorId,
null, changeDetail(before, after), null, now);
return response(order);
}
/** 按版本和独立幂等键推进允许的状态,同时记录不可变历史。 */
@Transactional
public LocalLooseboardResponses.Detail changeStatus(String customerId, String orderNo, String operatorId,
LocalLooseboardRequests.ChangeStatus request) {
LocalLooseboardOrder order = lock(customerId, orderNo);
String key = request.idempotencyKey().trim();
String requestHash = hash(request);
LocalLooseboardOperation existing = operations.findKey(customerId, order.getId(), key);
LocalLooseboardOperation existing = repository.findOperation(customerId, order.getId(), key);
if (existing != null) {
if (!"STATUS".equals(existing.getOperationType()) || !requestHash.equals(existing.getRequestHash())) {
throw conflict("Idempotency key was reused with a different looseboard status payload");
@@ -186,81 +167,95 @@ public class LocalLooseboardService {
LocalDateTime now = LocalDateTime.now();
order.setStatus(next);
touch(order, now);
orders.updateById(order);
repository.updateOrder(order);
syncLastlegOrder(order, next, now);
LocalLooseboardOperation operation = new LocalLooseboardOperation();
operation.setId(IdWorker.getId()); operation.setOrderId(order.getId());
operation.setCustomerId(customerId); operation.setOperationType("STATUS");
operation.setIdempotencyKey(key); operation.setRequestHash(requestHash);
operation.setResultVersion(order.getVersion()); operation.setCreatedBy(operatorId);
operation.setCreatedAt(now); operations.insert(operation);
operation.setCreatedAt(now); repository.insertOperation(operation);
history(order, "STATUS", from, next, operatorId, key, null, request.remark(), now);
return response(order);
}
private void apply(LocalLooseboardOrder order, String mode, String palletRequired, String transportType,
/** 保存申报货物与当地取送时间,不预设内部安排或实际收货。 */
private void apply(LocalLooseboardOrder order,
String origin, String originAddress, String destination, String destinationAddress,
String containerType, Integer packageCount, java.math.BigDecimal totalWeightKg,
String productName, Integer estimatedPalletCount, Integer packageCount, java.math.BigDecimal totalWeightKg,
java.math.BigDecimal totalVolumeCbm, java.math.BigDecimal budgetAmount, String currency,
String contactName, String contactInfo, java.time.LocalDate requestedDate, String remark) {
order.setTransportMode(norm(mode));
order.setPalletRequired(norm(palletRequired));
order.setTransportType(norm(transportType));
String originContactName, String originContactPhone, String destinationContactName, String destinationContactPhone, LocalDateTime pickupAt, LocalDateTime deliveryAt, String remark) {
if (!StringUtils.hasText(originContactName) || !StringUtils.hasText(originContactPhone)
|| !StringUtils.hasText(destinationContactName) || !StringUtils.hasText(destinationContactPhone)) {
throw badRequest("请填写起运地和目的地联系人及联系电话");
}
if (!LocalLooseboardRequests.validSchedule(pickupAt, deliveryAt)) {
throw badRequest("取货和送货时间必填,送货时间不能早于取货时间");
}
order.setProductName(trim(productName));
order.setEstimatedPalletCount(estimatedPalletCount);
order.setOrigin(origin.trim());
order.setOriginAddress(trim(originAddress));
order.setDestination(destination.trim());
order.setDestinationAddress(trim(destinationAddress));
order.setContainerType(StringUtils.hasText(containerType)
? ContainerSpecificationCatalog.require(containerType).containerType()
: ContainerSpecificationCatalog.DEFAULT_CONTAINER_TYPE);
order.setPackageCount(packageCount);
order.setTotalWeightKg(totalWeightKg);
order.setTotalVolumeCbm(totalVolumeCbm);
order.setBudgetAmount(budgetAmount);
order.setCurrency(StringUtils.hasText(currency) ? norm(currency) : "USD");
order.setContactName(trim(contactName));
order.setContactInfo(trim(contactInfo));
order.setRequestedDate(requestedDate);
order.setOriginContactName(originContactName.trim());
order.setOriginContactPhone(originContactPhone.trim());
order.setDestinationContactName(destinationContactName.trim());
order.setDestinationContactPhone(destinationContactPhone.trim());
order.setPickupAt(pickupAt);
order.setDeliveryAt(deliveryAt);
order.setRemark(trim(remark));
}
/** 拒绝幂等键绑定不同内容,重复请求返回原需求。 */
private LocalLooseboardResponses.Detail replay(String customerId, LocalLooseboardOrder order, String hash) {
if (!hash.equals(order.getRequestHash())) throw conflict("Idempotency key was reused with different looseboard data");
return detail(customerId, order.getOrderNo());
}
/** 锁定客户订单并拒绝池来源的普通编辑。 */
private LocalLooseboardOrder lock(String customerId, String orderNo) {
LocalLooseboardOrder order = orders.lockNo(customerId, orderNo);
LocalLooseboardOrder order = repository.lockNo(customerId, orderNo);
if (order == null) throw notFound();
fleetPool.requireStandalone(customerId, "LOCAL_LOOSEBOARD", order.getId());
return order;
}
/** 装配需求详情、真实承运安排与不可变历史。 */
private LocalLooseboardResponses.Detail response(LocalLooseboardOrder order) {
return new LocalLooseboardResponses.Detail(summary(order), order.getOriginAddress(),
order.getDestinationAddress(), order.getRemark(), order.getCreatedBy(),
histories.listOrder(order.getCustomerId(), order.getId()).stream()
repository.histories(order.getCustomerId(), order.getId()).stream()
.map(value -> new LocalLooseboardResponses.History(value.getOperationType(), value.getFromStatus(),
value.getToStatus(), value.getOperatorId(), value.getIdempotencyKey(),
value.getChangeDetail(), value.getRemark(),
value.getCreatedAt())).toList());
}
/** 构造同客户商业订单的承运查询身份。 */
private com.baiyue.lastleg.fleet.business.FleetPoolOrderModels.OrderKey poolKey(LocalLooseboardOrder order) {
return new com.baiyue.lastleg.fleet.business.FleetPoolOrderModels.OrderKey(order.getCustomerId(), "LOCAL_LOOSEBOARD", order.getId());
}
/** 装配原值需求摘要,预计托数与实际履约保持独立。 */
private LocalLooseboardResponses.Summary summary(LocalLooseboardOrder order) {
return summary(order, fleetPool.arrangement(poolKey(order)));
}
/** 装配原值需求摘要,预计托数与实际履约保持独立。 */
private LocalLooseboardResponses.Summary summary(LocalLooseboardOrder order, com.baiyue.lastleg.fleet.business.FleetPoolOrderModels.Arrangement arrangement) {
return new LocalLooseboardResponses.Summary(order.getId(), order.getCustomerId(), order.getOrderNo(),
order.getTransportMode(), order.getPalletRequired(), order.getTransportType(), order.getOrigin(),
order.getDestination(), order.getContainerType(), Objects.requireNonNullElse(order.getPackageCount(), 0),
order.getPalletRequired(), order.getTransportType(), order.getOrigin(),
order.getDestination(), order.getProductName(), order.getEstimatedPalletCount(), Objects.requireNonNullElse(order.getPackageCount(), 0),
order.getTotalWeightKg(), order.getTotalVolumeCbm(), order.getBudgetAmount(), order.getCurrency(),
order.getContactName(), order.getContactInfo(), order.getRequestedDate(), order.getStatus(),
order.getOriginContactName(), order.getOriginContactPhone(), order.getDestinationContactName(), order.getDestinationContactPhone(), order.getPickupAt(), order.getDeliveryAt(), order.getStatus(),
value(order.getVersion()), order.getCreatedAt(), order.getUpdatedAt(), arrangement);
}
/** 写入带操作者和变更快照的订单历史。 */
private void history(LocalLooseboardOrder order, String type, String from, String to, String operator,
String idempotencyKey, String changeDetail, String remark, LocalDateTime now) {
LocalLooseboardHistory history = new LocalLooseboardHistory();
@@ -275,9 +270,10 @@ public class LocalLooseboardService {
history.setChangeDetail(changeDetail);
history.setRemark(trim(remark));
history.setCreatedAt(now);
histories.insert(history);
repository.insertHistory(history);
}
/** 为独立下单创建唯一尾程来源,不按预计托数生成实物托盘。 */
private LastlegOrder createLastlegOrder(LocalLooseboardOrder source, String requestHash, LocalDateTime now) {
LastlegOrder order = new LastlegOrder();
order.setId(IdWorker.getId());
@@ -302,21 +298,23 @@ public class LocalLooseboardService {
order.setSourcePayloadHash("sha256:" + requestHash);
order.setCreatedAt(now);
order.setUpdatedAt(now);
lastlegOrders.insert(order);
repository.insertTailOrder(order);
return order;
}
/** 在同客户边界内同步关联尾程的商业状态。 */
private void syncLastlegOrder(LocalLooseboardOrder source, String status, LocalDateTime now) {
if (source.getLastlegOrderId() == null) return;
LastlegOrder order = lastlegOrders.selectById(source.getLastlegOrderId());
LastlegOrder order = repository.tailOrder(source.getLastlegOrderId());
if (order == null || !source.getCustomerId().equals(order.getCustomerId())) {
throw conflict("Linked tail-leg order is missing");
}
order.setSubOrderStatus("LOCAL_LOOSEBOARD:" + status);
order.setUpdatedAt(now);
lastlegOrders.updateById(order);
repository.updateTailOrder(order);
}
/** 追加尾程状态历史。 */
private void lastlegHistory(String customerId, Long orderId, String from, String to, String remark,
LocalDateTime now) {
LastlegOrderStatusHistory history = new LastlegOrderStatusHistory();
@@ -327,44 +325,47 @@ public class LocalLooseboardService {
history.setEventId("LOCAL_LOOSEBOARD");
history.setRemark(remark);
history.setCreatedAt(now);
lastlegHistories.insert(history);
repository.insertTailHistory(history);
}
/** 校验状态与取货日期筛选边界。 */
private void validateQuery(LocalLooseboardRequests.Query query) {
if (StringUtils.hasText(query.status()) && !STATUSES.contains(norm(query.status()))) {
throw badRequest("Unsupported looseboard order status");
}
if (StringUtils.hasText(query.transportMode()) && !MODES.contains(norm(query.transportMode()))) {
throw badRequest("Unsupported looseboard transport mode");
}
if (query.requestedFrom() != null && query.requestedTo() != null
&& query.requestedFrom().isAfter(query.requestedTo())) {
throw badRequest("requestedFrom must not be after requestedTo");
}
}
/** 冻结本次编辑的业务字段用于差异审计。 */
private Map<String, Object> snapshot(LocalLooseboardOrder order) {
Map<String, Object> value = new LinkedHashMap<>();
value.put("transportMode", order.getTransportMode());
value.put("palletRequired", order.getPalletRequired());
value.put("transportType", order.getTransportType());
value.put("origin", order.getOrigin());
value.put("originAddress", order.getOriginAddress());
value.put("destination", order.getDestination());
value.put("destinationAddress", order.getDestinationAddress());
value.put("containerType", order.getContainerType());
value.put("productName", order.getProductName());
value.put("estimatedPalletCount", order.getEstimatedPalletCount());
value.put("packageCount", order.getPackageCount());
value.put("totalWeightKg", order.getTotalWeightKg());
value.put("totalVolumeCbm", order.getTotalVolumeCbm());
value.put("budgetAmount", order.getBudgetAmount());
value.put("currency", order.getCurrency());
value.put("contactName", order.getContactName());
value.put("contactInfo", order.getContactInfo());
value.put("requestedDate", order.getRequestedDate());
value.put("originContactName", order.getOriginContactName());
value.put("originContactPhone", order.getOriginContactPhone());
value.put("destinationContactName", order.getDestinationContactName());
value.put("destinationContactPhone", order.getDestinationContactPhone());
value.put("pickupAt", order.getPickupAt());
value.put("deliveryAt", order.getDeliveryAt());
value.put("remark", order.getRemark());
return value;
}
/** 生成字段前后值的不可变变更快照。 */
private String changeDetail(Map<String, Object> before, Map<String, Object> after) {
Map<String, Object> changes = new LinkedHashMap<>();
before.forEach((field, oldValue) -> {
@@ -383,6 +384,7 @@ public class LocalLooseboardService {
}
}
/** 计算规范请求哈希以约束创建和状态命令幂等。 */
private String hash(Object value) {
try {
return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256")
@@ -392,20 +394,27 @@ public class LocalLooseboardService {
}
}
/** 递增锁定订单的版本并更新时间。 */
private static void touch(LocalLooseboardOrder order, LocalDateTime now) {
order.setVersion(value(order.getVersion()) + 1);
order.setUpdatedAt(now);
}
/** 将未初始化的版本视作初始版本。 */
private static long value(Long value) { return value == null ? 0 : value; }
/** 规范选填文字,空白保存为未知。 */
private static String trim(String value) { return value == null || value.isBlank() ? null : value.trim(); }
/** 统一内部枚举和币种大小写。 */
private static String norm(String value) { return value.trim().toUpperCase(); }
/** 返回明确的输入校验失败。 */
private static ResponseStatusException badRequest(String message) {
return new ResponseStatusException(HttpStatus.BAD_REQUEST, message);
}
/** 返回版本、幂等或状态冲突。 */
private static ResponseStatusException conflict(String message) {
return new ResponseStatusException(HttpStatus.CONFLICT, message);
}
/** 隐藏客户范围外订单并返回未找到。 */
private static ResponseStatusException notFound() {
return new ResponseStatusException(HttpStatus.NOT_FOUND, "本土散板订单不存在或无权查看");
}
@@ -74,6 +74,15 @@ public class MerchantRelabelApplicationController {
if ("ITEM".equalsIgnoreCase(scope)) return ApiResponse.success(service.uploadItemFile(customer(user), relabelNo, itemId == null ? 0L : itemId, operatorLong(user), file));
return ApiResponse.success(service.uploadTotalFile(customer(user), relabelNo, operatorLong(user), file, kind));
}
/** 按登录客户及申请绑定关系读取贴标资料,禁止跨客户或跨明细下载。 */
@GetMapping("/{relabelNo}/files/{fileId}/download")
@PreAuthorize("hasAuthority('" + OverseasPermissions.MERCHANT_RELABEL_LIST + "')")
public ResponseEntity<byte[]> downloadFile(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable String relabelNo, @PathVariable long fileId, @RequestParam String scope,
@RequestParam(required = false) Long itemId) {
var file = service.downloadFile(customer(user), relabelNo, scope, itemId, fileId);
return download(file.content(), file.fileName(), file.contentType());
}
@GetMapping("/{relabelNo}/shipping-label/{fileId}/download")
@PreAuthorize("hasAuthority('" + OverseasPermissions.MERCHANT_RELABEL_LIST + "')")
public ResponseEntity<byte[]> downloadShippingLabel(@AuthenticationPrincipal AuthenticatedUser user,
@@ -134,6 +134,17 @@ public class RelabelApplicationController {
return ResponseEntity.ok().cacheControl(org.springframework.http.CacheControl.noStore()).header("Content-Disposition", ContentDisposition.inline().filename(file.fileName(), StandardCharsets.UTF_8).build().toString())
.contentType(org.springframework.http.MediaType.parseMediaType(file.contentType())).contentLength(file.content().length).body(file.content());
}
/** 平台按申请绑定关系读取客户上传的面单。 */
@GetMapping("/{relabelNo}/shipping-label/{fileId}/download")
@PreAuthorize("hasAuthority('" + OverseasPermissions.RELABEL_ORDER_LIST + "')")
public ResponseEntity<byte[]> downloadShippingLabel(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable String relabelNo, @PathVariable long fileId) {
requirePlatform(user);
var file = service.downloadShippingLabel(service.customerId(relabelNo), relabelNo, fileId);
return ResponseEntity.ok().cacheControl(org.springframework.http.CacheControl.noStore())
.header("Content-Disposition", ContentDisposition.inline().filename(file.fileName(), StandardCharsets.UTF_8).build().toString())
.contentType(org.springframework.http.MediaType.parseMediaType(file.contentType())).contentLength(file.content().length).body(file.content());
}
@GetMapping("/{relabelNo}/completion-photos/{fileId}/download")
@PreAuthorize("hasAuthority('" + OverseasPermissions.RELABEL_ORDER_LIST + "')")
public ResponseEntity<byte[]> downloadCompletionPhoto(@AuthenticationPrincipal AuthenticatedUser user,
@@ -6,6 +6,16 @@ import org.apache.ibatis.annotations.Mapper;
@Mapper
public interface RelabelItemMapper extends BaseMapper<RelabelItem> {
/** 批量查询退货箱已完成的换标事实,避免逐箱查询。 */
@org.apache.ibatis.annotations.Select("<script>SELECT DISTINCT i.return_carton_id FROM ll_relabel_item i " +
"JOIN ll_relabel_application a ON a.id=i.application_id AND a.customer_id=i.customer_id " +
"WHERE i.customer_id=#{customerId} AND i.return_carton_id IN " +
"<foreach collection='cartonIds' item='cartonId' open='(' separator=',' close=')'>#{cartonId}</foreach> " +
"AND a.status NOT IN ('CANCELLED','REJECTED') AND a.customer_confirmed_at IS NOT NULL " +
"AND EXISTS(SELECT 1 FROM ll_relabel_pda_unit_fact f WHERE f.item_id=i.id AND f.result='RELABELLED')</script>")
java.util.List<Long> completedReturnCartonIds(@org.apache.ibatis.annotations.Param("customerId") String customerId,
@org.apache.ibatis.annotations.Param("cartonIds") java.util.List<Long> cartonIds);
@org.apache.ibatis.annotations.Select("SELECT ri.* FROM ll_relabel_item ri JOIN ll_relabel_application ra ON ra.id=ri.application_id " +
"WHERE ri.customer_id=#{customerId} AND ri.return_carton_id=#{cartonId} AND ri.application_id<>#{currentApplicationId} " +
"AND ra.status NOT IN ('CANCELLED','REJECTED') AND ra.customer_confirmed_at IS NOT NULL " +

Some files were not shown because too many files have changed in this diff Show More