diff --git a/deploy/sql/头程数据库统一增量-MySQL8.sql b/deploy/sql/头程数据库统一增量-MySQL8.sql index a90caa5bb..30d910930 100644 --- a/deploy/sql/头程数据库统一增量-MySQL8.sql +++ b/deploy/sql/头程数据库统一增量-MySQL8.sql @@ -18019,6 +18019,65 @@ DELIMITER ; CALL assert_wallet_event_schema(); DROP PROCEDURE IF EXISTS assert_wallet_event_schema; +-- 目的港码头拖车、提柜与还空闭环(模块来源:phase3-terminal-drayage-schema.sql) +USE baiyue_firstleg; +CREATE TABLE IF NOT EXISTS fl_terminal_drayage ( + id BIGINT UNSIGNED NOT NULL, container_booking_id BIGINT UNSIGNED NOT NULL, + fleet_supplier_id BIGINT UNSIGNED NOT NULL, fleet_supplier_name VARCHAR(128) NOT NULL, + pickup_appointment_at DATETIME(3) NULL, actual_pickup_at DATETIME(3) NULL, + estimated_empty_return_at DATETIME(3) NULL, actual_empty_return_at DATETIME(3) NULL, + version INT UNSIGNED NOT NULL DEFAULT 1, created_by BIGINT UNSIGNED NOT NULL, + created_by_name VARCHAR(64) NULL, created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + updated_by BIGINT UNSIGNED NOT NULL, updated_by_name VARCHAR(64) NULL, + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + PRIMARY KEY(id), UNIQUE KEY uk_terminal_drayage_container(container_booking_id), + KEY idx_terminal_drayage_warning(actual_empty_return_at,estimated_empty_return_at), + CONSTRAINT fk_terminal_drayage_container FOREIGN KEY(container_booking_id) REFERENCES fl_container_booking(id), + CONSTRAINT chk_terminal_drayage_times CHECK((actual_pickup_at IS NULL OR pickup_appointment_at IS NOT NULL) + AND (actual_empty_return_at IS NULL OR actual_pickup_at IS NOT NULL) + AND (actual_empty_return_at IS NULL OR actual_empty_return_at >= actual_pickup_at)) +) ENGINE=InnoDB COMMENT='目的港货柜级码头拖车与还空事实'; +CREATE TABLE IF NOT EXISTS fl_terminal_drayage_history ( + id BIGINT UNSIGNED NOT NULL, drayage_id BIGINT UNSIGNED NOT NULL, + container_booking_id BIGINT UNSIGNED NOT NULL, action VARCHAR(32) NOT NULL, + from_status VARCHAR(32) NOT NULL, to_status VARCHAR(32) NOT NULL, + from_version INT UNSIGNED NOT NULL, to_version INT UNSIGNED NOT NULL, + reason VARCHAR(500) NULL, operator_id BIGINT UNSIGNED NOT NULL, operator_name VARCHAR(64) NULL, + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), PRIMARY KEY(id), + KEY idx_terminal_drayage_history(container_booking_id,created_at,id), + CONSTRAINT fk_terminal_drayage_history FOREIGN KEY(drayage_id) REFERENCES fl_terminal_drayage(id), + CONSTRAINT chk_terminal_drayage_history_action CHECK(action IN + ('SELECT_FLEET','SCHEDULE_PICKUP','RECORD_PICKUP','RECORD_EMPTY_RETURN')) +) ENGINE=InnoDB COMMENT='目的港码头拖车不可变操作历史'; +CREATE TABLE IF NOT EXISTS fl_terminal_drayage_operation ( + id BIGINT UNSIGNED NOT NULL, container_booking_id BIGINT UNSIGNED NOT NULL, + operation VARCHAR(32) NOT NULL, idempotency_key VARCHAR(255) NOT NULL, + request_hash CHAR(64) NOT NULL, result_version INT UNSIGNED NOT NULL, + operator_id BIGINT UNSIGNED NOT NULL, created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + PRIMARY KEY(id), UNIQUE KEY uk_terminal_drayage_operation(container_booking_id,idempotency_key), + KEY idx_terminal_drayage_operation(container_booking_id,created_at), + CONSTRAINT fk_terminal_drayage_operation_container FOREIGN KEY(container_booking_id) REFERENCES fl_container_booking(id) +) ENGINE=InnoDB COMMENT='目的港码头拖车成功幂等操作'; + +DROP PROCEDURE IF EXISTS assert_terminal_drayage_schema; +DELIMITER $$ +CREATE PROCEDURE assert_terminal_drayage_schema() +BEGIN + IF (SELECT COUNT(*) FROM information_schema.TABLES WHERE TABLE_SCHEMA='baiyue_firstleg' + AND TABLE_NAME IN ('fl_terminal_drayage','fl_terminal_drayage_history','fl_terminal_drayage_operation')) <> 3 THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='统一迁移失败:目的港码头拖车表不完整'; + END IF; + IF (SELECT COUNT(*) FROM information_schema.STATISTICS WHERE TABLE_SCHEMA='baiyue_firstleg' + AND TABLE_NAME='fl_terminal_drayage' AND INDEX_NAME='uk_terminal_drayage_container' AND NON_UNIQUE=0) <> 1 + OR (SELECT COUNT(*) FROM information_schema.STATISTICS WHERE TABLE_SCHEMA='baiyue_firstleg' + AND TABLE_NAME='fl_terminal_drayage_operation' AND INDEX_NAME='uk_terminal_drayage_operation' AND NON_UNIQUE=0) <> 2 THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='统一迁移失败:目的港码头拖车唯一约束不完整'; + END IF; +END$$ +DELIMITER ; +CALL assert_terminal_drayage_schema(); +DROP PROCEDURE IF EXISTS assert_terminal_drayage_schema; + CALL baiyue_firstleg.assert_unified_final_schema(); DROP PROCEDURE IF EXISTS baiyue_firstleg.assert_unified_final_schema; SELECT 'PASS' AS migration_status, diff --git a/deploy/sql/海外区域统一增量-MySQL8.sql b/deploy/sql/海外区域统一增量-MySQL8.sql index 7f1bd7736..7b0bb79c2 100644 --- a/deploy/sql/海外区域统一增量-MySQL8.sql +++ b/deploy/sql/海外区域统一增量-MySQL8.sql @@ -219,6 +219,7 @@ SET @ddl = (SELECT IF(EXISTS ( WHERE table_schema=DATABASE() AND table_name='ll_overseas_procurement_order' AND column_name='review_idempotency_key' ), 'SELECT 1', 'ALTER TABLE ll_overseas_procurement_order ADD COLUMN review_idempotency_key VARCHAR(255) NULL COMMENT ''审核幂等键'' AFTER review_remark')); PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; + SET @ddl = (SELECT IF(EXISTS ( SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_overseas_procurement_order' AND column_name='review_request_hash' @@ -1860,3 +1861,93 @@ SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE tab PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; ALTER TABLE ll_inbound_stock_receipt MODIFY COLUMN pallet_no VARCHAR(64) NOT NULL COMMENT '海外仓实物托盘码'; + +-- 尾程单票人工计划、暂扣与人工展示节点。 +USE baiyue_lastleg; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_order' AND column_name='hold_chargeable'), 'SELECT 1', 'ALTER TABLE ll_order ADD COLUMN hold_chargeable TINYINT NOT NULL DEFAULT 0 COMMENT ''当前暂扣是否计费:0否1是'' AFTER detained')); +PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_order' AND column_name='hold_reason'), 'SELECT 1', 'ALTER TABLE ll_order ADD COLUMN hold_reason VARCHAR(500) NULL COMMENT ''当前暂扣原因;解除后为空'' AFTER hold_chargeable')); +PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_order' AND column_name='manual_node_override'), 'SELECT 1', 'ALTER TABLE ll_order ADD COLUMN manual_node_override TINYINT NOT NULL DEFAULT 0 COMMENT ''是否启用单票人工计划节点覆盖:0否1是'' AFTER hold_reason')); +PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_order' AND column_name='version'), 'SELECT 1', 'ALTER TABLE ll_order ADD COLUMN version INT NOT NULL DEFAULT 0 COMMENT ''尾程订单命令乐观锁版本'' AFTER manual_node_override')); +PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +CREATE TABLE IF NOT EXISTS ll_order_command_record ( + id BIGINT NOT NULL COMMENT '尾程订单命令幂等记录ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户数据隔离键', + order_id BIGINT NOT NULL COMMENT '尾程订单ID', action VARCHAR(32) NOT NULL COMMENT '命令动作', operator_id VARCHAR(128) NOT NULL COMMENT '平台操作人ID', + idempotency_key VARCHAR(128) NOT NULL COMMENT '客户端幂等键', request_hash VARCHAR(64) NOT NULL COMMENT '规范化请求SHA-256摘要', + result_ref VARCHAR(128) NULL COMMENT '结果业务引用', result_json LONGTEXT NOT NULL COMMENT '首次成功响应JSON快照', + result_version INT NOT NULL COMMENT '命令成功后的订单版本', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '首次成功时间', + PRIMARY KEY(id), UNIQUE KEY uk_ll_order_command_key(customer_id,order_id,action,operator_id,idempotency_key), + KEY idx_ll_order_command_history(customer_id,order_id,created_at) +) ENGINE=InnoDB COMMENT='尾程订单写命令幂等与结果审计记录'; +CREATE TABLE IF NOT EXISTS ll_order_hold_history ( + id BIGINT NOT NULL COMMENT '尾程订单暂扣历史ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户数据隔离键', order_id BIGINT NOT NULL COMMENT '尾程订单ID', + held TINYINT NOT NULL COMMENT '操作后是否暂扣:0否1是', chargeable TINYINT NOT NULL COMMENT '本次暂扣是否计费:0否1是', reason VARCHAR(500) NOT NULL COMMENT '暂扣或解除原因', + order_version INT NOT NULL COMMENT '操作成功后的订单版本', operator_id VARCHAR(128) NOT NULL COMMENT '操作人ID', operator_name VARCHAR(128) NOT NULL COMMENT '操作人名称', + idempotency_key VARCHAR(128) NOT NULL COMMENT '客户端幂等键', created_at DATETIME(3) NOT NULL COMMENT '操作时间', PRIMARY KEY(id), + UNIQUE KEY uk_ll_order_hold_key(customer_id,order_id,operator_id,idempotency_key), KEY idx_ll_order_hold_history(customer_id,order_id,created_at) +) ENGINE=InnoDB COMMENT='尾程订单暂扣与解除不可变历史'; +CREATE TABLE IF NOT EXISTS ll_order_manual_node ( + id BIGINT NOT NULL COMMENT '尾程订单人工节点ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户数据隔离键', order_id BIGINT NOT NULL COMMENT '尾程订单ID', + node_code VARCHAR(32) NOT NULL COMMENT '八行人工展示节点稳定代码', node_at DATETIME(3) NULL COMMENT '人工填写节点时间,允许为空', + source VARCHAR(16) NOT NULL DEFAULT 'MANUAL' COMMENT '节点来源,固定MANUAL', order_version INT NOT NULL COMMENT '最近编辑后的订单版本', + updated_by VARCHAR(128) NOT NULL COMMENT '最近操作人ID', updated_at DATETIME(3) NOT NULL COMMENT '最近编辑时间', PRIMARY KEY(id), + UNIQUE KEY uk_ll_order_manual_node(customer_id,order_id,node_code), KEY idx_ll_order_manual_node_order(customer_id,order_id,node_at) +) ENGINE=InnoDB COMMENT='尾程订单当前人工节点覆盖事实'; +CREATE TABLE IF NOT EXISTS ll_order_manual_node_history ( + id BIGINT NOT NULL COMMENT '尾程订单人工节点历史ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户数据隔离键', order_id BIGINT NOT NULL COMMENT '尾程订单ID', + nodes_json LONGTEXT NOT NULL COMMENT '本次全量人工节点JSON快照', order_version INT NOT NULL COMMENT '操作成功后的订单版本', operator_id VARCHAR(128) NOT NULL COMMENT '操作人ID', + operator_name VARCHAR(128) NOT NULL COMMENT '操作人名称', idempotency_key VARCHAR(128) NOT NULL COMMENT '客户端幂等键', created_at DATETIME(3) NOT NULL COMMENT '操作时间', + PRIMARY KEY(id), UNIQUE KEY uk_ll_order_manual_node_history_key(customer_id,order_id,operator_id,idempotency_key), + KEY idx_ll_order_manual_node_history(customer_id,order_id,created_at) +) ENGINE=InnoDB COMMENT='尾程订单人工节点覆盖不可变历史'; + +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan' AND column_name='planned_arrival_date'), 'SELECT 1', 'ALTER TABLE ll_inbound_plan ADD COLUMN planned_arrival_date DATE NULL COMMENT ''人工计划预计到仓日期'' AFTER overseas_warehouse_id')); +PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan' AND column_name='pallet_type'), 'SELECT 1', 'ALTER TABLE ll_inbound_plan ADD COLUMN pallet_type VARCHAR(32) NULL COMMENT ''人工计划托盘类型'' AFTER planned_arrival_date')); +PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan' AND column_name='assigned_flag'), 'SELECT 1', 'ALTER TABLE ll_inbound_plan ADD COLUMN assigned_flag TINYINT NOT NULL DEFAULT 0 COMMENT ''人工计划是否已分配:0否1是'' AFTER pallet_type')); +PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan' AND column_name='merge_required'), 'SELECT 1', 'ALTER TABLE ll_inbound_plan ADD COLUMN merge_required TINYINT NOT NULL DEFAULT 0 COMMENT ''人工计划是否要求合托:0否1是'' AFTER assigned_flag')); +PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan' AND column_name='urgent_flag'), 'SELECT 1', 'ALTER TABLE ll_inbound_plan ADD COLUMN urgent_flag TINYINT NOT NULL DEFAULT 0 COMMENT ''人工计划是否加急:0否1是'' AFTER merge_required')); +PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan' AND column_name='creation_mode'), 'SELECT 1', 'ALTER TABLE ll_inbound_plan ADD COLUMN creation_mode VARCHAR(16) NOT NULL DEFAULT ''AUTO'' COMMENT ''计划生成方式:AUTO柜级自动、MANUAL单票人工'' AFTER urgent_flag')); +PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan' AND column_name='plan_remark'), 'SELECT 1', 'ALTER TABLE ll_inbound_plan ADD COLUMN plan_remark VARCHAR(1000) NULL COMMENT ''入仓计划备注'' AFTER creation_mode')); +PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan_pallet' AND column_name='pallet_type'), 'SELECT 1', 'ALTER TABLE ll_inbound_plan_pallet ADD COLUMN pallet_type VARCHAR(32) NULL COMMENT ''人工计划托盘类型'' AFTER pallet_no')); +PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +DROP PROCEDURE IF EXISTS assert_tail_order_commands_ready; +DELIMITER $$ +CREATE PROCEDURE assert_tail_order_commands_ready() +BEGIN + IF (SELECT COUNT(*) FROM information_schema.columns WHERE table_schema='baiyue_lastleg' + AND table_name='ll_order' AND column_name IN + ('hold_chargeable','hold_reason','manual_node_override','version')) <> 4 THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='尾程订单人工计划、暂扣与人工节点字段未完整部署'; + END IF; + IF (SELECT COUNT(*) FROM information_schema.tables WHERE table_schema='baiyue_lastleg' + AND table_name IN ('ll_order_command_record','ll_order_hold_history','ll_order_manual_node','ll_order_manual_node_history')) <> 4 THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='尾程订单命令审计表未完整部署'; + END IF; + IF (SELECT COUNT(*) FROM information_schema.columns WHERE table_schema='baiyue_lastleg' + AND table_name='ll_inbound_plan' AND column_name IN + ('planned_arrival_date','pallet_type','assigned_flag','merge_required','urgent_flag','creation_mode','plan_remark')) <> 7 THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='尾程人工计划字段未完整部署'; + END IF; + IF (SELECT COUNT(*) FROM information_schema.statistics WHERE table_schema='baiyue_lastleg' + AND table_name='ll_order_command_record' AND index_name='uk_ll_order_command_key' AND non_unique=0) <> 5 + OR (SELECT COUNT(*) FROM information_schema.statistics WHERE table_schema='baiyue_lastleg' + AND table_name='ll_order_manual_node' AND index_name='uk_ll_order_manual_node' AND non_unique=0) <> 3 THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='尾程订单命令幂等或人工节点唯一约束未完整部署'; + END IF; +END$$ +DELIMITER ; +CALL assert_tail_order_commands_ready(); +DROP PROCEDURE IF EXISTS assert_tail_order_commands_ready; + +SELECT 'PASS' AS migration_status, '海外尾程人工计划、暂扣与人工节点结构已就绪' AS message; diff --git a/firstleg/AGENTS.md b/firstleg/AGENTS.md index ebb81a42e..28c98fb0e 100644 --- a/firstleg/AGENTS.md +++ b/firstleg/AGENTS.md @@ -43,6 +43,7 @@ | 海外暂扣状态回写 | 同一内部事件入口、消费者 `service/OverseasInboundOrderDetainedConsumer.java`;按来源入仓单 ID 更新 `fl_inbound_order.hold_flag/current_hold_reason`,复用跨区 Inbox 并写 `fl_inbound_hold_record` | | 国内货柜履约海运订舱、资料/打印、关联候选与出仓前船期资料 | `oceanbooking/controller/PlatformOceanBookingController.java`、`oceanbooking/service/OceanBookingService.java`、`OceanBookingDocumentService.java`、`OceanBookingPrintService.java`、`oceanbooking/mapper/OceanBookingMapper.java`、`portschedule/controller/PlatformPortScheduleController.java`;文件经 `client/OceanBookingFileClient.java` 进入 File,关联候选 `/api/platform/v1/ocean-bookings/link-candidates`,契约 `src/main/resources/openapi/ocean-bookings-v1.yaml`,表结构见 `src/main/resources/db/phase3-ocean-booking-schema.sql` | | 海外仓业务国内出仓后船节点定时查询 | `outbound/dispatch/service/OutboundDispatchService.java` 在确认出仓事务内幂等创建现有 `CarrierScheduleQuery`;编排与租约 `portschedule/service/CarrierScheduleQueryScheduler.java`,任务/十节点事实/异常事实/不可变 attempt `CarrierScheduleQueryService.java`,平台查询与人工补录入口 `PlatformCarrierScheduleQueryController.java` 的 `/{queryId}/manual-nodes`;停止只由实际 `EMPTY_RETURNED` 节点触发,不提供无节点停止入口;外部船司/轨迹调用经 `integration-service/`;标准见 `../../docs/海外仓业务/船节点标准化契约.md` | +| 目的港码头拖车与还空预警 | `terminaldrayage/controller/PlatformTerminalDrayageController.java`、`service/TerminalDrayageService.java`、`mapper/TerminalDrayageMapper.java`;货柜实际到港后引用 Warehouse `ACTIVE/TRUCKING` 供应商,依次选择车队、预约提柜、确认实际提柜,海外卸柜收货完成后确认还柜;服务端按卸柜完成时间加 72 小时返回报空预警,实际提柜和还柜通过现有 `CarrierScheduleQueryService` 推进 `PICKED_UP/EMPTY_RETURNED`,不复制船节点或车队主档。契约 `src/main/resources/openapi/terminal-drayage-v1.yaml`,基线 `src/main/resources/db/phase3-terminal-drayage-schema.sql` | | 船节点跨区交接事件 | `portschedule/service/CarrierScheduleNodeOutboxService.java`;实际节点落库后发布 `FIRST_LEG_CONTAINER_SHIPPING_NODE_RECORDED` V1,复用 `LoadingCompletedOutboxRepository`/Relay 投 Event Bridge;海外消费见 `../lastleg-service/src/main/java/com/baiyue/lastleg/shipping/service/ContainerShippingNodeConsumer.java` | | Firstleg 业务单据编号 | `service/FirstlegDocumentNumberGenerator.java`;当前支持 `OB`、`TA`、`CB`、`CAB`、`LP` 前缀,按北京时间每日独立编号;具体业务归属以各调用服务为准 | | 订舱级拖车与整柜出仓调度 | `controller/PlatformTruckAppointmentController.java` 的 `/candidates`、`service/TruckAppointmentService.java`、`repository/TruckAppointmentRepository.java`、`outbound/dispatch/controller/PlatformOutboundDispatchController.java`;拖车契约 `src/main/resources/openapi/truck-appointments-v1.yaml`。预约只绑定 `RELEASED` 订舱,候选排除缺提单/有效订舱文件或已有非终态预约的订舱;API 不含客户、柜次、装柜计划或真实柜号,每订舱最多一张非终态预约,PDA 完成不推进拖车状态 | diff --git a/firstleg/scripts/analyze-firstleg-dependencies.mjs b/firstleg/scripts/analyze-firstleg-dependencies.mjs index 302d466c7..cdcc88bd8 100644 --- a/firstleg/scripts/analyze-firstleg-dependencies.mjs +++ b/firstleg/scripts/analyze-firstleg-dependencies.mjs @@ -16,7 +16,7 @@ const sliceRules = [ ['pricebook-query', /(?:^|\.)(?:pricebookquery)(?:\.|$)/], ['finance-integration', /(?:InternalFinance|FinanceFeeSource|FinanceCostSnapshot|FeePaymentVoucher|FeePaymentSnapshot|ContainerFeeSourceSnapshot)/], ['shipping-nodes', /(?:CarrierScheduleQuery|CarrierScheduleNode|CarrierScheduleAdapter|CarrierScheduleAttempt|CarrierScheduleException)/], - ['booking-customs-trucking', /(?:^|\.)(?:oceanbooking|customs)(?:\.|$)|(?:TruckAppointment|WarehouseSupplier|PortSchedule|DispatchGate)/], + ['booking-customs-trucking', /(?:^|\.)(?:oceanbooking|customs|terminaldrayage)(?:\.|$)|(?:TruckAppointment|WarehouseSupplier|PortSchedule|DispatchGate)/], ['outbound', /(?:^|\.)outbound(?:\.|$)|(?:OutboundDispatch|ContainerTransport|OverseasContainerEvent)/], ['split-loading', /(?:^|\.)(?:split|loading)(?:\.|$)|(?:Loading|SmartLoading|Container(?:Booking|Overview|Service|Dtos)|PdaPick)/], ['measurement-pricing', /(?:^|\.)pricing(?:\.|$)|(?:Measurement|MeasureReview|Mojia|Pricing|PriceBook|PriceChannel|PriceOrigin|PriceWarehouse|Fba|Dimension|FeeItem|MerchantInboundBilling|InboundFee)/], diff --git a/firstleg/src/main/java/com/baiyue/firstleg/terminaldrayage/controller/PlatformTerminalDrayageController.java b/firstleg/src/main/java/com/baiyue/firstleg/terminaldrayage/controller/PlatformTerminalDrayageController.java new file mode 100644 index 000000000..777a9648f --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/terminaldrayage/controller/PlatformTerminalDrayageController.java @@ -0,0 +1,117 @@ +package com.baiyue.firstleg.terminaldrayage.controller; + +import com.baiyue.common.audit.BusinessAudit; +import com.baiyue.common.core.api.ApiResponse; +import com.baiyue.common.security.AuthenticatedUser; +import com.baiyue.firstleg.service.FirstlegScopeGuard; +import com.baiyue.firstleg.terminaldrayage.dto.TerminalDrayageDtos.ActualTimeRequest; +import com.baiyue.firstleg.terminaldrayage.dto.TerminalDrayageDtos.FleetSelectionRequest; +import com.baiyue.firstleg.terminaldrayage.dto.TerminalDrayageDtos.History; +import com.baiyue.firstleg.terminaldrayage.dto.TerminalDrayageDtos.PickupAppointmentRequest; +import com.baiyue.firstleg.terminaldrayage.dto.TerminalDrayageDtos.View; +import com.baiyue.firstleg.terminaldrayage.service.TerminalDrayageService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import jakarta.validation.constraints.Positive; +import jakarta.validation.constraints.Size; +import lombok.RequiredArgsConstructor; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestHeader; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import java.util.List; + +@Validated +@RestController +@RequiredArgsConstructor +@Tag(name = "目的港码头拖车") +@RequestMapping("/api/platform/v1/terminal-drayages") +public class PlatformTerminalDrayageController { + private final TerminalDrayageService service; + + @GetMapping + @PreAuthorize("hasAuthority('platform:firstleg:container:list')") + public ApiResponse> views(@AuthenticationPrincipal AuthenticatedUser user, + @RequestParam @Size(min = 1, max = 200) + List<@Positive Long> containerBookingIds) { + FirstlegScopeGuard.platform(user); + return ApiResponse.success(service.views(containerBookingIds)); + } + + @GetMapping("/{containerBookingId}") + @PreAuthorize("hasAuthority('platform:firstleg:container:list')") + public ApiResponse detail(@AuthenticationPrincipal AuthenticatedUser user, + @PathVariable @Positive long containerBookingId) { + FirstlegScopeGuard.platform(user); + return ApiResponse.success(service.detail(containerBookingId)); + } + + @GetMapping("/{containerBookingId}/history") + @PreAuthorize("hasAuthority('platform:firstleg:container:list')") + public ApiResponse> history(@AuthenticationPrincipal AuthenticatedUser user, + @PathVariable @Positive long containerBookingId) { + FirstlegScopeGuard.platform(user); + return ApiResponse.success(service.histories(containerBookingId)); + } + + @PostMapping("/{containerBookingId}/fleet") + @PreAuthorize("hasAuthority('platform:firstleg:container:edit')") + @Operation(summary = "选择或更换目的港码头车队") + @BusinessAudit(event = "TERMINAL_DRAYAGE_SELECT_FLEET", resourceType = "CONTAINER", + resourceId = "#containerBookingId", description = "选择目的港码头车队") + public ApiResponse selectFleet(@AuthenticationPrincipal AuthenticatedUser user, + @PathVariable @Positive long containerBookingId, + @RequestHeader("Idempotency-Key") String key, + @Valid @RequestBody FleetSelectionRequest request) { + FirstlegScopeGuard.platform(user); + return ApiResponse.success(service.selectFleet(containerBookingId, user.userId(), user.username(), key, request)); + } + + @PostMapping("/{containerBookingId}/pickup-appointment") + @PreAuthorize("hasAuthority('platform:firstleg:container:edit')") + @Operation(summary = "预约目的港提柜时间") + @BusinessAudit(event = "TERMINAL_DRAYAGE_SCHEDULE_PICKUP", resourceType = "CONTAINER", + resourceId = "#containerBookingId", description = "预约目的港提柜") + public ApiResponse schedulePickup(@AuthenticationPrincipal AuthenticatedUser user, + @PathVariable @Positive long containerBookingId, + @RequestHeader("Idempotency-Key") String key, + @Valid @RequestBody PickupAppointmentRequest request) { + FirstlegScopeGuard.platform(user); + return ApiResponse.success(service.schedulePickup(containerBookingId, user.userId(), user.username(), key, request)); + } + + @PostMapping("/{containerBookingId}/pickup") + @PreAuthorize("hasAuthority('platform:firstleg:container:edit')") + @Operation(summary = "确认目的港实际提柜") + @BusinessAudit(event = "TERMINAL_DRAYAGE_RECORD_PICKUP", resourceType = "CONTAINER", + resourceId = "#containerBookingId", description = "确认目的港实际提柜") + public ApiResponse recordPickup(@AuthenticationPrincipal AuthenticatedUser user, + @PathVariable @Positive long containerBookingId, + @RequestHeader("Idempotency-Key") String key, + @Valid @RequestBody ActualTimeRequest request) { + FirstlegScopeGuard.platform(user); + return ApiResponse.success(service.recordPickup(containerBookingId, user.userId(), user.username(), key, request)); + } + + @PostMapping("/{containerBookingId}/empty-return") + @PreAuthorize("hasAuthority('platform:firstleg:container:edit')") + @Operation(summary = "确认实际还空柜") + @BusinessAudit(event = "TERMINAL_DRAYAGE_RECORD_EMPTY_RETURN", resourceType = "CONTAINER", + resourceId = "#containerBookingId", description = "确认实际还空柜") + public ApiResponse recordEmptyReturn(@AuthenticationPrincipal AuthenticatedUser user, + @PathVariable @Positive long containerBookingId, + @RequestHeader("Idempotency-Key") String key, + @Valid @RequestBody ActualTimeRequest request) { + FirstlegScopeGuard.platform(user); + return ApiResponse.success(service.recordEmptyReturn(containerBookingId, user.userId(), user.username(), key, request)); + } +} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/terminaldrayage/dto/TerminalDrayageDtos.java b/firstleg/src/main/java/com/baiyue/firstleg/terminaldrayage/dto/TerminalDrayageDtos.java new file mode 100644 index 000000000..b193cf6b5 --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/terminaldrayage/dto/TerminalDrayageDtos.java @@ -0,0 +1,41 @@ +package com.baiyue.firstleg.terminaldrayage.dto; + +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Positive; +import jakarta.validation.constraints.Size; + +import java.time.LocalDateTime; +import java.util.List; + +public final class TerminalDrayageDtos { + private TerminalDrayageDtos() {} + + public record FleetSelectionRequest(@NotNull @Min(0) Integer expectedVersion, + @NotNull @Positive Long fleetSupplierId, + @Size(max = 128) String fleetSupplierName, + @Size(max = 500) String reason) {} + + public record PickupAppointmentRequest(@NotNull @Min(0) Integer expectedVersion, + @NotNull LocalDateTime pickupAppointmentAt, + @Size(max = 500) String reason) {} + + public record ActualTimeRequest(@NotNull @Min(0) Integer expectedVersion, + @NotNull LocalDateTime actualAt, + @Size(max = 500) String reason) {} + + public record Warning(String status, LocalDateTime deadlineAt, + long overdueHours, long overdueDays) {} + + public record View(Long containerBookingId, String containerNo, + Long oceanBookingId, String status, Integer version, + Long fleetSupplierId, String fleetSupplierName, + LocalDateTime pickupAppointmentAt, LocalDateTime actualPickupAt, + LocalDateTime estimatedEmptyReturnAt, LocalDateTime actualEmptyReturnAt, + LocalDateTime unloadingCompletedAt, Warning emptyReturnWarning, + List allowedActions, LocalDateTime updatedAt) {} + + public record History(String action, String fromStatus, String toStatus, + Integer fromVersion, Integer toVersion, String reason, + Long operatorId, String operatorName, LocalDateTime createdAt) {} +} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/terminaldrayage/entity/TerminalDrayageEntity.java b/firstleg/src/main/java/com/baiyue/firstleg/terminaldrayage/entity/TerminalDrayageEntity.java new file mode 100644 index 000000000..ddd847c04 --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/terminaldrayage/entity/TerminalDrayageEntity.java @@ -0,0 +1,30 @@ +package com.baiyue.firstleg.terminaldrayage.entity; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.Getter; +import lombok.Setter; + +import java.time.LocalDateTime; + +@Getter +@Setter +@TableName("fl_terminal_drayage") +public class TerminalDrayageEntity { + @TableId(type = IdType.INPUT) private Long id; + private Long containerBookingId; + private Long fleetSupplierId; + private String fleetSupplierName; + private LocalDateTime pickupAppointmentAt; + private LocalDateTime actualPickupAt; + private LocalDateTime estimatedEmptyReturnAt; + private LocalDateTime actualEmptyReturnAt; + private Integer version; + private Long createdBy; + private String createdByName; + private LocalDateTime createdAt; + private Long updatedBy; + private String updatedByName; + private LocalDateTime updatedAt; +} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/terminaldrayage/mapper/TerminalDrayageMapper.java b/firstleg/src/main/java/com/baiyue/firstleg/terminaldrayage/mapper/TerminalDrayageMapper.java new file mode 100644 index 000000000..f9124be45 --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/terminaldrayage/mapper/TerminalDrayageMapper.java @@ -0,0 +1,148 @@ +package com.baiyue.firstleg.terminaldrayage.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.baiyue.firstleg.terminaldrayage.dto.TerminalDrayageDtos.History; +import com.baiyue.firstleg.terminaldrayage.entity.TerminalDrayageEntity; +import org.apache.ibatis.annotations.Insert; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; +import org.apache.ibatis.annotations.Select; +import org.apache.ibatis.annotations.Update; + +import java.time.LocalDateTime; +import java.util.List; + +@Mapper +public interface TerminalDrayageMapper extends BaseMapper { + @Select(""" + SELECT b.id AS containerBookingId,b.container_no AS containerNo,b.ocean_booking_id AS oceanBookingId, + b.record_status AS recordStatus,b.unloading_completed_at AS unloadingCompletedAt, + q.id AS queryId,q.latest_standard_node AS latestActualNode, + q.latest_node_observed_at AS latestActualAt,q.version AS queryVersion, + (SELECT MAX(f.actual_at) FROM fl_carrier_schedule_node_fact f WHERE f.query_id=q.id + AND f.standard_node='ARRIVED_DESTINATION_PORT') AS actualArrivalNodeAt, + (SELECT MAX(f.actual_at) FROM fl_carrier_schedule_node_fact f WHERE f.query_id=q.id + AND f.standard_node='PICKED_UP') AS actualPickupNodeAt, + (SELECT MAX(f.actual_at) FROM fl_carrier_schedule_node_fact f WHERE f.query_id=q.id + AND f.standard_node='EMPTY_RETURNED') AS actualEmptyReturnNodeAt + FROM fl_container_booking b + LEFT JOIN fl_carrier_schedule_query q ON q.id=( + SELECT q2.id FROM fl_carrier_schedule_query q2 + WHERE q2.container_booking_id=b.id ORDER BY q2.id DESC LIMIT 1) + WHERE b.id=#{containerBookingId} FOR UPDATE + """) + Context lockContext(@Param("containerBookingId") long containerBookingId); + + @Select(""" + SELECT b.id AS containerBookingId,b.container_no AS containerNo,b.ocean_booking_id AS oceanBookingId, + b.record_status AS recordStatus,b.unloading_completed_at AS unloadingCompletedAt, + q.id AS queryId,q.latest_standard_node AS latestActualNode, + q.latest_node_observed_at AS latestActualAt,q.version AS queryVersion, + (SELECT MAX(f.actual_at) FROM fl_carrier_schedule_node_fact f WHERE f.query_id=q.id + AND f.standard_node='ARRIVED_DESTINATION_PORT') AS actualArrivalNodeAt, + (SELECT MAX(f.actual_at) FROM fl_carrier_schedule_node_fact f WHERE f.query_id=q.id + AND f.standard_node='PICKED_UP') AS actualPickupNodeAt, + (SELECT MAX(f.actual_at) FROM fl_carrier_schedule_node_fact f WHERE f.query_id=q.id + AND f.standard_node='EMPTY_RETURNED') AS actualEmptyReturnNodeAt + FROM fl_container_booking b + LEFT JOIN fl_carrier_schedule_query q ON q.id=( + SELECT q2.id FROM fl_carrier_schedule_query q2 + WHERE q2.container_booking_id=b.id ORDER BY q2.id DESC LIMIT 1) + WHERE b.id=#{containerBookingId} + """) + Context context(@Param("containerBookingId") long containerBookingId); + + @Select(""" + + """) + List contexts(@Param("containerBookingIds") List containerBookingIds); + + @Select("SELECT * FROM fl_terminal_drayage WHERE container_booking_id=#{containerBookingId}") + TerminalDrayageEntity find(@Param("containerBookingId") long containerBookingId); + + @Select("SELECT * FROM fl_terminal_drayage WHERE container_booking_id=#{containerBookingId} FOR UPDATE") + TerminalDrayageEntity lock(@Param("containerBookingId") long containerBookingId); + + @Select(""" + + """) + List findAll(@Param("containerBookingIds") List containerBookingIds); + + @Update(""" + UPDATE fl_terminal_drayage SET fleet_supplier_id=#{value.fleetSupplierId}, + fleet_supplier_name=#{value.fleetSupplierName},pickup_appointment_at=#{value.pickupAppointmentAt}, + actual_pickup_at=#{value.actualPickupAt},estimated_empty_return_at=#{value.estimatedEmptyReturnAt}, + actual_empty_return_at=#{value.actualEmptyReturnAt},updated_by=#{operatorId}, + updated_by_name=#{operatorName},updated_at=CURRENT_TIMESTAMP(3),version=version+1 + WHERE container_booking_id=#{value.containerBookingId} AND version=#{expectedVersion} + """) + int updateAggregate(@Param("value") TerminalDrayageEntity value, + @Param("expectedVersion") int expectedVersion, + @Param("operatorId") long operatorId, + @Param("operatorName") String operatorName); + + @Select(""" + SELECT action,from_status AS fromStatus,to_status AS toStatus,from_version AS fromVersion, + to_version AS toVersion,reason,operator_id AS operatorId,operator_name AS operatorName,created_at AS createdAt + FROM fl_terminal_drayage_history WHERE container_booking_id=#{containerBookingId} + ORDER BY created_at DESC,id DESC + """) + List histories(@Param("containerBookingId") long containerBookingId); + + @Insert(""" + INSERT INTO fl_terminal_drayage_history(id,drayage_id,container_booking_id,action,from_status,to_status, + from_version,to_version,reason,operator_id,operator_name) + VALUES(#{id},#{drayageId},#{containerBookingId},#{action},#{fromStatus},#{toStatus},#{fromVersion}, + #{toVersion},#{reason},#{operatorId},#{operatorName}) + """) + int insertHistory(@Param("id") long id, @Param("drayageId") long drayageId, + @Param("containerBookingId") long containerBookingId, + @Param("action") String action, @Param("fromStatus") String fromStatus, + @Param("toStatus") String toStatus, @Param("fromVersion") int fromVersion, + @Param("toVersion") int toVersion, @Param("reason") String reason, + @Param("operatorId") long operatorId, @Param("operatorName") String operatorName); + + @Select(""" + SELECT operation,request_hash AS requestHash,result_version AS resultVersion + FROM fl_terminal_drayage_operation + WHERE container_booking_id=#{containerBookingId} AND idempotency_key=#{key} + """) + Operation operation(@Param("containerBookingId") long containerBookingId, @Param("key") String key); + + @Insert(""" + INSERT INTO fl_terminal_drayage_operation(id,container_booking_id,operation,idempotency_key,request_hash, + result_version,operator_id) VALUES(#{id},#{containerBookingId},#{operation},#{key},#{hash}, + #{resultVersion},#{operatorId}) + """) + int insertOperation(@Param("id") long id, @Param("containerBookingId") long containerBookingId, + @Param("operation") String operation, @Param("key") String key, + @Param("hash") String hash, @Param("resultVersion") int resultVersion, + @Param("operatorId") long operatorId); + + record Context(Long containerBookingId, String containerNo, Long oceanBookingId, String recordStatus, + LocalDateTime unloadingCompletedAt, Long queryId, String latestActualNode, + LocalDateTime latestActualAt, Integer queryVersion, LocalDateTime actualArrivalNodeAt, + LocalDateTime actualPickupNodeAt, LocalDateTime actualEmptyReturnNodeAt) {} + record Operation(String operation, String requestHash, Integer resultVersion) {} +} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/terminaldrayage/service/TerminalDrayageService.java b/firstleg/src/main/java/com/baiyue/firstleg/terminaldrayage/service/TerminalDrayageService.java new file mode 100644 index 000000000..177e4ab7c --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/terminaldrayage/service/TerminalDrayageService.java @@ -0,0 +1,339 @@ +package com.baiyue.firstleg.terminaldrayage.service; + +import com.baomidou.mybatisplus.core.toolkit.IdWorker; +import com.baiyue.common.core.api.ResultCode; +import com.baiyue.common.core.exception.BizException; +import com.baiyue.firstleg.portschedule.domain.CarrierScheduleNode; +import com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.ManualNodeRequest; +import com.baiyue.firstleg.portschedule.service.CarrierScheduleQueryService; +import com.baiyue.firstleg.service.WarehouseSupplierSnapshotService; +import com.baiyue.firstleg.terminaldrayage.dto.TerminalDrayageDtos.ActualTimeRequest; +import com.baiyue.firstleg.terminaldrayage.dto.TerminalDrayageDtos.FleetSelectionRequest; +import com.baiyue.firstleg.terminaldrayage.dto.TerminalDrayageDtos.History; +import com.baiyue.firstleg.terminaldrayage.dto.TerminalDrayageDtos.PickupAppointmentRequest; +import com.baiyue.firstleg.terminaldrayage.dto.TerminalDrayageDtos.View; +import com.baiyue.firstleg.terminaldrayage.dto.TerminalDrayageDtos.Warning; +import com.baiyue.firstleg.terminaldrayage.entity.TerminalDrayageEntity; +import com.baiyue.firstleg.terminaldrayage.mapper.TerminalDrayageMapper; +import com.baiyue.firstleg.terminaldrayage.mapper.TerminalDrayageMapper.Context; +import com.baiyue.firstleg.terminaldrayage.mapper.TerminalDrayageMapper.Operation; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.util.StringUtils; + +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.time.Clock; +import java.time.Duration; +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.HexFormat; +import java.util.List; +import java.util.Map; +import java.util.Objects; + +@Service +@RequiredArgsConstructor +public class TerminalDrayageService { + private static final int EMPTY_RETURN_WARNING_HOURS = 72; + private final TerminalDrayageMapper mapper; + private final WarehouseSupplierSnapshotService suppliers; + private final CarrierScheduleQueryService carrierSchedules; + private final ObjectMapper objectMapper; + private Clock clock = Clock.systemDefaultZone(); + + public View detail(long containerBookingId) { + return view(requiredContext(containerBookingId), mapper.find(containerBookingId)); + } + + public List views(List containerBookingIds) { + if (containerBookingIds == null || containerBookingIds.isEmpty()) return List.of(); + List ids = containerBookingIds.stream().filter(Objects::nonNull).distinct().toList(); + if (ids.size() != containerBookingIds.size()) throw badRequest("containerBookingIds不能包含空值或重复值"); + Map rows = new HashMap<>(); + mapper.findAll(ids).forEach(value -> rows.put(value.getContainerBookingId(), value)); + return mapper.contexts(ids).stream().map(value -> view(value, rows.get(value.containerBookingId()))).toList(); + } + + public List histories(long containerBookingId) { + requiredContext(containerBookingId); + return mapper.histories(containerBookingId); + } + + @Transactional + public View selectFleet(long containerBookingId, long operatorId, String operatorName, String key, + FleetSelectionRequest request) { + String action = "SELECT_FLEET"; + Context context = requiredLockedContext(containerBookingId); + View replay = replay(context, action, key, request); + if (replay != null) return replay; + requireArrived(context); + TerminalDrayageEntity value = mapper.lock(containerBookingId); + requireVersion(value, request.expectedVersion()); + if (actualPickupAt(context, value) != null) throw conflict("实际提柜后不可更换码头车队"); + var fleet = suppliers.trucking(request.fleetSupplierId(), request.fleetSupplierName()); + int fromVersion = version(value); + String fromStatus = status(context, value); + if (value == null) value = create(containerBookingId, operatorId, operatorName); + value.setFleetSupplierId(fleet.supplierId()); + value.setFleetSupplierName(fleet.name()); + persist(value, fromVersion, operatorId, operatorName); + return finish(context, value, action, key, request, fromStatus, fromVersion, + request.reason(), operatorId, operatorName); + } + + @Transactional + public View schedulePickup(long containerBookingId, long operatorId, String operatorName, String key, + PickupAppointmentRequest request) { + String action = "SCHEDULE_PICKUP"; + Context context = requiredLockedContext(containerBookingId); + View replay = replay(context, action, key, request); + if (replay != null) return replay; + requireArrived(context); + TerminalDrayageEntity value = requiredAggregate(containerBookingId); + requireVersion(value, request.expectedVersion()); + if (value.getFleetSupplierId() == null) throw conflict("请先选择有效码头车队"); + if (actualPickupAt(context, value) != null) throw conflict("实际提柜后不可修改预约提柜时间"); + if (context.actualArrivalNodeAt() != null && request.pickupAppointmentAt().isBefore(context.actualArrivalNodeAt())) { + throw badRequest("预约提柜时间不能早于实际到港时间"); + } + int fromVersion = version(value); + String fromStatus = status(context, value); + value.setPickupAppointmentAt(request.pickupAppointmentAt()); + persist(value, fromVersion, operatorId, operatorName); + return finish(context, value, action, key, request, fromStatus, fromVersion, + request.reason(), operatorId, operatorName); + } + + @Transactional + public View recordPickup(long containerBookingId, long operatorId, String operatorName, String key, + ActualTimeRequest request) { + String action = "RECORD_PICKUP"; + Context context = requiredLockedContext(containerBookingId); + View replay = replay(context, action, key, request); + if (replay != null) return replay; + requireAtLeast(context, CarrierScheduleNode.PICKUP_READY, "货柜尚未清关放行,不允许确认实际提柜"); + TerminalDrayageEntity value = requiredAggregate(containerBookingId); + requireVersion(value, request.expectedVersion()); + if (value.getFleetSupplierId() == null || value.getPickupAppointmentAt() == null) { + throw conflict("选择码头车队并预约提柜后才能确认实际提柜"); + } + if (actualPickupAt(context, value) != null) throw conflict("实际提柜时间已确认"); + if (context.latestActualAt() != null && request.actualAt().isBefore(context.latestActualAt())) { + throw badRequest("实际提柜时间不能早于当前实际船节点时间"); + } + advanceNode(context, CarrierScheduleNode.PICKED_UP, request.actualAt(), action, key, + request.reason(), operatorId, operatorName); + int fromVersion = version(value); + String fromStatus = status(context, value); + value.setActualPickupAt(request.actualAt()); + applyAuthoritativeDeadline(context, value); + persist(value, fromVersion, operatorId, operatorName); + return finish(requiredContext(containerBookingId), value, action, key, request, fromStatus, + fromVersion, request.reason(), operatorId, operatorName); + } + + @Transactional + public View recordEmptyReturn(long containerBookingId, long operatorId, String operatorName, String key, + ActualTimeRequest request) { + String action = "RECORD_EMPTY_RETURN"; + Context context = requiredLockedContext(containerBookingId); + View replay = replay(context, action, key, request); + if (replay != null) return replay; + TerminalDrayageEntity value = requiredAggregate(containerBookingId); + requireVersion(value, request.expectedVersion()); + LocalDateTime pickupAt = actualPickupAt(context, value); + if (pickupAt == null) throw conflict("实际提柜后才能确认还柜"); + if (context.unloadingCompletedAt() == null) throw conflict("海外卸柜收货完成后才能确认还柜"); + LocalDateTime earliest = pickupAt.isAfter(context.unloadingCompletedAt()) + ? pickupAt : context.unloadingCompletedAt(); + if (request.actualAt().isBefore(earliest)) throw badRequest("实际还柜时间不能早于提柜或卸柜完成时间"); + if (actualEmptyReturnAt(context, value) != null) throw conflict("实际还柜时间已确认"); + advanceNode(context, CarrierScheduleNode.EMPTY_RETURNED, request.actualAt(), action, key, + request.reason(), operatorId, operatorName); + int fromVersion = version(value); + String fromStatus = status(context, value); + applyAuthoritativeDeadline(context, value); + value.setActualEmptyReturnAt(request.actualAt()); + persist(value, fromVersion, operatorId, operatorName); + return finish(requiredContext(containerBookingId), value, action, key, request, fromStatus, + fromVersion, request.reason(), operatorId, operatorName); + } + + private void advanceNode(Context context, CarrierScheduleNode target, LocalDateTime actualAt, + String action, String key, String reason, long operatorId, String operatorName) { + if (context.queryId() == null || context.queryVersion() == null) { + throw conflict("货柜尚未建立船节点跟踪任务"); + } + CarrierScheduleNode current = node(context.latestActualNode()); + if (current != null && current.sequence() >= target.sequence()) return; + carrierSchedules.addManualNode(context.queryId(), operatorId, operatorName, + "td-" + hash(context.containerBookingId() + "|" + action + "|" + key.trim()), + new ManualNodeRequest(context.queryVersion(), target.name(), LocalDateTime.now(clock), null, + actualAt, null, null, StringUtils.hasText(reason) ? reason.trim() : "码头拖车操作同步")); + } + + private View finish(Context context, TerminalDrayageEntity value, String action, String key, Object request, + String fromStatus, int fromVersion, String reason, + long operatorId, String operatorName) { + TerminalDrayageEntity stored = mapper.find(context.containerBookingId()); + Context currentContext = requiredContext(context.containerBookingId()); + String toStatus = status(currentContext, stored); + mapper.insertHistory(IdWorker.getId(), stored.getId(), context.containerBookingId(), action, + fromStatus, toStatus, fromVersion, version(stored), trim(reason), operatorId, operatorName); + mapper.insertOperation(IdWorker.getId(), context.containerBookingId(), action, key.trim(), hash(request), + version(stored), operatorId); + return view(currentContext, stored); + } + + private View replay(Context context, String action, String key, Object request) { + if (!StringUtils.hasText(key) || key.length() > 255) throw badRequest("Idempotency-Key不能为空且不能超过255字符"); + Operation operation = mapper.operation(context.containerBookingId(), key.trim()); + if (operation == null) return null; + if (!action.equals(operation.operation()) || !hash(request).equals(operation.requestHash())) { + throw conflict("Idempotency-Key已被不同的码头拖车操作占用"); + } + return view(context, mapper.find(context.containerBookingId())); + } + + private TerminalDrayageEntity create(long containerBookingId, long operatorId, String operatorName) { + TerminalDrayageEntity value = new TerminalDrayageEntity(); + value.setId(IdWorker.getId()); value.setContainerBookingId(containerBookingId); value.setVersion(1); + value.setCreatedBy(operatorId); value.setCreatedByName(operatorName); + value.setUpdatedBy(operatorId); value.setUpdatedByName(operatorName); + return value; + } + + private void persist(TerminalDrayageEntity value, int expectedVersion, long operatorId, String operatorName) { + if (expectedVersion == 0) { + mapper.insert(value); + return; + } + if (mapper.updateAggregate(value, expectedVersion, operatorId, operatorName) != 1) throw concurrent(); + } + + private void applyAuthoritativeDeadline(Context context, TerminalDrayageEntity value) { + if (context.unloadingCompletedAt() != null) { + value.setEstimatedEmptyReturnAt(context.unloadingCompletedAt().plusHours(EMPTY_RETURN_WARNING_HOURS)); + } + } + + private View view(Context context, TerminalDrayageEntity value) { + String status = status(context, value); + LocalDateTime deadline = context.unloadingCompletedAt() == null ? null + : context.unloadingCompletedAt().plusHours(EMPTY_RETURN_WARNING_HOURS); + LocalDateTime returnedAt = actualEmptyReturnAt(context, value); + String warningStatus; + long overdueHours = 0; + if (returnedAt != null) warningStatus = "RESOLVED"; + else if (deadline == null) warningStatus = "NOT_APPLICABLE"; + else if (LocalDateTime.now(clock).isAfter(deadline)) { + warningStatus = "OVERDUE"; + overdueHours = Math.max(0, Duration.between(deadline, LocalDateTime.now(clock)).toHours()); + } else warningStatus = "PENDING"; + Warning warning = new Warning(warningStatus, deadline, overdueHours, overdueHours / 24); + return new View(context.containerBookingId(), context.containerNo(), context.oceanBookingId(), status, + version(value), value == null ? null : value.getFleetSupplierId(), + value == null ? null : value.getFleetSupplierName(), + value == null ? null : value.getPickupAppointmentAt(), + actualPickupAt(context, value), deadline, returnedAt, + context.unloadingCompletedAt(), warning, allowedActions(context, value), + value == null ? null : value.getUpdatedAt()); + } + + private List allowedActions(Context context, TerminalDrayageEntity value) { + List result = new ArrayList<>(); + CarrierScheduleNode current = node(context.latestActualNode()); + if (current != null && current.sequence() >= CarrierScheduleNode.ARRIVED_DESTINATION_PORT.sequence() + && current.sequence() < CarrierScheduleNode.PICKED_UP.sequence() + && actualPickupAt(context, value) == null) { + result.add("SELECT_FLEET"); + if (value != null && value.getFleetSupplierId() != null) result.add("SCHEDULE_PICKUP"); + } + if (current != null && current.sequence() >= CarrierScheduleNode.PICKUP_READY.sequence() + && value != null && value.getFleetSupplierId() != null && value.getPickupAppointmentAt() != null + && actualPickupAt(context, value) == null) result.add("RECORD_PICKUP"); + if (context.unloadingCompletedAt() != null && actualPickupAt(context, value) != null + && actualEmptyReturnAt(context, value) == null) result.add("RECORD_EMPTY_RETURN"); + return List.copyOf(result); + } + + private String status(Context context, TerminalDrayageEntity value) { + if (actualEmptyReturnAt(context, value) != null) return "EMPTY_RETURNED"; + if (context.unloadingCompletedAt() != null && actualPickupAt(context, value) != null) return "UNLOADED"; + if (actualPickupAt(context, value) != null) return "PICKED_UP"; + if (value != null && value.getPickupAppointmentAt() != null) return "PICKUP_SCHEDULED"; + CarrierScheduleNode current = node(context.latestActualNode()); + return current != null && current.sequence() >= CarrierScheduleNode.ARRIVED_DESTINATION_PORT.sequence() + ? "ARRIVED" : "WAITING_FOR_ARRIVAL"; + } + + private void requireArrived(Context context) { + requireAtLeast(context, CarrierScheduleNode.ARRIVED_DESTINATION_PORT, "货柜实际到达目的港后才能安排码头车队"); + } + + private void requireAtLeast(Context context, CarrierScheduleNode required, String message) { + CarrierScheduleNode current = node(context.latestActualNode()); + if (current == null || current.sequence() < required.sequence()) throw conflict(message); + } + + private CarrierScheduleNode node(String value) { + return StringUtils.hasText(value) ? CarrierScheduleNode.require(value) : null; + } + + private LocalDateTime actualPickupAt(Context context, TerminalDrayageEntity value) { + return value != null && value.getActualPickupAt() != null ? value.getActualPickupAt() : context.actualPickupNodeAt(); + } + + private LocalDateTime actualEmptyReturnAt(Context context, TerminalDrayageEntity value) { + return value != null && value.getActualEmptyReturnAt() != null + ? value.getActualEmptyReturnAt() : context.actualEmptyReturnNodeAt(); + } + + private Context requiredContext(long id) { + Context value = mapper.context(id); + if (value == null || !"ACTIVE".equals(value.recordStatus())) throw notFound(); + return value; + } + + private Context requiredLockedContext(long id) { + Context value = mapper.lockContext(id); + if (value == null || !"ACTIVE".equals(value.recordStatus())) throw notFound(); + return value; + } + + private TerminalDrayageEntity requiredAggregate(long id) { + TerminalDrayageEntity value = mapper.lock(id); + if (value == null) throw conflict("请先选择码头车队"); + return value; + } + + private void requireVersion(TerminalDrayageEntity value, int expectedVersion) { + if (version(value) != expectedVersion) throw concurrent(); + } + + private int version(TerminalDrayageEntity value) { + return value == null || value.getVersion() == null ? 0 : value.getVersion(); + } + + private String hash(Object request) { + try { + byte[] json = objectMapper.writeValueAsBytes(request); + return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(json)); + } catch (JsonProcessingException | NoSuchAlgorithmException exception) { + throw new IllegalStateException("无法计算码头拖车请求摘要", exception); + } + } + + private static String trim(String value) { return StringUtils.hasText(value) ? value.trim() : null; } + private static BizException badRequest(String message) { return new BizException(ResultCode.BAD_REQUEST, message); } + private static BizException conflict(String message) { return new BizException(ResultCode.CONFLICT, message); } + private static BizException concurrent() { return conflict("码头拖车记录已变化,请刷新后重试"); } + private static BizException notFound() { return new BizException(ResultCode.NOT_FOUND, "货柜不存在或不可用"); } + + void setClock(Clock clock) { this.clock = clock; } +} diff --git a/firstleg/src/main/resources/db/phase3-terminal-drayage-schema.sql b/firstleg/src/main/resources/db/phase3-terminal-drayage-schema.sql new file mode 100644 index 000000000..47d88d067 --- /dev/null +++ b/firstleg/src/main/resources/db/phase3-terminal-drayage-schema.sql @@ -0,0 +1,63 @@ +SET NAMES utf8mb4; +USE baiyue_firstleg; + +CREATE TABLE IF NOT EXISTS fl_terminal_drayage ( + id BIGINT UNSIGNED NOT NULL COMMENT '目的港码头拖车聚合ID', + container_booking_id BIGINT UNSIGNED NOT NULL COMMENT '唯一归属物理货柜ID', + fleet_supplier_id BIGINT UNSIGNED NOT NULL COMMENT 'Warehouse ACTIVE/TRUCKING供应商ID快照', + fleet_supplier_name VARCHAR(128) NOT NULL COMMENT 'Warehouse供应商名称快照', + pickup_appointment_at DATETIME(3) NULL COMMENT '预约提柜时间', + actual_pickup_at DATETIME(3) NULL COMMENT '实际提柜时间', + estimated_empty_return_at DATETIME(3) NULL COMMENT '服务端按卸柜完成时间加72小时计算的预计还柜时间', + actual_empty_return_at DATETIME(3) NULL COMMENT '实际还空柜时间', + version INT UNSIGNED NOT NULL DEFAULT 1 COMMENT '乐观锁版本号', + created_by BIGINT UNSIGNED NOT NULL COMMENT '创建人ID', + created_by_name VARCHAR(64) NULL COMMENT '创建人名称快照', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', + updated_by BIGINT UNSIGNED NOT NULL COMMENT '最后更新人ID', + updated_by_name VARCHAR(64) NULL COMMENT '最后更新人名称快照', + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间', + PRIMARY KEY(id), + UNIQUE KEY uk_terminal_drayage_container(container_booking_id), + KEY idx_terminal_drayage_warning(actual_empty_return_at,estimated_empty_return_at), + CONSTRAINT fk_terminal_drayage_container FOREIGN KEY(container_booking_id) REFERENCES fl_container_booking(id), + CONSTRAINT chk_terminal_drayage_times CHECK( + (actual_pickup_at IS NULL OR pickup_appointment_at IS NOT NULL) AND + (actual_empty_return_at IS NULL OR actual_pickup_at IS NOT NULL) AND + (actual_empty_return_at IS NULL OR actual_empty_return_at >= actual_pickup_at)) +) ENGINE=InnoDB COMMENT='目的港货柜级码头拖车与还空事实'; + +CREATE TABLE IF NOT EXISTS fl_terminal_drayage_history ( + id BIGINT UNSIGNED NOT NULL COMMENT '历史ID', + drayage_id BIGINT UNSIGNED NOT NULL COMMENT '码头拖车聚合ID', + container_booking_id BIGINT UNSIGNED NOT NULL COMMENT '货柜ID快照', + action VARCHAR(32) NOT NULL COMMENT 'SELECT_FLEET/SCHEDULE_PICKUP/RECORD_PICKUP/RECORD_EMPTY_RETURN', + from_status VARCHAR(32) NOT NULL COMMENT '操作前状态', + to_status VARCHAR(32) NOT NULL COMMENT '操作后状态', + from_version INT UNSIGNED NOT NULL COMMENT '操作前版本', + to_version INT UNSIGNED NOT NULL COMMENT '操作后版本', + reason VARCHAR(500) NULL COMMENT '操作说明', + operator_id BIGINT UNSIGNED NOT NULL COMMENT '操作人ID', + operator_name VARCHAR(64) NULL COMMENT '操作人名称快照', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '操作时间', + PRIMARY KEY(id), + KEY idx_terminal_drayage_history(container_booking_id,created_at,id), + CONSTRAINT fk_terminal_drayage_history FOREIGN KEY(drayage_id) REFERENCES fl_terminal_drayage(id), + CONSTRAINT chk_terminal_drayage_history_action CHECK(action IN + ('SELECT_FLEET','SCHEDULE_PICKUP','RECORD_PICKUP','RECORD_EMPTY_RETURN')) +) ENGINE=InnoDB COMMENT='目的港码头拖车不可变操作历史'; + +CREATE TABLE IF NOT EXISTS fl_terminal_drayage_operation ( + id BIGINT UNSIGNED NOT NULL COMMENT '幂等操作ID', + container_booking_id BIGINT UNSIGNED NOT NULL COMMENT '货柜ID', + operation VARCHAR(32) NOT NULL COMMENT '业务动作', + idempotency_key VARCHAR(255) NOT NULL COMMENT '客户端幂等键', + request_hash CHAR(64) NOT NULL COMMENT '请求载荷SHA-256', + result_version INT UNSIGNED NOT NULL COMMENT '成功结果版本', + operator_id BIGINT UNSIGNED NOT NULL COMMENT '操作人ID', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '完成时间', + PRIMARY KEY(id), + UNIQUE KEY uk_terminal_drayage_operation(container_booking_id,idempotency_key), + KEY idx_terminal_drayage_operation(container_booking_id,created_at), + CONSTRAINT fk_terminal_drayage_operation_container FOREIGN KEY(container_booking_id) REFERENCES fl_container_booking(id) +) ENGINE=InnoDB COMMENT='目的港码头拖车成功幂等操作'; diff --git a/firstleg/src/main/resources/openapi/terminal-drayage-v1.yaml b/firstleg/src/main/resources/openapi/terminal-drayage-v1.yaml new file mode 100644 index 000000000..454e6c00d --- /dev/null +++ b/firstleg/src/main/resources/openapi/terminal-drayage-v1.yaml @@ -0,0 +1,127 @@ +openapi: 3.0.3 +info: {title: Firstleg Terminal Drayage API, version: 1.0.0} +paths: + /api/platform/v1/terminal-drayages: + get: + summary: 批量查询货柜码头拖车及服务端报空预警 + x-permission: platform:firstleg:container:list + parameters: + - name: containerBookingIds + in: query + required: true + schema: {type: array, minItems: 1, maxItems: 200, items: {type: string, pattern: '^[1-9][0-9]*$'}} + responses: {'200': {$ref: '#/components/responses/ViewsResponse'}} + /api/platform/v1/terminal-drayages/{containerBookingId}: + get: + summary: 查询单柜码头拖车详情 + x-permission: platform:firstleg:container:list + parameters: [{$ref: '#/components/parameters/ContainerBookingId'}] + responses: {'200': {$ref: '#/components/responses/ViewResponse'}} + /api/platform/v1/terminal-drayages/{containerBookingId}/history: + get: + summary: 查询单柜码头拖车操作历史 + x-permission: platform:firstleg:container:list + parameters: [{$ref: '#/components/parameters/ContainerBookingId'}] + responses: {'200': {$ref: '#/components/responses/HistoryResponse'}} + /api/platform/v1/terminal-drayages/{containerBookingId}/fleet: + post: + summary: 到港后选择或更换码头车队 + x-permission: platform:firstleg:container:edit + parameters: [{$ref: '#/components/parameters/ContainerBookingId'}, {$ref: '#/components/parameters/IdempotencyKey'}] + requestBody: {required: true, content: {application/json: {schema: {$ref: '#/components/schemas/FleetSelectionRequest'}}}} + responses: {'200': {$ref: '#/components/responses/ViewResponse'}} + /api/platform/v1/terminal-drayages/{containerBookingId}/pickup-appointment: + post: + summary: 预约目的港提柜时间 + x-permission: platform:firstleg:container:edit + parameters: [{$ref: '#/components/parameters/ContainerBookingId'}, {$ref: '#/components/parameters/IdempotencyKey'}] + requestBody: {required: true, content: {application/json: {schema: {$ref: '#/components/schemas/PickupAppointmentRequest'}}}} + responses: {'200': {$ref: '#/components/responses/ViewResponse'}} + /api/platform/v1/terminal-drayages/{containerBookingId}/pickup: + post: + summary: 清关可提柜后确认实际提柜并推进PICKED_UP节点 + x-permission: platform:firstleg:container:edit + parameters: [{$ref: '#/components/parameters/ContainerBookingId'}, {$ref: '#/components/parameters/IdempotencyKey'}] + requestBody: {required: true, content: {application/json: {schema: {$ref: '#/components/schemas/ActualTimeRequest'}}}} + responses: {'200': {$ref: '#/components/responses/ViewResponse'}} + /api/platform/v1/terminal-drayages/{containerBookingId}/empty-return: + post: + summary: 海外卸柜收货完成后确认实际还柜并推进EMPTY_RETURNED节点 + x-permission: platform:firstleg:container:edit + parameters: [{$ref: '#/components/parameters/ContainerBookingId'}, {$ref: '#/components/parameters/IdempotencyKey'}] + requestBody: {required: true, content: {application/json: {schema: {$ref: '#/components/schemas/ActualTimeRequest'}}}} + responses: {'200': {$ref: '#/components/responses/ViewResponse'}} +components: + parameters: + ContainerBookingId: {name: containerBookingId, in: path, required: true, schema: {type: string, pattern: '^[1-9][0-9]*$'}} + IdempotencyKey: {name: Idempotency-Key, in: header, required: true, schema: {type: string, minLength: 1, maxLength: 255}} + schemas: + FleetSelectionRequest: + type: object + required: [expectedVersion, fleetSupplierId] + properties: + expectedVersion: {type: integer, minimum: 0} + fleetSupplierId: {type: string, pattern: '^[1-9][0-9]*$', description: Warehouse ACTIVE/TRUCKING供应商ID} + fleetSupplierName: {type: string, maxLength: 128, nullable: true, description: 可选一致性提示,持久化名称以服务端快照为准} + reason: {type: string, maxLength: 500, nullable: true} + PickupAppointmentRequest: + type: object + required: [expectedVersion, pickupAppointmentAt] + properties: + expectedVersion: {type: integer, minimum: 0} + pickupAppointmentAt: {type: string, format: date-time} + reason: {type: string, maxLength: 500, nullable: true} + ActualTimeRequest: + type: object + required: [expectedVersion, actualAt] + properties: + expectedVersion: {type: integer, minimum: 0} + actualAt: {type: string, format: date-time} + reason: {type: string, maxLength: 500, nullable: true} + Warning: + type: object + required: [status, overdueHours, overdueDays] + properties: + status: {type: string, enum: [NOT_APPLICABLE, PENDING, OVERDUE, RESOLVED]} + deadlineAt: {type: string, format: date-time, nullable: true, description: 卸柜完成时间加72小时} + overdueHours: {type: integer, format: int64, minimum: 0} + overdueDays: {type: integer, format: int64, minimum: 0} + TerminalDrayageView: + type: object + required: [containerBookingId, status, version, emptyReturnWarning, allowedActions] + properties: + containerBookingId: {type: string, pattern: '^[1-9][0-9]*$'} + containerNo: {type: string, nullable: true} + oceanBookingId: {type: string, pattern: '^[1-9][0-9]*$', nullable: true} + status: {type: string, enum: [WAITING_FOR_ARRIVAL, ARRIVED, PICKUP_SCHEDULED, PICKED_UP, UNLOADED, EMPTY_RETURNED]} + version: {type: integer, minimum: 0} + fleetSupplierId: {type: string, pattern: '^[1-9][0-9]*$', nullable: true} + fleetSupplierName: {type: string, nullable: true} + pickupAppointmentAt: {type: string, format: date-time, nullable: true} + actualPickupAt: {type: string, format: date-time, nullable: true} + estimatedEmptyReturnAt: {type: string, format: date-time, nullable: true, description: 服务端权威值,卸柜完成时间加72小时} + actualEmptyReturnAt: {type: string, format: date-time, nullable: true} + unloadingCompletedAt: {type: string, format: date-time, nullable: true} + emptyReturnWarning: {$ref: '#/components/schemas/Warning'} + allowedActions: {type: array, items: {type: string, enum: [SELECT_FLEET, SCHEDULE_PICKUP, RECORD_PICKUP, RECORD_EMPTY_RETURN]}} + updatedAt: {type: string, format: date-time, nullable: true} + History: + type: object + required: [action, fromStatus, toStatus, fromVersion, toVersion, operatorId, createdAt] + properties: + action: {type: string, enum: [SELECT_FLEET, SCHEDULE_PICKUP, RECORD_PICKUP, RECORD_EMPTY_RETURN]} + fromStatus: {type: string} + toStatus: {type: string} + fromVersion: {type: integer, minimum: 0} + toVersion: {type: integer, minimum: 1} + reason: {type: string, nullable: true} + operatorId: {type: string, pattern: '^[1-9][0-9]*$'} + operatorName: {type: string, nullable: true} + createdAt: {type: string, format: date-time} + ApiView: {type: object, required: [code, data], properties: {code: {type: string}, message: {type: string}, data: {$ref: '#/components/schemas/TerminalDrayageView'}}} + ApiViews: {type: object, required: [code, data], properties: {code: {type: string}, message: {type: string}, data: {type: array, items: {$ref: '#/components/schemas/TerminalDrayageView'}}}} + ApiHistory: {type: object, required: [code, data], properties: {code: {type: string}, message: {type: string}, data: {type: array, items: {$ref: '#/components/schemas/History'}}}} + responses: + ViewResponse: {description: 成功, content: {application/json: {schema: {$ref: '#/components/schemas/ApiView'}}}} + ViewsResponse: {description: 成功, content: {application/json: {schema: {$ref: '#/components/schemas/ApiViews'}}}} + HistoryResponse: {description: 成功, content: {application/json: {schema: {$ref: '#/components/schemas/ApiHistory'}}}} diff --git a/firstleg/src/test/java/com/baiyue/firstleg/terminaldrayage/controller/TerminalDrayageOpenApiContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/terminaldrayage/controller/TerminalDrayageOpenApiContractTest.java new file mode 100644 index 000000000..9400ccb73 --- /dev/null +++ b/firstleg/src/test/java/com/baiyue/firstleg/terminaldrayage/controller/TerminalDrayageOpenApiContractTest.java @@ -0,0 +1,24 @@ +package com.baiyue.firstleg.terminaldrayage.controller; + +import org.junit.jupiter.api.Test; + +import java.nio.file.Files; +import java.nio.file.Path; + +import static org.assertj.core.api.Assertions.assertThat; + +class TerminalDrayageOpenApiContractTest { + @Test + void freezesActionsPermissionsAndServerWarningFields() throws Exception { + String yaml = Files.readString(Path.of("src/main/resources/openapi/terminal-drayage-v1.yaml")); + assertThat(yaml).contains( + "/api/platform/v1/terminal-drayages/{containerBookingId}/fleet:", + "/api/platform/v1/terminal-drayages/{containerBookingId}/pickup-appointment:", + "/api/platform/v1/terminal-drayages/{containerBookingId}/pickup:", + "/api/platform/v1/terminal-drayages/{containerBookingId}/empty-return:", + "platform:firstleg:container:list", "platform:firstleg:container:edit", + "expectedVersion", "Idempotency-Key", "estimatedEmptyReturnAt", + "emptyReturnWarning", "deadlineAt", "overdueHours", "overdueDays"); + assertThat(yaml).contains("description: 卸柜完成时间加72小时"); + } +} diff --git a/firstleg/src/test/java/com/baiyue/firstleg/terminaldrayage/mapper/TerminalDrayageSqlContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/terminaldrayage/mapper/TerminalDrayageSqlContractTest.java new file mode 100644 index 000000000..28da7a5b2 --- /dev/null +++ b/firstleg/src/test/java/com/baiyue/firstleg/terminaldrayage/mapper/TerminalDrayageSqlContractTest.java @@ -0,0 +1,27 @@ +package com.baiyue.firstleg.terminaldrayage.mapper; + +import org.junit.jupiter.api.Test; + +import java.nio.file.Files; +import java.nio.file.Path; + +import static org.assertj.core.api.Assertions.assertThat; + +class TerminalDrayageSqlContractTest { + @Test + void schemaKeepsOneAggregatePerContainerAndImmutableAudit() throws Exception { + String sql = Files.readString(Path.of("src/main/resources/db/phase3-terminal-drayage-schema.sql")); + String mapper = Files.readString(Path.of( + "src/main/java/com/baiyue/firstleg/terminaldrayage/mapper/TerminalDrayageMapper.java")); + assertThat(sql).contains( + "CREATE TABLE IF NOT EXISTS fl_terminal_drayage (", + "UNIQUE KEY uk_terminal_drayage_container(container_booking_id)", + "CREATE TABLE IF NOT EXISTS fl_terminal_drayage_history (", + "CREATE TABLE IF NOT EXISTS fl_terminal_drayage_operation (", + "UNIQUE KEY uk_terminal_drayage_operation(container_booking_id,idempotency_key)", + "服务端按卸柜完成时间加72小时计算的预计还柜时间"); + assertThat(sql).doesNotContain("ON DELETE CASCADE"); + assertThat(mapper).contains("WHERE b.id=#{containerBookingId} FOR UPDATE", + "WHERE container_booking_id=#{value.containerBookingId} AND version=#{expectedVersion}"); + } +} diff --git a/firstleg/src/test/java/com/baiyue/firstleg/terminaldrayage/service/TerminalDrayageServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/terminaldrayage/service/TerminalDrayageServiceTest.java new file mode 100644 index 000000000..d48b14f53 --- /dev/null +++ b/firstleg/src/test/java/com/baiyue/firstleg/terminaldrayage/service/TerminalDrayageServiceTest.java @@ -0,0 +1,251 @@ +package com.baiyue.firstleg.terminaldrayage.service; + +import com.baiyue.common.core.exception.BizException; +import com.baiyue.firstleg.client.WarehouseSupplierClient; +import com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.ManualNodeRequest; +import com.baiyue.firstleg.portschedule.service.CarrierScheduleQueryService; +import com.baiyue.firstleg.service.WarehouseSupplierSnapshotService; +import com.baiyue.firstleg.terminaldrayage.dto.TerminalDrayageDtos.ActualTimeRequest; +import com.baiyue.firstleg.terminaldrayage.dto.TerminalDrayageDtos.FleetSelectionRequest; +import com.baiyue.firstleg.terminaldrayage.entity.TerminalDrayageEntity; +import com.baiyue.firstleg.terminaldrayage.mapper.TerminalDrayageMapper; +import com.baiyue.firstleg.terminaldrayage.mapper.TerminalDrayageMapper.Context; +import com.baiyue.firstleg.terminaldrayage.mapper.TerminalDrayageMapper.Operation; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; + +import java.time.Clock; +import java.time.Instant; +import java.time.LocalDateTime; +import java.time.ZoneOffset; +import java.util.List; +import java.util.concurrent.atomic.AtomicReference; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +class TerminalDrayageServiceTest { + private static final LocalDateTime ARRIVED_AT = LocalDateTime.of(2026, 9, 1, 8, 0); + private TerminalDrayageMapper mapper; + private WarehouseSupplierSnapshotService suppliers; + private CarrierScheduleQueryService carrierSchedules; + private TerminalDrayageService service; + + @BeforeEach + void setUp() { + mapper = mock(TerminalDrayageMapper.class); + suppliers = mock(WarehouseSupplierSnapshotService.class); + carrierSchedules = mock(CarrierScheduleQueryService.class); + service = new TerminalDrayageService(mapper, suppliers, carrierSchedules, new ObjectMapper().findAndRegisterModules()); + service.setClock(Clock.fixed(Instant.parse("2026-09-05T02:00:00Z"), ZoneOffset.UTC)); + } + + @Test + void rejectsFleetSelectionBeforeActualDestinationArrival() { + when(mapper.lockContext(11L)).thenReturn(context("IN_TRANSIT", null)); + + assertThatThrownBy(() -> service.selectFleet(11L, 7L, "admin", "fleet-1", + new FleetSelectionRequest(0, 91L, null, null))) + .isInstanceOf(BizException.class) + .hasMessage("货柜实际到达目的港后才能安排码头车队"); + verify(suppliers, never()).trucking(anyLong(), any()); + } + + @Test + void selectsActiveTruckingSupplierAndPersistsServerSnapshot() { + Context context = context("ARRIVED_DESTINATION_PORT", null); + when(mapper.lockContext(11L)).thenReturn(context); + when(mapper.context(11L)).thenReturn(context); + when(mapper.lock(11L)).thenReturn(null); + when(mapper.operation(11L, "fleet-1")).thenReturn(null); + when(suppliers.trucking(91L, "用户提示名")).thenReturn( + new WarehouseSupplierClient.Snapshot(91L, "服务端车队", "TRUCKING", "ACTIVE", List.of())); + AtomicReference stored = new AtomicReference<>(); + when(mapper.insert(any(TerminalDrayageEntity.class))).thenAnswer(invocation -> { + TerminalDrayageEntity value = invocation.getArgument(0); + stored.set(value); + return 1; + }); + when(mapper.find(11L)).thenAnswer(ignored -> stored.get()); + + var result = service.selectFleet(11L, 7L, "admin", "fleet-1", + new FleetSelectionRequest(0, 91L, "用户提示名", "安排提柜")); + + assertThat(result.version()).isEqualTo(1); + assertThat(result.fleetSupplierId()).isEqualTo(91L); + assertThat(result.fleetSupplierName()).isEqualTo("服务端车队"); + assertThat(result.allowedActions()).containsExactlyInAnyOrder("SELECT_FLEET", "SCHEDULE_PICKUP"); + verify(mapper).insertHistory(anyLong(), anyLong(), anyLong(), anyString(), anyString(), anyString(), + anyInt(), anyInt(), any(), anyLong(), anyString()); + verify(mapper).insertOperation(anyLong(), anyLong(), anyString(), anyString(), anyString(), anyInt(), anyLong()); + } + + @Test + void recordsPickupOnceAndAdvancesCanonicalNode() { + Context before = context("PICKUP_READY", null); + Context after = context("PICKED_UP", null); + when(mapper.lockContext(11L)).thenReturn(before); + when(mapper.context(11L)).thenReturn(after); + when(mapper.operation(11L, "pickup-1")).thenReturn(null); + TerminalDrayageEntity row = row(2); + row.setPickupAppointmentAt(LocalDateTime.of(2026, 9, 2, 9, 0)); + when(mapper.lock(11L)).thenReturn(row); + when(mapper.updateAggregate(any(), anyInt(), anyLong(), anyString())).thenAnswer(invocation -> { + row.setVersion(3); + return 1; + }); + when(mapper.find(11L)).thenReturn(row); + + var result = service.recordPickup(11L, 7L, "admin", "pickup-1", + new ActualTimeRequest(2, LocalDateTime.of(2026, 9, 2, 10, 0), "码头提柜")); + + assertThat(result.status()).isEqualTo("PICKED_UP"); + assertThat(result.actualPickupAt()).isEqualTo(LocalDateTime.of(2026, 9, 2, 10, 0)); + ArgumentCaptor node = ArgumentCaptor.forClass(ManualNodeRequest.class); + verify(carrierSchedules).addManualNode(anyLong(), anyLong(), anyString(), anyString(), node.capture()); + assertThat(node.getValue().standardNode()).isEqualTo("PICKED_UP"); + verify(mapper, times(1)).updateAggregate(any(), anyInt(), anyLong(), anyString()); + } + + @Test + void requiresOverseasUnloadBeforeEmptyReturn() { + Context context = context("PICKED_UP", null); + when(mapper.lockContext(11L)).thenReturn(context); + when(mapper.operation(11L, "return-1")).thenReturn(null); + TerminalDrayageEntity row = row(3); + row.setActualPickupAt(LocalDateTime.of(2026, 9, 2, 10, 0)); + when(mapper.lock(11L)).thenReturn(row); + + assertThatThrownBy(() -> service.recordEmptyReturn(11L, 7L, "admin", "return-1", + new ActualTimeRequest(3, LocalDateTime.of(2026, 9, 4, 10, 0), null))) + .isInstanceOf(BizException.class) + .hasMessage("海外卸柜收货完成后才能确认还柜"); + verify(carrierSchedules, never()).addManualNode(anyLong(), anyLong(), anyString(), anyString(), any()); + } + + @Test + void computesSeventyTwoHourWarningAndClearsItAfterReturn() { + LocalDateTime unloadedAt = LocalDateTime.of(2026, 9, 1, 0, 0); + Context context = context("PICKED_UP", unloadedAt); + TerminalDrayageEntity row = row(3); + row.setActualPickupAt(LocalDateTime.of(2026, 8, 31, 18, 0)); + when(mapper.context(11L)).thenReturn(context); + when(mapper.find(11L)).thenReturn(row); + + var overdue = service.detail(11L); + assertThat(overdue.estimatedEmptyReturnAt()).isEqualTo(LocalDateTime.of(2026, 9, 4, 0, 0)); + assertThat(overdue.emptyReturnWarning().status()).isEqualTo("OVERDUE"); + assertThat(overdue.emptyReturnWarning().overdueHours()).isEqualTo(26); + assertThat(overdue.emptyReturnWarning().overdueDays()).isEqualTo(1); + + row.setActualEmptyReturnAt(LocalDateTime.of(2026, 9, 4, 1, 0)); + assertThat(service.detail(11L).emptyReturnWarning().status()).isEqualTo("RESOLVED"); + assertThat(service.detail(11L).emptyReturnWarning().overdueHours()).isZero(); + } + + @Test + void sameIdempotencyKeyWithDifferentPayloadConflictsWithoutWriting() { + Context context = context("ARRIVED_DESTINATION_PORT", null); + when(mapper.lockContext(11L)).thenReturn(context); + when(mapper.operation(11L, "same-key")).thenReturn(new Operation("SELECT_FLEET", "different-hash", 1)); + + assertThatThrownBy(() -> service.selectFleet(11L, 7L, "admin", "same-key", + new FleetSelectionRequest(0, 91L, null, null))) + .isInstanceOf(BizException.class) + .hasMessage("Idempotency-Key已被不同的码头拖车操作占用"); + verify(mapper, never()).insert(any(TerminalDrayageEntity.class)); + verify(mapper, never()).updateAggregate(any(), anyInt(), anyLong(), anyString()); + } + + @Test + void sameIdempotencyKeyAndPayloadReplaysWithoutSecondWrite() { + Context context = context("ARRIVED_DESTINATION_PORT", null); + when(mapper.lockContext(11L)).thenReturn(context); + when(mapper.context(11L)).thenReturn(context); + when(mapper.lock(11L)).thenReturn(null); + when(suppliers.trucking(91L, null)).thenReturn( + new WarehouseSupplierClient.Snapshot(91L, "服务端车队", "TRUCKING", "ACTIVE", List.of())); + AtomicReference stored = new AtomicReference<>(); + AtomicReference operation = new AtomicReference<>(); + when(mapper.insert(any(TerminalDrayageEntity.class))).thenAnswer(invocation -> { + stored.set(invocation.getArgument(0)); + return 1; + }); + when(mapper.find(11L)).thenAnswer(ignored -> stored.get()); + when(mapper.operation(11L, "fleet-replay")).thenAnswer(ignored -> operation.get()); + when(mapper.insertOperation(anyLong(), anyLong(), anyString(), anyString(), anyString(), anyInt(), anyLong())) + .thenAnswer(invocation -> { + operation.set(new Operation(invocation.getArgument(2), invocation.getArgument(4), invocation.getArgument(5))); + return 1; + }); + FleetSelectionRequest request = new FleetSelectionRequest(0, 91L, null, null); + + var first = service.selectFleet(11L, 7L, "admin", "fleet-replay", request); + var replay = service.selectFleet(11L, 7L, "admin", "fleet-replay", request); + + assertThat(replay).isEqualTo(first); + verify(mapper, times(1)).insert(any(TerminalDrayageEntity.class)); + verify(mapper, times(1)).insertOperation(anyLong(), anyLong(), anyString(), anyString(), anyString(), anyInt(), anyLong()); + verify(suppliers, times(1)).trucking(91L, null); + } + + @Test + void recordsEmptyReturnAndResolvesWarning() { + LocalDateTime unloadedAt = LocalDateTime.of(2026, 9, 3, 0, 0); + Context before = context("PICKED_UP", unloadedAt); + Context after = context("EMPTY_RETURNED", unloadedAt); + when(mapper.lockContext(11L)).thenReturn(before); + when(mapper.context(11L)).thenReturn(after); + when(mapper.operation(11L, "return-2")).thenReturn(null); + TerminalDrayageEntity row = row(3); + row.setPickupAppointmentAt(LocalDateTime.of(2026, 9, 2, 9, 0)); + row.setActualPickupAt(LocalDateTime.of(2026, 9, 2, 10, 0)); + when(mapper.lock(11L)).thenReturn(row); + when(mapper.updateAggregate(any(), anyInt(), anyLong(), anyString())).thenAnswer(invocation -> { + row.setVersion(4); + return 1; + }); + when(mapper.find(11L)).thenReturn(row); + + var result = service.recordEmptyReturn(11L, 7L, "admin", "return-2", + new ActualTimeRequest(3, LocalDateTime.of(2026, 9, 4, 9, 0), "已还柜")); + + assertThat(result.status()).isEqualTo("EMPTY_RETURNED"); + assertThat(result.estimatedEmptyReturnAt()).isEqualTo(unloadedAt.plusHours(72)); + assertThat(result.emptyReturnWarning().status()).isEqualTo("RESOLVED"); + ArgumentCaptor node = ArgumentCaptor.forClass(ManualNodeRequest.class); + verify(carrierSchedules).addManualNode(anyLong(), anyLong(), anyString(), anyString(), node.capture()); + assertThat(node.getValue().standardNode()).isEqualTo("EMPTY_RETURNED"); + } + + private Context context(String node, LocalDateTime unloadedAt) { + int sequenceVersion = switch (node) { + case "PICKED_UP" -> 5; + case "PICKUP_READY" -> 4; + case "ARRIVED_DESTINATION_PORT" -> 3; + default -> 2; + }; + return new Context(11L, "MSCU1234567", 21L, "ACTIVE", unloadedAt, + 31L, node, ARRIVED_AT, sequenceVersion, ARRIVED_AT, + "PICKED_UP".equals(node) || "EMPTY_RETURNED".equals(node) ? ARRIVED_AT.plusDays(1) : null, + "EMPTY_RETURNED".equals(node) ? ARRIVED_AT.plusDays(3) : null); + } + + private TerminalDrayageEntity row(int version) { + TerminalDrayageEntity value = new TerminalDrayageEntity(); + value.setId(41L); value.setContainerBookingId(11L); value.setVersion(version); + value.setFleetSupplierId(91L); value.setFleetSupplierName("服务端车队"); + return value; + } +} diff --git a/firstleg/src/test/resources/architecture/firstleg-slice-dependencies.json b/firstleg/src/test/resources/architecture/firstleg-slice-dependencies.json index 4ffb1a909..89298259e 100644 --- a/firstleg/src/test/resources/architecture/firstleg-slice-dependencies.json +++ b/firstleg/src/test/resources/architecture/firstleg-slice-dependencies.json @@ -7,6 +7,7 @@ "booking-customs-trucking->outbound", "booking-customs-trucking->pricebook-query", "booking-customs-trucking->shared-technical", + "booking-customs-trucking->shipping-nodes", "booking-customs-trucking->split-loading", "customer-statistics->shared-technical", "finance-integration->inbound", diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/controller/LastlegOrderController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/controller/LastlegOrderController.java index 9e369889d..0984202f9 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/controller/LastlegOrderController.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/controller/LastlegOrderController.java @@ -7,12 +7,14 @@ import com.baiyue.common.security.AuthenticatedUser; import com.baiyue.lastleg.dto.LastlegOrderDetailResponse; import com.baiyue.lastleg.dto.LastlegOrderRequests; import com.baiyue.lastleg.dto.LastlegOrderSummary; +import com.baiyue.lastleg.dto.LastlegOrderCommandDtos; import com.baiyue.lastleg.dto.ContainerPlanProgressDetail; import com.baiyue.lastleg.dto.ContainerPlanProgressSummary; import com.baiyue.lastleg.entity.LastlegOrderFee; import com.baiyue.lastleg.security.InternalServiceTokenGuard; import com.baiyue.lastleg.security.OverseasPermissions; import com.baiyue.lastleg.service.LastlegOrderService; +import com.baiyue.lastleg.service.LastlegOrderCommandService; import com.baiyue.lastleg.service.LoadingCompletedConsumer; import com.baiyue.lastleg.shipping.service.ContainerShippingNodeConsumer; import com.baiyue.common.event.EventTypes; @@ -45,6 +47,7 @@ public class LastlegOrderController { @Resource private LoadingCompletedConsumer consumer; @Resource private ContainerShippingNodeConsumer shippingNodeConsumer; @Resource private LastlegOrderService orderService; + @Resource private LastlegOrderCommandService commandService; @Resource private InternalServiceTokenGuard tokenGuard; @Resource private WalletCommandCompletedConsumer walletCommandCompletedConsumer; @@ -137,17 +140,43 @@ public class LastlegOrderController { return ApiResponse.success(orderService.createFee(platformScope(user, customerId), id, request, operatorName(user))); } - @PutMapping("/api/platform/v1/last-leg/orders/{id}/detained") + @PutMapping("/api/platform/v1/last-leg/orders/{id}/hold") @PreAuthorize("hasAuthority('" + OverseasPermissions.LAST_MILE_ORDER_EDIT + "')") - @Operation(summary = "Set tail-leg order hold status", description = "Required authority: " + OverseasPermissions.LAST_MILE_ORDER_EDIT) - public ApiResponse setDetained( + @Operation(summary = "暂扣或解除单票尾程订单", + description = "要求版本与幂等键;暂扣只作用于该票并阻断其出库推进。Required authority: " + OverseasPermissions.LAST_MILE_ORDER_EDIT) + public ApiResponse hold( @AuthenticationPrincipal AuthenticatedUser user, @RequestParam(required = false) String customerId, @PathVariable long id, - @Valid @RequestBody LastlegOrderRequests.SetDetained request) { - orderService.setDetained(platformScope(user, customerId), id, request, - String.valueOf(user.userId()), operatorName(user)); - return ApiResponse.success(null); + @RequestHeader("Idempotency-Key") String idempotencyKey, + @Valid @RequestBody LastlegOrderCommandDtos.HoldRequest request) { + return ApiResponse.success(commandService.hold(platformScope(user, customerId), id, idempotencyKey, + request, operatorId(user), operatorName(user))); + } + + @PutMapping("/api/platform/v1/last-leg/orders/{id}/manual-nodes") + @PreAuthorize("hasAuthority('" + OverseasPermissions.LAST_MILE_ORDER_EDIT + "')") + @Operation(summary = "保存单票人工节点覆盖", + description = "首次保存后该票永久使用 MANUAL 节点,不再回退柜级节点;当前无清除或恢复继承接口。Required authority: " + OverseasPermissions.LAST_MILE_ORDER_EDIT) + public ApiResponse saveManualNodes( + @AuthenticationPrincipal AuthenticatedUser user, + @RequestParam(required = false) String customerId, + @PathVariable long id, + @RequestHeader("Idempotency-Key") String idempotencyKey, + @Valid @RequestBody LastlegOrderCommandDtos.ManualNodesRequest request) { + return ApiResponse.success(commandService.saveManualNodes(platformScope(user, customerId), id, + idempotencyKey, request, operatorId(user), operatorName(user))); + } + + @GetMapping("/api/platform/v1/last-leg/orders/{id}/manual-nodes") + @PreAuthorize("hasAuthority('" + OverseasPermissions.LAST_MILE_ORDER_LIST + "')") + @Operation(summary = "查询单票人工节点覆盖", + description = "只返回人工填写节点及 MANUAL 来源。Required authority: " + OverseasPermissions.LAST_MILE_ORDER_LIST) + public ApiResponse manualNodes( + @AuthenticationPrincipal AuthenticatedUser user, + @RequestParam(required = false) String customerId, + @PathVariable long id) { + return ApiResponse.success(commandService.manualNodes(platformScope(user, customerId), id)); } @PutMapping("/api/platform/v1/last-leg/orders/{id}/pallet-merge-required") @@ -227,4 +256,8 @@ public class LastlegOrderController { private String operatorName(AuthenticatedUser user) { return user.username() == null || user.username().isBlank() ? String.valueOf(user.userId()) : user.username(); } + + private String operatorId(AuthenticatedUser user) { + return user.userId() == null ? operatorName(user) : String.valueOf(user.userId()); + } } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/mapper/DispatchCandidateMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/mapper/DispatchCandidateMapper.java index 8746a23e3..227e8ad57 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/mapper/DispatchCandidateMapper.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/mapper/DispatchCandidateMapper.java @@ -66,6 +66,10 @@ public interface DispatchCandidateMapper { " AND l.fba_warehouse_code=#{fbaWarehouseCode} " + " AND " + "CASE WHEN p.warehouse_pallet_id IS NULL THEN 'PLAN' ELSE 'RECEIVED' END=#{sourceStage} " + + "AND NOT EXISTS (SELECT 1 FROM ll_inbound_plan_pallet_allocation ha " + + "JOIN ll_inbound_plan_line hl ON hl.id=ha.line_id " + + "JOIN ll_order ho ON ho.id=hl.order_id AND ho.customer_id=ip.customer_id " + + "WHERE ha.pallet_id=p.id AND ho.detained=1) " + "AND (p.warehouse_pallet_id IS NULL OR EXISTS (SELECT 1 FROM ll_inventory_position pos " + "WHERE pos.customer_id=ip.customer_id AND pos.pallet_id=p.warehouse_pallet_id AND pos.available_packages > 0)) " + "AND NOT EXISTS (SELECT 1 FROM ll_dispatch_pallet_lock d " + @@ -124,6 +128,10 @@ public interface DispatchCandidateMapper { "LEFT JOIN ll_order o ON o.id=l.order_id " + "WHERE ip.customer_id=#{customerId} AND p.id IN " + "#{id} " + + "AND NOT EXISTS (SELECT 1 FROM ll_inbound_plan_pallet_allocation ha " + + "JOIN ll_inbound_plan_line hl ON hl.id=ha.line_id " + + "JOIN ll_order ho ON ho.id=hl.order_id AND ho.customer_id=ip.customer_id " + + "WHERE ha.pallet_id=p.id AND ho.detained=1) " + "AND (p.warehouse_pallet_id IS NULL OR EXISTS (SELECT 1 FROM ll_inventory_position pos " + "WHERE pos.customer_id=#{customerId} AND pos.pallet_id=p.warehouse_pallet_id AND pos.available_packages > 0)) " + "AND NOT EXISTS (SELECT 1 FROM ll_warehouse_transfer_inventory_allocation ta " + diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchService.java index b41154dda..05a5e000a 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchService.java @@ -22,6 +22,8 @@ import com.baiyue.lastleg.dispatch.dto.DispatchDraftResponse; import com.baiyue.lastleg.dispatch.model.DispatchPalletCandidate; import com.baiyue.lastleg.dispatch.pda.entity.DispatchPdaTask; import com.baiyue.lastleg.dispatch.pda.mapper.DispatchPdaTaskMapper; +import com.baiyue.lastleg.entity.LastlegOrder; +import com.baiyue.lastleg.mapper.LastlegOrderMapper; import jakarta.annotation.Resource; import org.springframework.dao.DuplicateKeyException; import org.springframework.http.HttpStatus; @@ -53,6 +55,7 @@ public class DispatchService { @Resource private DispatchCandidateMapper candidates; @Resource private DispatchPdaTaskMapper pdaTasks; @Resource private LastlegOrderNoGenerator orderNoGenerator; + @Resource private LastlegOrderMapper tailOrders; public String resolveCustomerScopeForPlans(List planIds) { return requireSingleCustomerScope(candidates.findCustomerScopesByPlanIds(normalizeIds(planIds, "Plan IDs")), @@ -221,6 +224,7 @@ public class DispatchService { if (selected.size() != palletIds.size()) { throw new ResponseStatusException(HttpStatus.NOT_FOUND, "One or more selected pallets are not in this tenant"); } + assertNoDetainedOrders(customerId, selected); boolean missingWarehouse = selected.stream().map(DispatchPalletCandidate::overseasWarehouseId) .anyMatch(value -> value == null || value.isBlank()); if (missingWarehouse) { @@ -351,6 +355,23 @@ public class DispatchService { return response(dispatch, items.findByDispatchOrder(customerId, dispatch.getId())); } + private void assertNoDetainedOrders(String customerId, List selected) { + List orderIds = selected.stream().map(DispatchPalletCandidate::orderId) + .filter(Objects::nonNull).distinct().sorted().toList(); + if (orderIds.isEmpty()) return; + List scoped = tailOrders.selectList(new LambdaQueryWrapper() + .eq(LastlegOrder::getCustomerId, customerId) + .in(LastlegOrder::getId, orderIds) + .orderByAsc(LastlegOrder::getId) + .last("FOR UPDATE")); + List detainedNos = scoped.stream().filter(order -> Boolean.TRUE.equals(order.getDetained())) + .map(LastlegOrder::getOrderNo).filter(StringUtils::hasText).distinct().toList(); + if (!detainedNos.isEmpty()) { + throw new ResponseStatusException(HttpStatus.CONFLICT, + "暂扣订单不可创建排车或出库计划:" + String.join("、", detainedNos)); + } + } + public PageResult page(String customerId, DispatchRequests.Query query) { if (!StringUtils.hasText(customerId)) { return new PageResult<>(List.of(), query.page(), query.size(), 0, 0); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dto/LastlegOrderCommandDtos.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dto/LastlegOrderCommandDtos.java new file mode 100644 index 000000000..d6e4a546f --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dto/LastlegOrderCommandDtos.java @@ -0,0 +1,27 @@ +package com.baiyue.lastleg.dto; + +import jakarta.validation.Valid; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotEmpty; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; +import jakarta.validation.constraints.Min; +import java.time.LocalDateTime; +import java.util.List; + +public final class LastlegOrderCommandDtos { + private LastlegOrderCommandDtos() { } + + public record HoldRequest(@NotNull Boolean held, @NotNull Boolean chargeable, + @NotBlank @Size(max = 500) String reason, + @NotNull @Min(0) Integer expectedVersion) { } + public record HoldResponse(long orderId, boolean held, boolean chargeable, String reason, int version) { } + + public record ManualNode(@NotBlank @Size(max = 32) String nodeCode, + LocalDateTime nodeAt) { } + public record ManualNodesRequest(@NotNull @Min(0) Integer expectedVersion, + @NotEmpty @Size(min = 8, max = 8) List<@Valid ManualNode> nodes) { } + public record ManualNodesResponse(long orderId, boolean manualOverrideEnabled, + List nodes, int version) { } + public record ManualNodeView(String nodeCode, LocalDateTime nodeAt, String source) { } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dto/LastlegOrderDetailResponse.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dto/LastlegOrderDetailResponse.java index 27650b7b0..7be87d591 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dto/LastlegOrderDetailResponse.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dto/LastlegOrderDetailResponse.java @@ -9,6 +9,7 @@ import java.util.List; public record LastlegOrderDetailResponse( LastlegOrderSummary order, + List manualNodes, List cargo, List statusHistory, List fees, diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dto/LastlegOrderSummary.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dto/LastlegOrderSummary.java index a87b5a39c..88f84bda9 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dto/LastlegOrderSummary.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dto/LastlegOrderSummary.java @@ -32,6 +32,11 @@ public record LastlegOrderSummary( String containerStatus, Boolean abnormal, Boolean detained, + Boolean heldChargeable, + String holdReason, + boolean manualOverrideEnabled, + String nodeSource, + int version, Integer palletCount, Integer packageCount, BigDecimal volumeCbm, diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/entity/LastlegOrder.java b/lastleg-service/src/main/java/com/baiyue/lastleg/entity/LastlegOrder.java index 93a4757ef..b86b2a53f 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/entity/LastlegOrder.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/entity/LastlegOrder.java @@ -39,6 +39,10 @@ public class LastlegOrder { private String containerStatus; private Boolean abnormal; private Boolean detained; + private Boolean holdChargeable; + private String holdReason; + private Boolean manualNodeOverride; + private Integer version; private Integer palletCount; private Integer packageCount; private Integer pickedPackageCount; diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/entity/LastlegOrderCommandRecord.java b/lastleg-service/src/main/java/com/baiyue/lastleg/entity/LastlegOrderCommandRecord.java new file mode 100644 index 000000000..9f026da84 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/entity/LastlegOrderCommandRecord.java @@ -0,0 +1,23 @@ +package com.baiyue.lastleg.entity; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.Data; +import java.time.LocalDateTime; + +@Data +@TableName("ll_order_command_record") +public class LastlegOrderCommandRecord { + @TableId(type = IdType.INPUT) private Long id; + private String customerId; + private Long orderId; + private String action; + private String operatorId; + private String idempotencyKey; + private String requestHash; + private String resultRef; + private String resultJson; + private Integer resultVersion; + private LocalDateTime createdAt; +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/entity/LastlegOrderHoldHistory.java b/lastleg-service/src/main/java/com/baiyue/lastleg/entity/LastlegOrderHoldHistory.java new file mode 100644 index 000000000..2abb9b1c9 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/entity/LastlegOrderHoldHistory.java @@ -0,0 +1,23 @@ +package com.baiyue.lastleg.entity; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.Data; +import java.time.LocalDateTime; + +@Data +@TableName("ll_order_hold_history") +public class LastlegOrderHoldHistory { + @TableId(type = IdType.INPUT) private Long id; + private String customerId; + private Long orderId; + private Boolean held; + private Boolean chargeable; + private String reason; + private Integer orderVersion; + private String operatorId; + private String operatorName; + private String idempotencyKey; + private LocalDateTime createdAt; +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/entity/LastlegOrderManualNode.java b/lastleg-service/src/main/java/com/baiyue/lastleg/entity/LastlegOrderManualNode.java new file mode 100644 index 000000000..323fc685c --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/entity/LastlegOrderManualNode.java @@ -0,0 +1,21 @@ +package com.baiyue.lastleg.entity; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.Data; +import java.time.LocalDateTime; + +@Data +@TableName("ll_order_manual_node") +public class LastlegOrderManualNode { + @TableId(type = IdType.INPUT) private Long id; + private String customerId; + private Long orderId; + private String nodeCode; + private LocalDateTime nodeAt; + private String source; + private Integer orderVersion; + private String updatedBy; + private LocalDateTime updatedAt; +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/entity/LastlegOrderManualNodeHistory.java b/lastleg-service/src/main/java/com/baiyue/lastleg/entity/LastlegOrderManualNodeHistory.java new file mode 100644 index 000000000..cc3ec6899 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/entity/LastlegOrderManualNodeHistory.java @@ -0,0 +1,21 @@ +package com.baiyue.lastleg.entity; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.Data; +import java.time.LocalDateTime; + +@Data +@TableName("ll_order_manual_node_history") +public class LastlegOrderManualNodeHistory { + @TableId(type = IdType.INPUT) private Long id; + private String customerId; + private Long orderId; + private String nodesJson; + private Integer orderVersion; + private String operatorId; + private String operatorName; + private String idempotencyKey; + private LocalDateTime createdAt; +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/controller/InboundPlanController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/controller/InboundPlanController.java index 849239c28..b626a54b2 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/controller/InboundPlanController.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/controller/InboundPlanController.java @@ -3,8 +3,8 @@ package com.baiyue.lastleg.inboundplan.controller; import com.baiyue.common.core.api.ApiResponse; import com.baiyue.common.core.api.PageResult; import com.baiyue.common.security.AuthenticatedUser; -import com.baiyue.lastleg.inboundplan.dto.ConvertInboundPlanRequest; import com.baiyue.lastleg.inboundplan.dto.ConvertContainerInboundPlanRequest; +import com.baiyue.lastleg.inboundplan.dto.ManualInboundPlanRequest; import com.baiyue.lastleg.inboundplan.dto.BatchFbaAppointmentRequest; import com.baiyue.lastleg.inboundplan.dto.FbaAppointmentResponse; import com.baiyue.lastleg.inboundplan.dto.InboundPlanResponse; @@ -44,14 +44,19 @@ public class InboundPlanController { return ApiResponse.success(service.page(scope(user, customerId), query)); } - @PostMapping("/convert") + @PostMapping("/manual") @PreAuthorize("hasAuthority('" + OverseasPermissions.INBOUND_ORDER_CREATE + "')") - /** 由平台人工将待分配尾程订单转换为入仓计划。 */ - @Operation(summary = "人工创建入仓计划", description = "头程装柜完成只生成待分配尾程订单;平台确认后调用本接口创建入仓计划。Required authority: " + @Operation(summary = "手工填写并创建单票入仓计划", description = "要求订单版本和 Idempotency-Key;仓库主档、入仓码及客户归属均由服务端校验。Required authority: " + OverseasPermissions.INBOUND_ORDER_CREATE) - public ApiResponse convert(@AuthenticationPrincipal AuthenticatedUser user, - @Valid @RequestBody ConvertInboundPlanRequest request) { - return ApiResponse.success(service.convert(convertScope(user, request.customerId()), request.orderId())); + public ApiResponse manual(@AuthenticationPrincipal AuthenticatedUser user, + @RequestHeader("Idempotency-Key") String idempotencyKey, + @Valid @RequestBody ManualInboundPlanRequest request) { + if (user == null || user.tenantId() == null || user.tenantId() != 0L || !"PLATFORM".equals(user.appCode())) { + throw new ResponseStatusException(HttpStatus.FORBIDDEN, "仅平台账号可手工创建入仓计划"); + } + convertScope(user, request.customerId()); + String operatorId = user.userId() == null ? user.username() : String.valueOf(user.userId()); + return ApiResponse.success(service.convertManual(request, idempotencyKey, operatorId)); } @PostMapping("/convert-container") diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/dto/ConvertInboundPlanRequest.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/dto/ConvertInboundPlanRequest.java deleted file mode 100644 index 615215fa4..000000000 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/dto/ConvertInboundPlanRequest.java +++ /dev/null @@ -1,10 +0,0 @@ -package com.baiyue.lastleg.inboundplan.dto; - -import jakarta.validation.constraints.NotNull; -import jakarta.validation.constraints.Size; - -public record ConvertInboundPlanRequest( - @NotNull Long orderId, - @Size(max = 128) String customerId -) { -} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/dto/InboundPlanResponse.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/dto/InboundPlanResponse.java index f7c7ece04..8188d0a75 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/dto/InboundPlanResponse.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/dto/InboundPlanResponse.java @@ -2,6 +2,7 @@ package com.baiyue.lastleg.inboundplan.dto; import java.math.BigDecimal; import java.time.LocalDateTime; +import java.time.LocalDate; import java.util.List; public record InboundPlanResponse( @@ -21,7 +22,14 @@ public record InboundPlanResponse( List lines, List pallets, List allocations, - int version + int version, + LocalDate plannedArrivalDate, + String palletType, + boolean assigned, + boolean mergeRequired, + boolean urgent, + String creationMode, + String remark ) { public record Line(Long id, Long orderId, String inboundCode, String fbaWarehouseCode, String destinationWarehouseId, String destinationWarehouseCode, String destinationWarehouseName, @@ -32,7 +40,7 @@ public record InboundPlanResponse( String destinationWarehouseCode, String destinationWarehouseName, BigDecimal capacityCbm, BigDecimal allocatedVolumeCbm, int sequenceNo, Long warehousePalletId, Long warehouseLocationId, Long warehouseResourceLockId, - String assignmentStatus, LocalDateTime lockedAt, BigDecimal actualVolumeCbm, + String palletType, String assignmentStatus, LocalDateTime lockedAt, BigDecimal actualVolumeCbm, int actualPackageCount) { } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/dto/ManualInboundPlanRequest.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/dto/ManualInboundPlanRequest.java new file mode 100644 index 000000000..fb8bd05e8 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/dto/ManualInboundPlanRequest.java @@ -0,0 +1,25 @@ +package com.baiyue.lastleg.inboundplan.dto; + +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; +import java.time.LocalDate; + +public record ManualInboundPlanRequest( + @NotBlank @Size(max = 128) String customerId, + @NotNull Long orderId, + @NotNull @Min(0) Integer expectedOrderVersion, + @NotNull Long plannedWarehouseId, + @NotBlank @Size(max = 128) String plannedWarehouseCode, + @NotBlank @Size(max = 255) String plannedWarehouseName, + @NotNull LocalDate plannedArrivalDate, + @NotBlank @Size(max = 128) String inboundCode, + @NotNull @Min(1) @Max(999) Integer palletCount, + @NotBlank @Size(max = 32) String palletType, + @NotNull Boolean assigned, + @NotNull Boolean mergeRequired, + @NotNull Boolean urgent, + @Size(max = 1000) String remark +) { } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/entity/InboundPlan.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/entity/InboundPlan.java index 51c30b470..5a675332c 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/entity/InboundPlan.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/entity/InboundPlan.java @@ -8,6 +8,7 @@ import lombok.Data; import java.math.BigDecimal; import java.time.LocalDateTime; +import java.time.LocalDate; @Data @TableName("ll_inbound_plan") @@ -19,6 +20,13 @@ public class InboundPlan { private Long sourceOrderId; private String containerNo; private Long overseasWarehouseId; + private LocalDate plannedArrivalDate; + private String palletType; + private Boolean assignedFlag; + private Boolean mergeRequired; + private Boolean urgentFlag; + private String creationMode; + private String planRemark; private String status; private Integer lineCount; private Integer palletCount; diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/entity/InboundPlanPallet.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/entity/InboundPlanPallet.java index fbd1be83b..5e2085168 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/entity/InboundPlanPallet.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/entity/InboundPlanPallet.java @@ -17,6 +17,7 @@ public class InboundPlanPallet { private Long planId; private String palletNo; private String sourcePalletNo; + private String palletType; private String destinationWarehouseId; private String destinationWarehouseCode; private String destinationWarehouseName; diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/mapper/InboundPlanOrderMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/mapper/InboundPlanOrderMapper.java index 8c180a6e8..77bfc983d 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/mapper/InboundPlanOrderMapper.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/mapper/InboundPlanOrderMapper.java @@ -36,12 +36,12 @@ public interface InboundPlanOrderMapper { "COALESCE(NULLIF(o.transfer_warehouse_name,''),MIN(NULLIF(d.destination_warehouse_name,''))) AS destinationWarehouseName," + "CASE WHEN COALESCE(NULLIF(o.transfer_warehouse_id,''),NULLIF(o.transfer_warehouse_code,'')) IS NOT NULL THEN 1 " + "ELSE COUNT(DISTINCT COALESCE(NULLIF(d.destination_warehouse_id,''),NULLIF(d.destination_warehouse_code,''),'#EMPTY#')) END AS destinationCount," + - "0 AS destinationMismatchCount " + + "0 AS destinationMismatchCount,o.detained AS detained,o.version AS version " + "FROM ll_order o LEFT JOIN ll_order_detail d ON d.order_id=o.id " + "WHERE o.customer_id=#{customerId} AND o.id=#{orderId} " + "GROUP BY o.id,o.customer_id,o.container_no,o.inbound_code,o.current_status,o.inbound_order_no,o.volume_cbm,o.package_count,o.pallet_count," + "o.overseas_warehouse_id,o.overseas_warehouse_code,o.overseas_warehouse_name,o.fba_warehouse_code," + - "o.transfer_warehouse_id,o.transfer_warehouse_code,o.transfer_warehouse_name " + + "o.transfer_warehouse_id,o.transfer_warehouse_code,o.transfer_warehouse_name,o.detained,o.version " + "ORDER BY o.id FOR UPDATE") List lockOrder(@Param("customerId") String customerId, @Param("orderId") Long orderId); @@ -59,6 +59,17 @@ public interface InboundPlanOrderMapper { @Param("planNo") String planNo, @Param("palletCount") int palletCount, @Param("updatedAt") LocalDateTime updatedAt); + @Update("UPDATE ll_order SET inbound_order_no=#{planNo},current_status='INBOUND_PLANNED'," + + "pallet_merge_flag=CASE WHEN #{mergeRequired}=1 THEN 'MERGE_REQUIRED' ELSE NULL END," + + "pallet_count=#{palletCount},version=version+1,updated_at=#{updatedAt} " + + "WHERE customer_id=#{customerId} AND id=#{orderId} AND version=#{expectedVersion} AND detained=0 " + + "AND current_status IN ('PENDING_ASSIGNMENT','DESTINATION_ASSIGNED') AND inbound_order_no IS NULL") + int markPlannedManual(@Param("customerId") String customerId, @Param("orderId") Long orderId, + @Param("planNo") String planNo, @Param("palletCount") int palletCount, + @Param("mergeRequired") boolean mergeRequired, + @Param("expectedVersion") int expectedVersion, + @Param("updatedAt") LocalDateTime updatedAt); + @Insert("INSERT INTO ll_loading_order_status_history(customer_id,order_id,from_status,to_status,remark,created_at) " + "VALUES(#{customerId},#{orderId},#{fromStatus},'INBOUND_PLANNED',#{remark},#{createdAt})") int insertHistory(@Param("customerId") String customerId, @Param("orderId") Long orderId, @Param("fromStatus") String fromStatus, diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/model/SourceOrderSnapshot.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/model/SourceOrderSnapshot.java index a816826c0..dd67b0d6d 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/model/SourceOrderSnapshot.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/model/SourceOrderSnapshot.java @@ -20,5 +20,19 @@ public record SourceOrderSnapshot( String destinationWarehouseCode, String destinationWarehouseName, int destinationCount, - int destinationMismatchCount -) { } + int destinationMismatchCount, + boolean detained, + int version +) { + public SourceOrderSnapshot(Long orderId, String customerId, String containerNo, String inboundCode, + String currentStatus, String inboundOrderNo, BigDecimal volumeCbm, + int packageCount, int recommendedPalletCount, String overseasWarehouseId, + String overseasWarehouseCode, String overseasWarehouseName, String fbaWarehouseCode, + String destinationWarehouseId, String destinationWarehouseCode, + String destinationWarehouseName, int destinationCount, int destinationMismatchCount) { + this(orderId, customerId, containerNo, inboundCode, currentStatus, inboundOrderNo, volumeCbm, + packageCount, recommendedPalletCount, overseasWarehouseId, overseasWarehouseCode, overseasWarehouseName, + fbaWarehouseCode, destinationWarehouseId, destinationWarehouseCode, destinationWarehouseName, + destinationCount, destinationMismatchCount, false, 0); + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/service/InboundPlanService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/service/InboundPlanService.java index 4c5f08a41..ff07150f4 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/service/InboundPlanService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/service/InboundPlanService.java @@ -6,6 +6,7 @@ import com.baiyue.common.core.api.PageResult; import com.baiyue.lastleg.inboundplan.dto.InboundPlanListResponse; import com.baiyue.lastleg.inboundplan.dto.InboundPlanRequests; import com.baiyue.lastleg.inboundplan.dto.InboundPlanResponse; +import com.baiyue.lastleg.inboundplan.dto.ManualInboundPlanRequest; import com.baiyue.lastleg.inboundplan.dto.SupplementPalletRequest; import com.baiyue.lastleg.inboundplan.dto.UpdateInboundPlanRequest; import com.baiyue.lastleg.inboundplan.entity.InboundPlan; @@ -23,6 +24,11 @@ import com.baiyue.lastleg.inventory.mapper.InventoryBalanceMapper; import com.baiyue.lastleg.dropship.model.DropshipPalletSnapshot; import com.baiyue.lastleg.dropship.service.DropshipOrderService; import com.baiyue.common.event.ContainerNumbers; +import com.baiyue.lastleg.entity.LastlegOrderCommandRecord; +import com.baiyue.lastleg.mapper.LastlegOrderCommandRecordMapper; +import com.baiyue.lastleg.inboundplan.pda.gateway.WarehouseResourceGateway; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; import jakarta.annotation.Resource; import org.springframework.http.HttpStatus; import org.springframework.stereotype.Service; @@ -31,6 +37,10 @@ import org.springframework.web.server.ResponseStatusException; import java.math.BigDecimal; import java.time.LocalDateTime; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.HexFormat; import java.util.HashMap; import java.util.List; import java.util.Map; @@ -50,6 +60,64 @@ public class InboundPlanService { @Resource private InboundPlanQueryMapper queryMapper; @Resource private InventoryBalanceMapper inventoryBalanceMapper; @Resource private DropshipOrderService dropshipOrders; + @Resource private WarehouseResourceGateway warehouseGateway; + @Resource private LastlegOrderCommandRecordMapper commandMapper; + @Resource private ObjectMapper objectMapper; + + @Transactional + public InboundPlanResponse convertManual(ManualInboundPlanRequest request, String idempotencyKey, + String operatorId) { + String customerId = trimToNull(request.customerId()); + if (customerId == null || "0".equals(customerId)) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "平台人工计划必须指定客户"); + } + String key = trimToNull(idempotencyKey); + if (key == null || key.length() > 128) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Idempotency-Key 必须为1至128个字符"); + } + List snapshots = orderMapper.lockOrder(customerId, request.orderId()); + if (snapshots == null || snapshots.size() != 1) { + throw new ResponseStatusException(HttpStatus.NOT_FOUND, "尾程订单不存在"); + } + SourceOrderSnapshot order = snapshots.get(0); + String requestHash = sha256(request); + LastlegOrderCommandRecord replay = commandMapper.find(customerId, request.orderId(), "MANUAL_PLAN", operatorId, key); + if (replay != null) { + if (!MessageDigest.isEqual(replay.getRequestHash().getBytes(StandardCharsets.UTF_8), + requestHash.getBytes(StandardCharsets.UTF_8))) { + throw new ResponseStatusException(HttpStatus.CONFLICT, "同一幂等键不能用于不同请求"); + } + return detailByPlanNo(customerId, replay.getResultRef()); + } + if (planMapper.findBySourceOrder(customerId, request.orderId()) != null) { + throw new ResponseStatusException(HttpStatus.CONFLICT, "尾程订单已生成入库计划"); + } + if (order.version() != request.expectedOrderVersion()) { + throw new ResponseStatusException(HttpStatus.CONFLICT, "尾程订单版本已变化,请刷新后重试"); + } + if (order.detained()) throw new ResponseStatusException(HttpStatus.CONFLICT, "暂扣尾程订单不能生成入库计划"); + if (!Objects.equals(trimToNull(order.inboundCode()), trimToNull(request.inboundCode()))) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "入仓码与尾程订单冻结事实不一致"); + } + WarehouseResourceGateway.WarehouseReference warehouse = + warehouseGateway.requireActiveSharedWarehouse(request.plannedWarehouseId()); + if (!Objects.equals(String.valueOf(warehouse.warehouseId()), trimToNull(order.overseasWarehouseId())) + || !Objects.equals(warehouse.warehouseCode(), request.plannedWarehouseCode().trim()) + || !Objects.equals(warehouse.warehouseName(), request.plannedWarehouseName().trim()) + || !Objects.equals(warehouse.warehouseCode(), trimToNull(order.overseasWarehouseCode())) + || !Objects.equals(warehouse.warehouseName(), trimToNull(order.overseasWarehouseName()))) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "计划入仓仓库不存在、已停用或与订单预分配事实不一致"); + } + validateOrders(snapshots); + InboundPlanResponse response = createPlan(customerId, request.orderId(), snapshots, List.of(), null, request); + LastlegOrderCommandRecord command = new LastlegOrderCommandRecord(); + command.setId(IdWorker.getId()); command.setCustomerId(customerId); command.setOrderId(request.orderId()); + command.setAction("MANUAL_PLAN"); command.setOperatorId(operatorId); command.setIdempotencyKey(key); + command.setRequestHash(requestHash); command.setResultRef(response.planNo()); + command.setResultJson(json(response)); command.setResultVersion(request.expectedOrderVersion() + 1); + command.setCreatedAt(LocalDateTime.now()); commandMapper.insert(command); + return response; + } @Transactional(readOnly = true) public PageResult page(String customerId, InboundPlanRequests.Query query) { @@ -112,12 +180,12 @@ public class InboundPlanService { return detail(customerId, existing); } validateOrders(orders); - return createPlan(customerId, selectedOrderId, orders, sourcePalletNos, wholePallets); + return createPlan(customerId, selectedOrderId, orders, sourcePalletNos, wholePallets, null); } private InboundPlanResponse createPlan(String customerId, Long selectedOrderId, List orders, List sourcePalletNos, - List wholePallets) { + List wholePallets, ManualInboundPlanRequest manual) { String containerNo = requiredContainerNo(orders.get(0).containerNo()); LocalDateTime now = LocalDateTime.now(); InboundPlan plan = new InboundPlan(); @@ -136,6 +204,13 @@ public class InboundPlanService { throw new ResponseStatusException(HttpStatus.CONFLICT, "接柜仓ID格式无效", exception); } plan.setStatus("PENDING_ASSIGNMENT"); + plan.setPlannedArrivalDate(manual == null ? null : manual.plannedArrivalDate()); + plan.setPalletType(manual == null ? null : manual.palletType().trim().toUpperCase()); + plan.setAssignedFlag(manual != null && manual.assigned()); + plan.setMergeRequired(manual != null && manual.mergeRequired()); + plan.setUrgentFlag(manual != null && manual.urgent()); + plan.setCreationMode(manual == null ? "AUTO" : "MANUAL"); + plan.setPlanRemark(manual == null ? null : trimToNull(manual.remark())); plan.setLineCount(orders.size()); plan.setTotalVolumeCbm(orders.stream().map(SourceOrderSnapshot::volumeCbm) .filter(Objects::nonNull).reduce(BigDecimal.ZERO, BigDecimal::add)); @@ -145,16 +220,20 @@ public class InboundPlanService { Map orderById = orders.stream() .collect(Collectors.toMap(SourceOrderSnapshot::orderId, value -> value)); Map lineByOrder = createLines(plan, orders); - PalletAllocator.Result allocation = wholePallets == null ? PalletAllocator.allocate(orders.stream() + PalletAllocator.Result allocation = wholePallets == null && manual == null ? PalletAllocator.allocate(orders.stream() .map(order -> new PalletAllocator.LineInput(order.orderId(), warehouseKey(order), volume(order), order.packageCount(), order.recommendedPalletCount())).toList()) : null; if (wholePallets != null) validateWholePallets(orders, wholePallets); - plan.setPalletCount(wholePallets == null ? allocation.pallets().size() : wholePallets.size()); + plan.setPalletCount(manual != null ? manual.palletCount() + : wholePallets == null ? allocation.pallets().size() : wholePallets.size()); planMapper.insert(plan); lineByOrder.values().forEach(lineMapper::insert); Map palletCounts; - if (wholePallets == null) { + if (manual != null) { + createManualPallets(plan, orders.get(0), lineByOrder.get(orders.get(0).orderId()), manual); + palletCounts = Map.of(orders.get(0).orderId(), manual.palletCount()); + } else if (wholePallets == null) { Map persistedPallets = createPallets(plan, allocation, orderById, sourcePalletNos == null ? List.of() : sourcePalletNos); createAllocations(plan, allocation, lineByOrder, persistedPallets); @@ -171,7 +250,11 @@ public class InboundPlanService { InboundPlanLine line = lineByOrder.get(order.orderId()); line.setPlannedPalletCount(count); lineMapper.updateById(line); - if (orderMapper.markPlanned(customerId, order.orderId(), plan.getPlanNo(), count, now) != 1) { + int updated = manual == null + ? orderMapper.markPlanned(customerId, order.orderId(), plan.getPlanNo(), count, now) + : orderMapper.markPlannedManual(customerId, order.orderId(), plan.getPlanNo(), count, + manual.mergeRequired(), manual.expectedOrderVersion(), now); + if (updated != 1) { throw new IllegalStateException("Failed to update order " + order.orderId()); } inventoryBalanceMapper.bindInboundPlan(customerId, order.orderId(), order.inboundCode(), @@ -248,7 +331,7 @@ public class InboundPlanService { for (int index = 0; index < refs.size(); index++) { InboundPlanOrderMapper.ContainerOrderRef ref = refs.get(index); created.add(createPlan(ref.customerId(), ref.orderId(), snapshots.get(index), List.of(), - wholePallets.get(index))); + wholePallets.get(index), null)); } return List.copyOf(created); } @@ -257,6 +340,43 @@ public class InboundPlanService { return new WholePalletInput(pallet.sourcePalletNo(), Math.toIntExact(pallet.packageCount())); } + private void createManualPallets(InboundPlan plan, SourceOrderSnapshot order, InboundPlanLine line, + ManualInboundPlanRequest request) { + if (order.packageCount() > 0 && request.palletCount() > order.packageCount()) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "人工计划托盘数不能大于订单箱数"); + } + BigDecimal remainingVolume = volume(order); + int remainingPackages = order.packageCount(); + for (int index = 0; index < request.palletCount(); index++) { + int slots = request.palletCount() - index; + int packages = slots == 1 ? remainingPackages : remainingPackages / slots; + BigDecimal palletVolume = slots == 1 ? remainingVolume + : remainingVolume.divide(BigDecimal.valueOf(slots), 4, java.math.RoundingMode.HALF_UP); + InboundPlanPallet pallet = new InboundPlanPallet(); + pallet.setId(IdWorker.getId()); pallet.setCustomerId(plan.getCustomerId()); pallet.setPlanId(plan.getId()); + pallet.setPalletNo(plan.getPlanNo() + "-P" + String.format("%03d", index + 1)); + pallet.setPalletType(request.palletType().trim().toUpperCase()); + pallet.setDestinationWarehouseId(order.destinationWarehouseId()); + pallet.setDestinationWarehouseCode(order.destinationWarehouseCode()); + pallet.setDestinationWarehouseName(order.destinationWarehouseName()); + pallet.setCapacityCbm(PalletAllocator.PALLET_CAPACITY_CBM); pallet.setAllocatedVolumeCbm(palletVolume); + pallet.setSequenceNo(index + 1); pallet.setAssignmentStatus("UNASSIGNED"); + pallet.setActualVolumeCbm(BigDecimal.ZERO); pallet.setActualPackageCount(0); palletMapper.insert(pallet); + + InboundPlanPalletAllocation allocation = new InboundPlanPalletAllocation(); + allocation.setId(IdWorker.getId()); allocation.setCustomerId(plan.getCustomerId()); allocation.setPlanId(plan.getId()); + allocation.setLineId(line.getId()); allocation.setPalletId(pallet.getId()); + allocation.setAllocatedVolumeCbm(palletVolume); allocation.setPlannedPackageCount(packages); + allocation.setCountedPackageCount(0); allocation.setPickedPackageCount(0); + allocation.setActualVolumeCbm(BigDecimal.ZERO); allocation.setStatus("PENDING_COUNT"); allocation.setVersion(0); + allocationMapper.insert(allocation); + remainingPackages -= packages; remainingVolume = remainingVolume.subtract(palletVolume); + } + if (remainingPackages != 0 || remainingVolume.signum() != 0) { + throw new IllegalStateException("人工计划托盘分配未保持箱数或体积守恒"); + } + } + private String requiredContainerNo(String value) { try { return ContainerNumbers.requireCanonical(value); @@ -395,7 +515,7 @@ public class InboundPlanService { plan.setPalletCount(plan.getPalletCount() + 1); plan.setUpdatedAt(LocalDateTime.now()); planMapper.updateById(plan); return new InboundPlanResponse.Pallet(pallet.getId(), pallet.getPalletNo(), pallet.getDestinationWarehouseId(), pallet.getDestinationWarehouseCode(), pallet.getDestinationWarehouseName(), pallet.getCapacityCbm(), - pallet.getAllocatedVolumeCbm(), pallet.getSequenceNo(), null, null, null, pallet.getAssignmentStatus(), + pallet.getAllocatedVolumeCbm(), pallet.getSequenceNo(), null, null, null, pallet.getPalletType(), pallet.getAssignmentStatus(), null, pallet.getActualVolumeCbm(), pallet.getActualPackageCount()); } @@ -533,12 +653,14 @@ public class InboundPlanService { pallet.getDestinationWarehouseId(), pallet.getDestinationWarehouseCode(), pallet.getDestinationWarehouseName(), pallet.getCapacityCbm(), pallet.getAllocatedVolumeCbm(), pallet.getSequenceNo(), pallet.getWarehousePalletId(), pallet.getWarehouseLocationId(), pallet.getWarehouseResourceLockId(), - pallet.getAssignmentStatus(), pallet.getLockedAt(), pallet.getActualVolumeCbm(), + pallet.getPalletType(), pallet.getAssignmentStatus(), pallet.getLockedAt(), pallet.getActualVolumeCbm(), pallet.getActualPackageCount())).toList(), allocations.stream().map(item -> new InboundPlanResponse.Allocation(item.getLineId(), item.getPalletId(), item.getAllocatedVolumeCbm(), item.getPlannedPackageCount(), item.getCountedPackageCount(), item.getPickedPackageCount(), item.getActualVolumeCbm(), item.getStatus(), item.getVersion())).toList(), - plan.getVersion() == null ? 0 : plan.getVersion()); + plan.getVersion() == null ? 0 : plan.getVersion(), plan.getPlannedArrivalDate(), plan.getPalletType(), + Boolean.TRUE.equals(plan.getAssignedFlag()), Boolean.TRUE.equals(plan.getMergeRequired()), + Boolean.TRUE.equals(plan.getUrgentFlag()), plan.getCreationMode(), plan.getPlanRemark()); } private BigDecimal volume(SourceOrderSnapshot order) { @@ -554,4 +676,18 @@ public class InboundPlanService { private static String trimToNull(String value) { return value == null || value.isBlank() ? null : value.trim(); } + + private String sha256(Object value) { + try { + return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256") + .digest(objectMapper.writeValueAsBytes(value))); + } catch (NoSuchAlgorithmException | JsonProcessingException exception) { + throw new IllegalStateException("无法生成命令摘要", exception); + } + } + + private String json(Object value) { + try { return objectMapper.writeValueAsString(value); } + catch (JsonProcessingException exception) { throw new IllegalStateException("无法保存命令结果", exception); } + } } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/mapper/LastlegOrderCommandRecordMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/mapper/LastlegOrderCommandRecordMapper.java new file mode 100644 index 000000000..45929d17a --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/mapper/LastlegOrderCommandRecordMapper.java @@ -0,0 +1,16 @@ +package com.baiyue.lastleg.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.baiyue.lastleg.entity.LastlegOrderCommandRecord; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; +import org.apache.ibatis.annotations.Select; + +@Mapper +public interface LastlegOrderCommandRecordMapper extends BaseMapper { + @Select("SELECT * FROM ll_order_command_record WHERE customer_id=#{customerId} AND order_id=#{orderId} " + + "AND action=#{action} AND operator_id=#{operatorId} AND idempotency_key=#{key} LIMIT 1") + LastlegOrderCommandRecord find(@Param("customerId") String customerId, @Param("orderId") long orderId, + @Param("action") String action, @Param("operatorId") String operatorId, + @Param("key") String key); +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/mapper/LastlegOrderHoldHistoryMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/mapper/LastlegOrderHoldHistoryMapper.java new file mode 100644 index 000000000..16f0dc36f --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/mapper/LastlegOrderHoldHistoryMapper.java @@ -0,0 +1,8 @@ +package com.baiyue.lastleg.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.baiyue.lastleg.entity.LastlegOrderHoldHistory; +import org.apache.ibatis.annotations.Mapper; + +@Mapper +public interface LastlegOrderHoldHistoryMapper extends BaseMapper { } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/mapper/LastlegOrderManualNodeHistoryMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/mapper/LastlegOrderManualNodeHistoryMapper.java new file mode 100644 index 000000000..3ec9721b9 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/mapper/LastlegOrderManualNodeHistoryMapper.java @@ -0,0 +1,8 @@ +package com.baiyue.lastleg.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.baiyue.lastleg.entity.LastlegOrderManualNodeHistory; +import org.apache.ibatis.annotations.Mapper; + +@Mapper +public interface LastlegOrderManualNodeHistoryMapper extends BaseMapper { } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/mapper/LastlegOrderManualNodeMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/mapper/LastlegOrderManualNodeMapper.java new file mode 100644 index 000000000..66910511f --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/mapper/LastlegOrderManualNodeMapper.java @@ -0,0 +1,13 @@ +package com.baiyue.lastleg.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.baiyue.lastleg.entity.LastlegOrderManualNode; +import org.apache.ibatis.annotations.Delete; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; + +@Mapper +public interface LastlegOrderManualNodeMapper extends BaseMapper { + @Delete("DELETE FROM ll_order_manual_node WHERE customer_id=#{customerId} AND order_id=#{orderId}") + int deleteCurrent(@Param("customerId") String customerId, @Param("orderId") long orderId); +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/mapper/LastlegOrderMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/mapper/LastlegOrderMapper.java index 57e3ac02d..1c629aedb 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/mapper/LastlegOrderMapper.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/mapper/LastlegOrderMapper.java @@ -146,8 +146,27 @@ public interface LastlegOrderMapper extends BaseMapper { @Select("SELECT * FROM ll_order WHERE customer_id=#{customerId} AND id=#{id} FOR UPDATE") LastlegOrder lockById(@Param("customerId") String customerId, @Param("id") Long id); + @Update("UPDATE ll_order SET detained=#{held},hold_chargeable=#{chargeable}," + + "hold_reason=CASE WHEN #{held}=1 THEN #{reason} ELSE NULL END,version=version+1,updated_at=#{updatedAt} " + + "WHERE customer_id=#{customerId} AND id=#{id} AND version=#{expectedVersion}") + int updateHold(@Param("customerId") String customerId, @Param("id") long id, + @Param("held") boolean held, @Param("chargeable") boolean chargeable, + @Param("reason") String reason, @Param("expectedVersion") int expectedVersion, + @Param("updatedAt") java.time.LocalDateTime updatedAt); + + @Update("UPDATE ll_order SET manual_node_override=1,estimated_arrival_at=#{estimatedArrivalAt}," + + "actual_arrival_at=#{actualArrivalAt},estimated_warehouse_at=#{estimatedWarehouseAt}," + + "version=version+1,updated_at=#{updatedAt} WHERE customer_id=#{customerId} AND id=#{id} " + + "AND version=#{expectedVersion}") + int updateManualNodes(@Param("customerId") String customerId, @Param("id") long id, + @Param("estimatedArrivalAt") java.time.LocalDateTime estimatedArrivalAt, + @Param("actualArrivalAt") java.time.LocalDateTime actualArrivalAt, + @Param("estimatedWarehouseAt") java.time.LocalDateTime estimatedWarehouseAt, + @Param("expectedVersion") int expectedVersion, + @Param("updatedAt") java.time.LocalDateTime updatedAt); + @Update("UPDATE ll_order SET picked_package_count=picked_package_count+#{packages},updated_at=NOW() " + - "WHERE customer_id=#{customerId} AND id=#{id} " + + "WHERE customer_id=#{customerId} AND id=#{id} AND detained=0 " + "AND package_count-shipped_package_count-picked_package_count >= #{packages}") int increasePicked(@Param("customerId") String customerId, @Param("id") Long id, @Param("packages") long packages); @@ -156,7 +175,7 @@ public interface LastlegOrderMapper extends BaseMapper { "THEN 'COMPLETED' ELSE 'PARTIALLY_SHIPPED' END," + "picked_package_count=picked_package_count-#{packages}," + "shipped_package_count=shipped_package_count+#{packages},outbound_order_no=#{outboundNo},updated_at=NOW() " + - "WHERE customer_id=#{customerId} AND id=#{id} AND picked_package_count >= #{packages} " + + "WHERE customer_id=#{customerId} AND id=#{id} AND detained=0 AND picked_package_count >= #{packages} " + "AND shipped_package_count+#{packages} <= package_count") int transferPickedToShipped(@Param("customerId") String customerId, @Param("id") Long id, @Param("packages") long packages, @Param("outboundNo") String outboundNo); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/service/LastlegOrderCommandService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/service/LastlegOrderCommandService.java new file mode 100644 index 000000000..4b71d885c --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/service/LastlegOrderCommandService.java @@ -0,0 +1,240 @@ +package com.baiyue.lastleg.service; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baomidou.mybatisplus.core.toolkit.IdWorker; +import com.baiyue.lastleg.dto.LastlegOrderCommandDtos; +import com.baiyue.lastleg.entity.LastlegOrder; +import com.baiyue.lastleg.entity.LastlegOrderCommandRecord; +import com.baiyue.lastleg.entity.LastlegOrderHoldHistory; +import com.baiyue.lastleg.entity.LastlegOrderManualNode; +import com.baiyue.lastleg.entity.LastlegOrderManualNodeHistory; +import com.baiyue.lastleg.entity.LastlegOrderOperationLog; +import com.baiyue.lastleg.mapper.LastlegOrderCommandRecordMapper; +import com.baiyue.lastleg.mapper.LastlegOrderHoldHistoryMapper; +import com.baiyue.lastleg.mapper.LastlegOrderManualNodeHistoryMapper; +import com.baiyue.lastleg.mapper.LastlegOrderManualNodeMapper; +import com.baiyue.lastleg.mapper.LastlegOrderMapper; +import com.baiyue.lastleg.mapper.LastlegOrderOperationLogMapper; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import jakarta.annotation.Resource; +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.server.ResponseStatusException; + +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.time.LocalDateTime; +import java.time.ZoneOffset; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.HashSet; +import java.util.HexFormat; +import java.util.List; +import java.util.Map; +import java.util.Set; + +@Service +public class LastlegOrderCommandService { + private static final String HOLD = "HOLD"; + private static final String MANUAL_NODES = "MANUAL_NODES"; + private static final List NODE_CODES = List.of( + "DOMESTIC_CUSTOMS_COMPLETED", "ESTIMATED_DEPARTURE", "ACTUAL_DEPARTURE", "ESTIMATED_ARRIVAL", + "OVERSEAS_CUSTOMS_COMPLETED", "ACTUAL_ARRIVAL_WAITING_UNLOAD", "PICKUP_APPOINTED", "UNLOADED"); + + @Resource private LastlegOrderMapper orders; + @Resource private LastlegOrderCommandRecordMapper commands; + @Resource private LastlegOrderHoldHistoryMapper holdHistory; + @Resource private LastlegOrderManualNodeMapper manualNodes; + @Resource private LastlegOrderManualNodeHistoryMapper manualNodeHistory; + @Resource private LastlegOrderOperationLogMapper operationLogs; + @Resource private InboundOrderDetainedOutboxService detainedOutbox; + @Resource private ObjectMapper objectMapper; + + @Transactional + public LastlegOrderCommandDtos.HoldResponse hold(String requestedCustomerId, long orderId, + String idempotencyKey, + LastlegOrderCommandDtos.HoldRequest request, + String operatorId, String operatorName) { + String key = key(idempotencyKey); + LastlegOrder order = lockOwned(requestedCustomerId, orderId); + String hash = hash(request); + LastlegOrderCommandRecord replay = commands.find(order.getCustomerId(), orderId, HOLD, operatorId, key); + if (replay != null) return replay(replay, hash, LastlegOrderCommandDtos.HoldResponse.class); + int version = version(order); + if (request.expectedVersion() != version) throw conflict("尾程订单版本已变化,请刷新后重试"); + boolean held = request.held(); + if (Boolean.TRUE.equals(order.getDetained()) == held) { + throw conflict(held ? "尾程订单已暂扣" : "尾程订单未处于暂扣状态"); + } + if (held && (positive(order.getPickedPackageCount()) || positive(order.getShippedPackageCount()) + || text(order.getOutboundOrderNo()) != null)) { + throw conflict("尾程订单已进入出库流程,不能新增暂扣"); + } + LocalDateTime now = LocalDateTime.now(); + boolean chargeable = held && request.chargeable(); + String reason = request.reason().trim(); + if (orders.updateHold(order.getCustomerId(), orderId, held, chargeable, reason, version, now) != 1) { + throw conflict("尾程订单版本已变化,请刷新后重试"); + } + int nextVersion = version + 1; + LastlegOrderCommandDtos.HoldResponse response = new LastlegOrderCommandDtos.HoldResponse( + orderId, held, chargeable, held ? reason : null, nextVersion); + LastlegOrderHoldHistory history = new LastlegOrderHoldHistory(); + history.setId(IdWorker.getId()); history.setCustomerId(order.getCustomerId()); history.setOrderId(orderId); + history.setHeld(held); history.setChargeable(chargeable); history.setReason(reason); + history.setOrderVersion(nextVersion); history.setOperatorId(operatorId); history.setOperatorName(operatorName); + history.setIdempotencyKey(key); history.setCreatedAt(now); holdHistory.insert(history); + operation(order, held ? "暂扣" : "解除暂扣", operatorName, + (chargeable ? "计费;" : "不计费;") + reason, now); + saveCommand(order, HOLD, operatorId, key, hash, null, response, nextVersion, now); + order.setDetained(held); + order.setHoldChargeable(chargeable); + order.setHoldReason(held ? reason : null); + order.setVersion(nextVersion); + if (text(order.getSourceInboundId()) != null) { + detainedOutbox.publish(order, held, reason, operatorId, operatorName, + now.toInstant(ZoneOffset.UTC), history.getId()); + } + return response; + } + + @Transactional + public LastlegOrderCommandDtos.ManualNodesResponse saveManualNodes(String requestedCustomerId, long orderId, + String idempotencyKey, + LastlegOrderCommandDtos.ManualNodesRequest request, + String operatorId, String operatorName) { + String key = key(idempotencyKey); + LastlegOrder order = lockOwned(requestedCustomerId, orderId); + List normalized = normalizeNodes(request.nodes()); + String hash = hash(new LastlegOrderCommandDtos.ManualNodesRequest(request.expectedVersion(), normalized)); + LastlegOrderCommandRecord replay = commands.find(order.getCustomerId(), orderId, MANUAL_NODES, operatorId, key); + if (replay != null) return replay(replay, hash, LastlegOrderCommandDtos.ManualNodesResponse.class); + int version = version(order); + if (request.expectedVersion() != version) throw conflict("尾程订单版本已变化,请刷新后重试"); + Map values = new HashMap<>(); + normalized.forEach(node -> values.put(node.nodeCode(), node.nodeAt())); + LocalDateTime now = LocalDateTime.now(); + if (orders.updateManualNodes(order.getCustomerId(), orderId, + values.get("ESTIMATED_ARRIVAL"), values.get("ACTUAL_ARRIVAL_WAITING_UNLOAD"), + null, version, now) != 1) { + throw conflict("尾程订单版本已变化,请刷新后重试"); + } + int nextVersion = version + 1; + manualNodes.deleteCurrent(order.getCustomerId(), orderId); + List views = new ArrayList<>(normalized.size()); + for (LastlegOrderCommandDtos.ManualNode node : normalized) { + LastlegOrderManualNode entity = new LastlegOrderManualNode(); + entity.setId(IdWorker.getId()); entity.setCustomerId(order.getCustomerId()); entity.setOrderId(orderId); + entity.setNodeCode(node.nodeCode()); entity.setNodeAt(node.nodeAt()); entity.setSource("MANUAL"); + entity.setOrderVersion(nextVersion); entity.setUpdatedBy(operatorId); entity.setUpdatedAt(now); + manualNodes.insert(entity); + views.add(new LastlegOrderCommandDtos.ManualNodeView(node.nodeCode(), node.nodeAt(), "MANUAL")); + } + LastlegOrderCommandDtos.ManualNodesResponse response = new LastlegOrderCommandDtos.ManualNodesResponse( + orderId, true, List.copyOf(views), nextVersion); + LastlegOrderManualNodeHistory history = new LastlegOrderManualNodeHistory(); + history.setId(IdWorker.getId()); history.setCustomerId(order.getCustomerId()); history.setOrderId(orderId); + history.setNodesJson(json(views)); history.setOrderVersion(nextVersion); history.setOperatorId(operatorId); + history.setOperatorName(operatorName); history.setIdempotencyKey(key); history.setCreatedAt(now); + manualNodeHistory.insert(history); + operation(order, "人工计划节点", operatorName, "保存" + views.size() + "个人工节点", now); + saveCommand(order, MANUAL_NODES, operatorId, key, hash, null, response, nextVersion, now); + return response; + } + + @Transactional(readOnly = true) + public LastlegOrderCommandDtos.ManualNodesResponse manualNodes(String requestedCustomerId, long orderId) { + LastlegOrder order = findOwned(requestedCustomerId, orderId); + List nodes = manualNodes.selectList( + new LambdaQueryWrapper() + .eq(LastlegOrderManualNode::getCustomerId, order.getCustomerId()) + .eq(LastlegOrderManualNode::getOrderId, orderId) + .orderByAsc(LastlegOrderManualNode::getNodeCode)) + .stream().map(node -> new LastlegOrderCommandDtos.ManualNodeView( + node.getNodeCode(), node.getNodeAt(), node.getSource())) + .sorted(java.util.Comparator.comparingInt(node -> NODE_CODES.indexOf(node.nodeCode()))).toList(); + return new LastlegOrderCommandDtos.ManualNodesResponse(orderId, + Boolean.TRUE.equals(order.getManualNodeOverride()), nodes, version(order)); + } + + private List normalizeNodes(List nodes) { + Set seen = new HashSet<>(); + boolean anyTime = false; + List result = new ArrayList<>(); + for (LastlegOrderCommandDtos.ManualNode node : nodes) { + String code = node.nodeCode().trim().toUpperCase(); + if (!NODE_CODES.contains(code)) throw badRequest("不支持的人工展示节点:" + code); + if (!seen.add(code)) throw badRequest("人工节点不能重复:" + code); + result.add(new LastlegOrderCommandDtos.ManualNode(code, node.nodeAt())); + anyTime |= node.nodeAt() != null; + } + if (!seen.equals(Set.copyOf(NODE_CODES))) throw badRequest("必须提交完整8行人工时间快照"); + if (!anyTime) throw badRequest("人工时间快照至少填写一个节点时间"); + result.sort(java.util.Comparator.comparingInt(node -> NODE_CODES.indexOf(node.nodeCode()))); + return List.copyOf(result); + } + + private LastlegOrder lockOwned(String requestedCustomerId, long orderId) { + LastlegOrder found = findOwned(requestedCustomerId, orderId); + LastlegOrder locked = orders.lockById(found.getCustomerId(), orderId); + if (locked == null) throw notFound(); + return locked; + } + + private LastlegOrder findOwned(String requestedCustomerId, long orderId) { + LastlegOrder order = orders.selectById(orderId); + if (order == null || (text(requestedCustomerId) != null + && !requestedCustomerId.trim().equals(order.getCustomerId()))) throw notFound(); + return order; + } + + private void operation(LastlegOrder order, String node, String operatorName, String result, LocalDateTime now) { + LastlegOrderOperationLog log = new LastlegOrderOperationLog(); + log.setId(IdWorker.getId()); log.setCustomerId(order.getCustomerId()); log.setOrderId(order.getId()); + log.setOperationNode(node); log.setOperatorName(operatorName); log.setResult(result); log.setCreatedAt(now); + operationLogs.insert(log); + } + + private void saveCommand(LastlegOrder order, String action, String operatorId, String key, String hash, + String resultRef, Object response, int resultVersion, LocalDateTime now) { + LastlegOrderCommandRecord record = new LastlegOrderCommandRecord(); + record.setId(IdWorker.getId()); record.setCustomerId(order.getCustomerId()); record.setOrderId(order.getId()); + record.setAction(action); record.setOperatorId(operatorId); record.setIdempotencyKey(key); + record.setRequestHash(hash); record.setResultRef(resultRef); record.setResultJson(json(response)); + record.setResultVersion(resultVersion); record.setCreatedAt(now); commands.insert(record); + } + + private T replay(LastlegOrderCommandRecord record, String hash, Class type) { + if (!MessageDigest.isEqual(record.getRequestHash().getBytes(StandardCharsets.UTF_8), + hash.getBytes(StandardCharsets.UTF_8))) throw conflict("同一幂等键不能用于不同请求"); + try { return objectMapper.readValue(record.getResultJson(), type); } + catch (JsonProcessingException exception) { throw new IllegalStateException("无法读取已保存命令结果", exception); } + } + + private String hash(Object request) { + try { + return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256") + .digest(objectMapper.writeValueAsBytes(request))); + } catch (NoSuchAlgorithmException | JsonProcessingException exception) { + throw new IllegalStateException("无法生成命令摘要", exception); + } + } + private String json(Object value) { + try { return objectMapper.writeValueAsString(value); } + catch (JsonProcessingException exception) { throw new IllegalStateException("无法保存命令结果", exception); } + } + private String key(String value) { + String key = text(value); + if (key == null || key.length() > 128) throw badRequest("Idempotency-Key 必须为1至128个字符"); + return key; + } + private int version(LastlegOrder order) { return order.getVersion() == null ? 0 : order.getVersion(); } + private boolean positive(Integer value) { return value != null && value > 0; } + private String text(String value) { return value == null || value.isBlank() ? null : value.trim(); } + private ResponseStatusException notFound() { return new ResponseStatusException(HttpStatus.NOT_FOUND, "尾程订单不存在"); } + private ResponseStatusException badRequest(String message) { return new ResponseStatusException(HttpStatus.BAD_REQUEST, message); } + private ResponseStatusException conflict(String message) { return new ResponseStatusException(HttpStatus.CONFLICT, message); } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/service/LastlegOrderService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/service/LastlegOrderService.java index b6ff873e0..8490574b0 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/service/LastlegOrderService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/service/LastlegOrderService.java @@ -6,6 +6,7 @@ import com.baomidou.mybatisplus.extension.plugins.pagination.Page; import com.baiyue.common.core.api.PageResult; import com.baiyue.common.event.ContainerNumbers; import com.baiyue.lastleg.dto.LastlegOrderDetailResponse; +import com.baiyue.lastleg.dto.LastlegOrderCommandDtos; import com.baiyue.lastleg.dto.LastlegOrderRequests; import com.baiyue.lastleg.dto.LastlegOrderSummary; import com.baiyue.lastleg.dto.ContainerPlanProgressDetail; @@ -15,6 +16,7 @@ import com.baiyue.lastleg.entity.LastlegOrderDetail; import com.baiyue.lastleg.entity.LastlegOrderStatusHistory; import com.baiyue.lastleg.entity.LastlegOrderFee; import com.baiyue.lastleg.entity.LastlegOrderOperationLog; +import com.baiyue.lastleg.entity.LastlegOrderManualNode; import com.baiyue.lastleg.entity.ReceivingWarehouseOperation; import com.baiyue.lastleg.inboundplan.pda.gateway.WarehouseResourceGateway; import com.baiyue.lastleg.inboundplan.entity.InboundPlanCarton; @@ -25,6 +27,7 @@ import com.baiyue.lastleg.mapper.LastlegOrderMapper; import com.baiyue.lastleg.mapper.LastlegOrderStatusHistoryMapper; import com.baiyue.lastleg.mapper.LastlegOrderFeeMapper; import com.baiyue.lastleg.mapper.LastlegOrderOperationLogMapper; +import com.baiyue.lastleg.mapper.LastlegOrderManualNodeMapper; import com.baiyue.lastleg.mapper.ReceivingWarehouseOperationMapper; import com.baiyue.lastleg.model.LastlegOrderType; import jakarta.annotation.Resource; @@ -54,12 +57,16 @@ public class LastlegOrderService { public static final String INBOUND_PLANNED = "INBOUND_PLANNED"; public static final String PALLET_MERGE_REQUIRED = "REQUIRED"; public static final String PALLET_MERGED = "MERGED"; + private static final List MANUAL_NODE_CODES = List.of( + "DOMESTIC_CUSTOMS_COMPLETED", "ESTIMATED_DEPARTURE", "ACTUAL_DEPARTURE", "ESTIMATED_ARRIVAL", + "OVERSEAS_CUSTOMS_COMPLETED", "ACTUAL_ARRIVAL_WAITING_UNLOAD", "PICKUP_APPOINTED", "UNLOADED"); @Resource private LastlegOrderMapper orderMapper; @Resource private LastlegOrderDetailMapper detailMapper; @Resource private LastlegOrderStatusHistoryMapper historyMapper; @Resource private LastlegOrderFeeMapper feeMapper; @Resource private LastlegOrderOperationLogMapper operationLogMapper; + @Resource private LastlegOrderManualNodeMapper manualNodeMapper; @Resource private InboundPlanCartonMapper cartonMapper; @Resource private FbaWarehouseValidator fbaWarehouseValidator; @Resource private InventoryCommandService inventory; @@ -243,7 +250,15 @@ public class LastlegOrderService { .eq(LastlegOrderOperationLog::getCustomerId, order.getCustomerId()) .eq(LastlegOrderOperationLog::getOrderId, orderId) .orderByAsc(LastlegOrderOperationLog::getCreatedAt)); - return new LastlegOrderDetailResponse(summary(order, palletMergeRelations(List.of(order)).get(order.getId())), cargo, history.stream() + List manualNodes = manualNodeMapper.selectList( + new LambdaQueryWrapper() + .eq(LastlegOrderManualNode::getCustomerId, order.getCustomerId()) + .eq(LastlegOrderManualNode::getOrderId, orderId) + .orderByAsc(LastlegOrderManualNode::getId)).stream() + .map(value -> new LastlegOrderCommandDtos.ManualNodeView( + value.getNodeCode(), value.getNodeAt(), value.getSource())) + .sorted(java.util.Comparator.comparingInt(value -> MANUAL_NODE_CODES.indexOf(value.nodeCode()))).toList(); + return new LastlegOrderDetailResponse(summary(order, palletMergeRelations(List.of(order)).get(order.getId())), manualNodes, cargo, history.stream() .map(value -> new LastlegOrderDetailResponse.History(value.getId(), value.getFromStatus(), value.getToStatus(), value.getRemark(), value.getCreatedAt())).toList(), fees, operations.stream() .map(value -> new LastlegOrderDetailResponse.Operation(value.getId(), value.getOperationNode(), @@ -552,7 +567,9 @@ public class LastlegOrderService { displayInboundCode(o), o.getLxCode(), o.getSourceOrderNo(), o.getCustomerId(), o.getCustomerName(), o.getOverseasWarehouseId(), o.getOverseasWarehouseCode(), o.getOverseasWarehouseName(), o.getTransferWarehouseId(), o.getTransferWarehouseCode(), o.getTransferWarehouseName(), o.getFbaWarehouseCode(), o.getContainerNo(), o.getContainerStatus(), - o.getAbnormal(), o.getDetained(), o.getPalletCount(), o.getPackageCount(), o.getVolumeCbm(), + o.getAbnormal(), o.getDetained(), o.getHoldChargeable(), o.getHoldReason(), + Boolean.TRUE.equals(o.getManualNodeOverride()), Boolean.TRUE.equals(o.getManualNodeOverride()) ? "MANUAL" : "CONTAINER", + o.getVersion() == null ? 0 : o.getVersion(), o.getPalletCount(), o.getPackageCount(), o.getVolumeCbm(), o.getProductQuantity(), o.getVolumeWeightKg(), o.getEstimatedArrivalAt(), o.getActualArrivalAt(), o.getEstimatedWarehouseAt(), o.getInboundOrderNo(), o.getRelabelOrderNo(), o.getRelabelStatus(), o.getPalletMergeFlag(), related.stream().map(LastlegOrderMapper.PalletMergeRelationRow::mergeGroupNo).distinct().findFirst().orElse(null), diff --git a/lastleg-service/src/main/resources/db/lastleg.sql b/lastleg-service/src/main/resources/db/lastleg.sql index 8749cd660..63b2c2a6f 100644 --- a/lastleg-service/src/main/resources/db/lastleg.sql +++ b/lastleg-service/src/main/resources/db/lastleg.sql @@ -70,6 +70,10 @@ CREATE TABLE IF NOT EXISTS ll_order ( container_status VARCHAR(32) DEFAULT '待转柜' COMMENT '货柜状态', abnormal TINYINT NOT NULL DEFAULT 0 COMMENT '是否异常:0否1是', detained TINYINT NOT NULL DEFAULT 0 COMMENT '是否暂扣:0否1是', + hold_chargeable TINYINT NOT NULL DEFAULT 0 COMMENT '当前暂扣是否计费:0否1是', + hold_reason VARCHAR(500) NULL COMMENT '当前暂扣原因;解除后为空', + manual_node_override TINYINT NOT NULL DEFAULT 0 COMMENT '是否启用单票人工计划节点覆盖:0否1是,启用后不再继承柜级节点', + version INT NOT NULL DEFAULT 0 COMMENT '尾程订单命令乐观锁版本', pallet_count INT NOT NULL DEFAULT 0 COMMENT '托数', package_count INT NOT NULL DEFAULT 0 COMMENT '箱数', picked_package_count INT NOT NULL DEFAULT 0 COMMENT '累计已拣箱数', @@ -110,6 +114,14 @@ CREATE TABLE IF NOT EXISTS ll_order ( SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_order' AND column_name='pallet_merge_batch_id'), 'SELECT 1', 'ALTER TABLE ll_order ADD COLUMN pallet_merge_batch_id VARCHAR(64) NULL COMMENT ''平台预标记合托分组号'' AFTER pallet_merge_flag')); PREPARE ll_order_merge_batch_ddl FROM @ddl; EXECUTE ll_order_merge_batch_ddl; DEALLOCATE PREPARE ll_order_merge_batch_ddl; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_order' AND column_name='hold_chargeable'), 'SELECT 1', 'ALTER TABLE ll_order ADD COLUMN hold_chargeable TINYINT NOT NULL DEFAULT 0 COMMENT ''当前暂扣是否计费:0否1是'' AFTER detained')); +PREPARE ll_order_command_ddl FROM @ddl; EXECUTE ll_order_command_ddl; DEALLOCATE PREPARE ll_order_command_ddl; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_order' AND column_name='hold_reason'), 'SELECT 1', 'ALTER TABLE ll_order ADD COLUMN hold_reason VARCHAR(500) NULL COMMENT ''当前暂扣原因;解除后为空'' AFTER hold_chargeable')); +PREPARE ll_order_command_ddl FROM @ddl; EXECUTE ll_order_command_ddl; DEALLOCATE PREPARE ll_order_command_ddl; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_order' AND column_name='manual_node_override'), 'SELECT 1', 'ALTER TABLE ll_order ADD COLUMN manual_node_override TINYINT NOT NULL DEFAULT 0 COMMENT ''是否启用单票人工计划节点覆盖:0否1是'' AFTER hold_reason')); +PREPARE ll_order_command_ddl FROM @ddl; EXECUTE ll_order_command_ddl; DEALLOCATE PREPARE ll_order_command_ddl; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_order' AND column_name='version'), 'SELECT 1', 'ALTER TABLE ll_order ADD COLUMN version INT NOT NULL DEFAULT 0 COMMENT ''尾程订单命令乐观锁版本'' AFTER manual_node_override')); +PREPARE ll_order_command_ddl FROM @ddl; EXECUTE ll_order_command_ddl; DEALLOCATE PREPARE ll_order_command_ddl; CREATE TABLE IF NOT EXISTS ll_order_detail ( id BIGINT NOT NULL AUTO_INCREMENT COMMENT '订单明细主键', @@ -179,6 +191,47 @@ CREATE TABLE IF NOT EXISTS ll_order_operation_log ( KEY idx_ll_order_operation_customer_order(customer_id,order_id,created_at) ) ENGINE=InnoDB COMMENT='尾程订单操作记录'; +CREATE TABLE IF NOT EXISTS ll_order_command_record ( + id BIGINT NOT NULL COMMENT '尾程订单命令幂等记录ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户数据隔离键', + order_id BIGINT NOT NULL COMMENT '尾程订单ID', action VARCHAR(32) NOT NULL COMMENT '命令动作:MANUAL_PLAN、HOLD、MANUAL_NODES', + operator_id VARCHAR(128) NOT NULL COMMENT '平台操作人ID', idempotency_key VARCHAR(128) NOT NULL COMMENT '客户端幂等键', + request_hash VARCHAR(64) NOT NULL COMMENT '规范化请求SHA-256摘要', result_ref VARCHAR(128) NULL COMMENT '结果业务引用', + result_json LONGTEXT NOT NULL COMMENT '首次成功响应JSON快照', result_version INT NOT NULL COMMENT '命令成功后的订单版本', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '首次成功时间', PRIMARY KEY(id), + UNIQUE KEY uk_ll_order_command_key(customer_id,order_id,action,operator_id,idempotency_key), + KEY idx_ll_order_command_history(customer_id,order_id,created_at) +) ENGINE=InnoDB COMMENT='尾程订单写命令幂等与结果审计记录'; + +CREATE TABLE IF NOT EXISTS ll_order_hold_history ( + id BIGINT NOT NULL COMMENT '尾程订单暂扣历史ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户数据隔离键', + order_id BIGINT NOT NULL COMMENT '尾程订单ID', held TINYINT NOT NULL COMMENT '操作后是否暂扣:0否1是', + chargeable TINYINT NOT NULL COMMENT '本次暂扣是否计费:0否1是', reason VARCHAR(500) NOT NULL COMMENT '暂扣或解除原因', + order_version INT NOT NULL COMMENT '操作成功后的订单版本', operator_id VARCHAR(128) NOT NULL COMMENT '操作人ID', + operator_name VARCHAR(128) NOT NULL COMMENT '操作人名称', idempotency_key VARCHAR(128) NOT NULL COMMENT '客户端幂等键', + created_at DATETIME(3) NOT NULL COMMENT '操作时间', PRIMARY KEY(id), + UNIQUE KEY uk_ll_order_hold_key(customer_id,order_id,operator_id,idempotency_key), + KEY idx_ll_order_hold_history(customer_id,order_id,created_at) +) ENGINE=InnoDB COMMENT='尾程订单暂扣与解除不可变历史'; + +CREATE TABLE IF NOT EXISTS ll_order_manual_node ( + id BIGINT NOT NULL COMMENT '尾程订单人工节点ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户数据隔离键', + order_id BIGINT NOT NULL COMMENT '尾程订单ID', node_code VARCHAR(32) NOT NULL COMMENT '八行人工展示节点稳定代码,与Carrier标准节点隔离', + node_at DATETIME(3) NULL COMMENT '人工填写节点时间,允许为空', source VARCHAR(16) NOT NULL DEFAULT 'MANUAL' COMMENT '节点来源,固定MANUAL', + order_version INT NOT NULL COMMENT '最近编辑后的订单版本', updated_by VARCHAR(128) NOT NULL COMMENT '最近操作人ID', + updated_at DATETIME(3) NOT NULL COMMENT '最近编辑时间', PRIMARY KEY(id), + UNIQUE KEY uk_ll_order_manual_node(customer_id,order_id,node_code), KEY idx_ll_order_manual_node_order(customer_id,order_id,node_at) +) ENGINE=InnoDB COMMENT='尾程订单当前人工节点覆盖事实'; + +CREATE TABLE IF NOT EXISTS ll_order_manual_node_history ( + id BIGINT NOT NULL COMMENT '尾程订单人工节点历史ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户数据隔离键', + order_id BIGINT NOT NULL COMMENT '尾程订单ID', nodes_json LONGTEXT NOT NULL COMMENT '本次全量人工节点JSON快照', + order_version INT NOT NULL COMMENT '操作成功后的订单版本', operator_id VARCHAR(128) NOT NULL COMMENT '操作人ID', + operator_name VARCHAR(128) NOT NULL COMMENT '操作人名称', idempotency_key VARCHAR(128) NOT NULL COMMENT '客户端幂等键', + created_at DATETIME(3) NOT NULL COMMENT '操作时间', PRIMARY KEY(id), + UNIQUE KEY uk_ll_order_manual_node_history_key(customer_id,order_id,operator_id,idempotency_key), + KEY idx_ll_order_manual_node_history(customer_id,order_id,created_at) +) ENGINE=InnoDB COMMENT='尾程订单人工节点覆盖不可变历史'; + CREATE TABLE IF NOT EXISTS ll_receiving_warehouse_operation ( id BIGINT NOT NULL COMMENT '柜级接柜仓修改操作ID', source_container_booking_id VARCHAR(128) NOT NULL COMMENT '稳定来源柜预订ID', @@ -504,6 +557,13 @@ CREATE TABLE IF NOT EXISTS ll_inbound_plan ( source_order_id BIGINT NOT NULL COMMENT '来源尾程订单ID,一张入仓单对应一张入库计划', container_no VARCHAR(64) NOT NULL COMMENT '集装箱号', overseas_warehouse_id BIGINT NOT NULL DEFAULT 193000000000200003 COMMENT '平台预分配的海外实际收货共享物理仓ID', + planned_arrival_date DATE NULL COMMENT '人工计划预计到仓日期', + pallet_type VARCHAR(32) NULL COMMENT '人工计划托盘类型', + assigned_flag TINYINT NOT NULL DEFAULT 0 COMMENT '人工计划是否已分配:0否1是', + merge_required TINYINT NOT NULL DEFAULT 0 COMMENT '人工计划是否要求合托:0否1是', + urgent_flag TINYINT NOT NULL DEFAULT 0 COMMENT '人工计划是否加急:0否1是', + creation_mode VARCHAR(16) NOT NULL DEFAULT 'AUTO' COMMENT '计划生成方式:AUTO柜级自动、MANUAL单票人工', + plan_remark VARCHAR(1000) NULL COMMENT '入仓计划备注', status VARCHAR(32) NOT NULL DEFAULT 'PLANNED' COMMENT '入库计划状态', line_count INT NOT NULL DEFAULT 0 COMMENT '入仓码明细数量', pallet_count INT NOT NULL DEFAULT 0 COMMENT '系统自动分配托盘数量', @@ -557,6 +617,7 @@ CREATE TABLE IF NOT EXISTS ll_inbound_plan_pallet ( plan_id BIGINT NOT NULL COMMENT '入库计划ID', pallet_no VARCHAR(64) NOT NULL COMMENT '当前托盘编号:分配前为计划占位码,分配后为海外仓实物托盘码', source_pallet_no VARCHAR(128) NULL COMMENT '一件代发不可变来源托盘号快照', + pallet_type VARCHAR(32) NULL COMMENT '人工计划托盘类型', destination_warehouse_id VARCHAR(128) NULL COMMENT '目的仓ID快照', destination_warehouse_code VARCHAR(128) NULL COMMENT '目的仓编码快照', destination_warehouse_name VARCHAR(255) NULL COMMENT '目的仓名称快照', @@ -588,6 +649,20 @@ CREATE TABLE IF NOT EXISTS ll_inbound_plan_pallet_allocation ( ALTER TABLE ll_inbound_plan COMMENT='尾程入库计划表:每张来源入仓单生成一张入库计划,同柜计划共享集装箱号'; SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan' AND column_name='source_order_id'), 'SELECT 1', 'ALTER TABLE ll_inbound_plan ADD COLUMN source_order_id BIGINT NOT NULL DEFAULT 0 COMMENT ''来源尾程订单ID,一张入仓单对应一张入库计划'' AFTER customer_id')); PREPARE inbound_plan_source_order_stmt FROM @ddl; EXECUTE inbound_plan_source_order_stmt; DEALLOCATE PREPARE inbound_plan_source_order_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan' AND column_name='planned_arrival_date'), 'SELECT 1', 'ALTER TABLE ll_inbound_plan ADD COLUMN planned_arrival_date DATE NULL COMMENT ''人工计划预计到仓日期'' AFTER overseas_warehouse_id')); +PREPARE inbound_plan_manual_stmt FROM @ddl; EXECUTE inbound_plan_manual_stmt; DEALLOCATE PREPARE inbound_plan_manual_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan' AND column_name='pallet_type'), 'SELECT 1', 'ALTER TABLE ll_inbound_plan ADD COLUMN pallet_type VARCHAR(32) NULL COMMENT ''人工计划托盘类型'' AFTER planned_arrival_date')); +PREPARE inbound_plan_manual_stmt FROM @ddl; EXECUTE inbound_plan_manual_stmt; DEALLOCATE PREPARE inbound_plan_manual_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan' AND column_name='assigned_flag'), 'SELECT 1', 'ALTER TABLE ll_inbound_plan ADD COLUMN assigned_flag TINYINT NOT NULL DEFAULT 0 COMMENT ''人工计划是否已分配:0否1是'' AFTER pallet_type')); +PREPARE inbound_plan_manual_stmt FROM @ddl; EXECUTE inbound_plan_manual_stmt; DEALLOCATE PREPARE inbound_plan_manual_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan' AND column_name='merge_required'), 'SELECT 1', 'ALTER TABLE ll_inbound_plan ADD COLUMN merge_required TINYINT NOT NULL DEFAULT 0 COMMENT ''人工计划是否要求合托:0否1是'' AFTER assigned_flag')); +PREPARE inbound_plan_manual_stmt FROM @ddl; EXECUTE inbound_plan_manual_stmt; DEALLOCATE PREPARE inbound_plan_manual_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan' AND column_name='urgent_flag'), 'SELECT 1', 'ALTER TABLE ll_inbound_plan ADD COLUMN urgent_flag TINYINT NOT NULL DEFAULT 0 COMMENT ''人工计划是否加急:0否1是'' AFTER merge_required')); +PREPARE inbound_plan_manual_stmt FROM @ddl; EXECUTE inbound_plan_manual_stmt; DEALLOCATE PREPARE inbound_plan_manual_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan' AND column_name='creation_mode'), 'SELECT 1', 'ALTER TABLE ll_inbound_plan ADD COLUMN creation_mode VARCHAR(16) NOT NULL DEFAULT ''AUTO'' COMMENT ''计划生成方式:AUTO柜级自动、MANUAL单票人工'' AFTER urgent_flag')); +PREPARE inbound_plan_manual_stmt FROM @ddl; EXECUTE inbound_plan_manual_stmt; DEALLOCATE PREPARE inbound_plan_manual_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan' AND column_name='plan_remark'), 'SELECT 1', 'ALTER TABLE ll_inbound_plan ADD COLUMN plan_remark VARCHAR(1000) NULL COMMENT ''入仓计划备注'' AFTER creation_mode')); +PREPARE inbound_plan_manual_stmt FROM @ddl; EXECUTE inbound_plan_manual_stmt; DEALLOCATE PREPARE inbound_plan_manual_stmt; ALTER TABLE ll_inbound_plan MODIFY COLUMN id BIGINT NOT NULL COMMENT '入库计划ID(雪花ID)', MODIFY COLUMN plan_no VARCHAR(32) NOT NULL COMMENT '入库单号', @@ -640,6 +715,8 @@ SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE tab 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 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='pallet_type'), 'SELECT 1', 'ALTER TABLE ll_inbound_plan_pallet ADD COLUMN pallet_type VARCHAR(32) NULL COMMENT ''人工计划托盘类型'' AFTER pallet_no')); +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 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 COMMENT ''海外仓资源锁ID''')); diff --git a/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml b/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml index 83f5a212a..9c24059fc 100644 --- a/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml +++ b/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml @@ -192,25 +192,46 @@ paths: responses: '200': { description: 新增后的费用明细, content: { application/json: { schema: { $ref: '#/components/schemas/LastMileFeeApiResponse' } } } } '404': { description: 尾程订单不存在或不属于操作客户 } - /api/platform/v1/last-leg/orders/{id}/detained: + /api/platform/v1/last-leg/orders/{id}/hold: put: summary: 设置或解除尾程订单暂扣 - description: 权限 platform:overseas-warehouse:last-mile-order:edit;库存同步转入或释放暂扣,并通过跨区事件回写国内入仓单。 + description: 权限 platform:overseas-warehouse:last-mile-order:edit;使用订单版本和 Idempotency-Key 原子写入计费标记、原因及不可变历史。已进入出库流程的订单不得新增暂扣;暂扣只作用于当前票,不影响同柜其他订单。 security: [{ bearerAuth: [] }] parameters: - { name: id, in: path, required: true, schema: { type: integer, format: int64 } } - { name: customerId, in: query, required: false, schema: { type: string, pattern: '^[1-9][0-9]*$' } } + - { name: Idempotency-Key, in: header, required: true, schema: { type: string, minLength: 1, maxLength: 128 } } requestBody: required: true content: application/json: - schema: - type: object - required: [detained, remark] - properties: - detained: { type: boolean } - remark: { type: string, minLength: 1, maxLength: 500 } - responses: { '200': { description: 暂扣状态已更新 } } + schema: { $ref: '#/components/schemas/LastlegOrderHoldRequest' } + responses: + '200': { description: 暂扣状态已更新, content: { application/json: { schema: { type: object, properties: { code: { type: string }, data: { $ref: '#/components/schemas/LastlegOrderHoldResponse' } } } } } } + '409': { description: 版本冲突、幂等键冲突、重复状态或订单已进入出库流程 } + /api/platform/v1/last-leg/orders/{id}/manual-nodes: + get: + summary: 查询尾程订单人工展示节点 + description: 权限 platform:overseas-warehouse:last-mile-order:list;人工覆盖开启后返回完整且固定顺序的8行快照,行值允许为空。 + security: [{ bearerAuth: [] }] + parameters: + - { name: id, in: path, required: true, schema: { type: integer, format: int64 } } + - { name: customerId, in: query, required: false, schema: { type: string, pattern: '^[1-9][0-9]*$' } } + responses: { '200': { description: 完整人工展示节点快照 } } + put: + summary: 永久启用并保存尾程订单人工展示节点 + description: 权限 platform:overseas-warehouse:last-mile-order:edit;提交完整8行快照且至少一个时间非空,使用订单版本和 Idempotency-Key;保存后 nodeSource 永久为 MANUAL,不回写柜节点,也不提供清除或恢复继承接口。 + security: [{ bearerAuth: [] }] + parameters: + - { name: id, in: path, required: true, schema: { type: integer, format: int64 } } + - { name: customerId, in: query, required: false, schema: { type: string, pattern: '^[1-9][0-9]*$' } } + - { name: Idempotency-Key, in: header, required: true, schema: { type: string, minLength: 1, maxLength: 128 } } + requestBody: + required: true + content: { application/json: { schema: { $ref: '#/components/schemas/LastlegOrderManualNodesRequest' } } } + responses: + '200': { description: 更新后的完整8行人工展示节点快照 } + '409': { description: 订单版本或幂等键冲突 } /api/platform/v1/last-leg/orders/{id}/pallet-merge-required: put: summary: 设置尾程订单合托标记 @@ -381,22 +402,22 @@ paths: responses: '200': { description: 平台入库订单详情, content: { application/json: { schema: { $ref: '#/components/schemas/InboundPlanDetailResponse' } } } } '404': { description: 入库订单不存在或不属于指定客户 } - /api/platform/v1/last-leg/inbound-plans/convert: + /api/platform/v1/last-leg/inbound-plans/manual: post: - summary: 平台生成入库计划 - description: 权限 platform:overseas-warehouse:inbound-order:create;必须同时传入真实 customerId 与 orderId,服务端按该客户和订单 ID 锁定归属,缺失 customerId 返回 A0400。 + summary: 平台按单票手工生成入库计划 + description: 权限 platform:overseas-warehouse:inbound-order:create;按 customerId 与 orderId 锁定单票,校验订单版本、状态、暂扣状态、真实入仓码、客户归属及有效公共接柜仓。一次只生成当前票的独立计划,不改变同柜其他订单;命令原子且幂等。 security: [{ bearerAuth: [] }] + parameters: + - { name: Idempotency-Key, in: header, required: true, schema: { type: string, minLength: 1, maxLength: 128 } } requestBody: required: true content: application/json: - schema: - type: object - required: [customerId, orderId] - properties: - customerId: { type: string, pattern: '^[1-9][0-9]*$' } - orderId: { type: string, pattern: '^[1-9][0-9]*$' } - responses: { '200': { description: 统一 ApiResponse 包装的入库计划详情 } } + schema: { $ref: '#/components/schemas/ManualInboundPlanRequest' } + responses: + '200': { description: 统一 ApiResponse 包装的入库计划详情 } + '400': { description: 客户、入仓码、仓库主数据或请求字段无效 } + '409': { description: 订单版本、状态、暂扣状态、重复计划或幂等键冲突 } /api/platform/v1/last-leg/inbound-plans/convert-container: post: summary: 按整柜批量生成入库计划 @@ -1796,6 +1817,61 @@ components: - { $ref: '#/components/schemas/MerchantApiResponse' } - type: object properties: { data: { $ref: '#/components/schemas/InboundPlanDetail' } } + LastlegOrderHoldRequest: + type: object + required: [held, chargeable, reason, expectedVersion] + properties: + held: { type: boolean } + chargeable: { type: boolean, description: 仅 held=true 时生效;解除暂扣后固定为 false } + reason: { type: string, minLength: 1, maxLength: 500 } + expectedVersion: { type: integer, minimum: 0 } + LastlegOrderHoldResponse: + type: object + required: [orderId, held, chargeable, version] + properties: + orderId: { type: integer, format: int64 } + held: { type: boolean } + chargeable: { type: boolean } + reason: { type: string, nullable: true } + version: { type: integer, minimum: 0 } + LastlegOrderManualNodeCode: + type: string + enum: [DOMESTIC_CUSTOMS_COMPLETED, ESTIMATED_DEPARTURE, ACTUAL_DEPARTURE, ESTIMATED_ARRIVAL, OVERSEAS_CUSTOMS_COMPLETED, ACTUAL_ARRIVAL_WAITING_UNLOAD, PICKUP_APPOINTED, UNLOADED] + LastlegOrderManualNode: + type: object + required: [nodeCode] + properties: + nodeCode: { $ref: '#/components/schemas/LastlegOrderManualNodeCode' } + nodeAt: { type: string, format: date-time, nullable: true } + LastlegOrderManualNodesRequest: + type: object + required: [expectedVersion, nodes] + properties: + expectedVersion: { type: integer, minimum: 0 } + nodes: + type: array + minItems: 8 + maxItems: 8 + description: 必须包含8个枚举节点且各出现一次,至少一个 nodeAt 非空。 + items: { $ref: '#/components/schemas/LastlegOrderManualNode' } + ManualInboundPlanRequest: + type: object + required: [customerId, orderId, expectedOrderVersion, plannedWarehouseId, plannedWarehouseCode, plannedWarehouseName, plannedArrivalDate, inboundCode, palletCount, palletType, assigned, mergeRequired, urgent] + properties: + customerId: { type: string, pattern: '^[1-9][0-9]*$' } + orderId: { type: integer, format: int64, minimum: 1 } + expectedOrderVersion: { type: integer, minimum: 0 } + plannedWarehouseId: { type: integer, format: int64, minimum: 1 } + plannedWarehouseCode: { type: string, minLength: 1, maxLength: 64 } + plannedWarehouseName: { type: string, minLength: 1, maxLength: 255 } + plannedArrivalDate: { type: string, format: date } + inboundCode: { type: string, minLength: 1, maxLength: 128 } + palletCount: { type: integer, minimum: 1, maximum: 999 } + palletType: { type: string, minLength: 1, maxLength: 64 } + assigned: { type: boolean } + mergeRequired: { type: boolean } + urgent: { type: boolean } + remark: { type: string, nullable: true, maxLength: 1000 } PageMetadata: type: object required: [page, size, total, pages] @@ -2022,6 +2098,13 @@ components: totalVolumeCbm: { type: number } expectedPackageCount: { type: integer } pickedPackageCount: { type: integer } + plannedArrivalDate: { type: string, format: date, nullable: true } + palletType: { type: string, nullable: true } + assigned: { type: boolean } + mergeRequired: { type: boolean } + urgent: { type: boolean } + creationMode: { type: string, enum: [AUTO, MANUAL] } + remark: { type: string, nullable: true } lines: { type: array, items: { $ref: '#/components/schemas/InboundPlanLine' } } pallets: { type: array, items: { $ref: '#/components/schemas/InboundPlanPallet' } } allocations: { type: array, items: { $ref: '#/components/schemas/InboundPlanAllocation' } } @@ -2044,6 +2127,7 @@ components: properties: id: { type: string, pattern: '^[0-9]+$' } palletNo: { type: string } + palletType: { type: string, nullable: true } destinationWarehouseId: { type: string, nullable: true } destinationWarehouseCode: { type: string, nullable: true } destinationWarehouseName: { type: string, nullable: true } diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/controller/LastlegOrderControllerTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/controller/LastlegOrderControllerTest.java index a039d62b0..f3ca58fc2 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/controller/LastlegOrderControllerTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/controller/LastlegOrderControllerTest.java @@ -9,6 +9,8 @@ import com.baiyue.lastleg.dto.LastlegOrderRequests; import com.baiyue.lastleg.dto.LastlegOrderSummary; import com.baiyue.lastleg.security.InternalServiceTokenGuard; import com.baiyue.lastleg.service.LastlegOrderService; +import com.baiyue.lastleg.service.LastlegOrderCommandService; +import com.baiyue.lastleg.dto.LastlegOrderCommandDtos; import com.baiyue.lastleg.service.LoadingCompletedConsumer; import com.baiyue.lastleg.shipping.service.ContainerShippingNodeConsumer; import org.junit.jupiter.api.Test; @@ -32,6 +34,7 @@ import static org.mockito.Mockito.when; class LastlegOrderControllerTest { // Controller contract tests intentionally instantiate the controller without a Spring context here. private final LastlegOrderService service = mock(LastlegOrderService.class); + private final LastlegOrderCommandService commandService = mock(LastlegOrderCommandService.class); private final LastlegOrderController controller = controller(); private final LastlegOrderRequests.Query query = new LastlegOrderRequests.Query( 1L, 20L, null, null, null, null, null, null, null, null, null, null, null, null, null, null); @@ -59,6 +62,23 @@ class LastlegOrderControllerTest { verify(service).page(eq("200"), eq(query)); } + @Test + void holdCommandPassesVersionedIdempotentRequestToSingleOrderService() { + var request = new LastlegOrderCommandDtos.HoldRequest(true, true, "海关查验", 2); + + controller.hold(user(0L, "PLATFORM"), "100", 11L, "hold-11", request); + + verify(commandService).hold("100", 11L, "hold-11", request, "1", "tester"); + } + + @Test + void manualNodeQueryUsesOrderScopeRatherThanContainerScope() { + controller.manualNodes(user(0L, "PLATFORM"), "100", 11L); + + verify(commandService).manualNodes("100", 11L); + verify(service, never()).containerProgressDetail(org.mockito.ArgumentMatchers.anyString()); + } + @Test void platformOperatorCanQueryCrossCustomerContainerSummaries() { var summaryQuery = new LastlegOrderRequests.ContainerProgressQuery(1, 20, "CONT-", null, null); @@ -130,6 +150,7 @@ class LastlegOrderControllerTest { ReflectionTestUtils.setField(controller, "consumer", mock(LoadingCompletedConsumer.class)); ReflectionTestUtils.setField(controller, "shippingNodeConsumer", mock(ContainerShippingNodeConsumer.class)); ReflectionTestUtils.setField(controller, "orderService", service); + ReflectionTestUtils.setField(controller, "commandService", commandService); ReflectionTestUtils.setField(controller, "tokenGuard", mock(InternalServiceTokenGuard.class)); return controller; } diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchServiceTest.java index 15669c6d1..aa328d90d 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchServiceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchServiceTest.java @@ -18,6 +18,8 @@ import com.baiyue.lastleg.dispatch.mapper.DispatchPalletLockMapper; import com.baiyue.lastleg.dispatch.model.DispatchPalletCandidate; import com.baiyue.lastleg.dispatch.pda.entity.DispatchPdaTask; import com.baiyue.lastleg.dispatch.pda.mapper.DispatchPdaTaskMapper; +import com.baiyue.lastleg.entity.LastlegOrder; +import com.baiyue.lastleg.mapper.LastlegOrderMapper; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; @@ -52,6 +54,7 @@ class DispatchServiceTest { @Mock DispatchDraftMapper drafts; @Mock DispatchDraftContainerMapper draftContainers; @Mock DispatchPdaTaskMapper pdaTasks; + @Mock LastlegOrderMapper tailOrders; private DispatchService service; @BeforeEach @@ -64,8 +67,26 @@ class DispatchServiceTest { ReflectionTestUtils.setField(service, "drafts", drafts); ReflectionTestUtils.setField(service, "draftContainers", draftContainers); ReflectionTestUtils.setField(service, "pdaTasks", pdaTasks); + ReflectionTestUtils.setField(service, "tailOrders", tailOrders); ReflectionTestUtils.setField(service, "orderNoGenerator", new com.baiyue.lastleg.service.LastlegOrderNoGenerator()); lenient().when(items.findByDispatchOrder(any(), any())).thenReturn(List.of()); + lenient().when(tailOrders.selectList(any())).thenReturn(List.of()); + } + + @Test + void preArrangeRejectsAHeldTailOrderBeforeCreatingDispatchDocuments() { + when(candidates.lockSelected("100", List.of(10L))).thenReturn(List.of(candidate(10L, null, "ONT8"))); + LastlegOrder held = new LastlegOrder(); + held.setId(2L); held.setCustomerId("100"); held.setOrderNo("LMO-HOLD-1"); held.setDetained(true); + when(tailOrders.selectList(any())).thenReturn(List.of(held)); + + ResponseStatusException exception = assertThrows(ResponseStatusException.class, + () -> service.preArrange("100", "7", new DispatchRequests.PreArrange(List.of(10L), null, + null, null, null))); + + assertEquals(409, exception.getStatusCode().value()); + assertEquals("暂扣订单不可创建排车或出库计划:LMO-HOLD-1", exception.getReason()); + verify(orders, never()).insert(any(DispatchOrder.class)); } @Test diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/InboundPlanSchemaContractTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/InboundPlanSchemaContractTest.java index cb4671a44..9624107b7 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/InboundPlanSchemaContractTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/InboundPlanSchemaContractTest.java @@ -27,6 +27,13 @@ class InboundPlanSchemaContractTest { assertTrue(schema.contains("create table if not exists ll_inbound_fba_appointment")); assertTrue(schema.contains("unique key uk_ll_fba_appointment_no (customer_id, appointment_no)")); assertTrue(schema.contains("expected_loading_at")); + assertTrue(schema.contains("planned_arrival_date")); + assertTrue(schema.contains("creation_mode")); + assertTrue(schema.contains("pallet_type")); + assertTrue(schema.contains("create table if not exists ll_order_command_record")); + assertTrue(schema.contains("create table if not exists ll_order_hold_history")); + assertTrue(schema.contains("create table if not exists ll_order_manual_node")); + assertTrue(schema.contains("create table if not exists ll_order_manual_node_history")); assertTrue(schema.contains("create table if not exists ll_inbound_fba_appointment_item")); assertTrue(schema.contains("fba_warehouse_code")); assertFalse(schema.contains("cabinet_no")); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/TailPlanCommandOpenApiContractTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/TailPlanCommandOpenApiContractTest.java new file mode 100644 index 000000000..435a92e0c --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/TailPlanCommandOpenApiContractTest.java @@ -0,0 +1,27 @@ +package com.baiyue.lastleg.inboundplan; + +import org.junit.jupiter.api.Test; + +import java.nio.charset.StandardCharsets; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class TailPlanCommandOpenApiContractTest { + @Test + void exposesOnlyLatestManualPlanHoldAndEightRowNodeContracts() throws Exception { + try (var stream = getClass().getClassLoader() + .getResourceAsStream("openapi/overseas-business-v1.yaml")) { + assertTrue(stream != null); + String api = new String(stream.readAllBytes(), StandardCharsets.UTF_8); + assertTrue(api.contains("/api/platform/v1/last-leg/inbound-plans/manual:")); + assertFalse(api.contains("/api/platform/v1/last-leg/inbound-plans/convert:")); + assertTrue(api.contains("/api/platform/v1/last-leg/orders/{id}/hold:")); + assertFalse(api.contains("/api/platform/v1/last-leg/orders/{id}/detained:")); + assertTrue(api.contains("/api/platform/v1/last-leg/orders/{id}/manual-nodes:")); + assertTrue(api.contains("minItems: 8")); + assertTrue(api.contains("maxItems: 8")); + assertTrue(api.contains("ACTUAL_ARRIVAL_WAITING_UNLOAD")); + } + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/controller/InboundPlanControllerTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/controller/InboundPlanControllerTest.java index 5bed91126..a540f63ed 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/controller/InboundPlanControllerTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/controller/InboundPlanControllerTest.java @@ -3,8 +3,8 @@ package com.baiyue.lastleg.inboundplan.controller; import com.baiyue.common.core.api.PageResult; import com.baiyue.common.security.AuthenticatedUser; import com.baiyue.lastleg.inboundplan.dto.InboundPlanRequests; -import com.baiyue.lastleg.inboundplan.dto.ConvertInboundPlanRequest; import com.baiyue.lastleg.inboundplan.dto.ConvertContainerInboundPlanRequest; +import com.baiyue.lastleg.inboundplan.dto.ManualInboundPlanRequest; import com.baiyue.lastleg.inboundplan.dto.UpdateFbaAppointmentRequest; import com.baiyue.lastleg.inboundplan.service.InboundFbaAppointmentService; import com.baiyue.lastleg.inboundplan.service.InboundPlanService; @@ -19,6 +19,7 @@ import org.springframework.web.server.ResponseStatusException; import java.util.List; import java.util.Set; import java.time.LocalDateTime; +import java.time.LocalDate; import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.mockito.Mockito.verify; @@ -61,31 +62,19 @@ class InboundPlanControllerTest { } @Test - void platformConversionRequiresAnExplicitCustomer() { - assertThatThrownBy(() -> controller.convert(platform(), new ConvertInboundPlanRequest(2L, null))) - .isInstanceOf(ResponseStatusException.class) - .hasMessageContaining("平台生成入库计划必须指定客户"); - verify(service, never()).convert(org.mockito.ArgumentMatchers.any(), org.mockito.ArgumentMatchers.any()); + void platformManualPlanPassesVersionedIdempotentCommand() { + ManualInboundPlanRequest request = manualRequest(); + controller.manual(platform(), "manual-key", request); + verify(service).convertManual(request, "manual-key", "7"); } @Test - void platformConversionPassesTheProvidedLongCustomerId() { - ConvertInboundPlanRequest request = new ConvertInboundPlanRequest(9223372036854775807L, - "193000000000200003"); - - controller.convert(platform(), request); - - verify(service).convert("193000000000200003", 9223372036854775807L); - } - - @Test - void customerConversionIgnoresRequestedCustomerAndUsesAuthenticatedScope() { + void merchantCannotCreateManualPlan() { AuthenticatedUser customer = new AuthenticatedUser(8L, 100L, 100L, 9L, "customer", "MERCHANT", "WEB", Set.of()); - ConvertInboundPlanRequest request = new ConvertInboundPlanRequest(2L, "200"); - - controller.convert(customer, request); - - verify(service).convert("100", 2L); + assertThatThrownBy(() -> controller.manual(customer, "manual-key", manualRequest())) + .isInstanceOf(ResponseStatusException.class); + verify(service, never()).convertManual(org.mockito.ArgumentMatchers.any(), + org.mockito.ArgumentMatchers.any(), org.mockito.ArgumentMatchers.any()); } @Test @@ -109,4 +98,10 @@ class InboundPlanControllerTest { return new AuthenticatedUser(7L, 0L, 0L, 8L, "operator", "PLATFORM", "WEB", Set.of("platform:overseas-warehouse:inbound-order:list")); } + + private static ManualInboundPlanRequest manualRequest() { + return new ManualInboundPlanRequest("193000000000200003", 9223372036854775807L, 0, + 193000000000200003L, "US-LAX-01", "洛杉矶仓", LocalDate.of(2026, 9, 8), + "FBA-001", 2, "STANDARD", false, true, true, "加急入仓"); + } } diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/controller/InboundPlanPermissionContractTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/controller/InboundPlanPermissionContractTest.java index 0fe426b28..49ebc5462 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/controller/InboundPlanPermissionContractTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/controller/InboundPlanPermissionContractTest.java @@ -33,7 +33,7 @@ class InboundPlanPermissionContractTest { @Test void conversionUsesCanonicalInboundOrderCreateAuthority() { Method method = Arrays.stream(InboundPlanController.class.getDeclaredMethods()) - .filter(candidate -> candidate.getName().equals("convert")) + .filter(candidate -> candidate.getName().equals("manual")) .findFirst() .orElseThrow(); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/service/InboundPlanServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/service/InboundPlanServiceTest.java index 0908f48d0..ce969a403 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/service/InboundPlanServiceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/service/InboundPlanServiceTest.java @@ -3,6 +3,7 @@ package com.baiyue.lastleg.inboundplan.service; import com.baiyue.lastleg.inboundplan.entity.InboundPlan; import com.baiyue.lastleg.inboundplan.dto.InboundPlanRequests; import com.baiyue.lastleg.inboundplan.dto.UpdateInboundPlanRequest; +import com.baiyue.lastleg.inboundplan.dto.ManualInboundPlanRequest; import com.baiyue.lastleg.inboundplan.entity.InboundPlanLine; import com.baiyue.lastleg.inboundplan.entity.InboundPlanPallet; import com.baiyue.lastleg.inboundplan.entity.InboundPlanPalletAllocation; @@ -15,6 +16,9 @@ import com.baiyue.lastleg.inboundplan.model.SourceOrderSnapshot; import com.baiyue.lastleg.inventory.mapper.InventoryBalanceMapper; import com.baiyue.lastleg.dropship.model.DropshipPalletSnapshot; import com.baiyue.lastleg.dropship.service.DropshipOrderService; +import com.baiyue.lastleg.inboundplan.pda.gateway.WarehouseResourceGateway; +import com.baiyue.lastleg.mapper.LastlegOrderCommandRecordMapper; +import com.fasterxml.jackson.databind.json.JsonMapper; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; @@ -28,11 +32,13 @@ import org.springframework.test.util.ReflectionTestUtils; import java.math.BigDecimal; import java.util.List; import java.time.LocalDateTime; +import java.time.LocalDate; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.never; import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; @@ -47,6 +53,8 @@ class InboundPlanServiceTest { @Mock InboundPlanOrderMapper orderMapper; @Mock InventoryBalanceMapper inventoryBalanceMapper; @Mock DropshipOrderService dropshipOrders; + @Mock WarehouseResourceGateway warehouseGateway; + @Mock LastlegOrderCommandRecordMapper commandMapper; private InboundPlanService service; @@ -60,6 +68,9 @@ class InboundPlanServiceTest { ReflectionTestUtils.setField(service, "orderMapper", orderMapper); ReflectionTestUtils.setField(service, "inventoryBalanceMapper", inventoryBalanceMapper); ReflectionTestUtils.setField(service, "dropshipOrders", dropshipOrders); + ReflectionTestUtils.setField(service, "warehouseGateway", warehouseGateway); + ReflectionTestUtils.setField(service, "commandMapper", commandMapper); + ReflectionTestUtils.setField(service, "objectMapper", JsonMapper.builder().findAndAddModules().build()); ReflectionTestUtils.setField(service, "orderNoGenerator", new com.baiyue.lastleg.service.LastlegOrderNoGenerator()); org.mockito.Mockito.lenient().when(lineMapper.selectList(any())).thenReturn(List.of()); org.mockito.Mockito.lenient().when(palletMapper.selectList(any())).thenReturn(List.of()); @@ -112,6 +123,52 @@ class InboundPlanServiceTest { assertEquals("洛杉矶仓", result.overseasWarehouseName()); } + @Test + void manuallyCreatesOneIndependentPlanWithFrozenMetadata() { + SourceOrderSnapshot source = order(2L, "RK-2", "1.0000", "WH-A"); + when(orderMapper.lockOrder("100", 2L)).thenReturn(List.of(source)); + when(warehouseGateway.requireActiveSharedWarehouse(193000000000200003L)).thenReturn( + new WarehouseResourceGateway.WarehouseReference( + 193000000000200003L, "US-LAX-01", "洛杉矶仓", "ACTIVE")); + when(orderMapper.markPlannedManual(eq("100"), eq(2L), any(), eq(2), eq(true), eq(0), any())) + .thenReturn(1); + ManualInboundPlanRequest request = new ManualInboundPlanRequest("100", 2L, 0, + 193000000000200003L, "US-LAX-01", "洛杉矶仓", LocalDate.of(2026, 9, 15), + "RK-2", 2, "STANDARD", true, true, true, "优先收货"); + + var result = service.convertManual(request, "manual-2", "900"); + + ArgumentCaptor plan = ArgumentCaptor.forClass(InboundPlan.class); + verify(planMapper).insert(plan.capture()); + assertEquals(2L, plan.getValue().getSourceOrderId()); + assertEquals(LocalDate.of(2026, 9, 15), plan.getValue().getPlannedArrivalDate()); + assertEquals("STANDARD", plan.getValue().getPalletType()); + assertEquals("MANUAL", plan.getValue().getCreationMode()); + assertEquals(2, result.palletCount()); + verify(palletMapper, times(2)).insert(any(InboundPlanPallet.class)); + verify(orderMapper).markPlannedManual(eq("100"), eq(2L), any(), eq(2), eq(true), eq(0), any()); + verify(commandMapper).insert(any(com.baiyue.lastleg.entity.LastlegOrderCommandRecord.class)); + verify(orderMapper, never()).lockContainerOrders(any()); + } + + @Test + void refusesManualPlanForHeldOrderBeforeWriting() { + SourceOrderSnapshot held = new SourceOrderSnapshot(2L, "100", "MSCU6639870", "RK-2", + "PENDING_ASSIGNMENT", null, new BigDecimal("1.0000"), 10, 0, + "193000000000200003", "US-LAX-01", "洛杉矶仓", null, + null, "WH-A", "仓A", 1, 0, true, 5); + when(orderMapper.lockOrder("100", 2L)).thenReturn(List.of(held)); + + ResponseStatusException error = assertThrows(ResponseStatusException.class, () -> service.convertManual( + new ManualInboundPlanRequest("100", 2L, 5, 193000000000200003L, "US-LAX-01", "洛杉矶仓", + LocalDate.of(2026, 9, 15), "RK-2", 1, "STANDARD", false, false, false, null), + "manual-held", "900")); + + assertEquals(409, error.getStatusCode().value()); + verify(planMapper, never()).insert(any(InboundPlan.class)); + verify(commandMapper, never()).insert(any(com.baiyue.lastleg.entity.LastlegOrderCommandRecord.class)); + } + @Test void convertsAConsistentDestinationAssignedOrder() { SourceOrderSnapshot assigned = new SourceOrderSnapshot(2L, "100", "MSCU6639870", "RK-2", diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/service/LastlegOrderCommandServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/service/LastlegOrderCommandServiceTest.java new file mode 100644 index 000000000..697f6ffaf --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/service/LastlegOrderCommandServiceTest.java @@ -0,0 +1,161 @@ +package com.baiyue.lastleg.service; + +import com.baiyue.lastleg.dto.LastlegOrderCommandDtos; +import com.baiyue.lastleg.entity.LastlegOrder; +import com.baiyue.lastleg.entity.LastlegOrderCommandRecord; +import com.baiyue.lastleg.entity.LastlegOrderHoldHistory; +import com.baiyue.lastleg.entity.LastlegOrderManualNodeHistory; +import com.baiyue.lastleg.entity.LastlegOrderManualNode; +import com.baiyue.lastleg.mapper.LastlegOrderCommandRecordMapper; +import com.baiyue.lastleg.mapper.LastlegOrderHoldHistoryMapper; +import com.baiyue.lastleg.mapper.LastlegOrderManualNodeHistoryMapper; +import com.baiyue.lastleg.mapper.LastlegOrderManualNodeMapper; +import com.baiyue.lastleg.mapper.LastlegOrderMapper; +import com.baiyue.lastleg.mapper.LastlegOrderOperationLogMapper; +import com.fasterxml.jackson.databind.json.JsonMapper; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.test.util.ReflectionTestUtils; +import org.springframework.web.server.ResponseStatusException; + +import java.time.LocalDateTime; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyBoolean; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.nullable; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class LastlegOrderCommandServiceTest { + @Mock LastlegOrderMapper orders; + @Mock LastlegOrderCommandRecordMapper commands; + @Mock LastlegOrderHoldHistoryMapper holdHistory; + @Mock LastlegOrderManualNodeMapper manualNodes; + @Mock LastlegOrderManualNodeHistoryMapper manualNodeHistory; + @Mock LastlegOrderOperationLogMapper operationLogs; + @Mock InboundOrderDetainedOutboxService detainedOutbox; + LastlegOrderCommandService service; + + @BeforeEach + void setUp() { + service = new LastlegOrderCommandService(); + ReflectionTestUtils.setField(service, "orders", orders); + ReflectionTestUtils.setField(service, "commands", commands); + ReflectionTestUtils.setField(service, "holdHistory", holdHistory); + ReflectionTestUtils.setField(service, "manualNodes", manualNodes); + ReflectionTestUtils.setField(service, "manualNodeHistory", manualNodeHistory); + ReflectionTestUtils.setField(service, "operationLogs", operationLogs); + ReflectionTestUtils.setField(service, "detainedOutbox", detainedOutbox); + ReflectionTestUtils.setField(service, "objectMapper", JsonMapper.builder().findAndAddModules().build()); + } + + @Test + void holdsOnlySelectedOrderWithVersionAndImmutableHistory() { + LastlegOrder order = order(11L, 3); + when(orders.selectById(11L)).thenReturn(order); + when(orders.lockById("100", 11L)).thenReturn(order); + when(orders.updateHold(eq("100"), eq(11L), eq(true), eq(true), eq("海关查验"), eq(3), + any(LocalDateTime.class))).thenReturn(1); + + var response = service.hold("100", 11L, "hold-11", + new LastlegOrderCommandDtos.HoldRequest(true, true, "海关查验", 3), "900", "管理员"); + + assertTrue(response.held()); + assertTrue(response.chargeable()); + assertEquals(4, response.version()); + verify(orders).updateHold(eq("100"), eq(11L), eq(true), eq(true), eq("海关查验"), eq(3), any()); + verify(holdHistory).insert(any(LastlegOrderHoldHistory.class)); + verify(commands).insert(any(LastlegOrderCommandRecord.class)); + verify(orders, never()).lockContainerOrders(any()); + } + + @Test + void rejectsStaleHoldWithoutAnyWrite() { + LastlegOrder order = order(11L, 4); + when(orders.selectById(11L)).thenReturn(order); + when(orders.lockById("100", 11L)).thenReturn(order); + + ResponseStatusException error = assertThrows(ResponseStatusException.class, () -> service.hold("100", 11L, + "hold-stale", new LastlegOrderCommandDtos.HoldRequest(true, false, "待核对", 3), "900", "管理员")); + + assertEquals(409, error.getStatusCode().value()); + verify(orders, never()).updateHold(anyString(), anyLong(), anyBoolean(), anyBoolean(), + nullable(String.class), anyInt(), any(LocalDateTime.class)); + verify(commands, never()).insert(any(LastlegOrderCommandRecord.class)); + } + + @Test + void savesExactEightRowManualSnapshotInFrozenDisplayOrder() { + LastlegOrder order = order(12L, 0); + when(orders.selectById(12L)).thenReturn(order); + when(orders.lockById("100", 12L)).thenReturn(order); + when(orders.updateManualNodes(eq("100"), eq(12L), any(), any(), any(), eq(0), any())).thenReturn(1); + LocalDateTime filled = LocalDateTime.of(2026, 9, 8, 10, 0); + List nodes = List.of( + node("UNLOADED", null), node("PICKUP_APPOINTED", null), + node("ACTUAL_ARRIVAL_WAITING_UNLOAD", null), node("OVERSEAS_CUSTOMS_COMPLETED", null), + node("ESTIMATED_ARRIVAL", filled), node("ACTUAL_DEPARTURE", null), + node("ESTIMATED_DEPARTURE", null), node("DOMESTIC_CUSTOMS_COMPLETED", null)); + + var response = service.saveManualNodes("100", 12L, "nodes-12", + new LastlegOrderCommandDtos.ManualNodesRequest(0, nodes), "900", "管理员"); + + assertTrue(response.manualOverrideEnabled()); + assertEquals(8, response.nodes().size()); + assertEquals("DOMESTIC_CUSTOMS_COMPLETED", response.nodes().get(0).nodeCode()); + assertEquals("UNLOADED", response.nodes().get(7).nodeCode()); + ArgumentCaptor rows = ArgumentCaptor.forClass(LastlegOrderManualNode.class); + verify(manualNodes, org.mockito.Mockito.times(8)).insert(rows.capture()); + assertEquals(8, rows.getAllValues().size()); + verify(manualNodeHistory).insert(any(LastlegOrderManualNodeHistory.class)); + } + + @Test + void rejectsIncompleteOrFullyEmptyManualSnapshot() { + LastlegOrder order = order(12L, 0); + when(orders.selectById(12L)).thenReturn(order); + when(orders.lockById("100", 12L)).thenReturn(order); + List nodes = List.of( + node("DOMESTIC_CUSTOMS_COMPLETED", null), node("ESTIMATED_DEPARTURE", null), + node("ACTUAL_DEPARTURE", null), node("ESTIMATED_ARRIVAL", null), + node("OVERSEAS_CUSTOMS_COMPLETED", null), node("ACTUAL_ARRIVAL_WAITING_UNLOAD", null), + node("PICKUP_APPOINTED", null), node("UNLOADED", null)); + + ResponseStatusException error = assertThrows(ResponseStatusException.class, () -> + service.saveManualNodes("100", 12L, "nodes-empty", + new LastlegOrderCommandDtos.ManualNodesRequest(0, nodes), "900", "管理员")); + + assertEquals(400, error.getStatusCode().value()); + verify(orders, never()).updateManualNodes(anyString(), anyLong(), nullable(LocalDateTime.class), + nullable(LocalDateTime.class), nullable(LocalDateTime.class), anyInt(), any(LocalDateTime.class)); + } + + private LastlegOrderCommandDtos.ManualNode node(String code, LocalDateTime time) { + return new LastlegOrderCommandDtos.ManualNode(code, time); + } + + private LastlegOrder order(long id, int version) { + LastlegOrder order = new LastlegOrder(); + order.setId(id); + order.setCustomerId("100"); + order.setOrderNo("LL-" + id); + order.setCurrentStatus("PENDING_ASSIGNMENT"); + order.setDetained(false); + order.setVersion(version); + return order; + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/service/LastlegOrderServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/service/LastlegOrderServiceTest.java index 73a4f0565..a47ffcc73 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/service/LastlegOrderServiceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/service/LastlegOrderServiceTest.java @@ -14,6 +14,7 @@ import com.baiyue.lastleg.mapper.LastlegOrderMapper; import com.baiyue.lastleg.mapper.LastlegOrderStatusHistoryMapper; import com.baiyue.lastleg.mapper.LastlegOrderFeeMapper; import com.baiyue.lastleg.mapper.LastlegOrderOperationLogMapper; +import com.baiyue.lastleg.mapper.LastlegOrderManualNodeMapper; import com.baiyue.lastleg.mapper.ReceivingWarehouseOperationMapper; import com.baiyue.lastleg.inboundplan.pda.gateway.WarehouseResourceGateway; import org.junit.jupiter.api.BeforeEach; @@ -54,6 +55,7 @@ class LastlegOrderServiceTest { @Mock LastlegOrderStatusHistoryMapper histories; @Mock LastlegOrderFeeMapper fees; @Mock LastlegOrderOperationLogMapper operations; + @Mock LastlegOrderManualNodeMapper manualNodes; @Mock InboundPlanCartonMapper cartons; @Mock FbaWarehouseValidator fbaWarehouses; @Mock InventoryCommandService inventory; @@ -70,6 +72,7 @@ class LastlegOrderServiceTest { ReflectionTestUtils.setField(service, "historyMapper", histories); ReflectionTestUtils.setField(service, "feeMapper", fees); ReflectionTestUtils.setField(service, "operationLogMapper", operations); + ReflectionTestUtils.setField(service, "manualNodeMapper", manualNodes); ReflectionTestUtils.setField(service, "cartonMapper", cartons); ReflectionTestUtils.setField(service, "fbaWarehouseValidator", fbaWarehouses); lenient().when(fbaWarehouses.required(any(), any(), any())).thenAnswer(invocation -> @@ -133,6 +136,7 @@ class LastlegOrderServiceTest { when(histories.selectList(any())).thenReturn(List.of()); when(fees.selectList(any())).thenReturn(List.of()); when(operations.selectList(any())).thenReturn(List.of()); + when(manualNodes.selectList(any())).thenReturn(List.of()); var response = service.detail("100", 11L);