diff --git a/AGENTS.md b/AGENTS.md index 955eb3f5e..45cec9723 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -18,7 +18,7 @@ | 任务关键词 | 首选服务 | 先看入口 | |---|---|---| -| 登录、Token、密码、注册 | `auth/` | `auth/AGENTS.md` | +| 登录、Token、密码、注册与顶层授权快照 | `auth/` | `auth/AGENTS.md`;海外 Token 只携带稳定身份与版本,角色、权限和冻结海外仓从登录/刷新响应顶层快照读取,海外 Gateway 再按 IAM 安全投影失败关闭校验 | | 客户档案、成员、审核、RBAC、授权快照 | `iam/` | `iam/AGENTS.md` | | 客户建档默认尺寸初始化与补偿 | `iam/`、`firstleg/` | IAM `CustomerDimensionRuleInitializationService` 持久化意图、`CustomerDimensionInitializationRelay` 后台投递;Firstleg 内部入口 `/internal/firstleg/v1/customer-dimension-configs/defaults`,未 `READY` 不得审核通过 | | 消息中心、站内通知、消息模板、收件箱 | `iam/` | `iam/AGENTS.md`,再按 `src/main/java/com/baiyue/iam/notification/` 定位;业务事件生产者回到所属服务 | @@ -27,7 +27,7 @@ | 平台修改客户原始申报、履约渠道变更与跨服务同步 | `order/`、`firstleg/` | Order `OrderDeclarationUpdateService.java`、`OrderInboundOutboxPublisher.java`、`order-v1.yaml`;Firstleg `OrderDeclarationSyncService.java`、`OrderDeclarationSyncMapper.java`、`firstleg-v1.yaml`。只更新申报快照;渠道变化先失败关闭校验履约可逆性与重新计价,成功后由版本化 `ORDER_DECLARATION_UPDATED_V1` 幂等同步,不覆盖实测、箱码或已确认费用 | | 履约分配、入库、测量、箱规算法、审核、FBA 计费 | `firstleg/` | `firstleg/AGENTS.md`;客户四档箱规与 `ADD/MULTIPLY` 默认算法从 `CustomerDimensionConfigService` 进入,单箱测量和最终计价分别由 `CustomerDimensionWeightingService`、`FbaPricingService` 保存并消费服务端快照 | | 入库单扣货、解除、凭证与履约门禁 | `firstleg/`、`file/` | Firstleg `InboundHoldService`、`InboundHoldGuard`、`InboundHoldEvidenceService`;平台接口 `/api/platform/inbounds/{inboundId}/hold`、`/release`、`/evidence`。扣货不改变 Order 主状态,命令使用入库聚合版本和幂等键;凭证只按客户、入库单及 `INBOUND_HOLD_EVIDENCE` 精确归属,所有装柜、PDA 拣货和国内出仓写入口统一复用门禁并失败关闭 | -| 称重确认、整票库位块与 PDA 资源结算 | `firstleg/`、`warehouse/` | Firstleg `MeasurementLocationOccupationService.java`、`InboundMeasurementService.java`、`WarehousePalletLocationClient.java`;Warehouse `LocationBlockService.java` 与 `location-block-v1.yaml`。首张称重确认分配整票块,测量提交只复用;部分拣货整段转移给余量子单,整票拣完才整块释放,A0 共享资源按 `reservationId` 区分 | +| 称重确认、整票库位块与 PDA 资源结算 | `firstleg/`、`warehouse/` | Firstleg `MeasurementLocationOccupationService.java`、`InboundMeasurementService.java`、`MeasurementSubmissionService.java`、`WarehousePalletLocationClient.java`;Warehouse `LocationBlockService.java` 与 `location-block-v1.yaml`。称重确认尽力建块、提交时再次补齐,库位指引失败不阻断测量主流程;部分拣货整段转移给余量子单,整票拣完才整块释放,A0 共享资源按 `reservationId` 区分 | | Firstleg 内部模块化与依赖基线 | `firstleg/` | `firstleg/docs/firstleg-internal-modularization-baseline.md`、`firstleg/scripts/analyze-firstleg-dependencies.mjs`;价格簿查询和客户统计按端口/适配器切片,不改变服务与数据库边界 | | Mapper 新增、移动、Bean 缺失与扫描遗漏 | 目标持久化服务 | 所有 Mapper 显式 `@Mapper`,启动类按服务根包注解过滤扫描;先运行 `node scripts/mapper-scan-contract.mjs`,再按目标模块执行 `test-compile/test`,不得等部署启动发现 | | 脚本管理、全量验证、测试造数与诊断 | 仓库根 `scripts/`;服务专属脚本留在 `/scripts/` | 先读 `scripts/AGENTS.md` 和 `scripts/README.md`;Admin HTTP 内部模块继续读 `scripts/admin-test-env-modules/README.md`。新增入口必须登记用途、目标环境、数据副作用和报告位置,并通过 `scripts/script-documentation-contract.test.mjs` | @@ -36,8 +36,9 @@ | 全局智能装柜候选、柜次、占位与移除 | `firstleg/`、`order/` | Firstleg `controller/PlatformSmartLoadingController.java` 的 `/inbound-candidates`、`/containers`、`/plans/{planId}/inbound-assignments`、`/pending-review-assignments`、`/group-assignments`、`/unpicked-items`,业务编排 `service/SmartLoadingService.java`;待审核事实由 `client/PendingReviewOrderClient.java` 经内部令牌查询 Order `/internal/order/v1/pending-review-orders` 与 `PendingReviewOrderQueryService.java` 的权威 `SUBMITTED/PENDING_REVIEW` 行。候选四态只用于筛选展示,资格以 `canAssign` 为准;待审核或待测量可先占位,确认预装和启动 PDA 前仍须进入 `WAIT_SPLIT`;计划明细总量/余量由 Order 原始申报或最新已审核测量补齐,调整时重验权威数量;人工加入允许跨渠道,智能推荐仍按柜次渠道;同柜组整组分页、分配和移除 | | 客户入库结果与头程费用确认 | `firstleg/` | `controller/MerchantInboundBillingController.java`、`service/MerchantInboundBillingService.java`、`FbaPricingService.java`,接口 `/api/merchant/v1/first-leg/orders/{orderId}/inbound-billing` 与 `/confirm`;客户按登录态范围读取并以费用版本和幂等键确认当前 `SENT` 费用,确认后锁定 | | 订舱资料/打印、船期、订舱级拖车、报关 | `firstleg/` | `oceanbooking/`、`portschedule/`、`TruckAppointmentService.java`、`outbound/dispatch/`、`customs/`;拖车只绑定 `RELEASED` 订舱,不携带客户、柜次、装柜计划或真实柜号,每订舱最多一张非终态预约,订舱文件联查 `file/AGENTS.md` | +| 目的港码头拖车与还空预警 | `firstleg/` | `terminaldrayage/controller/PlatformTerminalDrayageController.java`、`service/TerminalDrayageService.java`、`src/main/resources/openapi/terminal-drayage-v1.yaml`;按 `containerBookingId` 聚合并与订舱级拖车分离,引用 Warehouse `ACTIVE + TRUCKING` 供应商,提柜/还柜复用船节点,服务端按海外卸柜完成时间加 72 小时计算还空预警 | | 客户账期、费用池、账单、入仓码联查、收款、成本、核销 | `finance/` | `finance/AGENTS.md`;客户账单行 `inboundCode` 由 `FinanceQueryService` 从费用来源快照提供,Client 复用头程入仓关系详情;费用来源和柜次成本快照再联查 `firstleg/AGENTS.md`,凭证再联查 `file/AGENTS.md` | -| 客户资金账户、充值、授信与跨区资金指令 | `finance/`、`lastleg-service/`、`common-event-contract/`、`event-bridge-service/` | 国内 Finance `FinanceWalletService` 拥有唯一 CNY 账本;平台/客户入口分别见 `PlatformFinanceWalletController`、`ClientFinanceWalletController`。海外采购和换标由 Lastleg `wallet/` 发布 `WALLET_COMMAND_REQUESTED`,Finance 冻结、结算或释放后发布 `WALLET_COMMAND_COMPLETED` 回传,不跨区域同步调用钱包 | +| 客户资金账户、充值、授信、冻结、结算与跨区资金指令 | `finance/`、`lastleg-service/`、`common-event-contract/`、`event-bridge-service/` | 国内 `FinanceWalletService.java` 持有唯一 CNY 主账和不可变流水;平台、客户、内部入口见 `PlatformFinanceWalletController.java`、`ClientFinanceWalletController.java`、`InternalFinanceWalletController.java` 与 `finance-wallet-v1.yaml`。海外采购和换标由 Lastleg `wallet/` 发布 `WALLET_COMMAND_REQUESTED`,Finance 冻结、结算或释放后发布 `WALLET_COMMAND_COMPLETED` 回传,不跨区域同步调用钱包 | | 柜级公共费用跨客户分摊 | `firstleg/`、`finance/` | Firstleg `PlatformContainerFeeDetailController.java`、`ContainerFeeDetailService.java`、`container-fees-v1.yaml` 按柜内真实客户与订单原子创建 `allocation-groups`;金额合计必须等于总额,分组审核、驳回、调整、作废均整组处理,Finance 只消费各客户不可变来源快照 | | 海外业务费用来源跨区入池 | `lastleg-service/`、`common-event-contract/`、`event-bridge-service/`、`finance/` | Lastleg `financefee/` 审核五类海外费用并发布 `BUSINESS_FEE_SOURCE_CHANGED` 完整快照;Event Bridge 对 `targetRegion=CN` 定向投递 Finance `/internal/finance/v1/business-fee-source-events`;Finance 按事件 ID、Payload 哈希、来源系统/费用 ID/版本幂等入池,`PAYABLE + DIRECT_PAID` 单独形成已确认成本且不进入 AP 待结算 | | 入库/柜次应付、直接已付、付款流水与凭证 | `firstleg/`、`finance/`、`file/` | 业务费用与付款快照见 `firstleg/AGENTS.md` 的 `FeePaymentSnapshotValidator`;`/api/platform/finance/v1/vouchers` 上传凭证,File 按 `FINANCE_VOUCHER` 和客户范围校验 | @@ -50,8 +51,8 @@ | 百度/Amazon/OCR/第三方适配 | `integration-service/` | `integration-service/AGENTS.md` | | 海外 API 路由、海外 Token、Audience/JWKS | `gateway-overseas/` | `gateway-overseas/AGENTS.md` | | 海外 IAM 状态与权限版本投影 | `lastleg-service/security/` | `lastleg-service/AGENTS.md`;内部入口 `/internal/lastleg/security/v1/events`、`/validate`;消费三类 IAM 安全事件并为海外 Gateway 提供 fail-closed 校验,投影表归 `baiyue_lastleg` | -| 尾程订单、海外入仓计划、换标、库存、采购、本土卡车 | `lastleg-service/` | `lastleg-service/AGENTS.md`,再按 `inboundplan/`、`relabel/`、`inventory/`、`procurement/`、`trucking/` 定位 | -| 柜级入仓计划进度与整柜转换 | `lastleg-service/` | `orders/container-summaries` 按 `sourceContainerBookingId` 跨客户聚合,分页前排除全部入仓码均已生成有效独立计划的柜,只分页返回仍有入仓码待生成有效独立计划的柜;详情返回柜内全部订单/入仓码/计划且不受订单分页截断;`plannedArrivalAt` 只取装柜完成事件 `estimatedWarehouseArrivalAt` 冻结事实并升序、空值最后;`inbound-plans/convert-container` 锁定同柜订单,每票生成独立计划,整批原子且幂等 | +| 尾程订单、海外入仓计划、换标、库存、采购、本土卡车 | `lastleg-service/` | `lastleg-service/AGENTS.md`,尾程订单控制从 `LastlegOrderController.java`、`LastlegOrderCommandService.java` 进入,再按 `inboundplan/`、`relabel/`、`inventory/`、`procurement/`、`trucking/` 定位 | +| 尾程订单控制与柜级入仓计划转换 | `lastleg-service/` | `orders/container-summaries` 按 `sourceContainerBookingId` 跨客户聚合,分页前排除全部入仓码均已生成有效独立计划的柜,详情返回全柜数据;`inbound-plans/convert-container` 锁定同柜订单,每票生成独立计划,整批原子且幂等。单票完整入库计划进入 `InboundPlanService.java`,暂扣/解除和固定八行人工节点进入 `LastlegOrderCommandService.java`;命令按订单版本、客户范围和幂等键执行,暂扣中的订单不得进入排车或出库 | | 海外入仓分页、FBA 预约、PDA 柜号签收与入仓 | `lastleg-service/` | 平台入口 `inboundplan/controller/InboundPlanController.java`、`InboundPlanQueryMapper.java`、`InboundFbaAppointmentService.java`;PDA 公网入口 `inboundplan/pda/controller/ReceivingContainerController.java`、`ReceivingContainerService.java`,旧 planNo/allocationId Controller 已移除 | | 海外仓排车查询、候选与智能预排 | `lastleg-service/` | `dispatch/controller/DispatchQueryController.java`、`SmartDispatchController.java`;草稿柜、托盘锁和预排归 `dispatch/`,只读排车和预排创建独立开关,不自动开放编辑、取消、发车、PDA 出库或派送 | | 海外排车执行、PDA 出库、派送与一件代发 | `lastleg-service/` | `dispatch/controller/DispatchController.java`、`dispatch/pda/controller/DispatchPdaController.java`、`delivery/controller/DeliveryTransferController.java`、`OutboundPdaController.java`、`MerchantDeliveryController.java`,一件代发库存归 `dropship/`;统一经 `gateway-overseas/`,设备端正式入口继续查 `../apps/pda-overseas-uniapp/AGENTS.md` | @@ -112,6 +113,7 @@ - 固定客户待审核订单使用 `scripts/create-pending-review-orders.mjs`;固定“IT测试”与“义乌仓”、随机真实线路并推进至 `WAIT_SPLIT` 使用 `scripts/it-test-wait-split-bulk.mjs`。二者都是造数入口,不属于全量验证;会写目标环境且不自动清理成功数据。 - Admin 总入口默认按模块列表覆盖平台全链路;需要定位单个业务时使用 `--modules `,模块名称和静态合同先查 `scripts/admin-test-env-modules/README.md`。 - 订舱与拖车跨服务完整状态链由 Admin 注册模块 `service-center-flow` 验证,必须复用本轮已放舱订舱并由总入口展开依赖,不得直接执行模块文件或借历史数据。 +- 尾程公网控制由 `phase4-tail-order-controls` 在海外 PDA 前验证整柜接柜仓变更与恢复、单/批目的仓变更、单/批合托标记、CSV 导出、订单费用、暂扣/解除、八个人工节点和逐票人工计划;目的港码头拖车由 `phase5-terminal-drayage` 在卸柜后验证选车队、预约提柜、实际提柜和实际还柜,`phase4-carrier-nodes` 只读核验最终船节点终态。 - 这些脚本会创建、更新或最终停用测试数据,必须在当前对话明确授权测试环境验收后执行,并保存 JSON 报告;未授权不得请求 Gateway。 - 后端 Maven、前端 `pnpm test/typecheck`、PDA `node --test` 和数据库 Dry-run 都是本地质量检查,不属于全量 HTTP 验收。 - Java 模块、OpenAPI 覆盖、JaCoCo 与 SpotBugs 聚合摘要使用 `node scripts/java-quality-summary.mjs`;默认仅报告存量缺口,是否阻断由显式 `--require-*` 参数决定,输出位于 `target/` 时不得作为业务事实或提交物。 diff --git a/deploy/sql/头程数据库统一增量-MySQL8.sql b/deploy/sql/头程数据库统一增量-MySQL8.sql index a90caa5bb..ac44ef196 100644 --- a/deploy/sql/头程数据库统一增量-MySQL8.sql +++ b/deploy/sql/头程数据库统一增量-MySQL8.sql @@ -18019,6 +18019,107 @@ DELIMITER ; CALL assert_wallet_event_schema(); DROP PROCEDURE IF EXISTS assert_wallet_event_schema; +-- 目的港码头拖车、提柜与还空闭环(模块来源:phase3-terminal-drayage-schema.sql) +USE baiyue_firstleg; +CREATE TABLE IF NOT EXISTS fl_terminal_drayage ( + id BIGINT UNSIGNED NOT NULL COMMENT '目的港码头拖车聚合ID', container_booking_id BIGINT UNSIGNED NOT NULL COMMENT '唯一归属物理货柜ID', + fleet_supplier_id BIGINT UNSIGNED NOT NULL COMMENT 'Warehouse ACTIVE/TRUCKING供应商ID快照', fleet_supplier_name VARCHAR(128) NOT NULL COMMENT 'Warehouse供应商名称快照', + pickup_appointment_at DATETIME(3) NULL COMMENT '预约提柜时间', actual_pickup_at DATETIME(3) NULL COMMENT '实际提柜时间', + estimated_empty_return_at DATETIME(3) NULL COMMENT '服务端按卸柜完成时间加72小时计算的预计还柜时间', actual_empty_return_at DATETIME(3) NULL COMMENT '实际还空柜时间', + version INT UNSIGNED NOT NULL DEFAULT 1 COMMENT '乐观锁版本号', created_by BIGINT UNSIGNED NOT NULL COMMENT '创建人ID', + created_by_name VARCHAR(64) NULL COMMENT '创建人名称快照', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', + updated_by BIGINT UNSIGNED NOT NULL COMMENT '最后更新人ID', updated_by_name VARCHAR(64) NULL COMMENT '最后更新人名称快照', + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间', + PRIMARY KEY(id), UNIQUE KEY uk_terminal_drayage_container(container_booking_id), + KEY idx_terminal_drayage_warning(actual_empty_return_at,estimated_empty_return_at), + CONSTRAINT fk_terminal_drayage_container FOREIGN KEY(container_booking_id) REFERENCES fl_container_booking(id), + CONSTRAINT chk_terminal_drayage_times CHECK((actual_pickup_at IS NULL OR pickup_appointment_at IS NOT NULL) + AND (actual_empty_return_at IS NULL OR actual_pickup_at IS NOT NULL) + AND (actual_empty_return_at IS NULL OR actual_empty_return_at >= actual_pickup_at)) +) ENGINE=InnoDB COMMENT='目的港货柜级码头拖车与还空事实'; +CREATE TABLE IF NOT EXISTS fl_terminal_drayage_history ( + id BIGINT UNSIGNED NOT NULL COMMENT '历史ID', drayage_id BIGINT UNSIGNED NOT NULL COMMENT '码头拖车聚合ID', + container_booking_id BIGINT UNSIGNED NOT NULL COMMENT '货柜ID快照', action VARCHAR(32) NOT NULL COMMENT '业务动作', + from_status VARCHAR(32) NOT NULL COMMENT '操作前状态', to_status VARCHAR(32) NOT NULL COMMENT '操作后状态', + from_version INT UNSIGNED NOT NULL COMMENT '操作前版本', to_version INT UNSIGNED NOT NULL COMMENT '操作后版本', + reason VARCHAR(500) NULL COMMENT '操作说明', operator_id BIGINT UNSIGNED NOT NULL COMMENT '操作人ID', operator_name VARCHAR(64) NULL COMMENT '操作人名称快照', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '操作时间', PRIMARY KEY(id), + KEY idx_terminal_drayage_history(container_booking_id,created_at,id), + CONSTRAINT fk_terminal_drayage_history FOREIGN KEY(drayage_id) REFERENCES fl_terminal_drayage(id), + CONSTRAINT chk_terminal_drayage_history_action CHECK(action IN + ('SELECT_FLEET','SCHEDULE_PICKUP','RECORD_PICKUP','RECORD_EMPTY_RETURN')) +) ENGINE=InnoDB COMMENT='目的港码头拖车不可变操作历史'; +CREATE TABLE IF NOT EXISTS fl_terminal_drayage_operation ( + id BIGINT UNSIGNED NOT NULL COMMENT '幂等操作ID', container_booking_id BIGINT UNSIGNED NOT NULL COMMENT '货柜ID', + operation VARCHAR(32) NOT NULL COMMENT '业务动作', idempotency_key VARCHAR(255) NOT NULL COMMENT '客户端幂等键', + request_hash CHAR(64) NOT NULL COMMENT '请求载荷SHA-256', result_version INT UNSIGNED NOT NULL COMMENT '成功结果版本', + operator_id BIGINT UNSIGNED NOT NULL COMMENT '操作人ID', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '完成时间', + PRIMARY KEY(id), UNIQUE KEY uk_terminal_drayage_operation(container_booking_id,idempotency_key), + KEY idx_terminal_drayage_operation(container_booking_id,created_at), + CONSTRAINT fk_terminal_drayage_operation_container FOREIGN KEY(container_booking_id) REFERENCES fl_container_booking(id) +) ENGINE=InnoDB COMMENT='目的港码头拖车成功幂等操作'; + +-- MySQL DDL 会隐式提交;如果前次执行在末段校验失败,重跑时需把已创建表的列注释收敛到当前基线。 +ALTER TABLE fl_terminal_drayage + MODIFY COLUMN id BIGINT UNSIGNED NOT NULL COMMENT '目的港码头拖车聚合ID', + MODIFY COLUMN container_booking_id BIGINT UNSIGNED NOT NULL COMMENT '唯一归属物理货柜ID', + MODIFY COLUMN fleet_supplier_id BIGINT UNSIGNED NOT NULL COMMENT 'Warehouse ACTIVE/TRUCKING供应商ID快照', + MODIFY COLUMN fleet_supplier_name VARCHAR(128) NOT NULL COMMENT 'Warehouse供应商名称快照', + MODIFY COLUMN pickup_appointment_at DATETIME(3) NULL COMMENT '预约提柜时间', + MODIFY COLUMN actual_pickup_at DATETIME(3) NULL COMMENT '实际提柜时间', + MODIFY COLUMN estimated_empty_return_at DATETIME(3) NULL COMMENT '服务端按卸柜完成时间加72小时计算的预计还柜时间', + MODIFY COLUMN actual_empty_return_at DATETIME(3) NULL COMMENT '实际还空柜时间', + MODIFY COLUMN version INT UNSIGNED NOT NULL DEFAULT 1 COMMENT '乐观锁版本号', + MODIFY COLUMN created_by BIGINT UNSIGNED NOT NULL COMMENT '创建人ID', + MODIFY COLUMN created_by_name VARCHAR(64) NULL COMMENT '创建人名称快照', + MODIFY COLUMN created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', + MODIFY COLUMN updated_by BIGINT UNSIGNED NOT NULL COMMENT '最后更新人ID', + MODIFY COLUMN updated_by_name VARCHAR(64) NULL COMMENT '最后更新人名称快照', + MODIFY COLUMN updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间'; + +ALTER TABLE fl_terminal_drayage_history + MODIFY COLUMN id BIGINT UNSIGNED NOT NULL COMMENT '历史ID', + MODIFY COLUMN drayage_id BIGINT UNSIGNED NOT NULL COMMENT '码头拖车聚合ID', + MODIFY COLUMN container_booking_id BIGINT UNSIGNED NOT NULL COMMENT '货柜ID快照', + MODIFY COLUMN action VARCHAR(32) NOT NULL COMMENT '业务动作', + MODIFY COLUMN from_status VARCHAR(32) NOT NULL COMMENT '操作前状态', + MODIFY COLUMN to_status VARCHAR(32) NOT NULL COMMENT '操作后状态', + MODIFY COLUMN from_version INT UNSIGNED NOT NULL COMMENT '操作前版本', + MODIFY COLUMN to_version INT UNSIGNED NOT NULL COMMENT '操作后版本', + MODIFY COLUMN reason VARCHAR(500) NULL COMMENT '操作说明', + MODIFY COLUMN operator_id BIGINT UNSIGNED NOT NULL COMMENT '操作人ID', + MODIFY COLUMN operator_name VARCHAR(64) NULL COMMENT '操作人名称快照', + MODIFY COLUMN created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '操作时间'; + +ALTER TABLE fl_terminal_drayage_operation + MODIFY COLUMN id BIGINT UNSIGNED NOT NULL COMMENT '幂等操作ID', + MODIFY COLUMN container_booking_id BIGINT UNSIGNED NOT NULL COMMENT '货柜ID', + MODIFY COLUMN operation VARCHAR(32) NOT NULL COMMENT '业务动作', + MODIFY COLUMN idempotency_key VARCHAR(255) NOT NULL COMMENT '客户端幂等键', + MODIFY COLUMN request_hash CHAR(64) NOT NULL COMMENT '请求载荷SHA-256', + MODIFY COLUMN result_version INT UNSIGNED NOT NULL COMMENT '成功结果版本', + MODIFY COLUMN operator_id BIGINT UNSIGNED NOT NULL COMMENT '操作人ID', + MODIFY COLUMN created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '完成时间'; + +DROP PROCEDURE IF EXISTS assert_terminal_drayage_schema; +DELIMITER $$ +CREATE PROCEDURE assert_terminal_drayage_schema() +BEGIN + IF (SELECT COUNT(*) FROM information_schema.TABLES WHERE TABLE_SCHEMA='baiyue_firstleg' + AND TABLE_NAME IN ('fl_terminal_drayage','fl_terminal_drayage_history','fl_terminal_drayage_operation')) <> 3 THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='统一迁移失败:目的港码头拖车表不完整'; + END IF; + IF (SELECT COUNT(*) FROM information_schema.STATISTICS WHERE TABLE_SCHEMA='baiyue_firstleg' + AND TABLE_NAME='fl_terminal_drayage' AND INDEX_NAME='uk_terminal_drayage_container' AND NON_UNIQUE=0) <> 1 + OR (SELECT COUNT(*) FROM information_schema.STATISTICS WHERE TABLE_SCHEMA='baiyue_firstleg' + AND TABLE_NAME='fl_terminal_drayage_operation' AND INDEX_NAME='uk_terminal_drayage_operation' AND NON_UNIQUE=0) <> 2 THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='统一迁移失败:目的港码头拖车唯一约束不完整'; + END IF; +END$$ +DELIMITER ; +CALL assert_terminal_drayage_schema(); +DROP PROCEDURE IF EXISTS assert_terminal_drayage_schema; + CALL baiyue_firstleg.assert_unified_final_schema(); DROP PROCEDURE IF EXISTS baiyue_firstleg.assert_unified_final_schema; SELECT 'PASS' AS migration_status, diff --git a/deploy/sql/海外区域统一增量-MySQL8.sql b/deploy/sql/海外区域统一增量-MySQL8.sql index d2bf626c5..7b0bb79c2 100644 --- a/deploy/sql/海外区域统一增量-MySQL8.sql +++ b/deploy/sql/海外区域统一增量-MySQL8.sql @@ -219,6 +219,7 @@ SET @ddl = (SELECT IF(EXISTS ( WHERE table_schema=DATABASE() AND table_name='ll_overseas_procurement_order' AND column_name='review_idempotency_key' ), 'SELECT 1', 'ALTER TABLE ll_overseas_procurement_order ADD COLUMN review_idempotency_key VARCHAR(255) NULL COMMENT ''审核幂等键'' AFTER review_remark')); PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; + SET @ddl = (SELECT IF(EXISTS ( SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_overseas_procurement_order' AND column_name='review_request_hash' @@ -517,6 +518,8 @@ SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE tab 'ALTER TABLE ll_order MODIFY COLUMN destination_type VARCHAR(32) NULL COMMENT ''目的类型:FBA按箱数接收、OVERSEAS逐箱扫码''', 'ALTER TABLE ll_order ADD COLUMN destination_type VARCHAR(32) NULL COMMENT ''目的类型:FBA按箱数接收、OVERSEAS逐箱扫码'' AFTER order_type')); PREPARE phase4_destination_type_stmt FROM @ddl; EXECUTE phase4_destination_type_stmt; DEALLOCATE PREPARE phase4_destination_type_stmt; +ALTER TABLE ll_order + MODIFY COLUMN inbound_code VARCHAR(128) NULL COMMENT '真实来源入仓码;无入仓关系的海外本土业务为空'; CREATE TABLE IF NOT EXISTS ll_order_no_daily_sequence ( business_date DATE NOT NULL COMMENT '业务日期(Asia/Shanghai)', @@ -1754,6 +1757,11 @@ SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE tab SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_relabel_application' AND column_name='wallet_error_code'), 'SELECT 1', 'ALTER TABLE ll_relabel_application ADD COLUMN wallet_error_code VARCHAR(64) NULL COMMENT ''资金错误码'' AFTER wallet_status')); PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_relabel_application' AND column_name='wallet_error_message'), 'SELECT 1', 'ALTER TABLE ll_relabel_application ADD COLUMN wallet_error_message VARCHAR(500) NULL COMMENT ''资金错误说明'' AFTER wallet_error_code')); PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_relabel_application' AND column_name='wallet_command_event_id'), 'SELECT 1', 'ALTER TABLE ll_relabel_application ADD COLUMN wallet_command_event_id VARCHAR(64) NULL COMMENT ''当前资金指令事件ID'' AFTER wallet_error_message')); PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_relabel_item' AND column_name='remark'), 'SELECT 1', 'ALTER TABLE ll_relabel_item ADD COLUMN remark VARCHAR(1000) NULL COMMENT ''货物明细备注'' AFTER label_requirement')); PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_relabel_item' AND column_name='carton_mark'), 'SELECT 1', 'ALTER TABLE ll_relabel_item ADD COLUMN carton_mark VARCHAR(255) NULL COMMENT ''客户自有箱唛'' AFTER relabel_file_url')); PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_relabel_item' AND column_name='fba_carton_mark'), 'SELECT 1', 'ALTER TABLE ll_relabel_item ADD COLUMN fba_carton_mark VARCHAR(255) NULL COMMENT ''关联FBA箱唛'' AFTER carton_mark')); PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_relabel_item' AND column_name='destination_warehouse_id'), 'SELECT 1', 'ALTER TABLE ll_relabel_item ADD COLUMN destination_warehouse_id VARCHAR(128) NULL COMMENT ''该箱目的海外仓ID'' AFTER fba_carton_mark')); PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='ll_relabel_item' AND index_name='idx_ll_relabel_item_destination'), 'SELECT 1', 'ALTER TABLE ll_relabel_item ADD KEY idx_ll_relabel_item_destination (customer_id,destination_warehouse_id,application_id)')); PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; @@ -1769,3 +1777,177 @@ CREATE TABLE IF NOT EXISTS ll_destuff_pallet_cost_allocation ( PRIMARY KEY(id), UNIQUE KEY uk_ll_destuff_customer(cost_id,customer_id), UNIQUE KEY uk_ll_destuff_fee_source(fee_source_id), CONSTRAINT chk_ll_destuff_allocation CHECK(actual_volume_cbm>0 AND allocated_amount>=0) ) ENGINE=InnoDB COMMENT='拆柜打托成本按客户实收体积分摊'; + + +-- 海外接柜仓由平台在尾程建单时预分配;历史测试数据不回填,缺失事实由业务接口失败关闭。 +USE baiyue_lastleg; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() + AND table_name='ll_order' AND column_name='overseas_warehouse_code'), + 'ALTER TABLE ll_order MODIFY COLUMN overseas_warehouse_code VARCHAR(128) NULL COMMENT ''海外接柜仓业务编码快照''', + 'ALTER TABLE ll_order ADD COLUMN overseas_warehouse_code VARCHAR(128) NULL COMMENT ''海外接柜仓业务编码快照'' AFTER overseas_warehouse_id')); +PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() + AND table_name='ll_order' AND column_name='receiving_warehouse_version'), + 'ALTER TABLE ll_order MODIFY COLUMN receiving_warehouse_version INT NOT NULL DEFAULT 0 COMMENT ''柜级接柜仓分配乐观锁版本''', + 'ALTER TABLE ll_order ADD COLUMN receiving_warehouse_version INT NOT NULL DEFAULT 0 COMMENT ''柜级接柜仓分配乐观锁版本'' AFTER overseas_warehouse_name')); +PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; +ALTER TABLE ll_order + MODIFY COLUMN overseas_warehouse_id VARCHAR(128) NULL COMMENT '海外接柜仓ID;新建尾程订单默认洛杉矶仓', + MODIFY COLUMN overseas_warehouse_name VARCHAR(255) NULL COMMENT '海外接柜仓名称快照'; + +ALTER TABLE ll_inbound_plan + MODIFY COLUMN overseas_warehouse_id BIGINT NULL COMMENT '从尾程订单继承的平台预分配海外接柜仓ID'; + +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() + AND table_name='ll_receiving_container_session' AND column_name='overseas_warehouse_code'), + 'ALTER TABLE ll_receiving_container_session MODIFY COLUMN overseas_warehouse_code VARCHAR(128) NULL COMMENT ''海外接柜仓业务编码快照''', + 'ALTER TABLE ll_receiving_container_session ADD COLUMN overseas_warehouse_code VARCHAR(128) NULL COMMENT ''海外接柜仓业务编码快照'' AFTER overseas_warehouse_id')); +PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; +ALTER TABLE ll_receiving_container_session + MODIFY COLUMN overseas_warehouse_id BIGINT NOT NULL COMMENT '平台预分配并由操作员Token匹配的海外接柜仓ID', + MODIFY COLUMN overseas_warehouse_name VARCHAR(255) NOT NULL COMMENT '仓库服务校验后的海外接柜仓名称快照'; + +CREATE TABLE IF NOT EXISTS ll_receiving_warehouse_operation ( + id BIGINT NOT NULL COMMENT '柜级接柜仓修改操作ID', + source_container_booking_id VARCHAR(128) NOT NULL COMMENT '稳定来源柜预订ID', + idempotency_key VARCHAR(255) NOT NULL COMMENT '平台请求幂等键', + request_hash VARCHAR(64) NOT NULL COMMENT '规范化修改请求SHA-256摘要', + result_version INT NOT NULL COMMENT '修改成功后的柜级分配版本', + operator_name VARCHAR(128) NOT NULL COMMENT '平台操作人名称', + remark VARCHAR(500) NULL COMMENT '接柜仓修改备注', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '操作时间', + PRIMARY KEY(id), + UNIQUE KEY uk_ll_receiving_warehouse_idempotency(idempotency_key), + KEY idx_ll_receiving_warehouse_container(source_container_booking_id,created_at) +) ENGINE=InnoDB COMMENT='柜级海外接柜仓修改幂等审计记录'; + +DROP PROCEDURE IF EXISTS assert_receiving_warehouse_assignment_ready; +DELIMITER $$ +CREATE PROCEDURE assert_receiving_warehouse_assignment_ready() +BEGIN + IF (SELECT COUNT(*) FROM information_schema.columns WHERE table_schema='baiyue_lastleg' + AND table_name='ll_order' AND column_name IN ('overseas_warehouse_code','receiving_warehouse_version')) <> 2 THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='尾程订单海外接柜仓字段未完整部署'; + END IF; + IF (SELECT COUNT(*) FROM information_schema.columns WHERE table_schema='baiyue_lastleg' + AND table_name='ll_receiving_container_session' AND column_name='overseas_warehouse_code') <> 1 THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='海外PDA接柜会话仓库编码字段未部署'; + END IF; + IF (SELECT COUNT(*) FROM information_schema.tables WHERE table_schema='baiyue_lastleg' + AND table_name='ll_receiving_warehouse_operation') <> 1 THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='柜级海外接柜仓修改幂等审计表未部署'; + END IF; +END$$ +DELIMITER ; +CALL assert_receiving_warehouse_assignment_ready(); +DROP PROCEDURE assert_receiving_warehouse_assignment_ready; + +SELECT 'PASS' AS migration_status, '海外接柜仓预分配与柜级修改结构已就绪' AS message; + +-- 一件代发来源托盘与海外仓实物托盘分离,实物绑定不得覆盖来源身份。 +USE baiyue_lastleg; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() + AND table_name='ll_inbound_plan_pallet' AND column_name='source_pallet_no'), + 'SELECT 1', + 'ALTER TABLE ll_inbound_plan_pallet ADD COLUMN source_pallet_no VARCHAR(128) NULL COMMENT ''一件代发不可变来源托盘号快照'' AFTER pallet_no')); +PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; +ALTER TABLE ll_inbound_plan_pallet + MODIFY COLUMN pallet_no VARCHAR(64) NOT NULL COMMENT '当前托盘编号:分配前为计划占位码,分配后为海外仓实物托盘码'; + +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() + AND table_name='ll_inbound_stock_receipt' AND column_name='source_pallet_no'), + 'SELECT 1', + 'ALTER TABLE ll_inbound_stock_receipt ADD COLUMN source_pallet_no VARCHAR(128) NULL COMMENT ''一件代发不可变来源托盘号快照'' AFTER pallet_no')); +PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; +ALTER TABLE ll_inbound_stock_receipt + MODIFY COLUMN pallet_no VARCHAR(64) NOT NULL COMMENT '海外仓实物托盘码'; + +-- 尾程单票人工计划、暂扣与人工展示节点。 +USE baiyue_lastleg; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_order' AND column_name='hold_chargeable'), 'SELECT 1', 'ALTER TABLE ll_order ADD COLUMN hold_chargeable TINYINT NOT NULL DEFAULT 0 COMMENT ''当前暂扣是否计费:0否1是'' AFTER detained')); +PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_order' AND column_name='hold_reason'), 'SELECT 1', 'ALTER TABLE ll_order ADD COLUMN hold_reason VARCHAR(500) NULL COMMENT ''当前暂扣原因;解除后为空'' AFTER hold_chargeable')); +PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_order' AND column_name='manual_node_override'), 'SELECT 1', 'ALTER TABLE ll_order ADD COLUMN manual_node_override TINYINT NOT NULL DEFAULT 0 COMMENT ''是否启用单票人工计划节点覆盖:0否1是'' AFTER hold_reason')); +PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_order' AND column_name='version'), 'SELECT 1', 'ALTER TABLE ll_order ADD COLUMN version INT NOT NULL DEFAULT 0 COMMENT ''尾程订单命令乐观锁版本'' AFTER manual_node_override')); +PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +CREATE TABLE IF NOT EXISTS ll_order_command_record ( + id BIGINT NOT NULL COMMENT '尾程订单命令幂等记录ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户数据隔离键', + order_id BIGINT NOT NULL COMMENT '尾程订单ID', action VARCHAR(32) NOT NULL COMMENT '命令动作', operator_id VARCHAR(128) NOT NULL COMMENT '平台操作人ID', + idempotency_key VARCHAR(128) NOT NULL COMMENT '客户端幂等键', request_hash VARCHAR(64) NOT NULL COMMENT '规范化请求SHA-256摘要', + result_ref VARCHAR(128) NULL COMMENT '结果业务引用', result_json LONGTEXT NOT NULL COMMENT '首次成功响应JSON快照', + result_version INT NOT NULL COMMENT '命令成功后的订单版本', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '首次成功时间', + PRIMARY KEY(id), UNIQUE KEY uk_ll_order_command_key(customer_id,order_id,action,operator_id,idempotency_key), + KEY idx_ll_order_command_history(customer_id,order_id,created_at) +) ENGINE=InnoDB COMMENT='尾程订单写命令幂等与结果审计记录'; +CREATE TABLE IF NOT EXISTS ll_order_hold_history ( + id BIGINT NOT NULL COMMENT '尾程订单暂扣历史ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户数据隔离键', order_id BIGINT NOT NULL COMMENT '尾程订单ID', + held TINYINT NOT NULL COMMENT '操作后是否暂扣:0否1是', chargeable TINYINT NOT NULL COMMENT '本次暂扣是否计费:0否1是', reason VARCHAR(500) NOT NULL COMMENT '暂扣或解除原因', + order_version INT NOT NULL COMMENT '操作成功后的订单版本', operator_id VARCHAR(128) NOT NULL COMMENT '操作人ID', operator_name VARCHAR(128) NOT NULL COMMENT '操作人名称', + idempotency_key VARCHAR(128) NOT NULL COMMENT '客户端幂等键', created_at DATETIME(3) NOT NULL COMMENT '操作时间', PRIMARY KEY(id), + UNIQUE KEY uk_ll_order_hold_key(customer_id,order_id,operator_id,idempotency_key), KEY idx_ll_order_hold_history(customer_id,order_id,created_at) +) ENGINE=InnoDB COMMENT='尾程订单暂扣与解除不可变历史'; +CREATE TABLE IF NOT EXISTS ll_order_manual_node ( + id BIGINT NOT NULL COMMENT '尾程订单人工节点ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户数据隔离键', order_id BIGINT NOT NULL COMMENT '尾程订单ID', + node_code VARCHAR(32) NOT NULL COMMENT '八行人工展示节点稳定代码', node_at DATETIME(3) NULL COMMENT '人工填写节点时间,允许为空', + source VARCHAR(16) NOT NULL DEFAULT 'MANUAL' COMMENT '节点来源,固定MANUAL', order_version INT NOT NULL COMMENT '最近编辑后的订单版本', + updated_by VARCHAR(128) NOT NULL COMMENT '最近操作人ID', updated_at DATETIME(3) NOT NULL COMMENT '最近编辑时间', PRIMARY KEY(id), + UNIQUE KEY uk_ll_order_manual_node(customer_id,order_id,node_code), KEY idx_ll_order_manual_node_order(customer_id,order_id,node_at) +) ENGINE=InnoDB COMMENT='尾程订单当前人工节点覆盖事实'; +CREATE TABLE IF NOT EXISTS ll_order_manual_node_history ( + id BIGINT NOT NULL COMMENT '尾程订单人工节点历史ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户数据隔离键', order_id BIGINT NOT NULL COMMENT '尾程订单ID', + nodes_json LONGTEXT NOT NULL COMMENT '本次全量人工节点JSON快照', order_version INT NOT NULL COMMENT '操作成功后的订单版本', operator_id VARCHAR(128) NOT NULL COMMENT '操作人ID', + operator_name VARCHAR(128) NOT NULL COMMENT '操作人名称', idempotency_key VARCHAR(128) NOT NULL COMMENT '客户端幂等键', created_at DATETIME(3) NOT NULL COMMENT '操作时间', + PRIMARY KEY(id), UNIQUE KEY uk_ll_order_manual_node_history_key(customer_id,order_id,operator_id,idempotency_key), + KEY idx_ll_order_manual_node_history(customer_id,order_id,created_at) +) ENGINE=InnoDB COMMENT='尾程订单人工节点覆盖不可变历史'; + +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan' AND column_name='planned_arrival_date'), 'SELECT 1', 'ALTER TABLE ll_inbound_plan ADD COLUMN planned_arrival_date DATE NULL COMMENT ''人工计划预计到仓日期'' AFTER overseas_warehouse_id')); +PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan' AND column_name='pallet_type'), 'SELECT 1', 'ALTER TABLE ll_inbound_plan ADD COLUMN pallet_type VARCHAR(32) NULL COMMENT ''人工计划托盘类型'' AFTER planned_arrival_date')); +PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan' AND column_name='assigned_flag'), 'SELECT 1', 'ALTER TABLE ll_inbound_plan ADD COLUMN assigned_flag TINYINT NOT NULL DEFAULT 0 COMMENT ''人工计划是否已分配:0否1是'' AFTER pallet_type')); +PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan' AND column_name='merge_required'), 'SELECT 1', 'ALTER TABLE ll_inbound_plan ADD COLUMN merge_required TINYINT NOT NULL DEFAULT 0 COMMENT ''人工计划是否要求合托:0否1是'' AFTER assigned_flag')); +PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan' AND column_name='urgent_flag'), 'SELECT 1', 'ALTER TABLE ll_inbound_plan ADD COLUMN urgent_flag TINYINT NOT NULL DEFAULT 0 COMMENT ''人工计划是否加急:0否1是'' AFTER merge_required')); +PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan' AND column_name='creation_mode'), 'SELECT 1', 'ALTER TABLE ll_inbound_plan ADD COLUMN creation_mode VARCHAR(16) NOT NULL DEFAULT ''AUTO'' COMMENT ''计划生成方式:AUTO柜级自动、MANUAL单票人工'' AFTER urgent_flag')); +PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan' AND column_name='plan_remark'), 'SELECT 1', 'ALTER TABLE ll_inbound_plan ADD COLUMN plan_remark VARCHAR(1000) NULL COMMENT ''入仓计划备注'' AFTER creation_mode')); +PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan_pallet' AND column_name='pallet_type'), 'SELECT 1', 'ALTER TABLE ll_inbound_plan_pallet ADD COLUMN pallet_type VARCHAR(32) NULL COMMENT ''人工计划托盘类型'' AFTER pallet_no')); +PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +DROP PROCEDURE IF EXISTS assert_tail_order_commands_ready; +DELIMITER $$ +CREATE PROCEDURE assert_tail_order_commands_ready() +BEGIN + IF (SELECT COUNT(*) FROM information_schema.columns WHERE table_schema='baiyue_lastleg' + AND table_name='ll_order' AND column_name IN + ('hold_chargeable','hold_reason','manual_node_override','version')) <> 4 THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='尾程订单人工计划、暂扣与人工节点字段未完整部署'; + END IF; + IF (SELECT COUNT(*) FROM information_schema.tables WHERE table_schema='baiyue_lastleg' + AND table_name IN ('ll_order_command_record','ll_order_hold_history','ll_order_manual_node','ll_order_manual_node_history')) <> 4 THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='尾程订单命令审计表未完整部署'; + END IF; + IF (SELECT COUNT(*) FROM information_schema.columns WHERE table_schema='baiyue_lastleg' + AND table_name='ll_inbound_plan' AND column_name IN + ('planned_arrival_date','pallet_type','assigned_flag','merge_required','urgent_flag','creation_mode','plan_remark')) <> 7 THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='尾程人工计划字段未完整部署'; + END IF; + IF (SELECT COUNT(*) FROM information_schema.statistics WHERE table_schema='baiyue_lastleg' + AND table_name='ll_order_command_record' AND index_name='uk_ll_order_command_key' AND non_unique=0) <> 5 + OR (SELECT COUNT(*) FROM information_schema.statistics WHERE table_schema='baiyue_lastleg' + AND table_name='ll_order_manual_node' AND index_name='uk_ll_order_manual_node' AND non_unique=0) <> 3 THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='尾程订单命令幂等或人工节点唯一约束未完整部署'; + END IF; +END$$ +DELIMITER ; +CALL assert_tail_order_commands_ready(); +DROP PROCEDURE IF EXISTS assert_tail_order_commands_ready; + +SELECT 'PASS' AS migration_status, '海外尾程人工计划、暂扣与人工节点结构已就绪' AS message; diff --git a/firstleg/AGENTS.md b/firstleg/AGENTS.md index ebb81a42e..28c98fb0e 100644 --- a/firstleg/AGENTS.md +++ b/firstleg/AGENTS.md @@ -43,6 +43,7 @@ | 海外暂扣状态回写 | 同一内部事件入口、消费者 `service/OverseasInboundOrderDetainedConsumer.java`;按来源入仓单 ID 更新 `fl_inbound_order.hold_flag/current_hold_reason`,复用跨区 Inbox 并写 `fl_inbound_hold_record` | | 国内货柜履约海运订舱、资料/打印、关联候选与出仓前船期资料 | `oceanbooking/controller/PlatformOceanBookingController.java`、`oceanbooking/service/OceanBookingService.java`、`OceanBookingDocumentService.java`、`OceanBookingPrintService.java`、`oceanbooking/mapper/OceanBookingMapper.java`、`portschedule/controller/PlatformPortScheduleController.java`;文件经 `client/OceanBookingFileClient.java` 进入 File,关联候选 `/api/platform/v1/ocean-bookings/link-candidates`,契约 `src/main/resources/openapi/ocean-bookings-v1.yaml`,表结构见 `src/main/resources/db/phase3-ocean-booking-schema.sql` | | 海外仓业务国内出仓后船节点定时查询 | `outbound/dispatch/service/OutboundDispatchService.java` 在确认出仓事务内幂等创建现有 `CarrierScheduleQuery`;编排与租约 `portschedule/service/CarrierScheduleQueryScheduler.java`,任务/十节点事实/异常事实/不可变 attempt `CarrierScheduleQueryService.java`,平台查询与人工补录入口 `PlatformCarrierScheduleQueryController.java` 的 `/{queryId}/manual-nodes`;停止只由实际 `EMPTY_RETURNED` 节点触发,不提供无节点停止入口;外部船司/轨迹调用经 `integration-service/`;标准见 `../../docs/海外仓业务/船节点标准化契约.md` | +| 目的港码头拖车与还空预警 | `terminaldrayage/controller/PlatformTerminalDrayageController.java`、`service/TerminalDrayageService.java`、`mapper/TerminalDrayageMapper.java`;货柜实际到港后引用 Warehouse `ACTIVE/TRUCKING` 供应商,依次选择车队、预约提柜、确认实际提柜,海外卸柜收货完成后确认还柜;服务端按卸柜完成时间加 72 小时返回报空预警,实际提柜和还柜通过现有 `CarrierScheduleQueryService` 推进 `PICKED_UP/EMPTY_RETURNED`,不复制船节点或车队主档。契约 `src/main/resources/openapi/terminal-drayage-v1.yaml`,基线 `src/main/resources/db/phase3-terminal-drayage-schema.sql` | | 船节点跨区交接事件 | `portschedule/service/CarrierScheduleNodeOutboxService.java`;实际节点落库后发布 `FIRST_LEG_CONTAINER_SHIPPING_NODE_RECORDED` V1,复用 `LoadingCompletedOutboxRepository`/Relay 投 Event Bridge;海外消费见 `../lastleg-service/src/main/java/com/baiyue/lastleg/shipping/service/ContainerShippingNodeConsumer.java` | | Firstleg 业务单据编号 | `service/FirstlegDocumentNumberGenerator.java`;当前支持 `OB`、`TA`、`CB`、`CAB`、`LP` 前缀,按北京时间每日独立编号;具体业务归属以各调用服务为准 | | 订舱级拖车与整柜出仓调度 | `controller/PlatformTruckAppointmentController.java` 的 `/candidates`、`service/TruckAppointmentService.java`、`repository/TruckAppointmentRepository.java`、`outbound/dispatch/controller/PlatformOutboundDispatchController.java`;拖车契约 `src/main/resources/openapi/truck-appointments-v1.yaml`。预约只绑定 `RELEASED` 订舱,候选排除缺提单/有效订舱文件或已有非终态预约的订舱;API 不含客户、柜次、装柜计划或真实柜号,每订舱最多一张非终态预约,PDA 完成不推进拖车状态 | diff --git a/firstleg/scripts/analyze-firstleg-dependencies.mjs b/firstleg/scripts/analyze-firstleg-dependencies.mjs index 302d466c7..cdcc88bd8 100644 --- a/firstleg/scripts/analyze-firstleg-dependencies.mjs +++ b/firstleg/scripts/analyze-firstleg-dependencies.mjs @@ -16,7 +16,7 @@ const sliceRules = [ ['pricebook-query', /(?:^|\.)(?:pricebookquery)(?:\.|$)/], ['finance-integration', /(?:InternalFinance|FinanceFeeSource|FinanceCostSnapshot|FeePaymentVoucher|FeePaymentSnapshot|ContainerFeeSourceSnapshot)/], ['shipping-nodes', /(?:CarrierScheduleQuery|CarrierScheduleNode|CarrierScheduleAdapter|CarrierScheduleAttempt|CarrierScheduleException)/], - ['booking-customs-trucking', /(?:^|\.)(?:oceanbooking|customs)(?:\.|$)|(?:TruckAppointment|WarehouseSupplier|PortSchedule|DispatchGate)/], + ['booking-customs-trucking', /(?:^|\.)(?:oceanbooking|customs|terminaldrayage)(?:\.|$)|(?:TruckAppointment|WarehouseSupplier|PortSchedule|DispatchGate)/], ['outbound', /(?:^|\.)outbound(?:\.|$)|(?:OutboundDispatch|ContainerTransport|OverseasContainerEvent)/], ['split-loading', /(?:^|\.)(?:split|loading)(?:\.|$)|(?:Loading|SmartLoading|Container(?:Booking|Overview|Service|Dtos)|PdaPick)/], ['measurement-pricing', /(?:^|\.)pricing(?:\.|$)|(?:Measurement|MeasureReview|Mojia|Pricing|PriceBook|PriceChannel|PriceOrigin|PriceWarehouse|Fba|Dimension|FeeItem|MerchantInboundBilling|InboundFee)/], diff --git a/firstleg/src/main/java/com/baiyue/firstleg/terminaldrayage/controller/PlatformTerminalDrayageController.java b/firstleg/src/main/java/com/baiyue/firstleg/terminaldrayage/controller/PlatformTerminalDrayageController.java new file mode 100644 index 000000000..777a9648f --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/terminaldrayage/controller/PlatformTerminalDrayageController.java @@ -0,0 +1,117 @@ +package com.baiyue.firstleg.terminaldrayage.controller; + +import com.baiyue.common.audit.BusinessAudit; +import com.baiyue.common.core.api.ApiResponse; +import com.baiyue.common.security.AuthenticatedUser; +import com.baiyue.firstleg.service.FirstlegScopeGuard; +import com.baiyue.firstleg.terminaldrayage.dto.TerminalDrayageDtos.ActualTimeRequest; +import com.baiyue.firstleg.terminaldrayage.dto.TerminalDrayageDtos.FleetSelectionRequest; +import com.baiyue.firstleg.terminaldrayage.dto.TerminalDrayageDtos.History; +import com.baiyue.firstleg.terminaldrayage.dto.TerminalDrayageDtos.PickupAppointmentRequest; +import com.baiyue.firstleg.terminaldrayage.dto.TerminalDrayageDtos.View; +import com.baiyue.firstleg.terminaldrayage.service.TerminalDrayageService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import jakarta.validation.constraints.Positive; +import jakarta.validation.constraints.Size; +import lombok.RequiredArgsConstructor; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestHeader; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import java.util.List; + +@Validated +@RestController +@RequiredArgsConstructor +@Tag(name = "目的港码头拖车") +@RequestMapping("/api/platform/v1/terminal-drayages") +public class PlatformTerminalDrayageController { + private final TerminalDrayageService service; + + @GetMapping + @PreAuthorize("hasAuthority('platform:firstleg:container:list')") + public ApiResponse> views(@AuthenticationPrincipal AuthenticatedUser user, + @RequestParam @Size(min = 1, max = 200) + List<@Positive Long> containerBookingIds) { + FirstlegScopeGuard.platform(user); + return ApiResponse.success(service.views(containerBookingIds)); + } + + @GetMapping("/{containerBookingId}") + @PreAuthorize("hasAuthority('platform:firstleg:container:list')") + public ApiResponse detail(@AuthenticationPrincipal AuthenticatedUser user, + @PathVariable @Positive long containerBookingId) { + FirstlegScopeGuard.platform(user); + return ApiResponse.success(service.detail(containerBookingId)); + } + + @GetMapping("/{containerBookingId}/history") + @PreAuthorize("hasAuthority('platform:firstleg:container:list')") + public ApiResponse> history(@AuthenticationPrincipal AuthenticatedUser user, + @PathVariable @Positive long containerBookingId) { + FirstlegScopeGuard.platform(user); + return ApiResponse.success(service.histories(containerBookingId)); + } + + @PostMapping("/{containerBookingId}/fleet") + @PreAuthorize("hasAuthority('platform:firstleg:container:edit')") + @Operation(summary = "选择或更换目的港码头车队") + @BusinessAudit(event = "TERMINAL_DRAYAGE_SELECT_FLEET", resourceType = "CONTAINER", + resourceId = "#containerBookingId", description = "选择目的港码头车队") + public ApiResponse selectFleet(@AuthenticationPrincipal AuthenticatedUser user, + @PathVariable @Positive long containerBookingId, + @RequestHeader("Idempotency-Key") String key, + @Valid @RequestBody FleetSelectionRequest request) { + FirstlegScopeGuard.platform(user); + return ApiResponse.success(service.selectFleet(containerBookingId, user.userId(), user.username(), key, request)); + } + + @PostMapping("/{containerBookingId}/pickup-appointment") + @PreAuthorize("hasAuthority('platform:firstleg:container:edit')") + @Operation(summary = "预约目的港提柜时间") + @BusinessAudit(event = "TERMINAL_DRAYAGE_SCHEDULE_PICKUP", resourceType = "CONTAINER", + resourceId = "#containerBookingId", description = "预约目的港提柜") + public ApiResponse schedulePickup(@AuthenticationPrincipal AuthenticatedUser user, + @PathVariable @Positive long containerBookingId, + @RequestHeader("Idempotency-Key") String key, + @Valid @RequestBody PickupAppointmentRequest request) { + FirstlegScopeGuard.platform(user); + return ApiResponse.success(service.schedulePickup(containerBookingId, user.userId(), user.username(), key, request)); + } + + @PostMapping("/{containerBookingId}/pickup") + @PreAuthorize("hasAuthority('platform:firstleg:container:edit')") + @Operation(summary = "确认目的港实际提柜") + @BusinessAudit(event = "TERMINAL_DRAYAGE_RECORD_PICKUP", resourceType = "CONTAINER", + resourceId = "#containerBookingId", description = "确认目的港实际提柜") + public ApiResponse recordPickup(@AuthenticationPrincipal AuthenticatedUser user, + @PathVariable @Positive long containerBookingId, + @RequestHeader("Idempotency-Key") String key, + @Valid @RequestBody ActualTimeRequest request) { + FirstlegScopeGuard.platform(user); + return ApiResponse.success(service.recordPickup(containerBookingId, user.userId(), user.username(), key, request)); + } + + @PostMapping("/{containerBookingId}/empty-return") + @PreAuthorize("hasAuthority('platform:firstleg:container:edit')") + @Operation(summary = "确认实际还空柜") + @BusinessAudit(event = "TERMINAL_DRAYAGE_RECORD_EMPTY_RETURN", resourceType = "CONTAINER", + resourceId = "#containerBookingId", description = "确认实际还空柜") + public ApiResponse recordEmptyReturn(@AuthenticationPrincipal AuthenticatedUser user, + @PathVariable @Positive long containerBookingId, + @RequestHeader("Idempotency-Key") String key, + @Valid @RequestBody ActualTimeRequest request) { + FirstlegScopeGuard.platform(user); + return ApiResponse.success(service.recordEmptyReturn(containerBookingId, user.userId(), user.username(), key, request)); + } +} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/terminaldrayage/dto/TerminalDrayageDtos.java b/firstleg/src/main/java/com/baiyue/firstleg/terminaldrayage/dto/TerminalDrayageDtos.java new file mode 100644 index 000000000..b193cf6b5 --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/terminaldrayage/dto/TerminalDrayageDtos.java @@ -0,0 +1,41 @@ +package com.baiyue.firstleg.terminaldrayage.dto; + +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Positive; +import jakarta.validation.constraints.Size; + +import java.time.LocalDateTime; +import java.util.List; + +public final class TerminalDrayageDtos { + private TerminalDrayageDtos() {} + + public record FleetSelectionRequest(@NotNull @Min(0) Integer expectedVersion, + @NotNull @Positive Long fleetSupplierId, + @Size(max = 128) String fleetSupplierName, + @Size(max = 500) String reason) {} + + public record PickupAppointmentRequest(@NotNull @Min(0) Integer expectedVersion, + @NotNull LocalDateTime pickupAppointmentAt, + @Size(max = 500) String reason) {} + + public record ActualTimeRequest(@NotNull @Min(0) Integer expectedVersion, + @NotNull LocalDateTime actualAt, + @Size(max = 500) String reason) {} + + public record Warning(String status, LocalDateTime deadlineAt, + long overdueHours, long overdueDays) {} + + public record View(Long containerBookingId, String containerNo, + Long oceanBookingId, String status, Integer version, + Long fleetSupplierId, String fleetSupplierName, + LocalDateTime pickupAppointmentAt, LocalDateTime actualPickupAt, + LocalDateTime estimatedEmptyReturnAt, LocalDateTime actualEmptyReturnAt, + LocalDateTime unloadingCompletedAt, Warning emptyReturnWarning, + List allowedActions, LocalDateTime updatedAt) {} + + public record History(String action, String fromStatus, String toStatus, + Integer fromVersion, Integer toVersion, String reason, + Long operatorId, String operatorName, LocalDateTime createdAt) {} +} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/terminaldrayage/entity/TerminalDrayageEntity.java b/firstleg/src/main/java/com/baiyue/firstleg/terminaldrayage/entity/TerminalDrayageEntity.java new file mode 100644 index 000000000..ddd847c04 --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/terminaldrayage/entity/TerminalDrayageEntity.java @@ -0,0 +1,30 @@ +package com.baiyue.firstleg.terminaldrayage.entity; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.Getter; +import lombok.Setter; + +import java.time.LocalDateTime; + +@Getter +@Setter +@TableName("fl_terminal_drayage") +public class TerminalDrayageEntity { + @TableId(type = IdType.INPUT) private Long id; + private Long containerBookingId; + private Long fleetSupplierId; + private String fleetSupplierName; + private LocalDateTime pickupAppointmentAt; + private LocalDateTime actualPickupAt; + private LocalDateTime estimatedEmptyReturnAt; + private LocalDateTime actualEmptyReturnAt; + private Integer version; + private Long createdBy; + private String createdByName; + private LocalDateTime createdAt; + private Long updatedBy; + private String updatedByName; + private LocalDateTime updatedAt; +} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/terminaldrayage/mapper/TerminalDrayageMapper.java b/firstleg/src/main/java/com/baiyue/firstleg/terminaldrayage/mapper/TerminalDrayageMapper.java new file mode 100644 index 000000000..f9124be45 --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/terminaldrayage/mapper/TerminalDrayageMapper.java @@ -0,0 +1,148 @@ +package com.baiyue.firstleg.terminaldrayage.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.baiyue.firstleg.terminaldrayage.dto.TerminalDrayageDtos.History; +import com.baiyue.firstleg.terminaldrayage.entity.TerminalDrayageEntity; +import org.apache.ibatis.annotations.Insert; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; +import org.apache.ibatis.annotations.Select; +import org.apache.ibatis.annotations.Update; + +import java.time.LocalDateTime; +import java.util.List; + +@Mapper +public interface TerminalDrayageMapper extends BaseMapper { + @Select(""" + SELECT b.id AS containerBookingId,b.container_no AS containerNo,b.ocean_booking_id AS oceanBookingId, + b.record_status AS recordStatus,b.unloading_completed_at AS unloadingCompletedAt, + q.id AS queryId,q.latest_standard_node AS latestActualNode, + q.latest_node_observed_at AS latestActualAt,q.version AS queryVersion, + (SELECT MAX(f.actual_at) FROM fl_carrier_schedule_node_fact f WHERE f.query_id=q.id + AND f.standard_node='ARRIVED_DESTINATION_PORT') AS actualArrivalNodeAt, + (SELECT MAX(f.actual_at) FROM fl_carrier_schedule_node_fact f WHERE f.query_id=q.id + AND f.standard_node='PICKED_UP') AS actualPickupNodeAt, + (SELECT MAX(f.actual_at) FROM fl_carrier_schedule_node_fact f WHERE f.query_id=q.id + AND f.standard_node='EMPTY_RETURNED') AS actualEmptyReturnNodeAt + FROM fl_container_booking b + LEFT JOIN fl_carrier_schedule_query q ON q.id=( + SELECT q2.id FROM fl_carrier_schedule_query q2 + WHERE q2.container_booking_id=b.id ORDER BY q2.id DESC LIMIT 1) + WHERE b.id=#{containerBookingId} FOR UPDATE + """) + Context lockContext(@Param("containerBookingId") long containerBookingId); + + @Select(""" + SELECT b.id AS containerBookingId,b.container_no AS containerNo,b.ocean_booking_id AS oceanBookingId, + b.record_status AS recordStatus,b.unloading_completed_at AS unloadingCompletedAt, + q.id AS queryId,q.latest_standard_node AS latestActualNode, + q.latest_node_observed_at AS latestActualAt,q.version AS queryVersion, + (SELECT MAX(f.actual_at) FROM fl_carrier_schedule_node_fact f WHERE f.query_id=q.id + AND f.standard_node='ARRIVED_DESTINATION_PORT') AS actualArrivalNodeAt, + (SELECT MAX(f.actual_at) FROM fl_carrier_schedule_node_fact f WHERE f.query_id=q.id + AND f.standard_node='PICKED_UP') AS actualPickupNodeAt, + (SELECT MAX(f.actual_at) FROM fl_carrier_schedule_node_fact f WHERE f.query_id=q.id + AND f.standard_node='EMPTY_RETURNED') AS actualEmptyReturnNodeAt + FROM fl_container_booking b + LEFT JOIN fl_carrier_schedule_query q ON q.id=( + SELECT q2.id FROM fl_carrier_schedule_query q2 + WHERE q2.container_booking_id=b.id ORDER BY q2.id DESC LIMIT 1) + WHERE b.id=#{containerBookingId} + """) + Context context(@Param("containerBookingId") long containerBookingId); + + @Select(""" + + """) + List contexts(@Param("containerBookingIds") List containerBookingIds); + + @Select("SELECT * FROM fl_terminal_drayage WHERE container_booking_id=#{containerBookingId}") + TerminalDrayageEntity find(@Param("containerBookingId") long containerBookingId); + + @Select("SELECT * FROM fl_terminal_drayage WHERE container_booking_id=#{containerBookingId} FOR UPDATE") + TerminalDrayageEntity lock(@Param("containerBookingId") long containerBookingId); + + @Select(""" + + """) + List findAll(@Param("containerBookingIds") List containerBookingIds); + + @Update(""" + UPDATE fl_terminal_drayage SET fleet_supplier_id=#{value.fleetSupplierId}, + fleet_supplier_name=#{value.fleetSupplierName},pickup_appointment_at=#{value.pickupAppointmentAt}, + actual_pickup_at=#{value.actualPickupAt},estimated_empty_return_at=#{value.estimatedEmptyReturnAt}, + actual_empty_return_at=#{value.actualEmptyReturnAt},updated_by=#{operatorId}, + updated_by_name=#{operatorName},updated_at=CURRENT_TIMESTAMP(3),version=version+1 + WHERE container_booking_id=#{value.containerBookingId} AND version=#{expectedVersion} + """) + int updateAggregate(@Param("value") TerminalDrayageEntity value, + @Param("expectedVersion") int expectedVersion, + @Param("operatorId") long operatorId, + @Param("operatorName") String operatorName); + + @Select(""" + SELECT action,from_status AS fromStatus,to_status AS toStatus,from_version AS fromVersion, + to_version AS toVersion,reason,operator_id AS operatorId,operator_name AS operatorName,created_at AS createdAt + FROM fl_terminal_drayage_history WHERE container_booking_id=#{containerBookingId} + ORDER BY created_at DESC,id DESC + """) + List histories(@Param("containerBookingId") long containerBookingId); + + @Insert(""" + INSERT INTO fl_terminal_drayage_history(id,drayage_id,container_booking_id,action,from_status,to_status, + from_version,to_version,reason,operator_id,operator_name) + VALUES(#{id},#{drayageId},#{containerBookingId},#{action},#{fromStatus},#{toStatus},#{fromVersion}, + #{toVersion},#{reason},#{operatorId},#{operatorName}) + """) + int insertHistory(@Param("id") long id, @Param("drayageId") long drayageId, + @Param("containerBookingId") long containerBookingId, + @Param("action") String action, @Param("fromStatus") String fromStatus, + @Param("toStatus") String toStatus, @Param("fromVersion") int fromVersion, + @Param("toVersion") int toVersion, @Param("reason") String reason, + @Param("operatorId") long operatorId, @Param("operatorName") String operatorName); + + @Select(""" + SELECT operation,request_hash AS requestHash,result_version AS resultVersion + FROM fl_terminal_drayage_operation + WHERE container_booking_id=#{containerBookingId} AND idempotency_key=#{key} + """) + Operation operation(@Param("containerBookingId") long containerBookingId, @Param("key") String key); + + @Insert(""" + INSERT INTO fl_terminal_drayage_operation(id,container_booking_id,operation,idempotency_key,request_hash, + result_version,operator_id) VALUES(#{id},#{containerBookingId},#{operation},#{key},#{hash}, + #{resultVersion},#{operatorId}) + """) + int insertOperation(@Param("id") long id, @Param("containerBookingId") long containerBookingId, + @Param("operation") String operation, @Param("key") String key, + @Param("hash") String hash, @Param("resultVersion") int resultVersion, + @Param("operatorId") long operatorId); + + record Context(Long containerBookingId, String containerNo, Long oceanBookingId, String recordStatus, + LocalDateTime unloadingCompletedAt, Long queryId, String latestActualNode, + LocalDateTime latestActualAt, Integer queryVersion, LocalDateTime actualArrivalNodeAt, + LocalDateTime actualPickupNodeAt, LocalDateTime actualEmptyReturnNodeAt) {} + record Operation(String operation, String requestHash, Integer resultVersion) {} +} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/terminaldrayage/service/TerminalDrayageService.java b/firstleg/src/main/java/com/baiyue/firstleg/terminaldrayage/service/TerminalDrayageService.java new file mode 100644 index 000000000..47224afc3 --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/terminaldrayage/service/TerminalDrayageService.java @@ -0,0 +1,339 @@ +package com.baiyue.firstleg.terminaldrayage.service; + +import com.baomidou.mybatisplus.core.toolkit.IdWorker; +import com.baiyue.common.core.api.ResultCode; +import com.baiyue.common.core.exception.BizException; +import com.baiyue.firstleg.portschedule.domain.CarrierScheduleNode; +import com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.ManualNodeRequest; +import com.baiyue.firstleg.portschedule.service.CarrierScheduleQueryService; +import com.baiyue.firstleg.service.WarehouseSupplierSnapshotService; +import com.baiyue.firstleg.terminaldrayage.dto.TerminalDrayageDtos.ActualTimeRequest; +import com.baiyue.firstleg.terminaldrayage.dto.TerminalDrayageDtos.FleetSelectionRequest; +import com.baiyue.firstleg.terminaldrayage.dto.TerminalDrayageDtos.History; +import com.baiyue.firstleg.terminaldrayage.dto.TerminalDrayageDtos.PickupAppointmentRequest; +import com.baiyue.firstleg.terminaldrayage.dto.TerminalDrayageDtos.View; +import com.baiyue.firstleg.terminaldrayage.dto.TerminalDrayageDtos.Warning; +import com.baiyue.firstleg.terminaldrayage.entity.TerminalDrayageEntity; +import com.baiyue.firstleg.terminaldrayage.mapper.TerminalDrayageMapper; +import com.baiyue.firstleg.terminaldrayage.mapper.TerminalDrayageMapper.Context; +import com.baiyue.firstleg.terminaldrayage.mapper.TerminalDrayageMapper.Operation; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.util.StringUtils; + +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.time.Clock; +import java.time.Duration; +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.HexFormat; +import java.util.List; +import java.util.Map; +import java.util.Objects; + +@Service +@RequiredArgsConstructor +public class TerminalDrayageService { + private static final int EMPTY_RETURN_WARNING_HOURS = 72; + private final TerminalDrayageMapper mapper; + private final WarehouseSupplierSnapshotService suppliers; + private final CarrierScheduleQueryService carrierSchedules; + private final ObjectMapper objectMapper; + private Clock clock = Clock.systemDefaultZone(); + + public View detail(long containerBookingId) { + return view(requiredContext(containerBookingId), mapper.find(containerBookingId)); + } + + public List views(List containerBookingIds) { + if (containerBookingIds == null || containerBookingIds.isEmpty()) return List.of(); + List ids = containerBookingIds.stream().filter(Objects::nonNull).distinct().toList(); + if (ids.size() != containerBookingIds.size()) throw badRequest("containerBookingIds不能包含空值或重复值"); + Map rows = new HashMap<>(); + mapper.findAll(ids).forEach(value -> rows.put(value.getContainerBookingId(), value)); + return mapper.contexts(ids).stream().map(value -> view(value, rows.get(value.containerBookingId()))).toList(); + } + + public List histories(long containerBookingId) { + requiredContext(containerBookingId); + return mapper.histories(containerBookingId); + } + + @Transactional + public View selectFleet(long containerBookingId, long operatorId, String operatorName, String key, + FleetSelectionRequest request) { + String action = "SELECT_FLEET"; + Context context = requiredLockedContext(containerBookingId); + View replay = replay(context, action, key, request); + if (replay != null) return replay; + requireArrived(context); + TerminalDrayageEntity value = mapper.lock(containerBookingId); + requireVersion(value, request.expectedVersion()); + if (actualPickupAt(context, value) != null) throw conflict("实际提柜后不可更换码头车队"); + var fleet = suppliers.trucking(request.fleetSupplierId(), request.fleetSupplierName()); + int fromVersion = version(value); + String fromStatus = status(context, value); + if (value == null) value = create(containerBookingId, operatorId, operatorName); + value.setFleetSupplierId(fleet.supplierId()); + value.setFleetSupplierName(fleet.name()); + persist(value, fromVersion, operatorId, operatorName); + return finish(context, value, action, key, request, fromStatus, fromVersion, + request.reason(), operatorId, operatorName); + } + + @Transactional + public View schedulePickup(long containerBookingId, long operatorId, String operatorName, String key, + PickupAppointmentRequest request) { + String action = "SCHEDULE_PICKUP"; + Context context = requiredLockedContext(containerBookingId); + View replay = replay(context, action, key, request); + if (replay != null) return replay; + requireArrived(context); + TerminalDrayageEntity value = requiredAggregate(containerBookingId); + requireVersion(value, request.expectedVersion()); + if (value.getFleetSupplierId() == null) throw conflict("请先选择有效码头车队"); + if (actualPickupAt(context, value) != null) throw conflict("实际提柜后不可修改预约提柜时间"); + if (context.actualArrivalNodeAt() != null && request.pickupAppointmentAt().isBefore(context.actualArrivalNodeAt())) { + throw badRequest("预约提柜时间不能早于实际到港时间"); + } + int fromVersion = version(value); + String fromStatus = status(context, value); + value.setPickupAppointmentAt(request.pickupAppointmentAt()); + persist(value, fromVersion, operatorId, operatorName); + return finish(context, value, action, key, request, fromStatus, fromVersion, + request.reason(), operatorId, operatorName); + } + + @Transactional + public View recordPickup(long containerBookingId, long operatorId, String operatorName, String key, + ActualTimeRequest request) { + String action = "RECORD_PICKUP"; + Context context = requiredLockedContext(containerBookingId); + View replay = replay(context, action, key, request); + if (replay != null) return replay; + requireAtLeast(context, CarrierScheduleNode.PICKUP_READY, "货柜尚未清关放行,不允许确认实际提柜"); + TerminalDrayageEntity value = requiredAggregate(containerBookingId); + requireVersion(value, request.expectedVersion()); + if (value.getFleetSupplierId() == null || value.getPickupAppointmentAt() == null) { + throw conflict("选择码头车队并预约提柜后才能确认实际提柜"); + } + if (actualPickupAt(context, value) != null) throw conflict("实际提柜时间已确认"); + if (context.latestActualAt() != null && request.actualAt().isBefore(context.latestActualAt())) { + throw badRequest("实际提柜时间不能早于当前实际船节点时间"); + } + advanceNode(context, CarrierScheduleNode.PICKED_UP, request.actualAt(), action, key, + request.reason(), operatorId, operatorName); + int fromVersion = version(value); + String fromStatus = status(context, value); + value.setActualPickupAt(request.actualAt()); + applyAuthoritativeDeadline(context, value); + persist(value, fromVersion, operatorId, operatorName); + return finish(requiredContext(containerBookingId), value, action, key, request, fromStatus, + fromVersion, request.reason(), operatorId, operatorName); + } + + @Transactional + public View recordEmptyReturn(long containerBookingId, long operatorId, String operatorName, String key, + ActualTimeRequest request) { + String action = "RECORD_EMPTY_RETURN"; + Context context = requiredLockedContext(containerBookingId); + View replay = replay(context, action, key, request); + if (replay != null) return replay; + TerminalDrayageEntity value = requiredAggregate(containerBookingId); + requireVersion(value, request.expectedVersion()); + LocalDateTime pickupAt = actualPickupAt(context, value); + if (pickupAt == null) throw conflict("实际提柜后才能确认还柜"); + if (context.unloadingCompletedAt() == null) throw conflict("海外卸柜收货完成后才能确认还柜"); + LocalDateTime earliest = pickupAt.isAfter(context.unloadingCompletedAt()) + ? pickupAt : context.unloadingCompletedAt(); + if (request.actualAt().isBefore(earliest)) throw badRequest("实际还柜时间不能早于提柜或卸柜完成时间"); + if (actualEmptyReturnAt(context, value) != null) throw conflict("实际还柜时间已确认"); + advanceNode(context, CarrierScheduleNode.EMPTY_RETURNED, request.actualAt(), action, key, + request.reason(), operatorId, operatorName); + int fromVersion = version(value); + String fromStatus = status(context, value); + applyAuthoritativeDeadline(context, value); + value.setActualEmptyReturnAt(request.actualAt()); + persist(value, fromVersion, operatorId, operatorName); + return finish(requiredContext(containerBookingId), value, action, key, request, fromStatus, + fromVersion, request.reason(), operatorId, operatorName); + } + + private void advanceNode(Context context, CarrierScheduleNode target, LocalDateTime actualAt, + String action, String key, String reason, long operatorId, String operatorName) { + if (context.queryId() == null || context.queryVersion() == null) { + throw conflict("货柜尚未建立船节点跟踪任务"); + } + CarrierScheduleNode current = node(context.latestActualNode()); + if (current != null && current.sequence() >= target.sequence()) return; + carrierSchedules.addManualNode(context.queryId(), operatorId, operatorName, + "td-" + hash(context.containerBookingId() + "|" + action + "|" + key.trim()), + new ManualNodeRequest(context.queryVersion(), target.name(), LocalDateTime.now(clock), null, + actualAt, null, null, StringUtils.hasText(reason) ? reason.trim() : "码头拖车操作同步")); + } + + private View finish(Context context, TerminalDrayageEntity value, String action, String key, Object request, + String fromStatus, int fromVersion, String reason, + long operatorId, String operatorName) { + TerminalDrayageEntity stored = mapper.find(context.containerBookingId()); + Context currentContext = requiredContext(context.containerBookingId()); + String toStatus = status(currentContext, stored); + mapper.insertHistory(IdWorker.getId(), stored.getId(), context.containerBookingId(), action, + fromStatus, toStatus, fromVersion, version(stored), trim(reason), operatorId, operatorName); + mapper.insertOperation(IdWorker.getId(), context.containerBookingId(), action, key.trim(), hash(request), + version(stored), operatorId); + return view(currentContext, stored); + } + + private View replay(Context context, String action, String key, Object request) { + if (!StringUtils.hasText(key) || key.length() > 255) throw badRequest("Idempotency-Key不能为空且不能超过255字符"); + Operation operation = mapper.operation(context.containerBookingId(), key.trim()); + if (operation == null) return null; + if (!action.equals(operation.operation()) || !hash(request).equals(operation.requestHash())) { + throw conflict("Idempotency-Key已被不同的码头拖车操作占用"); + } + return view(context, mapper.find(context.containerBookingId())); + } + + private TerminalDrayageEntity create(long containerBookingId, long operatorId, String operatorName) { + TerminalDrayageEntity value = new TerminalDrayageEntity(); + value.setId(IdWorker.getId()); value.setContainerBookingId(containerBookingId); value.setVersion(1); + value.setCreatedBy(operatorId); value.setCreatedByName(operatorName); + value.setUpdatedBy(operatorId); value.setUpdatedByName(operatorName); + return value; + } + + private void persist(TerminalDrayageEntity value, int expectedVersion, long operatorId, String operatorName) { + if (expectedVersion == 0) { + mapper.insert(value); + return; + } + if (mapper.updateAggregate(value, expectedVersion, operatorId, operatorName) != 1) throw concurrent(); + } + + private void applyAuthoritativeDeadline(Context context, TerminalDrayageEntity value) { + if (context.unloadingCompletedAt() != null) { + value.setEstimatedEmptyReturnAt(context.unloadingCompletedAt().plusHours(EMPTY_RETURN_WARNING_HOURS)); + } + } + + private View view(Context context, TerminalDrayageEntity value) { + String status = status(context, value); + LocalDateTime deadline = context.unloadingCompletedAt() == null ? null + : context.unloadingCompletedAt().plusHours(EMPTY_RETURN_WARNING_HOURS); + LocalDateTime returnedAt = actualEmptyReturnAt(context, value); + String warningStatus; + long overdueHours = 0; + if (returnedAt != null) warningStatus = "RESOLVED"; + else if (deadline == null) warningStatus = "NOT_APPLICABLE"; + else if (LocalDateTime.now(clock).isAfter(deadline)) { + warningStatus = "OVERDUE"; + overdueHours = Math.max(0, Duration.between(deadline, LocalDateTime.now(clock)).toHours()); + } else warningStatus = "PENDING"; + Warning warning = new Warning(warningStatus, deadline, overdueHours, overdueHours / 24); + return new View(context.containerBookingId(), context.containerNo(), context.oceanBookingId(), status, + version(value), value == null ? null : value.getFleetSupplierId(), + value == null ? null : value.getFleetSupplierName(), + value == null ? null : value.getPickupAppointmentAt(), + actualPickupAt(context, value), deadline, returnedAt, + context.unloadingCompletedAt(), warning, allowedActions(context, value), + value == null ? null : value.getUpdatedAt()); + } + + private List allowedActions(Context context, TerminalDrayageEntity value) { + List result = new ArrayList<>(); + CarrierScheduleNode current = node(context.latestActualNode()); + if (current != null && current.sequence() >= CarrierScheduleNode.ARRIVED_DESTINATION_PORT.sequence() + && current.sequence() < CarrierScheduleNode.PICKED_UP.sequence() + && actualPickupAt(context, value) == null) { + result.add("SELECT_FLEET"); + if (value != null && value.getFleetSupplierId() != null) result.add("SCHEDULE_PICKUP"); + } + if (current != null && current.sequence() >= CarrierScheduleNode.PICKUP_READY.sequence() + && value != null && value.getFleetSupplierId() != null && value.getPickupAppointmentAt() != null + && actualPickupAt(context, value) == null) result.add("RECORD_PICKUP"); + if (value != null && context.unloadingCompletedAt() != null && actualPickupAt(context, value) != null + && actualEmptyReturnAt(context, value) == null) result.add("RECORD_EMPTY_RETURN"); + return List.copyOf(result); + } + + private String status(Context context, TerminalDrayageEntity value) { + if (actualEmptyReturnAt(context, value) != null) return "EMPTY_RETURNED"; + if (context.unloadingCompletedAt() != null && actualPickupAt(context, value) != null) return "UNLOADED"; + if (actualPickupAt(context, value) != null) return "PICKED_UP"; + if (value != null && value.getPickupAppointmentAt() != null) return "PICKUP_SCHEDULED"; + CarrierScheduleNode current = node(context.latestActualNode()); + return current != null && current.sequence() >= CarrierScheduleNode.ARRIVED_DESTINATION_PORT.sequence() + ? "ARRIVED" : "WAITING_FOR_ARRIVAL"; + } + + private void requireArrived(Context context) { + requireAtLeast(context, CarrierScheduleNode.ARRIVED_DESTINATION_PORT, "货柜实际到达目的港后才能安排码头车队"); + } + + private void requireAtLeast(Context context, CarrierScheduleNode required, String message) { + CarrierScheduleNode current = node(context.latestActualNode()); + if (current == null || current.sequence() < required.sequence()) throw conflict(message); + } + + private CarrierScheduleNode node(String value) { + return StringUtils.hasText(value) ? CarrierScheduleNode.require(value) : null; + } + + private LocalDateTime actualPickupAt(Context context, TerminalDrayageEntity value) { + return value != null && value.getActualPickupAt() != null ? value.getActualPickupAt() : context.actualPickupNodeAt(); + } + + private LocalDateTime actualEmptyReturnAt(Context context, TerminalDrayageEntity value) { + return value != null && value.getActualEmptyReturnAt() != null + ? value.getActualEmptyReturnAt() : context.actualEmptyReturnNodeAt(); + } + + private Context requiredContext(long id) { + Context value = mapper.context(id); + if (value == null || !"ACTIVE".equals(value.recordStatus())) throw notFound(); + return value; + } + + private Context requiredLockedContext(long id) { + Context value = mapper.lockContext(id); + if (value == null || !"ACTIVE".equals(value.recordStatus())) throw notFound(); + return value; + } + + private TerminalDrayageEntity requiredAggregate(long id) { + TerminalDrayageEntity value = mapper.lock(id); + if (value == null) throw conflict("请先选择码头车队"); + return value; + } + + private void requireVersion(TerminalDrayageEntity value, int expectedVersion) { + if (version(value) != expectedVersion) throw concurrent(); + } + + private int version(TerminalDrayageEntity value) { + return value == null || value.getVersion() == null ? 0 : value.getVersion(); + } + + private String hash(Object request) { + try { + byte[] json = objectMapper.writeValueAsBytes(request); + return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(json)); + } catch (JsonProcessingException | NoSuchAlgorithmException exception) { + throw new IllegalStateException("无法计算码头拖车请求摘要", exception); + } + } + + private static String trim(String value) { return StringUtils.hasText(value) ? value.trim() : null; } + private static BizException badRequest(String message) { return new BizException(ResultCode.BAD_REQUEST, message); } + private static BizException conflict(String message) { return new BizException(ResultCode.CONFLICT, message); } + private static BizException concurrent() { return conflict("码头拖车记录已变化,请刷新后重试"); } + private static BizException notFound() { return new BizException(ResultCode.NOT_FOUND, "货柜不存在或不可用"); } + + void setClock(Clock clock) { this.clock = clock; } +} diff --git a/firstleg/src/main/resources/db/phase3-terminal-drayage-schema.sql b/firstleg/src/main/resources/db/phase3-terminal-drayage-schema.sql new file mode 100644 index 000000000..47d88d067 --- /dev/null +++ b/firstleg/src/main/resources/db/phase3-terminal-drayage-schema.sql @@ -0,0 +1,63 @@ +SET NAMES utf8mb4; +USE baiyue_firstleg; + +CREATE TABLE IF NOT EXISTS fl_terminal_drayage ( + id BIGINT UNSIGNED NOT NULL COMMENT '目的港码头拖车聚合ID', + container_booking_id BIGINT UNSIGNED NOT NULL COMMENT '唯一归属物理货柜ID', + fleet_supplier_id BIGINT UNSIGNED NOT NULL COMMENT 'Warehouse ACTIVE/TRUCKING供应商ID快照', + fleet_supplier_name VARCHAR(128) NOT NULL COMMENT 'Warehouse供应商名称快照', + pickup_appointment_at DATETIME(3) NULL COMMENT '预约提柜时间', + actual_pickup_at DATETIME(3) NULL COMMENT '实际提柜时间', + estimated_empty_return_at DATETIME(3) NULL COMMENT '服务端按卸柜完成时间加72小时计算的预计还柜时间', + actual_empty_return_at DATETIME(3) NULL COMMENT '实际还空柜时间', + version INT UNSIGNED NOT NULL DEFAULT 1 COMMENT '乐观锁版本号', + created_by BIGINT UNSIGNED NOT NULL COMMENT '创建人ID', + created_by_name VARCHAR(64) NULL COMMENT '创建人名称快照', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', + updated_by BIGINT UNSIGNED NOT NULL COMMENT '最后更新人ID', + updated_by_name VARCHAR(64) NULL COMMENT '最后更新人名称快照', + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间', + PRIMARY KEY(id), + UNIQUE KEY uk_terminal_drayage_container(container_booking_id), + KEY idx_terminal_drayage_warning(actual_empty_return_at,estimated_empty_return_at), + CONSTRAINT fk_terminal_drayage_container FOREIGN KEY(container_booking_id) REFERENCES fl_container_booking(id), + CONSTRAINT chk_terminal_drayage_times CHECK( + (actual_pickup_at IS NULL OR pickup_appointment_at IS NOT NULL) AND + (actual_empty_return_at IS NULL OR actual_pickup_at IS NOT NULL) AND + (actual_empty_return_at IS NULL OR actual_empty_return_at >= actual_pickup_at)) +) ENGINE=InnoDB COMMENT='目的港货柜级码头拖车与还空事实'; + +CREATE TABLE IF NOT EXISTS fl_terminal_drayage_history ( + id BIGINT UNSIGNED NOT NULL COMMENT '历史ID', + drayage_id BIGINT UNSIGNED NOT NULL COMMENT '码头拖车聚合ID', + container_booking_id BIGINT UNSIGNED NOT NULL COMMENT '货柜ID快照', + action VARCHAR(32) NOT NULL COMMENT 'SELECT_FLEET/SCHEDULE_PICKUP/RECORD_PICKUP/RECORD_EMPTY_RETURN', + from_status VARCHAR(32) NOT NULL COMMENT '操作前状态', + to_status VARCHAR(32) NOT NULL COMMENT '操作后状态', + from_version INT UNSIGNED NOT NULL COMMENT '操作前版本', + to_version INT UNSIGNED NOT NULL COMMENT '操作后版本', + reason VARCHAR(500) NULL COMMENT '操作说明', + operator_id BIGINT UNSIGNED NOT NULL COMMENT '操作人ID', + operator_name VARCHAR(64) NULL COMMENT '操作人名称快照', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '操作时间', + PRIMARY KEY(id), + KEY idx_terminal_drayage_history(container_booking_id,created_at,id), + CONSTRAINT fk_terminal_drayage_history FOREIGN KEY(drayage_id) REFERENCES fl_terminal_drayage(id), + CONSTRAINT chk_terminal_drayage_history_action CHECK(action IN + ('SELECT_FLEET','SCHEDULE_PICKUP','RECORD_PICKUP','RECORD_EMPTY_RETURN')) +) ENGINE=InnoDB COMMENT='目的港码头拖车不可变操作历史'; + +CREATE TABLE IF NOT EXISTS fl_terminal_drayage_operation ( + id BIGINT UNSIGNED NOT NULL COMMENT '幂等操作ID', + container_booking_id BIGINT UNSIGNED NOT NULL COMMENT '货柜ID', + operation VARCHAR(32) NOT NULL COMMENT '业务动作', + idempotency_key VARCHAR(255) NOT NULL COMMENT '客户端幂等键', + request_hash CHAR(64) NOT NULL COMMENT '请求载荷SHA-256', + result_version INT UNSIGNED NOT NULL COMMENT '成功结果版本', + operator_id BIGINT UNSIGNED NOT NULL COMMENT '操作人ID', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '完成时间', + PRIMARY KEY(id), + UNIQUE KEY uk_terminal_drayage_operation(container_booking_id,idempotency_key), + KEY idx_terminal_drayage_operation(container_booking_id,created_at), + CONSTRAINT fk_terminal_drayage_operation_container FOREIGN KEY(container_booking_id) REFERENCES fl_container_booking(id) +) ENGINE=InnoDB COMMENT='目的港码头拖车成功幂等操作'; diff --git a/firstleg/src/main/resources/openapi/terminal-drayage-v1.yaml b/firstleg/src/main/resources/openapi/terminal-drayage-v1.yaml new file mode 100644 index 000000000..454e6c00d --- /dev/null +++ b/firstleg/src/main/resources/openapi/terminal-drayage-v1.yaml @@ -0,0 +1,127 @@ +openapi: 3.0.3 +info: {title: Firstleg Terminal Drayage API, version: 1.0.0} +paths: + /api/platform/v1/terminal-drayages: + get: + summary: 批量查询货柜码头拖车及服务端报空预警 + x-permission: platform:firstleg:container:list + parameters: + - name: containerBookingIds + in: query + required: true + schema: {type: array, minItems: 1, maxItems: 200, items: {type: string, pattern: '^[1-9][0-9]*$'}} + responses: {'200': {$ref: '#/components/responses/ViewsResponse'}} + /api/platform/v1/terminal-drayages/{containerBookingId}: + get: + summary: 查询单柜码头拖车详情 + x-permission: platform:firstleg:container:list + parameters: [{$ref: '#/components/parameters/ContainerBookingId'}] + responses: {'200': {$ref: '#/components/responses/ViewResponse'}} + /api/platform/v1/terminal-drayages/{containerBookingId}/history: + get: + summary: 查询单柜码头拖车操作历史 + x-permission: platform:firstleg:container:list + parameters: [{$ref: '#/components/parameters/ContainerBookingId'}] + responses: {'200': {$ref: '#/components/responses/HistoryResponse'}} + /api/platform/v1/terminal-drayages/{containerBookingId}/fleet: + post: + summary: 到港后选择或更换码头车队 + x-permission: platform:firstleg:container:edit + parameters: [{$ref: '#/components/parameters/ContainerBookingId'}, {$ref: '#/components/parameters/IdempotencyKey'}] + requestBody: {required: true, content: {application/json: {schema: {$ref: '#/components/schemas/FleetSelectionRequest'}}}} + responses: {'200': {$ref: '#/components/responses/ViewResponse'}} + /api/platform/v1/terminal-drayages/{containerBookingId}/pickup-appointment: + post: + summary: 预约目的港提柜时间 + x-permission: platform:firstleg:container:edit + parameters: [{$ref: '#/components/parameters/ContainerBookingId'}, {$ref: '#/components/parameters/IdempotencyKey'}] + requestBody: {required: true, content: {application/json: {schema: {$ref: '#/components/schemas/PickupAppointmentRequest'}}}} + responses: {'200': {$ref: '#/components/responses/ViewResponse'}} + /api/platform/v1/terminal-drayages/{containerBookingId}/pickup: + post: + summary: 清关可提柜后确认实际提柜并推进PICKED_UP节点 + x-permission: platform:firstleg:container:edit + parameters: [{$ref: '#/components/parameters/ContainerBookingId'}, {$ref: '#/components/parameters/IdempotencyKey'}] + requestBody: {required: true, content: {application/json: {schema: {$ref: '#/components/schemas/ActualTimeRequest'}}}} + responses: {'200': {$ref: '#/components/responses/ViewResponse'}} + /api/platform/v1/terminal-drayages/{containerBookingId}/empty-return: + post: + summary: 海外卸柜收货完成后确认实际还柜并推进EMPTY_RETURNED节点 + x-permission: platform:firstleg:container:edit + parameters: [{$ref: '#/components/parameters/ContainerBookingId'}, {$ref: '#/components/parameters/IdempotencyKey'}] + requestBody: {required: true, content: {application/json: {schema: {$ref: '#/components/schemas/ActualTimeRequest'}}}} + responses: {'200': {$ref: '#/components/responses/ViewResponse'}} +components: + parameters: + ContainerBookingId: {name: containerBookingId, in: path, required: true, schema: {type: string, pattern: '^[1-9][0-9]*$'}} + IdempotencyKey: {name: Idempotency-Key, in: header, required: true, schema: {type: string, minLength: 1, maxLength: 255}} + schemas: + FleetSelectionRequest: + type: object + required: [expectedVersion, fleetSupplierId] + properties: + expectedVersion: {type: integer, minimum: 0} + fleetSupplierId: {type: string, pattern: '^[1-9][0-9]*$', description: Warehouse ACTIVE/TRUCKING供应商ID} + fleetSupplierName: {type: string, maxLength: 128, nullable: true, description: 可选一致性提示,持久化名称以服务端快照为准} + reason: {type: string, maxLength: 500, nullable: true} + PickupAppointmentRequest: + type: object + required: [expectedVersion, pickupAppointmentAt] + properties: + expectedVersion: {type: integer, minimum: 0} + pickupAppointmentAt: {type: string, format: date-time} + reason: {type: string, maxLength: 500, nullable: true} + ActualTimeRequest: + type: object + required: [expectedVersion, actualAt] + properties: + expectedVersion: {type: integer, minimum: 0} + actualAt: {type: string, format: date-time} + reason: {type: string, maxLength: 500, nullable: true} + Warning: + type: object + required: [status, overdueHours, overdueDays] + properties: + status: {type: string, enum: [NOT_APPLICABLE, PENDING, OVERDUE, RESOLVED]} + deadlineAt: {type: string, format: date-time, nullable: true, description: 卸柜完成时间加72小时} + overdueHours: {type: integer, format: int64, minimum: 0} + overdueDays: {type: integer, format: int64, minimum: 0} + TerminalDrayageView: + type: object + required: [containerBookingId, status, version, emptyReturnWarning, allowedActions] + properties: + containerBookingId: {type: string, pattern: '^[1-9][0-9]*$'} + containerNo: {type: string, nullable: true} + oceanBookingId: {type: string, pattern: '^[1-9][0-9]*$', nullable: true} + status: {type: string, enum: [WAITING_FOR_ARRIVAL, ARRIVED, PICKUP_SCHEDULED, PICKED_UP, UNLOADED, EMPTY_RETURNED]} + version: {type: integer, minimum: 0} + fleetSupplierId: {type: string, pattern: '^[1-9][0-9]*$', nullable: true} + fleetSupplierName: {type: string, nullable: true} + pickupAppointmentAt: {type: string, format: date-time, nullable: true} + actualPickupAt: {type: string, format: date-time, nullable: true} + estimatedEmptyReturnAt: {type: string, format: date-time, nullable: true, description: 服务端权威值,卸柜完成时间加72小时} + actualEmptyReturnAt: {type: string, format: date-time, nullable: true} + unloadingCompletedAt: {type: string, format: date-time, nullable: true} + emptyReturnWarning: {$ref: '#/components/schemas/Warning'} + allowedActions: {type: array, items: {type: string, enum: [SELECT_FLEET, SCHEDULE_PICKUP, RECORD_PICKUP, RECORD_EMPTY_RETURN]}} + updatedAt: {type: string, format: date-time, nullable: true} + History: + type: object + required: [action, fromStatus, toStatus, fromVersion, toVersion, operatorId, createdAt] + properties: + action: {type: string, enum: [SELECT_FLEET, SCHEDULE_PICKUP, RECORD_PICKUP, RECORD_EMPTY_RETURN]} + fromStatus: {type: string} + toStatus: {type: string} + fromVersion: {type: integer, minimum: 0} + toVersion: {type: integer, minimum: 1} + reason: {type: string, nullable: true} + operatorId: {type: string, pattern: '^[1-9][0-9]*$'} + operatorName: {type: string, nullable: true} + createdAt: {type: string, format: date-time} + ApiView: {type: object, required: [code, data], properties: {code: {type: string}, message: {type: string}, data: {$ref: '#/components/schemas/TerminalDrayageView'}}} + ApiViews: {type: object, required: [code, data], properties: {code: {type: string}, message: {type: string}, data: {type: array, items: {$ref: '#/components/schemas/TerminalDrayageView'}}}} + ApiHistory: {type: object, required: [code, data], properties: {code: {type: string}, message: {type: string}, data: {type: array, items: {$ref: '#/components/schemas/History'}}}} + responses: + ViewResponse: {description: 成功, content: {application/json: {schema: {$ref: '#/components/schemas/ApiView'}}}} + ViewsResponse: {description: 成功, content: {application/json: {schema: {$ref: '#/components/schemas/ApiViews'}}}} + HistoryResponse: {description: 成功, content: {application/json: {schema: {$ref: '#/components/schemas/ApiHistory'}}}} diff --git a/firstleg/src/test/java/com/baiyue/firstleg/terminaldrayage/controller/TerminalDrayageOpenApiContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/terminaldrayage/controller/TerminalDrayageOpenApiContractTest.java new file mode 100644 index 000000000..9400ccb73 --- /dev/null +++ b/firstleg/src/test/java/com/baiyue/firstleg/terminaldrayage/controller/TerminalDrayageOpenApiContractTest.java @@ -0,0 +1,24 @@ +package com.baiyue.firstleg.terminaldrayage.controller; + +import org.junit.jupiter.api.Test; + +import java.nio.file.Files; +import java.nio.file.Path; + +import static org.assertj.core.api.Assertions.assertThat; + +class TerminalDrayageOpenApiContractTest { + @Test + void freezesActionsPermissionsAndServerWarningFields() throws Exception { + String yaml = Files.readString(Path.of("src/main/resources/openapi/terminal-drayage-v1.yaml")); + assertThat(yaml).contains( + "/api/platform/v1/terminal-drayages/{containerBookingId}/fleet:", + "/api/platform/v1/terminal-drayages/{containerBookingId}/pickup-appointment:", + "/api/platform/v1/terminal-drayages/{containerBookingId}/pickup:", + "/api/platform/v1/terminal-drayages/{containerBookingId}/empty-return:", + "platform:firstleg:container:list", "platform:firstleg:container:edit", + "expectedVersion", "Idempotency-Key", "estimatedEmptyReturnAt", + "emptyReturnWarning", "deadlineAt", "overdueHours", "overdueDays"); + assertThat(yaml).contains("description: 卸柜完成时间加72小时"); + } +} diff --git a/firstleg/src/test/java/com/baiyue/firstleg/terminaldrayage/mapper/TerminalDrayageSqlContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/terminaldrayage/mapper/TerminalDrayageSqlContractTest.java new file mode 100644 index 000000000..41c95d782 --- /dev/null +++ b/firstleg/src/test/java/com/baiyue/firstleg/terminaldrayage/mapper/TerminalDrayageSqlContractTest.java @@ -0,0 +1,37 @@ +package com.baiyue.firstleg.terminaldrayage.mapper; + +import org.junit.jupiter.api.Test; + +import java.nio.file.Files; +import java.nio.file.Path; + +import static org.assertj.core.api.Assertions.assertThat; + +class TerminalDrayageSqlContractTest { + @Test + void schemaKeepsOneAggregatePerContainerAndImmutableAudit() throws Exception { + String sql = Files.readString(Path.of("src/main/resources/db/phase3-terminal-drayage-schema.sql")); + String mapper = Files.readString(Path.of( + "src/main/java/com/baiyue/firstleg/terminaldrayage/mapper/TerminalDrayageMapper.java")); + assertThat(sql).contains( + "CREATE TABLE IF NOT EXISTS fl_terminal_drayage (", + "UNIQUE KEY uk_terminal_drayage_container(container_booking_id)", + "CREATE TABLE IF NOT EXISTS fl_terminal_drayage_history (", + "CREATE TABLE IF NOT EXISTS fl_terminal_drayage_operation (", + "UNIQUE KEY uk_terminal_drayage_operation(container_booking_id,idempotency_key)", + "服务端按卸柜完成时间加72小时计算的预计还柜时间"); + assertThat(sql).doesNotContain("ON DELETE CASCADE"); + assertThat(mapper).contains("WHERE b.id=#{containerBookingId} FOR UPDATE", + "WHERE container_booking_id=#{value.containerBookingId} AND version=#{expectedVersion}"); + } + + @Test + void unifiedMigrationRepairsColumnsAfterAPartialDdlRun() throws Exception { + String sql = Files.readString(Path.of("../deploy/sql/头程数据库统一增量-MySQL8.sql")); + assertThat(sql).contains( + "ALTER TABLE fl_terminal_drayage", + "ALTER TABLE fl_terminal_drayage_history", + "ALTER TABLE fl_terminal_drayage_operation", + "MODIFY COLUMN estimated_empty_return_at DATETIME(3) NULL COMMENT '服务端按卸柜完成时间加72小时计算的预计还柜时间'"); + } +} diff --git a/firstleg/src/test/java/com/baiyue/firstleg/terminaldrayage/service/TerminalDrayageServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/terminaldrayage/service/TerminalDrayageServiceTest.java new file mode 100644 index 000000000..03e270d7d --- /dev/null +++ b/firstleg/src/test/java/com/baiyue/firstleg/terminaldrayage/service/TerminalDrayageServiceTest.java @@ -0,0 +1,264 @@ +package com.baiyue.firstleg.terminaldrayage.service; + +import com.baiyue.common.core.exception.BizException; +import com.baiyue.firstleg.client.WarehouseSupplierClient; +import com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.ManualNodeRequest; +import com.baiyue.firstleg.portschedule.service.CarrierScheduleQueryService; +import com.baiyue.firstleg.service.WarehouseSupplierSnapshotService; +import com.baiyue.firstleg.terminaldrayage.dto.TerminalDrayageDtos.ActualTimeRequest; +import com.baiyue.firstleg.terminaldrayage.dto.TerminalDrayageDtos.FleetSelectionRequest; +import com.baiyue.firstleg.terminaldrayage.entity.TerminalDrayageEntity; +import com.baiyue.firstleg.terminaldrayage.mapper.TerminalDrayageMapper; +import com.baiyue.firstleg.terminaldrayage.mapper.TerminalDrayageMapper.Context; +import com.baiyue.firstleg.terminaldrayage.mapper.TerminalDrayageMapper.Operation; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; + +import java.time.Clock; +import java.time.Instant; +import java.time.LocalDateTime; +import java.time.ZoneOffset; +import java.util.List; +import java.util.concurrent.atomic.AtomicReference; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +class TerminalDrayageServiceTest { + private static final LocalDateTime ARRIVED_AT = LocalDateTime.of(2026, 9, 1, 8, 0); + private TerminalDrayageMapper mapper; + private WarehouseSupplierSnapshotService suppliers; + private CarrierScheduleQueryService carrierSchedules; + private TerminalDrayageService service; + + @BeforeEach + void setUp() { + mapper = mock(TerminalDrayageMapper.class); + suppliers = mock(WarehouseSupplierSnapshotService.class); + carrierSchedules = mock(CarrierScheduleQueryService.class); + service = new TerminalDrayageService(mapper, suppliers, carrierSchedules, new ObjectMapper().findAndRegisterModules()); + service.setClock(Clock.fixed(Instant.parse("2026-09-05T02:00:00Z"), ZoneOffset.UTC)); + } + + @Test + void rejectsFleetSelectionBeforeActualDestinationArrival() { + when(mapper.lockContext(11L)).thenReturn(context("IN_TRANSIT", null)); + + assertThatThrownBy(() -> service.selectFleet(11L, 7L, "admin", "fleet-1", + new FleetSelectionRequest(0, 91L, null, null))) + .isInstanceOf(BizException.class) + .hasMessage("货柜实际到达目的港后才能安排码头车队"); + verify(suppliers, never()).trucking(anyLong(), any()); + } + + @Test + void selectsActiveTruckingSupplierAndPersistsServerSnapshot() { + Context context = context("ARRIVED_DESTINATION_PORT", null); + when(mapper.lockContext(11L)).thenReturn(context); + when(mapper.context(11L)).thenReturn(context); + when(mapper.lock(11L)).thenReturn(null); + when(mapper.operation(11L, "fleet-1")).thenReturn(null); + when(suppliers.trucking(91L, "用户提示名")).thenReturn( + new WarehouseSupplierClient.Snapshot(91L, "服务端车队", "TRUCKING", "ACTIVE", List.of())); + AtomicReference stored = new AtomicReference<>(); + when(mapper.insert(any(TerminalDrayageEntity.class))).thenAnswer(invocation -> { + TerminalDrayageEntity value = invocation.getArgument(0); + stored.set(value); + return 1; + }); + when(mapper.find(11L)).thenAnswer(ignored -> stored.get()); + + var result = service.selectFleet(11L, 7L, "admin", "fleet-1", + new FleetSelectionRequest(0, 91L, "用户提示名", "安排提柜")); + + assertThat(result.version()).isEqualTo(1); + assertThat(result.fleetSupplierId()).isEqualTo(91L); + assertThat(result.fleetSupplierName()).isEqualTo("服务端车队"); + assertThat(result.allowedActions()).containsExactlyInAnyOrder("SELECT_FLEET", "SCHEDULE_PICKUP"); + verify(mapper).insertHistory(anyLong(), anyLong(), anyLong(), anyString(), anyString(), anyString(), + anyInt(), anyInt(), any(), anyLong(), anyString()); + verify(mapper).insertOperation(anyLong(), anyLong(), anyString(), anyString(), anyString(), anyInt(), anyLong()); + } + + @Test + void recordsPickupOnceAndAdvancesCanonicalNode() { + Context before = context("PICKUP_READY", null); + Context after = context("PICKED_UP", null); + when(mapper.lockContext(11L)).thenReturn(before); + when(mapper.context(11L)).thenReturn(after); + when(mapper.operation(11L, "pickup-1")).thenReturn(null); + TerminalDrayageEntity row = row(2); + row.setPickupAppointmentAt(LocalDateTime.of(2026, 9, 2, 9, 0)); + when(mapper.lock(11L)).thenReturn(row); + when(mapper.updateAggregate(any(), anyInt(), anyLong(), anyString())).thenAnswer(invocation -> { + row.setVersion(3); + return 1; + }); + when(mapper.find(11L)).thenReturn(row); + + var result = service.recordPickup(11L, 7L, "admin", "pickup-1", + new ActualTimeRequest(2, LocalDateTime.of(2026, 9, 2, 10, 0), "码头提柜")); + + assertThat(result.status()).isEqualTo("PICKED_UP"); + assertThat(result.actualPickupAt()).isEqualTo(LocalDateTime.of(2026, 9, 2, 10, 0)); + ArgumentCaptor node = ArgumentCaptor.forClass(ManualNodeRequest.class); + verify(carrierSchedules).addManualNode(anyLong(), anyLong(), anyString(), anyString(), node.capture()); + assertThat(node.getValue().standardNode()).isEqualTo("PICKED_UP"); + verify(mapper, times(1)).updateAggregate(any(), anyInt(), anyLong(), anyString()); + } + + @Test + void requiresOverseasUnloadBeforeEmptyReturn() { + Context context = context("PICKED_UP", null); + when(mapper.lockContext(11L)).thenReturn(context); + when(mapper.operation(11L, "return-1")).thenReturn(null); + TerminalDrayageEntity row = row(3); + row.setActualPickupAt(LocalDateTime.of(2026, 9, 2, 10, 0)); + when(mapper.lock(11L)).thenReturn(row); + + assertThatThrownBy(() -> service.recordEmptyReturn(11L, 7L, "admin", "return-1", + new ActualTimeRequest(3, LocalDateTime.of(2026, 9, 4, 10, 0), null))) + .isInstanceOf(BizException.class) + .hasMessage("海外卸柜收货完成后才能确认还柜"); + verify(carrierSchedules, never()).addManualNode(anyLong(), anyLong(), anyString(), anyString(), any()); + } + + @Test + void computesSeventyTwoHourWarningAndClearsItAfterReturn() { + LocalDateTime unloadedAt = LocalDateTime.of(2026, 9, 1, 0, 0); + Context context = context("PICKED_UP", unloadedAt); + TerminalDrayageEntity row = row(3); + row.setActualPickupAt(LocalDateTime.of(2026, 8, 31, 18, 0)); + when(mapper.context(11L)).thenReturn(context); + when(mapper.find(11L)).thenReturn(row); + + var overdue = service.detail(11L); + assertThat(overdue.estimatedEmptyReturnAt()).isEqualTo(LocalDateTime.of(2026, 9, 4, 0, 0)); + assertThat(overdue.emptyReturnWarning().status()).isEqualTo("OVERDUE"); + assertThat(overdue.emptyReturnWarning().overdueHours()).isEqualTo(26); + assertThat(overdue.emptyReturnWarning().overdueDays()).isEqualTo(1); + + row.setActualEmptyReturnAt(LocalDateTime.of(2026, 9, 4, 1, 0)); + assertThat(service.detail(11L).emptyReturnWarning().status()).isEqualTo("RESOLVED"); + assertThat(service.detail(11L).emptyReturnWarning().overdueHours()).isZero(); + } + + @Test + void doesNotOfferEmptyReturnWithoutExecutableDrayageAggregate() { + LocalDateTime unloadedAt = LocalDateTime.of(2026, 9, 3, 0, 0); + Context context = context("PICKED_UP", unloadedAt); + when(mapper.context(11L)).thenReturn(context); + when(mapper.find(11L)).thenReturn(null); + + var result = service.detail(11L); + + assertThat(result.status()).isEqualTo("UNLOADED"); + assertThat(result.allowedActions()).doesNotContain("RECORD_EMPTY_RETURN"); + } + + @Test + void sameIdempotencyKeyWithDifferentPayloadConflictsWithoutWriting() { + Context context = context("ARRIVED_DESTINATION_PORT", null); + when(mapper.lockContext(11L)).thenReturn(context); + when(mapper.operation(11L, "same-key")).thenReturn(new Operation("SELECT_FLEET", "different-hash", 1)); + + assertThatThrownBy(() -> service.selectFleet(11L, 7L, "admin", "same-key", + new FleetSelectionRequest(0, 91L, null, null))) + .isInstanceOf(BizException.class) + .hasMessage("Idempotency-Key已被不同的码头拖车操作占用"); + verify(mapper, never()).insert(any(TerminalDrayageEntity.class)); + verify(mapper, never()).updateAggregate(any(), anyInt(), anyLong(), anyString()); + } + + @Test + void sameIdempotencyKeyAndPayloadReplaysWithoutSecondWrite() { + Context context = context("ARRIVED_DESTINATION_PORT", null); + when(mapper.lockContext(11L)).thenReturn(context); + when(mapper.context(11L)).thenReturn(context); + when(mapper.lock(11L)).thenReturn(null); + when(suppliers.trucking(91L, null)).thenReturn( + new WarehouseSupplierClient.Snapshot(91L, "服务端车队", "TRUCKING", "ACTIVE", List.of())); + AtomicReference stored = new AtomicReference<>(); + AtomicReference operation = new AtomicReference<>(); + when(mapper.insert(any(TerminalDrayageEntity.class))).thenAnswer(invocation -> { + stored.set(invocation.getArgument(0)); + return 1; + }); + when(mapper.find(11L)).thenAnswer(ignored -> stored.get()); + when(mapper.operation(11L, "fleet-replay")).thenAnswer(ignored -> operation.get()); + when(mapper.insertOperation(anyLong(), anyLong(), anyString(), anyString(), anyString(), anyInt(), anyLong())) + .thenAnswer(invocation -> { + operation.set(new Operation(invocation.getArgument(2), invocation.getArgument(4), invocation.getArgument(5))); + return 1; + }); + FleetSelectionRequest request = new FleetSelectionRequest(0, 91L, null, null); + + var first = service.selectFleet(11L, 7L, "admin", "fleet-replay", request); + var replay = service.selectFleet(11L, 7L, "admin", "fleet-replay", request); + + assertThat(replay).isEqualTo(first); + verify(mapper, times(1)).insert(any(TerminalDrayageEntity.class)); + verify(mapper, times(1)).insertOperation(anyLong(), anyLong(), anyString(), anyString(), anyString(), anyInt(), anyLong()); + verify(suppliers, times(1)).trucking(91L, null); + } + + @Test + void recordsEmptyReturnAndResolvesWarning() { + LocalDateTime unloadedAt = LocalDateTime.of(2026, 9, 3, 0, 0); + Context before = context("PICKED_UP", unloadedAt); + Context after = context("EMPTY_RETURNED", unloadedAt); + when(mapper.lockContext(11L)).thenReturn(before); + when(mapper.context(11L)).thenReturn(after); + when(mapper.operation(11L, "return-2")).thenReturn(null); + TerminalDrayageEntity row = row(3); + row.setPickupAppointmentAt(LocalDateTime.of(2026, 9, 2, 9, 0)); + row.setActualPickupAt(LocalDateTime.of(2026, 9, 2, 10, 0)); + when(mapper.lock(11L)).thenReturn(row); + when(mapper.updateAggregate(any(), anyInt(), anyLong(), anyString())).thenAnswer(invocation -> { + row.setVersion(4); + return 1; + }); + when(mapper.find(11L)).thenReturn(row); + + var result = service.recordEmptyReturn(11L, 7L, "admin", "return-2", + new ActualTimeRequest(3, LocalDateTime.of(2026, 9, 4, 9, 0), "已还柜")); + + assertThat(result.status()).isEqualTo("EMPTY_RETURNED"); + assertThat(result.estimatedEmptyReturnAt()).isEqualTo(unloadedAt.plusHours(72)); + assertThat(result.emptyReturnWarning().status()).isEqualTo("RESOLVED"); + ArgumentCaptor node = ArgumentCaptor.forClass(ManualNodeRequest.class); + verify(carrierSchedules).addManualNode(anyLong(), anyLong(), anyString(), anyString(), node.capture()); + assertThat(node.getValue().standardNode()).isEqualTo("EMPTY_RETURNED"); + } + + private Context context(String node, LocalDateTime unloadedAt) { + int sequenceVersion = switch (node) { + case "PICKED_UP" -> 5; + case "PICKUP_READY" -> 4; + case "ARRIVED_DESTINATION_PORT" -> 3; + default -> 2; + }; + return new Context(11L, "MSCU1234567", 21L, "ACTIVE", unloadedAt, + 31L, node, ARRIVED_AT, sequenceVersion, ARRIVED_AT, + "PICKED_UP".equals(node) || "EMPTY_RETURNED".equals(node) ? ARRIVED_AT.plusDays(1) : null, + "EMPTY_RETURNED".equals(node) ? ARRIVED_AT.plusDays(3) : null); + } + + private TerminalDrayageEntity row(int version) { + TerminalDrayageEntity value = new TerminalDrayageEntity(); + value.setId(41L); value.setContainerBookingId(11L); value.setVersion(version); + value.setFleetSupplierId(91L); value.setFleetSupplierName("服务端车队"); + return value; + } +} diff --git a/firstleg/src/test/resources/architecture/firstleg-slice-dependencies.json b/firstleg/src/test/resources/architecture/firstleg-slice-dependencies.json index 4ffb1a909..89298259e 100644 --- a/firstleg/src/test/resources/architecture/firstleg-slice-dependencies.json +++ b/firstleg/src/test/resources/architecture/firstleg-slice-dependencies.json @@ -7,6 +7,7 @@ "booking-customs-trucking->outbound", "booking-customs-trucking->pricebook-query", "booking-customs-trucking->shared-technical", + "booking-customs-trucking->shipping-nodes", "booking-customs-trucking->split-loading", "customer-statistics->shared-technical", "finance-integration->inbound", diff --git a/gateway/src/main/resources/application.yml b/gateway/src/main/resources/application.yml index 141cf31ef..fbd66bd91 100644 --- a/gateway/src/main/resources/application.yml +++ b/gateway/src/main/resources/application.yml @@ -77,7 +77,7 @@ spring: - id: firstleg-service uri: lb://firstleg-service predicates: - - Path=/api/merchant/v1/first-leg/**,/api/merchant/v1/customs/**,/api/platform/v1/first-leg/**,/api/platform/v1/customs/**,/api/platform/v1/port-schedules/**,/api/platform/v1/carrier-schedule-queries/**,/api/platform/v1/containers/**,/api/platform/v1/customers/{customerId}/container-transit-summary,/api/platform/v1/ocean-bookings/**,/api/platform/v1/truck-appointments/**,/api/platform/v1/fba-pricing/**,/api/platform/v1/inbounds/**,/api/platform/v1/fee-items/**,/api/platform/v1/loading-plans/**,/api/platform/inbounds/**,/openapi/v1/inbound-measurements/** + - Path=/api/merchant/v1/first-leg/**,/api/merchant/v1/customs/**,/api/platform/v1/first-leg/**,/api/platform/v1/customs/**,/api/platform/v1/port-schedules/**,/api/platform/v1/carrier-schedule-queries/**,/api/platform/v1/containers/**,/api/platform/v1/terminal-drayages/**,/api/platform/v1/customers/{customerId}/container-transit-summary,/api/platform/v1/ocean-bookings/**,/api/platform/v1/truck-appointments/**,/api/platform/v1/fba-pricing/**,/api/platform/v1/inbounds/**,/api/platform/v1/fee-items/**,/api/platform/v1/loading-plans/**,/api/platform/inbounds/**,/openapi/v1/inbound-measurements/** - id: finance-service uri: lb://finance-service predicates: diff --git a/gateway/src/test/java/com/baiyue/gateway/config/FirstlegContainerRouteContractTest.java b/gateway/src/test/java/com/baiyue/gateway/config/FirstlegContainerRouteContractTest.java index 4584d620f..262b95956 100644 --- a/gateway/src/test/java/com/baiyue/gateway/config/FirstlegContainerRouteContractTest.java +++ b/gateway/src/test/java/com/baiyue/gateway/config/FirstlegContainerRouteContractTest.java @@ -15,6 +15,7 @@ class FirstlegContainerRouteContractTest { assertThat(yaml) .contains("/api/platform/v1/containers/**") + .contains("/api/platform/v1/terminal-drayages/**") .contains("/api/platform/v1/loading-plans/**"); } diff --git a/iam/src/main/java/com/baiyue/iam/service/MemberService.java b/iam/src/main/java/com/baiyue/iam/service/MemberService.java index 46813a762..43f79000e 100644 --- a/iam/src/main/java/com/baiyue/iam/service/MemberService.java +++ b/iam/src/main/java/com/baiyue/iam/service/MemberService.java @@ -170,6 +170,8 @@ public class MemberService { membership.setLeftAt("DISABLED".equals(request.status()) ? LocalDateTime.now() : null); user.setStatus(request.status()); repository.saveUser(user); repository.save(membership); securityEvents.userStatus(user.getAuthUserId(), tenantId, "MERCHANT", request.status()); + securityEvents.permissionChanged(user.getAuthUserId(), tenantId, "MERCHANT", + "MEMBER_STATUS_CHANGED"); } catch (RuntimeException localFailure) { try { authClient.status(internalToken, user.getAuthUserId(), new AuthAccountClient.StatusRequest(oldStatus)); diff --git a/iam/src/main/java/com/baiyue/iam/service/PlatformInternalUserService.java b/iam/src/main/java/com/baiyue/iam/service/PlatformInternalUserService.java index 032898b2c..8f94a889d 100644 --- a/iam/src/main/java/com/baiyue/iam/service/PlatformInternalUserService.java +++ b/iam/src/main/java/com/baiyue/iam/service/PlatformInternalUserService.java @@ -147,6 +147,8 @@ public class PlatformInternalUserService { repository.saveUser(user); repository.save(membership); securityEvents.userStatus(user.getAuthUserId(), PLATFORM_TENANT_ID, "PLATFORM", request.status()); + securityEvents.permissionChanged(user.getAuthUserId(), PLATFORM_TENANT_ID, "PLATFORM", + "PLATFORM_INTERNAL_USER_STATUS_CHANGED"); return response(membership, user, authAccount.username(), roleIds); } catch (RuntimeException localFailure) { compensateStatus(user.getAuthUserId(), authAccount.status(), localFailure); diff --git a/iam/src/main/java/com/baiyue/iam/service/PlatformUserManagementService.java b/iam/src/main/java/com/baiyue/iam/service/PlatformUserManagementService.java index a277858ae..fa73ece19 100644 --- a/iam/src/main/java/com/baiyue/iam/service/PlatformUserManagementService.java +++ b/iam/src/main/java/com/baiyue/iam/service/PlatformUserManagementService.java @@ -203,6 +203,8 @@ public class PlatformUserManagementService { members.save(membership); securityEvents.userStatus(user.getAuthUserId(), membership.getTenantId(), appCode(membership.getTenantId()), request.status()); + securityEvents.permissionChanged(user.getAuthUserId(), membership.getTenantId(), + appCode(membership.getTenantId()), "PLATFORM_USER_STATUS_CHANGED"); } catch (RuntimeException localFailure) { call(() -> authClient.status(internalToken, user.getAuthUserId(), new AuthAccountClient.StatusRequest(oldAccount.status()))); diff --git a/iam/src/test/java/com/baiyue/iam/service/MemberServiceTest.java b/iam/src/test/java/com/baiyue/iam/service/MemberServiceTest.java index 29f97b03f..b190038a9 100644 --- a/iam/src/test/java/com/baiyue/iam/service/MemberServiceTest.java +++ b/iam/src/test/java/com/baiyue/iam/service/MemberServiceTest.java @@ -106,6 +106,8 @@ class MemberServiceTest { assertEquals("DISABLED", result.status()); verify(repository).save(member); verify(repository).saveUser(user); + verify(securityEvents).userStatus(40L, 100L, "MERCHANT", "DISABLED"); + verify(securityEvents).permissionChanged(40L, 100L, "MERCHANT", "MEMBER_STATUS_CHANGED"); } @Test void shouldHandleCase05() { diff --git a/iam/src/test/java/com/baiyue/iam/service/PlatformInternalUserServiceTest.java b/iam/src/test/java/com/baiyue/iam/service/PlatformInternalUserServiceTest.java index 6e8da47c5..ede6e98a7 100644 --- a/iam/src/test/java/com/baiyue/iam/service/PlatformInternalUserServiceTest.java +++ b/iam/src/test/java/com/baiyue/iam/service/PlatformInternalUserServiceTest.java @@ -142,6 +142,19 @@ class PlatformInternalUserServiceTest { verify(auth, never()).status(anyString(), anyLong(), any()); } + @Test + void statusChangePublishesMatchingAuthorizationSnapshot() { + when(auth.status(anyString(), eq(10L), any())).thenReturn(ApiResponse.success(null)); + + InternalUserResponse result = service.changeStatus(30L, + new ChangeInternalUserStatusRequest("DISABLED"), 99L); + + assertEquals("DISABLED", result.status()); + verify(securityEvents).userStatus(10L, 0L, "PLATFORM", "DISABLED"); + verify(securityEvents).permissionChanged(10L, 0L, "PLATFORM", + "PLATFORM_INTERNAL_USER_STATUS_CHANGED"); + } + @Test void shouldHandleCase05() { when(auth.status(anyString(), eq(10L), any())).thenReturn(ApiResponse.success(null)); diff --git a/iam/src/test/java/com/baiyue/iam/service/PlatformUserManagementServiceTest.java b/iam/src/test/java/com/baiyue/iam/service/PlatformUserManagementServiceTest.java index 606955222..67b7cfc39 100644 --- a/iam/src/test/java/com/baiyue/iam/service/PlatformUserManagementServiceTest.java +++ b/iam/src/test/java/com/baiyue/iam/service/PlatformUserManagementServiceTest.java @@ -224,6 +224,20 @@ class PlatformUserManagementServiceTest { verify(auth, never()).status(anyString(), anyLong(), any()); } + @Test + void statusChangePublishesMatchingAuthorizationSnapshot() { + when(auth.status(anyString(), eq(10L), any())).thenReturn(ApiResponse.success(null)); + when(auth.summary("internal-token", 10L)).thenReturn(ApiResponse.success( + new AuthAccountClient.AccountSummary(10L, "operator", "平台运营", "ACTIVE"))); + when(members.findRoleIds(0L, 30L)).thenReturn(List.of(40001L)); + + UserResponse result = service.changeStatus(30L, new ChangeStatusRequest("ACTIVE"), 99L); + + assertEquals("ACTIVE", result.status()); + verify(securityEvents).userStatus(10L, 0L, "PLATFORM", "ACTIVE"); + verify(securityEvents).permissionChanged(10L, 0L, "PLATFORM", "PLATFORM_USER_STATUS_CHANGED"); + } + @Test void builtInPlatformAdministratorCannotBeDisabled() { IamRole platformAdmin = new IamRole(); diff --git a/lastleg-service/AGENTS.md b/lastleg-service/AGENTS.md index e87189e11..aa5f65a7f 100644 --- a/lastleg-service/AGENTS.md +++ b/lastleg-service/AGENTS.md @@ -3,8 +3,8 @@ ## 职责与边界 - Mapper 统一显式使用 `@Mapper`,仅由 `BaiyueLastlegApplication.java` 对 `com.baiyue.lastleg` 根包按 `annotationClass = Mapper.class` 扫描;各业务切片不得新增 `*MapperConfiguration` 或维护包白名单。静态门禁见根 `scripts/mapper-scan-contract.mjs`,模块内门禁见 `MapperRegistrationArchitectureTest.java`。 -- 负责消费头程装柜完成事件,保存业务 Inbox,并按最终实装入仓单建立待分配尾程订单、明细冻结快照和初始状态历史;事件不得自动创建入仓计划,平台人工调用 `/api/platform/v1/last-leg/inbound-plans/convert` 后才生成计划并推进订单。 -- 负责尾程订单列表、详情、导出和目的仓调整,以及海外入仓计划、托盘分配与 PDA 入仓作业;PDA 卸柜完成后通过本地 Outbox 向国内头程回传货柜报空事实。 +- 负责消费头程装柜完成事件,保存业务 Inbox,并按最终实装入仓单建立待分配尾程订单、明细冻结快照和初始状态历史;事件不得自动创建入仓计划,平台人工调用 `/api/platform/v1/last-leg/inbound-plans/convert-container` 后才按柜内每票生成独立计划并推进订单。 +- 负责尾程订单列表、详情、导出、完整入库计划、暂扣/解除、人工节点和目的仓调整,以及海外入仓计划、托盘分配与 PDA 入仓作业;PDA 卸柜完成后通过本地 Outbox 向国内头程回传货柜报空事实。 - 不查询头程数据库,不调用头程接口补齐建单字段;事件快照缺失时应拒绝消费并保留可诊断错误。 ## 快速索引 @@ -13,6 +13,7 @@ |---|---| | 内部模块化基线与依赖分析 | `docs/lastleg-internal-modularization-baseline.md`、`scripts/analyze-lastleg-dependencies.mjs`、`scripts/analyze-lastleg-dependencies.test.mjs`、`src/test/resources/architecture/lastleg-slice-dependencies.json`;只约束本服务内部包依赖,不改变部署单元或数据库边界 | | 事件接收、平台订单列表/详情/导出、柜级进度、目的仓调整 | `src/main/java/com/baiyue/lastleg/controller/LastlegOrderController.java`;`/api/platform/v1/last-leg/orders/container-summaries` 按 `sourceContainerBookingId` 服务端聚合全柜订单、入仓码和有效计划,分页前排除全部入仓码均已生成有效独立计划的柜,`/{sourceContainerBookingId}` 返回柜内全部订单/入仓码/计划状态且不受订单分页截断;`plannedArrivalAt` 只取装柜完成事件 `estimatedWarehouseArrivalAt` 冻结到 `ll_order.estimated_warehouse_at` 的正式事实,按其升序、null 最后、稳定柜 ID 排序;目的仓调整仅接受国内 Warehouse Service 平台公共 `ACTIVE + FBA` 主档,写入前由 `service/FbaWarehouseValidator.java` 经内部只读目录校验 ID/编码/名称一致 | +| 尾程订单完整计划、暂扣与人工节点 | `controller/LastlegOrderController.java`、`service/LastlegOrderCommandService.java`、`inboundplan/service/InboundPlanService.java`、`dto/LastlegOrderCommandDtos.java`;单票计划写入校验订单版本、客户范围、订单冻结入仓码、稳定接柜仓 ID、当前仓库主档与幂等键;接柜仓编码/名称以该 ID 当前目录快照为准,不要求订单残留旧展示快照。暂扣/解除写不可变历史并发布 `InboundOrderDetainedOutboxService`,已拣货、已发运或已有出库单时禁止新增暂扣;暂扣期间不得进入排车或出库。人工节点必须一次提交固定八行完整快照且至少一个时间,首次保存后永久使用 `MANUAL` 来源,不得回退柜级节点或反向修改头程船节点 | | 客户尾程订单列表与详情 | `controller/MerchantLastlegOrderController.java`;客户范围只取认证身份,稳定类型/状态见 `model/LastlegOrderType.java`、`LastlegOrderStatus.java` | | 装柜完成事件消费 | `service/LoadingCompletedConsumer.java`;当前生产信封为 `FIRST_LEG_LOADING_PLAN_COMPLETED` V2,消费端冻结 `destinationType`:FBA 可按箱数且不生成逐箱作业账,OVERSEAS 必须逐箱校验并生成箱级事实;历史缺字段事件仅按明确 FBA 单号或目的仓与接柜仓一致性安全推断,歧义直接拒绝 | | 尾程单号生成 | `service/LastlegOrderNoGenerator.java` | @@ -100,6 +101,7 @@ - 平台查询范围由可信身份/平台权限决定;商户查询只能使用认证身份中的客户 ID。 - 尾程平台只读查询接口的 `customerId` 为可选筛选条件:平台身份省略时查询全部客户数据,传入时按指定客户过滤;商户身份忽略客户端传入值并始终按 Token 中的自身客户隔离。创建、编辑、审核等需要明确目标客户的写接口仍必须传入 `customerId`。 - 常规入仓库存余额以客户、海外仓、目的仓和入仓单为唯一业务维度并汇总箱数;SKU 继续保存在箱唛、实收和流水快照中,余额 SKU 仅作单 SKU 展示或以 `MIXED` 标识多 SKU,不参与当前唯一维度。 +- `FIRST_LEG` 在途、计划转换与 PDA 入账统一使用尾程订单冻结的实际接柜海外仓 ID/编码/名称,禁止使用国内来源仓或 FBA 目的仓冒充;接柜仓变更必须在同一事务内修订尚未形成实物仓位的在途余额并写零差额审计流水。余额分页、汇总和 CSV 默认排除所有数量桶均为零的维度,跨仓入账清空的无仓位来源余额必须原子删除。 - 列表、详情、导出和更新必须使用同一租户边界,不能仅在列表过滤而让详情或更新越权。 - 目的仓变更必须写状态/操作历史或审计证据,并使用版本控制防止并发覆盖。 - 货物废弃只使用可核对 SKU 和箱唛的一件代发库存。创建申请只能将箱从 `AVAILABLE` 锁定为 `DISCARD_PENDING`,不得扣减 SKU 箱数;实际扣库存节点固定为具备 `lastleg:cargo-discard:approve` 权限的仓库/PDA 审核人确认废弃时,并且必须在审核事务内调用专用 discard adjustment。该 adjustment 必须将箱改为 `DISCARDED`、写逐箱业务流水及唯一幂等键,并校验 `扣减前箱数 - 废弃箱数 = 扣减后箱数` 且逐箱数等于申请总箱数;任一环节失败须整体回滚。拒绝申请只释放锁箱,不得扣库存。 diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/controller/LastlegOrderController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/controller/LastlegOrderController.java index a559f0cfc..0984202f9 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/controller/LastlegOrderController.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/controller/LastlegOrderController.java @@ -7,12 +7,14 @@ import com.baiyue.common.security.AuthenticatedUser; import com.baiyue.lastleg.dto.LastlegOrderDetailResponse; import com.baiyue.lastleg.dto.LastlegOrderRequests; import com.baiyue.lastleg.dto.LastlegOrderSummary; +import com.baiyue.lastleg.dto.LastlegOrderCommandDtos; import com.baiyue.lastleg.dto.ContainerPlanProgressDetail; import com.baiyue.lastleg.dto.ContainerPlanProgressSummary; import com.baiyue.lastleg.entity.LastlegOrderFee; import com.baiyue.lastleg.security.InternalServiceTokenGuard; import com.baiyue.lastleg.security.OverseasPermissions; import com.baiyue.lastleg.service.LastlegOrderService; +import com.baiyue.lastleg.service.LastlegOrderCommandService; import com.baiyue.lastleg.service.LoadingCompletedConsumer; import com.baiyue.lastleg.shipping.service.ContainerShippingNodeConsumer; import com.baiyue.common.event.EventTypes; @@ -45,6 +47,7 @@ public class LastlegOrderController { @Resource private LoadingCompletedConsumer consumer; @Resource private ContainerShippingNodeConsumer shippingNodeConsumer; @Resource private LastlegOrderService orderService; + @Resource private LastlegOrderCommandService commandService; @Resource private InternalServiceTokenGuard tokenGuard; @Resource private WalletCommandCompletedConsumer walletCommandCompletedConsumer; @@ -102,6 +105,20 @@ public class LastlegOrderController { return ApiResponse.success(orderService.containerProgressDetail(sourceContainerBookingId)); } + @PutMapping("/api/platform/v1/last-leg/orders/container-summaries/{sourceContainerBookingId}/receiving-warehouse") + @PreAuthorize("hasAuthority('" + OverseasPermissions.LAST_MILE_ORDER_EDIT + "')") + @Operation(summary = "修改整柜海外接柜仓", + description = "仅在海外PDA建立接柜会话前允许修改;按 expectedVersion 乐观锁原子同步柜内订单与入库计划。") + public ApiResponse changeReceivingWarehouse( + @AuthenticationPrincipal AuthenticatedUser user, + @PathVariable String sourceContainerBookingId, + @RequestHeader("Idempotency-Key") String idempotencyKey, + @Valid @RequestBody LastlegOrderRequests.ChangeReceivingWarehouse request) { + platformScope(user, null); + return ApiResponse.success(orderService.changeReceivingWarehouse(sourceContainerBookingId, + idempotencyKey, request, operatorName(user))); + } + @GetMapping("/api/platform/v1/last-leg/orders/{id}") @PreAuthorize("hasAuthority('" + OverseasPermissions.LAST_MILE_ORDER_LIST + "')") @Operation(summary = "Get one inbound-code-level tail-leg order", description = "Required authority: " + OverseasPermissions.LAST_MILE_ORDER_LIST) @@ -123,17 +140,43 @@ public class LastlegOrderController { return ApiResponse.success(orderService.createFee(platformScope(user, customerId), id, request, operatorName(user))); } - @PutMapping("/api/platform/v1/last-leg/orders/{id}/detained") + @PutMapping("/api/platform/v1/last-leg/orders/{id}/hold") @PreAuthorize("hasAuthority('" + OverseasPermissions.LAST_MILE_ORDER_EDIT + "')") - @Operation(summary = "Set tail-leg order hold status", description = "Required authority: " + OverseasPermissions.LAST_MILE_ORDER_EDIT) - public ApiResponse setDetained( + @Operation(summary = "暂扣或解除单票尾程订单", + description = "要求版本与幂等键;暂扣只作用于该票并阻断其出库推进。Required authority: " + OverseasPermissions.LAST_MILE_ORDER_EDIT) + public ApiResponse hold( @AuthenticationPrincipal AuthenticatedUser user, @RequestParam(required = false) String customerId, @PathVariable long id, - @Valid @RequestBody LastlegOrderRequests.SetDetained request) { - orderService.setDetained(platformScope(user, customerId), id, request, - String.valueOf(user.userId()), operatorName(user)); - return ApiResponse.success(null); + @RequestHeader("Idempotency-Key") String idempotencyKey, + @Valid @RequestBody LastlegOrderCommandDtos.HoldRequest request) { + return ApiResponse.success(commandService.hold(platformScope(user, customerId), id, idempotencyKey, + request, operatorId(user), operatorName(user))); + } + + @PutMapping("/api/platform/v1/last-leg/orders/{id}/manual-nodes") + @PreAuthorize("hasAuthority('" + OverseasPermissions.LAST_MILE_ORDER_EDIT + "')") + @Operation(summary = "保存单票人工节点覆盖", + description = "首次保存后该票永久使用 MANUAL 节点,不再回退柜级节点;当前无清除或恢复继承接口。Required authority: " + OverseasPermissions.LAST_MILE_ORDER_EDIT) + public ApiResponse saveManualNodes( + @AuthenticationPrincipal AuthenticatedUser user, + @RequestParam(required = false) String customerId, + @PathVariable long id, + @RequestHeader("Idempotency-Key") String idempotencyKey, + @Valid @RequestBody LastlegOrderCommandDtos.ManualNodesRequest request) { + return ApiResponse.success(commandService.saveManualNodes(platformScope(user, customerId), id, + idempotencyKey, request, operatorId(user), operatorName(user))); + } + + @GetMapping("/api/platform/v1/last-leg/orders/{id}/manual-nodes") + @PreAuthorize("hasAuthority('" + OverseasPermissions.LAST_MILE_ORDER_LIST + "')") + @Operation(summary = "查询单票人工节点覆盖", + description = "只返回人工填写节点及 MANUAL 来源。Required authority: " + OverseasPermissions.LAST_MILE_ORDER_LIST) + public ApiResponse manualNodes( + @AuthenticationPrincipal AuthenticatedUser user, + @RequestParam(required = false) String customerId, + @PathVariable long id) { + return ApiResponse.success(commandService.manualNodes(platformScope(user, customerId), id)); } @PutMapping("/api/platform/v1/last-leg/orders/{id}/pallet-merge-required") @@ -213,4 +256,8 @@ public class LastlegOrderController { private String operatorName(AuthenticatedUser user) { return user.username() == null || user.username().isBlank() ? String.valueOf(user.userId()) : user.username(); } + + private String operatorId(AuthenticatedUser user) { + return user.userId() == null ? operatorName(user) : String.valueOf(user.userId()); + } } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/controller/DeliveryTransferController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/controller/DeliveryTransferController.java index 838bf0905..d690de932 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/controller/DeliveryTransferController.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/controller/DeliveryTransferController.java @@ -55,9 +55,9 @@ public class DeliveryTransferController { @PreAuthorize("hasAuthority('" + OverseasPermissions.OUTBOUND_LIST + "')") public ApiResponse outboundDetail( @AuthenticationPrincipal AuthenticatedUser user, - @PathVariable String outboundNo, @Valid DeliveryRequests.PalletQuery query) { + @PathVariable String outboundNo) { requirePlatform(user); - return ApiResponse.success(service.outboundDetail(outboundNo, query)); + return ApiResponse.success(service.outboundDetail(outboundNo)); } @PostMapping("/outbound-orders/{outboundNo}/cancel") diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/controller/OutboundPdaController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/controller/OutboundPdaController.java index ad1d097db..67bdf0e02 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/controller/OutboundPdaController.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/controller/OutboundPdaController.java @@ -34,6 +34,13 @@ public class OutboundPdaController { return ApiResponse.success(service.pdaTasks(String.valueOf(identity.overseasWarehouseId()), status)); } + @GetMapping("/resolve/{businessNo}") + public ApiResponse resolve( + @AuthenticationPrincipal AuthenticatedUser user, @PathVariable String businessNo) { + PdaWarehouseIdentity identity = PdaWarehouseIdentity.require(user); + return ApiResponse.success(service.resolvePdaTask(String.valueOf(identity.overseasWarehouseId()), businessNo)); + } + @GetMapping("/{taskNo}") public ApiResponse detail(@AuthenticationPrincipal AuthenticatedUser user, @PathVariable String taskNo) { diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/dto/DeliveryRequests.java b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/dto/DeliveryRequests.java index 29bb07ab8..5716aaafd 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/dto/DeliveryRequests.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/dto/DeliveryRequests.java @@ -41,17 +41,6 @@ public final class DeliveryRequests { String driverName, String deliveryMethod, LocalDateTime plannedFrom, LocalDateTime plannedTo) { public Query { if (page == 0) page = 1; if (size == 0) size = 20; } - public Query(long page, long size, String documentNo, String warehouseId, String status, - String dispatchNo, String waybillNo, String vehiclePlate, String driverName, - String deliveryMethod, LocalDateTime plannedFrom, LocalDateTime plannedTo) { - this(page, size, documentNo, null, warehouseId, status, dispatchNo, waybillNo, vehiclePlate, - driverName, deliveryMethod, plannedFrom, plannedTo); - } - } - - public record PalletQuery(@Min(1) long page, @Min(1) @Max(200) long size, - String destinationWarehouseCode) { - public PalletQuery { if (page == 0) page = 1; if (size == 0) size = 20; } } public record OneClickOutbound(@NotBlank @Size(max=1000) String recipient, diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/dto/DeliveryResponses.java b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/dto/DeliveryResponses.java index 0ec6b6c33..eabea2350 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/dto/DeliveryResponses.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/dto/DeliveryResponses.java @@ -40,8 +40,12 @@ public final class DeliveryResponses { public record DeliveryDetail(DeliveryRow order, List skus, List fees, List operations) {} - public record OutboundDetail(OutboundRow outbound, DeliveryRow delivery) {} + /** palletNo is the warehouse-authoritative physical pallet code after inbound assignment. */ + public record OutboundPalletRow(String palletNo, String inboundCode, String destinationWarehouseCode, + String destinationWarehouseName, String skuCode, int packageCount) {} + public record OutboundDetail(OutboundRow outbound, List pallets, DeliveryRow delivery) {} public record PdaTask(Long id, String taskNo, String status, String outboundNo, LocalDateTime pushedAt, LocalDateTime completedAt) {} + public record PdaTaskResolution(String workflow, String taskNo, String outboundNo, String dispatchNo) {} public record PdaPickPosition(Long inventoryPositionId, String palletNo, String cartonMark, String skuCode, String inboundCode, long availablePackages, long pickedPackages) {} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/mapper/OutboundPdaTaskMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/mapper/OutboundPdaTaskMapper.java index 624436ef4..063871975 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/mapper/OutboundPdaTaskMapper.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/mapper/OutboundPdaTaskMapper.java @@ -23,6 +23,12 @@ public interface OutboundPdaTaskMapper extends BaseMapper { "AND o.customer_id=t.customer_id WHERE t.task_no=#{taskNo} AND o.overseas_warehouse_id=#{warehouseId} LIMIT 1") OutboundPdaTask findByNoAndWarehouse(@Param("taskNo") String taskNo, @Param("warehouseId") String warehouseId); + @Select("SELECT t.* FROM ll_outbound_pda_task t JOIN ll_outbound_order o ON o.id=t.outbound_order_id " + + "AND o.customer_id=t.customer_id WHERE t.customer_id=#{customerId} AND t.outbound_order_id=#{outboundOrderId} " + + "AND o.overseas_warehouse_id=#{warehouseId} LIMIT 1") + OutboundPdaTask findByOutboundAndWarehouse(@Param("customerId") String customerId, + @Param("outboundOrderId") Long outboundOrderId, @Param("warehouseId") String warehouseId); + @Select("SELECT t.* FROM ll_outbound_pda_task t JOIN ll_outbound_order o ON o.id=t.outbound_order_id " + "AND o.customer_id=t.customer_id WHERE o.overseas_warehouse_id=#{warehouseId} " + "AND (#{status} IS NULL OR t.status=#{status}) ORDER BY t.pushed_at DESC LIMIT 200") diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/service/DeliveryTransferService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/service/DeliveryTransferService.java index 2d139ea87..22545a256 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/service/DeliveryTransferService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/service/DeliveryTransferService.java @@ -114,6 +114,7 @@ public class DeliveryTransferService { if (Set.of("CANCELLED", "DEPARTED", "PARTIAL_DEPARTED", "COMPLETED").contains(dispatch.getStatus())) { throw conflict("Dispatch cannot be transferred to delivery in its current status"); } + dispatchItems.bindReceivedPallets(customerId, dispatch.getId()); List items = dispatchItems.findByDispatchOrder(customerId, dispatch.getId()); if (items.isEmpty()) throw conflict("Dispatch has no pallets"); if (items.stream().anyMatch(item -> item.getWarehousePalletId() == null)) { @@ -128,7 +129,7 @@ public class DeliveryTransferService { outbound.setOutboundNo(orderNoGenerator.next("OUTBOUND", dispatch.getOverseasWarehouseId(), outbound.getId())); outbound.setDispatchOrderId(dispatch.getId()); outbound.setDispatchNo(dispatch.getDispatchNo()); outbound.setOverseasWarehouseId(dispatch.getOverseasWarehouseId()); outbound.setStatus("WAIT_OUTBOUND"); outbound.setOutboundType("DISPATCH_DELIVERY"); - outbound.setLxCodes(items.stream().map(DispatchItem::getInboundCode).filter(StringUtils::hasText).map(code -> tailOrders.selectOne(new LambdaQueryWrapper().eq(LastlegOrder::getCustomerId, customerId).eq(LastlegOrder::getInboundCode, code))).filter(Objects::nonNull).map(LastlegOrder::getLxCode).filter(StringUtils::hasText).distinct().collect(java.util.stream.Collectors.joining(","))); + outbound.setLxCodes(resolveLxCodes(customerId, items)); outbound.setPackageCount(items.stream().map(DispatchItem::getPackageCount).filter(Objects::nonNull).mapToInt(Integer::intValue).sum()); outbound.setPalletCount(items.size()); outbound.setIdempotencyKey(request.idempotencyKey().trim()); outbound.setCreatedBy(operatorId); outbound.setCreatedAt(now); outbound.setUpdatedAt(now); @@ -408,6 +409,36 @@ public class DeliveryTransferService { return new DeliveryResponses.PdaTaskDetail(taskResult(outbound, task), items); } + public DeliveryResponses.PdaTaskResolution resolvePdaTask(String warehouseId, String businessNo) { + String boundWarehouseId = required(warehouseId, "PDA warehouse binding is required"); + String code = required(businessNo, "Outbound task, outbound or dispatch number is required"); + + OutboundPdaTask task = pdaTasks.findByNoAndWarehouse(code, boundWarehouseId); + if (task != null) { + OutboundOrder outbound = requireOutboundInWarehouse(task, boundWarehouseId); + return outboundResolution(outbound, task); + } + + OutboundOrder outbound = outboundOrders.findGlobalByNo(code); + if (outbound != null && boundWarehouseId.equals(outbound.getOverseasWarehouseId())) { + if ("DISPATCH_DELIVERY".equals(outbound.getOutboundType()) && StringUtils.hasText(outbound.getDispatchNo())) { + DispatchOrder dispatch = dispatchOrders.findGlobalByDispatchNo(outbound.getDispatchNo()); + if (dispatch != null && boundWarehouseId.equals(dispatch.getOverseasWarehouseId())) { + return new DeliveryResponses.PdaTaskResolution("DISPATCH", null, outbound.getOutboundNo(), + dispatch.getDispatchNo()); + } + } + task = pdaTasks.findByOutboundAndWarehouse(outbound.getCustomerId(), outbound.getId(), boundWarehouseId); + if (task != null) return outboundResolution(outbound, task); + } + + DispatchOrder dispatch = dispatchOrders.findGlobalByDispatchNo(code); + if (dispatch != null && boundWarehouseId.equals(dispatch.getOverseasWarehouseId())) { + return new DeliveryResponses.PdaTaskResolution("DISPATCH", null, null, dispatch.getDispatchNo()); + } + throw notFound("Outbound or dispatch task not found in the bound warehouse"); + } + private List pdaPickPositions(String customerId, String warehouseId, OutboundPalletItem item) { if (item.getWarehousePalletId() == null) return List.of(); @@ -447,6 +478,10 @@ public class DeliveryTransferService { } private DeliveryResponses.PdaTask taskResult(OutboundOrder outbound, OutboundPdaTask task) { return new DeliveryResponses.PdaTask(task.getId(), task.getTaskNo(), task.getStatus(), outbound.getOutboundNo(), task.getPushedAt(), task.getCompletedAt()); } + private DeliveryResponses.PdaTaskResolution outboundResolution(OutboundOrder outbound, OutboundPdaTask task) { + return new DeliveryResponses.PdaTaskResolution("OUTBOUND", task.getTaskNo(), outbound.getOutboundNo(), + outbound.getDispatchNo()); + } private OutboundOrder requireOutbound(OutboundPdaTask task) { OutboundOrder outbound = outboundOrders.selectById(task.getOutboundOrderId()); if (outbound == null || !task.getCustomerId().equals(outbound.getCustomerId())) { @@ -475,13 +510,14 @@ public class DeliveryTransferService { return page(page, page.getRecords().stream().map(this::outboundRow).toList()); } - public DeliveryResponses.OutboundDetail outboundDetail(String outboundNo, - DeliveryRequests.PalletQuery query) { + public DeliveryResponses.OutboundDetail outboundDetail(String outboundNo) { OutboundOrder outbound = outboundOrders.findGlobalByNo(trim(outboundNo)); if (outbound == null) throw notFound("Outbound order not found"); String customerId = outbound.getCustomerId(); DeliveryOrder delivery = requireDelivery(customerId, outbound.getId()); - return new DeliveryResponses.OutboundDetail(outboundRow(outbound), deliveryRow(delivery)); + List pallets = outboundPallets + .findByOutbound(customerId, outbound.getId()).stream().map(this::outboundPalletRow).toList(); + return new DeliveryResponses.OutboundDetail(outboundRow(outbound), pallets, deliveryRow(delivery)); } public PageResult deliveryPage(String customerId, DeliveryRequests.Query query) { @@ -556,12 +592,27 @@ public class DeliveryTransferService { d.getDeliveryNo(), d.getStatus(), o.getPalletCount(), o.getPackageCount(), replayed); } private DeliveryResponses.OutboundRow outboundRow(OutboundOrder o) { return new DeliveryResponses.OutboundRow(o.getId(), o.getOutboundNo(), o.getDispatchNo(), o.getOverseasWarehouseId(), o.getLxCodes(), o.getStatus(), o.getPalletCount(), o.getPackageCount(), o.getCreatedAt()); } + private DeliveryResponses.OutboundPalletRow outboundPalletRow(OutboundPalletItem item) { + return new DeliveryResponses.OutboundPalletRow(item.getPalletNo(), item.getInboundCode(), + item.getDestinationWarehouseCode(), item.getDestinationWarehouseName(), item.getSkuCode(), + value(item.getPackageCount())); + } private DeliveryResponses.DeliveryRow deliveryRow(DeliveryOrder d) { return new DeliveryResponses.DeliveryRow(d.getId(), d.getCustomerId(), d.getCustomerName(), d.getDeliveryNo(), d.getOutboundNo(), d.getDispatchNo(), d.getOverseasWarehouseId(), d.getOverseasWarehouseName(), d.getStatus(), d.getWaybillNo(), d.getDeliveryMethod(), d.getCarrierName(), d.getPickupMethod(), d.getLoadingType(), d.getVehicleType(), d.getVehicleSize(), d.getVehiclePlate(), d.getDriverName(), d.getDriverPhone(), d.getAppointmentAccount(), d.getSellerName(), value(d.getPalletCount()), value(d.getPackageCount()), d.getTotalWeightKg(), d.getTotalVolumeCbm(), d.getPlannedDeliveryAt(), d.getDispatchedAt(), d.getExpectedArrivalAt(), d.getSignedAt(), d.getRemark(), d.getCreatedAt()); } private DeliveryResponses.DeliverySku sku(LastlegOrderDetail d, LastlegOrder order) { return new DeliveryResponses.DeliverySku(order == null ? null : order.getInboundCode(), order == null ? null : order.getCustomerName(), d.getDestinationWarehouseCode(), null, d.getShipmentNo(), d.getFbaTrackingNo(), d.getSkuCode(), firstText(d.getProductNameZh(), d.getProductNameEn()), value(d.getPackageCount()), d.getGrossWeightKg(), d.getVolumeCbm()); } private DeliveryResponses.DeliveryFeeRow feeRow(DeliveryFee f) { return new DeliveryResponses.DeliveryFeeRow(f.getId(), f.getFeeDirection(), f.getFeeType(), f.getCurrency(), f.getAmount(), f.getRemark()); } private DeliveryResponses.DeliveryOperationRow operationRow(DeliveryOperation o) { return new DeliveryResponses.DeliveryOperationRow(o.getId(), o.getCreatedAt(), o.getOperationNode(), firstText(o.getOperatorName(), o.getOperatorId()), o.getResult()); } private void recordOperation(String customerId, Long deliveryId, String operatorId, String node, String result, LocalDateTime now) { DeliveryOperation operation = new DeliveryOperation(); operation.setCustomerId(customerId); operation.setDeliveryOrderId(deliveryId); operation.setOperationNode(node); operation.setOperatorId(operatorId); operation.setOperatorName(operatorId); operation.setResult(result); operation.setCreatedAt(now); deliveryOperations.insert(operation); } private LastlegOrder firstTailOrder(String customerId, List items) { return items.stream().map(DispatchItem::getOrderId).filter(Objects::nonNull).map(tailOrders::selectById).filter(Objects::nonNull).filter(order -> customerId.equals(order.getCustomerId())).findFirst().orElse(null); } + private String resolveLxCodes(String customerId, List items) { + List inboundCodes = items.stream().map(DispatchItem::getInboundCode) + .filter(StringUtils::hasText).map(String::trim).distinct().toList(); + if (inboundCodes.isEmpty()) return ""; + return tailOrders.selectList(new LambdaQueryWrapper() + .eq(LastlegOrder::getCustomerId, customerId) + .in(LastlegOrder::getInboundCode, inboundCodes)) + .stream().map(LastlegOrder::getLxCode).filter(StringUtils::hasText) + .map(String::trim).distinct().collect(java.util.stream.Collectors.joining(",")); + } private BigDecimal totalWeight(String customerId, List items) { List ids = items.stream().map(DispatchItem::getOrderId).filter(Objects::nonNull).distinct().toList(); if (ids.isEmpty()) return BigDecimal.ZERO; return tailOrderDetails.selectList(new LambdaQueryWrapper().eq(LastlegOrderDetail::getCustomerId, customerId).in(LastlegOrderDetail::getOrderId, ids)).stream().map(LastlegOrderDetail::getGrossWeightKg).filter(Objects::nonNull).reduce(BigDecimal.ZERO, BigDecimal::add); } private static String trim(String value) { return StringUtils.hasText(value) ? value.trim() : null; } private static String required(String value, String message) { diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/entity/DispatchPalletLock.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/entity/DispatchPalletLock.java index a62da4a4d..57239e4ed 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/entity/DispatchPalletLock.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/entity/DispatchPalletLock.java @@ -24,6 +24,7 @@ public class DispatchPalletLock { private Long planPalletId; private Long warehousePalletId; private Long dispatchOrderId; + @TableField(updateStrategy = FieldStrategy.ALWAYS) private Long draftContainerId; @TableField(updateStrategy = FieldStrategy.ALWAYS) private String draftToken; diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/mapper/DispatchCandidateMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/mapper/DispatchCandidateMapper.java index e5e4f5875..227e8ad57 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/mapper/DispatchCandidateMapper.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/mapper/DispatchCandidateMapper.java @@ -36,7 +36,13 @@ public interface DispatchCandidateMapper { "(SELECT MAX(fa.expected_loading_at) FROM ll_inbound_fba_appointment_item fi " + "JOIN ll_inbound_fba_appointment fa ON fa.id=fi.appointment_id WHERE fi.line_id IN " + "(SELECT pa.line_id FROM ll_inbound_plan_pallet_allocation pa WHERE pa.pallet_id=p.id)) AS expectedLoadingAt," + - "MIN(o.overseas_warehouse_id) AS overseasWarehouseId,MIN(o.overseas_warehouse_name) AS overseasWarehouseName,MIN(o.order_no) AS orderNo," + + "CASE WHEN p.warehouse_pallet_id IS NOT NULL THEN (SELECT MIN(pos.overseas_warehouse_id) " + + "FROM ll_inventory_position pos WHERE pos.customer_id=ip.customer_id AND pos.pallet_id=p.warehouse_pallet_id " + + "AND pos.available_packages > 0) ELSE CAST(ip.overseas_warehouse_id AS CHAR) END AS overseasWarehouseId," + + "CASE WHEN p.warehouse_pallet_id IS NOT NULL THEN (SELECT MIN(b.overseas_warehouse_name) " + + "FROM ll_inventory_position pos JOIN ll_inventory_balance b ON b.id=pos.balance_id AND b.customer_id=pos.customer_id " + + "WHERE pos.customer_id=ip.customer_id AND pos.pallet_id=p.warehouse_pallet_id AND pos.available_packages > 0) " + + "ELSE MIN(o.overseas_warehouse_name) END AS overseasWarehouseName,MIN(o.order_no) AS orderNo," + "GROUP_CONCAT(DISTINCT o.order_no ORDER BY o.order_no SEPARATOR ',') AS orderRefs,MIN(l.inbound_code) AS inboundCode," + "p.pallet_no AS palletNo,p.destination_warehouse_id AS destinationWarehouseId," + "p.destination_warehouse_code AS destinationWarehouseCode,p.destination_warehouse_name AS destinationWarehouseName," + @@ -60,6 +66,10 @@ public interface DispatchCandidateMapper { " AND l.fba_warehouse_code=#{fbaWarehouseCode} " + " AND " + "CASE WHEN p.warehouse_pallet_id IS NULL THEN 'PLAN' ELSE 'RECEIVED' END=#{sourceStage} " + + "AND NOT EXISTS (SELECT 1 FROM ll_inbound_plan_pallet_allocation ha " + + "JOIN ll_inbound_plan_line hl ON hl.id=ha.line_id " + + "JOIN ll_order ho ON ho.id=hl.order_id AND ho.customer_id=ip.customer_id " + + "WHERE ha.pallet_id=p.id AND ho.detained=1) " + "AND (p.warehouse_pallet_id IS NULL OR EXISTS (SELECT 1 FROM ll_inventory_position pos " + "WHERE pos.customer_id=ip.customer_id AND pos.pallet_id=p.warehouse_pallet_id AND pos.available_packages > 0)) " + "AND NOT EXISTS (SELECT 1 FROM ll_dispatch_pallet_lock d " + @@ -94,7 +104,13 @@ public interface DispatchCandidateMapper { "(SELECT MAX(fa.expected_loading_at) FROM ll_inbound_fba_appointment_item fi " + "JOIN ll_inbound_fba_appointment fa ON fa.id=fi.appointment_id WHERE fi.line_id IN " + "(SELECT pa.line_id FROM ll_inbound_plan_pallet_allocation pa WHERE pa.pallet_id=p.id)) AS expectedLoadingAt," + - "MIN(o.overseas_warehouse_id) AS overseasWarehouseId,MIN(o.overseas_warehouse_name) AS overseasWarehouseName,MIN(o.order_no) AS orderNo," + + "CASE WHEN p.warehouse_pallet_id IS NOT NULL THEN (SELECT MIN(pos.overseas_warehouse_id) " + + "FROM ll_inventory_position pos WHERE pos.customer_id=ip.customer_id AND pos.pallet_id=p.warehouse_pallet_id " + + "AND pos.available_packages > 0) ELSE CAST(ip.overseas_warehouse_id AS CHAR) END AS overseasWarehouseId," + + "CASE WHEN p.warehouse_pallet_id IS NOT NULL THEN (SELECT MIN(b.overseas_warehouse_name) " + + "FROM ll_inventory_position pos JOIN ll_inventory_balance b ON b.id=pos.balance_id AND b.customer_id=pos.customer_id " + + "WHERE pos.customer_id=ip.customer_id AND pos.pallet_id=p.warehouse_pallet_id AND pos.available_packages > 0) " + + "ELSE MIN(o.overseas_warehouse_name) END AS overseasWarehouseName,MIN(o.order_no) AS orderNo," + "GROUP_CONCAT(DISTINCT o.order_no ORDER BY o.order_no SEPARATOR ',') AS orderRefs,MIN(l.inbound_code) AS inboundCode," + "p.pallet_no AS palletNo,p.destination_warehouse_id AS destinationWarehouseId," + "p.destination_warehouse_code AS destinationWarehouseCode,p.destination_warehouse_name AS destinationWarehouseName," + @@ -112,6 +128,10 @@ public interface DispatchCandidateMapper { "LEFT JOIN ll_order o ON o.id=l.order_id " + "WHERE ip.customer_id=#{customerId} AND p.id IN " + "#{id} " + + "AND NOT EXISTS (SELECT 1 FROM ll_inbound_plan_pallet_allocation ha " + + "JOIN ll_inbound_plan_line hl ON hl.id=ha.line_id " + + "JOIN ll_order ho ON ho.id=hl.order_id AND ho.customer_id=ip.customer_id " + + "WHERE ha.pallet_id=p.id AND ho.detained=1) " + "AND (p.warehouse_pallet_id IS NULL OR EXISTS (SELECT 1 FROM ll_inventory_position pos " + "WHERE pos.customer_id=#{customerId} AND pos.pallet_id=p.warehouse_pallet_id AND pos.available_packages > 0)) " + "AND NOT EXISTS (SELECT 1 FROM ll_warehouse_transfer_inventory_allocation ta " + diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/mapper/DispatchPalletLockMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/mapper/DispatchPalletLockMapper.java index 38ed3c93d..5d849f04b 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/mapper/DispatchPalletLockMapper.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/mapper/DispatchPalletLockMapper.java @@ -7,8 +7,16 @@ import org.apache.ibatis.annotations.Param; import org.apache.ibatis.annotations.Select; import org.apache.ibatis.annotations.Update; +import java.util.List; + @Mapper public interface DispatchPalletLockMapper extends BaseMapper { + @Select("SELECT * FROM ll_dispatch_pallet_lock WHERE draft_token=#{draftToken} " + + "AND draft_container_id=#{draftContainerId} AND status='ACTIVE' " + + "AND (expires_at IS NULL OR expires_at>CURRENT_TIMESTAMP(3)) ORDER BY locked_at,id") + List findActiveByDraftContainer(@Param("draftToken") String draftToken, + @Param("draftContainerId") Long draftContainerId); + @Select("SELECT * FROM ll_dispatch_pallet_lock WHERE customer_id=#{customerId} AND source_pallet_key=#{sourcePalletKey} " + "AND status='ACTIVE' LIMIT 1 FOR UPDATE") DispatchPalletLock lockActive(@Param("customerId") String customerId, 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 e326e2166..05a5e000a 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 @@ -22,6 +22,8 @@ import com.baiyue.lastleg.dispatch.dto.DispatchDraftResponse; import com.baiyue.lastleg.dispatch.model.DispatchPalletCandidate; import com.baiyue.lastleg.dispatch.pda.entity.DispatchPdaTask; import com.baiyue.lastleg.dispatch.pda.mapper.DispatchPdaTaskMapper; +import com.baiyue.lastleg.entity.LastlegOrder; +import com.baiyue.lastleg.mapper.LastlegOrderMapper; import jakarta.annotation.Resource; import org.springframework.dao.DuplicateKeyException; import org.springframework.http.HttpStatus; @@ -53,6 +55,7 @@ public class DispatchService { @Resource private DispatchCandidateMapper candidates; @Resource private DispatchPdaTaskMapper pdaTasks; @Resource private LastlegOrderNoGenerator orderNoGenerator; + @Resource private LastlegOrderMapper tailOrders; public String resolveCustomerScopeForPlans(List planIds) { return requireSingleCustomerScope(candidates.findCustomerScopesByPlanIds(normalizeIds(planIds, "Plan IDs")), @@ -95,7 +98,8 @@ public class DispatchService { private List containers(String token) { return draftContainers.findByToken(token).stream().map(c -> new DispatchContainerResponse(String.valueOf(c.getId()), c.getCapacity(), - locks.selectList(new LambdaQueryWrapper().eq(DispatchPalletLock::getDraftContainerId, c.getId()).eq(DispatchPalletLock::getStatus, "ACTIVE")).stream().map(x -> String.valueOf(x.getPlanPalletId())).toList())).toList(); + locks.findActiveByDraftContainer(token, c.getId()).stream() + .map(x -> String.valueOf(x.getPlanPalletId())).toList())).toList(); } @Transactional @@ -220,10 +224,17 @@ public class DispatchService { if (selected.size() != palletIds.size()) { throw new ResponseStatusException(HttpStatus.NOT_FOUND, "One or more selected pallets are not in this tenant"); } + assertNoDetainedOrders(customerId, selected); + boolean missingWarehouse = selected.stream().map(DispatchPalletCandidate::overseasWarehouseId) + .anyMatch(value -> value == null || value.isBlank()); + if (missingWarehouse) { + throw new ResponseStatusException(HttpStatus.CONFLICT, + "所选托盘中存在尚未关联海外仓的托盘"); + } Set warehouseIds = selected.stream().map(DispatchPalletCandidate::overseasWarehouseId) - .filter(Objects::nonNull).map(String::trim).filter(value -> !value.isEmpty()).collect(Collectors.toSet()); - if (warehouseIds.size() != 1 || selected.stream().anyMatch(candidate -> candidate.overseasWarehouseId() == null)) { - throw new ResponseStatusException(HttpStatus.CONFLICT, "A dispatch must belong to exactly one overseas warehouse"); + .map(String::trim).collect(Collectors.toSet()); + if (warehouseIds.size() != 1) { + throw new ResponseStatusException(HttpStatus.CONFLICT, "一次排车只能包含同一海外仓的托盘"); } String draftToken = trimToNull(request.draftToken()); for (DispatchPalletCandidate candidate : selected) { @@ -268,6 +279,7 @@ public class DispatchService { if (draftToken != null) { requireDraftOwner(lock, operatorId, draftToken); lock.setDispatchOrderId(dispatch.getId()); + lock.setDraftContainerId(null); lock.setDraftToken(null); lock.setExpiresAt(null); lock.setUpdatedAt(now); @@ -329,9 +341,37 @@ public class DispatchService { pdaTask.setCreatedAt(now); pdaTask.setUpdatedAt(now); pdaTasks.insert(pdaTask); + if (draftToken != null) { + draftContainers.delete(new LambdaQueryWrapper() + .eq(DispatchDraftContainer::getDraftToken, draftToken)); + DispatchDraft draft = drafts.lockActive(draftToken, trimToNull(operatorId)); + if (draft != null) { + draft.setStatus("RELEASED"); + draft.setReleasedAt(now); + draft.setUpdatedAt(now); + drafts.updateById(draft); + } + } return response(dispatch, items.findByDispatchOrder(customerId, dispatch.getId())); } + private void assertNoDetainedOrders(String customerId, List selected) { + List orderIds = selected.stream().map(DispatchPalletCandidate::orderId) + .filter(Objects::nonNull).distinct().sorted().toList(); + if (orderIds.isEmpty()) return; + List scoped = tailOrders.selectList(new LambdaQueryWrapper() + .eq(LastlegOrder::getCustomerId, customerId) + .in(LastlegOrder::getId, orderIds) + .orderByAsc(LastlegOrder::getId) + .last("FOR UPDATE")); + List detainedNos = scoped.stream().filter(order -> Boolean.TRUE.equals(order.getDetained())) + .map(LastlegOrder::getOrderNo).filter(StringUtils::hasText).distinct().toList(); + if (!detainedNos.isEmpty()) { + throw new ResponseStatusException(HttpStatus.CONFLICT, + "暂扣订单不可创建排车或出库计划:" + String.join("、", detainedNos)); + } + } + public PageResult page(String customerId, DispatchRequests.Query query) { if (!StringUtils.hasText(customerId)) { return new PageResult<>(List.of(), query.page(), query.size(), 0, 0); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dropship/service/DropshipOrderService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dropship/service/DropshipOrderService.java index c8e1e029f..3c75db35d 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dropship/service/DropshipOrderService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dropship/service/DropshipOrderService.java @@ -135,8 +135,8 @@ public class DropshipOrderService { throw conflict("Dropship pallet activation is incomplete; repair profiles before retrying"); } Map> receiptsByPallet = receipts.stream() - .filter(receipt -> receipt.getPalletNo() != null) - .collect(Collectors.groupingBy(InboundStockReceipt::getPalletNo)); + .filter(receipt -> receipt.getSourcePalletNo() != null) + .collect(Collectors.groupingBy(InboundStockReceipt::getSourcePalletNo)); if (receiptsByPallet.size() != profilesForOrder.size()) { throw conflict("PDA pallet count does not match dropship source pallet count"); } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dto/ContainerPlanProgressSummary.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dto/ContainerPlanProgressSummary.java index 226f4bb40..ebd55205b 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dto/ContainerPlanProgressSummary.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dto/ContainerPlanProgressSummary.java @@ -6,6 +6,12 @@ import java.time.LocalDateTime; public record ContainerPlanProgressSummary( String sourceContainerBookingId, String containerNo, + String overseasWarehouseId, + String overseasWarehouseCode, + String overseasWarehouseName, + int assignmentVersion, + boolean canChangeReceivingWarehouse, + String receivingWarehouseLockReason, long totalOrderCount, long inboundCodeCount, long missingInboundCodeCount, @@ -16,4 +22,14 @@ public record ContainerPlanProgressSummary( boolean allPlansReady, String blockingReason ) { + public ContainerPlanProgressSummary(String sourceContainerBookingId, String containerNo, + long totalOrderCount, long inboundCodeCount, + long missingInboundCodeCount, long planCount, + long plannedInboundCodeCount, long pendingPlanCount, + LocalDateTime plannedArrivalAt, boolean allPlansReady, + String blockingReason) { + this(sourceContainerBookingId, containerNo, null, null, null, -1, false, null, + totalOrderCount, inboundCodeCount, missingInboundCodeCount, planCount, + plannedInboundCodeCount, pendingPlanCount, plannedArrivalAt, allPlansReady, blockingReason); + } } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dto/LastlegOrderCommandDtos.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dto/LastlegOrderCommandDtos.java new file mode 100644 index 000000000..d6e4a546f --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dto/LastlegOrderCommandDtos.java @@ -0,0 +1,27 @@ +package com.baiyue.lastleg.dto; + +import jakarta.validation.Valid; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotEmpty; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; +import jakarta.validation.constraints.Min; +import java.time.LocalDateTime; +import java.util.List; + +public final class LastlegOrderCommandDtos { + private LastlegOrderCommandDtos() { } + + public record HoldRequest(@NotNull Boolean held, @NotNull Boolean chargeable, + @NotBlank @Size(max = 500) String reason, + @NotNull @Min(0) Integer expectedVersion) { } + public record HoldResponse(long orderId, boolean held, boolean chargeable, String reason, int version) { } + + public record ManualNode(@NotBlank @Size(max = 32) String nodeCode, + LocalDateTime nodeAt) { } + public record ManualNodesRequest(@NotNull @Min(0) Integer expectedVersion, + @NotEmpty @Size(min = 8, max = 8) List<@Valid ManualNode> nodes) { } + public record ManualNodesResponse(long orderId, boolean manualOverrideEnabled, + List nodes, int version) { } + public record ManualNodeView(String nodeCode, LocalDateTime nodeAt, String source) { } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dto/LastlegOrderDetailResponse.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dto/LastlegOrderDetailResponse.java index 27650b7b0..7be87d591 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dto/LastlegOrderDetailResponse.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dto/LastlegOrderDetailResponse.java @@ -9,6 +9,7 @@ import java.util.List; public record LastlegOrderDetailResponse( LastlegOrderSummary order, + List manualNodes, List cargo, List statusHistory, List fees, diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dto/LastlegOrderRequests.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dto/LastlegOrderRequests.java index 604d581c9..cab84e090 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dto/LastlegOrderRequests.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dto/LastlegOrderRequests.java @@ -54,6 +54,12 @@ public final class LastlegOrderRequests { } } + public record ChangeReceivingWarehouse( + @NotNull Long overseasWarehouseId, + @NotNull @Min(0) Integer expectedVersion, + @Size(max = 500) String remark + ) { } + public record TransferDestination( @NotEmpty @Size(max = 200) List orderIds, @NotBlank @Size(max = 128) String warehouseId, diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dto/LastlegOrderSummary.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dto/LastlegOrderSummary.java index 74f5b8425..88f84bda9 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dto/LastlegOrderSummary.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dto/LastlegOrderSummary.java @@ -15,12 +15,14 @@ public record LastlegOrderSummary( @Schema(description = "跨区冻结目的类型", allowableValues = {"FBA", "OVERSEAS"}) String destinationType, String sourceInboundId, + @Schema(description = "真实来源入仓码;无入仓关系的海外本土业务为空") String inboundCode, String lxCode, String sourceOrderNo, String customerId, String customerName, String overseasWarehouseId, + String overseasWarehouseCode, String overseasWarehouseName, String transferWarehouseId, String transferWarehouseCode, @@ -30,6 +32,11 @@ public record LastlegOrderSummary( String containerStatus, Boolean abnormal, Boolean detained, + Boolean heldChargeable, + String holdReason, + boolean manualOverrideEnabled, + String nodeSource, + int version, Integer palletCount, Integer packageCount, BigDecimal volumeCbm, diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/entity/LastlegOrder.java b/lastleg-service/src/main/java/com/baiyue/lastleg/entity/LastlegOrder.java index 360cf1313..b86b2a53f 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/entity/LastlegOrder.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/entity/LastlegOrder.java @@ -28,7 +28,9 @@ public class LastlegOrder { private String sourceWarehouseId; private String sourceWarehouseName; private String overseasWarehouseId; + private String overseasWarehouseCode; private String overseasWarehouseName; + private Integer receivingWarehouseVersion; private String fbaWarehouseCode; private String containerNo; private String transferWarehouseId; @@ -37,6 +39,10 @@ public class LastlegOrder { private String containerStatus; private Boolean abnormal; private Boolean detained; + private Boolean holdChargeable; + private String holdReason; + private Boolean manualNodeOverride; + private Integer version; private Integer palletCount; private Integer packageCount; private Integer pickedPackageCount; diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/entity/LastlegOrderCommandRecord.java b/lastleg-service/src/main/java/com/baiyue/lastleg/entity/LastlegOrderCommandRecord.java new file mode 100644 index 000000000..9f026da84 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/entity/LastlegOrderCommandRecord.java @@ -0,0 +1,23 @@ +package com.baiyue.lastleg.entity; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.Data; +import java.time.LocalDateTime; + +@Data +@TableName("ll_order_command_record") +public class LastlegOrderCommandRecord { + @TableId(type = IdType.INPUT) private Long id; + private String customerId; + private Long orderId; + private String action; + private String operatorId; + private String idempotencyKey; + private String requestHash; + private String resultRef; + private String resultJson; + private Integer resultVersion; + private LocalDateTime createdAt; +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/entity/LastlegOrderHoldHistory.java b/lastleg-service/src/main/java/com/baiyue/lastleg/entity/LastlegOrderHoldHistory.java new file mode 100644 index 000000000..2abb9b1c9 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/entity/LastlegOrderHoldHistory.java @@ -0,0 +1,23 @@ +package com.baiyue.lastleg.entity; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.Data; +import java.time.LocalDateTime; + +@Data +@TableName("ll_order_hold_history") +public class LastlegOrderHoldHistory { + @TableId(type = IdType.INPUT) private Long id; + private String customerId; + private Long orderId; + private Boolean held; + private Boolean chargeable; + private String reason; + private Integer orderVersion; + private String operatorId; + private String operatorName; + private String idempotencyKey; + private LocalDateTime createdAt; +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/entity/LastlegOrderManualNode.java b/lastleg-service/src/main/java/com/baiyue/lastleg/entity/LastlegOrderManualNode.java new file mode 100644 index 000000000..323fc685c --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/entity/LastlegOrderManualNode.java @@ -0,0 +1,21 @@ +package com.baiyue.lastleg.entity; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.Data; +import java.time.LocalDateTime; + +@Data +@TableName("ll_order_manual_node") +public class LastlegOrderManualNode { + @TableId(type = IdType.INPUT) private Long id; + private String customerId; + private Long orderId; + private String nodeCode; + private LocalDateTime nodeAt; + private String source; + private Integer orderVersion; + private String updatedBy; + private LocalDateTime updatedAt; +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/entity/LastlegOrderManualNodeHistory.java b/lastleg-service/src/main/java/com/baiyue/lastleg/entity/LastlegOrderManualNodeHistory.java new file mode 100644 index 000000000..cc3ec6899 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/entity/LastlegOrderManualNodeHistory.java @@ -0,0 +1,21 @@ +package com.baiyue.lastleg.entity; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.Data; +import java.time.LocalDateTime; + +@Data +@TableName("ll_order_manual_node_history") +public class LastlegOrderManualNodeHistory { + @TableId(type = IdType.INPUT) private Long id; + private String customerId; + private Long orderId; + private String nodesJson; + private Integer orderVersion; + private String operatorId; + private String operatorName; + private String idempotencyKey; + private LocalDateTime createdAt; +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/entity/ReceivingWarehouseOperation.java b/lastleg-service/src/main/java/com/baiyue/lastleg/entity/ReceivingWarehouseOperation.java new file mode 100644 index 000000000..22b3c0117 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/entity/ReceivingWarehouseOperation.java @@ -0,0 +1,21 @@ +package com.baiyue.lastleg.entity; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.Data; + +import java.time.LocalDateTime; + +@Data +@TableName("ll_receiving_warehouse_operation") +public class ReceivingWarehouseOperation { + @TableId(type = IdType.INPUT) private Long id; + private String sourceContainerBookingId; + private String idempotencyKey; + private String requestHash; + private Integer resultVersion; + private String operatorName; + private String remark; + private LocalDateTime createdAt; +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/controller/InboundPlanController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/controller/InboundPlanController.java index 849239c28..3a3c83bef 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/controller/InboundPlanController.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/controller/InboundPlanController.java @@ -3,8 +3,8 @@ package com.baiyue.lastleg.inboundplan.controller; import com.baiyue.common.core.api.ApiResponse; import com.baiyue.common.core.api.PageResult; import com.baiyue.common.security.AuthenticatedUser; -import com.baiyue.lastleg.inboundplan.dto.ConvertInboundPlanRequest; import com.baiyue.lastleg.inboundplan.dto.ConvertContainerInboundPlanRequest; +import com.baiyue.lastleg.inboundplan.dto.ManualInboundPlanRequest; import com.baiyue.lastleg.inboundplan.dto.BatchFbaAppointmentRequest; import com.baiyue.lastleg.inboundplan.dto.FbaAppointmentResponse; import com.baiyue.lastleg.inboundplan.dto.InboundPlanResponse; @@ -44,19 +44,24 @@ public class InboundPlanController { return ApiResponse.success(service.page(scope(user, customerId), query)); } - @PostMapping("/convert") + @PostMapping("/manual") @PreAuthorize("hasAuthority('" + OverseasPermissions.INBOUND_ORDER_CREATE + "')") - /** 由平台人工将待分配尾程订单转换为入仓计划。 */ - @Operation(summary = "人工创建入仓计划", description = "头程装柜完成只生成待分配尾程订单;平台确认后调用本接口创建入仓计划。Required authority: " + @Operation(summary = "手工填写并创建单票入仓计划", description = "要求订单版本和 Idempotency-Key;仓库主档、入仓码及客户归属均由服务端校验。Required authority: " + OverseasPermissions.INBOUND_ORDER_CREATE) - public ApiResponse convert(@AuthenticationPrincipal AuthenticatedUser user, - @Valid @RequestBody ConvertInboundPlanRequest request) { - return ApiResponse.success(service.convert(convertScope(user, request.customerId()), request.orderId())); + public ApiResponse manual(@AuthenticationPrincipal AuthenticatedUser user, + @RequestHeader("Idempotency-Key") String idempotencyKey, + @Valid @RequestBody ManualInboundPlanRequest request) { + if (user == null || user.tenantId() == null || user.tenantId() != 0L || !"PLATFORM".equals(user.appCode())) { + throw new ResponseStatusException(HttpStatus.FORBIDDEN, "仅平台账号可手工创建入仓计划"); + } + convertScope(user, request.customerId()); + String operatorId = user.userId() == null ? user.username() : String.valueOf(user.userId()); + return ApiResponse.success(service.convertManual(request, idempotencyKey, operatorId)); } @PostMapping("/convert-container") @PreAuthorize("hasAuthority('" + OverseasPermissions.INBOUND_ORDER_CREATE + "')") - @Operation(summary = "按整柜批量生成入库计划", description = "平台按 sourceContainerBookingId 锁定整柜订单;每个入仓码独立生成计划,整批原子且可幂等重放。Required authority: " + OverseasPermissions.INBOUND_ORDER_CREATE) + @Operation(summary = "按整柜批量生成剩余入库计划", description = "平台按 sourceContainerBookingId 锁定整柜订单;保留已有有效计划,只为剩余入仓码分别生成独立计划,整批原子且可幂等重放。Required authority: " + OverseasPermissions.INBOUND_ORDER_CREATE) public ApiResponse> convertContainer( @AuthenticationPrincipal AuthenticatedUser user, @Valid @RequestBody ConvertContainerInboundPlanRequest request) { diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/dto/ConvertInboundPlanRequest.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/dto/ConvertInboundPlanRequest.java deleted file mode 100644 index 615215fa4..000000000 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/dto/ConvertInboundPlanRequest.java +++ /dev/null @@ -1,10 +0,0 @@ -package com.baiyue.lastleg.inboundplan.dto; - -import jakarta.validation.constraints.NotNull; -import jakarta.validation.constraints.Size; - -public record ConvertInboundPlanRequest( - @NotNull Long orderId, - @Size(max = 128) String customerId -) { -} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/dto/InboundPlanListResponse.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/dto/InboundPlanListResponse.java index 87a48969b..91840b731 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/dto/InboundPlanListResponse.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/dto/InboundPlanListResponse.java @@ -12,6 +12,8 @@ public record InboundPlanListResponse( String inboundCode, String lxCode, String orderType, + String overseasWarehouseCode, + String overseasWarehouseName, String warehouseId, String warehouseCode, String warehouseName, @@ -29,18 +31,4 @@ public record InboundPlanListResponse( String dispatchStatus, LocalDateTime createdAt, Integer version -) { - public InboundPlanListResponse(Long lineId, Long planId, String planNo, String customerId, - Long orderId, String inboundCode, String orderType, - String warehouseId, String warehouseCode, String warehouseName, - String fbaWarehouseCode, String customerName, Integer packageCount, - BigDecimal volumeCbm, Integer palletCount, String status, - String appointmentNo, String appointmentStatus, - LocalDateTime appointmentAt, LocalDateTime expectedLoadingAt, - String dispatchStatus, LocalDateTime createdAt) { - this(lineId, planId, planNo, customerId, orderId, inboundCode, null, orderType, warehouseId, - warehouseCode, warehouseName, fbaWarehouseCode, customerName, packageCount, 0, - volumeCbm, palletCount, status, appointmentNo, appointmentStatus, appointmentAt, - expectedLoadingAt, dispatchStatus, createdAt, 0); - } -} +) { } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/dto/InboundPlanRequests.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/dto/InboundPlanRequests.java index 7e36da893..a1c844d0d 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/dto/InboundPlanRequests.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/dto/InboundPlanRequests.java @@ -28,15 +28,6 @@ public final class InboundPlanRequests { LocalDateTime createdFrom, LocalDateTime createdTo ) { - public Query(long page, long size, String planNo, String inboundCode, String status, - String overseasWarehouseId, String destinationWarehouseCode, String warehouse, - String fbaWarehouseCode, String appointmentStatus, String dispatchStatus, - LocalDateTime createdFrom, LocalDateTime createdTo) { - this(page, size, planNo, inboundCode, null, status, overseasWarehouseId, - destinationWarehouseCode, warehouse, fbaWarehouseCode, appointmentStatus, - dispatchStatus, createdFrom, createdTo); - } - public Query { if (page == 0) page = 1; if (size == 0) size = 20; diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/dto/InboundPlanResponse.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/dto/InboundPlanResponse.java index 0fd8cd3e6..8188d0a75 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/dto/InboundPlanResponse.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/dto/InboundPlanResponse.java @@ -2,6 +2,7 @@ package com.baiyue.lastleg.inboundplan.dto; import java.math.BigDecimal; import java.time.LocalDateTime; +import java.time.LocalDate; import java.util.List; public record InboundPlanResponse( @@ -10,6 +11,8 @@ public record InboundPlanResponse( String customerId, Long sourceOrderId, String containerNo, + String overseasWarehouseCode, + String overseasWarehouseName, String status, int lineCount, int palletCount, @@ -19,24 +22,15 @@ public record InboundPlanResponse( List lines, List pallets, List allocations, - int version + int version, + LocalDate plannedArrivalDate, + String palletType, + boolean assigned, + boolean mergeRequired, + boolean urgent, + String creationMode, + String remark ) { - public InboundPlanResponse(Long id, String planNo, String customerId, Long sourceOrderId, - String containerNo, String status, int lineCount, int palletCount, - BigDecimal totalVolumeCbm, List lines, List pallets, - List allocations) { - this(id, planNo, customerId, sourceOrderId, containerNo, status, lineCount, palletCount, - totalVolumeCbm, lines, pallets, allocations, 0); - } - - public InboundPlanResponse(Long id, String planNo, String customerId, Long sourceOrderId, - String containerNo, String status, int lineCount, int palletCount, - BigDecimal totalVolumeCbm, List lines, List pallets, - List allocations, int version) { - this(id, planNo, customerId, sourceOrderId, containerNo, status, lineCount, palletCount, - totalVolumeCbm, lines.stream().mapToInt(Line::requiredPackageCount).sum(), - allocations.stream().mapToInt(Allocation::pickedPackageCount).sum(), lines, pallets, allocations, version); - } public record Line(Long id, Long orderId, String inboundCode, String fbaWarehouseCode, String destinationWarehouseId, String destinationWarehouseCode, String destinationWarehouseName, BigDecimal volumeCbm, int requiredPackageCount, int plannedPalletCount) { @@ -46,7 +40,7 @@ public record InboundPlanResponse( String destinationWarehouseCode, String destinationWarehouseName, BigDecimal capacityCbm, BigDecimal allocatedVolumeCbm, int sequenceNo, Long warehousePalletId, Long warehouseLocationId, Long warehouseResourceLockId, - String assignmentStatus, LocalDateTime lockedAt, BigDecimal actualVolumeCbm, + String palletType, String assignmentStatus, LocalDateTime lockedAt, BigDecimal actualVolumeCbm, int actualPackageCount) { } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/dto/ManualInboundPlanRequest.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/dto/ManualInboundPlanRequest.java new file mode 100644 index 000000000..ce7be8228 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/dto/ManualInboundPlanRequest.java @@ -0,0 +1,21 @@ +package com.baiyue.lastleg.inboundplan.dto; + +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; +import java.time.LocalDate; + +public record ManualInboundPlanRequest( + @NotBlank @Size(max = 128) String customerId, + @NotNull Long orderId, + @NotNull @Min(0) Integer expectedOrderVersion, + @NotNull LocalDate plannedArrivalDate, + @NotNull @Min(1) @Max(999) Integer palletCount, + @NotBlank @Size(max = 32) String palletType, + @NotNull Boolean assigned, + @NotNull Boolean mergeRequired, + @NotNull Boolean urgent, + @Size(max = 1000) String remark +) { } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/entity/InboundPlan.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/entity/InboundPlan.java index 51c30b470..5a675332c 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/entity/InboundPlan.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/entity/InboundPlan.java @@ -8,6 +8,7 @@ import lombok.Data; import java.math.BigDecimal; import java.time.LocalDateTime; +import java.time.LocalDate; @Data @TableName("ll_inbound_plan") @@ -19,6 +20,13 @@ public class InboundPlan { private Long sourceOrderId; private String containerNo; private Long overseasWarehouseId; + private LocalDate plannedArrivalDate; + private String palletType; + private Boolean assignedFlag; + private Boolean mergeRequired; + private Boolean urgentFlag; + private String creationMode; + private String planRemark; private String status; private Integer lineCount; private Integer palletCount; diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/entity/InboundPlanPallet.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/entity/InboundPlanPallet.java index 28eea73e4..5e2085168 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/entity/InboundPlanPallet.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/entity/InboundPlanPallet.java @@ -16,6 +16,8 @@ public class InboundPlanPallet { private String customerId; private Long planId; private String palletNo; + private String sourcePalletNo; + private String palletType; private String destinationWarehouseId; private String destinationWarehouseCode; private String destinationWarehouseName; diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/mapper/InboundPlanOrderMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/mapper/InboundPlanOrderMapper.java index f7669d5f6..8aedf41ff 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/mapper/InboundPlanOrderMapper.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/mapper/InboundPlanOrderMapper.java @@ -14,6 +14,19 @@ import java.util.List; public interface InboundPlanOrderMapper { record ContainerOrderRef(String customerId, Long orderId, String currentStatus, String containerNo, String orderType) { } + record ReceivingWarehouseSnapshot(String overseasWarehouseId, String overseasWarehouseCode, + String overseasWarehouseName) { } + record ContainerWarehouseRef(String customerId, Long orderId, String sourceContainerBookingId, + String overseasWarehouseId) { } + + @Select("SELECT c.customer_id AS customerId,c.id AS orderId," + + "c.source_container_booking_id AS sourceContainerBookingId,c.overseas_warehouse_id AS overseasWarehouseId " + + "FROM ll_order target JOIN ll_order c ON c.source_container_booking_id=target.source_container_booking_id " + + "WHERE target.customer_id=#{customerId} AND target.id=#{orderId} " + + "AND target.source_container_booking_id IS NOT NULL AND target.source_container_booking_id!='' " + + "ORDER BY c.customer_id,c.id FOR UPDATE") + List lockContainerWarehouseFacts(@Param("customerId") String customerId, + @Param("orderId") Long orderId); @Select("SELECT customer_id AS customerId,id AS orderId,current_status AS currentStatus,container_no AS containerNo," + "order_type AS orderType " + @@ -26,22 +39,30 @@ public interface InboundPlanOrderMapper { @Select("SELECT o.id AS orderId,o.customer_id AS customerId,o.container_no AS containerNo," + "o.inbound_code AS inboundCode,o.current_status AS currentStatus,o.inbound_order_no AS inboundOrderNo," + "COALESCE(o.volume_cbm,0) AS volumeCbm,COALESCE(o.package_count,0) AS packageCount," + - "COALESCE(o.pallet_count,0) AS recommendedPalletCount," + + "COALESCE(o.pallet_count,0) AS recommendedPalletCount,o.overseas_warehouse_id AS overseasWarehouseId," + + "o.overseas_warehouse_code AS overseasWarehouseCode,o.overseas_warehouse_name AS overseasWarehouseName," + "NULLIF(o.fba_warehouse_code,'') AS fbaWarehouseCode," + "COALESCE(NULLIF(o.transfer_warehouse_id,''),MIN(NULLIF(d.destination_warehouse_id,''))) AS destinationWarehouseId," + "COALESCE(NULLIF(o.transfer_warehouse_code,''),MIN(NULLIF(d.destination_warehouse_code,''))) AS destinationWarehouseCode," + "COALESCE(NULLIF(o.transfer_warehouse_name,''),MIN(NULLIF(d.destination_warehouse_name,''))) AS destinationWarehouseName," + "CASE WHEN COALESCE(NULLIF(o.transfer_warehouse_id,''),NULLIF(o.transfer_warehouse_code,'')) IS NOT NULL THEN 1 " + "ELSE COUNT(DISTINCT COALESCE(NULLIF(d.destination_warehouse_id,''),NULLIF(d.destination_warehouse_code,''),'#EMPTY#')) END AS destinationCount," + - "0 AS destinationMismatchCount " + + "0 AS destinationMismatchCount,o.detained AS detained,o.version AS version " + "FROM ll_order o LEFT JOIN ll_order_detail d ON d.order_id=o.id " + "WHERE o.customer_id=#{customerId} AND o.id=#{orderId} " + - "GROUP BY o.id,o.customer_id,o.container_no,o.inbound_code,o.current_status,o.inbound_order_no,o.volume_cbm,o.package_count,o.pallet_count,o.fba_warehouse_code," + - "o.transfer_warehouse_id,o.transfer_warehouse_code,o.transfer_warehouse_name " + + "GROUP BY o.id,o.customer_id,o.container_no,o.inbound_code,o.current_status,o.inbound_order_no,o.volume_cbm,o.package_count,o.pallet_count," + + "o.overseas_warehouse_id,o.overseas_warehouse_code,o.overseas_warehouse_name,o.fba_warehouse_code," + + "o.transfer_warehouse_id,o.transfer_warehouse_code,o.transfer_warehouse_name,o.detained,o.version " + "ORDER BY o.id FOR UPDATE") List lockOrder(@Param("customerId") String customerId, @Param("orderId") Long orderId); + @Select("SELECT overseas_warehouse_id AS overseasWarehouseId," + + "overseas_warehouse_code AS overseasWarehouseCode,overseas_warehouse_name AS overseasWarehouseName " + + "FROM ll_order WHERE customer_id=#{customerId} AND id=#{orderId} LIMIT 1") + ReceivingWarehouseSnapshot findReceivingWarehouse(@Param("customerId") String customerId, + @Param("orderId") Long orderId); + @Update("UPDATE ll_order SET inbound_order_no=#{planNo},current_status='INBOUND_PLANNED'," + "pallet_merge_flag='MERGED',pallet_count=#{palletCount},updated_at=#{updatedAt} " + "WHERE customer_id=#{customerId} AND id=#{orderId}") @@ -49,6 +70,17 @@ public interface InboundPlanOrderMapper { @Param("planNo") String planNo, @Param("palletCount") int palletCount, @Param("updatedAt") LocalDateTime updatedAt); + @Update("UPDATE ll_order SET inbound_order_no=#{planNo},current_status='INBOUND_PLANNED'," + + "pallet_merge_flag=CASE WHEN #{mergeRequired}=1 THEN 'MERGE_REQUIRED' ELSE NULL END," + + "pallet_count=#{palletCount},version=version+1,updated_at=#{updatedAt} " + + "WHERE customer_id=#{customerId} AND id=#{orderId} AND version=#{expectedVersion} AND detained=0 " + + "AND current_status IN ('PENDING_ASSIGNMENT','DESTINATION_ASSIGNED') AND inbound_order_no IS NULL") + int markPlannedManual(@Param("customerId") String customerId, @Param("orderId") Long orderId, + @Param("planNo") String planNo, @Param("palletCount") int palletCount, + @Param("mergeRequired") boolean mergeRequired, + @Param("expectedVersion") int expectedVersion, + @Param("updatedAt") LocalDateTime updatedAt); + @Insert("INSERT INTO ll_loading_order_status_history(customer_id,order_id,from_status,to_status,remark,created_at) " + "VALUES(#{customerId},#{orderId},#{fromStatus},'INBOUND_PLANNED',#{remark},#{createdAt})") int insertHistory(@Param("customerId") String customerId, @Param("orderId") Long orderId, @Param("fromStatus") String fromStatus, diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/mapper/InboundPlanQueryMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/mapper/InboundPlanQueryMapper.java index 7ee4ee6fb..86358277c 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/mapper/InboundPlanQueryMapper.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/mapper/InboundPlanQueryMapper.java @@ -25,7 +25,7 @@ public interface InboundPlanQueryMapper { " AND l.inbound_code LIKE CONCAT('%',#{q.inboundCode},'%')" + " AND o.lx_code LIKE CONCAT('%',#{q.lxCode},'%')" + " AND p.status=#{q.status}" + - " AND l.destination_warehouse_id=#{q.overseasWarehouseId}" + + " AND o.overseas_warehouse_id=#{q.overseasWarehouseId}" + " AND l.destination_warehouse_code=#{q.destinationWarehouseCode}" + " AND (l.destination_warehouse_id LIKE CONCAT('%',#{q.warehouse},'%') OR l.destination_warehouse_code LIKE CONCAT('%',#{q.warehouse},'%') OR l.destination_warehouse_name LIKE CONCAT('%',#{q.warehouse},'%'))" + " AND l.fba_warehouse_code LIKE CONCAT('%',#{q.fbaWarehouseCode},'%')" + @@ -38,6 +38,7 @@ public interface InboundPlanQueryMapper { @Select("