Merge remote-tracking branch 'origin/test' into test
This commit is contained in:
@@ -1756,6 +1756,9 @@ CREATE TABLE fl_customs_container (
|
|||||||
completed_by bigint unsigned DEFAULT NULL COMMENT '完成报关操作人ID',
|
completed_by bigint unsigned DEFAULT NULL COMMENT '完成报关操作人ID',
|
||||||
completed_by_name varchar(64) DEFAULT NULL COMMENT '完成报关操作人名称快照',
|
completed_by_name varchar(64) DEFAULT NULL COMMENT '完成报关操作人名称快照',
|
||||||
completed_at datetime(3) DEFAULT NULL COMMENT '报关完成时间',
|
completed_at datetime(3) DEFAULT NULL COMMENT '报关完成时间',
|
||||||
|
broker_submitted_by bigint unsigned DEFAULT NULL COMMENT '发送报关行执行操作人ID',
|
||||||
|
broker_submitted_by_name varchar(64) DEFAULT NULL COMMENT '发送报关行执行操作人名称快照',
|
||||||
|
broker_submitted_at datetime(3) DEFAULT NULL COMMENT '发送报关行执行时间',
|
||||||
created_at datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '聚合创建时间',
|
created_at datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '聚合创建时间',
|
||||||
updated_at datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '聚合更新时间',
|
updated_at datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '聚合更新时间',
|
||||||
version int unsigned NOT NULL DEFAULT '0' COMMENT '乐观锁版本号',
|
version int unsigned NOT NULL DEFAULT '0' COMMENT '乐观锁版本号',
|
||||||
@@ -1824,7 +1827,26 @@ CREATE TABLE fl_customs_completion_document (
|
|||||||
PRIMARY KEY (id),
|
PRIMARY KEY (id),
|
||||||
UNIQUE KEY uk_customs_completion_source (source_document_id),
|
UNIQUE KEY uk_customs_completion_source (source_document_id),
|
||||||
CONSTRAINT fk_customs_completion_source FOREIGN KEY (source_document_id) REFERENCES fl_customs_customer_document (id)
|
CONSTRAINT fk_customs_completion_source FOREIGN KEY (source_document_id) REFERENCES fl_customs_customer_document (id)
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='报关完成文件当前版本';
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='报关行回执当前版本';
|
||||||
|
|
||||||
|
CREATE TABLE fl_customs_final_document (
|
||||||
|
id bigint unsigned NOT NULL COMMENT '真实报关完税文件ID',
|
||||||
|
source_document_id bigint unsigned NOT NULL COMMENT '一份冻结客户资料严格对应一份完税文件逻辑根',
|
||||||
|
file_id bigint unsigned NOT NULL COMMENT 'File服务文件ID',
|
||||||
|
file_name varchar(255) NOT NULL COMMENT '完税文件名称快照',
|
||||||
|
file_hash char(64) NOT NULL COMMENT '完税文件SHA-256摘要',
|
||||||
|
content_type varchar(128) DEFAULT NULL COMMENT '完税文件媒体类型',
|
||||||
|
size_bytes bigint unsigned NOT NULL COMMENT '完税文件大小(字节)',
|
||||||
|
uploaded_by bigint unsigned NOT NULL COMMENT '上传操作人ID',
|
||||||
|
uploaded_by_name varchar(64) DEFAULT NULL COMMENT '上传操作人名称快照',
|
||||||
|
uploaded_at datetime(3) NOT NULL COMMENT '上传时间',
|
||||||
|
version int unsigned NOT NULL DEFAULT '1' COMMENT '完税文件版本号',
|
||||||
|
created_at datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '完税文件创建时间',
|
||||||
|
updated_at datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '完税文件更新时间',
|
||||||
|
PRIMARY KEY (id),
|
||||||
|
UNIQUE KEY uk_customs_final_source (source_document_id),
|
||||||
|
CONSTRAINT fk_customs_final_source FOREIGN KEY (source_document_id) REFERENCES fl_customs_customer_document (id)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='真实报关完税文件当前版本';
|
||||||
|
|
||||||
CREATE TABLE fl_customs_completion_document_version (
|
CREATE TABLE fl_customs_completion_document_version (
|
||||||
id bigint unsigned NOT NULL COMMENT '完成文件版本记录ID',
|
id bigint unsigned NOT NULL COMMENT '完成文件版本记录ID',
|
||||||
@@ -3316,6 +3338,7 @@ CREATE TABLE fl_mojia_measurement_receipt (
|
|||||||
tenant_id bigint unsigned NOT NULL COMMENT '客户ID和数据隔离键',
|
tenant_id bigint unsigned NOT NULL COMMENT '客户ID和数据隔离键',
|
||||||
subject_id bigint unsigned NOT NULL COMMENT '设备应用操作主体ID',
|
subject_id bigint unsigned NOT NULL COMMENT '设备应用操作主体ID',
|
||||||
box_code varchar(128) NOT NULL COMMENT '设备扫描的原始箱码或不带#的SKU',
|
box_code varchar(128) NOT NULL COMMENT '设备扫描的原始箱码或不带#的SKU',
|
||||||
|
inbound_code varchar(64) DEFAULT NULL COMMENT '箱码命中后的权威入仓码;SKU记录为空',
|
||||||
measurement_kind varchar(16) NOT NULL DEFAULT 'BOX_LABEL' COMMENT '测量分流:BOX_LABEL箱唛;SKU一件代发SKU',
|
measurement_kind varchar(16) NOT NULL DEFAULT 'BOX_LABEL' COMMENT '测量分流:BOX_LABEL箱唛;SKU一件代发SKU',
|
||||||
scanned_shipment_id varchar(64) DEFAULT NULL COMMENT '设备从Amazon FBA箱标实扫的Shipment ID',
|
scanned_shipment_id varchar(64) DEFAULT NULL COMMENT '设备从Amazon FBA箱标实扫的Shipment ID',
|
||||||
length_cm decimal(12,3) NOT NULL COMMENT '设备实测长度cm',
|
length_cm decimal(12,3) NOT NULL COMMENT '设备实测长度cm',
|
||||||
@@ -6121,9 +6144,9 @@ INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id,
|
|||||||
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, created_at, updated_at, deleted) VALUES (5102127,0,'PLATFORM','BUTTON',5102030,'platform:firstleg:inbound:delivery:complete','完成派送',NULL,NULL,200,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0);
|
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, created_at, updated_at, deleted) VALUES (5102127,0,'PLATFORM','BUTTON',5102030,'platform:firstleg:inbound:delivery:complete','完成派送',NULL,NULL,200,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0);
|
||||||
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, created_at, updated_at, deleted) VALUES (5102128,0,'PLATFORM','BUTTON',5102030,'platform:firstleg:inbound:picking:edit','执行拣货',NULL,NULL,210,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0);
|
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, created_at, updated_at, deleted) VALUES (5102128,0,'PLATFORM','BUTTON',5102030,'platform:firstleg:inbound:picking:edit','执行拣货',NULL,NULL,210,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0);
|
||||||
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, created_at, updated_at, deleted) VALUES (5102129,0,'PLATFORM','BUTTON',5102030,'platform:firstleg:inbound:picking:view','查看拣货任务',NULL,NULL,220,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0);
|
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, created_at, updated_at, deleted) VALUES (5102129,0,'PLATFORM','BUTTON',5102030,'platform:firstleg:inbound:picking:view','查看拣货任务',NULL,NULL,220,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0);
|
||||||
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, created_at, updated_at, deleted) VALUES (5102131,0,'PLATFORM','MENU',5103000,'platform:mojia-sku-sync:list','一件代发SKU同步','/general/mojia-sku-sync','GET',130,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0);
|
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, created_at, updated_at, deleted) VALUES (5102131,0,'PLATFORM','MENU',5103000,'platform:mojia-sku-sync:list','称重机器','/general/mojia-sku-sync','GET',130,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0);
|
||||||
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, created_at, updated_at, deleted) VALUES (5102132,0,'PLATFORM','BUTTON',5102131,'platform:mojia-sku-sync:edit','编辑一件代发SKU测量数据',NULL,'PUT',10,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0);
|
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, created_at, updated_at, deleted) VALUES (5102132,0,'PLATFORM','BUTTON',5102131,'platform:mojia-sku-sync:edit','编辑SKU测量数据',NULL,'PUT',10,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0);
|
||||||
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, created_at, updated_at, deleted) VALUES (5102133,0,'PLATFORM','BUTTON',5102131,'platform:mojia-sku-sync:retry','重新推送一件代发SKU',NULL,'POST',20,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0);
|
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, created_at, updated_at, deleted) VALUES (5102133,0,'PLATFORM','BUTTON',5102131,'platform:mojia-sku-sync:retry','重新推送SKU测量',NULL,'POST',20,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0);
|
||||||
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, created_at, updated_at, deleted) VALUES (5102140,0,'PLATFORM','MENU',5102000,'platform:firstleg:ocean-booking:list','订舱管理','/first-leg/ocean-bookings','GET',80,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0);
|
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, created_at, updated_at, deleted) VALUES (5102140,0,'PLATFORM','MENU',5102000,'platform:firstleg:ocean-booking:list','订舱管理','/first-leg/ocean-bookings','GET',80,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0);
|
||||||
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, created_at, updated_at, deleted) VALUES (5102142,0,'PLATFORM','BUTTON',5102140,'platform:firstleg:ocean-booking:create','创建订舱',NULL,'POST',20,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0);
|
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, created_at, updated_at, deleted) VALUES (5102142,0,'PLATFORM','BUTTON',5102140,'platform:firstleg:ocean-booking:create','创建订舱',NULL,'POST',20,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0);
|
||||||
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, created_at, updated_at, deleted) VALUES (5102146,0,'PLATFORM','BUTTON',5102140,'platform:firstleg:ocean-booking:release','确认放舱',NULL,'POST',60,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0);
|
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, created_at, updated_at, deleted) VALUES (5102146,0,'PLATFORM','BUTTON',5102140,'platform:firstleg:ocean-booking:release','确认放舱',NULL,'POST',60,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0);
|
||||||
@@ -7755,4 +7778,276 @@ USE baiyue_finance;
|
|||||||
-- 系统基线 fin_subject:1 行。
|
-- 系统基线 fin_subject:1 行。
|
||||||
INSERT INTO fin_subject (id, subject_code, subject_name, status, version, created_by, updated_by, created_at, updated_at) VALUES (1,'PLATFORM_DEFAULT','平台','ACTIVE',0,0,0,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3));
|
INSERT INTO fin_subject (id, subject_code, subject_name, status, version, created_by, updated_by, created_at, updated_at) VALUES (1,'PLATFORM_DEFAULT','平台','ACTIVE',0,0,0,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3));
|
||||||
|
|
||||||
SELECT 'PASS' AS initialization_status, 'CN' AS region, 210 AS initialized_tables;
|
-- phase4-sailing-calendar-schema.sql
|
||||||
|
|
||||||
|
SET NAMES utf8mb4;
|
||||||
|
USE baiyue_firstleg;
|
||||||
|
|
||||||
|
-- 船期计划日历首版基线。与 fl_port_schedule(港务船期观测)分离。
|
||||||
|
CREATE TABLE fl_sailing_channel (
|
||||||
|
id BIGINT NOT NULL,
|
||||||
|
channel_code VARCHAR(64) NOT NULL,
|
||||||
|
channel_name VARCHAR(128) NOT NULL,
|
||||||
|
source_type VARCHAR(24) NOT NULL,
|
||||||
|
mapped_channel_code VARCHAR(64) NULL,
|
||||||
|
default_departure_port_code VARCHAR(32) NULL,
|
||||||
|
default_destination_port_code VARCHAR(32) NULL,
|
||||||
|
default_cutoff_days TINYINT NOT NULL DEFAULT 3,
|
||||||
|
status VARCHAR(16) NOT NULL DEFAULT 'ACTIVE',
|
||||||
|
remark VARCHAR(1000) NULL,
|
||||||
|
created_by BIGINT NOT NULL,
|
||||||
|
created_by_name VARCHAR(128) NULL,
|
||||||
|
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||||
|
updated_by BIGINT NOT NULL,
|
||||||
|
updated_by_name VARCHAR(128) NULL,
|
||||||
|
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
|
||||||
|
version INT NOT NULL DEFAULT 0,
|
||||||
|
PRIMARY KEY (id),
|
||||||
|
UNIQUE KEY uk_fl_sailing_channel_code (channel_code),
|
||||||
|
KEY idx_fl_sailing_channel_status (status)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
CREATE TABLE fl_sailing_template (
|
||||||
|
id BIGINT NOT NULL,
|
||||||
|
template_no VARCHAR(64) NOT NULL,
|
||||||
|
template_name VARCHAR(128) NOT NULL,
|
||||||
|
channel_id BIGINT NOT NULL,
|
||||||
|
channel_code_snapshot VARCHAR(64) NOT NULL,
|
||||||
|
channel_name_snapshot VARCHAR(128) NOT NULL,
|
||||||
|
warehouse_id BIGINT NOT NULL,
|
||||||
|
warehouse_name_snapshot VARCHAR(128) NULL,
|
||||||
|
departure_port_code VARCHAR(32) NOT NULL,
|
||||||
|
destination_port_code VARCHAR(32) NOT NULL,
|
||||||
|
weekday TINYINT NOT NULL,
|
||||||
|
weekday_mask TINYINT NOT NULL DEFAULT 1,
|
||||||
|
etd_local_time TIME NOT NULL,
|
||||||
|
cutoff_days TINYINT NOT NULL DEFAULT 3,
|
||||||
|
cutoff_local_time TIME NOT NULL DEFAULT '18:00:00',
|
||||||
|
valid_from DATE NOT NULL,
|
||||||
|
valid_to DATE NULL,
|
||||||
|
voyage_rule VARCHAR(64) NULL,
|
||||||
|
vessel_name VARCHAR(128) NULL,
|
||||||
|
status VARCHAR(16) NOT NULL DEFAULT 'ACTIVE',
|
||||||
|
remark VARCHAR(1000) NULL,
|
||||||
|
created_by BIGINT NOT NULL,
|
||||||
|
created_by_name VARCHAR(128) NULL,
|
||||||
|
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||||
|
updated_by BIGINT NOT NULL,
|
||||||
|
updated_by_name VARCHAR(128) NULL,
|
||||||
|
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
|
||||||
|
version INT NOT NULL DEFAULT 0,
|
||||||
|
PRIMARY KEY (id),
|
||||||
|
UNIQUE KEY uk_fl_sailing_template_no (template_no),
|
||||||
|
KEY idx_fl_sailing_template_filter (warehouse_id,channel_id,status,weekday),
|
||||||
|
CONSTRAINT ck_fl_sailing_template_weekday CHECK (weekday BETWEEN 1 AND 7),
|
||||||
|
CONSTRAINT ck_fl_sailing_template_cutoff_days CHECK (cutoff_days BETWEEN 0 AND 30)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
CREATE TABLE fl_sailing (
|
||||||
|
id BIGINT NOT NULL,
|
||||||
|
sailing_no VARCHAR(64) NOT NULL,
|
||||||
|
template_id BIGINT NULL,
|
||||||
|
channel_id BIGINT NOT NULL,
|
||||||
|
channel_code_snapshot VARCHAR(64) NOT NULL,
|
||||||
|
channel_name_snapshot VARCHAR(128) NOT NULL,
|
||||||
|
warehouse_id BIGINT NOT NULL,
|
||||||
|
warehouse_name_snapshot VARCHAR(128) NULL,
|
||||||
|
departure_port_code VARCHAR(32) NOT NULL,
|
||||||
|
destination_port_code VARCHAR(32) NOT NULL,
|
||||||
|
vessel_name VARCHAR(128) NOT NULL,
|
||||||
|
voyage_no VARCHAR(64) NOT NULL,
|
||||||
|
etd_at DATETIME(3) NOT NULL,
|
||||||
|
warehouse_cutoff_at DATETIME(3) NOT NULL,
|
||||||
|
eta_at DATETIME(3) NULL,
|
||||||
|
status VARCHAR(16) NOT NULL DEFAULT 'OPEN',
|
||||||
|
source_type VARCHAR(24) NOT NULL DEFAULT 'MANUAL',
|
||||||
|
generated_key VARCHAR(192) NULL,
|
||||||
|
planned_capacity_cbm DECIMAL(14,3) NULL,
|
||||||
|
remark VARCHAR(1000) NULL,
|
||||||
|
created_by BIGINT NOT NULL,
|
||||||
|
created_by_name VARCHAR(128) NULL,
|
||||||
|
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||||
|
updated_by BIGINT NOT NULL,
|
||||||
|
updated_by_name VARCHAR(128) NULL,
|
||||||
|
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
|
||||||
|
version INT NOT NULL DEFAULT 0,
|
||||||
|
PRIMARY KEY (id),
|
||||||
|
UNIQUE KEY uk_fl_sailing_no (sailing_no),
|
||||||
|
UNIQUE KEY uk_fl_sailing_generated_key (generated_key),
|
||||||
|
UNIQUE KEY uk_fl_sailing_business (warehouse_id,channel_code_snapshot,departure_port_code,destination_port_code,etd_at,voyage_no),
|
||||||
|
KEY idx_fl_sailing_calendar (etd_at,warehouse_id,channel_id,status),
|
||||||
|
KEY idx_fl_sailing_route (departure_port_code,destination_port_code),
|
||||||
|
CONSTRAINT ck_fl_sailing_status CHECK (status IN ('DRAFT','OPEN','CLOSED','DEPARTED','CANCELLED','COMPLETED'))
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
CREATE TABLE fl_sailing_history (
|
||||||
|
id BIGINT NOT NULL,
|
||||||
|
sailing_id BIGINT NOT NULL,
|
||||||
|
action VARCHAR(32) NOT NULL,
|
||||||
|
from_status VARCHAR(16) NULL,
|
||||||
|
to_status VARCHAR(16) NULL,
|
||||||
|
reason VARCHAR(1000) NULL,
|
||||||
|
operator_id BIGINT NULL,
|
||||||
|
operator_name VARCHAR(128) NULL,
|
||||||
|
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||||
|
PRIMARY KEY (id),
|
||||||
|
KEY idx_fl_sailing_history_sailing (sailing_id,created_at,id)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
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
|
||||||
|
(5102206,0,'PLATFORM','MENU',5102000,'platform:firstleg:sailing-calendar:list','船期日历','/sailing-calendar','GET',125,'ACTIVE',1,0),
|
||||||
|
(5102207,0,'PLATFORM','BUTTON',5102206,'platform:firstleg:sailing-calendar:create','新增船期','/sailing-calendar','POST',10,'ACTIVE',1,0),
|
||||||
|
(5102208,0,'PLATFORM','BUTTON',5102206,'platform:firstleg:sailing-calendar:edit','编辑船期','/sailing-calendar','PUT',20,'ACTIVE',1,0),
|
||||||
|
(5102209,0,'PLATFORM','BUTTON',5102206,'platform:firstleg:sailing-template:manage','管理渠道与周模板','/sailing-calendar','POST',30,'ACTIVE',1,0),
|
||||||
|
(5102210,0,'PLATFORM','BUTTON',5102206,'platform:firstleg:sailing-booking:link','关联订舱','/sailing-calendar','POST',40,'ACTIVE',1,0),
|
||||||
|
(5102211,0,'PLATFORM','BUTTON',5102206,'platform:firstleg:sailing-volume:forecast-manage','维护货量预报','/sailing-calendar','POST',50,'ACTIVE',1,0),
|
||||||
|
(5102212,0,'PLATFORM','BUTTON',5102206,'platform:firstleg:sailing-volume:exception-reassign','异常重新安排','/sailing-calendar','POST',60,'ACTIVE',1,0);
|
||||||
|
|
||||||
|
INSERT INTO iam_role_permission(role_id,permission_id,tenant_id)
|
||||||
|
SELECT r.id,p.id,0 FROM iam_role r JOIN iam_permission p
|
||||||
|
ON p.tenant_id=0 AND p.app_code='PLATFORM'
|
||||||
|
AND p.permission_code IN (
|
||||||
|
'platform:firstleg:sailing-calendar:list',
|
||||||
|
'platform:firstleg:sailing-calendar:create',
|
||||||
|
'platform:firstleg:sailing-calendar:edit',
|
||||||
|
'platform:firstleg:sailing-template:manage',
|
||||||
|
'platform:firstleg:sailing-booking:link',
|
||||||
|
'platform:firstleg:sailing-volume:forecast-manage',
|
||||||
|
'platform:firstleg:sailing-volume:exception-reassign'
|
||||||
|
)
|
||||||
|
WHERE r.tenant_id=0 AND r.role_code IN ('PLATFORM_ADMIN','ORDER_OPERATOR');
|
||||||
|
|
||||||
|
-- phase4-sailing-facts-schema.sql
|
||||||
|
|
||||||
|
SET NAMES utf8mb4;
|
||||||
|
USE baiyue_firstleg;
|
||||||
|
|
||||||
|
/*
|
||||||
|
* 船期日历的事实契约。
|
||||||
|
*
|
||||||
|
* 这些表不修改入库单或海运订舱聚合:入库单仍是履约主数据,订舱仍是
|
||||||
|
* RELEASED/DRAFT 聚合。船期日历只消费这里的稳定事实和审计版本。
|
||||||
|
*/
|
||||||
|
|
||||||
|
CREATE TABLE fl_sailing_receipt_batch_fact (
|
||||||
|
id BIGINT UNSIGNED NOT NULL COMMENT '稳定收货批次事实ID(receiptBatchId)',
|
||||||
|
tenant_id BIGINT UNSIGNED NOT NULL COMMENT '客户隔离键,继承入库事实',
|
||||||
|
receipt_batch_key VARCHAR(64) NOT NULL COMMENT '同一物理收货批次的不可变业务键',
|
||||||
|
fact_version INT UNSIGNED NOT NULL DEFAULT 1 COMMENT '同一批次复测/冲正版本',
|
||||||
|
inbound_id BIGINT UNSIGNED NOT NULL COMMENT '入库单ID',
|
||||||
|
measurement_id BIGINT UNSIGNED NULL COMMENT '最终审核测量批次ID',
|
||||||
|
receiving_warehouse_id BIGINT UNSIGNED NOT NULL COMMENT '实际收货仓ID',
|
||||||
|
channel_code VARCHAR(64) NULL COMMENT '收货渠道编码快照',
|
||||||
|
channel_name VARCHAR(128) NULL COMMENT '收货渠道名称快照',
|
||||||
|
departure_port_code VARCHAR(32) NULL COMMENT '起运港编码快照',
|
||||||
|
destination_port_code VARCHAR(32) NULL COMMENT '目的港编码快照',
|
||||||
|
received_at DATETIME(3) NOT NULL COMMENT '实际收货完成时间,不使用入库单inbounded_at替代',
|
||||||
|
actual_volume_cbm DECIMAL(18,4) NOT NULL COMMENT '最终审核实测体积m3',
|
||||||
|
fact_status VARCHAR(24) NOT NULL DEFAULT 'APPROVED' COMMENT '事实状态:APPROVED/REVERSED/VOIDED/SUPERSEDED',
|
||||||
|
reversal_of_fact_id BIGINT UNSIGNED NULL COMMENT '冲正所替代的事实ID',
|
||||||
|
source_type VARCHAR(32) NOT NULL DEFAULT 'FIRSTLEG_RECEIPT' COMMENT '事实来源',
|
||||||
|
created_by BIGINT UNSIGNED NULL COMMENT '写入人或系统主体',
|
||||||
|
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_sailing_receipt_batch_version (tenant_id, receipt_batch_key, fact_version),
|
||||||
|
KEY idx_sailing_receipt_batch_current (tenant_id, receipt_batch_key, fact_status, fact_version),
|
||||||
|
KEY idx_sailing_receipt_match (tenant_id, receiving_warehouse_id, channel_code, received_at, id),
|
||||||
|
KEY idx_sailing_receipt_inbound (tenant_id, inbound_id, received_at, id),
|
||||||
|
CONSTRAINT chk_sailing_receipt_volume CHECK (actual_volume_cbm > 0),
|
||||||
|
CONSTRAINT chk_sailing_receipt_status CHECK (fact_status IN ('APPROVED','REVERSED','VOIDED','SUPERSEDED'))
|
||||||
|
) ENGINE=InnoDB COMMENT='船期日历稳定收货批次实测事实及版本历史';
|
||||||
|
|
||||||
|
CREATE TABLE fl_sailing_inbound_volume_forecast (
|
||||||
|
id BIGINT UNSIGNED NOT NULL COMMENT '货量预报事实ID(forecastId)',
|
||||||
|
tenant_id BIGINT UNSIGNED NOT NULL COMMENT '客户隔离键,继承入库事实',
|
||||||
|
forecast_key VARCHAR(64) NOT NULL COMMENT '同一未收货对象的不可变业务键',
|
||||||
|
fact_version INT UNSIGNED NOT NULL DEFAULT 1 COMMENT '预报修订版本',
|
||||||
|
inbound_id BIGINT UNSIGNED NOT NULL COMMENT '入库单ID',
|
||||||
|
receiving_warehouse_id BIGINT UNSIGNED NOT NULL COMMENT '预计收货仓ID',
|
||||||
|
channel_code VARCHAR(64) NULL COMMENT '预计渠道编码快照',
|
||||||
|
channel_name VARCHAR(128) NULL COMMENT '预计渠道名称快照',
|
||||||
|
departure_port_code VARCHAR(32) NULL COMMENT '预计起运港编码快照',
|
||||||
|
destination_port_code VARCHAR(32) NULL COMMENT '预计目的港编码快照',
|
||||||
|
expected_arrival_at DATETIME(3) NOT NULL COMMENT '预计到仓时间',
|
||||||
|
forecast_volume_cbm DECIMAL(18,4) NOT NULL COMMENT '未收货预计体积m3,不得使用申报体积代替',
|
||||||
|
source_type VARCHAR(32) NOT NULL COMMENT '预报来源,例如OPERATIONS/UPSTREAM_MEASUREMENT;不得使用客户申报体积冒充预报',
|
||||||
|
fact_status VARCHAR(24) NOT NULL DEFAULT 'VALID' COMMENT '事实状态:VALID/SUPERSEDED/VOIDED/CONSUMED',
|
||||||
|
effective_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '本版本生效时间',
|
||||||
|
created_by BIGINT UNSIGNED NULL COMMENT '写入人或系统主体',
|
||||||
|
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_sailing_forecast_version (tenant_id, forecast_key, fact_version),
|
||||||
|
KEY idx_sailing_forecast_current (tenant_id, forecast_key, fact_status, fact_version),
|
||||||
|
KEY idx_sailing_forecast_match (tenant_id, receiving_warehouse_id, channel_code, expected_arrival_at, id),
|
||||||
|
KEY idx_sailing_forecast_inbound (tenant_id, inbound_id, expected_arrival_at, id),
|
||||||
|
CONSTRAINT chk_sailing_forecast_volume CHECK (forecast_volume_cbm > 0),
|
||||||
|
CONSTRAINT chk_sailing_forecast_status CHECK (fact_status IN ('VALID','SUPERSEDED','VOIDED','CONSUMED'))
|
||||||
|
) ENGINE=InnoDB COMMENT='船期日历未收货量预报事实及版本历史';
|
||||||
|
|
||||||
|
CREATE TABLE fl_sailing_assignment (
|
||||||
|
id BIGINT UNSIGNED NOT NULL COMMENT '归班事实ID',
|
||||||
|
tenant_id BIGINT UNSIGNED NOT NULL COMMENT '客户隔离键',
|
||||||
|
sailing_id BIGINT UNSIGNED NULL COMMENT '船期实例ID,UNMATCHED为空',
|
||||||
|
origin_sailing_id BIGINT UNSIGNED NULL COMMENT '异常重排/取消前的原船期实例ID',
|
||||||
|
inbound_id BIGINT UNSIGNED NULL COMMENT '归班对象入库单ID',
|
||||||
|
inbound_code VARCHAR(64) NULL COMMENT '入仓码快照,仅用于展示',
|
||||||
|
receipt_batch_id BIGINT UNSIGNED NULL COMMENT '收货批次事实ID',
|
||||||
|
forecast_id BIGINT UNSIGNED NULL COMMENT '货量预报事实ID',
|
||||||
|
assignment_source VARCHAR(24) NOT NULL DEFAULT 'AUTO' COMMENT '归班来源:AUTO/MANUAL/ROLLOVER',
|
||||||
|
assignment_version INT UNSIGNED NOT NULL COMMENT '同一对象的归班版本',
|
||||||
|
match_status VARCHAR(24) NOT NULL COMMENT 'FORECAST_MATCHED/RECEIVED/LATE/UNMATCHED/SUPERSEDED',
|
||||||
|
volume_cbm DECIMAL(18,4) NULL COMMENT '已收实测体积',
|
||||||
|
forecast_volume_cbm DECIMAL(18,4) NULL COMMENT '未收有效预计体积',
|
||||||
|
route_source VARCHAR(32) NULL COMMENT '航线来源:INBOUND_FACT/FORECAST_FACT/CHANNEL_DEFAULT',
|
||||||
|
volume_source VARCHAR(32) NULL COMMENT 'MEASURED_ACTUAL或预报来源',
|
||||||
|
rule_version VARCHAR(64) NOT NULL COMMENT '自动归班规则版本',
|
||||||
|
matched_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||||
|
superseded_at DATETIME(3) NULL,
|
||||||
|
locked_at DATETIME(3) NULL COMMENT '截止/订舱/关闭后冻结时间',
|
||||||
|
lock_reason VARCHAR(32) NULL COMMENT 'CUTOFF/BOOKING_CONFIRMED/CLOSED',
|
||||||
|
operator_id BIGINT UNSIGNED NULL,
|
||||||
|
reason VARCHAR(500) NULL,
|
||||||
|
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||||
|
PRIMARY KEY (id),
|
||||||
|
KEY idx_sailing_assignment_current (tenant_id, sailing_id, match_status, matched_at, id),
|
||||||
|
KEY idx_sailing_assignment_origin (tenant_id, origin_sailing_id, match_status, id),
|
||||||
|
KEY idx_sailing_assignment_receipt (tenant_id, receipt_batch_id, assignment_version, id),
|
||||||
|
KEY idx_sailing_assignment_forecast (tenant_id, forecast_id, assignment_version, id),
|
||||||
|
KEY idx_sailing_assignment_inbound (tenant_id, inbound_id, match_status, id),
|
||||||
|
CONSTRAINT chk_sailing_assignment_status CHECK (match_status IN ('FORECAST_MATCHED','RECEIVED','LATE','UNMATCHED','SUPERSEDED')),
|
||||||
|
CONSTRAINT chk_sailing_assignment_source CHECK (assignment_source IN ('AUTO','MANUAL','ROLLOVER'))
|
||||||
|
) ENGINE=InnoDB COMMENT='船期日历自动归班事实及不可变版本历史';
|
||||||
|
|
||||||
|
CREATE TABLE fl_sailing_booking_link (
|
||||||
|
id BIGINT UNSIGNED NOT NULL COMMENT '船期-订舱关联ID',
|
||||||
|
tenant_id BIGINT UNSIGNED NOT NULL COMMENT '平台关联隔离键',
|
||||||
|
sailing_id BIGINT UNSIGNED NOT NULL COMMENT '计划船期实例ID',
|
||||||
|
ocean_booking_id BIGINT UNSIGNED NOT NULL COMMENT '海运订舱ID',
|
||||||
|
link_status VARCHAR(24) NOT NULL DEFAULT 'ACTIVE' COMMENT '关联状态:ACTIVE/REVOKED',
|
||||||
|
active_booking_guard TINYINT NULL COMMENT 'ACTIVE为1,REVOKED为空,用于唯一约束',
|
||||||
|
booking_status_snapshot VARCHAR(24) NOT NULL COMMENT '关联时订舱状态快照;只有RELEASED可建立',
|
||||||
|
warehouse_id BIGINT UNSIGNED NULL COMMENT '关联仓库快照',
|
||||||
|
channel_code VARCHAR(64) NULL COMMENT '关联渠道快照',
|
||||||
|
departure_port_code VARCHAR(32) NULL COMMENT '关联起运港快照',
|
||||||
|
destination_port_code VARCHAR(32) NULL COMMENT '关联目的港快照',
|
||||||
|
linked_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||||
|
revoked_at DATETIME(3) NULL,
|
||||||
|
operator_id BIGINT UNSIGNED NULL,
|
||||||
|
operator_name VARCHAR(64) NULL,
|
||||||
|
reason VARCHAR(500) NULL,
|
||||||
|
PRIMARY KEY (id),
|
||||||
|
UNIQUE KEY uk_sailing_booking_active (ocean_booking_id, active_booking_guard),
|
||||||
|
KEY idx_sailing_booking_link (sailing_id, link_status, ocean_booking_id),
|
||||||
|
KEY idx_sailing_booking_booking (ocean_booking_id, link_status, id),
|
||||||
|
CONSTRAINT chk_sailing_booking_link_status CHECK (link_status IN ('ACTIVE','REVOKED')),
|
||||||
|
CONSTRAINT chk_sailing_booking_link_guard CHECK ((link_status='ACTIVE' AND active_booking_guard=1) OR (link_status='REVOKED' AND active_booking_guard IS NULL)),
|
||||||
|
CONSTRAINT chk_sailing_booking_released CHECK (booking_status_snapshot='RELEASED')
|
||||||
|
) ENGINE=InnoDB COMMENT='船期计划与已放舱订舱的最小关联及历史';
|
||||||
|
|
||||||
|
SELECT 'PASS' AS initialization_status, 'CN' AS region, 219 AS initialized_tables;
|
||||||
|
|||||||
+1
-1
@@ -19,7 +19,7 @@
|
|||||||
| 海外异常订单证据 | 通用内部业务文件入口;固定 `businessType=OVERSEAS_EXCEPTION_EVIDENCE`、`tenantId=异常订单所属客户ID`、`businessId=异常订单ID`,File 只校验文件复合归属,异常状态、完成门禁和证据引用归 Lastleg |
|
| 海外异常订单证据 | 通用内部业务文件入口;固定 `businessType=OVERSEAS_EXCEPTION_EVIDENCE`、`tenantId=异常订单所属客户ID`、`businessId=异常订单ID`,File 只校验文件复合归属,异常状态、完成门禁和证据引用归 Lastleg |
|
||||||
| 海外换标 PDF 资料 | 通用内部业务文件入口,契约 `src/main/resources/openapi/overseas-relabel-document-v1.yaml`;`OVERSEAS_RELABEL_TOTAL_DOCUMENT` 按 `application.id`、`OVERSEAS_RELABEL_ITEM_DOCUMENT` 按 `item.id` 归属,必须指定明确客户与正数业务 ID;仅非空 PDF、最大 10MiB,校验 MIME 和文件签名。File 按客户、类型、业务 ID、文件 ID 复合校验,Lastleg 校验申请/明细关系、客户归属、权限和业务状态 |
|
| 海外换标 PDF 资料 | 通用内部业务文件入口,契约 `src/main/resources/openapi/overseas-relabel-document-v1.yaml`;`OVERSEAS_RELABEL_TOTAL_DOCUMENT` 按 `application.id`、`OVERSEAS_RELABEL_ITEM_DOCUMENT` 按 `item.id` 归属,必须指定明确客户与正数业务 ID;仅非空 PDF、最大 10MiB,校验 MIME 和文件签名。File 按客户、类型、业务 ID、文件 ID 复合校验,Lastleg 校验申请/明细关系、客户归属、权限和业务状态 |
|
||||||
| 入库扣货凭证 | 通用内部业务文件入口;固定 `businessType=INBOUND_HOLD_EVIDENCE`、`tenantId=客户ID`、`businessId=入库单ID`,客户作用域单文件最多 10MiB;可见性与历史引用由 Firstleg 校验 |
|
| 入库扣货凭证 | 通用内部业务文件入口;固定 `businessType=INBOUND_HOLD_EVIDENCE`、`tenantId=客户ID`、`businessId=入库单ID`,客户作用域单文件最多 10MiB;可见性与历史引用由 Firstleg 校验 |
|
||||||
| 报关源文件、完成文件与买单明细导出 | `src/main/resources/openapi/customs-file-and-export-v1.yaml`;文件类别 `LOADING_LIST_CUSTOMS_SOURCE`、`LOADING_LIST_CUSTOMS_RESULT` 均为客户作用域且最多 50MB,异步导出类型 `LOADING_LIST_BUY_DECLARATION_ITEMS` 由 Firstleg Provider 按固定十三列供数 |
|
| 报关源文件、报关行回执、完成文件与买单明细导出 | `src/main/resources/openapi/customs-file-and-export-v1.yaml`;文件类别 `LOADING_LIST_CUSTOMS_SOURCE`、`LOADING_LIST_CUSTOMS_RECEIPT`、`LOADING_LIST_CUSTOMS_RESULT` 均为客户作用域且最多 50MB,异步导出类型 `LOADING_LIST_BUY_DECLARATION_ITEMS` 由 Firstleg Provider 按固定十三列供数 |
|
||||||
| 订舱文件 | 通用内部业务文件入口,契约 `src/main/resources/openapi/ocean-booking-file-v1.yaml`;类型固定 `OCEAN_BOOKING_DOCUMENT`、`tenantId=0`(平台专属,客户可选快照不改变文件归属,拒绝客户作用域)、`businessId=oceanBookingId`,任意格式且最多 50MiB,不得绑定拖车;`BOOKING_CONFIRMATION/BILL_OF_LADING` 文档类型及有效状态由 Firstleg 保存 |
|
| 订舱文件 | 通用内部业务文件入口,契约 `src/main/resources/openapi/ocean-booking-file-v1.yaml`;类型固定 `OCEAN_BOOKING_DOCUMENT`、`tenantId=0`(平台专属,客户可选快照不改变文件归属,拒绝客户作用域)、`businessId=oceanBookingId`,任意格式且最多 50MiB,不得绑定拖车;`BOOKING_CONFIRMATION/BILL_OF_LADING` 文档类型及有效状态由 Firstleg 保存 |
|
||||||
| 拖车预约附件 | 通用内部业务文件入口;固定 `tenantId=0`、`businessType=TRUCK_APPOINTMENT_ATTACHMENT`、`businessId=appointmentId`,只允许派车单、司机/车辆资料和其他平台附件;提单文件归订舱 `OCEAN_BOOKING_DOCUMENT`,不得复制为拖车附件 |
|
| 拖车预约附件 | 通用内部业务文件入口;固定 `tenantId=0`、`businessType=TRUCK_APPOINTMENT_ATTACHMENT`、`businessId=appointmentId`,只允许派车单、司机/车辆资料和其他平台附件;提单文件归订舱 `OCEAN_BOOKING_DOCUMENT`,不得复制为拖车附件 |
|
||||||
| 存储与实体 | `service/LocalQualificationStorage.java`、`MeasurementImageStorage.java`、`entity/FileObjectEntity.java` |
|
| 存储与实体 | `service/LocalQualificationStorage.java`、`MeasurementImageStorage.java`、`entity/FileObjectEntity.java` |
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ enum BusinessFileCategory {
|
|||||||
OVERSEAS_RELABEL_ITEM_DOCUMENT(true, FormatPolicy.PDF, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG),
|
OVERSEAS_RELABEL_ITEM_DOCUMENT(true, FormatPolicy.PDF, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG),
|
||||||
OVERSEAS_EXCEPTION_EVIDENCE(true, FormatPolicy.STANDARD, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG),
|
OVERSEAS_EXCEPTION_EVIDENCE(true, FormatPolicy.STANDARD, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG),
|
||||||
LOADING_LIST_CUSTOMS_SOURCE(true, FormatPolicy.ARBITRARY, Limits.CUSTOMS, BusinessIdPolicy.POSITIVE_LONG),
|
LOADING_LIST_CUSTOMS_SOURCE(true, FormatPolicy.ARBITRARY, Limits.CUSTOMS, BusinessIdPolicy.POSITIVE_LONG),
|
||||||
|
LOADING_LIST_CUSTOMS_RECEIPT(true, FormatPolicy.ARBITRARY, Limits.CUSTOMS, BusinessIdPolicy.POSITIVE_LONG),
|
||||||
LOADING_LIST_CUSTOMS_RESULT(true, FormatPolicy.ARBITRARY, Limits.CUSTOMS, BusinessIdPolicy.POSITIVE_LONG),
|
LOADING_LIST_CUSTOMS_RESULT(true, FormatPolicy.ARBITRARY, Limits.CUSTOMS, BusinessIdPolicy.POSITIVE_LONG),
|
||||||
ORDER_BATCH_IMPORT_SOURCE(true, FormatPolicy.XLSX, Limits.ORDER_IMPORT, BusinessIdPolicy.ORDER_BATCH_NO),
|
ORDER_BATCH_IMPORT_SOURCE(true, FormatPolicy.XLSX, Limits.ORDER_IMPORT, BusinessIdPolicy.ORDER_BATCH_NO),
|
||||||
ORDER_BATCH_IMPORT_ERROR_REPORT(true, FormatPolicy.XLSX, Limits.ORDER_IMPORT, BusinessIdPolicy.ORDER_BATCH_NO);
|
ORDER_BATCH_IMPORT_ERROR_REPORT(true, FormatPolicy.XLSX, Limits.ORDER_IMPORT, BusinessIdPolicy.ORDER_BATCH_NO);
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ info:
|
|||||||
description: |
|
description: |
|
||||||
File 只保存不可变文件对象并生成异步 XLSX,不保存报关状态或业务版本。
|
File 只保存不可变文件对象并生成异步 XLSX,不保存报关状态或业务版本。
|
||||||
Firstleg 持有 loadingPlanId、文件版本、完成状态和可见性,并在调用本内部接口前完成 Platform/Client 鉴权。
|
Firstleg 持有 loadingPlanId、文件版本、完成状态和可见性,并在调用本内部接口前完成 Platform/Client 鉴权。
|
||||||
LOADING_LIST_CUSTOMS_SOURCE 与 LOADING_LIST_CUSTOMS_RESULT 均为 CUSTOMER owner scope,tenantId 必须大于 0;
|
LOADING_LIST_CUSTOMS_SOURCE、LOADING_LIST_CUSTOMS_RECEIPT 与 LOADING_LIST_CUSTOMS_RESULT 均为 CUSTOMER owner scope,tenantId 必须大于 0;
|
||||||
客户源文件上传/下载由 Firstleg 校验当前客户,结果文件下载还须由 Firstleg 校验整柜完成和结果可见性。
|
客户源文件上传/下载由 Firstleg 校验当前客户,结果文件下载还须由 Firstleg 校验整柜完成和结果可见性。
|
||||||
用户侧权限冻结为:Platform 上传结果 platform:firstleg:customs:manage,Platform 下载 platform:firstleg:customs:list;
|
用户侧权限冻结为:Platform 上传结果 platform:firstleg:customs:manage,Platform 下载 platform:firstleg:customs:list;
|
||||||
Client 上传源文件 merchant:order:edit,Client 下载自身源文件或获准结果 merchant:order:view。
|
Client 上传源文件 merchant:order:edit,Client 下载自身源文件或获准结果 merchant:order:view。
|
||||||
@@ -15,7 +15,7 @@ paths:
|
|||||||
post:
|
post:
|
||||||
summary: 保存报关业务文件对象
|
summary: 保存报关业务文件对象
|
||||||
description: |
|
description: |
|
||||||
仅服务令牌可调用。fileCategory 取 LOADING_LIST_CUSTOMS_SOURCE 或 LOADING_LIST_CUSTOMS_RESULT。
|
仅服务令牌可调用。fileCategory 取 LOADING_LIST_CUSTOMS_SOURCE、LOADING_LIST_CUSTOMS_RECEIPT 或 LOADING_LIST_CUSTOMS_RESULT。
|
||||||
支持任意文件格式,单文件最多 50MB。operatorId 为已通过 Firstleg 鉴权的实际操作人。
|
支持任意文件格式,单文件最多 50MB。operatorId 为已通过 Firstleg 鉴权的实际操作人。
|
||||||
parameters:
|
parameters:
|
||||||
- $ref: '#/components/parameters/InternalToken'
|
- $ref: '#/components/parameters/InternalToken'
|
||||||
@@ -94,7 +94,7 @@ components:
|
|||||||
InternalToken: {name: X-Internal-Service-Token, in: header, required: true, schema: {type: string}}
|
InternalToken: {name: X-Internal-Service-Token, in: header, required: true, schema: {type: string}}
|
||||||
OperatorId: {name: X-Operator-Id, in: header, required: true, schema: {type: integer, format: int64, minimum: 1}}
|
OperatorId: {name: X-Operator-Id, in: header, required: true, schema: {type: integer, format: int64, minimum: 1}}
|
||||||
TenantId: {name: tenantId, in: path, required: true, schema: {type: integer, format: int64, minimum: 1}, description: 客户ID;不得使用平台0作用域}
|
TenantId: {name: tenantId, in: path, required: true, schema: {type: integer, format: int64, minimum: 1}, description: 客户ID;不得使用平台0作用域}
|
||||||
FileCategory: {name: fileCategory, in: path, required: true, schema: {type: string, enum: [LOADING_LIST_CUSTOMS_SOURCE, LOADING_LIST_CUSTOMS_RESULT]}}
|
FileCategory: {name: fileCategory, in: path, required: true, schema: {type: string, enum: [LOADING_LIST_CUSTOMS_SOURCE, LOADING_LIST_CUSTOMS_RECEIPT, LOADING_LIST_CUSTOMS_RESULT]}}
|
||||||
LoadingPlanId: {name: loadingPlanId, in: path, required: true, schema: {type: integer, format: int64, minimum: 1}}
|
LoadingPlanId: {name: loadingPlanId, in: path, required: true, schema: {type: integer, format: int64, minimum: 1}}
|
||||||
FileId: {name: fileId, in: path, required: true, schema: {type: integer, format: int64, minimum: 1}}
|
FileId: {name: fileId, in: path, required: true, schema: {type: integer, format: int64, minimum: 1}}
|
||||||
schemas:
|
schemas:
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ class CustomsFileExportContractTest {
|
|||||||
String source = Files.readString(repositoryRoot()
|
String source = Files.readString(repositoryRoot()
|
||||||
.resolve("file/src/main/resources/openapi/customs-file-and-export-v1.yaml"));
|
.resolve("file/src/main/resources/openapi/customs-file-and-export-v1.yaml"));
|
||||||
|
|
||||||
assertThat(source).contains("LOADING_LIST_CUSTOMS_SOURCE", "LOADING_LIST_CUSTOMS_RESULT")
|
assertThat(source).contains("LOADING_LIST_CUSTOMS_SOURCE", "LOADING_LIST_CUSTOMS_RECEIPT", "LOADING_LIST_CUSTOMS_RESULT")
|
||||||
.contains("maximum: 52428800", "no-store", "CUSTOMER owner scope", "loadingPlanId")
|
.contains("maximum: 52428800", "no-store", "CUSTOMER owner scope", "loadingPlanId")
|
||||||
.contains("platform:firstleg:customs:manage", "platform:firstleg:customs:list",
|
.contains("platform:firstleg:customs:manage", "platform:firstleg:customs:list",
|
||||||
"merchant:order:edit", "merchant:order:view")
|
"merchant:order:edit", "merchant:order:view")
|
||||||
|
|||||||
@@ -159,7 +159,7 @@ class InternalBusinessFileServiceTest {
|
|||||||
.containsExactly(100L, "OVERSEAS_RELABEL_PHOTO", "81");
|
.containsExactly(100L, "OVERSEAS_RELABEL_PHOTO", "81");
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test void loadingCustomsSourceAndResultAreCustomerOwnedArbitraryFormatObjects() {
|
@Test void loadingCustomsSourceReceiptAndResultAreCustomerOwnedArbitraryFormatObjects() {
|
||||||
MockMultipartFile file = new MockMultipartFile("file", "资料包.zip", "application/zip", "zip".getBytes());
|
MockMultipartFile file = new MockMultipartFile("file", "资料包.zip", "application/zip", "zip".getBytes());
|
||||||
when(storage.store(eq(10001L), eq("51"), same(file), any(BusinessFileCategory.class)))
|
when(storage.store(eq(10001L), eq("51"), same(file), any(BusinessFileCategory.class)))
|
||||||
.thenReturn(new BusinessFileStorage.Stored("business-files/10001/51/key", "application/zip",
|
.thenReturn(new BusinessFileStorage.Stored("business-files/10001/51/key", "application/zip",
|
||||||
@@ -169,14 +169,17 @@ class InternalBusinessFileServiceTest {
|
|||||||
});
|
});
|
||||||
|
|
||||||
var source = service.upload(10001L, "LOADING_LIST_CUSTOMS_SOURCE", "51", 7L, file);
|
var source = service.upload(10001L, "LOADING_LIST_CUSTOMS_SOURCE", "51", 7L, file);
|
||||||
|
var receipt = service.upload(10001L, "LOADING_LIST_CUSTOMS_RECEIPT", "51", 7L, file);
|
||||||
var result = service.upload(10001L, "LOADING_LIST_CUSTOMS_RESULT", "51", 7L, file);
|
var result = service.upload(10001L, "LOADING_LIST_CUSTOMS_RESULT", "51", 7L, file);
|
||||||
|
|
||||||
assertThat(source).extracting(InternalBusinessFileService.Metadata::tenantId,
|
assertThat(source).extracting(InternalBusinessFileService.Metadata::tenantId,
|
||||||
InternalBusinessFileService.Metadata::businessType,
|
InternalBusinessFileService.Metadata::businessType,
|
||||||
InternalBusinessFileService.Metadata::businessId)
|
InternalBusinessFileService.Metadata::businessId)
|
||||||
.containsExactly(10001L, "LOADING_LIST_CUSTOMS_SOURCE", "51");
|
.containsExactly(10001L, "LOADING_LIST_CUSTOMS_SOURCE", "51");
|
||||||
|
assertThat(receipt.businessType()).isEqualTo("LOADING_LIST_CUSTOMS_RECEIPT");
|
||||||
assertThat(result.businessType()).isEqualTo("LOADING_LIST_CUSTOMS_RESULT");
|
assertThat(result.businessType()).isEqualTo("LOADING_LIST_CUSTOMS_RESULT");
|
||||||
verify(storage).store(10001L, "51", file, BusinessFileCategory.LOADING_LIST_CUSTOMS_SOURCE);
|
verify(storage).store(10001L, "51", file, BusinessFileCategory.LOADING_LIST_CUSTOMS_SOURCE);
|
||||||
|
verify(storage).store(10001L, "51", file, BusinessFileCategory.LOADING_LIST_CUSTOMS_RECEIPT);
|
||||||
verify(storage).store(10001L, "51", file, BusinessFileCategory.LOADING_LIST_CUSTOMS_RESULT);
|
verify(storage).store(10001L, "51", file, BusinessFileCategory.LOADING_LIST_CUSTOMS_RESULT);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -185,6 +188,8 @@ class InternalBusinessFileServiceTest {
|
|||||||
|
|
||||||
assertThatThrownBy(() -> service.upload(0L, "LOADING_LIST_CUSTOMS_SOURCE", "51", 7L, file))
|
assertThatThrownBy(() -> service.upload(0L, "LOADING_LIST_CUSTOMS_SOURCE", "51", 7L, file))
|
||||||
.isInstanceOf(BizException.class).hasMessageContaining("明确客户");
|
.isInstanceOf(BizException.class).hasMessageContaining("明确客户");
|
||||||
|
assertThatThrownBy(() -> service.upload(0L, "LOADING_LIST_CUSTOMS_RECEIPT", "51", 7L, file))
|
||||||
|
.isInstanceOf(BizException.class).hasMessageContaining("明确客户");
|
||||||
assertThatThrownBy(() -> service.metadata(0L, "LOADING_LIST_CUSTOMS_RESULT", "51", 91L))
|
assertThatThrownBy(() -> service.metadata(0L, "LOADING_LIST_CUSTOMS_RESULT", "51", 91L))
|
||||||
.isInstanceOf(BizException.class).hasMessageContaining("明确客户");
|
.isInstanceOf(BizException.class).hasMessageContaining("明确客户");
|
||||||
verifyNoInteractions(files, storage);
|
verifyNoInteractions(files, storage);
|
||||||
|
|||||||
+2
-2
@@ -51,7 +51,7 @@
|
|||||||
| 船节点跨区交接事件 | `portschedule/service/CarrierScheduleNodeOutboxService.java`;实际节点落库后按 `containerBookingId` 柜级发布 `FIRST_LEG_CONTAINER_SHIPPING_NODE_RECORDED` V1,信封固定使用 `tenantId=0` 平台技术范围且 Payload 不携带客户,复用 `LoadingCompletedOutboxRepository`/Relay 投 Event Bridge;海外消费见 `../lastleg-service/src/main/java/com/baiyue/lastleg/shipping/service/ContainerShippingNodeConsumer.java` |
|
| 船节点跨区交接事件 | `portschedule/service/CarrierScheduleNodeOutboxService.java`;实际节点落库后按 `containerBookingId` 柜级发布 `FIRST_LEG_CONTAINER_SHIPPING_NODE_RECORDED` V1,信封固定使用 `tenantId=0` 平台技术范围且 Payload 不携带客户,复用 `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` 前缀,按北京时间每日独立编号;具体业务归属以各调用服务为准 |
|
| 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 完成不推进拖车状态,全部要求货柜确认国内离仓后由 `TruckAppointmentCompletionService` 自动完成并经 `TruckAppointmentFeeService` 发布应付 |
|
| 订舱级拖车与整柜出仓调度 | `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 完成不推进拖车状态,全部要求货柜确认国内离仓后由 `TruckAppointmentCompletionService` 自动完成并经 `TruckAppointmentFeeService` 发布应付 |
|
||||||
| 整柜出口报关 | `customs/container/controller/PlatformContainerCustomsController.java`、`customs/container/controller/MerchantContainerCustomsController.java`、`customs/container/service/ContainerCustomsService.java`、`customs/container/mapper/ContainerCustomsMapper.java`;装柜完成后按柜收集退税资料,多对多覆盖入仓码,开始报关冻结,完成文件逐资料一对一回传,全买单柜由平台确认并通过导出中心取明细;契约 `src/main/resources/openapi/customs-v1.yaml`,基线 `src/main/resources/db/phase3-container-customs-schema.sql`;消费者为 Admin `../../apps/admin-web/src/modules/container-loading/components/ContainerCustomsPanel.vue` 和 Client `../../apps/client-web/src/modules/first-leg/components/CustomsDocumentsDialog.vue` |
|
| 整柜出口报关 | `customs/container/controller/PlatformContainerCustomsController.java`、`customs/container/controller/MerchantContainerCustomsController.java`、`customs/container/service/ContainerCustomsService.java`、`customs/container/mapper/ContainerCustomsMapper.java`;装柜完成后按柜收集退税资料,多对多覆盖入仓码,开始报关冻结,完成文件逐资料一对一回传,全买单柜由平台确认并通过导出中心取明细;正式申报批次与海关结果在同一模块独立记录,不改报关四态、PDA 或船期事实;契约 `src/main/resources/openapi/customs-v1.yaml`,基线 `src/main/resources/db/phase3-container-customs-schema.sql`;消费者为 Admin `../../apps/admin-web/src/modules/container-loading/components/ContainerCustomsPanel.vue` 和 Client `../../apps/client-web/src/modules/first-leg/components/CustomsDocumentsDialog.vue` |
|
||||||
| 买单订单明细导出 Provider | `customs/container/controller/LoadingListBuyDeclarationExportController.java`、`service/LoadingListBuyDeclarationExportService.java`;只为 File 导出中心提供 `LOADING_LIST_BUY_DECLARATION_ITEMS` 的已完成装柜实际买单行,不在 Firstleg 生成文件 |
|
| 买单订单明细导出 Provider | `customs/container/controller/LoadingListBuyDeclarationExportController.java`、`service/LoadingListBuyDeclarationExportService.java`;只为 File 导出中心提供 `LOADING_LIST_BUY_DECLARATION_ITEMS` 的已完成装柜实际买单行,不在 Firstleg 生成文件 |
|
||||||
| 跨模型桥接 | `loading/bridge/controller/PlatformLoadingModelBridgeController.java` |
|
| 跨模型桥接 | `loading/bridge/controller/PlatformLoadingModelBridgeController.java` |
|
||||||
| 跨服务客户端 | `client/OrderCargoClient.java`、`WarehousePalletLocationClient.java`、`MeasurementImageClient.java`、`LoadingEvidenceFileClient.java`、`EventBridgeClient.java` |
|
| 跨服务客户端 | `client/OrderCargoClient.java`、`WarehousePalletLocationClient.java`、`MeasurementImageClient.java`、`LoadingEvidenceFileClient.java`、`EventBridgeClient.java` |
|
||||||
@@ -77,7 +77,7 @@
|
|||||||
- 订舱列表 `containerCount` 固定为该订舱全部柜型需求 `quantity` 之和,与真实柜关联/解除无关;关联候选只返回 `RELEASED`、剩余数量大于 0 且 `warehouseId + channelCode + containerType` 精确匹配的需求,由服务端集合查询 ACTIVE 分配量。前端不得扇出详情计算或用已关联柜数覆盖该字段,最终关联命令仍须重验目标货柜可修改性。
|
- 订舱列表 `containerCount` 固定为该订舱全部柜型需求 `quantity` 之和,与真实柜关联/解除无关;关联候选只返回 `RELEASED`、剩余数量大于 0 且 `warehouseId + channelCode + containerType` 精确匹配的需求,由服务端集合查询 ACTIVE 分配量。前端不得扇出详情计算或用已关联柜数覆盖该字段,最终关联命令仍须重验目标货柜可修改性。
|
||||||
- 拖车预约是平台级订舱聚合,不是客户或柜次资源:请求、实体、查询、响应和附件不得恢复 `customerId/customerName`,公开 DTO 也不得出现 `containerBookingId/loadingPlanId/cabinetNo/containerNo`。创建候选只返回有提单号、ACTIVE `BOOKING_CONFIRMATION` 或 `BILL_OF_LADING` 且没有非终态拖车单的 `RELEASED` 订舱;创建或编辑只冻结订舱仓库/提单与 TRUCKING/DOMESTIC 国内拖车供应商快照。每订舱通过查询锁与唯一约束最多一张非终态预约,重复创建返回现有派车单号;PDA 装柜完成不得改变拖车状态;只有订舱全部柜型需求均已关联且每柜全部入仓绑定确认国内离仓,才自动完成(含草稿/晚提交)。离仓、拖车创建/编辑/操作统一先锁订舱,完成状态、`AUTO_COMPLETE` 历史、按实装体积守恒分摊的柜次应付及 Finance Outbox 同事务落库;取消/异常单不自动恢复,已有有效或完成单不得重复创建。应付拖车费仅人民币 RMB(财务快照为 CNY),固定使用 `FEE_TYPE_108`,关联 ACTIVE/TRUCKING/DOMESTIC 供应商,零金额不生成费用;正金额缺归属、有效体积或费用配置时失败关闭,不丢费、不伪造均分。手工 DEPART/COMPLETE 动作已移除。附件使用 `tenantId=0 + TRUCK_APPOINTMENT_ATTACHMENT + appointmentId`,提单文件继续归订舱。
|
- 拖车预约是平台级订舱聚合,不是客户或柜次资源:请求、实体、查询、响应和附件不得恢复 `customerId/customerName`,公开 DTO 也不得出现 `containerBookingId/loadingPlanId/cabinetNo/containerNo`。创建候选只返回有提单号、ACTIVE `BOOKING_CONFIRMATION` 或 `BILL_OF_LADING` 且没有非终态拖车单的 `RELEASED` 订舱;创建或编辑只冻结订舱仓库/提单与 TRUCKING/DOMESTIC 国内拖车供应商快照。每订舱通过查询锁与唯一约束最多一张非终态预约,重复创建返回现有派车单号;PDA 装柜完成不得改变拖车状态;只有订舱全部柜型需求均已关联且每柜全部入仓绑定确认国内离仓,才自动完成(含草稿/晚提交)。离仓、拖车创建/编辑/操作统一先锁订舱,完成状态、`AUTO_COMPLETE` 历史、按实装体积守恒分摊的柜次应付及 Finance Outbox 同事务落库;取消/异常单不自动恢复,已有有效或完成单不得重复创建。应付拖车费仅人民币 RMB(财务快照为 CNY),固定使用 `FEE_TYPE_108`,关联 ACTIVE/TRUCKING/DOMESTIC 供应商,零金额不生成费用;正金额缺归属、有效体积或费用配置时失败关闭,不丢费、不伪造均分。手工 DEPART/COMPLETE 动作已移除。附件使用 `tenantId=0 + TRUCK_APPOINTMENT_ATTACHMENT + appointmentId`,提单文件继续归订舱。
|
||||||
- 海外仓业务船节点任务从确认柜子离开国内仓库后启用,必须绑定真实订舱和柜次;十个标准节点、预计/实际时间、异常事实、供应商缺失节点和还柜停止规则统一以 `../../docs/海外仓业务/船节点标准化契约.md` 为准。`CarrierScheduleQuery` 的正常业务查询槽位固定为 `Asia/Shanghai` 每日 09:00、12:30、16:00;每分钟任务只扫描已到期 `next_allowed_at`,不是每分钟请求船司。成功、无变化、需人工或永久失败进入下一固定槽位;仅技术性 `RETRYABLE_FAILURE` 在当前槽内按15/30分钟退避,含首次最多3次且不得跨下一槽,实际 `EMPTY_RETURNED` 后停止。错过的正常槽位直接顺延,不在服务恢复时补跑;单轮按受控批次持续领取并受最大任务数保护。租约、调度尝试和内部停止入口不得暴露为平台页面 API,外部失败不得推进业务节点,不得在国内货柜履约另建查询任务。
|
- 海外仓业务船节点任务从确认柜子离开国内仓库后启用,必须绑定真实订舱和柜次;十个标准节点、预计/实际时间、异常事实、供应商缺失节点和还柜停止规则统一以 `../../docs/海外仓业务/船节点标准化契约.md` 为准。`CarrierScheduleQuery` 的正常业务查询槽位固定为 `Asia/Shanghai` 每日 09:00、12:30、16:00;每分钟任务只扫描已到期 `next_allowed_at`,不是每分钟请求船司。成功、无变化、需人工或永久失败进入下一固定槽位;仅技术性 `RETRYABLE_FAILURE` 在当前槽内按15/30分钟退避,含首次最多3次且不得跨下一槽,实际 `EMPTY_RETURNED` 后停止。错过的正常槽位直接顺延,不在服务恢复时补跑;单轮按受控批次持续领取并受最大任务数保护。租约、调度尝试和内部停止入口不得暴露为平台页面 API,外部失败不得推进业务节点,不得在国内货柜履约另建查询任务。
|
||||||
- 国内出口报关在装柜完成后启用,不再阻塞 `LOADED`。整柜 `fl_customs_container.status=COMPLETED` 才通过离仓报关门禁;完成后以系统身份、确定性幂等键立即尝试确认国内离仓,其他门禁未齐时由受控定时补偿重试,禁止部分入仓成功或重复创建离仓/船司查询事实。
|
- 国内出口报关在装柜完成后启用,不阻塞物理 `LOADED`。整柜 `fl_customs_container.status=COMPLETED` 才能推进“待发区”门禁;报关完成后以系统身份、确定性幂等键立即尝试确认待发区,其他物理/运输事实未齐时由受控定时补偿重试。待发区之后才推进离仓、进港和船期;甩柜/改配只在原柜船期上人工修改船信息并保留历史,不回写报关资料、回执或客户确认,禁止部分入仓成功或重复创建离仓/船司查询事实。
|
||||||
- 国内出仓确认以真实 `containerBookingId` 聚合全部入仓绑定并锁定校验;装柜证据按物理货柜只计算一次,PDA `taskId` 仅用于来源审计和任务授权,File 所有权仍固定 `tenantId=0 + containerBookingId`;拖车状态和独立船期只作参考。全部门禁在同一事务通过后推进所有技术行、记录一条可见历史并幂等创建 `CarrierScheduleQuery/DEPARTED_WAREHOUSE`,不允许单入仓局部成功。
|
- 国内出仓确认以真实 `containerBookingId` 聚合全部入仓绑定并锁定校验;装柜证据按物理货柜只计算一次,PDA `taskId` 仅用于来源审计和任务授权,File 所有权仍固定 `tenantId=0 + containerBookingId`;拖车状态和独立船期只作参考。全部门禁在同一事务通过后推进所有技术行、记录一条可见历史并幂等创建 `CarrierScheduleQuery/DEPARTED_WAREHOUSE`,不允许单入仓局部成功。
|
||||||
- 手工船节点的预计时间只追加事实,不推进 `latestStandardNode`;携带 `actualAt` 时标准节点序号必须严格大于最新实际节点,重复、相同或回退节点返回冲突。已审核实际时间修正只能更新同一事实的时间/证据,不得制造第二条同节点实际事实或绕过单调推进。
|
- 手工船节点的预计时间只追加事实,不推进 `latestStandardNode`;携带 `actualAt` 时标准节点序号必须严格大于最新实际节点,重复、相同或回退节点返回冲突。已审核实际时间修正只能更新同一事实的时间/证据,不得制造第二条同节点实际事实或绕过单调推进。
|
||||||
- 海外卸柜完成事件只接受 `OVERSEAS -> CN` 的 `OVERSEAS_CONTAINER_UNLOADED` V1,并通过内部服务令牌进入。消费者必须校验信封、Payload 哈希、来源货柜和时间顺序,以 event ID + Payload 哈希幂等,使用事件聚合版本阻止乱序覆盖;预计报空时间固定按卸柜完成时间加 72 小时计算,实际报空时间可为空。
|
- 海外卸柜完成事件只接受 `OVERSEAS -> CN` 的 `OVERSEAS_CONTAINER_UNLOADED` V1,并通过内部服务令牌进入。消费者必须校验信封、Payload 哈希、来源货柜和时间顺序,以 event ID + Payload 哈希幂等,使用事件聚合版本阻止乱序覆盖;预计报空时间固定按卸柜完成时间加 72 小时计算,实际报空时间可为空。
|
||||||
|
|||||||
+4
-3
@@ -35,15 +35,16 @@ public class PlatformMojiaSkuSyncController {
|
|||||||
|
|
||||||
@GetMapping
|
@GetMapping
|
||||||
@PreAuthorize("hasAuthority('platform:mojia-sku-sync:list')")
|
@PreAuthorize("hasAuthority('platform:mojia-sku-sync:list')")
|
||||||
@Operation(summary = "查询墨家一件代发SKU测量记录")
|
@Operation(summary = "查询墨家机器全部上传记录")
|
||||||
public ApiResponse<PageResult<MojiaSkuSyncDtos.Record>> page(
|
public ApiResponse<PageResult<MojiaSkuSyncDtos.Record>> page(
|
||||||
@AuthenticationPrincipal AuthenticatedUser user,
|
@AuthenticationPrincipal AuthenticatedUser user,
|
||||||
@RequestParam(required = false) String skuCode,
|
@RequestParam(required = false) String code,
|
||||||
|
@RequestParam(required = false) String measurementKind,
|
||||||
@RequestParam(required = false) String syncStatus,
|
@RequestParam(required = false) String syncStatus,
|
||||||
@RequestParam(defaultValue = "1") @Min(1) long page,
|
@RequestParam(defaultValue = "1") @Min(1) long page,
|
||||||
@RequestParam(defaultValue = "20") @Min(1) @Max(200) long size) {
|
@RequestParam(defaultValue = "20") @Min(1) @Max(200) long size) {
|
||||||
FirstlegScopeGuard.platform(user);
|
FirstlegScopeGuard.platform(user);
|
||||||
return ApiResponse.success(service.page(new MojiaSkuSyncDtos.Query(skuCode, syncStatus, page, size)));
|
return ApiResponse.success(service.page(new MojiaSkuSyncDtos.Query(code, measurementKind, syncStatus, page, size)));
|
||||||
}
|
}
|
||||||
@GetMapping("/config")
|
@GetMapping("/config")
|
||||||
@PreAuthorize("hasAuthority('platform:mojia-sku-sync:list')")
|
@PreAuthorize("hasAuthority('platform:mojia-sku-sync:list')")
|
||||||
|
|||||||
+16
-2
@@ -33,6 +33,13 @@ public class MerchantContainerCustomsController {
|
|||||||
return ApiResponse.success(service.merchantOrderView(FirstlegScopeGuard.merchant(user), orderId));
|
return ApiResponse.success(service.merchantOrderView(FirstlegScopeGuard.merchant(user), orderId));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@GetMapping("/orders/{orderId}/clearance-results")
|
||||||
|
@PreAuthorize("hasAuthority('merchant:order:view')")
|
||||||
|
public ApiResponse<List<ClearanceResultView>> clearanceResults(@AuthenticationPrincipal AuthenticatedUser user,
|
||||||
|
@PathVariable @Positive long orderId) {
|
||||||
|
return ApiResponse.success(service.merchantClearanceResults(FirstlegScopeGuard.merchant(user), orderId));
|
||||||
|
}
|
||||||
|
|
||||||
@PostMapping(value="/loading-plans/{planId}/documents/upload",consumes=MediaType.MULTIPART_FORM_DATA_VALUE)
|
@PostMapping(value="/loading-plans/{planId}/documents/upload",consumes=MediaType.MULTIPART_FORM_DATA_VALUE)
|
||||||
@PreAuthorize("hasAuthority('merchant:order:edit')")
|
@PreAuthorize("hasAuthority('merchant:order:edit')")
|
||||||
public ApiResponse<DocumentView> upload(@AuthenticationPrincipal AuthenticatedUser user,
|
public ApiResponse<DocumentView> upload(@AuthenticationPrincipal AuthenticatedUser user,
|
||||||
@@ -84,13 +91,20 @@ public class MerchantContainerCustomsController {
|
|||||||
return response(service.downloadSource(FirstlegScopeGuard.merchant(user),documentId,false));
|
return response(service.downloadSource(FirstlegScopeGuard.merchant(user),documentId,false));
|
||||||
}
|
}
|
||||||
|
|
||||||
@GetMapping("/documents/{documentId}/completion/download")
|
@GetMapping("/documents/{documentId}/receipt/download")
|
||||||
@PreAuthorize("hasAuthority('merchant:order:view')")
|
@PreAuthorize("hasAuthority('merchant:order:view')")
|
||||||
public ResponseEntity<byte[]> downloadCompletion(@AuthenticationPrincipal AuthenticatedUser user,
|
public ResponseEntity<byte[]> downloadReceipt(@AuthenticationPrincipal AuthenticatedUser user,
|
||||||
@PathVariable @Positive long documentId) {
|
@PathVariable @Positive long documentId) {
|
||||||
return response(service.downloadCompletion(FirstlegScopeGuard.merchant(user),documentId,false));
|
return response(service.downloadCompletion(FirstlegScopeGuard.merchant(user),documentId,false));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@GetMapping("/documents/{documentId}/final/download")
|
||||||
|
@PreAuthorize("hasAuthority('merchant:order:view')")
|
||||||
|
public ResponseEntity<byte[]> downloadFinal(@AuthenticationPrincipal AuthenticatedUser user,
|
||||||
|
@PathVariable @Positive long documentId) {
|
||||||
|
return response(service.downloadFinal(FirstlegScopeGuard.merchant(user),documentId,false));
|
||||||
|
}
|
||||||
|
|
||||||
@PostMapping("/documents/{documentId}/receipt/confirm")
|
@PostMapping("/documents/{documentId}/receipt/confirm")
|
||||||
@PreAuthorize("hasAuthority('merchant:order:edit')")
|
@PreAuthorize("hasAuthority('merchant:order:edit')")
|
||||||
public ApiResponse<ContainerView> confirmReceipt(@AuthenticationPrincipal AuthenticatedUser user,
|
public ApiResponse<ContainerView> confirmReceipt(@AuthenticationPrincipal AuthenticatedUser user,
|
||||||
|
|||||||
+93
-1
@@ -18,6 +18,7 @@ import org.springframework.web.bind.annotation.*;
|
|||||||
import org.springframework.web.multipart.MultipartFile;
|
import org.springframework.web.multipart.MultipartFile;
|
||||||
|
|
||||||
import java.nio.charset.StandardCharsets;
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
@Validated
|
@Validated
|
||||||
@RestController
|
@RestController
|
||||||
@@ -33,6 +34,66 @@ public class PlatformContainerCustomsController {
|
|||||||
return ApiResponse.success(service.platformView(planId));
|
return ApiResponse.success(service.platformView(planId));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@GetMapping("/{planId}/declaration-batches")
|
||||||
|
@PreAuthorize("hasAuthority('platform:firstleg:customs:list')")
|
||||||
|
public ApiResponse<List<DeclarationBatchView>> declarationBatches(@AuthenticationPrincipal AuthenticatedUser user,
|
||||||
|
@PathVariable @Positive long planId) {
|
||||||
|
FirstlegScopeGuard.platform(user);
|
||||||
|
return ApiResponse.success(service.platformDeclarationBatches(planId));
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/{planId}/declaration-batches")
|
||||||
|
@PreAuthorize("hasAuthority('platform:firstleg:customs:manage')")
|
||||||
|
@BusinessAudit(event="CUSTOMS_DECLARATION_BATCH_CREATE",resourceType="CUSTOMS_CONTAINER")
|
||||||
|
public ApiResponse<DeclarationBatchView> createDeclarationBatch(@AuthenticationPrincipal AuthenticatedUser user,
|
||||||
|
@PathVariable @Positive long planId,
|
||||||
|
@RequestHeader("Idempotency-Key") String key,
|
||||||
|
@Valid @RequestBody DeclarationBatchRequest request) {
|
||||||
|
FirstlegScopeGuard.platform(user);
|
||||||
|
return ApiResponse.success(service.createDeclarationBatch(planId,user.userId(),user.username(),key,request));
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/declaration-batches/{batchId}/submit")
|
||||||
|
@PreAuthorize("hasAuthority('platform:firstleg:customs:manage')")
|
||||||
|
@BusinessAudit(event="CUSTOMS_DECLARATION_BATCH_SUBMIT",resourceType="CUSTOMS_DECLARATION_BATCH")
|
||||||
|
public ApiResponse<DeclarationBatchView> submitDeclarationBatch(@AuthenticationPrincipal AuthenticatedUser user,
|
||||||
|
@PathVariable @Positive long batchId,
|
||||||
|
@RequestHeader("Idempotency-Key") String key,
|
||||||
|
@Valid @RequestBody DeclarationBatchRequest request) {
|
||||||
|
FirstlegScopeGuard.platform(user);
|
||||||
|
return ApiResponse.success(service.submitDeclarationBatch(batchId,user.userId(),user.username(),key,request));
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/declaration-batches/{batchId}/results")
|
||||||
|
@PreAuthorize("hasAuthority('platform:firstleg:customs:list')")
|
||||||
|
public ApiResponse<List<ClearanceResultView>> clearanceResults(@AuthenticationPrincipal AuthenticatedUser user,
|
||||||
|
@PathVariable @Positive long batchId) {
|
||||||
|
FirstlegScopeGuard.platform(user);
|
||||||
|
return ApiResponse.success(service.platformClearanceResults(batchId));
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/declaration-batches/{batchId}/results")
|
||||||
|
@PreAuthorize("hasAuthority('platform:firstleg:customs:manage')")
|
||||||
|
@BusinessAudit(event="CUSTOMS_CLEARANCE_RESULT_RECORD",resourceType="CUSTOMS_DECLARATION_BATCH")
|
||||||
|
public ApiResponse<ClearanceResultView> addClearanceResult(@AuthenticationPrincipal AuthenticatedUser user,
|
||||||
|
@PathVariable @Positive long batchId,
|
||||||
|
@RequestHeader("Idempotency-Key") String key,
|
||||||
|
@Valid @RequestBody ClearanceResultRequest request) {
|
||||||
|
FirstlegScopeGuard.platform(user);
|
||||||
|
return ApiResponse.success(service.addClearanceResult(batchId,user.userId(),user.username(),key,request));
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/clearance-results/{resultId}/resolve")
|
||||||
|
@PreAuthorize("hasAuthority('platform:firstleg:customs:manage')")
|
||||||
|
@BusinessAudit(event="CUSTOMS_CLEARANCE_RESULT_RESOLVE",resourceType="CUSTOMS_CLEARANCE_RESULT")
|
||||||
|
public ApiResponse<ClearanceResultView> resolveClearanceResult(@AuthenticationPrincipal AuthenticatedUser user,
|
||||||
|
@PathVariable @Positive long resultId,
|
||||||
|
@RequestHeader("Idempotency-Key") String key,
|
||||||
|
@Valid @RequestBody ClearanceResolveRequest request) {
|
||||||
|
FirstlegScopeGuard.platform(user);
|
||||||
|
return ApiResponse.success(service.resolveClearanceResult(resultId,user.userId(),user.username(),key,request));
|
||||||
|
}
|
||||||
|
|
||||||
@PostMapping("/{planId}/start")
|
@PostMapping("/{planId}/start")
|
||||||
@PreAuthorize("hasAuthority('platform:firstleg:customs:manage')")
|
@PreAuthorize("hasAuthority('platform:firstleg:customs:manage')")
|
||||||
@BusinessAudit(event="CONTAINER_CUSTOMS_START",resourceType="CUSTOMS_CONTAINER")
|
@BusinessAudit(event="CONTAINER_CUSTOMS_START",resourceType="CUSTOMS_CONTAINER")
|
||||||
@@ -44,7 +105,18 @@ public class PlatformContainerCustomsController {
|
|||||||
return ApiResponse.success(service.start(planId,user.userId(),user.username(),key,request));
|
return ApiResponse.success(service.start(planId,user.userId(),user.username(),key,request));
|
||||||
}
|
}
|
||||||
|
|
||||||
@PostMapping(value="/documents/{documentId}/completion/upload",consumes=MediaType.MULTIPART_FORM_DATA_VALUE)
|
@PostMapping("/{planId}/submit-to-broker")
|
||||||
|
@PreAuthorize("hasAuthority('platform:firstleg:customs:manage')")
|
||||||
|
@BusinessAudit(event="CUSTOMS_SUBMIT_TO_BROKER",resourceType="CUSTOMS_CONTAINER")
|
||||||
|
public ApiResponse<ContainerView> submitToBroker(@AuthenticationPrincipal AuthenticatedUser user,
|
||||||
|
@PathVariable @Positive long planId,
|
||||||
|
@RequestHeader("Idempotency-Key") String key,
|
||||||
|
@Valid @RequestBody ActionRequest request) {
|
||||||
|
FirstlegScopeGuard.platform(user);
|
||||||
|
return ApiResponse.success(service.submitToBroker(planId,user.userId(),user.username(),key,request));
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping(value="/documents/{documentId}/receipt/upload",consumes=MediaType.MULTIPART_FORM_DATA_VALUE)
|
||||||
@PreAuthorize("hasAuthority('platform:firstleg:customs:manage')")
|
@PreAuthorize("hasAuthority('platform:firstleg:customs:manage')")
|
||||||
@BusinessAudit(event="CUSTOMS_COMPLETION_UPLOAD",resourceType="CUSTOMS_CUSTOMER_DOCUMENT")
|
@BusinessAudit(event="CUSTOMS_COMPLETION_UPLOAD",resourceType="CUSTOMS_CUSTOMER_DOCUMENT")
|
||||||
public ApiResponse<DocumentView> uploadCompletion(@AuthenticationPrincipal AuthenticatedUser user,
|
public ApiResponse<DocumentView> uploadCompletion(@AuthenticationPrincipal AuthenticatedUser user,
|
||||||
@@ -57,6 +129,18 @@ public class PlatformContainerCustomsController {
|
|||||||
expectedVersion,file));
|
expectedVersion,file));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@PostMapping(value="/documents/{documentId}/final/upload",consumes=MediaType.MULTIPART_FORM_DATA_VALUE)
|
||||||
|
@PreAuthorize("hasAuthority('platform:firstleg:customs:manage')")
|
||||||
|
@BusinessAudit(event="CUSTOMS_FINAL_DOCUMENT_UPLOAD",resourceType="CUSTOMS_CUSTOMER_DOCUMENT")
|
||||||
|
public ApiResponse<DocumentView> uploadFinal(@AuthenticationPrincipal AuthenticatedUser user,
|
||||||
|
@PathVariable @Positive long documentId,
|
||||||
|
@RequestHeader("Idempotency-Key") String key,
|
||||||
|
@RequestParam @Min(0) int expectedVersion,
|
||||||
|
@RequestPart("file") MultipartFile file) {
|
||||||
|
FirstlegScopeGuard.platform(user);
|
||||||
|
return ApiResponse.success(service.uploadFinalDocument(documentId,user.userId(),user.username(),key,expectedVersion,file));
|
||||||
|
}
|
||||||
|
|
||||||
@PostMapping("/{planId}/complete-all-buy")
|
@PostMapping("/{planId}/complete-all-buy")
|
||||||
@PreAuthorize("hasAuthority('platform:firstleg:customs:manage')")
|
@PreAuthorize("hasAuthority('platform:firstleg:customs:manage')")
|
||||||
@BusinessAudit(event="ALL_BUY_CONTAINER_CUSTOMS_COMPLETE",resourceType="CUSTOMS_CONTAINER")
|
@BusinessAudit(event="ALL_BUY_CONTAINER_CUSTOMS_COMPLETE",resourceType="CUSTOMS_CONTAINER")
|
||||||
@@ -84,6 +168,14 @@ public class PlatformContainerCustomsController {
|
|||||||
return response(service.downloadCompletion(0,documentId,true));
|
return response(service.downloadCompletion(0,documentId,true));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@GetMapping("/documents/{documentId}/final/download")
|
||||||
|
@PreAuthorize("hasAuthority('platform:firstleg:customs:list')")
|
||||||
|
public ResponseEntity<byte[]> downloadFinal(@AuthenticationPrincipal AuthenticatedUser user,
|
||||||
|
@PathVariable @Positive long documentId) {
|
||||||
|
FirstlegScopeGuard.platform(user);
|
||||||
|
return response(service.downloadFinal(0,documentId,true));
|
||||||
|
}
|
||||||
|
|
||||||
private static ResponseEntity<byte[]> response(Download value) {
|
private static ResponseEntity<byte[]> response(Download value) {
|
||||||
return ResponseEntity.ok().cacheControl(CacheControl.noStore())
|
return ResponseEntity.ok().cacheControl(CacheControl.noStore())
|
||||||
.contentType(MediaType.parseMediaType(value.contentType()))
|
.contentType(MediaType.parseMediaType(value.contentType()))
|
||||||
|
|||||||
+32
-3
@@ -17,17 +17,21 @@ public final class ContainerCustomsDtos {
|
|||||||
public record FileView(long fileId, String fileName, String fileHash, String contentType,
|
public record FileView(long fileId, String fileName, String fileHash, String contentType,
|
||||||
long sizeBytes, int version, LocalDateTime uploadedAt) {}
|
long sizeBytes, int version, LocalDateTime uploadedAt) {}
|
||||||
|
|
||||||
|
public record ReceiptVersionView(long version, FileView file, String operatorName,
|
||||||
|
LocalDateTime createdAt) {}
|
||||||
|
|
||||||
public record DocumentView(long documentId, long tenantId, FileView source,
|
public record DocumentView(long documentId, long tenantId, FileView source,
|
||||||
List<Long> inboundIds, List<String> inboundCodes,
|
List<Long> inboundIds, List<String> inboundCodes,
|
||||||
boolean frozen, FileView completion, String receiptStatus,
|
boolean frozen, FileView receipt, String receiptStatus,
|
||||||
String receiptRejectReason) {}
|
String receiptRejectReason, FileView finalDocument,
|
||||||
|
List<ReceiptVersionView> receiptHistory) {}
|
||||||
|
|
||||||
public record ContainerView(long aggregateId, long loadingPlanId, long containerBookingId,
|
public record ContainerView(long aggregateId, long loadingPlanId, long containerBookingId,
|
||||||
String planNo, String cabinetNo, String containerNo, String status, boolean hasActualCargo,
|
String planNo, String cabinetNo, String containerNo, String status, boolean hasActualCargo,
|
||||||
String merchantStatus,
|
String merchantStatus,
|
||||||
int version, int taxRefundCount, int coveredTaxRefundCount, long actualBoxCount,
|
int version, int taxRefundCount, int coveredTaxRefundCount, long actualBoxCount,
|
||||||
List<InboundView> inbounds, List<DocumentView> documents,
|
List<InboundView> inbounds, List<DocumentView> documents,
|
||||||
LocalDateTime startedAt, LocalDateTime completedAt) {}
|
LocalDateTime startedAt, LocalDateTime completedAt, LocalDateTime brokerSubmittedAt) {}
|
||||||
|
|
||||||
public record ScopeRequest(@NotEmpty @Size(max = 200) List<@Positive Long> inboundIds,
|
public record ScopeRequest(@NotEmpty @Size(max = 200) List<@Positive Long> inboundIds,
|
||||||
@Min(0) int expectedVersion) {}
|
@Min(0) int expectedVersion) {}
|
||||||
@@ -35,5 +39,30 @@ public final class ContainerCustomsDtos {
|
|||||||
public record ActionRequest(@Min(0) int expectedVersion, boolean confirm,
|
public record ActionRequest(@Min(0) int expectedVersion, boolean confirm,
|
||||||
@Size(max = 500) String remark) {}
|
@Size(max = 500) String remark) {}
|
||||||
|
|
||||||
|
public record DeclarationBatchView(long id, long customsContainerId, long loadingPlanId,
|
||||||
|
String batchNo, String status, int sourceVersion,
|
||||||
|
String customsEntryNo, long version,
|
||||||
|
LocalDateTime submittedAt, String submittedByName,
|
||||||
|
LocalDateTime acknowledgedAt, String acknowledgedByName,
|
||||||
|
String remark, List<ClearanceResultView> results) {}
|
||||||
|
|
||||||
|
public record DeclarationBatchRequest(@Min(0) int expectedVersion,
|
||||||
|
@Size(max = 64) String batchNo,
|
||||||
|
@Size(max = 64) String customsEntryNo,
|
||||||
|
@Size(max = 500) String remark) {}
|
||||||
|
|
||||||
|
public record ClearanceResultView(long id, long declarationBatchId, long customsContainerId,
|
||||||
|
String resultType, String resultStatus, String customsEntryNo,
|
||||||
|
LocalDateTime occurredAt, String resolution, Long evidenceFileId,
|
||||||
|
long recordedBy, String recordedByName, LocalDateTime recordedAt,
|
||||||
|
Long previousResultId) {}
|
||||||
|
|
||||||
|
public record ClearanceResultRequest(@Min(0) int expectedVersion, @Size(max = 32) String resultType,
|
||||||
|
LocalDateTime occurredAt, @Size(max = 1000) String resolution,
|
||||||
|
Long evidenceFileId, boolean resolved) {}
|
||||||
|
|
||||||
|
public record ClearanceResolveRequest(@Min(0) int expectedVersion,
|
||||||
|
@Size(max = 1000) String resolution) {}
|
||||||
|
|
||||||
public record Download(String fileName, String contentType, byte[] content) {}
|
public record Download(String fileName, String contentType, byte[] content) {}
|
||||||
}
|
}
|
||||||
|
|||||||
+196
-1
@@ -20,6 +20,11 @@ public interface ContainerCustomsMapper {
|
|||||||
r.uploaded_by_name uploadedByName,r.uploaded_at uploadedAt,r.version version,
|
r.uploaded_by_name uploadedByName,r.uploaded_at uploadedAt,r.version version,
|
||||||
r.customer_confirm_status customerConfirmStatus,r.customer_reject_reason customerRejectReason
|
r.customer_confirm_status customerConfirmStatus,r.customer_reject_reason customerRejectReason
|
||||||
""";
|
""";
|
||||||
|
String FINAL_COLUMNS = """
|
||||||
|
f.id id,f.source_document_id sourceDocumentId,f.file_id fileId,f.file_name fileName,
|
||||||
|
f.file_hash fileHash,f.content_type contentType,f.size_bytes sizeBytes,f.uploaded_by uploadedBy,
|
||||||
|
f.uploaded_by_name uploadedByName,f.uploaded_at uploadedAt,f.version version
|
||||||
|
""";
|
||||||
|
|
||||||
@Select("""
|
@Select("""
|
||||||
SELECT DISTINCT p.id loadingPlanId,p.plan_no planNo,p.container_booking_id containerBookingId,
|
SELECT DISTINCT p.id loadingPlanId,p.plan_no planNo,p.container_booking_id containerBookingId,
|
||||||
@@ -102,6 +107,106 @@ public interface ContainerCustomsMapper {
|
|||||||
int start(@Param("id") long id,@Param("version") int version,@Param("operatorId") long operatorId,
|
int start(@Param("id") long id,@Param("version") int version,@Param("operatorId") long operatorId,
|
||||||
@Param("operatorName") String operatorName);
|
@Param("operatorName") String operatorName);
|
||||||
|
|
||||||
|
@Update("""
|
||||||
|
UPDATE fl_customs_container SET broker_submitted_at=CURRENT_TIMESTAMP(3),
|
||||||
|
broker_submitted_by=#{operatorId},broker_submitted_by_name=#{operatorName},
|
||||||
|
updated_at=CURRENT_TIMESTAMP(3),version=version+1
|
||||||
|
WHERE id=#{id} AND version=#{version} AND status='DECLARING' AND broker_submitted_at IS NULL
|
||||||
|
""")
|
||||||
|
int submitToBroker(@Param("id") long id,@Param("version") int version,@Param("operatorId") long operatorId,
|
||||||
|
@Param("operatorName") String operatorName);
|
||||||
|
|
||||||
|
@Select("SELECT broker_submitted_at FROM fl_customs_container WHERE id=#{id}")
|
||||||
|
LocalDateTime brokerSubmittedAt(@Param("id") long id);
|
||||||
|
|
||||||
|
@Select("SELECT " + DECLARATION_BATCH_COLUMNS + " FROM fl_customs_declaration_batch b "
|
||||||
|
+ "WHERE customs_container_id=#{containerId} ORDER BY created_at DESC,id DESC")
|
||||||
|
List<DeclarationBatchRow> declarationBatches(@Param("containerId") long containerId);
|
||||||
|
|
||||||
|
@Select("SELECT " + DECLARATION_BATCH_COLUMNS + " FROM fl_customs_declaration_batch b WHERE b.id=#{id}")
|
||||||
|
DeclarationBatchRow declarationBatch(@Param("id") long id);
|
||||||
|
|
||||||
|
@Select("SELECT " + DECLARATION_BATCH_COLUMNS + " FROM fl_customs_declaration_batch b WHERE b.id=#{id} FOR UPDATE")
|
||||||
|
DeclarationBatchRow lockDeclarationBatch(@Param("id") long id);
|
||||||
|
|
||||||
|
@Insert("""
|
||||||
|
INSERT INTO fl_customs_declaration_batch
|
||||||
|
(id,customs_container_id,loading_plan_id,batch_no,status,source_version,source_snapshot_json,remark,
|
||||||
|
version,created_at,updated_at)
|
||||||
|
VALUES(#{id},#{containerId},#{planId},#{batchNo},'READY',#{sourceVersion},CAST(#{snapshotJson} AS JSON),#{remark},
|
||||||
|
0,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3))
|
||||||
|
""")
|
||||||
|
int insertDeclarationBatch(@Param("id") long id,@Param("containerId") long containerId,@Param("planId") long planId,
|
||||||
|
@Param("batchNo") String batchNo,@Param("sourceVersion") int sourceVersion,
|
||||||
|
@Param("snapshotJson") String snapshotJson,@Param("remark") String remark);
|
||||||
|
|
||||||
|
@Update("""
|
||||||
|
UPDATE fl_customs_declaration_batch SET batch_no=#{batchNo},status='SUBMITTED',customs_entry_no=#{entryNo},
|
||||||
|
submitted_by=#{operatorId},submitted_by_name=#{operatorName},submitted_at=CURRENT_TIMESTAMP(3),remark=#{remark},
|
||||||
|
version=version+1,updated_at=CURRENT_TIMESTAMP(3)
|
||||||
|
WHERE id=#{id} AND version=#{version} AND status='READY'
|
||||||
|
""")
|
||||||
|
int submitDeclarationBatch(@Param("id") long id,@Param("version") int version,@Param("batchNo") String batchNo,
|
||||||
|
@Param("entryNo") String entryNo,@Param("operatorId") long operatorId,
|
||||||
|
@Param("operatorName") String operatorName,@Param("remark") String remark);
|
||||||
|
|
||||||
|
@Update("""
|
||||||
|
UPDATE fl_customs_declaration_batch SET status=#{status},acknowledged_by=#{operatorId},
|
||||||
|
acknowledged_by_name=#{operatorName},acknowledged_at=CURRENT_TIMESTAMP(3),version=version+1,updated_at=CURRENT_TIMESTAMP(3)
|
||||||
|
WHERE id=#{id} AND version=#{version} AND status IN ('SUBMITTED','RESULT_PENDING','ACKNOWLEDGED')
|
||||||
|
""")
|
||||||
|
int acknowledgeDeclarationBatch(@Param("id") long id,@Param("version") int version,@Param("status") String status,
|
||||||
|
@Param("operatorId") long operatorId,@Param("operatorName") String operatorName);
|
||||||
|
|
||||||
|
@Select("SELECT " + CLEARANCE_RESULT_COLUMNS + " FROM fl_customs_clearance_result r "
|
||||||
|
+ "WHERE declaration_batch_id=#{batchId} ORDER BY occurred_at,id")
|
||||||
|
List<ClearanceResultRow> clearanceResults(@Param("batchId") long batchId);
|
||||||
|
|
||||||
|
@Select("SELECT " + CLEARANCE_RESULT_COLUMNS + " FROM fl_customs_clearance_result r WHERE r.id=#{id}")
|
||||||
|
ClearanceResultRow clearanceResult(@Param("id") long id);
|
||||||
|
|
||||||
|
@Select("SELECT " + CLEARANCE_RESULT_COLUMNS + " FROM fl_customs_clearance_result r WHERE r.id=#{id} FOR UPDATE")
|
||||||
|
ClearanceResultRow lockClearanceResult(@Param("id") long id);
|
||||||
|
|
||||||
|
@Insert("""
|
||||||
|
INSERT INTO fl_customs_clearance_result
|
||||||
|
(id,declaration_batch_id,customs_container_id,result_type,result_status,customs_entry_no,occurred_at,
|
||||||
|
resolution,evidence_file_id,recorded_by,recorded_by_name,recorded_at,previous_result_id,created_at,updated_at)
|
||||||
|
VALUES(#{id},#{batchId},#{containerId},#{resultType},#{resultStatus},#{entryNo},#{occurredAt},#{resolution},
|
||||||
|
#{evidenceFileId},#{operatorId},#{operatorName},CURRENT_TIMESTAMP(3),#{previousResultId},CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3))
|
||||||
|
""")
|
||||||
|
int insertClearanceResult(@Param("id") long id,@Param("batchId") long batchId,@Param("containerId") long containerId,
|
||||||
|
@Param("resultType") String resultType,@Param("resultStatus") String resultStatus,
|
||||||
|
@Param("entryNo") String entryNo,@Param("occurredAt") LocalDateTime occurredAt,
|
||||||
|
@Param("resolution") String resolution,@Param("evidenceFileId") Long evidenceFileId,
|
||||||
|
@Param("operatorId") long operatorId,@Param("operatorName") String operatorName,
|
||||||
|
@Param("previousResultId") Long previousResultId);
|
||||||
|
|
||||||
|
@Update("""
|
||||||
|
UPDATE fl_customs_clearance_result SET result_status='RESOLVED',resolution=#{resolution},updated_at=CURRENT_TIMESTAMP(3)
|
||||||
|
WHERE id=#{id} AND result_status='OPEN'
|
||||||
|
""")
|
||||||
|
int resolveClearanceResult(@Param("id") long id,@Param("resolution") String resolution);
|
||||||
|
|
||||||
|
@Select("SELECT COUNT(*) FROM fl_customs_clearance_result WHERE declaration_batch_id=#{batchId} AND result_status='OPEN'")
|
||||||
|
int openClearanceResultCount(@Param("batchId") long batchId);
|
||||||
|
|
||||||
|
@Update("UPDATE fl_customs_declaration_batch SET status='CLOSED',version=version+1,updated_at=CURRENT_TIMESTAMP(3) WHERE id=#{id} AND status IN ('SUBMITTED','ACKNOWLEDGED','RESULT_PENDING')")
|
||||||
|
int closeDeclarationBatch(@Param("id") long id);
|
||||||
|
|
||||||
|
@Update("UPDATE fl_customs_declaration_batch SET status='RESULT_PENDING',version=version+1,updated_at=CURRENT_TIMESTAMP(3) WHERE id=#{id} AND status IN ('SUBMITTED','ACKNOWLEDGED')")
|
||||||
|
int markDeclarationResultPending(@Param("id") long id);
|
||||||
|
|
||||||
|
@Select("""
|
||||||
|
SELECT DISTINCT b.id
|
||||||
|
FROM fl_customs_declaration_batch b
|
||||||
|
JOIN fl_customs_clearance_result r ON r.declaration_batch_id=b.id
|
||||||
|
JOIN fl_customs_customer_document d ON d.customs_container_id=b.customs_container_id AND d.tenant_id=#{tenantId} AND d.status='ACTIVE'
|
||||||
|
JOIN fl_customs_frozen_document_inbound s ON s.document_id=d.id AND s.order_id=#{orderId}
|
||||||
|
ORDER BY b.id DESC
|
||||||
|
""")
|
||||||
|
List<Long> merchantDeclarationBatchIds(@Param("tenantId") long tenantId,@Param("orderId") long orderId);
|
||||||
|
|
||||||
@Update("""
|
@Update("""
|
||||||
UPDATE fl_customs_container SET status='COMPLETED',completed_by=#{operatorId},completed_by_name=#{operatorName},
|
UPDATE fl_customs_container SET status='COMPLETED',completed_by=#{operatorId},completed_by_name=#{operatorName},
|
||||||
completed_at=CURRENT_TIMESTAMP(3),updated_at=CURRENT_TIMESTAMP(3),version=version+1
|
completed_at=CURRENT_TIMESTAMP(3),updated_at=CURRENT_TIMESTAMP(3),version=version+1
|
||||||
@@ -200,6 +305,14 @@ public interface ContainerCustomsMapper {
|
|||||||
""")
|
""")
|
||||||
int missingRefundCount(@Param("planId") long planId,@Param("containerId") long containerId);
|
int missingRefundCount(@Param("planId") long planId,@Param("containerId") long containerId);
|
||||||
|
|
||||||
|
@Select("""
|
||||||
|
SELECT COUNT(DISTINCT i.id)
|
||||||
|
FROM fl_loading_plan_item item
|
||||||
|
JOIN fl_inbound_order i ON i.id=item.inbound_id AND i.tenant_id=item.account_id AND i.deleted=0
|
||||||
|
WHERE item.loading_plan_id=#{planId} AND item.deleted=0 AND i.tax_mode='TAX_REFUND'
|
||||||
|
""")
|
||||||
|
int taxRefundCount(@Param("planId") long planId);
|
||||||
|
|
||||||
@Update("UPDATE fl_customs_customer_document SET frozen_version=version,frozen_at=CURRENT_TIMESTAMP(3) " +
|
@Update("UPDATE fl_customs_customer_document SET frozen_version=version,frozen_at=CURRENT_TIMESTAMP(3) " +
|
||||||
"WHERE customs_container_id=#{containerId} AND status='ACTIVE' AND frozen_at IS NULL")
|
"WHERE customs_container_id=#{containerId} AND status='ACTIVE' AND frozen_at IS NULL")
|
||||||
int freezeDocuments(@Param("containerId") long containerId);
|
int freezeDocuments(@Param("containerId") long containerId);
|
||||||
@@ -227,6 +340,12 @@ public interface ContainerCustomsMapper {
|
|||||||
"WHERE r.source_document_id=#{documentId} FOR UPDATE")
|
"WHERE r.source_document_id=#{documentId} FOR UPDATE")
|
||||||
CompletionRow lockCompletion(@Param("documentId") long documentId);
|
CompletionRow lockCompletion(@Param("documentId") long documentId);
|
||||||
|
|
||||||
|
@Select("SELECT v.document_version documentVersion,v.file_id fileId,v.file_name fileName,v.file_hash fileHash,"
|
||||||
|
+ "v.content_type contentType,v.size_bytes sizeBytes,v.operator_id operatorId,v.operator_name operatorName,v.created_at createdAt "
|
||||||
|
+ "FROM fl_customs_completion_document_version v WHERE v.completion_document_id=#{completionId} "
|
||||||
|
+ "ORDER BY v.document_version")
|
||||||
|
List<CompletionVersionRow> completionVersions(@Param("completionId") long completionId);
|
||||||
|
|
||||||
@Insert("""
|
@Insert("""
|
||||||
INSERT INTO fl_customs_completion_document
|
INSERT INTO fl_customs_completion_document
|
||||||
(id,source_document_id,file_id,file_name,file_hash,content_type,size_bytes,uploaded_by,uploaded_by_name,
|
(id,source_document_id,file_id,file_name,file_hash,content_type,size_bytes,uploaded_by,uploaded_by_name,
|
||||||
@@ -268,6 +387,22 @@ public interface ContainerCustomsMapper {
|
|||||||
@Param("status") String status,@Param("reason") String reason,
|
@Param("status") String status,@Param("reason") String reason,
|
||||||
@Param("operatorId") long operatorId,@Param("operatorName") String operatorName);
|
@Param("operatorId") long operatorId,@Param("operatorName") String operatorName);
|
||||||
|
|
||||||
|
@Select("""
|
||||||
|
SELECT COUNT(*) FROM fl_customs_customer_document d
|
||||||
|
JOIN fl_customs_completion_document r ON r.source_document_id=d.id
|
||||||
|
WHERE d.customs_container_id=#{containerId} AND d.status='ACTIVE'
|
||||||
|
AND r.customer_confirm_status='CONFIRMED'
|
||||||
|
""")
|
||||||
|
int confirmedReceiptCount(@Param("containerId") long containerId);
|
||||||
|
|
||||||
|
@Select("""
|
||||||
|
SELECT COUNT(*) FROM fl_customs_customer_document d
|
||||||
|
LEFT JOIN fl_customs_completion_document r ON r.source_document_id=d.id
|
||||||
|
WHERE d.customs_container_id=#{containerId} AND d.status='ACTIVE'
|
||||||
|
AND (r.id IS NULL OR r.customer_confirm_status<>'CONFIRMED')
|
||||||
|
""")
|
||||||
|
int unconfirmedReceiptCount(@Param("containerId") long containerId);
|
||||||
|
|
||||||
@Select("""
|
@Select("""
|
||||||
SELECT COUNT(*) FROM fl_customs_customer_document d
|
SELECT COUNT(*) FROM fl_customs_customer_document d
|
||||||
JOIN fl_customs_completion_document r ON r.source_document_id=d.id
|
JOIN fl_customs_completion_document r ON r.source_document_id=d.id
|
||||||
@@ -288,6 +423,40 @@ public interface ContainerCustomsMapper {
|
|||||||
@Param("contentType") String contentType,@Param("sizeBytes") long sizeBytes,
|
@Param("contentType") String contentType,@Param("sizeBytes") long sizeBytes,
|
||||||
@Param("operatorId") long operatorId,@Param("operatorName") String operatorName);
|
@Param("operatorId") long operatorId,@Param("operatorName") String operatorName);
|
||||||
|
|
||||||
|
@Select("SELECT " + FINAL_COLUMNS + " FROM fl_customs_final_document f WHERE f.source_document_id=#{documentId}")
|
||||||
|
FinalRow finalDocument(@Param("documentId") long documentId);
|
||||||
|
|
||||||
|
@Select("SELECT " + FINAL_COLUMNS + " FROM fl_customs_final_document f WHERE f.source_document_id=#{documentId} FOR UPDATE")
|
||||||
|
FinalRow lockFinalDocument(@Param("documentId") long documentId);
|
||||||
|
|
||||||
|
@Insert("""
|
||||||
|
INSERT INTO fl_customs_final_document
|
||||||
|
(id,source_document_id,file_id,file_name,file_hash,content_type,size_bytes,uploaded_by,uploaded_by_name,
|
||||||
|
uploaded_at,version,created_at,updated_at)
|
||||||
|
VALUES(#{id},#{documentId},#{fileId},#{fileName},#{fileHash},#{contentType},#{sizeBytes},#{operatorId},#{operatorName},
|
||||||
|
CURRENT_TIMESTAMP(3),1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3))
|
||||||
|
""")
|
||||||
|
int insertFinal(@Param("id") long id,@Param("documentId") long documentId,@Param("fileId") long fileId,
|
||||||
|
@Param("fileName") String fileName,@Param("fileHash") String fileHash,@Param("contentType") String contentType,
|
||||||
|
@Param("sizeBytes") long sizeBytes,@Param("operatorId") long operatorId,@Param("operatorName") String operatorName);
|
||||||
|
|
||||||
|
@Update("""
|
||||||
|
UPDATE fl_customs_final_document SET file_id=#{fileId},file_name=#{fileName},file_hash=#{fileHash},
|
||||||
|
content_type=#{contentType},size_bytes=#{sizeBytes},uploaded_by=#{operatorId},uploaded_by_name=#{operatorName},
|
||||||
|
uploaded_at=CURRENT_TIMESTAMP(3),version=version+1,updated_at=CURRENT_TIMESTAMP(3)
|
||||||
|
WHERE id=#{id} AND version=#{version}
|
||||||
|
""")
|
||||||
|
int replaceFinal(@Param("id") long id,@Param("version") int version,@Param("fileId") long fileId,
|
||||||
|
@Param("fileName") String fileName,@Param("fileHash") String fileHash,@Param("contentType") String contentType,
|
||||||
|
@Param("sizeBytes") long sizeBytes,@Param("operatorId") long operatorId,@Param("operatorName") String operatorName);
|
||||||
|
|
||||||
|
@Select("""
|
||||||
|
SELECT COUNT(*) FROM fl_customs_customer_document d
|
||||||
|
LEFT JOIN fl_customs_final_document f ON f.source_document_id=d.id
|
||||||
|
WHERE d.customs_container_id=#{containerId} AND d.status='ACTIVE' AND f.id IS NULL
|
||||||
|
""")
|
||||||
|
int missingFinalCount(@Param("containerId") long containerId);
|
||||||
|
|
||||||
@Insert("""
|
@Insert("""
|
||||||
INSERT INTO fl_customs_container_history
|
INSERT INTO fl_customs_container_history
|
||||||
(id,customs_container_id,action,from_status,to_status,remark,operator_id,operator_name,created_at)
|
(id,customs_container_id,action,from_status,to_status,remark,operator_id,operator_name,created_at)
|
||||||
@@ -374,7 +543,15 @@ public interface ContainerCustomsMapper {
|
|||||||
String cabinetNo,String containerNo) {}
|
String cabinetNo,String containerNo) {}
|
||||||
record ContainerRow(Long id,Long loadingPlanId,Long containerBookingId,String status,Long startedBy,
|
record ContainerRow(Long id,Long loadingPlanId,Long containerBookingId,String status,Long startedBy,
|
||||||
String startedByName,LocalDateTime startedAt,Long completedBy,String completedByName,
|
String startedByName,LocalDateTime startedAt,Long completedBy,String completedByName,
|
||||||
LocalDateTime completedAt,LocalDateTime createdAt,LocalDateTime updatedAt,Integer version) {}
|
LocalDateTime completedAt,Long brokerSubmittedBy,String brokerSubmittedByName,
|
||||||
|
LocalDateTime brokerSubmittedAt,LocalDateTime createdAt,LocalDateTime updatedAt,Integer version) {
|
||||||
|
public ContainerRow(Long id,Long loadingPlanId,Long containerBookingId,String status,Long startedBy,
|
||||||
|
String startedByName,LocalDateTime startedAt,Long completedBy,String completedByName,
|
||||||
|
LocalDateTime completedAt,LocalDateTime createdAt,LocalDateTime updatedAt,Integer version) {
|
||||||
|
this(id, loadingPlanId, containerBookingId, status, startedBy, startedByName, startedAt,
|
||||||
|
completedBy, completedByName, completedAt, null, null, null, createdAt, updatedAt, version);
|
||||||
|
}
|
||||||
|
}
|
||||||
record DocumentRow(Long id,Long customsContainerId,Long tenantId,Long fileId,String fileName,String fileHash,
|
record DocumentRow(Long id,Long customsContainerId,Long tenantId,Long fileId,String fileName,String fileHash,
|
||||||
String contentType,Long sizeBytes,String status,Long uploadedBy,LocalDateTime uploadedAt,
|
String contentType,Long sizeBytes,String status,Long uploadedBy,LocalDateTime uploadedAt,
|
||||||
Integer frozenVersion,LocalDateTime frozenAt,Integer version) {}
|
Integer frozenVersion,LocalDateTime frozenAt,Integer version) {}
|
||||||
@@ -382,8 +559,26 @@ public interface ContainerCustomsMapper {
|
|||||||
record CompletionRow(Long id,Long sourceDocumentId,Long fileId,String fileName,String fileHash,String contentType,
|
record CompletionRow(Long id,Long sourceDocumentId,Long fileId,String fileName,String fileHash,String contentType,
|
||||||
Long sizeBytes,Long uploadedBy,String uploadedByName,LocalDateTime uploadedAt,Integer version,
|
Long sizeBytes,Long uploadedBy,String uploadedByName,LocalDateTime uploadedAt,Integer version,
|
||||||
String customerConfirmStatus,String customerRejectReason) {}
|
String customerConfirmStatus,String customerRejectReason) {}
|
||||||
|
record CompletionVersionRow(Integer documentVersion,Long fileId,String fileName,String fileHash,String contentType,
|
||||||
|
Long sizeBytes,Long operatorId,String operatorName,LocalDateTime createdAt) {}
|
||||||
|
record FinalRow(Long id,Long sourceDocumentId,Long fileId,String fileName,String fileHash,String contentType,
|
||||||
|
Long sizeBytes,Long uploadedBy,String uploadedByName,LocalDateTime uploadedAt,Integer version) {}
|
||||||
record CommandRow(Long id,Long actorId,String action,String idempotencyKey,String requestHash,String status,
|
record CommandRow(Long id,Long actorId,String action,String idempotencyKey,String requestHash,String status,
|
||||||
Long resultId,LocalDateTime createdAt,LocalDateTime completedAt) {}
|
Long resultId,LocalDateTime createdAt,LocalDateTime completedAt) {}
|
||||||
|
String DECLARATION_BATCH_COLUMNS = "b.id id,b.customs_container_id customsContainerId,b.loading_plan_id loadingPlanId,"+
|
||||||
|
"b.batch_no batchNo,b.status status,b.source_version sourceVersion,b.customs_entry_no customsEntryNo,b.submitted_by_name submittedByName,"+
|
||||||
|
"b.submitted_at submittedAt,b.acknowledged_by_name acknowledgedByName,b.acknowledged_at acknowledgedAt,b.remark remark,b.version version,b.created_at createdAt,b.updated_at updatedAt";
|
||||||
|
String CLEARANCE_RESULT_COLUMNS = "r.id id,r.declaration_batch_id declarationBatchId,r.customs_container_id customsContainerId,"+
|
||||||
|
"r.result_type resultType,r.result_status resultStatus,r.customs_entry_no customsEntryNo,r.occurred_at occurredAt,"+
|
||||||
|
"r.resolution resolution,r.evidence_file_id evidenceFileId,r.recorded_by recordedBy,r.recorded_by_name recordedByName,"+
|
||||||
|
"r.recorded_at recordedAt,r.previous_result_id previousResultId";
|
||||||
|
record DeclarationBatchRow(Long id,Long customsContainerId,Long loadingPlanId,String batchNo,String status,
|
||||||
|
Integer sourceVersion,String customsEntryNo,String submittedByName,LocalDateTime submittedAt,
|
||||||
|
String acknowledgedByName,LocalDateTime acknowledgedAt,String remark,Integer version,
|
||||||
|
LocalDateTime createdAt,LocalDateTime updatedAt) {}
|
||||||
|
record ClearanceResultRow(Long id,Long declarationBatchId,Long customsContainerId,String resultType,String resultStatus,
|
||||||
|
String customsEntryNo,LocalDateTime occurredAt,String resolution,Long evidenceFileId,
|
||||||
|
Long recordedBy,String recordedByName,LocalDateTime recordedAt,Long previousResultId) {}
|
||||||
record BuyExportRow(Long itemId,String planNo,String cabinetNo,String containerNo,String customerName,String orderNo,
|
record BuyExportRow(Long itemId,String planNo,String cabinetNo,String containerNo,String customerName,String orderNo,
|
||||||
String inboundCode,String skuCode,String productNameZh,String productNameEn,Integer packageCount,
|
String inboundCode,String skuCode,String productNameZh,String productNameEn,Integer packageCount,
|
||||||
Integer productQuantity,BigDecimal grossWeightKg,BigDecimal netWeightKg,BigDecimal volumeCbm,
|
Integer productQuantity,BigDecimal grossWeightKg,BigDecimal netWeightKg,BigDecimal volumeCbm,
|
||||||
|
|||||||
+229
-9
@@ -33,6 +33,7 @@ public class ContainerCustomsService {
|
|||||||
public static final String DECLARING = "DECLARING";
|
public static final String DECLARING = "DECLARING";
|
||||||
public static final String COMPLETED = "COMPLETED";
|
public static final String COMPLETED = "COMPLETED";
|
||||||
private static final String SOURCE_FILE_TYPE = "LOADING_LIST_CUSTOMS_SOURCE";
|
private static final String SOURCE_FILE_TYPE = "LOADING_LIST_CUSTOMS_SOURCE";
|
||||||
|
private static final String RECEIPT_FILE_TYPE = "LOADING_LIST_CUSTOMS_RECEIPT";
|
||||||
private static final String RESULT_FILE_TYPE = "LOADING_LIST_CUSTOMS_RESULT";
|
private static final String RESULT_FILE_TYPE = "LOADING_LIST_CUSTOMS_RESULT";
|
||||||
private static final long MAX_FILE_BYTES = 50L * 1024 * 1024;
|
private static final long MAX_FILE_BYTES = 50L * 1024 * 1024;
|
||||||
|
|
||||||
@@ -172,6 +173,25 @@ public class ContainerCustomsService {
|
|||||||
return platformView(planId);
|
return platformView(planId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
public ContainerView submitToBroker(long planId, long operatorId, String operatorName, String key, ActionRequest request) {
|
||||||
|
String action = "SUBMIT_CUSTOMS_TO_BROKER_" + planId;
|
||||||
|
if (!request.confirm()) throw bad("请确认已将客户确认结果发送给报关行");
|
||||||
|
String requestHash = hash(action, request.expectedVersion(), request.confirm(), request.remark());
|
||||||
|
CommandRow replay = claim(operatorId, action, key, requestHash);
|
||||||
|
if (replay != null) return platformView(planId);
|
||||||
|
ContainerRow container = mapper.lockContainer(planId);
|
||||||
|
if (container == null || !DECLARING.equals(container.status())) throw conflict("当前柜不在报关中");
|
||||||
|
if (!Objects.equals(container.version(), request.expectedVersion())) throw concurrent();
|
||||||
|
if (mapper.taxRefundCount(container.loadingPlanId()) == 0) throw conflict("全买单柜无需发送客户回执给报关行");
|
||||||
|
if (mapper.unconfirmedReceiptCount(container.id()) != 0) throw conflict("仍有报关行回执未完成客户确认");
|
||||||
|
if (mapper.brokerSubmittedAt(container.id()) != null) throw conflict("当前柜已发送给报关行执行");
|
||||||
|
if (mapper.submitToBroker(container.id(), container.version(), operatorId, operatorName) != 1) throw concurrent();
|
||||||
|
history(container, "SUBMIT_CUSTOMS_TO_BROKER", container.status(), container.status(), request.remark(), operatorId, operatorName);
|
||||||
|
completeCommand(operatorId, action, key, container.id());
|
||||||
|
return platformView(planId);
|
||||||
|
}
|
||||||
|
|
||||||
@Transactional
|
@Transactional
|
||||||
public DocumentView uploadCompletion(long documentId, long operatorId, String operatorName, String key,
|
public DocumentView uploadCompletion(long documentId, long operatorId, String operatorName, String key,
|
||||||
int expectedVersion, MultipartFile file) {
|
int expectedVersion, MultipartFile file) {
|
||||||
@@ -186,8 +206,11 @@ public class ContainerCustomsService {
|
|||||||
if (container == null || !DECLARING.equals(container.status()) || source.frozenAt() == null) {
|
if (container == null || !DECLARING.equals(container.status()) || source.frozenAt() == null) {
|
||||||
throw conflict("当前客户资料不在报关中冻结版本");
|
throw conflict("当前客户资料不在报关中冻结版本");
|
||||||
}
|
}
|
||||||
|
if (mapper.brokerSubmittedAt(container.id()) != null) {
|
||||||
|
throw conflict("客户确认回执已发送给报关行,不可再修改回执");
|
||||||
|
}
|
||||||
CompletionRow completion = mapper.lockCompletion(documentId);
|
CompletionRow completion = mapper.lockCompletion(documentId);
|
||||||
CustomsFileClient.Metadata metadata = upload(source.tenantId(), RESULT_FILE_TYPE, Long.toString(container.loadingPlanId()), operatorId, file);
|
CustomsFileClient.Metadata metadata = upload(source.tenantId(), RECEIPT_FILE_TYPE, Long.toString(container.loadingPlanId()), operatorId, file);
|
||||||
long completionId;
|
long completionId;
|
||||||
int version;
|
int version;
|
||||||
if (completion == null) {
|
if (completion == null) {
|
||||||
@@ -210,7 +233,7 @@ public class ContainerCustomsService {
|
|||||||
public ContainerView confirmReceipt(long tenantId, long documentId, long operatorId, String operatorName,
|
public ContainerView confirmReceipt(long tenantId, long documentId, long operatorId, String operatorName,
|
||||||
String key, int expectedVersion, boolean confirm, String reason) {
|
String key, int expectedVersion, boolean confirm, String reason) {
|
||||||
String action = (confirm ? "CONFIRM_CUSTOMS_RECEIPT_" : "REJECT_CUSTOMS_RECEIPT_") + documentId;
|
String action = (confirm ? "CONFIRM_CUSTOMS_RECEIPT_" : "REJECT_CUSTOMS_RECEIPT_") + documentId;
|
||||||
String requestHash = hash(action, confirm, reason);
|
String requestHash = hash(action, expectedVersion, confirm, reason);
|
||||||
CommandRow replay = claim(operatorId, action, key, requestHash);
|
CommandRow replay = claim(operatorId, action, key, requestHash);
|
||||||
if (replay != null) {
|
if (replay != null) {
|
||||||
DocumentRow source = requiredDocument(documentId);
|
DocumentRow source = requiredDocument(documentId);
|
||||||
@@ -227,12 +250,47 @@ public class ContainerCustomsService {
|
|||||||
if (mapper.confirmReceipt(documentId, tenantId, confirm ? "CONFIRMED" : "REJECTED",
|
if (mapper.confirmReceipt(documentId, tenantId, confirm ? "CONFIRMED" : "REJECTED",
|
||||||
confirm ? null : trim(reason), operatorId, operatorName) != 1) throw conflict("回执状态已变化,请刷新后重试");
|
confirm ? null : trim(reason), operatorId, operatorName) != 1) throw conflict("回执状态已变化,请刷新后重试");
|
||||||
if (confirm && mapper.pendingReceiptConfirmationCount(container.id()) == 0) {
|
if (confirm && mapper.pendingReceiptConfirmationCount(container.id()) == 0) {
|
||||||
completeContainer(container, operatorId, operatorName, "客户已确认全部报关行回执");
|
history(container, "ALL_CUSTOMS_RECEIPTS_CONFIRMED", container.status(), container.status(), "客户已确认全部报关行回执,待发送给报关行执行", operatorId, operatorName);
|
||||||
}
|
}
|
||||||
completeCommand(operatorId, action, key, documentId);
|
completeCommand(operatorId, action, key, documentId);
|
||||||
return merchantViewForDocument(tenantId, source);
|
return merchantViewForDocument(tenantId, source);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
public DocumentView uploadFinalDocument(long documentId, long operatorId, String operatorName, String key,
|
||||||
|
int expectedVersion, MultipartFile file) {
|
||||||
|
validateFile(file);
|
||||||
|
DocumentRow readable = requiredDocument(documentId);
|
||||||
|
String action = "UPLOAD_FINAL_CUSTOMS_DOCUMENT_" + documentId;
|
||||||
|
String requestHash = hash(action, expectedVersion, fileHash(file));
|
||||||
|
CommandRow replay = claim(operatorId, action, key, requestHash);
|
||||||
|
if (replay != null) return documentView(requiredDocument(documentId), false);
|
||||||
|
ContainerRow container = mapper.lockContainerById(readable.customsContainerId());
|
||||||
|
DocumentRow source = requiredDocumentLocked(documentId, null);
|
||||||
|
if (container == null || !DECLARING.equals(container.status()) || source.frozenAt() == null) {
|
||||||
|
throw conflict("当前客户资料不在报关中冻结版本");
|
||||||
|
}
|
||||||
|
if (mapper.brokerSubmittedAt(container.id()) == null) throw conflict("客户确认结果尚未发送给报关行");
|
||||||
|
CompletionRow receipt = mapper.lockCompletion(documentId);
|
||||||
|
if (receipt == null || !"CONFIRMED".equals(receipt.customerConfirmStatus())) throw conflict("该客户回执尚未确认");
|
||||||
|
FinalRow existing = mapper.lockFinalDocument(documentId);
|
||||||
|
CustomsFileClient.Metadata metadata = upload(source.tenantId(), RESULT_FILE_TYPE, Long.toString(container.loadingPlanId()), operatorId, file);
|
||||||
|
long finalId;
|
||||||
|
int version;
|
||||||
|
if (existing == null) {
|
||||||
|
if (expectedVersion != 0) throw concurrent();
|
||||||
|
finalId = IdWorker.getId(); version = 1;
|
||||||
|
mapper.insertFinal(finalId, documentId, metadata.fileId(), metadata.fileName(), metadata.sha256(), metadata.contentType(), size(metadata), operatorId, operatorName);
|
||||||
|
} else {
|
||||||
|
finalId = existing.id(); version = existing.version() + 1;
|
||||||
|
if (mapper.replaceFinal(existing.id(), expectedVersion, metadata.fileId(), metadata.fileName(), metadata.sha256(), metadata.contentType(), size(metadata), operatorId, operatorName) != 1) throw concurrent();
|
||||||
|
}
|
||||||
|
history(container, "UPLOAD_FINAL_CUSTOMS_DOCUMENT", container.status(), container.status(), "真实报关完税文件已上传", operatorId, operatorName);
|
||||||
|
if (mapper.missingFinalCount(container.id()) == 0) completeContainer(container, operatorId, operatorName, "全部真实报关完税文件已上传");
|
||||||
|
completeCommand(operatorId, action, key, finalId);
|
||||||
|
return documentView(requiredDocument(documentId), false);
|
||||||
|
}
|
||||||
|
|
||||||
@Transactional
|
@Transactional
|
||||||
public ContainerView completeAllBuy(long planId, long operatorId, String operatorName, String key, ActionRequest request) {
|
public ContainerView completeAllBuy(long planId, long operatorId, String operatorName, String key, ActionRequest request) {
|
||||||
String action = "COMPLETE_ALL_BUY_CUSTOMS_" + planId;
|
String action = "COMPLETE_ALL_BUY_CUSTOMS_" + planId;
|
||||||
@@ -251,6 +309,148 @@ public class ContainerCustomsService {
|
|||||||
return platformView(planId);
|
return platformView(planId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public List<DeclarationBatchView> platformDeclarationBatches(long planId) {
|
||||||
|
ContainerRow container = mapper.container(planId);
|
||||||
|
if (container == null) throw notFound("整柜报关尚未生成");
|
||||||
|
return mapper.declarationBatches(container.id()).stream().map(this::declarationBatchView).toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
public DeclarationBatchView createDeclarationBatch(long planId, long operatorId, String operatorName,
|
||||||
|
String key, DeclarationBatchRequest request) {
|
||||||
|
String action = "CREATE_CUSTOMS_DECLARATION_BATCH_" + planId;
|
||||||
|
String requestHash = hash(action, request.expectedVersion(), request.batchNo(), request.remark());
|
||||||
|
CommandRow replay = claim(operatorId, action, key, requestHash);
|
||||||
|
if (replay != null) return declarationBatchView(mapper.declarationBatch(replay.resultId()));
|
||||||
|
ContainerRow container = mapper.lockContainer(planId);
|
||||||
|
if (container == null || !(DECLARING.equals(container.status()) || COMPLETED.equals(container.status()))) throw conflict("当前柜尚未进入报关行执行阶段,不能创建正式申报批次");
|
||||||
|
if (mapper.taxRefundCount(planId) == 0) throw conflict("全买单柜不创建退税正式申报批次");
|
||||||
|
if (mapper.unconfirmedReceiptCount(container.id()) != 0 || mapper.brokerSubmittedAt(container.id()) == null) {
|
||||||
|
throw conflict("当前柜尚未完成退税资料、回执确认和报关行执行");
|
||||||
|
}
|
||||||
|
if (!Objects.equals(container.version(), request.expectedVersion())) throw concurrent();
|
||||||
|
long batchId = IdWorker.getId();
|
||||||
|
String batchNo = trim(request.batchNo());
|
||||||
|
if (batchNo == null) batchNo = "DECL-" + batchId;
|
||||||
|
String snapshot = declarationSnapshot(container.id());
|
||||||
|
if (mapper.insertDeclarationBatch(batchId, container.id(), planId, batchNo, container.version(), snapshot,
|
||||||
|
trim(request.remark())) != 1) throw conflict("正式申报批次创建失败");
|
||||||
|
history(container, "CREATE_CUSTOMS_DECLARATION_BATCH", container.status(), container.status(), batchNo, operatorId, operatorName);
|
||||||
|
completeCommand(operatorId, action, key, batchId);
|
||||||
|
return declarationBatchView(mapper.declarationBatch(batchId));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
public DeclarationBatchView submitDeclarationBatch(long batchId, long operatorId, String operatorName,
|
||||||
|
String key, DeclarationBatchRequest request) {
|
||||||
|
String action = "SUBMIT_CUSTOMS_DECLARATION_BATCH_" + batchId;
|
||||||
|
String entryNo = trim(request.customsEntryNo());
|
||||||
|
if (entryNo == null) throw bad("正式申报必须回填报关单号");
|
||||||
|
String requestHash = hash(action, request.expectedVersion(), request.batchNo(), entryNo, request.remark());
|
||||||
|
CommandRow replay = claim(operatorId, action, key, requestHash);
|
||||||
|
if (replay != null) return declarationBatchView(mapper.declarationBatch(batchId));
|
||||||
|
DeclarationBatchRow batch = mapper.lockDeclarationBatch(batchId);
|
||||||
|
if (batch == null || !"READY".equals(batch.status())) throw conflict("正式申报批次不在可提交状态");
|
||||||
|
if (!Objects.equals(batch.version(), request.expectedVersion())) throw concurrent();
|
||||||
|
String batchNo = trim(request.batchNo());
|
||||||
|
if (batchNo == null) batchNo = batch.batchNo();
|
||||||
|
if (mapper.submitDeclarationBatch(batchId, batch.version(), batchNo, entryNo, operatorId, operatorName,
|
||||||
|
trim(request.remark())) != 1) throw concurrent();
|
||||||
|
ContainerRow container = mapper.lockContainerById(batch.customsContainerId());
|
||||||
|
if (container != null) history(container, "SUBMIT_CUSTOMS_DECLARATION_BATCH", container.status(), container.status(), entryNo, operatorId, operatorName);
|
||||||
|
completeCommand(operatorId, action, key, batchId);
|
||||||
|
return declarationBatchView(mapper.declarationBatch(batchId));
|
||||||
|
}
|
||||||
|
|
||||||
|
public List<ClearanceResultView> platformClearanceResults(long batchId) {
|
||||||
|
if (mapper.declarationBatch(batchId) == null) throw notFound("正式申报批次不存在");
|
||||||
|
return mapper.clearanceResults(batchId).stream().map(this::clearanceResultView).toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
public ClearanceResultView addClearanceResult(long batchId, long operatorId, String operatorName,
|
||||||
|
String key, ClearanceResultRequest request) {
|
||||||
|
String type = trim(request.resultType());
|
||||||
|
if (!Set.of("INSPECTION", "SUPPLEMENT_REQUIRED", "RELEASED", "DETAINED", "OTHER").contains(type)) {
|
||||||
|
throw bad("海关结果类型无效");
|
||||||
|
}
|
||||||
|
if (request.occurredAt() == null) throw bad("海关结果发生时间不能为空");
|
||||||
|
if (Set.of("DETAINED", "OTHER", "SUPPLEMENT_REQUIRED").contains(type)
|
||||||
|
&& (request.resolution() == null || request.resolution().isBlank())) throw bad("该海关结果必须填写处理说明");
|
||||||
|
String action = "ADD_CUSTOMS_CLEARANCE_RESULT_" + batchId;
|
||||||
|
String requestHash = hash(action, request.expectedVersion(), type, request.occurredAt(), request.resolution(),
|
||||||
|
request.evidenceFileId(), request.resolved());
|
||||||
|
CommandRow replay = claim(operatorId, action, key, requestHash);
|
||||||
|
if (replay != null) return clearanceResultView(mapper.clearanceResult(replay.resultId()));
|
||||||
|
DeclarationBatchRow batch = mapper.lockDeclarationBatch(batchId);
|
||||||
|
if (batch == null || !Set.of("SUBMITTED", "ACKNOWLEDGED", "RESULT_PENDING").contains(batch.status())) {
|
||||||
|
throw conflict("正式申报批次不在可录入海关结果状态");
|
||||||
|
}
|
||||||
|
if (!Objects.equals(batch.version(), request.expectedVersion())) throw concurrent();
|
||||||
|
String resultStatus = "RELEASED".equals(type) || request.resolved() ? "RESOLVED" : "OPEN";
|
||||||
|
long resultId = IdWorker.getId();
|
||||||
|
if (mapper.insertClearanceResult(resultId, batch.id(), batch.customsContainerId(), type, resultStatus,
|
||||||
|
batch.customsEntryNo(), request.occurredAt(), trim(request.resolution()), request.evidenceFileId(),
|
||||||
|
operatorId, operatorName, null) != 1) throw conflict("海关结果保存失败");
|
||||||
|
if ("OPEN".equals(resultStatus)) mapper.markDeclarationResultPending(batch.id());
|
||||||
|
else mapper.closeDeclarationBatch(batch.id());
|
||||||
|
ContainerRow container = mapper.lockContainerById(batch.customsContainerId());
|
||||||
|
if (container != null) history(container, "RECORD_CUSTOMS_CLEARANCE_RESULT", container.status(), container.status(), type, operatorId, operatorName);
|
||||||
|
completeCommand(operatorId, action, key, resultId);
|
||||||
|
return clearanceResultView(mapper.clearanceResult(resultId));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
public ClearanceResultView resolveClearanceResult(long resultId, long operatorId, String operatorName,
|
||||||
|
String key, ClearanceResolveRequest request) {
|
||||||
|
String action = "RESOLVE_CUSTOMS_CLEARANCE_RESULT_" + resultId;
|
||||||
|
String requestHash = hash(action, request.expectedVersion(), request.resolution());
|
||||||
|
CommandRow replay = claim(operatorId, action, key, requestHash);
|
||||||
|
if (replay != null) return clearanceResultView(mapper.clearanceResult(resultId));
|
||||||
|
ClearanceResultRow result = mapper.lockClearanceResult(resultId);
|
||||||
|
if (result == null) throw notFound("海关结果不存在");
|
||||||
|
DeclarationBatchRow batch = mapper.lockDeclarationBatch(result.declarationBatchId());
|
||||||
|
if (batch == null || !Objects.equals(batch.version(), request.expectedVersion())) throw concurrent();
|
||||||
|
if (mapper.resolveClearanceResult(resultId, trim(request.resolution())) != 1) throw conflict("海关结果已被其他人处理");
|
||||||
|
if (mapper.openClearanceResultCount(batch.id()) == 0) mapper.closeDeclarationBatch(batch.id());
|
||||||
|
ContainerRow container = mapper.lockContainerById(batch.customsContainerId());
|
||||||
|
if (container != null) history(container, "RESOLVE_CUSTOMS_CLEARANCE_RESULT", container.status(), container.status(), trim(request.resolution()), operatorId, operatorName);
|
||||||
|
completeCommand(operatorId, action, key, resultId);
|
||||||
|
return clearanceResultView(mapper.clearanceResult(resultId));
|
||||||
|
}
|
||||||
|
|
||||||
|
public List<ClearanceResultView> merchantClearanceResults(long tenantId, long orderId) {
|
||||||
|
return mapper.merchantDeclarationBatchIds(tenantId, orderId).stream()
|
||||||
|
.flatMap(id -> mapper.clearanceResults(id).stream()).map(this::clearanceResultView).toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
private DeclarationBatchView declarationBatchView(DeclarationBatchRow row) {
|
||||||
|
if (row == null) throw notFound("正式申报批次不存在");
|
||||||
|
return new DeclarationBatchView(row.id(), row.customsContainerId(), row.loadingPlanId(), row.batchNo(), row.status(),
|
||||||
|
row.sourceVersion() == null ? 0 : row.sourceVersion(), row.customsEntryNo(), row.version() == null ? 0 : row.version(),
|
||||||
|
row.submittedAt(), row.submittedByName(), row.acknowledgedAt(), row.acknowledgedByName(), row.remark(),
|
||||||
|
mapper.clearanceResults(row.id()).stream().map(this::clearanceResultView).toList());
|
||||||
|
}
|
||||||
|
|
||||||
|
private ClearanceResultView clearanceResultView(ClearanceResultRow row) {
|
||||||
|
if (row == null) throw notFound("海关结果不存在");
|
||||||
|
return new ClearanceResultView(row.id(), row.declarationBatchId(), row.customsContainerId(), row.resultType(),
|
||||||
|
row.resultStatus(), row.customsEntryNo(), row.occurredAt(), row.resolution(), row.evidenceFileId(),
|
||||||
|
row.recordedBy() == null ? 0 : row.recordedBy(), row.recordedByName(), row.recordedAt(), row.previousResultId());
|
||||||
|
}
|
||||||
|
|
||||||
|
private String declarationSnapshot(long containerId) {
|
||||||
|
List<Map<String,Object>> snapshot = mapper.documents(containerId, null).stream().map(doc -> {
|
||||||
|
CompletionRow receipt = mapper.completion(doc.id());
|
||||||
|
FinalRow finalDocument = mapper.finalDocument(doc.id());
|
||||||
|
return Map.<String,Object>of("documentId", doc.id(), "tenantId", doc.tenantId(), "sourceVersion", doc.version(),
|
||||||
|
"receiptVersion", receipt == null ? 0 : receipt.version(), "receiptStatus", receipt == null ? "MISSING" : receipt.customerConfirmStatus(),
|
||||||
|
"finalVersion", finalDocument == null ? 0 : finalDocument.version());
|
||||||
|
}).toList();
|
||||||
|
try { return objectMapper.writeValueAsString(snapshot); }
|
||||||
|
catch (JsonProcessingException ex) { throw new IllegalStateException(ex); }
|
||||||
|
}
|
||||||
|
|
||||||
public Download downloadSource(long tenantId, long documentId, boolean platform) {
|
public Download downloadSource(long tenantId, long documentId, boolean platform) {
|
||||||
DocumentRow source = requiredDocument(documentId);
|
DocumentRow source = requiredDocument(documentId);
|
||||||
if (!platform && source.tenantId() != tenantId) throw notFound("客户报送资料不存在");
|
if (!platform && source.tenantId() != tenantId) throw notFound("客户报送资料不存在");
|
||||||
@@ -261,12 +461,23 @@ public class ContainerCustomsService {
|
|||||||
public Download downloadCompletion(long tenantId, long documentId, boolean platform) {
|
public Download downloadCompletion(long tenantId, long documentId, boolean platform) {
|
||||||
DocumentRow source = requiredDocument(documentId);
|
DocumentRow source = requiredDocument(documentId);
|
||||||
ContainerRow container = mapper.containerById(source.customsContainerId());
|
ContainerRow container = mapper.containerById(source.customsContainerId());
|
||||||
if (!platform && (source.tenantId() != tenantId || container == null || !COMPLETED.equals(container.status()))) {
|
if (!platform && (source.tenantId() != tenantId || container == null || !Set.of(DECLARING, COMPLETED).contains(container.status()))) {
|
||||||
throw notFound("报关完成文件不存在");
|
throw notFound("报关行回执不存在");
|
||||||
}
|
}
|
||||||
CompletionRow completion = mapper.completion(documentId);
|
CompletionRow completion = mapper.completion(documentId);
|
||||||
if (completion == null) throw notFound("报关完成文件不存在");
|
if (completion == null) throw notFound("报关行回执不存在");
|
||||||
return download(source.tenantId(), RESULT_FILE_TYPE, Long.toString(container.loadingPlanId()), completion.fileId(), completion.fileName());
|
return download(source.tenantId(), RECEIPT_FILE_TYPE, Long.toString(container.loadingPlanId()), completion.fileId(), completion.fileName());
|
||||||
|
}
|
||||||
|
|
||||||
|
public Download downloadFinal(long tenantId, long documentId, boolean platform) {
|
||||||
|
DocumentRow source = requiredDocument(documentId);
|
||||||
|
ContainerRow container = mapper.containerById(source.customsContainerId());
|
||||||
|
if (!platform && (source.tenantId() != tenantId || container == null || !COMPLETED.equals(container.status()))) {
|
||||||
|
throw notFound("报关完税文件不存在");
|
||||||
|
}
|
||||||
|
FinalRow finalDocument = mapper.finalDocument(documentId);
|
||||||
|
if (finalDocument == null) throw notFound("报关完税文件不存在");
|
||||||
|
return download(source.tenantId(), RESULT_FILE_TYPE, Long.toString(container.loadingPlanId()), finalDocument.fileId(), finalDocument.fileName());
|
||||||
}
|
}
|
||||||
|
|
||||||
private ContainerRow ensure(long planId) {
|
private ContainerRow ensure(long planId) {
|
||||||
@@ -361,18 +572,22 @@ public class ContainerCustomsService {
|
|||||||
String merchantStatus=tenantId==null?null:merchantStatus(container.status(),refund,coveredRefund);
|
String merchantStatus=tenantId==null?null:merchantStatus(container.status(),refund,coveredRefund);
|
||||||
return new ContainerView(container.id(), container.loadingPlanId(), container.containerBookingId(), plan.planNo(),
|
return new ContainerView(container.id(), container.loadingPlanId(), container.containerBookingId(), plan.planNo(),
|
||||||
plan.cabinetNo(), plan.containerNo(), container.status(), !rows.isEmpty(), merchantStatus, container.version(), refund, coveredRefund,
|
plan.cabinetNo(), plan.containerNo(), container.status(), !rows.isEmpty(), merchantStatus, container.version(), refund, coveredRefund,
|
||||||
actualBoxCount, inbounds, documentViews, container.startedAt(), container.completedAt());
|
actualBoxCount, inbounds, documentViews, container.startedAt(), container.completedAt(), container.brokerSubmittedAt());
|
||||||
}
|
}
|
||||||
|
|
||||||
private DocumentView documentView(DocumentRow source, boolean merchant) {
|
private DocumentView documentView(DocumentRow source, boolean merchant) {
|
||||||
List<ScopeRow> scopes = source.frozenAt() == null ? mapper.scopes(source.id()) : mapper.frozenScopes(source.id());
|
List<ScopeRow> scopes = source.frozenAt() == null ? mapper.scopes(source.id()) : mapper.frozenScopes(source.id());
|
||||||
CompletionRow completion = mapper.completion(source.id());
|
CompletionRow completion = mapper.completion(source.id());
|
||||||
|
FinalRow finalDocument = mapper.finalDocument(source.id());
|
||||||
ContainerRow container = mapper.containerById(source.customsContainerId());
|
ContainerRow container = mapper.containerById(source.customsContainerId());
|
||||||
FileView result = completion == null ? null : file(completion);
|
FileView result = completion == null ? null : file(completion);
|
||||||
|
List<ReceiptVersionView> receiptHistory = completion == null ? List.of() : Optional.ofNullable(mapper.completionVersions(completion.id())).orElseGet(List::of).stream()
|
||||||
|
.map(version -> new ReceiptVersionView(version.documentVersion(), file(version), version.operatorName(), version.createdAt())).toList();
|
||||||
return new DocumentView(source.id(), source.tenantId(), file(source), scopes.stream().map(ScopeRow::inboundId).toList(),
|
return new DocumentView(source.id(), source.tenantId(), file(source), scopes.stream().map(ScopeRow::inboundId).toList(),
|
||||||
scopes.stream().map(ScopeRow::inboundCode).toList(), source.frozenAt() != null, result,
|
scopes.stream().map(ScopeRow::inboundCode).toList(), source.frozenAt() != null, result,
|
||||||
completion == null ? null : completion.customerConfirmStatus(),
|
completion == null ? null : completion.customerConfirmStatus(),
|
||||||
completion == null ? null : completion.customerRejectReason());
|
completion == null ? null : completion.customerRejectReason(),
|
||||||
|
finalDocument == null ? null : file(finalDocument), receiptHistory);
|
||||||
}
|
}
|
||||||
|
|
||||||
private void completeContainer(ContainerRow container, long operatorId, String operatorName, String remark) {
|
private void completeContainer(ContainerRow container, long operatorId, String operatorName, String remark) {
|
||||||
@@ -444,6 +659,11 @@ public class ContainerCustomsService {
|
|||||||
|
|
||||||
private static FileView file(DocumentRow row) { return new FileView(row.fileId(),row.fileName(),row.fileHash(),row.contentType(),row.sizeBytes(),row.version(),row.uploadedAt()); }
|
private static FileView file(DocumentRow row) { return new FileView(row.fileId(),row.fileName(),row.fileHash(),row.contentType(),row.sizeBytes(),row.version(),row.uploadedAt()); }
|
||||||
private static FileView file(CompletionRow row) { return new FileView(row.fileId(),row.fileName(),row.fileHash(),row.contentType(),row.sizeBytes(),row.version(),row.uploadedAt()); }
|
private static FileView file(CompletionRow row) { return new FileView(row.fileId(),row.fileName(),row.fileHash(),row.contentType(),row.sizeBytes(),row.version(),row.uploadedAt()); }
|
||||||
|
private static FileView file(ContainerCustomsMapper.CompletionVersionRow row) {
|
||||||
|
return new FileView(row.fileId(), row.fileName(), row.fileHash(), row.contentType(), row.sizeBytes(),
|
||||||
|
row.documentVersion(), row.createdAt());
|
||||||
|
}
|
||||||
|
private static FileView file(FinalRow row) { return new FileView(row.fileId(),row.fileName(),row.fileHash(),row.contentType(),row.sizeBytes(),row.version(),row.uploadedAt()); }
|
||||||
private static long size(CustomsFileClient.Metadata value) { return value.sizeBytes() == null ? 0 : value.sizeBytes(); }
|
private static long size(CustomsFileClient.Metadata value) { return value.sizeBytes() == null ? 0 : value.sizeBytes(); }
|
||||||
private static void validateFile(MultipartFile file) { if (file == null || file.isEmpty()) throw bad("请选择文件"); if (file.getSize() > MAX_FILE_BYTES) throw bad("单文件不能超过50MB"); }
|
private static void validateFile(MultipartFile file) { if (file == null || file.isEmpty()) throw bad("请选择文件"); if (file.getSize() > MAX_FILE_BYTES) throw bad("单文件不能超过50MB"); }
|
||||||
private static String fileHash(MultipartFile file) { try (InputStream input=file.getInputStream()) { return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(input.readAllBytes())); } catch(Exception ex){ throw bad("无法读取上传文件"); } }
|
private static String fileHash(MultipartFile file) { try (InputStream input=file.getInputStream()) { return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(input.readAllBytes())); } catch(Exception ex){ throw bad("无法读取上传文件"); } }
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import java.time.LocalDateTime;
|
|||||||
public final class MojiaSkuSyncDtos {
|
public final class MojiaSkuSyncDtos {
|
||||||
private MojiaSkuSyncDtos() {}
|
private MojiaSkuSyncDtos() {}
|
||||||
|
|
||||||
public record Query(String skuCode, String syncStatus, long page, long size) {}
|
public record Query(String code, String measurementKind, String syncStatus, long page, long size) {}
|
||||||
|
|
||||||
public record Config(boolean autoSyncEnabled) {}
|
public record Config(boolean autoSyncEnabled) {}
|
||||||
|
|
||||||
@@ -25,7 +25,8 @@ public final class MojiaSkuSyncDtos {
|
|||||||
public record RetryRequest(@NotNull Integer revision) {}
|
public record RetryRequest(@NotNull Integer revision) {}
|
||||||
|
|
||||||
public record Record(
|
public record Record(
|
||||||
Long id, String skuCode, BigDecimal lengthCm, BigDecimal widthCm, BigDecimal heightCm,
|
Long id, String measurementKind, String boxCode, String inboundCode, String skuCode,
|
||||||
|
BigDecimal lengthCm, BigDecimal widthCm, BigDecimal heightCm,
|
||||||
BigDecimal weightKg, Long imageFileId, LocalDateTime measuredAt, LocalDateTime receivedAt,
|
BigDecimal weightKg, Long imageFileId, LocalDateTime measuredAt, LocalDateTime receivedAt,
|
||||||
String syncStatus, String syncFailureCode, String syncFailureReason, String lingxingSkuId,
|
String syncStatus, String syncFailureCode, String syncFailureReason, String lingxingSkuId,
|
||||||
Integer revision, LocalDateTime updatedAt) {}
|
Integer revision, LocalDateTime updatedAt) {}
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ public class MojiaMeasurementReceiptEntity {
|
|||||||
private Long tenantId;
|
private Long tenantId;
|
||||||
private Long subjectId;
|
private Long subjectId;
|
||||||
private String boxCode;
|
private String boxCode;
|
||||||
|
private String inboundCode;
|
||||||
/** BOX_LABEL 表示现有箱唛链路;SKU 表示一件代发新品测量同步收件。 */
|
/** BOX_LABEL 表示现有箱唛链路;SKU 表示一件代发新品测量同步收件。 */
|
||||||
private String measurementKind;
|
private String measurementKind;
|
||||||
private String scannedShipmentId;
|
private String scannedShipmentId;
|
||||||
|
|||||||
+38
-3
@@ -13,12 +13,47 @@ public interface UnifiedInboundTransportMapper {
|
|||||||
@Select("""
|
@Select("""
|
||||||
<script>
|
<script>
|
||||||
SELECT DISTINCT i.inbound_id, i.account_id, p.container_booking_id,
|
SELECT DISTINCT i.inbound_id, i.account_id, p.container_booking_id,
|
||||||
b.container_no, b.cabinet_no, b.vessel_name, b.voyage_no,
|
b.container_no, b.cabinet_no,
|
||||||
b.estimated_sailing_at, b.actual_sailing_at,
|
COALESCE(schedule.vessel_name, b.vessel_name) AS vessel_name,
|
||||||
b.estimated_port_arrival_at, b.actual_port_arrival_at
|
COALESCE(schedule.voyage_no, b.voyage_no) AS voyage_no,
|
||||||
|
COALESCE(schedule.etd_at, b.estimated_sailing_at) AS estimated_sailing_at,
|
||||||
|
b.actual_sailing_at,
|
||||||
|
COALESCE(schedule.eta_at, b.estimated_port_arrival_at) AS estimated_port_arrival_at,
|
||||||
|
b.actual_port_arrival_at
|
||||||
FROM fl_loading_plan_item i
|
FROM fl_loading_plan_item i
|
||||||
JOIN fl_loading_plan p ON p.id = i.loading_plan_id
|
JOIN fl_loading_plan p ON p.id = i.loading_plan_id
|
||||||
JOIN fl_container_booking b ON b.id = p.container_booking_id
|
JOIN fl_container_booking b ON b.id = p.container_booking_id
|
||||||
|
LEFT JOIN (
|
||||||
|
SELECT ranked.customer_id,
|
||||||
|
ranked.ocean_booking_id,
|
||||||
|
ranked.container_booking_id,
|
||||||
|
ranked.vessel_name,
|
||||||
|
ranked.voyage_no,
|
||||||
|
ranked.etd_at,
|
||||||
|
ranked.eta_at
|
||||||
|
FROM (
|
||||||
|
SELECT candidate.customer_id,
|
||||||
|
candidate.ocean_booking_id,
|
||||||
|
candidate.container_booking_id,
|
||||||
|
candidate.vessel_name,
|
||||||
|
candidate.voyage_no,
|
||||||
|
candidate.etd_at,
|
||||||
|
candidate.eta_at,
|
||||||
|
ROW_NUMBER() OVER (
|
||||||
|
PARTITION BY candidate.customer_id,
|
||||||
|
candidate.ocean_booking_id,
|
||||||
|
candidate.container_booking_id
|
||||||
|
ORDER BY candidate.observed_at DESC,
|
||||||
|
candidate.updated_at DESC,
|
||||||
|
candidate.id DESC
|
||||||
|
) AS schedule_rank
|
||||||
|
FROM fl_port_schedule candidate
|
||||||
|
) ranked
|
||||||
|
WHERE ranked.schedule_rank = 1
|
||||||
|
) schedule
|
||||||
|
ON schedule.customer_id = i.account_id
|
||||||
|
AND schedule.ocean_booking_id = b.ocean_booking_id
|
||||||
|
AND schedule.container_booking_id = b.id
|
||||||
WHERE i.deleted = 0
|
WHERE i.deleted = 0
|
||||||
AND p.plan_status IN ('LOADING', 'COMPLETED')
|
AND p.plan_status IN ('LOADING', 'COMPLETED')
|
||||||
AND i.inbound_id IN
|
AND i.inbound_id IN
|
||||||
|
|||||||
@@ -6,19 +6,22 @@ import com.baiyue.common.core.api.PageResult;
|
|||||||
import com.baiyue.firstleg.dto.ContainerDtos.Query;
|
import com.baiyue.firstleg.dto.ContainerDtos.Query;
|
||||||
import com.baiyue.firstleg.entity.ContainerBookingEntity;
|
import com.baiyue.firstleg.entity.ContainerBookingEntity;
|
||||||
import com.baiyue.firstleg.mapper.ContainerBookingMapper;
|
import com.baiyue.firstleg.mapper.ContainerBookingMapper;
|
||||||
|
import com.baiyue.firstleg.portschedule.entity.PortScheduleEntity;
|
||||||
|
import com.baiyue.firstleg.portschedule.mapper.PortScheduleMapper;
|
||||||
import jakarta.annotation.Resource;
|
import jakarta.annotation.Resource;
|
||||||
import org.springframework.stereotype.Repository;
|
import org.springframework.stereotype.Repository;
|
||||||
|
|
||||||
@Repository
|
@Repository
|
||||||
public class ContainerRepository {
|
public class ContainerRepository {
|
||||||
@Resource private ContainerBookingMapper mapper;
|
@Resource private ContainerBookingMapper mapper;
|
||||||
|
@Resource private PortScheduleMapper schedules;
|
||||||
public PageResult<ContainerBookingEntity> page(String workbench, Query query) {
|
public PageResult<ContainerBookingEntity> page(String workbench, Query query) {
|
||||||
Page<ContainerBookingEntity> result = mapper.selectBookingPage(
|
Page<ContainerBookingEntity> result = mapper.selectBookingPage(
|
||||||
new Page<>(query.page(), query.size()), query, workbench);
|
new Page<>(query.page(), query.size()), query, workbench);
|
||||||
result.getRecords().forEach(this::resolveLoadingTime);
|
result.getRecords().forEach(this::resolveReadModel);
|
||||||
return PageResult.from(result);
|
return PageResult.from(result);
|
||||||
}
|
}
|
||||||
public ContainerBookingEntity find(long id) { return resolveLoadingTime(mapper.selectBooking(id)); }
|
public ContainerBookingEntity find(long id) { return resolveReadModel(mapper.selectBooking(id)); }
|
||||||
public ContainerBookingEntity findForUpdate(long id) { return mapper.selectBookingForUpdate(id); }
|
public ContainerBookingEntity findForUpdate(long id) { return mapper.selectBookingForUpdate(id); }
|
||||||
public ContainerBookingEntity findActiveForUpdate(long id) { return mapper.selectActiveBookingForUpdate(id); }
|
public ContainerBookingEntity findActiveForUpdate(long id) { return mapper.selectActiveBookingForUpdate(id); }
|
||||||
public boolean departed(long id) { return mapper.hasActiveOutboundDeparture(id) > 0; }
|
public boolean departed(long id) { return mapper.hasActiveOutboundDeparture(id) > 0; }
|
||||||
@@ -62,6 +65,21 @@ public class ContainerRepository {
|
|||||||
estimatedSailingAt, operatorId, operatorName);
|
estimatedSailingAt, operatorId, operatorName);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private ContainerBookingEntity resolveReadModel(ContainerBookingEntity value) {
|
||||||
|
resolveCurrentPortSchedule(value);
|
||||||
|
return resolveLoadingTime(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void resolveCurrentPortSchedule(ContainerBookingEntity value) {
|
||||||
|
if (value == null || value.getCustomerId() == null || value.getOceanBookingId() == null) return;
|
||||||
|
PortScheduleEntity schedule = schedules.latestForDispatch(value.getCustomerId(), value.getOceanBookingId(), value.getId());
|
||||||
|
if (schedule == null) return;
|
||||||
|
value.setVesselName(schedule.getVesselName());
|
||||||
|
value.setVoyageNo(schedule.getVoyageNo());
|
||||||
|
value.setEstimatedSailingAt(schedule.getEtdAt());
|
||||||
|
value.setEstimatedPortArrivalAt(schedule.getEtaAt());
|
||||||
|
}
|
||||||
|
|
||||||
private ContainerBookingEntity resolveLoadingTime(ContainerBookingEntity value) {
|
private ContainerBookingEntity resolveLoadingTime(ContainerBookingEntity value) {
|
||||||
if (value != null && value.getLoadingAt() == null
|
if (value != null && value.getLoadingAt() == null
|
||||||
&& "COMPLETED".equals(value.getPlanStatus())) {
|
&& "COMPLETED".equals(value.getPlanStatus())) {
|
||||||
|
|||||||
@@ -14,14 +14,22 @@ import org.springframework.util.StringUtils;
|
|||||||
public class MojiaSkuSyncRepository {
|
public class MojiaSkuSyncRepository {
|
||||||
@Resource private MojiaMeasurementReceiptMapper mapper;
|
@Resource private MojiaMeasurementReceiptMapper mapper;
|
||||||
|
|
||||||
public PageResult<MojiaMeasurementReceiptEntity> page(String skuCode, String syncStatus,
|
/** 保留仓储层旧测试/内部调用的四参数入口,统一查询改为全部上传记录。 */
|
||||||
|
public PageResult<MojiaMeasurementReceiptEntity> page(String code, String syncStatus,
|
||||||
long page, long size) {
|
long page, long size) {
|
||||||
String normalizedSkuCode = StringUtils.hasText(skuCode) ? skuCode.trim() : null;
|
return page(code, null, syncStatus, page, size);
|
||||||
|
}
|
||||||
|
|
||||||
|
public PageResult<MojiaMeasurementReceiptEntity> page(String code, String measurementKind, String syncStatus,
|
||||||
|
long page, long size) {
|
||||||
|
String normalizedCode = StringUtils.hasText(code) ? code.trim() : null;
|
||||||
return PageResult.from(mapper.selectPage(new Page<>(page, size),
|
return PageResult.from(mapper.selectPage(new Page<>(page, size),
|
||||||
new LambdaQueryWrapper<MojiaMeasurementReceiptEntity>()
|
new LambdaQueryWrapper<MojiaMeasurementReceiptEntity>()
|
||||||
.eq(MojiaMeasurementReceiptEntity::getMeasurementKind, "SKU")
|
.eq(StringUtils.hasText(measurementKind), MojiaMeasurementReceiptEntity::getMeasurementKind, measurementKind)
|
||||||
.eq(StringUtils.hasText(syncStatus), MojiaMeasurementReceiptEntity::getSyncStatus, syncStatus)
|
.eq(StringUtils.hasText(syncStatus), MojiaMeasurementReceiptEntity::getSyncStatus, syncStatus)
|
||||||
.like(normalizedSkuCode != null, MojiaMeasurementReceiptEntity::getBoxCode, normalizedSkuCode)
|
.and(normalizedCode != null, wrapper -> wrapper
|
||||||
|
.like(MojiaMeasurementReceiptEntity::getBoxCode, normalizedCode)
|
||||||
|
.or().like(MojiaMeasurementReceiptEntity::getInboundCode, normalizedCode))
|
||||||
.orderByDesc(MojiaMeasurementReceiptEntity::getMeasuredAt)
|
.orderByDesc(MojiaMeasurementReceiptEntity::getMeasuredAt)
|
||||||
.orderByDesc(MojiaMeasurementReceiptEntity::getId)));
|
.orderByDesc(MojiaMeasurementReceiptEntity::getId)));
|
||||||
}
|
}
|
||||||
|
|||||||
+34
@@ -0,0 +1,34 @@
|
|||||||
|
package com.baiyue.firstleg.sailingcalendar.controller;
|
||||||
|
|
||||||
|
import com.baiyue.common.core.api.ApiResponse;
|
||||||
|
import com.baiyue.common.security.AuthenticatedUser;
|
||||||
|
import com.baiyue.firstleg.sailingcalendar.dto.SailingCalendarDtos.ExceptionPage;
|
||||||
|
import com.baiyue.firstleg.sailingcalendar.dto.SailingCalendarDtos.ExceptionQuery;
|
||||||
|
import com.baiyue.firstleg.sailingcalendar.service.SailingCalendarService;
|
||||||
|
import com.baiyue.firstleg.service.FirstlegScopeGuard;
|
||||||
|
import io.swagger.v3.oas.annotations.Operation;
|
||||||
|
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||||
|
import jakarta.annotation.Resource;
|
||||||
|
import jakarta.validation.Valid;
|
||||||
|
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.RequestMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
|
|
||||||
|
@Validated
|
||||||
|
@RestController
|
||||||
|
@Tag(name = "船期异常货量")
|
||||||
|
@RequestMapping("/api/platform/v1/sailing-assignments")
|
||||||
|
public class PlatformSailingAssignmentController {
|
||||||
|
@Resource private SailingCalendarService service;
|
||||||
|
|
||||||
|
@GetMapping("/exceptions")
|
||||||
|
@PreAuthorize("hasAuthority('platform:firstleg:sailing-calendar:list')")
|
||||||
|
@Operation(summary = "查询未安排和晚到货量")
|
||||||
|
public ApiResponse<ExceptionPage> exceptions(@AuthenticationPrincipal AuthenticatedUser user, @Valid ExceptionQuery query) {
|
||||||
|
FirstlegScopeGuard.platform(user);
|
||||||
|
return ApiResponse.success(service.exceptions(query));
|
||||||
|
}
|
||||||
|
}
|
||||||
+104
@@ -0,0 +1,104 @@
|
|||||||
|
package com.baiyue.firstleg.sailingcalendar.controller;
|
||||||
|
|
||||||
|
import com.baiyue.common.core.api.ApiResponse;
|
||||||
|
import com.baiyue.common.security.AuthenticatedUser;
|
||||||
|
import com.baiyue.firstleg.sailingcalendar.dto.SailingCalendarDtos;
|
||||||
|
import com.baiyue.firstleg.sailingcalendar.dto.SailingCalendarDtos.*;
|
||||||
|
import com.baiyue.firstleg.sailingcalendar.entity.SailingEntity;
|
||||||
|
import com.baiyue.firstleg.sailingcalendar.service.SailingCalendarService;
|
||||||
|
import com.baiyue.firstleg.service.FirstlegScopeGuard;
|
||||||
|
import io.swagger.v3.oas.annotations.Operation;
|
||||||
|
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||||
|
import jakarta.annotation.Resource;
|
||||||
|
import jakarta.validation.Valid;
|
||||||
|
import jakarta.validation.constraints.Positive;
|
||||||
|
import org.springframework.format.annotation.DateTimeFormat;
|
||||||
|
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.*;
|
||||||
|
|
||||||
|
import java.time.LocalDate;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
@Validated
|
||||||
|
@RestController
|
||||||
|
@Tag(name = "船期日历")
|
||||||
|
@RequestMapping("/api/platform/v1/sailings")
|
||||||
|
public class PlatformSailingCalendarController {
|
||||||
|
@Resource private SailingCalendarService service;
|
||||||
|
|
||||||
|
@GetMapping("/calendar")
|
||||||
|
@PreAuthorize("hasAuthority('platform:firstleg:sailing-calendar:list')")
|
||||||
|
@Operation(summary = "查询船期月历")
|
||||||
|
public ApiResponse<CalendarResult> calendar(@AuthenticationPrincipal AuthenticatedUser user, @Valid SailingQuery query) {
|
||||||
|
FirstlegScopeGuard.platform(user); return ApiResponse.success(service.calendar(query));
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/{id}")
|
||||||
|
@PreAuthorize("hasAuthority('platform:firstleg:sailing-calendar:list')")
|
||||||
|
public ApiResponse<SailingDetail> detail(@AuthenticationPrincipal AuthenticatedUser user, @PathVariable @Positive long id) {
|
||||||
|
FirstlegScopeGuard.platform(user); return ApiResponse.success(service.detail(id));
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/{id}/history")
|
||||||
|
@PreAuthorize("hasAuthority('platform:firstleg:sailing-calendar:list')")
|
||||||
|
@Operation(summary = "查询船期变更历史")
|
||||||
|
public ApiResponse<List<SailingHistoryView>> history(@AuthenticationPrincipal AuthenticatedUser user, @PathVariable @Positive long id) {
|
||||||
|
FirstlegScopeGuard.platform(user); return ApiResponse.success(service.history(id));
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping
|
||||||
|
@PreAuthorize("hasAuthority('platform:firstleg:sailing-calendar:create')")
|
||||||
|
public ApiResponse<SailingDetail> create(@AuthenticationPrincipal AuthenticatedUser user, @Valid @RequestBody SailingCreateRequest request) {
|
||||||
|
FirstlegScopeGuard.platform(user); return ApiResponse.success(service.detail(service.createSailing(user.userId(), user.username(), request).getId()));
|
||||||
|
}
|
||||||
|
|
||||||
|
@PutMapping("/{id}")
|
||||||
|
@PreAuthorize("hasAuthority('platform:firstleg:sailing-calendar:edit')")
|
||||||
|
public ApiResponse<SailingDetail> update(@AuthenticationPrincipal AuthenticatedUser user, @PathVariable @Positive long id,
|
||||||
|
@Valid @RequestBody SailingUpdateRequest request) {
|
||||||
|
FirstlegScopeGuard.platform(user); service.updateSailing(id, user.userId(), user.username(), request); return ApiResponse.success(service.detail(id));
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/{id}/cancel")
|
||||||
|
@PreAuthorize("hasAuthority('platform:firstleg:sailing-calendar:edit')")
|
||||||
|
public ApiResponse<SailingDetail> cancel(@AuthenticationPrincipal AuthenticatedUser user, @PathVariable @Positive long id,
|
||||||
|
@Valid @RequestBody CancelRequest request) {
|
||||||
|
FirstlegScopeGuard.platform(user); return ApiResponse.success(service.cancel(id, user.userId(), user.username(), request));
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/{id}/exception-reassign")
|
||||||
|
@PreAuthorize("hasAuthority('platform:firstleg:sailing-volume:exception-reassign')")
|
||||||
|
public ApiResponse<ExceptionReassignResult> exceptionReassign(@AuthenticationPrincipal AuthenticatedUser user,
|
||||||
|
@PathVariable @Positive long id,
|
||||||
|
@Valid @RequestBody ExceptionReassignRequest request) {
|
||||||
|
FirstlegScopeGuard.platform(user); return ApiResponse.success(service.exceptionReassign(id, user.userId(), user.username(), request));
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/{id}/copy")
|
||||||
|
@PreAuthorize("hasAuthority('platform:firstleg:sailing-calendar:create')")
|
||||||
|
public ApiResponse<SailingDetail> copy(@AuthenticationPrincipal AuthenticatedUser user, @PathVariable @Positive long id,
|
||||||
|
@Valid @RequestBody CopyRequest request) {
|
||||||
|
FirstlegScopeGuard.platform(user); return ApiResponse.success(service.copy(id, user.userId(), user.username(), request));
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/{id}/volume-summary")
|
||||||
|
@PreAuthorize("hasAuthority('platform:firstleg:sailing-calendar:list')")
|
||||||
|
public ApiResponse<VolumeDetail> volumeSummary(@AuthenticationPrincipal AuthenticatedUser user, @PathVariable @Positive long id) {
|
||||||
|
FirstlegScopeGuard.platform(user); return ApiResponse.success(service.volumeSummary(id));
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/{id}/booking-advice")
|
||||||
|
@PreAuthorize("hasAuthority('platform:firstleg:sailing-calendar:list')")
|
||||||
|
public ApiResponse<BookingAdvice> bookingAdvice(@AuthenticationPrincipal AuthenticatedUser user, @PathVariable @Positive long id) {
|
||||||
|
FirstlegScopeGuard.platform(user); return ApiResponse.success(service.bookingAdvice(id));
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/{id}/booking-links")
|
||||||
|
@PreAuthorize("hasAuthority('platform:firstleg:sailing-booking:link')")
|
||||||
|
public ApiResponse<java.util.Map<String, Object>> bookingLink(@AuthenticationPrincipal AuthenticatedUser user, @PathVariable @Positive long id,
|
||||||
|
@Valid @RequestBody BookingLinkRequest request) {
|
||||||
|
FirstlegScopeGuard.platform(user); return ApiResponse.success(service.linkBooking(id, user.userId(), user.username(), request));
|
||||||
|
}
|
||||||
|
}
|
||||||
+30
@@ -0,0 +1,30 @@
|
|||||||
|
package com.baiyue.firstleg.sailingcalendar.controller;
|
||||||
|
|
||||||
|
import com.baiyue.common.core.api.ApiResponse;
|
||||||
|
import com.baiyue.firstleg.sailingcalendar.dto.SailingCalendarDtos.*;
|
||||||
|
import com.baiyue.firstleg.sailingcalendar.entity.SailingChannelEntity;
|
||||||
|
import com.baiyue.firstleg.sailingcalendar.service.SailingCalendarService;
|
||||||
|
import com.baiyue.firstleg.service.FirstlegScopeGuard;
|
||||||
|
import com.baiyue.common.security.AuthenticatedUser;
|
||||||
|
import jakarta.annotation.Resource;
|
||||||
|
import jakarta.validation.Valid;
|
||||||
|
import jakarta.validation.constraints.Positive;
|
||||||
|
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.*;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
@Validated
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/api/platform/v1/sailing-channels")
|
||||||
|
public class PlatformSailingChannelController {
|
||||||
|
@Resource private SailingCalendarService service;
|
||||||
|
@GetMapping @PreAuthorize("hasAuthority('platform:firstleg:sailing-calendar:list')")
|
||||||
|
public ApiResponse<List<SailingChannelEntity>> list(@AuthenticationPrincipal AuthenticatedUser user, @Valid ChannelQuery query) { FirstlegScopeGuard.platform(user); return ApiResponse.success(service.channels(query).stream().map(ChannelView::channel).toList()); }
|
||||||
|
@PostMapping @PreAuthorize("hasAuthority('platform:firstleg:sailing-template:manage')")
|
||||||
|
public ApiResponse<SailingChannelEntity> create(@AuthenticationPrincipal AuthenticatedUser user, @Valid @RequestBody ChannelCreateRequest request) { FirstlegScopeGuard.platform(user); return ApiResponse.success(service.createChannel(user.userId(), user.username(), request)); }
|
||||||
|
@PutMapping("/{id}") @PreAuthorize("hasAuthority('platform:firstleg:sailing-template:manage')")
|
||||||
|
public ApiResponse<SailingChannelEntity> update(@AuthenticationPrincipal AuthenticatedUser user, @PathVariable @Positive long id, @Valid @RequestBody ChannelUpdateRequest request) { FirstlegScopeGuard.platform(user); return ApiResponse.success(service.updateChannel(id, user.userId(), user.username(), request)); }
|
||||||
|
}
|
||||||
+74
@@ -0,0 +1,74 @@
|
|||||||
|
package com.baiyue.firstleg.sailingcalendar.controller;
|
||||||
|
|
||||||
|
import com.baiyue.common.core.api.ApiResponse;
|
||||||
|
import com.baiyue.common.security.AuthenticatedUser;
|
||||||
|
import com.baiyue.firstleg.sailingcalendar.dto.SailingCalendarDtos.ForecastCreateRequest;
|
||||||
|
import com.baiyue.firstleg.sailingcalendar.dto.SailingCalendarDtos.ForecastRevisionRequest;
|
||||||
|
import com.baiyue.firstleg.sailingcalendar.dto.SailingCalendarDtos.ForecastReceiptBindRequest;
|
||||||
|
import com.baiyue.firstleg.sailingcalendar.dto.SailingCalendarDtos.ForecastVoidRequest;
|
||||||
|
import com.baiyue.firstleg.sailingcalendar.fact.entity.SailingInboundVolumeForecastEntity;
|
||||||
|
import com.baiyue.firstleg.sailingcalendar.service.SailingCalendarService;
|
||||||
|
import com.baiyue.firstleg.service.FirstlegScopeGuard;
|
||||||
|
import io.swagger.v3.oas.annotations.Operation;
|
||||||
|
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||||
|
import jakarta.annotation.Resource;
|
||||||
|
import jakarta.validation.Valid;
|
||||||
|
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.PostMapping;
|
||||||
|
import org.springframework.web.bind.annotation.PutMapping;
|
||||||
|
import org.springframework.web.bind.annotation.PathVariable;
|
||||||
|
import org.springframework.web.bind.annotation.RequestBody;
|
||||||
|
import org.springframework.web.bind.annotation.RequestMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
|
|
||||||
|
/** 平台运营维护船期日历使用的正式未收货量预报事实入口。 */
|
||||||
|
@Validated
|
||||||
|
@RestController
|
||||||
|
@Tag(name = "船期货量预报")
|
||||||
|
@RequestMapping("/api/platform/v1/sailing-forecasts")
|
||||||
|
public class PlatformSailingForecastController {
|
||||||
|
@Resource private SailingCalendarService service;
|
||||||
|
|
||||||
|
@PostMapping
|
||||||
|
@PreAuthorize("hasAuthority('platform:firstleg:sailing-volume:forecast-manage')")
|
||||||
|
@Operation(summary = "新增未收货量预报", description = "保存版本化预报事实并按仓库、渠道、港口和截止时间自动归班;无法解释时保留UNMATCHED。")
|
||||||
|
public ApiResponse<SailingInboundVolumeForecastEntity> create(@AuthenticationPrincipal AuthenticatedUser user,
|
||||||
|
@Valid @RequestBody ForecastCreateRequest request) {
|
||||||
|
FirstlegScopeGuard.platform(user, request.tenantId());
|
||||||
|
return ApiResponse.success(service.createForecast(user.userId(), request));
|
||||||
|
}
|
||||||
|
|
||||||
|
@PutMapping("/{id}")
|
||||||
|
@PreAuthorize("hasAuthority('platform:firstleg:sailing-volume:forecast-manage')")
|
||||||
|
@Operation(summary = "修订未收货量预报", description = "旧版本标记为SUPERSEDED,新版本重新归班;不覆盖历史版本。")
|
||||||
|
public ApiResponse<SailingInboundVolumeForecastEntity> revise(@AuthenticationPrincipal AuthenticatedUser user,
|
||||||
|
@PathVariable long id,
|
||||||
|
@Valid @RequestBody ForecastRevisionRequest request) {
|
||||||
|
FirstlegScopeGuard.platform(user, request.tenantId());
|
||||||
|
return ApiResponse.success(service.reviseForecast(user.userId(), id, request));
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/{id}/void")
|
||||||
|
@PreAuthorize("hasAuthority('platform:firstleg:sailing-volume:forecast-manage')")
|
||||||
|
@Operation(summary = "作废未收货量预报", description = "作废有效预报并将其当前归班版本标记为SUPERSEDED。")
|
||||||
|
public ApiResponse<Void> voidForecast(@AuthenticationPrincipal AuthenticatedUser user,
|
||||||
|
@PathVariable long id,
|
||||||
|
@Valid @RequestBody ForecastVoidRequest request) {
|
||||||
|
FirstlegScopeGuard.platform(user, request.tenantId());
|
||||||
|
service.voidForecast(user.userId(), id, request);
|
||||||
|
return ApiResponse.success((Void) null);
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/{id}/bind-receipt")
|
||||||
|
@PreAuthorize("hasAuthority('platform:firstleg:sailing-volume:forecast-manage')")
|
||||||
|
@Operation(summary = "将实收批次绑定到预报批次", description = "多批预报无法由实测体积唯一解释时,由运营显式选择预报批次;服务端保留原历史版本。")
|
||||||
|
public ApiResponse<Void> bindReceipt(@AuthenticationPrincipal AuthenticatedUser user,
|
||||||
|
@PathVariable long id,
|
||||||
|
@Valid @RequestBody ForecastReceiptBindRequest request) {
|
||||||
|
FirstlegScopeGuard.platform(user, request.tenantId());
|
||||||
|
service.bindReceiptToForecast(user.userId(), id, request);
|
||||||
|
return ApiResponse.success((Void) null);
|
||||||
|
}
|
||||||
|
}
|
||||||
+40
@@ -0,0 +1,40 @@
|
|||||||
|
package com.baiyue.firstleg.sailingcalendar.controller;
|
||||||
|
|
||||||
|
import com.baiyue.common.core.api.ApiResponse;
|
||||||
|
import com.baiyue.common.core.api.PageResult;
|
||||||
|
import com.baiyue.common.security.AuthenticatedUser;
|
||||||
|
import com.baiyue.firstleg.sailingcalendar.dto.SailingCalendarDtos.*;
|
||||||
|
import com.baiyue.firstleg.sailingcalendar.entity.SailingEntity;
|
||||||
|
import com.baiyue.firstleg.sailingcalendar.entity.SailingTemplateEntity;
|
||||||
|
import com.baiyue.firstleg.sailingcalendar.service.SailingCalendarService;
|
||||||
|
import com.baiyue.firstleg.service.FirstlegScopeGuard;
|
||||||
|
import jakarta.annotation.Resource;
|
||||||
|
import jakarta.validation.Valid;
|
||||||
|
import jakarta.validation.constraints.Positive;
|
||||||
|
import org.springframework.format.annotation.DateTimeFormat;
|
||||||
|
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.*;
|
||||||
|
|
||||||
|
import java.time.LocalDate;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
@Validated
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/api/platform/v1/sailing-templates")
|
||||||
|
public class PlatformSailingTemplateController {
|
||||||
|
@Resource private SailingCalendarService service;
|
||||||
|
@GetMapping @PreAuthorize("hasAuthority('platform:firstleg:sailing-template:manage')")
|
||||||
|
public ApiResponse<List<TemplateView>> page(@AuthenticationPrincipal AuthenticatedUser user, @Valid TemplateQuery query) { FirstlegScopeGuard.platform(user); return ApiResponse.success(service.templateViews(query)); }
|
||||||
|
@PostMapping @PreAuthorize("hasAuthority('platform:firstleg:sailing-template:manage')")
|
||||||
|
public ApiResponse<TemplateView> create(@AuthenticationPrincipal AuthenticatedUser user, @Valid @RequestBody TemplateCreateRequest request) { FirstlegScopeGuard.platform(user); return ApiResponse.success(service.templateView(service.createTemplate(user.userId(), user.username(), request).getId())); }
|
||||||
|
@PutMapping("/{id}") @PreAuthorize("hasAuthority('platform:firstleg:sailing-template:manage')")
|
||||||
|
public ApiResponse<TemplateView> update(@AuthenticationPrincipal AuthenticatedUser user, @PathVariable @Positive long id, @Valid @RequestBody TemplateUpdateRequest request) { FirstlegScopeGuard.platform(user); service.updateTemplate(id, user.userId(), user.username(), request); return ApiResponse.success(service.templateView(id)); }
|
||||||
|
@PostMapping("/{id}/generation-preview") @PreAuthorize("hasAuthority('platform:firstleg:sailing-template:manage')")
|
||||||
|
public ApiResponse<GenerationPreviewResult> preview(@AuthenticationPrincipal AuthenticatedUser user, @PathVariable @Positive long id,
|
||||||
|
@Valid @RequestBody GenerationRange range) { FirstlegScopeGuard.platform(user); return ApiResponse.success(new GenerationPreviewResult(id, service.generationPreview(id, range.from(), range.to()))); }
|
||||||
|
@PostMapping("/{id}/generate") @PreAuthorize("hasAuthority('platform:firstleg:sailing-template:manage')")
|
||||||
|
public ApiResponse<GenerationResult> generate(@AuthenticationPrincipal AuthenticatedUser user, @PathVariable @Positive long id,
|
||||||
|
@Valid @RequestBody GenerationRange range) { FirstlegScopeGuard.platform(user); List<SailingEntity> generated = service.generate(user.userId(), user.username(), id, range.from(), range.to()); List<GenerationPreview> records = service.generationPreview(id, range.from(), range.to()); return ApiResponse.success(new GenerationResult(id, records, generated.size(), Math.max(0, records.size() - generated.size()))); }
|
||||||
|
}
|
||||||
+283
@@ -0,0 +1,283 @@
|
|||||||
|
package com.baiyue.firstleg.sailingcalendar.dto;
|
||||||
|
|
||||||
|
import com.baiyue.firstleg.sailingcalendar.entity.SailingChannelEntity;
|
||||||
|
import com.baiyue.firstleg.sailingcalendar.entity.SailingEntity;
|
||||||
|
import com.baiyue.firstleg.sailingcalendar.entity.SailingTemplateEntity;
|
||||||
|
import jakarta.validation.constraints.Max;
|
||||||
|
import jakarta.validation.constraints.Min;
|
||||||
|
import jakarta.validation.constraints.NotBlank;
|
||||||
|
import jakarta.validation.constraints.NotNull;
|
||||||
|
import jakarta.validation.constraints.Positive;
|
||||||
|
import jakarta.validation.constraints.Size;
|
||||||
|
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
import java.time.LocalDate;
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import java.time.LocalTime;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
public final class SailingCalendarDtos {
|
||||||
|
private SailingCalendarDtos() {}
|
||||||
|
|
||||||
|
public record ChannelQuery(@Size(max = 64) String keyword, String status) {}
|
||||||
|
public record ChannelData(@NotBlank @Size(max = 64) String channelCode,
|
||||||
|
@NotBlank @Size(max = 128) String channelName,
|
||||||
|
@NotBlank String sourceType,
|
||||||
|
@Size(max = 64) String mappedChannelCode,
|
||||||
|
@Size(max = 32) String defaultDeparturePortCode,
|
||||||
|
@Size(max = 32) String defaultDestinationPortCode,
|
||||||
|
@Min(0) @Max(30) Integer defaultCutoffDays,
|
||||||
|
@Size(max = 1000) String remark) {}
|
||||||
|
/** Flat JSON contract; data() keeps the service boundary grouped by domain object. */
|
||||||
|
public record ChannelCreateRequest(@Min(0) Integer expectedVersion,
|
||||||
|
@NotBlank @Size(max = 64) String channelCode,
|
||||||
|
@NotBlank @Size(max = 128) String channelName,
|
||||||
|
@NotBlank String sourceType,
|
||||||
|
@Size(max = 64) String mappedChannelCode,
|
||||||
|
@Size(max = 32) String defaultDeparturePortCode,
|
||||||
|
@Size(max = 32) String defaultDestinationPortCode,
|
||||||
|
@Min(0) @Max(30) Integer defaultCutoffDays,
|
||||||
|
@Size(max = 1000) String remark) {
|
||||||
|
public ChannelData data() { return new ChannelData(channelCode, channelName, sourceType,
|
||||||
|
mappedChannelCode, defaultDeparturePortCode, defaultDestinationPortCode, defaultCutoffDays, remark); }
|
||||||
|
}
|
||||||
|
public record ChannelUpdateRequest(@Min(0) Integer expectedVersion,
|
||||||
|
@NotBlank @Size(max = 64) String channelCode,
|
||||||
|
@NotBlank @Size(max = 128) String channelName,
|
||||||
|
@NotBlank String sourceType,
|
||||||
|
@Size(max = 64) String mappedChannelCode,
|
||||||
|
@Size(max = 32) String defaultDeparturePortCode,
|
||||||
|
@Size(max = 32) String defaultDestinationPortCode,
|
||||||
|
@Min(0) @Max(30) Integer defaultCutoffDays,
|
||||||
|
@Size(max = 1000) String remark,
|
||||||
|
@NotBlank String status) {
|
||||||
|
public ChannelData data() { return new ChannelData(channelCode, channelName, sourceType,
|
||||||
|
mappedChannelCode, defaultDeparturePortCode, defaultDestinationPortCode, defaultCutoffDays, remark); }
|
||||||
|
}
|
||||||
|
public record ChannelView(SailingChannelEntity channel, boolean editable) {}
|
||||||
|
|
||||||
|
public record TemplateQuery(@Positive Long warehouseId, @Positive Long channelId,
|
||||||
|
String status, @Min(1) long page, @Min(1) @Max(200) long size) {
|
||||||
|
public TemplateQuery { if (page == 0) page = 1; if (size == 0) size = 20; }
|
||||||
|
}
|
||||||
|
public record TemplateData(@Size(max = 128) String templateName,
|
||||||
|
@NotNull @Positive Long channelId,
|
||||||
|
@NotNull @Positive Long warehouseId,
|
||||||
|
@Size(max = 128) String warehouseName,
|
||||||
|
List<@NotNull @Min(1) @Max(7) Integer> weekdays,
|
||||||
|
@NotNull LocalDate effectiveFrom,
|
||||||
|
LocalDate effectiveTo,
|
||||||
|
@NotNull LocalTime departureTime,
|
||||||
|
@NotNull @Min(0) @Max(30) Integer cutoffDays,
|
||||||
|
@NotNull LocalTime cutoffLocalTime,
|
||||||
|
@Size(max = 64) String voyageRule,
|
||||||
|
@Size(max = 128) String vesselName,
|
||||||
|
@Size(max = 32) String departurePortCode,
|
||||||
|
@Size(max = 32) String destinationPortCode,
|
||||||
|
@Size(max = 1000) String remark) {}
|
||||||
|
public record TemplateCreateRequest(@Min(0) Integer expectedVersion,
|
||||||
|
@Size(max = 128) String templateName,
|
||||||
|
@NotNull @Positive Long channelId,
|
||||||
|
@NotNull @Positive Long warehouseId,
|
||||||
|
@Size(max = 128) String warehouseName,
|
||||||
|
List<@NotNull @Min(1) @Max(7) Integer> weekdays,
|
||||||
|
@NotNull LocalDate effectiveFrom,
|
||||||
|
LocalDate effectiveTo,
|
||||||
|
@NotNull LocalTime departureTime,
|
||||||
|
@NotNull @Min(0) @Max(30) Integer cutoffDays,
|
||||||
|
@NotNull LocalTime cutoffLocalTime,
|
||||||
|
@Size(max = 64) String voyageRule,
|
||||||
|
@Size(max = 128) String vesselName,
|
||||||
|
@Size(max = 32) String departurePortCode,
|
||||||
|
@Size(max = 32) String destinationPortCode,
|
||||||
|
@Size(max = 1000) String remark) {
|
||||||
|
public TemplateData data() { return new TemplateData(templateName, channelId, warehouseId, warehouseName,
|
||||||
|
weekdays, effectiveFrom, effectiveTo, departureTime, cutoffDays, cutoffLocalTime, voyageRule,
|
||||||
|
vesselName, departurePortCode, destinationPortCode, remark); }
|
||||||
|
}
|
||||||
|
public record TemplateUpdateRequest(@Min(0) Integer expectedVersion,
|
||||||
|
@Size(max = 128) String templateName,
|
||||||
|
@NotNull @Positive Long channelId,
|
||||||
|
@NotNull @Positive Long warehouseId,
|
||||||
|
@Size(max = 128) String warehouseName,
|
||||||
|
List<@NotNull @Min(1) @Max(7) Integer> weekdays,
|
||||||
|
@NotNull LocalDate effectiveFrom,
|
||||||
|
LocalDate effectiveTo,
|
||||||
|
@NotNull LocalTime departureTime,
|
||||||
|
@NotNull @Min(0) @Max(30) Integer cutoffDays,
|
||||||
|
@NotNull LocalTime cutoffLocalTime,
|
||||||
|
@Size(max = 64) String voyageRule,
|
||||||
|
@Size(max = 128) String vesselName,
|
||||||
|
@Size(max = 32) String departurePortCode,
|
||||||
|
@Size(max = 32) String destinationPortCode,
|
||||||
|
@Size(max = 1000) String remark,
|
||||||
|
String status) {
|
||||||
|
public TemplateData data() { return new TemplateData(templateName, channelId, warehouseId, warehouseName,
|
||||||
|
weekdays, effectiveFrom, effectiveTo, departureTime, cutoffDays, cutoffLocalTime, voyageRule,
|
||||||
|
vesselName, departurePortCode, destinationPortCode, remark); }
|
||||||
|
}
|
||||||
|
public record TemplateView(Long id, String templateNo, String templateName, Long channelId, String channelCode,
|
||||||
|
String channelName, Long warehouseId, String warehouseName, List<Integer> weekdays,
|
||||||
|
LocalDate effectiveFrom, LocalDate effectiveTo, LocalTime departureTime,
|
||||||
|
Integer cutoffDays, LocalTime cutoffLocalTime, String voyageRule, String vesselName,
|
||||||
|
String departurePortCode, String destinationPortCode, String remark,
|
||||||
|
String status, Integer version) {}
|
||||||
|
|
||||||
|
public record SailingQuery(@NotBlank String month,
|
||||||
|
@Positive Long warehouseId, @Positive Long channelId,
|
||||||
|
@Size(max = 64) String channelCode,
|
||||||
|
@Size(max = 32) String departurePortCode,
|
||||||
|
@Size(max = 32) String destinationPortCode,
|
||||||
|
@Size(max = 128) String keyword, String status) {}
|
||||||
|
public record SailingData(@Positive Long templateId, @Positive Long channelId,
|
||||||
|
@NotNull @Positive Long warehouseId,
|
||||||
|
@Size(max = 128) String warehouseName,
|
||||||
|
@NotBlank @Size(max = 64) String channelCode,
|
||||||
|
@NotBlank @Size(max = 128) String channelName,
|
||||||
|
@NotBlank @Size(max = 32) String departurePortCode,
|
||||||
|
@Size(max = 128) String departurePortName,
|
||||||
|
@NotBlank @Size(max = 32) String destinationPortCode,
|
||||||
|
@Size(max = 128) String destinationPortName,
|
||||||
|
@NotBlank @Size(max = 128) String vesselName,
|
||||||
|
@NotBlank @Size(max = 64) String voyageNo,
|
||||||
|
@NotNull LocalDateTime etdAt,
|
||||||
|
LocalDateTime etaAt,
|
||||||
|
@NotNull LocalDateTime cutoffAt,
|
||||||
|
@Size(max = 64) String sourceType,
|
||||||
|
@Size(max = 128) String remark,
|
||||||
|
BigDecimal plannedCapacityCbm) {}
|
||||||
|
public record SailingCreateRequest(@Min(0) Integer expectedVersion,
|
||||||
|
@Positive Long templateId,
|
||||||
|
@Positive Long channelId,
|
||||||
|
@NotNull @Positive Long warehouseId,
|
||||||
|
@Size(max = 128) String warehouseName,
|
||||||
|
@NotBlank @Size(max = 64) String channelCode,
|
||||||
|
@NotBlank @Size(max = 128) String channelName,
|
||||||
|
@NotBlank @Size(max = 32) String departurePortCode,
|
||||||
|
@Size(max = 128) String departurePortName,
|
||||||
|
@NotBlank @Size(max = 32) String destinationPortCode,
|
||||||
|
@Size(max = 128) String destinationPortName,
|
||||||
|
@NotBlank @Size(max = 128) String vesselName,
|
||||||
|
@NotBlank @Size(max = 64) String voyageNo,
|
||||||
|
@NotNull LocalDateTime etdAt,
|
||||||
|
LocalDateTime etaAt,
|
||||||
|
@NotNull LocalDateTime cutoffAt,
|
||||||
|
@Size(max = 64) String sourceType,
|
||||||
|
@Size(max = 128) String remark,
|
||||||
|
BigDecimal plannedCapacityCbm) {
|
||||||
|
public SailingData data() { return new SailingData(templateId, channelId, warehouseId, warehouseName,
|
||||||
|
channelCode, channelName, departurePortCode, departurePortName, destinationPortCode, destinationPortName,
|
||||||
|
vesselName, voyageNo, etdAt, etaAt, cutoffAt, sourceType, remark, plannedCapacityCbm); }
|
||||||
|
}
|
||||||
|
public record SailingUpdateRequest(@Min(0) Integer expectedVersion,
|
||||||
|
@Positive Long templateId,
|
||||||
|
@Positive Long channelId,
|
||||||
|
@NotNull @Positive Long warehouseId,
|
||||||
|
@Size(max = 128) String warehouseName,
|
||||||
|
@NotBlank @Size(max = 64) String channelCode,
|
||||||
|
@NotBlank @Size(max = 128) String channelName,
|
||||||
|
@NotBlank @Size(max = 32) String departurePortCode,
|
||||||
|
@Size(max = 128) String departurePortName,
|
||||||
|
@NotBlank @Size(max = 32) String destinationPortCode,
|
||||||
|
@Size(max = 128) String destinationPortName,
|
||||||
|
@NotBlank @Size(max = 128) String vesselName,
|
||||||
|
@NotBlank @Size(max = 64) String voyageNo,
|
||||||
|
@NotNull LocalDateTime etdAt,
|
||||||
|
LocalDateTime etaAt,
|
||||||
|
@NotNull LocalDateTime cutoffAt,
|
||||||
|
@Size(max = 64) String sourceType,
|
||||||
|
@Size(max = 128) String remark,
|
||||||
|
BigDecimal plannedCapacityCbm,
|
||||||
|
String status) {
|
||||||
|
public SailingData data() { return new SailingData(templateId, channelId, warehouseId, warehouseName,
|
||||||
|
channelCode, channelName, departurePortCode, departurePortName, destinationPortCode, destinationPortName,
|
||||||
|
vesselName, voyageNo, etdAt, etaAt, cutoffAt, sourceType, remark, plannedCapacityCbm); }
|
||||||
|
}
|
||||||
|
public record CalendarItem(Long id, String sailingNo, Long warehouseId, String warehouseName,
|
||||||
|
String channelCode, String channelName, String vesselName, String voyageNo,
|
||||||
|
String departurePortCode, String destinationPortCode, LocalDateTime etdAt,
|
||||||
|
LocalDateTime cutoffAt, String status, Integer version,
|
||||||
|
VolumeSummary volumeSummary) {}
|
||||||
|
public record VolumeSummary(BigDecimal actualReceivedCbm, BigDecimal catchableForecastCbm,
|
||||||
|
BigDecimal riskUnmatchedCbm, int bookedContainerCount,
|
||||||
|
int recommendedContainerCount, int pendingContainerCount,
|
||||||
|
int lateBatchCount, boolean overCapacity, BigDecimal planningCapacityCbm,
|
||||||
|
BigDecimal bookedPlanningCapacityCbm, LocalDateTime snapshotAt,
|
||||||
|
BigDecimal catchableReceivedCbm, BigDecimal lateReceivedCbm) {}
|
||||||
|
public record CalendarResult(String month, String timezone, List<CalendarItem> records,
|
||||||
|
Summary summary, FilterOptions filterOptions) {
|
||||||
|
public record Summary(BigDecimal actualReceivedCbm, BigDecimal catchableForecastCbm, BigDecimal riskUnmatchedCbm,
|
||||||
|
int bookedContainerCount, int recommendedContainerCount, int pendingContainerCount,
|
||||||
|
int lateBatchCount, boolean overCapacity, BigDecimal planningCapacityCbm, int sailingCount,
|
||||||
|
BigDecimal catchableReceivedCbm, BigDecimal lateReceivedCbm) {}
|
||||||
|
public record FilterOptions(List<Option> warehouses, List<Option> channels, List<Option> departurePorts, List<Option> destinationPorts) {}
|
||||||
|
public record Option(String value, String label) {}
|
||||||
|
}
|
||||||
|
public record SailingDetail(CalendarItem sailing, List<SailingHistoryView> histories) {}
|
||||||
|
public record SailingHistoryView(Long id, String action, String fromStatus, String toStatus,
|
||||||
|
String reason, String operatorName, LocalDateTime createdAt) {}
|
||||||
|
public record GenerationPreview(LocalDate date, LocalDateTime etdAt, LocalDateTime cutoffAt,
|
||||||
|
String voyageNo, boolean conflict, String conflictReason) {}
|
||||||
|
public record GenerationRange(@NotNull LocalDate from, @NotNull LocalDate to) {}
|
||||||
|
public record GenerationPreviewResult(Long templateId, List<GenerationPreview> records) {}
|
||||||
|
public record GenerationResult(Long templateId, List<GenerationPreview> records, int generatedCount, int skippedCount) {}
|
||||||
|
public record CancelRequest(Integer expectedVersion, @Size(max = 1000) String reason) {}
|
||||||
|
public record CopyRequest(@NotNull LocalDateTime etdAt, @NotNull LocalDateTime cutoffAt) {}
|
||||||
|
public record AssignmentView(String id, String receiptBatchId, String forecastId, String inboundCode,
|
||||||
|
LocalDateTime receivedAt, BigDecimal volumeCbm, String status,
|
||||||
|
String assignmentSource, String reason, Integer assignmentVersion) {}
|
||||||
|
public record VolumeDetail(VolumeSummary summary, List<AssignmentView> assignments) {}
|
||||||
|
public record BookingAdvice(BigDecimal actualReceivedCbm, BigDecimal catchableTotalCbm,
|
||||||
|
BigDecimal planningCapacityCbm, int bookedContainerCount,
|
||||||
|
int recommendedContainerCount, int pendingContainerCount,
|
||||||
|
List<BookingScenario> scenarios, LocalDateTime calculatedAt,
|
||||||
|
BigDecimal catchableReceivedCbm, BigDecimal lateReceivedCbm) {}
|
||||||
|
public record BookingScenario(String containerTypeCode, String containerTypeName,
|
||||||
|
BigDecimal capacityCbm, int recommendedContainerCount, int pendingContainerCount) {}
|
||||||
|
public record BookingLinkRequest(@NotNull @Positive Long oceanBookingId, Integer expectedVersion) {}
|
||||||
|
public record ExceptionReassignRequest(@NotNull @Positive Long assignmentId,
|
||||||
|
@NotNull @Positive Long targetSailingId,
|
||||||
|
@NotNull @Min(0) Integer expectedAssignmentVersion,
|
||||||
|
@NotNull @Min(0) Integer expectedTargetVersion,
|
||||||
|
@NotBlank @Size(max = 500) String reason) {}
|
||||||
|
public record ExceptionReassignResult(Long oldAssignmentId, Long newAssignmentId,
|
||||||
|
Long sourceSailingId, Long targetSailingId,
|
||||||
|
Integer assignmentVersion, String matchStatus,
|
||||||
|
BigDecimal volumeCbm, BigDecimal forecastVolumeCbm,
|
||||||
|
String reason) {}
|
||||||
|
public record ExceptionQuery(Long warehouseId, String channelCode, String status,
|
||||||
|
@Min(1) long page, @Min(1) @Max(200) long size) {
|
||||||
|
public ExceptionQuery { if (page == 0) page = 1; if (size == 0) size = 20; }
|
||||||
|
}
|
||||||
|
public record ExceptionRecord(Long assignmentId, String inboundCode, Long warehouseId, String warehouseName,
|
||||||
|
String channelCode, String channelName, String sourceSailingNo,
|
||||||
|
String sourceVoyageNo, String status, BigDecimal volumeCbm,
|
||||||
|
LocalDateTime receivedAt, LocalDateTime expectedArrivalAt,
|
||||||
|
Integer waitingDays, String reason, Integer assignmentVersion) {}
|
||||||
|
public record ExceptionPage(List<ExceptionRecord> records, long page, long size, long total, long pages) {}
|
||||||
|
public record ForecastCreateRequest(@NotNull @Positive Long tenantId,
|
||||||
|
@NotNull @Positive Long inboundId,
|
||||||
|
@NotBlank @Size(max = 64) String forecastKey,
|
||||||
|
@NotNull @Positive Long receivingWarehouseId,
|
||||||
|
@Size(max = 64) String channelCode,
|
||||||
|
@Size(max = 128) String channelName,
|
||||||
|
@Size(max = 32) String departurePortCode,
|
||||||
|
@Size(max = 32) String destinationPortCode,
|
||||||
|
@NotNull LocalDateTime expectedArrivalAt,
|
||||||
|
@NotNull @jakarta.validation.constraints.DecimalMin(value = "0.0001") BigDecimal forecastVolumeCbm,
|
||||||
|
@NotBlank @Size(max = 32) String sourceType) {}
|
||||||
|
public record ForecastRevisionRequest(@NotNull @Positive Long tenantId,
|
||||||
|
@NotNull @Min(1) Integer expectedFactVersion,
|
||||||
|
@Size(max = 64) String channelCode,
|
||||||
|
@Size(max = 128) String channelName,
|
||||||
|
@Size(max = 32) String departurePortCode,
|
||||||
|
@Size(max = 32) String destinationPortCode,
|
||||||
|
@NotNull LocalDateTime expectedArrivalAt,
|
||||||
|
@NotNull @jakarta.validation.constraints.DecimalMin(value = "0.0001") BigDecimal forecastVolumeCbm,
|
||||||
|
@NotBlank @Size(max = 32) String sourceType) {}
|
||||||
|
public record ForecastVoidRequest(@NotNull @Positive Long tenantId, @NotBlank @Size(max = 500) String reason) {}
|
||||||
|
public record ForecastReceiptBindRequest(@NotNull @Positive Long tenantId,
|
||||||
|
@NotNull @Positive Long receiptBatchId,
|
||||||
|
@NotNull @Min(1) Integer expectedAssignmentVersion,
|
||||||
|
@NotBlank @Size(max = 500) String reason) {}
|
||||||
|
}
|
||||||
+32
@@ -0,0 +1,32 @@
|
|||||||
|
package com.baiyue.firstleg.sailingcalendar.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_sailing_channel")
|
||||||
|
public class SailingChannelEntity {
|
||||||
|
@TableId(type = IdType.ASSIGN_ID) private Long id;
|
||||||
|
private String channelCode;
|
||||||
|
private String channelName;
|
||||||
|
private String sourceType;
|
||||||
|
private String mappedChannelCode;
|
||||||
|
private String defaultDeparturePortCode;
|
||||||
|
private String defaultDestinationPortCode;
|
||||||
|
private Integer defaultCutoffDays;
|
||||||
|
private String status;
|
||||||
|
private String remark;
|
||||||
|
private Long createdBy;
|
||||||
|
private String createdByName;
|
||||||
|
private LocalDateTime createdAt;
|
||||||
|
private Long updatedBy;
|
||||||
|
private String updatedByName;
|
||||||
|
private LocalDateTime updatedAt;
|
||||||
|
private Integer version;
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
package com.baiyue.firstleg.sailingcalendar.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.math.BigDecimal;
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
|
||||||
|
@Getter
|
||||||
|
@Setter
|
||||||
|
@TableName("fl_sailing")
|
||||||
|
public class SailingEntity {
|
||||||
|
@TableId(type = IdType.ASSIGN_ID) private Long id;
|
||||||
|
private String sailingNo;
|
||||||
|
private Long templateId;
|
||||||
|
private Long channelId;
|
||||||
|
private String channelCodeSnapshot;
|
||||||
|
private String channelNameSnapshot;
|
||||||
|
private Long warehouseId;
|
||||||
|
private String warehouseNameSnapshot;
|
||||||
|
private String departurePortCode;
|
||||||
|
private String destinationPortCode;
|
||||||
|
private String vesselName;
|
||||||
|
private String voyageNo;
|
||||||
|
private LocalDateTime etdAt;
|
||||||
|
private LocalDateTime warehouseCutoffAt;
|
||||||
|
private LocalDateTime etaAt;
|
||||||
|
private String status;
|
||||||
|
private String sourceType;
|
||||||
|
private String generatedKey;
|
||||||
|
private BigDecimal plannedCapacityCbm;
|
||||||
|
private String remark;
|
||||||
|
private Long createdBy;
|
||||||
|
private String createdByName;
|
||||||
|
private LocalDateTime createdAt;
|
||||||
|
private Long updatedBy;
|
||||||
|
private String updatedByName;
|
||||||
|
private LocalDateTime updatedAt;
|
||||||
|
private Integer version;
|
||||||
|
}
|
||||||
+25
@@ -0,0 +1,25 @@
|
|||||||
|
package com.baiyue.firstleg.sailingcalendar.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_sailing_history")
|
||||||
|
public class SailingHistoryEntity {
|
||||||
|
@TableId(type = IdType.ASSIGN_ID) private Long id;
|
||||||
|
private Long sailingId;
|
||||||
|
private String action;
|
||||||
|
private String fromStatus;
|
||||||
|
private String toStatus;
|
||||||
|
private String reason;
|
||||||
|
private Long operatorId;
|
||||||
|
private String operatorName;
|
||||||
|
private LocalDateTime createdAt;
|
||||||
|
}
|
||||||
+45
@@ -0,0 +1,45 @@
|
|||||||
|
package com.baiyue.firstleg.sailingcalendar.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.LocalDate;
|
||||||
|
import java.time.LocalTime;
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
|
||||||
|
@Getter
|
||||||
|
@Setter
|
||||||
|
@TableName("fl_sailing_template")
|
||||||
|
public class SailingTemplateEntity {
|
||||||
|
@TableId(type = IdType.ASSIGN_ID) private Long id;
|
||||||
|
private String templateNo;
|
||||||
|
private String templateName;
|
||||||
|
private Long channelId;
|
||||||
|
private String channelCodeSnapshot;
|
||||||
|
private String channelNameSnapshot;
|
||||||
|
private Long warehouseId;
|
||||||
|
private String warehouseNameSnapshot;
|
||||||
|
private String departurePortCode;
|
||||||
|
private String destinationPortCode;
|
||||||
|
private Integer weekday;
|
||||||
|
private Integer weekdayMask;
|
||||||
|
private LocalTime etdLocalTime;
|
||||||
|
private Integer cutoffDays;
|
||||||
|
private LocalTime cutoffLocalTime;
|
||||||
|
private LocalDate validFrom;
|
||||||
|
private LocalDate validTo;
|
||||||
|
private String voyageRule;
|
||||||
|
private String vesselName;
|
||||||
|
private String status;
|
||||||
|
private String remark;
|
||||||
|
private Long createdBy;
|
||||||
|
private String createdByName;
|
||||||
|
private LocalDateTime createdAt;
|
||||||
|
private Long updatedBy;
|
||||||
|
private String updatedByName;
|
||||||
|
private LocalDateTime updatedAt;
|
||||||
|
private Integer version;
|
||||||
|
}
|
||||||
+40
@@ -0,0 +1,40 @@
|
|||||||
|
package com.baiyue.firstleg.sailingcalendar.fact.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.math.BigDecimal;
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
|
||||||
|
/** 自动归班事实;重算只追加新版本并将旧版本标记为SUPERSEDED。 */
|
||||||
|
@Getter
|
||||||
|
@Setter
|
||||||
|
@TableName("fl_sailing_assignment")
|
||||||
|
public class SailingAssignmentEntity {
|
||||||
|
@TableId(type = IdType.ASSIGN_ID) private Long id;
|
||||||
|
private Long tenantId;
|
||||||
|
private Long sailingId;
|
||||||
|
private Long originSailingId;
|
||||||
|
private Long inboundId;
|
||||||
|
private String inboundCode;
|
||||||
|
private Long receiptBatchId;
|
||||||
|
private Long forecastId;
|
||||||
|
private String assignmentSource;
|
||||||
|
private Integer assignmentVersion;
|
||||||
|
private String matchStatus;
|
||||||
|
private BigDecimal volumeCbm;
|
||||||
|
private BigDecimal forecastVolumeCbm;
|
||||||
|
private String routeSource;
|
||||||
|
private String volumeSource;
|
||||||
|
private String ruleVersion;
|
||||||
|
private LocalDateTime matchedAt;
|
||||||
|
private LocalDateTime supersededAt;
|
||||||
|
private LocalDateTime lockedAt;
|
||||||
|
private String lockReason;
|
||||||
|
private Long operatorId;
|
||||||
|
private String reason;
|
||||||
|
private LocalDateTime createdAt;
|
||||||
|
}
|
||||||
+32
@@ -0,0 +1,32 @@
|
|||||||
|
package com.baiyue.firstleg.sailingcalendar.fact.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;
|
||||||
|
|
||||||
|
/** 计划船期与RELEASED订舱的最小关联;不创建真实柜次。 */
|
||||||
|
@Getter
|
||||||
|
@Setter
|
||||||
|
@TableName("fl_sailing_booking_link")
|
||||||
|
public class SailingBookingLinkEntity {
|
||||||
|
@TableId(type = IdType.ASSIGN_ID) private Long id;
|
||||||
|
private Long tenantId;
|
||||||
|
private Long sailingId;
|
||||||
|
private Long oceanBookingId;
|
||||||
|
private String linkStatus;
|
||||||
|
private Integer activeBookingGuard;
|
||||||
|
private String bookingStatusSnapshot;
|
||||||
|
private Long warehouseId;
|
||||||
|
private String channelCode;
|
||||||
|
private String departurePortCode;
|
||||||
|
private String destinationPortCode;
|
||||||
|
private LocalDateTime linkedAt;
|
||||||
|
private LocalDateTime revokedAt;
|
||||||
|
private Long operatorId;
|
||||||
|
private String operatorName;
|
||||||
|
private String reason;
|
||||||
|
}
|
||||||
+35
@@ -0,0 +1,35 @@
|
|||||||
|
package com.baiyue.firstleg.sailingcalendar.fact.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.math.BigDecimal;
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
|
||||||
|
/** 未收货量预报事实;申报体积不是本实体的回退来源。 */
|
||||||
|
@Getter
|
||||||
|
@Setter
|
||||||
|
@TableName("fl_sailing_inbound_volume_forecast")
|
||||||
|
public class SailingInboundVolumeForecastEntity {
|
||||||
|
@TableId(type = IdType.ASSIGN_ID) private Long id;
|
||||||
|
private Long tenantId;
|
||||||
|
private String forecastKey;
|
||||||
|
private Integer factVersion;
|
||||||
|
private Long inboundId;
|
||||||
|
private Long receivingWarehouseId;
|
||||||
|
private String channelCode;
|
||||||
|
private String channelName;
|
||||||
|
private String departurePortCode;
|
||||||
|
private String destinationPortCode;
|
||||||
|
private LocalDateTime expectedArrivalAt;
|
||||||
|
private BigDecimal forecastVolumeCbm;
|
||||||
|
private String sourceType;
|
||||||
|
private String factStatus;
|
||||||
|
private LocalDateTime effectiveAt;
|
||||||
|
private Long createdBy;
|
||||||
|
private LocalDateTime createdAt;
|
||||||
|
private LocalDateTime updatedAt;
|
||||||
|
}
|
||||||
+36
@@ -0,0 +1,36 @@
|
|||||||
|
package com.baiyue.firstleg.sailingcalendar.fact.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.math.BigDecimal;
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
|
||||||
|
/** 船期日历消费的稳定收货批次实测事实;不直接替代入库单聚合。 */
|
||||||
|
@Getter
|
||||||
|
@Setter
|
||||||
|
@TableName("fl_sailing_receipt_batch_fact")
|
||||||
|
public class SailingReceiptBatchFactEntity {
|
||||||
|
@TableId(type = IdType.ASSIGN_ID) private Long id;
|
||||||
|
private Long tenantId;
|
||||||
|
private String receiptBatchKey;
|
||||||
|
private Integer factVersion;
|
||||||
|
private Long inboundId;
|
||||||
|
private Long measurementId;
|
||||||
|
private Long receivingWarehouseId;
|
||||||
|
private String channelCode;
|
||||||
|
private String channelName;
|
||||||
|
private String departurePortCode;
|
||||||
|
private String destinationPortCode;
|
||||||
|
private LocalDateTime receivedAt;
|
||||||
|
private BigDecimal actualVolumeCbm;
|
||||||
|
private String factStatus;
|
||||||
|
private Long reversalOfFactId;
|
||||||
|
private String sourceType;
|
||||||
|
private Long createdBy;
|
||||||
|
private LocalDateTime createdAt;
|
||||||
|
private LocalDateTime updatedAt;
|
||||||
|
}
|
||||||
+24
@@ -0,0 +1,24 @@
|
|||||||
|
package com.baiyue.firstleg.sailingcalendar.fact.mapper;
|
||||||
|
|
||||||
|
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||||
|
import com.baiyue.firstleg.sailingcalendar.fact.entity.SailingAssignmentEntity;
|
||||||
|
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.util.List;
|
||||||
|
|
||||||
|
@Mapper
|
||||||
|
public interface SailingAssignmentMapper extends BaseMapper<SailingAssignmentEntity> {
|
||||||
|
@Select("SELECT * FROM fl_sailing_assignment WHERE id=#{id} FOR UPDATE")
|
||||||
|
SailingAssignmentEntity selectForUpdate(@Param("id") long id);
|
||||||
|
|
||||||
|
@Update("UPDATE fl_sailing_assignment SET match_status='SUPERSEDED',superseded_at=#{at},operator_id=#{operatorId},reason=#{reason} "
|
||||||
|
+ "WHERE id=#{id} AND assignment_version=#{expectedVersion} AND match_status IN ('FORECAST_MATCHED','RECEIVED','LATE','UNMATCHED')")
|
||||||
|
int supersedeCurrent(@Param("id") long id, @Param("expectedVersion") int expectedVersion,
|
||||||
|
@Param("operatorId") long operatorId, @Param("reason") String reason,
|
||||||
|
@Param("at") java.time.LocalDateTime at);
|
||||||
|
|
||||||
|
@Select("SELECT * FROM fl_sailing_assignment WHERE match_status IN ('UNMATCHED','LATE') ORDER BY matched_at ASC,id ASC")
|
||||||
|
List<SailingAssignmentEntity> selectExceptions();
|
||||||
|
}
|
||||||
+8
@@ -0,0 +1,8 @@
|
|||||||
|
package com.baiyue.firstleg.sailingcalendar.fact.mapper;
|
||||||
|
|
||||||
|
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||||
|
import com.baiyue.firstleg.sailingcalendar.fact.entity.SailingBookingLinkEntity;
|
||||||
|
import org.apache.ibatis.annotations.Mapper;
|
||||||
|
|
||||||
|
@Mapper
|
||||||
|
public interface SailingBookingLinkMapper extends BaseMapper<SailingBookingLinkEntity> {}
|
||||||
+8
@@ -0,0 +1,8 @@
|
|||||||
|
package com.baiyue.firstleg.sailingcalendar.fact.mapper;
|
||||||
|
|
||||||
|
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||||
|
import com.baiyue.firstleg.sailingcalendar.fact.entity.SailingInboundVolumeForecastEntity;
|
||||||
|
import org.apache.ibatis.annotations.Mapper;
|
||||||
|
|
||||||
|
@Mapper
|
||||||
|
public interface SailingInboundVolumeForecastMapper extends BaseMapper<SailingInboundVolumeForecastEntity> {}
|
||||||
+18
@@ -0,0 +1,18 @@
|
|||||||
|
package com.baiyue.firstleg.sailingcalendar.fact.mapper;
|
||||||
|
|
||||||
|
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||||
|
import com.baomidou.mybatisplus.core.metadata.IPage;
|
||||||
|
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
|
||||||
|
import com.baiyue.firstleg.sailingcalendar.fact.entity.SailingReceiptBatchFactEntity;
|
||||||
|
import org.apache.ibatis.annotations.Mapper;
|
||||||
|
import org.apache.ibatis.annotations.Param;
|
||||||
|
import org.apache.ibatis.annotations.Select;
|
||||||
|
|
||||||
|
@Mapper
|
||||||
|
public interface SailingReceiptBatchFactMapper extends BaseMapper<SailingReceiptBatchFactEntity> {
|
||||||
|
@Select("SELECT * FROM fl_sailing_receipt_batch_fact WHERE tenant_id=#{tenantId} AND receipt_batch_key=#{batchKey} AND fact_status='APPROVED' ORDER BY fact_version DESC, id DESC LIMIT 1")
|
||||||
|
SailingReceiptBatchFactEntity selectCurrent(@Param("tenantId") long tenantId, @Param("batchKey") String batchKey);
|
||||||
|
|
||||||
|
@Select("SELECT * FROM fl_sailing_receipt_batch_fact WHERE tenant_id=#{tenantId} AND receiving_warehouse_id=#{warehouseId} AND fact_status='APPROVED' AND received_at >= #{from} AND received_at < #{to} ORDER BY received_at, id")
|
||||||
|
IPage<SailingReceiptBatchFactEntity> selectReceived(Page<SailingReceiptBatchFactEntity> page, @Param("tenantId") long tenantId, @Param("warehouseId") long warehouseId, @Param("from") java.time.LocalDateTime from, @Param("to") java.time.LocalDateTime to);
|
||||||
|
}
|
||||||
+195
@@ -0,0 +1,195 @@
|
|||||||
|
package com.baiyue.firstleg.sailingcalendar.fact.repository;
|
||||||
|
|
||||||
|
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||||
|
import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
|
||||||
|
import com.baiyue.firstleg.sailingcalendar.fact.entity.SailingAssignmentEntity;
|
||||||
|
import com.baiyue.firstleg.sailingcalendar.fact.entity.SailingBookingLinkEntity;
|
||||||
|
import com.baiyue.firstleg.sailingcalendar.fact.entity.SailingInboundVolumeForecastEntity;
|
||||||
|
import com.baiyue.firstleg.sailingcalendar.fact.entity.SailingReceiptBatchFactEntity;
|
||||||
|
import com.baiyue.firstleg.sailingcalendar.fact.mapper.SailingAssignmentMapper;
|
||||||
|
import com.baiyue.firstleg.sailingcalendar.fact.mapper.SailingBookingLinkMapper;
|
||||||
|
import com.baiyue.firstleg.sailingcalendar.fact.mapper.SailingInboundVolumeForecastMapper;
|
||||||
|
import com.baiyue.firstleg.sailingcalendar.fact.mapper.SailingReceiptBatchFactMapper;
|
||||||
|
import jakarta.annotation.Resource;
|
||||||
|
import org.springframework.stereotype.Repository;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 船期日历事实读写端口。
|
||||||
|
*
|
||||||
|
* 这里故意不从 fl_inbound_order 分页拼装数据,也不把订舱柜次关联当作已订柜数;
|
||||||
|
* 上层服务应在事务内写入新的事实版本,再由日历域按有效状态读取。
|
||||||
|
*/
|
||||||
|
@Repository
|
||||||
|
public class SailingFactsRepository {
|
||||||
|
@Resource private SailingReceiptBatchFactMapper receiptFacts;
|
||||||
|
@Resource private SailingInboundVolumeForecastMapper forecasts;
|
||||||
|
@Resource private SailingAssignmentMapper assignments;
|
||||||
|
@Resource private SailingBookingLinkMapper bookingLinks;
|
||||||
|
|
||||||
|
public void insertReceiptFact(SailingReceiptBatchFactEntity value) {
|
||||||
|
receiptFacts.insert(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
public SailingReceiptBatchFactEntity currentReceiptFact(long tenantId, String batchKey) {
|
||||||
|
return receiptFacts.selectCurrent(tenantId, batchKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 查询船期候选范围内的有效实收事实;平台汇总故意不带客户分页条件。 */
|
||||||
|
public List<SailingReceiptBatchFactEntity> approvedReceiptFacts(long warehouseId, LocalDateTime from, LocalDateTime to) {
|
||||||
|
return receiptFacts.selectList(new LambdaQueryWrapper<SailingReceiptBatchFactEntity>()
|
||||||
|
.eq(SailingReceiptBatchFactEntity::getReceivingWarehouseId, warehouseId)
|
||||||
|
.eq(SailingReceiptBatchFactEntity::getFactStatus, "APPROVED")
|
||||||
|
.ge(SailingReceiptBatchFactEntity::getReceivedAt, from)
|
||||||
|
.lt(SailingReceiptBatchFactEntity::getReceivedAt, to)
|
||||||
|
.orderByAsc(SailingReceiptBatchFactEntity::getReceivedAt)
|
||||||
|
.orderByAsc(SailingReceiptBatchFactEntity::getId));
|
||||||
|
}
|
||||||
|
|
||||||
|
public void insertForecast(SailingInboundVolumeForecastEntity value) {
|
||||||
|
forecasts.insert(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
public SailingInboundVolumeForecastEntity forecast(long id) { return forecasts.selectById(id); }
|
||||||
|
|
||||||
|
public int consumeForecast(long tenantId, long forecastId, LocalDateTime at) {
|
||||||
|
return forecasts.update(null, new LambdaUpdateWrapper<SailingInboundVolumeForecastEntity>()
|
||||||
|
.eq(SailingInboundVolumeForecastEntity::getId, forecastId)
|
||||||
|
.eq(SailingInboundVolumeForecastEntity::getTenantId, tenantId)
|
||||||
|
.eq(SailingInboundVolumeForecastEntity::getFactStatus, "VALID")
|
||||||
|
.set(SailingInboundVolumeForecastEntity::getFactStatus, "CONSUMED")
|
||||||
|
.set(SailingInboundVolumeForecastEntity::getUpdatedAt, at));
|
||||||
|
}
|
||||||
|
|
||||||
|
public int supersedeForecast(long tenantId, long forecastId, LocalDateTime at) {
|
||||||
|
return forecasts.update(null, new LambdaUpdateWrapper<SailingInboundVolumeForecastEntity>()
|
||||||
|
.eq(SailingInboundVolumeForecastEntity::getId, forecastId)
|
||||||
|
.eq(SailingInboundVolumeForecastEntity::getTenantId, tenantId)
|
||||||
|
.eq(SailingInboundVolumeForecastEntity::getFactStatus, "VALID")
|
||||||
|
.set(SailingInboundVolumeForecastEntity::getFactStatus, "SUPERSEDED")
|
||||||
|
.set(SailingInboundVolumeForecastEntity::getUpdatedAt, at));
|
||||||
|
}
|
||||||
|
|
||||||
|
public int voidForecast(long tenantId, long forecastId, LocalDateTime at) {
|
||||||
|
return forecasts.update(null, new LambdaUpdateWrapper<SailingInboundVolumeForecastEntity>()
|
||||||
|
.eq(SailingInboundVolumeForecastEntity::getId, forecastId)
|
||||||
|
.eq(SailingInboundVolumeForecastEntity::getTenantId, tenantId)
|
||||||
|
.eq(SailingInboundVolumeForecastEntity::getFactStatus, "VALID")
|
||||||
|
.set(SailingInboundVolumeForecastEntity::getFactStatus, "VOIDED")
|
||||||
|
.set(SailingInboundVolumeForecastEntity::getUpdatedAt, at));
|
||||||
|
}
|
||||||
|
|
||||||
|
public SailingReceiptBatchFactEntity receiptFact(long id) { return receiptFacts.selectById(id); }
|
||||||
|
|
||||||
|
public List<SailingInboundVolumeForecastEntity> currentForecasts(long tenantId, long inboundId) {
|
||||||
|
return forecasts.selectList(new LambdaQueryWrapper<SailingInboundVolumeForecastEntity>()
|
||||||
|
.eq(SailingInboundVolumeForecastEntity::getTenantId, tenantId)
|
||||||
|
.eq(SailingInboundVolumeForecastEntity::getInboundId, inboundId)
|
||||||
|
.eq(SailingInboundVolumeForecastEntity::getFactStatus, "VALID")
|
||||||
|
.orderByDesc(SailingInboundVolumeForecastEntity::getFactVersion)
|
||||||
|
.orderByDesc(SailingInboundVolumeForecastEntity::getId));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 返回某个预计批次当前仍有效的归班版本,供实收替换时保留原船期。 */
|
||||||
|
public SailingAssignmentEntity currentAssignmentForForecast(long tenantId, long forecastId) {
|
||||||
|
return assignments.selectOne(new LambdaQueryWrapper<SailingAssignmentEntity>()
|
||||||
|
.eq(SailingAssignmentEntity::getTenantId, tenantId)
|
||||||
|
.eq(SailingAssignmentEntity::getForecastId, forecastId)
|
||||||
|
.in(SailingAssignmentEntity::getMatchStatus, List.of("FORECAST_MATCHED", "RECEIVED", "LATE", "UNMATCHED"))
|
||||||
|
.orderByDesc(SailingAssignmentEntity::getAssignmentVersion)
|
||||||
|
.orderByDesc(SailingAssignmentEntity::getId)
|
||||||
|
.last("LIMIT 1"));
|
||||||
|
}
|
||||||
|
|
||||||
|
public SailingAssignmentEntity currentAssignmentForReceipt(long tenantId, long receiptBatchId) {
|
||||||
|
return assignments.selectOne(new LambdaQueryWrapper<SailingAssignmentEntity>()
|
||||||
|
.eq(SailingAssignmentEntity::getTenantId, tenantId)
|
||||||
|
.eq(SailingAssignmentEntity::getReceiptBatchId, receiptBatchId)
|
||||||
|
.in(SailingAssignmentEntity::getMatchStatus, List.of("FORECAST_MATCHED", "RECEIVED", "LATE", "UNMATCHED"))
|
||||||
|
.orderByDesc(SailingAssignmentEntity::getAssignmentVersion)
|
||||||
|
.orderByDesc(SailingAssignmentEntity::getId)
|
||||||
|
.last("LIMIT 1"));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 查询仍有效且有ETA的预计事实;实收事实应由上层扣除,避免重复计量。 */
|
||||||
|
public List<SailingInboundVolumeForecastEntity> validForecasts(long warehouseId, LocalDateTime from, LocalDateTime to) {
|
||||||
|
return forecasts.selectList(new LambdaQueryWrapper<SailingInboundVolumeForecastEntity>()
|
||||||
|
.eq(SailingInboundVolumeForecastEntity::getReceivingWarehouseId, warehouseId)
|
||||||
|
.eq(SailingInboundVolumeForecastEntity::getFactStatus, "VALID")
|
||||||
|
.ge(SailingInboundVolumeForecastEntity::getExpectedArrivalAt, from)
|
||||||
|
.lt(SailingInboundVolumeForecastEntity::getExpectedArrivalAt, to)
|
||||||
|
.orderByAsc(SailingInboundVolumeForecastEntity::getExpectedArrivalAt)
|
||||||
|
.orderByAsc(SailingInboundVolumeForecastEntity::getId));
|
||||||
|
}
|
||||||
|
|
||||||
|
public void insertAssignment(SailingAssignmentEntity value) {
|
||||||
|
assignments.insert(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
public SailingAssignmentEntity assignmentForUpdate(long id) { return assignments.selectForUpdate(id); }
|
||||||
|
|
||||||
|
public int supersedeAssignment(long id, int expectedVersion, long operatorId, String reason, LocalDateTime at) {
|
||||||
|
return assignments.supersedeCurrent(id, expectedVersion, operatorId, reason, at);
|
||||||
|
}
|
||||||
|
|
||||||
|
public List<SailingAssignmentEntity> assignments(long sailingId) {
|
||||||
|
return assignments.selectList(new LambdaQueryWrapper<SailingAssignmentEntity>()
|
||||||
|
.eq(SailingAssignmentEntity::getSailingId, sailingId)
|
||||||
|
.in(SailingAssignmentEntity::getMatchStatus, List.of("FORECAST_MATCHED", "RECEIVED", "LATE", "UNMATCHED"))
|
||||||
|
.orderByAsc(SailingAssignmentEntity::getMatchedAt)
|
||||||
|
.orderByAsc(SailingAssignmentEntity::getId));
|
||||||
|
}
|
||||||
|
|
||||||
|
public List<SailingAssignmentEntity> exceptionAssignments() { return assignments.selectExceptions(); }
|
||||||
|
|
||||||
|
/** 旧版本只允许转为SUPERSEDED,不物理删除或覆盖。 */
|
||||||
|
public int supersedeReceiptAssignments(long tenantId, long receiptBatchId, java.time.LocalDateTime at) {
|
||||||
|
return assignments.update(null, new LambdaUpdateWrapper<SailingAssignmentEntity>()
|
||||||
|
.eq(SailingAssignmentEntity::getTenantId, tenantId)
|
||||||
|
.eq(SailingAssignmentEntity::getReceiptBatchId, receiptBatchId)
|
||||||
|
.in(SailingAssignmentEntity::getMatchStatus, List.of("FORECAST_MATCHED", "RECEIVED", "LATE", "UNMATCHED"))
|
||||||
|
.set(SailingAssignmentEntity::getMatchStatus, "SUPERSEDED")
|
||||||
|
.set(SailingAssignmentEntity::getSupersededAt, at));
|
||||||
|
}
|
||||||
|
|
||||||
|
public int supersedeForecastAssignments(long tenantId, long forecastId, java.time.LocalDateTime at) {
|
||||||
|
return assignments.update(null, new LambdaUpdateWrapper<SailingAssignmentEntity>()
|
||||||
|
.eq(SailingAssignmentEntity::getTenantId, tenantId)
|
||||||
|
.eq(SailingAssignmentEntity::getForecastId, forecastId)
|
||||||
|
.in(SailingAssignmentEntity::getMatchStatus, List.of("FORECAST_MATCHED", "RECEIVED", "LATE", "UNMATCHED"))
|
||||||
|
.set(SailingAssignmentEntity::getMatchStatus, "SUPERSEDED")
|
||||||
|
.set(SailingAssignmentEntity::getSupersededAt, at));
|
||||||
|
}
|
||||||
|
|
||||||
|
public void insertBookingLink(SailingBookingLinkEntity value) {
|
||||||
|
bookingLinks.insert(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
public SailingBookingLinkEntity activeBookingLink(long oceanBookingId) {
|
||||||
|
return bookingLinks.selectOne(new LambdaQueryWrapper<SailingBookingLinkEntity>()
|
||||||
|
.eq(SailingBookingLinkEntity::getOceanBookingId, oceanBookingId)
|
||||||
|
.eq(SailingBookingLinkEntity::getLinkStatus, "ACTIVE")
|
||||||
|
.last("LIMIT 1"));
|
||||||
|
}
|
||||||
|
|
||||||
|
public List<SailingBookingLinkEntity> activeBookingLinks(long sailingId) {
|
||||||
|
return bookingLinks.selectList(new LambdaQueryWrapper<SailingBookingLinkEntity>()
|
||||||
|
.eq(SailingBookingLinkEntity::getSailingId, sailingId)
|
||||||
|
.eq(SailingBookingLinkEntity::getLinkStatus, "ACTIVE")
|
||||||
|
.orderByAsc(SailingBookingLinkEntity::getLinkedAt)
|
||||||
|
.orderByAsc(SailingBookingLinkEntity::getId));
|
||||||
|
}
|
||||||
|
|
||||||
|
public int revokeBookingLink(long id, long operatorId, String reason, java.time.LocalDateTime at) {
|
||||||
|
return bookingLinks.update(null, new LambdaUpdateWrapper<SailingBookingLinkEntity>()
|
||||||
|
.eq(SailingBookingLinkEntity::getId, id)
|
||||||
|
.eq(SailingBookingLinkEntity::getLinkStatus, "ACTIVE")
|
||||||
|
.set(SailingBookingLinkEntity::getLinkStatus, "REVOKED")
|
||||||
|
.set(SailingBookingLinkEntity::getActiveBookingGuard, null)
|
||||||
|
.set(SailingBookingLinkEntity::getRevokedAt, at)
|
||||||
|
.set(SailingBookingLinkEntity::getOperatorId, operatorId)
|
||||||
|
.set(SailingBookingLinkEntity::getReason, reason));
|
||||||
|
}
|
||||||
|
}
|
||||||
+82
@@ -0,0 +1,82 @@
|
|||||||
|
package com.baiyue.firstleg.sailingcalendar.mapper;
|
||||||
|
|
||||||
|
import org.apache.ibatis.annotations.Mapper;
|
||||||
|
import org.apache.ibatis.annotations.Param;
|
||||||
|
import org.apache.ibatis.annotations.Select;
|
||||||
|
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 船期日历的只读展示投影。
|
||||||
|
*
|
||||||
|
* 这里直接读取现有入库审核结果,不写入船期事实表,也不触发归班、改绑或智能排柜。
|
||||||
|
* 主子入库单只统计未拆分主单或仍可用的余量子单,避免把父单和子单重复计入。
|
||||||
|
*/
|
||||||
|
@Mapper
|
||||||
|
public interface SailingCalendarDisplayMapper {
|
||||||
|
@Select("""
|
||||||
|
SELECT
|
||||||
|
COALESCE(SUM(r.quantity), 0) AS containerCount,
|
||||||
|
COALESCE(SUM(COALESCE(r.capacity_cbm, 0) * r.quantity), 0) AS capacityCbm
|
||||||
|
FROM fl_ocean_booking b
|
||||||
|
JOIN fl_ocean_booking_container_requirement r ON r.ocean_booking_id = b.id
|
||||||
|
WHERE b.status = 'RELEASED'
|
||||||
|
AND (
|
||||||
|
EXISTS (
|
||||||
|
SELECT 1 FROM fl_sailing_booking_link link
|
||||||
|
WHERE link.sailing_id = #{sailingId}
|
||||||
|
AND link.ocean_booking_id = b.id
|
||||||
|
AND link.link_status = 'ACTIVE'
|
||||||
|
)
|
||||||
|
OR (
|
||||||
|
b.warehouse_id = #{warehouseId}
|
||||||
|
AND b.channel_code = #{channelCode}
|
||||||
|
AND b.departure_port_code = #{departurePortCode}
|
||||||
|
AND b.destination_port_code = #{destinationPortCode}
|
||||||
|
AND b.vessel_name = #{vesselName}
|
||||||
|
AND b.voyage_no = #{voyageNo}
|
||||||
|
AND (b.estimated_sailing_at IS NULL OR DATE(b.estimated_sailing_at) = DATE(#{etdAt}))
|
||||||
|
)
|
||||||
|
)
|
||||||
|
""")
|
||||||
|
BookingSummary bookingSummary(@Param("sailingId") long sailingId,
|
||||||
|
@Param("warehouseId") long warehouseId,
|
||||||
|
@Param("channelCode") String channelCode,
|
||||||
|
@Param("departurePortCode") String departurePortCode,
|
||||||
|
@Param("destinationPortCode") String destinationPortCode,
|
||||||
|
@Param("vesselName") String vesselName,
|
||||||
|
@Param("voyageNo") String voyageNo,
|
||||||
|
@Param("etdAt") java.time.LocalDateTime etdAt);
|
||||||
|
|
||||||
|
@Select("""
|
||||||
|
SELECT
|
||||||
|
COALESCE(SUM(CASE WHEN receipt_at <= #{cutoffAt} THEN volume_cbm ELSE 0 END), 0) AS catchableCbm,
|
||||||
|
COALESCE(SUM(CASE WHEN receipt_at > #{cutoffAt} THEN volume_cbm ELSE 0 END), 0) AS lateCbm,
|
||||||
|
COALESCE(SUM(CASE WHEN receipt_at > #{cutoffAt} THEN 1 ELSE 0 END), 0) AS lateCount
|
||||||
|
FROM (
|
||||||
|
SELECT
|
||||||
|
COALESCE(o.inbounded_at, o.measure_completed_at, o.created_at) AS receipt_at,
|
||||||
|
COALESCE(o.actual_volume_cbm, 0) AS volume_cbm
|
||||||
|
FROM fl_inbound_order o
|
||||||
|
WHERE o.deleted = 0
|
||||||
|
AND o.measure_review_status = 'APPROVED'
|
||||||
|
AND o.warehouse_id = #{warehouseId}
|
||||||
|
AND o.channel_code = #{channelCode}
|
||||||
|
AND (
|
||||||
|
(o.inbound_role = 'MASTER' AND COALESCE(o.split_status, 'NOT_SPLIT') = 'NOT_SPLIT')
|
||||||
|
OR (o.inbound_role = 'CHILD' AND o.split_purpose = 'REMAINDER' AND o.split_status = 'NOT_SPLIT')
|
||||||
|
)
|
||||||
|
AND o.actual_volume_cbm IS NOT NULL
|
||||||
|
AND (#{fromAt} IS NULL OR COALESCE(o.inbounded_at, o.measure_completed_at, o.created_at) > #{fromAt})
|
||||||
|
AND (#{toAt} IS NULL OR COALESCE(o.inbounded_at, o.measure_completed_at, o.created_at) <= #{toAt})
|
||||||
|
) received
|
||||||
|
""")
|
||||||
|
ReceiptSummary receiptSummary(@Param("warehouseId") long warehouseId,
|
||||||
|
@Param("channelCode") String channelCode,
|
||||||
|
@Param("cutoffAt") java.time.LocalDateTime cutoffAt,
|
||||||
|
@Param("fromAt") java.time.LocalDateTime fromAt,
|
||||||
|
@Param("toAt") java.time.LocalDateTime toAt);
|
||||||
|
|
||||||
|
record ReceiptSummary(BigDecimal catchableCbm, BigDecimal lateCbm, Integer lateCount) {}
|
||||||
|
record BookingSummary(Integer containerCount, BigDecimal capacityCbm) {}
|
||||||
|
}
|
||||||
+34
@@ -0,0 +1,34 @@
|
|||||||
|
package com.baiyue.firstleg.sailingcalendar.mapper;
|
||||||
|
|
||||||
|
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||||
|
import com.baiyue.firstleg.sailingcalendar.dto.SailingCalendarDtos.ChannelQuery;
|
||||||
|
import com.baiyue.firstleg.sailingcalendar.entity.SailingChannelEntity;
|
||||||
|
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.util.List;
|
||||||
|
|
||||||
|
@Mapper
|
||||||
|
public interface SailingChannelMapper extends BaseMapper<SailingChannelEntity> {
|
||||||
|
@Select("""
|
||||||
|
<script>SELECT * FROM fl_sailing_channel
|
||||||
|
WHERE (#{query.keyword} IS NULL OR channel_code LIKE CONCAT('%',#{query.keyword},'%') OR channel_name LIKE CONCAT('%',#{query.keyword},'%'))
|
||||||
|
AND (#{query.status} IS NULL OR status=#{query.status})
|
||||||
|
ORDER BY status ASC,channel_name ASC,id ASC</script>
|
||||||
|
""")
|
||||||
|
List<SailingChannelEntity> selectChannels(@Param("query") ChannelQuery query);
|
||||||
|
|
||||||
|
@Update("""
|
||||||
|
UPDATE fl_sailing_channel SET channel_code=#{value.channelCode},channel_name=#{value.channelName},
|
||||||
|
source_type=#{value.sourceType},mapped_channel_code=#{value.mappedChannelCode},
|
||||||
|
default_departure_port_code=#{value.defaultDeparturePortCode},default_destination_port_code=#{value.defaultDestinationPortCode},
|
||||||
|
default_cutoff_days=#{value.defaultCutoffDays},
|
||||||
|
remark=#{value.remark},status=#{value.status},updated_by=#{operatorId},updated_by_name=#{operatorName},
|
||||||
|
updated_at=CURRENT_TIMESTAMP(3),version=version+1
|
||||||
|
WHERE id=#{value.id} AND version=#{expectedVersion}
|
||||||
|
""")
|
||||||
|
int updateChannel(@Param("value") SailingChannelEntity value, @Param("expectedVersion") int expectedVersion,
|
||||||
|
@Param("operatorId") long operatorId, @Param("operatorName") String operatorName);
|
||||||
|
}
|
||||||
+8
@@ -0,0 +1,8 @@
|
|||||||
|
package com.baiyue.firstleg.sailingcalendar.mapper;
|
||||||
|
|
||||||
|
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||||
|
import com.baiyue.firstleg.sailingcalendar.entity.SailingHistoryEntity;
|
||||||
|
import org.apache.ibatis.annotations.Mapper;
|
||||||
|
|
||||||
|
@Mapper
|
||||||
|
public interface SailingHistoryMapper extends BaseMapper<SailingHistoryEntity> {}
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
package com.baiyue.firstleg.sailingcalendar.mapper;
|
||||||
|
|
||||||
|
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||||
|
import com.baiyue.firstleg.sailingcalendar.dto.SailingCalendarDtos.SailingQuery;
|
||||||
|
import com.baiyue.firstleg.sailingcalendar.entity.SailingEntity;
|
||||||
|
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.util.List;
|
||||||
|
|
||||||
|
@Mapper
|
||||||
|
public interface SailingMapper extends BaseMapper<SailingEntity> {
|
||||||
|
@Select("""
|
||||||
|
<script>SELECT * FROM fl_sailing
|
||||||
|
WHERE etd_at >= #{startAt} AND etd_at < #{endAt}
|
||||||
|
AND (#{query.warehouseId} IS NULL OR warehouse_id=#{query.warehouseId})
|
||||||
|
AND (#{query.channelId} IS NULL OR channel_id=#{query.channelId})
|
||||||
|
AND (#{query.channelCode} IS NULL OR channel_code_snapshot=#{query.channelCode})
|
||||||
|
AND (#{query.departurePortCode} IS NULL OR departure_port_code=#{query.departurePortCode})
|
||||||
|
AND (#{query.destinationPortCode} IS NULL OR destination_port_code=#{query.destinationPortCode})
|
||||||
|
AND (#{query.status} IS NULL OR status=#{query.status})
|
||||||
|
AND (#{query.keyword} IS NULL OR sailing_no LIKE CONCAT('%',#{query.keyword},'%')
|
||||||
|
OR vessel_name LIKE CONCAT('%',#{query.keyword},'%') OR voyage_no LIKE CONCAT('%',#{query.keyword},'%'))
|
||||||
|
ORDER BY etd_at ASC,sailing_no ASC,id ASC</script>
|
||||||
|
""")
|
||||||
|
List<SailingEntity> selectCalendar(@Param("query") SailingQuery query,
|
||||||
|
@Param("startAt") java.time.LocalDateTime startAt,
|
||||||
|
@Param("endAt") java.time.LocalDateTime endAt);
|
||||||
|
|
||||||
|
@Select("""
|
||||||
|
SELECT * FROM fl_sailing
|
||||||
|
WHERE warehouse_id=#{warehouseId} AND channel_code_snapshot=#{channelCode}
|
||||||
|
AND (#{departurePortCode} IS NULL OR departure_port_code=#{departurePortCode})
|
||||||
|
AND (#{destinationPortCode} IS NULL OR destination_port_code=#{destinationPortCode})
|
||||||
|
AND status='OPEN' AND etd_at >= #{from} AND warehouse_cutoff_at >= #{from}
|
||||||
|
ORDER BY etd_at ASC,sailing_no ASC,id ASC
|
||||||
|
""")
|
||||||
|
List<SailingEntity> selectFutureCandidates(@Param("warehouseId") long warehouseId,
|
||||||
|
@Param("channelCode") String channelCode,
|
||||||
|
@Param("departurePortCode") String departurePortCode,
|
||||||
|
@Param("destinationPortCode") String destinationPortCode,
|
||||||
|
@Param("from") java.time.LocalDateTime from);
|
||||||
|
|
||||||
|
@Select("""
|
||||||
|
SELECT * FROM fl_sailing
|
||||||
|
WHERE warehouse_id=#{warehouseId} AND channel_code_snapshot=#{channelCode}
|
||||||
|
AND (#{departurePortCode} IS NULL OR departure_port_code=#{departurePortCode})
|
||||||
|
AND (#{destinationPortCode} IS NULL OR destination_port_code=#{destinationPortCode})
|
||||||
|
AND status='OPEN' AND etd_at >= #{from}
|
||||||
|
ORDER BY etd_at ASC,sailing_no ASC,id ASC
|
||||||
|
""")
|
||||||
|
List<SailingEntity> selectOpenSailingsFrom(@Param("warehouseId") long warehouseId,
|
||||||
|
@Param("channelCode") String channelCode,
|
||||||
|
@Param("departurePortCode") String departurePortCode,
|
||||||
|
@Param("destinationPortCode") String destinationPortCode,
|
||||||
|
@Param("from") java.time.LocalDateTime from);
|
||||||
|
|
||||||
|
@Select("""
|
||||||
|
SELECT warehouse_cutoff_at
|
||||||
|
FROM fl_sailing
|
||||||
|
WHERE warehouse_id=#{warehouseId} AND channel_code_snapshot=#{channelCode}
|
||||||
|
AND departure_port_code=#{departurePortCode} AND destination_port_code=#{destinationPortCode}
|
||||||
|
AND status <> 'CANCELLED' AND warehouse_cutoff_at < #{cutoffAt}
|
||||||
|
ORDER BY warehouse_cutoff_at DESC, id DESC
|
||||||
|
LIMIT 1
|
||||||
|
""")
|
||||||
|
java.time.LocalDateTime selectPreviousCutoff(@Param("warehouseId") long warehouseId,
|
||||||
|
@Param("channelCode") String channelCode,
|
||||||
|
@Param("departurePortCode") String departurePortCode,
|
||||||
|
@Param("destinationPortCode") String destinationPortCode,
|
||||||
|
@Param("cutoffAt") java.time.LocalDateTime cutoffAt);
|
||||||
|
|
||||||
|
@Select("""
|
||||||
|
SELECT warehouse_cutoff_at
|
||||||
|
FROM fl_sailing
|
||||||
|
WHERE warehouse_id=#{warehouseId} AND channel_code_snapshot=#{channelCode}
|
||||||
|
AND departure_port_code=#{departurePortCode} AND destination_port_code=#{destinationPortCode}
|
||||||
|
AND status <> 'CANCELLED' AND warehouse_cutoff_at > #{cutoffAt}
|
||||||
|
ORDER BY warehouse_cutoff_at ASC, id ASC
|
||||||
|
LIMIT 1
|
||||||
|
""")
|
||||||
|
java.time.LocalDateTime selectNextCutoff(@Param("warehouseId") long warehouseId,
|
||||||
|
@Param("channelCode") String channelCode,
|
||||||
|
@Param("departurePortCode") String departurePortCode,
|
||||||
|
@Param("destinationPortCode") String destinationPortCode,
|
||||||
|
@Param("cutoffAt") java.time.LocalDateTime cutoffAt);
|
||||||
|
|
||||||
|
@Update("""
|
||||||
|
UPDATE fl_sailing SET channel_id=#{value.channelId},channel_code_snapshot=#{value.channelCodeSnapshot},
|
||||||
|
channel_name_snapshot=#{value.channelNameSnapshot},warehouse_id=#{value.warehouseId},warehouse_name_snapshot=#{value.warehouseNameSnapshot},
|
||||||
|
departure_port_code=#{value.departurePortCode},destination_port_code=#{value.destinationPortCode},vessel_name=#{value.vesselName},voyage_no=#{value.voyageNo},
|
||||||
|
etd_at=#{value.etdAt},eta_at=#{value.etaAt},warehouse_cutoff_at=#{value.warehouseCutoffAt},source_type=#{value.sourceType},
|
||||||
|
remark=#{value.remark},planned_capacity_cbm=#{value.plannedCapacityCbm},status=#{value.status},updated_by=#{operatorId},updated_by_name=#{operatorName},
|
||||||
|
updated_at=CURRENT_TIMESTAMP(3),version=version+1
|
||||||
|
WHERE id=#{value.id} AND version=#{expectedVersion}
|
||||||
|
""")
|
||||||
|
int updateSailing(@Param("value") SailingEntity value, @Param("expectedVersion") int expectedVersion,
|
||||||
|
@Param("operatorId") long operatorId, @Param("operatorName") String operatorName);
|
||||||
|
|
||||||
|
@Update("UPDATE fl_sailing SET status=#{status},updated_by=#{operatorId},updated_by_name=#{operatorName},updated_at=CURRENT_TIMESTAMP(3),version=version+1 WHERE id=#{id} AND version=#{expectedVersion}")
|
||||||
|
int updateStatus(@Param("id") long id, @Param("status") String status, @Param("expectedVersion") int expectedVersion,
|
||||||
|
@Param("operatorId") long operatorId, @Param("operatorName") String operatorName);
|
||||||
|
}
|
||||||
+36
@@ -0,0 +1,36 @@
|
|||||||
|
package com.baiyue.firstleg.sailingcalendar.mapper;
|
||||||
|
|
||||||
|
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||||
|
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
|
||||||
|
import com.baiyue.firstleg.sailingcalendar.dto.SailingCalendarDtos.TemplateQuery;
|
||||||
|
import com.baiyue.firstleg.sailingcalendar.entity.SailingTemplateEntity;
|
||||||
|
import org.apache.ibatis.annotations.Mapper;
|
||||||
|
import org.apache.ibatis.annotations.Param;
|
||||||
|
import org.apache.ibatis.annotations.Select;
|
||||||
|
import org.apache.ibatis.annotations.Update;
|
||||||
|
|
||||||
|
@Mapper
|
||||||
|
public interface SailingTemplateMapper extends BaseMapper<SailingTemplateEntity> {
|
||||||
|
@Select("""
|
||||||
|
<script>SELECT * FROM fl_sailing_template
|
||||||
|
WHERE (#{query.warehouseId} IS NULL OR warehouse_id=#{query.warehouseId})
|
||||||
|
AND (#{query.channelId} IS NULL OR channel_id=#{query.channelId})
|
||||||
|
AND (#{query.status} IS NULL OR status=#{query.status})
|
||||||
|
ORDER BY updated_at DESC,id DESC</script>
|
||||||
|
""")
|
||||||
|
Page<SailingTemplateEntity> selectPage(Page<SailingTemplateEntity> page, @Param("query") TemplateQuery query);
|
||||||
|
|
||||||
|
@Update("""
|
||||||
|
UPDATE fl_sailing_template SET template_name=#{value.templateName},channel_id=#{value.channelId},
|
||||||
|
channel_code_snapshot=#{value.channelCodeSnapshot},channel_name_snapshot=#{value.channelNameSnapshot},
|
||||||
|
warehouse_id=#{value.warehouseId},warehouse_name_snapshot=#{value.warehouseNameSnapshot},
|
||||||
|
departure_port_code=#{value.departurePortCode},destination_port_code=#{value.destinationPortCode},weekday=#{value.weekday},weekday_mask=#{value.weekdayMask},
|
||||||
|
etd_local_time=#{value.etdLocalTime},cutoff_days=#{value.cutoffDays},cutoff_local_time=#{value.cutoffLocalTime},
|
||||||
|
valid_from=#{value.validFrom},valid_to=#{value.validTo},voyage_rule=#{value.voyageRule},vessel_name=#{value.vesselName},
|
||||||
|
status=#{value.status},remark=#{value.remark},updated_by=#{operatorId},updated_by_name=#{operatorName},
|
||||||
|
updated_at=CURRENT_TIMESTAMP(3),version=version+1
|
||||||
|
WHERE id=#{value.id} AND version=#{expectedVersion}
|
||||||
|
""")
|
||||||
|
int updateTemplate(@Param("value") SailingTemplateEntity value, @Param("expectedVersion") int expectedVersion,
|
||||||
|
@Param("operatorId") long operatorId, @Param("operatorName") String operatorName);
|
||||||
|
}
|
||||||
+141
@@ -0,0 +1,141 @@
|
|||||||
|
package com.baiyue.firstleg.sailingcalendar.repository;
|
||||||
|
|
||||||
|
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||||
|
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
|
||||||
|
import com.baiyue.common.core.api.PageResult;
|
||||||
|
import com.baiyue.firstleg.sailingcalendar.dto.SailingCalendarDtos.ChannelQuery;
|
||||||
|
import com.baiyue.firstleg.sailingcalendar.dto.SailingCalendarDtos.SailingQuery;
|
||||||
|
import com.baiyue.firstleg.sailingcalendar.dto.SailingCalendarDtos.TemplateQuery;
|
||||||
|
import com.baiyue.firstleg.sailingcalendar.entity.SailingChannelEntity;
|
||||||
|
import com.baiyue.firstleg.sailingcalendar.entity.SailingEntity;
|
||||||
|
import com.baiyue.firstleg.sailingcalendar.entity.SailingTemplateEntity;
|
||||||
|
import com.baiyue.firstleg.sailingcalendar.entity.SailingHistoryEntity;
|
||||||
|
import com.baiyue.firstleg.sailingcalendar.mapper.SailingChannelMapper;
|
||||||
|
import com.baiyue.firstleg.sailingcalendar.mapper.SailingHistoryMapper;
|
||||||
|
import com.baiyue.firstleg.sailingcalendar.mapper.SailingMapper;
|
||||||
|
import com.baiyue.firstleg.sailingcalendar.mapper.SailingTemplateMapper;
|
||||||
|
import com.baiyue.firstleg.sailingcalendar.mapper.SailingCalendarDisplayMapper;
|
||||||
|
import com.baiyue.firstleg.sailingcalendar.fact.entity.SailingAssignmentEntity;
|
||||||
|
import com.baiyue.firstleg.sailingcalendar.fact.entity.SailingBookingLinkEntity;
|
||||||
|
import com.baiyue.firstleg.sailingcalendar.fact.entity.SailingInboundVolumeForecastEntity;
|
||||||
|
import com.baiyue.firstleg.sailingcalendar.fact.entity.SailingReceiptBatchFactEntity;
|
||||||
|
import com.baiyue.firstleg.sailingcalendar.fact.repository.SailingFactsRepository;
|
||||||
|
import com.baiyue.firstleg.oceanbooking.entity.OceanBookingEntity;
|
||||||
|
import com.baiyue.firstleg.oceanbooking.entity.OceanBookingContainerRequirementEntity;
|
||||||
|
import com.baiyue.firstleg.oceanbooking.repository.OceanBookingRepository;
|
||||||
|
import jakarta.annotation.Resource;
|
||||||
|
import org.springframework.stereotype.Repository;
|
||||||
|
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
@Repository
|
||||||
|
public class SailingCalendarRepository {
|
||||||
|
@Resource private SailingChannelMapper channels;
|
||||||
|
@Resource private SailingTemplateMapper templates;
|
||||||
|
@Resource private SailingHistoryMapper histories;
|
||||||
|
@Resource private SailingMapper sailings;
|
||||||
|
@Resource private SailingFactsRepository facts;
|
||||||
|
@Resource private OceanBookingRepository oceanBookings;
|
||||||
|
@Resource private SailingCalendarDisplayMapper display;
|
||||||
|
|
||||||
|
public List<SailingChannelEntity> channels(ChannelQuery query) { return channels.selectChannels(query); }
|
||||||
|
public SailingChannelEntity channel(long id) { return channels.selectById(id); }
|
||||||
|
public SailingChannelEntity channelByCode(String code) {
|
||||||
|
return channels.selectOne(new LambdaQueryWrapper<SailingChannelEntity>()
|
||||||
|
.eq(SailingChannelEntity::getChannelCode, code));
|
||||||
|
}
|
||||||
|
public void insert(SailingChannelEntity value) { channels.insert(value); }
|
||||||
|
public int update(SailingChannelEntity value, int version, long operatorId, String operatorName) {
|
||||||
|
return channels.updateChannel(value, version, operatorId, operatorName);
|
||||||
|
}
|
||||||
|
|
||||||
|
public PageResult<SailingTemplateEntity> templates(TemplateQuery query) {
|
||||||
|
Page<SailingTemplateEntity> page = templates.selectPage(new Page<>(query.page(), query.size()), query);
|
||||||
|
return PageResult.from(page);
|
||||||
|
}
|
||||||
|
public SailingTemplateEntity template(long id) { return templates.selectById(id); }
|
||||||
|
public void insert(SailingTemplateEntity value) { templates.insert(value); }
|
||||||
|
public int update(SailingTemplateEntity value, int version, long operatorId, String operatorName) {
|
||||||
|
return templates.updateTemplate(value, version, operatorId, operatorName);
|
||||||
|
}
|
||||||
|
|
||||||
|
public List<SailingEntity> calendar(SailingQuery query, LocalDateTime start, LocalDateTime end) {
|
||||||
|
return sailings.selectCalendar(query, start, end);
|
||||||
|
}
|
||||||
|
public List<SailingEntity> futureCandidates(long warehouseId, String channelCode, String departurePortCode, String destinationPortCode, LocalDateTime from) {
|
||||||
|
return sailings.selectFutureCandidates(warehouseId, channelCode, departurePortCode, destinationPortCode, from);
|
||||||
|
}
|
||||||
|
public List<SailingEntity> openSailingsFrom(long warehouseId, String channelCode, String departurePortCode, String destinationPortCode, LocalDateTime from) {
|
||||||
|
return sailings.selectOpenSailingsFrom(warehouseId, channelCode, departurePortCode, destinationPortCode, from);
|
||||||
|
}
|
||||||
|
public LocalDateTime previousCutoff(SailingEntity sailing) {
|
||||||
|
return sailings.selectPreviousCutoff(sailing.getWarehouseId(), sailing.getChannelCodeSnapshot(),
|
||||||
|
sailing.getDeparturePortCode(), sailing.getDestinationPortCode(), sailing.getWarehouseCutoffAt());
|
||||||
|
}
|
||||||
|
public LocalDateTime nextCutoff(SailingEntity sailing) {
|
||||||
|
return sailings.selectNextCutoff(sailing.getWarehouseId(), sailing.getChannelCodeSnapshot(),
|
||||||
|
sailing.getDeparturePortCode(), sailing.getDestinationPortCode(), sailing.getWarehouseCutoffAt());
|
||||||
|
}
|
||||||
|
public SailingEntity sailing(long id) { return sailings.selectById(id); }
|
||||||
|
public SailingEntity sailingByBusinessKey(long warehouseId, String channelCode, String departurePortCode,
|
||||||
|
String destinationPortCode, LocalDateTime etdAt, String voyageNo) {
|
||||||
|
return sailings.selectOne(new LambdaQueryWrapper<SailingEntity>()
|
||||||
|
.eq(SailingEntity::getWarehouseId, warehouseId)
|
||||||
|
.eq(SailingEntity::getChannelCodeSnapshot, channelCode)
|
||||||
|
.eq(SailingEntity::getDeparturePortCode, departurePortCode)
|
||||||
|
.eq(SailingEntity::getDestinationPortCode, destinationPortCode)
|
||||||
|
.eq(SailingEntity::getEtdAt, etdAt)
|
||||||
|
.eq(SailingEntity::getVoyageNo, voyageNo));
|
||||||
|
}
|
||||||
|
public SailingEntity sailingByGeneratedKey(String key) {
|
||||||
|
return sailings.selectOne(new LambdaQueryWrapper<SailingEntity>().eq(SailingEntity::getGeneratedKey, key));
|
||||||
|
}
|
||||||
|
public void insert(SailingEntity value) { sailings.insert(value); }
|
||||||
|
public int update(SailingEntity value, int version, long operatorId, String operatorName) {
|
||||||
|
return sailings.updateSailing(value, version, operatorId, operatorName);
|
||||||
|
}
|
||||||
|
public int updateStatus(long id, String status, int version, long operatorId, String operatorName) {
|
||||||
|
return sailings.updateStatus(id, status, version, operatorId, operatorName);
|
||||||
|
}
|
||||||
|
public void insertHistory(SailingHistoryEntity value) { histories.insert(value); }
|
||||||
|
public List<SailingHistoryEntity> histories(long sailingId) {
|
||||||
|
return histories.selectList(new LambdaQueryWrapper<SailingHistoryEntity>()
|
||||||
|
.eq(SailingHistoryEntity::getSailingId, sailingId)
|
||||||
|
.orderByDesc(SailingHistoryEntity::getCreatedAt)
|
||||||
|
.orderByDesc(SailingHistoryEntity::getId));
|
||||||
|
}
|
||||||
|
public List<SailingAssignmentEntity> assignments(long sailingId) { return facts.assignments(sailingId); }
|
||||||
|
public void insertAssignment(SailingAssignmentEntity value) { facts.insertAssignment(value); }
|
||||||
|
public List<SailingAssignmentEntity> exceptionAssignments() { return facts.exceptionAssignments(); }
|
||||||
|
public SailingAssignmentEntity assignmentForUpdate(long id) { return facts.assignmentForUpdate(id); }
|
||||||
|
public int supersedeAssignment(long id, int expectedVersion, long operatorId, String reason, LocalDateTime at) {
|
||||||
|
return facts.supersedeAssignment(id, expectedVersion, operatorId, reason, at);
|
||||||
|
}
|
||||||
|
public SailingReceiptBatchFactEntity receiptFact(long id) { return facts.receiptFact(id); }
|
||||||
|
public SailingInboundVolumeForecastEntity forecast(long id) { return facts.forecast(id); }
|
||||||
|
public void insertForecast(SailingInboundVolumeForecastEntity value) { facts.insertForecast(value); }
|
||||||
|
public int consumeForecast(long tenantId, long id, LocalDateTime at) { return facts.consumeForecast(tenantId, id, at); }
|
||||||
|
public int supersedeForecast(long tenantId, long id, LocalDateTime at) { return facts.supersedeForecast(tenantId, id, at); }
|
||||||
|
public int voidForecast(long tenantId, long id, LocalDateTime at) { return facts.voidForecast(tenantId, id, at); }
|
||||||
|
public int supersedeForecastAssignments(long tenantId, long id, LocalDateTime at) { return facts.supersedeForecastAssignments(tenantId, id, at); }
|
||||||
|
public SailingAssignmentEntity currentAssignmentForForecast(long tenantId, long id) { return facts.currentAssignmentForForecast(tenantId, id); }
|
||||||
|
public SailingAssignmentEntity currentAssignmentForReceipt(long tenantId, long id) { return facts.currentAssignmentForReceipt(tenantId, id); }
|
||||||
|
public List<SailingBookingLinkEntity> bookingLinks(long sailingId) { return facts.activeBookingLinks(sailingId); }
|
||||||
|
public SailingBookingLinkEntity activeBookingLink(long oceanBookingId) { return facts.activeBookingLink(oceanBookingId); }
|
||||||
|
public void insertBookingLink(SailingBookingLinkEntity value) { facts.insertBookingLink(value); }
|
||||||
|
public int revokeBookingLink(long id, long operatorId, String reason, LocalDateTime at) {
|
||||||
|
return facts.revokeBookingLink(id, operatorId, reason, at);
|
||||||
|
}
|
||||||
|
public OceanBookingEntity booking(long id) { return oceanBookings.find(id); }
|
||||||
|
public List<OceanBookingContainerRequirementEntity> requirements(long id) { return oceanBookings.requirements(id); }
|
||||||
|
public SailingCalendarDisplayMapper.ReceiptSummary receiptSummary(SailingEntity sailing) {
|
||||||
|
return display.receiptSummary(sailing.getWarehouseId(), sailing.getChannelCodeSnapshot(),
|
||||||
|
sailing.getWarehouseCutoffAt(), previousCutoff(sailing), nextCutoff(sailing));
|
||||||
|
}
|
||||||
|
public SailingCalendarDisplayMapper.BookingSummary bookingSummary(SailingEntity sailing) {
|
||||||
|
return display.bookingSummary(sailing.getId(), sailing.getWarehouseId(), sailing.getChannelCodeSnapshot(),
|
||||||
|
sailing.getDeparturePortCode(), sailing.getDestinationPortCode(), sailing.getVesselName(),
|
||||||
|
sailing.getVoyageNo(), sailing.getEtdAt());
|
||||||
|
}
|
||||||
|
}
|
||||||
+505
@@ -0,0 +1,505 @@
|
|||||||
|
package com.baiyue.firstleg.sailingcalendar.service;
|
||||||
|
|
||||||
|
import com.baomidou.mybatisplus.core.toolkit.IdWorker;
|
||||||
|
import com.baiyue.common.core.api.PageResult;
|
||||||
|
import com.baiyue.common.core.api.ResultCode;
|
||||||
|
import com.baiyue.common.core.exception.BizException;
|
||||||
|
import com.baiyue.firstleg.sailingcalendar.dto.SailingCalendarDtos;
|
||||||
|
import com.baiyue.firstleg.sailingcalendar.entity.SailingChannelEntity;
|
||||||
|
import com.baiyue.firstleg.sailingcalendar.entity.SailingEntity;
|
||||||
|
import com.baiyue.firstleg.sailingcalendar.entity.SailingTemplateEntity;
|
||||||
|
import com.baiyue.firstleg.sailingcalendar.entity.SailingHistoryEntity;
|
||||||
|
import com.baiyue.firstleg.sailingcalendar.repository.SailingCalendarRepository;
|
||||||
|
import jakarta.annotation.Resource;
|
||||||
|
import org.springframework.dao.DuplicateKeyException;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
import org.springframework.util.StringUtils;
|
||||||
|
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
import java.time.LocalDate;
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import java.time.LocalTime;
|
||||||
|
import java.time.ZoneId;
|
||||||
|
import java.time.temporal.ChronoUnit;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Objects;
|
||||||
|
import java.util.Map;
|
||||||
|
import com.baiyue.firstleg.sailingcalendar.fact.entity.SailingAssignmentEntity;
|
||||||
|
import com.baiyue.firstleg.sailingcalendar.fact.entity.SailingBookingLinkEntity;
|
||||||
|
import com.baiyue.firstleg.sailingcalendar.fact.entity.SailingInboundVolumeForecastEntity;
|
||||||
|
import com.baiyue.firstleg.sailingcalendar.fact.entity.SailingReceiptBatchFactEntity;
|
||||||
|
import com.baiyue.firstleg.sailingcalendar.mapper.SailingCalendarDisplayMapper;
|
||||||
|
import com.baiyue.firstleg.oceanbooking.entity.OceanBookingContainerRequirementEntity;
|
||||||
|
import com.baiyue.firstleg.oceanbooking.entity.OceanBookingEntity;
|
||||||
|
import com.baiyue.common.core.container.ContainerSpecificationCatalog;
|
||||||
|
|
||||||
|
import static com.baiyue.firstleg.sailingcalendar.dto.SailingCalendarDtos.*;
|
||||||
|
|
||||||
|
@Service
|
||||||
|
public class SailingCalendarService {
|
||||||
|
private static final ZoneId ZONE = ZoneId.of("Asia/Shanghai");
|
||||||
|
private static final String OPEN = "OPEN";
|
||||||
|
@Resource private SailingCalendarRepository repository;
|
||||||
|
|
||||||
|
public List<ChannelView> channels(ChannelQuery query) {
|
||||||
|
return repository.channels(query).stream().map(v -> new ChannelView(v, "ACTIVE".equals(v.getStatus()))).toList();
|
||||||
|
}
|
||||||
|
@Transactional
|
||||||
|
public SailingChannelEntity createChannel(long operatorId, String operatorName, ChannelCreateRequest request) {
|
||||||
|
if (version(request.expectedVersion()) != 0) throw bad("新建渠道expectedVersion必须为0");
|
||||||
|
validateChannel(request.data());
|
||||||
|
if (repository.channelByCode(request.data().channelCode().trim().toUpperCase()) != null) throw conflict("渠道代码已存在");
|
||||||
|
SailingChannelEntity value = new SailingChannelEntity(); value.setId(IdWorker.getId());
|
||||||
|
copy(value, request.data()); value.setStatus("ACTIVE"); value.setCreatedBy(operatorId); value.setCreatedByName(operatorName);
|
||||||
|
value.setUpdatedBy(operatorId); value.setUpdatedByName(operatorName); value.setVersion(0); now(value);
|
||||||
|
try { repository.insert(value); } catch (DuplicateKeyException ex) { throw conflict("渠道代码已存在"); }
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
@Transactional
|
||||||
|
public SailingChannelEntity updateChannel(long id, long operatorId, String operatorName, ChannelUpdateRequest request) {
|
||||||
|
SailingChannelEntity before = requiredChannel(id); validateChannel(request.data());
|
||||||
|
if (!before.getChannelCode().equalsIgnoreCase(request.data().channelCode().trim())
|
||||||
|
&& repository.channelByCode(request.data().channelCode().trim().toUpperCase()) != null) throw conflict("渠道代码已存在");
|
||||||
|
SailingChannelEntity value = new SailingChannelEntity(); value.setId(id); copy(value, request.data());
|
||||||
|
value.setStatus(normalizeStatus(request.status(), "渠道状态"));
|
||||||
|
if (repository.update(value, version(request.expectedVersion()), operatorId, operatorName) != 1) throw conflict("渠道已被其他人修改");
|
||||||
|
return requiredChannel(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
public PageResult<SailingTemplateEntity> templates(TemplateQuery query) { return repository.templates(query); }
|
||||||
|
public List<TemplateView> templateViews(TemplateQuery query) { return repository.templates(query).records().stream().map(v -> templateViewOf(v)).toList(); }
|
||||||
|
public TemplateView templateView(long id) { return templateViewOf(requiredTemplate(id)); }
|
||||||
|
@Transactional
|
||||||
|
public SailingTemplateEntity createTemplate(long operatorId, String operatorName, TemplateCreateRequest request) {
|
||||||
|
if (version(request.expectedVersion()) != 0) throw bad("新建模板expectedVersion必须为0");
|
||||||
|
SailingChannelEntity channel = requiredChannel(request.data().channelId()); validateTemplate(request.data(), channel);
|
||||||
|
if (!"ACTIVE".equals(channel.getStatus())) throw conflict("停用渠道不能创建模板");
|
||||||
|
SailingTemplateEntity value = new SailingTemplateEntity(); value.setId(IdWorker.getId()); value.setTemplateNo("ST-" + value.getId());
|
||||||
|
copy(value, request.data(), channel); value.setStatus("ACTIVE"); value.setCreatedBy(operatorId); value.setCreatedByName(operatorName);
|
||||||
|
value.setUpdatedBy(operatorId); value.setUpdatedByName(operatorName); value.setVersion(0); now(value);
|
||||||
|
repository.insert(value); return value;
|
||||||
|
}
|
||||||
|
@Transactional
|
||||||
|
public SailingTemplateEntity updateTemplate(long id, long operatorId, String operatorName, TemplateUpdateRequest request) {
|
||||||
|
requiredTemplate(id); SailingChannelEntity channel = requiredChannel(request.data().channelId()); validateTemplate(request.data(), channel);
|
||||||
|
SailingTemplateEntity value = new SailingTemplateEntity(); value.setId(id); copy(value, request.data(), channel);
|
||||||
|
value.setStatus(StringUtils.hasText(request.status()) ? normalizeStatus(request.status(), "模板状态") : requiredTemplate(id).getStatus());
|
||||||
|
if (repository.update(value, version(request.expectedVersion()), operatorId, operatorName) != 1) throw conflict("模板已被其他人修改");
|
||||||
|
return requiredTemplate(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
public CalendarResult calendar(SailingQuery query) {
|
||||||
|
LocalDate first;
|
||||||
|
try { first = LocalDate.parse(query.month() + "-01"); } catch (RuntimeException ex) { throw bad("month必须为yyyy-MM"); }
|
||||||
|
LocalDate next = first.plusMonths(1);
|
||||||
|
List<SailingEntity> values = repository.calendar(query, first.atStartOfDay(), next.atStartOfDay());
|
||||||
|
List<CalendarItem> items = values.stream().map(this::item).toList();
|
||||||
|
BigDecimal capacity = items.stream().map(v -> v.volumeSummary().planningCapacityCbm() == null ? BigDecimal.ZERO : v.volumeSummary().planningCapacityCbm()).reduce(BigDecimal.ZERO, BigDecimal::add);
|
||||||
|
BigDecimal actual = items.stream().map(v -> v.volumeSummary().actualReceivedCbm()).reduce(BigDecimal.ZERO, BigDecimal::add);
|
||||||
|
BigDecimal forecast = items.stream().map(v -> v.volumeSummary().catchableForecastCbm()).reduce(BigDecimal.ZERO, BigDecimal::add);
|
||||||
|
BigDecimal risk = items.stream().map(v -> v.volumeSummary().riskUnmatchedCbm()).reduce(BigDecimal.ZERO, BigDecimal::add);
|
||||||
|
int booked = items.stream().mapToInt(v -> v.volumeSummary().bookedContainerCount()).sum();
|
||||||
|
int recommended = items.stream().mapToInt(v -> v.volumeSummary().recommendedContainerCount()).sum();
|
||||||
|
int pending = items.stream().mapToInt(v -> v.volumeSummary().pendingContainerCount()).sum();
|
||||||
|
int late = items.stream().mapToInt(v -> v.volumeSummary().lateBatchCount()).sum();
|
||||||
|
BigDecimal catchableReceived = items.stream().map(v -> nvl(v.volumeSummary().catchableReceivedCbm())).reduce(BigDecimal.ZERO, BigDecimal::add);
|
||||||
|
BigDecimal lateReceived = items.stream().map(v -> nvl(v.volumeSummary().lateReceivedCbm())).reduce(BigDecimal.ZERO, BigDecimal::add);
|
||||||
|
var summary = new CalendarResult.Summary(actual, forecast, risk, booked, recommended, pending, late, items.stream().anyMatch(v -> v.volumeSummary().overCapacity()), capacity, items.size(), catchableReceived, lateReceived);
|
||||||
|
return new CalendarResult(query.month(), ZONE.getId(), items, summary,
|
||||||
|
new CalendarResult.FilterOptions(
|
||||||
|
values.stream().map(v -> new CalendarResult.Option(String.valueOf(v.getWarehouseId()), v.getWarehouseNameSnapshot())).distinct().toList(),
|
||||||
|
values.stream().map(v -> new CalendarResult.Option(v.getChannelCodeSnapshot(), v.getChannelNameSnapshot())).distinct().toList(),
|
||||||
|
values.stream().map(v -> new CalendarResult.Option(v.getDeparturePortCode(), v.getDeparturePortCode())).distinct().toList(),
|
||||||
|
values.stream().map(v -> new CalendarResult.Option(v.getDestinationPortCode(), v.getDestinationPortCode())).distinct().toList()));
|
||||||
|
}
|
||||||
|
public SailingDetail detail(long id) {
|
||||||
|
SailingEntity value = requiredSailing(id);
|
||||||
|
return new SailingDetail(item(value), history(id));
|
||||||
|
}
|
||||||
|
public List<SailingHistoryView> history(long id) {
|
||||||
|
requiredSailing(id);
|
||||||
|
return repository.histories(id).stream()
|
||||||
|
.map(v -> new SailingHistoryView(v.getId(), v.getAction(), v.getFromStatus(), v.getToStatus(), v.getReason(), v.getOperatorName(), v.getCreatedAt()))
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
public ExceptionPage exceptions(ExceptionQuery query) {
|
||||||
|
List<ExceptionRecord> all = repository.exceptionAssignments().stream().map(a -> {
|
||||||
|
Long sourceId = a.getSailingId() != null ? a.getSailingId() : a.getOriginSailingId();
|
||||||
|
SailingEntity source = sourceId == null ? null : repository.sailing(sourceId);
|
||||||
|
LocalDateTime receivedAt = null, expectedAt = null;
|
||||||
|
if (a.getReceiptBatchId() != null && repository.receiptFact(a.getReceiptBatchId()) != null) receivedAt = repository.receiptFact(a.getReceiptBatchId()).getReceivedAt();
|
||||||
|
if (a.getForecastId() != null && repository.forecast(a.getForecastId()) != null) expectedAt = repository.forecast(a.getForecastId()).getExpectedArrivalAt();
|
||||||
|
LocalDateTime eventAt = receivedAt != null ? receivedAt : expectedAt;
|
||||||
|
Integer waiting = eventAt == null ? null : (int) Math.max(0, ChronoUnit.DAYS.between(eventAt.toLocalDate(), LocalDateTime.now(ZONE).toLocalDate()));
|
||||||
|
return new ExceptionRecord(a.getId(), a.getInboundCode(), source == null ? null : source.getWarehouseId(), source == null ? null : source.getWarehouseNameSnapshot(), source == null ? null : source.getChannelCodeSnapshot(), source == null ? null : source.getChannelNameSnapshot(), source == null ? null : source.getSailingNo(), source == null ? null : source.getVoyageNo(), a.getMatchStatus(), nvl(a.getVolumeCbm()).add(nvl(a.getForecastVolumeCbm())), receivedAt, expectedAt, waiting, a.getReason(), a.getAssignmentVersion());
|
||||||
|
}).filter(r -> query.status() == null || query.status().equals(r.status()))
|
||||||
|
.filter(r -> query.warehouseId() == null || Objects.equals(query.warehouseId(), r.warehouseId()))
|
||||||
|
.filter(r -> query.channelCode() == null || Objects.equals(query.channelCode(), r.channelCode())).toList();
|
||||||
|
long page = query.page(), size = query.size(), total = all.size(), from = Math.min(total, (page - 1) * size), to = Math.min(total, from + size);
|
||||||
|
return new ExceptionPage(all.subList((int) from, (int) to), page, size, total, (total + size - 1) / size);
|
||||||
|
}
|
||||||
|
@Transactional
|
||||||
|
public SailingDetail cancel(long id, long operatorId, String operatorName, CancelRequest request) {
|
||||||
|
SailingEntity before = requiredSailing(id);
|
||||||
|
if (!Objects.equals(before.getVersion(), version(request.expectedVersion()))) throw conflict("船期已被其他人修改");
|
||||||
|
if (List.of("DEPARTED", "COMPLETED", "CANCELLED").contains(before.getStatus())) throw conflict("当前船期不可取消");
|
||||||
|
if (repository.updateStatus(id, "CANCELLED", version(request.expectedVersion()), operatorId, operatorName) != 1) throw conflict("船期已被其他人修改");
|
||||||
|
LocalDateTime at = LocalDateTime.now(ZONE); String reason = StringUtils.hasText(request.reason()) ? request.reason().trim() : "CANCELLED";
|
||||||
|
for (SailingAssignmentEntity current : repository.assignments(id)) {
|
||||||
|
int assignmentVersion = current.getAssignmentVersion() == null ? 1 : current.getAssignmentVersion();
|
||||||
|
if (repository.supersedeAssignment(current.getId(), assignmentVersion, operatorId, reason, at) != 1) {
|
||||||
|
throw conflict("取消船期时归班事实已被其他人修改,操作已回滚,请刷新后重试");
|
||||||
|
}
|
||||||
|
SailingAssignmentEntity unmatched = copyAssignment(current); unmatched.setId(IdWorker.getId()); unmatched.setSailingId(null); unmatched.setOriginSailingId(id);
|
||||||
|
unmatched.setAssignmentSource("ROLLOVER"); unmatched.setAssignmentVersion(assignmentVersion + 1); unmatched.setMatchStatus("UNMATCHED"); unmatched.setMatchedAt(at); unmatched.setReason(reason); unmatched.setOperatorId(operatorId); unmatched.setCreatedAt(at); unmatched.setLockedAt(null); unmatched.setLockReason(null); repository.insertAssignment(unmatched);
|
||||||
|
}
|
||||||
|
for (SailingBookingLinkEntity link : repository.bookingLinks(id)) repository.revokeBookingLink(link.getId(), operatorId, reason, at);
|
||||||
|
recordHistory(id, "CANCEL", before.getStatus(), "CANCELLED", reason, operatorId, operatorName, at);
|
||||||
|
return detail(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
public ExceptionReassignResult exceptionReassign(long sourceSailingId, long operatorId, String operatorName, ExceptionReassignRequest request) {
|
||||||
|
SailingEntity source = requiredSailing(sourceSailingId); SailingEntity target = requiredSailing(request.targetSailingId());
|
||||||
|
if (source.getId().equals(target.getId())) throw bad("目标船期不能与原船期相同");
|
||||||
|
if (!"OPEN".equals(target.getStatus()) || target.getWarehouseCutoffAt() == null || !target.getWarehouseCutoffAt().isAfter(LocalDateTime.now(ZONE))) throw conflict("目标船期必须为未截止的OPEN状态");
|
||||||
|
if (!Objects.equals(source.getWarehouseId(), target.getWarehouseId()) || !Objects.equals(source.getChannelCodeSnapshot(), target.getChannelCodeSnapshot())
|
||||||
|
|| !Objects.equals(source.getDeparturePortCode(), target.getDeparturePortCode()) || !Objects.equals(source.getDestinationPortCode(), target.getDestinationPortCode())) throw conflict("目标船期的仓库、渠道或港口不一致");
|
||||||
|
if (!Objects.equals(target.getVersion(), version(request.expectedTargetVersion()))) throw conflict("目标船期版本已变化,请刷新后重试");
|
||||||
|
SailingAssignmentEntity current = repository.assignmentForUpdate(request.assignmentId());
|
||||||
|
if (current == null) throw notFound("归班事实不存在");
|
||||||
|
if (!Objects.equals(current.getAssignmentVersion(), version(request.expectedAssignmentVersion()))) throw conflict("归班事实版本已变化,请刷新后重试");
|
||||||
|
if (!List.of("LATE", "UNMATCHED").contains(current.getMatchStatus())) throw conflict("只有晚到或未安排事实允许异常重新安排");
|
||||||
|
if (current.getSailingId() != null && !Objects.equals(current.getSailingId(), sourceSailingId)
|
||||||
|
|| current.getSailingId() == null && !Objects.equals(current.getOriginSailingId(), sourceSailingId)) throw conflict("归班事实不属于原船期");
|
||||||
|
String newStatus; LocalDateTime eventAt = null;
|
||||||
|
if (current.getReceiptBatchId() != null) {
|
||||||
|
var fact = repository.receiptFact(current.getReceiptBatchId()); if (fact == null || !"APPROVED".equals(fact.getFactStatus())) throw conflict("实收事实不可重新安排");
|
||||||
|
eventAt = fact.getReceivedAt(); if (eventAt == null || eventAt.isAfter(target.getWarehouseCutoffAt())) throw conflict("实收时间晚于目标船期截止时间"); newStatus = "RECEIVED";
|
||||||
|
} else if (current.getForecastId() != null) {
|
||||||
|
var fact = repository.forecast(current.getForecastId()); if (fact == null || !"VALID".equals(fact.getFactStatus())) throw conflict("预报事实已失效");
|
||||||
|
eventAt = fact.getExpectedArrivalAt(); if (eventAt == null || eventAt.isAfter(target.getWarehouseCutoffAt())) throw conflict("预计到仓时间晚于目标船期截止时间"); newStatus = "FORECAST_MATCHED";
|
||||||
|
} else throw conflict("缺少可核验的实收或预报事实");
|
||||||
|
LocalDateTime at = LocalDateTime.now(ZONE); if (repository.supersedeAssignment(current.getId(), current.getAssignmentVersion(), operatorId, request.reason().trim(), at) != 1) throw conflict("归班事实已被其他人修改");
|
||||||
|
SailingAssignmentEntity next = copyAssignment(current); next.setId(IdWorker.getId()); next.setSailingId(target.getId()); next.setOriginSailingId(sourceSailingId); next.setAssignmentSource("MANUAL"); next.setAssignmentVersion(current.getAssignmentVersion() + 1); next.setMatchStatus(newStatus); next.setMatchedAt(at); next.setLockedAt(null); next.setLockReason(null); next.setOperatorId(operatorId); next.setReason(request.reason().trim()); next.setCreatedAt(at); repository.insertAssignment(next);
|
||||||
|
return new ExceptionReassignResult(current.getId(), next.getId(), sourceSailingId, target.getId(), next.getAssignmentVersion(), newStatus, next.getVolumeCbm(), next.getForecastVolumeCbm(), request.reason().trim());
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 运营维护未收货量预报,并立即生成一条可追溯的自动归班事实。 */
|
||||||
|
@Transactional
|
||||||
|
public SailingInboundVolumeForecastEntity createForecast(long operatorId, ForecastCreateRequest request) {
|
||||||
|
if (request.expectedArrivalAt() == null || request.expectedArrivalAt().isBefore(LocalDateTime.now(ZONE))) {
|
||||||
|
throw bad("预计到仓时间不能早于当前时间");
|
||||||
|
}
|
||||||
|
if (request.forecastVolumeCbm() == null || request.forecastVolumeCbm().signum() <= 0) {
|
||||||
|
throw bad("预计体积必须大于0");
|
||||||
|
}
|
||||||
|
String channelCode = trimUpper(request.channelCode());
|
||||||
|
SailingChannelEntity channel = StringUtils.hasText(channelCode) ? repository.channelByCode(channelCode) : null;
|
||||||
|
if (channel == null || !"ACTIVE".equals(channel.getStatus())) throw bad("预报必须填写已启用的渠道编码");
|
||||||
|
String departurePort = trimUpper(request.departurePortCode());
|
||||||
|
String destinationPort = trimUpper(request.destinationPortCode());
|
||||||
|
if (!StringUtils.hasText(departurePort)) departurePort = trimUpper(channel.getDefaultDeparturePortCode());
|
||||||
|
if (!StringUtils.hasText(destinationPort)) destinationPort = trimUpper(channel.getDefaultDestinationPortCode());
|
||||||
|
SailingInboundVolumeForecastEntity value = new SailingInboundVolumeForecastEntity();
|
||||||
|
value.setId(IdWorker.getId()); value.setTenantId(request.tenantId()); value.setForecastKey(request.forecastKey().trim());
|
||||||
|
value.setFactVersion(1); value.setInboundId(request.inboundId()); value.setReceivingWarehouseId(request.receivingWarehouseId());
|
||||||
|
value.setChannelCode(channelCode); value.setChannelName(trim(request.channelName()));
|
||||||
|
value.setDeparturePortCode(departurePort); value.setDestinationPortCode(destinationPort);
|
||||||
|
value.setExpectedArrivalAt(request.expectedArrivalAt()); value.setForecastVolumeCbm(request.forecastVolumeCbm());
|
||||||
|
value.setSourceType(request.sourceType().trim().toUpperCase()); value.setFactStatus("VALID");
|
||||||
|
value.setEffectiveAt(LocalDateTime.now(ZONE)); value.setCreatedBy(operatorId); value.setCreatedAt(LocalDateTime.now(ZONE)); value.setUpdatedAt(value.getCreatedAt());
|
||||||
|
repository.insertForecast(value);
|
||||||
|
insertForecastAssignment(value, operatorId);
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
public SailingInboundVolumeForecastEntity reviseForecast(long operatorId, long forecastId, ForecastRevisionRequest request) {
|
||||||
|
SailingInboundVolumeForecastEntity current = repository.forecast(forecastId);
|
||||||
|
if (current == null || !Objects.equals(current.getTenantId(), request.tenantId())) throw notFound("货量预报不存在");
|
||||||
|
if (!"VALID".equals(current.getFactStatus())) throw conflict("只有有效预报可以修订");
|
||||||
|
if (!Objects.equals(current.getFactVersion(), request.expectedFactVersion())) throw conflict("预报版本已变化,请刷新后重试");
|
||||||
|
if (request.expectedArrivalAt().isBefore(LocalDateTime.now(ZONE))) throw bad("预计到仓时间不能早于当前时间");
|
||||||
|
if (request.forecastVolumeCbm().signum() <= 0) throw bad("预计体积必须大于0");
|
||||||
|
String channelCode = trimUpper(request.channelCode());
|
||||||
|
if (!StringUtils.hasText(channelCode)) channelCode = current.getChannelCode();
|
||||||
|
SailingChannelEntity channel = repository.channelByCode(channelCode);
|
||||||
|
if (channel == null || !"ACTIVE".equals(channel.getStatus())) throw bad("预报必须填写已启用的渠道编码");
|
||||||
|
String departurePort = trimUpper(request.departurePortCode());
|
||||||
|
String destinationPort = trimUpper(request.destinationPortCode());
|
||||||
|
if (!StringUtils.hasText(departurePort)) departurePort = trimUpper(channel.getDefaultDeparturePortCode());
|
||||||
|
if (!StringUtils.hasText(destinationPort)) destinationPort = trimUpper(channel.getDefaultDestinationPortCode());
|
||||||
|
LocalDateTime now = LocalDateTime.now(ZONE);
|
||||||
|
if (repository.supersedeForecast(request.tenantId(), forecastId, now) != 1) throw conflict("预报已被其他人修改,请刷新后重试");
|
||||||
|
repository.supersedeForecastAssignments(request.tenantId(), forecastId, now);
|
||||||
|
SailingInboundVolumeForecastEntity next = new SailingInboundVolumeForecastEntity();
|
||||||
|
next.setId(IdWorker.getId()); next.setTenantId(current.getTenantId()); next.setForecastKey(current.getForecastKey());
|
||||||
|
next.setFactVersion(current.getFactVersion() + 1); next.setInboundId(current.getInboundId()); next.setReceivingWarehouseId(current.getReceivingWarehouseId());
|
||||||
|
next.setChannelCode(channelCode); next.setChannelName(trim(request.channelName())); next.setDeparturePortCode(departurePort); next.setDestinationPortCode(destinationPort);
|
||||||
|
next.setExpectedArrivalAt(request.expectedArrivalAt()); next.setForecastVolumeCbm(request.forecastVolumeCbm()); next.setSourceType(request.sourceType().trim().toUpperCase());
|
||||||
|
next.setFactStatus("VALID"); next.setEffectiveAt(now); next.setCreatedBy(operatorId); next.setCreatedAt(now); next.setUpdatedAt(now);
|
||||||
|
repository.insertForecast(next); insertForecastAssignment(next, operatorId);
|
||||||
|
return next;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
public void voidForecast(long operatorId, long forecastId, ForecastVoidRequest request) {
|
||||||
|
SailingInboundVolumeForecastEntity current = repository.forecast(forecastId);
|
||||||
|
if (current == null || !Objects.equals(current.getTenantId(), request.tenantId())) throw notFound("货量预报不存在");
|
||||||
|
LocalDateTime now = LocalDateTime.now(ZONE);
|
||||||
|
if (repository.voidForecast(request.tenantId(), forecastId, now) != 1) throw conflict("预报已被其他人修改,请刷新后重试");
|
||||||
|
repository.supersedeForecastAssignments(request.tenantId(), forecastId, now);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
public void bindReceiptToForecast(long operatorId, long forecastId, ForecastReceiptBindRequest request) {
|
||||||
|
SailingInboundVolumeForecastEntity forecast = repository.forecast(forecastId);
|
||||||
|
SailingReceiptBatchFactEntity receipt = repository.receiptFact(request.receiptBatchId());
|
||||||
|
if (forecast == null || receipt == null || !Objects.equals(forecast.getTenantId(), request.tenantId())
|
||||||
|
|| !Objects.equals(receipt.getTenantId(), request.tenantId())) throw notFound("预报或实收批次不存在");
|
||||||
|
if (!"VALID".equals(forecast.getFactStatus()) || !"APPROVED".equals(receipt.getFactStatus())) throw conflict("预报或实收批次已失效");
|
||||||
|
if (!Objects.equals(forecast.getInboundId(), receipt.getInboundId())) throw conflict("预报与实收批次不属于同一入仓单");
|
||||||
|
SailingAssignmentEntity currentReceipt = repository.currentAssignmentForReceipt(request.tenantId(), request.receiptBatchId());
|
||||||
|
if (currentReceipt == null) throw conflict("实收批次当前没有可绑定的归班版本");
|
||||||
|
if (!Objects.equals(currentReceipt.getAssignmentVersion(), request.expectedAssignmentVersion())) throw conflict("实收归班版本已变化,请刷新后重试");
|
||||||
|
SailingAssignmentEntity previousForecast = repository.currentAssignmentForForecast(request.tenantId(), forecastId);
|
||||||
|
LocalDateTime now = LocalDateTime.now(ZONE);
|
||||||
|
if (repository.supersedeAssignment(currentReceipt.getId(), currentReceipt.getAssignmentVersion(), operatorId, request.reason().trim(), now) != 1) throw conflict("实收归班已被其他人修改,请刷新后重试");
|
||||||
|
repository.supersedeForecastAssignments(request.tenantId(), forecastId, now);
|
||||||
|
if (repository.consumeForecast(request.tenantId(), forecastId, now) != 1) throw conflict("预报已被其他人消费,请刷新后重试");
|
||||||
|
SailingEntity sailing = previousForecast == null || previousForecast.getSailingId() == null ? null : repository.sailing(previousForecast.getSailingId());
|
||||||
|
if (sailing != null && "CANCELLED".equals(sailing.getStatus())) sailing = null;
|
||||||
|
if (sailing == null && StringUtils.hasText(forecast.getDeparturePortCode()) && StringUtils.hasText(forecast.getDestinationPortCode())) {
|
||||||
|
List<SailingEntity> candidates = repository.openSailingsFrom(forecast.getReceivingWarehouseId(), forecast.getChannelCode(), forecast.getDeparturePortCode(), forecast.getDestinationPortCode(), receipt.getReceivedAt());
|
||||||
|
sailing = candidates.isEmpty() ? null : candidates.get(0);
|
||||||
|
}
|
||||||
|
boolean late = sailing != null && receipt.getReceivedAt().isAfter(sailing.getWarehouseCutoffAt());
|
||||||
|
SailingAssignmentEntity next = new SailingAssignmentEntity(); next.setId(IdWorker.getId()); next.setTenantId(request.tenantId()); next.setSailingId(sailing == null ? null : sailing.getId());
|
||||||
|
next.setInboundId(receipt.getInboundId()); next.setReceiptBatchId(receipt.getId()); next.setForecastId(forecast.getId()); next.setAssignmentSource("MANUAL"); next.setAssignmentVersion(currentReceipt.getAssignmentVersion() + 1);
|
||||||
|
next.setMatchStatus(sailing == null ? "UNMATCHED" : late ? "LATE" : "RECEIVED"); next.setVolumeCbm(receipt.getActualVolumeCbm()); next.setRouteSource("FORECAST_FACT"); next.setVolumeSource("MEASURED_ACTUAL"); next.setRuleVersion("V1_CUTOFF_3D_18");
|
||||||
|
next.setMatchedAt(now); next.setLockedAt(late ? now : null); next.setLockReason(late ? "CUTOFF" : null); next.setReason(request.reason().trim()); next.setOperatorId(operatorId); next.setCreatedAt(now); repository.insertAssignment(next);
|
||||||
|
BigDecimal remaining = forecast.getForecastVolumeCbm().subtract(receipt.getActualVolumeCbm());
|
||||||
|
if (remaining.signum() > 0) insertResidualForecast(forecast, remaining, operatorId, now);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void insertForecastAssignment(SailingInboundVolumeForecastEntity value, long operatorId) {
|
||||||
|
LocalDateTime now = LocalDateTime.now(ZONE);
|
||||||
|
List<SailingEntity> candidates = StringUtils.hasText(value.getDeparturePortCode()) && StringUtils.hasText(value.getDestinationPortCode())
|
||||||
|
? repository.futureCandidates(value.getReceivingWarehouseId(), value.getChannelCode(), value.getDeparturePortCode(), value.getDestinationPortCode(), value.getExpectedArrivalAt()) : List.of();
|
||||||
|
SailingAssignmentEntity assignment = new SailingAssignmentEntity(); assignment.setId(IdWorker.getId()); assignment.setTenantId(value.getTenantId()); assignment.setSailingId(candidates.isEmpty() ? null : candidates.get(0).getId());
|
||||||
|
assignment.setInboundId(value.getInboundId()); assignment.setForecastId(value.getId()); assignment.setAssignmentSource("AUTO"); assignment.setAssignmentVersion(1); assignment.setMatchStatus(candidates.isEmpty() ? "UNMATCHED" : "FORECAST_MATCHED"); assignment.setForecastVolumeCbm(value.getForecastVolumeCbm());
|
||||||
|
assignment.setRouteSource("FORECAST_FACT"); assignment.setVolumeSource(value.getSourceType()); assignment.setRuleVersion("V1_CUTOFF_3D_18"); assignment.setMatchedAt(now); assignment.setReason(candidates.isEmpty() ? "没有找到截止前的OPEN船期" : null); assignment.setOperatorId(operatorId); assignment.setCreatedAt(now); repository.insertAssignment(assignment);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void insertResidualForecast(SailingInboundVolumeForecastEntity forecast, BigDecimal remaining, long operatorId, LocalDateTime now) {
|
||||||
|
SailingInboundVolumeForecastEntity residual = new SailingInboundVolumeForecastEntity(); residual.setId(IdWorker.getId()); residual.setTenantId(forecast.getTenantId()); residual.setForecastKey(forecast.getForecastKey()); residual.setFactVersion(forecast.getFactVersion() + 1); residual.setInboundId(forecast.getInboundId());
|
||||||
|
residual.setReceivingWarehouseId(forecast.getReceivingWarehouseId()); residual.setChannelCode(forecast.getChannelCode()); residual.setChannelName(forecast.getChannelName()); residual.setDeparturePortCode(forecast.getDeparturePortCode()); residual.setDestinationPortCode(forecast.getDestinationPortCode()); residual.setExpectedArrivalAt(forecast.getExpectedArrivalAt()); residual.setForecastVolumeCbm(remaining); residual.setSourceType(forecast.getSourceType()); residual.setFactStatus("VALID"); residual.setEffectiveAt(now); residual.setCreatedBy(operatorId); residual.setCreatedAt(now); residual.setUpdatedAt(now); repository.insertForecast(residual); insertForecastAssignment(residual, operatorId);
|
||||||
|
}
|
||||||
|
@Transactional
|
||||||
|
public SailingDetail copy(long id, long operatorId, String operatorName, CopyRequest request) {
|
||||||
|
SailingEntity source = requiredSailing(id);
|
||||||
|
if (request.etdAt().isBefore(LocalDateTime.now(ZONE))) throw bad("复制船期不能早于当前时间");
|
||||||
|
if (!request.cutoffAt().isBefore(request.etdAt())) throw bad("收货截止必须早于开船时间");
|
||||||
|
String copyReason = "复制自" + source.getSailingNo();
|
||||||
|
SailingEntity existing = repository.sailingByBusinessKey(source.getWarehouseId(), source.getChannelCodeSnapshot(),
|
||||||
|
source.getDeparturePortCode(), source.getDestinationPortCode(), request.etdAt(), source.getVoyageNo());
|
||||||
|
if (existing != null) return isCopyOf(existing, copyReason) ? detail(existing.getId())
|
||||||
|
: throwCopyConflict(request.etdAt(), source.getVoyageNo());
|
||||||
|
|
||||||
|
SailingEntity value = new SailingEntity(); value.setId(IdWorker.getId()); value.setSailingNo("SL-" + value.getId()); value.setTemplateId(source.getTemplateId()); value.setChannelId(source.getChannelId()); value.setChannelCodeSnapshot(source.getChannelCodeSnapshot()); value.setChannelNameSnapshot(source.getChannelNameSnapshot()); value.setWarehouseId(source.getWarehouseId()); value.setWarehouseNameSnapshot(source.getWarehouseNameSnapshot()); value.setDeparturePortCode(source.getDeparturePortCode()); value.setDestinationPortCode(source.getDestinationPortCode()); value.setVesselName(source.getVesselName()); value.setVoyageNo(source.getVoyageNo()); value.setEtdAt(request.etdAt()); value.setWarehouseCutoffAt(request.cutoffAt()); value.setEtaAt(copyEta(source, request.etdAt())); value.setStatus("OPEN"); value.setSourceType("COPY"); value.setPlannedCapacityCbm(source.getPlannedCapacityCbm()); value.setVersion(0); value.setCreatedBy(operatorId); value.setCreatedByName(operatorName); value.setUpdatedBy(operatorId); value.setUpdatedByName(operatorName); now(value);
|
||||||
|
try { repository.insert(value); } catch (DuplicateKeyException ex) {
|
||||||
|
SailingEntity duplicate = repository.sailingByBusinessKey(source.getWarehouseId(), source.getChannelCodeSnapshot(),
|
||||||
|
source.getDeparturePortCode(), source.getDestinationPortCode(), request.etdAt(), source.getVoyageNo());
|
||||||
|
if (duplicate != null && isCopyOf(duplicate, copyReason)) return detail(duplicate.getId());
|
||||||
|
throwCopyConflict(request.etdAt(), source.getVoyageNo());
|
||||||
|
}
|
||||||
|
recordHistory(value.getId(), "COPY", null, OPEN, copyReason, operatorId, operatorName, value.getCreatedAt()); return detail(value.getId());
|
||||||
|
}
|
||||||
|
public VolumeDetail volumeSummary(long id) {
|
||||||
|
SailingEntity value = requiredSailing(id);
|
||||||
|
List<AssignmentView> assignments = repository.assignments(id).stream().map(a -> {
|
||||||
|
LocalDateTime receivedAt = a.getReceiptBatchId() == null || repository.receiptFact(a.getReceiptBatchId()) == null
|
||||||
|
? null : repository.receiptFact(a.getReceiptBatchId()).getReceivedAt();
|
||||||
|
return new AssignmentView(String.valueOf(a.getId()), a.getReceiptBatchId() == null ? null : String.valueOf(a.getReceiptBatchId()), a.getForecastId() == null ? null : String.valueOf(a.getForecastId()), a.getInboundCode(), receivedAt, nvl(a.getVolumeCbm()).add(nvl(a.getForecastVolumeCbm())), a.getMatchStatus(), a.getAssignmentSource(), a.getReason(), a.getAssignmentVersion());
|
||||||
|
}).toList();
|
||||||
|
return new VolumeDetail(summary(value), assignments);
|
||||||
|
}
|
||||||
|
public BookingAdvice bookingAdvice(long id) {
|
||||||
|
SailingEntity value = requiredSailing(id); VolumeSummary s = summary(value);
|
||||||
|
BigDecimal catchable = catchableVolume(value.getId());
|
||||||
|
int recommended = recommendedContainers(catchable);
|
||||||
|
BigDecimal bookedCapacity = bookedPlanningCapacity(value.getId());
|
||||||
|
int pending = ceil(nvl(catchable.subtract(bookedCapacity)), defaultContainerCapacity());
|
||||||
|
List<BookingScenario> scenarios = ContainerSpecificationCatalog.values().stream().map(spec -> {
|
||||||
|
int count = ceil(catchable, spec.capacityCbm());
|
||||||
|
int pendingForType = ceil(nvl(catchable.subtract(bookedCapacity)), spec.capacityCbm());
|
||||||
|
return new BookingScenario(spec.containerType(), spec.containerType(), spec.capacityCbm(), count, pendingForType);
|
||||||
|
}).toList();
|
||||||
|
return new BookingAdvice(s.actualReceivedCbm(), catchable, s.planningCapacityCbm(), s.bookedContainerCount(), recommended, pending, scenarios, LocalDateTime.now(ZONE), s.catchableReceivedCbm(), s.lateReceivedCbm());
|
||||||
|
}
|
||||||
|
@Transactional
|
||||||
|
public Map<String, Object> linkBooking(long id, long operatorId, String operatorName, BookingLinkRequest request) {
|
||||||
|
SailingEntity sailing = requiredSailing(id);
|
||||||
|
var booking = repository.booking(request.oceanBookingId());
|
||||||
|
if (booking == null) throw notFound("订舱不存在");
|
||||||
|
if (!"RELEASED".equals(booking.getStatus())) throw conflict("只有RELEASED订舱可以关联船期");
|
||||||
|
if (repository.activeBookingLink(request.oceanBookingId()) != null) throw conflict("订舱已经关联其他船期");
|
||||||
|
if (!Objects.equals(sailing.getWarehouseId(), booking.getWarehouseId()) || !Objects.equals(sailing.getChannelCodeSnapshot(), booking.getChannelCode())
|
||||||
|
|| !Objects.equals(sailing.getDeparturePortCode(), booking.getDeparturePortCode()) || !Objects.equals(sailing.getDestinationPortCode(), booking.getDestinationPortCode())) throw conflict("订舱的仓库、渠道或港口与船期不一致");
|
||||||
|
SailingBookingLinkEntity value = new SailingBookingLinkEntity(); value.setId(IdWorker.getId()); value.setTenantId(booking.getCustomerId() == null ? 0L : booking.getCustomerId()); value.setSailingId(id); value.setOceanBookingId(request.oceanBookingId()); value.setLinkStatus("ACTIVE"); value.setActiveBookingGuard(1); value.setBookingStatusSnapshot(booking.getStatus()); value.setWarehouseId(booking.getWarehouseId()); value.setChannelCode(booking.getChannelCode()); value.setDeparturePortCode(booking.getDeparturePortCode()); value.setDestinationPortCode(booking.getDestinationPortCode()); value.setLinkedAt(LocalDateTime.now(ZONE)); value.setOperatorId(operatorId); value.setOperatorName(operatorName); repository.insertBookingLink(value);
|
||||||
|
int booked = repository.requirements(booking.getId()).stream().mapToInt(v -> v.getQuantity() == null ? 0 : v.getQuantity()).sum();
|
||||||
|
return Map.of("id", value.getId(), "sailingId", id, "oceanBookingId", request.oceanBookingId(), "status", value.getLinkStatus(), "bookedContainerCount", booked);
|
||||||
|
}
|
||||||
|
@Transactional
|
||||||
|
public SailingEntity createSailing(long operatorId, String operatorName, SailingCreateRequest request) {
|
||||||
|
if (version(request.expectedVersion()) != 0) throw bad("新建船期expectedVersion必须为0");
|
||||||
|
validateSailing(request.data()); SailingChannelEntity channel = resolveChannel(request.data());
|
||||||
|
if (!"ACTIVE".equals(channel.getStatus())) throw conflict("停用渠道不能创建船期");
|
||||||
|
SailingEntity value = new SailingEntity(); value.setId(IdWorker.getId()); value.setSailingNo("SL-" + value.getId());
|
||||||
|
copy(value, request.data(), channel); value.setStatus(OPEN); value.setSourceType(normalizeSource(request.data().sourceType()));
|
||||||
|
value.setCreatedBy(operatorId); value.setCreatedByName(operatorName); value.setUpdatedBy(operatorId); value.setUpdatedByName(operatorName); value.setVersion(0); now(value);
|
||||||
|
try { repository.insert(value); } catch (DuplicateKeyException ex) { throw conflict("船期业务唯一键冲突"); }
|
||||||
|
recordHistory(value.getId(), "CREATE", null, OPEN, "新增船期", operatorId, operatorName, value.getCreatedAt());
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
@Transactional
|
||||||
|
public SailingEntity updateSailing(long id, long operatorId, String operatorName, SailingUpdateRequest request) {
|
||||||
|
SailingEntity before = requiredSailing(id); validateSailing(request.data()); SailingChannelEntity channel = resolveChannel(request.data());
|
||||||
|
if (!Objects.equals(before.getStatus(), "OPEN") && !Objects.equals(before.getStatus(), "DRAFT")) throw conflict("当前船期状态不可编辑");
|
||||||
|
SailingEntity value = new SailingEntity(); value.setId(id); copy(value, request.data(), channel); value.setStatus(StringUtils.hasText(request.status()) ? normalizeSailingStatus(request.status()) : before.getStatus());
|
||||||
|
value.setSourceType(normalizeSource(request.data().sourceType()));
|
||||||
|
if (repository.update(value, version(request.expectedVersion()), operatorId, operatorName) != 1) throw conflict("船期已被其他人修改");
|
||||||
|
recordHistory(id, "EDIT", before.getStatus(), value.getStatus(), "编辑船期", operatorId, operatorName, LocalDateTime.now(ZONE));
|
||||||
|
return requiredSailing(id);
|
||||||
|
}
|
||||||
|
public List<GenerationPreview> generationPreview(long templateId, LocalDate from, LocalDate to) {
|
||||||
|
SailingTemplateEntity template = requiredTemplate(templateId); if (!from.isBefore(to.plusDays(1))) throw bad("日期范围无效");
|
||||||
|
java.util.ArrayList<GenerationPreview> result = new java.util.ArrayList<>();
|
||||||
|
for (LocalDate date = from; !date.isAfter(to); date = date.plusDays(1)) if (matchesWeekday(template, date)
|
||||||
|
&& !date.isBefore(template.getValidFrom()) && (template.getValidTo() == null || !date.isAfter(template.getValidTo()))) {
|
||||||
|
LocalDateTime etd = LocalDateTime.of(date, template.getEtdLocalTime()); LocalDateTime cutoff = cutoff(template, etd);
|
||||||
|
String voyage = voyage(template, date); String key = key(template, etd, voyage);
|
||||||
|
result.add(new GenerationPreview(date, etd, cutoff, voyage, repository.sailingByGeneratedKey(key) != null, ""));
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
@Transactional
|
||||||
|
public List<SailingEntity> generate(long operatorId, String operatorName, long templateId, LocalDate from, LocalDate to) {
|
||||||
|
SailingTemplateEntity template = requiredTemplate(templateId); if (!"ACTIVE".equals(template.getStatus())) throw conflict("停用模板不能生成船期");
|
||||||
|
SailingChannelEntity channel = requiredChannel(template.getChannelId());
|
||||||
|
if (!"ACTIVE".equals(channel.getStatus())) throw conflict("停用渠道不能生成船期");
|
||||||
|
validateTemplateSnapshot(template);
|
||||||
|
List<SailingEntity> result = new java.util.ArrayList<>();
|
||||||
|
for (GenerationPreview preview : generationPreview(templateId, from, to)) if (!preview.conflict()) {
|
||||||
|
SailingEntity value = new SailingEntity(); value.setId(IdWorker.getId()); value.setSailingNo("SL-" + value.getId()); value.setTemplateId(templateId);
|
||||||
|
value.setChannelId(channel.getId()); value.setChannelCodeSnapshot(channel.getChannelCode()); value.setChannelNameSnapshot(channel.getChannelName());
|
||||||
|
value.setWarehouseId(template.getWarehouseId()); value.setWarehouseNameSnapshot(template.getWarehouseNameSnapshot()); value.setDeparturePortCode(template.getDeparturePortCode()); value.setDestinationPortCode(template.getDestinationPortCode()); value.setVesselName(template.getVesselName()); value.setVoyageNo(preview.voyageNo()); value.setEtdAt(preview.etdAt()); value.setWarehouseCutoffAt(preview.cutoffAt()); value.setStatus(OPEN); value.setSourceType("TEMPLATE"); value.setGeneratedKey(key(template, preview.etdAt(), preview.voyageNo())); value.setVersion(0); value.setCreatedBy(operatorId); value.setCreatedByName(operatorName); value.setUpdatedBy(operatorId); value.setUpdatedByName(operatorName); now(value); repository.insert(value); recordHistory(value.getId(), "GENERATE", null, OPEN, "按固定周模板生成", operatorId, operatorName, value.getCreatedAt()); result.add(value);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void recordHistory(long sailingId, String action, String fromStatus, String toStatus, String reason, long operatorId, String operatorName, LocalDateTime at) {
|
||||||
|
SailingHistoryEntity history = new SailingHistoryEntity(); history.setId(IdWorker.getId()); history.setSailingId(sailingId); history.setAction(action); history.setFromStatus(fromStatus); history.setToStatus(toStatus); history.setReason(reason); history.setOperatorId(operatorId); history.setOperatorName(operatorName); history.setCreatedAt(at); repository.insertHistory(history);
|
||||||
|
}
|
||||||
|
private CalendarItem item(SailingEntity v) { return new CalendarItem(v.getId(), v.getSailingNo(), v.getWarehouseId(), v.getWarehouseNameSnapshot(), v.getChannelCodeSnapshot(), v.getChannelNameSnapshot(), v.getVesselName(), v.getVoyageNo(), v.getDeparturePortCode(), v.getDestinationPortCode(), v.getEtdAt(), v.getWarehouseCutoffAt(), v.getStatus(), v.getVersion(), summary(v)); }
|
||||||
|
private VolumeSummary summary(SailingEntity v) {
|
||||||
|
SailingCalendarDisplayMapper.ReceiptSummary received = repository.receiptSummary(v);
|
||||||
|
BigDecimal eligibleActual = received == null ? BigDecimal.ZERO : nvl(received.catchableCbm());
|
||||||
|
BigDecimal lateActual = received == null ? BigDecimal.ZERO : nvl(received.lateCbm());
|
||||||
|
BigDecimal actual = eligibleActual.add(lateActual);
|
||||||
|
BigDecimal forecast = BigDecimal.ZERO;
|
||||||
|
// 当前展示版不再产生“未归班”事实;没有可靠的预计体积源时不把它冒充成待确认货量。
|
||||||
|
BigDecimal risk = BigDecimal.ZERO;
|
||||||
|
int late = received == null || received.lateCount() == null ? 0 : received.lateCount();
|
||||||
|
BookingTotals bookedTotals = bookingTotals(v);
|
||||||
|
BigDecimal capacity = v.getPlannedCapacityCbm(); BigDecimal catchable = catchableVolume(v.getId());
|
||||||
|
boolean over = capacity != null && catchable.compareTo(capacity) > 0;
|
||||||
|
int recommended = recommendedContainers(catchable);
|
||||||
|
int pending = ceil(nvl(catchable.subtract(bookedTotals.capacityCbm())), defaultContainerCapacity());
|
||||||
|
return new VolumeSummary(actual, forecast, risk, bookedTotals.containerCount(), recommended, pending, late, over, capacity, bookedTotals.capacityCbm(), LocalDateTime.now(ZONE), eligibleActual, lateActual);
|
||||||
|
}
|
||||||
|
private static int recommendedContainers(BigDecimal volume) { return ceil(volume, defaultContainerCapacity()); }
|
||||||
|
private static BigDecimal defaultContainerCapacity() {
|
||||||
|
return ContainerSpecificationCatalog.values().stream()
|
||||||
|
.filter(spec -> ContainerSpecificationCatalog.DEFAULT_CONTAINER_TYPE.equals(spec.containerType()))
|
||||||
|
.map(ContainerSpecificationCatalog.Specification::capacityCbm)
|
||||||
|
.findFirst()
|
||||||
|
.orElseThrow(() -> new IllegalStateException("默认柜型未配置"));
|
||||||
|
}
|
||||||
|
private BigDecimal catchableVolume(long sailingId) {
|
||||||
|
SailingEntity sailing = requiredSailing(sailingId);
|
||||||
|
SailingCalendarDisplayMapper.ReceiptSummary received = repository.receiptSummary(sailing);
|
||||||
|
return received == null ? BigDecimal.ZERO : nvl(received.catchableCbm());
|
||||||
|
}
|
||||||
|
private BigDecimal bookedPlanningCapacity(long sailingId) { return bookingTotals(requiredSailing(sailingId)).capacityCbm(); }
|
||||||
|
private BookingTotals bookingTotals(SailingEntity sailing) {
|
||||||
|
SailingCalendarDisplayMapper.BookingSummary value = repository.bookingSummary(sailing);
|
||||||
|
return new BookingTotals(value == null || value.containerCount() == null ? 0 : value.containerCount(),
|
||||||
|
value == null ? BigDecimal.ZERO : nvl(value.capacityCbm()));
|
||||||
|
}
|
||||||
|
private record BookingTotals(int containerCount, BigDecimal capacityCbm) {}
|
||||||
|
private static int ceil(BigDecimal volume, BigDecimal unit) { return volume == null || volume.signum() <= 0 ? 0 : volume.divide(unit, 0, java.math.RoundingMode.CEILING).intValue(); }
|
||||||
|
private static BigDecimal nvl(BigDecimal value) { return value == null ? BigDecimal.ZERO : value; }
|
||||||
|
private static void copy(SailingChannelEntity v, ChannelData d) { v.setChannelCode(d.channelCode().trim().toUpperCase()); v.setChannelName(d.channelName().trim()); v.setSourceType(d.sourceType().trim().toUpperCase()); v.setMappedChannelCode(trimUpper(d.mappedChannelCode())); v.setDefaultDeparturePortCode(trimUpper(d.defaultDeparturePortCode())); v.setDefaultDestinationPortCode(trimUpper(d.defaultDestinationPortCode())); v.setDefaultCutoffDays(d.defaultCutoffDays() == null ? 3 : d.defaultCutoffDays()); v.setRemark(trim(d.remark())); }
|
||||||
|
private static void copy(SailingTemplateEntity v, TemplateData d, SailingChannelEntity c) {
|
||||||
|
v.setTemplateName(StringUtils.hasText(d.templateName()) ? d.templateName().trim() : c.getChannelName()); v.setChannelId(c.getId()); v.setChannelCodeSnapshot(c.getChannelCode()); v.setChannelNameSnapshot(c.getChannelName()); v.setWarehouseId(d.warehouseId()); v.setWarehouseNameSnapshot(trim(d.warehouseName()));
|
||||||
|
v.setDeparturePortCode(trimUpper(StringUtils.hasText(d.departurePortCode()) ? d.departurePortCode() : c.getDefaultDeparturePortCode())); v.setDestinationPortCode(trimUpper(StringUtils.hasText(d.destinationPortCode()) ? d.destinationPortCode() : c.getDefaultDestinationPortCode()));
|
||||||
|
int mask = d.weekdays() == null || d.weekdays().isEmpty() ? 1 : d.weekdays().stream().mapToInt(day -> 1 << (day - 1)).reduce(0, (a, b) -> a | b);
|
||||||
|
v.setWeekday(Integer.numberOfTrailingZeros(mask) + 1); v.setWeekdayMask(mask); v.setEtdLocalTime(d.departureTime()); v.setCutoffDays(d.cutoffDays()); v.setCutoffLocalTime(d.cutoffLocalTime()); v.setValidFrom(d.effectiveFrom()); v.setValidTo(d.effectiveTo()); v.setVoyageRule(trim(d.voyageRule())); v.setVesselName(trim(d.vesselName())); v.setRemark(trim(d.remark()));
|
||||||
|
}
|
||||||
|
private static void copy(SailingEntity v, SailingData d, SailingChannelEntity c) { v.setTemplateId(d.templateId()); v.setChannelId(c.getId()); v.setChannelCodeSnapshot(c.getChannelCode()); v.setChannelNameSnapshot(c.getChannelName()); v.setWarehouseId(d.warehouseId()); v.setWarehouseNameSnapshot(trim(d.warehouseName())); v.setDeparturePortCode(d.departurePortCode().trim().toUpperCase()); v.setDestinationPortCode(d.destinationPortCode().trim().toUpperCase()); v.setVesselName(d.vesselName().trim()); v.setVoyageNo(d.voyageNo().trim()); v.setEtdAt(d.etdAt()); v.setEtaAt(d.etaAt()); v.setWarehouseCutoffAt(d.cutoffAt()); v.setPlannedCapacityCbm(d.plannedCapacityCbm()); v.setRemark(trim(d.remark())); }
|
||||||
|
private static SailingAssignmentEntity copyAssignment(SailingAssignmentEntity source) { SailingAssignmentEntity target = new SailingAssignmentEntity(); target.setTenantId(source.getTenantId()); target.setOriginSailingId(source.getOriginSailingId()); target.setInboundId(source.getInboundId()); target.setInboundCode(source.getInboundCode()); target.setReceiptBatchId(source.getReceiptBatchId()); target.setForecastId(source.getForecastId()); target.setVolumeCbm(source.getVolumeCbm()); target.setForecastVolumeCbm(source.getForecastVolumeCbm()); target.setRouteSource(source.getRouteSource()); target.setVolumeSource(source.getVolumeSource()); target.setRuleVersion(source.getRuleVersion()); return target; }
|
||||||
|
private static void validateChannel(ChannelData d) { if (!List.of("CATALOG", "SCHEDULE_ONLY").contains(d.sourceType().trim().toUpperCase())) throw bad("sourceType仅支持CATALOG或SCHEDULE_ONLY"); }
|
||||||
|
private static void validateTemplate(TemplateData d, SailingChannelEntity channel) {
|
||||||
|
if (d.effectiveFrom() == null || d.departureTime() == null || d.cutoffDays() == null || d.cutoffLocalTime() == null) throw bad("模板必须填写生效日期、开船时间和截止规则");
|
||||||
|
if (d.effectiveTo() != null && d.effectiveTo().isBefore(d.effectiveFrom())) throw bad("模板结束日期不能早于开始日期");
|
||||||
|
if (d.weekdays() == null || d.weekdays().isEmpty() || d.weekdays().stream().anyMatch(day -> day == null || day < 1 || day > 7)) throw bad("模板至少选择一个有效星期几");
|
||||||
|
if (!StringUtils.hasText(d.vesselName())) throw bad("模板必须填写船名");
|
||||||
|
if (!StringUtils.hasText(d.departurePortCode()) && !StringUtils.hasText(channel.getDefaultDeparturePortCode())) throw bad("模板必须填写起运港");
|
||||||
|
if (!StringUtils.hasText(d.destinationPortCode()) && !StringUtils.hasText(channel.getDefaultDestinationPortCode())) throw bad("模板必须填写目的港");
|
||||||
|
if (d.cutoffDays() == 0 && !d.cutoffLocalTime().isBefore(d.departureTime())) throw bad("同日收货截止时刻必须早于开船时间");
|
||||||
|
}
|
||||||
|
private static void validateTemplateSnapshot(SailingTemplateEntity t) {
|
||||||
|
if (!StringUtils.hasText(t.getVesselName()) || !StringUtils.hasText(t.getDeparturePortCode()) || !StringUtils.hasText(t.getDestinationPortCode())
|
||||||
|
|| t.getEtdLocalTime() == null || t.getCutoffDays() == null || t.getCutoffLocalTime() == null || t.getValidFrom() == null) throw bad("模板快照字段不完整,不能生成船期");
|
||||||
|
if (t.getCutoffDays() == 0 && !t.getCutoffLocalTime().isBefore(t.getEtdLocalTime())) throw bad("同日收货截止时刻必须早于开船时间");
|
||||||
|
}
|
||||||
|
private static void validateSailing(SailingData d) {
|
||||||
|
if (d.etaAt() != null && d.etaAt().isBefore(d.etdAt())) throw bad("ETA不能早于ETD");
|
||||||
|
if (d.cutoffAt() == null || !d.cutoffAt().isBefore(d.etdAt())) throw bad("收货截止必须早于开船时间");
|
||||||
|
}
|
||||||
|
private SailingChannelEntity resolveChannel(SailingData d) { if (d.channelId() != null) return requiredChannel(d.channelId()); SailingChannelEntity v = repository.channelByCode(d.channelCode().trim().toUpperCase()); if (v == null) throw notFound("渠道不存在"); return v; }
|
||||||
|
private SailingChannelEntity requiredChannel(long id) { SailingChannelEntity v = repository.channel(id); if (v == null) throw notFound("渠道不存在"); return v; }
|
||||||
|
private SailingTemplateEntity requiredTemplate(long id) { SailingTemplateEntity v = repository.template(id); if (v == null) throw notFound("船期模板不存在"); return v; }
|
||||||
|
private SailingEntity requiredSailing(long id) { SailingEntity v = repository.sailing(id); if (v == null) throw notFound("船期不存在"); return v; }
|
||||||
|
private boolean isCopyOf(SailingEntity value, String reason) {
|
||||||
|
return "COPY".equals(value.getSourceType()) && repository.histories(value.getId()).stream()
|
||||||
|
.anyMatch(history -> "COPY".equals(history.getAction()) && Objects.equals(reason, history.getReason()));
|
||||||
|
}
|
||||||
|
private static LocalDateTime copyEta(SailingEntity source, LocalDateTime etdAt) {
|
||||||
|
if (source.getEtdAt() == null || source.getEtaAt() == null) return null;
|
||||||
|
return etdAt.plus(ChronoUnit.MILLIS.between(source.getEtdAt(), source.getEtaAt()), ChronoUnit.MILLIS);
|
||||||
|
}
|
||||||
|
private static SailingDetail throwCopyConflict(LocalDateTime etdAt, String voyageNo) {
|
||||||
|
throw conflict("目标开船时间 " + etdAt + " 已存在航次 " + voyageNo + ",请勿重复复制");
|
||||||
|
}
|
||||||
|
private static LocalDateTime cutoff(SailingTemplateEntity t, LocalDateTime etd) { return etd.toLocalDate().minusDays(t.getCutoffDays()).atTime(t.getCutoffLocalTime()); }
|
||||||
|
private static String voyage(SailingTemplateEntity t, LocalDate date) { return StringUtils.hasText(t.getVoyageRule()) ? t.getVoyageRule().replace("{date}", date.toString().replace("-", "")) : "V" + date.toString().replace("-", ""); }
|
||||||
|
private static String key(SailingTemplateEntity t, LocalDateTime etd, String voyage) { return t.getId() + "|" + etd + "|" + voyage; }
|
||||||
|
private static boolean matchesWeekday(SailingTemplateEntity t, LocalDate date) { int mask = t.getWeekdayMask() == null ? 1 << ((t.getWeekday() == null ? 1 : t.getWeekday()) - 1) : t.getWeekdayMask(); return (mask & (1 << (date.getDayOfWeek().getValue() - 1))) != 0; }
|
||||||
|
private static TemplateView templateViewOf(SailingTemplateEntity v) { int mask = v.getWeekdayMask() == null ? 1 << ((v.getWeekday() == null ? 1 : v.getWeekday()) - 1) : v.getWeekdayMask(); List<Integer> days = java.util.stream.IntStream.rangeClosed(1, 7).filter(day -> (mask & (1 << (day - 1))) != 0).boxed().toList(); return new TemplateView(v.getId(), v.getTemplateNo(), v.getTemplateName(), v.getChannelId(), v.getChannelCodeSnapshot(), v.getChannelNameSnapshot(), v.getWarehouseId(), v.getWarehouseNameSnapshot(), days, v.getValidFrom(), v.getValidTo(), v.getEtdLocalTime(), v.getCutoffDays(), v.getCutoffLocalTime(), v.getVoyageRule(), v.getVesselName(), v.getDeparturePortCode(), v.getDestinationPortCode(), v.getRemark(), v.getStatus(), v.getVersion()); }
|
||||||
|
private static void now(SailingChannelEntity v) { LocalDateTime n = LocalDateTime.now(ZONE); v.setCreatedAt(n); v.setUpdatedAt(n); }
|
||||||
|
private static void now(SailingTemplateEntity v) { LocalDateTime n = LocalDateTime.now(ZONE); v.setCreatedAt(n); v.setUpdatedAt(n); }
|
||||||
|
private static void now(SailingEntity v) { LocalDateTime n = LocalDateTime.now(ZONE); v.setCreatedAt(n); v.setUpdatedAt(n); }
|
||||||
|
private static String normalizeSource(String v) { return StringUtils.hasText(v) ? v.trim().toUpperCase() : "MANUAL"; }
|
||||||
|
private static int version(Integer v) { return v == null ? 0 : v; }
|
||||||
|
private static String normalizeStatus(String value, String label) { String v = value.trim().toUpperCase(); if (!List.of("ACTIVE", "INACTIVE", "DRAFT").contains(v)) throw bad(label + "无效"); return v; }
|
||||||
|
private static String normalizeSailingStatus(String value) { String v = value.trim().toUpperCase(); if (!List.of("DRAFT", "OPEN", "CLOSED", "DEPARTED", "CANCELLED", "COMPLETED").contains(v)) throw bad("船期状态无效"); return v; }
|
||||||
|
private static String trim(String v) { if (v == null) return null; String x = v.trim(); return x.isEmpty() ? null : x; }
|
||||||
|
private static String trimUpper(String v) { String x = trim(v); return x == null ? null : x.toUpperCase(); }
|
||||||
|
private static BizException bad(String message) { return new BizException(ResultCode.BAD_REQUEST, message); }
|
||||||
|
private static BizException conflict(String message) { return new BizException(ResultCode.CONFLICT, message); }
|
||||||
|
private static BizException notFound(String message) { return new BizException(ResultCode.NOT_FOUND, message); }
|
||||||
|
}
|
||||||
+31
-1
@@ -111,7 +111,8 @@ public class MojiaMeasurementOpenApiService {
|
|||||||
throw exception;
|
throw exception;
|
||||||
}
|
}
|
||||||
if (platformBinding.warehouseId() != machineWarehouseId) {
|
if (platformBinding.warehouseId() != machineWarehouseId) {
|
||||||
throw new BizException(ResultCode.FORBIDDEN, "箱号不属于当前设备绑定仓库");
|
return saveUnmatched(app, request, image,
|
||||||
|
new BizException(ResultCode.FORBIDDEN, "箱号不属于当前设备绑定仓库"));
|
||||||
}
|
}
|
||||||
CartonMeasurementResult result;
|
CartonMeasurementResult result;
|
||||||
try {
|
try {
|
||||||
@@ -132,6 +133,7 @@ public class MojiaMeasurementOpenApiService {
|
|||||||
correlation("requestId"), correlation("traceId"), safe(request.boxCode()),
|
correlation("requestId"), correlation("traceId"), safe(request.boxCode()),
|
||||||
safe(request.machineNo()), request.length(), request.width(), request.height(),
|
safe(request.machineNo()), request.length(), request.width(), request.height(),
|
||||||
image.getSize(), result.measurementId(), result.itemId(), result.created());
|
image.getSize(), result.measurementId(), result.itemId(), result.created());
|
||||||
|
saveMatchedReceipt(app, request, result, platformBinding.binding().inboundCode());
|
||||||
return new UploadResponse(true, true, null, result.measurementId(), result.itemId(),
|
return new UploadResponse(true, true, null, result.measurementId(), result.itemId(),
|
||||||
result.boxCode(), result.imageFileId(), result.created());
|
result.boxCode(), result.imageFileId(), result.created());
|
||||||
} catch (RuntimeException exception) {
|
} catch (RuntimeException exception) {
|
||||||
@@ -146,6 +148,33 @@ public class MojiaMeasurementOpenApiService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 每次箱码上传都保留一条设备收件流水,正式测量成功后补上权威入仓码。 */
|
||||||
|
private void saveMatchedReceipt(AuthenticatedMeasurementApp app, UploadRequest request,
|
||||||
|
CartonMeasurementResult result, String inboundCode) {
|
||||||
|
MojiaMeasurementReceiptEntity receipt = new MojiaMeasurementReceiptEntity();
|
||||||
|
receipt.setId(IdWorker.getId());
|
||||||
|
receipt.setTenantId(app.tenantId());
|
||||||
|
receipt.setSubjectId(app.subjectId());
|
||||||
|
receipt.setBoxCode(request.boxCode().trim());
|
||||||
|
receipt.setInboundCode(inboundCode);
|
||||||
|
receipt.setMeasurementKind("BOX_LABEL");
|
||||||
|
receipt.setScannedShipmentId(optionalTrim(request.shipmentId()));
|
||||||
|
receipt.setLengthCm(request.length());
|
||||||
|
receipt.setWidthCm(request.width());
|
||||||
|
receipt.setHeightCm(request.height());
|
||||||
|
receipt.setWeightKg(request.weightKg());
|
||||||
|
receipt.setMachineNo(request.machineNo().trim());
|
||||||
|
receipt.setMeasuredAt(request.measuredAt() == null ? LocalDateTime.now() : request.measuredAt());
|
||||||
|
receipt.setImageFileId(result.imageFileId());
|
||||||
|
receipt.setStatus("MATCHED");
|
||||||
|
receipt.setRevision(1);
|
||||||
|
receipt.setRequestId(correlation("requestId"));
|
||||||
|
receipt.setTraceId(correlation("traceId"));
|
||||||
|
receipt.setCreatedAt(LocalDateTime.now());
|
||||||
|
receipt.setUpdatedAt(LocalDateTime.now());
|
||||||
|
receipts.insert(receipt);
|
||||||
|
}
|
||||||
|
|
||||||
private UploadResponse saveSkuPending(AuthenticatedMeasurementApp app, UploadRequest request,
|
private UploadResponse saveSkuPending(AuthenticatedMeasurementApp app, UploadRequest request,
|
||||||
MultipartFile image) {
|
MultipartFile image) {
|
||||||
long receiptId = IdWorker.getId();
|
long receiptId = IdWorker.getId();
|
||||||
@@ -203,6 +232,7 @@ public class MojiaMeasurementOpenApiService {
|
|||||||
receipt.setTenantId(app.tenantId());
|
receipt.setTenantId(app.tenantId());
|
||||||
receipt.setSubjectId(app.subjectId());
|
receipt.setSubjectId(app.subjectId());
|
||||||
receipt.setBoxCode(request.boxCode().trim());
|
receipt.setBoxCode(request.boxCode().trim());
|
||||||
|
receipt.setMeasurementKind("BOX_LABEL");
|
||||||
receipt.setScannedShipmentId(optionalTrim(request.shipmentId()));
|
receipt.setScannedShipmentId(optionalTrim(request.shipmentId()));
|
||||||
receipt.setLengthCm(request.length());
|
receipt.setLengthCm(request.length());
|
||||||
receipt.setWidthCm(request.width());
|
receipt.setWidthCm(request.width());
|
||||||
|
|||||||
@@ -42,7 +42,7 @@ public class MojiaSkuSyncService {
|
|||||||
|
|
||||||
public PageResult<MojiaSkuSyncDtos.Record> page(MojiaSkuSyncDtos.Query query) {
|
public PageResult<MojiaSkuSyncDtos.Record> page(MojiaSkuSyncDtos.Query query) {
|
||||||
PageResult<MojiaMeasurementReceiptEntity> source = repository.page(
|
PageResult<MojiaMeasurementReceiptEntity> source = repository.page(
|
||||||
query.skuCode(), query.syncStatus(), query.page(), query.size());
|
query.code(), query.measurementKind(), query.syncStatus(), query.page(), query.size());
|
||||||
return new PageResult<>(source.records().stream().map(MojiaSkuSyncService::view).toList(),
|
return new PageResult<>(source.records().stream().map(MojiaSkuSyncService::view).toList(),
|
||||||
source.page(), source.size(), source.total(), source.pages());
|
source.page(), source.size(), source.total(), source.pages());
|
||||||
}
|
}
|
||||||
@@ -104,7 +104,9 @@ public class MojiaSkuSyncService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private static MojiaSkuSyncDtos.Record view(MojiaMeasurementReceiptEntity value) {
|
private static MojiaSkuSyncDtos.Record view(MojiaMeasurementReceiptEntity value) {
|
||||||
return new MojiaSkuSyncDtos.Record(value.getId(), value.getBoxCode(), value.getLengthCm(),
|
return new MojiaSkuSyncDtos.Record(value.getId(), value.getMeasurementKind(), value.getBoxCode(),
|
||||||
|
value.getInboundCode(), "SKU".equals(value.getMeasurementKind()) ? value.getBoxCode() : null,
|
||||||
|
value.getLengthCm(),
|
||||||
value.getWidthCm(), value.getHeightCm(), value.getWeightKg(), value.getImageFileId(),
|
value.getWidthCm(), value.getHeightCm(), value.getWeightKg(), value.getImageFileId(),
|
||||||
value.getMeasuredAt(), value.getCreatedAt(), value.getSyncStatus(), value.getSyncFailureCode(),
|
value.getMeasuredAt(), value.getCreatedAt(), value.getSyncStatus(), value.getSyncFailureCode(),
|
||||||
value.getSyncFailureReason(), value.getLingxingSkuId(), value.getRevision(), value.getUpdatedAt());
|
value.getSyncFailureReason(), value.getLingxingSkuId(), value.getRevision(), value.getUpdatedAt());
|
||||||
|
|||||||
@@ -329,6 +329,7 @@ CREATE TABLE IF NOT EXISTS fl_mojia_measurement_receipt (
|
|||||||
tenant_id BIGINT UNSIGNED NOT NULL COMMENT '客户ID和数据隔离键',
|
tenant_id BIGINT UNSIGNED NOT NULL COMMENT '客户ID和数据隔离键',
|
||||||
subject_id BIGINT UNSIGNED NOT NULL COMMENT '设备应用操作主体ID',
|
subject_id BIGINT UNSIGNED NOT NULL COMMENT '设备应用操作主体ID',
|
||||||
box_code VARCHAR(128) NOT NULL COMMENT '设备扫描的原始箱码',
|
box_code VARCHAR(128) NOT NULL COMMENT '设备扫描的原始箱码',
|
||||||
|
inbound_code VARCHAR(64) NULL COMMENT '箱码命中后的权威入仓码;SKU记录为空',
|
||||||
measurement_kind VARCHAR(16) NOT NULL DEFAULT 'BOX_LABEL' COMMENT '测量分流:BOX_LABEL箱唛;SKU一件代发SKU',
|
measurement_kind VARCHAR(16) NOT NULL DEFAULT 'BOX_LABEL' COMMENT '测量分流:BOX_LABEL箱唛;SKU一件代发SKU',
|
||||||
scanned_shipment_id VARCHAR(64) NULL COMMENT '设备从Amazon FBA箱标实扫的Shipment ID',
|
scanned_shipment_id VARCHAR(64) NULL COMMENT '设备从Amazon FBA箱标实扫的Shipment ID',
|
||||||
length_cm DECIMAL(12,3) NOT NULL COMMENT '设备实测长度cm',
|
length_cm DECIMAL(12,3) NOT NULL COMMENT '设备实测长度cm',
|
||||||
|
|||||||
@@ -12,6 +12,9 @@ CREATE TABLE IF NOT EXISTS fl_customs_container (
|
|||||||
completed_by BIGINT UNSIGNED NULL COMMENT '完成报关操作人ID',
|
completed_by BIGINT UNSIGNED NULL COMMENT '完成报关操作人ID',
|
||||||
completed_by_name VARCHAR(64) NULL COMMENT '完成报关操作人名称快照',
|
completed_by_name VARCHAR(64) NULL COMMENT '完成报关操作人名称快照',
|
||||||
completed_at DATETIME(3) NULL COMMENT '报关完成时间',
|
completed_at DATETIME(3) NULL COMMENT '报关完成时间',
|
||||||
|
broker_submitted_by BIGINT UNSIGNED NULL COMMENT '已将客户确认结果发送给报关行的操作人ID',
|
||||||
|
broker_submitted_by_name VARCHAR(64) NULL COMMENT '发送报关行操作人名称快照',
|
||||||
|
broker_submitted_at DATETIME(3) NULL COMMENT '发送报关行执行时间',
|
||||||
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '聚合创建时间',
|
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '聚合创建时间',
|
||||||
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '聚合更新时间',
|
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '聚合更新时间',
|
||||||
version INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '乐观锁版本号',
|
version INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '乐观锁版本号',
|
||||||
@@ -93,10 +96,10 @@ CREATE TABLE IF NOT EXISTS fl_customs_frozen_document_inbound (
|
|||||||
) ENGINE=InnoDB COMMENT='开始报关时冻结的客户资料覆盖范围';
|
) ENGINE=InnoDB COMMENT='开始报关时冻结的客户资料覆盖范围';
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS fl_customs_completion_document (
|
CREATE TABLE IF NOT EXISTS fl_customs_completion_document (
|
||||||
id BIGINT UNSIGNED NOT NULL COMMENT '报关完成文件ID',
|
id BIGINT UNSIGNED NOT NULL COMMENT '报关行回执文件ID',
|
||||||
source_document_id BIGINT UNSIGNED NOT NULL COMMENT '一份冻结客户资料严格对应一份完成文件逻辑根',
|
source_document_id BIGINT UNSIGNED NOT NULL COMMENT '一份冻结客户资料严格对应一份完成文件逻辑根',
|
||||||
file_id BIGINT UNSIGNED NOT NULL COMMENT 'File服务文件ID',
|
file_id BIGINT UNSIGNED NOT NULL COMMENT 'File服务文件ID',
|
||||||
file_name VARCHAR(255) NOT NULL COMMENT '完成文件名称快照',
|
file_name VARCHAR(255) NOT NULL COMMENT '回执文件名称快照',
|
||||||
file_hash CHAR(64) NOT NULL COMMENT '完成文件SHA-256摘要',
|
file_hash CHAR(64) NOT NULL COMMENT '完成文件SHA-256摘要',
|
||||||
content_type VARCHAR(128) NULL COMMENT '完成文件媒体类型',
|
content_type VARCHAR(128) NULL COMMENT '完成文件媒体类型',
|
||||||
size_bytes BIGINT UNSIGNED NOT NULL COMMENT '完成文件大小(字节)',
|
size_bytes BIGINT UNSIGNED NOT NULL COMMENT '完成文件大小(字节)',
|
||||||
@@ -114,7 +117,7 @@ CREATE TABLE IF NOT EXISTS fl_customs_completion_document (
|
|||||||
PRIMARY KEY(id),
|
PRIMARY KEY(id),
|
||||||
UNIQUE KEY uk_customs_completion_source(source_document_id),
|
UNIQUE KEY uk_customs_completion_source(source_document_id),
|
||||||
CONSTRAINT fk_customs_completion_source FOREIGN KEY(source_document_id) REFERENCES fl_customs_customer_document(id)
|
CONSTRAINT fk_customs_completion_source FOREIGN KEY(source_document_id) REFERENCES fl_customs_customer_document(id)
|
||||||
) ENGINE=InnoDB COMMENT='报关完成文件当前版本';
|
) ENGINE=InnoDB COMMENT='报关行回执当前版本';
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS fl_customs_completion_document_version (
|
CREATE TABLE IF NOT EXISTS fl_customs_completion_document_version (
|
||||||
id BIGINT UNSIGNED NOT NULL COMMENT '完成文件版本记录ID',
|
id BIGINT UNSIGNED NOT NULL COMMENT '完成文件版本记录ID',
|
||||||
@@ -133,6 +136,25 @@ CREATE TABLE IF NOT EXISTS fl_customs_completion_document_version (
|
|||||||
CONSTRAINT fk_customs_completion_version FOREIGN KEY(completion_document_id) REFERENCES fl_customs_completion_document(id)
|
CONSTRAINT fk_customs_completion_version FOREIGN KEY(completion_document_id) REFERENCES fl_customs_completion_document(id)
|
||||||
) ENGINE=InnoDB COMMENT='报关完成文件不可变覆盖版本';
|
) ENGINE=InnoDB COMMENT='报关完成文件不可变覆盖版本';
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS fl_customs_final_document (
|
||||||
|
id BIGINT UNSIGNED NOT NULL COMMENT '真实报关完税文件ID',
|
||||||
|
source_document_id BIGINT UNSIGNED NOT NULL COMMENT '对应冻结客户资料ID',
|
||||||
|
file_id BIGINT UNSIGNED NOT NULL COMMENT 'File服务文件ID',
|
||||||
|
file_name VARCHAR(255) NOT NULL COMMENT '真实报关完税文件名称快照',
|
||||||
|
file_hash CHAR(64) NOT NULL COMMENT '真实报关完税文件SHA-256摘要',
|
||||||
|
content_type VARCHAR(128) NULL COMMENT '真实报关完税文件媒体类型',
|
||||||
|
size_bytes BIGINT UNSIGNED NOT NULL COMMENT '真实报关完税文件大小(字节)',
|
||||||
|
uploaded_by BIGINT UNSIGNED NOT NULL COMMENT '上传操作人ID',
|
||||||
|
uploaded_by_name VARCHAR(64) NULL COMMENT '上传操作人名称快照',
|
||||||
|
uploaded_at DATETIME(3) NOT NULL COMMENT '上传时间',
|
||||||
|
version INT UNSIGNED NOT NULL DEFAULT 1 COMMENT '完税文件版本号',
|
||||||
|
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间',
|
||||||
|
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间',
|
||||||
|
PRIMARY KEY(id),
|
||||||
|
UNIQUE KEY uk_customs_final_source(source_document_id),
|
||||||
|
CONSTRAINT fk_customs_final_source FOREIGN KEY(source_document_id) REFERENCES fl_customs_customer_document(id)
|
||||||
|
) ENGINE=InnoDB COMMENT='真实报关完税文件当前版本';
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS fl_customs_container_history (
|
CREATE TABLE IF NOT EXISTS fl_customs_container_history (
|
||||||
id BIGINT UNSIGNED NOT NULL COMMENT '报关历史记录ID',
|
id BIGINT UNSIGNED NOT NULL COMMENT '报关历史记录ID',
|
||||||
customs_container_id BIGINT UNSIGNED NOT NULL COMMENT '所属整柜报关聚合ID',
|
customs_container_id BIGINT UNSIGNED NOT NULL COMMENT '所属整柜报关聚合ID',
|
||||||
@@ -163,6 +185,56 @@ CREATE TABLE IF NOT EXISTS fl_customs_command (
|
|||||||
CONSTRAINT chk_customs_command_status CHECK(status IN('PROCESSING','SUCCESS'))
|
CONSTRAINT chk_customs_command_status CHECK(status IN('PROCESSING','SUCCESS'))
|
||||||
) ENGINE=InnoDB COMMENT='整柜报关写命令幂等事实';
|
) ENGINE=InnoDB COMMENT='整柜报关写命令幂等事实';
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS fl_customs_declaration_batch (
|
||||||
|
id BIGINT UNSIGNED NOT NULL COMMENT '正式申报批次ID',
|
||||||
|
customs_container_id BIGINT UNSIGNED NOT NULL COMMENT '所属整柜报关聚合ID',
|
||||||
|
loading_plan_id BIGINT UNSIGNED NOT NULL COMMENT '装柜计划ID快照',
|
||||||
|
batch_no VARCHAR(64) NULL COMMENT '平台正式申报批次号',
|
||||||
|
status VARCHAR(32) NOT NULL COMMENT '批次状态:READY/SUBMITTED/ACKNOWLEDGED/RESULT_PENDING/CLOSED/CANCELLED',
|
||||||
|
source_version INT UNSIGNED NOT NULL COMMENT '创建时整柜报关聚合版本',
|
||||||
|
source_snapshot_json JSON NOT NULL COMMENT '提交时资料、回执和确认版本快照',
|
||||||
|
customs_entry_no VARCHAR(64) NULL COMMENT '报关单号',
|
||||||
|
submitted_by BIGINT UNSIGNED NULL COMMENT '正式申报提交人',
|
||||||
|
submitted_by_name VARCHAR(64) NULL COMMENT '正式申报提交人名称快照',
|
||||||
|
submitted_at DATETIME(3) NULL COMMENT '正式申报提交时间',
|
||||||
|
acknowledged_by BIGINT UNSIGNED NULL COMMENT '报关行确认受理人',
|
||||||
|
acknowledged_by_name VARCHAR(64) NULL COMMENT '报关行确认受理人名称快照',
|
||||||
|
acknowledged_at DATETIME(3) NULL COMMENT '报关行确认受理时间',
|
||||||
|
remark VARCHAR(500) NULL COMMENT '批次说明',
|
||||||
|
version INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '乐观锁版本号',
|
||||||
|
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '批次创建时间',
|
||||||
|
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '批次更新时间',
|
||||||
|
PRIMARY KEY(id),
|
||||||
|
UNIQUE KEY uk_customs_declaration_batch_container(customs_container_id, id),
|
||||||
|
KEY idx_customs_declaration_batch_plan(loading_plan_id,status,created_at),
|
||||||
|
CONSTRAINT fk_customs_declaration_batch_container FOREIGN KEY(customs_container_id) REFERENCES fl_customs_container(id),
|
||||||
|
CONSTRAINT chk_customs_declaration_batch_status CHECK(status IN ('READY','SUBMITTED','ACKNOWLEDGED','RESULT_PENDING','CLOSED','CANCELLED'))
|
||||||
|
) ENGINE=InnoDB COMMENT='整柜正式申报批次事实';
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS fl_customs_clearance_result (
|
||||||
|
id BIGINT UNSIGNED NOT NULL COMMENT '海关结果事实ID',
|
||||||
|
declaration_batch_id BIGINT UNSIGNED NOT NULL COMMENT '所属正式申报批次ID',
|
||||||
|
customs_container_id BIGINT UNSIGNED NOT NULL COMMENT '所属整柜报关聚合ID',
|
||||||
|
result_type VARCHAR(32) NOT NULL COMMENT '结果类型:INSPECTION/SUPPLEMENT_REQUIRED/RELEASED/DETAINED/OTHER',
|
||||||
|
result_status VARCHAR(16) NOT NULL DEFAULT 'OPEN' COMMENT '结果状态:OPEN/RESOLVED/CANCELLED',
|
||||||
|
customs_entry_no VARCHAR(64) NULL COMMENT '报关单号快照',
|
||||||
|
occurred_at DATETIME(3) NOT NULL COMMENT '海关事实发生时间',
|
||||||
|
resolution VARCHAR(1000) NULL COMMENT '处理结果或异常说明',
|
||||||
|
evidence_file_id BIGINT UNSIGNED NULL COMMENT '结果凭证File文件ID',
|
||||||
|
recorded_by BIGINT UNSIGNED NOT NULL COMMENT '平台录入人',
|
||||||
|
recorded_by_name VARCHAR(64) NULL COMMENT '平台录入人名称快照',
|
||||||
|
recorded_at DATETIME(3) NOT NULL COMMENT '平台录入时间',
|
||||||
|
previous_result_id BIGINT UNSIGNED NULL COMMENT '前序结果事实ID',
|
||||||
|
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '结果创建时间',
|
||||||
|
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '结果更新时间',
|
||||||
|
PRIMARY KEY(id),
|
||||||
|
KEY idx_customs_clearance_result_batch(declaration_batch_id,occurred_at,id),
|
||||||
|
KEY idx_customs_clearance_result_container(customs_container_id, result_type, result_status),
|
||||||
|
CONSTRAINT fk_customs_clearance_result_batch FOREIGN KEY(declaration_batch_id) REFERENCES fl_customs_declaration_batch(id),
|
||||||
|
CONSTRAINT chk_customs_clearance_result_type CHECK(result_type IN ('INSPECTION','SUPPLEMENT_REQUIRED','RELEASED','DETAINED','OTHER')),
|
||||||
|
CONSTRAINT chk_customs_clearance_result_status CHECK(result_status IN ('OPEN','RESOLVED','CANCELLED'))
|
||||||
|
) ENGINE=InnoDB COMMENT='正式申报后的海关结果不可变事实';
|
||||||
|
|
||||||
USE baiyue_iam;
|
USE baiyue_iam;
|
||||||
DELETE rp FROM iam_role_permission rp WHERE rp.permission_id IN(5102171,5102173,5102174,5102175);
|
DELETE rp FROM iam_role_permission rp WHERE rp.permission_id IN(5102171,5102173,5102174,5102175);
|
||||||
DELETE FROM iam_permission WHERE id IN(5102171,5102173,5102174,5102175)
|
DELETE FROM iam_permission WHERE id IN(5102171,5102173,5102174,5102175)
|
||||||
|
|||||||
@@ -0,0 +1,143 @@
|
|||||||
|
SET NAMES utf8mb4;
|
||||||
|
USE baiyue_firstleg;
|
||||||
|
|
||||||
|
-- 船期计划日历首版基线。与 fl_port_schedule(港务船期观测)分离。
|
||||||
|
CREATE TABLE fl_sailing_channel (
|
||||||
|
id BIGINT NOT NULL,
|
||||||
|
channel_code VARCHAR(64) NOT NULL,
|
||||||
|
channel_name VARCHAR(128) NOT NULL,
|
||||||
|
source_type VARCHAR(24) NOT NULL,
|
||||||
|
mapped_channel_code VARCHAR(64) NULL,
|
||||||
|
default_departure_port_code VARCHAR(32) NULL,
|
||||||
|
default_destination_port_code VARCHAR(32) NULL,
|
||||||
|
default_cutoff_days TINYINT NOT NULL DEFAULT 3,
|
||||||
|
status VARCHAR(16) NOT NULL DEFAULT 'ACTIVE',
|
||||||
|
remark VARCHAR(1000) NULL,
|
||||||
|
created_by BIGINT NOT NULL,
|
||||||
|
created_by_name VARCHAR(128) NULL,
|
||||||
|
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||||
|
updated_by BIGINT NOT NULL,
|
||||||
|
updated_by_name VARCHAR(128) NULL,
|
||||||
|
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
|
||||||
|
version INT NOT NULL DEFAULT 0,
|
||||||
|
PRIMARY KEY (id),
|
||||||
|
UNIQUE KEY uk_fl_sailing_channel_code (channel_code),
|
||||||
|
KEY idx_fl_sailing_channel_status (status)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
CREATE TABLE fl_sailing_template (
|
||||||
|
id BIGINT NOT NULL,
|
||||||
|
template_no VARCHAR(64) NOT NULL,
|
||||||
|
template_name VARCHAR(128) NOT NULL,
|
||||||
|
channel_id BIGINT NOT NULL,
|
||||||
|
channel_code_snapshot VARCHAR(64) NOT NULL,
|
||||||
|
channel_name_snapshot VARCHAR(128) NOT NULL,
|
||||||
|
warehouse_id BIGINT NOT NULL,
|
||||||
|
warehouse_name_snapshot VARCHAR(128) NULL,
|
||||||
|
departure_port_code VARCHAR(32) NOT NULL,
|
||||||
|
destination_port_code VARCHAR(32) NOT NULL,
|
||||||
|
weekday TINYINT NOT NULL,
|
||||||
|
weekday_mask TINYINT NOT NULL DEFAULT 1,
|
||||||
|
etd_local_time TIME NOT NULL,
|
||||||
|
cutoff_days TINYINT NOT NULL DEFAULT 3,
|
||||||
|
cutoff_local_time TIME NOT NULL DEFAULT '18:00:00',
|
||||||
|
valid_from DATE NOT NULL,
|
||||||
|
valid_to DATE NULL,
|
||||||
|
voyage_rule VARCHAR(64) NULL,
|
||||||
|
vessel_name VARCHAR(128) NULL,
|
||||||
|
status VARCHAR(16) NOT NULL DEFAULT 'ACTIVE',
|
||||||
|
remark VARCHAR(1000) NULL,
|
||||||
|
created_by BIGINT NOT NULL,
|
||||||
|
created_by_name VARCHAR(128) NULL,
|
||||||
|
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||||
|
updated_by BIGINT NOT NULL,
|
||||||
|
updated_by_name VARCHAR(128) NULL,
|
||||||
|
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
|
||||||
|
version INT NOT NULL DEFAULT 0,
|
||||||
|
PRIMARY KEY (id),
|
||||||
|
UNIQUE KEY uk_fl_sailing_template_no (template_no),
|
||||||
|
KEY idx_fl_sailing_template_filter (warehouse_id,channel_id,status,weekday),
|
||||||
|
CONSTRAINT ck_fl_sailing_template_weekday CHECK (weekday BETWEEN 1 AND 7),
|
||||||
|
CONSTRAINT ck_fl_sailing_template_cutoff_days CHECK (cutoff_days BETWEEN 0 AND 30)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
CREATE TABLE fl_sailing (
|
||||||
|
id BIGINT NOT NULL,
|
||||||
|
sailing_no VARCHAR(64) NOT NULL,
|
||||||
|
template_id BIGINT NULL,
|
||||||
|
channel_id BIGINT NOT NULL,
|
||||||
|
channel_code_snapshot VARCHAR(64) NOT NULL,
|
||||||
|
channel_name_snapshot VARCHAR(128) NOT NULL,
|
||||||
|
warehouse_id BIGINT NOT NULL,
|
||||||
|
warehouse_name_snapshot VARCHAR(128) NULL,
|
||||||
|
departure_port_code VARCHAR(32) NOT NULL,
|
||||||
|
destination_port_code VARCHAR(32) NOT NULL,
|
||||||
|
vessel_name VARCHAR(128) NOT NULL,
|
||||||
|
voyage_no VARCHAR(64) NOT NULL,
|
||||||
|
etd_at DATETIME(3) NOT NULL,
|
||||||
|
warehouse_cutoff_at DATETIME(3) NOT NULL,
|
||||||
|
eta_at DATETIME(3) NULL,
|
||||||
|
status VARCHAR(16) NOT NULL DEFAULT 'OPEN',
|
||||||
|
source_type VARCHAR(24) NOT NULL DEFAULT 'MANUAL',
|
||||||
|
generated_key VARCHAR(192) NULL,
|
||||||
|
planned_capacity_cbm DECIMAL(14,3) NULL,
|
||||||
|
remark VARCHAR(1000) NULL,
|
||||||
|
created_by BIGINT NOT NULL,
|
||||||
|
created_by_name VARCHAR(128) NULL,
|
||||||
|
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||||
|
updated_by BIGINT NOT NULL,
|
||||||
|
updated_by_name VARCHAR(128) NULL,
|
||||||
|
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
|
||||||
|
version INT NOT NULL DEFAULT 0,
|
||||||
|
PRIMARY KEY (id),
|
||||||
|
UNIQUE KEY uk_fl_sailing_no (sailing_no),
|
||||||
|
UNIQUE KEY uk_fl_sailing_generated_key (generated_key),
|
||||||
|
UNIQUE KEY uk_fl_sailing_business (warehouse_id,channel_code_snapshot,departure_port_code,destination_port_code,etd_at,voyage_no),
|
||||||
|
KEY idx_fl_sailing_calendar (etd_at,warehouse_id,channel_id,status),
|
||||||
|
KEY idx_fl_sailing_route (departure_port_code,destination_port_code),
|
||||||
|
CONSTRAINT ck_fl_sailing_status CHECK (status IN ('DRAFT','OPEN','CLOSED','DEPARTED','CANCELLED','COMPLETED'))
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
CREATE TABLE fl_sailing_history (
|
||||||
|
id BIGINT NOT NULL,
|
||||||
|
sailing_id BIGINT NOT NULL,
|
||||||
|
action VARCHAR(32) NOT NULL,
|
||||||
|
from_status VARCHAR(16) NULL,
|
||||||
|
to_status VARCHAR(16) NULL,
|
||||||
|
reason VARCHAR(1000) NULL,
|
||||||
|
operator_id BIGINT NULL,
|
||||||
|
operator_name VARCHAR(128) NULL,
|
||||||
|
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||||
|
PRIMARY KEY (id),
|
||||||
|
KEY idx_fl_sailing_history_sailing (sailing_id,created_at,id)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
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
|
||||||
|
(5102206,0,'PLATFORM','MENU',5102000,'platform:firstleg:sailing-calendar:list','船期日历','/sailing-calendar','GET',125,'ACTIVE',1,0),
|
||||||
|
(5102207,0,'PLATFORM','BUTTON',5102206,'platform:firstleg:sailing-calendar:create','新增船期','/sailing-calendar','POST',10,'ACTIVE',1,0),
|
||||||
|
(5102208,0,'PLATFORM','BUTTON',5102206,'platform:firstleg:sailing-calendar:edit','编辑船期','/sailing-calendar','PUT',20,'ACTIVE',1,0),
|
||||||
|
(5102209,0,'PLATFORM','BUTTON',5102206,'platform:firstleg:sailing-template:manage','管理渠道与周模板','/sailing-calendar','POST',30,'ACTIVE',1,0),
|
||||||
|
(5102210,0,'PLATFORM','BUTTON',5102206,'platform:firstleg:sailing-booking:link','关联订舱','/sailing-calendar','POST',40,'ACTIVE',1,0),
|
||||||
|
(5102211,0,'PLATFORM','BUTTON',5102206,'platform:firstleg:sailing-volume:forecast-manage','维护货量预报','/sailing-calendar','POST',50,'ACTIVE',1,0),
|
||||||
|
(5102212,0,'PLATFORM','BUTTON',5102206,'platform:firstleg:sailing-volume:exception-reassign','异常重新安排','/sailing-calendar','POST',60,'ACTIVE',1,0)
|
||||||
|
ON DUPLICATE KEY UPDATE
|
||||||
|
parent_id=VALUES(parent_id), permission_code=VALUES(permission_code), permission_name=VALUES(permission_name),
|
||||||
|
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 r.id,p.id,0 FROM iam_role r JOIN iam_permission p
|
||||||
|
ON p.tenant_id=0 AND p.app_code='PLATFORM'
|
||||||
|
AND p.permission_code IN (
|
||||||
|
'platform:firstleg:sailing-calendar:list',
|
||||||
|
'platform:firstleg:sailing-calendar:create',
|
||||||
|
'platform:firstleg:sailing-calendar:edit',
|
||||||
|
'platform:firstleg:sailing-template:manage',
|
||||||
|
'platform:firstleg:sailing-booking:link',
|
||||||
|
'platform:firstleg:sailing-volume:forecast-manage',
|
||||||
|
'platform:firstleg:sailing-volume:exception-reassign'
|
||||||
|
)
|
||||||
|
WHERE r.tenant_id=0 AND r.role_code IN ('PLATFORM_ADMIN','ORDER_OPERATOR');
|
||||||
@@ -0,0 +1,126 @@
|
|||||||
|
SET NAMES utf8mb4;
|
||||||
|
USE baiyue_firstleg;
|
||||||
|
|
||||||
|
/*
|
||||||
|
* 船期日历的事实契约。
|
||||||
|
*
|
||||||
|
* 这些表不修改入库单或海运订舱聚合:入库单仍是履约主数据,订舱仍是
|
||||||
|
* RELEASED/DRAFT 聚合。船期日历只消费这里的稳定事实和审计版本。
|
||||||
|
*/
|
||||||
|
|
||||||
|
CREATE TABLE fl_sailing_receipt_batch_fact (
|
||||||
|
id BIGINT UNSIGNED NOT NULL COMMENT '稳定收货批次事实ID(receiptBatchId)',
|
||||||
|
tenant_id BIGINT UNSIGNED NOT NULL COMMENT '客户隔离键,继承入库事实',
|
||||||
|
receipt_batch_key VARCHAR(64) NOT NULL COMMENT '同一物理收货批次的不可变业务键',
|
||||||
|
fact_version INT UNSIGNED NOT NULL DEFAULT 1 COMMENT '同一批次复测/冲正版本',
|
||||||
|
inbound_id BIGINT UNSIGNED NOT NULL COMMENT '入库单ID',
|
||||||
|
measurement_id BIGINT UNSIGNED NULL COMMENT '最终审核测量批次ID',
|
||||||
|
receiving_warehouse_id BIGINT UNSIGNED NOT NULL COMMENT '实际收货仓ID',
|
||||||
|
channel_code VARCHAR(64) NULL COMMENT '收货渠道编码快照',
|
||||||
|
channel_name VARCHAR(128) NULL COMMENT '收货渠道名称快照',
|
||||||
|
departure_port_code VARCHAR(32) NULL COMMENT '起运港编码快照',
|
||||||
|
destination_port_code VARCHAR(32) NULL COMMENT '目的港编码快照',
|
||||||
|
received_at DATETIME(3) NOT NULL COMMENT '实际收货完成时间,不使用入库单inbounded_at替代',
|
||||||
|
actual_volume_cbm DECIMAL(18,4) NOT NULL COMMENT '最终审核实测体积m3',
|
||||||
|
fact_status VARCHAR(24) NOT NULL DEFAULT 'APPROVED' COMMENT '事实状态:APPROVED/REVERSED/VOIDED/SUPERSEDED',
|
||||||
|
reversal_of_fact_id BIGINT UNSIGNED NULL COMMENT '冲正所替代的事实ID',
|
||||||
|
source_type VARCHAR(32) NOT NULL DEFAULT 'FIRSTLEG_RECEIPT' COMMENT '事实来源',
|
||||||
|
created_by BIGINT UNSIGNED NULL COMMENT '写入人或系统主体',
|
||||||
|
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_sailing_receipt_batch_version (tenant_id, receipt_batch_key, fact_version),
|
||||||
|
KEY idx_sailing_receipt_batch_current (tenant_id, receipt_batch_key, fact_status, fact_version),
|
||||||
|
KEY idx_sailing_receipt_match (tenant_id, receiving_warehouse_id, channel_code, received_at, id),
|
||||||
|
KEY idx_sailing_receipt_inbound (tenant_id, inbound_id, received_at, id),
|
||||||
|
CONSTRAINT chk_sailing_receipt_volume CHECK (actual_volume_cbm > 0),
|
||||||
|
CONSTRAINT chk_sailing_receipt_status CHECK (fact_status IN ('APPROVED','REVERSED','VOIDED','SUPERSEDED'))
|
||||||
|
) ENGINE=InnoDB COMMENT='船期日历稳定收货批次实测事实及版本历史';
|
||||||
|
|
||||||
|
CREATE TABLE fl_sailing_inbound_volume_forecast (
|
||||||
|
id BIGINT UNSIGNED NOT NULL COMMENT '货量预报事实ID(forecastId)',
|
||||||
|
tenant_id BIGINT UNSIGNED NOT NULL COMMENT '客户隔离键,继承入库事实',
|
||||||
|
forecast_key VARCHAR(64) NOT NULL COMMENT '同一未收货对象的不可变业务键',
|
||||||
|
fact_version INT UNSIGNED NOT NULL DEFAULT 1 COMMENT '预报修订版本',
|
||||||
|
inbound_id BIGINT UNSIGNED NOT NULL COMMENT '入库单ID',
|
||||||
|
receiving_warehouse_id BIGINT UNSIGNED NOT NULL COMMENT '预计收货仓ID',
|
||||||
|
channel_code VARCHAR(64) NULL COMMENT '预计渠道编码快照',
|
||||||
|
channel_name VARCHAR(128) NULL COMMENT '预计渠道名称快照',
|
||||||
|
departure_port_code VARCHAR(32) NULL COMMENT '预计起运港编码快照',
|
||||||
|
destination_port_code VARCHAR(32) NULL COMMENT '预计目的港编码快照',
|
||||||
|
expected_arrival_at DATETIME(3) NOT NULL COMMENT '预计到仓时间',
|
||||||
|
forecast_volume_cbm DECIMAL(18,4) NOT NULL COMMENT '未收货预计体积m3,不得使用申报体积代替',
|
||||||
|
source_type VARCHAR(32) NOT NULL COMMENT '预报来源,例如OPERATIONS/UPSTREAM_MEASUREMENT;不得使用客户申报体积冒充预报',
|
||||||
|
fact_status VARCHAR(24) NOT NULL DEFAULT 'VALID' COMMENT '事实状态:VALID/SUPERSEDED/VOIDED/CONSUMED',
|
||||||
|
effective_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '本版本生效时间',
|
||||||
|
created_by BIGINT UNSIGNED NULL COMMENT '写入人或系统主体',
|
||||||
|
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_sailing_forecast_version (tenant_id, forecast_key, fact_version),
|
||||||
|
KEY idx_sailing_forecast_current (tenant_id, forecast_key, fact_status, fact_version),
|
||||||
|
KEY idx_sailing_forecast_match (tenant_id, receiving_warehouse_id, channel_code, expected_arrival_at, id),
|
||||||
|
KEY idx_sailing_forecast_inbound (tenant_id, inbound_id, expected_arrival_at, id),
|
||||||
|
CONSTRAINT chk_sailing_forecast_volume CHECK (forecast_volume_cbm > 0),
|
||||||
|
CONSTRAINT chk_sailing_forecast_status CHECK (fact_status IN ('VALID','SUPERSEDED','VOIDED','CONSUMED'))
|
||||||
|
) ENGINE=InnoDB COMMENT='船期日历未收货量预报事实及版本历史';
|
||||||
|
|
||||||
|
CREATE TABLE fl_sailing_assignment (
|
||||||
|
id BIGINT UNSIGNED NOT NULL COMMENT '归班事实ID',
|
||||||
|
tenant_id BIGINT UNSIGNED NOT NULL COMMENT '客户隔离键',
|
||||||
|
sailing_id BIGINT UNSIGNED NULL COMMENT '船期实例ID,UNMATCHED为空',
|
||||||
|
origin_sailing_id BIGINT UNSIGNED NULL COMMENT '异常重排/取消前的原船期实例ID',
|
||||||
|
inbound_id BIGINT UNSIGNED NULL COMMENT '归班对象入库单ID',
|
||||||
|
inbound_code VARCHAR(64) NULL COMMENT '入仓码快照,仅用于展示',
|
||||||
|
receipt_batch_id BIGINT UNSIGNED NULL COMMENT '收货批次事实ID',
|
||||||
|
forecast_id BIGINT UNSIGNED NULL COMMENT '货量预报事实ID',
|
||||||
|
assignment_source VARCHAR(24) NOT NULL DEFAULT 'AUTO' COMMENT '归班来源:AUTO/MANUAL/ROLLOVER',
|
||||||
|
assignment_version INT UNSIGNED NOT NULL COMMENT '同一对象的归班版本',
|
||||||
|
match_status VARCHAR(24) NOT NULL COMMENT 'FORECAST_MATCHED/RECEIVED/LATE/UNMATCHED/SUPERSEDED',
|
||||||
|
volume_cbm DECIMAL(18,4) NULL COMMENT '已收实测体积',
|
||||||
|
forecast_volume_cbm DECIMAL(18,4) NULL COMMENT '未收有效预计体积',
|
||||||
|
route_source VARCHAR(32) NULL COMMENT '航线来源:INBOUND_FACT/FORECAST_FACT/CHANNEL_DEFAULT',
|
||||||
|
volume_source VARCHAR(32) NULL COMMENT 'MEASURED_ACTUAL或预报来源',
|
||||||
|
rule_version VARCHAR(64) NOT NULL COMMENT '自动归班规则版本',
|
||||||
|
matched_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||||
|
superseded_at DATETIME(3) NULL,
|
||||||
|
locked_at DATETIME(3) NULL COMMENT '截止/订舱/关闭后冻结时间',
|
||||||
|
lock_reason VARCHAR(32) NULL COMMENT 'CUTOFF/BOOKING_CONFIRMED/CLOSED',
|
||||||
|
operator_id BIGINT UNSIGNED NULL,
|
||||||
|
reason VARCHAR(500) NULL,
|
||||||
|
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||||
|
PRIMARY KEY (id),
|
||||||
|
KEY idx_sailing_assignment_current (tenant_id, sailing_id, match_status, matched_at, id),
|
||||||
|
KEY idx_sailing_assignment_origin (tenant_id, origin_sailing_id, match_status, id),
|
||||||
|
KEY idx_sailing_assignment_receipt (tenant_id, receipt_batch_id, assignment_version, id),
|
||||||
|
KEY idx_sailing_assignment_forecast (tenant_id, forecast_id, assignment_version, id),
|
||||||
|
KEY idx_sailing_assignment_inbound (tenant_id, inbound_id, match_status, id),
|
||||||
|
CONSTRAINT chk_sailing_assignment_status CHECK (match_status IN ('FORECAST_MATCHED','RECEIVED','LATE','UNMATCHED','SUPERSEDED')),
|
||||||
|
CONSTRAINT chk_sailing_assignment_source CHECK (assignment_source IN ('AUTO','MANUAL','ROLLOVER'))
|
||||||
|
) ENGINE=InnoDB COMMENT='船期日历自动归班事实及不可变版本历史';
|
||||||
|
|
||||||
|
CREATE TABLE fl_sailing_booking_link (
|
||||||
|
id BIGINT UNSIGNED NOT NULL COMMENT '船期-订舱关联ID',
|
||||||
|
tenant_id BIGINT UNSIGNED NOT NULL COMMENT '平台关联隔离键',
|
||||||
|
sailing_id BIGINT UNSIGNED NOT NULL COMMENT '计划船期实例ID',
|
||||||
|
ocean_booking_id BIGINT UNSIGNED NOT NULL COMMENT '海运订舱ID',
|
||||||
|
link_status VARCHAR(24) NOT NULL DEFAULT 'ACTIVE' COMMENT '关联状态:ACTIVE/REVOKED',
|
||||||
|
active_booking_guard TINYINT NULL COMMENT 'ACTIVE为1,REVOKED为空,用于唯一约束',
|
||||||
|
booking_status_snapshot VARCHAR(24) NOT NULL COMMENT '关联时订舱状态快照;只有RELEASED可建立',
|
||||||
|
warehouse_id BIGINT UNSIGNED NULL COMMENT '关联仓库快照',
|
||||||
|
channel_code VARCHAR(64) NULL COMMENT '关联渠道快照',
|
||||||
|
departure_port_code VARCHAR(32) NULL COMMENT '关联起运港快照',
|
||||||
|
destination_port_code VARCHAR(32) NULL COMMENT '关联目的港快照',
|
||||||
|
linked_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||||
|
revoked_at DATETIME(3) NULL,
|
||||||
|
operator_id BIGINT UNSIGNED NULL,
|
||||||
|
operator_name VARCHAR(64) NULL,
|
||||||
|
reason VARCHAR(500) NULL,
|
||||||
|
PRIMARY KEY (id),
|
||||||
|
UNIQUE KEY uk_sailing_booking_active (ocean_booking_id, active_booking_guard),
|
||||||
|
KEY idx_sailing_booking_link (sailing_id, link_status, ocean_booking_id),
|
||||||
|
KEY idx_sailing_booking_booking (ocean_booking_id, link_status, id),
|
||||||
|
CONSTRAINT chk_sailing_booking_link_status CHECK (link_status IN ('ACTIVE','REVOKED')),
|
||||||
|
CONSTRAINT chk_sailing_booking_link_guard CHECK ((link_status='ACTIVE' AND active_booking_guard=1) OR (link_status='REVOKED' AND active_booking_guard IS NULL)),
|
||||||
|
CONSTRAINT chk_sailing_booking_released CHECK (booking_status_snapshot='RELEASED')
|
||||||
|
) ENGINE=InnoDB COMMENT='船期计划与已放舱订舱的最小关联及历史';
|
||||||
@@ -21,10 +21,55 @@ paths:
|
|||||||
parameters: [{$ref: '#/components/parameters/PlanId'}, {$ref: '#/components/parameters/IdempotencyKey'}]
|
parameters: [{$ref: '#/components/parameters/PlanId'}, {$ref: '#/components/parameters/IdempotencyKey'}]
|
||||||
requestBody: {$ref: '#/components/requestBodies/ActionBody'}
|
requestBody: {$ref: '#/components/requestBodies/ActionBody'}
|
||||||
responses: {'200': {$ref: '#/components/responses/ContainerResponse'}}
|
responses: {'200': {$ref: '#/components/responses/ContainerResponse'}}
|
||||||
/api/platform/v1/customs/loading-plans/documents/{documentId}/completion/upload:
|
/api/platform/v1/customs/loading-plans/{planId}/declaration-batches:
|
||||||
|
get:
|
||||||
|
summary: 查询整柜正式申报批次和海关结果
|
||||||
|
x-permission: platform:firstleg:customs:list
|
||||||
|
parameters: [{$ref: '#/components/parameters/PlanId'}]
|
||||||
|
responses: {'200': {$ref: '#/components/responses/DeclarationBatchListResponse'}}
|
||||||
post:
|
post:
|
||||||
summary: 上传或覆盖冻结客户资料的一对一完成文件
|
summary: 创建整柜正式申报批次并冻结提交快照
|
||||||
description: 报关行回执按冻结客户资料一对一保存;上传后等待对应客户确认或打回,不自动完成整柜报关。
|
x-permission: platform:firstleg:customs:manage
|
||||||
|
parameters: [{$ref: '#/components/parameters/PlanId'}, {$ref: '#/components/parameters/IdempotencyKey'}]
|
||||||
|
requestBody: {required: true, content: {application/json: {schema: {$ref: '#/components/schemas/DeclarationBatchRequest'}}}}
|
||||||
|
responses: {'200': {$ref: '#/components/responses/DeclarationBatchResponse'}}
|
||||||
|
/api/platform/v1/customs/loading-plans/declaration-batches/{batchId}/submit:
|
||||||
|
post:
|
||||||
|
summary: 平台人工提交正式申报并回填报关单号
|
||||||
|
x-permission: platform:firstleg:customs:manage
|
||||||
|
parameters: [{$ref: '#/components/parameters/BatchId'}, {$ref: '#/components/parameters/IdempotencyKey'}]
|
||||||
|
requestBody: {required: true, content: {application/json: {schema: {$ref: '#/components/schemas/DeclarationBatchRequest'}}}}
|
||||||
|
responses: {'200': {$ref: '#/components/responses/DeclarationBatchResponse'}}
|
||||||
|
/api/platform/v1/customs/loading-plans/declaration-batches/{batchId}/results:
|
||||||
|
get:
|
||||||
|
summary: 查询正式申报批次的海关结果历史
|
||||||
|
x-permission: platform:firstleg:customs:list
|
||||||
|
parameters: [{$ref: '#/components/parameters/BatchId'}]
|
||||||
|
responses: {'200': {$ref: '#/components/responses/ClearanceResultListResponse'}}
|
||||||
|
post:
|
||||||
|
summary: 平台人工录入查验、补资料、放行、扣货或其他海关结果
|
||||||
|
x-permission: platform:firstleg:customs:manage
|
||||||
|
parameters: [{$ref: '#/components/parameters/BatchId'}, {$ref: '#/components/parameters/IdempotencyKey'}]
|
||||||
|
requestBody: {required: true, content: {application/json: {schema: {$ref: '#/components/schemas/ClearanceResultRequest'}}}}
|
||||||
|
responses: {'200': {$ref: '#/components/responses/ClearanceResultResponse'}}
|
||||||
|
/api/platform/v1/customs/loading-plans/clearance-results/{resultId}/resolve:
|
||||||
|
post:
|
||||||
|
summary: 解决一条待处理海关结果
|
||||||
|
x-permission: platform:firstleg:customs:manage
|
||||||
|
parameters: [{$ref: '#/components/parameters/ResultId'}, {$ref: '#/components/parameters/IdempotencyKey'}]
|
||||||
|
requestBody: {required: true, content: {application/json: {schema: {$ref: '#/components/schemas/ClearanceResolveRequest'}}}}
|
||||||
|
responses: {'200': {$ref: '#/components/responses/ClearanceResultResponse'}}
|
||||||
|
/api/platform/v1/customs/loading-plans/{planId}/submit-to-broker:
|
||||||
|
post:
|
||||||
|
summary: 将客户确认回执发送给报关行执行
|
||||||
|
x-permission: platform:firstleg:customs:manage
|
||||||
|
parameters: [{$ref: '#/components/parameters/PlanId'}, {$ref: '#/components/parameters/IdempotencyKey'}]
|
||||||
|
requestBody: {$ref: '#/components/requestBodies/ActionBody'}
|
||||||
|
responses: {'200': {$ref: '#/components/responses/ContainerResponse'}}
|
||||||
|
/api/platform/v1/customs/loading-plans/documents/{documentId}/receipt/upload:
|
||||||
|
post:
|
||||||
|
summary: 上传或覆盖冻结客户资料的一对一报关行回执
|
||||||
|
description: 报关行回执按冻结客户资料一对一保存;上传后等待对应客户确认或打回。
|
||||||
x-permission: platform:firstleg:customs:manage
|
x-permission: platform:firstleg:customs:manage
|
||||||
parameters:
|
parameters:
|
||||||
- {$ref: '#/components/parameters/DocumentId'}
|
- {$ref: '#/components/parameters/DocumentId'}
|
||||||
@@ -38,9 +83,22 @@ paths:
|
|||||||
x-permission: platform:firstleg:customs:list
|
x-permission: platform:firstleg:customs:list
|
||||||
parameters: [{$ref: '#/components/parameters/DocumentId'}]
|
parameters: [{$ref: '#/components/parameters/DocumentId'}]
|
||||||
responses: {'200': {$ref: '#/components/responses/BinaryResponse'}}
|
responses: {'200': {$ref: '#/components/responses/BinaryResponse'}}
|
||||||
/api/platform/v1/customs/loading-plans/documents/{documentId}/completion/download:
|
/api/platform/v1/customs/loading-plans/documents/{documentId}/receipt/download:
|
||||||
get:
|
get:
|
||||||
summary: 平台下载报关完成文件
|
summary: 平台下载报关行回执
|
||||||
|
x-permission: platform:firstleg:customs:list
|
||||||
|
parameters: [{$ref: '#/components/parameters/DocumentId'}]
|
||||||
|
responses: {'200': {$ref: '#/components/responses/BinaryResponse'}}
|
||||||
|
/api/platform/v1/customs/loading-plans/documents/{documentId}/final/upload:
|
||||||
|
post:
|
||||||
|
summary: 上传真实报关完税文件
|
||||||
|
x-permission: platform:firstleg:customs:manage
|
||||||
|
parameters: [{$ref: '#/components/parameters/DocumentId'}, {$ref: '#/components/parameters/IdempotencyKey'}, {name: expectedVersion, in: query, required: true, schema: {type: integer, minimum: 0}}]
|
||||||
|
requestBody: {$ref: '#/components/requestBodies/FileBody'}
|
||||||
|
responses: {'200': {$ref: '#/components/responses/DocumentResponse'}}
|
||||||
|
/api/platform/v1/customs/loading-plans/documents/{documentId}/final/download:
|
||||||
|
get:
|
||||||
|
summary: 平台下载真实报关完税文件
|
||||||
x-permission: platform:firstleg:customs:list
|
x-permission: platform:firstleg:customs:list
|
||||||
parameters: [{$ref: '#/components/parameters/DocumentId'}]
|
parameters: [{$ref: '#/components/parameters/DocumentId'}]
|
||||||
responses: {'200': {$ref: '#/components/responses/BinaryResponse'}}
|
responses: {'200': {$ref: '#/components/responses/BinaryResponse'}}
|
||||||
@@ -50,6 +108,12 @@ paths:
|
|||||||
x-permission: merchant:order:view
|
x-permission: merchant:order:view
|
||||||
parameters: [{$ref: '#/components/parameters/OrderId'}]
|
parameters: [{$ref: '#/components/parameters/OrderId'}]
|
||||||
responses: {'200': {$ref: '#/components/responses/ContainerResponse'}}
|
responses: {'200': {$ref: '#/components/responses/ContainerResponse'}}
|
||||||
|
/api/merchant/v1/customs/orders/{orderId}/clearance-results:
|
||||||
|
get:
|
||||||
|
summary: 客户只读查看自身订单可见的海关结果
|
||||||
|
x-permission: merchant:order:view
|
||||||
|
parameters: [{$ref: '#/components/parameters/OrderId'}]
|
||||||
|
responses: {'200': {$ref: '#/components/responses/ClearanceResultListResponse'}}
|
||||||
/api/merchant/v1/customs/loading-plans/{planId}/documents/upload:
|
/api/merchant/v1/customs/loading-plans/{planId}/documents/upload:
|
||||||
post:
|
post:
|
||||||
summary: 上传资料并关联当前柜内本客户一个或多个退税入仓码
|
summary: 上传资料并关联当前柜内本客户一个或多个退税入仓码
|
||||||
@@ -93,9 +157,15 @@ paths:
|
|||||||
x-permission: merchant:order:view
|
x-permission: merchant:order:view
|
||||||
parameters: [{$ref: '#/components/parameters/DocumentId'}]
|
parameters: [{$ref: '#/components/parameters/DocumentId'}]
|
||||||
responses: {'200': {$ref: '#/components/responses/BinaryResponse'}}
|
responses: {'200': {$ref: '#/components/responses/BinaryResponse'}}
|
||||||
/api/merchant/v1/customs/documents/{documentId}/completion/download:
|
/api/merchant/v1/customs/documents/{documentId}/receipt/download:
|
||||||
get:
|
get:
|
||||||
summary: 整柜完成后客户下载对应完成文件
|
summary: 客户下载报关行回执
|
||||||
|
x-permission: merchant:order:view
|
||||||
|
parameters: [{$ref: '#/components/parameters/DocumentId'}]
|
||||||
|
responses: {'200': {$ref: '#/components/responses/BinaryResponse'}}
|
||||||
|
/api/merchant/v1/customs/documents/{documentId}/final/download:
|
||||||
|
get:
|
||||||
|
summary: 整柜完成后客户下载真实报关完税文件
|
||||||
x-permission: merchant:order:view
|
x-permission: merchant:order:view
|
||||||
parameters: [{$ref: '#/components/parameters/DocumentId'}]
|
parameters: [{$ref: '#/components/parameters/DocumentId'}]
|
||||||
responses: {'200': {$ref: '#/components/responses/BinaryResponse'}}
|
responses: {'200': {$ref: '#/components/responses/BinaryResponse'}}
|
||||||
@@ -124,6 +194,8 @@ components:
|
|||||||
PlanId: {name: planId, in: path, required: true, schema: {$ref: '#/components/schemas/LongId'}}
|
PlanId: {name: planId, in: path, required: true, schema: {$ref: '#/components/schemas/LongId'}}
|
||||||
OrderId: {name: orderId, in: path, required: true, schema: {$ref: '#/components/schemas/LongId'}}
|
OrderId: {name: orderId, in: path, required: true, schema: {$ref: '#/components/schemas/LongId'}}
|
||||||
DocumentId: {name: documentId, in: path, required: true, schema: {$ref: '#/components/schemas/LongId'}}
|
DocumentId: {name: documentId, in: path, required: true, schema: {$ref: '#/components/schemas/LongId'}}
|
||||||
|
BatchId: {name: batchId, in: path, required: true, schema: {$ref: '#/components/schemas/LongId'}}
|
||||||
|
ResultId: {name: resultId, in: path, required: true, schema: {$ref: '#/components/schemas/LongId'}}
|
||||||
IdempotencyKey: {name: Idempotency-Key, in: header, required: true, schema: {type: string, minLength: 1, maxLength: 128}}
|
IdempotencyKey: {name: Idempotency-Key, in: header, required: true, schema: {type: string, minLength: 1, maxLength: 128}}
|
||||||
InboundIds: {name: inboundIds, in: query, required: true, schema: {type: array, minItems: 1, maxItems: 200, items: {$ref: '#/components/schemas/LongId'}}}
|
InboundIds: {name: inboundIds, in: query, required: true, schema: {type: array, minItems: 1, maxItems: 200, items: {$ref: '#/components/schemas/LongId'}}}
|
||||||
requestBodies:
|
requestBodies:
|
||||||
@@ -133,16 +205,30 @@ components:
|
|||||||
ContainerResponse: {description: 成功或业务失败, content: {application/json: {schema: {$ref: '#/components/schemas/ContainerEnvelope'}}}}
|
ContainerResponse: {description: 成功或业务失败, content: {application/json: {schema: {$ref: '#/components/schemas/ContainerEnvelope'}}}}
|
||||||
DocumentResponse: {description: 成功或业务失败, content: {application/json: {schema: {$ref: '#/components/schemas/DocumentEnvelope'}}}}
|
DocumentResponse: {description: 成功或业务失败, content: {application/json: {schema: {$ref: '#/components/schemas/DocumentEnvelope'}}}}
|
||||||
BinaryResponse: {description: 文件二进制, headers: {Content-Disposition: {schema: {type: string}}, Cache-Control: {schema: {type: string, example: no-store}}}, content: {application/octet-stream: {schema: {type: string, format: binary}}}}
|
BinaryResponse: {description: 文件二进制, headers: {Content-Disposition: {schema: {type: string}}, Cache-Control: {schema: {type: string, example: no-store}}}, content: {application/octet-stream: {schema: {type: string, format: binary}}}}
|
||||||
|
DeclarationBatchResponse: {description: 正式申报批次, content: {application/json: {schema: {$ref: '#/components/schemas/DeclarationBatchEnvelope'}}}}
|
||||||
|
DeclarationBatchListResponse: {description: 正式申报批次列表, content: {application/json: {schema: {$ref: '#/components/schemas/DeclarationBatchListEnvelope'}}}}
|
||||||
|
ClearanceResultResponse: {description: 海关结果, content: {application/json: {schema: {$ref: '#/components/schemas/ClearanceResultEnvelope'}}}}
|
||||||
|
ClearanceResultListResponse: {description: 海关结果列表, content: {application/json: {schema: {$ref: '#/components/schemas/ClearanceResultListEnvelope'}}}}
|
||||||
schemas:
|
schemas:
|
||||||
LongId: {type: string, pattern: '^[1-9][0-9]*$', description: Long ID序列化为字符串}
|
LongId: {type: string, pattern: '^[1-9][0-9]*$', description: Long ID序列化为字符串}
|
||||||
ApiEnvelope: {type: object, required: [code, message, timestamp], properties: {code: {type: string}, message: {type: string}, requestId: {type: string, nullable: true}, traceId: {type: string, nullable: true}, timestamp: {type: string, format: date-time}}}
|
ApiEnvelope: {type: object, required: [code, message, timestamp], properties: {code: {type: string}, message: {type: string}, requestId: {type: string, nullable: true}, traceId: {type: string, nullable: true}, timestamp: {type: string, format: date-time}}}
|
||||||
ContainerEnvelope: {allOf: [{$ref: '#/components/schemas/ApiEnvelope'}, {type: object, properties: {data: {allOf: [{$ref: '#/components/schemas/ContainerView'}], nullable: true, description: 装柜计划尚未完成时返回null,表示报关流程尚未生成}}}]}
|
ContainerEnvelope: {allOf: [{$ref: '#/components/schemas/ApiEnvelope'}, {type: object, properties: {data: {allOf: [{$ref: '#/components/schemas/ContainerView'}], nullable: true, description: 装柜计划尚未完成时返回null,表示报关流程尚未生成}}}]}
|
||||||
DocumentEnvelope: {allOf: [{$ref: '#/components/schemas/ApiEnvelope'}, {type: object, properties: {data: {$ref: '#/components/schemas/DocumentView'}}}]}
|
DocumentEnvelope: {allOf: [{$ref: '#/components/schemas/ApiEnvelope'}, {type: object, properties: {data: {$ref: '#/components/schemas/DocumentView'}}}]}
|
||||||
ActionRequest: {type: object, required: [expectedVersion, confirm], additionalProperties: false, properties: {expectedVersion: {type: integer, minimum: 0}, confirm: {type: boolean}, remark: {type: string, maxLength: 500, nullable: true}}}
|
ActionRequest: {type: object, required: [expectedVersion, confirm], additionalProperties: false, properties: {expectedVersion: {type: integer, minimum: 0}, confirm: {type: boolean}, remark: {type: string, maxLength: 500, nullable: true}}}
|
||||||
|
DeclarationBatchRequest: {type: object, required: [expectedVersion], additionalProperties: false, properties: {expectedVersion: {type: integer, minimum: 0}, batchNo: {type: string, maxLength: 64, nullable: true}, customsEntryNo: {type: string, maxLength: 64, nullable: true}, remark: {type: string, maxLength: 500, nullable: true}}}
|
||||||
|
ClearanceResultRequest: {type: object, required: [expectedVersion, resultType, occurredAt], additionalProperties: false, properties: {expectedVersion: {type: integer, minimum: 0}, resultType: {type: string, enum: [INSPECTION, SUPPLEMENT_REQUIRED, RELEASED, DETAINED, OTHER]}, occurredAt: {type: string, format: date-time}, resolution: {type: string, maxLength: 1000, nullable: true}, evidenceFileId: {$ref: '#/components/schemas/LongId', nullable: true}, resolved: {type: boolean}}}
|
||||||
|
ClearanceResolveRequest: {type: object, required: [expectedVersion], additionalProperties: false, properties: {expectedVersion: {type: integer, minimum: 0}, resolution: {type: string, maxLength: 1000, nullable: true}}}
|
||||||
ScopeRequest: {type: object, required: [inboundIds, expectedVersion], additionalProperties: false, properties: {inboundIds: {type: array, minItems: 1, maxItems: 200, items: {$ref: '#/components/schemas/LongId'}}, expectedVersion: {type: integer, minimum: 1}}}
|
ScopeRequest: {type: object, required: [inboundIds, expectedVersion], additionalProperties: false, properties: {inboundIds: {type: array, minItems: 1, maxItems: 200, items: {$ref: '#/components/schemas/LongId'}}, expectedVersion: {type: integer, minimum: 1}}}
|
||||||
FileView: {type: object, required: [fileId, fileName, fileHash, sizeBytes, version, uploadedAt], properties: {fileId: {$ref: '#/components/schemas/LongId'}, fileName: {type: string}, fileHash: {type: string}, contentType: {type: string, nullable: true}, sizeBytes: {type: integer, format: int64}, version: {type: integer}, uploadedAt: {type: string, format: date-time}}}
|
FileView: {type: object, required: [fileId, fileName, fileHash, sizeBytes, version, uploadedAt], properties: {fileId: {$ref: '#/components/schemas/LongId'}, fileName: {type: string}, fileHash: {type: string}, contentType: {type: string, nullable: true}, sizeBytes: {type: integer, format: int64}, version: {type: integer}, uploadedAt: {type: string, format: date-time}}}
|
||||||
InboundView: {type: object, required: [inboundId, orderId, orderNo, inboundCode, tenantId, customerName, taxMode, covered], properties: {inboundId: {$ref: '#/components/schemas/LongId'}, orderId: {$ref: '#/components/schemas/LongId'}, orderNo: {type: string}, inboundCode: {type: string}, tenantId: {$ref: '#/components/schemas/LongId'}, customerName: {type: string}, taxMode: {type: string, enum: [TAX_REFUND, BUY_DECLARATION]}, covered: {type: boolean}}}
|
InboundView: {type: object, required: [inboundId, orderId, orderNo, inboundCode, tenantId, customerName, taxMode, covered], properties: {inboundId: {$ref: '#/components/schemas/LongId'}, orderId: {$ref: '#/components/schemas/LongId'}, orderNo: {type: string}, inboundCode: {type: string}, tenantId: {$ref: '#/components/schemas/LongId'}, customerName: {type: string}, taxMode: {type: string, enum: [TAX_REFUND, BUY_DECLARATION]}, covered: {type: boolean}}}
|
||||||
DocumentView: {type: object, required: [documentId, tenantId, source, inboundIds, inboundCodes, frozen], properties: {documentId: {$ref: '#/components/schemas/LongId'}, tenantId: {$ref: '#/components/schemas/LongId'}, source: {$ref: '#/components/schemas/FileView'}, inboundIds: {type: array, items: {$ref: '#/components/schemas/LongId'}}, inboundCodes: {type: array, items: {type: string}}, frozen: {type: boolean}, completion: {allOf: [{$ref: '#/components/schemas/FileView'}], nullable: true, description: 退税报关行回执文件}, receiptStatus: {type: string, enum: [PENDING, CONFIRMED, REJECTED], nullable: true}, receiptRejectReason: {type: string, nullable: true}}}
|
ReceiptVersionView: {type: object, required: [version, file, createdAt], properties: {version: {type: integer}, file: {$ref: '#/components/schemas/FileView'}, operatorName: {type: string, nullable: true}, createdAt: {type: string, format: date-time}}}
|
||||||
ContainerView: {type: object, required: [aggregateId, loadingPlanId, containerBookingId, status, hasActualCargo, version, taxRefundCount, coveredTaxRefundCount, actualBoxCount, inbounds, documents], properties: {aggregateId: {$ref: '#/components/schemas/LongId'}, loadingPlanId: {$ref: '#/components/schemas/LongId'}, containerBookingId: {$ref: '#/components/schemas/LongId'}, planNo: {type: string}, cabinetNo: {type: string}, containerNo: {type: string, nullable: true}, status: {type: string, enum: [WAITING_CUSTOMER_DOCUMENTS, READY_TO_START, DECLARING, COMPLETED]}, hasActualCargo: {type: boolean, description: 当前完成装柜计划是否仍有实际装柜货物;false时查询成功返回空明细且无需发起报关}, merchantStatus: {type: string, enum: [WAITING_UPLOAD, DOCUMENT_UPLOADED, DECLARING, CUSTOMS_COMPLETED], nullable: true}, version: {type: integer}, taxRefundCount: {type: integer}, coveredTaxRefundCount: {type: integer}, actualBoxCount: {type: integer, format: int64, minimum: 0, description: 当前视角可见的实际装箱数;部分装柜只展示该数量,线下处理后续}, inbounds: {type: array, items: {$ref: '#/components/schemas/InboundView'}}, documents: {type: array, items: {$ref: '#/components/schemas/DocumentView'}}, startedAt: {type: string, format: date-time, nullable: true}, completedAt: {type: string, format: date-time, nullable: true}}}
|
DocumentView: {type: object, required: [documentId, tenantId, source, inboundIds, inboundCodes, frozen, receiptHistory], properties: {documentId: {$ref: '#/components/schemas/LongId'}, tenantId: {$ref: '#/components/schemas/LongId'}, source: {$ref: '#/components/schemas/FileView'}, inboundIds: {type: array, items: {$ref: '#/components/schemas/LongId'}}, inboundCodes: {type: array, items: {type: string}}, frozen: {type: boolean}, receipt: {allOf: [{$ref: '#/components/schemas/FileView'}], nullable: true, description: 当前报关行回执文件}, receiptStatus: {type: string, enum: [PENDING, CONFIRMED, REJECTED], nullable: true}, receiptRejectReason: {type: string, nullable: true}, finalDocument: {allOf: [{$ref: '#/components/schemas/FileView'}], nullable: true, description: 真实报关完税文件}, receiptHistory: {type: array, items: {$ref: '#/components/schemas/ReceiptVersionView'}, description: 报关行回执历次上传记录,按版本升序}}}
|
||||||
|
ContainerView: {type: object, required: [aggregateId, loadingPlanId, containerBookingId, status, hasActualCargo, version, taxRefundCount, coveredTaxRefundCount, actualBoxCount, inbounds, documents], properties: {aggregateId: {$ref: '#/components/schemas/LongId'}, loadingPlanId: {$ref: '#/components/schemas/LongId'}, containerBookingId: {$ref: '#/components/schemas/LongId'}, planNo: {type: string}, cabinetNo: {type: string}, containerNo: {type: string, nullable: true}, status: {type: string, enum: [WAITING_CUSTOMER_DOCUMENTS, READY_TO_START, DECLARING, COMPLETED]}, hasActualCargo: {type: boolean, description: 当前完成装柜计划是否仍有实际装柜货物;false时查询成功返回空明细且无需发起报关}, merchantStatus: {type: string, enum: [WAITING_UPLOAD, DOCUMENT_UPLOADED, DECLARING, CUSTOMS_COMPLETED], nullable: true}, version: {type: integer}, taxRefundCount: {type: integer}, coveredTaxRefundCount: {type: integer}, actualBoxCount: {type: integer, format: int64, minimum: 0, description: 当前视角可见的实际装箱数;部分装柜只展示该数量,线下处理后续}, inbounds: {type: array, items: {$ref: '#/components/schemas/InboundView'}}, documents: {type: array, items: {$ref: '#/components/schemas/DocumentView'}}, startedAt: {type: string, format: date-time, nullable: true}, brokerSubmittedAt: {type: string, format: date-time, nullable: true, description: 已将客户确认后的回执发送给报关行执行的时间;不表示报关完成}, completedAt: {type: string, format: date-time, nullable: true}}}
|
||||||
|
DeclarationBatch: {type: object, required: [id, customsContainerId, loadingPlanId, status, sourceVersion, version, results], properties: {id: {$ref: '#/components/schemas/LongId'}, customsContainerId: {$ref: '#/components/schemas/LongId'}, loadingPlanId: {$ref: '#/components/schemas/LongId'}, batchNo: {type: string}, status: {type: string, enum: [READY, SUBMITTED, ACKNOWLEDGED, RESULT_PENDING, CLOSED, CANCELLED]}, sourceVersion: {type: integer}, customsEntryNo: {type: string, nullable: true}, version: {type: integer}, submittedAt: {type: string, format: date-time, nullable: true}, submittedByName: {type: string, nullable: true}, acknowledgedAt: {type: string, format: date-time, nullable: true}, acknowledgedByName: {type: string, nullable: true}, remark: {type: string, nullable: true}, results: {type: array, items: {$ref: '#/components/schemas/ClearanceResult'}}}}
|
||||||
|
ClearanceResult: {type: object, required: [id, declarationBatchId, customsContainerId, resultType, resultStatus, occurredAt, recordedAt], properties: {id: {$ref: '#/components/schemas/LongId'}, declarationBatchId: {$ref: '#/components/schemas/LongId'}, customsContainerId: {$ref: '#/components/schemas/LongId'}, resultType: {type: string, enum: [INSPECTION, SUPPLEMENT_REQUIRED, RELEASED, DETAINED, OTHER]}, resultStatus: {type: string, enum: [OPEN, RESOLVED, CANCELLED]}, customsEntryNo: {type: string, nullable: true}, occurredAt: {type: string, format: date-time}, resolution: {type: string, nullable: true}, evidenceFileId: {$ref: '#/components/schemas/LongId', nullable: true}, recordedBy: {$ref: '#/components/schemas/LongId'}, recordedByName: {type: string, nullable: true}, recordedAt: {type: string, format: date-time}, previousResultId: {$ref: '#/components/schemas/LongId', nullable: true}}}
|
||||||
|
DeclarationBatchEnvelope: {allOf: [{$ref: '#/components/schemas/ApiEnvelope'}, {type: object, properties: {data: {$ref: '#/components/schemas/DeclarationBatch'}}}]}
|
||||||
|
DeclarationBatchListEnvelope: {allOf: [{$ref: '#/components/schemas/ApiEnvelope'}, {type: object, properties: {data: {type: array, items: {$ref: '#/components/schemas/DeclarationBatch'}}}}]}
|
||||||
|
ClearanceResultEnvelope: {allOf: [{$ref: '#/components/schemas/ApiEnvelope'}, {type: object, properties: {data: {$ref: '#/components/schemas/ClearanceResult'}}}]}
|
||||||
|
ClearanceResultListEnvelope: {allOf: [{$ref: '#/components/schemas/ApiEnvelope'}, {type: object, properties: {data: {type: array, items: {$ref: '#/components/schemas/ClearanceResult'}}}}]}
|
||||||
ExportBatchRequest: {type: object, required: [taskId, tenantId, appCode, requestedBy, dispatchVersion, parameters, limit], properties: {taskId: {type: string}, tenantId: {type: string, enum: ['0']}, appCode: {type: string, enum: [PLATFORM]}, requestedBy: {type: string}, dispatchVersion: {type: integer}, parameters: {type: object, required: [loadingPlanId], additionalProperties: false, properties: {loadingPlanId: {type: string}}}, cursor: {type: string, nullable: true}, snapshotToken: {type: string, nullable: true}, limit: {type: integer, minimum: 1, maximum: 500}}}
|
ExportBatchRequest: {type: object, required: [taskId, tenantId, appCode, requestedBy, dispatchVersion, parameters, limit], properties: {taskId: {type: string}, tenantId: {type: string, enum: ['0']}, appCode: {type: string, enum: [PLATFORM]}, requestedBy: {type: string}, dispatchVersion: {type: integer}, parameters: {type: object, required: [loadingPlanId], additionalProperties: false, properties: {loadingPlanId: {type: string}}}, cursor: {type: string, nullable: true}, snapshotToken: {type: string, nullable: true}, limit: {type: integer, minimum: 1, maximum: 500}}}
|
||||||
ExportBatchEnvelope: {allOf: [{$ref: '#/components/schemas/ApiEnvelope'}, {type: object, properties: {data: {type: object, properties: {schemaVersion: {type: integer}, schemaHash: {type: string}, snapshotToken: {type: string}, columns: {type: array, items: {type: object}}, rows: {type: array, items: {type: object}}, nextCursor: {type: string}, hasMore: {type: boolean}, totalRows: {type: integer, format: int64, nullable: true}}}}}]}
|
ExportBatchEnvelope: {allOf: [{$ref: '#/components/schemas/ApiEnvelope'}, {type: object, properties: {data: {type: object, properties: {schemaVersion: {type: integer}, schemaHash: {type: string}, snapshotToken: {type: string}, columns: {type: array, items: {type: object}}, rows: {type: array, items: {type: object}}, nextCursor: {type: string}, hasMore: {type: boolean}, totalRows: {type: integer, format: int64, nullable: true}}}}}]}
|
||||||
|
|||||||
@@ -0,0 +1,138 @@
|
|||||||
|
openapi: 3.0.3
|
||||||
|
info:
|
||||||
|
title: Firstleg Sailing Calendar API
|
||||||
|
version: v1
|
||||||
|
description: 船期实例、固定周模板、渠道管理、收货事实汇总和已放舱订舱关联。
|
||||||
|
paths:
|
||||||
|
/api/platform/v1/sailings/calendar:
|
||||||
|
get:
|
||||||
|
summary: 查询船期月历
|
||||||
|
x-permission: platform:firstleg:sailing-calendar:list
|
||||||
|
parameters:
|
||||||
|
- {name: month, in: query, required: true, schema: {type: string, pattern: '^\\d{4}-\\d{2}$'}}
|
||||||
|
- {name: warehouseId, in: query, schema: {type: integer, format: int64}}
|
||||||
|
- {name: channelId, in: query, schema: {type: integer, format: int64}}
|
||||||
|
- {name: channelCode, in: query, schema: {type: string, maxLength: 64}}
|
||||||
|
- {name: departurePortCode, in: query, schema: {type: string, maxLength: 32}}
|
||||||
|
- {name: destinationPortCode, in: query, schema: {type: string, maxLength: 32}}
|
||||||
|
- {name: keyword, in: query, schema: {type: string, maxLength: 128}}
|
||||||
|
- {name: status, in: query, schema: {type: string}}
|
||||||
|
responses: {'200': {description: 月历和按船期汇总}}
|
||||||
|
/api/platform/v1/sailings:
|
||||||
|
post:
|
||||||
|
summary: 新增单次船期
|
||||||
|
x-permission: platform:firstleg:sailing-calendar:create
|
||||||
|
responses: {'200': {description: OK}}
|
||||||
|
/api/platform/v1/sailings/{id}:
|
||||||
|
get:
|
||||||
|
summary: 查询船期详情
|
||||||
|
x-permission: platform:firstleg:sailing-calendar:list
|
||||||
|
parameters: [{name: id, in: path, required: true, schema: {type: integer, format: int64}}]
|
||||||
|
responses: {'200': {description: OK}}
|
||||||
|
put:
|
||||||
|
summary: 编辑船期
|
||||||
|
x-permission: platform:firstleg:sailing-calendar:edit
|
||||||
|
parameters: [{name: id, in: path, required: true, schema: {type: integer, format: int64}}]
|
||||||
|
responses: {'200': {description: OK}}
|
||||||
|
/api/platform/v1/sailings/{id}/history:
|
||||||
|
get:
|
||||||
|
summary: 查询船期变更历史
|
||||||
|
x-permission: platform:firstleg:sailing-calendar:list
|
||||||
|
parameters: [{name: id, in: path, required: true, schema: {type: integer, format: int64}}]
|
||||||
|
responses: {'200': {description: OK}}
|
||||||
|
/api/platform/v1/sailings/{id}/cancel:
|
||||||
|
post:
|
||||||
|
summary: 取消船期
|
||||||
|
x-permission: platform:firstleg:sailing-calendar:edit
|
||||||
|
responses: {'200': {description: OK}}
|
||||||
|
/api/platform/v1/sailings/{id}/copy:
|
||||||
|
post:
|
||||||
|
summary: 复制下一班船期
|
||||||
|
x-permission: platform:firstleg:sailing-calendar:create
|
||||||
|
responses: {'200': {description: OK}}
|
||||||
|
/api/platform/v1/sailings/{id}/volume-summary:
|
||||||
|
get:
|
||||||
|
summary: 查询实收、预报和归班明细
|
||||||
|
x-permission: platform:firstleg:sailing-calendar:list
|
||||||
|
responses: {'200': {description: OK}}
|
||||||
|
/api/platform/v1/sailings/{id}/booking-advice:
|
||||||
|
get:
|
||||||
|
summary: 查询只读订柜建议
|
||||||
|
x-permission: platform:firstleg:sailing-calendar:list
|
||||||
|
responses: {'200': {description: OK}}
|
||||||
|
/api/platform/v1/sailings/{id}/booking-links:
|
||||||
|
post:
|
||||||
|
summary: 关联一条 RELEASED 海运订舱
|
||||||
|
x-permission: platform:firstleg:sailing-booking:link
|
||||||
|
responses: {'200': {description: ACTIVE 关联结果}}
|
||||||
|
/api/platform/v1/sailing-channels:
|
||||||
|
get:
|
||||||
|
summary: 查询船期渠道
|
||||||
|
x-permission: platform:firstleg:sailing-calendar:list
|
||||||
|
responses: {'200': {description: OK}}
|
||||||
|
post:
|
||||||
|
summary: 新增船期渠道
|
||||||
|
x-permission: platform:firstleg:sailing-template:manage
|
||||||
|
responses: {'200': {description: OK}}
|
||||||
|
/api/platform/v1/sailing-channels/{id}:
|
||||||
|
put:
|
||||||
|
summary: 编辑或启停船期渠道
|
||||||
|
x-permission: platform:firstleg:sailing-template:manage
|
||||||
|
responses: {'200': {description: OK}}
|
||||||
|
/api/platform/v1/sailing-templates:
|
||||||
|
get:
|
||||||
|
summary: 查询固定周模板
|
||||||
|
x-permission: platform:firstleg:sailing-template:manage
|
||||||
|
responses: {'200': {description: OK}}
|
||||||
|
post:
|
||||||
|
summary: 新增固定周模板
|
||||||
|
x-permission: platform:firstleg:sailing-template:manage
|
||||||
|
responses: {'200': {description: OK}}
|
||||||
|
/api/platform/v1/sailing-templates/{id}:
|
||||||
|
put:
|
||||||
|
summary: 编辑固定周模板
|
||||||
|
x-permission: platform:firstleg:sailing-template:manage
|
||||||
|
responses: {'200': {description: OK}}
|
||||||
|
/api/platform/v1/sailing-templates/{id}/generation-preview:
|
||||||
|
post:
|
||||||
|
summary: 预览模板生成实例
|
||||||
|
x-permission: platform:firstleg:sailing-template:manage
|
||||||
|
responses: {'200': {description: OK}}
|
||||||
|
/api/platform/v1/sailing-templates/{id}/generate:
|
||||||
|
post:
|
||||||
|
summary: 按模板生成实例
|
||||||
|
x-permission: platform:firstleg:sailing-template:manage
|
||||||
|
responses: {'200': {description: OK}}
|
||||||
|
/api/platform/v1/sailings/{id}/exception-reassign:
|
||||||
|
post:
|
||||||
|
summary: 受限异常重新安排
|
||||||
|
x-permission: platform:firstleg:sailing-volume:exception-reassign
|
||||||
|
responses: {'200': {description: OK}}
|
||||||
|
/api/platform/v1/sailing-assignments/exceptions:
|
||||||
|
get:
|
||||||
|
summary: 查询未安排和晚到待办
|
||||||
|
x-permission: platform:firstleg:sailing-calendar:list
|
||||||
|
responses: {'200': {description: OK}}
|
||||||
|
/api/platform/v1/sailing-forecasts:
|
||||||
|
post:
|
||||||
|
summary: 新增未收货量预报
|
||||||
|
x-permission: platform:firstleg:sailing-volume:forecast-manage
|
||||||
|
responses: {'200': {description: OK}}
|
||||||
|
/api/platform/v1/sailing-forecasts/{id}:
|
||||||
|
put:
|
||||||
|
summary: 修订未收货量预报
|
||||||
|
x-permission: platform:firstleg:sailing-volume:forecast-manage
|
||||||
|
parameters: [{name: id, in: path, required: true, schema: {type: integer, format: int64}}]
|
||||||
|
responses: {'200': {description: 新版本预报}}
|
||||||
|
/api/platform/v1/sailing-forecasts/{id}/void:
|
||||||
|
post:
|
||||||
|
summary: 作废未收货量预报
|
||||||
|
x-permission: platform:firstleg:sailing-volume:forecast-manage
|
||||||
|
parameters: [{name: id, in: path, required: true, schema: {type: integer, format: int64}}]
|
||||||
|
responses: {'200': {description: OK}}
|
||||||
|
/api/platform/v1/sailing-forecasts/{id}/bind-receipt:
|
||||||
|
post:
|
||||||
|
summary: 将实收批次绑定到预报批次
|
||||||
|
x-permission: platform:firstleg:sailing-volume:forecast-manage
|
||||||
|
parameters: [{name: id, in: path, required: true, schema: {type: integer, format: int64}}]
|
||||||
|
responses: {'200': {description: OK}}
|
||||||
+8
-1
@@ -19,6 +19,11 @@ class ContainerCustomsControllerContractTest {
|
|||||||
assertPermission(PlatformContainerCustomsController.class,"start","platform:firstleg:customs:manage");
|
assertPermission(PlatformContainerCustomsController.class,"start","platform:firstleg:customs:manage");
|
||||||
assertPermission(PlatformContainerCustomsController.class,"uploadCompletion","platform:firstleg:customs:manage");
|
assertPermission(PlatformContainerCustomsController.class,"uploadCompletion","platform:firstleg:customs:manage");
|
||||||
assertPermission(PlatformContainerCustomsController.class,"completeAllBuy","platform:firstleg:customs:manage");
|
assertPermission(PlatformContainerCustomsController.class,"completeAllBuy","platform:firstleg:customs:manage");
|
||||||
|
assertPermission(PlatformContainerCustomsController.class,"declarationBatches","platform:firstleg:customs:list");
|
||||||
|
assertPermission(PlatformContainerCustomsController.class,"createDeclarationBatch","platform:firstleg:customs:manage");
|
||||||
|
assertPermission(PlatformContainerCustomsController.class,"submitDeclarationBatch","platform:firstleg:customs:manage");
|
||||||
|
assertPermission(PlatformContainerCustomsController.class,"addClearanceResult","platform:firstleg:customs:manage");
|
||||||
|
assertPermission(PlatformContainerCustomsController.class,"resolveClearanceResult","platform:firstleg:customs:manage");
|
||||||
}
|
}
|
||||||
@Test void merchantUsesOrderViewAndEditWithoutTrustingTenantInput() {
|
@Test void merchantUsesOrderViewAndEditWithoutTrustingTenantInput() {
|
||||||
assertThat(MerchantContainerCustomsController.class.getAnnotation(RequestMapping.class).value())
|
assertThat(MerchantContainerCustomsController.class.getAnnotation(RequestMapping.class).value())
|
||||||
@@ -28,11 +33,13 @@ class ContainerCustomsControllerContractTest {
|
|||||||
assertPermission(MerchantContainerCustomsController.class,"replace","merchant:order:edit");
|
assertPermission(MerchantContainerCustomsController.class,"replace","merchant:order:edit");
|
||||||
assertPermission(MerchantContainerCustomsController.class,"scope","merchant:order:edit");
|
assertPermission(MerchantContainerCustomsController.class,"scope","merchant:order:edit");
|
||||||
assertPermission(MerchantContainerCustomsController.class,"delete","merchant:order:edit");
|
assertPermission(MerchantContainerCustomsController.class,"delete","merchant:order:edit");
|
||||||
|
assertPermission(MerchantContainerCustomsController.class,"clearanceResults","merchant:order:view");
|
||||||
}
|
}
|
||||||
@Test void openApiPublishesEveryPathParameterAndGeneratableDtos()throws Exception{
|
@Test void openApiPublishesEveryPathParameterAndGeneratableDtos()throws Exception{
|
||||||
String yaml=new ClassPathResource("openapi/customs-v1.yaml").getContentAsString(java.nio.charset.StandardCharsets.UTF_8);
|
String yaml=new ClassPathResource("openapi/customs-v1.yaml").getContentAsString(java.nio.charset.StandardCharsets.UTF_8);
|
||||||
new Yaml().load(yaml);
|
new Yaml().load(yaml);
|
||||||
assertThat(yaml).contains("ContainerView:","DocumentView:","FileView:","InboundView:","ActionRequest:","ScopeRequest:")
|
assertThat(yaml).contains("ContainerView:","DocumentView:","FileView:","InboundView:","ActionRequest:","ScopeRequest:",
|
||||||
|
"DeclarationBatch:","ClearanceResult:","declaration-batches","clearance-results")
|
||||||
.contains("merchantStatus:","WAITING_UPLOAD","CUSTOMS_COMPLETED","loadingPlanId:","hasActualCargo:","nullable: true")
|
.contains("merchantStatus:","WAITING_UPLOAD","CUSTOMS_COMPLETED","loadingPlanId:","hasActualCargo:","nullable: true")
|
||||||
.contains("LOADING_LIST_BUY_DECLARATION_ITEMS")
|
.contains("LOADING_LIST_BUY_DECLARATION_ITEMS")
|
||||||
.doesNotContain("/cases/","CUSTOMS_DECLARATION");
|
.doesNotContain("/cases/","CUSTOMS_DECLARATION");
|
||||||
|
|||||||
+20
-1
@@ -45,9 +45,27 @@ class ContainerCustomsMapperContractTest {
|
|||||||
@Test void completionQueriesExplicitlyMapEveryRecordComponentIncludingVersion() throws Exception {
|
@Test void completionQueriesExplicitlyMapEveryRecordComponentIncludingVersion() throws Exception {
|
||||||
assertRecordProjection("completion", ContainerCustomsMapper.CompletionRow.class, long.class);
|
assertRecordProjection("completion", ContainerCustomsMapper.CompletionRow.class, long.class);
|
||||||
assertRecordProjection("lockCompletion", ContainerCustomsMapper.CompletionRow.class, long.class);
|
assertRecordProjection("lockCompletion", ContainerCustomsMapper.CompletionRow.class, long.class);
|
||||||
|
assertRecordProjectionWithoutVersion("completionVersions", ContainerCustomsMapper.CompletionVersionRow.class, long.class);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test void declarationAndClearanceQueriesMapExplicitHistoricalFacts() throws Exception {
|
||||||
|
assertRecordProjection("declarationBatches", ContainerCustomsMapper.DeclarationBatchRow.class, long.class);
|
||||||
|
assertRecordProjection("declarationBatch", ContainerCustomsMapper.DeclarationBatchRow.class, long.class);
|
||||||
|
assertRecordProjection("lockDeclarationBatch", ContainerCustomsMapper.DeclarationBatchRow.class, long.class);
|
||||||
|
assertRecordProjectionWithoutVersion("clearanceResults", ContainerCustomsMapper.ClearanceResultRow.class, long.class);
|
||||||
|
assertRecordProjectionWithoutVersion("clearanceResult", ContainerCustomsMapper.ClearanceResultRow.class, long.class);
|
||||||
|
assertRecordProjectionWithoutVersion("lockClearanceResult", ContainerCustomsMapper.ClearanceResultRow.class, long.class);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static void assertRecordProjection(String method,Class<?> recordType,Class<?>... parameterTypes) throws Exception {
|
private static void assertRecordProjection(String method,Class<?> recordType,Class<?>... parameterTypes) throws Exception {
|
||||||
|
assertProjection(method, recordType, true, parameterTypes);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void assertRecordProjectionWithoutVersion(String method,Class<?> recordType,Class<?>... parameterTypes) throws Exception {
|
||||||
|
assertProjection(method, recordType, false, parameterTypes);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void assertProjection(String method,Class<?> recordType,boolean requireVersion,Class<?>... parameterTypes) throws Exception {
|
||||||
String value=sql(method,parameterTypes).replaceAll("\\s+"," ").trim();
|
String value=sql(method,parameterTypes).replaceAll("\\s+"," ").trim();
|
||||||
assertThat(value).doesNotContainIgnoringCase("SELECT *");
|
assertThat(value).doesNotContainIgnoringCase("SELECT *");
|
||||||
String projection=value.substring(value.indexOf("SELECT ")+7,value.indexOf(" FROM "));
|
String projection=value.substring(value.indexOf("SELECT ")+7,value.indexOf(" FROM "));
|
||||||
@@ -56,7 +74,8 @@ class ContainerCustomsMapperContractTest {
|
|||||||
.map(column->column.substring(column.lastIndexOf(' ')+1))
|
.map(column->column.substring(column.lastIndexOf(' ')+1))
|
||||||
.toList();
|
.toList();
|
||||||
List<String> components=Arrays.stream(recordType.getRecordComponents()).map(RecordComponent::getName).toList();
|
List<String> components=Arrays.stream(recordType.getRecordComponents()).map(RecordComponent::getName).toList();
|
||||||
assertThat(aliases).containsExactlyElementsOf(components).contains("version");
|
assertThat(aliases).containsExactlyElementsOf(components);
|
||||||
|
if (requireVersion) assertThat(aliases).contains("version");
|
||||||
}
|
}
|
||||||
|
|
||||||
private static String sql(String name,Class<?>... types) throws Exception {
|
private static String sql(String name,Class<?>... types) throws Exception {
|
||||||
|
|||||||
+21
-9
@@ -248,9 +248,9 @@ class ContainerCustomsServiceTest {
|
|||||||
when(mapper.claimCommand(anyLong(),eq(5L),eq("UPLOAD_COMPLETION_1"),eq("k"),anyString())).thenReturn(1);
|
when(mapper.claimCommand(anyLong(),eq(5L),eq("UPLOAD_COMPLETION_1"),eq("k"),anyString())).thenReturn(1);
|
||||||
when(mapper.document(1L)).thenReturn(source);when(mapper.lockContainerById(2L)).thenReturn(container);
|
when(mapper.document(1L)).thenReturn(source);when(mapper.lockContainerById(2L)).thenReturn(container);
|
||||||
when(mapper.lockDocument(1L)).thenReturn(source);when(mapper.lockCompletion(1L)).thenReturn(null);
|
when(mapper.lockDocument(1L)).thenReturn(source);when(mapper.lockCompletion(1L)).thenReturn(null);
|
||||||
when(files.upload(any(),eq(5L),eq(7L),eq("LOADING_LIST_CUSTOMS_RESULT"),eq("9"),any()))
|
when(files.upload(any(),eq(5L),eq(7L),eq("LOADING_LIST_CUSTOMS_RECEIPT"),eq("9"),any()))
|
||||||
.thenReturn(com.baiyue.common.core.api.ApiResponse.success(new CustomsFileClient.Metadata(99L,7L,
|
.thenReturn(com.baiyue.common.core.api.ApiResponse.success(new CustomsFileClient.Metadata(99L,7L,
|
||||||
"LOADING_LIST_CUSTOMS_RESULT","9","result.bin","b".repeat(64),"application/octet-stream",1L)));
|
"LOADING_LIST_CUSTOMS_RECEIPT","9","result.bin","b".repeat(64),"application/octet-stream",1L)));
|
||||||
when(mapper.insertCompletion(anyLong(),eq(1L),eq(99L),anyString(),anyString(),anyString(),eq(1L),eq(5L),eq("admin"))).thenReturn(1);
|
when(mapper.insertCompletion(anyLong(),eq(1L),eq(99L),anyString(),anyString(),anyString(),eq(1L),eq(5L),eq("admin"))).thenReturn(1);
|
||||||
when(mapper.insertCompletionVersion(anyLong(),anyLong(),eq(1),eq(99L),anyString(),anyString(),anyString(),eq(1L),eq(5L),eq("admin"))).thenReturn(1);
|
when(mapper.insertCompletionVersion(anyLong(),anyLong(),eq(1),eq(99L),anyString(),anyString(),anyString(),eq(1L),eq(5L),eq("admin"))).thenReturn(1);
|
||||||
when(mapper.frozenScopes(1L)).thenReturn(List.of(new ContainerCustomsMapper.ScopeRow(1L,11L,21L,"I")));
|
when(mapper.frozenScopes(1L)).thenReturn(List.of(new ContainerCustomsMapper.ScopeRow(1L,11L,21L,"I")));
|
||||||
@@ -259,7 +259,7 @@ class ContainerCustomsServiceTest {
|
|||||||
var file=new MockMultipartFile("file","result.bin","application/octet-stream",new byte[]{1});
|
var file=new MockMultipartFile("file","result.bin","application/octet-stream",new byte[]{1});
|
||||||
var result=service.uploadCompletion(1L,5L,"admin","k",0,file);
|
var result=service.uploadCompletion(1L,5L,"admin","k",0,file);
|
||||||
assertThat(result).isNotNull();
|
assertThat(result).isNotNull();
|
||||||
verify(files).upload(any(),eq(5L),eq(7L),eq("LOADING_LIST_CUSTOMS_RESULT"),eq("9"),same(file));
|
verify(files).upload(any(),eq(5L),eq(7L),eq("LOADING_LIST_CUSTOMS_RECEIPT"),eq("9"),same(file));
|
||||||
verify(mapper,never()).complete(anyLong(),anyInt(),anyLong(),anyString());
|
verify(mapper,never()).complete(anyLong(),anyInt(),anyLong(),anyString());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -274,6 +274,18 @@ class ContainerCustomsServiceTest {
|
|||||||
verifyNoInteractions(files);
|
verifyNoInteractions(files);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test void receiptCannotBeReplacedAfterBrokerSubmission(){
|
||||||
|
var source=document(1L);when(mapper.document(1L)).thenReturn(source);
|
||||||
|
when(mapper.claimCommand(anyLong(),eq(5L),eq("UPLOAD_COMPLETION_1"),eq("broker-sent-key"),anyString())).thenReturn(1);
|
||||||
|
when(mapper.lockContainerById(2L)).thenReturn(container(ContainerCustomsService.DECLARING,4));
|
||||||
|
when(mapper.lockDocument(1L)).thenReturn(source);
|
||||||
|
when(mapper.brokerSubmittedAt(2L)).thenReturn(java.time.LocalDateTime.now());
|
||||||
|
var file=new MockMultipartFile("file","result.bin","application/octet-stream",new byte[]{1});
|
||||||
|
assertThatThrownBy(()->service.uploadCompletion(1L,5L,"admin","broker-sent-key",0,file))
|
||||||
|
.isInstanceOf(BizException.class).hasMessageContaining("不可再修改回执");
|
||||||
|
verifyNoInteractions(files);
|
||||||
|
}
|
||||||
|
|
||||||
@Test void receiptReplaySucceedsAndDoesNotCompleteContainer(){
|
@Test void receiptReplaySucceedsAndDoesNotCompleteContainer(){
|
||||||
var source=document(1L);
|
var source=document(1L);
|
||||||
var declaring=container(ContainerCustomsService.DECLARING,3);
|
var declaring=container(ContainerCustomsService.DECLARING,3);
|
||||||
@@ -282,7 +294,7 @@ class ContainerCustomsServiceTest {
|
|||||||
var commandState=new AtomicReference<ContainerCustomsMapper.CommandRow>();
|
var commandState=new AtomicReference<ContainerCustomsMapper.CommandRow>();
|
||||||
var file=new MockMultipartFile("file","result.pdf","application/pdf",new byte[]{1});
|
var file=new MockMultipartFile("file","result.pdf","application/pdf",new byte[]{1});
|
||||||
var changedFile=new MockMultipartFile("file","result.pdf","application/pdf",new byte[]{2});
|
var changedFile=new MockMultipartFile("file","result.pdf","application/pdf",new byte[]{2});
|
||||||
var metadata=new CustomsFileClient.Metadata(99L,7L,"LOADING_LIST_CUSTOMS_RESULT","9","result.pdf",
|
var metadata=new CustomsFileClient.Metadata(99L,7L,"LOADING_LIST_CUSTOMS_RECEIPT","9","result.pdf",
|
||||||
"b".repeat(64),"application/pdf",1L);
|
"b".repeat(64),"application/pdf",1L);
|
||||||
when(mapper.document(1L)).thenReturn(source);
|
when(mapper.document(1L)).thenReturn(source);
|
||||||
when(mapper.claimCommand(anyLong(),eq(5L),eq("UPLOAD_COMPLETION_1"),eq("completion-key"),anyString()))
|
when(mapper.claimCommand(anyLong(),eq(5L),eq("UPLOAD_COMPLETION_1"),eq("completion-key"),anyString()))
|
||||||
@@ -296,7 +308,7 @@ class ContainerCustomsServiceTest {
|
|||||||
when(mapper.lockContainerById(2L)).thenAnswer(invocation->containerState.get());
|
when(mapper.lockContainerById(2L)).thenAnswer(invocation->containerState.get());
|
||||||
when(mapper.lockDocument(1L)).thenReturn(source);
|
when(mapper.lockDocument(1L)).thenReturn(source);
|
||||||
when(mapper.lockCompletion(1L)).thenAnswer(invocation->completionState.get());
|
when(mapper.lockCompletion(1L)).thenAnswer(invocation->completionState.get());
|
||||||
when(files.upload(any(),eq(5L),eq(7L),eq("LOADING_LIST_CUSTOMS_RESULT"),eq("9"),same(file)))
|
when(files.upload(any(),eq(5L),eq(7L),eq("LOADING_LIST_CUSTOMS_RECEIPT"),eq("9"),same(file)))
|
||||||
.thenReturn(com.baiyue.common.core.api.ApiResponse.success(metadata));
|
.thenReturn(com.baiyue.common.core.api.ApiResponse.success(metadata));
|
||||||
when(mapper.insertCompletion(anyLong(),eq(1L),eq(99L),eq("result.pdf"),eq("b".repeat(64)),
|
when(mapper.insertCompletion(anyLong(),eq(1L),eq(99L),eq("result.pdf"),eq("b".repeat(64)),
|
||||||
eq("application/pdf"),eq(1L),eq(5L),eq("admin"))).thenAnswer(invocation->{
|
eq("application/pdf"),eq(1L),eq(5L),eq("admin"))).thenAnswer(invocation->{
|
||||||
@@ -319,12 +331,12 @@ class ContainerCustomsServiceTest {
|
|||||||
var first=service.uploadCompletion(1L,5L,"admin","completion-key",0,file);
|
var first=service.uploadCompletion(1L,5L,"admin","completion-key",0,file);
|
||||||
var replay=service.uploadCompletion(1L,5L,"admin","completion-key",0,file);
|
var replay=service.uploadCompletion(1L,5L,"admin","completion-key",0,file);
|
||||||
|
|
||||||
assertThat(first.completion().fileId()).isEqualTo(99L);
|
assertThat(first.receipt().fileId()).isEqualTo(99L);
|
||||||
assertThat(replay.completion().fileId()).isEqualTo(99L);
|
assertThat(replay.receipt().fileId()).isEqualTo(99L);
|
||||||
assertThatThrownBy(()->service.uploadCompletion(1L,5L,"admin","completion-key",0,changedFile))
|
assertThatThrownBy(()->service.uploadCompletion(1L,5L,"admin","completion-key",0,changedFile))
|
||||||
.isInstanceOfSatisfying(BizException.class,
|
.isInstanceOfSatisfying(BizException.class,
|
||||||
error->assertThat(error.getResultCode()).isEqualTo(ResultCode.CONFLICT));
|
error->assertThat(error.getResultCode()).isEqualTo(ResultCode.CONFLICT));
|
||||||
verify(files,times(1)).upload(any(),eq(5L),eq(7L),eq("LOADING_LIST_CUSTOMS_RESULT"),eq("9"),same(file));
|
verify(files,times(1)).upload(any(),eq(5L),eq(7L),eq("LOADING_LIST_CUSTOMS_RECEIPT"),eq("9"),same(file));
|
||||||
verify(mapper,times(1)).lockContainerById(2L);
|
verify(mapper,times(1)).lockContainerById(2L);
|
||||||
verify(mapper,times(1)).insertCompletion(anyLong(),eq(1L),eq(99L),anyString(),anyString(),anyString(),
|
verify(mapper,times(1)).insertCompletion(anyLong(),eq(1L),eq(99L),anyString(),anyString(),anyString(),
|
||||||
eq(1L),eq(5L),eq("admin"));
|
eq(1L),eq(5L),eq("admin"));
|
||||||
@@ -335,7 +347,7 @@ class ContainerCustomsServiceTest {
|
|||||||
var source=document(1L);when(mapper.document(1L)).thenReturn(source);
|
var source=document(1L);when(mapper.document(1L)).thenReturn(source);
|
||||||
when(mapper.containerById(2L)).thenReturn(container(ContainerCustomsService.DECLARING,3));
|
when(mapper.containerById(2L)).thenReturn(container(ContainerCustomsService.DECLARING,3));
|
||||||
assertThatThrownBy(()->service.downloadCompletion(7L,1L,false)).isInstanceOf(BizException.class)
|
assertThatThrownBy(()->service.downloadCompletion(7L,1L,false)).isInstanceOf(BizException.class)
|
||||||
.hasMessageContaining("完成文件不存在");
|
.hasMessageContaining("报关行回执不存在");
|
||||||
verifyNoInteractions(files);
|
verifyNoInteractions(files);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+5
@@ -21,6 +21,9 @@ class CustomsSqlContractTest {
|
|||||||
"fl_customs_frozen_document_inbound",
|
"fl_customs_frozen_document_inbound",
|
||||||
"fl_customs_completion_document",
|
"fl_customs_completion_document",
|
||||||
"fl_customs_completion_document_version",
|
"fl_customs_completion_document_version",
|
||||||
|
"fl_customs_final_document",
|
||||||
|
"fl_customs_declaration_batch",
|
||||||
|
"fl_customs_clearance_result",
|
||||||
"fl_customs_container_history",
|
"fl_customs_container_history",
|
||||||
"fl_customs_command");
|
"fl_customs_command");
|
||||||
private static final Pattern CREATE_TABLE = Pattern.compile(
|
private static final Pattern CREATE_TABLE = Pattern.compile(
|
||||||
@@ -38,6 +41,8 @@ class CustomsSqlContractTest {
|
|||||||
.contains("fl_customs_customer_document_version", "scope_snapshot_json JSON NOT NULL")
|
.contains("fl_customs_customer_document_version", "scope_snapshot_json JSON NOT NULL")
|
||||||
.contains("fl_customs_frozen_document_inbound", "fl_customs_completion_document_version")
|
.contains("fl_customs_frozen_document_inbound", "fl_customs_completion_document_version")
|
||||||
.contains("UNIQUE KEY uk_customs_completion_source(source_document_id)")
|
.contains("UNIQUE KEY uk_customs_completion_source(source_document_id)")
|
||||||
|
.contains("fl_customs_declaration_batch", "fl_customs_clearance_result", "customs_entry_no")
|
||||||
|
.contains("INSPECTION", "SUPPLEMENT_REQUIRED", "RELEASED", "DETAINED")
|
||||||
.contains("fl_customs_command", "uk_customs_command(actor_id,action,idempotency_key)")
|
.contains("fl_customs_command", "uk_customs_command(actor_id,action,idempotency_key)")
|
||||||
.doesNotContain("platform:firstleg:loading-list:export");
|
.doesNotContain("platform:firstleg:loading-list:export");
|
||||||
}
|
}
|
||||||
|
|||||||
+29
@@ -2,6 +2,8 @@ package com.baiyue.firstleg.repository;
|
|||||||
|
|
||||||
import com.baiyue.firstleg.entity.ContainerBookingEntity;
|
import com.baiyue.firstleg.entity.ContainerBookingEntity;
|
||||||
import com.baiyue.firstleg.mapper.ContainerBookingMapper;
|
import com.baiyue.firstleg.mapper.ContainerBookingMapper;
|
||||||
|
import com.baiyue.firstleg.portschedule.entity.PortScheduleEntity;
|
||||||
|
import com.baiyue.firstleg.portschedule.mapper.PortScheduleMapper;
|
||||||
import org.junit.jupiter.api.Test;
|
import org.junit.jupiter.api.Test;
|
||||||
import org.junit.jupiter.api.extension.ExtendWith;
|
import org.junit.jupiter.api.extension.ExtendWith;
|
||||||
import org.mockito.InjectMocks;
|
import org.mockito.InjectMocks;
|
||||||
@@ -16,6 +18,7 @@ import static org.mockito.Mockito.when;
|
|||||||
@ExtendWith(MockitoExtension.class)
|
@ExtendWith(MockitoExtension.class)
|
||||||
class ContainerRepositoryLoadingTimeTest {
|
class ContainerRepositoryLoadingTimeTest {
|
||||||
@Mock private ContainerBookingMapper mapper;
|
@Mock private ContainerBookingMapper mapper;
|
||||||
|
@Mock private PortScheduleMapper schedules;
|
||||||
@InjectMocks private ContainerRepository repository;
|
@InjectMocks private ContainerRepository repository;
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -44,4 +47,30 @@ class ContainerRepositoryLoadingTimeTest {
|
|||||||
|
|
||||||
assertThat(result.getLoadingAt()).isEqualTo(plannedAt);
|
assertThat(result.getLoadingAt()).isEqualTo(plannedAt);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void projectsLatestPortScheduleWithoutChangingContainerIdentity() {
|
||||||
|
ContainerBookingEntity booking = new ContainerBookingEntity();
|
||||||
|
booking.setId(8L);
|
||||||
|
booking.setCustomerId(9L);
|
||||||
|
booking.setOceanBookingId(10L);
|
||||||
|
booking.setContainerNo("TSTU0000001");
|
||||||
|
booking.setVesselName("OLD-VESSEL");
|
||||||
|
booking.setVoyageNo("OLD-V01");
|
||||||
|
PortScheduleEntity schedule = new PortScheduleEntity();
|
||||||
|
schedule.setVesselName("NEW-VESSEL");
|
||||||
|
schedule.setVoyageNo("NEW-V01");
|
||||||
|
schedule.setEtdAt(LocalDateTime.of(2026, 10, 7, 8, 0));
|
||||||
|
schedule.setEtaAt(LocalDateTime.of(2026, 10, 27, 8, 0));
|
||||||
|
when(mapper.selectBooking(8L)).thenReturn(booking);
|
||||||
|
when(schedules.latestForDispatch(9L, 10L, 8L)).thenReturn(schedule);
|
||||||
|
|
||||||
|
ContainerBookingEntity result = repository.find(8L);
|
||||||
|
|
||||||
|
assertThat(result.getContainerNo()).isEqualTo("TSTU0000001");
|
||||||
|
assertThat(result.getVesselName()).isEqualTo("NEW-VESSEL");
|
||||||
|
assertThat(result.getVoyageNo()).isEqualTo("NEW-V01");
|
||||||
|
assertThat(result.getEstimatedSailingAt()).isEqualTo(schedule.getEtdAt());
|
||||||
|
assertThat(result.getEstimatedPortArrivalAt()).isEqualTo(schedule.getEtaAt());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-1
@@ -73,7 +73,7 @@ class Phase3FinalUnifiedSqlContractTest {
|
|||||||
@Test
|
@Test
|
||||||
void initializationPassIsUniqueAndFinal() throws Exception {
|
void initializationPassIsUniqueAndFinal() throws Exception {
|
||||||
String sql = sql();
|
String sql = sql();
|
||||||
String pass = "SELECT 'PASS' AS initialization_status, 'CN' AS region, 210 AS initialized_tables;";
|
String pass = "SELECT 'PASS' AS initialization_status, 'CN' AS region, 219 AS initialized_tables;";
|
||||||
assertThat(count(sql, pass)).isEqualTo(1);
|
assertThat(count(sql, pass)).isEqualTo(1);
|
||||||
assertThat(sql.trim()).endsWith(pass);
|
assertThat(sql.trim()).endsWith(pass);
|
||||||
assertThat(sql)
|
assertThat(sql)
|
||||||
|
|||||||
+35
@@ -0,0 +1,35 @@
|
|||||||
|
package com.baiyue.firstleg.sailingcalendar.controller;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.springframework.core.io.ClassPathResource;
|
||||||
|
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
|
||||||
|
class SailingCalendarOpenApiContractTest {
|
||||||
|
@Test
|
||||||
|
void publishesEditAndHistoryOnTheControllerPaths() throws Exception {
|
||||||
|
String yaml = new ClassPathResource("openapi/sailing-calendar-v1.yaml")
|
||||||
|
.getContentAsString(StandardCharsets.UTF_8);
|
||||||
|
String editPath = "/api/platform/v1/sailings/{id}:";
|
||||||
|
String historyPath = "/api/platform/v1/sailings/{id}/history:";
|
||||||
|
int editStart = yaml.indexOf(editPath);
|
||||||
|
int historyStart = yaml.indexOf(historyPath);
|
||||||
|
|
||||||
|
assertThat(editStart).isGreaterThanOrEqualTo(0);
|
||||||
|
assertThat(historyStart).isGreaterThan(editStart);
|
||||||
|
String editBlock = yaml.substring(editStart, historyStart);
|
||||||
|
String historyBlock = yaml.substring(historyStart);
|
||||||
|
int nextPath = historyBlock.indexOf("\n /api/");
|
||||||
|
if (nextPath >= 0) historyBlock = historyBlock.substring(0, nextPath);
|
||||||
|
|
||||||
|
assertThat(editBlock)
|
||||||
|
.contains(" put:")
|
||||||
|
.contains("x-permission: platform:firstleg:sailing-calendar:edit");
|
||||||
|
assertThat(historyBlock)
|
||||||
|
.contains(" get:")
|
||||||
|
.contains("summary: 查询船期变更历史")
|
||||||
|
.doesNotContain(" put:");
|
||||||
|
}
|
||||||
|
}
|
||||||
+52
@@ -0,0 +1,52 @@
|
|||||||
|
package com.baiyue.firstleg.sailingcalendar.fact;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.springframework.core.io.ClassPathResource;
|
||||||
|
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.nio.file.Files;
|
||||||
|
import java.nio.file.Path;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
|
||||||
|
class SailingFactsSqlContractTest {
|
||||||
|
@Test
|
||||||
|
void definesVersionedReceiptForecastAssignmentAndBookingFacts() throws Exception {
|
||||||
|
String sql = new ClassPathResource("db/phase4-sailing-facts-schema.sql")
|
||||||
|
.getContentAsString(StandardCharsets.UTF_8);
|
||||||
|
assertThat(sql)
|
||||||
|
.contains("CREATE TABLE fl_sailing_receipt_batch_fact")
|
||||||
|
.contains("CREATE TABLE fl_sailing_inbound_volume_forecast")
|
||||||
|
.contains("CREATE TABLE fl_sailing_assignment")
|
||||||
|
.contains("CREATE TABLE fl_sailing_booking_link")
|
||||||
|
.contains("receipt_batch_key VARCHAR(64) NOT NULL")
|
||||||
|
.contains("fact_version INT UNSIGNED NOT NULL")
|
||||||
|
.contains("received_at DATETIME(3) NOT NULL")
|
||||||
|
.contains("actual_volume_cbm DECIMAL(18,4) NOT NULL")
|
||||||
|
.contains("expected_arrival_at DATETIME(3) NOT NULL")
|
||||||
|
.contains("forecast_volume_cbm DECIMAL(18,4) NOT NULL")
|
||||||
|
.contains("match_status IN ('FORECAST_MATCHED','RECEIVED','LATE','UNMATCHED','SUPERSEDED')")
|
||||||
|
.contains("booking_status_snapshot='RELEASED'")
|
||||||
|
.contains("UNIQUE KEY uk_sailing_booking_active (ocean_booking_id, active_booking_guard)");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void doesNotUseLegacyInboundTimestampOrDeclarationVolumeAsFact() throws Exception {
|
||||||
|
String sql = new ClassPathResource("db/phase4-sailing-facts-schema.sql")
|
||||||
|
.getContentAsString(StandardCharsets.UTF_8);
|
||||||
|
assertThat(sql)
|
||||||
|
.doesNotContain("inbounded_at DATETIME")
|
||||||
|
.doesNotContain("original_volume_cbm DECIMAL");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void unifiedInitializationIncludesThePhase4FactsInTheFirstlegSchema() throws Exception {
|
||||||
|
String unified = Files.readString(Path.of("../deploy/sql/头程数据库统一增量-MySQL8.sql"));
|
||||||
|
assertThat(unified)
|
||||||
|
.contains("-- phase4-sailing-calendar-schema.sql")
|
||||||
|
.contains("-- phase4-sailing-facts-schema.sql")
|
||||||
|
.contains("USE baiyue_firstleg;")
|
||||||
|
.contains("CREATE TABLE fl_sailing_receipt_batch_fact")
|
||||||
|
.contains("CREATE TABLE fl_sailing_booking_link");
|
||||||
|
}
|
||||||
|
}
|
||||||
+36
@@ -0,0 +1,36 @@
|
|||||||
|
package com.baiyue.firstleg.sailingcalendar.mapper;
|
||||||
|
|
||||||
|
import com.baomidou.mybatisplus.core.MybatisConfiguration;
|
||||||
|
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||||
|
import com.baomidou.mybatisplus.core.toolkit.Constants;
|
||||||
|
import com.baiyue.firstleg.sailingcalendar.entity.SailingChannelEntity;
|
||||||
|
import org.apache.ibatis.binding.MapperMethod;
|
||||||
|
import org.apache.ibatis.mapping.BoundSql;
|
||||||
|
import org.apache.ibatis.mapping.MappedStatement;
|
||||||
|
import org.apache.ibatis.scripting.defaults.DefaultParameterHandler;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
import java.sql.PreparedStatement;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
import static org.mockito.Mockito.mock;
|
||||||
|
import static org.mockito.Mockito.verify;
|
||||||
|
|
||||||
|
class SailingChannelMapperTest {
|
||||||
|
@Test
|
||||||
|
void inheritedSelectListStillBindsWrapperForChannelCodeLookup() throws Exception {
|
||||||
|
MybatisConfiguration configuration = new MybatisConfiguration();
|
||||||
|
configuration.addMapper(SailingChannelMapper.class);
|
||||||
|
MappedStatement statement = configuration.getMappedStatement(SailingChannelMapper.class.getName() + ".selectList");
|
||||||
|
MapperMethod.ParamMap<Object> parameters = new MapperMethod.ParamMap<>();
|
||||||
|
parameters.put(Constants.WRAPPER, new LambdaQueryWrapper<SailingChannelEntity>()
|
||||||
|
.eq(SailingChannelEntity::getChannelCode, "MATSON"));
|
||||||
|
BoundSql sql = statement.getBoundSql(parameters);
|
||||||
|
PreparedStatement preparedStatement = mock(PreparedStatement.class);
|
||||||
|
|
||||||
|
new DefaultParameterHandler(statement, parameters, sql).setParameters(preparedStatement);
|
||||||
|
|
||||||
|
assertThat(sql.getSql()).contains("channel_code = ?");
|
||||||
|
verify(preparedStatement).setString(1, "MATSON");
|
||||||
|
}
|
||||||
|
}
|
||||||
+89
@@ -0,0 +1,89 @@
|
|||||||
|
package com.baiyue.firstleg.sailingcalendar.service;
|
||||||
|
|
||||||
|
import com.baiyue.common.core.exception.BizException;
|
||||||
|
import com.baiyue.firstleg.sailingcalendar.dto.SailingCalendarDtos.CopyRequest;
|
||||||
|
import com.baiyue.firstleg.sailingcalendar.entity.SailingEntity;
|
||||||
|
import com.baiyue.firstleg.sailingcalendar.entity.SailingHistoryEntity;
|
||||||
|
import com.baiyue.firstleg.sailingcalendar.repository.SailingCalendarRepository;
|
||||||
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.mockito.InjectMocks;
|
||||||
|
import org.mockito.Mock;
|
||||||
|
import org.mockito.MockitoAnnotations;
|
||||||
|
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
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.Mockito.never;
|
||||||
|
import static org.mockito.Mockito.verify;
|
||||||
|
import static org.mockito.Mockito.when;
|
||||||
|
|
||||||
|
class SailingCalendarServiceTest {
|
||||||
|
@Mock private SailingCalendarRepository repository;
|
||||||
|
@InjectMocks private SailingCalendarService service;
|
||||||
|
|
||||||
|
@BeforeEach
|
||||||
|
void setUp() {
|
||||||
|
MockitoAnnotations.openMocks(this);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void replaysAnExistingCopyInsteadOfReturningAnInternalError() {
|
||||||
|
SailingEntity source = sailing(1L, "SL-SOURCE", "MANUAL", "QA-20261005", LocalDateTime.of(2026, 10, 5, 10, 0));
|
||||||
|
SailingEntity existing = sailing(2L, "SL-COPY", "COPY", "QA-20261005", LocalDateTime.of(2026, 10, 12, 10, 0));
|
||||||
|
SailingHistoryEntity history = new SailingHistoryEntity();
|
||||||
|
history.setAction("COPY"); history.setReason("复制自SL-SOURCE"); history.setSailingId(2L);
|
||||||
|
when(repository.sailing(1L)).thenReturn(source);
|
||||||
|
when(repository.sailing(2L)).thenReturn(existing);
|
||||||
|
when(repository.sailingByBusinessKey(8L, "MATSON", "CNNGB", "USLGB", existing.getEtdAt(), "QA-20261005")).thenReturn(existing);
|
||||||
|
when(repository.histories(2L)).thenReturn(List.of(history));
|
||||||
|
when(repository.receiptSummary(any(SailingEntity.class))).thenReturn(null);
|
||||||
|
when(repository.bookingSummary(any(SailingEntity.class))).thenReturn(null);
|
||||||
|
|
||||||
|
var result = service.copy(1L, 7L, "admin",
|
||||||
|
new CopyRequest(existing.getEtdAt(), LocalDateTime.of(2026, 10, 9, 18, 0)));
|
||||||
|
|
||||||
|
assertThat(result.sailing().id()).isEqualTo(2L);
|
||||||
|
verify(repository, never()).insert(any(SailingEntity.class));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void reportsBusinessConflictForAnUnrelatedExistingSailing() {
|
||||||
|
SailingEntity source = sailing(1L, "SL-SOURCE", "MANUAL", "QA-20261005", LocalDateTime.of(2026, 10, 5, 10, 0));
|
||||||
|
SailingEntity existing = sailing(2L, "SL-OTHER", "TEMPLATE", "QA-20261005", LocalDateTime.of(2026, 10, 12, 10, 0));
|
||||||
|
when(repository.sailing(1L)).thenReturn(source);
|
||||||
|
when(repository.sailingByBusinessKey(8L, "MATSON", "CNNGB", "USLGB", existing.getEtdAt(), "QA-20261005")).thenReturn(existing);
|
||||||
|
|
||||||
|
assertThatThrownBy(() -> service.copy(1L, 7L, "admin",
|
||||||
|
new CopyRequest(existing.getEtdAt(), LocalDateTime.of(2026, 10, 9, 18, 0))))
|
||||||
|
.isInstanceOf(BizException.class)
|
||||||
|
.hasMessageContaining("已存在航次 QA-20261005");
|
||||||
|
verify(repository, never()).insert(any(SailingEntity.class));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void rejectsCutoffAtOrAfterCopiedDeparture() {
|
||||||
|
SailingEntity source = sailing(1L, "SL-SOURCE", "MANUAL", "QA-20261005", LocalDateTime.of(2026, 10, 5, 10, 0));
|
||||||
|
when(repository.sailing(1L)).thenReturn(source);
|
||||||
|
|
||||||
|
assertThatThrownBy(() -> service.copy(1L, 7L, "admin",
|
||||||
|
new CopyRequest(LocalDateTime.of(2026, 10, 12, 10, 0), LocalDateTime.of(2026, 10, 12, 10, 0))))
|
||||||
|
.isInstanceOf(BizException.class)
|
||||||
|
.hasMessageContaining("收货截止必须早于开船");
|
||||||
|
verify(repository, never()).insert(any(SailingEntity.class));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static SailingEntity sailing(long id, String sailingNo, String sourceType, String voyageNo, LocalDateTime etdAt) {
|
||||||
|
SailingEntity value = new SailingEntity();
|
||||||
|
value.setId(id); value.setSailingNo(sailingNo); value.setSourceType(sourceType); value.setVoyageNo(voyageNo);
|
||||||
|
value.setWarehouseId(8L); value.setChannelCodeSnapshot("MATSON"); value.setChannelNameSnapshot("美森正班");
|
||||||
|
value.setDeparturePortCode("CNNGB"); value.setDestinationPortCode("USLGB"); value.setVesselName("MATSON");
|
||||||
|
value.setEtdAt(etdAt); value.setWarehouseCutoffAt(etdAt.minusDays(3).withHour(18)); value.setStatus("OPEN");
|
||||||
|
value.setVersion(0); value.setPlannedCapacityCbm(BigDecimal.ZERO);
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -21,6 +21,7 @@ import org.springframework.test.context.junit.jupiter.SpringJUnitConfig;
|
|||||||
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.math.BigDecimal;
|
import java.math.BigDecimal;
|
||||||
|
import java.time.LocalDateTime;
|
||||||
import java.util.concurrent.Callable;
|
import java.util.concurrent.Callable;
|
||||||
import java.util.concurrent.CountDownLatch;
|
import java.util.concurrent.CountDownLatch;
|
||||||
import java.util.concurrent.Executors;
|
import java.util.concurrent.Executors;
|
||||||
@@ -34,6 +35,7 @@ import static org.mockito.ArgumentMatchers.anyBoolean;
|
|||||||
import static org.mockito.ArgumentMatchers.anyInt;
|
import static org.mockito.ArgumentMatchers.anyInt;
|
||||||
import static org.mockito.ArgumentMatchers.anyLong;
|
import static org.mockito.ArgumentMatchers.anyLong;
|
||||||
import static org.mockito.ArgumentMatchers.anyString;
|
import static org.mockito.ArgumentMatchers.anyString;
|
||||||
|
import static org.mockito.ArgumentMatchers.eq;
|
||||||
import static org.mockito.Mockito.mock;
|
import static org.mockito.Mockito.mock;
|
||||||
import static org.mockito.Mockito.never;
|
import static org.mockito.Mockito.never;
|
||||||
import static org.mockito.Mockito.verify;
|
import static org.mockito.Mockito.verify;
|
||||||
@@ -100,6 +102,7 @@ class MeasurementReviewServiceTest {
|
|||||||
assertThat(history.getValue().getToStatus()).isEqualTo("WAIT_SPLIT");
|
assertThat(history.getValue().getToStatus()).isEqualTo("WAIT_SPLIT");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@DisplayName("审核通过使用订单权威货物总重量,不依赖箱规重量")
|
@DisplayName("审核通过使用订单权威货物总重量,不依赖箱规重量")
|
||||||
void shouldApproveWithActualGrossWeightWhenCartonWeightIsMissing() {
|
void shouldApproveWithActualGrossWeightWhenCartonWeightIsMissing() {
|
||||||
@@ -298,6 +301,8 @@ class MeasurementReviewServiceTest {
|
|||||||
.thenReturn(new InboundIdempotencyService.Context(10001L, 1L, false));
|
.thenReturn(new InboundIdempotencyService.Context(10001L, 1L, false));
|
||||||
when(properties.isAllowRejectedMeasurement()).thenReturn(allowRelease);
|
when(properties.isAllowRejectedMeasurement()).thenReturn(allowRelease);
|
||||||
InboundOrderEntity order = new InboundOrderEntity();
|
InboundOrderEntity order = new InboundOrderEntity();
|
||||||
|
order.setId(10L);
|
||||||
|
order.setTenantId(10001L);
|
||||||
order.setProcessStatus("MEASURE_PENDING");
|
order.setProcessStatus("MEASURE_PENDING");
|
||||||
order.setOrderStatusSnapshot("APPROVED");
|
order.setOrderStatusSnapshot("APPROVED");
|
||||||
order.setMeasureReviewStatus("PENDING");
|
order.setMeasureReviewStatus("PENDING");
|
||||||
@@ -309,6 +314,7 @@ class MeasurementReviewServiceTest {
|
|||||||
InboundMeasurementEntity measurement = new InboundMeasurementEntity();
|
InboundMeasurementEntity measurement = new InboundMeasurementEntity();
|
||||||
measurement.setId(20L);
|
measurement.setId(20L);
|
||||||
measurement.setStatus("SUBMITTED");
|
measurement.setStatus("SUBMITTED");
|
||||||
|
measurement.setMeasuredAt(LocalDateTime.of(2026, 9, 20, 10, 0));
|
||||||
measurement.setTotalPackageCount(5);
|
measurement.setTotalPackageCount(5);
|
||||||
measurement.setTotalVolumeCbm(new BigDecimal("0.7480"));
|
measurement.setTotalVolumeCbm(new BigDecimal("0.7480"));
|
||||||
measurement.setVolumeWeightKg(new BigDecimal("124.916"));
|
measurement.setVolumeWeightKg(new BigDecimal("124.916"));
|
||||||
@@ -346,3 +352,4 @@ class MeasurementReviewServiceTest {
|
|||||||
InboundMeasurementEntity measurement) {
|
InboundMeasurementEntity measurement) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+13
-3
@@ -59,6 +59,7 @@ class MojiaMeasurementOpenApiServiceTest {
|
|||||||
var service = new MojiaMeasurementOpenApiService(authenticator, cartons,
|
var service = new MojiaMeasurementOpenApiService(authenticator, cartons,
|
||||||
new ObjectMapper().findAndRegisterModules(), Validation.buildDefaultValidatorFactory().getValidator(),
|
new ObjectMapper().findAndRegisterModules(), Validation.buildDefaultValidatorFactory().getValidator(),
|
||||||
images, receipts, labels);
|
images, receipts, labels);
|
||||||
|
ReflectionTestUtils.setField(service, "internalToken", "internal-token");
|
||||||
MeasurementRequestHeaders headers = new MeasurementRequestHeaders("mojia", null, null, null, null);
|
MeasurementRequestHeaders headers = new MeasurementRequestHeaders("mojia", null, null, null, null);
|
||||||
when(authenticator.authenticate("POST", MojiaMeasurementOpenApiService.CALLBACK_PATH,
|
when(authenticator.authenticate("POST", MojiaMeasurementOpenApiService.CALLBACK_PATH,
|
||||||
headers, json(), false)).thenReturn(new AuthenticatedMeasurementApp(
|
headers, json(), false)).thenReturn(new AuthenticatedMeasurementApp(
|
||||||
@@ -170,6 +171,7 @@ class MojiaMeasurementOpenApiServiceTest {
|
|||||||
var service = new MojiaMeasurementOpenApiService(authenticator, cartons,
|
var service = new MojiaMeasurementOpenApiService(authenticator, cartons,
|
||||||
new ObjectMapper().findAndRegisterModules(), Validation.buildDefaultValidatorFactory().getValidator(),
|
new ObjectMapper().findAndRegisterModules(), Validation.buildDefaultValidatorFactory().getValidator(),
|
||||||
images, receipts, labels);
|
images, receipts, labels);
|
||||||
|
ReflectionTestUtils.setField(service, "internalToken", "internal-token");
|
||||||
MeasurementRequestHeaders headers = new MeasurementRequestHeaders("mojia", null, null, null, null);
|
MeasurementRequestHeaders headers = new MeasurementRequestHeaders("mojia", null, null, null, null);
|
||||||
when(authenticator.authenticate("POST", MojiaMeasurementOpenApiService.CALLBACK_PATH,
|
when(authenticator.authenticate("POST", MojiaMeasurementOpenApiService.CALLBACK_PATH,
|
||||||
headers, json(), false)).thenReturn(new AuthenticatedMeasurementApp(
|
headers, json(), false)).thenReturn(new AuthenticatedMeasurementApp(
|
||||||
@@ -178,10 +180,18 @@ class MojiaMeasurementOpenApiServiceTest {
|
|||||||
.thenReturn(new InboundCartonLabelService.PlatformBinding(10002L, 30002L,
|
.thenReturn(new InboundCartonLabelService.PlatformBinding(10002L, 30002L,
|
||||||
new CartonLabelBindingResolver.Binding(
|
new CartonLabelBindingResolver.Binding(
|
||||||
70002L, 80002L, "PCHK2608140001", 1, 5, "60x40x30cm")));
|
70002L, 80002L, "PCHK2608140001", 1, 5, "60x40x30cm")));
|
||||||
|
when(images.uploadReceipt(eq("internal-token"), eq(90001L), eq(0L), any(Long.TYPE), any()))
|
||||||
|
.thenReturn(ApiResponse.success(new MeasurementImageClient.UploadResult(
|
||||||
|
9L, "mojia.jpg", "image/jpeg", 4L, "NOT_REQUIRED", null, null, null, null)));
|
||||||
|
|
||||||
org.assertj.core.api.Assertions.assertThatThrownBy(() -> service.receive(headers, json()))
|
UploadResponse result = service.receive(headers, json());
|
||||||
.isInstanceOf(BizException.class)
|
assertThat(result.accepted()).isTrue();
|
||||||
.hasMessageContaining("不属于当前设备绑定仓库");
|
assertThat(result.matched()).isFalse();
|
||||||
|
ArgumentCaptor<MojiaMeasurementReceiptEntity> receipt =
|
||||||
|
ArgumentCaptor.forClass(MojiaMeasurementReceiptEntity.class);
|
||||||
|
verify(receipts).insert(receipt.capture());
|
||||||
|
assertThat(receipt.getValue().getMeasurementKind()).isEqualTo("BOX_LABEL");
|
||||||
|
assertThat(receipt.getValue().getMatchFailureReason()).contains("不属于当前设备绑定仓库");
|
||||||
|
|
||||||
verify(cartons, org.mockito.Mockito.never()).upsert(any(Long.TYPE), any(Long.TYPE), any());
|
verify(cartons, org.mockito.Mockito.never()).upsert(any(Long.TYPE), any(Long.TYPE), any());
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -77,7 +77,7 @@ spring:
|
|||||||
- id: firstleg-service
|
- id: firstleg-service
|
||||||
uri: lb://firstleg-service
|
uri: lb://firstleg-service
|
||||||
predicates:
|
predicates:
|
||||||
- Path=/api/merchant/v1/first-leg/**,/api/merchant/v1/customs/**,/api/platform/v1/first-leg/**,/api/platform/v1/customs/**,/api/platform/v1/port-schedules/**,/api/platform/v1/carrier-schedule-queries/**,/api/platform/v1/container-shipping-nodes/**,/api/platform/v1/containers/**,/api/platform/v1/terminal-drayages/**,/api/platform/v1/customers/{customerId}/container-transit-summary,/api/platform/v1/ocean-bookings/**,/api/platform/v1/truck-appointments/**,/api/platform/v1/fba-pricing/**,/api/platform/v1/inbounds/**,/api/platform/v1/fee-items/**,/api/platform/v1/loading-plans/**,/api/platform/v1/firstleg/mojia-sku-sync/**,/api/platform/inbounds/**,/openapi/v1/inbound-measurements/**
|
- Path=/api/merchant/v1/first-leg/**,/api/merchant/v1/customs/**,/api/platform/v1/first-leg/**,/api/platform/v1/customs/**,/api/platform/v1/port-schedules/**,/api/platform/v1/carrier-schedule-queries/**,/api/platform/v1/container-shipping-nodes/**,/api/platform/v1/containers/**,/api/platform/v1/terminal-drayages/**,/api/platform/v1/customers/{customerId}/container-transit-summary,/api/platform/v1/ocean-bookings/**,/api/platform/v1/truck-appointments/**,/api/platform/v1/fba-pricing/**,/api/platform/v1/inbounds/**,/api/platform/v1/fee-items/**,/api/platform/v1/loading-plans/**,/api/platform/v1/sailings/**,/api/platform/v1/sailing-channels/**,/api/platform/v1/sailing-templates/**,/api/platform/v1/sailing-assignments/**,/api/platform/v1/sailing-forecasts/**,/api/platform/v1/firstleg/mojia-sku-sync/**,/api/platform/inbounds/**,/openapi/v1/inbound-measurements/**
|
||||||
- id: finance-service
|
- id: finance-service
|
||||||
uri: lb://finance-service
|
uri: lb://finance-service
|
||||||
predicates:
|
predicates:
|
||||||
|
|||||||
@@ -22,9 +22,9 @@ BEGIN
|
|||||||
(id, tenant_id, app_code, permission_type, parent_id, permission_code,
|
(id, tenant_id, app_code, permission_type, parent_id, permission_code,
|
||||||
permission_name, route_path, http_method, sort_no, status, built_in, deleted)
|
permission_name, route_path, http_method, sort_no, status, built_in, deleted)
|
||||||
VALUES
|
VALUES
|
||||||
(5102131, 0, 'PLATFORM', 'MENU', 5103000, 'platform:mojia-sku-sync:list', '一件代发SKU同步', '/general/mojia-sku-sync', 'GET', 130, 'ACTIVE', 1, 0),
|
(5102131, 0, 'PLATFORM', 'MENU', 5103000, 'platform:mojia-sku-sync:list', '称重机器', '/general/mojia-sku-sync', 'GET', 130, 'ACTIVE', 1, 0),
|
||||||
(5102132, 0, 'PLATFORM', 'BUTTON', 5102131, 'platform:mojia-sku-sync:edit', '编辑一件代发SKU测量数据', NULL, 'PUT', 10, 'ACTIVE', 1, 0),
|
(5102132, 0, 'PLATFORM', 'BUTTON', 5102131, 'platform:mojia-sku-sync:edit', '编辑SKU测量数据', NULL, 'PUT', 10, 'ACTIVE', 1, 0),
|
||||||
(5102133, 0, 'PLATFORM', 'BUTTON', 5102131, 'platform:mojia-sku-sync:retry', '重新推送一件代发SKU', NULL, 'POST', 20, 'ACTIVE', 1, 0)
|
(5102133, 0, 'PLATFORM', 'BUTTON', 5102131, 'platform:mojia-sku-sync:retry', '重新推送SKU测量', NULL, 'POST', 20, 'ACTIVE', 1, 0)
|
||||||
ON DUPLICATE KEY UPDATE
|
ON DUPLICATE KEY UPDATE
|
||||||
permission_type = VALUES(permission_type), permission_name = VALUES(permission_name), parent_id = VALUES(parent_id),
|
permission_type = VALUES(permission_type), permission_name = VALUES(permission_name), parent_id = VALUES(parent_id),
|
||||||
http_method = VALUES(http_method), status = 'ACTIVE', built_in = 1, deleted = 0;
|
http_method = VALUES(http_method), status = 'ACTIVE', built_in = 1, deleted = 0;
|
||||||
|
|||||||
@@ -4,6 +4,8 @@
|
|||||||
|
|
||||||
`db/recent-schema-incremental-20260923.sql`:汇总 2026-09-21 至 2026-09-22 的近期结构变更,依次显式指向 `baiyue_iam`、`baiyue_firstleg`、`baiyue_lastleg`、`baiyue_lastlag_warehouse`;补齐国内 PDA 作业仓字段及索引、人工 FBA 报价可空字段、墨家 SKU 测量字段、换标申请普通索引和 A0 共享溢出位字段。所有字段和索引变更均经 `information_schema` 判断,可重复执行;不创建数据库、不含凭据、权限种子或业务状态修正,末尾输出逐库 `READY/CHECK_REQUIRED`。由操作者选择并确认目标连接后执行,任一 DDL 报错立即停止。
|
`db/recent-schema-incremental-20260923.sql`:汇总 2026-09-21 至 2026-09-22 的近期结构变更,依次显式指向 `baiyue_iam`、`baiyue_firstleg`、`baiyue_lastleg`、`baiyue_lastlag_warehouse`;补齐国内 PDA 作业仓字段及索引、人工 FBA 报价可空字段、墨家 SKU 测量字段、换标申请普通索引和 A0 共享溢出位字段。所有字段和索引变更均经 `information_schema` 判断,可重复执行;不创建数据库、不含凭据、权限种子或业务状态修正,末尾输出逐库 `READY/CHECK_REQUIRED`。由操作者选择并确认目标连接后执行,任一 DDL 报错立即停止。
|
||||||
|
|
||||||
|
`db/customs-two-file-incremental-20260926.sql`:报关两文件流程增量结构,保留报关行回执与新增真实报关完税文件,补充“已发送给报关行执行”的操作人和时间;仅新增结构,可重复执行,不修改业务数据。
|
||||||
|
|
||||||
`local-return-relabel-acceptance.mjs` 的 `LOCAL_RETURN_VERIFY_REMAINING=true` 专项新建3件退货,先换1件并客户确认,再对同一退货单剩余2件中的1件新建第二申请,验证同键重放、第二轮完工后剩余1件;不执行出库,报告 scope 为 `REMAINING_RETURN_RELABEL`,不代表出库验收通过。仍保留单据、使用正式API和合成照片,默认单票模式不变。
|
`local-return-relabel-acceptance.mjs` 的 `LOCAL_RETURN_VERIFY_REMAINING=true` 专项新建3件退货,先换1件并客户确认,再对同一退货单剩余2件中的1件新建第二申请,验证同键重放、第二轮完工后剩余1件;不执行出库,报告 scope 为 `REMAINING_RETURN_RELABEL`,不代表出库验收通过。仍保留单据、使用正式API和合成照片,默认单票模式不变。
|
||||||
|
|
||||||
`local-return-relabel-outbound.mjs`:仅本机网关,读取显式 `LOCAL_RETURN_SOURCE` 单票报告,为其中同客户、同仓、已完成换标的唯一一件可用库存执行平台创建出库、原键重放、PDA拣货及完成。沿用平台账号环境参数,报告默认 `reports/local-return-relabel-outbound-<marker>.json` 或 `--output`;创建临时PDA并finally停用,不改客户权限。平台库存核验不替代源报告的客户读权限失败。会推进库存与出库状态,保留单据及报告;失败后按已保存单号核查,不自动清理,不可对已出库库存盲目重跑。
|
`local-return-relabel-outbound.mjs`:仅本机网关,读取显式 `LOCAL_RETURN_SOURCE` 单票报告,为其中同客户、同仓、已完成换标的唯一一件可用库存执行平台创建出库、原键重放、PDA拣货及完成。沿用平台账号环境参数,报告默认 `reports/local-return-relabel-outbound-<marker>.json` 或 `--output`;创建临时PDA并finally停用,不改客户权限。平台库存核验不替代源报告的客户读权限失败。会推进库存与出库状态,保留单据及报告;失败后按已保存单号核查,不自动清理,不可对已出库库存盲目重跑。
|
||||||
|
|||||||
@@ -168,6 +168,7 @@ export async function run(context) {
|
|||||||
| 入仓自动头程费用、派生详情、人工发布与 MANUAL 其他费用版本 | `inbound-fees.mjs`(前置:`merchant-qualification-fixture,orders,inbound-measurement`) | `inbound-fees-contract.test.mjs` |
|
| 入仓自动头程费用、派生详情、人工发布与 MANUAL 其他费用版本 | `inbound-fees.mjs`(前置:`merchant-qualification-fixture,orders,inbound-measurement`) | `inbound-fees-contract.test.mjs` |
|
||||||
| 国内货柜履约模拟柜关联、柜次概览、渠道隔离、装柜模型桥接、PDA、动态增量、国内报关证据、出仓及离仓后目的港资料 | `phase3-container-loading.mjs`、`ocean-booking-fixture.mjs`、`phase3-container-overview.mjs`、`phase3-loading.mjs`、`phase3-loading-model-bridge.mjs`、`phase3-pda-convergence.mjs`、`phase3-evidence-gate.mjs`、`phase3-loading-finalize.mjs`、`phase3-customs.mjs`、`phase3-outbound-dispatch.mjs`、`phase3-container-documents.mjs` | `container-simulation-association-contract.test.mjs`、`container-specification-fixture-contract.test.mjs`、`ocean-booking-channel-contract.test.mjs`、`phase3-container-regression-contract.test.mjs`、`phase3-loading-model-bridge-contract.test.mjs`、`phase3-loading-dynamic-final-contract.test.mjs`、`phase3-container-documents-contract.test.mjs`、`phase3-phase4-departure-pickup-chain-contract.test.mjs`、`phase3-producer-chain-contract.test.mjs`、`manual-state-loading-pda-contract.test.mjs` |
|
| 国内货柜履约模拟柜关联、柜次概览、渠道隔离、装柜模型桥接、PDA、动态增量、国内报关证据、出仓及离仓后目的港资料 | `phase3-container-loading.mjs`、`ocean-booking-fixture.mjs`、`phase3-container-overview.mjs`、`phase3-loading.mjs`、`phase3-loading-model-bridge.mjs`、`phase3-pda-convergence.mjs`、`phase3-evidence-gate.mjs`、`phase3-loading-finalize.mjs`、`phase3-customs.mjs`、`phase3-outbound-dispatch.mjs`、`phase3-container-documents.mjs` | `container-simulation-association-contract.test.mjs`、`container-specification-fixture-contract.test.mjs`、`ocean-booking-channel-contract.test.mjs`、`phase3-container-regression-contract.test.mjs`、`phase3-loading-model-bridge-contract.test.mjs`、`phase3-loading-dynamic-final-contract.test.mjs`、`phase3-container-documents-contract.test.mjs`、`phase3-phase4-departure-pickup-chain-contract.test.mjs`、`phase3-producer-chain-contract.test.mjs`、`manual-state-loading-pda-contract.test.mjs` |
|
||||||
| 头程整柜报关 | `phase3-container-customs-fixture.mjs`、`phase3-container-customs.mjs`;定向入口通过正式订单、测量、同柜装载和最终完成 API 生成独立 `BUY_DECLARATION + TAX_REFUND` 混合柜,客户资料只覆盖退税票,买单行由下载中心 XLSX 验证;不复用历史柜、不执行 SQL | `container-customs-acceptance-contract.test.mjs`、`container-customs-acceptance-matrix.mjs` |
|
| 头程整柜报关 | `phase3-container-customs-fixture.mjs`、`phase3-container-customs.mjs`;定向入口通过正式订单、测量、同柜装载和最终完成 API 生成独立 `BUY_DECLARATION + TAX_REFUND` 混合柜,客户资料只覆盖退税票,买单行由下载中心 XLSX 验证;不复用历史柜、不执行 SQL | `container-customs-acceptance-contract.test.mjs`、`container-customs-acceptance-matrix.mjs` |
|
||||||
|
| 国内装柜验收作业员选择 | `domestic-pda-operator.mjs`;按本次 PDA 登录 Token 的字符串 subject 匹配目标仓库候选 userId,缺少匹配即停止,不使用首位候选或成员编号 | `domestic-pda-operator-contract.test.mjs` |
|
||||||
| 分柜与父子入库单数量守恒 | `phase3-order-splitting.mjs` | `phase3-order-splitting-contract.test.mjs` |
|
| 分柜与父子入库单数量守恒 | `phase3-order-splitting.mjs` | `phase3-order-splitting-contract.test.mjs` |
|
||||||
| 同柜且不可拆组 | `coload-groups.mjs`(可选;前置正式生成至少7票同线路 `WAIT_SPLIT`,另造不同线路反向票;覆盖创建/取消幂等、原子分配/移除、目标组隔离超容拒绝与零漂移、推送防绕过、PDA短拣完成拒绝且无余量子单、足量整组消费、普通单拆分不回归及已有父子单建组拒绝) | `coload-groups-contract.test.mjs` |
|
| 同柜且不可拆组 | `coload-groups.mjs`(可选;前置正式生成至少7票同线路 `WAIT_SPLIT`,另造不同线路反向票;覆盖创建/取消幂等、原子分配/移除、目标组隔离超容拒绝与零漂移、推送防绕过、PDA短拣完成拒绝且无余量子单、足量整组消费、普通单拆分不回归及已有父子单建组拒绝) | `coload-groups-contract.test.mjs` |
|
||||||
| 人工计划分柜准备与只读核验 | `manual-order-splitting-fixture.mjs`、`manual-order-splitting-verify.mjs`(可选,不进入默认全量) | `manual-order-splitting-contract.test.mjs` |
|
| 人工计划分柜准备与只读核验 | `manual-order-splitting-fixture.mjs`、`manual-order-splitting-verify.mjs`(可选,不进入默认全量) | `manual-order-splitting-contract.test.mjs` |
|
||||||
|
|||||||
@@ -49,8 +49,8 @@ test('mixed fixture producer is registered and uses only public HTTP composition
|
|||||||
|
|
||||||
test('coverage matrix freezes every requested product boundary', () => {
|
test('coverage matrix freezes every requested product boundary', () => {
|
||||||
const text = CONTAINER_CUSTOMS_MATRIX.map((item) => `${item.scenario}:${item.expected}`).join('\n')
|
const text = CONTAINER_CUSTOMS_MATRIX.map((item) => `${item.scenario}:${item.expected}`).join('\n')
|
||||||
for (const token of ['买单XLSX', '多对多', '覆盖', '改关联', '删除', '冻结', '逐份完成文件',
|
for (const token of ['买单XLSX', '多对多', '覆盖', '改关联', '删除', '冻结', '回执确认', '报关行执行', '完税文件',
|
||||||
'最后一份', '混合', '缺资料', '开始后锁定', '客户隔离', '买单隐藏', '50MiB', '任意格式',
|
'最后上传', '混合', '缺资料', '开始后锁定', '客户隔离', '买单隐藏', '50MiB', '任意格式',
|
||||||
'幂等', '并发', 'A0409', '零漂移', '自动离仓', 'Outbox', '历史', 'Admin与Client权限']) {
|
'幂等', '并发', 'A0409', '零漂移', '自动离仓', 'Outbox', '历史', 'Admin与Client权限']) {
|
||||||
assert.match(text, new RegExp(token))
|
assert.match(text, new RegExp(token))
|
||||||
}
|
}
|
||||||
@@ -69,7 +69,7 @@ test('HTTP scenario contains file, state, idempotency, concurrency and zero-drif
|
|||||||
for (const token of ['EXACT_50_MB + 1', '.customblob', 'application/octet-stream',
|
for (const token of ['EXACT_50_MB + 1', '.customblob', 'application/octet-stream',
|
||||||
'缺退税资料禁止开始且零漂移', '开始后资料范围锁定A0409零漂移',
|
'缺退税资料禁止开始且零漂移', '开始后资料范围锁定A0409零漂移',
|
||||||
"['上传'", "['覆盖'", "['改关联'", "['删除'", '开始后${label}锁定A0409零漂移',
|
"['上传'", "['覆盖'", "['改关联'", "['删除'", '开始后${label}锁定A0409零漂移',
|
||||||
'最后一份完成文件前禁止整柜完成', '最后一份完成文件后纯退税柜完成',
|
'回执未确认且未发送执行时禁止上传完税文件', '客户确认整份回执后仍处于报关中', '真实报关完税文件齐全后纯退税柜完成', '客户确认回执后仍未完成整柜',
|
||||||
'混合柜平台详情精确包含BUY与TAX长ID票', 'Client只见本客户TAX票且完全隐藏BUY票',
|
'混合柜平台详情精确包含BUY与TAX长ID票', 'Client只见本客户TAX票且完全隐藏BUY票',
|
||||||
'买单导出只处理实际装柜BUY行', '混合柜最后一份完成文件后整柜COMPLETED',
|
'买单导出只处理实际装柜BUY行', '混合柜最后一份完成文件后整柜COMPLETED',
|
||||||
'自动离仓只写一次SYSTEM历史', '自动离仓只创建一个OUTBOUND_CONFIRM长ID任务',
|
'自动离仓只写一次SYSTEM历史', '自动离仓只创建一个OUTBOUND_CONFIRM长ID任务',
|
||||||
@@ -87,7 +87,7 @@ test('HTTP scenario contains file, state, idempotency, concurrency and zero-drif
|
|||||||
|
|
||||||
test('database evidence list covers atomicity, history, dispatch and outbox without embedding SQL writes', () => {
|
test('database evidence list covers atomicity, history, dispatch and outbox without embedding SQL writes', () => {
|
||||||
for (const table of ['fl_customs_container', 'fl_customs_customer_document_inbound',
|
for (const table of ['fl_customs_container', 'fl_customs_customer_document_inbound',
|
||||||
'fl_customs_completion_document', 'fl_customs_command', 'fl_customs_container_history',
|
'fl_customs_completion_document', 'fl_customs_final_document', 'fl_customs_command', 'fl_customs_container_history',
|
||||||
'fl_outbound_dispatch', 'fl_event_outbox']) assert.ok(READ_ONLY_DB_EVIDENCE.includes(table), table)
|
'fl_outbound_dispatch', 'fl_event_outbox']) assert.ok(READ_ONLY_DB_EVIDENCE.includes(table), table)
|
||||||
assert.doesNotMatch(source, /\b(?:INSERT|UPDATE|DELETE)\s+(?:INTO|FROM|[a-z_])/i)
|
assert.doesNotMatch(source, /\b(?:INSERT|UPDATE|DELETE)\s+(?:INTO|FROM|[a-z_])/i)
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -15,11 +15,16 @@ export const REQUIRED_ROUTE_OPERATIONS = Object.freeze([
|
|||||||
'merchantReplaceDocument',
|
'merchantReplaceDocument',
|
||||||
'merchantUpdateScope',
|
'merchantUpdateScope',
|
||||||
'merchantDeleteDocument',
|
'merchantDeleteDocument',
|
||||||
|
'merchantConfirmReceipt',
|
||||||
'platformStart',
|
'platformStart',
|
||||||
'platformUploadCompletion',
|
'platformUploadReceipt',
|
||||||
|
'platformSubmitToBroker',
|
||||||
|
'platformUploadFinal',
|
||||||
'platformComplete',
|
'platformComplete',
|
||||||
'platformDownload',
|
'platformDownloadReceipt',
|
||||||
'merchantDownload',
|
'platformDownloadFinal',
|
||||||
|
'merchantDownloadReceipt',
|
||||||
|
'merchantDownloadFinal',
|
||||||
'outboundState',
|
'outboundState',
|
||||||
'satisfyOutboundPrerequisites',
|
'satisfyOutboundPrerequisites',
|
||||||
])
|
])
|
||||||
@@ -28,7 +33,7 @@ export const CONTAINER_CUSTOMS_MATRIX = Object.freeze([
|
|||||||
{ priority: 'P0', fixture: 'ALL_BUY_DECLARATION', scenario: 'LOADED详情与买单XLSX', expected: 'XLSX只含柜内买单实装行,下载窗口可用' },
|
{ priority: 'P0', fixture: 'ALL_BUY_DECLARATION', scenario: 'LOADED详情与买单XLSX', expected: 'XLSX只含柜内买单实装行,下载窗口可用' },
|
||||||
{ priority: 'P0', fixture: 'ALL_BUY_DECLARATION', scenario: '开始与完成', expected: 'READY_TO_START→DECLARING→COMPLETED,重放返回同一结果' },
|
{ priority: 'P0', fixture: 'ALL_BUY_DECLARATION', scenario: '开始与完成', expected: 'READY_TO_START→DECLARING→COMPLETED,重放返回同一结果' },
|
||||||
{ priority: 'P0', fixture: 'ALL_TAX_REFUND', scenario: '客户多对多资料', expected: '上传、覆盖、改关联、删除均保留版本与客户范围' },
|
{ priority: 'P0', fixture: 'ALL_TAX_REFUND', scenario: '客户多对多资料', expected: '上传、覆盖、改关联、删除均保留版本与客户范围' },
|
||||||
{ priority: 'P0', fixture: 'ALL_TAX_REFUND', scenario: '冻结与逐份完成文件', expected: '开始冻结来源文件和关联;最后一份完成文件后才允许整柜完成' },
|
{ priority: 'P0', fixture: 'ALL_TAX_REFUND', scenario: '冻结、回执确认、报关行执行与完税文件', expected: '开始冻结来源文件和关联;上传回执、客户确认、发送报关行执行,最后上传真实报关完税文件才允许整柜完成' },
|
||||||
{ priority: 'P0', fixture: 'MIXED', scenario: '买单与退税混合', expected: '买单行进入XLSX,退税票逐票被资料覆盖,整柜统一开始/完成' },
|
{ priority: 'P0', fixture: 'MIXED', scenario: '买单与退税混合', expected: '买单行进入XLSX,退税票逐票被资料覆盖,整柜统一开始/完成' },
|
||||||
{ priority: 'P0', fixture: 'ALL_TAX_REFUND', scenario: '缺资料禁止开始', expected: 'A0409且聚合、文件、关联、历史零漂移' },
|
{ priority: 'P0', fixture: 'ALL_TAX_REFUND', scenario: '缺资料禁止开始', expected: 'A0409且聚合、文件、关联、历史零漂移' },
|
||||||
{ priority: 'P0', fixture: 'MIXED', scenario: '自动离仓失败恢复', expected: '报关COMPLETED不回滚;待条件满足后后台重试并单次离仓' },
|
{ priority: 'P0', fixture: 'MIXED', scenario: '自动离仓失败恢复', expected: '报关COMPLETED不回滚;待条件满足后后台重试并单次离仓' },
|
||||||
@@ -90,6 +95,7 @@ export const READ_ONLY_DB_EVIDENCE = Object.freeze([
|
|||||||
'fl_customs_frozen_document_inbound',
|
'fl_customs_frozen_document_inbound',
|
||||||
'fl_customs_completion_document',
|
'fl_customs_completion_document',
|
||||||
'fl_customs_completion_document_version',
|
'fl_customs_completion_document_version',
|
||||||
|
'fl_customs_final_document',
|
||||||
'fl_customs_command',
|
'fl_customs_command',
|
||||||
'fl_customs_container_history',
|
'fl_customs_container_history',
|
||||||
'fl_outbound_dispatch',
|
'fl_outbound_dispatch',
|
||||||
|
|||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import assert from 'node:assert/strict'
|
||||||
|
import test from 'node:test'
|
||||||
|
import { selectSessionPdaOperator } from './domestic-pda-operator.mjs'
|
||||||
|
|
||||||
|
const subject = '2103886047128588290'
|
||||||
|
const token = `header.${Buffer.from(JSON.stringify({ sub: subject })).toString('base64url')}.signature`
|
||||||
|
test('task assignee matches authenticated user even when another operator is first', () => {
|
||||||
|
const current = { userId: subject, membershipId: '2103886047128588291' }
|
||||||
|
const operators = [{ userId: '194000000000000101' }, current]
|
||||||
|
assert.equal(selectSessionPdaOperator(operators, token), current)
|
||||||
|
assert.equal(selectSessionPdaOperator({ records: operators }, token), current)
|
||||||
|
})
|
||||||
|
test('missing warehouse candidate does not fall back to another operator or membership ID', () => {
|
||||||
|
assert.throws(() => selectSessionPdaOperator([{ userId: '2103886047128588291' }], token), /不在目标仓库候选/)
|
||||||
|
assert.throws(() => selectSessionPdaOperator([], ''), /有效登录身份/)
|
||||||
|
})
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
// Use the subject of the authenticated test session, never the first candidate
|
||||||
|
// or IAM membership ID. Server-side warehouse and assignment checks remain authoritative.
|
||||||
|
export function selectSessionPdaOperator(operators, token) {
|
||||||
|
let subject
|
||||||
|
try { subject = JSON.parse(Buffer.from(token.split('.')[1], 'base64url').toString('utf8')).sub }
|
||||||
|
catch { throw new Error('国内 PDA 验收缺少有效登录身份') }
|
||||||
|
if (typeof subject !== 'string' || !/^[1-9]\d*$/.test(subject)) {
|
||||||
|
throw new Error('国内 PDA 登录身份必须包含字符串用户编号')
|
||||||
|
}
|
||||||
|
const rows = Array.isArray(operators) ? operators : operators?.records || []
|
||||||
|
const operator = rows.find((item) => String(item.userId) === subject)
|
||||||
|
if (!operator) throw new Error('当前登录 PDA 作业员不在目标仓库候选中,停止分配任务')
|
||||||
|
return operator
|
||||||
|
}
|
||||||
@@ -9,7 +9,7 @@ const domesticSchemas = ['baiyue_auth', 'baiyue_iam', 'baiyue_order', 'baiyue_wa
|
|||||||
const overseasSchemas = ['baiyue_event_bridge_overseas', 'baiyue_lastleg', 'baiyue_lastlag_warehouse']
|
const overseasSchemas = ['baiyue_event_bridge_overseas', 'baiyue_lastleg', 'baiyue_lastlag_warehouse']
|
||||||
|
|
||||||
test('两个入口分别只操作国内九库与海外三库,包括限定名引用', () => {
|
test('两个入口分别只操作国内九库与海外三库,包括限定名引用', () => {
|
||||||
for (const [sql, allowed, other, count] of [[cn, domesticSchemas, overseasSchemas, 210], [overseas, overseasSchemas, domesticSchemas, 138]]) {
|
for (const [sql, allowed, other, count] of [[cn, domesticSchemas, overseasSchemas, 219], [overseas, overseasSchemas, domesticSchemas, 138]]) {
|
||||||
assert.deepEqual([...sql.matchAll(/^CREATE DATABASE IF NOT EXISTS (\w+)/gm)].map(m => m[1]), allowed)
|
assert.deepEqual([...sql.matchAll(/^CREATE DATABASE IF NOT EXISTS (\w+)/gm)].map(m => m[1]), allowed)
|
||||||
assert.deepEqual([...new Set([...sql.matchAll(/^USE (\w+);/gm)].map(m => m[1]))], allowed)
|
assert.deepEqual([...new Set([...sql.matchAll(/^USE (\w+);/gm)].map(m => m[1]))], allowed)
|
||||||
const body = sql.replace(/^--.*$/gm, '')
|
const body = sql.replace(/^--.*$/gm, '')
|
||||||
@@ -28,7 +28,7 @@ test('纯初始化禁止迁移、数据回填、清理、重复建表和提前
|
|||||||
assert.doesNotMatch(body.replace(/^CREATE TRIGGER [\s\S]*?\$\$/gm, ''), /^\s*(?:UPDATE|DELETE FROM|REPLACE INTO)\b/gmi)
|
assert.doesNotMatch(body.replace(/^CREATE TRIGGER [\s\S]*?\$\$/gm, ''), /^\s*(?:UPDATE|DELETE FROM|REPLACE INTO)\b/gmi)
|
||||||
assert.doesNotMatch(body, /CREATE TABLE IF NOT EXISTS/i)
|
assert.doesNotMatch(body, /CREATE TABLE IF NOT EXISTS/i)
|
||||||
assert.equal((body.match(/AS initialization_status/g) ?? []).length, 1)
|
assert.equal((body.match(/AS initialization_status/g) ?? []).length, 1)
|
||||||
assert.match(body.trim(), /SELECT 'PASS' AS initialization_status, '(CN|OVERSEAS)' AS region, (210|138) AS initialized_tables;$/)
|
assert.match(body.trim(), /SELECT 'PASS' AS initialization_status, '(CN|OVERSEAS)' AS region, (219|138) AS initialized_tables;$/)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ test('retains all clickable container, loading-list and PDA workflow states thro
|
|||||||
assert.doesNotMatch(source, /push-preload/)
|
assert.doesNotMatch(source, /push-preload/)
|
||||||
assert.match(source, /计划确认前不生成 PDA 任务/)
|
assert.match(source, /计划确认前不生成 PDA 任务/)
|
||||||
assert.match(source, /summary\?\.readiness\?\.canConfirm === true/)
|
assert.match(source, /summary\?\.readiness\?\.canConfirm === true/)
|
||||||
assert.match(source, /Object\.freeze\(\{ expectedVersion: confirmableVersion \}\)/)
|
assert.match(source, /Object\.freeze\(\{ expectedVersion: confirmableVersion, assigneeId: id\(pdaOperator.userId\) \}\)/)
|
||||||
assert.match(source, /confirmationStatus === 'CONFIRMED'/)
|
assert.match(source, /confirmationStatus === 'CONFIRMED'/)
|
||||||
assert.match(source, /readiness\?\.readyForPda === true/)
|
assert.match(source, /readiness\?\.readyForPda === true/)
|
||||||
assert.match(source, /id\(task\?\.id\) !== confirmedTaskId/)
|
assert.match(source, /id\(task\?\.id\) !== confirmedTaskId/)
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { selectSessionPdaOperator } from './domestic-pda-operator.mjs'
|
||||||
const MODULE = 'manual-state-loading-pda'
|
const MODULE = 'manual-state-loading-pda'
|
||||||
const OPTIONS = { scenario: 'manual-loading-pda-state-matrix', deploymentBlocker: true }
|
const OPTIONS = { scenario: 'manual-loading-pda-state-matrix', deploymentBlocker: true }
|
||||||
const CONTAINERS = '/api/platform/v1/containers'
|
const CONTAINERS = '/api/platform/v1/containers'
|
||||||
@@ -216,7 +217,7 @@ async function createPlanTask(context, label) {
|
|||||||
const pdaOperatorsResponse = await context.request('GET',
|
const pdaOperatorsResponse = await context.request('GET',
|
||||||
`/api/platform/v1/iam/users/domestic-pda-operators?warehouseId=${encodeURIComponent(source.warehouseId)}`)
|
`/api/platform/v1/iam/users/domestic-pda-operators?warehouseId=${encodeURIComponent(source.warehouseId)}`)
|
||||||
const pdaOperators = context.apiSuccess(MODULE, `${label} 查询国内头程 PDA 作业员候选`, pdaOperatorsResponse, '', OPTIONS)
|
const pdaOperators = context.apiSuccess(MODULE, `${label} 查询国内头程 PDA 作业员候选`, pdaOperatorsResponse, '', OPTIONS)
|
||||||
const pdaOperator = (Array.isArray(pdaOperators) ? pdaOperators : pdaOperators?.records || [])[0]
|
const pdaOperator = selectSessionPdaOperator(pdaOperators, context.pdaToken)
|
||||||
context.assert(MODULE, `${label} 存在可用 PDA 作业员`, pdaOperatorsResponse,
|
context.assert(MODULE, `${label} 存在可用 PDA 作业员`, pdaOperatorsResponse,
|
||||||
Boolean(pdaOperator?.userId), `operatorId=${id(pdaOperator?.userId) || 'missing'}`, OPTIONS)
|
Boolean(pdaOperator?.userId), `operatorId=${id(pdaOperator?.userId) || 'missing'}`, OPTIONS)
|
||||||
if (!pdaOperator?.userId) throw new Error(`${label} 缺少可用 PDA 作业员`)
|
if (!pdaOperator?.userId) throw new Error(`${label} 缺少可用 PDA 作业员`)
|
||||||
|
|||||||
@@ -81,19 +81,34 @@ function routes(context, merchantToken) {
|
|||||||
merchantDeleteDocument: (ctx, fixture, customer, request, idempotencyKey) => ctx.request('DELETE',
|
merchantDeleteDocument: (ctx, fixture, customer, request, idempotencyKey) => ctx.request('DELETE',
|
||||||
`${MERCHANT}/documents/${encodeURIComponent(id(request.documentId))}?expectedVersion=${Number(request.expectedVersion)}`,
|
`${MERCHANT}/documents/${encodeURIComponent(id(request.documentId))}?expectedVersion=${Number(request.expectedVersion)}`,
|
||||||
undefined, customer.customerToken || merchantToken, { 'Idempotency-Key': idempotencyKey }),
|
undefined, customer.customerToken || merchantToken, { 'Idempotency-Key': idempotencyKey }),
|
||||||
|
merchantConfirmReceipt: (ctx, fixture, customer, request, idempotencyKey) => ctx.request('POST',
|
||||||
|
`${MERCHANT}/documents/${encodeURIComponent(id(request.documentId))}/receipt/${request.confirm === false ? 'reject' : 'confirm'}`,
|
||||||
|
{ expectedVersion: Number(request.expectedVersion), confirm: request.confirm !== false, remark: request.remark || '' },
|
||||||
|
customer.customerToken || merchantToken, { 'Idempotency-Key': idempotencyKey }),
|
||||||
platformStart: (ctx, fixture, request, idempotencyKey) => ctx.request('POST',
|
platformStart: (ctx, fixture, request, idempotencyKey) => ctx.request('POST',
|
||||||
`${PLATFORM}/${encodeURIComponent(id(fixture.loadingPlanId))}/start`, request, ctx.token,
|
`${PLATFORM}/${encodeURIComponent(id(fixture.loadingPlanId))}/start`, request, ctx.token,
|
||||||
{ 'Idempotency-Key': idempotencyKey }),
|
{ 'Idempotency-Key': idempotencyKey }),
|
||||||
platformUploadCompletion: (ctx, fixture, request, idempotencyKey) => multipart(
|
platformUploadReceipt: (ctx, fixture, request, idempotencyKey) => multipart(
|
||||||
`${PLATFORM}/documents/${encodeURIComponent(id(request.documentId))}/completion/upload?expectedVersion=${Number(request.expectedVersion)}&finalConfirmation=${request.finalConfirmation === true}`,
|
`${PLATFORM}/documents/${encodeURIComponent(id(request.documentId))}/receipt/upload?expectedVersion=${Number(request.expectedVersion)}`,
|
||||||
{}, request, ctx.token, idempotencyKey),
|
{}, request, ctx.token, idempotencyKey),
|
||||||
|
platformSubmitToBroker: (ctx, fixture, request, idempotencyKey) => ctx.request('POST',
|
||||||
|
`${PLATFORM}/${encodeURIComponent(id(fixture.loadingPlanId))}/submit-to-broker`, request, ctx.token,
|
||||||
|
{ 'Idempotency-Key': idempotencyKey }),
|
||||||
platformComplete: (ctx, fixture, request, idempotencyKey) => ctx.request('POST',
|
platformComplete: (ctx, fixture, request, idempotencyKey) => ctx.request('POST',
|
||||||
`${PLATFORM}/${encodeURIComponent(id(fixture.loadingPlanId))}/complete-all-buy`, request, ctx.token,
|
`${PLATFORM}/${encodeURIComponent(id(fixture.loadingPlanId))}/complete-all-buy`, request, ctx.token,
|
||||||
{ 'Idempotency-Key': idempotencyKey }),
|
{ 'Idempotency-Key': idempotencyKey }),
|
||||||
platformDownload: (ctx, fixture, request) => binary('GET',
|
platformUploadFinal: (ctx, fixture, request, idempotencyKey) => multipart(
|
||||||
`${PLATFORM}/documents/${encodeURIComponent(id(request.documentId))}/${request.completion ? 'completion/' : ''}download`),
|
`${PLATFORM}/documents/${encodeURIComponent(id(request.documentId))}/final/upload?expectedVersion=${Number(request.expectedVersion)}`,
|
||||||
merchantDownload: (ctx, fixture, customer, request) => binary('GET',
|
{}, request, ctx.token, idempotencyKey),
|
||||||
`${MERCHANT}/documents/${encodeURIComponent(id(request.documentId))}/${request.completion ? 'completion/' : ''}download`,
|
platformDownloadReceipt: (ctx, fixture, request) => binary('GET',
|
||||||
|
`${PLATFORM}/documents/${encodeURIComponent(id(request.documentId))}/receipt/download`),
|
||||||
|
platformDownloadFinal: (ctx, fixture, request) => binary('GET',
|
||||||
|
`${PLATFORM}/documents/${encodeURIComponent(id(request.documentId))}/final/download`),
|
||||||
|
merchantDownloadReceipt: (ctx, fixture, customer, request) => binary('GET',
|
||||||
|
`${MERCHANT}/documents/${encodeURIComponent(id(request.documentId))}/receipt/download`,
|
||||||
|
customer.customerToken || merchantToken),
|
||||||
|
merchantDownloadFinal: (ctx, fixture, customer, request) => binary('GET',
|
||||||
|
`${MERCHANT}/documents/${encodeURIComponent(id(request.documentId))}/final/download`,
|
||||||
customer.customerToken || merchantToken),
|
customer.customerToken || merchantToken),
|
||||||
buyDeclarationXlsx: async (ctx, request) => {
|
buyDeclarationXlsx: async (ctx, request) => {
|
||||||
const created = await ctx.request('POST', '/api/platform/v1/files/export-tasks', {
|
const created = await ctx.request('POST', '/api/platform/v1/files/export-tasks', {
|
||||||
|
|||||||
@@ -58,12 +58,12 @@ async function runAllBuy(context, routes, fixture) {
|
|||||||
xlsx.http === 200 && xlsxMagic(xlsx.bytes),
|
xlsx.http === 200 && xlsxMagic(xlsx.bytes),
|
||||||
`HTTP=${xlsx.http}, bytes=${xlsx.bytes?.length ?? 0}, contentType=${xlsx.contentType || 'missing'}`, OPTIONS)
|
`HTTP=${xlsx.http}, bytes=${xlsx.bytes?.length ?? 0}, contentType=${xlsx.contentType || 'missing'}`, OPTIONS)
|
||||||
|
|
||||||
const startBody = { expectedVersion: Number(detail.body?.data?.version), remark: context.marker }
|
const startBody = { expectedVersion: Number(detail.body?.data?.version), confirm: true, remark: context.marker }
|
||||||
const started = await routes.platformStart(context, fixture, startBody, key(context, 'all-buy-start'))
|
const started = await routes.platformStart(context, fixture, startBody, key(context, 'all-buy-start'))
|
||||||
context.assert(MODULE, '全买单柜开始报关', started,
|
context.assert(MODULE, '全买单柜开始报关', started,
|
||||||
success(started) && started.body?.data?.status === 'DECLARING', evidence(started), OPTIONS)
|
success(started) && started.body?.data?.status === 'DECLARING', evidence(started), OPTIONS)
|
||||||
|
|
||||||
const completeBody = { expectedVersion: Number(started.body?.data?.version), remark: context.marker }
|
const completeBody = { expectedVersion: Number(started.body?.data?.version), confirm: true, remark: context.marker }
|
||||||
const completed = await routes.platformComplete(context, fixture, completeBody, key(context, 'all-buy-complete'))
|
const completed = await routes.platformComplete(context, fixture, completeBody, key(context, 'all-buy-complete'))
|
||||||
context.assert(MODULE, '全买单柜完成并触发自动离仓意图', completed,
|
context.assert(MODULE, '全买单柜完成并触发自动离仓意图', completed,
|
||||||
success(completed) && completed.body?.data?.status === 'COMPLETED', evidence(completed), OPTIONS)
|
success(completed) && completed.body?.data?.status === 'COMPLETED', evidence(completed), OPTIONS)
|
||||||
@@ -153,7 +153,7 @@ async function runRefund(context, routes, fixture) {
|
|||||||
|
|
||||||
const ready = await platformDetail(context, routes, fixture)
|
const ready = await platformDetail(context, routes, fixture)
|
||||||
const started = await routes.platformStart(context, fixture, {
|
const started = await routes.platformStart(context, fixture, {
|
||||||
expectedVersion: Number(ready.body?.data?.version), remark: context.marker,
|
expectedVersion: Number(ready.body?.data?.version), confirm: true, remark: context.marker,
|
||||||
}, key(context, 'refund-start'))
|
}, key(context, 'refund-start'))
|
||||||
context.assert(MODULE, '纯退税柜资料齐全后开始并冻结', started,
|
context.assert(MODULE, '纯退税柜资料齐全后开始并冻结', started,
|
||||||
success(started) && started.body?.data?.status === 'DECLARING'
|
success(started) && started.body?.data?.status === 'DECLARING'
|
||||||
@@ -183,45 +183,71 @@ async function runRefund(context, routes, fixture) {
|
|||||||
|
|
||||||
const documents = frozenSnapshot?.documents || []
|
const documents = frozenSnapshot?.documents || []
|
||||||
for (const [index, document] of documents.entries()) {
|
for (const [index, document] of documents.entries()) {
|
||||||
const completion = await routes.platformUploadCompletion(context, fixture, {
|
const receipt = await routes.platformUploadReceipt(context, fixture, {
|
||||||
documentId: id(document.documentId), expectedVersion: Number(document.source?.version),
|
documentId: id(document.documentId), expectedVersion: 0,
|
||||||
fileName: `${context.marker}-completion-${index + 1}.done`,
|
fileName: `${context.marker}-broker-receipt-${index + 1}.pdf`,
|
||||||
contentType: 'application/x-customs-completion', content: Uint8Array.from([0x43, index + 1]),
|
contentType: 'application/pdf', content: Uint8Array.from([0x52, index + 1]),
|
||||||
}, key(context, `completion-${index + 1}`))
|
}, key(context, `receipt-${index + 1}`))
|
||||||
context.assert(MODULE, `逐份上传完成文件${index + 1}`, completion,
|
context.assert(MODULE, `逐份上传报关行回执${index + 1}`, receipt,
|
||||||
success(completion)
|
success(receipt) && zeroDrift(receipt.body?.data?.inboundIds, document.inboundIds),
|
||||||
&& zeroDrift(completion.body?.data?.inboundIds, document.inboundIds),
|
evidence(receipt, `sourceDocumentId=${id(document.documentId)}`), OPTIONS)
|
||||||
evidence(completion, `sourceDocumentId=${id(document.documentId)}`), OPTIONS)
|
|
||||||
if (index + 1 < documents.length) {
|
|
||||||
const beforeEarlyComplete = await platformDetail(context, routes, fixture)
|
|
||||||
const early = await routes.platformComplete(context, fixture, {
|
|
||||||
expectedVersion: Number(beforeEarlyComplete.body?.data?.version), remark: 'not-last-completion',
|
|
||||||
}, key(context, `early-complete-${index + 1}`))
|
|
||||||
await assertZeroDrift(context, '最后一份完成文件前禁止整柜完成', routes, fixture, beforeEarlyComplete, early)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
const completedReady = await platformDetail(context, routes, fixture)
|
const beforeConfirm = await platformDetail(context, routes, fixture)
|
||||||
const completed = await routes.platformComplete(context, fixture, {
|
const earlyFinal = await routes.platformUploadFinal(context, fixture, {
|
||||||
expectedVersion: Number(completedReady.body?.data?.version), remark: context.marker,
|
documentId: id(documents[0]?.documentId), expectedVersion: 0,
|
||||||
}, key(context, 'refund-complete'))
|
fileName: `${context.marker}-early-final.pdf`, contentType: 'application/pdf', content: Uint8Array.from([0x45]),
|
||||||
context.assert(MODULE, '最后一份完成文件后纯退税柜完成', completed,
|
}, key(context, 'early-final'))
|
||||||
|
await assertZeroDrift(context, '回执未确认且未发送执行时禁止上传完税文件', routes, fixture, beforeConfirm, earlyFinal)
|
||||||
|
const confirmed = []
|
||||||
|
for (const customer of customers) {
|
||||||
|
const document = documents.find((item) => (item.inboundIds || []).some((inboundId) =>
|
||||||
|
customer.inboundIds.map(id).includes(id(inboundId))))
|
||||||
|
if (!document) continue
|
||||||
|
const current = await platformDetail(context, routes, fixture)
|
||||||
|
const live = current.body?.data?.documents?.find((item) => id(item.documentId) === id(document.documentId))
|
||||||
|
const version = Number(live?.receipt?.version || 0)
|
||||||
|
const response = await routes.merchantConfirmReceipt(context, fixture, customer, {
|
||||||
|
documentId: id(document.documentId), expectedVersion: version, confirm: true, remark: context.marker,
|
||||||
|
}, key(context, `refund-receipt-confirm-${customer.customerId}`))
|
||||||
|
confirmed.push(response)
|
||||||
|
}
|
||||||
|
const afterConfirm = await platformDetail(context, routes, fixture)
|
||||||
|
context.assert(MODULE, '客户确认整份回执后仍处于报关中', afterConfirm,
|
||||||
|
success(afterConfirm) && afterConfirm.body?.data?.status === 'DECLARING' && confirmed.every(success),
|
||||||
|
evidence(afterConfirm), OPTIONS)
|
||||||
|
const submitted = await routes.platformSubmitToBroker(context, fixture,
|
||||||
|
{ expectedVersion: Number(afterConfirm.body?.data?.version), confirm: true, remark: context.marker }, key(context, 'submit-to-broker'))
|
||||||
|
context.assert(MODULE, '平台发送客户确认回执给报关行执行', submitted,
|
||||||
|
success(submitted) && submitted.body?.data?.status === 'DECLARING', evidence(submitted), OPTIONS)
|
||||||
|
const submitReplay = await routes.platformSubmitToBroker(context, fixture,
|
||||||
|
{ expectedVersion: Number(afterConfirm.body?.data?.version), confirm: true, remark: context.marker }, key(context, 'submit-to-broker'))
|
||||||
|
context.assert(MODULE, '发送报关行执行同键重放', submitReplay, success(submitReplay), evidence(submitReplay), OPTIONS)
|
||||||
|
for (const [index, document] of documents.entries()) {
|
||||||
|
const finalDocument = await routes.platformUploadFinal(context, fixture, {
|
||||||
|
documentId: id(document.documentId), expectedVersion: 0,
|
||||||
|
fileName: `${context.marker}-customs-tax-final-${index + 1}.pdf`,
|
||||||
|
contentType: 'application/pdf', content: Uint8Array.from([0x46, index + 1]),
|
||||||
|
}, key(context, `final-${index + 1}`))
|
||||||
|
context.assert(MODULE, `逐份上传真实报关完税文件${index + 1}`, finalDocument,
|
||||||
|
success(finalDocument) && zeroDrift(finalDocument.body?.data?.inboundIds, document.inboundIds),
|
||||||
|
evidence(finalDocument), OPTIONS)
|
||||||
|
}
|
||||||
|
const completed = await platformDetail(context, routes, fixture)
|
||||||
|
context.assert(MODULE, '真实报关完税文件齐全后纯退税柜完成', completed,
|
||||||
success(completed) && completed.body?.data?.status === 'COMPLETED', evidence(completed), OPTIONS)
|
success(completed) && completed.body?.data?.status === 'COMPLETED', evidence(completed), OPTIONS)
|
||||||
|
|
||||||
const firstDocument = documents[0]
|
const firstDocument = documents[0]
|
||||||
if (firstDocument) {
|
if (firstDocument) {
|
||||||
const platformDownload = await routes.platformDownload(context, fixture, {
|
const platformReceipt = await routes.platformDownloadReceipt(context, fixture, { documentId: id(firstDocument.documentId) })
|
||||||
documentId: id(firstDocument.documentId), completion: true,
|
context.assert(MODULE, 'Admin下载报关行回执', platformReceipt,
|
||||||
})
|
platformReceipt.http === 200 && platformReceipt.bytes?.length > 0, evidence(platformReceipt), OPTIONS)
|
||||||
context.assert(MODULE, 'Admin下载完成文件窗口', platformDownload,
|
const platformFinal = await routes.platformDownloadFinal(context, fixture, { documentId: id(firstDocument.documentId) })
|
||||||
platformDownload.http === 200 && platformDownload.bytes?.length > 0,
|
context.assert(MODULE, 'Admin下载真实报关完税文件', platformFinal,
|
||||||
`HTTP=${platformDownload.http}, bytes=${platformDownload.bytes?.length ?? 0}`, OPTIONS)
|
platformFinal.http === 200 && platformFinal.bytes?.length > 0, evidence(platformFinal), OPTIONS)
|
||||||
const merchantDownload = await routes.merchantDownload(context, fixture, mutable, {
|
const merchantFinal = await routes.merchantDownloadFinal(context, fixture, mutable, { documentId: id(firstDocument.documentId) })
|
||||||
documentId: id(firstDocument.documentId), completion: true,
|
context.assert(MODULE, 'Client仅下载本客户真实报关完税文件', merchantFinal,
|
||||||
})
|
merchantFinal.http === 200 && merchantFinal.bytes?.length > 0, evidence(merchantFinal), OPTIONS)
|
||||||
context.assert(MODULE, 'Client仅下载本客户完成文件窗口', merchantDownload,
|
|
||||||
merchantDownload.http === 200 && merchantDownload.bytes?.length > 0,
|
|
||||||
`HTTP=${merchantDownload.http}, bytes=${merchantDownload.bytes?.length ?? 0}`, OPTIONS)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function runMixedConcurrency(context, routes, fixture) {
|
async function runMixedConcurrency(context, routes, fixture) {
|
||||||
@@ -309,31 +335,58 @@ async function runMixedConcurrency(context, routes, fixture) {
|
|||||||
}, key(context, 'locked-scope'))
|
}, key(context, 'locked-scope'))
|
||||||
await assertZeroDrift(context, '开始后资料范围锁定A0409零漂移', routes, fixture, { body: { data: frozen } }, locked)
|
await assertZeroDrift(context, '开始后资料范围锁定A0409零漂移', routes, fixture, { body: { data: frozen } }, locked)
|
||||||
|
|
||||||
const completionRequest = {
|
const receiptRequest = {
|
||||||
documentId: customer.documentId, expectedVersion: 0,
|
documentId: customer.documentId, expectedVersion: 0,
|
||||||
finalConfirmation: true, fileName: `${context.marker}-mixed-result.done`,
|
fileName: `${context.marker}-mixed-broker-receipt.pdf`,
|
||||||
contentType: 'application/x-customs-completion', content: Uint8Array.from([0x52, 0x45, 0x53]),
|
contentType: 'application/pdf', content: Uint8Array.from([0x52, 0x45, 0x43]),
|
||||||
}
|
}
|
||||||
const completionKey = key(context, 'mixed-completion')
|
const receiptKey = key(context, 'mixed-receipt-upload')
|
||||||
const completion = await routes.platformUploadCompletion(context, fixture, completionRequest, completionKey)
|
const receipt = await routes.platformUploadReceipt(context, fixture, receiptRequest, receiptKey)
|
||||||
const completionReplay = success(completion)
|
const receiptReplay = success(receipt)
|
||||||
? await routes.platformUploadCompletion(context, fixture, completionRequest, completionKey) : null
|
? await routes.platformUploadReceipt(context, fixture, receiptRequest, receiptKey) : null
|
||||||
const completionConverged = success(completion) && success(completionReplay)
|
const receiptConverged = success(receipt) && success(receiptReplay)
|
||||||
&& id(completion.body?.data?.documentId) === id(completionReplay.body?.data?.documentId)
|
&& id(receipt.body?.data?.documentId) === id(receiptReplay.body?.data?.documentId)
|
||||||
&& id(completion.body?.data?.completion?.fileId) === id(completionReplay.body?.data?.completion?.fileId)
|
&& id(receipt.body?.data?.receipt?.fileId) === id(receiptReplay.body?.data?.receipt?.fileId)
|
||||||
context.assert(MODULE, '最后一份TAX完成文件二次确认并同键重放', completionReplay || completion,
|
context.assert(MODULE, '报关行回执同键重放收敛', receiptReplay || receipt, receiptConverged,
|
||||||
completionConverged,
|
[receipt, receiptReplay].filter(Boolean).map((response) => evidence(response)).join('; '), OPTIONS)
|
||||||
[completion, completionReplay].filter(Boolean).map((response) => evidence(response)).join('; '), OPTIONS)
|
const receiptDifferent = await routes.platformUploadReceipt(context, fixture,
|
||||||
if (!completionConverged) {
|
{ ...receiptRequest, content: Uint8Array.from([0x44, 0x49, 0x46]) }, receiptKey)
|
||||||
context.addCoverageGap(MODULE, OPTIONS.scenario, '最后一份完成文件二次确认',
|
context.assert(MODULE, '回执同键异载荷A0409', receiptDifferent, a0409(receiptDifferent), evidence(receiptDifferent), OPTIONS)
|
||||||
'完成文件未成功或同键重放未收敛,已停止后续自动离仓写入',
|
const receiptVersion = Number(receipt.body?.data?.receipt?.version || 0)
|
||||||
'completion-idempotency', { deploymentBlocker: true })
|
const receiptConfirm = await routes.merchantConfirmReceipt(context, fixture, customer, {
|
||||||
return
|
documentId: customer.documentId, expectedVersion: receiptVersion, confirm: true, remark: context.marker,
|
||||||
|
}, key(context, 'mixed-receipt-confirm'))
|
||||||
|
const receiptConfirmReplay = await routes.merchantConfirmReceipt(context, fixture, customer, {
|
||||||
|
documentId: customer.documentId, expectedVersion: receiptVersion, confirm: true, remark: context.marker,
|
||||||
|
}, key(context, 'mixed-receipt-confirm'))
|
||||||
|
context.assert(MODULE, '客户确认回执后仍未完成整柜', receiptConfirmReplay || receiptConfirm,
|
||||||
|
success(receiptConfirm) && success(receiptConfirmReplay)
|
||||||
|
&& receiptConfirm.body?.data?.status === 'DECLARING'
|
||||||
|
&& receiptConfirmReplay.body?.data?.status === 'DECLARING',
|
||||||
|
[receiptConfirm, receiptConfirmReplay].map((response) => evidence(response)).join('; '), OPTIONS)
|
||||||
|
const submitted = await routes.platformSubmitToBroker(context, fixture,
|
||||||
|
{ expectedVersion: Number(receiptConfirm.body?.data?.version), confirm: true, remark: context.marker }, key(context, 'mixed-submit-to-broker'))
|
||||||
|
context.assert(MODULE, '混合柜发送确认回执给报关行执行', submitted,
|
||||||
|
success(submitted) && submitted.body?.data?.status === 'DECLARING', evidence(submitted), OPTIONS)
|
||||||
|
const finalRequest = {
|
||||||
|
documentId: customer.documentId, expectedVersion: 0,
|
||||||
|
fileName: `${context.marker}-mixed-customs-tax-final.pdf`,
|
||||||
|
contentType: 'application/pdf', content: Uint8Array.from([0x46, 0x49, 0x4e]),
|
||||||
}
|
}
|
||||||
const completionDifferent = await routes.platformUploadCompletion(context, fixture,
|
const finalKey = key(context, 'mixed-final')
|
||||||
{ ...completionRequest, content: Uint8Array.from([0x44, 0x49, 0x46]) }, completionKey)
|
const finalDocument = await routes.platformUploadFinal(context, fixture, finalRequest, finalKey)
|
||||||
context.assert(MODULE, '完成文件同键异载荷A0409', completionDifferent,
|
const finalReplay = success(finalDocument)
|
||||||
a0409(completionDifferent), evidence(completionDifferent), OPTIONS)
|
? await routes.platformUploadFinal(context, fixture, finalRequest, finalKey) : null
|
||||||
|
const finalConverged = success(finalDocument) && success(finalReplay)
|
||||||
|
&& id(finalDocument.body?.data?.finalDocument?.fileId) === id(finalReplay.body?.data?.finalDocument?.fileId)
|
||||||
|
context.assert(MODULE, '真实报关完税文件同键重放收敛', finalReplay || finalDocument, finalConverged,
|
||||||
|
[finalDocument, finalReplay].filter(Boolean).map((response) => evidence(response)).join('; '), OPTIONS)
|
||||||
|
if (!finalConverged) {
|
||||||
|
blocker(context, '完税文件幂等收敛', '完成文件未成功或同键重放未收敛,已停止后续自动离仓写入')
|
||||||
|
}
|
||||||
|
const finalDifferent = await routes.platformUploadFinal(context, fixture,
|
||||||
|
{ ...finalRequest, content: Uint8Array.from([0x44, 0x49, 0x46]) }, finalKey)
|
||||||
|
context.assert(MODULE, '完税文件同键异载荷A0409', finalDifferent, a0409(finalDifferent), evidence(finalDifferent), OPTIONS)
|
||||||
|
|
||||||
let completed = await platformDetail(context, routes, fixture)
|
let completed = await platformDetail(context, routes, fixture)
|
||||||
context.assert(MODULE, '混合柜最后一份完成文件后整柜COMPLETED', completed,
|
context.assert(MODULE, '混合柜最后一份完成文件后整柜COMPLETED', completed,
|
||||||
@@ -372,10 +425,10 @@ async function runMixedConcurrency(context, routes, fixture) {
|
|||||||
evidence(queryDetail), OPTIONS)
|
evidence(queryDetail), OPTIONS)
|
||||||
}
|
}
|
||||||
|
|
||||||
const completionDownload = await routes.merchantDownload(context, fixture, customer, {
|
const completionDownload = await routes.merchantDownloadFinal(context, fixture, customer, {
|
||||||
documentId: customer.documentId, completion: true,
|
documentId: customer.documentId,
|
||||||
})
|
})
|
||||||
context.assert(MODULE, '整柜完成后Client可下载自身TAX完成文件', completionDownload,
|
context.assert(MODULE, '整柜完成后Client可下载自身真实报关完税文件', completionDownload,
|
||||||
completionDownload.http === 200 && completionDownload.bytes?.length > 0,
|
completionDownload.http === 200 && completionDownload.bytes?.length > 0,
|
||||||
`HTTP=${completionDownload.http}, bytes=${completionDownload.bytes?.length ?? 0}`, OPTIONS)
|
`HTTP=${completionDownload.http}, bytes=${completionDownload.bytes?.length ?? 0}`, OPTIONS)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { selectSessionPdaOperator } from './domestic-pda-operator.mjs'
|
||||||
import { publishPlatformLogisticsChannels, resolveOceanBookingAgent } from './ocean-booking-fixture.mjs'
|
import { publishPlatformLogisticsChannels, resolveOceanBookingAgent } from './ocean-booking-fixture.mjs'
|
||||||
import { diffPushPreloadStability, projectPushPreloadStability } from './push-preload-stability.mjs'
|
import { diffPushPreloadStability, projectPushPreloadStability } from './push-preload-stability.mjs'
|
||||||
import { containerFieldGaps, loadingListFieldGaps, preloadFieldGaps } from './fulfillment-list-field-contract.mjs'
|
import { containerFieldGaps, loadingListFieldGaps, preloadFieldGaps } from './fulfillment-list-field-contract.mjs'
|
||||||
@@ -563,7 +564,7 @@ export async function run(context) {
|
|||||||
const pdaOperatorsResponse = await context.request('GET',
|
const pdaOperatorsResponse = await context.request('GET',
|
||||||
`/api/platform/v1/iam/users/domestic-pda-operators?warehouseId=${encodeURIComponent(warehouseId)}`)
|
`/api/platform/v1/iam/users/domestic-pda-operators?warehouseId=${encodeURIComponent(warehouseId)}`)
|
||||||
const pdaOperators = context.apiSuccess(MODULE, '查询当前国内头程 PDA 作业员候选', pdaOperatorsResponse, '', OPTIONS)
|
const pdaOperators = context.apiSuccess(MODULE, '查询当前国内头程 PDA 作业员候选', pdaOperatorsResponse, '', OPTIONS)
|
||||||
const pdaOperator = (Array.isArray(pdaOperators) ? pdaOperators : records(pdaOperators))[0]
|
const pdaOperator = selectSessionPdaOperator(pdaOperators, context.pdaToken)
|
||||||
context.assert(MODULE, '存在可用于计划确认的国内头程 PDA 作业员', pdaOperatorsResponse,
|
context.assert(MODULE, '存在可用于计划确认的国内头程 PDA 作业员', pdaOperatorsResponse,
|
||||||
Boolean(pdaOperator?.userId), `operatorId=${id(pdaOperator?.userId) || 'missing'}`, OPTIONS)
|
Boolean(pdaOperator?.userId), `operatorId=${id(pdaOperator?.userId) || 'missing'}`, OPTIONS)
|
||||||
if (!pdaOperator?.userId) return
|
if (!pdaOperator?.userId) return
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { selectSessionPdaOperator } from './domestic-pda-operator.mjs'
|
||||||
import { diffPushPreloadStability, projectPushPreloadStability } from './push-preload-stability.mjs'
|
import { diffPushPreloadStability, projectPushPreloadStability } from './push-preload-stability.mjs'
|
||||||
import { isPureStatusNoop, visibleLoadingHistories } from './loading-history-noop.mjs'
|
import { isPureStatusNoop, visibleLoadingHistories } from './loading-history-noop.mjs'
|
||||||
import { ensureMatsonCarrierPartner } from './carrier-partner-fixture.mjs'
|
import { ensureMatsonCarrierPartner } from './carrier-partner-fixture.mjs'
|
||||||
@@ -672,7 +673,7 @@ export async function run(context) {
|
|||||||
const pdaOperatorsResponse = await context.request('GET',
|
const pdaOperatorsResponse = await context.request('GET',
|
||||||
`/api/platform/v1/iam/users/domestic-pda-operators?warehouseId=${encodeURIComponent(warehouseId)}`)
|
`/api/platform/v1/iam/users/domestic-pda-operators?warehouseId=${encodeURIComponent(warehouseId)}`)
|
||||||
const pdaOperators = context.apiSuccess(MODULE, '查询当前国内头程 PDA 作业员候选', pdaOperatorsResponse, '', OPTIONS)
|
const pdaOperators = context.apiSuccess(MODULE, '查询当前国内头程 PDA 作业员候选', pdaOperatorsResponse, '', OPTIONS)
|
||||||
const pdaOperator = (Array.isArray(pdaOperators) ? pdaOperators : pdaOperators?.records || [])[0]
|
const pdaOperator = selectSessionPdaOperator(pdaOperators, context.pdaToken)
|
||||||
context.assert(MODULE, '存在可用于计划确认的国内头程 PDA 作业员', pdaOperatorsResponse,
|
context.assert(MODULE, '存在可用于计划确认的国内头程 PDA 作业员', pdaOperatorsResponse,
|
||||||
Boolean(pdaOperator?.userId), `operatorId=${id(pdaOperator?.userId) || 'missing'}`, OPTIONS)
|
Boolean(pdaOperator?.userId), `operatorId=${id(pdaOperator?.userId) || 'missing'}`, OPTIONS)
|
||||||
if (!pdaOperator?.userId) return
|
if (!pdaOperator?.userId) return
|
||||||
|
|||||||
@@ -52,7 +52,7 @@ test('PDA opens only after linking a released requirement and uses stable plan i
|
|||||||
assert.match(source, /if \(!transportStable\) return null/)
|
assert.match(source, /if \(!transportStable\) return null/)
|
||||||
assert.match(source, /计划确认前不生成 PDA 任务/)
|
assert.match(source, /计划确认前不生成 PDA 任务/)
|
||||||
assert.match(source, /summary\?\.readiness\?\.canConfirm === true/)
|
assert.match(source, /summary\?\.readiness\?\.canConfirm === true/)
|
||||||
assert.match(source, /Object\.freeze\(\{ expectedVersion: confirmableVersion \}\)/)
|
assert.match(source, /Object\.freeze\(\{ expectedVersion: confirmableVersion, assigneeId: id\(pdaOperator.userId\) \}\)/)
|
||||||
assert.match(source, /confirmationStatus === 'CONFIRMED'/)
|
assert.match(source, /confirmationStatus === 'CONFIRMED'/)
|
||||||
assert.match(source, /readiness\?\.readyForPda === true/)
|
assert.match(source, /readiness\?\.readyForPda === true/)
|
||||||
assert.match(source, /taskId === confirmedTaskId/)
|
assert.match(source, /taskId === confirmedTaskId/)
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { selectSessionPdaOperator } from './domestic-pda-operator.mjs'
|
||||||
const MODULE = 'phase3-order-splitting'
|
const MODULE = 'phase3-order-splitting'
|
||||||
const SCENARIO = 'source-loaded-single-remainder-chain'
|
const SCENARIO = 'source-loaded-single-remainder-chain'
|
||||||
const OPTIONS = { scenario: SCENARIO, deploymentBlocker: true }
|
const OPTIONS = { scenario: SCENARIO, deploymentBlocker: true }
|
||||||
@@ -339,7 +340,7 @@ async function loadOneFragment(context, state) {
|
|||||||
const pdaOperatorsResponse = await context.request('GET',
|
const pdaOperatorsResponse = await context.request('GET',
|
||||||
`/api/platform/v1/iam/users/domestic-pda-operators?warehouseId=${encodeURIComponent(warehouseId)}`)
|
`/api/platform/v1/iam/users/domestic-pda-operators?warehouseId=${encodeURIComponent(warehouseId)}`)
|
||||||
const pdaOperators = context.apiSuccess(MODULE, `查询 ${sourceOrderNo} 国内头程 PDA 作业员候选`, pdaOperatorsResponse, '', OPTIONS)
|
const pdaOperators = context.apiSuccess(MODULE, `查询 ${sourceOrderNo} 国内头程 PDA 作业员候选`, pdaOperatorsResponse, '', OPTIONS)
|
||||||
const pdaOperator = (Array.isArray(pdaOperators) ? pdaOperators : pdaOperators?.records || [])[0]
|
const pdaOperator = selectSessionPdaOperator(pdaOperators, context.pdaToken)
|
||||||
context.assert(MODULE, `${sourceOrderNo} 存在可用 PDA 作业员`, pdaOperatorsResponse,
|
context.assert(MODULE, `${sourceOrderNo} 存在可用 PDA 作业员`, pdaOperatorsResponse,
|
||||||
Boolean(pdaOperator?.userId), `operatorId=${id(pdaOperator?.userId) || 'missing'}`, OPTIONS)
|
Boolean(pdaOperator?.userId), `operatorId=${id(pdaOperator?.userId) || 'missing'}`, OPTIONS)
|
||||||
if (!pdaOperator?.userId) return null
|
if (!pdaOperator?.userId) return null
|
||||||
|
|||||||
@@ -172,6 +172,9 @@ export async function run(context) {
|
|||||||
const initialVersion = Number(created.portSchedule.schedule.version)
|
const initialVersion = Number(created.portSchedule.schedule.version)
|
||||||
const expiredData = scheduleData(context, suffix, ocean.id, knownAllocation, master.customerId, true)
|
const expiredData = scheduleData(context, suffix, ocean.id, knownAllocation, master.customerId, true)
|
||||||
expiredData.sourceRef = payload.data.sourceRef
|
expiredData.sourceRef = payload.data.sourceRef
|
||||||
|
// 甩柜只修改原柜的船信息;柜次、订舱关联和船期任务身份保持不变。
|
||||||
|
expiredData.vesselName = `甩柜改配船-${suffix}`
|
||||||
|
expiredData.voyageNo = `R${suffix}`
|
||||||
const updateResponse = await request(context, 'PUT', `${BASE}/${scheduleId}`,
|
const updateResponse = await request(context, 'PUT', `${BASE}/${scheduleId}`,
|
||||||
{ expectedVersion: initialVersion, data: expiredData, reason: '来源刷新并过期' },
|
{ expectedVersion: initialVersion, data: expiredData, reason: '来源刷新并过期' },
|
||||||
{ 'Idempotency-Key': `${context.marker}-port-update`.slice(0, 128) })
|
{ 'Idempotency-Key': `${context.marker}-port-update`.slice(0, 128) })
|
||||||
@@ -180,6 +183,13 @@ export async function run(context) {
|
|||||||
updated?.portSchedule?.expired === true
|
updated?.portSchedule?.expired === true
|
||||||
&& Number(updated?.portSchedule?.schedule?.version) === initialVersion + 1,
|
&& Number(updated?.portSchedule?.schedule?.version) === initialVersion + 1,
|
||||||
`expired=${updated?.portSchedule?.expired ?? 'missing'}, version=${updated?.portSchedule?.schedule?.version ?? 'missing'}`, OPTIONS)
|
`expired=${updated?.portSchedule?.expired ?? 'missing'}, version=${updated?.portSchedule?.schedule?.version ?? 'missing'}`, OPTIONS)
|
||||||
|
context.assert(MODULE, '甩柜改船只更新原柜船信息且不改变柜次订舱关联', updateResponse,
|
||||||
|
id(updated?.portSchedule?.schedule?.id) === scheduleId
|
||||||
|
&& id(updated?.portSchedule?.schedule?.oceanBookingId) === ocean.id
|
||||||
|
&& id(updated?.portSchedule?.schedule?.containerBookingId) === (knownAllocation || '')
|
||||||
|
&& updated?.portSchedule?.schedule?.vesselName === expiredData.vesselName
|
||||||
|
&& updated?.portSchedule?.schedule?.voyageNo === expiredData.voyageNo,
|
||||||
|
`schedule=${id(updated?.portSchedule?.schedule?.id)}, ocean=${id(updated?.portSchedule?.schedule?.oceanBookingId)}, container=${id(updated?.portSchedule?.schedule?.containerBookingId)}, vessel=${updated?.portSchedule?.schedule?.vesselName || 'missing'}, voyage=${updated?.portSchedule?.schedule?.voyageNo || 'missing'}`, OPTIONS)
|
||||||
|
|
||||||
const stale = await request(context, 'PUT', `${BASE}/${scheduleId}`,
|
const stale = await request(context, 'PUT', `${BASE}/${scheduleId}`,
|
||||||
{ expectedVersion: initialVersion, data: expiredData, reason: '旧版本覆盖' },
|
{ expectedVersion: initialVersion, data: expiredData, reason: '旧版本覆盖' },
|
||||||
|
|||||||
@@ -776,13 +776,15 @@ Assert-Contains $firstlegAgents 'customs/container/controller/MerchantContainerC
|
|||||||
Assert-Contains $containerCustomsOpenApi '/api/merchant/v1/customs/loading-plans/{planId}/documents/upload:' '客户退税资料上传契约'
|
Assert-Contains $containerCustomsOpenApi '/api/merchant/v1/customs/loading-plans/{planId}/documents/upload:' '客户退税资料上传契约'
|
||||||
Assert-Contains $containerCustomsOpenApi '/api/merchant/v1/customs/documents/{documentId}/inbounds:' '客户资料入仓码范围契约'
|
Assert-Contains $containerCustomsOpenApi '/api/merchant/v1/customs/documents/{documentId}/inbounds:' '客户资料入仓码范围契约'
|
||||||
Assert-Contains $containerCustomsOpenApi '/api/platform/v1/customs/loading-plans/{planId}/start:' '平台开始报关冻结契约'
|
Assert-Contains $containerCustomsOpenApi '/api/platform/v1/customs/loading-plans/{planId}/start:' '平台开始报关冻结契约'
|
||||||
Assert-Contains $containerCustomsOpenApi '/api/platform/v1/customs/loading-plans/documents/{documentId}/completion/upload:' '平台完成文件契约'
|
Assert-Contains $containerCustomsOpenApi '/api/platform/v1/customs/loading-plans/documents/{documentId}/receipt/upload:' '平台报关行回执契约'
|
||||||
|
Assert-Contains $containerCustomsOpenApi '/api/platform/v1/customs/loading-plans/documents/{documentId}/final/upload:' '平台完成文件契约'
|
||||||
Assert-Contains $containerCustomsOpenApi '/api/platform/v1/customs/loading-plans/{planId}/complete-all-buy:' '全买单柜完成契约'
|
Assert-Contains $containerCustomsOpenApi '/api/platform/v1/customs/loading-plans/{planId}/complete-all-buy:' '全买单柜完成契约'
|
||||||
Assert-Contains $containerCustomsOpenApi 'enum: [TAX_REFUND, BUY_DECLARATION]' '整柜报关税务模式'
|
Assert-Contains $containerCustomsOpenApi 'enum: [TAX_REFUND, BUY_DECLARATION]' '整柜报关税务模式'
|
||||||
Assert-Contains $adminContainerCustomsApi "const basePath = '/platform/v1/customs/loading-plans'" 'Admin 整柜报关真实调用'
|
Assert-Contains $adminContainerCustomsApi "const basePath = '/platform/v1/customs/loading-plans'" 'Admin 整柜报关真实调用'
|
||||||
Assert-Contains $clientContainerCustomsApi '/merchant/v1/customs/orders/' 'Client 订单报关真实调用'
|
Assert-Contains $clientContainerCustomsApi '/merchant/v1/customs/orders/' 'Client 订单报关真实调用'
|
||||||
Assert-Contains $clientContainerCustomsApi 'Idempotency-Key' 'Client 报关写入幂等'
|
Assert-Contains $clientContainerCustomsApi 'Idempotency-Key' 'Client 报关写入幂等'
|
||||||
Assert-Contains $customsFileOpenApi 'LOADING_LIST_CUSTOMS_SOURCE' 'File 报关源文件类别'
|
Assert-Contains $customsFileOpenApi 'LOADING_LIST_CUSTOMS_SOURCE' 'File 报关源文件类别'
|
||||||
|
Assert-Contains $customsFileOpenApi 'LOADING_LIST_CUSTOMS_RECEIPT' 'File 报关行回执类别'
|
||||||
Assert-Contains $customsFileOpenApi 'LOADING_LIST_CUSTOMS_RESULT' 'File 报关完成文件类别'
|
Assert-Contains $customsFileOpenApi 'LOADING_LIST_CUSTOMS_RESULT' 'File 报关完成文件类别'
|
||||||
|
|
||||||
# 平台商品图片和海外 PDA 签收照片都必须走鉴权下载,不暴露内部存储路径。
|
# 平台商品图片和海外 PDA 签收照片都必须走鉴权下载,不暴露内部存储路径。
|
||||||
|
|||||||
@@ -0,0 +1,28 @@
|
|||||||
|
-- 报关两文件流程:回执与真实报关完税文件分离。
|
||||||
|
-- 仅在已确认目标测试库后执行;所有 DDL 可重复执行,不创建数据库、不写业务数据。
|
||||||
|
USE baiyue_firstleg;
|
||||||
|
|
||||||
|
SET @ddl = IF(NOT EXISTS (
|
||||||
|
SELECT 1 FROM information_schema.columns
|
||||||
|
WHERE table_schema=DATABASE() AND table_name='fl_customs_container' AND column_name='broker_submitted_at'
|
||||||
|
), 'ALTER TABLE fl_customs_container ADD COLUMN broker_submitted_by BIGINT UNSIGNED NULL COMMENT ''发送报关行执行的操作人ID'' AFTER completed_at, ADD COLUMN broker_submitted_by_name VARCHAR(64) NULL COMMENT ''发送报关行执行的操作人名称'' AFTER broker_submitted_by, ADD COLUMN broker_submitted_at DATETIME(3) NULL COMMENT ''发送报关行执行时间'' AFTER broker_submitted_by_name', 'SELECT 1');
|
||||||
|
PREPARE customs_container_broker_submit_stmt FROM @ddl; EXECUTE customs_container_broker_submit_stmt; DEALLOCATE PREPARE customs_container_broker_submit_stmt;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS fl_customs_final_document (
|
||||||
|
id BIGINT UNSIGNED NOT NULL COMMENT '真实报关完税文件ID',
|
||||||
|
source_document_id BIGINT UNSIGNED NOT NULL COMMENT '对应冻结客户资料ID',
|
||||||
|
file_id BIGINT UNSIGNED NOT NULL COMMENT 'File服务文件ID',
|
||||||
|
file_name VARCHAR(255) NOT NULL COMMENT '真实报关完税文件名称快照',
|
||||||
|
file_hash CHAR(64) NOT NULL COMMENT '真实报关完税文件SHA-256摘要',
|
||||||
|
content_type VARCHAR(128) NULL COMMENT '真实报关完税文件媒体类型',
|
||||||
|
size_bytes BIGINT UNSIGNED NOT NULL COMMENT '真实报关完税文件大小(字节)',
|
||||||
|
uploaded_by BIGINT UNSIGNED NOT NULL COMMENT '上传操作人ID',
|
||||||
|
uploaded_by_name VARCHAR(64) NULL COMMENT '上传操作人名称快照',
|
||||||
|
uploaded_at DATETIME(3) NOT NULL COMMENT '上传时间',
|
||||||
|
version INT UNSIGNED NOT NULL DEFAULT 1 COMMENT '完税文件版本号',
|
||||||
|
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间',
|
||||||
|
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间',
|
||||||
|
PRIMARY KEY(id),
|
||||||
|
UNIQUE KEY uk_customs_final_source(source_document_id),
|
||||||
|
CONSTRAINT fk_customs_final_source FOREIGN KEY(source_document_id) REFERENCES fl_customs_customer_document(id)
|
||||||
|
) ENGINE=InnoDB COMMENT='真实报关完税文件当前版本';
|
||||||
@@ -108,6 +108,19 @@ DEALLOCATE PREPARE recent_schema_stmt;
|
|||||||
-- Firstleg:墨家测量收件箱扩展为箱唛/SKU 双分流
|
-- Firstleg:墨家测量收件箱扩展为箱唛/SKU 双分流
|
||||||
-- ============================================================================
|
-- ============================================================================
|
||||||
|
|
||||||
|
SET @ddl = IF(
|
||||||
|
NOT EXISTS (
|
||||||
|
SELECT 1 FROM information_schema.columns
|
||||||
|
WHERE table_schema = DATABASE() AND table_name = 'fl_mojia_measurement_receipt'
|
||||||
|
AND column_name = 'inbound_code'
|
||||||
|
),
|
||||||
|
'ALTER TABLE fl_mojia_measurement_receipt ADD COLUMN inbound_code VARCHAR(64) NULL COMMENT ''箱码命中后的权威入仓码;SKU记录为空'' AFTER box_code',
|
||||||
|
'SELECT 1'
|
||||||
|
);
|
||||||
|
PREPARE recent_schema_stmt FROM @ddl;
|
||||||
|
EXECUTE recent_schema_stmt;
|
||||||
|
DEALLOCATE PREPARE recent_schema_stmt;
|
||||||
|
|
||||||
SET @ddl = IF(
|
SET @ddl = IF(
|
||||||
NOT EXISTS (
|
NOT EXISTS (
|
||||||
SELECT 1 FROM information_schema.columns
|
SELECT 1 FROM information_schema.columns
|
||||||
|
|||||||
Reference in New Issue
Block a user