This commit is contained in:
2026-08-08 14:26:02 +08:00
parent 13a2b90e98
commit 355b8629d9
7 changed files with 1282 additions and 23 deletions
@@ -7541,6 +7541,406 @@ DROP PROCEDURE IF EXISTS phase3_assert_inbound_parent_child_schema;
CALL assert_loading_pick_task_shapes(1);
DROP PROCEDURE IF EXISTS assert_loading_pick_task_shapes;
-- ============================================================
-- 三期服务中心订舱与拖车预约
-- 来源:firstleg/src/main/resources/db/phase3-ocean-booking-schema.sql
-- 来源:firstleg/src/main/resources/db/phase3-truck-appointment-schema.sql
-- ============================================================
SET NAMES utf8mb4;
USE baiyue_firstleg;
CREATE TABLE IF NOT EXISTS fl_ocean_booking (
id BIGINT UNSIGNED NOT NULL,
booking_no VARCHAR(40) NOT NULL,
status VARCHAR(24) NOT NULL DEFAULT 'DRAFT',
customer_id BIGINT UNSIGNED NOT NULL COMMENT '当前产品客户ID',
customer_name VARCHAR(128) NOT NULL,
bill_of_lading_no VARCHAR(128) NULL,
shipping_company_id BIGINT UNSIGNED NULL,
shipping_company_name VARCHAR(128) NULL,
booking_agent_id BIGINT UNSIGNED NULL,
booking_agent_name VARCHAR(128) NULL,
vessel_name VARCHAR(128) NULL,
voyage_no VARCHAR(64) NULL,
departure_port_code VARCHAR(32) NULL,
departure_port_name VARCHAR(128) NULL,
destination_port_code VARCHAR(32) NULL,
destination_port_name VARCHAR(128) NULL,
place_of_delivery VARCHAR(128) NULL,
service_term VARCHAR(32) NULL,
freight_term VARCHAR(32) NULL,
estimated_sailing_at DATETIME(3) NULL,
estimated_arrival_at DATETIME(3) NULL,
pending_vessel_name VARCHAR(128) NULL,
pending_voyage_no VARCHAR(64) NULL,
pending_estimated_sailing_at DATETIME(3) NULL,
pending_estimated_arrival_at DATETIME(3) NULL,
change_reason VARCHAR(1000) NULL,
failure_reason VARCHAR(1000) NULL,
cancellation_reason VARCHAR(1000) NULL,
remark VARCHAR(1000) NULL,
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),
version INT UNSIGNED NOT NULL DEFAULT 0,
PRIMARY KEY (id),
UNIQUE KEY uk_ocean_booking_no (booking_no),
KEY idx_ocean_booking_customer (customer_id,status,updated_at,id),
KEY idx_ocean_booking_voyage (vessel_name,voyage_no,updated_at),
CONSTRAINT chk_ocean_booking_customer CHECK (customer_id > 0),
CONSTRAINT chk_ocean_booking_status CHECK (status IN
('DRAFT','SUBMITTED','CONFIRMED','RELEASED','FAILED','CHANGE_PENDING','CANCELLED')),
CONSTRAINT chk_ocean_booking_schedule CHECK
(estimated_sailing_at IS NULL OR estimated_arrival_at IS NULL OR estimated_arrival_at >= estimated_sailing_at),
CONSTRAINT chk_ocean_booking_pending_schedule CHECK
(pending_estimated_sailing_at IS NULL OR pending_estimated_arrival_at IS NULL
OR pending_estimated_arrival_at >= pending_estimated_sailing_at)
) ENGINE=InnoDB COMMENT='独立海运订舱聚合;柜次仍由fl_container_booking承载';
CREATE TABLE IF NOT EXISTS fl_ocean_booking_party (
id BIGINT UNSIGNED NOT NULL,
ocean_booking_id BIGINT UNSIGNED NOT NULL,
party_type VARCHAR(24) NOT NULL COMMENT 'SHIPPER/CONSIGNEE/NOTIFY_PARTY',
party_name VARCHAR(255) NOT NULL,
address VARCHAR(1000) NULL,
contact_name VARCHAR(128) NULL,
contact_phone VARCHAR(64) NULL,
PRIMARY KEY (id),
UNIQUE KEY uk_ocean_booking_party (ocean_booking_id,party_type),
CONSTRAINT fk_ocean_booking_party FOREIGN KEY (ocean_booking_id) REFERENCES fl_ocean_booking(id),
CONSTRAINT chk_ocean_booking_party_type CHECK (party_type IN ('SHIPPER','CONSIGNEE','NOTIFY_PARTY'))
) ENGINE=InnoDB COMMENT='订舱提单关系方快照';
CREATE TABLE IF NOT EXISTS fl_ocean_booking_container_requirement (
id BIGINT UNSIGNED NOT NULL,
ocean_booking_id BIGINT UNSIGNED NOT NULL,
container_type VARCHAR(32) NOT NULL,
container_kind VARCHAR(32) NOT NULL,
quantity INT UNSIGNED NOT NULL,
sort_no INT UNSIGNED NOT NULL DEFAULT 0,
PRIMARY KEY (id),
KEY idx_ocean_requirement_booking (ocean_booking_id,sort_no,id),
CONSTRAINT fk_ocean_requirement_booking FOREIGN KEY (ocean_booking_id) REFERENCES fl_ocean_booking(id),
CONSTRAINT chk_ocean_requirement_quantity CHECK (quantity > 0)
) ENGINE=InnoDB COMMENT='订舱箱型箱种及数量需求';
CREATE TABLE IF NOT EXISTS fl_ocean_booking_container_allocation (
id BIGINT UNSIGNED NOT NULL,
ocean_booking_id BIGINT UNSIGNED NOT NULL,
requirement_id BIGINT UNSIGNED NOT NULL,
container_booking_id BIGINT UNSIGNED NOT NULL COMMENT '明确引用现有柜次ID',
allocated_by BIGINT UNSIGNED NOT NULL,
allocated_by_name VARCHAR(64) NULL,
allocated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
PRIMARY KEY (id),
UNIQUE KEY uk_ocean_allocation_container (container_booking_id),
KEY idx_ocean_allocation_booking (ocean_booking_id,requirement_id,allocated_at),
CONSTRAINT fk_ocean_allocation_booking FOREIGN KEY (ocean_booking_id) REFERENCES fl_ocean_booking(id),
CONSTRAINT fk_ocean_allocation_requirement FOREIGN KEY (requirement_id)
REFERENCES fl_ocean_booking_container_requirement(id),
CONSTRAINT fk_ocean_allocation_container FOREIGN KEY (container_booking_id) REFERENCES fl_container_booking(id)
) ENGINE=InnoDB COMMENT='订舱需求与现有柜次的唯一分配关系';
CREATE TABLE IF NOT EXISTS fl_ocean_booking_status_history (
id BIGINT UNSIGNED NOT NULL,
ocean_booking_id BIGINT UNSIGNED NOT NULL,
from_status VARCHAR(24) NULL,
to_status VARCHAR(24) NOT NULL,
action VARCHAR(40) NOT NULL,
reason VARCHAR(1000) NULL,
before_snapshot JSON NULL,
after_snapshot JSON NOT 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_ocean_history_booking (ocean_booking_id,created_at,id),
CONSTRAINT fk_ocean_history_booking FOREIGN KEY (ocean_booking_id) REFERENCES fl_ocean_booking(id)
) ENGINE=InnoDB COMMENT='海运订舱不可变状态历史';
CREATE TABLE IF NOT EXISTS fl_ocean_booking_operation (
id BIGINT UNSIGNED NOT NULL,
ocean_booking_id BIGINT UNSIGNED NULL COMMENT '创建请求完成前允许为空',
operator_id BIGINT UNSIGNED NOT NULL,
operation VARCHAR(40) NOT NULL,
idempotency_key VARCHAR(128) NOT NULL,
request_hash CHAR(64) NOT NULL,
status VARCHAR(16) NOT NULL DEFAULT 'PROCESSING',
result_json JSON NULL,
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
completed_at DATETIME(3) NULL,
PRIMARY KEY (id),
UNIQUE KEY uk_ocean_operation (operator_id,operation,idempotency_key),
KEY idx_ocean_operation_booking (ocean_booking_id,created_at),
CONSTRAINT fk_ocean_operation_booking FOREIGN KEY (ocean_booking_id) REFERENCES fl_ocean_booking(id),
CONSTRAINT chk_ocean_operation_status CHECK (status IN ('PROCESSING','SUCCESS'))
) ENGINE=InnoDB COMMENT='海运订舱写操作幂等与请求哈希';
DROP TRIGGER IF EXISTS trg_ocean_booking_before_update;
DELIMITER $$
CREATE TRIGGER trg_ocean_booking_before_update BEFORE UPDATE ON fl_ocean_booking FOR EACH ROW
BEGIN
IF OLD.status IN ('RELEASED','CANCELLED') THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='已放舱或已取消订舱不可修改';
END IF;
IF OLD.status <> NEW.status AND NOT (
(OLD.status='DRAFT' AND NEW.status IN ('SUBMITTED','CANCELLED'))
OR (OLD.status='SUBMITTED' AND NEW.status IN ('CONFIRMED','FAILED','CANCELLED'))
OR (OLD.status='CONFIRMED' AND NEW.status IN ('RELEASED','CHANGE_PENDING','CANCELLED'))
OR (OLD.status='CHANGE_PENDING' AND NEW.status IN ('CONFIRMED','FAILED','CANCELLED'))
OR (OLD.status='FAILED' AND NEW.status='CANCELLED')) THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='海运订舱状态迁移非法';
END IF;
END$$
DELIMITER ;
DROP TRIGGER IF EXISTS trg_ocean_party_before_insert;
DROP TRIGGER IF EXISTS trg_ocean_party_before_update;
DROP TRIGGER IF EXISTS trg_ocean_party_before_delete;
DELIMITER $$
CREATE TRIGGER trg_ocean_party_before_insert BEFORE INSERT ON fl_ocean_booking_party FOR EACH ROW
BEGIN IF (SELECT status FROM fl_ocean_booking WHERE id=NEW.ocean_booking_id) <> 'DRAFT' THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='仅草稿可维护订舱方'; END IF; END$$
CREATE TRIGGER trg_ocean_party_before_update BEFORE UPDATE ON fl_ocean_booking_party FOR EACH ROW
BEGIN IF (SELECT status FROM fl_ocean_booking WHERE id=OLD.ocean_booking_id) <> 'DRAFT' THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='仅草稿可维护订舱方'; END IF; END$$
CREATE TRIGGER trg_ocean_party_before_delete BEFORE DELETE ON fl_ocean_booking_party FOR EACH ROW
BEGIN IF (SELECT status FROM fl_ocean_booking WHERE id=OLD.ocean_booking_id) <> 'DRAFT' THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='仅草稿可维护订舱方'; END IF; END$$
DELIMITER ;
DROP TRIGGER IF EXISTS trg_ocean_requirement_before_insert;
DROP TRIGGER IF EXISTS trg_ocean_requirement_before_update;
DROP TRIGGER IF EXISTS trg_ocean_requirement_before_delete;
DELIMITER $$
CREATE TRIGGER trg_ocean_requirement_before_insert BEFORE INSERT ON fl_ocean_booking_container_requirement FOR EACH ROW
BEGIN IF (SELECT status FROM fl_ocean_booking WHERE id=NEW.ocean_booking_id) <> 'DRAFT' THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='仅草稿可维护箱型需求'; END IF; END$$
CREATE TRIGGER trg_ocean_requirement_before_update BEFORE UPDATE ON fl_ocean_booking_container_requirement FOR EACH ROW
BEGIN IF (SELECT status FROM fl_ocean_booking WHERE id=OLD.ocean_booking_id) <> 'DRAFT' THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='仅草稿可维护箱型需求'; END IF; END$$
CREATE TRIGGER trg_ocean_requirement_before_delete BEFORE DELETE ON fl_ocean_booking_container_requirement FOR EACH ROW
BEGIN IF (SELECT status FROM fl_ocean_booking WHERE id=OLD.ocean_booking_id) <> 'DRAFT' THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='仅草稿可维护箱型需求'; END IF; END$$
DELIMITER ;
DROP TRIGGER IF EXISTS trg_ocean_allocation_before_insert;
DELIMITER $$
CREATE TRIGGER trg_ocean_allocation_before_insert BEFORE INSERT ON fl_ocean_booking_container_allocation FOR EACH ROW
BEGIN
IF (SELECT ocean_booking_id FROM fl_ocean_booking_container_requirement WHERE id=NEW.requirement_id) <> NEW.ocean_booking_id THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='箱型需求不属于当前订舱';
END IF;
IF (SELECT status FROM fl_ocean_booking WHERE id=NEW.ocean_booking_id) NOT IN
('SUBMITTED','CONFIRMED','CHANGE_PENDING') THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='当前订舱状态不可分配柜次';
END IF;
END$$
DELIMITER ;
DROP TRIGGER IF EXISTS trg_ocean_history_before_update;
DROP TRIGGER IF EXISTS trg_ocean_history_before_delete;
DELIMITER $$
CREATE TRIGGER trg_ocean_history_before_update BEFORE UPDATE ON fl_ocean_booking_status_history FOR EACH ROW
BEGIN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='订舱状态历史不可修改'; END$$
CREATE TRIGGER trg_ocean_history_before_delete BEFORE DELETE ON fl_ocean_booking_status_history FOR EACH ROW
BEGIN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='订舱状态历史不可删除'; END$$
DELIMITER ;
USE baiyue_iam;
DROP PROCEDURE IF EXISTS assert_ocean_booking_permission_ids_available;
DELIMITER $$
CREATE PROCEDURE assert_ocean_booking_permission_ids_available()
BEGIN
IF EXISTS (
SELECT 1 FROM iam_permission
WHERE (id BETWEEN 5102140 AND 5102149
AND permission_code <> CASE id
WHEN 5102140 THEN 'platform:firstleg:ocean-booking:list'
WHEN 5102141 THEN 'platform:firstleg:ocean-booking:view'
WHEN 5102142 THEN 'platform:firstleg:ocean-booking:create'
WHEN 5102143 THEN 'platform:firstleg:ocean-booking:edit'
WHEN 5102144 THEN 'platform:firstleg:ocean-booking:submit'
WHEN 5102145 THEN 'platform:firstleg:ocean-booking:confirm'
WHEN 5102146 THEN 'platform:firstleg:ocean-booking:release'
WHEN 5102147 THEN 'platform:firstleg:ocean-booking:cancel'
WHEN 5102148 THEN 'platform:firstleg:ocean-booking:change'
WHEN 5102149 THEN 'platform:firstleg:ocean-booking:allocate'
END)
OR (permission_code LIKE 'platform:firstleg:ocean-booking:%'
AND (tenant_id <> 0 OR app_code <> 'PLATFORM' OR id NOT BETWEEN 5102140 AND 5102149))
) THEN
SIGNAL SQLSTATE '45000'
SET MESSAGE_TEXT = '三期订舱权限迁移失败:5102140-5102149 ID 或权限码被其他语义占用';
END IF;
END$$
DELIMITER ;
CALL assert_ocean_booking_permission_ids_available();
DROP PROCEDURE IF EXISTS assert_ocean_booking_permission_ids_available;
INSERT INTO iam_permission
(id,tenant_id,app_code,permission_type,parent_id,permission_code,permission_name,
route_path,http_method,sort_no,status,built_in,deleted)
VALUES
(5102140,0,'PLATFORM','MENU',5102000,'platform:firstleg:ocean-booking:list','订舱管理','/first-leg/ocean-bookings','GET',80,'ACTIVE',1,0),
(5102141,0,'PLATFORM','BUTTON',5102140,'platform:firstleg:ocean-booking:view','查看订舱',NULL,'GET',10,'ACTIVE',1,0),
(5102142,0,'PLATFORM','BUTTON',5102140,'platform:firstleg:ocean-booking:create','创建订舱',NULL,'POST',20,'ACTIVE',1,0),
(5102143,0,'PLATFORM','BUTTON',5102140,'platform:firstleg:ocean-booking:edit','编辑订舱',NULL,'PUT',30,'ACTIVE',1,0),
(5102144,0,'PLATFORM','BUTTON',5102140,'platform:firstleg:ocean-booking:submit','提交订舱',NULL,'POST',40,'ACTIVE',1,0),
(5102145,0,'PLATFORM','BUTTON',5102140,'platform:firstleg:ocean-booking:confirm','确认订舱',NULL,'POST',50,'ACTIVE',1,0),
(5102146,0,'PLATFORM','BUTTON',5102140,'platform:firstleg:ocean-booking:release','确认放舱',NULL,'POST',60,'ACTIVE',1,0),
(5102147,0,'PLATFORM','BUTTON',5102140,'platform:firstleg:ocean-booking:cancel','取消订舱',NULL,'POST',70,'ACTIVE',1,0),
(5102148,0,'PLATFORM','BUTTON',5102140,'platform:firstleg:ocean-booking:change','变更船期',NULL,'POST',80,'ACTIVE',1,0),
(5102149,0,'PLATFORM','BUTTON',5102140,'platform:firstleg:ocean-booking:allocate','分配柜次',NULL,'POST',90,'ACTIVE',1,0)
ON DUPLICATE KEY UPDATE tenant_id=0,app_code='PLATFORM',permission_type=VALUES(permission_type),
permission_code=VALUES(permission_code),permission_name=VALUES(permission_name),parent_id=VALUES(parent_id),
route_path=VALUES(route_path),http_method=VALUES(http_method),sort_no=VALUES(sort_no),status='ACTIVE',built_in=1,deleted=0;
INSERT IGNORE INTO iam_role_permission(role_id,permission_id,tenant_id)
SELECT role.id,permission.id,0 FROM iam_role role JOIN iam_permission permission
ON permission.tenant_id=0 AND permission.permission_code LIKE 'platform:firstleg:ocean-booking:%'
WHERE role.tenant_id=0 AND role.role_code IN ('PLATFORM_ADMIN','ORDER_OPERATOR');
USE baiyue_firstleg;
SET NAMES utf8mb4;
CREATE TABLE IF NOT EXISTS fl_truck_appointment (
id BIGINT UNSIGNED NOT NULL, appointment_no VARCHAR(40) NOT NULL, outbound_order_id BIGINT UNSIGNED NULL,
ocean_booking_id BIGINT UNSIGNED NULL, container_booking_id BIGINT UNSIGNED NULL, loading_plan_id BIGINT UNSIGNED NULL,
warehouse_id BIGINT UNSIGNED NULL, customer_id BIGINT UNSIGNED NULL, customer_name VARCHAR(128) NULL,
carrier_id BIGINT UNSIGNED NULL, carrier_name VARCHAR(128) NULL, fleet_contact VARCHAR(64) NULL, fleet_phone VARCHAR(32) NULL,
driver_name VARCHAR(64) NULL, driver_phone VARCHAR(32) NULL, driver_id_card VARCHAR(32) NULL, vehicle_no VARCHAR(32) NULL,
store_name VARCHAR(128) NULL, store_contact VARCHAR(64) NULL, store_phone VARCHAR(32) NULL, store_address VARCHAR(255) NULL,
store_city VARCHAR(64) NULL, store_zipcode VARCHAR(16) NULL, package_count INT UNSIGNED NULL, gross_weight_kg DECIMAL(18,3) NULL,
volume_cbm DECIMAL(18,4) NULL, pallet_count INT UNSIGNED NULL, po_no VARCHAR(255) NULL, planned_loading_at DATETIME(3) NULL,
actual_loading_at DATETIME(3) NULL, transit_minutes INT UNSIGNED NULL, payable_amount DECIMAL(18,2) NULL, receivable_amount DECIMAL(18,2) NULL,
currency VARCHAR(8) NOT NULL DEFAULT 'RMB', status VARCHAR(32) NOT NULL DEFAULT 'DRAFT', revision INT UNSIGNED NOT NULL DEFAULT 0,
version INT UNSIGNED NOT NULL DEFAULT 0, remark VARCHAR(1000) NULL, created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), PRIMARY KEY(id), UNIQUE KEY uk_truck_appointment_no(appointment_no),
KEY idx_truck_appointment_status(status,updated_at), KEY idx_truck_appointment_container(container_booking_id), KEY idx_truck_appointment_customer(customer_id),
CONSTRAINT chk_truck_appointment_status CHECK(status IN ('DRAFT','PENDING_DISPATCH','DISPATCHED','PICKUP_IN_PROGRESS','ARRIVED_WAREHOUSE','LOADING','DEPARTED','COMPLETED','RESCHEDULED','EXCEPTION','CANCELLED'))
) ENGINE=InnoDB COMMENT='三期拖车预约派车聚合';
CREATE TABLE IF NOT EXISTS fl_truck_appointment_history (
id BIGINT UNSIGNED NOT NULL, appointment_id BIGINT UNSIGNED NOT NULL, revision INT UNSIGNED NOT NULL, action VARCHAR(32) NOT NULL,
from_status VARCHAR(32) NULL, to_status VARCHAR(32) NULL, reason VARCHAR(500) NULL, operator_id BIGINT UNSIGNED NOT NULL,
operator_name VARCHAR(64) NULL, request_id VARCHAR(128) NULL, created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), PRIMARY KEY(id), KEY idx_truck_history(appointment_id,revision)
) ENGINE=InnoDB COMMENT='拖车状态与改约历史';
CREATE TABLE IF NOT EXISTS fl_truck_appointment_attachment (
id BIGINT UNSIGNED NOT NULL, appointment_id BIGINT UNSIGNED NOT NULL, file_id BIGINT UNSIGNED NOT NULL, attachment_type VARCHAR(32) NOT NULL,
file_name VARCHAR(255) NULL, file_hash CHAR(64) NULL, visibility VARCHAR(24) NOT NULL DEFAULT 'INTERNAL', uploaded_by BIGINT UNSIGNED NOT NULL,
uploaded_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), PRIMARY KEY(id), UNIQUE KEY uk_truck_attachment(appointment_id,file_id), KEY idx_truck_attachment(appointment_id)
) ENGINE=InnoDB COMMENT='拖车附件文件引用';
CREATE TABLE IF NOT EXISTS fl_truck_appointment_operation (
id BIGINT UNSIGNED NOT NULL, appointment_id BIGINT UNSIGNED NOT NULL, idempotency_key VARCHAR(128) NOT NULL, request_hash CHAR(64) NOT NULL,
action VARCHAR(32) NOT NULL, response_snapshot JSON NULL, created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), PRIMARY KEY(id), UNIQUE KEY uk_truck_operation(idempotency_key), KEY idx_truck_operation_appointment(appointment_id)
) ENGINE=InnoDB COMMENT='拖车操作幂等记录';
USE baiyue_iam;
DROP PROCEDURE IF EXISTS assert_truck_appointment_permission_ids_available;
DELIMITER $$
CREATE PROCEDURE assert_truck_appointment_permission_ids_available()
BEGIN
IF EXISTS (
SELECT 1 FROM iam_permission
WHERE (id BETWEEN 5102150 AND 5102154
AND permission_code <> CASE id
WHEN 5102150 THEN 'platform:firstleg:truck-appointment:list'
WHEN 5102151 THEN 'platform:firstleg:truck-appointment:view'
WHEN 5102152 THEN 'platform:firstleg:truck-appointment:create'
WHEN 5102153 THEN 'platform:firstleg:truck-appointment:edit'
WHEN 5102154 THEN 'platform:firstleg:truck-appointment:operate'
END)
OR (permission_code LIKE 'platform:firstleg:truck-appointment:%'
AND (tenant_id <> 0 OR app_code <> 'PLATFORM' OR id NOT BETWEEN 5102150 AND 5102154))
) THEN
SIGNAL SQLSTATE '45000'
SET MESSAGE_TEXT = '三期拖车权限迁移失败:5102150-5102154 ID 或权限码被其他语义占用';
END IF;
END$$
DELIMITER ;
CALL assert_truck_appointment_permission_ids_available();
DROP PROCEDURE IF EXISTS assert_truck_appointment_permission_ids_available;
INSERT INTO iam_permission
(id,tenant_id,app_code,permission_type,parent_id,permission_code,permission_name,
route_path,http_method,sort_no,status,built_in,deleted)
VALUES
(5102150,0,'PLATFORM','MENU',5102000,'platform:firstleg:truck-appointment:list','拖车预约','/first-leg/truck-appointments','GET',90,'ACTIVE',1,0),
(5102151,0,'PLATFORM','BUTTON',5102150,'platform:firstleg:truck-appointment:view','查看拖车预约',NULL,'GET',10,'ACTIVE',1,0),
(5102152,0,'PLATFORM','BUTTON',5102150,'platform:firstleg:truck-appointment:create','创建拖车预约',NULL,'POST',20,'ACTIVE',1,0),
(5102153,0,'PLATFORM','BUTTON',5102150,'platform:firstleg:truck-appointment:edit','编辑拖车预约',NULL,'PUT',30,'ACTIVE',1,0),
(5102154,0,'PLATFORM','BUTTON',5102150,'platform:firstleg:truck-appointment:operate','操作拖车预约',NULL,'POST',40,'ACTIVE',1,0)
ON DUPLICATE KEY UPDATE tenant_id=0,app_code='PLATFORM',permission_type=VALUES(permission_type),
permission_code=VALUES(permission_code),permission_name=VALUES(permission_name),parent_id=VALUES(parent_id),
route_path=VALUES(route_path),http_method=VALUES(http_method),sort_no=VALUES(sort_no),status='ACTIVE',built_in=1,deleted=0;
INSERT IGNORE INTO iam_role_permission(role_id,permission_id,tenant_id)
SELECT role.id,permission.id,0 FROM iam_role role JOIN iam_permission permission
ON permission.tenant_id=0 AND permission.app_code='PLATFORM'
AND permission.permission_code LIKE 'platform:firstleg:truck-appointment:%'
WHERE role.tenant_id=0 AND role.role_code IN ('PLATFORM_ADMIN','ORDER_OPERATOR');
USE baiyue_firstleg;
USE baiyue_iam;
INSERT INTO iam_permission
(id,tenant_id,app_code,permission_type,parent_id,permission_code,permission_name,
route_path,http_method,sort_no,status,built_in,deleted)
VALUES
(5102150,0,'PLATFORM','MENU',5102000,'platform:firstleg:truck-appointment:list','拖车预约','/trucking','GET',90,'ACTIVE',1,0),
(5102151,0,'PLATFORM','BUTTON',5102150,'platform:firstleg:truck-appointment:view','查看拖车预约',NULL,'GET',10,'ACTIVE',1,0),
(5102152,0,'PLATFORM','BUTTON',5102150,'platform:firstleg:truck-appointment:create','创建拖车预约',NULL,'POST',20,'ACTIVE',1,0),
(5102153,0,'PLATFORM','BUTTON',5102150,'platform:firstleg:truck-appointment:edit','编辑拖车预约',NULL,'PUT',30,'ACTIVE',1,0),
(5102154,0,'PLATFORM','BUTTON',5102150,'platform:firstleg:truck-appointment:operate','操作拖车状态',NULL,'POST',40,'ACTIVE',1,0)
ON DUPLICATE KEY UPDATE permission_name=VALUES(permission_name),parent_id=VALUES(parent_id),
route_path=VALUES(route_path),http_method=VALUES(http_method),sort_no=VALUES(sort_no),status='ACTIVE',built_in=1,deleted=0;
INSERT IGNORE INTO iam_role_permission(role_id,permission_id,tenant_id)
SELECT role.id,permission.id,0 FROM iam_role role JOIN iam_permission permission
ON permission.tenant_id=0 AND permission.permission_code LIKE 'platform:firstleg:truck-appointment:%'
WHERE role.tenant_id=0 AND role.role_code IN ('PLATFORM_ADMIN','ORDER_OPERATOR');
USE baiyue_firstleg;
DROP PROCEDURE IF EXISTS assert_phase3_service_center_schema;
DELIMITER $$
CREATE PROCEDURE assert_phase3_service_center_schema()
BEGIN
IF (SELECT COUNT(*) FROM information_schema.tables
WHERE table_schema='baiyue_firstleg'
AND table_name IN ('fl_ocean_booking','fl_ocean_booking_party',
'fl_ocean_booking_container_requirement','fl_ocean_booking_container_allocation',
'fl_ocean_booking_status_history','fl_ocean_booking_operation',
'fl_truck_appointment','fl_truck_appointment_history',
'fl_truck_appointment_attachment','fl_truck_appointment_operation')) <> 10 THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='统一迁移失败:订舱或拖车预约表未完整创建';
END IF;
IF (SELECT COUNT(*) FROM baiyue_iam.iam_permission
WHERE tenant_id=0 AND app_code='PLATFORM' AND id BETWEEN 5102140 AND 5102149
AND permission_code LIKE 'platform:firstleg:ocean-booking:%') <> 10
OR (SELECT COUNT(*) FROM baiyue_iam.iam_permission
WHERE tenant_id=0 AND app_code='PLATFORM' AND id BETWEEN 5102150 AND 5102154
AND permission_code LIKE 'platform:firstleg:truck-appointment:%') <> 5 THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='统一迁移失败:订舱或拖车预约权限未完整写入';
END IF;
IF (SELECT COUNT(DISTINCT p.permission_code)
FROM baiyue_iam.iam_role_permission rp
JOIN baiyue_iam.iam_role r ON r.id=rp.role_id AND r.tenant_id=rp.tenant_id
JOIN baiyue_iam.iam_permission p ON p.id=rp.permission_id AND p.tenant_id=rp.tenant_id
WHERE r.tenant_id=0 AND r.role_code IN ('PLATFORM_ADMIN','ORDER_OPERATOR')
AND p.permission_code LIKE 'platform:firstleg:ocean-booking:%') <> 10
OR (SELECT COUNT(DISTINCT p.permission_code)
FROM baiyue_iam.iam_role_permission rp
JOIN baiyue_iam.iam_role r ON r.id=rp.role_id AND r.tenant_id=rp.tenant_id
JOIN baiyue_iam.iam_permission p ON p.id=rp.permission_id AND p.tenant_id=rp.tenant_id
WHERE r.tenant_id=0 AND r.role_code IN ('PLATFORM_ADMIN','ORDER_OPERATOR')
AND p.permission_code LIKE 'platform:firstleg:truck-appointment:%') <> 5 THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='统一迁移失败:订舱或拖车预约角色授权未完整写入';
END IF;
END$$
DELIMITER ;
CALL assert_phase3_service_center_schema();
DROP PROCEDURE IF EXISTS assert_phase3_service_center_schema;
-- WAIT_SPLIT 是二期当前有效终点;统一迁移不得改写主表或历史状态。
SELECT 'PASS' AS migration_status,
'统一增量全部执行完成;箱号映射、单箱测量、三期出仓、费用、公共资源及可靠审计结构校验通过' AS message;
'统一增量全部执行完成;箱号映射、单箱测量、三期订舱、拖车预约、出仓、费用、公共资源及可靠审计结构校验通过' AS message;
@@ -4,7 +4,7 @@ import com.baomidou.mybatisplus.core.toolkit.IdWorker; import com.baiyue.common.
public PageResult<TruckAppointmentEntity> page(Query q){return repo.page(q);} public TruckAppointmentEntity detail(long id){return required(id);}
@Transactional public TruckAppointmentEntity create(long uid,String name,SaveRequest r){if(r.expectedVersion()!=0)throw bad("新增 expectedVersion 必须为0"); TruckAppointmentEntity e=new TruckAppointmentEntity(); e.setId(IdWorker.getId()); e.setAppointmentNo("TA"+e.getId()); e.setStatus(TruckAppointmentStatus.DRAFT); e.setRevision(0); e.setVersion(0); e.setCreatedAt(LocalDateTime.now()); e.setUpdatedAt(e.getCreatedAt()); copy(e,r); repo.insert(e); record(e,null,e.getStatus().name(),"CREATE",null,uid,name); return e;}
@Transactional public TruckAppointmentEntity edit(long id,long uid,String name,SaveRequest r){TruckAppointmentEntity e=required(id); checkVersion(e,r.expectedVersion()); if(!(e.getStatus()==TruckAppointmentStatus.DRAFT||e.getStatus()==TruckAppointmentStatus.PENDING_DISPATCH||e.getStatus()==TruckAppointmentStatus.RESCHEDULED))throw conflict("当前状态不可编辑"); copy(e,r); if(repo.updateEdit(e,r.expectedVersion())!=1)throw conflict("版本已变化,请刷新后重试"); record(e,e.getStatus().name(),e.getStatus().name(),"EDIT",null,uid,name); return required(id);}
@Transactional public TruckAppointmentEntity action(long id,String action,ActionRequest r,String idem,String hash,long uid,String name){TruckAppointmentEntity e=required(id); checkVersion(e,r.expectedVersion()); TruckAppointmentStatus from=e.getStatus(),to=next(from,action); if("RESCHEDULE".equals(action)){throw conflict("改约请使用专用接口");} if(repo.transition(id,from.name(),to.name(),e.getVersion())!=1)throw conflict("状态或版本已变化,请刷新后重试"); e.setStatus(to);e.setVersion(e.getVersion()+1);e.setRevision(e.getRevision()+1); record(e,from.name(),to.name(),action,r.reason(),uid,name); operation(e,action,idem,hash); return e;}
@Transactional public TruckAppointmentEntity action(long id,String action,ActionRequest r,String idem,String hash,long uid,String name){TruckAppointmentEntity e=required(id); checkVersion(e,r.expectedVersion()); if("RESCHEDULE".equals(action)){throw conflict("改约请使用专用接口");} TruckAppointmentStatus from=e.getStatus(),to=next(from,action); if(repo.transition(id,from.name(),to.name(),e.getVersion())!=1)throw conflict("状态或版本已变化,请刷新后重试"); e.setStatus(to);e.setVersion(e.getVersion()+1);e.setRevision(e.getRevision()+1); record(e,from.name(),to.name(),action,r.reason(),uid,name); operation(e,action,idem,hash); return e;}
@Transactional public TruckAppointmentEntity reschedule(long id,LocalDateTime at,ActionRequest r,long uid,String name){TruckAppointmentEntity e=required(id);checkVersion(e,r.expectedVersion()); if(e.getStatus()==TruckAppointmentStatus.COMPLETED||e.getStatus()==TruckAppointmentStatus.CANCELLED)throw conflict("当前状态不可改约"); String from=e.getStatus().name(); if(repo.reschedule(id,at,r.reason(),e.getVersion())!=1)throw conflict("状态或版本已变化,请刷新后重试");e.setStatus(TruckAppointmentStatus.RESCHEDULED);e.setPlannedLoadingAt(at);e.setVersion(e.getVersion()+1);e.setRevision(e.getRevision()+1);record(e,from,"RESCHEDULED","RESCHEDULE",r.reason(),uid,name);return e;}
private TruckAppointmentStatus next(TruckAppointmentStatus s,String a){return switch(a){case"SUBMIT"->s==TruckAppointmentStatus.DRAFT?TruckAppointmentStatus.PENDING_DISPATCH:badStatus();case"DISPATCH"->s==TruckAppointmentStatus.PENDING_DISPATCH||s==TruckAppointmentStatus.RESCHEDULED?TruckAppointmentStatus.DISPATCHED:badStatus();case"PICKUP"->s==TruckAppointmentStatus.DISPATCHED?TruckAppointmentStatus.PICKUP_IN_PROGRESS:badStatus();case"ARRIVE"->s==TruckAppointmentStatus.PICKUP_IN_PROGRESS?TruckAppointmentStatus.ARRIVED_WAREHOUSE:badStatus();case"LOAD"->s==TruckAppointmentStatus.ARRIVED_WAREHOUSE?TruckAppointmentStatus.LOADING:badStatus();case"DEPART"->s==TruckAppointmentStatus.LOADING?TruckAppointmentStatus.DEPARTED:badStatus();case"COMPLETE"->s==TruckAppointmentStatus.DEPARTED?TruckAppointmentStatus.COMPLETED:badStatus();case"EXCEPTION"->s!=TruckAppointmentStatus.COMPLETED&&s!=TruckAppointmentStatus.CANCELLED?TruckAppointmentStatus.EXCEPTION:badStatus();case"CANCEL"->s!=TruckAppointmentStatus.COMPLETED?TruckAppointmentStatus.CANCELLED:badStatus();default->badStatus();};}
private TruckAppointmentStatus badStatus(){throw conflict("不允许的状态动作");} private TruckAppointmentEntity required(long id){TruckAppointmentEntity e=repo.find(id);if(e==null)throw new BizException(ResultCode.NOT_FOUND,"拖车预约不存在");return e;} private void checkVersion(TruckAppointmentEntity e,int v){if(e.getVersion()!=v)throw conflict("版本已变化,请刷新后重试");} private static BizException conflict(String m){return new BizException(ResultCode.CONFLICT,m);} private static BizException bad(String m){return new BizException(ResultCode.BAD_REQUEST,m);}
@@ -76,23 +76,3 @@ SELECT role.id,permission.id,0 FROM iam_role role JOIN iam_permission permission
WHERE role.tenant_id=0 AND role.role_code IN ('PLATFORM_ADMIN','ORDER_OPERATOR');
USE baiyue_firstleg;
USE baiyue_iam;
INSERT INTO iam_permission
(id,tenant_id,app_code,permission_type,parent_id,permission_code,permission_name,
route_path,http_method,sort_no,status,built_in,deleted)
VALUES
(5102150,0,'PLATFORM','MENU',5102000,'platform:firstleg:truck-appointment:list','拖车预约','/trucking','GET',90,'ACTIVE',1,0),
(5102151,0,'PLATFORM','BUTTON',5102150,'platform:firstleg:truck-appointment:view','查看拖车预约',NULL,'GET',10,'ACTIVE',1,0),
(5102152,0,'PLATFORM','BUTTON',5102150,'platform:firstleg:truck-appointment:create','创建拖车预约',NULL,'POST',20,'ACTIVE',1,0),
(5102153,0,'PLATFORM','BUTTON',5102150,'platform:firstleg:truck-appointment:edit','编辑拖车预约',NULL,'PUT',30,'ACTIVE',1,0),
(5102154,0,'PLATFORM','BUTTON',5102150,'platform:firstleg:truck-appointment:operate','操作拖车状态',NULL,'POST',40,'ACTIVE',1,0)
ON DUPLICATE KEY UPDATE permission_name=VALUES(permission_name),parent_id=VALUES(parent_id),
route_path=VALUES(route_path),http_method=VALUES(http_method),sort_no=VALUES(sort_no),status='ACTIVE',built_in=1,deleted=0;
INSERT IGNORE INTO iam_role_permission(role_id,permission_id,tenant_id)
SELECT role.id,permission.id,0 FROM iam_role role JOIN iam_permission permission
ON permission.tenant_id=0 AND permission.permission_code LIKE 'platform:firstleg:truck-appointment:%'
WHERE role.tenant_id=0 AND role.role_code IN ('PLATFORM_ADMIN','ORDER_OPERATOR');
USE baiyue_firstleg;
@@ -0,0 +1,111 @@
package com.baiyue.firstleg.repository;
import com.baiyue.firstleg.controller.PlatformTruckAppointmentController;
import com.baiyue.firstleg.oceanbooking.controller.PlatformOceanBookingController;
import org.junit.jupiter.api.Test;
import org.springframework.core.io.ClassPathResource;
import org.springframework.security.access.prepost.PreAuthorize;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.Arrays;
import java.util.LinkedHashSet;
import java.util.Set;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import static org.assertj.core.api.Assertions.assertThat;
class Phase3BookingTruckPermissionSqlContractTest {
private static final Pattern AUTHORITY = Pattern.compile("hasAuthority\\('([^']+)'\\)");
private static final Pattern OCEAN_CODE = Pattern.compile("platform:firstleg:ocean-booking:[a-z-]+");
private static final Pattern TRUCK_CODE = Pattern.compile("platform:firstleg:truck-appointment:[a-z-]+");
private static final Set<String> OCEAN_PERMISSIONS = Set.of(
"platform:firstleg:ocean-booking:list",
"platform:firstleg:ocean-booking:view",
"platform:firstleg:ocean-booking:create",
"platform:firstleg:ocean-booking:edit",
"platform:firstleg:ocean-booking:submit",
"platform:firstleg:ocean-booking:confirm",
"platform:firstleg:ocean-booking:release",
"platform:firstleg:ocean-booking:cancel",
"platform:firstleg:ocean-booking:change",
"platform:firstleg:ocean-booking:allocate");
private static final Set<String> TRUCK_PERMISSIONS = Set.of(
"platform:firstleg:truck-appointment:list",
"platform:firstleg:truck-appointment:view",
"platform:firstleg:truck-appointment:create",
"platform:firstleg:truck-appointment:edit",
"platform:firstleg:truck-appointment:operate");
@Test
void controllerAuthoritiesExactlyMatchModuleAndUnifiedPermissionCodes() throws Exception {
assertThat(authorities(PlatformOceanBookingController.class)).isEqualTo(OCEAN_PERMISSIONS);
assertThat(authorities(PlatformTruckAppointmentController.class)).isEqualTo(TRUCK_PERMISSIONS);
String oceanModule = resource("db/phase3-ocean-booking-schema.sql");
String truckModule = resource("db/phase3-truck-appointment-schema.sql");
String unified = unifiedSql();
assertThat(codes(oceanModule, OCEAN_CODE)).isEqualTo(OCEAN_PERMISSIONS);
assertThat(codes(truckModule, TRUCK_CODE)).isEqualTo(TRUCK_PERMISSIONS);
assertThat(codes(unified, OCEAN_CODE)).isEqualTo(OCEAN_PERMISSIONS);
assertThat(codes(unified, TRUCK_CODE)).isEqualTo(TRUCK_PERMISSIONS);
}
@Test
void unifiedSqlLocksCanonicalIdsScopeParentsAndRoleGrants() throws Exception {
String sql = unifiedSql();
assertThat(sql)
.contains("UNIQUE KEY uk_permission_scope_code (tenant_id, app_code, permission_code)")
.contains("CREATE PROCEDURE assert_phase3_booking_truck_permission_ids_available()")
.contains("三期订舱/拖车权限迁移失败:5102140-5102154 ID 或权限码被其他语义占用")
.contains("(5102140,0,'PLATFORM','MENU',5102000,'platform:firstleg:ocean-booking:list'")
.contains("(5102149,0,'PLATFORM','BUTTON',5102140,'platform:firstleg:ocean-booking:allocate'")
.contains("(5102150,0,'PLATFORM','MENU',5102000,'platform:firstleg:truck-appointment:list'")
.contains("(5102154,0,'PLATFORM','BUTTON',5102150,'platform:firstleg:truck-appointment:operate'")
.contains("role.role_code IN ('PLATFORM_ADMIN','ORDER_OPERATOR')")
.contains("CREATE PROCEDURE assert_phase3_booking_truck_permissions()")
.contains("统一迁移失败:三期订舱/拖车 15 项权限映射不完整")
.contains("统一迁移失败:三期订舱/拖车角色授权不完整")
.contains("CALL assert_phase3_booking_truck_permissions()")
.contains("DROP PROCEDURE IF EXISTS assert_phase3_booking_truck_permissions;");
}
private static Set<String> authorities(Class<?> controller) {
Set<String> result = new LinkedHashSet<>();
Arrays.stream(controller.getDeclaredMethods())
.map(method -> method.getAnnotation(PreAuthorize.class))
.filter(annotation -> annotation != null)
.map(PreAuthorize::value)
.forEach(expression -> {
Matcher matcher = AUTHORITY.matcher(expression);
assertThat(matcher.find()).as(expression).isTrue();
result.add(matcher.group(1));
});
return result;
}
private static Set<String> codes(String sql, Pattern pattern) {
Set<String> result = new LinkedHashSet<>();
Matcher matcher = pattern.matcher(sql);
while (matcher.find()) {
result.add(matcher.group());
}
return result;
}
private static String resource(String path) throws Exception {
return new ClassPathResource(path).getContentAsString(StandardCharsets.UTF_8);
}
private static String unifiedSql() throws Exception {
Path path = Path.of("deploy/sql/头程数据库统一增量-MySQL8.sql");
if (!Files.exists(path)) {
path = Path.of("../deploy/sql/头程数据库统一增量-MySQL8.sql").normalize();
}
return Files.readString(path, StandardCharsets.UTF_8);
}
}
@@ -0,0 +1,41 @@
package com.baiyue.firstleg.repository;
import org.junit.jupiter.api.Test;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import static org.assertj.core.api.Assertions.assertThat;
class ServiceCenterUnifiedSqlContractTest {
@Test
void unifiedMigrationContainsServiceCenterSchemaAndGrants() throws Exception {
Path working = Path.of(System.getProperty("user.dir"));
Path path = working.resolve("deploy/sql/头程数据库统一增量-MySQL8.sql");
if (!Files.exists(path)) {
path = working.resolve("../deploy/sql/头程数据库统一增量-MySQL8.sql").normalize();
}
String sql = Files.readString(path, StandardCharsets.UTF_8);
assertThat(sql)
.contains("CREATE TABLE IF NOT EXISTS fl_ocean_booking")
.contains("CREATE TABLE IF NOT EXISTS fl_ocean_booking_party")
.contains("CREATE TABLE IF NOT EXISTS fl_ocean_booking_container_requirement")
.contains("CREATE TABLE IF NOT EXISTS fl_ocean_booking_container_allocation")
.contains("CREATE TABLE IF NOT EXISTS fl_ocean_booking_status_history")
.contains("CREATE TABLE IF NOT EXISTS fl_ocean_booking_operation")
.contains("CREATE TABLE IF NOT EXISTS fl_truck_appointment")
.contains("CREATE TABLE IF NOT EXISTS fl_truck_appointment_history")
.contains("CREATE TABLE IF NOT EXISTS fl_truck_appointment_attachment")
.contains("CREATE TABLE IF NOT EXISTS fl_truck_appointment_operation")
.contains("5102140")
.contains("5102149")
.contains("5102150")
.contains("5102154")
.contains("PLATFORM_ADMIN")
.contains("ORDER_OPERATOR")
.contains("assert_phase3_service_center_schema()")
.contains("CALL assert_phase3_service_center_schema();");
}
}
@@ -40,7 +40,7 @@ class TruckAppointmentServiceTest {
when(repository.find(1L)).thenReturn(e);
when(repository.transition(1L, "DRAFT", "PENDING_DISPATCH", 0)).thenReturn(1);
var result = service.action(1L, "submit", new ActionRequest(0, "提交"), "idem-1", "hash-1", 7L, "admin");
var result = service.action(1L, "SUBMIT", new ActionRequest(0, "提交"), "idem-1", "hash-1", 7L, "admin");
assertThat(result.getStatus()).isEqualTo(TruckAppointmentStatus.PENDING_DISPATCH);
assertThat(result.getVersion()).isEqualTo(1);
@@ -0,0 +1,727 @@
{
"marker": "20260808062035-760e776d",
"gateway": "http://8.163.30.128:9000",
"generatedAt": "2026-08-08T06:20:35.779Z",
"completedAt": "2026-08-08T06:20:38.048Z",
"success": false,
"fatal": null,
"testAccount": {
"username": "merchant_admin"
},
"expectedTerminalState": {
"processStatus": "WAIT_SPLIT",
"measureReviewStatus": "APPROVED",
"feeStatus": "CONFIRMED"
},
"resources": {
"tenantId": "10001",
"terminalInboundId": "2085909619958919170",
"terminalOrderId": "2085909608158265345",
"terminalInboundCode": "RK-2085909608158265345",
"terminalMeasurementId": "2085909625084358657",
"pendingOrderId": "2085962256093872130",
"foreignOrderId": "2085665774056833025",
"measurementImageFileIds": [],
"weightImageFileIds": []
},
"steps": [
{
"module": "phase2-session",
"name": "client login",
"passed": true,
"skipped": false,
"startedAt": "2026-08-08T06:20:35.781Z",
"durationMs": 412,
"http": 200,
"code": "200",
"message": "success",
"requestId": "client-phase2-2a854847-f135-4c48-9f38-4ebd134847cd, client-phase2-2a854847-f135-4c48-9f38-4ebd134847cd",
"traceId": "544367752d7e438d906928bd2f1c13ca",
"evidence": {
"username": "merchant_admin"
}
},
{
"module": "phase2-session",
"name": "load current enterprise profile",
"passed": true,
"skipped": false,
"startedAt": "2026-08-08T06:20:36.193Z",
"durationMs": 45,
"http": 200,
"code": "200",
"message": "success",
"requestId": "client-phase2-ba9836bc-1ee7-4169-80de-81619ea930f0, client-phase2-ba9836bc-1ee7-4169-80de-81619ea930f0",
"traceId": "ee177b1b0bbb47bb80a9d885024b6f1b",
"evidence": {
"tenantId": "10001",
"tenantName": "一期演示企业",
"status": "ACTIVE"
}
},
{
"module": "phase2-session",
"name": "client has order view permission",
"passed": true,
"skipped": false,
"startedAt": "2026-08-08T06:20:36.238Z",
"durationMs": 44,
"http": 200,
"code": "200",
"message": "success",
"requestId": "client-phase2-79e2fae4-9149-4907-969a-c9e432b09dbf, client-phase2-79e2fae4-9149-4907-969a-c9e432b09dbf",
"traceId": "db355c884ffc4878b5df63be012984f7",
"evidence": {
"permissionCount": 27
}
},
{
"module": "phase2-fixture",
"name": "platform fixture account login",
"passed": true,
"skipped": false,
"startedAt": "2026-08-08T06:20:36.283Z",
"durationMs": 342,
"http": 200,
"code": "200",
"message": "success",
"requestId": "client-phase2-4076b5e9-01aa-4ae6-b5a7-be519e32e59a, client-phase2-4076b5e9-01aa-4ae6-b5a7-be519e32e59a",
"traceId": "8206fa371a274e19b4ed37aa57d00d6f",
"evidence": {
"username": "platform_admin"
}
},
{
"module": "phase2-fixture",
"name": "discover current enterprise inbound fixtures",
"passed": true,
"skipped": false,
"startedAt": "2026-08-08T06:20:36.625Z",
"durationMs": 85,
"http": 200,
"code": "200",
"message": "success",
"requestId": "client-phase2-7428b82e-bf10-4c31-a118-5c14c766ade0, client-phase2-7428b82e-bf10-4c31-a118-5c14c766ade0",
"traceId": "f24ae5dcddb446d1b88dd32c5faee5d6",
"evidence": {
"total": "38",
"statusCounts": {
"INBOUNDED|APPROVED|UNPRICED": 8,
"MEASURING|NOT_SUBMITTED|UNPRICED": 14,
"WAIT_MEASURE|NOT_SUBMITTED|UNPRICED": 12,
"WAIT_SPLIT|APPROVED|UNPRICED": 1,
"WAIT_SPLIT|APPROVED|CONFIRMED": 2,
"WAIT_SPLIT|APPROVED|CALCULATED": 1
}
}
},
{
"module": "phase2-fixture",
"name": "terminal state filters find selected inbound",
"passed": true,
"skipped": false,
"startedAt": "2026-08-08T06:20:36.711Z",
"durationMs": 48,
"http": 200,
"code": "200",
"message": "success",
"requestId": "client-phase2-9ef014fe-ef45-4dbb-94b7-d931fc10b47d, client-phase2-9ef014fe-ef45-4dbb-94b7-d931fc10b47d",
"traceId": "4c52bbc80b4f4ae49022d074eff854a5",
"evidence": {
"total": "2",
"inboundId": "2085909619958919170"
}
},
{
"module": "phase2-fixture",
"name": "load platform terminal inbound reference detail",
"passed": true,
"skipped": false,
"startedAt": "2026-08-08T06:20:36.759Z",
"durationMs": 38,
"http": 200,
"code": "200",
"message": "success",
"requestId": "client-phase2-536ff03a-2233-405a-8ac0-28cafa13e7fc, client-phase2-536ff03a-2233-405a-8ac0-28cafa13e7fc",
"traceId": "b6fc5ba4288b4361b8d0a85566ab21bb",
"evidence": {
"inboundId": "2085909619958919170",
"orderId": "2085909608158265345",
"processStatus": "WAIT_SPLIT",
"measureReviewStatus": "APPROVED",
"feeStatus": "CONFIRMED"
}
},
{
"module": "phase2-measurement-fixture",
"name": "load four customer dimension weighting rules",
"passed": true,
"skipped": false,
"startedAt": "2026-08-08T06:20:36.797Z",
"durationMs": 40,
"http": 200,
"code": "200",
"message": "success",
"requestId": "client-phase2-92588486-fed5-41fb-9322-a67416fdb478, client-phase2-92588486-fed5-41fb-9322-a67416fdb478",
"traceId": "b225b9bcb92d46fd9502eef48596a4f7",
"evidence": {
"tenantId": "10001",
"rules": [
{
"boxType": "SMALL",
"inner": [
30,
20,
20
],
"edgeAdditionCm": 2,
"outer": [
34,
24,
24
],
"volumeCbm": 0.02,
"version": 0
},
{
"boxType": "MEDIUM",
"inner": [
40,
30,
30
],
"edgeAdditionCm": 2,
"outer": [
44,
34,
34
],
"volumeCbm": 0.051,
"version": 0
},
{
"boxType": "LARGE",
"inner": [
50,
40,
40
],
"edgeAdditionCm": 2,
"outer": [
54,
44,
44
],
"volumeCbm": 0.105,
"version": 0
},
{
"boxType": "EXTRA_LARGE",
"inner": [
60,
50,
50
],
"edgeAdditionCm": 2,
"outer": [
64,
54,
54
],
"volumeCbm": 0.187,
"version": 0
}
]
}
},
{
"module": "phase2-measurement-fixture",
"name": "load approved terminal measurement detail",
"passed": true,
"skipped": false,
"startedAt": "2026-08-08T06:20:36.837Z",
"durationMs": 38,
"http": 200,
"code": "200",
"message": "success",
"requestId": "client-phase2-fc3f4698-b49b-4f3f-b946-557fdc250803, client-phase2-fc3f4698-b49b-4f3f-b946-557fdc250803",
"traceId": "66371b88f5ba4511824b947163518393",
"evidence": {
"inboundId": "2085909619958919170",
"measurementId": "2085909625084358657",
"measurementStatus": "APPROVED",
"actualPackageCount": 4,
"actualGrossWeightKg": 1383,
"actualVolumeCbm": 0.1895,
"volumeWeightKg": 31.647,
"itemCount": 4,
"weightResultCount": 3
}
},
{
"module": "phase2-measurement-fixture",
"name": "carton measurements contain complete traceable data",
"passed": true,
"skipped": false,
"startedAt": "2026-08-08T06:20:36.875Z",
"durationMs": 0,
"http": null,
"code": null,
"message": null,
"requestId": null,
"traceId": null,
"evidence": {
"itemCount": 4
}
},
{
"module": "phase2-measurement-fixture",
"name": "carton volume and package aggregates are consistent",
"passed": true,
"skipped": false,
"startedAt": "2026-08-08T06:20:36.875Z",
"durationMs": 0,
"http": null,
"code": null,
"message": null,
"requestId": null,
"traceId": null,
"evidence": {
"actualPackageCount": 4,
"actualVolumeCbm": 0.1895,
"volumeWeightKg": 31.647
}
},
{
"module": "phase2-measurement-fixture",
"name": "confirmed weight results form an independent weight line",
"passed": true,
"skipped": false,
"startedAt": "2026-08-08T06:20:36.875Z",
"durationMs": 0,
"http": null,
"code": null,
"message": null,
"requestId": null,
"traceId": null,
"evidence": {
"actualGrossWeightKg": 1383,
"weightResultCount": 3
}
},
{
"module": "phase2-measurement-fixture",
"name": "carton billing volume uses current customer dimension weighting",
"passed": false,
"skipped": false,
"startedAt": "2026-08-08T06:20:36.876Z",
"durationMs": 0,
"http": null,
"code": null,
"message": null,
"requestId": null,
"traceId": null,
"evidence": {
"measurementId": "2085909625084358657",
"items": [
{
"itemNo": "B1",
"measured": [
10,
10,
10
],
"storedOuter": [
null,
null,
null
],
"unitVolumeCbm": 0.001,
"expected": {
"boxType": "SMALL",
"ruleVersion": 0,
"outer": [
34,
24,
24
],
"unitVolumeCbm": 0.02
}
},
{
"itemNo": "B2",
"measured": [
35,
25,
25
],
"storedOuter": [
null,
null,
null
],
"unitVolumeCbm": 0.0219,
"expected": {
"boxType": "MEDIUM",
"ruleVersion": 0,
"outer": [
44,
34,
34
],
"unitVolumeCbm": 0.051
}
},
{
"itemNo": "B3",
"measured": [
45,
35,
35
],
"storedOuter": [
null,
null,
null
],
"unitVolumeCbm": 0.0552,
"expected": {
"boxType": "LARGE",
"ruleVersion": 0,
"outer": [
54,
44,
44
],
"unitVolumeCbm": 0.105
}
},
{
"itemNo": "B4",
"measured": [
55,
45,
45
],
"storedOuter": [
null,
null,
null
],
"unitVolumeCbm": 0.1114,
"expected": {
"boxType": "EXTRA_LARGE",
"ruleVersion": 0,
"outer": [
64,
54,
54
],
"unitVolumeCbm": 0.187
}
}
]
},
"error": {
"name": "Error",
"message": "B1 weighted unit volume: expected=0.02 actual=0.001"
}
},
{
"module": "phase2-client-security",
"name": "client cannot read platform measurement workspace directly",
"passed": true,
"skipped": false,
"startedAt": "2026-08-08T06:20:36.876Z",
"durationMs": 25,
"http": 403,
"code": "A0403",
"message": "无权访问该资源",
"requestId": "client-phase2-9108c904-ee43-40c2-bc11-288455d4c191",
"traceId": null,
"evidence": {
"rejectionCode": "A0403"
}
},
{
"module": "phase2-client-security",
"name": "client cannot bypass billing publication to read platform image",
"passed": true,
"skipped": false,
"startedAt": "2026-08-08T06:20:36.901Z",
"durationMs": 31,
"http": 403,
"code": "A0403",
"message": "无权访问该资源",
"requestId": "client-phase2-1a11b1f0-afd4-4091-b52e-2a2fa1006fdf",
"traceId": null,
"evidence": {
"fileId": "2085909625048727553",
"rejectionCode": "A0403"
}
},
{
"module": "phase2-fixture",
"name": "discover foreign terminal fixture for isolation",
"passed": true,
"skipped": false,
"startedAt": "2026-08-08T06:20:36.932Z",
"durationMs": 113,
"http": 200,
"code": "200",
"message": "success",
"requestId": "client-phase2-46e3bf79-d804-409a-ad00-ebc36c50bd40, client-phase2-46e3bf79-d804-409a-ad00-ebc36c50bd40",
"traceId": "2983ab506e59449cb57e342ab58ccb20",
"evidence": {
"total": "278"
}
},
{
"module": "phase2-client-visibility",
"name": "unfinished inbound billing remains unavailable",
"passed": true,
"skipped": false,
"startedAt": "2026-08-08T06:20:37.045Z",
"durationMs": 43,
"http": 200,
"code": "A0404",
"message": "入库费用尚未由客服审核并发布",
"requestId": "client-phase2-c50771d6-4041-4eb1-984e-a4a0885bf432, client-phase2-c50771d6-4041-4eb1-984e-a4a0885bf432",
"traceId": "5bff6ee6f75242d9abdc7649c3202911",
"evidence": {
"orderId": "2085962256093872130",
"processStatus": "INBOUNDED",
"feeStatus": "UNPRICED",
"rejectionCode": "A0404"
}
},
{
"module": "phase2-client-visibility",
"name": "unknown order inbound billing is rejected",
"passed": true,
"skipped": false,
"startedAt": "2026-08-08T06:20:37.089Z",
"durationMs": 45,
"http": 200,
"code": "A0404",
"message": "订单尚未生成入库单",
"requestId": "client-phase2-4ab809c7-ff3b-43f8-a6a9-a1cb4fa2367d, client-phase2-4ab809c7-ff3b-43f8-a6a9-a1cb4fa2367d",
"traceId": "08541a78635746319916499da91df6a9",
"evidence": {
"orderId": 1,
"rejectionCode": "A0404"
}
},
{
"module": "phase2-client-billing",
"name": "terminal inbound billing is visible to client",
"passed": false,
"skipped": false,
"startedAt": "2026-08-08T06:20:37.134Z",
"durationMs": 84,
"http": 200,
"code": "A0404",
"message": "入库运费明细尚未发布",
"requestId": "client-phase2-0283d6e1-1ed2-4480-8a02-880459fb5f5c, client-phase2-0283d6e1-1ed2-4480-8a02-880459fb5f5c",
"traceId": "d6edff82e4834ec6ba483ff29b86e244",
"evidence": {
"rejectionCode": "A0404"
},
"error": {
"name": "Error",
"message": "business code: expected=\"200\" actual=\"A0404\""
}
},
{
"module": "phase2-client-billing",
"name": "terminal billing aggregate contract",
"passed": true,
"skipped": true,
"startedAt": "2026-08-08T06:20:37.219Z",
"durationMs": 0,
"http": null,
"code": null,
"message": "Terminal billing endpoint did not return a visible aggregate",
"requestId": null,
"traceId": null,
"evidence": null
},
{
"module": "phase2-client-billing",
"name": "published measurement summary matches approved platform snapshot",
"passed": true,
"skipped": true,
"startedAt": "2026-08-08T06:20:37.219Z",
"durationMs": 0,
"http": null,
"code": null,
"message": "Terminal billing endpoint did not return a visible aggregate",
"requestId": null,
"traceId": null,
"evidence": null
},
{
"module": "phase2-client-billing",
"name": "published carton measurements match platform snapshot",
"passed": true,
"skipped": true,
"startedAt": "2026-08-08T06:20:37.219Z",
"durationMs": 0,
"http": null,
"code": null,
"message": "Terminal billing endpoint did not return a visible aggregate",
"requestId": null,
"traceId": null,
"evidence": null
},
{
"module": "phase2-client-billing",
"name": "published carton volumes follow current weighting rules",
"passed": true,
"skipped": true,
"startedAt": "2026-08-08T06:20:37.219Z",
"durationMs": 0,
"http": null,
"code": null,
"message": "Terminal billing endpoint did not return a visible aggregate",
"requestId": null,
"traceId": null,
"evidence": null
},
{
"module": "phase2-client-billing",
"name": "published weight results match confirmed platform weights",
"passed": true,
"skipped": true,
"startedAt": "2026-08-08T06:20:37.219Z",
"durationMs": 0,
"http": null,
"code": null,
"message": "Terminal billing endpoint did not return a visible aggregate",
"requestId": null,
"traceId": null,
"evidence": null
},
{
"module": "phase2-client-billing",
"name": "weight and volume billing lines are independently consistent",
"passed": true,
"skipped": true,
"startedAt": "2026-08-08T06:20:37.219Z",
"durationMs": 0,
"http": null,
"code": null,
"message": "Terminal billing endpoint did not return a visible aggregate",
"requestId": null,
"traceId": null,
"evidence": null
},
{
"module": "phase2-client-billing",
"name": "published fee detail matches confirmed fee snapshot",
"passed": true,
"skipped": true,
"startedAt": "2026-08-08T06:20:37.219Z",
"durationMs": 0,
"http": null,
"code": null,
"message": "Terminal billing endpoint did not return a visible aggregate",
"requestId": null,
"traceId": null,
"evidence": null
},
{
"module": "phase2-client-billing",
"name": "client aggregate excludes platform internal collections",
"passed": true,
"skipped": true,
"startedAt": "2026-08-08T06:20:37.219Z",
"durationMs": 0,
"http": null,
"code": null,
"message": "Terminal billing endpoint did not return a visible aggregate",
"requestId": null,
"traceId": null,
"evidence": null
},
{
"module": "phase2-client-images",
"name": "published measurement and weight image access",
"passed": true,
"skipped": true,
"startedAt": "2026-08-08T06:20:37.219Z",
"durationMs": 0,
"http": null,
"code": null,
"message": "Terminal billing endpoint did not return a visible aggregate",
"requestId": null,
"traceId": null,
"evidence": null
},
{
"module": "phase2-unified-orders",
"name": "unified order list exposes phase-two terminal state",
"passed": false,
"skipped": false,
"startedAt": "2026-08-08T06:20:37.219Z",
"durationMs": 711,
"http": 200,
"code": "200",
"message": "success",
"requestId": "client-phase2-02fe53ec-c024-42b6-841b-22c145879272, client-phase2-02fe53ec-c024-42b6-841b-22c145879272",
"traceId": "75cd022c9f6f43e4b5595575753b3597",
"evidence": {
"orderId": "2085909608158265345",
"mainStatus": "WAIT_SPLIT",
"inboundId": "2085909619958919170",
"measurementReviewStatus": "APPROVED",
"actualGrossWeightKg": 1383,
"actualVolumeCbm": 0.1895
},
"error": {
"name": "Error",
"message": "unified main status: expected=\"INBOUNDED\" actual=\"WAIT_SPLIT\""
}
},
{
"module": "phase2-unified-orders",
"name": "unified order detail exposes phase-two summary and timeline",
"passed": false,
"skipped": false,
"startedAt": "2026-08-08T06:20:37.930Z",
"durationMs": 91,
"http": 200,
"code": "200",
"message": "success",
"requestId": "client-phase2-ad957e7f-9520-40cc-a1c9-30ec978f058e, client-phase2-ad957e7f-9520-40cc-a1c9-30ec978f058e",
"traceId": "f5f613e3459e427aac9f24e80baedec4",
"evidence": {
"orderId": "2085909608158265345",
"mainStatus": "WAIT_SPLIT",
"timelineCount": 5
},
"error": {
"name": "Error",
"message": "unified main status: expected=\"INBOUNDED\" actual=\"WAIT_SPLIT\""
}
},
{
"module": "phase2-isolation",
"name": "cross-enterprise terminal billing is rejected",
"passed": true,
"skipped": false,
"startedAt": "2026-08-08T06:20:38.021Z",
"durationMs": 27,
"http": 200,
"code": "A0404",
"message": "订单尚未生成入库单",
"requestId": "client-phase2-16f5e503-bb89-4b9d-8992-038d489dec0c, client-phase2-16f5e503-bb89-4b9d-8992-038d489dec0c",
"traceId": "30a61022096b48a299edf68a59e455a9",
"evidence": {
"orderId": "2085665774056833025",
"tenantId": "2085308606829346818",
"rejectionCode": "A0404"
}
}
]
}