feat: complete tail order controls and terminal drayage
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 完成不推进拖车状态 |
|
||||
|
||||
@@ -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)/],
|
||||
|
||||
+117
@@ -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<List<View>> 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<View> 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<List<History>> 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<View> 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<View> 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<View> 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<View> 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));
|
||||
}
|
||||
}
|
||||
+41
@@ -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<String> allowedActions, LocalDateTime updatedAt) {}
|
||||
|
||||
public record History(String action, String fromStatus, String toStatus,
|
||||
Integer fromVersion, Integer toVersion, String reason,
|
||||
Long operatorId, String operatorName, LocalDateTime createdAt) {}
|
||||
}
|
||||
+30
@@ -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;
|
||||
}
|
||||
+148
@@ -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<TerminalDrayageEntity> {
|
||||
@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("""
|
||||
<script>
|
||||
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 IN
|
||||
<foreach collection="containerBookingIds" item="id" open="(" separator="," close=")">#{id}</foreach>
|
||||
ORDER BY b.id
|
||||
</script>
|
||||
""")
|
||||
List<Context> contexts(@Param("containerBookingIds") List<Long> 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("""
|
||||
<script>
|
||||
SELECT * FROM fl_terminal_drayage WHERE container_booking_id IN
|
||||
<foreach collection="containerBookingIds" item="id" open="(" separator="," close=")">#{id}</foreach>
|
||||
</script>
|
||||
""")
|
||||
List<TerminalDrayageEntity> findAll(@Param("containerBookingIds") List<Long> 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<History> 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) {}
|
||||
}
|
||||
+339
@@ -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<View> views(List<Long> containerBookingIds) {
|
||||
if (containerBookingIds == null || containerBookingIds.isEmpty()) return List.of();
|
||||
List<Long> ids = containerBookingIds.stream().filter(Objects::nonNull).distinct().toList();
|
||||
if (ids.size() != containerBookingIds.size()) throw badRequest("containerBookingIds不能包含空值或重复值");
|
||||
Map<Long, TerminalDrayageEntity> 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<History> 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<String> allowedActions(Context context, TerminalDrayageEntity value) {
|
||||
List<String> 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; }
|
||||
}
|
||||
@@ -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='目的港码头拖车成功幂等操作';
|
||||
@@ -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'}}}}
|
||||
+24
@@ -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小时");
|
||||
}
|
||||
}
|
||||
+27
@@ -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}");
|
||||
}
|
||||
}
|
||||
+251
@@ -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<TerminalDrayageEntity> 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<ManualNodeRequest> 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<TerminalDrayageEntity> stored = new AtomicReference<>();
|
||||
AtomicReference<Operation> 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<ManualNodeRequest> 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;
|
||||
}
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
+40
-7
@@ -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<Void> setDetained(
|
||||
@Operation(summary = "暂扣或解除单票尾程订单",
|
||||
description = "要求版本与幂等键;暂扣只作用于该票并阻断其出库推进。Required authority: " + OverseasPermissions.LAST_MILE_ORDER_EDIT)
|
||||
public ApiResponse<LastlegOrderCommandDtos.HoldResponse> 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<LastlegOrderCommandDtos.ManualNodesResponse> 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<LastlegOrderCommandDtos.ManualNodesResponse> 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());
|
||||
}
|
||||
}
|
||||
|
||||
+8
@@ -66,6 +66,10 @@ public interface DispatchCandidateMapper {
|
||||
"<if test='fbaWarehouseCode != null and fbaWarehouseCode != \"\"'> AND l.fba_warehouse_code=#{fbaWarehouseCode}</if> " +
|
||||
"<if test='sourceStage != null and sourceStage != \"\"'> AND " +
|
||||
"CASE WHEN p.warehouse_pallet_id IS NULL THEN 'PLAN' ELSE 'RECEIVED' END=#{sourceStage}</if> " +
|
||||
"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 " +
|
||||
"<foreach collection='planPalletIds' item='id' open='(' separator=',' close=')'>#{id}</foreach> " +
|
||||
"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 " +
|
||||
|
||||
+21
@@ -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<Long> 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<DispatchPalletCandidate> selected) {
|
||||
List<Long> orderIds = selected.stream().map(DispatchPalletCandidate::orderId)
|
||||
.filter(Objects::nonNull).distinct().sorted().toList();
|
||||
if (orderIds.isEmpty()) return;
|
||||
List<LastlegOrder> scoped = tailOrders.selectList(new LambdaQueryWrapper<LastlegOrder>()
|
||||
.eq(LastlegOrder::getCustomerId, customerId)
|
||||
.in(LastlegOrder::getId, orderIds)
|
||||
.orderByAsc(LastlegOrder::getId)
|
||||
.last("FOR UPDATE"));
|
||||
List<String> 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<DispatchResponse> page(String customerId, DispatchRequests.Query query) {
|
||||
if (!StringUtils.hasText(customerId)) {
|
||||
return new PageResult<>(List.of(), query.page(), query.size(), 0, 0);
|
||||
|
||||
@@ -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<ManualNodeView> nodes, int version) { }
|
||||
public record ManualNodeView(String nodeCode, LocalDateTime nodeAt, String source) { }
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import java.util.List;
|
||||
|
||||
public record LastlegOrderDetailResponse(
|
||||
LastlegOrderSummary order,
|
||||
List<LastlegOrderCommandDtos.ManualNodeView> manualNodes,
|
||||
List<LastlegOrderDetail> cargo,
|
||||
List<History> statusHistory,
|
||||
List<LastlegOrderFee> fees,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
+23
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
+21
@@ -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;
|
||||
}
|
||||
+12
-7
@@ -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<InboundPlanResponse> convert(@AuthenticationPrincipal AuthenticatedUser user,
|
||||
@Valid @RequestBody ConvertInboundPlanRequest request) {
|
||||
return ApiResponse.success(service.convert(convertScope(user, request.customerId()), request.orderId()));
|
||||
public ApiResponse<InboundPlanResponse> 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")
|
||||
|
||||
-10
@@ -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
|
||||
) {
|
||||
}
|
||||
+10
-2
@@ -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<Line> lines,
|
||||
List<Pallet> pallets,
|
||||
List<Allocation> 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) {
|
||||
}
|
||||
|
||||
|
||||
+25
@@ -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
|
||||
) { }
|
||||
@@ -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;
|
||||
|
||||
+1
@@ -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;
|
||||
|
||||
+13
-2
@@ -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<SourceOrderSnapshot> 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,
|
||||
|
||||
+16
-2
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
+146
-10
@@ -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<SourceOrderSnapshot> 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<InboundPlanListResponse> 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<SourceOrderSnapshot> orders, List<String> sourcePalletNos,
|
||||
List<WholePalletInput> wholePallets) {
|
||||
List<WholePalletInput> 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<Long, SourceOrderSnapshot> orderById = orders.stream()
|
||||
.collect(Collectors.toMap(SourceOrderSnapshot::orderId, value -> value));
|
||||
Map<Long, InboundPlanLine> 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<Long, Integer> 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<PalletAllocator.PalletDraft, InboundPlanPallet> 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); }
|
||||
}
|
||||
}
|
||||
|
||||
+16
@@ -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<LastlegOrderCommandRecord> {
|
||||
@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);
|
||||
}
|
||||
+8
@@ -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<LastlegOrderHoldHistory> { }
|
||||
+8
@@ -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<LastlegOrderManualNodeHistory> { }
|
||||
+13
@@ -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<LastlegOrderManualNode> {
|
||||
@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);
|
||||
}
|
||||
@@ -146,8 +146,27 @@ public interface LastlegOrderMapper extends BaseMapper<LastlegOrder> {
|
||||
@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<LastlegOrder> {
|
||||
"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);
|
||||
|
||||
+240
@@ -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<String> 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<LastlegOrderCommandDtos.ManualNode> 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<String, LocalDateTime> 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<LastlegOrderCommandDtos.ManualNodeView> 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<LastlegOrderCommandDtos.ManualNodeView> nodes = manualNodes.selectList(
|
||||
new LambdaQueryWrapper<LastlegOrderManualNode>()
|
||||
.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<LastlegOrderCommandDtos.ManualNode> normalizeNodes(List<LastlegOrderCommandDtos.ManualNode> nodes) {
|
||||
Set<String> seen = new HashSet<>();
|
||||
boolean anyTime = false;
|
||||
List<LastlegOrderCommandDtos.ManualNode> 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> T replay(LastlegOrderCommandRecord record, String hash, Class<T> 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); }
|
||||
}
|
||||
@@ -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<String> 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<LastlegOrderCommandDtos.ManualNodeView> manualNodes = manualNodeMapper.selectList(
|
||||
new LambdaQueryWrapper<LastlegOrderManualNode>()
|
||||
.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),
|
||||
|
||||
@@ -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'''));
|
||||
|
||||
@@ -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 }
|
||||
|
||||
+21
@@ -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;
|
||||
}
|
||||
|
||||
+21
@@ -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
|
||||
|
||||
+7
@@ -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"));
|
||||
|
||||
+27
@@ -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"));
|
||||
}
|
||||
}
|
||||
}
|
||||
+17
-22
@@ -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, "加急入仓");
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
@@ -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();
|
||||
|
||||
|
||||
+57
@@ -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<InboundPlan> 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",
|
||||
|
||||
+161
@@ -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<LastlegOrderCommandDtos.ManualNode> 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<LastlegOrderManualNode> 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<LastlegOrderCommandDtos.ManualNode> 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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user