fix(firstleg): scope truck appointments by booking

This commit is contained in:
2026-09-01 16:21:27 +08:00
parent ca8127ce83
commit ecc83ed64a
20 changed files with 176 additions and 591 deletions
+2 -2
View File
@@ -27,10 +27,10 @@
| 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/`;服务专属脚本留在 `<service>/scripts/` | 先读 `scripts/AGENTS.md` 和 `scripts/README.md`;Admin HTTP 内部模块继续读 `scripts/admin-test-env-modules/README.md`。新增入口必须登记用途、目标环境、数据副作用和报告位置,并通过 `scripts/script-documentation-contract.test.mjs` |
| 分柜、柜次、智能排柜、预装/装柜清单、PDA | `firstleg/` | `firstleg/AGENTS.md`,再按 `split/`、`loading/`、`outbound/` 定位;预装/正式装柜详情阶段由 `/loading-plans/{planId}/lifecycle` 按稳定 planId 解析,不从 404 或文案推断 |
| 分柜、柜次、智能排柜、预装/装柜清单、真实柜号后补、PDA | `firstleg/` | `firstleg/AGENTS.md`,再按 `split/`、`loading/`、`outbound/` 定位;预装/正式详情由 `/loading-plans/{planId}/lifecycle` 解析。真实集装箱号不阻塞订舱关联、计划确认或 PDA task 创建,可在已 push 的 `EDITING/LOADING` 首次补录,但必须在 PDA 最终完成前存在 |
| 合柜报关 / 同柜不可拆组、跨客户合柜、整组分配/移除 | `firstleg/` | `loading/coload/controller/PlatformColoadGroupController.java`、`service/ColoadGroupService.java`、`persistence/ColoadGroupMapper.java`、`src/main/resources/openapi/coload-groups-v1.yaml`;按订单建组并冻结全部可用入仓片段,仅要求同国内仓、同渠道 |
| 智能装柜入仓码候选与箱数分配 | `firstleg/` | `controller/PlatformSmartLoadingController.java` 的 `/inbound-candidates`、`/plans/{planId}/inbound-assignments`,业务编排 `service/SmartLoadingService.java` |
| 订舱资料/打印、船期、订舱拥有的拖车、报关 | `firstleg/` | `oceanbooking/`、`portschedule/`、`TruckAppointmentService.java`、`outbound/dispatch/`、`customs/`;拖车不带客户归属,必须绑定 `RELEASED` 订舱及其真实柜,订舱文件联查 `file/AGENTS.md` |
| 订舱资料/打印、船期、订舱级拖车、报关 | `firstleg/` | `oceanbooking/`、`portschedule/`、`TruckAppointmentService.java`、`outbound/dispatch/`、`customs/`;拖车只绑定 `RELEASED` 订舱,不携带客户、柜次、装柜计划或真实柜号,每订舱最多一张非终态预约,订舱文件联查 `file/AGENTS.md` |
| 客户账期、费用池、账单、收款、成本、核销 | `finance/` | `finance/AGENTS.md`;费用来源和柜次成本快照再联查 `firstleg/AGENTS.md`,凭证再联查 `file/AGENTS.md` |
| 入库/柜次应付、直接已付、付款流水与凭证 | `firstleg/`、`finance/`、`file/` | 业务费用与付款快照见 `firstleg/AGENTS.md` 的 `FeePaymentSnapshotValidator`;`/api/platform/finance/v1/vouchers` 上传凭证,File 按 `FINANCE_VOUCHER` 和客户范围校验 |
| 仓库、往来单位、快递、托盘、库位 | `warehouse/` | `warehouse/AGENTS.md` |
@@ -9388,7 +9388,7 @@ USE baiyue_firstleg;
SET NAMES utf8mb4;
CREATE TABLE IF NOT EXISTS fl_truck_appointment (
id BIGINT UNSIGNED NOT NULL COMMENT '拖车预约ID', appointment_no VARCHAR(40) NOT NULL COMMENT '平台拖车预约编号', outbound_order_id BIGINT UNSIGNED NULL COMMENT '关联头程订单逻辑ID;不跨库建外键',
ocean_booking_id BIGINT UNSIGNED NULL COMMENT '关联海运订舱逻辑ID', container_booking_id BIGINT UNSIGNED NULL COMMENT '关联柜次逻辑ID;拖车完成供装柜门禁校验', loading_plan_id BIGINT UNSIGNED NULL COMMENT '关联装柜计划逻辑ID',
ocean_booking_id BIGINT UNSIGNED NULL COMMENT '关联海运订舱逻辑ID', container_booking_id BIGINT UNSIGNED NULL COMMENT '停用保留列;订舱级拖车单不写入、不读取', loading_plan_id BIGINT UNSIGNED NULL COMMENT '停用保留列;订舱级拖车单不写入、不读取',
warehouse_id BIGINT UNSIGNED NULL COMMENT '预约到仓的国内收货仓逻辑ID',
carrier_id BIGINT UNSIGNED NULL COMMENT '拖车服务商逻辑ID', carrier_name VARCHAR(128) NULL COMMENT '拖车服务商名称快照', bill_of_lading_no VARCHAR(128) NULL COMMENT '派车提单号快照;关联RELEASED订舱时由订舱事实源冻结', fleet_contact VARCHAR(64) NULL COMMENT '车队联系人', fleet_phone VARCHAR(32) NULL COMMENT '车队联系电话',
driver_name VARCHAR(64) NULL COMMENT '司机姓名', driver_phone VARCHAR(32) NULL COMMENT '司机联系电话', driver_id_card VARCHAR(32) NULL COMMENT '司机身份证号;平台内部敏感信息', vehicle_no VARCHAR(32) NULL COMMENT '车辆车牌号',
@@ -9396,10 +9396,10 @@ CREATE TABLE IF NOT EXISTS fl_truck_appointment (
store_city VARCHAR(64) NULL COMMENT '到仓地点城市快照', store_zipcode VARCHAR(16) NULL COMMENT '到仓地点邮编快照', package_count INT UNSIGNED NULL COMMENT '预约货物包装件数快照', gross_weight_kg DECIMAL(18,3) NULL COMMENT '预约货物毛重,单位千克',
volume_cbm DECIMAL(18,4) NULL COMMENT '预约货物体积,单位立方米', pallet_count INT UNSIGNED NULL COMMENT '预约托盘数量', po_no VARCHAR(255) NULL COMMENT '关联采购单号', planned_loading_at DATETIME(3) NULL COMMENT '预约到仓装柜时间',
actual_arrived_warehouse_at DATETIME(3) NULL COMMENT '车辆实际到达收货仓时间', actual_loading_at DATETIME(3) NULL COMMENT '实际开始装柜时间', actual_departed_at DATETIME(3) NULL COMMENT '车辆实际离仓时间;DEPARTED后供LOADED门禁使用', transit_minutes INT UNSIGNED NULL COMMENT '从到仓到离仓的运输作业耗时,单位分钟', payable_amount DECIMAL(18,2) NULL COMMENT '应付拖车服务商金额;币种由currency指定',
currency VARCHAR(8) NOT NULL DEFAULT 'RMB' COMMENT '应付金额币种;仅RMB或USD', status VARCHAR(32) NOT NULL DEFAULT 'DRAFT' COMMENT '拖车预约状态:DRAFT草稿、PENDING_DISPATCH待派车、DISPATCHED已派车、PICKUP_IN_PROGRESS提货中、ARRIVED_WAREHOUSE已到仓、LOADING装柜中、DEPARTED已离仓、COMPLETED已完成、RESCHEDULED已改约、EXCEPTION异常、CANCELLED已取消', active_container_booking_id BIGINT UNSIGNED GENERATED ALWAYS AS (CASE WHEN status NOT IN ('COMPLETED','CANCELLED','EXCEPTION') THEN container_booking_id ELSE NULL END) STORED COMMENT '非终态预约的柜次唯一键', revision INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '拖车预约业务修订号;每次状态或改约递增',
currency VARCHAR(8) NOT NULL DEFAULT 'RMB' COMMENT '应付金额币种;仅RMB或USD', status VARCHAR(32) NOT NULL DEFAULT 'DRAFT' COMMENT '拖车预约状态:DRAFT草稿、PENDING_DISPATCH待派车、DISPATCHED已派车、PICKUP_IN_PROGRESS提货中、ARRIVED_WAREHOUSE已到仓、LOADING装柜中、DEPARTED已离仓、COMPLETED已完成、RESCHEDULED已改约、EXCEPTION异常、CANCELLED已取消', active_ocean_booking_id BIGINT UNSIGNED GENERATED ALWAYS AS (CASE WHEN status NOT IN ('COMPLETED','CANCELLED','EXCEPTION') THEN ocean_booking_id ELSE NULL END) STORED COMMENT '非终态预约的订舱唯一键', revision INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '拖车预约业务修订号;每次状态或改约递增',
version INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '乐观锁版本号', remark VARCHAR(1000) NULL COMMENT '平台内部备注或异常说明', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '拖车预约创建时间',
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '拖车预约最后更新时间', PRIMARY KEY(id), UNIQUE KEY uk_truck_appointment_no(appointment_no), UNIQUE KEY uk_truck_active_container(active_container_booking_id),
KEY idx_truck_appointment_status(status,updated_at), KEY idx_truck_appointment_container(container_booking_id),
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '拖车预约最后更新时间', PRIMARY KEY(id), UNIQUE KEY uk_truck_appointment_no(appointment_no), UNIQUE KEY uk_truck_active_ocean_booking(active_ocean_booking_id),
KEY idx_truck_appointment_status(status,updated_at), KEY idx_truck_appointment_ocean(ocean_booking_id),
CONSTRAINT chk_truck_appointment_status CHECK(status IN ('DRAFT','PENDING_DISPATCH','DISPATCHED','PICKUP_IN_PROGRESS','ARRIVED_WAREHOUSE','LOADING','DEPARTED','COMPLETED','RESCHEDULED','EXCEPTION','CANCELLED')),
CONSTRAINT chk_truck_appointment_currency CHECK(currency IN ('RMB','USD'))
) ENGINE=InnoDB COMMENT='三期拖车预约派车聚合';
@@ -9457,16 +9457,26 @@ CALL phase3_truck_add_column(
'DATETIME(3) NULL AFTER actual_loading_at'
);
CALL phase3_truck_add_column(
'active_container_booking_id',
'BIGINT UNSIGNED GENERATED ALWAYS AS (CASE WHEN status NOT IN (''COMPLETED'',''CANCELLED'',''EXCEPTION'') THEN container_booking_id ELSE NULL END) STORED COMMENT ''非终态预约的柜次唯一键'' AFTER status'
'active_ocean_booking_id',
'BIGINT UNSIGNED GENERATED ALWAYS AS (CASE WHEN status NOT IN (''COMPLETED'',''CANCELLED'',''EXCEPTION'') THEN ocean_booking_id ELSE NULL END) STORED COMMENT ''非终态预约的订舱唯一键'' AFTER status'
);
DROP PROCEDURE IF EXISTS phase3_truck_add_column;
SET @phase3_truck_active_index = IF(
SET @phase3_truck_drop_old_active_index = IF(
EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE()
AND table_name='fl_truck_appointment' AND index_name='uk_truck_active_container' AND non_unique=0),
'ALTER TABLE fl_truck_appointment DROP INDEX uk_truck_active_container',
'SELECT 1'
);
PREPARE phase3_truck_active_stmt FROM @phase3_truck_drop_old_active_index;
EXECUTE phase3_truck_active_stmt;
DEALLOCATE PREPARE phase3_truck_active_stmt;
SET @phase3_truck_active_index = IF(
EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE()
AND table_name='fl_truck_appointment' AND index_name='uk_truck_active_ocean_booking' AND non_unique=0),
'SELECT 1',
'ALTER TABLE fl_truck_appointment ADD UNIQUE KEY uk_truck_active_container(active_container_booking_id)'
'ALTER TABLE fl_truck_appointment ADD UNIQUE KEY uk_truck_active_ocean_booking(active_ocean_booking_id)'
);
PREPARE phase3_truck_active_stmt FROM @phase3_truck_active_index;
EXECUTE phase3_truck_active_stmt;
@@ -13903,12 +13913,12 @@ CALL queue_column_comment('baiyue_firstleg', 'fl_third_party_call_log', 'tenant_
CALL queue_column_comment('baiyue_firstleg', 'fl_truck_appointment', 'actual_arrived_warehouse_at', '车辆实际到达收货仓时间');
CALL queue_column_comment('baiyue_firstleg', 'fl_truck_appointment', 'actual_departed_at', '车辆实际离仓时间;DEPARTED后供LOADED门禁使用');
CALL queue_column_comment('baiyue_firstleg', 'fl_truck_appointment', 'actual_loading_at', '实际开始装柜时间');
CALL queue_column_comment('baiyue_firstleg', 'fl_truck_appointment', 'active_container_booking_id', '非终态预约的柜次唯一键');
CALL queue_column_comment('baiyue_firstleg', 'fl_truck_appointment', 'active_ocean_booking_id', '非终态预约的订舱唯一键');
CALL queue_column_comment('baiyue_firstleg', 'fl_truck_appointment', 'appointment_no', '平台拖车预约编号');
CALL queue_column_comment('baiyue_firstleg', 'fl_truck_appointment', 'bill_of_lading_no', '派车提单号快照;关联RELEASED订舱时由订舱事实源冻结');
CALL queue_column_comment('baiyue_firstleg', 'fl_truck_appointment', 'carrier_id', '拖车服务商逻辑ID');
CALL queue_column_comment('baiyue_firstleg', 'fl_truck_appointment', 'carrier_name', '拖车服务商名称快照');
CALL queue_column_comment('baiyue_firstleg', 'fl_truck_appointment', 'container_booking_id', '关联柜次逻辑ID;拖车完成供装柜门禁校验');
CALL queue_column_comment('baiyue_firstleg', 'fl_truck_appointment', 'container_booking_id', '停用保留列;订舱级拖车单不写入、不读取');
CALL queue_column_comment('baiyue_firstleg', 'fl_truck_appointment', 'created_at', '拖车预约创建时间');
CALL queue_column_comment('baiyue_firstleg', 'fl_truck_appointment', 'currency', '应付金额币种;仅RMB或USD');
CALL queue_column_comment('baiyue_firstleg', 'fl_truck_appointment', 'driver_id_card', '司机身份证号;平台内部敏感信息');
@@ -13918,7 +13928,7 @@ CALL queue_column_comment('baiyue_firstleg', 'fl_truck_appointment', 'fleet_cont
CALL queue_column_comment('baiyue_firstleg', 'fl_truck_appointment', 'fleet_phone', '车队联系电话');
CALL queue_column_comment('baiyue_firstleg', 'fl_truck_appointment', 'gross_weight_kg', '预约货物毛重,单位千克');
CALL queue_column_comment('baiyue_firstleg', 'fl_truck_appointment', 'id', '拖车预约ID');
CALL queue_column_comment('baiyue_firstleg', 'fl_truck_appointment', 'loading_plan_id', '关联装柜计划逻辑ID');
CALL queue_column_comment('baiyue_firstleg', 'fl_truck_appointment', 'loading_plan_id', '停用保留列;订舱级拖车单不写入、不读取');
CALL queue_column_comment('baiyue_firstleg', 'fl_truck_appointment', 'ocean_booking_id', '关联海运订舱逻辑ID');
CALL queue_column_comment('baiyue_firstleg', 'fl_truck_appointment', 'outbound_order_id', '关联头程订单逻辑ID;不跨库建外键');
CALL queue_column_comment('baiyue_firstleg', 'fl_truck_appointment', 'package_count', '预约货物包装件数快照');
+3 -3
View File
@@ -28,7 +28,7 @@
| 财务费用来源快照 | `controller/InternalFinanceFeeSourceController.java`;入库费用与柜次费用分别按来源 ID、版本向 Finance 提供不可变快照 |
| 柜次费用明细 | `controller/PlatformContainerFeeDetailController.java`、`service/ContainerFeeDetailService.java`、`dto/ContainerFeeDetailDtos.java`;Admin 消费端见 `../../apps/admin-web/src/modules/loading-lists/api/containerFees.ts` |
| 财务整柜成本分摊快照 | `loading/controller/InternalFinanceCostSnapshotController.java`、`loading/service/FinanceCostSnapshotService.java` |
| 分柜、预装与正式装柜清单生命周期 | `split/controller/PlatformSplitPreloadController.java`、`controller/PlatformLoadingListController.java`、`service/LoadingListService.java`;契约 `src/main/resources/openapi/loading-lists-v1.yaml`。同一 `planId` 在 `EDITING/LOADING` 返回 `PRELOAD`,`COMPLETED` 返回 `LOADING_LIST` 与独立读取权限;调用方先查 `/loading-plans/{planId}/lifecycle`,不得用 A0404 或错误文案猜阶段 |
| 分柜、预装、真实柜号后补与正式装柜清单生命周期 | `split/controller/PlatformSplitPreloadController.java`、`controller/PlatformLoadingListController.java`、`service/LoadingListService.java`;契约 `src/main/resources/openapi/loading-lists-v1.yaml`,详情阶段先查 `/loading-plans/{planId}/lifecycle`。同一 `planId` 在 `EDITING/LOADING` 返回 `PRELOAD`,`COMPLETED` 返回 `LOADING_LIST`。订舱关联、预装确认和 PDA task 创建不依赖真实集装箱号;已 push 后可在 `EDITING/LOADING` 首次补录,最终 PDA 完成前必须存在;已有值纠正仅限 `EDITING` 且任务未领取、无物理作业事实 |
| 同柜不可拆组 | `loading/coload/controller/PlatformColoadGroupController.java`、`service/ColoadGroupService.java`、`persistence/ColoadGroupMapper.java`;契约 `src/main/resources/openapi/coload-groups-v1.yaml`。请求按订单 ID 建组,服务端冻结每票全部 `WAIT_SPLIT` 可用入仓片段与满箱数;仅要求同国内仓、同渠道,允许跨客户及不同目的仓港;分配、移除、取消均保持整组原子 |
| 货柜与智能排柜 | `controller/PlatformContainerController.java`、`PlatformSmartLoadingController.java`、`service/SmartLoadingService.java`、`repository/SmartLoadingRepository.java`、`loading/`;按入仓码候选与箱数分配分别从 `/inbound-candidates`、`/plans/{planId}/inbound-assignments` 进入,明细规格拆分留在服务端 |
| 货柜资料、PDA 证据与业务总览 | `controller/PlatformContainerController.java` 的 `/{id}/overview`、`/{id}/documents/{documentType}`,聚合 `mapper/ContainerOverviewMapper.java`;契约 `src/main/resources/openapi/phase3-container-overview-v1.yaml`,四类资料固定为目的港清关、国内出口报关、DO、PU 的当前唯一版本 |
@@ -39,7 +39,7 @@
| 四期国内出仓后船节点定时查询 | `outbound/dispatch/service/OutboundDispatchService.java` 在确认出仓事务内幂等创建现有 `CarrierScheduleQuery`;编排与租约 `portschedule/service/CarrierScheduleQueryScheduler.java`,任务/十节点事实/异常事实/不可变 attempt `CarrierScheduleQueryService.java`,平台查询与人工补录入口 `PlatformCarrierScheduleQueryController.java` 的 `/{queryId}/manual-nodes`;停止只由实际 `EMPTY_RETURNED` 节点触发,不提供无节点停止入口;外部船司/轨迹调用经 `integration-service/`;标准见 `../../docs/四期/船节点标准化契约.md` |
| 船节点跨区交接事件 | `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`、`service/TruckAppointmentService.java`、`repository/TruckAppointmentRepository.java`、`outbound/dispatch/controller/PlatformOutboundDispatchController.java`;拖车契约 `src/main/resources/openapi/truck-appointments-v1.yaml`,出仓契约 `phase3-outbound-dispatch-v1.yaml`。预约无客户字段,必须绑定 `RELEASED` 订舱及其已分配真实 ISO 柜,同柜最多一条有效预约;提单号和仓库取订舱/柜次权威事实,附件固定平台作用域 |
| 订舱级拖车与整柜出仓调度 | `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 完成不推进拖车状态 |
| 整柜出口报关 | `customs/container/controller/PlatformContainerCustomsController.java`、`customs/container/controller/MerchantContainerCustomsController.java`、`customs/container/service/ContainerCustomsService.java`、`customs/container/mapper/ContainerCustomsMapper.java`;装柜完成后按柜收集退税资料,多对多覆盖入仓码,开始报关冻结,完成文件逐资料一对一回传,全买单柜由平台确认并通过导出中心取明细;契约 `src/main/resources/openapi/customs-v1.yaml`,基线 `src/main/resources/db/phase3-container-customs-schema.sql`;消费者为 Admin `../../apps/admin-web/src/modules/container-loading/components/ContainerCustomsPanel.vue` 和 Client `../../apps/client-web/src/modules/first-leg/components/CustomsDocumentsDialog.vue` |
| 买单订单明细导出 Provider | `customs/container/controller/LoadingListBuyDeclarationExportController.java`、`service/LoadingListBuyDeclarationExportService.java`;只为 File 导出中心提供 `LOADING_LIST_BUY_DECLARATION_ITEMS` 的已完成装柜实际买单行,不在 Firstleg 生成文件 |
| 跨模型桥接 | `loading/bridge/controller/PlatformLoadingModelBridgeController.java` |
@@ -61,7 +61,7 @@
- 订舱柜型需求至少一项、最多 20 项,数量必须为正整数,容量和限重必须匹配平台柜型规格。起运仓必须是启用的国内中转仓;物流渠道必须来自该仓当前 ACTIVE 价格版本,服务端保存渠道名称快照,渠道失效时失败关闭。
- 订舱号统一使用 `OB-yyMMdd-####`,按北京时间创建日每日独立递增且单日最多 9999;不得以实体 ID 拼接生成。放舱不要求真实柜号,也不得为满足放舱校验伪造柜号。
- 订舱列表 `containerCount` 固定为该订舱全部柜型需求 `quantity` 之和,与真实柜关联/解除无关;关联候选只返回 `RELEASED`、剩余数量大于 0 且 `warehouseId + channelCode + containerType` 精确匹配的需求,由服务端集合查询 ACTIVE 分配量。前端不得扇出详情计算或用已关联柜数覆盖该字段,最终关联命令仍须重验目标货柜可修改性。
- 拖车预约是平台级订舱履约资源,不是客户资源:请求、实体、查询和附件不得恢复 `customerId/customerName`。创建或编辑必须选择 `RELEASED` 订舱及其已分配、有效且带真实 ISO 6346 柜号的柜次,订舱还须有有效提单号和至少一份 ACTIVE `BOOKING_CONFIRMATION` 或 `BILL_OF_LADING`;同一真实柜通过查询锁与唯一约束最多保留一条非取消预约。拖车附件使用 `tenantId=0 + TRUCK_APPOINTMENT_ATTACHMENT + appointmentId`,提单文件继续归订舱,不复制到拖车附件。
- 拖车预约是平台级订舱聚合,不是客户或柜次资源:请求、实体、查询、响应和附件不得恢复 `customerId/customerName`,公开 DTO 也不得出现 `containerBookingId/loadingPlanId/cabinetNo/containerNo`。创建候选只返回有提单号、ACTIVE `BOOKING_CONFIRMATION` 或 `BILL_OF_LADING` 且没有非终态拖车单的 `RELEASED` 订舱;创建或编辑只冻结订舱仓库/提单与 TRUCKING 供应商快照。每订舱通过查询锁与唯一约束最多一张非终态预约,重复创建返回现有派车单号;PDA 装柜完成不得改变拖车状态。附件使用 `tenantId=0 + TRUCK_APPOINTMENT_ATTACHMENT + appointmentId`,提单文件继续归订舱。
- 四期船节点任务从确认柜子离开国内仓库后启用,必须绑定真实订舱和柜次;十个标准节点、预计/实际时间、异常事实、供应商缺失节点和还柜停止规则统一以 `../../docs/四期/船节点标准化契约.md` 为准。`CarrierScheduleQuery` 的正常业务查询槽位固定为 `Asia/Shanghai` 每日 09:00、12:30、16:00;每分钟任务只扫描已到期 `next_allowed_at`,不是每分钟请求船司。成功、无变化、需人工或永久失败进入下一固定槽位;仅技术性 `RETRYABLE_FAILURE` 在当前槽内按15/30分钟退避,含首次最多3次且不得跨下一槽,实际 `EMPTY_RETURNED` 后停止。错过的正常槽位直接顺延,不在服务恢复时补跑;单轮按受控批次持续领取并受最大任务数保护。租约、调度尝试和内部停止入口不得暴露为平台页面 API,外部失败不得推进业务节点,不得在三期另建查询任务。
- 国内出口报关在装柜完成后启用,不再阻塞 `LOADED`。整柜 `fl_customs_container.status=COMPLETED` 才通过离仓报关门禁;完成后以系统身份、确定性幂等键立即尝试确认国内离仓,其他门禁未齐时由受控定时补偿重试,禁止部分入仓成功或重复创建离仓/船司查询事实。
- 国内出仓确认以真实 `containerBookingId` 聚合全部入仓绑定并锁定校验;装柜证据按物理货柜只计算一次,PDA `taskId` 仅用于来源审计和任务授权,File 所有权仍固定 `tenantId=0 + containerBookingId`;拖车状态和独立船期只作参考。全部门禁在同一事务通过后推进所有技术行、记录一条可见历史并幂等创建 `CarrierScheduleQuery/DEPARTED_WAREHOUSE`,不允许单入仓局部成功。
@@ -1,10 +1,10 @@
package com.baiyue.firstleg.dto;
import jakarta.validation.constraints.*; import java.math.BigDecimal; import java.time.LocalDateTime;
public final class TruckAppointmentDtos { private TruckAppointmentDtos(){}
public record Query(@Size(max=64) String keyword, com.baiyue.firstleg.domain.TruckAppointmentStatus status, @Positive Long containerBookingId, @Min(1) long page, @Min(1) @Max(200) long size){ public Query{if(page==0)page=1;if(size==0)size=20;} }
public record Query(@Size(max=64) String keyword, com.baiyue.firstleg.domain.TruckAppointmentStatus status, @Positive Long oceanBookingId, @Min(1) long page, @Min(1) @Max(200) long size){ public Query{if(page==0)page=1;if(size==0)size=20;} }
public record CandidateQuery(@Size(max=64) String keyword,@Min(1) long page,@Min(1) @Max(200) long size){public CandidateQuery{if(page==0)page=1;if(size==0)size=20;}}
public record Candidate(Long oceanBookingId,String oceanBookingNo,String billOfLadingNo,Long warehouseId,String warehouseName,String channelCode,String channelName,Long requirementId,String containerType,Integer requirementQuantity,Long containerBookingId,String cabinetNo,String containerNo,Long loadingPlanId,String loadingPlanNo,String loadingPlanStatus,Boolean bookingDocumentReady){}
public record SaveRequest(@Positive Long outboundOrderId,@NotNull @Positive Long oceanBookingId,@NotNull @Positive Long containerBookingId,@Positive Long loadingPlanId,@Positive Long warehouseId,@NotNull @Positive Long carrierId,@Size(max=128) String carrierName,@Size(max=128) String billOfLadingNo,@Size(max=64) String fleetContact,@Size(max=32) String fleetPhone,@Size(max=64) String driverName,@Size(max=32) String driverPhone,@Size(max=32) String driverIdCard,@Size(max=32) String vehicleNo,@Size(max=128) String storeName,@Size(max=64) String storeContact,@Size(max=32) String storePhone,@Size(max=255) String storeAddress,@Size(max=64) String storeCity,@Size(max=16) String storeZipcode,@PositiveOrZero Integer packageCount,@DecimalMin("0") BigDecimal grossWeightKg,@DecimalMin("0") BigDecimal volumeCbm,@PositiveOrZero Integer palletCount,@Size(max=255) String poNo,@NotNull LocalDateTime plannedLoadingAt,@PositiveOrZero Integer transitMinutes,@DecimalMin("0") BigDecimal payableAmount,@Pattern(regexp="RMB|USD") String currency,@Size(max=1000) String remark,@NotNull @PositiveOrZero Integer expectedVersion){}
public record ActionRequest(@NotNull @PositiveOrZero Integer expectedVersion,@Size(max=500) String reason,@Size(max=32) String containerNo){}
public record Candidate(Long oceanBookingId,String oceanBookingNo,String billOfLadingNo,Long warehouseId,String warehouseName,String channelCode,String channelName,Boolean bookingDocumentReady){}
public record SaveRequest(@Positive Long outboundOrderId,@NotNull @Positive Long oceanBookingId,@Positive Long warehouseId,@NotNull @Positive Long carrierId,@Size(max=128) String carrierName,@Size(max=128) String billOfLadingNo,@Size(max=64) String fleetContact,@Size(max=32) String fleetPhone,@Size(max=64) String driverName,@Size(max=32) String driverPhone,@Size(max=32) String driverIdCard,@Size(max=32) String vehicleNo,@Size(max=128) String storeName,@Size(max=64) String storeContact,@Size(max=32) String storePhone,@Size(max=255) String storeAddress,@Size(max=64) String storeCity,@Size(max=16) String storeZipcode,@PositiveOrZero Integer packageCount,@DecimalMin("0") BigDecimal grossWeightKg,@DecimalMin("0") BigDecimal volumeCbm,@PositiveOrZero Integer palletCount,@Size(max=255) String poNo,@NotNull LocalDateTime plannedLoadingAt,@PositiveOrZero Integer transitMinutes,@DecimalMin("0") BigDecimal payableAmount,@Pattern(regexp="RMB|USD") String currency,@Size(max=1000) String remark,@NotNull @PositiveOrZero Integer expectedVersion){}
public record ActionRequest(@NotNull @PositiveOrZero Integer expectedVersion,@Size(max=500) String reason){}
public record AttachmentRequest(@NotNull @Positive Long fileId,@NotBlank @Pattern(regexp="DISPATCH_ORDER|DRIVER_DOCUMENT|VEHICLE_DOCUMENT|OTHER") String attachmentType,@Size(max=255) String fileName,@Size(max=128) String fileHash){ }
}
@@ -9,6 +9,7 @@ import lombok.Setter;
import java.math.BigDecimal;
import java.time.LocalDateTime;
import com.baiyue.firstleg.domain.TruckAppointmentStatus;
import com.fasterxml.jackson.annotation.JsonIgnore;
@Getter @Setter @TableName("fl_truck_appointment")
public class TruckAppointmentEntity {
@@ -16,8 +17,8 @@ public class TruckAppointmentEntity {
private String appointmentNo;
private Long outboundOrderId;
private Long oceanBookingId;
private Long containerBookingId;
private Long loadingPlanId;
@JsonIgnore private Long containerBookingId;
@JsonIgnore private Long loadingPlanId;
private Long warehouseId;
private Long carrierId;
private String carrierName;
@@ -58,7 +59,7 @@ public class TruckAppointmentEntity {
@TableField(exist = false) private String shippingCompanyName;
@TableField(exist = false) private Long bookingAgentId;
@TableField(exist = false) private String bookingAgentName;
@TableField(exist = false) private String cabinetNo;
@TableField(exist = false) private String containerNo;
@JsonIgnore @TableField(exist = false) private String cabinetNo;
@JsonIgnore @TableField(exist = false) private String containerNo;
@TableField(exist = false) private Long attachmentCount;
}
@@ -5,34 +5,22 @@ import com.baomidou.mybatisplus.core.mapper.BaseMapper; import com.baomidou.myba
<script>
SELECT o.id oceanBookingId,o.booking_no oceanBookingNo,o.bill_of_lading_no billOfLadingNo,
o.warehouse_id warehouseId,o.warehouse_name warehouseName,o.channel_code channelCode,
o.channel_name channelName,r.id requirementId,r.container_type containerType,
r.quantity requirementQuantity,c.id containerBookingId,c.cabinet_no cabinetNo,
c.container_no containerNo,p.id loadingPlanId,p.plan_no loadingPlanNo,
p.plan_status loadingPlanStatus,TRUE bookingDocumentReady
FROM fl_ocean_booking_container_allocation a
JOIN fl_ocean_booking o ON o.id=a.ocean_booking_id AND o.status='RELEASED'
JOIN fl_ocean_booking_container_requirement r ON r.id=a.requirement_id AND r.ocean_booking_id=o.id
JOIN fl_container_booking c ON c.id=a.container_booking_id AND c.ocean_booking_id=o.id AND c.record_status='ACTIVE'
LEFT JOIN fl_loading_plan p ON p.container_booking_id=c.id
o.channel_name channelName,TRUE bookingDocumentReady
FROM fl_ocean_booking o
WHERE EXISTS (SELECT 1 FROM fl_ocean_booking_document d WHERE d.ocean_booking_id=o.id
AND d.valid=1 AND d.document_type IN ('BOOKING_CONFIRMATION','BILL_OF_LADING'))
AND (p.id IS NULL OR p.plan_status&lt;&gt;'COMPLETED')
AND NOT EXISTS (SELECT 1 FROM fl_truck_appointment t WHERE t.container_booking_id=c.id
AND o.status='RELEASED' AND o.bill_of_lading_no IS NOT NULL AND o.bill_of_lading_no&lt;&gt;''
AND NOT EXISTS (SELECT 1 FROM fl_truck_appointment t WHERE t.ocean_booking_id=o.id
AND t.status NOT IN ('COMPLETED','CANCELLED','EXCEPTION'))
AND (#{q.keyword} IS NULL OR o.booking_no LIKE CONCAT('%',#{q.keyword},'%')
OR o.bill_of_lading_no LIKE CONCAT('%',#{q.keyword},'%')
OR c.cabinet_no LIKE CONCAT('%',#{q.keyword},'%')
OR c.container_no LIKE CONCAT('%',#{q.keyword},'%'))
ORDER BY o.updated_at DESC,o.id DESC,c.updated_at DESC,c.id DESC
OR o.bill_of_lading_no LIKE CONCAT('%',#{q.keyword},'%'))
ORDER BY o.updated_at DESC,o.id DESC
</script>
""") Page<Candidate> candidates(Page<Candidate> p,@Param("q") CandidateQuery q);
@Select("SELECT * FROM fl_truck_appointment WHERE container_booking_id=#{containerBookingId} AND status NOT IN ('COMPLETED','CANCELLED','EXCEPTION') ORDER BY created_at,id LIMIT 1 FOR UPDATE") TruckAppointmentEntity firstActiveForUpdate(@Param("containerBookingId") long containerBookingId);
@Select("SELECT * FROM fl_truck_appointment WHERE container_booking_id=#{containerBookingId} AND status NOT IN ('COMPLETED','CANCELLED','EXCEPTION') ORDER BY id FOR UPDATE") java.util.List<TruckAppointmentEntity> activeForUpdate(@Param("containerBookingId") long containerBookingId);
@Select("<script>SELECT a.*,o.booking_no ocean_booking_no,COALESCE(o.warehouse_name,c.warehouse_name) warehouse_name,o.shipping_company_id,o.shipping_company_name,o.booking_agent_id,o.booking_agent_name,c.cabinet_no,c.container_no,(SELECT COUNT(*) FROM fl_truck_appointment_attachment x WHERE x.appointment_id=a.id) attachment_count FROM fl_truck_appointment a LEFT JOIN fl_ocean_booking o ON o.id=a.ocean_booking_id LEFT JOIN fl_container_booking c ON c.id=a.container_booking_id AND c.record_status='ACTIVE' WHERE (#{q.keyword} IS NULL OR a.appointment_no LIKE CONCAT('%',#{q.keyword},'%') OR a.vehicle_no LIKE CONCAT('%',#{q.keyword},'%') OR a.carrier_name LIKE CONCAT('%',#{q.keyword},'%')) AND (#{q.status} IS NULL OR a.status=#{q.status}) AND (#{q.containerBookingId} IS NULL OR a.container_booking_id=#{q.containerBookingId}) ORDER BY a.updated_at DESC,a.id DESC</script>") Page<TruckAppointmentEntity> page(Page<TruckAppointmentEntity> p,@Param("q") Query q);
@Select("SELECT * FROM fl_truck_appointment WHERE ocean_booking_id=#{oceanBookingId} AND status NOT IN ('COMPLETED','CANCELLED','EXCEPTION') ORDER BY created_at,id LIMIT 1 FOR UPDATE") TruckAppointmentEntity firstActiveForUpdate(@Param("oceanBookingId") long oceanBookingId);
@Select("<script>SELECT a.*,o.booking_no ocean_booking_no,o.warehouse_name warehouse_name,o.shipping_company_id,o.shipping_company_name,o.booking_agent_id,o.booking_agent_name,(SELECT COUNT(*) FROM fl_truck_appointment_attachment x WHERE x.appointment_id=a.id) attachment_count FROM fl_truck_appointment a LEFT JOIN fl_ocean_booking o ON o.id=a.ocean_booking_id WHERE (#{q.keyword} IS NULL OR a.appointment_no LIKE CONCAT('%',#{q.keyword},'%') OR o.booking_no LIKE CONCAT('%',#{q.keyword},'%') OR a.vehicle_no LIKE CONCAT('%',#{q.keyword},'%') OR a.carrier_name LIKE CONCAT('%',#{q.keyword},'%')) AND (#{q.status} IS NULL OR a.status=#{q.status}) AND (#{q.oceanBookingId} IS NULL OR a.ocean_booking_id=#{q.oceanBookingId}) ORDER BY a.updated_at DESC,a.id DESC</script>") Page<TruckAppointmentEntity> page(Page<TruckAppointmentEntity> p,@Param("q") Query q);
@Update("UPDATE fl_truck_appointment SET status=#{toStatus}, actual_arrived_warehouse_at=CASE WHEN #{toStatus}='ARRIVED_WAREHOUSE' THEN CURRENT_TIMESTAMP(3) ELSE actual_arrived_warehouse_at END, actual_loading_at=CASE WHEN #{toStatus}='LOADING' THEN CURRENT_TIMESTAMP(3) ELSE actual_loading_at END, actual_departed_at=CASE WHEN #{toStatus}='DEPARTED' THEN CURRENT_TIMESTAMP(3) ELSE actual_departed_at END, revision=revision+1, version=version+1, updated_at=CURRENT_TIMESTAMP(3) WHERE id=#{id} AND status=#{fromStatus} AND version=#{version}") int transition(@Param("id") long id,@Param("fromStatus") String from,@Param("toStatus") String to,@Param("version") int version);
@Update("UPDATE fl_truck_appointment SET status='DISPATCHED',bill_of_lading_no=#{billOfLadingNo},revision=revision+1,version=version+1,updated_at=CURRENT_TIMESTAMP(3) WHERE id=#{id} AND status=#{fromStatus} AND version=#{version}") int dispatch(@Param("id") long id,@Param("fromStatus") String fromStatus,@Param("billOfLadingNo") String billOfLadingNo,@Param("version") int version);
@Update("UPDATE fl_container_booking SET container_no=#{containerNo},updated_by=#{operatorId},updated_by_name=#{operatorName},updated_at=CURRENT_TIMESTAMP(3),version=version+1 WHERE id=#{containerBookingId} AND record_status='ACTIVE' AND container_no IS NULL") int bindContainerNoForPickup(@Param("containerBookingId") long containerBookingId,@Param("containerNo") String containerNo,@Param("operatorId") long operatorId,@Param("operatorName") String operatorName);
@Update("UPDATE fl_truck_appointment SET status='COMPLETED',actual_loading_at=COALESCE(actual_loading_at,CURRENT_TIMESTAMP(3)),revision=revision+1,version=version+1,updated_at=CURRENT_TIMESTAMP(3) WHERE id=#{id} AND status=#{fromStatus} AND version=#{version}") int completeLoadingFromPda(@Param("id") long id,@Param("fromStatus") String fromStatus,@Param("version") int version);
@Update("UPDATE fl_truck_appointment SET planned_loading_at=#{at}, revision=revision+1, version=version+1, status='RESCHEDULED', remark=#{reason}, updated_at=CURRENT_TIMESTAMP(3) WHERE id=#{id} AND version=#{version}") int reschedule(@Param("id") long id,@Param("at") java.time.LocalDateTime at,@Param("reason") String reason,@Param("version") int version);
@Update("UPDATE fl_truck_appointment SET outbound_order_id=#{e.outboundOrderId},ocean_booking_id=#{e.oceanBookingId},container_booking_id=#{e.containerBookingId},loading_plan_id=#{e.loadingPlanId},warehouse_id=#{e.warehouseId},carrier_id=#{e.carrierId},carrier_name=#{e.carrierName},bill_of_lading_no=#{e.billOfLadingNo},fleet_contact=#{e.fleetContact},fleet_phone=#{e.fleetPhone},driver_name=#{e.driverName},driver_phone=#{e.driverPhone},driver_id_card=#{e.driverIdCard},vehicle_no=#{e.vehicleNo},store_name=#{e.storeName},store_contact=#{e.storeContact},store_phone=#{e.storePhone},store_address=#{e.storeAddress},store_city=#{e.storeCity},store_zipcode=#{e.storeZipcode},package_count=#{e.packageCount},gross_weight_kg=#{e.grossWeightKg},volume_cbm=#{e.volumeCbm},pallet_count=#{e.palletCount},po_no=#{e.poNo},planned_loading_at=#{e.plannedLoadingAt},transit_minutes=#{e.transitMinutes},payable_amount=#{e.payableAmount},currency=#{e.currency},remark=#{e.remark},version=version+1,revision=revision+1,updated_at=CURRENT_TIMESTAMP(3) WHERE id=#{e.id} AND version=#{version}") int updateEdit(@Param("e") TruckAppointmentEntity e,@Param("version") int version);
@Update("UPDATE fl_truck_appointment SET outbound_order_id=#{e.outboundOrderId},ocean_booking_id=#{e.oceanBookingId},warehouse_id=#{e.warehouseId},carrier_id=#{e.carrierId},carrier_name=#{e.carrierName},bill_of_lading_no=#{e.billOfLadingNo},fleet_contact=#{e.fleetContact},fleet_phone=#{e.fleetPhone},driver_name=#{e.driverName},driver_phone=#{e.driverPhone},driver_id_card=#{e.driverIdCard},vehicle_no=#{e.vehicleNo},store_name=#{e.storeName},store_contact=#{e.storeContact},store_phone=#{e.storePhone},store_address=#{e.storeAddress},store_city=#{e.storeCity},store_zipcode=#{e.storeZipcode},package_count=#{e.packageCount},gross_weight_kg=#{e.grossWeightKg},volume_cbm=#{e.volumeCbm},pallet_count=#{e.palletCount},po_no=#{e.poNo},planned_loading_at=#{e.plannedLoadingAt},transit_minutes=#{e.transitMinutes},payable_amount=#{e.payableAmount},currency=#{e.currency},remark=#{e.remark},version=version+1,revision=revision+1,updated_at=CURRENT_TIMESTAMP(3) WHERE id=#{e.id} AND version=#{version}") int updateEdit(@Param("e") TruckAppointmentEntity e,@Param("version") int version);
}
@@ -56,11 +56,14 @@ public interface OutboundDispatchReadMapper {
OceanBinding ocean(@Param("containerBookingId") long containerBookingId);
@Select("""
SELECT id,status FROM fl_truck_appointment
WHERE container_booking_id=#{containerBookingId} AND loading_plan_id=#{loadingPlanId}
ORDER BY updated_at DESC,id DESC LIMIT 1
SELECT appointment.id,appointment.status
FROM fl_truck_appointment appointment
JOIN fl_ocean_booking_container_allocation allocation
ON allocation.ocean_booking_id=appointment.ocean_booking_id
WHERE allocation.container_booking_id=#{containerBookingId}
ORDER BY appointment.updated_at DESC,appointment.id DESC LIMIT 1
""")
IdStatus truck(@Param("containerBookingId") long containerBookingId, @Param("loadingPlanId") long loadingPlanId);
IdStatus truck(@Param("containerBookingId") long containerBookingId);
@Select("""
SELECT COUNT(DISTINCT CASE WHEN evidence.status='ACTIVE' AND evidence.file_id IS NOT NULL
@@ -27,8 +27,8 @@ public class OutboundDispatchRepository {
}
public OutboundDispatchReadMapper.IdStatus pick(long planId) { return reads.pick(planId); }
public OutboundDispatchReadMapper.OceanBinding ocean(long containerId) { return reads.ocean(containerId); }
public OutboundDispatchReadMapper.IdStatus truck(long containerId, long planId) {
return reads.truck(containerId, planId);
public OutboundDispatchReadMapper.IdStatus truck(long containerId) {
return reads.truck(containerId);
}
public OutboundDispatchReadMapper.Evidence evidence(long containerId, long loadingPlanId) {
return reads.evidence(containerId, loadingPlanId);
@@ -151,7 +151,7 @@ public class OutboundDispatchService {
OutboundDispatchReadMapper.Binding first = validateContainer(bindings);
var pick = repository.pick(first.loadingPlanId());
var ocean = repository.ocean(first.containerBookingId());
var truck = repository.truck(first.containerBookingId(), first.loadingPlanId());
var truck = repository.truck(first.containerBookingId());
var evidence = repository.evidence(first.containerBookingId(), first.loadingPlanId());
List<GateItem> containerContributorGates = contributorGates(first, ocean);
List<InboundGate> inboundGates = new ArrayList<>();
@@ -1,8 +1,8 @@
package com.baiyue.firstleg.repository;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page; import com.baiyue.common.core.api.PageResult; import com.baiyue.firstleg.dto.TruckAppointmentDtos.*; import com.baiyue.firstleg.entity.*; import com.baiyue.firstleg.mapper.*; import jakarta.annotation.Resource; import org.springframework.stereotype.Repository;
@Repository public class TruckAppointmentRepository { @Resource TruckAppointmentMapper mapper; @Resource TruckAppointmentHistoryMapper history; @Resource TruckAppointmentAttachmentMapper attachments; @Resource TruckAppointmentOperationMapper operations;
public PageResult<TruckAppointmentEntity> page(Query q){return PageResult.from(mapper.page(new Page<>(q.page(),q.size()),q));} public PageResult<Candidate> candidates(CandidateQuery q){return PageResult.from(mapper.candidates(new Page<>(q.page(),q.size()),q));} public TruckAppointmentEntity find(long id){return mapper.selectById(id);} public void insert(TruckAppointmentEntity e){mapper.insert(e);} public int transition(long id,String from,String to,int v){return mapper.transition(id,from,to,v);} public int dispatch(long id,String from,String billOfLadingNo,int v){return mapper.dispatch(id,from,billOfLadingNo,v);} public int bindContainerNoForPickup(long containerBookingId,String containerNo,long operatorId,String operatorName){return mapper.bindContainerNoForPickup(containerBookingId,containerNo,operatorId,operatorName);} public int reschedule(long id,java.time.LocalDateTime at,String reason,int v){return mapper.reschedule(id,at,reason,v);} public void history(TruckAppointmentHistoryEntity e){history.insert(e);} public void operation(TruckAppointmentOperationEntity e){operations.insert(e);} public void attachment(TruckAppointmentAttachmentEntity e){attachments.insert(e);}
public PageResult<TruckAppointmentEntity> page(Query q){return PageResult.from(mapper.page(new Page<>(q.page(),q.size()),q));} public PageResult<Candidate> candidates(CandidateQuery q){return PageResult.from(mapper.candidates(new Page<>(q.page(),q.size()),q));} public TruckAppointmentEntity find(long id){return mapper.selectById(id);} public void insert(TruckAppointmentEntity e){mapper.insert(e);} public int transition(long id,String from,String to,int v){return mapper.transition(id,from,to,v);} public int dispatch(long id,String from,String billOfLadingNo,int v){return mapper.dispatch(id,from,billOfLadingNo,v);} public int reschedule(long id,java.time.LocalDateTime at,String reason,int v){return mapper.reschedule(id,at,reason,v);} public void history(TruckAppointmentHistoryEntity e){history.insert(e);} public void operation(TruckAppointmentOperationEntity e){operations.insert(e);} public void attachment(TruckAppointmentAttachmentEntity e){attachments.insert(e);}
public boolean claimOperation(long operationId,long appointmentId,String action,String key,String hash){return operations.claim(operationId,appointmentId,action,key,hash)==1;} public TruckAppointmentOperationEntity operation(String key){return operations.find(key);} public int completeOperation(long appointmentId,String action,String key,String response){return operations.complete(appointmentId,action,key,response);}
public int updateEdit(TruckAppointmentEntity e,int v){return mapper.updateEdit(e,v);} public java.util.List<TruckAppointmentHistoryEntity> histories(long id){return history.selectList(new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper<TruckAppointmentHistoryEntity>().eq(TruckAppointmentHistoryEntity::getAppointmentId,id).orderByAsc(TruckAppointmentHistoryEntity::getCreatedAt));} public java.util.List<TruckAppointmentAttachmentEntity> attachments(long id){return attachments.selectList(new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper<TruckAppointmentAttachmentEntity>().eq(TruckAppointmentAttachmentEntity::getAppointmentId,id).orderByAsc(TruckAppointmentAttachmentEntity::getUploadedAt));} public int deleteAttachment(long appointmentId,long attachmentId){return attachments.delete(new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper<TruckAppointmentAttachmentEntity>().eq(TruckAppointmentAttachmentEntity::getAppointmentId,appointmentId).eq(TruckAppointmentAttachmentEntity::getId,attachmentId));}
public TruckAppointmentEntity firstActiveForUpdate(long containerBookingId){return mapper.firstActiveForUpdate(containerBookingId);} public java.util.List<TruckAppointmentEntity> activeForUpdate(long containerBookingId){return mapper.activeForUpdate(containerBookingId);} public int completeLoadingFromPda(long id,String fromStatus,int version){return mapper.completeLoadingFromPda(id,fromStatus,version);}
public TruckAppointmentEntity firstActiveForUpdate(long oceanBookingId){return mapper.firstActiveForUpdate(oceanBookingId);}
}
@@ -54,8 +54,6 @@ import feign.FeignException;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.dao.DuplicateKeyException;
import org.springframework.stereotype.Service;
import org.springframework.transaction.support.TransactionSynchronization;
import org.springframework.transaction.support.TransactionSynchronizationManager;
import org.springframework.transaction.support.TransactionTemplate;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.util.StringUtils;
@@ -105,8 +103,6 @@ public class LoadingPickService {
@Resource
private ColoadGroupAssignmentPort coloadGroups;
@Resource
private TruckAppointmentService truckAppointmentService;
@Resource
private InboundStatusHistoryMapper inboundHistories;
@Resource
private PdaPickSettlementService settlementService;
@@ -581,7 +577,6 @@ public class LoadingPickService {
}
}
completionTransaction.executeWithoutResult(ignored -> finalizeCompletion(command, worker, task, taskGroups));
completeTruckAfterCommit(task.getContainerBookingId(), taskId, command.getOperatorId(), command.getOperatorName());
}
/** Final Firstleg convergence only; all remote owner settlement calls have completed before this short transaction. */
@@ -701,27 +696,6 @@ public class LoadingPickService {
}
}
private void completeTruckAfterCommit(long containerBookingId, long taskId,
long operatorId, String operatorName) {
Runnable completion = () -> {
try {
truckAppointmentService.completeLoadingFromPda(containerBookingId, taskId,
operatorId, operatorName);
} catch (RuntimeException exception) {
log.warn("PDA loading completed but linked truck appointment was not auto-completed: "
+ "containerBookingId={}, taskId={}, reason={}", containerBookingId, taskId,
exception.getMessage());
}
};
if (TransactionSynchronizationManager.isSynchronizationActive()) {
TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() {
@Override public void afterCommit() { completion.run(); }
});
} else {
completion.run();
}
}
public List<Operation> operations(long taskId) {
requiredTask(taskId);
return repository.operations(taskId).stream().map(item -> new Operation(item.getId(), item.getPickTaskId(),
@@ -5,22 +5,18 @@ import com.baiyue.common.core.api.PageResult;
import com.baiyue.common.core.api.ResultCode;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.firstleg.domain.TruckAppointmentStatus;
import com.baiyue.firstleg.domain.Iso6346ContainerNumber;
import com.baiyue.firstleg.dto.TruckAppointmentDtos.*;
import com.baiyue.firstleg.entity.*;
import com.baiyue.firstleg.oceanbooking.entity.OceanBookingEntity;
import com.baiyue.firstleg.oceanbooking.repository.OceanBookingRepository;
import com.baiyue.firstleg.oceanbooking.service.OceanBookingDocumentService;
import com.baiyue.firstleg.repository.TruckAppointmentRepository;
import com.baiyue.firstleg.repository.SmartLoadingRepository;
import com.baiyue.firstleg.repository.ContainerRepository;
import com.baiyue.firstleg.client.TruckAppointmentFileClient;
import jakarta.annotation.Resource;
import feign.FeignException;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.transaction.annotation.Propagation;
import org.springframework.dao.DuplicateKeyException;
import org.springframework.util.StringUtils;
import com.fasterxml.jackson.core.JsonProcessingException;
@@ -40,46 +36,35 @@ public class TruckAppointmentService {
@Resource TruckAppointmentRepository repo;
@Resource OceanBookingRepository oceanBookings;
@Resource OceanBookingDocumentService oceanBookingDocuments;
@Resource SmartLoadingRepository loadingPlans;
@Resource ContainerRepository containers;
@Resource TruckAppointmentFileClient files;
@Resource WarehouseSupplierSnapshotService suppliers;
@Resource FirstlegDocumentNumberGenerator documentNumbers;
@Resource ObjectMapper objectMapper;
@Value("${baiyue.security.internal-service-token}") String internalToken;
public PageResult<TruckAppointmentEntity> page(Query q){return repo.page(q);} public PageResult<Candidate> candidates(CandidateQuery q){return repo.candidates(q);} public TruckAppointmentEntity detail(long id){return enrich(required(id));}
public PageResult<TruckAppointmentEntity> page(Query q){PageResult<TruckAppointmentEntity> page=repo.page(q);return new PageResult<>(page.records().stream().map(this::enrich).toList(),page.page(),page.size(),page.total(),page.pages());} public PageResult<Candidate> candidates(CandidateQuery q){return repo.candidates(q);} public TruckAppointmentEntity detail(long id){return enrich(required(id));}
public java.util.List<TruckAppointmentHistoryEntity> history(long id){required(id);return repo.histories(id);} public java.util.List<TruckAppointmentAttachmentEntity> attachments(long id){required(id);return repo.attachments(id);}
@Transactional public TruckAppointmentAttachmentEntity upload(long id,String type,MultipartFile file,long uid){String attachmentType=attachmentType(type);required(id);try{var response=files.upload(internalToken,uid,0L,"TRUCK_APPOINTMENT_ATTACHMENT",Long.toString(id),file);if(response==null||!response.successful()||response.data()==null)throw bad("附件上传失败");var m=response.data();TruckAppointmentAttachmentEntity a=new TruckAppointmentAttachmentEntity();a.setId(IdWorker.getId());a.setAppointmentId(id);a.setFileId(m.fileId());a.setAttachmentType(attachmentType);a.setFileName(m.fileName());a.setFileHash(m.sha256());a.setVisibility("PLATFORM");a.setUploadedBy(uid);a.setUploadedAt(LocalDateTime.now());repo.attachment(a);return a;}catch(FeignException ex){throw new BizException(ResultCode.SERVICE_UNAVAILABLE,"文件服务暂不可用");}}
@Transactional public TruckAppointmentAttachmentEntity attach(long id,AttachmentRequest r,long uid){String attachmentType=attachmentType(r.attachmentType());required(id);TruckAppointmentFileClient.Metadata metadata;try{var response=files.metadata(internalToken,0L,"TRUCK_APPOINTMENT_ATTACHMENT",Long.toString(id),r.fileId());if(response==null||!response.successful()||response.data()==null)throw bad("附件不存在或归属不匹配");metadata=response.data();}catch(FeignException ex){throw new BizException(ResultCode.SERVICE_UNAVAILABLE,"文件服务暂不可用");} TruckAppointmentAttachmentEntity a=new TruckAppointmentAttachmentEntity();a.setId(IdWorker.getId());a.setAppointmentId(id);a.setFileId(metadata.fileId());a.setAttachmentType(attachmentType);a.setFileName(metadata.fileName());a.setFileHash(metadata.sha256());a.setVisibility("PLATFORM");a.setUploadedBy(uid);a.setUploadedAt(LocalDateTime.now());repo.attachment(a);return a;}
@Transactional public void deleteAttachment(long id,long attachmentId){required(id);if(repo.deleteAttachment(id,attachmentId)!=1)throw new BizException(ResultCode.NOT_FOUND,"附件引用不存在");}
@Transactional public TruckAppointmentEntity create(long uid,String name,SaveRequest r){if(r.expectedVersion()==null||r.expectedVersion()!=0)throw bad("新增 expectedVersion 必须为0");validateRequired(r);TruckAppointmentEntity active=repo.firstActiveForUpdate(r.containerBookingId());if(active!=null)throw existingAppointmentConflict(active);AssociationContext links=validateAssociations(r);var carrier=suppliers.trucking(r.carrierId(),r.carrierName());TruckAppointmentEntity e=new TruckAppointmentEntity();e.setId(IdWorker.getId());e.setAppointmentNo(documentNumbers.next("TA"));e.setStatus(TruckAppointmentStatus.DRAFT);e.setRevision(0);e.setVersion(0);e.setCreatedAt(LocalDateTime.now());e.setUpdatedAt(e.getCreatedAt());copy(e,r,links,carrier.name());try{repo.insert(e);}catch(DuplicateKeyException exception){if(exception.getMessage()!=null&&exception.getMessage().contains("uk_truck_active_container"))throw existingAppointmentConflict(repo.firstActiveForUpdate(r.containerBookingId()));throw exception;}record(e,null,e.getStatus().name(),"CREATE",null,uid,name);return enrich(e);}
@Transactional public TruckAppointmentEntity edit(long id,long uid,String name,SaveRequest r){validateRequired(r);TruckAppointmentEntity e=required(id);checkVersion(e,r.expectedVersion());if(!(e.getStatus()==TruckAppointmentStatus.DRAFT||e.getStatus()==TruckAppointmentStatus.PENDING_DISPATCH||e.getStatus()==TruckAppointmentStatus.RESCHEDULED))throw conflict("当前状态不可编辑");AssociationContext links=validateAssociations(r);var carrier=suppliers.trucking(r.carrierId(),r.carrierName());copy(e,r,links,carrier.name());if(repo.updateEdit(e,r.expectedVersion())!=1)throw conflict("版本已变化,请刷新后重试");record(e,e.getStatus().name(),e.getStatus().name(),"EDIT",null,uid,name);return required(id);}
@Transactional public TruckAppointmentEntity action(long id,String action,ActionRequest r,String idem,String clientHash,long uid,String name){String requestHash=hash(action,id,r);verifyClientHash(clientHash,requestHash);TruckAppointmentEntity replay=claimOrReplay(id,action,idem,requestHash);if(replay!=null)return replay;TruckAppointmentEntity e=required(id);checkVersion(e,r.expectedVersion());if("RESCHEDULE".equals(action))throw conflict("改约请使用专用接口");if(!"PICKUP".equals(action)&&StringUtils.hasText(r.containerNo()))throw bad("containerNo仅PICKUP动作可填写");TruckAppointmentStatus from=e.getStatus(),to=next(from,action);String dispatchBill="DISPATCH".equals(action)?requireDispatchReady(e):null;if("PICKUP".equals(action))bindContainerNoForPickup(e,r.containerNo(),uid,name);int changed="DISPATCH".equals(action)?repo.dispatch(id,from.name(),dispatchBill,e.getVersion()):repo.transition(id,from.name(),to.name(),e.getVersion());if(changed!=1)throw conflict("状态或版本已变化,请刷新后重试");TruckAppointmentEntity after=required(id);record(after,from.name(),to.name(),action,r.reason(),uid,name);return completeOperation(id,action,idem,after);}
@Transactional public TruckAppointmentEntity reschedule(long id,LocalDateTime at,ActionRequest r,String idem,String clientHash,long uid,String name){if(StringUtils.hasText(r.containerNo()))throw bad("containerNo仅PICKUP动作可填写");String action="RESCHEDULE";String requestHash=hash(action,id,new ReschedulePayload(at,r));verifyClientHash(clientHash,requestHash);TruckAppointmentEntity replay=claimOrReplay(id,action,idem,requestHash);if(replay!=null)return replay;TruckAppointmentEntity e=required(id);checkVersion(e,r.expectedVersion()); if(e.getStatus()==TruckAppointmentStatus.COMPLETED||e.getStatus()==TruckAppointmentStatus.CANCELLED)throw conflict("当前状态不可改约"); String from=e.getStatus().name(); if(repo.reschedule(id,at,r.reason(),e.getVersion())!=1)throw conflict("状态或版本已变化,请刷新后重试");TruckAppointmentEntity after=required(id);record(after,from,"RESCHEDULED",action,r.reason(),uid,name);return completeOperation(id,action,idem,after);}
@Transactional(propagation=Propagation.REQUIRES_NEW) public void completeLoadingFromPda(long containerBookingId,long taskId,long uid,String name){java.util.List<TruckAppointmentEntity> active=repo.activeForUpdate(containerBookingId);if(active.isEmpty())return;if(active.size()!=1)throw conflict("同一柜次存在多条有效拖车预约,跳过自动完成");TruckAppointmentEntity appointment=active.get(0);String from=appointment.getStatus().name();if(repo.completeLoadingFromPda(appointment.getId(),from,appointment.getVersion())!=1)throw conflict("拖车预约已被并发修改,跳过自动完成");TruckAppointmentEntity after=required(appointment.getId());record(after,from,TruckAppointmentStatus.COMPLETED.name(),"PDA_COMPLETE_LOADING","PDA task "+taskId+" 确认装柜完成",uid,name);}
@Transactional public TruckAppointmentEntity create(long uid,String name,SaveRequest r){if(r.expectedVersion()==null||r.expectedVersion()!=0)throw bad("新增 expectedVersion 必须为0");validateRequired(r);TruckAppointmentEntity active=repo.firstActiveForUpdate(r.oceanBookingId());if(active!=null)throw existingAppointmentConflict(active);AssociationContext links=validateAssociations(r);var carrier=suppliers.trucking(r.carrierId(),r.carrierName());TruckAppointmentEntity e=new TruckAppointmentEntity();e.setId(IdWorker.getId());e.setAppointmentNo(documentNumbers.next("TA"));e.setStatus(TruckAppointmentStatus.DRAFT);e.setRevision(0);e.setVersion(0);e.setCreatedAt(LocalDateTime.now());e.setUpdatedAt(e.getCreatedAt());copy(e,r,links,carrier.name());try{repo.insert(e);}catch(DuplicateKeyException exception){if(exception.getMessage()!=null&&exception.getMessage().contains("uk_truck_active_ocean_booking"))throw existingAppointmentConflict(repo.firstActiveForUpdate(r.oceanBookingId()));throw exception;}record(e,null,e.getStatus().name(),"CREATE",null,uid,name);return enrich(e);}
@Transactional public TruckAppointmentEntity edit(long id,long uid,String name,SaveRequest r){validateRequired(r);TruckAppointmentEntity e=required(id);checkVersion(e,r.expectedVersion());if(!(e.getStatus()==TruckAppointmentStatus.DRAFT||e.getStatus()==TruckAppointmentStatus.PENDING_DISPATCH||e.getStatus()==TruckAppointmentStatus.RESCHEDULED))throw conflict("当前状态不可编辑");TruckAppointmentEntity active=repo.firstActiveForUpdate(r.oceanBookingId());if(active!=null&&!Objects.equals(active.getId(),e.getId()))throw existingAppointmentConflict(active);AssociationContext links=validateAssociations(r);var carrier=suppliers.trucking(r.carrierId(),r.carrierName());copy(e,r,links,carrier.name());try{if(repo.updateEdit(e,r.expectedVersion())!=1)throw conflict("版本已变化,请刷新后重试");}catch(DuplicateKeyException exception){if(exception.getMessage()!=null&&exception.getMessage().contains("uk_truck_active_ocean_booking"))throw existingAppointmentConflict(repo.firstActiveForUpdate(r.oceanBookingId()));throw exception;}record(e,e.getStatus().name(),e.getStatus().name(),"EDIT",null,uid,name);return enrich(required(id));}
@Transactional public TruckAppointmentEntity action(long id,String action,ActionRequest r,String idem,String clientHash,long uid,String name){String requestHash=hash(action,id,r);verifyClientHash(clientHash,requestHash);TruckAppointmentEntity replay=claimOrReplay(id,action,idem,requestHash);if(replay!=null)return replay;TruckAppointmentEntity e=required(id);checkVersion(e,r.expectedVersion());if("RESCHEDULE".equals(action))throw conflict("改约请使用专用接口");TruckAppointmentStatus from=e.getStatus(),to=next(from,action);String dispatchBill="DISPATCH".equals(action)?requireDispatchReady(e):null;int changed="DISPATCH".equals(action)?repo.dispatch(id,from.name(),dispatchBill,e.getVersion()):repo.transition(id,from.name(),to.name(),e.getVersion());if(changed!=1)throw conflict("状态或版本已变化,请刷新后重试");TruckAppointmentEntity after=enrich(required(id));record(after,from.name(),to.name(),action,r.reason(),uid,name);return completeOperation(id,action,idem,after);}
@Transactional public TruckAppointmentEntity reschedule(long id,LocalDateTime at,ActionRequest r,String idem,String clientHash,long uid,String name){String action="RESCHEDULE";String requestHash=hash(action,id,new ReschedulePayload(at,r));verifyClientHash(clientHash,requestHash);TruckAppointmentEntity replay=claimOrReplay(id,action,idem,requestHash);if(replay!=null)return replay;TruckAppointmentEntity e=required(id);checkVersion(e,r.expectedVersion()); if(e.getStatus()==TruckAppointmentStatus.COMPLETED||e.getStatus()==TruckAppointmentStatus.CANCELLED)throw conflict("当前状态不可改约"); String from=e.getStatus().name(); if(repo.reschedule(id,at,r.reason(),e.getVersion())!=1)throw conflict("状态或版本已变化,请刷新后重试");TruckAppointmentEntity after=enrich(required(id));record(after,from,"RESCHEDULED",action,r.reason(),uid,name);return completeOperation(id,action,idem,after);}
private AssociationContext validateAssociations(SaveRequest r){
OceanBookingEntity booking=oceanBookings.find(r.oceanBookingId());
if(booking==null)throw new BizException(ResultCode.NOT_FOUND,"关联订舱不存在");
if(!"RELEASED".equals(booking.getStatus()))throw conflict("拖车预约只能选择已放舱订舱");
Long warehouseId=mergeWarehouse(r.warehouseId(),booking.getWarehouseId(),"订舱");
String billOfLadingNo=releasedBookingBill(booking,trim(r.billOfLadingNo()));
ContainerBookingEntity container=containers.find(r.containerBookingId());
if(container==null||!"ACTIVE".equals(container.getRecordStatus()))throw new BizException(ResultCode.NOT_FOUND,"关联有效柜次不存在");
warehouseId=mergeWarehouse(warehouseId,container.getWarehouseId(),"柜次");
if(!Objects.equals(container.getOceanBookingId(),booking.getId()))throw bad("柜次未关联至所选订舱");
long containerId=container.getId();
boolean allocated=oceanBookings.allocations(booking.getId()).stream().anyMatch(a->Objects.equals(a.getContainerBookingId(),containerId));
if(!allocated)throw bad("containerBookingId未分配至该订舱");
oceanBookingDocuments.requireActiveValidDocument(booking);
LoadingPlanEntity plan=loadingPlans.planByBooking(container.getId());if(plan!=null&&"COMPLETED".equals(plan.getPlanStatus()))throw conflict("关联柜次已装柜完成,不能新建或修改拖车预约");if(r.loadingPlanId()!=null&&(plan==null||!Objects.equals(r.loadingPlanId(),plan.getId())))throw bad("loadingPlanId与柜次当前装柜计划不一致");
return new AssociationContext(booking,container,warehouseId,billOfLadingNo);
return new AssociationContext(booking,warehouseId,billOfLadingNo);
}
private static void validateRequired(SaveRequest r){if(r.oceanBookingId()==null||r.oceanBookingId()<=0)throw bad("oceanBookingId不能为空");if(r.containerBookingId()==null||r.containerBookingId()<=0)throw bad("containerBookingId不能为空");if(r.carrierId()==null||r.carrierId()<=0)throw bad("carrierId不能为空");if(r.plannedLoadingAt()==null)throw bad("plannedLoadingAt不能为空");if(r.expectedVersion()==null||r.expectedVersion()<0)throw bad("expectedVersion不合法");}
private static void validateRequired(SaveRequest r){if(r.oceanBookingId()==null||r.oceanBookingId()<=0)throw bad("oceanBookingId不能为空");if(r.carrierId()==null||r.carrierId()<=0)throw bad("carrierId不能为空");if(r.plannedLoadingAt()==null)throw bad("plannedLoadingAt不能为空");if(r.expectedVersion()==null||r.expectedVersion()<0)throw bad("expectedVersion不合法");}
private static Long mergeWarehouse(Long requested,Long linked,String source){if(requested!=null&&linked!=null&&!Objects.equals(requested,linked))throw bad("warehouseId与"+source+"不一致");return requested!=null?requested:linked;}
private TruckAppointmentStatus next(TruckAppointmentStatus s,String a){return switch(a){case"SUBMIT"->s==TruckAppointmentStatus.DRAFT?TruckAppointmentStatus.PENDING_DISPATCH:badStatus();case"DISPATCH"->s==TruckAppointmentStatus.PENDING_DISPATCH||s==TruckAppointmentStatus.RESCHEDULED?TruckAppointmentStatus.DISPATCHED:badStatus();case"PICKUP"->s==TruckAppointmentStatus.DISPATCHED?TruckAppointmentStatus.PICKUP_IN_PROGRESS:badStatus();case"ARRIVE"->s==TruckAppointmentStatus.PICKUP_IN_PROGRESS?TruckAppointmentStatus.ARRIVED_WAREHOUSE:badStatus();case"LOAD"->s==TruckAppointmentStatus.ARRIVED_WAREHOUSE?TruckAppointmentStatus.LOADING:badStatus();case"DEPART"->s==TruckAppointmentStatus.LOADING?TruckAppointmentStatus.DEPARTED:badStatus();case"COMPLETE"->s==TruckAppointmentStatus.DEPARTED?TruckAppointmentStatus.COMPLETED:badStatus();case"EXCEPTION"->s!=TruckAppointmentStatus.COMPLETED&&s!=TruckAppointmentStatus.CANCELLED?TruckAppointmentStatus.EXCEPTION:badStatus();case"CANCEL"->s!=TruckAppointmentStatus.COMPLETED?TruckAppointmentStatus.CANCELLED:badStatus();default->badStatus();};}
private TruckAppointmentStatus badStatus(){throw conflict("不允许的状态动作");} private TruckAppointmentEntity required(long id){TruckAppointmentEntity e=repo.find(id);if(e==null)throw new BizException(ResultCode.NOT_FOUND,"拖车预约不存在");return e;} private void checkVersion(TruckAppointmentEntity e,int v){if(e.getVersion()!=v)throw conflict("版本已变化,请刷新后重试");} private static BizException conflict(String m){return new BizException(ResultCode.CONFLICT,m);} private static BizException bad(String m){return new BizException(ResultCode.BAD_REQUEST,m);}
private TruckAppointmentEntity enrich(TruckAppointmentEntity value){if(value.getOceanBookingId()!=null){OceanBookingEntity booking=oceanBookings.find(value.getOceanBookingId());if(booking!=null){value.setOceanBookingNo(booking.getBookingNo());value.setWarehouseName(booking.getWarehouseName());value.setShippingCompanyId(booking.getShippingCompanyId());value.setShippingCompanyName(booking.getShippingCompanyName());value.setBookingAgentId(booking.getBookingAgentId());value.setBookingAgentName(booking.getBookingAgentName());}}if(value.getContainerBookingId()!=null){ContainerBookingEntity container=containers.find(value.getContainerBookingId());if(container!=null){value.setCabinetNo(container.getCabinetNo());value.setContainerNo(container.getContainerNo());if(!StringUtils.hasText(value.getWarehouseName()))value.setWarehouseName(container.getWarehouseName());}}value.setAttachmentCount((long)repo.attachments(value.getId()).size());return value;}
private void copy(TruckAppointmentEntity e,SaveRequest r,AssociationContext links,String carrierName){e.setOutboundOrderId(r.outboundOrderId());e.setOceanBookingId(r.oceanBookingId());e.setContainerBookingId(r.containerBookingId());e.setLoadingPlanId(r.loadingPlanId());e.setWarehouseId(links.warehouseId());e.setCarrierId(r.carrierId());e.setCarrierName(carrierName);e.setBillOfLadingNo(links.billOfLadingNo());e.setFleetContact(r.fleetContact());e.setFleetPhone(r.fleetPhone());e.setDriverName(r.driverName());e.setDriverPhone(r.driverPhone());e.setDriverIdCard(r.driverIdCard());e.setVehicleNo(r.vehicleNo());e.setStoreName(r.storeName());e.setStoreContact(r.storeContact());e.setStorePhone(r.storePhone());e.setStoreAddress(r.storeAddress());e.setStoreCity(r.storeCity());e.setStoreZipcode(r.storeZipcode());e.setPackageCount(r.packageCount());e.setGrossWeightKg(r.grossWeightKg());e.setVolumeCbm(r.volumeCbm());e.setPalletCount(r.palletCount());e.setPoNo(r.poNo());e.setPlannedLoadingAt(r.plannedLoadingAt());e.setTransitMinutes(r.transitMinutes());e.setPayableAmount(r.payableAmount());e.setCurrency(currency(r.currency()));e.setRemark(r.remark());}
private TruckAppointmentEntity enrich(TruckAppointmentEntity value){if(value.getOceanBookingId()!=null){OceanBookingEntity booking=oceanBookings.find(value.getOceanBookingId());if(booking!=null){value.setOceanBookingNo(booking.getBookingNo());value.setWarehouseName(booking.getWarehouseName());value.setShippingCompanyId(booking.getShippingCompanyId());value.setShippingCompanyName(booking.getShippingCompanyName());value.setBookingAgentId(booking.getBookingAgentId());value.setBookingAgentName(booking.getBookingAgentName());}}value.setAttachmentCount((long)repo.attachments(value.getId()).size());return value;}
private void copy(TruckAppointmentEntity e,SaveRequest r,AssociationContext links,String carrierName){e.setOutboundOrderId(r.outboundOrderId());e.setOceanBookingId(r.oceanBookingId());e.setContainerBookingId(null);e.setLoadingPlanId(null);e.setWarehouseId(links.warehouseId());e.setCarrierId(r.carrierId());e.setCarrierName(carrierName);e.setBillOfLadingNo(links.billOfLadingNo());e.setFleetContact(r.fleetContact());e.setFleetPhone(r.fleetPhone());e.setDriverName(r.driverName());e.setDriverPhone(r.driverPhone());e.setDriverIdCard(r.driverIdCard());e.setVehicleNo(r.vehicleNo());e.setStoreName(r.storeName());e.setStoreContact(r.storeContact());e.setStorePhone(r.storePhone());e.setStoreAddress(r.storeAddress());e.setStoreCity(r.storeCity());e.setStoreZipcode(r.storeZipcode());e.setPackageCount(r.packageCount());e.setGrossWeightKg(r.grossWeightKg());e.setVolumeCbm(r.volumeCbm());e.setPalletCount(r.palletCount());e.setPoNo(r.poNo());e.setPlannedLoadingAt(r.plannedLoadingAt());e.setTransitMinutes(r.transitMinutes());e.setPayableAmount(r.payableAmount());e.setCurrency(currency(r.currency()));e.setRemark(r.remark());}
private TruckAppointmentEntity claimOrReplay(long id,String action,String key,String hash){validateKey(key);String normalized=key.trim();if(repo.claimOperation(IdWorker.getId(),id,action,normalized,hash))return null;TruckAppointmentOperationEntity existing=repo.operation(normalized);if(existing==null||!Objects.equals(existing.getAppointmentId(),id)||!action.equals(existing.getAction())||!hash.equals(existing.getRequestHash()))throw conflict("Idempotency-Key已用于不同请求");if(!StringUtils.hasText(existing.getResponseSnapshot()))throw conflict("相同请求正在处理中,请稍后重试");try{return objectMapper.readValue(existing.getResponseSnapshot(),TruckAppointmentEntity.class);}catch(JsonProcessingException ex){throw new IllegalStateException("无法读取拖车预约幂等结果",ex);}}
private TruckAppointmentEntity completeOperation(long id,String action,String key,TruckAppointmentEntity result){try{String normalized=key.trim();if(repo.completeOperation(id,action,normalized,objectMapper.writeValueAsString(result))!=1)throw conflict("拖车预约幂等请求完成状态冲突");return result;}catch(JsonProcessingException ex){throw new IllegalStateException("无法保存拖车预约幂等结果",ex);}}
private String hash(String action,long id,Object request){try{return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest((action+'|'+id+'|'+objectMapper.writeValueAsString(request)).getBytes(StandardCharsets.UTF_8)));}catch(Exception ex){throw new IllegalStateException("无法计算拖车预约请求摘要",ex);}}
@@ -87,24 +72,19 @@ public class TruckAppointmentService {
private static void validateKey(String key){if(!StringUtils.hasText(key)||key.trim().length()>128)throw bad("Idempotency-Key不能为空且不能超过128个字符");}
private record ReschedulePayload(LocalDateTime plannedLoadingAt,ActionRequest request){}
private String requireDispatchReady(TruckAppointmentEntity appointment){
if(appointment.getOceanBookingId()==null||appointment.getContainerBookingId()==null)throw conflict("拖车预约缺少订舱或柜次关联");
if(appointment.getOceanBookingId()==null)throw conflict("拖车预约缺少订舱关联");
OceanBookingEntity booking=oceanBookings.find(appointment.getOceanBookingId());
if(booking==null)throw new BizException(ResultCode.NOT_FOUND,"关联订舱不存在");
if(!"RELEASED".equals(booking.getStatus()))throw conflict("关联订舱尚未放舱");
String bill=releasedBookingBill(booking,trim(appointment.getBillOfLadingNo()));
ContainerBookingEntity container=containers.find(appointment.getContainerBookingId());
if(container==null||!"ACTIVE".equals(container.getRecordStatus())||!Objects.equals(container.getOceanBookingId(),booking.getId()))throw conflict("关联柜次已失效或不属于当前订舱");
boolean allocated=oceanBookings.allocations(booking.getId()).stream().anyMatch(a->Objects.equals(a.getContainerBookingId(),container.getId()));
if(!allocated)throw conflict("关联柜次未分配至当前订舱");
oceanBookingDocuments.requireActiveValidDocument(booking);
return bill;
}
private void bindContainerNoForPickup(TruckAppointmentEntity appointment,String requested,long operatorId,String operatorName){if(appointment.getContainerBookingId()==null)throw conflict("拖车预约缺少柜次关联");ContainerBookingEntity container=containers.findActiveForUpdate(appointment.getContainerBookingId());if(container==null||!Objects.equals(container.getOceanBookingId(),appointment.getOceanBookingId()))throw conflict("关联柜次已失效或不属于当前订舱");String existing=trim(container.getContainerNo());String supplied=trim(requested);if(StringUtils.hasText(existing)){String normalizedExisting=Iso6346ContainerNumber.require(existing);if(StringUtils.hasText(supplied)&&!normalizedExisting.equals(Iso6346ContainerNumber.require(supplied)))throw conflict("PICKUP提交的真实集装箱号与柜次已有号码不一致,请通过预装纠错修改");return;}if(!StringUtils.hasText(supplied))return;String normalized=Iso6346ContainerNumber.require(supplied);if(containers.containerNoExists(normalized,container.getId()))throw conflict("真实集装箱号已被其他柜次使用");try{if(repo.bindContainerNoForPickup(container.getId(),normalized,operatorId,operatorName)!=1)throw conflict("柜次真实集装箱号已被并发修改,请刷新后重试");}catch(DuplicateKeyException exception){throw conflict("真实集装箱号已被其他柜次使用");}}
private static String releasedBookingBill(OceanBookingEntity booking,String requested){String authoritative=trim(booking.getBillOfLadingNo());if(!StringUtils.hasText(authoritative))throw conflict("关联已放舱订舱缺少提单号");if(StringUtils.hasText(requested)&&!authoritative.equals(requested))throw bad("billOfLadingNo与关联订舱不一致");return authoritative;}
private static String attachmentType(String value){String normalized=trim(value);if(!ATTACHMENT_TYPES.contains(normalized))throw bad("不支持的拖车附件类型");return normalized;}
private static String currency(String value){if(!StringUtils.hasText(value))return "RMB";String normalized=value.trim();if(!CURRENCIES.contains(normalized))throw bad("currency仅支持RMB或USD");return normalized;}
private static BizException existingAppointmentConflict(TruckAppointmentEntity existing){String appointmentNo=existing==null?null:trim(existing.getAppointmentNo());return conflict(StringUtils.hasText(appointmentNo)?"该柜已存在拖车预约 "+appointmentNo+",请继续编辑":"该柜已存在拖车预约,请刷新后继续编辑");}
private static BizException existingAppointmentConflict(TruckAppointmentEntity existing){String appointmentNo=existing==null?null:trim(existing.getAppointmentNo());return conflict(StringUtils.hasText(appointmentNo)?"该订舱已存在拖车预约 "+appointmentNo+",请继续编辑":"该订舱已存在拖车预约,请刷新后继续编辑");}
private static String trim(String value){return StringUtils.hasText(value)?value.trim():null;}
private record AssociationContext(OceanBookingEntity booking,ContainerBookingEntity container,Long warehouseId,String billOfLadingNo){}
private record AssociationContext(OceanBookingEntity booking,Long warehouseId,String billOfLadingNo){}
private void record(TruckAppointmentEntity e,String from,String to,String action,String reason,long uid,String name){TruckAppointmentHistoryEntity h=new TruckAppointmentHistoryEntity();h.setId(IdWorker.getId());h.setAppointmentId(e.getId());h.setRevision(e.getRevision());h.setAction(action);h.setFromStatus(from);h.setToStatus(to);h.setReason(reason);h.setOperatorId(uid);h.setOperatorName(name);h.setCreatedAt(LocalDateTime.now());repo.history(h);}
}
@@ -4,8 +4,8 @@ CREATE TABLE IF NOT EXISTS fl_truck_appointment (
appointment_no VARCHAR(40) NOT NULL COMMENT '平台拖车预约编号',
outbound_order_id BIGINT UNSIGNED NULL COMMENT '关联头程订单逻辑ID;不跨库建外键',
ocean_booking_id BIGINT UNSIGNED NULL COMMENT '关联海运订舱逻辑ID',
container_booking_id BIGINT UNSIGNED NULL COMMENT '关联柜次逻辑ID;拖车完成供装柜门禁校验',
loading_plan_id BIGINT UNSIGNED NULL COMMENT '关联装柜计划逻辑ID',
container_booking_id BIGINT UNSIGNED NULL COMMENT '停用保留列;订舱级拖车单不写入、不读取',
loading_plan_id BIGINT UNSIGNED NULL COMMENT '停用保留列;订舱级拖车单不写入、不读取',
warehouse_id BIGINT UNSIGNED NULL COMMENT '预约到仓的国内收货仓逻辑ID',
carrier_id BIGINT UNSIGNED NULL COMMENT '拖车服务商逻辑ID',
carrier_name VARCHAR(128) NULL COMMENT '拖车服务商名称快照',
@@ -35,14 +35,14 @@ CREATE TABLE IF NOT EXISTS fl_truck_appointment (
payable_amount DECIMAL(18,2) NULL COMMENT '应付拖车服务商金额;币种由currency指定',
currency VARCHAR(8) NOT NULL DEFAULT 'RMB' COMMENT '应付金额币种;仅RMB或USD',
status VARCHAR(32) NOT NULL DEFAULT 'DRAFT' COMMENT '拖车预约状态:DRAFT草稿、PENDING_DISPATCH待派车、DISPATCHED已派车、PICKUP_IN_PROGRESS提货中、ARRIVED_WAREHOUSE已到仓、LOADING装柜中、DEPARTED已离仓、COMPLETED已完成、RESCHEDULED已改约、EXCEPTION异常、CANCELLED已取消',
active_container_booking_id BIGINT UNSIGNED GENERATED ALWAYS AS (CASE WHEN status NOT IN ('COMPLETED','CANCELLED','EXCEPTION') THEN container_booking_id ELSE NULL END) STORED COMMENT '非终态预约的柜次唯一键',
active_ocean_booking_id BIGINT UNSIGNED GENERATED ALWAYS AS (CASE WHEN status NOT IN ('COMPLETED','CANCELLED','EXCEPTION') THEN ocean_booking_id ELSE NULL END) STORED COMMENT '非终态预约的订舱唯一键',
revision INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '拖车预约业务修订号;每次状态或改约递增',
version INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '乐观锁版本号',
remark VARCHAR(1000) NULL COMMENT '平台内部备注或异常说明',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '拖车预约创建时间',
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '拖车预约最后更新时间',
PRIMARY KEY(id), UNIQUE KEY uk_truck_appointment_no(appointment_no), UNIQUE KEY uk_truck_active_container(active_container_booking_id),
KEY idx_truck_appointment_status(status,updated_at), KEY idx_truck_appointment_container(container_booking_id),
PRIMARY KEY(id), UNIQUE KEY uk_truck_appointment_no(appointment_no), UNIQUE KEY uk_truck_active_ocean_booking(active_ocean_booking_id),
KEY idx_truck_appointment_status(status,updated_at), KEY idx_truck_appointment_ocean(ocean_booking_id),
CONSTRAINT chk_truck_appointment_status CHECK(status IN ('DRAFT','PENDING_DISPATCH','DISPATCHED','PICKUP_IN_PROGRESS','ARRIVED_WAREHOUSE','LOADING','DEPARTED','COMPLETED','RESCHEDULED','EXCEPTION','CANCELLED')),
CONSTRAINT chk_truck_appointment_currency CHECK(currency IN ('RMB','USD'))
) ENGINE=InnoDB COMMENT='三期拖车预约派车聚合';
@@ -100,16 +100,26 @@ CALL phase3_truck_add_column(
'DATETIME(3) NULL COMMENT ''车辆实际离仓时间;DEPARTED后供LOADED门禁使用'' AFTER actual_loading_at'
);
CALL phase3_truck_add_column(
'active_container_booking_id',
'BIGINT UNSIGNED GENERATED ALWAYS AS (CASE WHEN status NOT IN (''COMPLETED'',''CANCELLED'',''EXCEPTION'') THEN container_booking_id ELSE NULL END) STORED COMMENT ''非终态预约的柜次唯一键'' AFTER status'
'active_ocean_booking_id',
'BIGINT UNSIGNED GENERATED ALWAYS AS (CASE WHEN status NOT IN (''COMPLETED'',''CANCELLED'',''EXCEPTION'') THEN ocean_booking_id ELSE NULL END) STORED COMMENT ''非终态预约的订舱唯一键'' AFTER status'
);
DROP PROCEDURE IF EXISTS phase3_truck_add_column;
SET @phase3_truck_active_index = IF(
SET @phase3_truck_drop_old_active_index = IF(
EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE()
AND table_name='fl_truck_appointment' AND index_name='uk_truck_active_container' AND non_unique=0),
'ALTER TABLE fl_truck_appointment DROP INDEX uk_truck_active_container',
'SELECT 1'
);
PREPARE phase3_truck_active_stmt FROM @phase3_truck_drop_old_active_index;
EXECUTE phase3_truck_active_stmt;
DEALLOCATE PREPARE phase3_truck_active_stmt;
SET @phase3_truck_active_index = IF(
EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE()
AND table_name='fl_truck_appointment' AND index_name='uk_truck_active_ocean_booking' AND non_unique=0),
'SELECT 1',
'ALTER TABLE fl_truck_appointment ADD UNIQUE KEY uk_truck_active_container(active_container_booking_id)'
'ALTER TABLE fl_truck_appointment ADD UNIQUE KEY uk_truck_active_ocean_booking(active_ocean_booking_id)'
);
PREPARE phase3_truck_active_stmt FROM @phase3_truck_active_index;
EXECUTE phase3_truck_active_stmt;
@@ -1,15 +1,15 @@
openapi: 3.0.3
info:
title: Truck Appointment API
version: 1.3.0
description: Every truck appointment must select one RELEASED ocean booking and its allocated ACTIVE internal container identity. A real ISO 6346 container number is not required for create, edit, submit or dispatch. PICKUP may omit it and still advances normally; when supplied, PICKUP atomically records it if still missing or reuses the existing valid number. The bill-of-lading number is frozen from the booking, and the booking must own at least one active BOOKING_CONFIRMATION or BILL_OF_LADING file under OCEAN_BOOKING_DOCUMENT. Booking and bill files are not owned by truck appointments. After a PDA loading transaction commits, the server best-effort completes the unique active appointment explicitly linked to that container in an isolated transaction; absence, duplicates or concurrent truck edits never roll back loading completion.
version: 2.0.0
description: A truck appointment belongs to one RELEASED ocean booking, independently of when or how that booking's internal containers are linked. Each booking may have at most one non-terminal appointment. The bill-of-lading number and warehouse are frozen from the booking, which must own at least one active BOOKING_CONFIRMATION or BILL_OF_LADING file. Container identities and real container numbers remain in the container/loading modules and are not part of this API. PDA loading completion does not change a booking-level truck appointment.
paths:
/api/platform/v1/truck-appointments:
get:
summary: Query truck appointments
responses:
'200':
description: Paged truck appointments with stable booking/container snapshots and attachment count
description: Paged booking-level truck appointments with stable booking snapshots and attachment count
content:
application/json:
schema:
@@ -20,8 +20,8 @@ paths:
properties:
records: {type: array, items: {$ref: '#/components/schemas/DetailStableReferences'}}
post:
summary: Create a truck appointment for a released booking and its allocated internal container
description: If the real container already has a non-terminal appointment, the server returns A0409 with the existing appointment number and instructs the operator to continue editing. This duplicate guard runs before booking-document validation; the database unique key provides the same response for concurrent creates.
summary: Create a truck appointment for a released booking
description: If the booking already has a non-terminal appointment, the server returns A0409 with the existing appointment number and instructs the operator to continue editing. The database booking-level unique key provides the same guarantee for concurrent creates.
requestBody:
required: true
content:
@@ -29,17 +29,17 @@ paths:
schema: {$ref: '#/components/schemas/SaveRequest'}
responses:
'200': {description: 'Created appointment', content: {application/json: {schema: {$ref: '#/components/schemas/DetailEnvelope'}}}}
'409': {description: 'A0409 - this container already has a non-terminal appointment; message includes its appointment number and asks the operator to continue editing'}
'409': {description: 'A0409 - this booking already has a non-terminal appointment; message includes its appointment number and asks the operator to continue editing'}
/api/platform/v1/truck-appointments/candidates:
get:
summary: Query authoritative create-time ocean-booking and internal-container candidates
description: Requires platform:firstleg:truck-appointment:list. Returns only RELEASED bookings with an active booking-owned confirmation or bill file and allocated ACTIVE internal containers that have no non-terminal truck appointment and whose loading plan is absent or not COMPLETED. A missing containerNo remains eligible. Completed/LOADED containers, occupied containers and bookings without any remaining eligible allocated container are absent. Edit pages must display their current binding from appointment detail rather than injecting it into this create-only candidate result.
summary: Query authoritative create-time ocean-booking candidates
description: Requires platform:firstleg:truck-appointment:list. Returns one row per RELEASED booking with a bill of lading and an active booking-owned confirmation or bill file, excluding bookings that already have a non-terminal truck appointment. Container allocation and loading status do not affect eligibility. Edit pages display their current booking from appointment detail rather than injecting it into this create-only result.
parameters:
- {name: keyword, in: query, required: false, schema: {type: string, maxLength: 64}, description: Matches booking number, bill of lading, cabinet number or real container number}
- {name: keyword, in: query, required: false, schema: {type: string, maxLength: 64}, description: Matches booking number or bill of lading}
- {name: page, in: query, required: false, schema: {type: integer, format: int64, minimum: 1, default: 1}}
- {name: size, in: query, required: false, schema: {type: integer, format: int64, minimum: 1, maximum: 200, default: 20}}
responses:
'200': {description: Paged flat booking-container candidates, content: {application/json: {schema: {$ref: '#/components/schemas/CandidatePageEnvelope'}}}}
'200': {description: Paged booking candidates, content: {application/json: {schema: {$ref: '#/components/schemas/CandidatePageEnvelope'}}}}
/api/platform/v1/truck-appointments/{id}:
parameters: [{$ref: '#/components/parameters/AppointmentId'}]
get:
@@ -63,7 +63,7 @@ paths:
- {$ref: '#/components/parameters/RequestHash'}
post:
summary: Apply an idempotent appointment lifecycle action
description: PICKUP accepts optional containerNo. Omitting it never blocks the transition to PICKUP_IN_PROGRESS; when supplied, the server atomically records it on the linked internal container if missing. An existing valid ISO 6346 number may be omitted or resubmitted unchanged; a different value is rejected and must use the preload correction flow. Other actions reject containerNo. ARRIVE, LOAD and DEPART persist their actual event timestamps atomically with the status transition.
description: All actions transition the booking-level appointment as one aggregate. PICKUP does not accept or write a container number. ARRIVE, LOAD and DEPART persist their actual event timestamps atomically with the status transition.
requestBody:
required: true
content:
@@ -168,7 +168,7 @@ components:
timestamp: {type: string, format: date-time}
Candidate:
type: object
required: [oceanBookingId, oceanBookingNo, warehouseId, requirementId, containerType, requirementQuantity, containerBookingId, cabinetNo, bookingDocumentReady]
required: [oceanBookingId, oceanBookingNo, warehouseId, bookingDocumentReady]
properties:
oceanBookingId: {type: integer, format: int64}
oceanBookingNo: {type: string}
@@ -177,15 +177,6 @@ components:
warehouseName: {type: string, nullable: true}
channelCode: {type: string, nullable: true}
channelName: {type: string, nullable: true}
requirementId: {type: integer, format: int64}
containerType: {type: string}
requirementQuantity: {type: integer, minimum: 1}
containerBookingId: {type: integer, format: int64}
cabinetNo: {type: string}
containerNo: {type: string, nullable: true, description: Optional until PICKUP or preload correction; absence does not make the candidate unavailable}
loadingPlanId: {type: integer, format: int64, nullable: true}
loadingPlanNo: {type: string, nullable: true}
loadingPlanStatus: {type: string, nullable: true}
bookingDocumentReady: {type: boolean, enum: [true]}
DetailEnvelope:
type: object
@@ -225,16 +216,13 @@ components:
properties:
expectedVersion: {type: integer, minimum: 0}
reason: {type: string, maxLength: 500}
containerNo: {type: string, maxLength: 32, nullable: true, description: Optional and only valid for PICKUP. Omission does not block PICKUP; when supplied it is normalized and validated as unique ISO 6346 before being written atomically. A different value from an existing number is rejected and must use preload correction.}
SaveRequest:
type: object
required: [oceanBookingId, containerBookingId, carrierId, plannedLoadingAt, expectedVersion]
required: [oceanBookingId, carrierId, plannedLoadingAt, expectedVersion]
properties:
outboundOrderId: {type: integer, format: int64, minimum: 1, nullable: true}
oceanBookingId: {type: integer, format: int64, minimum: 1, description: Required RELEASED booking; must own an active BOOKING_CONFIRMATION or BILL_OF_LADING file.}
containerBookingId: {type: integer, format: int64, minimum: 1, description: Required ACTIVE internal container already allocated to oceanBookingId. Its real ISO 6346 number may remain empty until PICKUP.}
loadingPlanId: {type: integer, format: int64, minimum: 1, nullable: true, description: Optional loading-plan association. It may be supplied only with containerBookingId and must be that container's current plan.}
warehouseId: {type: integer, format: int64, minimum: 1, nullable: true, description: Optional warehouse scope. Linked booking/container warehouse facts must agree; when omitted the linked fact is frozen if available.}
oceanBookingId: {type: integer, format: int64, minimum: 1, description: Required RELEASED booking; must have a bill of lading and own an active BOOKING_CONFIRMATION or BILL_OF_LADING file.}
warehouseId: {type: integer, format: int64, minimum: 1, nullable: true, description: Optional consistency hint. The persisted warehouse is frozen from the selected booking.}
carrierId: {type: integer, format: int64, minimum: 1, description: Required ACTIVE Warehouse supplier ID validated as TRUCKING.}
carrierName: {type: string, maxLength: 128, nullable: true, description: Optional consistency hint only; the persisted snapshot comes from Warehouse supplier master data.}
billOfLadingNo: {type: string, maxLength: 128, nullable: true, description: Optional consistency hint; the persisted snapshot is always frozen from the selected RELEASED booking.}
@@ -245,15 +233,11 @@ components:
DetailStableReferences:
type: object
required: [appointmentNo, carrierId, carrierName, attachmentCount]
description: Stable IDs plus server-resolved saved resource names returned by appointment detail. Ocean booking and container associations are always present.
description: Stable booking and supplier references returned by appointment detail. Container identities are intentionally absent.
properties:
appointmentNo: {type: string, pattern: '^TA-[0-9]{6}-[0-9]{4}$', example: TA-260818-0001, description: 派车单号;按北京时间创建日逐日编号}
oceanBookingId: {type: integer, format: int64, nullable: true}
oceanBookingNo: {type: string, nullable: true, pattern: '^OB-[0-9]{6}-[0-9]{4}$', example: OB-260818-0001}
containerBookingId: {type: integer, format: int64, nullable: true}
cabinetNo: {type: string, nullable: true, pattern: '^CAB-[0-9]{6}-[0-9]{4}$', example: CAB-260818-0001}
containerNo: {type: string, nullable: true}
loadingPlanId: {type: integer, format: int64, nullable: true}
warehouseId: {type: integer, format: int64, nullable: true}
warehouseName: {type: string, nullable: true}
shippingCompanyId: {type: integer, format: int64, nullable: true}
@@ -19,7 +19,7 @@ import static org.assertj.core.api.Assertions.assertThat;
class TruckAppointmentControllerContractTest {
@Test void exposesDedicatedEndpointAndPermissions() {String p=PlatformTruckAppointmentController.class.getAnnotation(org.springframework.web.bind.annotation.RequestMapping.class).value()[0];assertThat(p).isEqualTo("/api/platform/v1/truck-appointments"); for(String n:new String[]{"page","candidates","detail","history","attachments","attach","upload","deleteAttachment","create","edit","action","reschedule"}) assertThat(java.util.Arrays.stream(PlatformTruckAppointmentController.class.getDeclaredMethods()).anyMatch(m->m.getName().equals(n))).isTrue();}
@Test void schemaHasPlatformAggregateWithoutCustomerOrReceivableColumns() throws Exception {String s=new ClassPathResource("db/phase3-truck-appointment-schema.sql").getContentAsString(StandardCharsets.UTF_8);assertThat(s).contains("fl_truck_appointment","fl_truck_appointment_history","fl_truck_appointment_attachment","fl_truck_appointment_operation","bill_of_lading_no VARCHAR(128)","chk_truck_attachment_type","DISPATCH_ORDER","PENDING_DISPATCH","PICKUP_IN_PROGRESS","ARRIVED_WAREHOUSE","RESCHEDULED","actual_arrived_warehouse_at","actual_loading_at","actual_departed_at","CALL phase3_truck_add_column","visibility VARCHAR(24) NOT NULL DEFAULT 'PLATFORM'","active_container_booking_id BIGINT UNSIGNED GENERATED ALWAYS AS","UNIQUE KEY uk_truck_active_container(active_container_booking_id)","chk_truck_appointment_currency CHECK(currency IN ('RMB','USD'))").doesNotContain("customer_id", "customer_name", "idx_truck_appointment_customer", "receivable_amount");}
@Test void schemaHasBookingLevelUniqueAggregateWithoutCustomerScope() throws Exception {String s=new ClassPathResource("db/phase3-truck-appointment-schema.sql").getContentAsString(StandardCharsets.UTF_8);assertThat(s).contains("fl_truck_appointment","bill_of_lading_no VARCHAR(128)","active_ocean_booking_id BIGINT UNSIGNED GENERATED ALWAYS AS","UNIQUE KEY uk_truck_active_ocean_booking(active_ocean_booking_id)","ALTER TABLE fl_truck_appointment DROP INDEX uk_truck_active_container","chk_truck_appointment_currency CHECK(currency IN ('RMB','USD'))").doesNotContain("customer_id", "customer_name", "receivable_amount");}
@Test void existingAppointmentTableReceivesAllLateColumns() throws Exception {
String moduleSql=new ClassPathResource("db/phase3-truck-appointment-schema.sql").getContentAsString(StandardCharsets.UTF_8);
Path direct=Path.of("deploy","sql","头程数据库统一增量-MySQL8.sql");
@@ -30,10 +30,10 @@ class TruckAppointmentControllerContractTest {
}
}
@Test void openApiDocumentsLifecycleIdempotencyAndActualTimes() throws Exception {String s=new ClassPathResource("openapi/truck-appointments-v1.yaml").getContentAsString(StandardCharsets.UTF_8);assertThat(s).contains("/api/platform/v1/truck-appointments/{id}/{action}:","/api/platform/v1/truck-appointments/{id}/reschedule:","name: Idempotency-Key","required: true","actual event timestamps");}
@Test void openApiDocumentsActionableDuplicateCreateConflict() throws Exception {String s=new ClassPathResource("openapi/truck-appointments-v1.yaml").getContentAsString(StandardCharsets.UTF_8);assertThat(s).contains("A0409","existing appointment number","continue editing","before booking-document validation","database unique key");}
@Test void openApiPublishesCreateOnlyAuthoritativeCandidateContract() throws Exception {String s=new ClassPathResource("openapi/truck-appointments-v1.yaml").getContentAsString(StandardCharsets.UTF_8);assertThat(s).contains("/api/platform/v1/truck-appointments/candidates:","Paged flat booking-container candidates","bookingDocumentReady","A missing containerNo remains eligible","Completed/LOADED containers, occupied containers","Edit pages must display their current binding from appointment detail");}
@Test void openApiRequiresReleasedBookingAndAllocatedInternalContainerWithoutCustomerScope() throws Exception {String s=new ClassPathResource("openapi/truck-appointments-v1.yaml").getContentAsString(StandardCharsets.UTF_8);assertThat(s).contains("required: [oceanBookingId, containerBookingId, carrierId, plannedLoadingAt, expectedVersion]","RELEASED ocean booking","allocated ACTIVE internal container identity","real ISO 6346 container number is not required for create, edit, submit or dispatch","PICKUP may omit it and still advances normally","Omitting it never blocks the transition to PICKUP_IN_PROGRESS","must use the preload correction flow","Other actions reject containerNo","OCEAN_BOOKING_DOCUMENT","schema: {$ref: '#/components/schemas/DetailEnvelope'}","required: [code, message, data, timestamp]","allOf: [{$ref: '#/components/schemas/DetailStableReferences'}]","nullable: true").doesNotContain("customerId", "customerName", "Filter by customer");}
@Test void openApiKeepsOnlyNonBookingTruckAttachments() throws Exception {String s=new ClassPathResource("openapi/truck-appointments-v1.yaml").getContentAsString(StandardCharsets.UTF_8);assertThat(s).contains("AttachmentRequest:","required: [fileId, attachmentType]","required: [attachmentType, file]","TRUCK_APPOINTMENT_ATTACHMENT","Deletes the appointment reference only","DetailStableReferences:","oceanBookingNo:","shippingCompanyName:","bookingAgentName:","cabinetNo:","billOfLadingNo:").doesNotContain("enum: [BILL_OF_LADING, DISPATCH_ORDER");}
@Test void openApiDocumentsActionableDuplicateCreateConflict() throws Exception {String s=new ClassPathResource("openapi/truck-appointments-v1.yaml").getContentAsString(StandardCharsets.UTF_8);assertThat(s).contains("A0409","existing appointment number","continue editing","database booking-level unique key");}
@Test void openApiPublishesBookingLevelCandidateContract() throws Exception {String s=new ClassPathResource("openapi/truck-appointments-v1.yaml").getContentAsString(StandardCharsets.UTF_8);assertThat(s).contains("/api/platform/v1/truck-appointments/candidates:","Paged booking candidates","bookingDocumentReady","Container allocation and loading status do not affect eligibility").doesNotContain("requirementId:","containerBookingId:","loadingPlanId:");}
@Test void openApiRequiresReleasedBookingWithoutContainerWriteProtocol() throws Exception {String s=new ClassPathResource("openapi/truck-appointments-v1.yaml").getContentAsString(StandardCharsets.UTF_8);assertThat(s).contains("required: [oceanBookingId, carrierId, plannedLoadingAt, expectedVersion]","RELEASED ocean booking","PICKUP does not accept or write a container number","schema: {$ref: '#/components/schemas/DetailEnvelope'}").doesNotContain("containerBookingId:","loadingPlanId:","containerNo:","customerId", "customerName");}
@Test void openApiKeepsOnlyNonBookingTruckAttachments() throws Exception {String s=new ClassPathResource("openapi/truck-appointments-v1.yaml").getContentAsString(StandardCharsets.UTF_8);assertThat(s).contains("AttachmentRequest:","required: [fileId, attachmentType]","required: [attachmentType, file]","TRUCK_APPOINTMENT_ATTACHMENT","Deletes the appointment reference only","DetailStableReferences:","oceanBookingNo:","shippingCompanyName:","bookingAgentName:","billOfLadingNo:").doesNotContain("enum: [BILL_OF_LADING, DISPATCH_ORDER");}
@Test void openApiExposesOnlyPayableWithFixedCurrencies() throws Exception {String s=new ClassPathResource("openapi/truck-appointments-v1.yaml").getContentAsString(StandardCharsets.UTF_8);assertThat(s).contains("payableAmount:","enum: [RMB, USD]","default: RMB").doesNotContain("receivableAmount");}
@Test void methodsHavePlatformPermissionsAndAuthenticatedScope() {
Map<String,String> expected=Map.ofEntries(
@@ -69,7 +69,7 @@ class OutboundDispatchServiceTest {
void confirmAllowsMissingTruckAppointmentWhenEveryAuthoritativeGatePasses() {
OutboundDispatchService service = serviceWithoutCarrier();
stubContainer();
when(repository.truck(22L, 44L)).thenReturn(null);
when(repository.truck(22L)).thenReturn(null);
stubConfirmMutation("no-truck");
Detail result = service.confirm(22L, 9L, "admin", "no-truck", new ActionRequest(0, null));
@@ -83,7 +83,7 @@ class OutboundDispatchServiceTest {
void confirmAllowsPendingTruckAppointmentWhenEveryAuthoritativeGatePasses() {
OutboundDispatchService service = serviceWithoutCarrier();
stubContainer();
when(repository.truck(22L, 44L)).thenReturn(new OutboundDispatchReadMapper.IdStatus(77L, "PENDING"));
when(repository.truck(22L)).thenReturn(new OutboundDispatchReadMapper.IdStatus(77L, "PENDING"));
stubConfirmMutation("pending-truck");
Detail result = service.confirm(22L, 9L, "admin", "pending-truck", new ActionRequest(0, null));
@@ -247,7 +247,7 @@ class OutboundDispatchServiceTest {
when(repository.bindings(22L)).thenReturn(values);
when(repository.pick(44L)).thenReturn(new OutboundDispatchReadMapper.IdStatus(55L, "COMPLETED"));
when(repository.ocean(22L)).thenReturn(new OutboundDispatchReadMapper.OceanBinding(66L, "RELEASED", 7L));
when(repository.truck(22L, 44L)).thenReturn(new OutboundDispatchReadMapper.IdStatus(77L, "DEPARTED"));
when(repository.truck(22L)).thenReturn(new OutboundDispatchReadMapper.IdStatus(77L, "DEPARTED"));
when(repository.evidence(22L, 44L)).thenReturn(new OutboundDispatchReadMapper.Evidence(5, 1));
}
@@ -13,39 +13,30 @@ class TruckAppointmentMapperContractTest {
@Test void mapsToPhase3TableAndUsesOptimisticGuards() throws Exception {
assertThat(TruckAppointmentEntity.class.getAnnotation(TableName.class).value()).isEqualTo("fl_truck_appointment");
String page = TruckAppointmentMapper.class.getMethod("page", com.baomidou.mybatisplus.extension.plugins.pagination.Page.class, com.baiyue.firstleg.dto.TruckAppointmentDtos.Query.class).getAnnotation(Select.class).value()[0];
assertThat(page).contains("fl_truck_appointment", "appointment_no", "vehicle_no", "status=#{q.status}", "container_booking_id=#{q.containerBookingId}",
"fl_ocean_booking", "fl_container_booking", "record_status='ACTIVE'", "cabinet_no", "container_no", "fl_truck_appointment_attachment", "attachment_count");
assertThat(page).contains("fl_truck_appointment", "appointment_no", "vehicle_no", "status=#{q.status}", "ocean_booking_id=#{q.oceanBookingId}",
"fl_ocean_booking", "fl_truck_appointment_attachment", "attachment_count")
.doesNotContain("fl_container_booking", "cabinet_no", "container_no");
assertThat(page).doesNotContain("customer_id", "customerId", "customer_name");
String candidates = TruckAppointmentMapper.class.getMethod("candidates", com.baomidou.mybatisplus.extension.plugins.pagination.Page.class, com.baiyue.firstleg.dto.TruckAppointmentDtos.CandidateQuery.class).getAnnotation(Select.class).value()[0];
assertThat(candidates).contains("o.status='RELEASED'", "fl_ocean_booking_container_allocation",
"fl_ocean_booking_container_requirement", "c.record_status='ACTIVE'", "d.valid=1",
"'BOOKING_CONFIRMATION','BILL_OF_LADING'", "p.plan_status&lt;&gt;'COMPLETED'",
"t.status NOT IN ('COMPLETED','CANCELLED','EXCEPTION')", "containerBookingId",
"bookingDocumentReady");
assertThat(candidates).doesNotContain("c.container_no IS NOT NULL", "currentAppointmentId", "currentContainerBookingId");
assertThat(candidates).contains("FROM fl_ocean_booking o", "o.status='RELEASED'", "d.valid=1",
"'BOOKING_CONFIRMATION','BILL_OF_LADING'", "t.ocean_booking_id=o.id",
"t.status NOT IN ('COMPLETED','CANCELLED','EXCEPTION')", "bookingDocumentReady");
assertThat(candidates).doesNotContain("fl_ocean_booking_container_allocation", "fl_container_booking",
"loadingPlanId", "containerBookingId", "cabinetNo", "containerNo");
String transition = TruckAppointmentMapper.class.getMethod("transition", long.class, String.class, String.class, int.class).getAnnotation(Update.class).value()[0];
assertThat(transition).contains("status=#{toStatus}", "status=#{fromStatus}", "version=#{version}", "revision=revision+1", "version=version+1");
String dispatch = TruckAppointmentMapper.class.getMethod("dispatch", long.class, String.class, String.class, int.class).getAnnotation(Update.class).value()[0];
assertThat(dispatch).contains("status='DISPATCHED'", "bill_of_lading_no=#{billOfLadingNo}", "status=#{fromStatus}", "version=#{version}", "revision=revision+1");
String pickupContainer = TruckAppointmentMapper.class.getMethod("bindContainerNoForPickup", long.class, String.class, long.class, String.class).getAnnotation(Update.class).value()[0];
assertThat(pickupContainer).contains("UPDATE fl_container_booking", "container_no=#{containerNo}",
"id=#{containerBookingId}", "record_status='ACTIVE'", "container_no IS NULL", "version=version+1");
String active = TruckAppointmentMapper.class.getMethod("activeForUpdate", long.class).getAnnotation(Select.class).value()[0];
assertThat(active).contains("container_booking_id=#{containerBookingId}", "FOR UPDATE")
.contains("'COMPLETED','CANCELLED','EXCEPTION'");
String firstActive = TruckAppointmentMapper.class.getMethod("firstActiveForUpdate", long.class).getAnnotation(Select.class).value()[0];
assertThat(firstActive).contains("container_booking_id=#{containerBookingId}", "ORDER BY created_at,id", "LIMIT 1 FOR UPDATE")
assertThat(firstActive).contains("ocean_booking_id=#{oceanBookingId}", "ORDER BY created_at,id", "LIMIT 1 FOR UPDATE")
.contains("'COMPLETED','CANCELLED','EXCEPTION'");
String pdaComplete = TruckAppointmentMapper.class.getMethod("completeLoadingFromPda", long.class, String.class, int.class).getAnnotation(Update.class).value()[0];
assertThat(pdaComplete).contains("status='COMPLETED'", "actual_loading_at=COALESCE", "status=#{fromStatus}",
"version=#{version}", "revision=revision+1", "version=version+1");
String reschedule = TruckAppointmentMapper.class.getMethod("reschedule", long.class, java.time.LocalDateTime.class, String.class, int.class).getAnnotation(Update.class).value()[0];
assertThat(reschedule).contains("status='RESCHEDULED'", "version=#{version}", "revision=revision+1");
String edit = TruckAppointmentMapper.class.getMethod("updateEdit", TruckAppointmentEntity.class, int.class).getAnnotation(Update.class).value()[0];
assertThat(edit).contains("ocean_booking_id=#{e.oceanBookingId}", "container_booking_id=#{e.containerBookingId}",
"loading_plan_id=#{e.loadingPlanId}", "carrier_id=#{e.carrierId}",
assertThat(edit).contains("ocean_booking_id=#{e.oceanBookingId}", "carrier_id=#{e.carrierId}",
"planned_loading_at=#{e.plannedLoadingAt}", "bill_of_lading_no=#{e.billOfLadingNo}", "WHERE id=#{e.id} AND version=#{version}",
"version=version+1", "revision=revision+1");
assertThat(edit).doesNotContain("container_booking_id", "loading_plan_id");
assertThat(edit).doesNotContain("customer_id", "customer_name", "customerId", "customerName");
assertThat(edit).doesNotContain("receivable_amount", "receivableAmount");
}
@@ -93,8 +93,6 @@ class LoadingPickServiceTest {
@MockBean
private ColoadGroupAssignmentPort coloadGroups;
@MockBean
private TruckAppointmentService truckAppointmentService;
@MockBean
private InboundStatusHistoryMapper inboundHistories;
@MockBean
private PdaPickSettlementService settlementService;
@@ -594,7 +592,6 @@ class LoadingPickServiceTest {
verify(repository).syncInboundProjections(20L, 9L, "operator");
verify(loadingDeliveryService).requirePdaCompletionReady(101L, 50L);
verify(loadingDeliveryService).requirePdaCompletionReady(101L, 60L);
verify(truckAppointmentService).completeLoadingFromPda(99L, 20L, 9L, "operator");
verify(loadingDeliveryService).completeAfterPdaSettlement(101L, 50L, 9L, "operator",
"PDA-complete-1-50", "checked");
verify(loadingDeliveryService).completeAfterPdaSettlement(101L, 60L, 9L, "operator",
@@ -607,37 +604,6 @@ class LoadingPickServiceTest {
finalization.verify(loadingListService).publishCompletedEvent(10L);
}
@Test
@org.junit.jupiter.api.Disabled("Replaced by durable asynchronous completion command contract")
void conflictingTruckAppointmentsDoNotBlockPdaCompletion() {
LoadingPickTaskEntity task = task();
LoadingPickItemEntity item = item();
when(repository.taskForUpdate(20L)).thenReturn(task);
when(repository.task(20L)).thenReturn(task);
when(repository.items(20L)).thenReturn(List.of(item));
when(repository.allTaskItemsResolved(20L)).thenReturn(true);
when(repository.allPlanItemsPicked(10L)).thenReturn(true);
when(repository.approve(20L, 9L, "operator", "checked")).thenReturn(1);
when(repository.review(20L)).thenReturn(null);
var owner = new LoadingPickItemMapper.InboundOwner(101L, 50L);
when(repository.inboundOwners(20L)).thenReturn(List.of(owner));
when(repository.inboundForUpdate(101L, 50L)).thenReturn(
new LoadingInboundMapper.Snapshot(50L, 101L, "LOADING", 7));
doThrow(new BizException(com.baiyue.common.core.api.ResultCode.CONFLICT,
"同一柜次存在多条有效拖车预约,跳过自动完成"))
.when(truckAppointmentService).completeLoadingFromPda(99L, 20L, 9L, "operator");
service.complete(20L,
new CompleteLoadingRequest(0, "complete-not-blocked", "checked"), 9L, "operator");
verify(settlementService).settle(20L, 9L, "operator");
verify(repository).approve(20L, 9L, "operator", "checked");
verify(loadingListService).finishFromPda(10L, 20L, 9L, "operator");
verify(loadingDeliveryService).completeAfterPdaSettlement(101L, 50L, 9L, "operator",
"PDA-complete-not-blocked-50", "checked");
verify(loadingListService).publishCompletedEvent(10L);
}
@Test
@org.junit.jupiter.api.Disabled("Replaced by durable asynchronous completion command contract")
void doesNotPublishCompletedEventWhenInboundFinalizationFails() {
@@ -12,14 +12,9 @@ import com.baiyue.firstleg.entity.TruckAppointmentEntity;
import com.baiyue.firstleg.entity.TruckAppointmentAttachmentEntity;
import com.baiyue.firstleg.entity.TruckAppointmentHistoryEntity;
import com.baiyue.firstleg.entity.TruckAppointmentOperationEntity;
import com.baiyue.firstleg.entity.LoadingPlanEntity;
import com.baiyue.firstleg.entity.ContainerBookingEntity;
import com.baiyue.firstleg.repository.TruckAppointmentRepository;
import com.baiyue.firstleg.repository.SmartLoadingRepository;
import com.baiyue.firstleg.repository.ContainerRepository;
import com.baiyue.firstleg.oceanbooking.repository.OceanBookingRepository;
import com.baiyue.firstleg.oceanbooking.entity.OceanBookingEntity;
import com.baiyue.firstleg.oceanbooking.entity.OceanBookingContainerAllocationEntity;
import com.baiyue.firstleg.oceanbooking.service.OceanBookingDocumentService;
import com.baiyue.firstleg.client.TruckAppointmentFileClient;
import com.baiyue.firstleg.client.WarehouseSupplierClient;
@@ -28,7 +23,6 @@ import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;
import org.mockito.InjectMocks;
import org.mockito.InOrder;
import org.mockito.Mock;
import org.mockito.MockitoAnnotations;
import org.mockito.Spy;
@@ -48,8 +42,6 @@ import static org.mockito.Mockito.*;
class TruckAppointmentServiceTest {
@Mock TruckAppointmentRepository repository;
@Mock OceanBookingRepository oceanBookings;
@Mock SmartLoadingRepository loadingPlans;
@Mock ContainerRepository containers;
@Mock WarehouseSupplierSnapshotService suppliers;
@Mock TruckAppointmentFileClient files;
@Mock OceanBookingDocumentService oceanBookingDocuments;
@@ -64,46 +56,36 @@ class TruckAppointmentServiceTest {
new WarehouseSupplierClient.Snapshot(8L, "Canonical Carrier", "TRUCKING", "ACTIVE", java.util.List.of()));
lenient().when(repository.attachments(anyLong())).thenReturn(List.of());
OceanBookingEntity booking = releasedBooking();
ContainerBookingEntity container = realContainer();
OceanBookingContainerAllocationEntity allocation = new OceanBookingContainerAllocationEntity();
allocation.setContainerBookingId(88L);
lenient().when(oceanBookings.find(99L)).thenReturn(booking);
lenient().when(containers.find(88L)).thenReturn(container);
lenient().when(oceanBookings.allocations(99L)).thenReturn(List.of(allocation));
}
@Test void detailResolvesStableNamesAndIdentifiersFromLinkedResources() {
@Test void detailResolvesStableNamesFromLinkedBooking() {
TruckAppointmentEntity appointment = new TruckAppointmentEntity();
appointment.setId(51L); appointment.setOceanBookingId(61L); appointment.setContainerBookingId(71L);
appointment.setId(51L); appointment.setOceanBookingId(61L);
when(repository.find(51L)).thenReturn(appointment);
OceanBookingEntity booking = new OceanBookingEntity(); booking.setId(61L); booking.setBookingNo("OB61");
booking.setWarehouseName("Shenzhen Transit"); booking.setShippingCompanyId(81L);
booking.setShippingCompanyName("Carrier Line"); booking.setBookingAgentId(91L); booking.setBookingAgentName("Agent Co");
when(oceanBookings.find(61L)).thenReturn(booking);
ContainerBookingEntity container = new ContainerBookingEntity(); container.setId(71L);
container.setCabinetNo("CAB-71"); container.setContainerNo("MSCU1234567");
when(containers.find(71L)).thenReturn(container);
TruckAppointmentEntity detail = service.detail(51L);
assertThat(detail.getOceanBookingNo()).isEqualTo("OB61");
assertThat(detail.getWarehouseName()).isEqualTo("Shenzhen Transit");
assertThat(detail.getShippingCompanyName()).isEqualTo("Carrier Line");
assertThat(detail.getBookingAgentName()).isEqualTo("Agent Co");
assertThat(detail.getCabinetNo()).isEqualTo("CAB-71");
assertThat(detail.getContainerNo()).isEqualTo("MSCU1234567");
assertThat(detail.getContainerBookingId()).isNull();
assertThat(detail.getLoadingPlanId()).isNull();
}
@Test void createCandidatesDelegateOneBoundedAuthoritativeQuery() {
Candidate candidate = new Candidate(99L, "OB-1", "BL-1", 18L, "义乌仓", "SEA-US", "美西",
70L, "45HC", 1, 88L, "CAB-1", null, 77L, "LP-1", "EDITING", true);
Candidate candidate = new Candidate(99L, "OB-1", "BL-1", 18L, "义乌仓", "SEA-US", "美西", true);
when(repository.candidates(any())).thenReturn(new PageResult<>(List.of(candidate), 1, 20, 1, 1));
PageResult<Candidate> result = service.candidates(new CandidateQuery(" OB-1 ", 1, 20));
assertThat(result.records()).containsExactly(candidate);
verify(repository, times(1)).candidates(any(CandidateQuery.class));
verifyNoInteractions(oceanBookings, containers, loadingPlans);
verifyNoInteractions(oceanBookings);
}
@Test void createRequiresZeroVersionAndInitialisesDraft() {
@@ -112,14 +94,12 @@ class TruckAppointmentServiceTest {
verifyNoInteractions(repository);
}
@Test void createRejectsMissingBookingAndContainer() {
@Test void createRejectsMissingBooking() {
SaveRequest request = mock(SaveRequest.class);
when(request.expectedVersion()).thenReturn(0);
when(request.carrierId()).thenReturn(8L);
when(request.plannedLoadingAt()).thenReturn(LocalDateTime.of(2026, 8, 17, 10, 0));
when(request.oceanBookingId()).thenReturn(null);
when(request.containerBookingId()).thenReturn(null);
when(request.loadingPlanId()).thenReturn(null);
when(request.warehouseId()).thenReturn(null);
assertThatThrownBy(() -> service.create(7L, "admin", request))
@@ -139,14 +119,14 @@ class TruckAppointmentServiceTest {
verifyNoInteractions(files);
}
@Test void createRejectsAnotherActiveAppointmentForTheSameRealContainerWithoutWriting() {
@Test void createRejectsAnotherActiveAppointmentForTheSameBookingWithoutWriting() {
SaveRequest request = validRequest();
TruckAppointmentEntity existing = entity(TruckAppointmentStatus.DRAFT, 0);
existing.setAppointmentNo("TA-260901-0003");
when(repository.firstActiveForUpdate(88L)).thenReturn(existing);
when(repository.firstActiveForUpdate(99L)).thenReturn(existing);
assertThatThrownBy(() -> service.create(7L, "admin", request))
.isInstanceOf(BizException.class).hasMessageContaining("该柜已存在拖车预约 TA-260901-0003,请继续编辑");
.isInstanceOf(BizException.class).hasMessageContaining("该订舱已存在拖车预约 TA-260901-0003,请继续编辑");
verifyNoInteractions(oceanBookingDocuments);
verify(repository, never()).insert(any());
@@ -159,14 +139,14 @@ class TruckAppointmentServiceTest {
SaveRequest request = validRequest();
TruckAppointmentEntity concurrent = entity(TruckAppointmentStatus.DRAFT, 0);
concurrent.setAppointmentNo("TA-260901-0003");
when(repository.firstActiveForUpdate(88L)).thenReturn(null, concurrent);
doThrow(new DuplicateKeyException("Duplicate entry for key 'uk_truck_active_container'"))
when(repository.firstActiveForUpdate(99L)).thenReturn(null, concurrent);
doThrow(new DuplicateKeyException("Duplicate entry for key 'uk_truck_active_ocean_booking'"))
.when(repository).insert(any());
assertThatThrownBy(() -> service.create(7L, "admin", request))
.isInstanceOf(BizException.class).hasMessageContaining("该柜已存在拖车预约 TA-260901-0003,请继续编辑");
.isInstanceOf(BizException.class).hasMessageContaining("该订舱已存在拖车预约 TA-260901-0003,请继续编辑");
verify(repository, times(2)).firstActiveForUpdate(88L);
verify(repository, times(2)).firstActiveForUpdate(99L);
verify(repository, never()).history(any());
assertThat(concurrent.getStatus()).isEqualTo(TruckAppointmentStatus.DRAFT);
assertThat(concurrent.getVersion()).isZero();
@@ -261,8 +241,6 @@ class TruckAppointmentServiceTest {
when(request.oceanBookingId()).thenReturn(99L);
when(request.carrierId()).thenReturn(8L);
when(request.plannedLoadingAt()).thenReturn(LocalDateTime.of(2026, 8, 17, 10, 0));
when(request.containerBookingId()).thenReturn(88L);
when(request.loadingPlanId()).thenReturn(null);
when(request.warehouseId()).thenReturn(null);
OceanBookingEntity booking = new OceanBookingEntity(); booking.setId(99L); booking.setStatus("CONFIRMED"); booking.setWarehouseId(18L);
when(oceanBookings.find(99L)).thenReturn(booking);
@@ -271,38 +249,8 @@ class TruckAppointmentServiceTest {
verify(repository, never()).insert(any());
}
@Test void createWithBookingAndContainerRequiresTheirAllocation() {
@Test void createStillRequiresBookingDocumentWithoutAnyContainerAssociation() {
SaveRequest request = validRequest();
when(request.oceanBookingId()).thenReturn(99L);
when(request.containerBookingId()).thenReturn(88L);
OceanBookingEntity booking = new OceanBookingEntity();
booking.setId(99L); booking.setStatus("RELEASED"); booking.setWarehouseId(18L); booking.setBillOfLadingNo("BL-1");
ContainerBookingEntity container = new ContainerBookingEntity();
container.setId(88L); container.setRecordStatus("ACTIVE"); container.setWarehouseId(18L); container.setOceanBookingId(99L); container.setContainerNo("MSCU6639870");
when(oceanBookings.find(99L)).thenReturn(booking);
when(containers.find(88L)).thenReturn(container);
when(oceanBookings.allocations(99L)).thenReturn(List.of());
assertThatThrownBy(() -> service.create(7L, "admin", request))
.isInstanceOf(BizException.class).hasMessageContaining("未分配至该订舱");
verify(repository, never()).insert(any());
}
@Test void createAcceptsAllocatedInternalContainerWithoutRealNumber() {
SaveRequest request = validRequest();
ContainerBookingEntity missingNo = realContainer(); missingNo.setContainerNo(null);
when(containers.find(88L)).thenReturn(missingNo);
service.create(7L, "admin", request);
verify(repository).insert(any(TruckAppointmentEntity.class));
verify(oceanBookingDocuments).requireActiveValidDocument(any(OceanBookingEntity.class));
}
@Test void createStillRequiresBookingDocumentBeforeRealNumberIsAssigned() {
SaveRequest request = validRequest();
ContainerBookingEntity missingNo = realContainer(); missingNo.setContainerNo(null);
when(containers.find(88L)).thenReturn(missingNo);
doThrow(new BizException(com.baiyue.common.core.api.ResultCode.CONFLICT,
"订舱缺少有效订舱确认或提单文件"))
.when(oceanBookingDocuments).requireActiveValidDocument(any());
@@ -311,22 +259,16 @@ class TruckAppointmentServiceTest {
verify(repository, never()).insert(any());
}
@Test void editCanSwitchOnlyToAnotherMatchedReleasedBookingAndContainer() {
@Test void editCanSwitchToAnotherUnoccupiedReleasedBooking() {
TruckAppointmentEntity existing = entity(TruckAppointmentStatus.DRAFT, 3);
existing.setOceanBookingId(99L); existing.setContainerBookingId(88L);
existing.setOceanBookingId(99L);
when(repository.find(1L)).thenReturn(existing, existing);
SaveRequest request = validRequest();
when(request.expectedVersion()).thenReturn(3);
when(request.oceanBookingId()).thenReturn(199L);
when(request.containerBookingId()).thenReturn(188L);
OceanBookingEntity booking = releasedBooking(); booking.setId(199L); booking.setBillOfLadingNo("BL-NEW");
ContainerBookingEntity container = realContainer(); container.setId(188L); container.setOceanBookingId(199L);
container.setContainerNo("MSCU6639870");
OceanBookingContainerAllocationEntity allocation = new OceanBookingContainerAllocationEntity();
allocation.setContainerBookingId(188L);
when(oceanBookings.find(199L)).thenReturn(booking);
when(containers.find(188L)).thenReturn(container);
when(oceanBookings.allocations(199L)).thenReturn(List.of(allocation));
when(repository.firstActiveForUpdate(199L)).thenReturn(null);
when(repository.updateEdit(any(), eq(3))).thenReturn(1);
service.edit(1L, 7L, "admin", request);
@@ -334,82 +276,9 @@ class TruckAppointmentServiceTest {
ArgumentCaptor<TruckAppointmentEntity> updated = ArgumentCaptor.forClass(TruckAppointmentEntity.class);
verify(repository).updateEdit(updated.capture(), eq(3));
assertThat(updated.getValue().getOceanBookingId()).isEqualTo(199L);
assertThat(updated.getValue().getContainerBookingId()).isEqualTo(188L);
assertThat(updated.getValue().getContainerBookingId()).isNull();
assertThat(updated.getValue().getLoadingPlanId()).isNull();
assertThat(updated.getValue().getBillOfLadingNo()).isEqualTo("BL-NEW");
container.setOceanBookingId(99L);
assertThatThrownBy(() -> service.edit(1L, 7L, "admin", request))
.isInstanceOf(BizException.class).hasMessageContaining("未关联至所选订舱");
}
@Test void createRejectsMissingContainerBeforeLoadingPlanValidation() {
SaveRequest request = validRequest();
when(request.loadingPlanId()).thenReturn(77L);
when(request.containerBookingId()).thenReturn(null);
assertThatThrownBy(() -> service.create(7L, "admin", request))
.isInstanceOf(BizException.class).hasMessageContaining("containerBookingId");
verify(repository, never()).insert(any());
}
@Test void createRejectsLoadingPlanFromAnotherContainer() {
SaveRequest request = mock(SaveRequest.class);
when(request.expectedVersion()).thenReturn(0);
when(request.oceanBookingId()).thenReturn(99L);
when(request.containerBookingId()).thenReturn(88L);
when(request.loadingPlanId()).thenReturn(77L);
when(request.carrierId()).thenReturn(8L);
when(request.plannedLoadingAt()).thenReturn(LocalDateTime.of(2026, 8, 17, 10, 0));
when(request.warehouseId()).thenReturn(null);
OceanBookingEntity booking = releasedBooking();
when(oceanBookings.find(99L)).thenReturn(booking);
ContainerBookingEntity container = realContainer();
when(containers.find(88L)).thenReturn(container);
var allocation = new OceanBookingContainerAllocationEntity();
allocation.setContainerBookingId(88L);
when(oceanBookings.allocations(99L)).thenReturn(java.util.List.of(allocation));
LoadingPlanEntity plan = new LoadingPlanEntity(); plan.setId(78L); plan.setContainerBookingId(88L);
when(loadingPlans.planByBooking(88L)).thenReturn(plan);
assertThatThrownBy(() -> service.create(7L, "admin", request))
.isInstanceOf(BizException.class).hasMessageContaining("loadingPlanId").hasMessageContaining("当前装柜计划");
verify(repository, never()).insert(any());
}
@Test void createRejectsUnknownLoadingPlan() {
SaveRequest request = mock(SaveRequest.class);
when(request.expectedVersion()).thenReturn(0);
when(request.oceanBookingId()).thenReturn(99L);
when(request.containerBookingId()).thenReturn(88L);
when(request.loadingPlanId()).thenReturn(77L);
when(request.carrierId()).thenReturn(8L);
when(request.plannedLoadingAt()).thenReturn(LocalDateTime.of(2026, 8, 17, 10, 0));
when(request.warehouseId()).thenReturn(null);
OceanBookingEntity booking = releasedBooking();
when(oceanBookings.find(99L)).thenReturn(booking);
ContainerBookingEntity container = realContainer();
when(containers.find(88L)).thenReturn(container);
var allocation = new OceanBookingContainerAllocationEntity();
allocation.setContainerBookingId(88L);
when(oceanBookings.allocations(99L)).thenReturn(java.util.List.of(allocation));
when(loadingPlans.planByBooking(88L)).thenReturn(null);
assertThatThrownBy(() -> service.create(7L, "admin", request))
.isInstanceOf(BizException.class).hasMessageContaining("loadingPlanId").hasMessageContaining("当前装柜计划");
verify(repository, never()).insert(any());
}
@Test void createRejectsCompletedLoadingContainerEvenWhenAppointmentIsTerminal() {
SaveRequest request = validRequest();
LoadingPlanEntity completed = new LoadingPlanEntity(); completed.setId(77L);
completed.setContainerBookingId(88L); completed.setPlanStatus("COMPLETED");
when(loadingPlans.planByBooking(88L)).thenReturn(completed);
assertThatThrownBy(() -> service.create(7L, "admin", request))
.isInstanceOf(BizException.class).hasMessageContaining("已装柜完成");
verify(repository, never()).insert(any());
verify(repository, never()).history(any());
}
@Test void actionAdvancesMainLifecycleAndWritesIdempotentOperation() {
@@ -420,7 +289,7 @@ class TruckAppointmentServiceTest {
when(repository.transition(1L, "DRAFT", "PENDING_DISPATCH", 0)).thenReturn(1);
when(repository.completeOperation(eq(1L), eq("SUBMIT"), eq("idem-1"), anyString())).thenReturn(1);
var result = service.action(1L, "SUBMIT", new ActionRequest(0, "提交", null), "idem-1", null, 7L, "admin");
var result = service.action(1L, "SUBMIT", new ActionRequest(0, "提交"), "idem-1", null, 7L, "admin");
assertThat(result.getStatus()).isEqualTo(TruckAppointmentStatus.PENDING_DISPATCH);
assertThat(result.getVersion()).isEqualTo(1);
@@ -434,12 +303,12 @@ class TruckAppointmentServiceTest {
TruckAppointmentEntity completed = entity(TruckAppointmentStatus.PENDING_DISPATCH, 1);
TruckAppointmentOperationEntity operation = new TruckAppointmentOperationEntity();
operation.setAppointmentId(1L); operation.setAction("SUBMIT"); operation.setIdempotencyKey("idem-1");
String hash = requestHash("SUBMIT", 1L, new ActionRequest(0, "提交", null));
String hash = requestHash("SUBMIT", 1L, new ActionRequest(0, "提交"));
operation.setRequestHash(hash); operation.setResponseSnapshot(objectMapper.writeValueAsString(completed));
when(repository.claimOperation(anyLong(), eq(1L), eq("SUBMIT"), eq("idem-1"), eq(hash))).thenReturn(false);
when(repository.operation("idem-1")).thenReturn(operation);
var replay = service.action(1L, "SUBMIT", new ActionRequest(0, "提交", null), "idem-1", hash, 7L, "admin");
var replay = service.action(1L, "SUBMIT", new ActionRequest(0, "提交"), "idem-1", hash, 7L, "admin");
assertThat(replay.getStatus()).isEqualTo(TruckAppointmentStatus.PENDING_DISPATCH);
verify(repository, never()).transition(anyLong(), anyString(), anyString(), anyInt());
@@ -447,7 +316,7 @@ class TruckAppointmentServiceTest {
}
@Test void concurrentSameKeyBeforeFirstCompletionReturnsRetryableConflictWithoutDrift() throws Exception {
var request = new ActionRequest(0, "并发提交", null);
var request = new ActionRequest(0, "并发提交");
String hash = requestHash("SUBMIT", 1L, request);
TruckAppointmentOperationEntity processing = new TruckAppointmentOperationEntity();
processing.setAppointmentId(1L); processing.setAction("SUBMIT");
@@ -472,7 +341,7 @@ class TruckAppointmentServiceTest {
.thenReturn(false);
when(repository.operation("reused-key")).thenReturn(existing);
assertThatThrownBy(() -> service.action(1L, "SUBMIT", new ActionRequest(0, "载荷A", null),
assertThatThrownBy(() -> service.action(1L, "SUBMIT", new ActionRequest(0, "载荷A"),
"reused-key", null, 7L, "admin"))
.isInstanceOf(BizException.class).hasMessageContaining("不同请求");
@@ -488,7 +357,7 @@ class TruckAppointmentServiceTest {
when(repository.claimOperation(anyLong(), eq(1L), eq("DISPATCH"), eq("dispatch-no-bill"), anyString())).thenReturn(true);
when(repository.find(1L)).thenReturn(missingBill);
assertThatThrownBy(() -> service.action(1L, "DISPATCH", new ActionRequest(1, null, null),
assertThatThrownBy(() -> service.action(1L, "DISPATCH", new ActionRequest(1, null),
"dispatch-no-bill", null, 7L, "admin"))
.isInstanceOf(BizException.class).hasMessageContaining("提单号");
@@ -500,7 +369,7 @@ class TruckAppointmentServiceTest {
doThrow(new BizException(com.baiyue.common.core.api.ResultCode.CONFLICT,"订舱缺少有效订舱确认或提单文件"))
.when(oceanBookingDocuments).requireActiveValidDocument(any());
assertThatThrownBy(() -> service.action(1L, "DISPATCH", new ActionRequest(1, null, null),
assertThatThrownBy(() -> service.action(1L, "DISPATCH", new ActionRequest(1, null),
"dispatch-no-file", null, 7L, "admin"))
.isInstanceOf(BizException.class).hasMessageContaining("有效订舱确认或提单文件");
verify(repository, never()).dispatch(anyLong(), anyString(), anyString(), anyInt());
@@ -513,15 +382,13 @@ class TruckAppointmentServiceTest {
after.setOceanBookingId(99L); after.setContainerBookingId(88L); after.setBillOfLadingNo("BL-BOOKING-1");
OceanBookingEntity booking = new OceanBookingEntity();
booking.setId(99L); booking.setStatus("RELEASED"); booking.setBillOfLadingNo("BL-BOOKING-1");
ContainerBookingEntity internalContainer = realContainer(); internalContainer.setContainerNo(null);
when(repository.claimOperation(anyLong(), eq(1L), eq("DISPATCH"), eq("dispatch-ok"), anyString())).thenReturn(true);
when(repository.find(1L)).thenReturn(before, after);
when(oceanBookings.find(99L)).thenReturn(booking);
when(containers.find(88L)).thenReturn(internalContainer);
when(repository.dispatch(1L, "PENDING_DISPATCH", "BL-BOOKING-1", 1)).thenReturn(1);
when(repository.completeOperation(eq(1L), eq("DISPATCH"), eq("dispatch-ok"), anyString())).thenReturn(1);
TruckAppointmentEntity result = service.action(1L, "DISPATCH", new ActionRequest(1, null, null),
TruckAppointmentEntity result = service.action(1L, "DISPATCH", new ActionRequest(1, null),
"dispatch-ok", null, 7L, "admin");
assertThat(result.getStatus()).isEqualTo(TruckAppointmentStatus.DISPATCHED);
@@ -533,166 +400,24 @@ class TruckAppointmentServiceTest {
@Test void pickupWithoutContainerNumberStillAdvancesWithoutBinding() {
TruckAppointmentEntity before = entity(TruckAppointmentStatus.DISPATCHED, 2);
before.setOceanBookingId(99L); before.setContainerBookingId(88L);
before.setOceanBookingId(99L);
TruckAppointmentEntity after = entity(TruckAppointmentStatus.PICKUP_IN_PROGRESS, 3);
after.setOceanBookingId(99L); after.setContainerBookingId(88L);
ContainerBookingEntity container = realContainer(); container.setContainerNo(null);
after.setOceanBookingId(99L);
when(repository.find(1L)).thenReturn(before, after);
when(repository.claimOperation(anyLong(), eq(1L), eq("PICKUP"), eq("pickup-without-number"), anyString())).thenReturn(true);
when(containers.findActiveForUpdate(88L)).thenReturn(container);
when(repository.transition(1L, "DISPATCHED", "PICKUP_IN_PROGRESS", 2)).thenReturn(1);
when(repository.completeOperation(eq(1L), eq("PICKUP"), eq("pickup-without-number"), anyString())).thenReturn(1);
TruckAppointmentEntity result = service.action(1L, "PICKUP", new ActionRequest(2, "提柜", null),
TruckAppointmentEntity result = service.action(1L, "PICKUP", new ActionRequest(2, "提柜"),
"pickup-without-number", null, 7L, "admin");
assertThat(result.getStatus()).isEqualTo(TruckAppointmentStatus.PICKUP_IN_PROGRESS);
verify(repository, never()).bindContainerNoForPickup(anyLong(), anyString(), anyLong(), anyString());
verify(repository).transition(1L, "DISPATCHED", "PICKUP_IN_PROGRESS", 2);
verify(repository).history(any(TruckAppointmentHistoryEntity.class));
}
@Test void pickupRejectsSuppliedInvalidContainerNumberWithoutDrift() {
TruckAppointmentEntity before = entity(TruckAppointmentStatus.DISPATCHED, 2);
before.setOceanBookingId(99L); before.setContainerBookingId(88L);
ContainerBookingEntity container = realContainer(); container.setContainerNo(null);
when(repository.find(1L)).thenReturn(before);
when(repository.claimOperation(anyLong(), eq(1L), eq("PICKUP"), eq("pickup-invalid"), anyString())).thenReturn(true);
when(containers.findActiveForUpdate(88L)).thenReturn(container);
assertThatThrownBy(() -> service.action(1L, "PICKUP", new ActionRequest(2, "提柜", "MSCU6639871"),
"pickup-invalid", null, 7L, "admin"))
.isInstanceOf(BizException.class).hasMessageContaining("ISO 6346");
verify(repository, never()).bindContainerNoForPickup(anyLong(), anyString(), anyLong(), anyString());
verify(repository, never()).transition(anyLong(), anyString(), anyString(), anyInt());
verify(repository, never()).history(any());
}
@Test void pickupNormalizesAndAtomicallyBindsContainerNumberBeforeTransition() {
TruckAppointmentEntity before = entity(TruckAppointmentStatus.DISPATCHED, 2);
before.setOceanBookingId(99L); before.setContainerBookingId(88L);
TruckAppointmentEntity after = entity(TruckAppointmentStatus.PICKUP_IN_PROGRESS, 3);
after.setOceanBookingId(99L); after.setContainerBookingId(88L);
ContainerBookingEntity container = realContainer(); container.setContainerNo(null);
when(repository.find(1L)).thenReturn(before, after);
when(repository.claimOperation(anyLong(), eq(1L), eq("PICKUP"), eq("pickup-bind"), anyString())).thenReturn(true);
when(containers.findActiveForUpdate(88L)).thenReturn(container);
when(containers.containerNoExists("MSCU6639870", 88L)).thenReturn(false);
when(repository.bindContainerNoForPickup(88L, "MSCU6639870", 7L, "admin")).thenReturn(1);
when(repository.transition(1L, "DISPATCHED", "PICKUP_IN_PROGRESS", 2)).thenReturn(1);
when(repository.completeOperation(eq(1L), eq("PICKUP"), eq("pickup-bind"), anyString())).thenReturn(1);
TruckAppointmentEntity result = service.action(1L, "PICKUP",
new ActionRequest(2, "司机已提柜", " ms cu 663987 0 "), "pickup-bind", null, 7L, "admin");
assertThat(result.getStatus()).isEqualTo(TruckAppointmentStatus.PICKUP_IN_PROGRESS);
InOrder writes = inOrder(repository);
writes.verify(repository).bindContainerNoForPickup(88L, "MSCU6639870", 7L, "admin");
writes.verify(repository).transition(1L, "DISPATCHED", "PICKUP_IN_PROGRESS", 2);
writes.verify(repository).history(any(TruckAppointmentHistoryEntity.class));
}
@Test void pickupReusesExistingValidContainerNumberWithoutRewritingIt() {
TruckAppointmentEntity before = entity(TruckAppointmentStatus.DISPATCHED, 2);
before.setOceanBookingId(99L); before.setContainerBookingId(88L);
TruckAppointmentEntity after = entity(TruckAppointmentStatus.PICKUP_IN_PROGRESS, 3);
after.setOceanBookingId(99L); after.setContainerBookingId(88L);
when(repository.find(1L)).thenReturn(before, after);
when(repository.claimOperation(anyLong(), eq(1L), eq("PICKUP"), eq("pickup-reuse"), anyString())).thenReturn(true);
when(containers.findActiveForUpdate(88L)).thenReturn(realContainer());
when(repository.transition(1L, "DISPATCHED", "PICKUP_IN_PROGRESS", 2)).thenReturn(1);
when(repository.completeOperation(eq(1L), eq("PICKUP"), eq("pickup-reuse"), anyString())).thenReturn(1);
service.action(1L, "PICKUP", new ActionRequest(2, "复用已有柜号", null),
"pickup-reuse", null, 7L, "admin");
verify(repository, never()).bindContainerNoForPickup(anyLong(), anyString(), anyLong(), anyString());
verify(repository).transition(1L, "DISPATCHED", "PICKUP_IN_PROGRESS", 2);
}
@Test void pickupIdempotentReplayDoesNotLockOrRewriteContainerAndChangedNumberConflicts() throws Exception {
ActionRequest request = new ActionRequest(2, "提柜", "MSCU6639870");
TruckAppointmentEntity completed = entity(TruckAppointmentStatus.PICKUP_IN_PROGRESS, 3);
completed.setOceanBookingId(99L); completed.setContainerBookingId(88L);
TruckAppointmentOperationEntity operation = new TruckAppointmentOperationEntity();
operation.setAppointmentId(1L); operation.setAction("PICKUP"); operation.setIdempotencyKey("pickup-replay");
operation.setRequestHash(requestHash("PICKUP", 1L, request));
operation.setResponseSnapshot(objectMapper.writeValueAsString(completed));
when(repository.claimOperation(anyLong(), eq(1L), eq("PICKUP"), eq("pickup-replay"), anyString()))
.thenReturn(false);
when(repository.operation("pickup-replay")).thenReturn(operation);
TruckAppointmentEntity replay = service.action(1L, "PICKUP", request,
"pickup-replay", operation.getRequestHash(), 7L, "admin");
assertThat(replay.getStatus()).isEqualTo(TruckAppointmentStatus.PICKUP_IN_PROGRESS);
assertThatThrownBy(() -> service.action(1L, "PICKUP",
new ActionRequest(2, "提柜", "TSTU1503306"), "pickup-replay", null, 7L, "admin"))
.isInstanceOf(BizException.class).hasMessageContaining("不同请求");
verify(containers, never()).findActiveForUpdate(anyLong());
verify(repository, never()).bindContainerNoForPickup(anyLong(), anyString(), anyLong(), anyString());
verify(repository, never()).transition(anyLong(), anyString(), anyString(), anyInt());
}
@Test void pickupRejectsMismatchedOrDuplicateContainerNumberBeforeStatusDrift() {
TruckAppointmentEntity before = entity(TruckAppointmentStatus.DISPATCHED, 2);
before.setOceanBookingId(99L); before.setContainerBookingId(88L);
when(repository.find(1L)).thenReturn(before);
when(repository.claimOperation(anyLong(), eq(1L), eq("PICKUP"), anyString(), anyString())).thenReturn(true);
when(containers.findActiveForUpdate(88L)).thenReturn(realContainer());
assertThatThrownBy(() -> service.action(1L, "PICKUP",
new ActionRequest(2, "错误柜号", "TSTU1503306"), "pickup-mismatch", null, 7L, "admin"))
.isInstanceOf(BizException.class).hasMessageContaining("不一致").hasMessageContaining("预装纠错");
ContainerBookingEntity unnumbered = realContainer(); unnumbered.setContainerNo(null);
when(containers.findActiveForUpdate(88L)).thenReturn(unnumbered);
when(containers.containerNoExists("TSTU1503306", 88L)).thenReturn(true);
assertThatThrownBy(() -> service.action(1L, "PICKUP",
new ActionRequest(2, "重复柜号", "TSTU1503306"), "pickup-duplicate", null, 7L, "admin"))
.isInstanceOf(BizException.class).hasMessageContaining("其他柜次使用");
verify(repository, never()).transition(anyLong(), anyString(), anyString(), anyInt());
verify(repository, never()).history(any());
}
@Test void concurrentPickupContainerNumberCollisionRollsBackBeforeStatusTransition() {
TruckAppointmentEntity before = entity(TruckAppointmentStatus.DISPATCHED, 2);
before.setOceanBookingId(99L); before.setContainerBookingId(88L);
ContainerBookingEntity container = realContainer(); container.setContainerNo(null);
when(repository.find(1L)).thenReturn(before);
when(repository.claimOperation(anyLong(), eq(1L), eq("PICKUP"), eq("pickup-concurrent"), anyString())).thenReturn(true);
when(containers.findActiveForUpdate(88L)).thenReturn(container);
when(containers.containerNoExists("MSCU6639870", 88L)).thenReturn(false);
doThrow(new DuplicateKeyException("uk_container_booking_container_no"))
.when(repository).bindContainerNoForPickup(88L, "MSCU6639870", 7L, "admin");
assertThatThrownBy(() -> service.action(1L, "PICKUP",
new ActionRequest(2, "并发提柜", "MSCU6639870"), "pickup-concurrent", null, 7L, "admin"))
.isInstanceOf(BizException.class).hasMessageContaining("其他柜次使用");
verify(repository, never()).transition(anyLong(), anyString(), anyString(), anyInt());
verify(repository, never()).history(any());
verify(repository, never()).completeOperation(anyLong(), anyString(), anyString(), anyString());
}
@Test void nonPickupActionsRejectContainerNumberWithoutWritingIt() {
TruckAppointmentEntity before = entity(TruckAppointmentStatus.DRAFT, 0);
when(repository.find(1L)).thenReturn(before);
when(repository.claimOperation(anyLong(), eq(1L), eq("SUBMIT"), eq("submit-with-container"), anyString())).thenReturn(true);
assertThatThrownBy(() -> service.action(1L, "SUBMIT",
new ActionRequest(0, "错误携带柜号", "MSCU6639870"), "submit-with-container", null, 7L, "admin"))
.isInstanceOf(BizException.class).hasMessageContaining("仅PICKUP动作");
verify(repository, never()).bindContainerNoForPickup(anyLong(), anyString(), anyLong(), anyString());
verify(repository, never()).transition(anyLong(), anyString(), anyString(), anyInt());
verify(repository, never()).history(any());
}
@Test void actionRejectsMissingKeyAndMismatchedClientHash() {
var request = new ActionRequest(0, "提交", null);
var request = new ActionRequest(0, "提交");
assertThatThrownBy(() -> service.action(1L, "SUBMIT", request, null, null, 7L, "admin"))
.isInstanceOf(BizException.class).hasMessageContaining("Idempotency-Key");
assertThatThrownBy(() -> service.action(1L, "SUBMIT", request, "idem-1", "wrong", 7L, "admin"))
@@ -704,11 +429,11 @@ class TruckAppointmentServiceTest {
TruckAppointmentEntity e = entity(TruckAppointmentStatus.DRAFT, 0);
when(repository.find(1L)).thenReturn(e);
when(repository.claimOperation(anyLong(), eq(1L), eq("RESCHEDULE"), eq("idem-action"), anyString())).thenReturn(true);
assertThatThrownBy(() -> service.action(1L, "RESCHEDULE", new ActionRequest(0, null, null), "idem-action", null, 7L, "admin"))
assertThatThrownBy(() -> service.action(1L, "RESCHEDULE", new ActionRequest(0, null), "idem-action", null, 7L, "admin"))
.isInstanceOf(BizException.class).hasMessageContaining("专用接口");
e.setStatus(TruckAppointmentStatus.COMPLETED);
when(repository.claimOperation(anyLong(), eq(1L), eq("RESCHEDULE"), eq("idem-reschedule"), anyString())).thenReturn(true);
assertThatThrownBy(() -> service.reschedule(1L, LocalDateTime.now(), new ActionRequest(0, "改约", null), "idem-reschedule", null, 7L, "admin"))
assertThatThrownBy(() -> service.reschedule(1L, LocalDateTime.now(), new ActionRequest(0, "改约"), "idem-reschedule", null, 7L, "admin"))
.isInstanceOf(BizException.class).hasMessageContaining("不可改约");
verify(repository, never()).reschedule(anyLong(), any(), anyString(), anyInt());
}
@@ -721,7 +446,7 @@ class TruckAppointmentServiceTest {
when(repository.reschedule(eq(1L), any(), eq("改约"), eq(2))).thenReturn(1);
when(repository.completeOperation(eq(1L), eq("RESCHEDULE"), eq("idem-reschedule"), anyString())).thenReturn(1);
var at = LocalDateTime.of(2026, 8, 12, 10, 0);
var result = service.reschedule(1L, at, new ActionRequest(2, "改约", null), "idem-reschedule", null, 7L, "admin");
var result = service.reschedule(1L, at, new ActionRequest(2, "改约"), "idem-reschedule", null, 7L, "admin");
assertThat(result.getStatus()).isEqualTo(TruckAppointmentStatus.RESCHEDULED);
assertThat(result.getVersion()).isEqualTo(3);
ArgumentCaptor<TruckAppointmentHistoryEntity> history = ArgumentCaptor.forClass(TruckAppointmentHistoryEntity.class);
@@ -730,56 +455,6 @@ class TruckAppointmentServiceTest {
assertThat(history.getValue().getToStatus()).isEqualTo("RESCHEDULED");
}
@Test void pdaCompletionDoesNotRequireTruckAppointment() {
when(repository.activeForUpdate(99L)).thenReturn(List.of());
service.completeLoadingFromPda(99L, 20L, 7L, "admin");
verify(repository, never()).completeLoadingFromPda(anyLong(), anyString(), anyInt());
verify(repository, never()).history(any());
}
@Test void pdaCompletionLocksAndCompletesTheOnlyActiveTruckWithHistory() {
TruckAppointmentEntity before = entity(TruckAppointmentStatus.LOADING, 4);
TruckAppointmentEntity after = entity(TruckAppointmentStatus.COMPLETED, 5);
when(repository.activeForUpdate(99L)).thenReturn(List.of(before));
when(repository.completeLoadingFromPda(1L, "LOADING", 4)).thenReturn(1);
when(repository.find(1L)).thenReturn(after);
service.completeLoadingFromPda(99L, 20L, 7L, "admin");
verify(repository).completeLoadingFromPda(1L, "LOADING", 4);
ArgumentCaptor<TruckAppointmentHistoryEntity> history = ArgumentCaptor.forClass(TruckAppointmentHistoryEntity.class);
verify(repository).history(history.capture());
assertThat(history.getValue().getFromStatus()).isEqualTo("LOADING");
assertThat(history.getValue().getToStatus()).isEqualTo("COMPLETED");
assertThat(history.getValue().getAction()).isEqualTo("PDA_COMPLETE_LOADING");
assertThat(history.getValue().getOperatorId()).isEqualTo(7L);
assertThat(history.getValue().getReason()).contains("20");
}
@Test void pdaCompletionRejectsMultipleActiveTruckAppointmentsWithoutMutation() {
when(repository.activeForUpdate(99L)).thenReturn(List.of(
entity(TruckAppointmentStatus.LOADING, 1), entity(TruckAppointmentStatus.DEPARTED, 2)));
assertThatThrownBy(() -> service.completeLoadingFromPda(99L, 20L, 7L, "admin"))
.isInstanceOf(BizException.class).hasMessageContaining("多条有效拖车预约");
verify(repository, never()).completeLoadingFromPda(anyLong(), anyString(), anyInt());
verify(repository, never()).history(any());
}
@Test void pdaCompletionRejectsConcurrentTruckModificationWithoutHistory() {
TruckAppointmentEntity before = entity(TruckAppointmentStatus.RESCHEDULED, 4);
when(repository.activeForUpdate(99L)).thenReturn(List.of(before));
when(repository.completeLoadingFromPda(1L, "RESCHEDULED", 4)).thenReturn(0);
assertThatThrownBy(() -> service.completeLoadingFromPda(99L, 20L, 7L, "admin"))
.isInstanceOf(BizException.class).hasMessageContaining("并发修改");
verify(repository, never()).history(any());
}
private String requestHash(String action, long id, Object request) throws Exception {
String canonical = action + '|' + id + '|' + objectMapper.writeValueAsString(request);
return java.util.HexFormat.of().formatHex(java.security.MessageDigest.getInstance("SHA-256")
@@ -804,8 +479,6 @@ class TruckAppointmentServiceTest {
when(request.carrierId()).thenReturn(8L);
when(request.plannedLoadingAt()).thenReturn(LocalDateTime.of(2026, 8, 17, 10, 0));
when(request.oceanBookingId()).thenReturn(99L);
when(request.containerBookingId()).thenReturn(88L);
when(request.loadingPlanId()).thenReturn(null);
when(request.warehouseId()).thenReturn(null);
return request;
}
@@ -817,10 +490,4 @@ class TruckAppointmentServiceTest {
return booking;
}
private static ContainerBookingEntity realContainer() {
ContainerBookingEntity container = new ContainerBookingEntity();
container.setId(88L); container.setRecordStatus("ACTIVE");
container.setWarehouseId(18L); container.setOceanBookingId(99L); container.setContainerNo("MSCU6639870");
return container;
}
}
+19 -8
View File
@@ -142,17 +142,28 @@ Assert-Contains $orderAgents 'InboundCodeAssociationService.java' 'Order 入仓
Assert-Contains $firstlegAgents '/loading-plans/{planId}/lifecycle' 'Firstleg 装柜生命周期入口'
$fileAgents = Read-RequiredText (Join-Path $backendRoot 'file/AGENTS.md')
$truckOpenApi = Read-RequiredText (Join-Path $backendRoot 'firstleg/src/main/resources/openapi/truck-appointments-v1.yaml')
$loadingListsOpenApi = Read-RequiredText (Join-Path $backendRoot 'firstleg/src/main/resources/openapi/loading-lists-v1.yaml')
$pdaLoadingApi = Read-RequiredText (Join-Path $workspaceRoot 'apps/pda-uniapp/api/pda-loading.js')
Assert-Contains $rootAgents '拖车预约由已放舱订舱拥有,不按客户归属' '根订舱拥有拖车语义'
Assert-Contains $rootAgents '拖车预约由 `RELEASED` 订舱拥有,不按客户、柜次、装柜计划或真实柜号归属' '根订舱级拖车语义'
Assert-Contains $rootAgents '证据以 `taskId + warehouseId` 进入任务专用 API' '根柜级证据入口'
Assert-Contains $adminAgents '订舱拥有的拖车预约' 'Admin 拖车快速入口'
Assert-Contains $adminAgents '不选择或提交客户' 'Admin 拖车无客户语义'
Assert-Contains $backendAgents '订舱拥有的拖车' '后端拖车快速入口'
Assert-Contains $firstlegAgents '请求、实体、查询和附件不得恢复 `customerId/customerName`' 'Firstleg 拖车无客户边界'
Assert-Contains $firstlegAgents '同一真实柜通过查询锁与唯一约束最多保留一条非取消预约' 'Firstleg 拖车唯一有效预约'
Assert-Contains $adminAgents '订舱级拖车预约' 'Admin 拖车快速入口'
Assert-Contains $adminAgents '不选择或提交客户、柜次、装柜计划或真实集装箱号' 'Admin 拖车订舱级边界'
Assert-Contains $backendAgents '订舱级拖车' '后端拖车快速入口'
Assert-Contains $firstlegAgents '请求、实体、查询、响应和附件不得恢复 `customerId/customerName`' 'Firstleg 拖车无客户边界'
Assert-Contains $firstlegAgents '公开 DTO 也不得出现 `containerBookingId/loadingPlanId/cabinetNo/containerNo`' 'Firstleg 拖车无柜次边界'
Assert-Contains $firstlegAgents '每订舱通过查询锁与唯一约束最多一张非终态预约' 'Firstleg 拖车唯一有效预约'
Assert-Contains $fileAgents '`businessType=TRUCK_APPOINTMENT_ATTACHMENT`' 'File 拖车平台附件入口'
Assert-Contains $truckOpenApi 'required: [oceanBookingId, containerBookingId, carrierId, plannedLoadingAt, expectedVersion]' '拖车请求无客户字段'
Assert-Contains $truckOpenApi 'required: [oceanBookingId, carrierId, plannedLoadingAt, expectedVersion]' '拖车订舱级请求字段'
Assert-NotContains $truckOpenApi 'customerId:' '拖车 OpenAPI 客户字段'
Assert-NotContains $truckOpenApi 'containerBookingId:' '拖车 OpenAPI 柜次字段'
Assert-NotContains $truckOpenApi 'loadingPlanId:' '拖车 OpenAPI 装柜计划字段'
Assert-Contains $truckOpenApi 'Each booking may have at most one non-terminal appointment' '拖车订舱级唯一性'
Assert-Contains $truckOpenApi 'PDA loading completion does not change a booking-level truck appointment' '拖车与 PDA 生命周期解耦'
Assert-Contains $truckOpenApi 'excluding bookings that already have a non-terminal truck appointment' '拖车创建候选过滤'
Assert-Contains $loadingListsOpenApi '真实集装箱号可在PDA确认装柜完成前补录' '预装真实柜号晚录门禁'
Assert-Contains $loadingListsOpenApi '已推送后可在 EDITING 或 LOADING 阶段首次补录真实集装箱号' '预装真实柜号首次补录阶段'
Assert-Contains $loadingListsOpenApi '不依赖真实集装箱号,task 已创建后仍为 true' 'PDA task 不依赖真实柜号'
Assert-Contains $adminAgents 'PDA 最终完成前必须补齐' 'Admin 真实柜号最终门禁'
Assert-Contains $pdaAgents '当前 `taskId + warehouseId` 走任务专用 API' '国内 PDA 柜级证据入口'
Assert-Contains $pdaLoadingApi '/first-leg/pda-loading/tasks/${encodeURIComponent(taskId)}/evidence?warehouseId=' '国内 PDA 证据查询 API'
Assert-NotContains $pdaLoadingApi '/inbounds/${encodeURIComponent(inboundId)}/loading/evidence' '国内 PDA 旧入仓证据 API'
@@ -732,5 +743,5 @@ if ($failures.Count -gt 0) {
exit 1
}
Write-Host "AGENTS 索引合同通过:$checked 项检查,覆盖快速表唯一性、部署单元、前端模块、Client active 路由、业务编号与内部 ID 隔离、装柜清单生命周期、订舱拥有拖车与柜级证据、四期范围漂移、仓库主档唯一权威、订舱候选与柜量语义、整柜出仓与船节点单调性、Gateway 覆盖、PDA 页面/运行配置/独立更新/语言预留/能力开关、人工分段验收链、Maven 模块、Lastleg 海外安全投影、同步 Trace、关键入口和排除目录。" -ForegroundColor Green
Write-Host "AGENTS 索引合同通过:$checked 项检查,覆盖快速表唯一性、部署单元、前端模块、Client active 路由、脚本目录治理、业务编号与内部 ID 隔离、装柜清单生命周期与真实柜号后补、订舱级拖车与柜级证据、四期范围漂移、仓库主档唯一权威、订舱候选与柜量语义、整柜出仓与船节点单调性、Gateway 覆盖、PDA 页面/运行配置/独立更新/语言预留/能力开关、人工分段验收链、Maven 模块、Lastleg 海外安全投影、同步 Trace、关键入口和排除目录。" -ForegroundColor Green
exit 0