diff --git a/AGENTS.md b/AGENTS.md index 2b22b6897..631c78982 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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/`;服务专属脚本留在 `/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` | diff --git a/deploy/sql/头程数据库统一增量-MySQL8.sql b/deploy/sql/头程数据库统一增量-MySQL8.sql index 266bf326b..3b426487e 100644 --- a/deploy/sql/头程数据库统一增量-MySQL8.sql +++ b/deploy/sql/头程数据库统一增量-MySQL8.sql @@ -9451,7 +9451,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 '车辆车牌号', @@ -9459,10 +9459,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='三期拖车预约派车聚合'; @@ -9520,16 +9520,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; @@ -14124,12 +14134,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', '司机身份证号;平台内部敏感信息'); @@ -14139,7 +14149,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', '预约货物包装件数快照'); diff --git a/firstleg/AGENTS.md b/firstleg/AGENTS.md index 886ad6a8e..ca6dfdc80 100644 --- a/firstleg/AGENTS.md +++ b/firstleg/AGENTS.md @@ -29,7 +29,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 的当前唯一版本 | @@ -41,7 +41,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` | @@ -64,7 +64,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`,不允许单入仓局部成功。 diff --git a/firstleg/src/main/java/com/baiyue/firstleg/dto/TruckAppointmentDtos.java b/firstleg/src/main/java/com/baiyue/firstleg/dto/TruckAppointmentDtos.java index 75c569f10..97284b0c0 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/dto/TruckAppointmentDtos.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/dto/TruckAppointmentDtos.java @@ -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){ } } diff --git a/firstleg/src/main/java/com/baiyue/firstleg/entity/TruckAppointmentEntity.java b/firstleg/src/main/java/com/baiyue/firstleg/entity/TruckAppointmentEntity.java index 3d8b72ee1..91def5387 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/entity/TruckAppointmentEntity.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/entity/TruckAppointmentEntity.java @@ -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; } diff --git a/firstleg/src/main/java/com/baiyue/firstleg/mapper/TruckAppointmentMapper.java b/firstleg/src/main/java/com/baiyue/firstleg/mapper/TruckAppointmentMapper.java index 12d1aa57a..1c931bbc0 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/mapper/TruckAppointmentMapper.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/mapper/TruckAppointmentMapper.java @@ -5,34 +5,22 @@ import com.baomidou.mybatisplus.core.mapper.BaseMapper; import com.baomidou.myba """) Page candidates(Page 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 activeForUpdate(@Param("containerBookingId") long containerBookingId); - @Select("") Page page(Page 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("") Page page(Page 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); } diff --git a/firstleg/src/main/java/com/baiyue/firstleg/outbound/dispatch/mapper/OutboundDispatchReadMapper.java b/firstleg/src/main/java/com/baiyue/firstleg/outbound/dispatch/mapper/OutboundDispatchReadMapper.java index e8e28ff55..0ef6aacdd 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/outbound/dispatch/mapper/OutboundDispatchReadMapper.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/outbound/dispatch/mapper/OutboundDispatchReadMapper.java @@ -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 diff --git a/firstleg/src/main/java/com/baiyue/firstleg/outbound/dispatch/repository/OutboundDispatchRepository.java b/firstleg/src/main/java/com/baiyue/firstleg/outbound/dispatch/repository/OutboundDispatchRepository.java index cf510a1e8..f3e023b7d 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/outbound/dispatch/repository/OutboundDispatchRepository.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/outbound/dispatch/repository/OutboundDispatchRepository.java @@ -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); diff --git a/firstleg/src/main/java/com/baiyue/firstleg/outbound/dispatch/service/OutboundDispatchService.java b/firstleg/src/main/java/com/baiyue/firstleg/outbound/dispatch/service/OutboundDispatchService.java index 0568fef73..3c88b2fd0 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/outbound/dispatch/service/OutboundDispatchService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/outbound/dispatch/service/OutboundDispatchService.java @@ -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 containerContributorGates = contributorGates(first, ocean); List inboundGates = new ArrayList<>(); diff --git a/firstleg/src/main/java/com/baiyue/firstleg/repository/TruckAppointmentRepository.java b/firstleg/src/main/java/com/baiyue/firstleg/repository/TruckAppointmentRepository.java index bdfca4c94..1228c3a30 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/repository/TruckAppointmentRepository.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/repository/TruckAppointmentRepository.java @@ -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 page(Query q){return PageResult.from(mapper.page(new Page<>(q.page(),q.size()),q));} public PageResult 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 page(Query q){return PageResult.from(mapper.page(new Page<>(q.page(),q.size()),q));} public PageResult 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 histories(long id){return history.selectList(new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper().eq(TruckAppointmentHistoryEntity::getAppointmentId,id).orderByAsc(TruckAppointmentHistoryEntity::getCreatedAt));} public java.util.List attachments(long id){return attachments.selectList(new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper().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().eq(TruckAppointmentAttachmentEntity::getAppointmentId,appointmentId).eq(TruckAppointmentAttachmentEntity::getId,attachmentId));} - public TruckAppointmentEntity firstActiveForUpdate(long containerBookingId){return mapper.firstActiveForUpdate(containerBookingId);} public java.util.List 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);} } diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingPickService.java b/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingPickService.java index d6b3d9f05..37cf9cbd4 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingPickService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingPickService.java @@ -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 operations(long taskId) { requiredTask(taskId); return repository.operations(taskId).stream().map(item -> new Operation(item.getId(), item.getPickTaskId(), diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/TruckAppointmentService.java b/firstleg/src/main/java/com/baiyue/firstleg/service/TruckAppointmentService.java index a19c2fec3..6c95dc6b8 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/TruckAppointmentService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/TruckAppointmentService.java @@ -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 page(Query q){return repo.page(q);} public PageResult candidates(CandidateQuery q){return repo.candidates(q);} public TruckAppointmentEntity detail(long id){return enrich(required(id));} + public PageResult page(Query q){PageResult page=repo.page(q);return new PageResult<>(page.records().stream().map(this::enrich).toList(),page.page(),page.size(),page.total(),page.pages());} public PageResult candidates(CandidateQuery q){return repo.candidates(q);} public TruckAppointmentEntity detail(long id){return enrich(required(id));} public java.util.List history(long id){required(id);return repo.histories(id);} public java.util.List 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 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);} } diff --git a/firstleg/src/main/resources/db/phase3-truck-appointment-schema.sql b/firstleg/src/main/resources/db/phase3-truck-appointment-schema.sql index d271e784e..1f4b0d9ee 100644 --- a/firstleg/src/main/resources/db/phase3-truck-appointment-schema.sql +++ b/firstleg/src/main/resources/db/phase3-truck-appointment-schema.sql @@ -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; diff --git a/firstleg/src/main/resources/openapi/truck-appointments-v1.yaml b/firstleg/src/main/resources/openapi/truck-appointments-v1.yaml index 4cea5d827..a6f6e6ce2 100644 --- a/firstleg/src/main/resources/openapi/truck-appointments-v1.yaml +++ b/firstleg/src/main/resources/openapi/truck-appointments-v1.yaml @@ -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} diff --git a/firstleg/src/test/java/com/baiyue/firstleg/controller/TruckAppointmentControllerContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/controller/TruckAppointmentControllerContractTest.java index 8df54537a..4e8e19389 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/controller/TruckAppointmentControllerContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/controller/TruckAppointmentControllerContractTest.java @@ -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 expected=Map.ofEntries( diff --git a/firstleg/src/test/java/com/baiyue/firstleg/outbound/dispatch/OutboundDispatchReadMapperContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/outbound/dispatch/OutboundDispatchReadMapperContractTest.java index 79814ad18..554119772 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/outbound/dispatch/OutboundDispatchReadMapperContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/outbound/dispatch/OutboundDispatchReadMapperContractTest.java @@ -67,7 +67,7 @@ class OutboundDispatchReadMapperContractTest { void oceanAndTruckQueriesAreResolvedOnceFromTheContainerRoot() throws Exception { String oceanSql = OutboundDispatchReadMapper.class.getMethod("ocean", long.class) .getAnnotation(Select.class).value()[0]; - String truckSql = OutboundDispatchReadMapper.class.getMethod("truck", long.class, long.class) + String truckSql = OutboundDispatchReadMapper.class.getMethod("truck", long.class) .getAnnotation(Select.class).value()[0]; assertThat(oceanSql) @@ -75,7 +75,10 @@ class OutboundDispatchReadMapperContractTest { .contains("booking.customer_id customerId") .doesNotContain("#{tenantId}"); assertThat(truckSql) - .contains("container_booking_id=#{containerBookingId}", "loading_plan_id=#{loadingPlanId}") + .contains("fl_truck_appointment appointment", "fl_ocean_booking_container_allocation allocation") + .contains("allocation.ocean_booking_id=appointment.ocean_booking_id") + .contains("allocation.container_booking_id=#{containerBookingId}") + .doesNotContain("loading_plan_id", "#{loadingPlanId}") .doesNotContain("#{tenantId}"); } diff --git a/firstleg/src/test/java/com/baiyue/firstleg/outbound/dispatch/OutboundDispatchServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/outbound/dispatch/OutboundDispatchServiceTest.java index 66f19b493..9e9897742 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/outbound/dispatch/OutboundDispatchServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/outbound/dispatch/OutboundDispatchServiceTest.java @@ -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)); } diff --git a/firstleg/src/test/java/com/baiyue/firstleg/repository/TruckAppointmentMapperContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/repository/TruckAppointmentMapperContractTest.java index a0b5cd5c8..b6a88d402 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/repository/TruckAppointmentMapperContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/repository/TruckAppointmentMapperContractTest.java @@ -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<>'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"); } diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingPickServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingPickServiceTest.java index 38c953f01..64f19c923 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingPickServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingPickServiceTest.java @@ -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() { diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/TruckAppointmentServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/TruckAppointmentServiceTest.java index cdcb39a55..124f90696 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/service/TruckAppointmentServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/TruckAppointmentServiceTest.java @@ -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 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 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 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 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; - } } diff --git a/lastleg-service/src/main/resources/db/lastleg.sql b/lastleg-service/src/main/resources/db/lastleg.sql index 71d0a3cdf..befd914cf 100644 --- a/lastleg-service/src/main/resources/db/lastleg.sql +++ b/lastleg-service/src/main/resources/db/lastleg.sql @@ -615,34 +615,34 @@ ALTER TABLE ll_inbound_plan_pallet_allocation MODIFY COLUMN allocated_volume_cbm DECIMAL(18,4) NOT NULL COMMENT '该明细分配到托盘的体积(立方米)'; -- Additive inbound picking schema. Guarded statements preserve existing rows on restart. -ALTER TABLE ll_inbound_plan MODIFY COLUMN status VARCHAR(32) NOT NULL DEFAULT 'PENDING_ASSIGNMENT'; -SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan_line' AND column_name='required_package_count'), 'SELECT 1', 'ALTER TABLE ll_inbound_plan_line ADD COLUMN required_package_count INT NOT NULL DEFAULT 0')); +ALTER TABLE ll_inbound_plan MODIFY COLUMN status VARCHAR(32) NOT NULL DEFAULT 'PENDING_ASSIGNMENT' COMMENT '入库计划状态:PENDING_ASSIGNMENT待分配及后续正式状态'; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan_line' AND column_name='required_package_count'), 'SELECT 1', 'ALTER TABLE ll_inbound_plan_line ADD COLUMN required_package_count INT NOT NULL DEFAULT 0 COMMENT ''计划明细应收箱数''')); PREPARE inbound_schema_stmt FROM @ddl; EXECUTE inbound_schema_stmt; DEALLOCATE PREPARE inbound_schema_stmt; -SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan_pallet' AND column_name='warehouse_pallet_id'), 'SELECT 1', 'ALTER TABLE ll_inbound_plan_pallet ADD COLUMN warehouse_pallet_id BIGINT NULL')); +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan_pallet' AND column_name='warehouse_pallet_id'), 'SELECT 1', 'ALTER TABLE ll_inbound_plan_pallet ADD COLUMN warehouse_pallet_id BIGINT NULL COMMENT ''海外仓实物托盘ID''')); PREPARE inbound_schema_stmt FROM @ddl; EXECUTE inbound_schema_stmt; DEALLOCATE PREPARE inbound_schema_stmt; -SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan_pallet' AND column_name='warehouse_location_id'), 'SELECT 1', 'ALTER TABLE ll_inbound_plan_pallet ADD COLUMN warehouse_location_id BIGINT NULL')); +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan_pallet' AND column_name='warehouse_location_id'), 'SELECT 1', 'ALTER TABLE ll_inbound_plan_pallet ADD COLUMN warehouse_location_id BIGINT NULL COMMENT ''海外仓库位ID''')); PREPARE inbound_schema_stmt FROM @ddl; EXECUTE inbound_schema_stmt; DEALLOCATE PREPARE inbound_schema_stmt; -SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan_pallet' AND column_name='warehouse_resource_lock_id'), 'SELECT 1', 'ALTER TABLE ll_inbound_plan_pallet ADD COLUMN warehouse_resource_lock_id BIGINT NULL')); +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan_pallet' AND column_name='warehouse_resource_lock_id'), 'SELECT 1', 'ALTER TABLE ll_inbound_plan_pallet ADD COLUMN warehouse_resource_lock_id BIGINT NULL COMMENT ''海外仓资源锁ID''')); PREPARE inbound_schema_stmt FROM @ddl; EXECUTE inbound_schema_stmt; DEALLOCATE PREPARE inbound_schema_stmt; -SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan_pallet' AND column_name='assignment_status'), 'SELECT 1', 'ALTER TABLE ll_inbound_plan_pallet ADD COLUMN assignment_status VARCHAR(32) NOT NULL DEFAULT ''UNASSIGNED''')); +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan_pallet' AND column_name='assignment_status'), 'SELECT 1', 'ALTER TABLE ll_inbound_plan_pallet ADD COLUMN assignment_status VARCHAR(32) NOT NULL DEFAULT ''UNASSIGNED'' COMMENT ''托盘分配状态:UNASSIGNED未分配及后续正式状态''')); PREPARE inbound_schema_stmt FROM @ddl; EXECUTE inbound_schema_stmt; DEALLOCATE PREPARE inbound_schema_stmt; -SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan_pallet' AND column_name='locked_at'), 'SELECT 1', 'ALTER TABLE ll_inbound_plan_pallet ADD COLUMN locked_at DATETIME(3) NULL')); +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan_pallet' AND column_name='locked_at'), 'SELECT 1', 'ALTER TABLE ll_inbound_plan_pallet ADD COLUMN locked_at DATETIME(3) NULL COMMENT ''资源锁定时间''')); PREPARE inbound_schema_stmt FROM @ddl; EXECUTE inbound_schema_stmt; DEALLOCATE PREPARE inbound_schema_stmt; -SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan_pallet' AND column_name='actual_volume_cbm'), 'SELECT 1', 'ALTER TABLE ll_inbound_plan_pallet ADD COLUMN actual_volume_cbm DECIMAL(18,4) NOT NULL DEFAULT 0')); +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan_pallet' AND column_name='actual_volume_cbm'), 'SELECT 1', 'ALTER TABLE ll_inbound_plan_pallet ADD COLUMN actual_volume_cbm DECIMAL(18,4) NOT NULL DEFAULT 0 COMMENT ''实收体积(立方米)''')); PREPARE inbound_schema_stmt FROM @ddl; EXECUTE inbound_schema_stmt; DEALLOCATE PREPARE inbound_schema_stmt; -SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan_pallet' AND column_name='actual_package_count'), 'SELECT 1', 'ALTER TABLE ll_inbound_plan_pallet ADD COLUMN actual_package_count INT NOT NULL DEFAULT 0')); +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan_pallet' AND column_name='actual_package_count'), 'SELECT 1', 'ALTER TABLE ll_inbound_plan_pallet ADD COLUMN actual_package_count INT NOT NULL DEFAULT 0 COMMENT ''实收箱数''')); PREPARE inbound_schema_stmt FROM @ddl; EXECUTE inbound_schema_stmt; DEALLOCATE PREPARE inbound_schema_stmt; -SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan_pallet_allocation' AND column_name='planned_package_count'), 'SELECT 1', 'ALTER TABLE ll_inbound_plan_pallet_allocation ADD COLUMN planned_package_count INT NOT NULL DEFAULT 0')); +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan_pallet_allocation' AND column_name='planned_package_count'), 'SELECT 1', 'ALTER TABLE ll_inbound_plan_pallet_allocation ADD COLUMN planned_package_count INT NOT NULL DEFAULT 0 COMMENT ''计划分配箱数''')); PREPARE inbound_schema_stmt FROM @ddl; EXECUTE inbound_schema_stmt; DEALLOCATE PREPARE inbound_schema_stmt; -SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan_pallet_allocation' AND column_name='counted_package_count'), 'SELECT 1', 'ALTER TABLE ll_inbound_plan_pallet_allocation ADD COLUMN counted_package_count INT NOT NULL DEFAULT 0')); +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan_pallet_allocation' AND column_name='counted_package_count'), 'SELECT 1', 'ALTER TABLE ll_inbound_plan_pallet_allocation ADD COLUMN counted_package_count INT NOT NULL DEFAULT 0 COMMENT ''清点箱数''')); PREPARE inbound_schema_stmt FROM @ddl; EXECUTE inbound_schema_stmt; DEALLOCATE PREPARE inbound_schema_stmt; -SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan_pallet_allocation' AND column_name='picked_package_count'), 'SELECT 1', 'ALTER TABLE ll_inbound_plan_pallet_allocation ADD COLUMN picked_package_count INT NOT NULL DEFAULT 0')); +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan_pallet_allocation' AND column_name='picked_package_count'), 'SELECT 1', 'ALTER TABLE ll_inbound_plan_pallet_allocation ADD COLUMN picked_package_count INT NOT NULL DEFAULT 0 COMMENT ''已拣箱数''')); PREPARE inbound_schema_stmt FROM @ddl; EXECUTE inbound_schema_stmt; DEALLOCATE PREPARE inbound_schema_stmt; -SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan_pallet_allocation' AND column_name='actual_volume_cbm'), 'SELECT 1', 'ALTER TABLE ll_inbound_plan_pallet_allocation ADD COLUMN actual_volume_cbm DECIMAL(18,4) NOT NULL DEFAULT 0')); +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan_pallet_allocation' AND column_name='actual_volume_cbm'), 'SELECT 1', 'ALTER TABLE ll_inbound_plan_pallet_allocation ADD COLUMN actual_volume_cbm DECIMAL(18,4) NOT NULL DEFAULT 0 COMMENT ''实收体积(立方米)''')); PREPARE inbound_schema_stmt FROM @ddl; EXECUTE inbound_schema_stmt; DEALLOCATE PREPARE inbound_schema_stmt; -SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan_pallet_allocation' AND column_name='status'), 'SELECT 1', 'ALTER TABLE ll_inbound_plan_pallet_allocation ADD COLUMN status VARCHAR(32) NOT NULL DEFAULT ''PENDING_COUNT''')); +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan_pallet_allocation' AND column_name='status'), 'SELECT 1', 'ALTER TABLE ll_inbound_plan_pallet_allocation ADD COLUMN status VARCHAR(32) NOT NULL DEFAULT ''PENDING_COUNT'' COMMENT ''分配作业状态:PENDING_COUNT待清点及后续正式状态''')); PREPARE inbound_schema_stmt FROM @ddl; EXECUTE inbound_schema_stmt; DEALLOCATE PREPARE inbound_schema_stmt; -SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan_pallet_allocation' AND column_name='version'), 'SELECT 1', 'ALTER TABLE ll_inbound_plan_pallet_allocation ADD COLUMN version INT NOT NULL DEFAULT 0')); +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan_pallet_allocation' AND column_name='version'), 'SELECT 1', 'ALTER TABLE ll_inbound_plan_pallet_allocation ADD COLUMN version INT NOT NULL DEFAULT 0 COMMENT ''托盘分配乐观锁版本''')); PREPARE inbound_schema_stmt FROM @ddl; EXECUTE inbound_schema_stmt; DEALLOCATE PREPARE inbound_schema_stmt; CREATE TABLE IF NOT EXISTS ll_inbound_pda_task ( diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/schema/SchemaChineseCommentContractTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/schema/SchemaChineseCommentContractTest.java index abdaccab3..773f78e3d 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/schema/SchemaChineseCommentContractTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/schema/SchemaChineseCommentContractTest.java @@ -61,6 +61,36 @@ class SchemaChineseCommentContractTest { } } + @Test + void inboundPlanMigrationsPreserveChineseCommentsForEveryAlteredColumn() throws Exception { + String baseline = Files.readString( + Path.of("src", "main", "resources", "db", "lastleg.sql"), StandardCharsets.UTF_8); + String overseasUnified = Files.readString( + Path.of("..", "deploy", "sql", "海外区域统一增量-MySQL8.sql"), StandardCharsets.UTF_8); + + List required = List.of( + new MigrationColumn("ll_inbound_plan", "status"), + new MigrationColumn("ll_inbound_plan_line", "required_package_count"), + new MigrationColumn("ll_inbound_plan_pallet", "actual_package_count"), + new MigrationColumn("ll_inbound_plan_pallet", "actual_volume_cbm"), + new MigrationColumn("ll_inbound_plan_pallet", "assignment_status"), + new MigrationColumn("ll_inbound_plan_pallet", "locked_at"), + new MigrationColumn("ll_inbound_plan_pallet", "warehouse_location_id"), + new MigrationColumn("ll_inbound_plan_pallet", "warehouse_pallet_id"), + new MigrationColumn("ll_inbound_plan_pallet", "warehouse_resource_lock_id"), + new MigrationColumn("ll_inbound_plan_pallet_allocation", "actual_volume_cbm"), + new MigrationColumn("ll_inbound_plan_pallet_allocation", "counted_package_count"), + new MigrationColumn("ll_inbound_plan_pallet_allocation", "picked_package_count"), + new MigrationColumn("ll_inbound_plan_pallet_allocation", "planned_package_count"), + new MigrationColumn("ll_inbound_plan_pallet_allocation", "status"), + new MigrationColumn("ll_inbound_plan_pallet_allocation", "version") + ); + for (MigrationColumn column : required) { + assertSpecificAlterationHasChineseComment(baseline, column); + } + assertAllAlteredColumnsHaveChineseComments(overseasUnified, "海外统一增量 SQL"); + } + private static List assertAllChineseComments(String sql, String source, int expectedCount) { List tables = new ArrayList<>(); Matcher matcher = TABLE.matcher(sql); @@ -152,6 +182,44 @@ class SchemaChineseCommentContractTest { assertTrue(addColumnPattern.matcher(sql).find(), "增量 SQL 的 ADD COLUMN 缺少中文备注: " + column); } + private static void assertSpecificAlterationHasChineseComment(String sql, MigrationColumn target) { + Pattern pattern = Pattern.compile( + "(?is)ALTER\\s+TABLE\\s+" + Pattern.quote(target.table()) + + "\\s+(?:ADD|MODIFY)\\s+COLUMN\\s+" + Pattern.quote(target.column()) + "\\b"); + Matcher matcher = pattern.matcher(sql); + assertTrue(matcher.find(), "缺少字段增量定义: " + target.table() + "." + target.column()); + assertAlterationSegmentHasChineseComment(sql, matcher.start(), matcher.end(), + "字段增量缺少中文备注: " + target.table() + "." + target.column()); + } + + private static void assertAllAlteredColumnsHaveChineseComments(String sql, String source) { + Pattern pattern = Pattern.compile("(?i)\\b(?:ADD|MODIFY)\\s+COLUMN\\s+([a-z0-9_]+)\\b"); + Matcher matcher = pattern.matcher(sql); + int count = 0; + while (matcher.find()) { + count++; + assertAlterationSegmentHasChineseComment(sql, matcher.start(), matcher.end(), + source + " 字段增量缺少中文备注: " + matcher.group(1)); + } + assertTrue(count > 0, source + " 未解析到 ADD/MODIFY COLUMN"); + } + + private static void assertAlterationSegmentHasChineseComment( + String sql, + int start, + int tokenEnd, + String message + ) { + Matcher next = Pattern.compile("(?i)\\b(?:ADD|MODIFY)\\s+COLUMN\\s+[a-z0-9_]+\\b") + .matcher(sql); + int end = sql.indexOf(';', tokenEnd); + if (end < 0) end = sql.length(); + if (next.find(tokenEnd) && next.start() < end) end = next.start(); + String definition = sql.substring(start, end); + assertTrue(Pattern.compile("(?is)\\bCOMMENT\\s+''?[^;]*[\\u4e00-\\u9fff][^;]*''?") + .matcher(definition).find(), message); + } + private static List splitTopLevel(String body) { List definitions = new ArrayList<>(); int start = 0; @@ -176,4 +244,7 @@ class SchemaChineseCommentContractTest { private record TableDefinition(String name, List columns) { } + + private record MigrationColumn(String table, String column) { + } } diff --git a/scripts/agents-index-contract.ps1 b/scripts/agents-index-contract.ps1 index 167316597..3fef39e6b 100644 --- a/scripts/agents-index-contract.ps1 +++ b/scripts/agents-index-contract.ps1 @@ -168,17 +168,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' @@ -783,5 +794,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