Merge latest test updates into dev [skip ci]

This commit is contained in:
2026-10-23 09:41:44 +08:00
226 changed files with 7833 additions and 892 deletions
+4
View File
@@ -1,5 +1,9 @@
# 单测试环境数据库空库初始化
放舱后预计到港窄维护需要配套更新 `trg_ocean_booking_before_update`:仅从冻结比较中移除 `estimated_arrival_at`,其余运输、预报、关系方及柜型冻结不变。定义已同步 `firstleg/src/main/resources/db/phase3-ocean-booking-schema.sql` 与国内统一基线;已有 TEST 需另行审阅并授权替换该触发器,不重放空库文件,不回填数据。本轮只准备定义,未执行 SQL 或部署。
货柜关联已放舱订舱时不再限制 ISO 柜号格式,Firstleg 模块基线与国内统一空库基线已移除 `chk_container_booking_iso6346`,保留同订舱柜号唯一键。既有 TEST 发布前需核对并单独授权移除该约束:`ALTER TABLE baiyue_firstleg.fl_container_booking DROP CHECK chk_container_booking_iso6346;`。配套发布 Firstleg 与 Admin;不重放空库初始化、不修改业务数据。本次仅准备源码与 SQL 定义,未执行环境 SQL 或部署。
仓储转运到仓取货、本土卡车接入现有排车的**可重复执行汇总SQL**为 `lastleg-dispatch-transport-tasks.sql`,文件内固定 `USE baiyue_lastleg`,对已存在字段、表安全跳过。新增 `ll_dispatch_order.task_type` 字段、`ll_dispatch_truck_task`、`ll_dispatch_transport_create`、`ll_dispatch_transport_command` 三张表,并确保既有正式排车单号日流水表存在;不回填或删除业务数据。前置是目标库已具备现有整车排车主表、FleetTrip、取货任务及本土卡车原单结构。按序先执行此 SQL,再配套发布 Lastleg 与 Admin;取消释放有效原单关联、完成保留关联。`migrations/overseas/baiyue_lastleg/V32__dispatch_transport_tasks.sql` 是迁移系统中的新增结构记录。本次仅在已授权本机执行并验收,不代表远端已部署。
排车单号使用`PCyyyyMMdd0001`,日期取`Asia/Shanghai`,全平台跨客户/仓库共用独立日流水。`ll_dispatch_daily_sequence`同步Lastleg源基线与海外空库基线;已有结构的定向新增入口为`lastleg-dispatch-daily-sequence.sql`,只建序列表,不改已有业务号、不回填历史、不重放空库初始化。源码和DDL须配套发布;文件存在不表示已执行或部署。流水在独立短事务分配,回滚或取消不复用,四位范围每日0001至9999,准确单量以主单统计为准。
@@ -1484,7 +1484,6 @@ CREATE TABLE fl_container_booking (
KEY idx_container_booking_voyage (voyage_no,updated_at),
KEY idx_container_booking_transit_summary (actual_sailing_at,actual_warehouse_arrival_at,ocean_booking_id,container_type),
CONSTRAINT chk_container_booking_capacity CHECK (((capacity_cbm is null) or (capacity_cbm > 0))),
CONSTRAINT chk_container_booking_iso6346 CHECK (((container_no is null) or regexp_like(container_no,_utf8mb4'^[A-Z]{3}[UJZ][0-9]{7}$'))),
CONSTRAINT chk_container_booking_pickup_code CHECK (((pickup_code is null) or regexp_like(pickup_code,_utf8mb4'^[0-9]{1,64}$'))),
CONSTRAINT chk_container_booking_record_status CHECK ((record_status in (_utf8mb4'ACTIVE',_utf8mb4'MERGED'))),
CONSTRAINT chk_container_booking_source CHECK ((source_type in (_utf8mb4'SIMULATION',_utf8mb4'OCEAN_BOOKING'))),
@@ -6049,7 +6048,7 @@ END$$
CREATE TRIGGER trg_ocean_booking_before_update BEFORE UPDATE ON fl_ocean_booking FOR EACH ROW
BEGIN
-- 已放舱只允许实货量及其审计元数据变化,运输和预报快照保持冻结。
-- 已放舱只允许预计到港、实货量及审计元数据变化,其余运输和预报快照保持冻结。
IF OLD.status = 'RELEASED' AND NOT (
(NEW.id <=> OLD.id)
AND (NEW.booking_no <=> OLD.booking_no)
@@ -6074,7 +6073,6 @@ BEGIN
AND (NEW.destination_port_name <=> OLD.destination_port_name)
AND (NEW.place_of_delivery <=> OLD.place_of_delivery)
AND (NEW.estimated_sailing_at <=> OLD.estimated_sailing_at)
AND (NEW.estimated_arrival_at <=> OLD.estimated_arrival_at)
AND (NEW.remark <=> OLD.remark)
AND (NEW.freight_term <=> OLD.freight_term)
AND (NEW.service_term <=> OLD.service_term)
@@ -6089,7 +6087,7 @@ BEGIN
AND (NEW.created_at <=> OLD.created_at)
AND NEW.version = OLD.version + 1
) THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='已放舱运输资料不可修改,仅允许补录实货量';
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='已放舱仅允许维护预计到港及补录实货量';
END IF;
IF OLD.status <> NEW.status AND NOT (
(OLD.status='DRAFT' AND NEW.status='RELEASED')) THEN
+3 -3
View File
@@ -79,8 +79,8 @@
- 客户箱规配置必须恰好保持四档且默认算法在四行一致;`ADD` 以实测尺寸为基准对三个维度分别增加命中箱规的尺寸值,`MULTIPLY` 使用当前客户唯一默认计费规则,并由服务端按规则中的规格档位选择系数。测量行必须保存实际尺寸、选用规则和算法快照,页面不得自行重算后覆盖服务端结果;计价使用用户确认的重量/体积选择值并纳入幂等请求哈希。
- 报关、订舱和拖车引用的供应商必须通过 `WarehouseSupplierSnapshotService` 按 ID 向 Warehouse 校验:报关使用 `CUSTOMS`,订舱使用 `BOOKING`(不区分国内/国外 scope),国内拖车使用 `TRUCKING + truckingScope=DOMESTIC`,清关提柜/目的港码头拖车使用 `TRUCKING + truckingScope=OVERSEAS`;供应商必须为 `ACTIVE`。请求中的名称只能作为一致性提示,持久化名称必须来自 Warehouse 快照,禁止用自由文本绕过 ID、类别或启用状态校验。修改任一供应商引用 DTO、OpenAPI 或内部客户端时,必须同步检查三条业务链、Warehouse `supplier-v1.yaml`、数据库快照字段和对应契约测试。
- Order 渠道变更门禁必须检查装柜或后续状态、PDA task、预装、分柜及同柜组兼容性;任何不确定结果均失败关闭。`ORDER_DECLARATION_UPDATED_V1` 消费必须校验订单号/入仓码归属,永久保留 `eventId + payload hash` 幂等历史并按版本单调更新申报投影;不得覆盖仓库实测、箱码、测量审核或已确认费用。
- 订舱状态固定为 `DRAFT → RELEASED`:草稿允许资料不完整并可替换或删除,放舱时校验完整持久化聚合,放舱后运输、预报货量、三方和柜型需求只读;仅三项实际货量允许通过 `OceanBookingService.updateActualCargo` 按编辑权限、版本、幂等键和原因补录并保存前后审计快照。创建草稿和放舱都只维护订舱聚合、三类参与方及柜型需求,不创建真实 `containerNo`、货柜行或分配关系;真实货柜只在后续提柜关联时占用已放舱柜型需求。
- 订舱柜型需求至少一项、最多 20 项,数量必须为正整数,容量和限重必须匹配平台柜型规格。起运仓必须是启用的国内中转仓;物流渠道必须来自该仓当前 ACTIVE 价格版本,服务端保存渠道名称快照,渠道失效时失败关闭。
- 订舱状态固定为 `DRAFT → RELEASED`:草稿允许资料不完整并可替换或删除,放舱时校验完整持久化聚合,放舱后除预计到港外的运输、预报货量、三方和柜型需求只读;预计到港允许通过 `/estimated-arrival` 按编辑权限、版本与幂等键单独维护,保留前后审计快照且不得清空或早于预计开船,不改实际到港或计划到仓;三项实际货量允许通过 `OceanBookingService.updateActualCargo` 按编辑权限、版本、幂等键和原因补录并保存前后审计快照。创建草稿和放舱都只维护订舱聚合、三类参与方及柜型需求,不创建真实 `containerNo`、货柜行或分配关系;真实货柜只在后续提柜关联时占用已放舱柜型需求。
- 订舱柜型需求至少一项、最多 20 项,数量必须为正整数,容量和限重必须匹配平台柜型规格。起运仓必须是启用的国内中转仓;物流渠道必须来自该仓当前 ACTIVE 价格版本,服务端保存渠道名称快照,渠道失效时失败关闭。预计到港草稿可留空,放舱前必填且不得早于预计开船时间;关联柜次时带入柜级到港来源,供日历和默认到仓日期解析使用。
- 订舱号遵循 `../../../docs/业务单据编号契约.md`;不得以实体 ID 拼接生成。提单号在订舱主表全局唯一,柜次表只保存快照;真实集装箱号只在同一订舱内唯一,不同提单允许复用。放舱不要求真实柜号,也不得为满足放舱校验伪造柜号。
- 订舱列表 `containerCount` 固定为该订舱全部柜型需求 `quantity` 之和,与真实柜关联/解除无关;关联候选只返回 `RELEASED`、剩余数量大于 0 且 `warehouseId + channelCode + containerType` 精确匹配的需求,由服务端集合查询 ACTIVE 分配量。前端不得扇出详情计算或用已关联柜数覆盖该字段,最终关联命令仍须重验目标货柜可修改性。
- 拖车预约是平台级订舱聚合,不是客户或柜次资源:请求、实体、查询、响应和附件不得恢复 `customerId/customerName`,公开 DTO 也不得出现 `containerBookingId/loadingPlanId/cabinetNo/containerNo`。创建候选只返回有提单号、ACTIVE `BOOKING_CONFIRMATION` 或 `BILL_OF_LADING` 且没有非终态拖车单的 `RELEASED` 订舱;创建或编辑只冻结订舱仓库/提单与 TRUCKING/DOMESTIC 国内拖车供应商快照。每订舱通过查询锁与唯一约束最多一张非终态预约,重复创建返回现有派车单号;PDA 装柜完成不得改变拖车状态;只有订舱全部柜型需求均已关联且每柜全部入仓绑定确认国内离仓,才自动完成(含草稿/晚提交)。离仓、拖车创建/编辑/操作统一先锁订舱,完成状态、`AUTO_COMPLETE` 历史、按实装体积守恒分摊的柜次应付及 Finance Outbox 同事务落库;取消/异常单不自动恢复,已有有效或完成单不得重复创建。应付拖车费仅人民币 RMB(财务快照为 CNY),固定使用 `FEE_TYPE_108`,关联 ACTIVE/TRUCKING/DOMESTIC 供应商,零金额不生成费用;正金额缺归属、有效体积或费用配置时失败关闭,不丢费、不伪造均分。手工 DEPART/COMPLETE 动作已移除。附件使用 `tenantId=0 + TRUCK_APPOINTMENT_ATTACHMENT + appointmentId`,提单文件继续归订舱。
@@ -94,7 +94,7 @@
## 国内货柜履约装柜与 PDA 资源结算
- 新增模拟柜只保存智能装柜的 9 类计划渠道 channel_group_code;未关联已放舱订舱时 channel_code/channel_name 为空,关联后才冻结订舱的实际渠道。智能装柜全局工作台和订舱关联候选均按计划渠道组匹配;服务端把业务渠道分组映射为实际渠道,并分别分页查询候选与 `WAIT_LOADING/EDITING/LOADING` 柜次;候选支持按创建日期和预计到货日期闭区间筛选,状态筛选使用最多四项的 `orderStatuses`,未传表示 `PENDING_REVIEW/WAIT_MEASURE/WAIT_SPLIT/LOADING` 全部未完成状态。状态只用于搜索和展示,能否加入以响应 `canAssign` 为准,由有效柜次占用、剩余箱数、容量、组锁和版本等事实决定;人工加入允许订单国内仓及渠道与柜次不同,智能推荐允许跨国内仓但仍只选择柜次渠道。容积和重量均允许达到柜型额定值的 150%,超过即拒绝。待审核事实必须经 `PendingReviewOrderClient` 重新核验 Order 权威 `SUBMITTED` 订单,待审核或待测量可先占位,但确认预装和启动 PDA 前仍必须进入 `WAIT_SPLIT`。计划明细通过 `totalPackageCount/remainingPackageCount` 返回可调总量和余量:测量前取 Order 原始申报箱数,进入 `WAIT_SPLIT/LOADING` 后取最新已审核测量箱数;只读补充失败时保守回退当前已分配数,调整写入仍重新查询并校验权威数量。同柜不可拆组只有全部成员命中状态集合时才返回,且必须成员完整、满箱并作为单个分页与分配单元,不能跨页或拆分。一次性移除未拣明细时,普通未拣项直接移除;同柜组仅全组未拣时整组移除,任一成员已拣则整组保留,单事务只递增一次计划版本。推荐顺序按入库时间先进先出。真实集装箱号允许在关联已放舱订舱时暂缺,但确认预装及创建 PDA 任务前必须补齐;历史已建但缺号的 PDA 任务可在预装详情首次补录,补录后按柜次当前真实集装箱号恢复作业。装柜计划状态为 `EDITING -> LOADING -> COMPLETED`,PDA 全部明细处理完成后自动完成。
- 新增模拟柜只保存智能装柜的 9 类计划渠道 channel_group_code;未关联已放舱订舱时 channel_code/channel_name 为空,关联后才冻结订舱的实际渠道。智能装柜全局工作台和订舱关联候选均按计划渠道组匹配;服务端把业务渠道分组映射为实际渠道,并分别分页查询候选与 `WAIT_LOADING/EDITING/LOADING` 柜次;候选支持按创建日期和预计到货日期闭区间筛选,状态筛选使用最多四项的 `orderStatuses`,未传表示 `PENDING_REVIEW/WAIT_MEASURE/WAIT_SPLIT/LOADING` 全部未完成状态。状态只用于搜索和展示,能否加入以响应 `canAssign` 为准,由有效柜次占用、剩余箱数、容量、组锁和版本等事实决定;人工加入允许订单国内仓及渠道与柜次不同,智能推荐允许跨国内仓但仍只选择柜次渠道。容积和重量均允许达到柜型额定值的 150%,超过即拒绝。待审核事实必须经 `PendingReviewOrderClient` 重新核验 Order 权威 `SUBMITTED` 订单,待审核或待测量可先占位,但确认预装和启动 PDA 前仍必须进入 `WAIT_SPLIT`。计划明细通过 `totalPackageCount/remainingPackageCount` 返回可调总量和余量:测量前取 Order 原始申报箱数,进入 `WAIT_SPLIT/LOADING` 后取最新已审核测量箱数;只读补充失败时保守回退当前已分配数,调整写入仍重新查询并校验权威数量。同柜不可拆组只有全部成员命中状态集合时才返回,且必须成员完整、满箱并作为单个分页与分配单元,不能跨页或拆分。一次性移除未拣明细时,普通未拣项直接移除;同柜组仅全组未拣时整组移除,任一成员已拣则整组保留,单事务只递增一次计划版本。推荐顺序按入库时间先进先出。真实集装箱号允许在关联已放舱订舱时暂缺;该关联入口仅规范化空白和大小写,不以 ISO 长度、结构、设备类别或校验位阻断,数据库不设置 ISO 格式 CHECK,仍遵守同订舱唯一及已有柜号一致性。关联已接受的柜号在运输信息未改号时不重复做 ISO 校验;确认预装与创建 PDA 任务前必须校验 ISO 格式并提示在预装运输信息中纠错,避免实物作业后无法完成,PDA 完成及跨服务事件保持原有格式校验;运输或提柜首次录入及纠正为不同柜号保留原有规则。确认预装及创建 PDA 任务前必须补齐;历史已建但缺号的 PDA 任务可在预装详情首次补录,补录后按柜次当前真实集装箱号恢复作业。装柜计划状态为 `EDITING -> LOADING -> COMPLETED`,PDA 全部明细处理完成后自动完成。
- 预装进入 `CONFIRMING/CONFIRMED` 只固化确认快照,不得锁死尚未完成柜次的智能装柜分配;装柜计划仍处于 `EDITING/LOADING` 时可继续新增,未产生实物拣货的明细可调整或移除,已拣明细保持锁定。一件代发智能装柜候选以每个未被有效柜次占用的已封托托盘为独立分页、展示和分配单位;同一入仓单有多托时必须逐托返回,其中一托已分配不得隐藏或禁用其余未分配托盘。智能推荐必须逐个调用整托分配,容量不足时跳过整托,不得回退按箱分配;全局工作台传入当前 `customerId/inboundCode` 筛选范围时,服务端只能推荐同时命中的候选,同一次推荐按受影响客户统一重算费用,不得逐托重复重算。单行删除必须按该行 `palletId` 整托移除。装柜完成事件必须按计划项 `palletId` 精确冻结每托箱唛与 SKU 清单,不得仅按入仓单或货物明细关联而造成多托交叉重复。
- 客户入库费用只在登录客户范围、测量审核通过、`feeStatus=SENT` 且存在已发布明细时可见;确认必须具备 `merchant:order:edit`,携带当前 `expectedFeeVersion` 与幂等键。成功后记录确认成员和时间;客户未确认或该票尚未装柜完成时,平台仍可修改箱规规则、生成新费用版本并重新发送,新版本必须清空旧版客户确认快照。仅当“客户已确认且该票已装柜完成”同时成立时最终冻结;装柜完成不得代替客户自动确认。旧版本、未发送或重复异参确认均返回冲突,不做兼容覆盖。
- PDA 普通散箱拣货以 `fl_inbound_carton_label.box_code` 对应的稳定箱唛箱码为扫描身份;审核建单、重复打印、手工测量和墨家测量复用同一映射,版本字段只用于并发控制,不进入扫码文本。一个有效且未重复的箱码扫描表示拣出一箱,不得以 SKU、商品件数、入库单号或页面展示字段替代箱码。一件代发已封托的 `SEALED_PALLET` 是不可拆整托单元,PDA 可扫描计划快照中的 `palletNo`、用该托任一成员 `boxCode` 反查所属整托,也可在明细卡片手工确认整托;三种入口最终都必须携带权威 `palletNo` 以 `PICK_PALLET` 一次性提交,不能按成员箱逐箱累计、部分拣货或跳拣。PDA 首页从 Warehouse 主档选择启用国内仓,一件代发与装柜待办列表必须按所选 `warehouseId` 服务端过滤;装柜任务内部仍允许跨国内仓,仓库与渠道限制统一由上游装柜编排负责,PDA 只按已生成任务读取各入仓单自身的 `CONFIRMED` 库位资源,不得再用柜次仓库过滤任务明细、库存快照或拣货写入。任务下发与提交契约见 `src/main/resources/openapi/phase3-pda-convergence-v1.yaml`。
@@ -211,6 +211,7 @@ public class ContainerCustomsService {
return platformView(planId);
}
/** 全部预录单经客户确认后发送报关行执行,保留版本与发送状态校验。 */
@Transactional
public ContainerView submitToBroker(long planId, long operatorId, String operatorName, String key, ActionRequest request) {
String action = "SUBMIT_CUSTOMS_TO_BROKER_" + planId;
@@ -221,8 +222,8 @@ public class ContainerCustomsService {
ContainerRow container = mapper.lockContainer(planId);
if (container == null || !DECLARING.equals(container.status())) throw conflict("当前柜不在报关中");
if (!Objects.equals(container.version(), request.expectedVersion())) throw concurrent();
if (mapper.taxRefundCount(container.loadingPlanId()) == 0) throw conflict("全买单柜无需发送客户回执给报关行");
if (mapper.unconfirmedReceiptCount(container.id()) != 0) throw conflict("仍有报关行回执未完成客户确认");
if (mapper.taxRefundCount(container.loadingPlanId()) == 0) throw conflict("全买单柜无需发送预录单确认结果给报关行");
if (mapper.unconfirmedReceiptCount(container.id()) != 0) throw conflict("仍有预录单未完成客户确认");
if (mapper.brokerSubmission(container.id()) == null) throw conflict("当前柜尚未完成首次发送报关行资料");
if (mapper.brokerSubmittedAt(container.id()) != null) throw conflict("当前柜已发送给报关行执行");
if (mapper.submitToBroker(container.id(), container.version(), operatorId, operatorName) != 1) throw concurrent();
@@ -231,6 +232,7 @@ public class ContainerCustomsService {
return platformView(planId);
}
/** 上传预录单并保存文件版本,已发送报关行执行后仍禁止替换。 */
@Transactional
public DocumentView uploadCompletion(long documentId, long operatorId, String operatorName, String key,
int expectedVersion, MultipartFile file) {
@@ -246,7 +248,7 @@ public class ContainerCustomsService {
throw conflict("当前客户资料不在报关中冻结版本");
}
if (mapper.brokerSubmittedAt(container.id()) != null) {
throw conflict("客户确认回执已发送给报关行,不可再修改回执");
throw conflict("客户确认的预录单已发送给报关行,不可再修改预录单");
}
if (mapper.brokerSubmission(container.id()) == null) {
throw conflict("当前柜尚未完成首次发送报关行资料");
@@ -300,6 +302,7 @@ public class ContainerCustomsService {
return merchantViewForDocument(tenantId, source);
}
/** 预录单确认并发送报关行执行后,接收真实报关完税文件。 */
@Transactional
public DocumentView uploadFinalDocument(long documentId, long operatorId, String operatorName, String key,
int expectedVersion, MultipartFile file) {
@@ -316,7 +319,7 @@ public class ContainerCustomsService {
}
if (mapper.brokerSubmittedAt(container.id()) == null) throw conflict("客户确认结果尚未发送给报关行");
CompletionRow receipt = mapper.lockCompletion(documentId);
if (receipt == null || !"CONFIRMED".equals(receipt.customerConfirmStatus())) throw conflict("该客户回执尚未确认");
if (receipt == null || !"CONFIRMED".equals(receipt.customerConfirmStatus())) throw conflict("该客户的预录单尚未确认");
FinalRow existing = mapper.lockFinalDocument(documentId);
CustomsFileClient.Metadata metadata = upload(source.tenantId(), RESULT_FILE_TYPE, Long.toString(container.loadingPlanId()), operatorId, file);
long finalId;
@@ -371,7 +374,7 @@ public class ContainerCustomsService {
if (container == null || !(DECLARING.equals(container.status()) || COMPLETED.equals(container.status()))) throw conflict("当前柜尚未进入报关行执行阶段,不能创建正式申报批次");
if (mapper.taxRefundCount(planId) == 0) throw conflict("全买单柜不创建退税正式申报批次");
if (mapper.unconfirmedReceiptCount(container.id()) != 0 || mapper.brokerSubmittedAt(container.id()) == null) {
throw conflict("当前柜尚未完成退税资料、回执确认和报关行执行");
throw conflict("当前柜尚未完成退税资料、预录单确认和报关行执行");
}
if (!Objects.equals(container.version(), request.expectedVersion())) throw concurrent();
long batchId = IdWorker.getId();
@@ -536,14 +539,15 @@ public class ContainerCustomsService {
row.operatorName(), row.createdAt())).toList();
}
/** 按既有客户范围与可见状态下载预录单,沿用文件真实名称。 */
public Download downloadCompletion(long tenantId, long documentId, boolean platform) {
DocumentRow source = requiredDocument(documentId);
ContainerRow container = mapper.containerById(source.customsContainerId());
if (!platform && (source.tenantId() != tenantId || container == null || !Set.of(DECLARING, COMPLETED).contains(container.status()))) {
throw notFound("报关行回执不存在");
throw notFound("预录单不存在");
}
CompletionRow completion = mapper.completion(documentId);
if (completion == null) throw notFound("报关行回执不存在");
if (completion == null) throw notFound("预录单不存在");
return download(source.tenantId(), RECEIPT_FILE_TYPE, Long.toString(container.loadingPlanId()), completion.fileId(), completion.fileName());
}
@@ -126,6 +126,17 @@ public class PlatformOceanBookingController {
@PutMapping("/{oceanBookingId}") @PreAuthorize("hasAuthority('platform:firstleg:ocean-booking:edit')")
@BusinessAudit(event="OCEAN_BOOKING_UPDATE_DRAFT", resourceType="OCEAN_BOOKING", resourceId="#oceanBookingId", description="编辑海运订舱草稿")
public ApiResponse<Detail> updateDraft(@AuthenticationPrincipal AuthenticatedUser user, @PathVariable @Positive long oceanBookingId, @RequestHeader("Idempotency-Key") String key, @Valid @RequestBody UpdateRequest request) { validateOptionalCustomer(user, request.data().customerId()); return ApiResponse.success(service.updateDraft(oceanBookingId, user.userId(), user.username(), key, request)); }
/** 放舱后单独维护预计到港,其他运输资料仍冻结。 */
@PutMapping("/{oceanBookingId}/estimated-arrival")
@PreAuthorize("hasAuthority('platform:firstleg:ocean-booking:edit')")
@BusinessAudit(event="OCEAN_BOOKING_UPDATE_ESTIMATED_ARRIVAL", resourceType="OCEAN_BOOKING", resourceId="#oceanBookingId", description="维护订舱预计到港")
public ApiResponse<Detail> updateEstimatedArrival(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable @Positive long oceanBookingId, @RequestHeader("Idempotency-Key") String key,
@Valid @RequestBody EstimatedArrivalRequest request) {
FirstlegScopeGuard.platform(user);
return ApiResponse.success(service.updateEstimatedArrival(oceanBookingId, user.userId(), user.username(), key, request));
}
/** 客服在放舱后补录实货量,沿用订舱编辑权限并记录审计。 */
@PutMapping("/{oceanBookingId}/actual-cargo")
@PreAuthorize("hasAuthority('platform:firstleg:ocean-booking:edit')")
@@ -95,6 +95,10 @@ public final class OceanBookingDtos {
@jakarta.validation.constraints.DecimalMin(value = "0", inclusive = false)
@jakarta.validation.constraints.Digits(integer = 12, fraction = 3) BigDecimal actualVolumeCbm) {}
/** 放舱后仅维护预计到港,不接收其他运输资料。 */
public record EstimatedArrivalRequest(@NotNull @Min(0) Integer expectedVersion,
@NotNull LocalDateTime estimatedArrivalAt) {}
/** 放舱后的受控实货量补录,不接收运输资料。 */
public record ActualCargoRequest(@NotNull @Min(0) Integer expectedVersion,
@Min(1) @Max(999999999) Integer actualPackageCount,
@@ -27,6 +27,11 @@ public interface OceanBookingMapper extends BaseMapper<OceanBookingEntity> {
@Param("operatorId") long operatorId, @Param("operatorName") String operatorName);
@Update("UPDATE fl_ocean_booking SET customer_id=#{value.customerId},customer_name=#{value.customerName},channel_code=#{value.channelCode},channel_name=#{value.channelName},warehouse_id=#{value.warehouseId},warehouse_name=#{value.warehouseName},bill_of_lading_no=#{value.billOfLadingNo},shipping_company_id=#{value.shippingCompanyId},shipping_company_name=#{value.shippingCompanyName},carrier_code=#{value.carrierCode},booking_agent_id=#{value.bookingAgentId},booking_agent_name=#{value.bookingAgentName},vessel_name=#{value.vesselName},voyage_no=#{value.voyageNo},departure_port_code=#{value.departurePortCode},departure_port_name=#{value.departurePortName},destination_port_code=#{value.destinationPortCode},destination_port_name=#{value.destinationPortName},place_of_delivery=#{value.placeOfDelivery},estimated_sailing_at=#{value.estimatedSailingAt},estimated_arrival_at=#{value.estimatedArrivalAt},remark=#{value.remark},freight_term=#{value.freightTerm},service_term=#{value.serviceTerm},package_unit=#{value.packageUnit},cargo_description=#{value.cargoDescription},marks_and_nos=#{value.marksAndNos},declared_package_count=#{value.declaredPackageCount},declared_gross_weight_kg=#{value.declaredGrossWeightKg},declared_volume_cbm=#{value.declaredVolumeCbm},actual_package_count=#{value.actualPackageCount},actual_gross_weight_kg=#{value.actualGrossWeightKg},actual_volume_cbm=#{value.actualVolumeCbm},updated_by=#{operatorId},updated_by_name=#{operatorName},version=version+1,updated_at=NOW() WHERE id=#{id} AND status='DRAFT' AND version=#{version}")
int updateDraft(@Param("id") long id, @Param("value") OceanBookingEntity value, @Param("version") int version, @Param("operatorId") long operatorId, @Param("operatorName") String operatorName);
/** 已放舱预计到港窄更新,保留状态、版本和操作审计。 */
@Update("UPDATE fl_ocean_booking SET estimated_arrival_at=#{estimatedArrivalAt},updated_by=#{operatorId},updated_by_name=#{operatorName},updated_at=CURRENT_TIMESTAMP(3),version=version+1 WHERE id=#{id} AND status='RELEASED' AND version=#{version}")
int updateEstimatedArrival(@Param("id") long id, @Param("estimatedArrivalAt") java.time.LocalDateTime estimatedArrivalAt,
@Param("version") int version, @Param("operatorId") long operatorId, @Param("operatorName") String operatorName);
/** 已放舱补实只更新白名单字段,保留乐观锁及状态门禁。 */
@Update("""
UPDATE fl_ocean_booking SET actual_package_count=#{value.actualPackageCount},
@@ -54,6 +54,11 @@ public class OceanBookingRepository {
public int touchDraft(long id, int version, long operatorId, String operatorName) {
return bookings.touchDraft(id, version, operatorId, operatorName);
}
/** 按订舱版本仅更新已放舱预计到港,不重写其他运输或货量资料。 */
public int updateEstimatedArrival(long id, java.time.LocalDateTime estimatedArrivalAt, int version, long operatorId, String operatorName) {
return bookings.updateEstimatedArrival(id, estimatedArrivalAt, version, operatorId, operatorName);
}
/** 只更新已放舱订舱的实际货量,运输快照保持冻结。 */
public int updateActualCargo(OceanBookingEntity value, int version, long operatorId, String operatorName) {
return bookings.updateActualCargo(value, version, operatorId, operatorName);
@@ -145,6 +145,24 @@ public class OceanBookingService {
addHistory(id, OceanBookingStatus.DRAFT.name(), OceanBookingStatus.DRAFT.name(), action, null, before, after, operatorId, operatorName);
return complete(id, operatorId, action, key, detail(after));
}
/** 放舱后单独维护预计到港,按版本和幂等键保存并审计前后快照。 */
@Transactional
public Detail updateEstimatedArrival(long id, long operatorId, String name, String key, EstimatedArrivalRequest request) {
String action = "UPDATE_ESTIMATED_ARRIVAL";
Detail replay = claimOrReplay(id, operatorId, action, key, hash(action, id, request));
if (replay != null) return replay;
OceanBookingEntity before = requiredVersionForUpdate(id, request.expectedVersion());
requireStatus(before, OceanBookingStatus.RELEASED);
if (request.estimatedArrivalAt() == null) throw badRequest("预计到港时间不能为空");
if (before.getEstimatedSailingAt() != null && request.estimatedArrivalAt().isBefore(before.getEstimatedSailingAt())) {
throw badRequest("预计到港时间不能早于预计开船时间");
}
if (repository.updateEstimatedArrival(id, request.estimatedArrivalAt(), request.expectedVersion(), operatorId, name) != 1) throw concurrent();
OceanBookingEntity saved = required(id);
addHistory(id, before.getStatus(), saved.getStatus(), action, "维护预计到港", before, saved, operatorId, name);
return complete(id, operatorId, action, key, detail(saved));
}
/** 放舱后仅补录实货量,版本、幂等结果和修改前后快照在同一事务保存。 */
@Transactional
public Detail updateActualCargo(long id, long operatorId, String name, String key, ActualCargoRequest request) {
@@ -262,6 +280,7 @@ public class OceanBookingService {
}
}
private static void validateRange(LocalDateTime from, LocalDateTime to) { if (from != null && to != null && to.isBefore(from)) throw badRequest("预计到港时间不能早于预计开船时间"); }
/** 放舱要求预计到港,供关联柜次的到港日历及默认到仓日期使用。 */
private static void validateRelease(OceanBookingEntity booking, List<OceanBookingPartyEntity> parties,
List<OceanBookingContainerRequirementEntity> requirements) {
if (!StringUtils.hasText(booking.getChannelCode()) || booking.getWarehouseId() == null
@@ -82,15 +82,19 @@ public class ContainerService {
return value;
}
/** 首次关联已放舱订舱,仅规范化柜号并保留关联资格与并发校验。 */
@Transactional
public ContainerBookingEntity linkOceanBooking(long id, long operatorId, String operatorName, OceanBookingLinkRequest request) {
ContainerBookingEntity container = required(id); requireVersion(container, request.expectedVersion()); requireFirstLinkMutable(container);
if (container.getOceanBookingId() != null) throw conflict("模拟柜已关联真实订舱柜");
String existingContainerNo = trim(container.getContainerNo());
String requestedContainerNo = trim(request.containerNo());
String containerNo = requestedContainerNo == null
? existingContainerNo
: Iso6346ContainerNumber.require(requestedContainerNo);
// 本关联入口不按 ISO 长度、结构或校验位阻断,仍统一空白与大小写以核对唯一性。
if (requestedContainerNo != null) {
requestedContainerNo = trim(requestedContainerNo.replaceAll("[\\p{javaWhitespace}\\p{Z}]", "")
.toUpperCase(java.util.Locale.ROOT));
}
String containerNo = requestedContainerNo == null ? existingContainerNo : requestedContainerNo;
if (existingContainerNo != null && !existingContainerNo.equals(containerNo)) {
throw conflict("输入的真实集装箱号与当前柜次已录入柜号不一致,请先在预装运输信息中纠正");
}
@@ -493,10 +493,15 @@ public class LoadingListService {
return repository.preloadDetail(requirePlan(planId));
}
/** 保留关联入口已接受的柜号,运输入口首次录入或纠错仍执行原有校验。 */
private static String targetContainerNo(AggregateRow before, String requested) {
if (requested != null) return Iso6346ContainerNumber.require(requested);
return StringUtils.hasText(before.getContainerNo())
? Iso6346ContainerNumber.require(before.getContainerNo()) : null;
if (requested == null) return StringUtils.hasText(before.getContainerNo()) ? before.getContainerNo() : null;
String normalized = requested.replaceAll("[\\p{javaWhitespace}\\p{Z}]", "")
.toUpperCase(java.util.Locale.ROOT);
if (StringUtils.hasText(normalized) && normalized.equals(before.getContainerNo())) {
return before.getContainerNo();
}
return Iso6346ContainerNumber.require(requested);
}
private static String transportHash(long planId, int version, String containerNo,
@@ -140,6 +140,7 @@ public class LoadingPickService {
smartLoadingRepository.items(plan.getId()));
}
/** 确认预装前验证作业资格和真实柜号,格式问题必须在实物作业前纠正。 */
public List<Link> requirePreloadConfirmationReady(long loadingPlanId) {
inboundHoldGuard.requirePlanNotHeld(loadingPlanId);
List<Link> links = loadingModelBridge.requireAllocatedLinksForConfirmation(loadingPlanId);
@@ -151,6 +152,7 @@ public class LoadingPickService {
if (booking == null || !StringUtils.hasText(booking.getContainerNo())) {
throw conflict("确认预装前必须录入真实集装箱号");
}
requireContainerNumberBeforeStarting(booking.getContainerNo());
var allocation = oceanBookingRepository.allocationByContainer(link.containerBookingId());
if (allocation == null) throw conflict("确认预装前必须关联真实订舱柜");
var oceanBooking = oceanBookingRepository.find(allocation.getOceanBookingId());
@@ -164,12 +166,14 @@ public class LoadingPickService {
return links;
}
/** 仅在柜号已满足完成事件要求时创建 PDA 任务,已有任务的重复同步保持原语义。 */
@Transactional
public void begin(long planId, String containerNo, long operatorId, String operatorName,
List<LoadingPlanItemEntity> planItems) {
inboundHoldGuard.requirePlanNotHeld(planId);
LoadingPickTaskEntity task = repository.taskByPlan(planId);
if (task == null) {
requireContainerNumberBeforeStarting(containerNo);
task = new LoadingPickTaskEntity();
task.setLoadingPlanId(planId);
var booking = smartLoadingRepository.booking(smartLoadingRepository.plan(planId).getContainerBookingId());
@@ -610,6 +614,16 @@ public class LoadingPickService {
/** 本进程事务提交后的唤醒信号,权威命令和恢复事实仍保存在数据库。 */
public record CompletionAccepted(long commandId, long acceptedAtNanos) {}
/** 在仍可纠错的预装阶段提示格式错误,避免领取或实物作业后卡在完成门禁。 */
private static void requireContainerNumberBeforeStarting(String containerNo) {
try {
Iso6346ContainerNumber.require(containerNo);
} catch (BizException exception) {
throw conflict("请先在预装运输信息中纠正真实集装箱号后再确认预装或启动PDA,需符合ISO 6346");
}
}
/** 完成前复核真实柜号,以生成一致的跨服务装柜完成事件。 */
private void requireCompletionContainerNumber(long taskId) {
ContainerBookingEntity booking = repository.activeBookingByTask(taskId);
if (booking == null || !StringUtils.hasText(booking.getContainerNo())) {
@@ -232,7 +232,7 @@ DROP TRIGGER IF EXISTS trg_ocean_booking_before_update;
DELIMITER $$
CREATE TRIGGER trg_ocean_booking_before_update BEFORE UPDATE ON fl_ocean_booking FOR EACH ROW
BEGIN
-- 已放舱只允许实货量及其审计元数据变化,运输和预报快照保持冻结。
-- 已放舱只允许预计到港、实货量及审计元数据变化,其余运输和预报快照保持冻结。
IF OLD.status = 'RELEASED' AND NOT (
(NEW.id <=> OLD.id)
AND (NEW.booking_no <=> OLD.booking_no)
@@ -257,7 +257,6 @@ BEGIN
AND (NEW.destination_port_name <=> OLD.destination_port_name)
AND (NEW.place_of_delivery <=> OLD.place_of_delivery)
AND (NEW.estimated_sailing_at <=> OLD.estimated_sailing_at)
AND (NEW.estimated_arrival_at <=> OLD.estimated_arrival_at)
AND (NEW.remark <=> OLD.remark)
AND (NEW.freight_term <=> OLD.freight_term)
AND (NEW.service_term <=> OLD.service_term)
@@ -272,7 +271,7 @@ BEGIN
AND (NEW.created_at <=> OLD.created_at)
AND NEW.version = OLD.version + 1
) THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='已放舱运输资料不可修改,仅允许补录实货量';
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='已放舱仅允许维护预计到港及补录实货量';
END IF;
IF OLD.status <> NEW.status AND NOT (
(OLD.status='DRAFT' AND NEW.status='RELEASED')) THEN
@@ -35,7 +35,7 @@ CREATE TABLE IF NOT EXISTS fl_document_sequence (
CREATE TABLE IF NOT EXISTS fl_container_booking (
id BIGINT UNSIGNED NOT NULL COMMENT '货柜预订ID(雪花ID)',
container_no VARCHAR(64) NULL COMMENT '国内柜次真实集装箱号权威快照;ISO 6346标准格式;同一订舱内唯一,不同提单允许复用;订舱关联可选写入、提柜后可原子补录、预装纠错命令可修改',
container_no VARCHAR(64) NULL COMMENT '国内柜次真实集装箱号权威快照;订舱关联不限制ISO格式;同一订舱内唯一,不同提单允许复用;订舱关联可选写入、提柜后可原子补录、预装纠错命令可修改',
cabinet_no VARCHAR(64) NOT NULL COMMENT '平台唯一内部柜号',
source_type VARCHAR(24) NOT NULL DEFAULT 'SIMULATION' COMMENT '记录来源:SIMULATION内部模拟柜、OCEAN_BOOKING订舱真实柜',
record_status VARCHAR(16) NOT NULL DEFAULT 'ACTIVE' COMMENT '记录状态:ACTIVE可操作、MERGED已合并历史',
@@ -100,9 +100,7 @@ CREATE TABLE IF NOT EXISTS fl_container_booking (
CONSTRAINT chk_container_booking_record_status CHECK (record_status IN ('ACTIVE','MERGED')),
CONSTRAINT chk_container_booking_capacity CHECK (capacity_cbm IS NULL OR capacity_cbm > 0),
CONSTRAINT chk_container_booking_weight CHECK (max_weight_kg IS NULL OR max_weight_kg > 0),
CONSTRAINT chk_container_booking_pickup_code CHECK (pickup_code IS NULL OR pickup_code REGEXP '^[0-9]{1,64}$'),
CONSTRAINT chk_container_booking_iso6346 CHECK
(container_no IS NULL OR container_no REGEXP '^[A-Z]{3}[UJZ][0-9]{7}$')
CONSTRAINT chk_container_booking_pickup_code CHECK (pickup_code IS NULL OR pickup_code REGEXP '^[0-9]{1,64}$')
) ENGINE=InnoDB COMMENT='平台货柜预订资料;预装状态唯一来源为装柜计划';
CREATE TABLE IF NOT EXISTS fl_container_event_inbox (
@@ -228,11 +226,6 @@ BEGIN
ALTER TABLE fl_container_booking ADD CONSTRAINT chk_container_booking_record_status
CHECK (record_status IN ('ACTIVE','MERGED'));
END IF;
IF NOT EXISTS (SELECT 1 FROM information_schema.table_constraints WHERE constraint_schema=DATABASE()
AND table_name='fl_container_booking' AND constraint_name='chk_container_booking_iso6346') THEN
ALTER TABLE fl_container_booking ADD CONSTRAINT chk_container_booking_iso6346
CHECK (container_no IS NULL OR container_no REGEXP '^[A-Z]{3}[UJZ][0-9]{7}$');
END IF;
IF NOT EXISTS (SELECT 1 FROM information_schema.table_constraints WHERE constraint_schema=DATABASE()
AND table_name='fl_container_booking' AND constraint_name='chk_container_booking_pickup_code') THEN
ALTER TABLE fl_container_booking ADD CONSTRAINT chk_container_booking_pickup_code
@@ -118,9 +118,9 @@ paths:
x-permission: platform:firstleg:customs:list
parameters: [{$ref: '#/components/parameters/DocumentId'}]
responses: {'200': {$ref: '#/components/responses/BinaryResponse'}}
/api/platform/v1/customs/loading-plans/documents/{documentId}/receipt/download:
/api/platform/v1/customs/loading-plans/documents/{documentId}/completion/download:
get:
summary: 平台下载报关行回执
summary: 平台下载预录单
x-permission: platform:firstleg:customs:list
parameters: [{$ref: '#/components/parameters/DocumentId'}]
responses: {'200': {$ref: '#/components/responses/BinaryResponse'}}
@@ -607,7 +607,7 @@ paths:
/api/platform/v1/containers/{id}/ocean-booking-link:
post:
summary: 将内部模拟柜关联到已发布订舱需求
description: 需要 platform:firstleg:container:edit;请求指定 RELEASED oceanBookingId 和未满配 requirementId,containerNo 可空。首次关联允许 WAIT_LOADING 或已有预排货的 EDITING 模拟柜,保留原 containerBookingId/cabinetNo/loadingPlanId/items,原子写入订舱、运输和范围快照并创建 allocation;提柜后由拖车链调用柜次服务原子补录真实柜号。若关联请求已提供 containerNo,服务端仍规范化并校验 ISO 6346 结构、设备类别和校验位,并保证同一订舱内唯一;不同提单允许复用同一集装箱号。存在跨仓、跨渠道、柜型规格不一致、超量、同提单重复柜号、PDA、有效装柜证据、业务单证或 LOADING/COMPLETED 等事实时拒绝。
description: 需要 platform:firstleg:container:edit;请求指定 RELEASED oceanBookingId 和未满配 requirementId,containerNo 可空。首次关联允许 WAIT_LOADING 或已有预排货的 EDITING 模拟柜,保留原 containerBookingId/cabinetNo/loadingPlanId/items,原子写入订舱、运输和范围快照并创建 allocation;提柜后由拖车链调用柜次服务原子补录真实柜号。若关联请求已提供 containerNo,服务端仅移除空白并转大写,不校验 ISO 6346 长度、结构或校验位,并保证同一订舱内唯一;不同提单允许复用同一集装箱号。关联接受的柜号在运输信息未修改柜号时不重复执行ISO校验;确认预装和创建PDA任务前仍校验ISO格式,并提示在预装运输信息中纠错,防止实物作业后才发现无法完成。存在跨仓、跨渠道、柜型规格不一致、超量、同提单重复柜号、PDA、有效装柜证据、业务单证或 LOADING/COMPLETED 等事实时拒绝。
parameters: [{$ref: '#/components/parameters/ContainerBookingId'}]
requestBody: {required: true, content: {application/json: {schema: {$ref: '#/components/schemas/ContainerOceanBookingLinkRequest'}}}}
responses: {'200': {description: 关联后的同一模拟柜 containerBookingId/cabinetNo;列表仍只有这一条 ACTIVE 操作身份}}
@@ -2947,7 +2947,7 @@ components:
properties:
oceanBookingId: {type: integer, format: int64, minimum: 1, description: RELEASED订舱ID}
requirementId: {type: integer, format: int64, minimum: 1, description: 未满配的订舱柜型需求ID}
containerNo: {type: string, nullable: true, maxLength: 64, description: 可选;港口提柜后可由拖车链另行原子补录。非空时服务端移除格式空白、转大写并校验ISO 6346结构、设备类别、校验位和同一订舱内唯一性}
containerNo: {type: string, nullable: true, maxLength: 64, description: 可选;港口提柜后可由拖车链另行原子补录。关联时移除格式空白并转大写,不校验ISO长度、结构或校验位,仍保证同一订舱内唯一;确认预装及创建PDA任务前需纠正为符合ISO格式的真实柜号}
expectedVersion: {type: integer, minimum: 0}
ContainerFeeAdjustRequest:
type: object
@@ -50,7 +50,7 @@ paths:
summary: 修改预装运输信息
description: >-
可在 EDITING 或 LOADING 阶段首次补录真实集装箱号;已有柜号的纠正仅限仍为 EDITING、PDA任务尚未领取且无任何物理作业事实时,并必须提供原因和当前版本;
containerNo 为 null 表示保持当前值,非空时服务端移除格式空白、转大写并校验ISO 6346结构、设备类别和校验位,空白字符串非法且禁止清空。
containerNo 为 null 或规范化后等于当前值时保留关联入口已接受的柜号,不重复校验格式;首次录入或纠正为不同值时服务端移除格式空白、转大写并校验ISO 6346结构、设备类别和校验位,空白字符串非法且禁止清空。
x-permission: platform:firstleg:preload:edit
parameters:
- {$ref: '#/components/parameters/PlanId'}
@@ -29,6 +29,14 @@ paths:
parameters: [{$ref: '#/components/parameters/OceanBookingId'}, {$ref: '#/components/parameters/IdempotencyKey'}]
requestBody: {required: true, content: {application/json: {schema: {$ref: '#/components/schemas/ActionRequest'}}}}
responses: {'200': {description: Deleted}}
/api/platform/v1/ocean-bookings/{oceanBookingId}/estimated-arrival:
put:
summary: 放舱后维护预计到港
description: 仅允许RELEASED订舱按当前版本与幂等键维护estimatedArrivalAt,不得清空或早于预计开船时间;其他运输、预报、关系方、柜型与附件仍冻结,保存前后快照及操作人。不修改实际到港或计划到仓。
x-permission: platform:firstleg:ocean-booking:edit
parameters: [{$ref: '#/components/parameters/OceanBookingId'}, {$ref: '#/components/parameters/IdempotencyKey'}]
requestBody: {required: true, content: {application/json: {schema: {$ref: '#/components/schemas/EstimatedArrivalRequest'}}}}
responses: {'200': {description: 已保存的RELEASED订舱详情,包含当前版本和预计到港}}
/api/platform/v1/ocean-bookings/{oceanBookingId}/actual-cargo:
put:
summary: Supplement manual actual cargo on a RELEASED booking
@@ -200,7 +208,7 @@ components:
destinationPortName: {type: string, nullable: true}
placeOfDelivery: {type: string, nullable: true}
estimatedSailingAt: {type: string, format: date-time, nullable: true}
estimatedArrivalAt: {type: string, format: date-time, nullable: true}
estimatedArrivalAt: {type: string, format: date-time, nullable: true, description: 草稿可留空,放舱前必填;不得早于预计开船时间,关联柜次后作为到港日历及默认到仓日期来源}
remark: {type: string, nullable: true}
freightTerm: {type: string, maxLength: 64, nullable: true, description: 运费条款;新建页面默认FREIGHT PREPAID}
serviceTerm: {type: string, maxLength: 64, nullable: true, description: 运输条款;新建页面默认CY-CY}
@@ -217,6 +225,13 @@ components:
containerRequirements: {type: array, nullable: true, items: {$ref: '#/components/schemas/ContainerRequirementRequest'}}
PartyRequest: {type: object, required: [partyType, partyName], properties: {partyType: {type: string, enum: [SHIPPER, CONSIGNEE, NOTIFY_PARTY]}, partyName: {type: string}, address: {type: string, nullable: true}, contactName: {type: string, nullable: true}, contactPhone: {type: string, nullable: true}, contactEmail: {type: string, format: email, maxLength: 255, nullable: true}, taxNo: {type: string, maxLength: 128, nullable: true}}}
ContainerRequirementRequest: {type: object, required: [containerType, containerKind, quantity, capacityCbm, maxWeightKg], properties: {containerType: {type: string, enum: [40GP, 40HQ, 45HQ], default: 40HQ}, containerKind: {type: string}, quantity: {type: integer, minimum: 1}, capacityCbm: {type: number, exclusiveMinimum: true, minimum: 0, description: 统一实装上限;40GP=58、40HQ=68、45HQ=86}, maxWeightKg: {type: number, enum: [19500], description: 统一货重上限}}}
EstimatedArrivalRequest:
type: object
additionalProperties: false
required: [expectedVersion, estimatedArrivalAt]
properties:
expectedVersion: {type: integer, minimum: 0}
estimatedArrivalAt: {type: string, format: date-time}
ActualCargoRequest:
type: object
additionalProperties: false
@@ -88,7 +88,7 @@ class ContainerControllerContractTest {
}
@Test
@DisplayName("OpenAPI声明平台级货柜路径和冻结规则")
@DisplayName("OpenAPI声明平台级货柜路径、关联柜号格式放行及冻结规则")
void documentsContainerContract() throws Exception {
String openapi = new ClassPathResource("openapi/firstleg-v1.yaml")
.getContentAsString(StandardCharsets.UTF_8);
@@ -106,7 +106,8 @@ class ContainerControllerContractTest {
.contains("未满配 requirementId")
.contains("containerNo 可空")
.contains("提柜后由拖车链调用柜次服务原子补录真实柜号")
.contains("校验 ISO 6346 结构、设备类别和校验位,并保证同一订舱内唯一")
.contains("不校验 ISO 6346 长度、结构或校验位,并保证同一订舱内唯一")
.contains("确认预装和创建PDA任务前仍校验ISO格式")
.doesNotContain("/api/platform/v1/containers/{id}/transport:")
.doesNotContain("ContainerOceanBookingUnlinkRequest")
.doesNotContain("ContainerTransportUpdateRequest")
@@ -4,6 +4,7 @@ import com.baiyue.firstleg.customs.container.controller.*;
import org.junit.jupiter.api.Test;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.core.io.ClassPathResource;
import org.yaml.snakeyaml.Yaml;
@@ -12,6 +13,22 @@ import java.lang.reflect.Method;
import static org.assertj.core.api.Assertions.assertThat;
class ContainerCustomsControllerContractTest {
/** 下载契约必须跟随实际 Controller,避免前端和文档共同固化不存在的地址。 */
@Test void platformPreEntryDownloadMatchesActualControllerAndOpenApi() throws Exception {
Method download = java.util.Arrays.stream(PlatformContainerCustomsController.class.getDeclaredMethods())
.filter(method -> method.getName().equals("downloadCompletion")).findFirst().orElseThrow();
String root = PlatformContainerCustomsController.class.getAnnotation(RequestMapping.class).value()[0];
String path = root + download.getAnnotation(GetMapping.class).value()[0];
String source = new ClassPathResource("openapi/customs-v1.yaml").getContentAsString(java.nio.charset.StandardCharsets.UTF_8);
java.util.Map<?, ?> specification = new Yaml().load(source);
java.util.Map<?, ?> paths = (java.util.Map<?, ?>) specification.get("paths");
assertThat(path).isEqualTo("/api/platform/v1/customs/loading-plans/documents/{documentId}/completion/download");
assertThat(paths.containsKey(path)).isTrue();
assertThat(paths.containsKey(root + "/documents/{documentId}/receipt/download")).isFalse();
assertThat(download.getAnnotation(PreAuthorize.class).value()).contains("platform:firstleg:customs:list");
}
@Test void platformUsesListForReadsAndManageForWrites() {
assertThat(PlatformContainerCustomsController.class.getAnnotation(RequestMapping.class).value())
.containsExactly("/api/platform/v1/customs/loading-plans");
@@ -442,6 +442,7 @@ class ContainerCustomsServiceTest {
verifyNoInteractions(files);
}
/** 上传被拒绝时使用预录单名称,并保留报关行执行后的替换门禁。 */
@Test void receiptCannotBeReplacedAfterBrokerSubmission(){
var source=document(1L);when(mapper.document(1L)).thenReturn(source);
when(mapper.claimCommand(anyLong(),eq(5L),eq("UPLOAD_COMPLETION_1"),eq("broker-sent-key"),anyString())).thenReturn(1);
@@ -450,7 +451,10 @@ class ContainerCustomsServiceTest {
when(mapper.brokerSubmittedAt(2L)).thenReturn(java.time.LocalDateTime.now());
var file=new MockMultipartFile("file","result.bin","application/octet-stream",new byte[]{1});
assertThatThrownBy(()->service.uploadCompletion(1L,5L,"admin","broker-sent-key",0,file))
.isInstanceOf(BizException.class).hasMessageContaining("不可再修改回执");
.isInstanceOfSatisfying(BizException.class, error -> {
assertThat(error.getResultCode()).isEqualTo(ResultCode.CONFLICT);
assertThat(error).hasMessage("客户确认的预录单已发送给报关行,不可再修改预录单");
});
verifyNoInteractions(files);
}
@@ -512,11 +516,43 @@ class ContainerCustomsServiceTest {
verify(mapper,never()).complete(anyLong(),anyInt(),anyLong(),anyString());
}
/** 下载不可见或缺失的预录单时使用正式名称,不改变客户范围校验。 */
@Test void merchantCannotDownloadCompletionBeforeWholeContainerCompletes(){
var source=document(1L);when(mapper.document(1L)).thenReturn(source);
when(mapper.containerById(2L)).thenReturn(container(ContainerCustomsService.DECLARING,3));
assertThatThrownBy(()->service.downloadCompletion(7L,1L,false)).isInstanceOf(BizException.class)
.hasMessageContaining("报关行回执不存在");
.hasMessageContaining("预录单不存在");
verifyNoInteractions(files);
}
/** 平台下载的当前预录单不存在时仍返回原业务错误码和正式名称。 */
@Test void platformMissingPreEntryDocumentUsesCurrentName() {
when(mapper.document(1L)).thenReturn(document(1L));
when(mapper.completion(1L)).thenReturn(null);
assertThatThrownBy(() -> service.downloadCompletion(0L, 1L, true))
.isInstanceOfSatisfying(BizException.class, error -> {
assertThat(error.getResultCode()).isEqualTo(ResultCode.NOT_FOUND);
assertThat(error).hasMessage("预录单不存在");
});
verifyNoInteractions(files);
}
/** 发送前置校验仅调整用户可见名称,不改变全买单或客户确认门禁。 */
@ParameterizedTest
@CsvSource({"0,0,全买单柜无需发送预录单确认结果给报关行", "1,1,仍有预录单未完成客户确认"})
void brokerSubmissionErrorsUsePreEntryDocumentName(int taxRefundCount, int unconfirmedCount, String message) {
when(mapper.claimCommand(anyLong(), eq(5L), eq("SUBMIT_CUSTOMS_TO_BROKER_9"), eq("pre-entry-name"), anyString())).thenReturn(1);
when(mapper.lockContainer(9L)).thenReturn(container(ContainerCustomsService.DECLARING, 3));
when(mapper.taxRefundCount(9L)).thenReturn(taxRefundCount);
when(mapper.unconfirmedReceiptCount(2L)).thenReturn(unconfirmedCount);
assertThatThrownBy(() -> service.submitToBroker(9L, 5L, "admin", "pre-entry-name", new ActionRequest(3, true, null)))
.isInstanceOfSatisfying(BizException.class, error -> {
assertThat(error.getResultCode()).isEqualTo(ResultCode.CONFLICT);
assertThat(error).hasMessage(message);
});
verify(mapper, never()).submitToBroker(anyLong(), anyInt(), anyLong(), anyString());
verifyNoInteractions(files);
}
@@ -14,6 +14,37 @@ import java.nio.file.Path;
import static org.assertj.core.api.Assertions.assertThat;
class PlatformOceanBookingControllerContractTest {
/** 预计到港命令仅接收版本与时间,沿用编辑权限及独立审计。 */
@Test void estimatedArrivalCommandHasNarrowDtoAndEditPermission() throws Exception {
var endpoint = method("updateEstimatedArrival");
assertThat(endpoint.getAnnotation(org.springframework.web.bind.annotation.PutMapping.class).value()).containsExactly("/{oceanBookingId}/estimated-arrival");
assertThat(endpoint.getAnnotation(PreAuthorize.class).value()).isEqualTo("hasAuthority('platform:firstleg:ocean-booking:edit')");
assertThat(endpoint.getAnnotation(BusinessAudit.class)).isNotNull();
assertThat(Arrays.stream(com.baiyue.firstleg.oceanbooking.dto.OceanBookingDtos.EstimatedArrivalRequest.class.getRecordComponents())
.map(java.lang.reflect.RecordComponent::getName)).containsExactly("expectedVersion", "estimatedArrivalAt");
assertThat(Files.readString(Path.of("src/main/resources/openapi/ocean-bookings-v1.yaml")))
.contains("/api/platform/v1/ocean-bookings/{oceanBookingId}/estimated-arrival:", "#/components/schemas/EstimatedArrivalRequest");
}
/** 真实方法权限拦截器拒绝只有读取权限的账号,命令不会进入服务层。 */
@Test void estimatedArrivalRejectsReadOnlyAccountBeforeServiceCall() {
var service = org.mockito.Mockito.mock(com.baiyue.firstleg.oceanbooking.service.OceanBookingService.class);
var target = new PlatformOceanBookingController();
org.springframework.test.util.ReflectionTestUtils.setField(target, "service", service);
var factory = new org.springframework.aop.framework.ProxyFactory(target);
factory.setProxyTargetClass(true);
factory.addAdvisor(org.springframework.security.authorization.method.AuthorizationManagerBeforeMethodInterceptor.preAuthorize());
var secured = (PlatformOceanBookingController) factory.getProxy();
var context = org.springframework.security.core.context.SecurityContextHolder.getContext();
context.setAuthentication(new org.springframework.security.authentication.UsernamePasswordAuthenticationToken("reader", "",
java.util.List.of(new org.springframework.security.core.authority.SimpleGrantedAuthority("platform:firstleg:ocean-booking:list"))));
try {
org.assertj.core.api.Assertions.assertThatThrownBy(() -> secured.updateEstimatedArrival(null, 1L, "eta-denied",
new com.baiyue.firstleg.oceanbooking.dto.OceanBookingDtos.EstimatedArrivalRequest(3, java.time.LocalDateTime.now())))
.isInstanceOf(org.springframework.security.access.AccessDeniedException.class);
org.mockito.Mockito.verifyNoInteractions(service);
} finally { org.springframework.security.core.context.SecurityContextHolder.clearContext(); }
}
@Test
void usesDedicatedResourceAndPermissions() {
assertThat(PlatformOceanBookingController.class.getAnnotation(RequestMapping.class).value())
@@ -14,6 +14,13 @@ import java.util.Arrays;
import static org.assertj.core.api.Assertions.assertThat;
class OceanBookingMapperContractTest {
/** 放舱后预计到港只更新该字段及版本审计,不允许带出其他冻结资料。 */
@Test void estimatedArrivalUpdateIsNarrowAndVersioned() throws Exception {
String sql = String.join("\n", OceanBookingMapper.class.getDeclaredMethod("updateEstimatedArrival", long.class,
java.time.LocalDateTime.class, int.class, long.class, String.class).getAnnotation(Update.class).value());
assertThat(sql).contains("estimated_arrival_at=#{estimatedArrivalAt}", "status='RELEASED'", "version=#{version}", "version=version+1")
.doesNotContain("vessel_name=", "voyage_no=", "estimated_sailing_at=", "actual_", "declared_", "warehouse_id=");
}
@Test
void listUsesPlatformAggregateFilters() throws Exception {
String sql = String.join("\n", OceanBookingMapper.class.getDeclaredMethod("selectPage",
@@ -10,6 +10,14 @@ import java.nio.file.Path;
import static org.assertj.core.api.Assertions.assertThat;
class OceanBookingSqlContractTest {
/** 两份触发器基线只放开预计到港,继续冻结船名、开船、预报和其他运输资料。 */
@Test void releasedFreezeAllowsEstimatedArrivalOnly() throws Exception {
for (String source : new String[] {sql(), Files.readString(Path.of("../deploy/sql/头程数据库统一增量-MySQL8.sql"))}) {
assertThat(source).doesNotContain("NEW.estimated_arrival_at <=> OLD.estimated_arrival_at")
.contains("NEW.vessel_name <=> OLD.vessel_name", "NEW.estimated_sailing_at <=> OLD.estimated_sailing_at",
"NEW.declared_package_count <=> OLD.declared_package_count", "NEW.version = OLD.version + 1");
}
}
@Test
void definesDedicatedAggregateWithoutChangingContainerSemantics() throws Exception {
String sql = sql();
@@ -231,6 +231,19 @@ class OceanBookingServiceTest {
verify(repository, times(1)).transition(1L, "DRAFT", "RELEASED", 3, 9L, "admin");
}
/** 缺预计到港不能放舱,避免关联柜次后丢失日历及默认到仓日期来源。 */
@Test
void rejectsReleaseWithoutEstimatedArrival() {
OceanBookingEntity draft = completeBooking("DRAFT", 3);
draft.setEstimatedArrivalAt(null);
when(repository.find(1L)).thenReturn(draft);
when(repository.parties(1L)).thenReturn(parties());
when(repository.requirements(1L)).thenReturn(List.of(requirement()));
assertThatThrownBy(() -> service.release(1L, 9L, "admin", "release-no-arrival", new ReleaseRequest(3, "确认放舱")))
.isInstanceOf(BizException.class).hasMessageContaining("尚未填写完整");
verify(repository, never()).transition(anyLong(), anyString(), anyString(), anyInt(), anyLong(), anyString());
}
@Test
void rejectsCarrierSnapshotMismatchAndUnassociatedBookingAgent() {
stubMasterData();
@@ -386,6 +399,57 @@ class OceanBookingServiceTest {
verify(repository, never()).transition(anyLong(), anyString(), anyString(), anyInt(), anyLong(), anyString());
}
/** 预计到港独立更新、回显并保留修改前后审计,不能触及其他冻结字段。 */
@Test void maintainsReleasedEstimatedArrivalWithVersionAndAudit() {
var before = completeBooking("RELEASED", 3);
var after = objectMapper.convertValue(before, OceanBookingEntity.class);
var arrival = before.getEstimatedSailingAt().plusDays(20);
after.setEstimatedArrivalAt(arrival); after.setVersion(4);
when(repository.find(1L)).thenReturn(before, after);
when(repository.updateEstimatedArrival(1L, arrival, 3, 9L, "admin")).thenReturn(1);
var result = service.updateEstimatedArrival(1L, 9L, "admin", "eta-save", new EstimatedArrivalRequest(3, arrival));
assertThat(result.oceanBooking().getEstimatedArrivalAt()).isEqualTo(arrival);
assertThat(result.oceanBooking().getVersion()).isEqualTo(4);
assertThat(result.oceanBooking().getVesselName()).isEqualTo(before.getVesselName());
var history = ArgumentCaptor.forClass(com.baiyue.firstleg.oceanbooking.entity.OceanBookingStatusHistoryEntity.class);
verify(repository).insert(history.capture());
assertThat(history.getValue().getAction()).isEqualTo("UPDATE_ESTIMATED_ARRIVAL");
assertThat(history.getValue().getBeforeSnapshot()).contains("estimatedArrivalAt");
assertThat(history.getValue().getAfterSnapshot()).contains("estimatedArrivalAt");
verify(repository, never()).updateDraft(any(), anyInt(), anyLong(), any());
verify(repository, never()).replaceRequirements(anyLong(), any());
}
/** 草稿、旧版本、空值及逆序时间均不能走放舱后的预计到港命令。 */
@Test void rejectsInvalidEstimatedArrivalCommands() {
var value = completeBooking("DRAFT", 3);
when(repository.find(1L)).thenReturn(value);
var arrival = value.getEstimatedSailingAt().plusDays(20);
assertThatThrownBy(() -> service.updateEstimatedArrival(1L, 9L, "admin", "eta-draft", new EstimatedArrivalRequest(3, arrival))).isInstanceOf(BizException.class);
value.setStatus("RELEASED");
assertThatThrownBy(() -> service.updateEstimatedArrival(1L, 9L, "admin", "eta-stale", new EstimatedArrivalRequest(2, arrival))).isInstanceOf(BizException.class);
assertThatThrownBy(() -> service.updateEstimatedArrival(1L, 9L, "admin", "eta-empty", new EstimatedArrivalRequest(3, null))).hasMessageContaining("不能为空");
assertThatThrownBy(() -> service.updateEstimatedArrival(1L, 9L, "admin", "eta-early", new EstimatedArrivalRequest(3, value.getEstimatedSailingAt().minusSeconds(1)))).hasMessageContaining("不能早于");
when(repository.updateEstimatedArrival(anyLong(), any(), anyInt(), anyLong(), any())).thenReturn(0);
assertThatThrownBy(() -> service.updateEstimatedArrival(1L, 9L, "admin", "eta-conflict", new EstimatedArrivalRequest(3, arrival))).isInstanceOf(BizException.class);
}
/** 同一预计到港命令重放复用原结果,不重复保存或追加审计。 */
@Test void estimatedArrivalReplayDoesNotWriteAgain() throws Exception {
var request = new EstimatedArrivalRequest(3, LocalDateTime.of(2026, 10, 25, 10, 0));
var replay = new Detail(completeBooking("RELEASED", 4), List.of(), List.of(), List.of(), List.of(), List.of());
var operation = new OceanBookingOperationEntity();
operation.setRequestHash(hash("UPDATE_ESTIMATED_ARRIVAL", 1L, request));
operation.setStatus("SUCCESS"); operation.setResultJson(objectMapper.writeValueAsString(replay));
when(repository.claimOperation(1L, 9L, "UPDATE_ESTIMATED_ARRIVAL", "eta-replay", operation.getRequestHash())).thenReturn(false);
when(repository.operation(9L, "UPDATE_ESTIMATED_ARRIVAL", "eta-replay")).thenReturn(operation);
assertThat(service.updateEstimatedArrival(1L, 9L, "admin", "eta-replay", request).oceanBooking().getVersion()).isEqualTo(4);
verify(repository, never()).updateEstimatedArrival(anyLong(), any(), anyInt(), anyLong(), any());
verify(repository, never()).insert(any(com.baiyue.firstleg.oceanbooking.entity.OceanBookingStatusHistoryEntity.class));
}
@Test
void supplementsReleasedActualCargoWithVersionAndBeforeAfterAudit() {
OceanBookingEntity before = completeBooking("RELEASED", 3);
@@ -8,12 +8,23 @@ import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Update;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.ValueSource;
import com.baomidou.mybatisplus.core.MybatisConfiguration;
import com.baomidou.mybatisplus.extension.spring.MybatisSqlSessionFactoryBean;
import com.baiyue.firstleg.oceanbooking.entity.OceanBookingEntity;
import org.h2.jdbcx.JdbcDataSource;
import org.mybatis.spring.SqlSessionTemplate;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.dao.DuplicateKeyException;
import org.springframework.test.util.ReflectionTestUtils;
import java.util.Arrays;
import java.nio.file.Files;
import java.nio.file.Path;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
@DisplayName("货柜数据库映射契约")
class ContainerBookingMapperContractTest {
@@ -106,7 +117,7 @@ class ContainerBookingMapperContractTest {
}
@Test
@DisplayName("初始化与统一增量锁定真实柜号权威、订舱内唯一和ISO结构约束")
@DisplayName("初始化与统一增量保留柜号权威及订舱内唯一,不限制关联柜号ISO格式")
void databaseContractsFreezeAuthoritativeContainerNumber() throws Exception {
String module = Files.readString(Path.of("src/main/resources/db/phase3-smart-loading-schema.sql"));
String unified = Files.readString(Path.of("../deploy/sql/头程数据库统一增量-MySQL8.sql"));
@@ -118,8 +129,41 @@ class ContainerBookingMapperContractTest {
assertThat(source.toLowerCase())
.contains("unique key uk_container_booking_ocean_container (ocean_booking_id,container_no)")
.contains("key idx_container_booking_container_no (container_no,updated_at,id)")
.contains("constraint chk_container_booking_iso6346")
.contains("^[a-z]{3}[ujz][0-9]{7}$");
.doesNotContain("chk_container_booking_iso6346", "^[a-z]{3}[ujz][0-9]{7}$");
}
}
/** 从当前建表基线创建隔离数据库,真实执行关联 Mapper 并验证唯一键和版本。 */
@ParameterizedTest
@ValueSource(strings = {"MSCU6639871", "ABC", "ABCU123456789012", "LOCAL-2026-001"})
void linksNonIsoContainerNumbersThroughActualSchemaAndMapper(String containerNo) throws Exception {
var dataSource = new JdbcDataSource();
dataSource.setURL("jdbc:h2:mem:container-link-" + java.util.UUID.randomUUID() + ";MODE=MySQL;DB_CLOSE_DELAY=-1");
var jdbc = new JdbcTemplate(dataSource);
String source = Files.readString(Path.of("src/main/resources/db/phase3-smart-loading-schema.sql"));
int start = source.indexOf("CREATE TABLE IF NOT EXISTS fl_container_booking (");
jdbc.execute(source.substring(start, source.indexOf(';', source.indexOf(") ENGINE=InnoDB", start)) + 1));
for (long id = 1; id <= 3; id++) {
jdbc.update("INSERT INTO fl_container_booking(id,cabinet_no,warehouse_id,warehouse_name,container_type,created_by,updated_by) VALUES(?,?,8,'测试仓','40HQ',9,9)",
id, "D" + id);
}
var configuration = new MybatisConfiguration();
configuration.setMapUnderscoreToCamelCase(true);
configuration.addMapper(ContainerBookingMapper.class);
var factory = new MybatisSqlSessionFactoryBean();
factory.setDataSource(dataSource);
factory.setConfiguration(configuration);
var session = new SqlSessionTemplate(factory.getObject());
var repository = new ContainerRepository();
ReflectionTestUtils.setField(repository, "mapper", session.getMapper(ContainerBookingMapper.class));
var booking = new OceanBookingEntity();
assertThat(repository.linkReleasedBooking(1L, 21L, 31L, containerNo, booking, 0, 9L, "操作员")).isEqualTo(1);
assertThat(jdbc.queryForObject("SELECT container_no FROM fl_container_booking WHERE id=1", String.class)).isEqualTo(containerNo);
assertThat(jdbc.queryForObject("SELECT version FROM fl_container_booking WHERE id=1", Integer.class)).isEqualTo(1);
assertThat(repository.linkReleasedBooking(1L, 21L, 31L, containerNo, booking, 0, 9L, "操作员")).isZero();
assertThatThrownBy(() -> repository.linkReleasedBooking(2L, 21L, 31L, containerNo, booking, 0, 9L, "操作员"))
.isInstanceOf(DuplicateKeyException.class);
assertThat(repository.linkReleasedBooking(3L, 22L, 32L, containerNo, booking, 0, 9L, "操作员")).isEqualTo(1);
}
}
@@ -329,17 +329,21 @@ class ContainerServiceTest {
verify(repository, never()).assignRealContainerNo(anyLong(), anyString(), anyInt(), anyLong(), anyString());
}
@Test
@DisplayName("首次关联拒绝错误ISO 6346校验位")
void rejectsInvalidContainerNumberCheckDigit() {
/** 关联入口允许非 ISO 柜号,继续按原版本和关联范围保存。 */
@ParameterizedTest
@ValueSource(strings = {"MSCU6639871", "ABC", "ABCU123456789012", "LOCAL-2026-001"})
@DisplayName("首次关联不因柜号长度结构或校验位阻断")
void linksWithoutIsoContainerNumberValidation(String containerNo) {
stubLinkContext();
when(repository.linkReleasedBooking(eq(11L), eq(21L), eq(31L), eq(containerNo),
any(OceanBookingEntity.class), eq(2), eq(9L), eq("admin"))).thenReturn(1);
assertThatThrownBy(() -> service.linkOceanBooking(11L, 9L, "admin",
new OceanBookingLinkRequest(21L, 31L, "MSCU6639871", 2)))
.isInstanceOf(BizException.class).hasMessageContaining("校验位");
service.linkOceanBooking(11L, 9L, "admin", new OceanBookingLinkRequest(21L, 31L, containerNo, 2));
verify(repository, never()).linkReleasedBooking(anyLong(), anyLong(), anyLong(), anyString(),
any(), anyInt(), anyLong(), anyString());
verify(repository).containerNoExists(21L, containerNo, 11L);
verify(repository).linkReleasedBooking(eq(11L), eq(21L), eq(31L), eq(containerNo),
any(OceanBookingEntity.class), eq(2), eq(9L), eq("admin"));
verify(oceanBookings).insert(any(OceanBookingContainerAllocationEntity.class));
}
@Test
@@ -26,6 +26,9 @@ import com.fasterxml.jackson.databind.ObjectMapper;
import jakarta.annotation.Resource;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.NullSource;
import org.junit.jupiter.params.provider.ValueSource;
import org.mockito.ArgumentCaptor;
import org.springframework.boot.test.context.TestConfiguration;
import org.springframework.boot.test.mock.mockito.MockBean;
@@ -147,10 +150,42 @@ class LoadingListServiceTest {
verifyNoInteractions(loadingPickService);
}
/** 只更新航次时,关联已接受的非 ISO 柜号不应被重新校验或改写。 */
@ParameterizedTest
@NullSource
@ValueSource(strings = {"LOCAL-1", " local-1 "})
void preservesLinkedNonIsoNumberWhenOnlyVoyageChanges(String requested) {
AggregateRow before = row("EDITING", 3);
before.setContainerNo("LOCAL-1");
before.setVoyageNo("001E");
when(repository.aggregate(10L)).thenReturn(before);
when(repository.updateTransport(10L, 3, "LOCAL-1", "023E", 9L, "operator")).thenReturn(1);
service.updateTransport(10L, 100L, 9L, "operator", "preserve-linked-number",
new TransportUpdateRequest(requested, "023E", 3, null));
verify(repository).updateTransport(10L, 3, "LOCAL-1", "023E", 9L, "operator");
verify(loadingPickService).beginIfReady(30L, 9L, "operator");
}
/** 非关联入口新录入或纠正柜号仍使用原有规则,不放宽到所有录入场景。 */
@Test
void stillRejectsNewNonIsoNumberInTransportCorrection() {
AggregateRow before = row("EDITING", 3);
before.setContainerNo("LOCAL-1");
when(repository.aggregate(10L)).thenReturn(before);
assertThatThrownBy(() -> service.updateTransport(10L, 100L, 9L, "operator", "invalid-correction",
new TransportUpdateRequest("OTHER-2", null, 3, "纠错")))
.isInstanceOf(BizException.class).hasMessageContaining("ISO 6346");
verify(repository, never()).updateTransport(anyLong(), anyInt(), any(), any(), anyLong(), any());
}
/** 关联入口接受的非 ISO 柜号,在 PDA 领取前仍可按版本和原因纠正为真实柜号。 */
@Test
void pushedEditingPreloadCanCorrectExistingContainerNumberBeforePdaClaim() {
AggregateRow before = row("EDITING", 3);
before.setContainerNo("MSCU6639870");
before.setContainerNo("LOCAL-1");
AggregateRow after = row("EDITING", 4);
after.setContainerNo("CSQU3054383");
when(repository.aggregate(10L)).thenReturn(before, after);
@@ -227,7 +227,7 @@ class LoadingPickServiceTest {
Link link = new Link(1L, 1L, 50L, 2L, "PENDING", 1,
10L, 40L, "MSCU-001", "EDITING", 3, 2, null);
var booking = new com.baiyue.firstleg.entity.ContainerBookingEntity();
booking.setId(40L); booking.setContainerNo("MSCU-001");
booking.setId(40L); booking.setContainerNo("MSCU6639870");
var allocation = new OceanBookingContainerAllocationEntity();
allocation.setOceanBookingId(90L);
var ocean = new OceanBookingEntity();
@@ -962,6 +962,57 @@ class LoadingPickServiceTest {
verify(repository, never()).acceptCompletion(anyLong(), anyInt());
}
/** 关联放行的非 ISO 柜号必须在可纠错阶段明确反馈,纠正后可继续确认。 */
@ParameterizedTest
@ValueSource(strings = {"MSCU6639871", "ABC", "ABCU123456789012", "LOCAL-2026-001"})
void rejectsNonIsoNumberBeforeConfirmationAndAllowsCorrection(String containerNo) {
Link link = new Link(1L, 1L, 50L, 2L, "PENDING", 1,
10L, 40L, containerNo, "EDITING", 3, 2, null);
var booking = new ContainerBookingEntity();
booking.setId(40L);
booking.setContainerNo(containerNo);
when(loadingModelBridge.requireAllocatedLinksForConfirmation(10L)).thenReturn(List.of(link));
when(smartLoadingRepository.booking(40L)).thenReturn(booking);
assertThatThrownBy(() -> service.requirePreloadConfirmationReady(10L))
.isInstanceOf(BizException.class).hasMessageContaining("预装运输信息");
verify(repository, never()).insert(any(LoadingPickTaskEntity.class));
verify(completionCommands, never()).insert(any());
booking.setContainerNo("MSCU6639870");
var allocation = new OceanBookingContainerAllocationEntity();
allocation.setOceanBookingId(90L);
var ocean = new OceanBookingEntity();
ocean.setId(90L);
ocean.setStatus("RELEASED");
when(oceanBookingRepository.allocationByContainer(40L)).thenReturn(allocation);
when(oceanBookingRepository.find(90L)).thenReturn(ocean);
assertThat(service.requirePreloadConfirmationReady(10L)).containsExactly(link);
}
/** 任务创建入口同样先校验格式,不能绕过确认门禁进入实物作业。 */
@ParameterizedTest
@ValueSource(strings = {"MSCU6639871", "ABC", "ABCU123456789012", "LOCAL-2026-001"})
void rejectsNonIsoNumberBeforeCreatingPdaTask(String containerNo) {
assertThatThrownBy(() -> service.begin(10L, containerNo, 9L, "operator", List.of()))
.isInstanceOf(BizException.class).hasMessageContaining("预装运输信息");
verify(repository, never()).insert(any(LoadingPickTaskEntity.class));
verify(repository, never()).insert(any(LoadingPickItemEntity.class));
}
/** 完成前的最终校验仍保留,不能让异常柜号生成无法投递的装柜完成事件。 */
@Test
void completionStillRequiresIsoNumberForCrossServiceFacts() {
repository.activeBookingByTask(20L).setContainerNo("MSCU6639871");
when(repository.taskForUpdate(20L)).thenReturn(task());
assertThatThrownBy(() -> service.complete(20L,
new CompleteLoadingRequest(0, "invalid-final-container", "checked"), 9L, "operator"))
.isInstanceOf(BizException.class).hasMessageContaining("ISO 6346");
verify(completionCommands, never()).insert(any());
verify(repository, never()).acceptCompletion(anyLong(), anyInt());
}
@Test
void differentOperationIdIsRejectedImmediatelyWithConflict() {
// different operationId is a canonical A0409 CONFLICT while the durable command is active.
+2 -2
View File
@@ -7,8 +7,8 @@
退货预报、签收、库存、合箱与退货出库:
- 客户预报 `returnreceipt/controller/MerchantReturnOrderController.java` → `returnreceipt/service/ReturnReceiptService.java`,路径 `/api/merchant/v1/last-leg/return-orders`;客户范围取认证身份,预报按 SKU 保存期望数量。状态 `PENDING_RECEIPT → PARTIALLY_RECEIVED → COMPLETED` 表示签收进度,不等于换标或出库完成。
- PDA 后端签收 `returnreceipt/controller/PdaReturnReceiptController.java`,前缀 `/api/pda/v1/last-leg/return-receipts`,依次 `start/sku-candidates/confirm`;按冻结仓和退货单校验实收,不允许未预报 SKU 或超量,幂等重放不得重复入账。确认同时生成签收箱与 SKU 库存;设备页面是否接通另查 PDA 工作台,不能以 Controller 存在代替前端接入。
- 库存/箱号 `returnreceipt/controller/PlatformReturnInventoryController.java`、`MerchantReturnInventoryController.java` → `returnreceipt/service/ReturnInventoryService.java`,前缀分别为 `/api/platform/v1/last-leg/return-inventory`、`/api/merchant/v1/last-leg/return-inventory`;PDA 合箱入口 `PdaReturnInventoryController.java` 的 `/packing/orders`、`/cartons/merge`、`/cartons/{cartonMark}/label`。同仓同客户选取退货单及可用 SKU,合箱扣减来源箱数量并生成目标箱,空来源箱标为 `MERGED`,不得跨客户或超出可用量。
- PDA 后端签收 `returnreceipt/controller/PdaReturnReceiptController.java`,前缀 `/api/pda/v1/last-leg/return-receipts`,依次 `start/sku-candidates/confirm`;按冻结仓和退货单校验实收,不允许未预报 SKU 或超量。确认必须传提交前全单累计实收量 `expectedReceivedQuantity`,在订单锁内校验;响应丢失后换新键重复提交旧快照返回冲突,刷新后重新操作,同键幂等请求不得重复入账。确认同时生成签收箱与 SKU 库存;设备页面是否接通另查 PDA 工作台,不能以 Controller 存在代替前端接入。
- 库存/箱号 `returnreceipt/controller/PlatformReturnInventoryController.java`、`MerchantReturnInventoryController.java` → `returnreceipt/service/ReturnInventoryService.java`,前缀分别为 `/api/platform/v1/last-leg/return-inventory`、`/api/merchant/v1/last-leg/return-inventory`;PDA 合箱入口 `PdaReturnInventoryController.java` 的 `/packing/orders`、`/cartons/merge`、`/cartons/{cartonMark}/label`。同仓同客户选取退货单及可用 SKU,合箱扣减来源箱数量并生成目标箱,空来源箱标为 `MERGED`,不得跨客户或超出可用量。实物合箱 `/cartons/merge-boxes` 必须提交扫码时全部来源箱 SKU 数量 `expectedSourceQuantities`,来源行锁内核对完整快照;旧快照换新键也拒绝扣减,重新扫描后才能提交当前实物。内部退货换标调用从已锁定任务及来源库存生成快照。
- 退货出库继续进入 `delivery/service/DeliveryTransferService.java` 的 `RETURN_OUTBOUND`,按退货库存可用箱唛创建与拣货;不能与 `exceptionorder/` 的派送退回恢复库存混用。契约见 `src/main/resources/openapi/overseas-business-v1.yaml`,表入口 `src/main/resources/db/lastleg.sql` 的 `ll_return_*`。
- 客户箱号管理使用 `GET /api/merchant/v1/last-leg/return-inventory/cartons` 按实物箱分页,库存箱内 SKU 明细仍使用根查询;两者均按登录客户精确隔离。合箱 `sourceBatchNo/sourceReturnNo` 保存实际消耗来源的去重业务编号(多个以逗号分隔),不得写状态或包含未取货订单。`ReturnCartonBarcode` 按箱号生成 Code 128 SVG,打印内容不得使用内部客户/仓库 ID。
@@ -16,6 +16,12 @@ public interface AppointmentWorkDispatchPort {
record CandidateBatches(List<BatchReference> appointmentBatches,List<String> blockReasons,boolean occupied) { }
/** 整页真实客户/计划托盘一次批读;occupied含有效草稿与正式实托占用,由调用方核对本草稿身份。 */
Map<CandidatePhysical,CandidateBatches> candidateBatches(List<CandidatePhysical> candidates);
/** 只读取本车已选实物的逐票预约选项;本车原占用不扣减本车可重新分配量。 */
Map<CandidatePhysical,List<AllocationSource>> allocationOptions(long dispatchId,List<CandidatePhysical> items);
record AllocationOption(String batchKey,String batchNo,Long recordVersion,int availablePackages,
String confirmationNo,String appointmentAt,String appointmentEndAt,String timezone) { }
record AllocationSource(String sourceKey,String orderNo,String inboundCode,String destinationKey,Integer packageCount,
List<AllocationOption> options,List<String> blockReasons) { }
/** Current original allocations, grouped for authorized dispatches; historical copies are read-only evidence. */
java.util.Map<Long,List<BatchReference>> references(List<Long> dispatchIds);
/** 在来源锁前按批次键一次性预锁并核对权威版本,版本零合法。 */
@@ -27,7 +33,7 @@ public interface AppointmentWorkDispatchPort {
String departureBlockReason(String dispatchNo);
record ReceivingWindow(String batchKey,String batchNo,String customerId,String destinationKey,String confirmationNo,
java.time.Instant startAt,java.time.Instant endAt,String agreementKey,int version) { }
/** 预排不受限;提交审核、审核通过和开始拣货须有真实收货确认及可执行时间。 */
/** FBA选入前须已有正式时间确认;提交审核、审核通过和开始拣货继续复核运输安排。 */
String reviewBlockReason(String dispatchNo,java.time.Instant plannedDepartureAt,java.time.Instant estimatedArrivalAt);
List<ReceivingWindow> reviewEvidence(String dispatchNo);
void removeItem(long dispatchId,long itemId);
@@ -59,16 +59,26 @@ public class DeliveryCargoGroups {
var rows=entry.getValue().stream().sorted(Comparator.comparing(CargoLine::dispatchItemId)
.thenComparing(CargoLine::attributionId)).toList();
var first=rows.get(0);
if (rows.stream().anyMatch(row->!Objects.equals(row.destination(),first.destination())))
// FBA以真实仓码分组;外接来货可缺内部仓ID,只沿同组已知ID核对,不能覆盖冲突值。
var destination=rows.stream().map(CargoLine::destination)
.filter(value->!blank(value.warehouseId())).findFirst().orElse(first.destination());
if (rows.stream().anyMatch(row->!Objects.equals(withKnownFbaWarehouseId(row.destination(),destination.warehouseId()),destination)))
throw conflict("同组货物的目的地信息不一致,请核对后安排");
long packages=rows.stream().mapToLong(CargoLine::packages).reduce(0,Math::addExact);
long pallets=rows.stream().map(CargoLine::warehousePalletId).filter(Objects::nonNull).distinct().count();
result.add(new Group(entry.getKey(),first.customerId(),first.customerName(),first.destination(),
result.add(new Group(entry.getKey(),first.customerId(),first.customerName(),destination,
hash(rows),rows,packages,pallets));
}
return List.copyOf(result);
}
/** 仅补同一分组FBA缺失的内部引用,其他目的地事实必须保持完全一致。 */
private Destination withKnownFbaWarehouseId(Destination value,String warehouseId) {
if (!"FBA".equals(value.type()) || !blank(value.warehouseId()) || blank(warehouseId)) return value;
return new Destination(value.key(),value.type(),warehouseId,value.warehouseCode(),value.warehouseName(),
value.address(),value.postalCode(),value.contactName(),value.contactPhone());
}
/** 客户是组身份的一部分,同目的仓的其他客户永不合单。 */
public String groupKey(String customerId,Destination destination) {
return hash(List.of(customerId,destination.key()));
@@ -53,6 +53,18 @@ public class DispatchAggregateController {
public ApiResponse<com.baiyue.lastleg.dispatch.port.DispatchWorkModels.Items> items(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String dispatchNo) {
return ApiResponse.success(service.items(DispatchActorFactory.platform(user),dispatchNo));
}
/** 本车货物逐票读取正式预约选项及当前分配。 */
@GetMapping("/{dispatchNo}/appointment-allocations")
@PreAuthorize("hasAuthority('platform:overseas-warehouse:dispatch:list')")
public ApiResponse<com.baiyue.lastleg.dispatch.port.DispatchWorkModels.AppointmentAllocations> appointmentAllocations(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String dispatchNo) {
return ApiResponse.success(service.appointmentAllocations(DispatchActorFactory.platform(user),dispatchNo));
}
/** 原车版本化关联正式预约,不新建排车或修改库存。 */
@PutMapping("/{dispatchNo}/appointment-allocations")
@PreAuthorize("hasAuthority('platform:overseas-warehouse:dispatch:edit')")
public ApiResponse<CommandResult> appointmentAllocations(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String dispatchNo,@RequestBody com.baiyue.lastleg.dispatch.port.DispatchWorkModels.AppointmentAllocationCommand request) {
return ApiResponse.success(service.saveAppointmentAllocations(DispatchActorFactory.platform(user),dispatchNo,request));
}
/** 读取实际起点和卸货顺序。 */
@GetMapping("/{dispatchNo}/route")
@PreAuthorize("hasAuthority('platform:overseas-warehouse:dispatch:list')")
@@ -19,7 +19,20 @@ public interface DispatchAggregateMapper {
OR EXISTS(SELECT 1 FROM ll_dispatch_truck_task t WHERE t.dispatch_no=d.dispatch_no AND t.customer_id=#{q.customerId})
OR EXISTS(SELECT 1 FROM ll_fleet_pickup_task p JOIN ll_fleet_trip f ON f.trip_no=p.trip_no WHERE f.id=d.trip_id AND p.customer_id=#{q.customerId})
)</if>
<if test='q.fbaAppointmentNo != null'> AND EXISTS(SELECT 1 FROM ll_dispatch_appointment_allocation a JOIN ll_inbound_fba_appointment ap ON a.batch_key=CONCAT('FBA_APPOINTMENT:',ap.id) WHERE a.dispatch_id=d.id AND ap.confirmation_no=#{q.fbaAppointmentNo})</if>
<if test='q.fbaAppointmentNo != null'> AND (
EXISTS(SELECT 1 FROM ll_dispatch_item i WHERE i.dispatch_order_id=d.id AND i.destination_type='FBA'
AND JSON_CONTAINS(COALESCE(JSON_EXTRACT(d.resources_json,'$.maintenance.destinationAppointments'),'[]'),
JSON_OBJECT('destinationKey',CONCAT('FBA:',UPPER(TRIM(COALESCE(NULLIF(i.destination_warehouse_code,''),i.fba_warehouse_code)))),'appointmentNo',#{q.fbaAppointmentNo}))=1)
OR EXISTS(SELECT 1 FROM ll_dispatch_appointment_allocation a
WHERE a.dispatch_id=d.id AND a.batch_key LIKE 'FBA_APPOINTMENT:%' AND
""" + DispatchReceivingSql.CONFIRMATION_NO + """
=#{q.fbaAppointmentNo}
AND NOT EXISTS(SELECT 1 FROM JSON_TABLE(
COALESCE(JSON_EXTRACT(d.resources_json,'$.maintenance.destinationAppointments'),'[]'),
'$[*]' COLUMNS(destination_key VARCHAR(256) PATH '$.destinationKey',
appointment_no VARCHAR(128) PATH '$.appointmentNo' NULL ON EMPTY)) saved
WHERE saved.destination_key=a.destination_key AND NULLIF(TRIM(saved.appointment_no),'') IS NOT NULL))
)</if>
""";
String REVIEW_DISPATCH = "CASE d.review_state WHEN 'DRAFT' THEN 'NOT_SUBMITTED' WHEN 'DISPATCH_PENDING' THEN 'PENDING' WHEN 'DISPATCH_REJECTED' THEN 'REJECTED' ELSE 'APPROVED' END";
String REVIEW_LOADING = "CASE d.review_state WHEN 'LOADING_PENDING' THEN 'PENDING' WHEN 'LOADING_REJECTED' THEN 'REJECTED' WHEN 'APPROVED' THEN 'APPROVED' ELSE 'NOT_SUBMITTED' END";
@@ -39,7 +39,7 @@ public interface DispatchBolMapper {
LEFT JOIN ll_inbound_plan ip ON ip.id=p.plan_id AND ip.customer_id=di.customer_id
LEFT JOIN ll_inbound_plan_pallet_allocation a ON a.pallet_id=p.id AND a.customer_id=di.customer_id
AND di.return_receipt_id IS NULL AND COALESCE(di.source_business_type,'')<>'RELABEL_ORDER'
LEFT JOIN ll_inbound_plan_line l ON l.id=a.line_id AND l.customer_id=di.customer_id AND l.plan_id=p.plan_id
LEFT JOIN ll_inbound_plan_line l ON l.id=a.line_id AND l.customer_id=di.customer_id AND l.plan_id=a.plan_id
LEFT JOIN ll_order o ON o.id=l.order_id AND o.customer_id=di.customer_id
LEFT JOIN ll_order_detail od ON od.order_id=l.order_id AND od.customer_id=l.customer_id
WHERE di.dispatch_order_id=#{dispatchOrderId}
@@ -49,11 +49,13 @@ public interface DispatchBolMapper {
""")
List<Cargo> findWholeDispatch(@Param("dispatchOrderId") long dispatchOrderId);
/** 原batch+客户+稳定来源+目的地定位预约货物;BOOKING使用持久化order_id,绝不自行拼ORDER键。 */
/** 原batch+客户+稳定来源+目的地定位货物,编号取当前客户收货确认;BOOKING保留持久化order_id。 */
@Select("""
SELECT a.dispatch_item_id AS dispatchItemId,a.plan_pallet_id AS planPalletId,a.batch_key AS batchKey,
a.customer_id AS customerId,a.source_key AS sourceKey,a.destination_key AS destinationKey,
a.package_count AS packageCount,p.id AS appointmentId,i.order_id AS orderId,p.confirmation_no AS confirmationNo
a.package_count AS packageCount,p.id AS appointmentId,i.order_id AS orderId,
""" + DispatchReceivingSql.CONFIRMATION_NO + """
AS confirmationNo
FROM ll_dispatch_appointment_allocation a
LEFT JOIN ll_inbound_fba_appointment p ON a.batch_key=CONCAT('FBA_APPOINTMENT:',p.id)
LEFT JOIN ll_inbound_fba_appointment_item i ON i.appointment_id=p.id AND i.customer_id=a.customer_id
@@ -11,6 +11,16 @@ import java.util.List;
@Mapper
public interface DispatchItemMapper extends BaseMapper<DispatchItem> {
/** 收货只定位原计划已关联的有效排车,按车辆父锁顺序返回。 */
@Select("<script>SELECT DISTINCT d.dispatch_no,d.trip_id,d.id FROM ll_dispatch_order d "
+ "JOIN ll_dispatch_item i ON i.dispatch_order_id=d.id "
+ "WHERE d.status='ACTIVE' AND i.plan_id IN "
+ "<foreach collection='ids' item='id' open='(' separator=',' close=')'>#{id}</foreach> "
+ "ORDER BY d.trip_id,d.id</script>")
List<ReceiptDispatch> receiptDispatches(@Param("ids") List<Long> planIds);
record ReceiptDispatch(String dispatchNo, Long tripId, Long id) { }
/** 平台整车读取全客户明细,后续库存与费用仍按每条真实客户执行。 */
@Select("SELECT * FROM ll_dispatch_item WHERE dispatch_order_id=#{dispatchOrderId} ORDER BY customer_id,id")
List<DispatchItem> findWholeDispatch(@Param("dispatchOrderId") Long dispatchOrderId);
@@ -55,6 +55,24 @@ public interface DispatchReadMapper {
""")
List<BatchRow> batches(@Param("id") long dispatchId);
record CargoShipment(String customerId, String destinationKey, String shipmentNo) { }
/** 逐票实际货物决定货件号;手填预约号或没有正式批次引用也能读取,客户严格随来源关联。 */
@Select("""
SELECT DISTINCT i.customer_id AS customerId,
CONCAT('FBA:',UPPER(TRIM(COALESCE(NULLIF(TRIM(l.fba_warehouse_code),''),NULLIF(TRIM(i.destination_warehouse_code),''),NULLIF(TRIM(i.fba_warehouse_code),''))))) AS destinationKey,
CASE WHEN o.order_type='WAREHOUSE_TRANSFER' THEN wt.fba_shipment_no ELSE d.shipment_no END AS shipmentNo
FROM ll_dispatch_item i
LEFT JOIN ll_inbound_plan_pallet_allocation a ON a.pallet_id=i.plan_pallet_id AND a.customer_id=i.customer_id
AND i.return_receipt_id IS NULL AND COALESCE(i.source_business_type,'')<>'RELABEL_ORDER'
LEFT JOIN ll_inbound_plan_line l ON l.id=a.line_id AND l.customer_id=i.customer_id
JOIN ll_order o ON o.id=CASE WHEN l.id IS NULL THEN i.order_id ELSE l.order_id END AND o.customer_id=i.customer_id
LEFT JOIN ll_order_detail d ON d.order_id=o.id AND d.customer_id=o.customer_id
AND UPPER(TRIM(d.destination_warehouse_code))=UPPER(TRIM(COALESCE(NULLIF(TRIM(l.fba_warehouse_code),''),NULLIF(TRIM(i.destination_warehouse_code),''),NULLIF(TRIM(i.fba_warehouse_code),''))))
LEFT JOIN ll_warehouse_transfer_order wt ON wt.lastleg_order_id=o.id AND wt.customer_id=o.customer_id
WHERE i.dispatch_order_id=#{id} AND i.destination_type='FBA'
""")
List<CargoShipment> cargoShipmentNumbers(@Param("id") long dispatchId);
/** 未接通跨客户批次写提供者前必须阻断静默丢批次,不能写无依据运输。 */
@Select("""
SELECT COUNT(*) FROM ll_inbound_plan_pallet_allocation a JOIN ll_inbound_plan_line l ON l.id=a.line_id
@@ -0,0 +1,18 @@
package com.baiyue.lastleg.dispatch.mapper;
/** 排车列表和BOL共用当前客户收货确认;调用方的a必须是逐票预约分配。 */
final class DispatchReceivingSql {
/** 只读当前有效覆盖,不使用多客户批次头汇总值,历史退出记录不能提供编号。 */
static final String CONFIRMATION_NO = """
(SELECT rg.confirmation_no
FROM ll_fba_receiving_member rm
JOIN ll_fba_receiving_agreement rg ON rg.agreement_key=rm.agreement_key
WHERE rm.batch_key=a.batch_key AND rm.customer_id=a.customer_id AND rm.active_ref IS NOT NULL
AND rg.destination_key=a.destination_key AND rg.status='ACTIVE'
AND JSON_UNQUOTE(JSON_EXTRACT(rg.snapshot_json,'$.details.appointmentStatus'))='SUCCESS'
AND NULLIF(JSON_UNQUOTE(JSON_EXTRACT(rg.snapshot_json,'$.details.appointmentAt')),'null') IS NOT NULL)
""";
/** 仅提供Mapper编译期SQL片段。 */
private DispatchReceivingSql() { }
}
@@ -21,6 +21,24 @@ public interface DispatchSourceMapper {
record OrderReference(Long orderId,String customerId) { }
record ItemPicked(Long itemId,long packages) { }
/** 收货交接只读已确认逐票实收,不以可用库存、出货资格或计划均分反推箱数。 */
@Select("""
SELECT p.id AS planPalletId,p.plan_id AS planId,p.customer_id AS customerId,
p.warehouse_pallet_id AS warehousePalletId,p.pallet_no AS palletNo,
CAST(ip.overseas_warehouse_id AS CHAR(128)) AS overseasWarehouseId,
MIN(l.destination_type) AS destinationType,MIN(l.fba_warehouse_code) AS fbaWarehouseCode,
p.delivery_address AS deliveryAddress,SUM(a.counted_package_count) AS packageCount,'RECEIVED' AS sourceStage
FROM ll_inbound_plan_pallet p JOIN ll_inbound_plan ip ON ip.id=p.plan_id AND ip.customer_id=p.customer_id
JOIN ll_inbound_plan_pallet_allocation a ON a.pallet_id=p.id AND a.customer_id=p.customer_id
JOIN ll_inbound_plan_line l ON l.id=a.line_id AND l.customer_id=a.customer_id
WHERE p.id=#{id} AND p.customer_id=#{customer} AND ip.status='COMPLETED' AND p.warehouse_pallet_id IS NOT NULL
GROUP BY p.id,p.plan_id,p.customer_id,p.warehouse_pallet_id,p.pallet_no,ip.overseas_warehouse_id,p.delivery_address
HAVING COUNT(*)=COUNT(a.counted_package_count) AND COUNT(DISTINCT l.destination_type)=1
AND COUNT(DISTINCT COALESCE(l.fba_warehouse_code,''))=1
""")
@Options(useCache=false,flushCache=Options.FlushCachePolicy.TRUE)
List<com.baiyue.lastleg.dispatch.model.DispatchPalletCandidate> confirmedPlan(@Param("customer") String customer,@Param("id") long id);
/** 全车普通来源剩余箱数依据实际扫码,不以当前聚合库存反推选中份额。 */
@Select("SELECT i.id AS itemId,COALESCE(SUM(p.packages),0) AS packages FROM ll_dispatch_item i LEFT JOIN ll_dispatch_pda_pick p "
+ "ON p.dispatch_item_id=i.id AND p.customer_id=i.customer_id WHERE i.dispatch_order_id=#{dispatch} GROUP BY i.id")
@@ -49,7 +49,7 @@ public final class DispatchContracts {
@JsonDeserialize(using = DispatchMaintenanceDateDeserializer.class)
LocalDate plannedDeliveryDate, String deliveryMethod, String pickupMethod,
String loadType, String fleetEmail, String vehicleType,
String appointmentAccount, String appointmentNo) {
String appointmentAccount, List<DestinationAppointment> destinationAppointments) {
/** 空白统一为空;只约束已填写的选项及长度,不阻止空草稿保存。 */
public Maintenance {
deliveryMethod = option(deliveryMethod, "派送方式", "FBA自提", "客户自提", "卡派打托", "卡派地板", "快递", "外配");
@@ -58,7 +58,10 @@ public final class DispatchContracts {
vehicleType = option(vehicleType, "车辆类型", "厢式货车", "平板车", "集装箱卡车");
fleetEmail = limited(fleetEmail, "车队邮箱", 254);
appointmentAccount = limited(appointmentAccount, "预约账号", 128);
appointmentNo = limited(appointmentNo, "本趟预约号", 128);
if (destinationAppointments != null && destinationAppointments.stream().anyMatch(java.util.Objects::isNull)) throw new IllegalArgumentException("预约目的仓不能空缺");
destinationAppointments = destinationAppointments == null ? List.of() : List.copyOf(destinationAppointments);
if (destinationAppointments.size() > 500 || destinationAppointments.stream().map(DestinationAppointment::destinationKey).distinct().count() != destinationAppointments.size())
throw new IllegalArgumentException("同一目的仓只能填写一个预约号");
}
/** 选项按正式文案保存,拒绝未知值而不猜测业务含义。 */
private static String option(String value, String label, String... allowed) {
@@ -74,6 +77,18 @@ public final class DispatchContracts {
}
}
/** 同一排车按目的仓登记约好的编号,不要求客服逐票或逐托分配。 */
public record DestinationAppointment(String destinationKey, String appointmentNo) {
/** 草稿允许暂缺编号,目的仓身份及文本长度仍须明确。 */
public DestinationAppointment {
destinationKey = destinationKey == null ? null : destinationKey.trim();
appointmentNo = appointmentNo == null || appointmentNo.isBlank() ? null : appointmentNo.trim();
if (destinationKey == null || !destinationKey.startsWith("FBA:") || destinationKey.length() <= 4 || destinationKey.length() > 256)
throw new IllegalArgumentException("预约目的仓无效");
if (appointmentNo != null && appointmentNo.length() > 128) throw new IllegalArgumentException("预约号最多128字符");
}
}
public record Resources(VehicleSnapshot vehicle, DriverSnapshot driver, Maintenance maintenance) { }
public record Destination(String key, String type, String warehouseId, String warehouseCode,
@@ -32,15 +32,15 @@ public final class DispatchViews {
public record ListRow(String dispatchNo, String taskType, Status status, Stage stage, long recordVersion,
ReviewState reviewState, Reviews reviews, Resources resources,
String warehouseId, String warehouseCode, String warehouseName,
Instant plannedDepartureAt, Instant departedAt, Counts counts,
Instant createdAt, Instant plannedDepartureAt, Instant departedAt, Counts counts,
boolean pickingReconciled, boolean arranged,
Map<Action, Gate> actions) { }
Map<Action, Gate> actions, List<FbaAppointment> fbaAppointments) { }
public record VehicleInfo(VehicleSnapshot vehicle, DriverSnapshot driver, Maintenance maintenance) { }
public record FbaAppointment(String batchKey, String batchNo, String appointmentNo,
String destinationKey, String destinationWarehouseCode,
String status, Instant confirmedAt, String blockReason) { }
String status, Instant confirmedAt, String blockReason, String customerId, List<String> shipmentNumbers) { }
public record DispatchInfo(String route, Instant plannedDepartureAt, Instant departedAt,
Instant estimatedArrivalAt, List<FbaAppointment> fbaAppointments,
@@ -14,6 +14,14 @@ public final class DispatchWorkModels {
public record Items(String dispatchNo,long recordVersion,Status status,Stage stage,List<CargoItem> items) { }
public record Stop(int index,String name,String address,String destinationKey) { }
public record Route(String dispatchNo,long recordVersion,List<Stop> stops,Gate edit) { }
public record AppointmentItem(Long dispatchItemId,Long planPalletId,String customerId,String customerName,String palletNo,
List<com.baiyue.lastleg.appointmentwork.AppointmentWorkDispatchPort.AllocationSource> sources,List<BatchAllocation> allocations) { }
public record AppointmentAllocations(String dispatchNo,long recordVersion,List<AppointmentItem> items,Gate edit) { }
/** 只替换明确选中的本车实物分配,未触及客户和实物保持原关联。 */
public record AppointmentAllocationCommand(Long recordVersion,String idempotencyKey,List<Long> dispatchItemIds,List<BatchAllocation> batchAllocations) {
/** 主版本零合法;保存失败不能用默认版本覆盖后续修改。 */
public AppointmentAllocationCommand {recordVersion=DispatchCommands.requireVersion(recordVersion);}
}
/** 仅重排现有真实卸货点;起运仓由服务端保留,外接目的地不得丢失。 */
public record RouteCommand(Long recordVersion,String idempotencyKey,List<String> destinationKeys,String reason) {
/** 显式零版本合法,缺版本不能覆盖并发变更。 */
@@ -42,6 +42,10 @@ public class DispatchAggregateRepository {
public List<DispatchAggregateMapper.Summary> summaries(DispatchAggregateMapper.Search query) { return mapper.summaries(query); }
/** 按整车读取全部真实客户明细。 */
public List<DispatchItem> cargo(long dispatchId) { return items.findWholeDispatch(dispatchId); }
/** 只读取本次确认入库的原计划关联排车。 */
public List<String> receiptDispatches(List<Long> planIds) {
return planIds.isEmpty()?List.of():items.receiptDispatches(planIds).stream().map(DispatchItemMapper.ReceiptDispatch::dispatchNo).toList();
}
/** 保存已核对真实客户与仓库的来源快照。 */
public int insertItem(DispatchItem item) { return items.insert(item); }
/** 未执行明细才可移除,资格和根版本由应用层保证。 */
@@ -61,6 +65,8 @@ public class DispatchAggregateRepository {
public List<DispatchReadMapper.DeliveryRow> deliveries(long id) { return reads.deliveries(id); }
/** 已有关联预约批次只读事实。 */
public List<DispatchReadMapper.BatchRow> batches(long id) { return reads.batches(id); }
/** 本车实际货物的权威货件号,不依赖预约分配,也不把重复托盘当作新货件。 */
public List<DispatchReadMapper.CargoShipment> cargoShipmentNumbers(long id) { return reads.cargoShipmentNumbers(id); }
/** 是否必须走受保护提供者核验批次分配。 */
public long activeAppointments(String customer, Long pallet) { return reads.activeAppointments(customer,pallet); }
}
@@ -66,6 +66,8 @@ public class DispatchSourceRepository {
public DispatchSourceMapper.RelabelBasis relabelBasis(String no) { return source.relabelBasis(no); }
/** 取得已有普通计划候选,保留原来源资格SQL。 */
public List<DispatchPalletCandidate> selectedPlan(String customer, Long id) { return plans.readSelected(customer, List.of(id)); }
/** 确认入库直接读取逐票实收,与可用、暂扣等出货资格分离。 */
public List<DispatchPalletCandidate> confirmedPlan(String customer,long id) { return source.confirmedPlan(customer,id); }
/** 取得已有真实退回候选。 */
public List<DispatchPalletCandidate> selectedReturn(String customer, Long id) { return returns.readSelected(customer, List.of(id)); }
/** 同托真实来源订单组成。 */
@@ -174,7 +174,7 @@ public class DispatchAggregateService implements DispatchExecutionPort,DispatchA
var destination=new Destination(com.baiyue.lastleg.fleet.trip.FleetDispatchGateway.destinationKey(row.destinationType(),row.destinationWarehouseCode(),
row.fbaWarehouseCode(),row.deliveryAddress(),row.postalCode()),row.destinationType(),row.destinationWarehouseId(),row.destinationWarehouseCode(),
row.destinationWarehouseName(),row.deliveryAddress(),row.postalCode(),row.contactName(),row.contactPhone());
var fact=row.planPalletId()==null?null:batchFacts.get(new com.baiyue.lastleg.appointmentwork.AppointmentWorkDispatchPort.CandidatePhysical(row.customerId(),row.planPalletId()));
var fact=type!=SourceType.INBOUND_PLAN || row.planPalletId()==null?null:batchFacts.get(new com.baiyue.lastleg.appointmentwork.AppointmentWorkDispatchPort.CandidatePhysical(row.customerId(),row.planPalletId()));
var references=new ArrayList<BatchReference>();var reasons=new LinkedHashSet<String>();
reasons.addAll(group.blockReasons());
if(fact!=null) {
@@ -268,14 +268,14 @@ public class DispatchAggregateService implements DispatchExecutionPort,DispatchA
}
return evidence.download(row.getId(),kind,fileId);
}
/** 未命中冻结MASTER引用的文件仅可按原排车归属读取,保留编辑中尚未保存的本车外配上传。 */
/** 主档驾驶证按明确来源标记读取,本趟补传照片保持排车文件归属。 */
private Long masterEvidenceDriverId(DispatchAggregate row,com.baiyue.lastleg.dispatch.gateway.DispatchEvidenceGateway.Kind kind,long fileId) {
if(kind!=com.baiyue.lastleg.dispatch.gateway.DispatchEvidenceGateway.Kind.DRIVER)return null;
var resources=read(row.getResourcesJson(),Resources.class);var driver=resources==null?null:resources.driver();
if(driver==null || driver.mode()!=ResourceMode.MASTER || driver.evidence()==null || driver.evidence().fileIds()==null
|| !driver.evidence().fileIds().contains(fileId))return null;
// 取货允许补传本车驾驶证,仅明确冻结的主档引用才走司机归属。
if("PICKUP".equals(row.getTaskType()) && !Objects.equals(driver.evidence().masterLicenseFileId(),fileId))return null;
// 只有车队解析并冻结的主档引用才走司机归属,不能把补传图片当作主档文件。
if(!Objects.equals(driver.evidence().masterLicenseFileId(),fileId))return null;
if(driver.driverId()==null || driver.driverId()<=0)throw bad("排车冻结司机档案身份无效,不能读取证照");
return driver.driverId();
}
@@ -380,6 +380,53 @@ public class DispatchAggregateService implements DispatchExecutionPort,DispatchA
return saveCommand(row,actor,identity,request.reason());
}
/** 已有排车按当前真实客户托盘读取逐票正式预约,不将本车占用当成其他车占用。 */
@Transactional(readOnly=true,isolation=Isolation.REPEATABLE_READ)
public DispatchWorkModels.AppointmentAllocations appointmentAllocations(Actor actor,String number) {
requirePlatform(actor);var row=required(actor,number,false);
var items=appointmentItems(row);var options=appointments.options(row.getId(),items);var current=repository.batches(row.getId());
var result=items.stream().map(i->new DispatchWorkModels.AppointmentItem(i.getId(),i.getPlanPalletId(),i.getCustomerId(),i.getCustomerName(),i.getPalletNo(),
options.get(new com.baiyue.lastleg.appointmentwork.AppointmentWorkDispatchPort.CandidatePhysical(i.getCustomerId(),i.getPlanPalletId())),
current.stream().filter(a->Objects.equals(a.dispatchItemId(),i.getId())).map(a->new BatchAllocation(a.planPalletId(),a.batchKey(),a.customerId(),a.sourceKey(),a.destinationKey(),a.packageCount(),a.recordVersion())).toList())).toList();
boolean permitted=hasPermission(actor,Action.EDIT);boolean editable=appointmentEditable(row) && !items.isEmpty();
return new DispatchWorkModels.AppointmentAllocations(number,row.getRecordVersion(),result,new Gate(permitted && editable,
!permitted?"FORBIDDEN":editable?null:"NOT_ELIGIBLE",!permitted?"当前无此操作权限":editable?null:"仅装车前、未扫码且无有效下游单据的FBA货物可关联预约"));
}
/** 原车补选或改选正式预约,分配、主版本、审核失效、历史和命令结果同事务保存。 */
@Transactional(isolation=Isolation.READ_COMMITTED)
public CommandResult saveAppointmentAllocations(Actor actor,String number,DispatchWorkModels.AppointmentAllocationCommand request) {
var row=lockPlatform(actor,number);
if(!hasPermission(actor,Action.EDIT))throw new BizException(ResultCode.FORBIDDEN,"当前无排车维护权限");
var identity=identity(actor,number,Action.EDIT,request.recordVersion(),request.idempotencyKey(),request);
var replay=commands.replay(actor,number,identity);if(replay.isPresent())return replay.get();
requireEditing(row,request.recordVersion());
if(!appointmentEditable(row))throw conflict("本车已有实际扫码或有效下游单据,不能调整预约分配");
var ids=request.dispatchItemIds();
if(ids==null || ids.isEmpty() || ids.size()>1000 || ids.stream().anyMatch(Objects::isNull) || new HashSet<>(ids).size()!=ids.size())throw bad("请选择完整且不重复的本车货物明细");
if(request.batchAllocations()==null || request.batchAllocations().size()>1000)throw bad("请明确本次正式预约分配");
var touched=appointmentItems(row).stream().filter(i->ids.contains(i.getId())).toList();
if(touched.size()!=ids.size())throw conflict("预约分配只能调整本车已有的真实FBA货物");
// 先父车/资源/主单,再全部旧新预约,最后订单/实托/计划来源;不反向追加批次锁。
appointments.lock(repository.batches(row.getId()),request.batchAllocations());
sourceRules.lockAppointmentSources(row.getOverseasWarehouseId(),touched);
appointments.bind(row.getId(),touched,request.batchAllocations());
String reason="正式预约分配已调整,请客服重新提交两级审核";invalidate(row,reason);
return saveCommand(row,actor,identity,reason,new DispatchResults.Saved(number,touched.stream().map(DispatchItem::getId).toList()));
}
/** 只处理普通计划来源的FBA货物,其他来源与客户原分配保持不动。 */
private List<DispatchItem> appointmentItems(DispatchAggregate row) {
return repository.cargo(row.getId()).stream().filter(i->i.getPlanPalletId()!=null && "FBA".equals(i.getDestinationType())
&& !"RELABEL_ORDER".equals(i.getSourceBusinessType())).toList();
}
/** 显式查真实扫码流水,不以主单零计数覆盖实际执行事实。 */
private boolean appointmentEditable(DispatchAggregate row) {
return (row.getTaskType()==null || "DELIVERY".equals(row.getTaskType())) && "ACTIVE".equals(row.getStatus()) && "BEFORE_LOADING".equals(row.getStage())
&& n(row.getPickedPackageCount())==0 && n(row.getShippedPackageCount())==0 && repository.realPicked(row.getId())==0
&& repository.outbounds(row.getId()).stream().allMatch(o->"CANCELLED".equals(o.status()))
&& repository.deliveries(row.getId()).stream().allMatch(d->"CANCELLED".equals(d.status()));
}
/** 旧新资源身份一次锁齐,资料和本趟照片随正式资源快照保存,原审核失效规则保留。 */
@Transactional(isolation=Isolation.READ_COMMITTED)
public CommandResult update(Actor actor,String number,DispatchCommands.Update request) {
@@ -394,8 +441,15 @@ public class DispatchAggregateService implements DispatchExecutionPort,DispatchA
if(!photos.isEmpty())verifyEvidence(row.getId(),com.baiyue.lastleg.dispatch.gateway.DispatchEvidenceGateway.Kind.DRIVER_PHOTO,photos);
var vehiclePhotos=request.vehicle()==null?null:request.vehicle().evidenceFileIds();
if(vehiclePhotos!=null && !vehiclePhotos.isEmpty())verifyEvidence(row.getId(),com.baiyue.lastleg.dispatch.gateway.DispatchEvidenceGateway.Kind.VEHICLE,vehiclePhotos);
var actual=fleet.dispatchResources(number,fleetVehicle(request.vehicle()),fleetDriver(request.driver()));
row.setResourcesJson(write(resources(actual,photos,request.maintenance())));row.setVehiclePlate(actual.vehicle()==null?null:actual.vehicle().plate());
var actual=fleet.dispatchResources(number,fleetVehicle(request.vehicle()),updatedFleetDriver(request.driver(),read(row.getResourcesJson(),Resources.class)));
DispatchDestinationAppointments.validate(request.maintenance(),reviewAppointments(row));
var savedResources=resources(actual,photos,request.maintenance());
// 主档引用只能由车队解析产生;补传照片必须核验本车归属后才允许保存。
var savedDriver=savedResources.driver();
if(savedDriver!=null && savedDriver.evidence()!=null)for(var id:savedDriver.evidence().fileIds()) {
if(!Objects.equals(id,savedDriver.evidence().masterLicenseFileId()))evidence.metadata(row.getId(),com.baiyue.lastleg.dispatch.gateway.DispatchEvidenceGateway.Kind.DRIVER,id);
}
row.setResourcesJson(write(savedResources));row.setVehiclePlate(actual.vehicle()==null?null:actual.vehicle().plate());
row.setPlannedDepartureAt(utc(request.plannedDepartureAt()));row.setEstimatedArrivalAt(utc(request.estimatedArrivalAt()));row.setRemark(request.remark());
row.setPublishLooseboardDemand(request.publishLooseboardDemand());
if(!Objects.equals(before,basis(row)))invalidate(row,"车辆、司机或计划信息已修改");
@@ -565,7 +619,8 @@ public class DispatchAggregateService implements DispatchExecutionPort,DispatchA
if(items.isEmpty() || items.stream().anyMatch(i->i.getWarehousePalletId()==null))throw conflict("逐项真实拣货尚未核平");
lockExecutionSources(row,items);
var preview=loading(actor,number);if(!preview.pickingReconciled())throw conflict("逐项真实拣货尚未核平");
String appointmentBlock=appointments.departureBlockReason(number);
String appointmentBlock=reviewAppointmentBlock(row);
if(appointmentBlock==null)appointmentBlock=appointments.departureBlockReason(number);
if(appointmentBlock!=null)throw conflict(appointmentBlock);
if(!Objects.equals(preview.manifestHash(),request.manifestHash()))throw conflict("真实装载清单已变化,请重新核对");
if(preview.pallets().stream().anyMatch(p->p.deliveryIndex()==null))throw conflict("托盘卸货站尚未匹配,请调度核对路线");
@@ -609,7 +664,7 @@ public class DispatchAggregateService implements DispatchExecutionPort,DispatchA
var origin=pick.dispatchItemSourceId()==null?null:origins.get(pick.dispatchItemSourceId());
var sourceOrigin=origin==null?null:new SourceOrigin(origin.getId(),origin.getRelabelApplicationId(),origin.getRelabelItemId(),origin.getSourceInventoryBalanceId(),origin.getSourceInboundOrderId(),origin.getSourceInboundOrderNo(),origin.getSourceInboundCode(),origin.getProductQuantity()!=null || pick.productQuantity()>0?pick.productQuantity():null);
cargo.add(new CargoLine(item.getId(),pick.customerId(),item.getCustomerName(),pick.orderId(),pick.orderNo(),source,item.getSourceStage(),pick.sourceRegion(),pick.sourceInboundId(),pick.inboundCode(),
item.getPlanPalletId(),item.getWarehousePalletId(),item.getPalletNo(),pick.pickId(),pick.attributionId(),pick.positionId(),pick.receiptId(),pick.returnReceiptId(),pick.returnCartonId(),pick.cartonMark(),
item.getPlanPalletId(),item.getWarehousePalletId(),item.getPalletNo(),pick.pickId(),pick.attributionId(),pick.positionId(),pick.receiptId(),pick.returnReceiptId(),pick.returnCartonId(),origin==null?pick.cartonMark():relabelCartonMarks(origin),
"RELABEL_ORDER".equals(item.getSourceBusinessType())?item.getSourceBusinessNo():null,destination(item),pick.packages(),pick.productQuantity(),null,null,allocations,sourceOrigin));
}
var picked=executionReads.itemPicked(row.getId()).stream().collect(Collectors.toMap(p->p.itemId(),p->p.packages()));
@@ -620,6 +675,22 @@ public class DispatchAggregateService implements DispatchExecutionPort,DispatchA
read(row.getResourcesJson(),Resources.class),counts(row),reviews(row),complete,List.copyOf(cargo));
}
/** 普通换标出库沿本次冻结贡献的新箱标,不从退货箱表或原箱唛生成快照。 */
private String relabelCartonMarks(com.baiyue.lastleg.dispatch.entity.DispatchItemSource origin) {
try {
var facts=json.readTree(origin.getUnitFactsJson());var marks=new java.util.TreeSet<String>();
if(!facts.isArray())throw conflict("换标冻结箱标事实缺失");
for(var fact:facts)if("RELABELLED".equals(fact.path("result").asText())) {
var mark=fact.path("cartonMark").asText("").trim();
if(mark.isEmpty() || !marks.add(mark))throw conflict("换标冻结新箱标缺失或重复");
}
if(marks.isEmpty())throw conflict("换标冻结新箱标缺失");
return String.join(",",marks);
} catch(com.fasterxml.jackson.core.JsonProcessingException error) {
throw new IllegalStateException("换标冻结箱标事实无法读取",error);
}
}
/** 共同根锁顺序固定,当前阶段资格留给原命令恢复之后。 */
private DispatchAggregate lockPlatform(Actor actor,String number) { requirePlatform(actor);lockReviewRoot(actor,number);return required(actor,number,true); }
/** 运输取货锁原车次,仓库派送锁其整车资源;统一先父后主单。 */
@@ -645,9 +716,30 @@ public class DispatchAggregateService implements DispatchExecutionPort,DispatchA
var facts=new TreeMap<String,Object>();facts.put("cargoHash",row.getCargoHash());facts.put("resources",read(row.getResourcesJson(),Resources.class));
facts.put("plannedDepartureAt",row.getPlannedDepartureAt());facts.put("estimatedArrivalAt",row.getEstimatedArrivalAt());
if("PICKUP".equals(row.getTaskType()))facts.put("route",transportTasks.transport(row.getDispatchNo(),false).getStopsJson());
else {facts.put("batches",repository.batches(row.getId()));facts.put("route",fleet.dispatchStops(row.getDispatchNo()));facts.put("receiving",appointments.reviewEvidence(row.getDispatchNo()));}
else {
var batches=json.valueToTree(repository.batches(row.getId()));
// 客户仓的DISPATCH引用属于本车自身,扫码递增公共版本不代表预约或货物依据变化。
for(var batch:batches)if(("DISPATCH:"+row.getId()).equals(batch.path("batchKey").asText()))
((com.fasterxml.jackson.databind.node.ObjectNode)batch).remove("recordVersion");
facts.put("batches",batches);facts.put("route",fleet.dispatchStops(row.getDispatchNo()));facts.put("receiving",reviewAppointments(row));
}
return hashes.source(facts);
}
/** 交接前记住原审核依据是否仍有效,不借实收替资源或预约变化重新背书。 */
boolean currentReceiptBasis(DispatchAggregate row) {
return row.getReviewBasisHash()!=null && Objects.equals(row.getReviewBasisHash(),basis(row));
}
/** 自然实收只延续仍有效的原审核依据,失效审核继续保持失效。 */
void saveReceivedCargo(DispatchAggregate row,String operator,LocalDateTime at,boolean preserveReview) {
long version=row.getRecordVersion();
if(preserveReview)row.setReviewBasisHash(basis(row));
row.setRecordVersion(Math.addExact(version,1));row.setUpdatedAt(at);
if(repository.saveState(row,version)!=1)throw conflict("收货交接期间排车版本已变化");
if(repository.history(IdWorker.getId(),row,"RECEIVING_COMPLETE",operator,"按确认入库的逐托实收交接原预排",write(row),at)!=1)
throw conflict("排车实收交接历史未保存");
}
/** 作废原因进入两级显式记录,旧审核证据仍保留在不可变历史中。 */
private void invalidate(DispatchAggregate row,String reason) {
row.setReviewState(ReviewState.DRAFT.name());row.setReviewBasisHash(null);
@@ -678,20 +770,37 @@ public class DispatchAggregateService implements DispatchExecutionPort,DispatchA
var capacity=v.capacity();if(capacity==null || capacity.palletSpaces()==null || capacity.palletSpaces()<=0 || capacity.maxLoadKg()==null || capacity.maxLoadKg().signum()<=0 || capacity.maxVolumeCbm()==null || capacity.maxVolumeCbm().signum()<=0)throw bad("请补齐车辆真实托位、载重及容积");
var e=d.evidence();if(e==null || e.expectedTaskEndAt()==null || row.getPlannedDepartureAt()==null || !e.expectedTaskEndAt().isAfter(instant(row.getPlannedDepartureAt())) || !e.expectedTaskEndAt().isAfter(Instant.now()))throw bad("预计任务结束须晚于发车及当前时间");
if(trim(e.licenseNo())==null || e.licenseExpiresOn()==null || e.licenseExpiresOn().isBefore(e.expectedTaskEndAt().atZone(ZoneOffset.UTC).toLocalDate()))throw bad("司机证照须覆盖预计任务结束日期");
if(v.mode()==ResourceMode.EXTERNAL || v.evidenceFileIds()!=null && !v.evidenceFileIds().isEmpty())verifyEvidence(row.getId(),com.baiyue.lastleg.dispatch.gateway.DispatchEvidenceGateway.Kind.VEHICLE,v.evidenceFileIds());
if(d.mode()==ResourceMode.EXTERNAL)verifyEvidence(row.getId(),com.baiyue.lastleg.dispatch.gateway.DispatchEvidenceGateway.Kind.DRIVER,e.fileIds());
if(!e.photoFileIds().isEmpty())verifyEvidence(row.getId(),com.baiyue.lastleg.dispatch.gateway.DispatchEvidenceGateway.Kind.DRIVER_PHOTO,e.photoFileIds());
verifyReviewPhotos(row,r);
var actual=fleet.dispatchResources(row.getDispatchNo(),new FleetExecutionModels.DispatchVehicle(v.mode().name(),v.vehicleId(),v.plate(),v.carrierName(),capacity.palletSpaces(),capacity.maxLoadKg(),capacity.maxVolumeCbm(),v.evidenceFileIds()),
new FleetExecutionModels.DispatchDriver(d.mode().name(),d.driverId(),d.name(),d.phone(),e.licenseNo(),e.licenseExpiresOn(),e.fileIds(),e.expectedTaskEndAt()));
if(!Objects.equals(hashes.source(r),hashes.source(resources(actual,e.photoFileIds(),r.maintenance()))))throw conflict("车辆或司机主档已变化,请重新保存并提交审核");
}
/** 三类均须含至少一张当前有效图片;保存草稿不调用此送审/通过门禁。 */
private void verifyReviewPhotos(DispatchAggregate row,Resources resources) {
var driver=resources.driver();var proof=driver.evidence();
requireReviewPhoto(row,com.baiyue.lastleg.dispatch.gateway.DispatchEvidenceGateway.Kind.VEHICLE,resources.vehicle().evidenceFileIds(),"车辆图片");
requireReviewPhoto(row,com.baiyue.lastleg.dispatch.gateway.DispatchEvidenceGateway.Kind.DRIVER_PHOTO,proof.photoFileIds(),"司机照片");
requireReviewPhoto(row,com.baiyue.lastleg.dispatch.gateway.DispatchEvidenceGateway.Kind.DRIVER,proof.fileIds(),"驾驶证照片");
}
/** 文件存在、归属及有效性由原文件提供者复验,PDF不能充当照片。 */
private void requireReviewPhoto(DispatchAggregate row,com.baiyue.lastleg.dispatch.gateway.DispatchEvidenceGateway.Kind kind,List<Long> ids,String label) {
if(ids==null || ids.isEmpty() || ids.size()>20 || ids.stream().anyMatch(id->id==null || id<=0) || ids.stream().distinct().count()!=ids.size())throw bad("请上传至少一张有效"+label);
boolean image=false;
for(var id:ids) {
var master=masterEvidenceDriverId(row,kind,id);
var file=master==null?evidence.metadata(row.getId(),kind,id):driverEvidenceMetadata(fleet.dispatchDriverLicense(master,id).metadata(),id);
if(file!=null && ("image/jpeg".equals(file.contentType()) || "image/png".equals(file.contentType())))image=true;
}
if(!image)throw bad("请上传至少一张有效"+label);
}
/** 从客服提交到真实扫码共用预约及时间复验,保留预排但不放行未确认货物。 */
void requireReviewAppointment(DispatchAggregate row) {
String reason=reviewAppointmentBlock(row);
if(reason!=null)throw conflict(reason);
}
private String reviewAppointmentBlock(DispatchAggregate row) {
String reason=appointments.reviewBlockReason(row.getDispatchNo(),instant(row.getPlannedDepartureAt()),instant(row.getEstimatedArrivalAt()));
String reason=DispatchDestinationAppointments.blockReason(reviewAppointments(row),instant(row.getPlannedDepartureAt()),instant(row.getEstimatedArrivalAt()),appointments.reviewEvidence(row.getDispatchNo()));
if(reason==null)reason=appointments.reviewBlockReason(row.getDispatchNo(),instant(row.getPlannedDepartureAt()),instant(row.getEstimatedArrivalAt()));
if(reason==null && row.getReviewBasisHash()!=null && !canSubmitReview(row.getReviewState()) && !Objects.equals(row.getReviewBasisHash(),basis(row)))
return "预约或运输安排已变化,原审核依据失效,请重新核对审核";
return reason;
@@ -753,20 +862,26 @@ public class DispatchAggregateService implements DispatchExecutionPort,DispatchA
statistics(repository.summaries(filter)),Instant.now());
}
/** Admin与PDA审核读取相同的当前有效预约,未确认成员不能被已确认成员覆盖。 */
/** Admin与PDA共用本车目的仓预约,每个目的仓仅返回一行。 */
List<DispatchViews.FbaAppointment> reviewAppointments(DispatchAggregate row) {
String number=row.getDispatchNo();var batches=repository.batches(row.getId());
var resources=read(row.getResourcesJson(),Resources.class);
return DispatchDestinationAppointments.resolve(repository.cargo(row.getId()),resources==null?null:resources.maintenance(),bookedAppointments(row),repository.cargoShipmentNumbers(row.getId()));
}
/** 原预约仅用于带出已有确认号,客服按目的仓维护后不再逐票录入。 */
private List<DispatchViews.FbaAppointment> bookedAppointments(DispatchAggregate row) {
String number=row.getDispatchNo();var batches=repository.batches(row.getId()).stream().filter(b->b.batchKey().startsWith("FBA_APPOINTMENT:")).toList();
if(batches.isEmpty())return List.of();
var confirmedAppointments=this.appointments.reviewEvidence(number);
return batches.stream().filter(b -> b.batchKey().startsWith("FBA_APPOINTMENT:")).collect(Collectors.toMap(
return batches.stream().collect(Collectors.toMap(
b -> List.of(b.batchKey(),b.customerId(),b.destinationKey()),b->b,(a,b)->a,LinkedHashMap::new)).values().stream().map(b -> {
var confirmed=confirmedAppointments.stream().filter(w->w.batchKey().equals(b.batchKey()) && w.customerId().equals(b.customerId()) && w.destinationKey().equals(b.destinationKey())).findFirst().orElse(null);
return new DispatchViews.FbaAppointment(b.batchKey(),b.batchNo(),confirmed==null?null:confirmed.confirmationNo(),b.destinationKey(),b.warehouseCode(),
confirmed==null?"PENDING":"SUCCESS",confirmed==null?null:confirmed.startAt(),confirmed==null?"尚无当前有效收货确认":null);
confirmed==null?"PENDING":"SUCCESS",confirmed==null?null:confirmed.startAt(),confirmed==null?"尚无当前有效收货确认":null,b.customerId(),List.of());
}).toList();
}
/** 一次读取真实全部客户明细和单据,客户端不暴露整车入口。 */
/** 四页签读取同一资源快照,维护资料和本趟车辆照片不回退为主档或备注。 */
/** 四页签读取同一资源快照,维护资料及本趟照片保持直接业务归属。 */
@Transactional(readOnly=true,isolation=Isolation.REPEATABLE_READ)
public DispatchViews.Detail detail(Actor actor, String number) {
var row=required(actor,number,false); var cargo=repository.cargo(row.getId());
@@ -811,6 +926,7 @@ public class DispatchAggregateService implements DispatchExecutionPort,DispatchA
for(var source:resolved) {
if(keys.contains(source.sourceKey())) throw conflict("所选来源已在本车,不能重复加入");
var c=source.candidate();
if(source.selection().sourceType()==SourceType.INBOUND_PLAN && "FBA".equals(c.destinationType()) && c.planPalletId()==null)throw conflict("本票FBA货物缺少可核对的正式预约来源,请先确认送仓时间");
sources.expire(c.customerId(),source.sourceKey(),at);
if(!sources.claims(c.customerId(),source.sourceKey(),at).isEmpty())throw conflict("所选逻辑来源已被有效运输安排占用");
try {if(sources.claim(IdWorker.getId(),c.customerId(),source.sourceKey(),c.planPalletId(),c.warehousePalletId(),c.returnReceiptId(),c.warehousePalletId()==null?null:physical.get(c.warehousePalletId()),row.getId(),actor.operatorId(),at)!=1)throw conflict("来源占用未保存");}
@@ -985,10 +1101,10 @@ public class DispatchAggregateService implements DispatchExecutionPort,DispatchA
new DispatchViews.ReviewCounts(dispatch[0],dispatch[1],dispatch[2],dispatch[3]),new DispatchViews.ReviewCounts(loading[0],loading[1],loading[2],loading[3])));
}
/** 列表不逐行读取明细、附件或派送。 */
/** 列表带出与详情相同的正式预约投影,不读取附件或派送。 */
private DispatchViews.ListRow listRow(Actor actor,DispatchAggregate row) {
return new DispatchViews.ListRow(row.getDispatchNo(),row.getTaskType()==null?"DELIVERY":row.getTaskType(),Status.valueOf(row.getStatus()),Stage.valueOf(row.getStage()),row.getRecordVersion(),ReviewState.valueOf(row.getReviewState()),
reviews(row),read(row.getResourcesJson(),Resources.class),row.getOverseasWarehouseId(),row.getOverseasWarehouseCode(),row.getOverseasWarehouseName(),instant(row.getPlannedDepartureAt()),instant(row.getDepartedAt()),counts(row),reconciled(row),row.getArrangementKey()!=null,actions(actor,row));
reviews(row),read(row.getResourcesJson(),Resources.class),row.getOverseasWarehouseId(),row.getOverseasWarehouseCode(),row.getOverseasWarehouseName(),instant(row.getCreatedAt()),instant(row.getPlannedDepartureAt()),instant(row.getDepartedAt()),counts(row),reconciled(row),row.getArrangementKey()!=null,actions(actor,row),reviewAppointments(row));
}
/** 混托多订单无法真实分摊重量体积时保持未知,不能给每票复制整托总量。 */
@@ -1009,7 +1125,8 @@ public class DispatchAggregateService implements DispatchExecutionPort,DispatchA
var v=shell.vehicle(); var d=shell.driver();
return new Resources(v==null?null:new VehicleSnapshot(ResourceMode.valueOf(v.mode()),v.vehicleId(),v.plate(),v.carrierName(),shell.vehicleStatus(),
new Capacity(v.palletSpaces(),v.maxLoadKg(),v.maxVolumeCbm()),v.evidenceFileIds()),d==null?null:new DriverSnapshot(ResourceMode.valueOf(d.mode()),d.driverId(),d.name(),d.phone(),
new DriverEvidence(d.licenseNo(),d.licenseExpiresOn(),d.evidenceFileIds(),d.expectedTaskEndAt(),photos)),maintenance);
new DriverEvidence(d.licenseNo(),d.licenseExpiresOn(),d.evidenceFileIds(),d.expectedTaskEndAt(),photos,
"MASTER".equals(d.mode()) && !d.evidenceFileIds().isEmpty()?d.evidenceFileIds().get(0):null)),maintenance);
}
/** 主档选择不接受请求覆盖其车牌和容量。 */
@@ -1019,6 +1136,17 @@ public class DispatchAggregateService implements DispatchExecutionPort,DispatchA
v.capacity()==null?null:v.capacity().maxLoadKg(),v.capacity()==null?null:v.capacity().maxVolumeCbm(),v.evidenceFileIds()==null?List.of():List.copyOf(v.evidenceFileIds()));
}
/** 旧主档证照由服务端保存的来源标记识别;换证后只向车队传本趟补传文件,不能信任请求自报来源。 */
private static FleetExecutionModels.DispatchDriver updatedFleetDriver(DispatchCommands.Driver requested,Resources previous) {
var driver=fleetDriver(requested);
var old=previous==null?null:previous.driver();
if(driver==null || old==null || old.mode()!=ResourceMode.MASTER || requested.mode()!=ResourceMode.MASTER
|| !Objects.equals(old.driverId(),requested.driverId()) || old.evidence()==null || old.evidence().masterLicenseFileId()==null)return driver;
var oldMaster=old.evidence().masterLicenseFileId();
return new FleetExecutionModels.DispatchDriver(driver.mode(),driver.driverId(),driver.name(),driver.phone(),driver.licenseNo(),driver.licenseExpiresOn(),
driver.evidenceFileIds().stream().filter(id->!Objects.equals(id,oldMaster)).toList(),driver.expectedTaskEndAt());
}
/** 司机证据是本趟输入,主档事实仍由Fleet提供者核验。 */
private static FleetExecutionModels.DispatchDriver fleetDriver(DispatchCommands.Driver d) {
if(d==null) return null; if(d.mode()==null) throw bad("请选择司机来源方式"); var e=d.evidence();
@@ -22,6 +22,14 @@ public class DispatchAppointmentAllocations {
/** 预排与执行门禁分开;审核之前不要求已有拣货记录。 */
public String reviewBlockReason(String dispatchNo,java.time.Instant departure,java.time.Instant arrival){return provider.reviewBlockReason(dispatchNo,departure,arrival);}
public List<AppointmentWorkDispatchPort.ReceivingWindow> reviewEvidence(String dispatchNo){return provider.reviewEvidence(dispatchNo);}
/** 本车货物已经由主单范围核实,选项不重新选货或制造托盘占用。 */
public Map<AppointmentWorkDispatchPort.CandidatePhysical,List<AppointmentWorkDispatchPort.AllocationSource>> options(long dispatch,List<DispatchItem> items) {
var identities=items.stream().map(i->new AppointmentWorkDispatchPort.CandidatePhysical(i.getCustomerId(),i.getPlanPalletId())).distinct().toList();
if(identities.isEmpty())return Map.of();
var result=provider.allocationOptions(dispatch,identities);
if(result==null || !result.keySet().equals(new HashSet<>(identities)))throw new BizException(ResultCode.SERVICE_UNAVAILABLE,"本车预约选项范围不完整,请重新读取");
return result;
}
/** 一整页只做一次Provider批读;没有计划来源的独立退货不伪造计划ID。 */
public Map<AppointmentWorkDispatchPort.CandidatePhysical,AppointmentWorkDispatchPort.CandidateBatches> candidates(List<com.baiyue.lastleg.dispatch.model.DispatchPalletCandidate> rows) {
@@ -58,6 +58,12 @@ public class DispatchBolReadService {
remaining.forEach((key,count)->{if(count>0)result.add(copy(cargo.get(key),count,null));});
var printedTotals=new HashMap<Long,Integer>();result.forEach(line->printedTotals.merge(line.dispatchItemId(),line.packageCount(),Math::addExact));
if(!printedTotals.equals(totals))throw invalid();
// 仅已填写编号覆盖同仓来源;空草稿保留后来取得的正式预约号,不改变各客户货量。
if(resources.maintenance()!=null) {
var numbers=new HashMap<String,String>();resources.maintenance().destinationAppointments().stream()
.filter(a->a.appointmentNo()!=null).forEach(a->numbers.put(a.destinationKey(),a.appointmentNo()));
result.replaceAll(line->numbers.containsKey(line.destinationKey())?copy(line,line.packageCount(),numbers.get(line.destinationKey())):line);
}
return new DispatchBolData(row.getDispatchNo(),row.getOverseasWarehouseId(),row.getOverseasWarehouseName(),resources.vehicle()==null?null:resources.vehicle().carrierName(),resources.vehicle()==null?null:resources.vehicle().plate(),
row.getEstimatedArrivalAt()==null?null:LocalDateTime.ofInstant(row.getEstimatedArrivalAt().toInstant(ZoneOffset.UTC),ZoneId.systemDefault()),List.copyOf(result));
}
@@ -0,0 +1,66 @@
package com.baiyue.lastleg.dispatch.service;
import com.baiyue.lastleg.dispatch.entity.DispatchItem;
import com.baiyue.lastleg.dispatch.port.DispatchContracts.Maintenance;
import com.baiyue.lastleg.dispatch.port.DispatchViews.FbaAppointment;
import com.baiyue.lastleg.appointmentwork.AppointmentWorkDispatchPort.ReceivingWindow;
import com.baiyue.common.core.api.ResultCode;
import com.baiyue.common.core.exception.BizException;
import java.time.Instant;
import java.util.*;
/** 本车送仓预约按目的仓汇总;货物的客户归属及真实数量仍由原明细维护。 */
final class DispatchDestinationAppointments {
/** 纯规则容器不持有数据访问或单据状态。 */
private DispatchDestinationAppointments() { }
/** 先预约的唯一确认号自动带入;同仓多个不同号时交由客服填写本车实际使用的一个号。 */
static List<FbaAppointment> resolve(List<DispatchItem> cargo, Maintenance maintenance, List<FbaAppointment> booked, List<com.baiyue.lastleg.dispatch.mapper.DispatchReadMapper.CargoShipment> cargoShipments) {
var destinations = new TreeMap<String,String>();
for (var item : cargo) if ("FBA".equals(item.getDestinationType())) {
var key = com.baiyue.lastleg.fleet.trip.FleetDispatchGateway.destinationKey(item.getDestinationType(),item.getDestinationWarehouseCode(),item.getFbaWarehouseCode(),item.getDeliveryAddress(),item.getPostalCode());
if (key == null) throw new BizException(ResultCode.CONFLICT,"本车FBA货物缺少目的仓码");
destinations.put(key, key.substring(4));
}
var entered = new HashMap<String,String>();
if (maintenance != null) for (var entry : maintenance.destinationAppointments()) entered.put(entry.destinationKey(),entry.appointmentNo());
var result = new ArrayList<FbaAppointment>();
destinations.forEach((key,code) -> {
var confirmed = booked.stream().filter(a -> key.equals(a.destinationKey()) && "SUCCESS".equals(a.status()) && a.appointmentNo() != null && !a.appointmentNo().isBlank()).toList();
var numbers = confirmed.stream().map(FbaAppointment::appointmentNo).distinct().toList();
// 草稿暂未填写不能遮挡后来取得的唯一正式确认号,已填写值仍保留。
var enteredNumber = entered.get(key);
var number = enteredNumber != null ? enteredNumber : numbers.size() == 1 ? numbers.get(0) : null;
var matching = confirmed.stream().filter(a -> Objects.equals(number,a.appointmentNo())).toList();
var at = matching.stream().map(FbaAppointment::confirmedAt).filter(Objects::nonNull).min(Comparator.naturalOrder()).orElse(null);
var shipments = cargoShipments.stream().filter(a -> key.equals(a.destinationKey())).map(com.baiyue.lastleg.dispatch.mapper.DispatchReadMapper.CargoShipment::shipmentNo).filter(Objects::nonNull).map(String::trim).filter(s -> !s.isEmpty()).distinct().sorted().toList();
result.add(new FbaAppointment(null,null,number,key,code,number == null ? "PENDING" : at == null ? "REGISTERED" : "SUCCESS",at,
number == null ? "请填写目的仓 " + code + " 的预约号" : null,null,shipments));
});
return List.copyOf(result);
}
/** 保存允许暂缺编号,但不能把别的目的仓写入本车。 */
static void validate(Maintenance maintenance, List<FbaAppointment> destinations) {
if (maintenance == null) return;
var keys = destinations.stream().map(FbaAppointment::destinationKey).toList();
if (maintenance.destinationAppointments().stream().anyMatch(a -> !keys.contains(a.destinationKey())))
throw new BizException(ResultCode.BAD_REQUEST,"预约只能填写本车已有的目的仓");
}
/** 预约完整性只在提交审核及后续执行检查;明确已知的正式时间继续参与核对。 */
static String blockReason(List<FbaAppointment> destinations, Instant departure, Instant arrival, List<ReceivingWindow> windows) {
var missing = destinations.stream().filter(a -> a.appointmentNo() == null).map(FbaAppointment::destinationWarehouseCode).toList();
if (!missing.isEmpty()) return "请填写目的仓 " + String.join("、",missing) + " 的预约号后提交审核";
if (destinations.isEmpty()) return null;
if (departure != null && arrival != null && arrival.isBefore(departure)) return "预计送达时间不能早于计划发车时间";
var first = windows.stream().filter(w -> destinations.stream().anyMatch(a -> w.destinationKey().equals(a.destinationKey()) && Objects.equals(w.confirmationNo(),a.appointmentNo())))
.min(Comparator.comparing(ReceivingWindow::startAt)).orElse(null);
if (first != null) {
if (departure == null || arrival == null) return "请先明确计划发车时间和预计送达时间,再提交审核";
if (!departure.isBefore(first.startAt())) return "计划发车时间须早于本车最早的FBA预约送达时间";
if (arrival.isAfter(first.endAt() == null ? first.startAt() : first.endAt())) return "预计送达时间晚于本车首个FBA预约收货时间,请调度重新核对";
}
return null;
}
}
@@ -113,7 +113,9 @@ public class DispatchDraftService implements DispatchPlanningPort {
var resolved=lockedIndex.get(member.sourceKey());
if(!Objects.equals(resolved.selection(),member.selection()) || !Objects.equals(resolved.candidate().customerId(),member.customerId())
|| resolved.candidate().packageCount()==null || resolved.candidate().packageCount().longValue()!=member.packages())throw conflict("锁内完整来源身份、版本或箱数已变化");
next.add(candidate(resolved,group));keys.add(member.sourceKey());
var projected=candidate(resolved,group);
if(!projected.blockReasons().isEmpty())throw conflict(String.join(";",projected.blockReasons()));
next.add(projected);keys.add(member.sourceKey());
}
requireCapacity(container.getCapacity(),keys,index(next));
var at=now();var physical=physicalClaims.reserve(physicalOwner(row,container.getId()),members.values().stream()
@@ -421,14 +423,18 @@ public class DispatchDraftService implements DispatchPlanningPort {
.map(c->new DraftContainer(c.getId(),c.getSequenceNo(),c.getCapacity(),keys(c))).toList(),selected(row).stream().map(c->projectCandidate(c,batches)).toList(),row.getExpiresAt().toInstant(ZoneOffset.UTC));}
/** 真实客户及计划托盘来自服务器来源,整页一次调用原Provider。 */
private Map<AppointmentWorkDispatchPort.CandidatePhysical,AppointmentWorkDispatchPort.CandidateBatches> readCandidateBatches(List<Candidate> candidates) {
var identities=candidates.stream().filter(c->c.selection().planPalletId()!=null).map(c->new AppointmentWorkDispatchPort.CandidatePhysical(c.customerId(),c.selection().planPalletId())).distinct().toList();
var identities=candidates.stream().filter(c->c.selection().sourceType()==DispatchTypes.SourceType.INBOUND_PLAN && c.selection().planPalletId()!=null).map(c->new AppointmentWorkDispatchPort.CandidatePhysical(c.customerId(),c.selection().planPalletId())).distinct().toList();
var result=new LinkedHashMap<AppointmentWorkDispatchPort.CandidatePhysical,AppointmentWorkDispatchPort.CandidateBatches>();
for(int offset=0;offset<identities.size();offset+=1000)result.putAll(appointmentProvider.candidateBatches(identities.subList(offset,Math.min(offset+1000,identities.size()))));
return result;
}
/** 显式映射原批次版本;本草稿有效占用不作为所选来源自身的阻断理由。 */
private Candidate projectCandidate(Candidate c,Map<AppointmentWorkDispatchPort.CandidatePhysical,AppointmentWorkDispatchPort.CandidateBatches> batches) {
if(c.selection().planPalletId()==null)return c;
if(c.selection().sourceType()!=DispatchTypes.SourceType.INBOUND_PLAN)return c;
if(c.selection().planPalletId()==null) {
if(!"FBA".equals(c.destination().type()))return c;
return new Candidate(c.selection(),c.sourceKey(),c.sourceVersion(),c.customerId(),c.customerName(),c.warehouseId(),c.warehouseCode(),c.warehouseName(),c.orderNo(),c.inboundCode(),c.planNo(),c.palletNo(),c.containerNo(),c.destination(),c.availablePackages(),c.suggestedWeightKg(),c.suggestedVolumeCbm(),c.measurementSource(),List.of(),List.of("本票FBA货物缺少可核对的正式预约来源,请先确认送仓时间"),c.selectionGroup());
}
var result=batches.get(new AppointmentWorkDispatchPort.CandidatePhysical(c.customerId(),c.selection().planPalletId()));
var reasons=new LinkedHashSet<String>();
if(result==null)reasons.add("预约批次实物读取不完整,请重新核对来源");else reasons.addAll(result.blockReasons());
@@ -0,0 +1,111 @@
package com.baiyue.lastleg.dispatch.service;
import com.baiyue.common.core.api.ResultCode;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.lastleg.dispatch.entity.DispatchItem;
import com.baiyue.lastleg.dispatch.model.DispatchPalletCandidate;
import com.baiyue.lastleg.dispatch.repository.*;
import com.baiyue.lastleg.fleet.trip.FleetTripExecutionService;
import com.baiyue.lastleg.inboundplan.pda.gateway.InboundDispatchReceiptGateway;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.*;
import org.springframework.transaction.support.*;
import java.time.LocalDateTime;
import java.util.*;
/** 确认入库时完成原预排实收交接,出货只执行已确认的逐托数量。 */
@Service
@Transactional(propagation=Propagation.MANDATORY)
public class DispatchInboundReceiptService implements InboundDispatchReceiptGateway {
private final DispatchAggregateRepository repository;
private final DispatchSourceRepository sources;
private final DispatchPickingRepository picking;
private final DispatchPhysicalClaimService physicalClaims;
private final DispatchAggregateService workflow;
private final FleetTripExecutionService fleet;
private final InboundDispatchReceiptGateway.Appointments appointments;
private final Object locksKey=new Object();
/** 使用原排车、实物占用及预约边界,不新增库存出库动作。 */
public DispatchInboundReceiptService(DispatchAggregateRepository repository, DispatchSourceRepository sources,
DispatchPickingRepository picking,
DispatchPhysicalClaimService physicalClaims, DispatchAggregateService workflow, FleetTripExecutionService fleet,
InboundDispatchReceiptGateway.Appointments appointments) {
this.repository=repository;this.sources=sources;this.picking=picking;
this.physicalClaims=physicalClaims;this.workflow=workflow;this.fleet=fleet;this.appointments=appointments;
}
/** 先车辆父锁、排车主锁和预约锁,再由收货链路锁来源与库存。 */
@Override public void lock(List<Long> planIds) {
var numbers=repository.receiptDispatches(planIds);
@SuppressWarnings("unchecked") var previous=(Set<String>)TransactionSynchronizationManager.getResource(locksKey);
if(previous!=null) {
if(!previous.containsAll(numbers))throw conflict("收货期间关联排车已变化,请刷新后重新确认入库");
return;
}
if(!numbers.isEmpty())fleet.lockExistingDispatchShells(numbers);
for(var row:repository.findMany(numbers).stream().sorted(Comparator.comparing(com.baiyue.lastleg.dispatch.entity.DispatchAggregate::getId)).toList())repository.lock(row.getDispatchNo());
appointments.lock(numbers);
TransactionSynchronizationManager.bindResource(locksKey,Set.copyOf(numbers));
TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() {
/** 事务完成后清除本次收货持锁范围。 */
@Override public void afterCompletion(int status) { TransactionSynchronizationManager.unbindResourceIfPossible(locksKey); }
});
}
/** 实收确认、原排车数量及预约交接随同一收货事务提交或回滚。 */
@Override public void complete(List<Long> planIds, String operator, LocalDateTime at) {
var numbers=repository.receiptDispatches(planIds);
@SuppressWarnings("unchecked") var locked=(Set<String>)TransactionSynchronizationManager.getResource(locksKey);
if(locked==null || !locked.containsAll(numbers))throw conflict("收货前未锁齐原排车,请刷新后重新确认入库");
for(var number:numbers) {
var row=repository.lock(number);
if(row==null || !"ACTIVE".equals(row.getStatus()))continue;
var items=repository.cargo(row.getId());
var pending=items.stream().filter(i->planIds.contains(i.getPlanId()) && i.getWarehousePalletId()==null).toList();
if(pending.isEmpty())continue;
if(!Set.of("BEFORE_LOADING","LOADING").contains(row.getStage()))throw conflict("原排车已进入出货后阶段,无法确认实收交接");
boolean preserveReview=workflow.currentReceiptBasis(row);
var received=new LinkedHashMap<DispatchItem,DispatchPalletCandidate>();
for(var item:pending) {
var actual=DispatchReceivedPlan.resolve(row.getOverseasWarehouseId(),item,sources)
.orElseThrow(()->conflict("实收托盘与原排车来源或占用不一致,请核对后确认入库"));
received.put(item,actual);
}
for(var entry:received.entrySet()) {
entry.getKey().setWarehousePalletId(entry.getValue().warehousePalletId());
entry.getKey().setPackageCount(entry.getValue().packageCount());
}
for(var item:pending.stream().sorted(Comparator.comparing(DispatchItem::getPlanPalletId)).toList()) {
if(sources.lockPlan(item.getCustomerId(),item.getPlanPalletId())==null)throw conflict("原计划来源已变化");
var fresh=sources.confirmedPlan(item.getCustomerId(),item.getPlanPalletId());
if(fresh.size()!=1 || !DispatchReceivedPlan.sameIdentity(item,fresh.get(0))
|| !Objects.equals(item.getWarehousePalletId(),fresh.get(0).warehousePalletId())
|| !Objects.equals(item.getPackageCount(),fresh.get(0).packageCount()))throw conflict("实收托盘已变化,请重新确认入库");
}
var pallets=pending.stream().map(DispatchItem::getWarehousePalletId).distinct().sorted().toList();
var physical=physicalClaims.reserve(new DispatchPhysicalClaimService.Owner(row.getOverseasWarehouseId(),row.getId(),null,null,operator,null),pallets,at);
for(var entry:received.entrySet()) {
var item=entry.getKey();var actual=entry.getValue();
if(sources.materialize(row.getId(),item.getCustomerId(),item.getPlanPalletId(),actual.warehousePalletId(),physical.get(actual.warehousePalletId()),at)!=1)
throw conflict("原排车托盘占用已变化,入库交接未保存");
item.setPalletNo(actual.palletNo());item.setSourceStage("RECEIVED");
if(picking.materialize(item)!=1)throw conflict("原排车逐托实收数量未保存");
}
appointments.complete(row.getId(),pending.stream().map(i->new InboundDispatchReceiptGateway.Item(i.getId(),i.getCustomerId(),i.getPlanPalletId(),i.getPackageCount())).toList());
int total=Math.toIntExact(items.stream().mapToLong(DispatchItem::getPackageCount).sum());
if(total!=row.getPackageCount()) {
var stops=items.stream().map(i->new com.baiyue.lastleg.fleet.trip.FleetExecutionModels.DispatchStop(
i.getDestinationWarehouseName(),i.getDeliveryAddress(),workflow.destination(i).key()))
.collect(java.util.stream.Collectors.toMap(com.baiyue.lastleg.fleet.trip.FleetExecutionModels.DispatchStop::destinationKey,s->s,(a,b)->a,TreeMap::new)).values().stream().toList();
fleet.updateDispatchCargo(number,total,stops);
}
row.setPackageCount(total);
row.setPalletCount(Math.toIntExact(items.stream().map(i->i.getWarehousePalletId()==null?"PLAN:"+i.getPlanPalletId():"PALLET:"+i.getWarehousePalletId()).distinct().count()));
workflow.saveReceivedCargo(row,operator,at,preserveReview);
}
}
/** 来源变化必须使收货交接整体失败,不留下半份运输事实。 */
private static BizException conflict(String message) { return new BizException(ResultCode.CONFLICT,message); }
}
@@ -16,8 +16,6 @@ import static com.baiyue.lastleg.dispatch.port.DispatchTypes.*;
@Service
public class DispatchPdaQueryService {
@jakarta.annotation.Resource private com.baiyue.lastleg.dispatch.repository.DispatchItemSourceRepository sourceOrigins;
@jakarta.annotation.Resource private com.baiyue.lastleg.dispatch.repository.DispatchSourceRepository sources;
@jakarta.annotation.Resource private DispatchService sourceRules;
private final DispatchPdaReadRepository repository;private final DispatchAggregateService workflow;
/** 共用主流程资格,PDA不维护第二套版本和阶段。 */
public DispatchPdaQueryService(DispatchPdaReadRepository repository,DispatchAggregateService workflow){this.repository=repository;this.workflow=workflow;}
@@ -30,7 +28,7 @@ public class DispatchPdaQueryService {
require(actor);if(number==null || number.isBlank())throw new BizException(ResultCode.BAD_REQUEST,"请输入排车单号");
var rows=repository.headers(actor.boundWarehouseId(),number,null);if(rows.size()!=1)throw new BizException(ResultCode.NOT_FOUND,"当前绑定仓无此排车作业");return assemble(actor,rows).get(0);
}
/** 已绑定实托批量读取;尚未绑定的原PLAN仅在本次只读快照内核对自然实收。 */
/** 按收货已确认的实托批量读取;未交接的原PLAN不推算实收仓位或箱数。 */
private List<DispatchViews.PdaTask> assemble(Actor actor,List<DispatchAggregate> roots) {
if(roots.isEmpty())return List.of();if(roots.size()>200)throw new BizException(ResultCode.BAD_REQUEST,"作业超过完整读取上限,请按阶段筛选或扫描具体排车单号");
var ids=roots.stream().map(DispatchAggregate::getId).toList();var cargo=repository.items(ids).stream().collect(Collectors.groupingBy(DispatchItem::getDispatchOrderId));
@@ -61,8 +59,6 @@ public class DispatchPdaQueryService {
var marks=Arrays.stream(Objects.requireNonNullElse(item.getCartonMarks(),"").split(",")).map(String::trim).filter(s->!s.isEmpty()).collect(Collectors.toSet());
var sourcePositions=positions.getOrDefault(item.getCustomerId()+":"+item.getWarehousePalletId(),List.of()).stream()
.map(p->new DispatchViews.Position(p.getId(),p.getBalanceId(),n(p.getAvailablePackages()),n(p.getAllocatedPackages()),n(p.getPickedPackages()))).toList();
if(item.getWarehousePalletId()==null && item.getPlanPalletId()!=null)
sourcePositions=receivedPlanPositions(actor,item,items);
if(normalRelabel(item)) {
var contributions=byItem.getOrDefault(item.getId(),List.of());var ownPositions=new ArrayList<DispatchViews.Position>();
if(contributions.isEmpty() || contributions.stream().mapToLong(s->n(s.getPackageCount())).sum()!=n(item.getPackageCount()))sourceValid=false;
@@ -95,14 +91,6 @@ public class DispatchPdaQueryService {
}
return List.copyOf(result);
}
/** 预排首次扫码前只解析原计划实收仓位,不在GET补写实托;真正绑定仍由pick锁内重验。 */
private List<DispatchViews.Position> receivedPlanPositions(Actor actor,DispatchItem item,List<DispatchItem> items) {
var received=DispatchReceivedPlan.resolve(actor.boundWarehouseId(),item,items,sources,sourceRules);
if(received.isEmpty())return List.of();
// 只修改本次投影对象,不在GET更新排车、版本或库存。
item.setPackageCount(received.get().source().packageCount());
return received.get().positions();
}
/** 仓和操作者均来自受信身份,不接收请求自报。 */
private static void require(Actor actor){if(actor==null || actor.mode()!=ActorMode.PDA || actor.operatorId()==null || actor.boundWarehouseId()==null)throw new BizException(ResultCode.FORBIDDEN,"仅绑定仓作业人员可读取PDA作业");}
/** 缺失计数不作为业务标识使用。 */
@@ -12,7 +12,6 @@ import com.baiyue.lastleg.inventory.command.dto.InventoryCommandRequests;
import com.baiyue.lastleg.inventory.command.service.InventoryCommandService;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.dao.DuplicateKeyException;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.*;
import java.time.*;
@@ -24,10 +23,8 @@ import static com.baiyue.lastleg.dispatch.port.DispatchTypes.*;
/** 新整车PDA实际扫码入口,真实客户从明细解析,库存SHIP只在成功扫码事务发生一次。 */
@Service
public class DispatchPickingService {
@jakarta.annotation.Resource private DispatchPhysicalClaimService physicalClaims;
@jakarta.annotation.Resource private DispatchItemSourceService relabelSources;
@jakarta.annotation.Resource private DispatchService sourceRules;
@jakarta.annotation.Resource private com.baiyue.lastleg.fleet.trip.FleetTripExecutionService fleet;
private final DispatchAggregateService workflow;private final DispatchAggregateRepository repository;private final DispatchSourceRepository sources;
private final DispatchPickingRepository picking;private final DispatchPickOrderAttributionService attribution;
private final InventoryCommandService inventory;private final ReturnRelabelDispatchStock returns;
@@ -53,7 +50,8 @@ public class DispatchPickingService {
var currentItems=repository.cargo(row.getId());
var item=currentItems.stream().filter(i->Objects.equals(i.getId(),itemId)).findFirst().orElseThrow(()->new BizException(ResultCode.NOT_FOUND,"本车货物明细不存在"));
if(!Objects.equals(item.getOverseasWarehouseId(),actor.boundWarehouseId()))throw conflict("本货物不属于当前绑定仓");
materialize(row,item,actor,currentItems);
if(item.getWarehousePalletId()==null)throw conflict("该托盘尚未完成收货交接,请先确认入库后再拣货");
relabelSources.validateFrozen(row,currentItems,sourceRules.lockExecutionSources(row.getOverseasWarehouseId(),currentItems));
var task=picking.task(row.getId());var at=LocalDateTime.ofInstant(Instant.now().truncatedTo(ChronoUnit.MILLIS),ZoneOffset.UTC);
if(task==null) {
task=new DispatchPdaTask();task.setId(IdWorker.getId());task.setDispatchOrderId(row.getId());
@@ -103,45 +101,6 @@ public class DispatchPickingService {
new Counts(row.getItemCount(),row.getPalletCount(),row.getPackageCount(),row.getPickedPackageCount(),row.getShippedPackageCount()),new DispatchResults.Picked(itemId,request.positionId(),returnCartonId,request.packages(),before+request.packages(),complete),false,false);
commands.append(row.getId(),actor,identity,result);return result;
}
/** 首拣将本车已实收原计划按真实整托数量绑定,保持原审核身份并与扣货同事务回滚。 */
private void materialize(com.baiyue.lastleg.dispatch.entity.DispatchAggregate row,DispatchItem item,Actor actor,List<DispatchItem> items) {
long dispatch=row.getId();
if(item.getWarehousePalletId()!=null) {
relabelSources.validateFrozen(row,items,sourceRules.lockExecutionSources(row.getOverseasWarehouseId(),items));return;
}
var received=new LinkedHashMap<DispatchItem,DispatchReceivedPlan.Fact>();
for(var candidate:items)DispatchReceivedPlan.resolve(actor.boundWarehouseId(),candidate,items,sources,sourceRules)
.ifPresent(fact->received.put(candidate,fact));
if(!received.containsKey(item))throw conflict("该托盘尚未完成可拣实收,或原来源、目的地、库存占用已变化,请刷新核对");
// 同车同实托的多个原计划一次绑定,避免首拣把其他已审核成员误判为新增货物。
for(var entry:received.entrySet()) {
entry.getKey().setWarehousePalletId(entry.getValue().source().warehousePalletId());
entry.getKey().setPackageCount(entry.getValue().source().packageCount());
}
relabelSources.validateFrozen(row,items,sourceRules.lockExecutionSources(row.getOverseasWarehouseId(),items));
for(var candidate:received.keySet().stream().sorted(Comparator.comparing(DispatchItem::getPlanPalletId)).toList()) {
if(sources.lockPlan(candidate.getCustomerId(),candidate.getPlanPalletId())==null)throw conflict("原计划来源已变化");
var fresh=sources.selectedPlan(candidate.getCustomerId(),candidate.getPlanPalletId());
if(fresh.size()!=1 || !DispatchReceivedPlan.sameIdentity(candidate,fresh.get(0))
|| !Objects.equals(candidate.getWarehousePalletId(),fresh.get(0).warehousePalletId())
|| !Objects.equals(candidate.getPackageCount(),fresh.get(0).packageCount()))throw conflict("实收托盘已变化,请刷新后重新扫码");
}
var at=LocalDateTime.ofInstant(Instant.now(),ZoneOffset.UTC);
var pallets=items.stream().map(DispatchItem::getWarehousePalletId).filter(Objects::nonNull).distinct().sorted().toList();
var physical=physicalClaims.reserve(new DispatchPhysicalClaimService.Owner(row.getOverseasWarehouseId(),dispatch,null,null,actor.operatorId(),null),pallets,at);
for(var entry:received.entrySet()) {
var candidate=entry.getKey();var actual=entry.getValue().source();
try{if(sources.materialize(dispatch,candidate.getCustomerId(),candidate.getPlanPalletId(),actual.warehousePalletId(),physical.get(actual.warehousePalletId()),at)!=1)throw conflict("原计划来源占用已变化");}
catch(DuplicateKeyException occupied){throw conflict("实托已经被其他排车占用");}
candidate.setPalletNo(actual.palletNo());candidate.setSourceStage("RECEIVED");
if(picking.materialize(candidate)!=1)throw conflict("本来源实际实托未保存");
}
row.setPackageCount(Math.toIntExact(items.stream().mapToLong(DispatchItem::getPackageCount).sum()));
var stops=items.stream().map(i->new com.baiyue.lastleg.fleet.trip.FleetExecutionModels.DispatchStop(
i.getDestinationWarehouseName(),i.getDeliveryAddress(),workflow.destination(i).key()))
.collect(java.util.stream.Collectors.toMap(com.baiyue.lastleg.fleet.trip.FleetExecutionModels.DispatchStop::destinationKey,s->s,(a,b)->a,TreeMap::new)).values().stream().toList();
fleet.updateDispatchCargo(row.getDispatchNo(),row.getPackageCount(),stops);
}
/** 库存幂等键分别标识一次实际动作,业务单号始终使用本车号。 */
private static InventoryCommandRequests.Move move(DispatchCommands.Pick request,String dispatch,String reference,String key){return new InventoryCommandRequests.Move(request.positionId(),request.packages(),reference,dispatch,null,key,request.remark());}
/** 不可变扫码证据序列化失败随根命令回滚。 */
@@ -2,45 +2,23 @@ package com.baiyue.lastleg.dispatch.service;
import com.baiyue.lastleg.dispatch.entity.DispatchItem;
import com.baiyue.lastleg.dispatch.model.DispatchPalletCandidate;
import com.baiyue.lastleg.dispatch.port.*;
import com.baiyue.lastleg.dispatch.repository.DispatchSourceRepository;
import java.util.*;
import static com.baiyue.lastleg.dispatch.port.DispatchTypes.SourceType;
/** 预排来源只冻结货物身份;自然实收的逐托箱数由真实库存提供,查询和首拣共用此规则。 */
/** 预排来源只冻结货物身份;自然实收的逐托箱数在确认入库时由真实库存提供。 */
final class DispatchReceivedPlan {
record Fact(DispatchPalletCandidate source, List<DispatchViews.Position> positions) { }
/** 仅解析原车完整来源,禁止把新客户、新订单或冻结货物混入实收数量。 */
static Optional<Fact> resolve(String warehouse, DispatchItem item, List<DispatchItem> items,
DispatchSourceRepository sources, DispatchService rules) {
/** 原客户、计划及目的地保持一致,数量直接取确认入库的逐票实收。 */
static Optional<DispatchPalletCandidate> resolve(String warehouse, DispatchItem item, DispatchSourceRepository sources) {
if(item.getWarehousePalletId()!=null || item.getPlanPalletId()==null
|| !Objects.equals(warehouse,item.getOverseasWarehouseId()))return Optional.empty();
if(!sources.receivedPlanCompleted(item.getCustomerId(),item.getPlanPalletId()))return Optional.empty();
var rows=sources.selectedPlan(item.getCustomerId(),item.getPlanPalletId());
var rows=sources.confirmedPlan(item.getCustomerId(),item.getPlanPalletId());
if(rows.size()!=1)return Optional.empty();
var actual=rows.get(0);
if(!sameIdentity(item,actual) || actual.packageCount()==null || actual.packageCount()<=0)return Optional.empty();
var original=sources.sourceOrders(item.getCustomerId(),null,item.getPlanPalletId());
if(original.isEmpty() || !new HashSet<>(original).containsAll(
sources.sourceOrders(item.getCustomerId(),actual.warehousePalletId(),item.getPlanPalletId())))return Optional.empty();
var group=rules.selectionGroup(warehouse,new DispatchCommands.CargoSelection(
SourceType.INBOUND_PLAN,item.getPlanPalletId(),null,null,actual.warehousePalletId(),null));
if(!group.complete() || group.sources().stream().noneMatch(s->Objects.equals(s.sourceKey(),item.getSourceKey())))return Optional.empty();
for(var member:group.sources()) {
var selected=items.stream().filter(i->Objects.equals(i.getSourceKey(),member.sourceKey())).findFirst().orElse(null);
if(selected==null || !Objects.equals(selected.getCustomerId(),member.customerId()))return Optional.empty();
// 原计划的估算箱数不限制实收;已经绑定或换标的冻结贡献仍严格核对。
if((selected.getWarehousePalletId()!=null || selected.getPlanPalletId()==null)
&& (selected.getPackageCount()==null || selected.getPackageCount().longValue()!=member.packages()))return Optional.empty();
}
var stock=sources.stock(item.getCustomerId(),actual.warehousePalletId(),false);
if(stock.stream().anyMatch(p->!Objects.equals(p.warehouseId(),warehouse)
|| !Objects.equals(p.palletId(),actual.warehousePalletId()) || p.holdPackages()!=0
|| p.allocatedPackages()!=0 || p.pickedPackages()!=0)
|| stock.stream().mapToLong(p->p.availablePackages()).sum()!=actual.packageCount())return Optional.empty();
return Optional.of(new Fact(actual,stock.stream().filter(p->p.availablePackages()>0)
.map(p->new DispatchViews.Position(p.id(),p.balanceId(),p.availablePackages(),p.allocatedPackages(),p.pickedPackages())).toList()));
return Optional.of(actual);
}
/** 箱数允许随自然实收变化,客户、原计划、起运仓和目的地必须保持原身份。 */
@@ -394,6 +394,18 @@ public class DispatchService {
if(!pallets.isEmpty())palletSerial.lock(pallets);
}
/** 关联预约只锁原订单、共同实托及计划托盘,不要求尚未发生的实收库存。 */
@Transactional(propagation=org.springframework.transaction.annotation.Propagation.MANDATORY)
public void lockAppointmentSources(String warehouse,List<DispatchItem> items) {
var pallets=items.stream().map(DispatchItem::getWarehousePalletId).filter(Objects::nonNull).collect(Collectors.toCollection(TreeSet::new));
var orders=executionOrders(warehouse,items,pallets);
orders.forEach((id,customer)->{if(wholeSources.lockOrder(customer,id)==null)throw new ResponseStatusException(HttpStatus.CONFLICT,"本车真实来源订单已缺失或暂扣");});
palletSerial.lock(List.copyOf(pallets));
if(!orders.equals(executionOrders(warehouse,items,pallets)))throw new ResponseStatusException(HttpStatus.CONFLICT,"本车逐票来源已变化,请重新核对");
for(var item:items.stream().sorted(Comparator.comparing(DispatchItem::getPlanPalletId)).toList())
if(wholeSources.lockPlan(item.getCustomerId(),item.getPlanPalletId())==null)throw new ResponseStatusException(HttpStatus.CONFLICT,"本车计划托盘已缺失或归属变化");
}
/** 执行沿用全车申请/订单→共同实托串行顺序;库存变化不重新解释原冻结贡献。 */
@Transactional(propagation=org.springframework.transaction.annotation.Propagation.MANDATORY)
public RelabelDispatchSourcePort.Snapshot lockExecutionSources(String warehouse,List<DispatchItem> items) {
@@ -81,13 +81,19 @@ public class TripOperationsService {
if(!pallets.add(pallet.warehousePalletId())) throw new IllegalStateException("发车实物托盘重复");
spaces+=pallet.palletSpaces();
}
long own=0,external=0; var destinations=new TreeMap<String,String>(); var seen=new HashSet<String>();
long own=0,external=0; var destinations=new TreeMap<String,String>();
var cargoIdentity=new HashMap<String,List<String>>();
var shippedByPallet=new HashMap<String,Long>();
for(var cargo:load.cargo()) {
if (!pallets.contains(cargo.warehousePalletId()) || !seen.add(cargo.dispatchNo()+":"+cargo.dispatchItemId()))
throw new IllegalStateException("发车装载货物未核平或重复");
shippedByPallet.merge(cargo.dispatchNo()+":"+cargo.customerId()+":"+cargo.warehousePalletId(),(long)cargo.packages(),Long::sum);
if (!pallets.contains(cargo.warehousePalletId()) || cargo.packages()<=0
|| cargo.dispatchNo()==null || cargo.dispatchItemId()==null || cargo.customerId()==null)
throw new IllegalStateException("发车装载货物未核平");
if(cargo.destinationKey()==null || cargo.destinationLabel()==null)throw new IllegalStateException("发车货物缺少目的地依据");
// 装车快照按真实拣货来源逐段记录,同一排车明细可含多次拣货;逐段累加但归属不能漂移。
var identity=List.of(cargo.customerId(),cargo.warehousePalletId(),cargo.destinationKey(),cargo.destinationLabel());
var previous=cargoIdentity.putIfAbsent(cargo.dispatchNo()+":"+cargo.dispatchItemId(),identity);
if(previous!=null && !previous.equals(identity))throw new IllegalStateException("发车装载明细归属不一致");
shippedByPallet.merge(cargo.dispatchNo()+":"+cargo.customerId()+":"+cargo.warehousePalletId(),(long)cargo.packages(),Long::sum);
destinations.put(cargo.destinationKey(),cargo.destinationLabel());
}
for(var entry:externalByPallet.entrySet()) if(entry.getValue()>shippedByPallet.getOrDefault(entry.getKey(),0L))throw new IllegalStateException("外接实收超过实际发车货量");
@@ -20,6 +20,8 @@ import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.time.LocalDateTime;
import java.time.ZoneOffset;
import java.time.Clock;
import java.time.temporal.ChronoUnit;
import java.util.Currency;
import java.util.HexFormat;
import java.util.Objects;
@@ -38,15 +40,22 @@ public class FleetBookingService {
private final FleetBookingIncomingGateway incoming;
private final FleetBookingQuoteContext quoteContext;
private final FleetQuoteCandidateService quotes;
private final Clock clock;
@jakarta.annotation.Resource private FleetPoolOrderBridge poolOrders;
@jakarta.annotation.Resource private FleetDispatchPlanningGateway planning;
@jakarta.annotation.Resource private FleetDispatchGateway dispatches;
@jakarta.annotation.Resource private FleetCapacityOfferCommandMapper commands;
@org.springframework.beans.factory.annotation.Autowired
public FleetBookingService(FleetTripMapper trips,FleetBookingMapper bookings,FleetBookingReservations reservations,
FleetTripService history,FleetTripExecutionService execution,FleetCustomerDirectory customers,ObjectMapper json,FleetBookingIncomingGateway incoming,
FleetBookingQuoteContext quoteContext,FleetQuoteCandidateService quotes){
this(trips,bookings,reservations,history,execution,customers,json,incoming,quoteContext,quotes,Clock.systemUTC());
}
FleetBookingService(FleetTripMapper trips,FleetBookingMapper bookings,FleetBookingReservations reservations,
FleetTripService history,FleetTripExecutionService execution,FleetCustomerDirectory customers,ObjectMapper json,FleetBookingIncomingGateway incoming,
FleetBookingQuoteContext quoteContext,FleetQuoteCandidateService quotes,Clock clock){
this.trips=trips;this.bookings=bookings;this.reservations=reservations;this.history=history;this.execution=execution;this.customers=customers;this.json=json;this.incoming=incoming;
this.quoteContext=quoteContext;this.quotes=quotes;
this.quoteContext=quoteContext;this.quotes=quotes;this.clock=clock;
}
public java.util.Map<FleetBookingIncomingGateway.Source,String> incomingStatuses(java.util.List<FleetBookingEntity> rows) {
return incoming.statuses(rows.stream().filter(r->r.getIncomingNo()!=null).map(r->new FleetBookingIncomingGateway.Source(r.getCustomerId(),r.getIncomingNo())).distinct().toList());
@@ -305,7 +314,8 @@ public class FleetBookingService {
private String write(Object value){try{return json.writeValueAsString(value);}catch(Exception error){throw new IllegalStateException(error);}}
/** 请求对象排除重放元数据;排车来源键的数组必须保留全部元素及顺序。 */
private String hash(Object value){try{com.fasterxml.jackson.databind.JsonNode node=json.valueToTree(value);if(node instanceof ObjectNode object){object.remove("idempotencyKey");object.remove("tripRecordVersion");}return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(write(node).getBytes(StandardCharsets.UTF_8)));}catch(Exception error){throw new IllegalStateException(error);}}
private static LocalDateTime now(){return LocalDateTime.now(ZoneOffset.UTC);}
/** DATETIME(3)持久化前固定毫秒精度,首次回执与重新读取后的幂等重放保持同一时间事实。 */
private LocalDateTime now(){return LocalDateTime.ofInstant(clock.instant(),ZoneOffset.UTC).truncatedTo(ChronoUnit.MILLIS);}
private static String trim(String value){return value==null||value.isBlank()?null:value.trim();}
private static String reason(String value,String fallback){return trim(value)==null?fallback:trim(value);}
private static ResponseStatusException conflict(String value){return new ResponseStatusException(HttpStatus.CONFLICT,value);}
@@ -21,5 +21,12 @@ public interface FleetResourceMapper {
int reserve(@Param("kind") String kind, @Param("id") long id, @Param("tripId") long tripId, @Param("tripNo") String tripNo);
@Delete("DELETE FROM ll_fleet_resource_assignment WHERE resource_kind=#{kind} AND resource_id=#{id} AND trip_id=#{tripId}")
int release(@Param("kind") String kind, @Param("id") long id, @Param("tripId") long tripId);
/** 排除其他车次占用;当前排车绑定的原资源允许保留。 */
@Select("""
SELECT a.resource_id FROM ll_fleet_resource_assignment a
WHERE a.resource_kind='VEHICLE' AND NOT EXISTS (
SELECT 1 FROM ll_fleet_trip_dispatch d WHERE d.trip_id=a.trip_id AND d.dispatch_no=#{dispatchNo})
""")
List<Long> unavailableVehicles(@Param("dispatchNo") String dispatchNo);
record Assignment(String resourceKind, long resourceId, long tripId, String tripNo) { }
}
@@ -7,7 +7,7 @@ import org.springframework.stereotype.Repository;
/** 日历只读访问保留真实主单范围,整批读取原货物快照而不逐车查询。 */
@Repository
public class FleetTripCalendarRepository {
public record Source(String dispatchNo, String sourceSnapshotJson) { }
public record Source(String dispatchNo, String sourceSnapshotJson, String loadingPayloadJson) { }
private final FleetTripMapper mapper;
/** 装配现有车次Mapper,日历服务不直接访问SQL。 */
@@ -18,10 +18,10 @@ public class FleetTripCalendarRepository {
return mapper.calendar(from, to);
}
/** 一次读取指定主单的全部货物快照,空窗口不发送空IN查询。 */
/** 一次读取指定主单的全部货物与原装柜快照,空窗口不发送空IN查询。 */
public List<Source> sourceSnapshots(List<String> dispatchNumbers) {
if (dispatchNumbers.isEmpty()) return List.of();
return mapper.calendarSources(dispatchNumbers).stream()
.map(row -> new Source(row.dispatchNo(), row.sourceSnapshotJson())).toList();
.map(row -> new Source(row.dispatchNo(), row.sourceSnapshotJson(), row.loadingPayloadJson())).toList();
}
}
@@ -19,8 +19,9 @@ public class FleetTripCalendarService {
String originWarehouseCode, String originWarehouseName, String originCountryCode,
String originTimezone, String originWarehouseStatus, Instant plannedDepartureAt,
Instant expectedDriverEndAt, Instant departedAt, Instant completedAt,
List<FleetTripModels.Stop> stops, List<String> containerNumbers, List<String> incompleteReasons) { }
List<FleetTripModels.Stop> stops, List<String> containerNumbers, List<String> cabinetNumbers, List<String> incompleteReasons) { }
public record Result(List<Entry> records, List<Entry> unresolvedRecords) { }
private record ContainerReferences(List<String> containerNumbers, List<String> cabinetNumbers) { }
private static final Set<String> COUNTRIES = Set.of(Locale.getISOCountries());
private final FleetTripCalendarRepository trips;
private final FleetWarehouseClient warehouses;
@@ -41,7 +42,7 @@ public class FleetTripCalendarService {
var rows=trips.calendar(LocalDateTime.ofInstant(from,ZoneOffset.UTC),LocalDateTime.ofInstant(to,ZoneOffset.UTC));
if (rows.size()>2000) throw new ResponseStatusException(HttpStatus.BAD_REQUEST,"排车过多,请缩小查询日期范围");
if (rows.isEmpty()) return new Result(List.of(),List.of());
var containers=containerNumbers(rows);
var containers=containerReferences(rows);
var ids=rows.stream().map(FleetTripEntity::getWarehouseId).filter(Objects::nonNull).filter(id->id>0).distinct().sorted().toList();
var metadata=metadata(ids);
var records=new ArrayList<Entry>(); var unresolved=new ArrayList<Entry>();
@@ -64,35 +65,50 @@ public class FleetTripCalendarService {
throw new IllegalArgumentException("incomplete route");
stops=List.copyOf(stops);
} catch (Exception invalid) { stops=List.of(); reasons.add("ROUTE_INCOMPLETE"); }
var references=containers.getOrDefault(row.getDispatchNo(),new ContainerReferences(List.of(),List.of()));
var entry=new Entry("DISPATCH",row.getDispatchNo(),row.getStatus(),row.getWarehouseId()==null?null:row.getWarehouseId().toString(),
warehouse==null?row.getWarehouseCode():warehouse.warehouseCode(),warehouse==null?row.getWarehouseName():warehouse.warehouseName(),
country,zone,warehouse==null?null:warehouse.status(),instant(row.getPlannedDepartureAt()),instant(row.getExpectedDriverEndAt()),
instant(row.getDepartedAt()),instant(row.getCompletedAt()),stops,containers.getOrDefault(row.getDispatchNo(),List.of()),List.copyOf(reasons));
instant(row.getDepartedAt()),instant(row.getCompletedAt()),stops,references.containerNumbers(),references.cabinetNumbers(),List.copyOf(reasons));
(unresolvedOrigin?unresolved:records).add(entry);
}
return new Result(List.copyOf(records),List.copyOf(unresolved));
}
/** 柜号只取排车建单时正式保存的SourceDisplay.containerNo,不反查或推测其他来源。 */
private Map<String,List<String>> containerNumbers(List<FleetTripCalendarRow> rows) {
/** 完整柜号取排车货物快照,柜次短号取同客户真实来源的原装柜事件,分别去空去重。 */
private Map<String,ContainerReferences> containerReferences(List<FleetTripCalendarRow> rows) {
var numbers=rows.stream().map(FleetTripCalendarRow::getDispatchNo).distinct().sorted().toList();
var requested=new HashSet<>(numbers);var grouped=new LinkedHashMap<String,LinkedHashSet<String>>();
var requested=new HashSet<>(numbers);
var containers=new LinkedHashMap<String,LinkedHashSet<String>>();
var cabinets=new LinkedHashMap<String,LinkedHashSet<String>>();
for(var source:trips.sourceSnapshots(numbers)) {
if(source==null || !requested.contains(source.dispatchNo()) || source.sourceSnapshotJson()==null || source.sourceSnapshotJson().isBlank())
throw sourceUnavailable();
try {
var snapshot=json.reader().with(com.fasterxml.jackson.databind.DeserializationFeature.FAIL_ON_TRAILING_TOKENS)
.readTree(source.sourceSnapshotJson());
if(snapshot==null || !snapshot.isObject())throw sourceUnavailable();
var value=snapshot.get("containerNo");
if(value==null || value.isNull())continue;
if(!value.isTextual())throw sourceUnavailable();
var number=value.textValue().strip();
if(!number.isEmpty())grouped.computeIfAbsent(source.dispatchNo(),ignored->new LinkedHashSet<>()).add(number);
} catch(com.fasterxml.jackson.core.JsonProcessingException invalid) { throw sourceUnavailable(); }
var container=snapshotReference(source.sourceSnapshotJson(),"containerNo");
if(container!=null)containers.computeIfAbsent(source.dispatchNo(),ignored->new LinkedHashSet<>()).add(container);
// 非头程来源没有装柜事件,保持短号为空,不从完整柜号或内部ID猜测。
if(source.loadingPayloadJson()!=null) {
var cabinet=snapshotReference(source.loadingPayloadJson(),"cabinetNo");
if(cabinet!=null)cabinets.computeIfAbsent(source.dispatchNo(),ignored->new LinkedHashSet<>()).add(cabinet);
}
}
var result=new LinkedHashMap<String,List<String>>();
grouped.forEach((number,values)->result.put(number,List.copyOf(values)));return Map.copyOf(result);
var result=new LinkedHashMap<String,ContainerReferences>();
for(var number:numbers)result.put(number,new ContainerReferences(
List.copyOf(containers.getOrDefault(number,new LinkedHashSet<>())),List.copyOf(cabinets.getOrDefault(number,new LinkedHashSet<>()))));
return Map.copyOf(result);
}
/** 只接受正式JSON对象中的文本业务标识,缺失留空,损坏资料明确报错。 */
private String snapshotReference(String payload,String field) {
try {
var snapshot=json.reader().with(com.fasterxml.jackson.databind.DeserializationFeature.FAIL_ON_TRAILING_TOKENS).readTree(payload);
if(snapshot==null || !snapshot.isObject())throw sourceUnavailable();
var value=snapshot.get(field);
if(value==null || value.isNull())return null;
if(!value.isTextual())throw sourceUnavailable();
var number=value.textValue().strip();
return number.isEmpty()?null:number;
} catch(com.fasterxml.jackson.core.JsonProcessingException invalid) { throw sourceUnavailable(); }
}
/** 元数据批次必须完整覆盖请求身份,未知地点保持显式未核实。 */
@@ -19,12 +19,14 @@ public interface FleetTripMapper extends BaseMapper<FleetTripEntity> {
ORDER BY COALESCE(t.departed_at,t.planned_departure_at),d.dispatch_no LIMIT 2001
""")
java.util.List<FleetTripCalendarRow> calendar(@Param("from") java.time.LocalDateTime from,@Param("to") java.time.LocalDateTime to);
record CalendarSource(String dispatchNo, String sourceSnapshotJson) { }
/** 主查询限额之后批量读取原货物快照,不以货物JOIN放大整车记录或截断多柜。 */
record CalendarSource(String dispatchNo, String sourceSnapshotJson, String loadingPayloadJson) { }
/** 主查询限额后批读货物快照及同客户原装柜事件,柜次短号只沿真实订单关联读取。 */
@Select("""
<script>
SELECT d.dispatch_no AS dispatchNo,i.source_snapshot_json AS sourceSnapshotJson
SELECT d.dispatch_no AS dispatchNo,i.source_snapshot_json AS sourceSnapshotJson,e.payload_json AS loadingPayloadJson
FROM ll_dispatch_order d JOIN ll_dispatch_item i ON i.dispatch_order_id=d.id
LEFT JOIN ll_order o ON o.id=i.order_id AND o.customer_id=i.customer_id
LEFT JOIN ll_loading_event_inbox e ON e.event_id=o.source_event_id AND e.customer_id=o.customer_id
WHERE d.status='ACTIVE' AND d.dispatch_no IN
<foreach collection='numbers' item='number' open='(' separator=',' close=')'>#{number}</foreach>
ORDER BY d.dispatch_no,i.id
@@ -323,8 +323,12 @@ public class FleetTripService {
if(!"OVERSEAS".equals(actual.details().region()))throw conflict("司机当前不可执行海外任务");
var assignment=repository.resource("DRIVER",driver.driverId());
if(assignment!=null && assignment.tripId()!=trip.getId())throw conflict("司机已被其他车次占用");
// 主档证照固定在首位,本趟补传的驾驶证照片独立保留;排车域负责逐个核验本车文件归属。
var licenseFiles=new java.util.LinkedHashSet<Long>();
if(actual.licenseFileId()!=null)licenseFiles.add(Long.valueOf(actual.licenseFileId()));
if(driver.evidenceFileIds()!=null)licenseFiles.addAll(driver.evidenceFileIds());
driver=new FleetExecutionModels.DispatchDriver("MASTER",Long.valueOf(actual.id()),actual.details().name(),actual.details().phone(),
actual.details().licenseNumber(),actual.details().licenseExpiresOn(),actual.licenseFileId()==null?List.of():List.of(Long.valueOf(actual.licenseFileId())),driver.expectedTaskEndAt());
actual.details().licenseNumber(),actual.details().licenseExpiresOn(),List.copyOf(licenseFiles),driver.expectedTaskEndAt());
} else if(driver!=null && (!"EXTERNAL".equals(driver.mode()) || driver.driverId()!=null))throw bad("临时司机不得关联司机档案");
return new FleetExecutionModels.DispatchShell(trip.getId(),trip.getTripNo(),trip.getWarehouseId().toString(),trip.getWarehouseCode(),trip.getWarehouseName(),vehicle,driver,vehicleStatus);
}
@@ -8,9 +8,12 @@ import org.springframework.stereotype.Component;
public class FleetVehicleAssignmentGuard implements VehicleAssignmentGuard {
private final FleetResourceMapper resources;
public FleetVehicleAssignmentGuard(FleetResourceMapper resources) { this.resources = resources; }
/** 主档变更仍要求车辆没有真实车次占用。 */
@Override public void requireUnassigned(long vehicleId) {
var assignment = resources.find("VEHICLE", vehicleId);
if (assignment != null) throw new VehicleApplicationService.VehicleFailure(VehicleApplicationService.FailureKind.CONFLICT,
"车辆正在车次 " + assignment.tripNo() + " 中使用,请先在车次中改派或取消安排");
}
/** 由车队权威占用记录提供候选排除集。 */
@Override public java.util.List<Long> unavailableForDispatch(String dispatchNo) { return resources.unavailableVehicles(dispatchNo); }
}
@@ -0,0 +1,25 @@
package com.baiyue.lastleg.inboundplan.pda.gateway;
import java.time.LocalDateTime;
import java.util.List;
/** 收货确认在原事务内交接真实托盘,预排估算不进入出货执行。 */
public interface InboundDispatchReceiptGateway {
/** 收货锁来源前,按固定顺序锁齐已有车辆、排车及预约。 */
void lock(List<Long> planIds);
/** 库存与计划完成后,按实收逐托数量更新原运输安排,不扣出货库存。 */
void complete(List<Long> planIds, String operatorId, LocalDateTime at);
/** 只传同一客户原计划的真实托盘与数量,不传推算的逐票分摊。 */
record Item(long itemId, String customerId, long planPalletId, int packages) { }
/** 预约域负责核对原批次归属及实收来源,排车域不直接修改预约账本。 */
interface Appointments {
/** 在库存来源锁之前锁齐全部原预约。 */
void lock(List<String> dispatchNumbers);
/** 逐票采用已确认实收,禁止对多批次来源猜测分配。 */
void complete(long dispatchId, List<Item> items);
}
}
@@ -37,6 +37,13 @@ public class PdaWorkflowRepository {
@Resource private InboundPlanCartonMapper cartons;
@Resource private LastlegOrderMapper orders;
/** 收货完成前只读计划身份,运输父锁取得后再加来源行锁。 */
public InboundPlan findPlan(String customerId, String planNo) {
return plans.selectOne(new LambdaQueryWrapper<InboundPlan>()
.eq(InboundPlan::getCustomerId, customerId).eq(InboundPlan::getPlanNo, planNo));
}
/** 父运输锁完成后锁定原客户入仓计划。 */
public InboundPlan lockPlan(String customerId, String planNo) {
return plans.selectOne(new LambdaQueryWrapper<InboundPlan>()
.eq(InboundPlan::getCustomerId, customerId).eq(InboundPlan::getPlanNo, planNo).last("FOR UPDATE"));
@@ -55,6 +55,7 @@ public class InboundPdaWorkflowService {
@Resource private PdaWorkflowRepository repository;
@Resource private WarehouseResourceGateway warehouse;
@Resource private InboundStockReceiptService stockReceipts;
@Resource private com.baiyue.lastleg.inboundplan.pda.gateway.InboundDispatchReceiptGateway dispatchReceipts;
@Resource private InboundUnloadCompletedOutboxService unloadCompletedOutbox;
@Resource private LastlegOrderMapper orders;
@Resource private UnloadPhotoFileGateway unloadPhotoFiles;
@@ -544,16 +545,21 @@ public class InboundPdaWorkflowService {
return result;
}
/** 单票确认入库也先锁运输父记录,再复用整柜实收交接事务。 */
@Transactional
public PdaResponses.Task complete(String customerId, String planNo) {
InboundPlan original = repository.findPlan(customerId, planNo);
if (original == null) throw conflict("入仓计划不存在");
dispatchReceipts.lock(List.of(original.getId()));
InboundPlan plan = requirePlan(customerId, planNo);
return completeContainer(List.of(plan)).get(0);
}
/** Completes every plan of one physical container and emits exactly one container-level outbox event. */
/** 整柜确认实收、库存及原预排交接,并且仅发布一次柜级卸货事件。 */
@Transactional
public List<PdaResponses.Task> completeContainer(List<InboundPlan> containerPlans) {
if (containerPlans == null || containerPlans.isEmpty()) throw conflict("Receiving container has no inbound plans");
dispatchReceipts.lock(containerPlans.stream().map(InboundPlan::getId).toList());
List<InboundPlan> orderedPlans = containerPlans.stream().sorted(Comparator.comparing(InboundPlan::getId)).toList();
String containerNo = orderedPlans.get(0).getContainerNo();
if (containerNo == null || containerNo.isBlank() || orderedPlans.stream().anyMatch(plan ->
@@ -569,6 +575,8 @@ public class InboundPdaWorkflowService {
LocalDateTime now = LocalDateTime.now();
for (CompletionContext context : contexts) completePlan(context.plan().getCustomerId(), context, now);
// 实收库存和原预排一起交接,出货时不再补写数量。
dispatchReceipts.complete(orderedPlans.stream().map(InboundPlan::getId).toList(), contexts.get(0).task().getOperatorId(), now);
String sourceContainerBookingId = sourceContainerBookingId(contexts);
LocalDateTime photoUploadedAt = contexts.stream().map(context -> context.task().getUnloadPhotoUploadedAt())
.max(LocalDateTime::compareTo).orElseThrow();
@@ -587,6 +595,7 @@ public class InboundPdaWorkflowService {
public void completeContainerWithDifferences(List<InboundPlan> containerPlans,
List<InboundPlan> reconciledPlans) {
if (containerPlans == null || containerPlans.isEmpty()) throw conflict("Receiving container has no inbound plans");
dispatchReceipts.lock(containerPlans.stream().map(InboundPlan::getId).toList());
Set<Long> reconciledIds = reconciledPlans.stream().map(InboundPlan::getId).collect(java.util.stream.Collectors.toSet());
LocalDateTime now = LocalDateTime.now();
List<InboundPdaTask> allTasks = new ArrayList<>();
@@ -606,6 +615,7 @@ public class InboundPdaWorkflowService {
allTasks.add(task);
}
}
if (!reconciledPlans.isEmpty()) dispatchReceipts.complete(reconciledPlans.stream().map(InboundPlan::getId).toList(), allTasks.get(0).getOperatorId(), now);
String containerNo = containerPlans.get(0).getContainerNo();
if (containerPlans.stream().anyMatch(plan -> !Objects.equals(containerNo, plan.getContainerNo()))) {
throw conflict("Inbound plans must belong to one physical container");
@@ -678,6 +688,7 @@ public class InboundPdaWorkflowService {
return new CompletionContext(plan, task, unloadPhotoFileIds, lines, allocations, cartons);
}
/** 按确认的逐托实收入库,完成状态与运输交接属于同一事务。 */
private void completePlan(String customerId, CompletionContext context, LocalDateTime now) {
InboundPlan plan = context.plan();
InboundPdaTask task = context.task();
@@ -84,6 +84,7 @@ public class ReceivingContainerService {
"一个托盘最多只能收66箱,请拆分到其他托盘";
@Resource private ReceivingCandidateRepository candidateRepository;
@Resource private com.baiyue.lastleg.inboundplan.pda.gateway.InboundDispatchReceiptGateway dispatchReceipts;
@Resource private InboundPlanMapper plans;
@Resource private InboundPlanLineMapper lines;
@Resource private InboundPlanPalletMapper pallets;
@@ -594,11 +595,15 @@ public class ReceivingContainerService {
return result;
}
/** 确认拆柜入库前先锁原运输安排;实际数量在本次收货事务完成交接。 */
@Transactional(isolation = Isolation.READ_COMMITTED)
public ReceivingContainerResponses.Session complete(String operatorKey,
ReceivingContainerRequests.Complete request) {
ReceivingContainerOperation replay = replay(operatorKey, "COMPLETE", request.idempotencyKey(), request);
if (replay != null) return read(replay, ReceivingContainerResponses.Session.class);
// 在收货会话、订单及库存来源加锁之前,先锁齐原运输父记录。
ReceivingContainerSession original = requireCurrent(operatorKey, false);
dispatchReceipts.lock(requirePlans(original.getSourceContainerBookingId(), original.getContainerNo()).stream().map(InboundPlan::getId).toList());
ReceivingContainerSession session = requireSigned(operatorKey);
List<InboundPlan> containerPlans = requirePlans(session.getSourceContainerBookingId(), session.getContainerNo());
Totals totals = totals(session, containerPlans);
@@ -0,0 +1,62 @@
package com.baiyue.lastleg.inboundplan.work;
import com.baiyue.lastleg.appointmentplanning.*;
import com.baiyue.lastleg.appointmentwork.AppointmentWorkDispatchPort.*;
import org.springframework.stereotype.Service;
import java.util.*;
import static com.baiyue.lastleg.appointmentwork.AppointmentWorkPolicy.*;
/** 为本车已有货物提供逐票原预约选项,读取不会释放占用或修改预约。 */
@Service
public class AppointmentDispatchAllocationReader {
private final AppointmentDispatchAllocationRepository repository;
private final AppointmentSourceService sources;
private final AppointmentDispatchAvailability availability;
/** 注入本域只读仓储、来源规范化和正式确认资格。 */
public AppointmentDispatchAllocationReader(AppointmentDispatchAllocationRepository repository,AppointmentSourceService sources,AppointmentDispatchAvailability availability) {
this.repository=repository;this.sources=sources;this.availability=availability;
}
/** 本车来源身份由上层核对;只返回明确已确认且仍有可分配数量的原批次。 */
public Map<CandidatePhysical,List<AllocationSource>> read(long dispatch,List<CandidatePhysical> items) {
if(dispatch<=0 || items==null || items.size()>1000 || items.stream().anyMatch(Objects::isNull))throw bad("本车预约实物范围无效");
if(items.isEmpty())return Map.of();
var physical=repository.physical(dispatch,items);
if(physical.size()>20000)throw unavailable("本车逐票实物超过完整读取上限");
var orders=physical.stream().map(p->new AppointmentWorkDispatchMapper.Physical(p.orderId(),p.customerId(),p.destinationKey(),null,null)).toList();
var canonical=new HashMap<AppointmentExternalSourceGateway.OrderRef,AppointmentCoverageService.SourceRef>();
var distinct=orders.stream().filter(p->p.orderId()!=null).distinct().toList();
for(int offset=0;offset<distinct.size();offset+=100)canonical.putAll(AppointmentWorkPhysicalSources.canonical(distinct.subList(offset,Math.min(offset+100,distinct.size())),sources));
var refs=orders.stream().filter(p->p.orderId()!=null && p.destinationKey()!=null && p.destinationKey().startsWith("FBA:"))
.map(p->new AppointmentCoverageService.SourceRef(p.customerId(),AppointmentWorkPhysicalSources.ref(canonical,p).sourceKey(),p.destinationKey())).distinct().toList();
var batches=refs.isEmpty()?List.<AppointmentWorkDispatchMapper.CandidateBatch>of():repository.batches(dispatch,refs,orders);
if(batches.size()>20000)throw unavailable("本车原预约选项超过完整读取上限");
var confirmations=availability.snapshots(batches.stream().map(AppointmentWorkDispatchMapper.CandidateBatch::batchKey).toList());
var result=new LinkedHashMap<CandidatePhysical,List<AllocationSource>>();
for(var identity:items) {
var rows=new ArrayList<AllocationSource>();
for(var p:physical.stream().filter(p->identity.planPalletId().equals(p.planPalletId()) && identity.customerId().equals(p.customerId())).toList()) {
var reasons=new ArrayList<String>();var options=new ArrayList<AllocationOption>();
if(p.orderId()==null || p.destinationKey()==null || p.packages()==null || p.packages()<=0)throw conflict("本车逐票实物来源或箱数尚未核实");
var source=AppointmentWorkPhysicalSources.ref(canonical,new AppointmentWorkDispatchMapper.Physical(p.orderId(),p.customerId(),p.destinationKey(),null,null)).sourceKey();
if(p.destinationKey().startsWith("FBA:")) {
for(var b:batches)if(identity.customerId().equals(b.customerId()) && source.equals(b.sourceKey()) && p.destinationKey().equals(b.destinationKey())) {
int confirmed=availability.confirmedPackages(confirmations.get(b.batchKey()),b.customerId(),source,p.destinationKey());
long remaining=b.packages()==null?0:Math.min(b.packages(),confirmed)-b.allocated();
if(remaining>0 && b.recordVersion()!=null && b.recordVersion()>=0) {
var agreement=confirmations.get(b.batchKey()).agreements().stream().filter(a->"ACTIVE".equals(a.status())
&& a.members().stream().anyMatch(m->b.batchKey().equals(m.batchKey()) && b.customerId().equals(m.customerId()))).findFirst().orElseThrow();
var details=agreement.details();
options.add(new AllocationOption(b.batchKey(),b.batchNo(),b.recordVersion(),Math.toIntExact(remaining),details.confirmationNo(),
details.appointmentAt().toString(),details.appointmentEndAt()==null?null:details.appointmentEndAt().toString(),agreement.timezone()));
}
}
if(options.stream().mapToLong(AllocationOption::availablePackages).sum()<p.packages())reasons.add("请先在预约列表确认本票FBA送仓时间及覆盖箱数,再返回刷新");
}
rows.add(new AllocationSource(source,p.orderNo(),p.inboundCode(),p.destinationKey(),p.packages(),List.copyOf(options),List.copyOf(reasons)));
}
if(rows.isEmpty())throw conflict("本车托盘缺少真实逐票货物来源");
result.put(identity,List.copyOf(rows));
}
return result;
}
}
@@ -0,0 +1,20 @@
package com.baiyue.lastleg.inboundplan.work;
import com.baiyue.lastleg.appointmentplanning.AppointmentCoverageService.SourceRef;
import com.baiyue.lastleg.appointmentwork.AppointmentWorkDispatchPort.CandidatePhysical;
import org.springframework.stereotype.Repository;
import java.util.List;
/** 已有排车关联预约的本域只读持久化边界。 */
@Repository
public class AppointmentDispatchAllocationRepository {
private final AppointmentWorkDispatchMapper mapper;
/** 复用权威实物与预约Mapper,不新增分配存储。 */
public AppointmentDispatchAllocationRepository(AppointmentWorkDispatchMapper mapper) {this.mapper=mapper;}
/** 按服务层已核实的本车实物读取逐票业务标识及数量。 */
public List<AppointmentWorkDispatchMapper.AllocationPhysical> physical(long dispatch,List<CandidatePhysical> items) {return mapper.allocationPhysical(dispatch,items);}
/** 同一原批次来源扣除其他有效车辆占用,本车原分配由替换事务重新核对。 */
public List<AppointmentWorkDispatchMapper.CandidateBatch> batches(long dispatch,List<SourceRef> refs,List<AppointmentWorkDispatchMapper.Physical> orders) {
return mapper.candidateBatches(refs,orders,dispatch);
}
}
@@ -0,0 +1,49 @@
package com.baiyue.lastleg.inboundplan.work;
import com.baiyue.lastleg.inboundplan.receiving.FbaReceivingStore;
import com.baiyue.lastleg.inboundplan.receiving.FbaReceivingContracts.*;
import org.springframework.stereotype.Service;
import java.time.*;
import java.util.*;
/** 读取既有正式预约的确认范围,供自动带入及预约业务自身核验使用。 */
@Service
public class AppointmentDispatchAvailability {
private final FbaReceivingStore receiving;
/** 只消费原收货确认权威,不使用车辆补充预约号。 */
public AppointmentDispatchAvailability(FbaReceivingStore receiving) {this.receiving=receiving;}
/** 按既有200批上限分块批读,避免逐托查询预约。 */
public Map<String,BatchReceiving> snapshots(List<String> keys) {
var unique=keys.stream().distinct().sorted().toList();var result=new HashMap<String,BatchReceiving>();
for(int offset=0;offset<unique.size();offset+=200) for(var snapshot:receiving.snapshots(unique.subList(offset,Math.min(offset+200,unique.size()))))result.put(snapshot.batchKey(),snapshot);
return result;
}
/** 未确认、待仓库回复、时间无效或覆盖身份不唯一时均不可作为排车依据。 */
public int confirmedPackages(BatchReceiving snapshot,String customer,String source,String destination) {
if(snapshot==null || snapshot.pendingChanges().stream().anyMatch(p->"WAITING".equals(p.status()) && customer.equals(p.intent().customerId())))return 0;
var agreements=snapshot.agreements().stream().filter(a->"ACTIVE".equals(a.status()) && destination.equals(a.destinationKey())
&& a.members().stream().anyMatch(m->snapshot.batchKey().equals(m.batchKey()) && customer.equals(m.customerId()))).toList();
if(agreements.size()!=1)return 0;
var agreement=agreements.get(0);var details=agreement.details();
if(details==null || !"SUCCESS".equals(details.appointmentStatus()) || details.appointmentAt()==null)return 0;
var start=confirmedInstant(details.appointmentAt(),agreement.timezone(),details.startOffset());
if(start==null)return 0;
if(details.appointmentEndAt()!=null) {
var end=confirmedInstant(details.appointmentEndAt(),agreement.timezone(),details.endOffset());
if(end==null || end.isBefore(start))return 0;
}
var members=agreement.members().stream().filter(m->snapshot.batchKey().equals(m.batchKey()) && customer.equals(m.customerId())).toList();
if(members.size()!=1)return 0;
var sources=members.get(0).sources().stream().filter(s->source.equals(s.sourceKey()) && destination.equals(s.destinationKey())).toList();
return sources.size()==1?Math.max(0,sources.get(0).packageCount()):0;
}
/** 夏令时歧义须有明确偏移;不能把当地时间按服务器时区猜测。 */
private Instant confirmedInstant(LocalDateTime at,String timezone,String offset) {
try {
var offsets=ZoneId.of(timezone).getRules().getValidOffsets(at);
if(offsets.isEmpty() || offset==null && offsets.size()!=1)return null;
var selected=offset==null?offsets.get(0):ZoneOffset.of(offset);
return offsets.contains(selected)?at.toInstant(selected):null;
} catch(DateTimeException|NullPointerException error){return null;}
}
}
@@ -0,0 +1,68 @@
package com.baiyue.lastleg.inboundplan.work;
import com.baiyue.lastleg.appointmentplanning.AppointmentSourceService;
import com.baiyue.lastleg.inboundplan.pda.gateway.InboundDispatchReceiptGateway;
import com.baiyue.lastleg.inboundplan.service.InboundFbaAppointmentService;
import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.*;
import java.util.*;
import static com.baiyue.lastleg.appointmentwork.AppointmentWorkPolicy.conflict;
/** 实收沿原逐票预约归属更新数量,绝不按托数均分货物或新选批次。 */
@Component
@Transactional(propagation=Propagation.MANDATORY)
public class AppointmentReceiptDispatchAdapter implements InboundDispatchReceiptGateway.Appointments {
private final AppointmentReceiptRepository repository;
private final AppointmentWorkDispatchBinding binding;
private final InboundFbaAppointmentService appointments;
private final AppointmentSourceService sources;
/** 复用原批次锁和真实来源;收货记录不依赖预约是否已满足出货资格。 */
public AppointmentReceiptDispatchAdapter(AppointmentReceiptRepository repository, AppointmentWorkDispatchBinding binding,
InboundFbaAppointmentService appointments, AppointmentSourceService sources) {
this.repository=repository;this.binding=binding;this.appointments=appointments;this.sources=sources;
}
/** 收货前按业务批次顺序锁齐原有效预约,避免库存与预约反向取锁。 */
@Override public void lock(List<String> numbers) {
var versions=new TreeMap<String,Long>();
for(var number:numbers) for(var row:repository.bindings(number))
if(!row.batchKey().startsWith("DISPATCH:"))versions.computeIfAbsent(row.batchKey(),this::version);
binding.lockBatches(versions);
}
/** 实收来源必须与原绑定唯一对应,多预约分摊不明确时拒绝猜测。 */
@Override public void complete(long dispatch, List<InboundDispatchReceiptGateway.Item> items) {
var previous=repository.bindings(dispatch);
for(var item:items) {
var original=previous.stream().filter(r->r.itemId()==item.itemId()).toList();
var physical=repository.physical(item.planPalletId(),item.customerId());
var canonical=AppointmentWorkPhysicalSources.canonical(physical,sources);
var quantities=new LinkedHashMap<List<String>,Integer>();
for(var part:physical) {
if(part.countedPackages()==null || part.countedPackages()<=0)throw conflict("收货逐票实物箱数尚未确认");
var key=List.of(AppointmentWorkPhysicalSources.ref(canonical,part).sourceKey(),part.destinationKey());
quantities.merge(key,part.countedPackages(),Math::addExact);
}
var originalKeys=new HashSet<List<String>>();
for(var row:original)originalKeys.add(List.of(row.sourceKey(),row.destinationKey()));
if(!quantities.keySet().equals(originalKeys))throw conflict("实收货物来源与原排车预约不一致,请核对原逐票安排");
if(quantities.values().stream().mapToLong(Integer::longValue).sum()!=item.packages())throw conflict("逐票实收与托盘确认箱数不一致");
for(var entry:quantities.entrySet()) {
var matches=original.stream().filter(r->r.sourceKey().equals(entry.getKey().get(0)) && r.destinationKey().equals(entry.getKey().get(1))).toList();
if(matches.size()!=1)throw conflict("同一实收来源关联多个预约批次,请明确逐票分配,不能自动均分");
var row=matches.get(0);
if(!row.customerId().equals(item.customerId()) || row.palletId()!=item.planPalletId())throw conflict("原预约客户或托盘归属已变化");
if(row.packages()!=entry.getValue() && repository.receiveQuantity(row,entry.getValue())!=1)throw conflict("原预约逐托分配已变化,收货交接未保存");
}
}
binding.receiptQuantitiesChanged();
}
/** 使用同事务已锁定的原预约版本,不沿用客户端旧快照。 */
private long version(String batch) {
var value=appointments.batchContext(batch).appointment().version();
if(value==null || value<0)throw conflict("原预约批次版本缺失,无法确认收货交接");
return value.longValue();
}
}
@@ -0,0 +1,25 @@
package com.baiyue.lastleg.inboundplan.work;
import org.springframework.stereotype.Repository;
import java.util.List;
/** 收货交接只读取原排车预约绑定和已确认逐票来源。 */
@Repository
public class AppointmentReceiptRepository {
private final AppointmentWorkDispatchMapper mapper;
/** 复用权威预约与托盘分配Mapper。 */
public AppointmentReceiptRepository(AppointmentWorkDispatchMapper mapper) { this.mapper=mapper; }
/** 读取本车已选择的批次,不选择新预约。 */
public List<AppointmentWorkDispatchMapper.Bound> bindings(String number) { return mapper.bindings(number); }
/** 读取原车逐托绑定,供实收来源逐项匹配。 */
public List<AppointmentWorkDispatchMapper.Bound> bindings(long id) { return mapper.byDispatch(id); }
/** 真实逐票数量以收货确认的分配记录为准。 */
public List<AppointmentWorkDispatchMapper.Physical> physical(long pallet, String customer) { return mapper.physical(pallet,customer); }
/** 原绑定身份及旧数量同时参与更新,防止覆盖并发改量。 */
public int receiveQuantity(AppointmentWorkDispatchMapper.Bound original,int actual) { return mapper.receiveQuantity(original,actual); }
}
@@ -38,6 +38,7 @@ public class AppointmentWorkDispatchBinding implements AppointmentWorkDispatchPo
private final AppointmentWorkChanges changes;
private final com.baiyue.lastleg.inboundplan.receiving.FbaReceivingStore receiving;
private final AppointmentWorkDispatchCandidates candidates;
@jakarta.annotation.Resource private AppointmentDispatchAllocationReader allocationReader;
private final Object prelockedBatchesKey=new Object();
private final Object pendingInvalidationKey=new Object();
/** 注入原预约、真实实物和收货确认权威。 */
@@ -48,6 +49,9 @@ public class AppointmentWorkDispatchBinding implements AppointmentWorkDispatchPo
/** 候选批读沿唯一原实物Provider实现,不读取工作清单投影。 */
@Transactional(readOnly=true,propagation=Propagation.SUPPORTS)
public Map<CandidatePhysical,CandidateBatches> candidateBatches(List<CandidatePhysical> input) {return candidates.batchReferences(input);}
/** 已有排车的选项读取不受本车原占用影响,也不会修改来源。 */
@Transactional(readOnly=true,propagation=Propagation.SUPPORTS)
public Map<CandidatePhysical,List<AllocationSource>> allocationOptions(long dispatchId,List<CandidatePhysical> items) {return allocationReader.read(dispatchId,items);}
/** 来源锁前一次锁齐全部批次;后续调用只能复用本事务已经锁定的范围。 */
public void lockBatches(Map<String,Long> expectedBatchVersions) {
if(expectedBatchVersions==null || expectedBatchVersions.size()>1000) throw bad("预约批次版本范围无效");
@@ -100,7 +104,6 @@ public class AppointmentWorkDispatchBinding implements AppointmentWorkDispatchPo
for(var item:items) if(item.planPalletId()!=null) {
var selected=allocations.stream().filter(a->item.customerId().equals(a.customerId()) && item.planPalletId().equals(a.planPalletId())).toList();
if(selected.isEmpty()) {
requireNoBatch(item.customerId(),item.planPalletId());
next.addAll(privateAllocation(item.customerId(),dispatch,item));
} else {
validatePhysical(item.customerId(),item,selected,"RECEIVED".equals(item.sourceStage()),false);
@@ -115,9 +118,11 @@ public class AppointmentWorkDispatchBinding implements AppointmentWorkDispatchPo
var appointment=appointments.batchContext(batch).appointment();
if(appointment.version()==null || appointment.version()<0 || versions.containsKey(batch) && appointment.version().longValue()!=versions.get(batch)) throw conflict("预约批次版本已变化,请重新读取实物候选");
if(!appointment.replacementBatchKeys().isEmpty()) throw conflict("不能绑定已被替代的预约批次");
for(var customer:effective.stream().filter(a->batch.equals(a.batchKey())).map(AppointmentWorkDispatchMapper.Bound::customerId).distinct().sorted().toList())
for(var customer:effective.stream().filter(a->batch.equals(a.batchKey())).map(AppointmentWorkDispatchMapper.Bound::customerId).distinct().sorted().toList()) {
requireCurrentAllocation(appointment,customer,dispatch,effective.stream().filter(a->batch.equals(a.batchKey()) && customer.equals(a.customerId()))
.map(a->allocation(a,appointment.version().longValue())).toList());
// 建单允许先排车;本车预约号在客服提交审核时按目的仓核对。
}
}
// 所有校验结束后才按明确item删除;没有整车清空操作。
for(var item:touched) mapper.removeItem(dispatch,item);
@@ -128,7 +133,10 @@ public class AppointmentWorkDispatchBinding implements AppointmentWorkDispatchPo
return new Allocation(row.palletId(),row.batchKey(),version,row.customerId(),row.sourceKey(),row.destinationKey(),row.packages());
}
private void validatePhysical(String customer,PhysicalItem item,List<Allocation> selected,boolean received,boolean picked) {
var physical=picked?mapper.pickedPhysical(item.itemId(),customer):mapper.physical(item.planPalletId(),customer);
var physical=new ArrayList<>(picked?mapper.pickedPhysical(item.itemId(),customer)
:received?mapper.receivedPhysical(item.planPalletId(),customer):mapper.physical(item.planPalletId(),customer));
// 扫码已从库位扣减;审核与后续扫码须合并本明细已拣逐票归属,发车仍只认真实已拣。
if(received && !picked) physical.addAll(mapper.pickedPhysical(item.itemId(),customer));
if(physical.isEmpty()) throw conflict("托盘缺少真实逐票分配,不能按整票或整托总数猜测批次");
var canonical=AppointmentWorkPhysicalSources.canonical(physical,sources);
var remaining=new HashMap<String,Long>();
@@ -153,14 +161,6 @@ public class AppointmentWorkDispatchBinding implements AppointmentWorkDispatchPo
public String reviewBlockReason(String dispatchNo,java.time.Instant departure,java.time.Instant arrival) {
try {
checkDeparture(dispatchNo,false,false);
var windows=reviewEvidence(dispatchNo);
if(!windows.isEmpty()) {
if(departure==null || arrival==null)return "请先明确计划来车/发车时间和预计送达时间,再提交审核";
if(arrival.isBefore(departure))return "预计送达时间不能早于计划发车时间";
var first=windows.stream().min(Comparator.comparing(ReceivingWindow::startAt)).orElseThrow();
if(!departure.isBefore(first.startAt()))return "计划发车时间须早于本车最早的FBA预约送达时间";
if(arrival.isAfter(first.endAt()==null?first.startAt():first.endAt()))return "预计送达时间晚于本车首个FBA预约收货时间,请调度重新核对";
}
return null;
} catch(org.springframework.web.server.ResponseStatusException error) {
if(error.getStatusCode().value()!=409)throw error;
@@ -203,9 +203,8 @@ public class AppointmentWorkDispatchBinding implements AppointmentWorkDispatchPo
var batches=rows.stream().map(AppointmentWorkDispatchMapper.Bound::batchKey).filter(b->!b.startsWith("DISPATCH:")).distinct().sorted().toList();
if(locked) for(var batch:batches) lock(batch);
for(var item:physical) if(item.planPalletId()!=null && rows.stream().noneMatch(r->r.itemId()==item.itemId() && r.customerId().equals(item.customerId()) && item.planPalletId().equals(r.palletId()))) {
requireNoBatch(item.customerId(),item.planPalletId());
if(!picked && mapper.physical(item.planPalletId(),item.customerId()).stream().anyMatch(p->p.destinationKey()!=null && p.destinationKey().startsWith("FBA:")))throw conflict("本车FBA货物尚未关联已确认预约,请客服核对送达时间后提交审核");
if(mapper.physical(item.planPalletId(),item.customerId()).stream().anyMatch(p->p.destinationKey()!=null && p.destinationKey().startsWith("CUSTOMER:"))) throw conflict("客户仓排车缺少逐票实物批次关联,请重新核对排车");
if(mapper.physical(item.planPalletId(),item.customerId()).stream().anyMatch(p->p.destinationKey()!=null && (p.destinationKey().startsWith("FBA:") || p.destinationKey().startsWith("CUSTOMER:"))))
throw conflict("本车货物缺少真实来源明细,请重新核对排车");
}
for(var batch:batches) {
var a=appointments.batchContext(batch).appointment();
@@ -214,7 +213,6 @@ public class AppointmentWorkDispatchBinding implements AppointmentWorkDispatchPo
var dispatchIds=allocated.stream().map(AppointmentWorkDispatchMapper.Bound::dispatchId).distinct().toList();
if(dispatchIds.size()!=1) throw conflict("排车批次关联不一致,请重新核对实物分配");
for(var customer:allocated.stream().map(AppointmentWorkDispatchMapper.Bound::customerId).distinct().toList()) {
requireReceiving(batch,customer,allocated.stream().filter(row->customer.equals(row.customerId())).toList());
requireCurrentAllocation(a,customer,dispatchIds.get(0),allocated.stream().filter(row->customer.equals(row.customerId()))
.map(row->allocation(row,a.version()==null?null:a.version().longValue())).toList());
}
@@ -226,21 +224,6 @@ public class AppointmentWorkDispatchBinding implements AppointmentWorkDispatchPo
group.stream().map(a->allocation(a,null)).toList(),picked || "RECEIVED".equals(item.sourceStage()),picked);
}
}
private void requireReceiving(String batch,String customer,List<AppointmentWorkDispatchMapper.Bound> rows) {
if(receiving.hasPending(batch,customer)) throw conflict("本客户有待仓库确认的变化,保留原安排但禁止发车,请先完成明确回复");
var agreement=receiving.currentAgreement(batch,customer);
if(agreement==null || !"ACTIVE".equals(agreement.status()) || agreement.details()==null || !"SUCCESS".equals(agreement.details().appointmentStatus())
|| agreement.details().appointmentAt()==null) throw conflict("本客户尚无有效收货确认,请先完成当前预约后发车");
var members=agreement.members().stream().filter(m->batch.equals(m.batchKey()) && customer.equals(m.customerId())).toList();
if(members.size()!=1) throw conflict("当前收货确认未唯一覆盖本客户批次,请重新核对");
for(var group:rows.stream().collect(Collectors.groupingBy(r->List.of(r.sourceKey(),r.destinationKey()))).values()) {
var first=group.get(0);
var sources=members.get(0).sources().stream().filter(s->first.sourceKey().equals(s.sourceKey()) && first.destinationKey().equals(s.destinationKey())).toList();
if(sources.size()!=1 || mapper.allocated(batch,customer,first.sourceKey(),first.destinationKey(),first.dispatchId())
+group.stream().mapToLong(AppointmentWorkDispatchMapper.Bound::packages).sum()>sources.get(0).packageCount())
throw conflict("当前收货确认未完整覆盖本次逐票实物,请重新核对");
}
}
private void requireCurrentAllocation(com.baiyue.lastleg.inboundplan.dto.FbaAppointmentResponse appointment,String customer,long dispatch,List<Allocation> allocations) {
var grouped=allocations.stream().collect(Collectors.groupingBy(a->List.of(a.sourceKey(),a.destinationKey())));
for(var group:grouped.values()) {
@@ -254,13 +237,9 @@ public class AppointmentWorkDispatchBinding implements AppointmentWorkDispatchPo
throw conflict("所选实物超过当前批次逐票分配量,请重新核对");
}
}
private void requireNoBatch(String customer,long pallet) {
var physical=mapper.physical(pallet,customer).stream().filter(p->p.destinationKey()!=null && p.destinationKey().startsWith("FBA:")).toList();
var canonical=AppointmentWorkPhysicalSources.canonical(physical,sources);
var refs=physical.stream().map(p->new AppointmentCoverageService.SourceRef(customer,AppointmentWorkPhysicalSources.ref(canonical,p).sourceKey(),p.destinationKey())).distinct().toList();
if(!refs.isEmpty() && appointments.relatedBatchSnapshots(refs).stream().anyMatch(a->a.replacementBatchKeys().isEmpty()))
throw conflict("所选实物已有预约批次,必须明确逐批逐票分配后才能排车或发车");
}
/** 实收只更新原逐票绑定后,仍在业务提交末尾刷新工作视图。 */
void receiptQuantitiesChanged() { invalidateAtBusinessEnd(); }
/** 调用方已锁主单与原批次,删除只作用于指定实物。 */
public void removeItem(long dispatchId,long itemId) { mapper.removeItem(dispatchId,itemId);invalidateAtBusinessEnd(); }
/** 同事务提交前统一失效,避免逐车移货时在后续来源释放前持有guard。 */
@@ -283,9 +262,9 @@ public class AppointmentWorkDispatchBinding implements AppointmentWorkDispatchPo
}
}
private List<AppointmentWorkDispatchMapper.Bound> privateAllocation(String customer,long dispatch,PhysicalItem item) {
var physical=mapper.physical(item.planPalletId(),customer);
if(physical.isEmpty() || physical.stream().noneMatch(p->p.destinationKey()!=null && p.destinationKey().startsWith("CUSTOMER:"))) return List.of();
if(physical.stream().anyMatch(p->p.destinationKey()==null || !p.destinationKey().startsWith("CUSTOMER:"))) throw conflict("客户仓实物不能混入FBA批次,请先核对托盘目的地");
var physical="RECEIVED".equals(item.sourceStage())?mapper.receivedPhysical(item.planPalletId(),customer):mapper.physical(item.planPalletId(),customer);
if(physical.isEmpty()) throw conflict("托盘缺少真实逐票库存归属,不能推算分配数量");
if(physical.stream().anyMatch(p->p.destinationKey()==null)) throw conflict("实物目的地尚未核实");
var canonical=AppointmentWorkPhysicalSources.canonical(physical,sources); var rows=new ArrayList<AppointmentWorkDispatchMapper.Bound>();
for(var part:physical) {
var count="RECEIVED".equals(item.sourceStage())?part.countedPackages():part.plannedPackages();
@@ -16,8 +16,9 @@ public class AppointmentWorkDispatchCandidates {
private final AppointmentWorkDispatchMapper mapper;
private final InboundFbaAppointmentService appointments;
private final AppointmentSourceService sources;
private final AppointmentDispatchAvailability availability;
/** 注入唯一原批次、实物和来源规范化边界。 */
public AppointmentWorkDispatchCandidates(AppointmentWorkDispatchMapper mapper,InboundFbaAppointmentService appointments,AppointmentSourceService sources) { this.mapper=mapper; this.appointments=appointments; this.sources=sources; }
public AppointmentWorkDispatchCandidates(AppointmentWorkDispatchMapper mapper,InboundFbaAppointmentService appointments,AppointmentSourceService sources,AppointmentDispatchAvailability availability) { this.mapper=mapper; this.appointments=appointments; this.sources=sources;this.availability=availability; }
/** 整页两次本地SQL;来源按已有100票上限分块规范化,不逐托或逐批读取上下文。 */
@Transactional(readOnly=true)
public Map<CandidatePhysical,CandidateBatches> batchReferences(List<CandidatePhysical> input) {
@@ -32,13 +33,14 @@ public class AppointmentWorkDispatchCandidates {
var refs=physical.stream().filter(p->p.destinationKey().startsWith("FBA:")).map(p->new AppointmentCoverageService.SourceRef(p.customerId(),AppointmentWorkPhysicalSources.ref(canonical,p).sourceKey(),p.destinationKey())).distinct().toList();
if(refs.size()>20000) throw unavailable("候选逐票来源超过完整读取上限");
var orders=physical.stream().filter(p->p.destinationKey().startsWith("FBA:")).map(p->new AppointmentWorkDispatchMapper.Physical(p.orderId(),p.customerId(),p.destinationKey(),null,null)).distinct().toList();
var batches=refs.isEmpty()?List.<AppointmentWorkDispatchMapper.CandidateBatch>of():mapper.candidateBatches(refs,orders);
var batches=refs.isEmpty()?List.<AppointmentWorkDispatchMapper.CandidateBatch>of():mapper.candidateBatches(refs,orders,-1);
if(batches.size()>20000) throw unavailable("候选原批次超过完整读取上限");
var confirmations=availability.snapshots(batches.stream().map(AppointmentWorkDispatchMapper.CandidateBatch::batchKey).toList());
var output=new LinkedHashMap<CandidatePhysical,CandidateBatches>();
for(var identity:identities) {
var selected=rows.stream().filter(r->r.planPalletId()==identity.planPalletId() && identity.customerId().equals(r.customerId())).toList();
var reasons=new LinkedHashSet<String>();var references=new LinkedHashMap<List<String>,List<BatchSource>>();
boolean related=false,unassigned=false;boolean occupied=selected.stream().anyMatch(AppointmentWorkDispatchMapper.CandidateSource::locked);
boolean unassigned=false;boolean occupied=selected.stream().anyMatch(AppointmentWorkDispatchMapper.CandidateSource::locked);
if(selected.isEmpty()) reasons.add("客户实物托盘不存在或归属已变化");
for(var row:selected) {
var count="RECEIVED".equals(row.sourceStage())?row.countedPackages():row.plannedPackages();
@@ -47,16 +49,18 @@ public class AppointmentWorkDispatchCandidates {
var source=AppointmentWorkPhysicalSources.ref(canonical,new AppointmentWorkDispatchMapper.Physical(row.orderId(),row.customerId(),row.destinationKey(),row.plannedPackages(),row.countedPackages())).sourceKey();
var matched=batches.stream().filter(b->identity.customerId().equals(b.customerId()) && (source.equals(b.sourceKey()) || row.orderId().equals(b.orderId())) && row.destinationKey().equals(b.destinationKey())).toList();
if(matched.isEmpty()) {unassigned=true;continue;}
related=true;long available=0;var seen=new HashSet<String>();
long available=0;var seen=new HashSet<String>();
for(var b:matched) {
if(!source.equals(b.sourceKey())) {reasons.add("原预约逐票来源与当前真实规范不一致,请先核对原批次");continue;}
if(b.batchKey()==null || !seen.add(b.batchKey()) || b.batchNo()==null || b.batchNo().isBlank() || b.recordVersion()==null || b.recordVersion()<0 || b.packages()==null || b.packages()<=0 || b.allocated()<0 || b.allocated()>b.packages()) {reasons.add("原预约批次版本、逐票分配或占用不完整");continue;}
int remaining=Math.toIntExact(b.packages()-b.allocated());available=Math.addExact(available,remaining);
if(!source.equals(b.sourceKey())) {unassigned=true;continue;}
if(b.batchKey()==null || !seen.add(b.batchKey()) || b.batchNo()==null || b.batchNo().isBlank() || b.recordVersion()==null || b.recordVersion()<0 || b.packages()==null || b.packages()<=0 || b.allocated()<0 || b.allocated()>b.packages()) {unassigned=true;continue;}
int confirmed=availability.confirmedPackages(confirmations.get(b.batchKey()),b.customerId(),source,row.destinationKey());
int remaining=Math.toIntExact(Math.max(0,Math.min(b.packages(),confirmed)-b.allocated()));available=Math.addExact(available,remaining);
if(remaining>0) references.computeIfAbsent(List.of(b.batchKey(),b.batchNo(),b.recordVersion().toString(),b.customerId()),ignored->new ArrayList<>()).add(new BatchSource(source,row.destinationKey(),Math.min(remaining,count)));
}
if(available<count) reasons.add("关联批次逐票剩余量不足以覆盖本托实物");
if(available<count || matched.size()!=1) unassigned=true;
}
if(related && unassigned) reasons.add("托盘含未归属当前预约批次的货物,请先核对逐票分配");
// 未约、未确认或多个批次均允许先排;只有完整且唯一的已约来源才自动携带。
if(unassigned)references.clear();
var result=references.entrySet().stream().map(e->new BatchReference(e.getKey().get(0),e.getKey().get(1),Long.valueOf(e.getKey().get(2)),e.getKey().get(3),List.copyOf(e.getValue()))).toList();
output.put(identity,new CandidateBatches(result,List.copyOf(reasons),occupied));
}
@@ -77,6 +81,7 @@ public class AppointmentWorkDispatchCandidates {
// Receiving outcome does not release this batch's cargo or decide another customer's physical eligibility.
if(!appointment.replacementBatchKeys().isEmpty()) return new Result(batch,appointment.version(),List.of(),List.of("原批次已替代,不能选择实物"));
var candidates=mapper.candidates(appointment.id());
var confirmation=availability.snapshots(List.of(batch)).get(batch);
if(candidates.size()>500) throw conflict("本批实物超过500托,请先按原预约分批");
var output=new ArrayList<Candidate>();
for(var candidate:candidates) {
@@ -93,6 +98,7 @@ public class AppointmentWorkDispatchCandidates {
if(item==null) { reasons.add("托盘含本批之外的货物,请先拆托或选择完整的关联批次"); continue; }
long used=mapper.allocated(batch,part.customerId(),ref.sourceKey(),part.destinationKey(),-1);
if(item.packageCount()==null || used+count>item.packageCount()) reasons.add("本托货量超过该来源本批剩余可排量,请先核对分批或拆托");
if(used+count>availability.confirmedPackages(confirmation,part.customerId(),ref.sourceKey(),part.destinationKey()))reasons.add("FBA货物须先预约成功并明确送仓时间,再加入排车");
if(appointment.version()==null || appointment.version()<0) throw unavailable("原预约批次版本缺失");
allocations.add(new Allocation(candidate.planPalletId(),batch,appointment.version().longValue(),part.customerId(),ref.sourceKey(),part.destinationKey(),count));
}
@@ -18,6 +18,26 @@ public interface AppointmentWorkDispatchMapper {
record ReferenceRow(long dispatchId,String batchKey,String batchNo,Long recordVersion,String customerId,String sourceKey,String destinationKey,int packages) { }
record CandidateSource(long planPalletId,String customerId,String sourceStage,boolean locked,Long orderId,String destinationKey,Integer plannedPackages,Integer countedPackages) { }
record CandidateBatch(String batchKey,String batchNo,Long recordVersion,String customerId,Long orderId,String sourceKey,String destinationKey,Integer packages,long allocated) { }
record AllocationPhysical(long planPalletId,String customerId,Long orderId,String orderNo,String inboundCode,String destinationKey,Integer packages) { }
/** 来源展示使用业务编号;逐票数量按本车原计划或实收阶段读取。 */
@Select("""
<script>
SELECT i.plan_pallet_id,i.customer_id,l.order_id,o.order_no,o.inbound_code,
CASE WHEN l.destination_type='PRIVATE' THEN CONCAT('CUSTOMER:',l.order_id) ELSE CONCAT('FBA:',l.fba_warehouse_code) END destination_key,
CASE WHEN i.source_stage='PLAN' THEN
CASE WHEN COUNT(a.planned_package_count)=COUNT(a.line_id) THEN SUM(a.planned_package_count) END
ELSE CASE WHEN COUNT(a.counted_package_count)=COUNT(a.line_id) THEN SUM(a.counted_package_count) END END packages
FROM ll_dispatch_item i
JOIN ll_inbound_plan_pallet_allocation a ON a.pallet_id=i.plan_pallet_id AND a.customer_id=i.customer_id
JOIN ll_inbound_plan_line l ON l.id=a.line_id AND l.customer_id=a.customer_id
LEFT JOIN ll_order o ON o.id=l.order_id AND o.customer_id=l.customer_id
WHERE i.dispatch_order_id=#{dispatchId} AND
(<foreach collection='items' item='p' separator=' OR '>(i.plan_pallet_id=#{p.planPalletId} AND i.customer_id=#{p.customerId})</foreach>)
GROUP BY i.plan_pallet_id,i.customer_id,i.source_stage,l.order_id,o.order_no,o.inbound_code,l.destination_type,l.fba_warehouse_code
ORDER BY i.plan_pallet_id,l.order_id LIMIT 20001
</script>
""")
List<AllocationPhysical> allocationPhysical(@Param("dispatchId") long dispatchId,@Param("items") List<com.baiyue.lastleg.appointmentwork.AppointmentWorkDispatchPort.CandidatePhysical> items);
/** 一次读取整页逐票来源及占用;仅预留但未收货的托盘仍按计划箱数预排。 */
@Select("""
<script>
@@ -43,7 +63,7 @@ public interface AppointmentWorkDispatchMapper {
i.customer_id,i.order_id,i.source_key,CONCAT('FBA:',i.fba_warehouse_code) destination_key,i.package_count packages,
(SELECT COALESCE(SUM(a.package_count),0) FROM ll_dispatch_appointment_allocation a JOIN ll_dispatch_order d ON d.id=a.dispatch_id
WHERE a.batch_key=CONCAT('FBA_APPOINTMENT:',p.id) AND a.customer_id=i.customer_id AND a.source_key=i.source_key
AND a.destination_key=CONCAT('FBA:',i.fba_warehouse_code) AND d.status&lt;&gt;'CANCELLED') allocated
AND a.destination_key=CONCAT('FBA:',i.fba_warehouse_code) AND d.status&lt;&gt;'CANCELLED' AND d.id&lt;&gt;#{excludedDispatchId}) allocated
FROM ll_inbound_fba_appointment_item i JOIN ll_inbound_fba_appointment p ON p.id=i.appointment_id
WHERE i.active=1 AND (p.replacement_keys_json IS NULL OR p.replacement_keys_json='[]') AND
(<foreach collection='refs' item='r' separator=' OR '>(i.customer_id=#{r.customerId} AND i.source_key=#{r.sourceKey} AND CONCAT('FBA:',i.fba_warehouse_code)=#{r.destinationKey})</foreach>
@@ -51,7 +71,7 @@ public interface AppointmentWorkDispatchMapper {
ORDER BY p.id,i.customer_id,i.source_key,i.fba_warehouse_code LIMIT 20001
</script>
""")
List<CandidateBatch> candidateBatches(@Param("refs") List<com.baiyue.lastleg.appointmentplanning.AppointmentCoverageService.SourceRef> refs,@Param("orders") List<Physical> orders);
List<CandidateBatch> candidateBatches(@Param("refs") List<com.baiyue.lastleg.appointmentplanning.AppointmentCoverageService.SourceRef> refs,@Param("orders") List<Physical> orders,@Param("excludedDispatchId") long excludedDispatchId);
@Select("""
<script>
SELECT a.dispatch_id,a.batch_key,CASE WHEN a.batch_key=CONCAT('DISPATCH:',d.id) THEN d.dispatch_no ELSE p.appointment_no END batch_no,
@@ -102,6 +122,21 @@ public interface AppointmentWorkDispatchMapper {
List<Candidate> candidates(long id);
@Select("SELECT l.order_id,l.customer_id,CASE WHEN l.destination_type='PRIVATE' THEN CONCAT('CUSTOMER:',l.order_id) ELSE CONCAT('FBA:',l.fba_warehouse_code) END destination_key,CASE WHEN COUNT(a.planned_package_count)=COUNT(a.line_id) THEN SUM(a.planned_package_count) END planned_packages,CASE WHEN COUNT(a.counted_package_count)=COUNT(a.line_id) THEN SUM(a.counted_package_count) END counted_packages FROM ll_inbound_plan_pallet_allocation a JOIN ll_inbound_plan_line l ON l.id=a.line_id AND l.customer_id=a.customer_id WHERE a.pallet_id=#{pallet} AND a.customer_id=#{customer} GROUP BY l.order_id,l.customer_id,l.destination_type,l.fba_warehouse_code")
List<Physical> physical(@Param("pallet") long pallet,@Param("customer") String customer);
/** 已收货托盘按当前库位逐票归属读取;移托后的库存不能继续使用原收货分配箱数。 */
@Select("""
SELECT o.id order_id,o.customer_id,
CASE WHEN o.destination_type='PRIVATE' THEN CONCAT('CUSTOMER:',o.id) ELSE CONCAT('FBA:',o.fba_warehouse_code) END destination_key,
NULL planned_packages,
SUM(COALESCE(pos.available_packages,0)+COALESCE(pos.allocated_packages,0)+COALESCE(pos.picked_packages,0)) counted_packages
FROM ll_inbound_plan_pallet pp
JOIN ll_inventory_position pos ON pos.pallet_id=pp.warehouse_pallet_id AND pos.customer_id=pp.customer_id
JOIN ll_inventory_balance b ON b.id=pos.balance_id AND b.customer_id=pos.customer_id
JOIN ll_order o ON o.customer_id=b.customer_id AND o.inbound_code=b.inbound_code
WHERE pp.id=#{pallet} AND pp.customer_id=#{customer}
AND COALESCE(pos.available_packages,0)+COALESCE(pos.allocated_packages,0)+COALESCE(pos.picked_packages,0)>0
GROUP BY o.id,o.customer_id,o.destination_type,o.fba_warehouse_code
""")
List<Physical> receivedPhysical(@Param("pallet") long pallet,@Param("customer") String customer);
@Select("SELECT a.order_id,a.customer_id,CASE WHEN i.destination_type='PRIVATE' THEN CONCAT('CUSTOMER:',a.order_id) ELSE CONCAT('FBA:',i.fba_warehouse_code) END destination_key,NULL planned_packages,SUM(a.packages) counted_packages FROM ll_dispatch_pda_pick_order a JOIN ll_dispatch_pda_pick p ON p.id=a.pick_id AND p.customer_id=a.customer_id JOIN ll_dispatch_item i ON i.id=p.dispatch_item_id AND i.customer_id=p.customer_id WHERE i.id=#{item} AND a.customer_id=#{customer} GROUP BY a.order_id,a.customer_id,i.destination_type,i.fba_warehouse_code")
List<Physical> pickedPhysical(@Param("item") long item,@Param("customer") String customer);
@Select("SELECT * FROM ll_inbound_fba_appointment WHERE id=#{id} FOR UPDATE")
@@ -115,5 +150,14 @@ public interface AppointmentWorkDispatchMapper {
@Select("SELECT DISTINCT t.trip_id FROM ll_dispatch_appointment_allocation a JOIN ll_fleet_trip_dispatch t ON t.dispatch_id=a.dispatch_id JOIN ll_dispatch_order d ON d.id=a.dispatch_id AND d.status<>'CANCELLED' WHERE a.batch_key=#{batch}") List<Long> trips(String batch);
@Select("SELECT b.dispatch_id,b.dispatch_item_id item_id,b.plan_pallet_id pallet_id,b.batch_key,b.customer_id,b.source_key,b.destination_key,b.package_count packages FROM ll_dispatch_appointment_allocation b JOIN ll_dispatch_order d ON d.id=b.dispatch_id WHERE d.dispatch_no=#{number}")
List<Bound> bindings(String number);
/** 实收只改原绑定箱数,原客户、实物、来源及预约批次必须全部一致。 */
@Update("""
UPDATE ll_dispatch_appointment_allocation SET package_count=#{actual}
WHERE dispatch_id=#{row.dispatchId} AND dispatch_item_id=#{row.itemId} AND plan_pallet_id=#{row.palletId}
AND batch_key=#{row.batchKey} AND customer_id=#{row.customerId} AND source_key=#{row.sourceKey}
AND destination_key=#{row.destinationKey} AND package_count=#{row.packages}
""")
int receiveQuantity(@Param("row") Bound original,@Param("actual") int actual);
@Select("SELECT dispatch_id,dispatch_item_id item_id,plan_pallet_id pallet_id,batch_key,customer_id,source_key,destination_key,package_count packages FROM ll_dispatch_appointment_allocation WHERE dispatch_id=#{id}") List<Bound> byDispatch(long id);
}
@@ -27,6 +27,12 @@ public interface InventoryBalanceMapper extends BaseMapper<InventoryBalance> {
WHERE c.customer_id=#{customerId} AND t.inbound_order_id=#{inboundOrderId}
AND t.operating_warehouse_id=#{warehouseId} AND c.status='COUNTED'
AND (c.carton_mark=#{cartonMark} OR c.customer_carton_mark=#{cartonMark})
UNION ALL
SELECT 1 FROM ll_dropship_carton c
JOIN ll_order o ON o.id=c.order_id AND o.customer_id=c.customer_id
JOIN ll_inbound_order i ON i.customer_id=o.customer_id AND i.inbound_code=o.inbound_code
WHERE c.customer_id=#{customerId} AND i.id=#{inboundOrderId}
AND o.overseas_warehouse_id=#{warehouseId} AND c.carton_mark=#{cartonMark}
)
""")
boolean cartonBelongsToInbound(@Param("customerId") String customerId,
@@ -103,6 +103,7 @@ public class VehiclePersistenceAdapter implements VehicleRepository {
.set(VehicleRecord::getUpdatedAt, updatedAt).setSql("record_version = record_version + 1")) == 1;
}
/** 可用性排除在数据库分页前执行,保持候选总数与结果一致。 */
private LambdaQueryWrapper<VehicleRecord> wrapper(SearchCriteria criteria) {
return new LambdaQueryWrapper<VehicleRecord>()
.and(StringUtils.hasText(criteria.keyword()), wrapper -> wrapper.like(VehicleRecord::getPlateNumber, criteria.keyword())
@@ -111,6 +112,7 @@ public class VehiclePersistenceAdapter implements VehicleRepository {
.eq(criteria.ownership() != null, VehicleRecord::getOwnership, criteria.ownership())
.eq(criteria.region() != null, VehicleRecord::getRegion, criteria.region())
.eq(criteria.status() != null, VehicleRecord::getStatus, criteria.status())
.notIn(criteria.excludedIds()!=null && !criteria.excludedIds().isEmpty(), VehicleRecord::getId, criteria.excludedIds())
.orderByDesc(VehicleRecord::getUpdatedAt).orderByDesc(VehicleRecord::getId);
}
@@ -100,11 +100,13 @@ public class VehicleApplicationService {
return detail(id);
}
/** 候选资格在分页前排除真实占用,不按显示文案推断可用。 */
private SearchCriteria criteria(VehicleModels.Query query) {
return new SearchCriteria(query.page(), query.size(), trim(query.keyword()),
optional(query.ownership(), OWNERSHIPS, "车辆归属"),
optional(query.region(), REGIONS, "车辆区域"),
optional(query.status(), STATUSES, "车辆状态"));
optional(query.status(), STATUSES, "车辆状态"),
trim(query.dispatchNo())==null?java.util.List.of():assignments.unavailableForDispatch(trim(query.dispatchNo())));
}
private VehicleSnapshot required(long id) {
@@ -15,7 +15,7 @@ public final class VehicleModels {
private VehicleModels() { }
public record Query(@Min(1) Integer page, @Min(1) @Max(200) Integer size,
@Size(max = 128) String keyword, String ownership, String region, String status) {
@Size(max = 128) String keyword, String ownership, String region, String status, @Size(max = 64) String dispatchNo) {
public Query { page = page == null ? 1 : page; size = size == null ? 20 : size; }
}
@@ -3,4 +3,6 @@ package com.baiyue.lastleg.masterdata.vehicle.application.port.out;
/** Called while the vehicle row is locked; fleet owns the active assignment. */
public interface VehicleAssignmentGuard {
void requireUnassigned(long vehicleId);
/** 排车候选排除其他车次的真实占用,本车已选车辆仍可维护。 */
java.util.List<Long> unavailableForDispatch(String dispatchNo);
}
@@ -20,7 +20,7 @@ public interface VehicleRepository {
boolean updateStatus(long id, long expectedVersion, String status, String operator, LocalDateTime updatedAt);
record SearchCriteria(int page, int size, String keyword, String ownership, String region, String status) { }
record SearchCriteria(int page, int size, String keyword, String ownership, String region, String status, List<Long> excludedIds) { }
record PageSlice(List<VehicleSnapshot> records, long page, long size, long total, long pages) { }
@@ -6,7 +6,7 @@ import java.util.List;
/** 换标域向排车提供原始组成事实,不读取排车账本或代替排车锁定库存。 */
public interface RelabelDispatchSourcePort {
/** 当前作业轮次的逐件成功或异常事实;未知件数保持空值。 */
record UnitFact(Long id, String oldLabel, String newLabel, String result, Integer quantity) { }
record UnitFact(Long id, String oldLabel, String newLabel, String result, Integer quantity, String cartonMark) { }
/** 原申请身份、内部作业状态、履约订单及客户确认轮次。 */
record Application(Long applicationId, String relabelNo, String customerId,
@@ -859,7 +859,8 @@ public class RelabelApplicationService implements WalletResultHandler {
if ("COMPLETED".equals(application.getStatus())) return;
if (!READY_FOR_OUTBOUND.equals(application.getStatus())
|| !"OUTBOUNDED".equals(application.getFulfillmentStatus())
|| !Set.of("FROZEN", "NOT_REQUIRED").contains(application.getWalletStatus())) {
// PDA 完工已发起结算;转运收货只结束履约,不能拒绝已结算的原单或再次扣款。
|| !Set.of("FROZEN", "NOT_REQUIRED", "SETTLE_PENDING", "SETTLED").contains(application.getWalletStatus())) {
throw conflict("本票换标尚未完成出库或资金冻结");
}
List<LastlegOrder> linkedOrders = tailOrders(application);
@@ -168,7 +168,7 @@ public class RelabelDispatchSourceService implements RelabelDispatchSourcePort {
item.getSourceInboundOrderId(), item.getSourceInboundOrderNo(), item.getSourceInboundCode(),
notified, bound ? notified : 0, productQuantity(item, itemFacts, histories, round), basis,
itemFacts.stream().map(f -> new UnitFact(f.getId(), f.getOldLabel(), f.getNewLabel(),
f.getResult(), f.getQuantity())).toList()));
f.getResult(), f.getQuantity(), "SKU_LABEL".equals(item.getOperationType()) ? f.getOldLabel() : f.getNewLabel())).toList()));
}
if (rows.items().isEmpty()) reasons.add(app.getRelabelNo() + ":申请明细为空");
if (facts.stream().anyMatch(f -> rows.items().stream().noneMatch(i -> Objects.equals(i.getId(), f.getItemId()))))
@@ -25,6 +25,7 @@ public class ReturnRelabelPackingService {
@Resource private com.baiyue.lastleg.relabel.pda.mapper.PdaRelabelTaskMapper tasks;
@Resource private com.fasterxml.jackson.databind.ObjectMapper json;
/** 换标任务锁定来源后生成当前库存快照,随精确换标数量提交合箱。 */
@org.springframework.transaction.annotation.Transactional
public ReturnInventoryResponses.MergeResult pack(PdaWarehouseIdentity identity, RelabelApplication app,
List<RelabelItem> selected, ReturnInventoryRequests.BoxMerge request) {
@@ -37,17 +38,21 @@ public class ReturnRelabelPackingService {
if (request.sourceDeductions() != null && !totals(deductions).equals(totals(request.sourceDeductions())))
throw conflict("合箱数量必须等于本次已完成换标的明细数量");
var inheritedTransfers = new ArrayList<Map<String, Object>>();
var expectedSourceQuantities = new ArrayList<ReturnInventoryRequests.SourceQuantity>();
for (String mark : marks) {
var source = cartons.lockMark(mark);
if (source == null || !app.getCustomerId().equals(source.getCustomerId())
|| !app.getId().equals(source.getRelabelApplicationId()) || !"RELABEL_PENDING".equals(source.getStatus()))
throw conflict("来源包裹/箱已变化,请刷新");
var sourceContents = cartonItems.lockCarton(source.getId());
sourceContents.forEach(line -> expectedSourceQuantities.add(
new ReturnInventoryRequests.SourceQuantity(mark, line.getSkuCode(), line.getQuantity())));
var prior = items.priorCompletedReturnItems(app.getCustomerId(), source.getId(), app.getId());
if (!prior.isEmpty()) {
var ids = prior.stream().map(i -> i.getId().toString()).toList();
var raw = inheritedQuantities(prior);
var physical = new HashMap<String, Long>();
cartonItems.lockCarton(source.getId()).forEach(i -> physical.merge(i.getSkuCode().toUpperCase(Locale.ROOT), i.getQuantity(), Math::addExact));
sourceContents.forEach(i -> physical.merge(i.getSkuCode().toUpperCase(Locale.ROOT), i.getQuantity(), Math::addExact));
var moved = new ArrayList<Map<String, Object>>();
for (var entry : totals(deductions).entrySet()) {
if (!mark.equals(entry.getKey().get(0))) continue;
@@ -66,7 +71,7 @@ public class ReturnRelabelPackingService {
}
var result = inventory.mergeBoxes(identity, new ReturnInventoryRequests.BoxMerge(marks, null,
"RELABEL-PACK:" + app.getId() + ":" + request.idempotencyKey(), deductions,
request.length(), request.width(), request.height(), request.weight()));
request.length(), request.width(), request.height(), request.weight(), expectedSourceQuantities));
for (String mark : marks) {
var source = cartons.lockMark(mark);
cartons.clearRelabel(source.getId());
@@ -3,7 +3,9 @@ package com.baiyue.lastleg.returnreceipt.dto;
import jakarta.validation.Valid;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotEmpty;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Positive;
import jakarta.validation.constraints.PositiveOrZero;
import jakarta.validation.constraints.Size;
import java.util.List;
@@ -25,20 +27,8 @@ public final class PdaReturnReceiptRequests {
@Size(max = 16) String unitType,
@Size(max = 128) String unitCode,
BigDecimal length, BigDecimal width, BigDecimal height, BigDecimal weight,
Long totalBoxCount, Boolean originalPackaging) {
public Confirm(String returnNo, String trackingNo, String startedBy, String palletCode,
List<@Valid Item> items, String idempotencyKey) {
this(returnNo, trackingNo, startedBy, palletCode, items, idempotencyKey,
null, null, null, null, null, null, null, null);
}
public Confirm(String returnNo, String trackingNo, String startedBy, String palletCode,
List<@Valid Item> items, String idempotencyKey, String unitType, String unitCode,
BigDecimal length, BigDecimal width, BigDecimal height, BigDecimal weight,
Long totalBoxCount) {
this(returnNo, trackingNo, startedBy, palletCode, items, idempotencyKey, unitType, unitCode,
length, width, height, weight, totalBoxCount, null);
}
}
Long totalBoxCount, Boolean originalPackaging,
@NotNull @PositiveOrZero Long expectedReceivedQuantity) { }
public record Item(@NotBlank @Size(max = 128) String skuCode,
@com.fasterxml.jackson.databind.annotation.JsonDeserialize(using = ReturnQuantityDeserializer.class)
@Positive long receivedQuantity,
@@ -73,6 +73,8 @@ public final class ReturnInventoryRequests {
public PackingPlanComplete(List<PlanBox> boxes, String idempotencyKey) { this(boxes, idempotencyKey, null); }
}
public record SourceDeduction(@NotBlank String cartonMark, @NotBlank String skuCode, @Positive long quantity) { }
/** 扫码时来源箱各 SKU 的库存快照,用于拒绝过期页面重复扣减。 */
public record SourceQuantity(@NotBlank String cartonMark, @NotBlank String skuCode, @Min(0) long quantity) { }
public record PackingExecution(@NotEmpty @Size(max = 100) List<@NotBlank String> returnNos,
@NotEmpty @Size(max = 100) List<@Valid PlanBox> boxes,
@@ -99,9 +101,6 @@ public final class ReturnInventoryRequests {
@NotBlank @Size(max = 255) String idempotencyKey,
@Size(max = 1000) List<@Valid SourceDeduction> sourceDeductions,
@Positive BigDecimal length, @Positive BigDecimal width,
@Positive BigDecimal height, @Positive BigDecimal weight) {
public BoxMerge(List<String> sourceCartonMarks, String targetCartonMark, String idempotencyKey) {
this(sourceCartonMarks, targetCartonMark, idempotencyKey, null, null, null, null, null);
}
}
@Positive BigDecimal height, @Positive BigDecimal weight,
@Size(max = 1000) List<@NotNull @Valid SourceQuantity> expectedSourceQuantities) { }
}
@@ -928,16 +928,26 @@ public class ReturnInventoryService {
return cartonDetail(carton);
}
/** 校验全部来源箱的扫码库存快照,再执行单事务合箱。 */
@Transactional
public ReturnInventoryResponses.MergeResult mergeBoxes(PdaWarehouseIdentity identity, ReturnInventoryRequests.BoxMerge request) {
// Bind replay to the complete scan command before looking up emptied sources.
// 同键结果先返回;新键必须在来源行锁内校验扫码库存,避免重复扣减。
ReturnInventoryOperation replay = operations.lockKey(identity.operatorKey(), request.idempotencyKey());
String digest = hash(request);
if (replay != null) return replayMerge(replay, digest);
if (request.expectedSourceQuantities() == null || request.expectedSourceQuantities().isEmpty())
throw badRequest("缺少来源箱库存快照,请重新扫描来源箱");
Map<List<String>, Long> expectedQuantities = new LinkedHashMap<>();
for (var snapshot : request.expectedSourceQuantities()) {
if (snapshot == null || snapshot.quantity() < 0 || !StringUtils.hasText(snapshot.skuCode()) || !request.sourceCartonMarks().contains(snapshot.cartonMark())
|| expectedQuantities.putIfAbsent(List.of(snapshot.cartonMark(), snapshot.skuCode()), snapshot.quantity()) != null)
throw badRequest("来源箱库存快照无效,请重新扫描来源箱");
}
java.util.stream.Stream.concat(request.sourceCartonMarks().stream(), java.util.stream.Stream.of(request.targetCartonMark()))
.filter(StringUtils::hasText).distinct().sorted().forEach(cartons::lockMark);
Map<String, Map<String, Long>> limits = new LinkedHashMap<>();
Map<String, Long> quantities = new LinkedHashMap<>();
Map<List<String>, Long> currentQuantities = new LinkedHashMap<>();
LinkedHashSet<String> returnNos = new LinkedHashSet<>();
String customer = null;
for (String mark : request.sourceCartonMarks().stream().distinct().sorted().toList()) {
@@ -950,6 +960,7 @@ public class ReturnInventoryService {
customer = carton.getCustomerId(); returnNos.addAll(java.util.Arrays.asList(required(carton.getSourceReturnNo(), "来源退货单缺失").split(",")));
Map<String, Long> items = new LinkedHashMap<>();
for (var item : cartonItems.lockCarton(carton.getId())) {
currentQuantities.put(List.of(mark, item.getSkuCode()), value(item.getQuantity()));
if (value(item.getPackingReservedQuantity()) > 0) throw conflict("来源箱已被合箱计划占用");
long selected = request.sourceDeductions() == null ? value(item.getQuantity()) : request.sourceDeductions().stream()
.filter(d -> mark.equals(d.cartonMark()) && item.getSkuCode().equals(d.skuCode())).mapToLong(ReturnInventoryRequests.SourceDeduction::quantity).sum();
@@ -958,6 +969,7 @@ public class ReturnInventoryService {
}
limits.put(mark, items);
}
if (!expectedQuantities.equals(currentQuantities)) throw conflict("来源箱库存已变化,请重新扫描来源箱后操作");
if(request.sourceDeductions() != null && request.sourceDeductions().stream().anyMatch(d -> !limits.containsKey(d.cartonMark()) || !limits.get(d.cartonMark()).containsKey(d.skuCode()))) throw badRequest("扣除明细不属于已扫描来源箱");
String targetMark = text(request.targetCartonMark());
if (targetMark != null && request.sourceCartonMarks().stream().anyMatch(mark ->
@@ -571,7 +571,7 @@ public class ReturnReceiptService {
return detail(order.getCustomerId(), order.getReturnNo());
}
/** 确认退货实收,新增实物箱码沿用原编码,不使用单据日流水。 */
/** 锁定退货单并校验提交前实收量,避免响应丢失后换新请求重复入账。 */
@Transactional
public PdaReturnReceiptResponses.Confirmed confirm(PdaWarehouseIdentity identity, PdaReturnReceiptRequests.Confirm request) {
String key = text(request.idempotencyKey());
@@ -591,8 +591,7 @@ public class ReturnReceiptService {
if (order == null || !String.valueOf(identity.overseasWarehouseId()).equals(order.getOverseasWarehouseId())) {
throw notFound("当前绑定仓没有该退货单");
}
// Lock the order before any consistent read: a snapshot opened before
// waiting would hide the winner's cartons when rendering replay labels.
// 先锁订单再读取幂等记录,避免等待锁前的快照遗漏已提交的实物箱。
ReturnReceipt concurrentReplay = receipts.lockKey(identity.operatorKey(), key);
if (concurrentReplay != null) {
if (!requestHash.equals(concurrentReplay.getRequestHash())) throw conflict("幂等键已用于不同的签收请求");
@@ -602,6 +601,13 @@ public class ReturnReceiptService {
throw conflict("仅待签收或签收中的退货单允许 PDA 签收");
}
List<ReturnOrderItem> lockedItems = orderItems.lockOrder(order.getId());
if (request.expectedReceivedQuantity() == null || request.expectedReceivedQuantity() < 0) {
throw badRequest("必须提供提交前的退货单实收数量");
}
long currentReceivedQuantity = lockedItems.stream().mapToLong(item -> value(item.getReceivedQuantity())).sum();
if (request.expectedReceivedQuantity() != currentReceivedQuantity) {
throw conflict("退货单实收数量已变化,请刷新退货单后重新操作");
}
Map<String, Long> receivedBySku = aggregate(request.items());
Map<String, Long> boxCountBySku = request.items().stream().filter(item -> item.boxCount() != null)
.collect(java.util.stream.Collectors.toMap(item -> text(item.skuCode()), PdaReturnReceiptRequests.Item::boxCount, Math::addExact, LinkedHashMap::new));
@@ -109,21 +109,26 @@ public class LastlegOrderService {
if (query.sailingStage() == null && relabelNo == null) return aggregateAllPage(customerId, query);
Page<LastlegOrder> page = orderMapper.selectPage(
new Page<>(query.page(), query.size()), filter(customerId, query, relabelNo).orderByDesc(LastlegOrder::getCreatedAt).orderByDesc(LastlegOrder::getId));
Map<Long, List<LastlegOrderMapper.PalletMergeRelationRow>> mergeRelations = palletMergeRelations(page.getRecords());
returnOrderReferences.populateForRead(page.getRecords());
refreshReturnVolumes(page.getRecords());
Map<String, String> containerNodes = containerNodes(page.getRecords());
Map<Long, String> dispatchStatuses = dispatchStatuses(page.getRecords());
Map<Long, LastlegOrderMapper.OrderWeightRow> weights = orderWeights(page.getRecords());
Map<Long, List<com.baiyue.lastleg.dto.RelabelSubOrder>> relabels = relabelSubOrders(page.getRecords());
Map<Long, LastlegOrderSummary.BusinessSource> businessSources = businessSources(page.getRecords());
var quantities = transferQuantities.forOrders(page.getRecords());
return new PageResult<>(page.getRecords().stream().map(order -> summary(order, mergeRelations.get(order.getId()),
return new PageResult<>(nativeSummaries(page.getRecords()),
page.getCurrent(), page.getSize(), page.getTotal(), page.getPages());
}
/** 全部及已开船列表使用同一组业务事实,避免聚合列表丢失并托、排车和换标信息。 */
private List<LastlegOrderSummary> nativeSummaries(List<LastlegOrder> orders) {
Map<Long, List<LastlegOrderMapper.PalletMergeRelationRow>> mergeRelations = palletMergeRelations(orders);
returnOrderReferences.populateForRead(orders);
refreshReturnVolumes(orders);
Map<String, String> containerNodes = containerNodes(orders);
Map<Long, String> dispatchStatuses = dispatchStatuses(orders);
Map<Long, LastlegOrderMapper.OrderWeightRow> weights = orderWeights(orders);
Map<Long, List<com.baiyue.lastleg.dto.RelabelSubOrder>> relabels = relabelSubOrders(orders);
Map<Long, LastlegOrderSummary.BusinessSource> businessSources = businessSources(orders);
var quantities = transferQuantities.forOrders(orders);
return orders.stream().map(order -> summary(order, mergeRelations.get(order.getId()),
StringUtils.hasText(order.getSourceContainerBookingId())
? containerNodes.get(order.getSourceContainerBookingId()) : null,
dispatchStatuses.getOrDefault(order.getId(), "UNSCHEDULED"), weights.get(order.getId()),
relabels.getOrDefault(order.getId(), List.of()), businessSources.get(order.getId()), quantities.get(order.getId()))).toList(),
page.getCurrent(), page.getSize(), page.getTotal(), page.getPages());
relabels.getOrDefault(order.getId(), List.of()), businessSources.get(order.getId()), quantities.get(order.getId()))).toList();
}
/** 全部页签合并订单服务的未开船头程数据与本服务已有尾程单,尾程记录优先覆盖同一来源订单。 */
@@ -132,18 +137,14 @@ public class LastlegOrderService {
if (source == null || source.data() == null) throw new ResponseStatusException(HttpStatus.SERVICE_UNAVAILABLE, "订单服务暂时不可用");
List<LastlegOrder> existing = orderMapper.selectList(filter(customerId, query, null)
.orderByDesc(LastlegOrder::getCreatedAt).orderByDesc(LastlegOrder::getId));
Map<String, String> existingNodes = containerNodes(existing);
Map<Long, LastlegOrderMapper.OrderWeightRow> existingWeights = orderWeights(existing);
Map<String, LastlegOrderSummary> merged = new LinkedHashMap<>();
source.data().records().forEach(row -> {
var summary = preDepartureSummary(row);
merged.put(orderKey(summary.customerId(), summary.sourceOrderNo()), summary);
});
for (LastlegOrder order : existing) {
String key = orderKey(order.getCustomerId(), StringUtils.hasText(order.getSourceOrderNo()) ? order.getSourceOrderNo() : order.getOrderNo());
merged.put(key, summary(order, List.of(), StringUtils.hasText(order.getSourceContainerBookingId())
? existingNodes.get(order.getSourceContainerBookingId()) : null, "UNSCHEDULED",
existingWeights.get(order.getId()), List.of(), null, null));
for (LastlegOrderSummary order : nativeSummaries(existing)) {
String key = orderKey(order.customerId(), StringUtils.hasText(order.sourceOrderNo()) ? order.sourceOrderNo() : order.orderNo());
merged.put(key, order);
}
List<LastlegOrderSummary> rows = merged.values().stream().sorted(java.util.Comparator.comparing(LastlegOrderSummary::createdAt,
java.util.Comparator.nullsLast(java.util.Comparator.reverseOrder()))).toList();
@@ -214,6 +214,16 @@ public class WarehouseTransferService {
@Transactional
public WarehouseTransferResponses.Detail create(String customerId, String operatorId,
WarehouseTransferRequests.Create request) {
// 已成功创建的命令先恢复原业务身份;不能用后来冻结或换标后的箱状态否定重放。
String key = request.idempotencyKey().trim();
String requestHash = hash(request);
WarehouseTransferOrder replay = transfers.findKey(customerId, key);
if (replay != null) {
if (!requestHash.equals(replay.getRequestHash())) {
throw conflict("Idempotency key was reused with different warehouse transfer data");
}
return detail(customerId, replay.getTransferNo());
}
if (!request.isInboundModeValid()) throw badRequest("外来货物须填写入仓信息,库存转运须选择FBA仓或私人仓");
int itemPackages = request.items().stream().mapToInt(WarehouseTransferRequests.Item::packageCount).sum();
if (itemPackages != request.packageCount()) {
@@ -224,16 +234,6 @@ public class WarehouseTransferService {
validateDestination(request.destinationType(), request.destinationWarehouseCode(), request.fbaWarehouseCode(),
request.privateDestinationAddress(), request.privateDestinationPostalCode(),
request.privateDestinationContactName(), request.privateDestinationContactPhone());
String key = request.idempotencyKey().trim();
String requestHash = hash(request);
WarehouseTransferOrder replay = transfers.findKey(customerId, key);
if (replay != null) {
if (!requestHash.equals(replay.getRequestHash())) {
throw conflict("Idempotency key was reused with different warehouse transfer data");
}
return detail(customerId, replay.getTransferNo());
}
var customer = customerDirectory.find(customerId);
if (customer == null || !customerId.equals(customer.id()) || !StringUtils.hasText(customer.name())) {
throw new ResponseStatusException(HttpStatus.SERVICE_UNAVAILABLE, "客户名称暂不可用,请稍后重试");
@@ -359,7 +359,7 @@ components:
schemas:
TripCalendarEntry:
type: object
required: [sourceType, dispatchNo, status, plannedDepartureAt, expectedDriverEndAt, stops, containerNumbers, incompleteReasons]
required: [sourceType, dispatchNo, status, plannedDepartureAt, expectedDriverEndAt, stops, containerNumbers, cabinetNumbers, incompleteReasons]
properties:
sourceType: {type: string, enum: [DISPATCH]}
dispatchNo: {type: string, description: 真实整车主单排车号,用于排车详情路由}
@@ -380,6 +380,11 @@ components:
description: 本车全部货物正式来源快照中的真实柜号,去空去重;多客户、多柜保留完整数组,无柜号为空数组,不推测或补造。
items: {type: string}
uniqueItems: true
cabinetNumbers:
type: array
description: 本车货物通过同客户真实尾程订单关联的原装柜事件中的柜次短号(如D1821),去空去重;无装柜来源为空数组,不用完整柜号或内部ID推测。
items: {type: string}
uniqueItems: true
incompleteReasons:
type: array
items: {type: string, enum: [ORIGIN_WAREHOUSE_UNKNOWN, ORIGIN_COUNTRY_UNKNOWN, ORIGIN_TIMEZONE_UNKNOWN, ROUTE_INCOMPLETE]}
@@ -1798,6 +1798,53 @@ paths:
required: true
schema:
type: string
/api/platform/v1/last-leg/dispatches/{dispatchNo}/appointment-allocations:
parameters:
- name: dispatchNo
in: path
required: true
schema:
type: string
get:
summary: 读取本车逐票正式预约选项及当前分配
description: 权限 platform:overseas-warehouse:dispatch:list。只读本车真实货物;已确认时段和覆盖来自正式收货预约。本车原占用不扣减本车可重分配量,其他有效车辆占用仍扣减。
security:
- bearerAuth: []
responses:
'200':
description: 统一ApiResponse;业务失败通过code返回。
content:
application/json:
schema:
type: object
properties:
code: {type: string}
message: {type: string}
data:
$ref: '#/components/schemas/DispatchAppointmentAllocations'
put:
summary: 在原排车关联或调整正式预约分配
description: 权限 platform:overseas-warehouse:dispatch:edit。主单及原批次版本必填,0合法;每个触及实物必须按客户/来源/目的仓完整分配。仅装车前零真实扫码且无有效下游单据,成功增加主版本并使两级审核失效,不新建排车、不改变货物或库存。失败原子回滚;同键同载荷恢复原结果,同键异载荷拒绝。
security:
- bearerAuth: []
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/DispatchAppointmentAllocationCommand'
responses:
'200':
description: 统一ApiResponse;业务失败通过code返回。
content:
application/json:
schema:
type: object
properties:
code: {type: string}
message: {type: string}
data:
$ref: '#/components/schemas/DispatchCommandResult'
/api/platform/v1/last-leg/dispatches/{dispatchNo}/route:
get:
summary: 读取实际起点与既有卸货站序
@@ -3182,6 +3229,13 @@ paths:
security: [{ bearerAuth: [] }]
requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/ReturnInventoryMerge' } } } }
responses: { '200': { description: 新箱号、SKU 汇总数量及可打印箱唛 }, '409': { description: 箱状态、客户或 SKU 不满足合箱条件,或幂等键冲突 } }
/api/pda/v1/last-leg/return-inventory/cartons/merge-boxes:
post:
summary: PDA 按实物来源箱合箱
description: 同一客户、当前绑定仓内合箱;新请求必须携带扫码时全部来源箱 SKU 数量,在来源行锁内校验,过期返回冲突,重新扫描后操作。
security: [{ bearerAuth: [] }]
requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/ReturnInventoryBoxMerge' } } } }
responses: { '200': { description: 合箱实物箱号与标签 }, '400': { description: 缺少或无效来源库存快照 }, '409': { description: 来源库存或资格已变化,或幂等键冲突 } }
/api/pda/v1/last-leg/return-inventory/packing/orders:
get:
summary: PDA 查询可合箱退货单和 SKU 库存
@@ -3379,8 +3433,9 @@ components:
expectedQuantity: { type: integer, minimum: 1 }
ReturnReceiptConfirm:
type: object
required: [returnNo, startedBy, items, idempotencyKey]
required: [returnNo, startedBy, items, idempotencyKey, expectedReceivedQuantity]
properties:
expectedReceivedQuantity: { type: integer, format: int64, minimum: 0, description: '提交前退货单全部 SKU 的累计实收数量;在订单锁内校验,过期返回冲突,刷新后重新操作' }
returnNo: { type: string, maxLength: 128 }
trackingNo: { type: string, nullable: true, maxLength: 128, description: '本次签收包裹有快递面单时扫描绑定;没有时为空' }
startedBy: { type: string, enum: [RETURN_NO, SKU], description: '本次通过退货单号查询或 SKU 候选开始签收' }
@@ -3403,6 +3458,39 @@ components:
skuCode: { type: string, maxLength: 128 }
receivedQuantity: { type: integer, minimum: 1 }
boxCount: { type: integer, nullable: true, minimum: 1, description: 托盘或箱子签收时该 SKU 对应箱数,包裹签收不填 }
ReturnInventoryBoxMerge:
type: object
required: [sourceCartonMarks, idempotencyKey, expectedSourceQuantities]
properties:
sourceCartonMarks: { type: array, minItems: 1, maxItems: 100, items: { type: string } }
targetCartonMark: { type: string, nullable: true, description: 不填时生成新箱 }
idempotencyKey: { type: string, maxLength: 255 }
expectedSourceQuantities:
type: array
minItems: 1
maxItems: 1000
description: 扫码时全部来源箱各 SKU 的数量,必须完整且不能重复
items:
type: object
required: [cartonMark, skuCode, quantity]
properties:
cartonMark: { type: string }
skuCode: { type: string }
quantity: { type: integer, format: int64, minimum: 0 }
sourceDeductions:
type: array
maxItems: 1000
items:
type: object
required: [cartonMark, skuCode, quantity]
properties:
cartonMark: { type: string }
skuCode: { type: string }
quantity: { type: integer, format: int64, minimum: 1 }
length: { type: number, minimum: 0, exclusiveMinimum: true }
width: { type: number, minimum: 0, exclusiveMinimum: true }
height: { type: number, minimum: 0, exclusiveMinimum: true }
weight: { type: number, minimum: 0, exclusiveMinimum: true }
ReturnInventoryMerge:
type: object
required: [returnNos, items, length, width, height, weight, idempotencyKey]
@@ -5400,7 +5488,7 @@ components:
DispatchMaintenance:
type: object
x-java-record: DispatchContracts.Maintenance
description: 保存于排车resourcesJson.maintenance的唯一正式资料结构,不新增费用金额或审核门禁。
description: 保存于排车resourcesJson.maintenance;预约按本车目的仓登记,保存可暂缺,提交审核及后续执行须填写完整。
properties:
plannedDeliveryDate:
type: string
@@ -5430,10 +5518,18 @@ components:
type: string
maxLength: 128
nullable: true
appointmentNo:
type: string
maxLength: 128
nullable: true
destinationAppointments:
type: array
maxItems: 500
items: { $ref: '#/components/schemas/DispatchDestinationAppointment' }
DispatchDestinationAppointment:
type: object
x-java-record: DispatchContracts.DestinationAppointment
description: 同一排车同一FBA目的仓仅一个预约号,空白可暂存。
required: [destinationKey, appointmentNo]
properties:
destinationKey: { type: string, pattern: '^FBA:.+', maxLength: 256 }
appointmentNo: { type: string, maxLength: 128, nullable: true }
DispatchResources:
type: object
x-java-record: DispatchContracts.Resources
@@ -5927,7 +6023,7 @@ components:
required:
- idempotencyKey
- warehouseId
description: 同一起运仓,允许空单或多客户、多目的仓。客户和来源归属由服务端解析,临时资源不生成假档案。
description: 同一起运仓,允许空单或多客户、多目的仓。普通入仓FBA货物须先取得覆盖本客户、来源和箱数的正式预约时间才能选入,不能等提交审核才检查;换标和退货独立来源保留原流程。客户和来源归属由服务端解析,临时资源不生成假档案。
DispatchAddCargo:
type: object
x-java-record: DispatchCommands.AddCargo
@@ -6316,6 +6412,14 @@ components:
type: object
x-java-record: DispatchViews.ListRow
properties:
createdAt:
type: string
format: date-time
nullable: true
description: 排车单真实创建时间,取原排车主单created_at。
fbaAppointments:
type: array
items: { $ref: '#/components/schemas/DispatchFbaAppointment' }
dispatchNo:
type: string
nullable: true
@@ -6377,6 +6481,7 @@ components:
$ref: '#/components/schemas/DispatchGate'
nullable: true
required:
- createdAt
- dispatchNo
- status
- stage
@@ -6393,6 +6498,7 @@ components:
- pickingReconciled
- arranged
- actions
- fbaAppointments
DispatchVehicleInfo:
type: object
x-java-record: DispatchViews.VehicleInfo
@@ -6416,7 +6522,14 @@ components:
DispatchFbaAppointment:
type: object
x-java-record: DispatchViews.FbaAppointment
description: 按本车目的仓汇总的预约;唯一已约编号自动带入,人工登记不冒充正式确认时间。批次及客户在目的仓汇总行为空。
properties:
customerId:
type: string
nullable: true
shipmentNumbers:
type: array
items: { type: string }
batchKey:
type: string
nullable: true
@@ -6451,6 +6564,8 @@ components:
- status
- confirmedAt
- blockReason
- customerId
- shipmentNumbers
DispatchDispatchInfo:
type: object
x-java-record: DispatchViews.DispatchInfo
@@ -6951,6 +7066,94 @@ components:
destinationKey:
type: string
nullable: true
DispatchAppointmentAllocations:
type: object
x-java-record: DispatchWorkModels.AppointmentAllocations
required: [dispatchNo, recordVersion, items, edit]
properties:
dispatchNo: {type: string}
recordVersion:
type: string
pattern: ^[0-9]+$
items:
type: array
items:
$ref: '#/components/schemas/DispatchAppointmentItem'
edit:
$ref: '#/components/schemas/DispatchGate'
DispatchAppointmentItem:
type: object
x-java-record: DispatchWorkModels.AppointmentItem
required: [dispatchItemId, planPalletId, customerId, palletNo, sources, allocations]
properties:
dispatchItemId: {type: string}
planPalletId: {type: string}
customerId: {type: string}
customerName: {type: string, nullable: true}
palletNo: {type: string}
sources:
type: array
items:
$ref: '#/components/schemas/DispatchAppointmentSource'
allocations:
type: array
items:
$ref: '#/components/schemas/DispatchBatchAllocation'
DispatchAppointmentSource:
type: object
required: [sourceKey, destinationKey, options, blockReasons]
properties:
sourceKey: {type: string}
orderNo: {type: string, nullable: true}
inboundCode: {type: string, nullable: true}
destinationKey: {type: string}
packageCount: {type: integer, nullable: true, minimum: 1}
options:
type: array
items:
$ref: '#/components/schemas/DispatchAppointmentOption'
blockReasons:
type: array
items: {type: string}
DispatchAppointmentOption:
type: object
required: [batchKey, batchNo, recordVersion, availablePackages, appointmentAt, timezone]
properties:
batchKey: {type: string}
batchNo: {type: string}
recordVersion:
type: string
pattern: ^[0-9]+$
availablePackages: {type: integer, minimum: 1}
confirmationNo: {type: string, nullable: true}
appointmentAt:
type: string
description: 目的仓当地确认开始时间,须结合timezone解释,不按浏览器时区转换。
appointmentEndAt: {type: string, nullable: true}
timezone: {type: string}
DispatchAppointmentAllocationCommand:
type: object
x-java-record: DispatchWorkModels.AppointmentAllocationCommand
required: [recordVersion, idempotencyKey, dispatchItemIds, batchAllocations]
properties:
recordVersion:
oneOf:
- type: string
pattern: ^[0-9]+$
- type: integer
minimum: 0
idempotencyKey: {type: string}
dispatchItemIds:
type: array
minItems: 1
maxItems: 1000
uniqueItems: true
items: {type: string}
batchAllocations:
type: array
maxItems: 1000
items:
$ref: '#/components/schemas/DispatchBatchAllocation'
DispatchRoute:
type: object
x-java-record: DispatchWorkModels.Route
@@ -116,6 +116,7 @@ paths:
- { name: ownership, in: query, schema: { $ref: '#/components/schemas/VehicleOwnership' } }
- { name: region, in: query, schema: { $ref: '#/components/schemas/VehicleRegion' } }
- { name: status, in: query, schema: { $ref: '#/components/schemas/VehicleStatus' } }
- { name: dispatchNo, in: query, description: 排车维护候选,分页前排除其他车次实际占用,保留本车资源, schema: { type: string, maxLength: 64 } }
responses: { '200': { description: 业务响应,data为PageResult<Vehicle> } }
post:
summary: 创建车辆主数据
@@ -58,8 +58,13 @@ class Phase4BrowserApiBoundaryTest {
assertTrue(condition != null);
assertTrue(Arrays.asList(condition.name()).contains("lastleg.dispatch-query-api.enabled"));
assertFalse(condition.matchIfMissing());
assertTrue(Arrays.stream(DispatchQueryController.class.getDeclaredMethods())
.filter(method -> method.isAnnotationPresent(GetMapping.class)).count() == 3);
Set<String> readPaths = Arrays.stream(DispatchQueryController.class.getDeclaredMethods())
.filter(method -> method.isAnnotationPresent(GetMapping.class))
.flatMap(method -> {
String[] values = method.getAnnotation(GetMapping.class).value();
return values.length == 0 ? java.util.stream.Stream.of("") : Arrays.stream(values);
}).collect(Collectors.toSet());
assertEquals(Set.of("", "/capacity-demands", "/{dispatchNo}", "/{dispatchNo}/prints/bol"), readPaths);
assertFalse(Arrays.stream(DispatchQueryController.class.getDeclaredMethods())
.flatMap(method -> Arrays.stream(method.getAnnotations()))
.anyMatch(annotation -> Set.of("PostMapping", "PutMapping", "DeleteMapping")
@@ -1,6 +1,8 @@
package com.baiyue.lastleg.delivery;
import com.baiyue.lastleg.delivery.model.DeliveryCargoGroups;
import com.baiyue.lastleg.dispatch.port.DispatchContracts.CargoLine;
import com.baiyue.lastleg.dispatch.port.DispatchContracts.Destination;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.Test;
import java.util.List;
@@ -27,4 +29,24 @@ class DeliveryCargoGroupsTest {
assertThrows(org.springframework.web.server.ResponseStatusException.class,()->policy.groups(context(List.of(row,row),false)));
assertThrows(org.springframework.web.server.ResponseStatusException.class,()->policy.groups(context(List.of(row,cargo(2,"101","LAX9",51,3)),false)));
}
@Test void sameFbaCodeKeepsKnownWarehouseReferenceWhenIncomingCargoHasNoInternalId() {
var incoming=cargo(1,"101","ONT8",51,3);
var known=destination(cargo(2,"101","ONT8",52,4),new Destination("FBA:ONT8","FBA","900","ONT8","ONT8",null,null,null,null));
var group=policy.groups(context(List.of(known,incoming),false)).get(0);
assertEquals("900",group.destination().warehouseId());assertEquals(7,group.packages());
assertNull(group.cargo().get(0).destination().warehouseId(),"真实来源快照不被覆盖");
assertEquals(group,policy.groups(context(List.of(incoming,known),false)).get(0));
var conflicting=destination(incoming,new Destination("FBA:ONT8","FBA","901","ONT8","ONT8",null,null,null,null));
assertThrows(org.springframework.web.server.ResponseStatusException.class,()->policy.groups(context(List.of(known,conflicting),false)));
var wrongCode=destination(incoming,new Destination("FBA:ONT8","FBA",null,"LAX9","ONT8",null,null,null,null));
assertThrows(org.springframework.web.server.ResponseStatusException.class,()->policy.groups(context(List.of(known,wrongCode),false)));
var changedAddress=destination(incoming,new Destination("FBA:ONT8","FBA",null,"ONT8","ONT8","other",null,null,null));
assertThrows(org.springframework.web.server.ResponseStatusException.class,()->policy.groups(context(List.of(known,changedAddress),false)));
}
private CargoLine destination(CargoLine row,Destination destination) {
return new CargoLine(row.dispatchItemId(),row.customerId(),row.customerName(),row.orderId(),row.orderNo(),row.sourceKey(),row.sourceStage(),row.sourceRegion(),
row.sourceInboundId(),row.inboundCode(),row.planPalletId(),row.warehousePalletId(),row.palletNo(),row.pickId(),row.attributionId(),row.inventoryPositionId(),
row.receiptId(),row.returnReceiptId(),row.returnCartonId(),row.cartonMark(),row.relabelNo(),destination,row.packages(),row.productQuantity(),
row.grossWeightKg(),row.volumeCbm(),row.batchAllocations(),row.sourceOrigin());
}
}
@@ -123,7 +123,7 @@ class PlatformDispatchOutboundContractTest {
assertTrue(Arrays.stream(DispatchController.class.getDeclaredMethods())
.noneMatch(method -> Set.of("depart", "cancel").contains(method.getName())));
assertEquals(Set.of("recordVersion", "idempotencyKey", "vehicle", "driver",
"plannedDepartureAt", "estimatedArrivalAt", "remark", "publishLooseboardDemand"),
"plannedDepartureAt", "estimatedArrivalAt", "remark", "publishLooseboardDemand", "maintenance"),
Arrays.stream(DispatchCommands.Update.class.getRecordComponents())
.map(component -> component.getName()).collect(java.util.stream.Collectors.toSet()));
assertEquals(Set.of("recordVersion", "idempotencyKey", "reason"),
@@ -0,0 +1,62 @@
package com.baiyue.lastleg.dispatch.mapper;
import com.baomidou.mybatisplus.core.MybatisConfiguration;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.Test;
import org.springframework.jdbc.core.JdbcTemplate;
import java.util.Map;
import static org.assertj.core.api.Assertions.assertThat;
/** 核对分页、总数、分组统计及BOL接入同一正式编号查询;JSON函数适配仅用于隔离H2测试。 */
public class DispatchReceivingSqlTest {
/** 三种列表查询必须在SQL分页前使用当前客户确认,且保留非空人工编号的覆盖条件。 */
@Test void allListQueriesUseCurrentReceivingAuthorityBeforePagination() {
var config=new MybatisConfiguration();config.addMapper(DispatchAggregateMapper.class);
var query=new DispatchAggregateMapper.Search(null,null,null,null,"ISA-CURRENT","ALL",null,null,null,0,20);
for(var method:new String[]{"page","count","summaries"}) {
var bound=config.getMappedStatement(DispatchAggregateMapper.class.getName()+"."+method).getBoundSql(Map.of("q",query));
var sql=normalize(bound.getSql());
assertThat(sql).contains(normalize(DispatchReceivingSql.CONFIRMATION_NO)+" =?");
assertThat(sql).doesNotContain("ll_inbound_fba_appointment ap","ap.confirmation_no");
assertThat(sql).contains("saved.destination_key=a.destination_key AND NULLIF(TRIM(saved.appointment_no),'') IS NOT NULL");
assertThat(bound.getParameterMappings().stream().filter(p->"q.fbaAppointmentNo".equals(p.getProperty())).count()).isEqualTo(2);
if(method.equals("page"))assertThat(sql.indexOf("LIMIT")).isGreaterThan(sql.indexOf("ll_fba_receiving_member"));
}
}
/** BOL只替换编号来源,仍由原批次item核对客户、来源和订单映射。 */
@Test void bolUsesSameCurrentConfirmationAndKeepsSourceMapping() {
var config=new MybatisConfiguration();config.addMapper(DispatchBolMapper.class);
var sql=normalize(config.getMappedStatement(DispatchBolMapper.class.getName()+".findAppointmentAllocations")
.getBoundSql(Map.of("dispatchOrderId",1L)).getSql());
assertThat(sql).contains(normalize(DispatchReceivingSql.CONFIRMATION_NO)+" AS confirmationNo");
assertThat(sql).contains("i.customer_id=a.customer_id","i.source_key=a.source_key","i.order_id AS orderId");
assertThat(sql).doesNotContain("p.confirmation_no");
}
/** H2没有MySQL JSON函数;仅适配标量路径读取,SQL关联、过滤和数量计算仍执行真实Mapper。 */
public static void installJsonFunctions(JdbcTemplate db) {
db.execute("CREATE ALIAS JSON_EXTRACT FOR 'com.baiyue.lastleg.dispatch.mapper.DispatchReceivingSqlTest.jsonExtract'");
db.execute("CREATE ALIAS JSON_UNQUOTE FOR 'com.baiyue.lastleg.dispatch.mapper.DispatchReceivingSqlTest.jsonUnquote'");
}
/** 区分缺失路径的SQL NULL与JSON显式null,覆盖未填写确认时间的两种存储形态。 */
public static String jsonExtract(String document,String path) throws Exception {
if(document==null)return null;
var node=new ObjectMapper().readTree(document);
for(var key:path.substring(2).split("\\."))node=node.path(key);
return node.isMissingNode()?null:node.toString();
}
/** 与MySQL标量JSON_UNQUOTE一致,保留JSON null文本供生产SQL判断。 */
public static String jsonUnquote(String value) throws Exception {
if(value==null)return null;
var node=new ObjectMapper().readTree(value);
return node.isTextual()?node.textValue():node.toString();
}
/** 去除文本块换行差异,仅比较实际SQL表达式。 */
private static String normalize(String sql) {return sql.replaceAll("\\s+"," ").trim();}
}
@@ -33,7 +33,7 @@ public final class DispatchPublicJsonContract {
DispatchCommands.Depart.class, ArrangeDelivery.class,
DispatchPlanningModels.CreateContainer.class, DispatchPlanningModels.DraftSelection.class,
DispatchPlanningModels.DraftSnapshot.class, DispatchPlanningModels.DraftRelease.class,
DispatchPlanningModels.ConvertDraft.class,DispatchWorkModels.RouteCommand.class,
DispatchPlanningModels.ConvertDraft.class,DispatchWorkModels.RouteCommand.class,DispatchWorkModels.AppointmentAllocationCommand.class,
BatchAllocation.class,BatchReference.class
}) {
rejectsMissing(requests, request, "{}");
@@ -77,7 +77,7 @@ public final class DispatchPublicJsonContract {
codec.decode("{\"action\":\"PICK\",\"recordVersion\":0,\"result\":{\"dispatchNo\":\"DP-CONTRACT\"}}");
throw new AssertionError("损坏或错误动作结果不得恢复为成功");
} catch (IllegalStateException expected) { require(expected.getCause() != null, "保留损坏原因"); }
System.out.println("DispatchPublicJsonContract: PASS (16 requests and 2 batch records missing/null/negative/zero; vehicle appointment removed; FBA batch references retained; all action JSON restart/replay)");
System.out.println("DispatchPublicJsonContract: PASS (17 requests and 2 batch records missing/null/negative/zero; vehicle appointment removed; FBA batch references retained; all action JSON restart/replay)");
}
/** 与正式Long按字符串输出约定共同验证账本恢复。 */
@@ -167,7 +167,7 @@ public class DispatchAggregatePersistenceTest {
String dispatch(ResourceMode mode,Long driverId,List<Long> ids) throws Exception {
var created=f.tx.execute(t->f.service.create(f.actor,f.create("evidence-"+UUID.randomUUID(),List.of())));
var resource=new DispatchContracts.Resources(null,new DispatchContracts.DriverSnapshot(mode,driverId,"测试司机","123456789",
new DispatchContracts.DriverEvidence("TEST-LICENSE",LocalDate.of(2099,1,1),ids,Instant.parse("2098-01-01T00:00:00Z"),List.of())), null);
new DispatchContracts.DriverEvidence("TEST-LICENSE",LocalDate.of(2099,1,1),ids,Instant.parse("2098-01-01T00:00:00Z"),List.of(),mode==ResourceMode.MASTER && !ids.isEmpty()?ids.get(0):null)), null);
f.db.update("UPDATE ll_dispatch_order SET resources_json=? WHERE dispatch_no=?",f.json.writeValueAsString(resource),created.dispatchNo());return created.dispatchNo();
}
/** File内容固定为离线测试字节;真实复合归属由既有两个HTTP网关合同分别覆盖。 */
@@ -374,6 +374,7 @@ public class DispatchAggregatePersistenceTest {
try {
var f=new Fixture();var source=f.source(1,"101",501,"ONT8",3);
var created=f.tx.execute(s->f.service.create(f.actor,f.configured("submission-gate-create",List.of(source))));
f.prepareAppointmentNumbers(created.dispatchNo());
var request=new DispatchCommands.SubmitReview(created.recordVersion(),"submission-gate-original",null);
var submitted=f.tx.execute(s->f.service.submitReview(f.actor,created.dispatchNo(),request));
var detail=f.tx.execute(s->f.service.detail(f.actor,created.dispatchNo()));
@@ -403,6 +404,7 @@ public class DispatchAggregatePersistenceTest {
try {
var f=new Fixture();var source=f.source(1,"101",501,"ONT8",3);
var created=f.tx.execute(s->f.service.create(f.actor,f.configured("submission-states-create",List.of(source))));
f.prepareAppointmentNumbers(created.dispatchNo());
assertSubmissionGate(f,created.dispatchNo(),ReviewState.DRAFT,true);
var submitted=f.tx.execute(s->f.service.submitReview(f.actor,created.dispatchNo(),new DispatchCommands.SubmitReview(created.recordVersion(),"submission-states-1",null)));
assertSubmissionGate(f,created.dispatchNo(),ReviewState.DISPATCH_PENDING,false);
@@ -428,6 +430,7 @@ public class DispatchAggregatePersistenceTest {
@Test void reviewsUseActualRootRoundAndImmutableCommandsBeforeAndAfterLoading() throws Exception {
var f=new Fixture();var source=f.source(1,"101",501,"ONT8",3);
var created=f.tx.execute(s->f.service.create(f.actor,f.configured("review-create",List.of(source))));
f.prepareAppointmentNumbers(created.dispatchNo());
var submit=f.tx.execute(s->f.service.submitReview(f.actor,created.dispatchNo(),new DispatchCommands.SubmitReview(created.recordVersion(),"submit-1",null)));
assertThatThrownBy(()->f.tx.execute(s->f.service.review(f.actor,created.dispatchNo(),true,new DispatchCommands.ReviewCommand(submit.recordVersion(),"wrong-order",Decision.APPROVED,null,null)))).isInstanceOf(BizException.class);
var rejected=f.tx.execute(s->f.service.review(f.actor,created.dispatchNo(),false,new DispatchCommands.ReviewCommand(submit.recordVersion(),"reject-1",Decision.REJECTED,"补核装载计划",null)));
@@ -505,7 +508,7 @@ public class DispatchAggregatePersistenceTest {
var provider=mock(com.baiyue.lastleg.appointmentwork.AppointmentWorkDispatchPort.class);
when(provider.reviewEvidence(original.dispatchNo())).thenReturn(List.of(new com.baiyue.lastleg.appointmentwork.AppointmentWorkDispatchPort.ReceivingWindow("FBA_APPOINTMENT:900","AP-EXISTING","101","FBA:ONT8","CURRENT-CONFIRM",Instant.parse("2026-10-20T17:00:00Z"),null,"AGR-1",1)));
org.springframework.test.util.ReflectionTestUtils.setField(f.service,"appointments",new DispatchAppointmentAllocations(provider));
var current=f.tx.execute(s->f.service.detail(f.actor,original.dispatchNo())).dispatchInfo().fbaAppointments().get(0);
var current=f.tx.execute(s->f.service.detail(f.actor,original.dispatchNo())).dispatchInfo().fbaAppointments().stream().filter(a0->"FBA:ONT8".equals(a0.destinationKey())).findFirst().orElseThrow();
assertThat(current.appointmentNo()).isEqualTo("CURRENT-CONFIRM");assertThat(current.confirmedAt()).isEqualTo(Instant.parse("2026-10-20T17:00:00Z"));
var c=f.source(3,"101",503,"ONT8",2);
f.tx.executeWithoutResult(s->f.service.addCargo(f.actor,original.dispatchNo(),new DispatchCommands.AddCargo(original.recordVersion(),"add-third",List.of(c),List.of())));
@@ -588,19 +591,20 @@ public class DispatchAggregatePersistenceTest {
db.execute("CREATE TABLE ll_dispatch_history(id BIGINT PRIMARY KEY,dispatch_id BIGINT,record_version BIGINT,action VARCHAR(40),operator_id VARCHAR(128),reason VARCHAR(1000),snapshot_json CLOB,occurred_at TIMESTAMP(3))");
db.execute("CREATE TABLE ll_dispatch_pda_pick(id BIGINT,customer_id VARCHAR(64),dispatch_item_id BIGINT,inventory_position_id BIGINT,packages BIGINT,product_quantity BIGINT)");
db.execute("CREATE TABLE ll_dispatch_pda_pick_order(id BIGINT,customer_id VARCHAR(64),pick_id BIGINT,order_id BIGINT,receipt_id BIGINT,return_receipt_id BIGINT,return_carton_id BIGINT,dispatch_item_source_id BIGINT,packages BIGINT,product_quantity BIGINT)");
db.execute("CREATE TABLE ll_order(id BIGINT,customer_id VARCHAR(64),order_no VARCHAR(128),source_region VARCHAR(64),source_inbound_id VARCHAR(128))");
db.execute("CREATE TABLE ll_order(id BIGINT,customer_id VARCHAR(64),order_no VARCHAR(128),source_region VARCHAR(64),source_inbound_id VARCHAR(128),order_type VARCHAR(64))");
db.execute("CREATE TABLE ll_order_detail(order_id BIGINT,customer_id VARCHAR(128),shipment_no VARCHAR(128),destination_warehouse_code VARCHAR(128))");
db.execute("CREATE TABLE ll_inbound_stock_receipt(id BIGINT,customer_id VARCHAR(64),inbound_code VARCHAR(128))");
db.execute("CREATE TABLE ll_return_stock_receipt(id BIGINT,customer_id VARCHAR(64),inventory_balance_id BIGINT,order_id BIGINT,inventory_position_id BIGINT,received_packages BIGINT)");
db.execute("CREATE TABLE ll_inventory_balance(id BIGINT,customer_id VARCHAR(64),inbound_code VARCHAR(128))");
db.execute("CREATE TABLE ll_return_inventory_carton(id BIGINT,customer_id VARCHAR(64),carton_mark VARCHAR(128),pallet_code VARCHAR(128),relabel_no VARCHAR(128),overseas_warehouse_id VARCHAR(64),total_quantity BIGINT,status VARCHAR(32))");
db.execute("CREATE TABLE ll_warehouse_transfer_order(id BIGINT,customer_id VARCHAR(64),transfer_no VARCHAR(128))");
db.execute("CREATE TABLE ll_warehouse_transfer_order(id BIGINT,customer_id VARCHAR(64),transfer_no VARCHAR(128),lastleg_order_id BIGINT,fba_shipment_no VARCHAR(128))");
db.execute("CREATE TABLE ll_warehouse_transfer_pallet(transfer_order_id BIGINT,customer_id VARCHAR(64),warehouse_pallet_id BIGINT,carton_count INT,status VARCHAR(32),completed_at TIMESTAMP(3),gross_weight_kg DECIMAL(18,6),volume_cbm DECIMAL(18,6))");
db.execute("CREATE TABLE ll_inventory_ledger(customer_id VARCHAR(64),pallet_id BIGINT,created_at TIMESTAMP(3),business_type VARCHAR(64))");
db.execute("CREATE TABLE ll_fleet_resource_assignment(resource_kind VARCHAR(32),resource_id BIGINT,trip_id BIGINT,trip_no VARCHAR(32),PRIMARY KEY(resource_kind,resource_id))");
db.execute("CREATE TABLE ll_dispatch_appointment_allocation(dispatch_id BIGINT,dispatch_item_id BIGINT,plan_pallet_id BIGINT,batch_key VARCHAR(128),customer_id VARCHAR(128),source_key VARCHAR(128),destination_key VARCHAR(128),package_count INT)");
db.execute("CREATE TABLE ll_inbound_fba_appointment(id BIGINT,version INT DEFAULT 0,appointment_no VARCHAR(64),confirmation_no VARCHAR(128),appointment_status VARCHAR(32),fba_warehouse_code VARCHAR(128),appointment_start_utc TIMESTAMP(3))");
db.execute("CREATE TABLE ll_inbound_fba_appointment_item(order_id BIGINT,customer_id VARCHAR(128),active INT)");
db.execute("CREATE TABLE ll_inbound_plan_pallet_allocation(pallet_id BIGINT,line_id BIGINT)"); db.execute("CREATE TABLE ll_inbound_plan_line(id BIGINT,order_id BIGINT,customer_id VARCHAR(128))");
db.execute("CREATE TABLE ll_inbound_fba_appointment_item(order_id BIGINT,customer_id VARCHAR(128),active INT,appointment_id BIGINT,source_key VARCHAR(128),fba_warehouse_code VARCHAR(128))");
db.execute("CREATE TABLE ll_inbound_plan_pallet_allocation(pallet_id BIGINT,line_id BIGINT,customer_id VARCHAR(128))"); db.execute("CREATE TABLE ll_inbound_plan_line(id BIGINT,order_id BIGINT,customer_id VARCHAR(128),fba_warehouse_code VARCHAR(128))");
json=new ObjectMapper().registerModule(new JavaTimeModule());
var fleetRepo=new FleetTripRepository(session.getMapper(FleetTripMapper.class),session.getMapper(FleetResourceMapper.class),session.getMapper(FleetTripHistoryMapper.class),session.getMapper(FleetTripDispatchMapper.class));
var warehouses=mock(FleetWarehouseDirectory.class); when(warehouses.required(9L,"OVERSEAS")).thenReturn(new DriverModels.WarehouseOption("9","WH9","测试起运仓","OVERSEAS"));
@@ -632,7 +636,9 @@ public class DispatchAggregatePersistenceTest {
var numbers=mock(DispatchNumberGenerator.class);var dailySequence=new java.util.concurrent.atomic.AtomicInteger();
when(numbers.next()).thenAnswer(invocation->"PC20261020"+String.format(java.util.Locale.ROOT,"%04d",dailySequence.incrementAndGet()));
org.springframework.test.util.ReflectionTestUtils.setField(service,"numbers",numbers);
org.springframework.test.util.ReflectionTestUtils.setField(service,"evidence",mock(com.baiyue.lastleg.dispatch.gateway.DispatchEvidenceGateway.class));
var dispatchEvidence=mock(com.baiyue.lastleg.dispatch.gateway.DispatchEvidenceGateway.class);
when(dispatchEvidence.metadata(anyLong(),any(),anyLong())).thenAnswer(call->new com.baiyue.lastleg.dispatch.gateway.DispatchEvidenceGateway.File(call.getArgument(2),"照片.png","image/png",10));
org.springframework.test.util.ReflectionTestUtils.setField(service,"evidence",dispatchEvidence);
org.springframework.test.util.ReflectionTestUtils.setField(service,"ownedBusinessOwnership",ownedBusinessOwnership);
org.springframework.test.util.ReflectionTestUtils.setField(gateway,"workflow",service);
org.springframework.test.util.ReflectionTestUtils.setField(service,"appointments",new DispatchAppointmentAllocations(mock(com.baiyue.lastleg.appointmentwork.AppointmentWorkDispatchPort.class)));
@@ -663,20 +669,32 @@ public class DispatchAggregatePersistenceTest {
return new DispatchCommands.Create(key,"9",new DispatchCommands.Vehicle(ResourceMode.EXTERNAL,null,"TEST-TRUCK","测试承运方",
new DispatchContracts.Capacity(15,new BigDecimal("10000"),new BigDecimal("50")),List.of(101L)),
new DispatchCommands.Driver(ResourceMode.EXTERNAL,null,"测试司机","123456789",
new DispatchContracts.DriverEvidence("TEST-LICENSE",java.time.LocalDate.of(2099,1,1),List.of(202L),departure.plusSeconds(86400),List.of())),
new DispatchContracts.DriverEvidence("TEST-LICENSE",java.time.LocalDate.of(2099,1,1),List.of(202L),departure.plusSeconds(86400),List.of(303L))),
departure,null,null,sources,List.of(), true);
}
/** 通过实际两级流程进入装车,测试不直接伪造通过状态。 */
public DispatchContracts.CommandResult approve(DispatchContracts.CommandResult created) {
prepareAppointmentNumbers(created.dispatchNo());
var submitted=tx.execute(s->service.submitReview(actor,created.dispatchNo(),new DispatchCommands.SubmitReview(created.recordVersion(),created.dispatchNo()+"-submit",null)));
var reviewed=tx.execute(s->service.review(actor,created.dispatchNo(),false,new DispatchCommands.ReviewCommand(submitted.recordVersion(),created.dispatchNo()+"-dispatch",Decision.APPROVED,null,null)));
return tx.execute(s->service.review(actor,created.dispatchNo(),true,new DispatchCommands.ReviewCommand(reviewed.recordVersion(),created.dispatchNo()+"-loading",Decision.APPROVED,null,null)));
}
/** 普通装车夹具预置已约编号;预约缺失及填写流程由专项测试单独验证。 */
public void prepareAppointmentNumbers(String number) {
try {
var row=repository.find(number);var resources=(com.fasterxml.jackson.databind.node.ObjectNode)json.readTree(row.getResourcesJson());
var maintenance=resources.withObject("/maintenance");var entries=maintenance.withArray("destinationAppointments");
var known=new java.util.HashSet<String>();entries.forEach(e->known.add(e.path("destinationKey").asText()));
for(var item:repository.cargo(row.getId()))if("FBA".equals(item.getDestinationType()) && known.add("FBA:"+item.getFbaWarehouseCode()))
entries.addObject().put("destinationKey","FBA:"+item.getFbaWarehouseCode()).put("appointmentNo","TEST-ISA-"+item.getFbaWarehouseCode());
db.update("UPDATE ll_dispatch_order SET resources_json=? WHERE dispatch_no=?",json.writeValueAsString(resources),number);
} catch(com.fasterxml.jackson.core.JsonProcessingException error) {throw new IllegalStateException(error);}
}
/** 只为实载端口夹具准备真实扫码/归属记录,不声称运行库存命令。 */
public void picked(long source,String customer,long pallet,int packages) {
long item=db.queryForObject("SELECT id FROM ll_dispatch_item WHERE plan_pallet_id=?",Long.class,source);
db.update("INSERT INTO ll_order VALUES(?,?,?,'OVERSEAS',?)",9000+source,customer,"ORDER-"+source,"SOURCE-"+source);
db.update("INSERT INTO ll_order(id,customer_id,order_no,source_region,source_inbound_id) VALUES(?,?,?,'OVERSEAS',?)",9000+source,customer,"ORDER-"+source,"SOURCE-"+source);
db.update("INSERT INTO ll_inbound_stock_receipt VALUES(?,?,?)",source,customer,"IN-"+source);
db.update("INSERT INTO ll_dispatch_pda_pick VALUES(?,?,?,?,?,?)",source,customer,item,pallet*10,packages,packages*2);
db.update("INSERT INTO ll_dispatch_pda_pick_order(id,customer_id,pick_id,order_id,receipt_id,packages,product_quantity) VALUES(?,?,?,?,?,?,?)",source,customer,source,9000+source,source,packages,packages*2);
@@ -0,0 +1,82 @@
package com.baiyue.lastleg.dispatch.service;
import com.baiyue.lastleg.appointmentwork.AppointmentWorkDispatchPort;
import com.baiyue.lastleg.dispatch.port.*;
import org.junit.jupiter.api.*;
import org.springframework.security.authentication.TestingAuthenticationToken;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.test.util.ReflectionTestUtils;
import java.util.*;
import static org.assertj.core.api.Assertions.*;
import static org.mockito.Mockito.*;
/** 原车关联命令使用真实主单、版本CAS、历史与命令账本事务。 */
class DispatchAppointmentLinkPersistenceTest {
/** 每项测试分别授权列表与维护,避免缺权限造成伪业务拦截。 */
@BeforeEach void authorize() {SecurityContextHolder.getContext().setAuthentication(new TestingAuthenticationToken("operator-test","","platform:overseas-warehouse:dispatch:list","platform:overseas-warehouse:dispatch:edit"));}
/** 不把测试身份传给后续其他用例。 */
@AfterEach void clear() {SecurityContextHolder.clearContext();}
/** 补分配保留原车、同车其他客户与来源占用,作废旧审批且支持原键恢复。 */
@Test void linksExistingCargoAndInvalidatesReviewWithoutRecreatingTruckOrClaims() throws Exception {
var s=new Scenario();s.f.db.update("UPDATE ll_dispatch_order SET review_state='DISPATCH_PENDING',review_basis_hash='old-basis'");
long claims=s.count("ll_dispatch_pallet_lock"),history=s.count("ll_dispatch_history");
var cargo=s.f.db.queryForList("SELECT * FROM ll_dispatch_item ORDER BY id");
var saved=s.f.tx.execute(t->s.f.service.saveAppointmentAllocations(s.f.actor,s.no,s.request(s.version,"link-original")));
assertThat(saved.dispatchNo()).isEqualTo(s.no);assertThat(saved.recordVersion()).isEqualTo(s.version+1);
assertThat(s.f.repository.find(s.no).getReviewState()).isEqualTo("DRAFT");assertThat(s.f.repository.find(s.no).getReviewBasisHash()).isNull();
assertThat(s.f.db.queryForList("SELECT * FROM ll_dispatch_item ORDER BY id")).isEqualTo(cargo);
assertThat(s.count("ll_dispatch_pallet_lock")).isEqualTo(claims);assertThat(s.count("ll_dispatch_order")).isOne();assertThat(s.count("ll_dispatch_history")).isEqualTo(history+1);
verify(s.provider).bindItems(eq(s.root),eq(List.of(new AppointmentWorkDispatchPort.PhysicalItem(s.item,"101",1L,"RECEIVED",10))),eq(List.of(new AppointmentWorkDispatchPort.Allocation(1L,"FBA_APPOINTMENT:9",0L,"101","ORDER:9001","FBA:ACY2",10))));
assertThat(s.f.tx.execute(t->s.f.service.saveAppointmentAllocations(s.f.actor,s.no,s.request(s.version,"link-original"))).replayed()).isTrue();
verify(s.provider,times(1)).bindItems(anyLong(),anyList(),anyList());assertThat(s.count("ll_dispatch_history")).isEqualTo(history+1);
assertThatThrownBy(()->s.f.tx.execute(t->s.f.service.saveAppointmentAllocations(s.f.actor,s.no,s.request(s.version,"stale-new-key")))).hasMessageContaining("版本");
}
/** 真实扫码优先于头部零计数,外车明细与只读权限都不能进入写入。 */
@Test void rejectsActualPicksForeignItemsAndReadOnlyActorBeforeProviderMutation() throws Exception {
var s=new Scenario();
assertThatThrownBy(()->s.f.tx.execute(t->s.f.service.saveAppointmentAllocations(s.f.actor,s.no,new DispatchWorkModels.AppointmentAllocationCommand(s.version,"foreign",List.of(s.item+999),s.allocations())))).hasMessageContaining("本车");
s.f.picked(1,"101",501,1);
assertThatThrownBy(()->s.f.tx.execute(t->s.f.service.saveAppointmentAllocations(s.f.actor,s.no,s.request(s.version,"picked")))).hasMessageContaining("实际扫码");
SecurityContextHolder.getContext().setAuthentication(new TestingAuthenticationToken("operator-test","","platform:overseas-warehouse:dispatch:list"));
assertThatThrownBy(()->s.f.tx.execute(t->s.f.service.saveAppointmentAllocations(s.f.actor,s.no,s.request(s.version,"read-only")))).hasMessageContaining("权限");
verifyNoInteractions(s.provider);assertThat(s.f.repository.find(s.no).getRecordVersion()).isEqualTo(s.version);
}
/** 原批次拒绝时,主单版本、审核和账本都保留修改前事实。 */
@Test void providerFailureRollsBackVersionReviewAndCommandLedger() throws Exception {
var s=new Scenario();var before=s.f.json.writeValueAsString(s.f.repository.find(s.no));long history=s.count("ll_dispatch_history"),commands=s.count("ll_dispatch_command");
doThrow(new IllegalStateException("预约版本已变化")).when(s.provider).bindItems(anyLong(),anyList(),anyList());
assertThatThrownBy(()->s.f.tx.execute(t->s.f.service.saveAppointmentAllocations(s.f.actor,s.no,s.request(s.version,"reject-batch")))).hasMessageContaining("预约版本");
assertThat(s.f.json.writeValueAsString(s.f.repository.find(s.no))).isEqualTo(before);assertThat(s.count("ll_dispatch_history")).isEqualTo(history);assertThat(s.count("ll_dispatch_command")).isEqualTo(commands);
}
/** 候选必须属于本车真实实物,原版本零与本车分配对称返回。 */
@Test void readsOnlyOwnedPhysicalOptionsAndDisablesWriteAfterActualPick() throws Exception {
var s=new Scenario();
when(s.provider.allocationOptions(eq(s.root),anyList())).thenAnswer(call->{
List<AppointmentWorkDispatchPort.CandidatePhysical> identities=call.getArgument(1);var result=new LinkedHashMap<AppointmentWorkDispatchPort.CandidatePhysical,List<AppointmentWorkDispatchPort.AllocationSource>>();
for(var identity:identities)result.put(identity,List.of(new AppointmentWorkDispatchPort.AllocationSource("ORDER:9001","WC-1","IN-1","FBA:ACY2",10,List.of(),List.of())));return result;
});
var result=s.f.tx.execute(t->s.f.service.appointmentAllocations(s.f.actor,s.no));
assertThat(result.dispatchNo()).isEqualTo(s.no);assertThat(result.items()).hasSize(2);assertThat(result.edit().allowed()).isTrue();
s.f.picked(1,"101",501,1);
assertThat(s.f.tx.execute(t->s.f.service.appointmentAllocations(s.f.actor,s.no)).edit().allowed()).isFalse();
}
private static class Scenario {
final DispatchAggregatePersistenceTest.Fixture f=new DispatchAggregatePersistenceTest.Fixture();
final AppointmentWorkDispatchPort provider=mock(AppointmentWorkDispatchPort.class);
final String no;final long root,item,version;
/** 先建已有车和两客户货物,模拟后续新增正式预约,不能靠重建车完成补关联。 */
Scenario() throws Exception {
var created=f.tx.execute(t->f.service.create(f.actor,f.configured("original-before-appointment",List.of(f.source(1,"101",501,"ACY2",10),f.source(2,"202",502,"ONT8",5)))));
no=created.dispatchNo();version=created.recordVersion();root=f.repository.find(no).getId();
item=f.db.queryForObject("SELECT id FROM ll_dispatch_item WHERE plan_pallet_id=1",Long.class);
ReflectionTestUtils.setField(f.service,"appointments",new DispatchAppointmentAllocations(provider));
}
/** 本次只触及第一客户原有托盘,不重发选货请求。 */
DispatchWorkModels.AppointmentAllocationCommand request(long at,String key) {return new DispatchWorkModels.AppointmentAllocationCommand(at,key,List.of(item),allocations());}
/** 正式预约显式提交当前权威版本零。 */
List<DispatchContracts.BatchAllocation> allocations() {return List.of(new DispatchContracts.BatchAllocation(1L,"FBA_APPOINTMENT:9","101","ORDER:9001","FBA:ACY2",10,0L));}
/** 统计真实持久化记录,验证失败和重放均不产生额外副作用。 */
long count(String table) {return f.db.queryForObject("SELECT COUNT(*) FROM "+table,Long.class);}
}
}

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