diff --git a/deploy/sql/README.md b/deploy/sql/README.md index dc50965a4..fda18d5bf 100644 --- a/deploy/sql/README.md +++ b/deploy/sql/README.md @@ -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,准确单量以主单统计为准。 diff --git a/deploy/sql/头程数据库统一增量-MySQL8.sql b/deploy/sql/头程数据库统一增量-MySQL8.sql index 22935332d..8d6c1cedb 100644 --- a/deploy/sql/头程数据库统一增量-MySQL8.sql +++ b/deploy/sql/头程数据库统一增量-MySQL8.sql @@ -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 diff --git a/firstleg/docs/business-index.md b/firstleg/docs/business-index.md index e4ef56b22..53f3cf7a5 100644 --- a/firstleg/docs/business-index.md +++ b/firstleg/docs/business-index.md @@ -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`。 diff --git a/firstleg/src/main/java/com/baiyue/firstleg/customs/container/service/ContainerCustomsService.java b/firstleg/src/main/java/com/baiyue/firstleg/customs/container/service/ContainerCustomsService.java index 257f30a81..5e204711a 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/customs/container/service/ContainerCustomsService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/customs/container/service/ContainerCustomsService.java @@ -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()); } diff --git a/firstleg/src/main/java/com/baiyue/firstleg/oceanbooking/controller/PlatformOceanBookingController.java b/firstleg/src/main/java/com/baiyue/firstleg/oceanbooking/controller/PlatformOceanBookingController.java index 5b390f5c6..60a87fee7 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/oceanbooking/controller/PlatformOceanBookingController.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/oceanbooking/controller/PlatformOceanBookingController.java @@ -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 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 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')") diff --git a/firstleg/src/main/java/com/baiyue/firstleg/oceanbooking/dto/OceanBookingDtos.java b/firstleg/src/main/java/com/baiyue/firstleg/oceanbooking/dto/OceanBookingDtos.java index 7ff4b807b..aba3bf946 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/oceanbooking/dto/OceanBookingDtos.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/oceanbooking/dto/OceanBookingDtos.java @@ -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, diff --git a/firstleg/src/main/java/com/baiyue/firstleg/oceanbooking/mapper/OceanBookingMapper.java b/firstleg/src/main/java/com/baiyue/firstleg/oceanbooking/mapper/OceanBookingMapper.java index 7b8aae173..4b5686c2b 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/oceanbooking/mapper/OceanBookingMapper.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/oceanbooking/mapper/OceanBookingMapper.java @@ -27,6 +27,11 @@ public interface OceanBookingMapper extends BaseMapper { @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}, diff --git a/firstleg/src/main/java/com/baiyue/firstleg/oceanbooking/repository/OceanBookingRepository.java b/firstleg/src/main/java/com/baiyue/firstleg/oceanbooking/repository/OceanBookingRepository.java index 1246d23b6..eba6abfd3 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/oceanbooking/repository/OceanBookingRepository.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/oceanbooking/repository/OceanBookingRepository.java @@ -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); diff --git a/firstleg/src/main/java/com/baiyue/firstleg/oceanbooking/service/OceanBookingService.java b/firstleg/src/main/java/com/baiyue/firstleg/oceanbooking/service/OceanBookingService.java index f55a9770b..633e69d9a 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/oceanbooking/service/OceanBookingService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/oceanbooking/service/OceanBookingService.java @@ -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 parties, List requirements) { if (!StringUtils.hasText(booking.getChannelCode()) || booking.getWarehouseId() == null diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/ContainerService.java b/firstleg/src/main/java/com/baiyue/firstleg/service/ContainerService.java index 172078964..ee39b285d 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/ContainerService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/ContainerService.java @@ -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("输入的真实集装箱号与当前柜次已录入柜号不一致,请先在预装运输信息中纠正"); } diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingListService.java b/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingListService.java index 98c15ed52..fef777aa1 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingListService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingListService.java @@ -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, diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingPickService.java b/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingPickService.java index d40166d80..98c1ae578 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingPickService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingPickService.java @@ -140,6 +140,7 @@ public class LoadingPickService { smartLoadingRepository.items(plan.getId())); } + /** 确认预装前验证作业资格和真实柜号,格式问题必须在实物作业前纠正。 */ public List requirePreloadConfirmationReady(long loadingPlanId) { inboundHoldGuard.requirePlanNotHeld(loadingPlanId); List 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 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())) { diff --git a/firstleg/src/main/resources/db/phase3-ocean-booking-schema.sql b/firstleg/src/main/resources/db/phase3-ocean-booking-schema.sql index 86ff6b97f..01eb28bc0 100644 --- a/firstleg/src/main/resources/db/phase3-ocean-booking-schema.sql +++ b/firstleg/src/main/resources/db/phase3-ocean-booking-schema.sql @@ -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 diff --git a/firstleg/src/main/resources/db/phase3-smart-loading-schema.sql b/firstleg/src/main/resources/db/phase3-smart-loading-schema.sql index 3593d195d..c18ae430a 100644 --- a/firstleg/src/main/resources/db/phase3-smart-loading-schema.sql +++ b/firstleg/src/main/resources/db/phase3-smart-loading-schema.sql @@ -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 diff --git a/firstleg/src/main/resources/openapi/customs-v1.yaml b/firstleg/src/main/resources/openapi/customs-v1.yaml index 7852ed3ab..665e2e00c 100644 --- a/firstleg/src/main/resources/openapi/customs-v1.yaml +++ b/firstleg/src/main/resources/openapi/customs-v1.yaml @@ -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'}} diff --git a/firstleg/src/main/resources/openapi/firstleg-v1.yaml b/firstleg/src/main/resources/openapi/firstleg-v1.yaml index 81c7b0448..9fe296818 100644 --- a/firstleg/src/main/resources/openapi/firstleg-v1.yaml +++ b/firstleg/src/main/resources/openapi/firstleg-v1.yaml @@ -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 diff --git a/firstleg/src/main/resources/openapi/loading-lists-v1.yaml b/firstleg/src/main/resources/openapi/loading-lists-v1.yaml index 5b1354570..13b7e276f 100644 --- a/firstleg/src/main/resources/openapi/loading-lists-v1.yaml +++ b/firstleg/src/main/resources/openapi/loading-lists-v1.yaml @@ -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'} diff --git a/firstleg/src/main/resources/openapi/ocean-bookings-v1.yaml b/firstleg/src/main/resources/openapi/ocean-bookings-v1.yaml index 187ad0953..7edef50af 100644 --- a/firstleg/src/main/resources/openapi/ocean-bookings-v1.yaml +++ b/firstleg/src/main/resources/openapi/ocean-bookings-v1.yaml @@ -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 diff --git a/firstleg/src/test/java/com/baiyue/firstleg/controller/ContainerControllerContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/controller/ContainerControllerContractTest.java index 7da4e3849..d76ae2945 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/controller/ContainerControllerContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/controller/ContainerControllerContractTest.java @@ -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") diff --git a/firstleg/src/test/java/com/baiyue/firstleg/customs/container/ContainerCustomsControllerContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/customs/container/ContainerCustomsControllerContractTest.java index 3d26731a7..102a7fda5 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/customs/container/ContainerCustomsControllerContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/customs/container/ContainerCustomsControllerContractTest.java @@ -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"); diff --git a/firstleg/src/test/java/com/baiyue/firstleg/customs/container/ContainerCustomsServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/customs/container/ContainerCustomsServiceTest.java index 9521e272a..f2396f29f 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/customs/container/ContainerCustomsServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/customs/container/ContainerCustomsServiceTest.java @@ -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); } diff --git a/firstleg/src/test/java/com/baiyue/firstleg/oceanbooking/controller/PlatformOceanBookingControllerContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/oceanbooking/controller/PlatformOceanBookingControllerContractTest.java index cf9e422b4..bd4a08656 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/oceanbooking/controller/PlatformOceanBookingControllerContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/oceanbooking/controller/PlatformOceanBookingControllerContractTest.java @@ -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()) diff --git a/firstleg/src/test/java/com/baiyue/firstleg/oceanbooking/repository/OceanBookingMapperContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/oceanbooking/repository/OceanBookingMapperContractTest.java index 8a5e0934d..c1743242a 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/oceanbooking/repository/OceanBookingMapperContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/oceanbooking/repository/OceanBookingMapperContractTest.java @@ -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", diff --git a/firstleg/src/test/java/com/baiyue/firstleg/oceanbooking/repository/OceanBookingSqlContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/oceanbooking/repository/OceanBookingSqlContractTest.java index 6df996887..832702c01 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/oceanbooking/repository/OceanBookingSqlContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/oceanbooking/repository/OceanBookingSqlContractTest.java @@ -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(); diff --git a/firstleg/src/test/java/com/baiyue/firstleg/oceanbooking/service/OceanBookingServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/oceanbooking/service/OceanBookingServiceTest.java index d40fb7097..2baeff079 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/oceanbooking/service/OceanBookingServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/oceanbooking/service/OceanBookingServiceTest.java @@ -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); diff --git a/firstleg/src/test/java/com/baiyue/firstleg/repository/ContainerBookingMapperContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/repository/ContainerBookingMapperContractTest.java index ba1b0e742..b34b5d14a 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/repository/ContainerBookingMapperContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/repository/ContainerBookingMapperContractTest.java @@ -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); + } } diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/ContainerServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/ContainerServiceTest.java index 02cc706d6..9e1f6b88d 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/service/ContainerServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/ContainerServiceTest.java @@ -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 diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingListServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingListServiceTest.java index 5546fbc96..f6bd68f95 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingListServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingListServiceTest.java @@ -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); diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingPickServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingPickServiceTest.java index 982738c4c..c9d9e4a27 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingPickServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingPickServiceTest.java @@ -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. diff --git a/lastleg-service/docs/business-index.md b/lastleg-service/docs/business-index.md index 18aae008f..e576142c9 100644 --- a/lastleg-service/docs/business-index.md +++ b/lastleg-service/docs/business-index.md @@ -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。 diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentwork/AppointmentWorkDispatchPort.java b/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentwork/AppointmentWorkDispatchPort.java index 188106dc0..9a5ff8842 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentwork/AppointmentWorkDispatchPort.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentwork/AppointmentWorkDispatchPort.java @@ -16,6 +16,12 @@ public interface AppointmentWorkDispatchPort { record CandidateBatches(List appointmentBatches,List blockReasons,boolean occupied) { } /** 整页真实客户/计划托盘一次批读;occupied含有效草稿与正式实托占用,由调用方核对本草稿身份。 */ Map candidateBatches(List candidates); + /** 只读取本车已选实物的逐票预约选项;本车原占用不扣减本车可重新分配量。 */ + Map> allocationOptions(long dispatchId,List 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 options,List blockReasons) { } /** Current original allocations, grouped for authorized dispatches; historical copies are read-only evidence. */ java.util.Map> references(List 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 reviewEvidence(String dispatchNo); void removeItem(long dispatchId,long itemId); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/model/DeliveryCargoGroups.java b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/model/DeliveryCargoGroups.java index db94a64a1..26bf1f3a2 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/model/DeliveryCargoGroups.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/model/DeliveryCargoGroups.java @@ -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())); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/controller/DispatchAggregateController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/controller/DispatchAggregateController.java index b0ea5d313..c27f77439 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/controller/DispatchAggregateController.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/controller/DispatchAggregateController.java @@ -53,6 +53,18 @@ public class DispatchAggregateController { public ApiResponse 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 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 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')") diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/mapper/DispatchAggregateMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/mapper/DispatchAggregateMapper.java index e53ef8f34..60088ca91 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/mapper/DispatchAggregateMapper.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/mapper/DispatchAggregateMapper.java @@ -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}) ) - 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}) + 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)) + ) """; 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"; diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/mapper/DispatchBolMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/mapper/DispatchBolMapper.java index 15000acf6..be34014ad 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/mapper/DispatchBolMapper.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/mapper/DispatchBolMapper.java @@ -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 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 diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/mapper/DispatchItemMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/mapper/DispatchItemMapper.java index 77a0990d2..24930abe1 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/mapper/DispatchItemMapper.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/mapper/DispatchItemMapper.java @@ -11,6 +11,16 @@ import java.util.List; @Mapper public interface DispatchItemMapper extends BaseMapper { + /** 收货只定位原计划已关联的有效排车,按车辆父锁顺序返回。 */ + @Select("") + List receiptDispatches(@Param("ids") List 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 findWholeDispatch(@Param("dispatchOrderId") Long dispatchOrderId); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/mapper/DispatchReadMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/mapper/DispatchReadMapper.java index 200b18f78..8d976068b 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/mapper/DispatchReadMapper.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/mapper/DispatchReadMapper.java @@ -55,6 +55,24 @@ public interface DispatchReadMapper { """) List 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 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 diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/mapper/DispatchReceivingSql.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/mapper/DispatchReceivingSql.java new file mode 100644 index 000000000..2c7765835 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/mapper/DispatchReceivingSql.java @@ -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() { } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/mapper/DispatchSourceMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/mapper/DispatchSourceMapper.java index 80fff1894..5b045037b 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/mapper/DispatchSourceMapper.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/mapper/DispatchSourceMapper.java @@ -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 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") diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/port/DispatchContracts.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/port/DispatchContracts.java index db3d45492..81d3f7dd1 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/port/DispatchContracts.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/port/DispatchContracts.java @@ -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 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, diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/port/DispatchViews.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/port/DispatchViews.java index fafe56542..3f88b63f6 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/port/DispatchViews.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/port/DispatchViews.java @@ -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 actions) { } + Map actions, List 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 shipmentNumbers) { } public record DispatchInfo(String route, Instant plannedDepartureAt, Instant departedAt, Instant estimatedArrivalAt, List fbaAppointments, diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/port/DispatchWorkModels.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/port/DispatchWorkModels.java index d2f1b708b..3480b5e3c 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/port/DispatchWorkModels.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/port/DispatchWorkModels.java @@ -14,6 +14,14 @@ public final class DispatchWorkModels { public record Items(String dispatchNo,long recordVersion,Status status,Stage stage,List items) { } public record Stop(int index,String name,String address,String destinationKey) { } public record Route(String dispatchNo,long recordVersion,List stops,Gate edit) { } + public record AppointmentItem(Long dispatchItemId,Long planPalletId,String customerId,String customerName,String palletNo, + List sources,List allocations) { } + public record AppointmentAllocations(String dispatchNo,long recordVersion,List items,Gate edit) { } + /** 只替换明确选中的本车实物分配,未触及客户和实物保持原关联。 */ + public record AppointmentAllocationCommand(Long recordVersion,String idempotencyKey,List dispatchItemIds,List batchAllocations) { + /** 主版本零合法;保存失败不能用默认版本覆盖后续修改。 */ + public AppointmentAllocationCommand {recordVersion=DispatchCommands.requireVersion(recordVersion);} + } /** 仅重排现有真实卸货点;起运仓由服务端保留,外接目的地不得丢失。 */ public record RouteCommand(Long recordVersion,String idempotencyKey,List destinationKeys,String reason) { /** 显式零版本合法,缺版本不能覆盖并发变更。 */ diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/repository/DispatchAggregateRepository.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/repository/DispatchAggregateRepository.java index f98e11c32..25e302411 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/repository/DispatchAggregateRepository.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/repository/DispatchAggregateRepository.java @@ -42,6 +42,10 @@ public class DispatchAggregateRepository { public List summaries(DispatchAggregateMapper.Search query) { return mapper.summaries(query); } /** 按整车读取全部真实客户明细。 */ public List cargo(long dispatchId) { return items.findWholeDispatch(dispatchId); } + /** 只读取本次确认入库的原计划关联排车。 */ + public List receiptDispatches(List 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 deliveries(long id) { return reads.deliveries(id); } /** 已有关联预约批次只读事实。 */ public List batches(long id) { return reads.batches(id); } + /** 本车实际货物的权威货件号,不依赖预约分配,也不把重复托盘当作新货件。 */ + public List cargoShipmentNumbers(long id) { return reads.cargoShipmentNumbers(id); } /** 是否必须走受保护提供者核验批次分配。 */ public long activeAppointments(String customer, Long pallet) { return reads.activeAppointments(customer,pallet); } } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/repository/DispatchSourceRepository.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/repository/DispatchSourceRepository.java index bf2d59126..3e7f1328c 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/repository/DispatchSourceRepository.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/repository/DispatchSourceRepository.java @@ -66,6 +66,8 @@ public class DispatchSourceRepository { public DispatchSourceMapper.RelabelBasis relabelBasis(String no) { return source.relabelBasis(no); } /** 取得已有普通计划候选,保留原来源资格SQL。 */ public List selectedPlan(String customer, Long id) { return plans.readSelected(customer, List.of(id)); } + /** 确认入库直接读取逐票实收,与可用、暂扣等出货资格分离。 */ + public List confirmedPlan(String customer,long id) { return source.confirmedPlan(customer,id); } /** 取得已有真实退回候选。 */ public List selectedReturn(String customer, Long id) { return returns.readSelected(customer, List.of(id)); } /** 同托真实来源订单组成。 */ diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchAggregateService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchAggregateService.java index 62e2b1bac..5b6b233c2 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchAggregateService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchAggregateService.java @@ -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();var reasons=new LinkedHashSet(); 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 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(); + 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();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 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 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 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(); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchAppointmentAllocations.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchAppointmentAllocations.java index 34434c8af..d5223c1d1 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchAppointmentAllocations.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchAppointmentAllocations.java @@ -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 reviewEvidence(String dispatchNo){return provider.reviewEvidence(dispatchNo);} + /** 本车货物已经由主单范围核实,选项不重新选货或制造托盘占用。 */ + public Map> options(long dispatch,List 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 candidates(List rows) { diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchBolReadService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchBolReadService.java index 4d361fde2..fc7450c88 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchBolReadService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchBolReadService.java @@ -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();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();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)); } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchDestinationAppointments.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchDestinationAppointments.java new file mode 100644 index 000000000..3c27a9f7c --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchDestinationAppointments.java @@ -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 resolve(List cargo, Maintenance maintenance, List booked, List cargoShipments) { + var destinations = new TreeMap(); + 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(); + if (maintenance != null) for (var entry : maintenance.destinationAppointments()) entered.put(entry.destinationKey(),entry.appointmentNo()); + var result = new ArrayList(); + 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 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 destinations, Instant departure, Instant arrival, List 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; + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchDraftService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchDraftService.java index a6c4667e6..c4f2113db 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchDraftService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchDraftService.java @@ -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 readCandidateBatches(List 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(); for(int offset=0;offset 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(); if(result==null)reasons.add("预约批次实物读取不完整,请重新核对来源");else reasons.addAll(result.blockReasons()); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchInboundReceiptService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchInboundReceiptService.java new file mode 100644 index 000000000..d07d57d93 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchInboundReceiptService.java @@ -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 planIds) { + var numbers=repository.receiptDispatches(planIds); + @SuppressWarnings("unchecked") var previous=(Set)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 planIds, String operator, LocalDateTime at) { + var numbers=repository.receiptDispatches(planIds); + @SuppressWarnings("unchecked") var locked=(Set)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(); + 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); } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchPdaQueryService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchPdaQueryService.java index 0272daf1a..e32cfe195 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchPdaQueryService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchPdaQueryService.java @@ -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 assemble(Actor actor,List 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(); 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 receivedPlanPositions(Actor actor,DispatchItem item,List 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作业");} /** 缺失计数不作为业务标识使用。 */ diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchPickingService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchPickingService.java index 79dd6bc11..9faad5cba 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchPickingService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchPickingService.java @@ -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 items) { - long dispatch=row.getId(); - if(item.getWarehousePalletId()!=null) { - relabelSources.validateFrozen(row,items,sourceRules.lockExecutionSources(row.getOverseasWarehouseId(),items));return; - } - var received=new LinkedHashMap(); - 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());} /** 不可变扫码证据序列化失败随根命令回滚。 */ diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchReceivedPlan.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchReceivedPlan.java index 2eb896b59..b23211c9d 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchReceivedPlan.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchReceivedPlan.java @@ -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 positions) { } - - /** 仅解析原车完整来源,禁止把新客户、新订单或冻结货物混入实收数量。 */ - static Optional resolve(String warehouse, DispatchItem item, List items, - DispatchSourceRepository sources, DispatchService rules) { + /** 原客户、计划及目的地保持一致,数量直接取确认入库的逐票实收。 */ + static Optional 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); } /** 箱数允许随自然实收变化,客户、原计划、起运仓和目的地必须保持原身份。 */ diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchService.java index 3d9e901ca..5b1527594 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchService.java @@ -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 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 items) { diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/reporting/TripOperationsService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/reporting/TripOperationsService.java index 9460c8b9d..0826034ea 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/reporting/TripOperationsService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/reporting/TripOperationsService.java @@ -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(); var seen=new HashSet(); + long own=0,external=0; var destinations=new TreeMap(); + var cargoIdentity=new HashMap>(); var shippedByPallet=new HashMap(); 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("外接实收超过实际发车货量"); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetBookingService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetBookingService.java index 82e53d986..73e8df782 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetBookingService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetBookingService.java @@ -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 incomingStatuses(java.util.List 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);} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetResourceMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetResourceMapper.java index a1675fc05..081d616b2 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetResourceMapper.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetResourceMapper.java @@ -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 unavailableVehicles(@Param("dispatchNo") String dispatchNo); record Assignment(String resourceKind, long resourceId, long tripId, String tripNo) { } } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetTripCalendarRepository.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetTripCalendarRepository.java index 3e877bc27..21f0e00c2 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetTripCalendarRepository.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetTripCalendarRepository.java @@ -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 sourceSnapshots(List 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(); } } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetTripCalendarService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetTripCalendarService.java index caeabfde1..8c70177db 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetTripCalendarService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetTripCalendarService.java @@ -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 stops, List containerNumbers, List incompleteReasons) { } + List stops, List containerNumbers, List cabinetNumbers, List incompleteReasons) { } public record Result(List records, List unresolvedRecords) { } + private record ContainerReferences(List containerNumbers, List cabinetNumbers) { } private static final Set 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(); var unresolved=new ArrayList(); @@ -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> containerNumbers(List rows) { + /** 完整柜号取排车货物快照,柜次短号取同客户真实来源的原装柜事件,分别去空去重。 */ + private Map containerReferences(List rows) { var numbers=rows.stream().map(FleetTripCalendarRow::getDispatchNo).distinct().sorted().toList(); - var requested=new HashSet<>(numbers);var grouped=new LinkedHashMap>(); + var requested=new HashSet<>(numbers); + var containers=new LinkedHashMap>(); + var cabinets=new LinkedHashMap>(); 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>(); - grouped.forEach((number,values)->result.put(number,List.copyOf(values)));return Map.copyOf(result); + var result=new LinkedHashMap(); + 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(); } } /** 元数据批次必须完整覆盖请求身份,未知地点保持显式未核实。 */ diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetTripMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetTripMapper.java index 391401a36..d335449ba 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetTripMapper.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetTripMapper.java @@ -19,12 +19,14 @@ public interface FleetTripMapper extends BaseMapper { ORDER BY COALESCE(t.departed_at,t.planned_departure_at),d.dispatch_no LIMIT 2001 """) java.util.List 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(""" + """) + List allocationPhysical(@Param("dispatchId") long dispatchId,@Param("items") List items); /** 一次读取整页逐票来源及占用;仅预留但未收货的托盘仍按计划箱数预排。 */ @Select(""" """) - List candidateBatches(@Param("refs") List refs,@Param("orders") List orders); + List candidateBatches(@Param("refs") List refs,@Param("orders") List orders,@Param("excludedDispatchId") long excludedDispatchId); @Select("""