Merge remote-tracking branch 'origin/test' into test

This commit is contained in:
2026-09-28 11:59:16 +08:00
85 changed files with 4157 additions and 162 deletions
@@ -1756,6 +1756,9 @@ CREATE TABLE fl_customs_container (
completed_by bigint unsigned DEFAULT NULL COMMENT '完成报关操作人ID',
completed_by_name varchar(64) 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 '聚合创建时间',
updated_at datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '聚合更新时间',
version int unsigned NOT NULL DEFAULT '0' COMMENT '乐观锁版本号',
@@ -1824,7 +1827,26 @@ CREATE TABLE fl_customs_completion_document (
PRIMARY KEY (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)
) 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 (
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和数据隔离键',
subject_id bigint unsigned NOT NULL COMMENT '设备应用操作主体ID',
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',
scanned_shipment_id varchar(64) DEFAULT NULL COMMENT '设备从Amazon FBA箱标实扫的Shipment ID',
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 (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 (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 (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 (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 (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 (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);
@@ -7755,4 +7778,276 @@ USE baiyue_finance;
-- 系统基线 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));
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
View File
@@ -19,7 +19,7 @@
| 海外异常订单证据 | 通用内部业务文件入口;固定 `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 校验申请/明细关系、客户归属、权限和业务状态 |
| 入库扣货凭证 | 通用内部业务文件入口;固定 `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 保存 |
| 拖车预约附件 | 通用内部业务文件入口;固定 `tenantId=0`、`businessType=TRUCK_APPOINTMENT_ATTACHMENT`、`businessId=appointmentId`,只允许派车单、司机/车辆资料和其他平台附件;提单文件归订舱 `OCEAN_BOOKING_DOCUMENT`,不得复制为拖车附件 |
| 存储与实体 | `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_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_RECEIPT(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_ERROR_REPORT(true, FormatPolicy.XLSX, Limits.ORDER_IMPORT, BusinessIdPolicy.ORDER_BATCH_NO);
@@ -5,7 +5,7 @@ info:
description: |
File 只保存不可变文件对象并生成异步 XLSX,不保存报关状态或业务版本。
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 校验整柜完成和结果可见性。
用户侧权限冻结为:Platform 上传结果 platform:firstleg:customs:manage,Platform 下载 platform:firstleg:customs:list;
Client 上传源文件 merchant:order:edit,Client 下载自身源文件或获准结果 merchant:order:view。
@@ -15,7 +15,7 @@ paths:
post:
summary: 保存报关业务文件对象
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 鉴权的实际操作人。
parameters:
- $ref: '#/components/parameters/InternalToken'
@@ -94,7 +94,7 @@ components:
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}}
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}}
FileId: {name: fileId, in: path, required: true, schema: {type: integer, format: int64, minimum: 1}}
schemas:
@@ -29,7 +29,7 @@ class CustomsFileExportContractTest {
String source = Files.readString(repositoryRoot()
.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("platform:firstleg:customs:manage", "platform:firstleg:customs:list",
"merchant:order:edit", "merchant:order:view")
@@ -159,7 +159,7 @@ class InternalBusinessFileServiceTest {
.containsExactly(100L, "OVERSEAS_RELABEL_PHOTO", "81");
}
@Test void loadingCustomsSourceAndResultAreCustomerOwnedArbitraryFormatObjects() {
@Test void loadingCustomsSourceReceiptAndResultAreCustomerOwnedArbitraryFormatObjects() {
MockMultipartFile file = new MockMultipartFile("file", "资料包.zip", "application/zip", "zip".getBytes());
when(storage.store(eq(10001L), eq("51"), same(file), any(BusinessFileCategory.class)))
.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 receipt = service.upload(10001L, "LOADING_LIST_CUSTOMS_RECEIPT", "51", 7L, file);
var result = service.upload(10001L, "LOADING_LIST_CUSTOMS_RESULT", "51", 7L, file);
assertThat(source).extracting(InternalBusinessFileService.Metadata::tenantId,
InternalBusinessFileService.Metadata::businessType,
InternalBusinessFileService.Metadata::businessId)
.containsExactly(10001L, "LOADING_LIST_CUSTOMS_SOURCE", "51");
assertThat(receipt.businessType()).isEqualTo("LOADING_LIST_CUSTOMS_RECEIPT");
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_RECEIPT);
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))
.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))
.isInstanceOf(BizException.class).hasMessageContaining("明确客户");
verifyNoInteractions(files, storage);
+2 -2
View File
@@ -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` |
| 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` 发布应付 |
| 整柜出口报关 | `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 生成文件 |
| 跨模型桥接 | `loading/bridge/controller/PlatformLoadingModelBridgeController.java` |
| 跨服务客户端 | `client/OrderCargoClient.java`、`WarehousePalletLocationClient.java`、`MeasurementImageClient.java`、`LoadingEvidenceFileClient.java`、`EventBridgeClient.java` |
@@ -77,7 +77,7 @@
- 订舱列表 `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`,提单文件继续归订舱。
- 海外仓业务船节点任务从确认柜子离开国内仓库后启用,必须绑定真实订舱和柜次;十个标准节点、预计/实际时间、异常事实、供应商缺失节点和还柜停止规则统一以 `../../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`,不允许单入仓局部成功。
- 手工船节点的预计时间只追加事实,不推进 `latestStandardNode`;携带 `actualAt` 时标准节点序号必须严格大于最新实际节点,重复、相同或回退节点返回冲突。已审核实际时间修正只能更新同一事实的时间/证据,不得制造第二条同节点实际事实或绕过单调推进。
- 海外卸柜完成事件只接受 `OVERSEAS -> CN` 的 `OVERSEAS_CONTAINER_UNLOADED` V1,并通过内部服务令牌进入。消费者必须校验信封、Payload 哈希、来源货柜和时间顺序,以 event ID + Payload 哈希幂等,使用事件聚合版本阻止乱序覆盖;预计报空时间固定按卸柜完成时间加 72 小时计算,实际报空时间可为空。
@@ -35,15 +35,16 @@ public class PlatformMojiaSkuSyncController {
@GetMapping
@PreAuthorize("hasAuthority('platform:mojia-sku-sync:list')")
@Operation(summary = "查询墨家一件代发SKU测量记录")
@Operation(summary = "查询墨家机器全部上传记录")
public ApiResponse<PageResult<MojiaSkuSyncDtos.Record>> page(
@AuthenticationPrincipal AuthenticatedUser user,
@RequestParam(required = false) String skuCode,
@RequestParam(required = false) String code,
@RequestParam(required = false) String measurementKind,
@RequestParam(required = false) String syncStatus,
@RequestParam(defaultValue = "1") @Min(1) long page,
@RequestParam(defaultValue = "20") @Min(1) @Max(200) long size) {
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")
@PreAuthorize("hasAuthority('platform:mojia-sku-sync:list')")
@@ -33,6 +33,13 @@ public class MerchantContainerCustomsController {
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)
@PreAuthorize("hasAuthority('merchant:order:edit')")
public ApiResponse<DocumentView> upload(@AuthenticationPrincipal AuthenticatedUser user,
@@ -84,13 +91,20 @@ public class MerchantContainerCustomsController {
return response(service.downloadSource(FirstlegScopeGuard.merchant(user),documentId,false));
}
@GetMapping("/documents/{documentId}/completion/download")
@GetMapping("/documents/{documentId}/receipt/download")
@PreAuthorize("hasAuthority('merchant:order:view')")
public ResponseEntity<byte[]> downloadCompletion(@AuthenticationPrincipal AuthenticatedUser user,
public ResponseEntity<byte[]> downloadReceipt(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable @Positive long documentId) {
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")
@PreAuthorize("hasAuthority('merchant:order:edit')")
public ApiResponse<ContainerView> confirmReceipt(@AuthenticationPrincipal AuthenticatedUser user,
@@ -18,6 +18,7 @@ import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile;
import java.nio.charset.StandardCharsets;
import java.util.List;
@Validated
@RestController
@@ -33,6 +34,66 @@ public class PlatformContainerCustomsController {
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")
@PreAuthorize("hasAuthority('platform:firstleg:customs:manage')")
@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));
}
@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')")
@BusinessAudit(event="CUSTOMS_COMPLETION_UPLOAD",resourceType="CUSTOMS_CUSTOMER_DOCUMENT")
public ApiResponse<DocumentView> uploadCompletion(@AuthenticationPrincipal AuthenticatedUser user,
@@ -57,6 +129,18 @@ public class PlatformContainerCustomsController {
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")
@PreAuthorize("hasAuthority('platform:firstleg:customs:manage')")
@BusinessAudit(event="ALL_BUY_CONTAINER_CUSTOMS_COMPLETE",resourceType="CUSTOMS_CONTAINER")
@@ -84,6 +168,14 @@ public class PlatformContainerCustomsController {
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) {
return ResponseEntity.ok().cacheControl(CacheControl.noStore())
.contentType(MediaType.parseMediaType(value.contentType()))
@@ -17,17 +17,21 @@ public final class ContainerCustomsDtos {
public record FileView(long fileId, String fileName, String fileHash, String contentType,
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,
List<Long> inboundIds, List<String> inboundCodes,
boolean frozen, FileView completion, String receiptStatus,
String receiptRejectReason) {}
boolean frozen, FileView receipt, String receiptStatus,
String receiptRejectReason, FileView finalDocument,
List<ReceiptVersionView> receiptHistory) {}
public record ContainerView(long aggregateId, long loadingPlanId, long containerBookingId,
String planNo, String cabinetNo, String containerNo, String status, boolean hasActualCargo,
String merchantStatus,
int version, int taxRefundCount, int coveredTaxRefundCount, long actualBoxCount,
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,
@Min(0) int expectedVersion) {}
@@ -35,5 +39,30 @@ public final class ContainerCustomsDtos {
public record ActionRequest(@Min(0) int expectedVersion, boolean confirm,
@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) {}
}
@@ -20,6 +20,11 @@ public interface ContainerCustomsMapper {
r.uploaded_by_name uploadedByName,r.uploaded_at uploadedAt,r.version version,
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 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,
@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 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
@@ -200,6 +305,14 @@ public interface ContainerCustomsMapper {
""")
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) " +
"WHERE customs_container_id=#{containerId} AND status='ACTIVE' AND frozen_at IS NULL")
int freezeDocuments(@Param("containerId") long containerId);
@@ -227,6 +340,12 @@ public interface ContainerCustomsMapper {
"WHERE r.source_document_id=#{documentId} FOR UPDATE")
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 INTO fl_customs_completion_document
(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("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 COUNT(*) FROM fl_customs_customer_document d
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("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 INTO fl_customs_container_history
(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) {}
record 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) {}
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,
String contentType,Long sizeBytes,String status,Long uploadedBy,LocalDateTime uploadedAt,
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,
Long sizeBytes,Long uploadedBy,String uploadedByName,LocalDateTime uploadedAt,Integer version,
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,
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,
String inboundCode,String skuCode,String productNameZh,String productNameEn,Integer packageCount,
Integer productQuantity,BigDecimal grossWeightKg,BigDecimal netWeightKg,BigDecimal volumeCbm,
@@ -33,6 +33,7 @@ public class ContainerCustomsService {
public static final String DECLARING = "DECLARING";
public static final String COMPLETED = "COMPLETED";
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 long MAX_FILE_BYTES = 50L * 1024 * 1024;
@@ -172,6 +173,25 @@ public class ContainerCustomsService {
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
public DocumentView uploadCompletion(long documentId, long operatorId, String operatorName, String key,
int expectedVersion, MultipartFile file) {
@@ -186,8 +206,11 @@ public class ContainerCustomsService {
if (container == null || !DECLARING.equals(container.status()) || source.frozenAt() == null) {
throw conflict("当前客户资料不在报关中冻结版本");
}
if (mapper.brokerSubmittedAt(container.id()) != null) {
throw conflict("客户确认回执已发送给报关行,不可再修改回执");
}
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;
int version;
if (completion == null) {
@@ -210,7 +233,7 @@ public class ContainerCustomsService {
public ContainerView confirmReceipt(long tenantId, long documentId, long operatorId, String operatorName,
String key, int expectedVersion, boolean confirm, String reason) {
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);
if (replay != null) {
DocumentRow source = requiredDocument(documentId);
@@ -227,12 +250,47 @@ public class ContainerCustomsService {
if (mapper.confirmReceipt(documentId, tenantId, confirm ? "CONFIRMED" : "REJECTED",
confirm ? null : trim(reason), operatorId, operatorName) != 1) throw conflict("回执状态已变化,请刷新后重试");
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);
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
public ContainerView completeAllBuy(long planId, long operatorId, String operatorName, String key, ActionRequest request) {
String action = "COMPLETE_ALL_BUY_CUSTOMS_" + planId;
@@ -251,6 +309,148 @@ public class ContainerCustomsService {
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) {
DocumentRow source = requiredDocument(documentId);
if (!platform && source.tenantId() != tenantId) throw notFound("客户报送资料不存在");
@@ -261,12 +461,23 @@ public class ContainerCustomsService {
public Download downloadCompletion(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("报关完成文件不存在");
if (!platform && (source.tenantId() != tenantId || container == null || !Set.of(DECLARING, COMPLETED).contains(container.status()))) {
throw notFound("报关行回执不存在");
}
CompletionRow completion = mapper.completion(documentId);
if (completion == null) throw notFound("报关完成文件不存在");
return download(source.tenantId(), RESULT_FILE_TYPE, Long.toString(container.loadingPlanId()), completion.fileId(), completion.fileName());
if (completion == null) throw notFound("报关行回执不存在");
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) {
@@ -361,18 +572,22 @@ public class ContainerCustomsService {
String merchantStatus=tenantId==null?null:merchantStatus(container.status(),refund,coveredRefund);
return new ContainerView(container.id(), container.loadingPlanId(), container.containerBookingId(), plan.planNo(),
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) {
List<ScopeRow> scopes = source.frozenAt() == null ? mapper.scopes(source.id()) : mapper.frozenScopes(source.id());
CompletionRow completion = mapper.completion(source.id());
FinalRow finalDocument = mapper.finalDocument(source.id());
ContainerRow container = mapper.containerById(source.customsContainerId());
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(),
scopes.stream().map(ScopeRow::inboundCode).toList(), source.frozenAt() != null, result,
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) {
@@ -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(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 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("无法读取上传文件"); } }
@@ -10,7 +10,7 @@ import java.time.LocalDateTime;
public final class 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) {}
@@ -25,7 +25,8 @@ public final class MojiaSkuSyncDtos {
public record RetryRequest(@NotNull Integer revision) {}
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,
String syncStatus, String syncFailureCode, String syncFailureReason, String lingxingSkuId,
Integer revision, LocalDateTime updatedAt) {}
@@ -19,6 +19,7 @@ public class MojiaMeasurementReceiptEntity {
private Long tenantId;
private Long subjectId;
private String boxCode;
private String inboundCode;
/** BOX_LABEL 表示现有箱唛链路;SKU 表示一件代发新品测量同步收件。 */
private String measurementKind;
private String scannedShipmentId;
@@ -13,12 +13,47 @@ public interface UnifiedInboundTransportMapper {
@Select("""
<script>
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.estimated_sailing_at, b.actual_sailing_at,
b.estimated_port_arrival_at, b.actual_port_arrival_at
b.container_no, b.cabinet_no,
COALESCE(schedule.vessel_name, b.vessel_name) AS vessel_name,
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
JOIN fl_loading_plan p ON p.id = i.loading_plan_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
AND p.plan_status IN ('LOADING', 'COMPLETED')
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.entity.ContainerBookingEntity;
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 org.springframework.stereotype.Repository;
@Repository
public class ContainerRepository {
@Resource private ContainerBookingMapper mapper;
@Resource private PortScheduleMapper schedules;
public PageResult<ContainerBookingEntity> page(String workbench, Query query) {
Page<ContainerBookingEntity> result = mapper.selectBookingPage(
new Page<>(query.page(), query.size()), query, workbench);
result.getRecords().forEach(this::resolveLoadingTime);
result.getRecords().forEach(this::resolveReadModel);
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 findActiveForUpdate(long id) { return mapper.selectActiveBookingForUpdate(id); }
public boolean departed(long id) { return mapper.hasActiveOutboundDeparture(id) > 0; }
@@ -62,6 +65,21 @@ public class ContainerRepository {
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) {
if (value != null && value.getLoadingAt() == null
&& "COMPLETED".equals(value.getPlanStatus())) {
@@ -14,14 +14,22 @@ import org.springframework.util.StringUtils;
public class MojiaSkuSyncRepository {
@Resource private MojiaMeasurementReceiptMapper mapper;
public PageResult<MojiaMeasurementReceiptEntity> page(String skuCode, String syncStatus,
/** 保留仓储层旧测试/内部调用的四参数入口,统一查询改为全部上传记录。 */
public PageResult<MojiaMeasurementReceiptEntity> page(String code, String syncStatus,
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),
new LambdaQueryWrapper<MojiaMeasurementReceiptEntity>()
.eq(MojiaMeasurementReceiptEntity::getMeasurementKind, "SKU")
.eq(StringUtils.hasText(measurementKind), MojiaMeasurementReceiptEntity::getMeasurementKind, measurementKind)
.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::getId)));
}
@@ -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));
}
}
@@ -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));
}
}
@@ -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)); }
}
@@ -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);
}
}
@@ -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()))); }
}
@@ -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) {}
}
@@ -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;
}
@@ -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;
}
@@ -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;
}
@@ -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;
}
@@ -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;
}
@@ -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;
}
@@ -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;
}
@@ -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();
}
@@ -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> {}
@@ -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> {}
@@ -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);
}
@@ -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));
}
}
@@ -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) {}
}
@@ -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);
}
@@ -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 &gt;= #{startAt} AND etd_at &lt; #{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);
}
@@ -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);
}
@@ -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());
}
}
@@ -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); }
}
@@ -111,7 +111,8 @@ public class MojiaMeasurementOpenApiService {
throw exception;
}
if (platformBinding.warehouseId() != machineWarehouseId) {
throw new BizException(ResultCode.FORBIDDEN, "箱号不属于当前设备绑定仓库");
return saveUnmatched(app, request, image,
new BizException(ResultCode.FORBIDDEN, "箱号不属于当前设备绑定仓库"));
}
CartonMeasurementResult result;
try {
@@ -132,6 +133,7 @@ public class MojiaMeasurementOpenApiService {
correlation("requestId"), correlation("traceId"), safe(request.boxCode()),
safe(request.machineNo()), request.length(), request.width(), request.height(),
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(),
result.boxCode(), result.imageFileId(), result.created());
} 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,
MultipartFile image) {
long receiptId = IdWorker.getId();
@@ -203,6 +232,7 @@ public class MojiaMeasurementOpenApiService {
receipt.setTenantId(app.tenantId());
receipt.setSubjectId(app.subjectId());
receipt.setBoxCode(request.boxCode().trim());
receipt.setMeasurementKind("BOX_LABEL");
receipt.setScannedShipmentId(optionalTrim(request.shipmentId()));
receipt.setLengthCm(request.length());
receipt.setWidthCm(request.width());
@@ -42,7 +42,7 @@ public class MojiaSkuSyncService {
public PageResult<MojiaSkuSyncDtos.Record> page(MojiaSkuSyncDtos.Query query) {
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(),
source.page(), source.size(), source.total(), source.pages());
}
@@ -104,7 +104,9 @@ public class MojiaSkuSyncService {
}
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.getMeasuredAt(), value.getCreatedAt(), value.getSyncStatus(), value.getSyncFailureCode(),
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和数据隔离键',
subject_id BIGINT UNSIGNED NOT NULL COMMENT '设备应用操作主体ID',
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',
scanned_shipment_id VARCHAR(64) NULL COMMENT '设备从Amazon FBA箱标实扫的Shipment ID',
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_name VARCHAR(64) 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 '聚合创建时间',
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) 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='开始报关时冻结的客户资料覆盖范围';
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 '一份冻结客户资料严格对应一份完成文件逻辑根',
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摘要',
content_type VARCHAR(128) 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),
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)
) ENGINE=InnoDB COMMENT='报关完成文件当前版本';
) ENGINE=InnoDB COMMENT='报关行回执当前版本';
CREATE TABLE IF NOT EXISTS fl_customs_completion_document_version (
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)
) 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 (
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'))
) 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;
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)
@@ -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'}]
requestBody: {$ref: '#/components/requestBodies/ActionBody'}
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:
summary: 上传或覆盖冻结客户资料的一对一完成文件
description: 报关行回执按冻结客户资料一对一保存;上传后等待对应客户确认或打回,不自动完成整柜报关。
summary: 创建整柜正式申报批次并冻结提交快照
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
parameters:
- {$ref: '#/components/parameters/DocumentId'}
@@ -38,9 +83,22 @@ paths:
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}/completion/download:
/api/platform/v1/customs/loading-plans/documents/{documentId}/receipt/download:
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
parameters: [{$ref: '#/components/parameters/DocumentId'}]
responses: {'200': {$ref: '#/components/responses/BinaryResponse'}}
@@ -50,6 +108,12 @@ paths:
x-permission: merchant:order:view
parameters: [{$ref: '#/components/parameters/OrderId'}]
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:
post:
summary: 上传资料并关联当前柜内本客户一个或多个退税入仓码
@@ -93,9 +157,15 @@ paths:
x-permission: merchant:order:view
parameters: [{$ref: '#/components/parameters/DocumentId'}]
responses: {'200': {$ref: '#/components/responses/BinaryResponse'}}
/api/merchant/v1/customs/documents/{documentId}/completion/download:
/api/merchant/v1/customs/documents/{documentId}/receipt/download:
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
parameters: [{$ref: '#/components/parameters/DocumentId'}]
responses: {'200': {$ref: '#/components/responses/BinaryResponse'}}
@@ -124,6 +194,8 @@ components:
PlanId: {name: planId, 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'}}
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}}
InboundIds: {name: inboundIds, in: query, required: true, schema: {type: array, minItems: 1, maxItems: 200, items: {$ref: '#/components/schemas/LongId'}}}
requestBodies:
@@ -133,16 +205,30 @@ components:
ContainerResponse: {description: 成功或业务失败, content: {application/json: {schema: {$ref: '#/components/schemas/ContainerEnvelope'}}}}
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}}}}
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:
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}}}
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'}}}]}
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}}}
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}}}
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}}}
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}}}
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}}}
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}}}
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}}
@@ -19,6 +19,11 @@ class ContainerCustomsControllerContractTest {
assertPermission(PlatformContainerCustomsController.class,"start","platform:firstleg:customs:manage");
assertPermission(PlatformContainerCustomsController.class,"uploadCompletion","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() {
assertThat(MerchantContainerCustomsController.class.getAnnotation(RequestMapping.class).value())
@@ -28,11 +33,13 @@ class ContainerCustomsControllerContractTest {
assertPermission(MerchantContainerCustomsController.class,"replace","merchant:order:edit");
assertPermission(MerchantContainerCustomsController.class,"scope","merchant:order:edit");
assertPermission(MerchantContainerCustomsController.class,"delete","merchant:order:edit");
assertPermission(MerchantContainerCustomsController.class,"clearanceResults","merchant:order:view");
}
@Test void openApiPublishesEveryPathParameterAndGeneratableDtos()throws Exception{
String yaml=new ClassPathResource("openapi/customs-v1.yaml").getContentAsString(java.nio.charset.StandardCharsets.UTF_8);
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("LOADING_LIST_BUY_DECLARATION_ITEMS")
.doesNotContain("/cases/","CUSTOMS_DECLARATION");
@@ -45,9 +45,27 @@ class ContainerCustomsMapperContractTest {
@Test void completionQueriesExplicitlyMapEveryRecordComponentIncludingVersion() throws Exception {
assertRecordProjection("completion", 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 {
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();
assertThat(value).doesNotContainIgnoringCase("SELECT *");
String projection=value.substring(value.indexOf("SELECT ")+7,value.indexOf(" FROM "));
@@ -56,7 +74,8 @@ class ContainerCustomsMapperContractTest {
.map(column->column.substring(column.lastIndexOf(' ')+1))
.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 {
@@ -248,9 +248,9 @@ class ContainerCustomsServiceTest {
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.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,
"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.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")));
@@ -259,7 +259,7 @@ class ContainerCustomsServiceTest {
var file=new MockMultipartFile("file","result.bin","application/octet-stream",new byte[]{1});
var result=service.uploadCompletion(1L,5L,"admin","k",0,file);
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());
}
@@ -274,6 +274,18 @@ class ContainerCustomsServiceTest {
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(){
var source=document(1L);
var declaring=container(ContainerCustomsService.DECLARING,3);
@@ -282,7 +294,7 @@ class ContainerCustomsServiceTest {
var commandState=new AtomicReference<ContainerCustomsMapper.CommandRow>();
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 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);
when(mapper.document(1L)).thenReturn(source);
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.lockDocument(1L)).thenReturn(source);
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));
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->{
@@ -319,12 +331,12 @@ class ContainerCustomsServiceTest {
var first=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(replay.completion().fileId()).isEqualTo(99L);
assertThat(first.receipt().fileId()).isEqualTo(99L);
assertThat(replay.receipt().fileId()).isEqualTo(99L);
assertThatThrownBy(()->service.uploadCompletion(1L,5L,"admin","completion-key",0,changedFile))
.isInstanceOfSatisfying(BizException.class,
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)).insertCompletion(anyLong(),eq(1L),eq(99L),anyString(),anyString(),anyString(),
eq(1L),eq(5L),eq("admin"));
@@ -335,7 +347,7 @@ class ContainerCustomsServiceTest {
var source=document(1L);when(mapper.document(1L)).thenReturn(source);
when(mapper.containerById(2L)).thenReturn(container(ContainerCustomsService.DECLARING,3));
assertThatThrownBy(()->service.downloadCompletion(7L,1L,false)).isInstanceOf(BizException.class)
.hasMessageContaining("完成文件不存在");
.hasMessageContaining("报关行回执不存在");
verifyNoInteractions(files);
}
@@ -21,6 +21,9 @@ class CustomsSqlContractTest {
"fl_customs_frozen_document_inbound",
"fl_customs_completion_document",
"fl_customs_completion_document_version",
"fl_customs_final_document",
"fl_customs_declaration_batch",
"fl_customs_clearance_result",
"fl_customs_container_history",
"fl_customs_command");
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_frozen_document_inbound", "fl_customs_completion_document_version")
.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)")
.doesNotContain("platform:firstleg:loading-list:export");
}
@@ -2,6 +2,8 @@ package com.baiyue.firstleg.repository;
import com.baiyue.firstleg.entity.ContainerBookingEntity;
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.extension.ExtendWith;
import org.mockito.InjectMocks;
@@ -16,6 +18,7 @@ import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class)
class ContainerRepositoryLoadingTimeTest {
@Mock private ContainerBookingMapper mapper;
@Mock private PortScheduleMapper schedules;
@InjectMocks private ContainerRepository repository;
@Test
@@ -44,4 +47,30 @@ class ContainerRepositoryLoadingTimeTest {
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());
}
}
@@ -73,7 +73,7 @@ class Phase3FinalUnifiedSqlContractTest {
@Test
void initializationPassIsUniqueAndFinal() throws Exception {
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(sql.trim()).endsWith(pass);
assertThat(sql)
@@ -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:");
}
}
@@ -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");
}
}
@@ -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");
}
}
@@ -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.math.BigDecimal;
import java.time.LocalDateTime;
import java.util.concurrent.Callable;
import java.util.concurrent.CountDownLatch;
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.anyLong;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
@@ -100,6 +102,7 @@ class MeasurementReviewServiceTest {
assertThat(history.getValue().getToStatus()).isEqualTo("WAIT_SPLIT");
}
@Test
@DisplayName("审核通过使用订单权威货物总重量,不依赖箱规重量")
void shouldApproveWithActualGrossWeightWhenCartonWeightIsMissing() {
@@ -298,6 +301,8 @@ class MeasurementReviewServiceTest {
.thenReturn(new InboundIdempotencyService.Context(10001L, 1L, false));
when(properties.isAllowRejectedMeasurement()).thenReturn(allowRelease);
InboundOrderEntity order = new InboundOrderEntity();
order.setId(10L);
order.setTenantId(10001L);
order.setProcessStatus("MEASURE_PENDING");
order.setOrderStatusSnapshot("APPROVED");
order.setMeasureReviewStatus("PENDING");
@@ -309,6 +314,7 @@ class MeasurementReviewServiceTest {
InboundMeasurementEntity measurement = new InboundMeasurementEntity();
measurement.setId(20L);
measurement.setStatus("SUBMITTED");
measurement.setMeasuredAt(LocalDateTime.of(2026, 9, 20, 10, 0));
measurement.setTotalPackageCount(5);
measurement.setTotalVolumeCbm(new BigDecimal("0.7480"));
measurement.setVolumeWeightKg(new BigDecimal("124.916"));
@@ -346,3 +352,4 @@ class MeasurementReviewServiceTest {
InboundMeasurementEntity measurement) {
}
}
@@ -59,6 +59,7 @@ class MojiaMeasurementOpenApiServiceTest {
var service = new MojiaMeasurementOpenApiService(authenticator, cartons,
new ObjectMapper().findAndRegisterModules(), Validation.buildDefaultValidatorFactory().getValidator(),
images, receipts, labels);
ReflectionTestUtils.setField(service, "internalToken", "internal-token");
MeasurementRequestHeaders headers = new MeasurementRequestHeaders("mojia", null, null, null, null);
when(authenticator.authenticate("POST", MojiaMeasurementOpenApiService.CALLBACK_PATH,
headers, json(), false)).thenReturn(new AuthenticatedMeasurementApp(
@@ -170,6 +171,7 @@ class MojiaMeasurementOpenApiServiceTest {
var service = new MojiaMeasurementOpenApiService(authenticator, cartons,
new ObjectMapper().findAndRegisterModules(), Validation.buildDefaultValidatorFactory().getValidator(),
images, receipts, labels);
ReflectionTestUtils.setField(service, "internalToken", "internal-token");
MeasurementRequestHeaders headers = new MeasurementRequestHeaders("mojia", null, null, null, null);
when(authenticator.authenticate("POST", MojiaMeasurementOpenApiService.CALLBACK_PATH,
headers, json(), false)).thenReturn(new AuthenticatedMeasurementApp(
@@ -178,10 +180,18 @@ class MojiaMeasurementOpenApiServiceTest {
.thenReturn(new InboundCartonLabelService.PlatformBinding(10002L, 30002L,
new CartonLabelBindingResolver.Binding(
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()))
.isInstanceOf(BizException.class)
.hasMessageContaining("不属于当前设备绑定仓库");
UploadResponse result = service.receive(headers, json());
assertThat(result.accepted()).isTrue();
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());
}
+1 -1
View File
@@ -77,7 +77,7 @@ spring:
- id: firstleg-service
uri: lb://firstleg-service
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
uri: lb://finance-service
predicates:
@@ -22,9 +22,9 @@ BEGIN
(id, tenant_id, app_code, permission_type, parent_id, permission_code,
permission_name, route_path, http_method, sort_no, status, built_in, deleted)
VALUES
(5102131, 0, 'PLATFORM', 'MENU', 5103000, 'platform:mojia-sku-sync:list', '一件代发SKU同步', '/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),
(5102133, 0, 'PLATFORM', 'BUTTON', 5102131, 'platform:mojia-sku-sync:retry', '重新推送一件代发SKU', NULL, 'POST', 20, '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),
(5102133, 0, 'PLATFORM', 'BUTTON', 5102131, 'platform:mojia-sku-sync:retry', '重新推送SKU测量', NULL, 'POST', 20, 'ACTIVE', 1, 0)
ON DUPLICATE KEY UPDATE
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;
+2
View File
@@ -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/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-outbound.mjs`:仅本机网关,读取显式 `LOCAL_RETURN_SOURCE` 单票报告,为其中同客户、同仓、已完成换标的唯一一件可用库存执行平台创建出库、原键重放、PDA拣货及完成。沿用平台账号环境参数,报告默认 `reports/local-return-relabel-outbound-<marker>.json` 或 `--output`;创建临时PDA并finally停用,不改客户权限。平台库存核验不替代源报告的客户读权限失败。会推进库存与出库状态,保留单据及报告;失败后按已保存单号核查,不自动清理,不可对已出库库存盲目重跑。
+1
View File
@@ -168,6 +168,7 @@ export async function run(context) {
| 入仓自动头程费用、派生详情、人工发布与 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` |
| 头程整柜报关 | `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` |
| 同柜且不可拆组 | `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` |
@@ -49,8 +49,8 @@ test('mixed fixture producer is registered and uses only public HTTP composition
test('coverage matrix freezes every requested product boundary', () => {
const text = CONTAINER_CUSTOMS_MATRIX.map((item) => `${item.scenario}:${item.expected}`).join('\n')
for (const token of ['买单XLSX', '多对多', '覆盖', '改关联', '删除', '冻结', '逐份完成文件',
'最后一份', '混合', '缺资料', '开始后锁定', '客户隔离', '买单隐藏', '50MiB', '任意格式',
for (const token of ['买单XLSX', '多对多', '覆盖', '改关联', '删除', '冻结', '回执确认', '报关行执行', '完税文件',
'最后上传', '混合', '缺资料', '开始后锁定', '客户隔离', '买单隐藏', '50MiB', '任意格式',
'幂等', '并发', 'A0409', '零漂移', '自动离仓', 'Outbox', '历史', 'Admin与Client权限']) {
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',
'缺退税资料禁止开始且零漂移', '开始后资料范围锁定A0409零漂移',
"['上传'", "['覆盖'", "['改关联'", "['删除'", '开始后${label}锁定A0409零漂移',
'最后一份完成文件前禁止整柜完成', '最后一份完成文件后纯退税柜完成',
'回执未确认且未发送执行时禁止上传完税文件', '客户确认整份回执后仍处于报关中', '真实报关完税文件齐全后纯退税柜完成', '客户确认回执后仍未完成整柜',
'混合柜平台详情精确包含BUY与TAX长ID票', 'Client只见本客户TAX票且完全隐藏BUY票',
'买单导出只处理实际装柜BUY行', '混合柜最后一份完成文件后整柜COMPLETED',
'自动离仓只写一次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', () => {
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)
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',
'merchantUpdateScope',
'merchantDeleteDocument',
'merchantConfirmReceipt',
'platformStart',
'platformUploadCompletion',
'platformUploadReceipt',
'platformSubmitToBroker',
'platformUploadFinal',
'platformComplete',
'platformDownload',
'merchantDownload',
'platformDownloadReceipt',
'platformDownloadFinal',
'merchantDownloadReceipt',
'merchantDownloadFinal',
'outboundState',
'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: '开始与完成', 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: 'MIXED', scenario: '买单与退税混合', expected: '买单行进入XLSX,退税票逐票被资料覆盖,整柜统一开始/完成' },
{ priority: 'P0', fixture: 'ALL_TAX_REFUND', scenario: '缺资料禁止开始', expected: 'A0409且聚合、文件、关联、历史零漂移' },
{ 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_completion_document',
'fl_customs_completion_document_version',
'fl_customs_final_document',
'fl_customs_command',
'fl_customs_container_history',
'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']
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([...new Set([...sql.matchAll(/^USE (\w+);/gm)].map(m => m[1]))], allowed)
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, /CREATE TABLE IF NOT EXISTS/i)
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.match(source, /计划确认前不生成 PDA 任务/)
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, /readiness\?\.readyForPda === true/)
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 OPTIONS = { scenario: 'manual-loading-pda-state-matrix', deploymentBlocker: true }
const CONTAINERS = '/api/platform/v1/containers'
@@ -216,7 +217,7 @@ async function createPlanTask(context, label) {
const pdaOperatorsResponse = await context.request('GET',
`/api/platform/v1/iam/users/domestic-pda-operators?warehouseId=${encodeURIComponent(source.warehouseId)}`)
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,
Boolean(pdaOperator?.userId), `operatorId=${id(pdaOperator?.userId) || 'missing'}`, OPTIONS)
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',
`${MERCHANT}/documents/${encodeURIComponent(id(request.documentId))}?expectedVersion=${Number(request.expectedVersion)}`,
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',
`${PLATFORM}/${encodeURIComponent(id(fixture.loadingPlanId))}/start`, request, ctx.token,
{ 'Idempotency-Key': idempotencyKey }),
platformUploadCompletion: (ctx, fixture, request, idempotencyKey) => multipart(
`${PLATFORM}/documents/${encodeURIComponent(id(request.documentId))}/completion/upload?expectedVersion=${Number(request.expectedVersion)}&finalConfirmation=${request.finalConfirmation === true}`,
platformUploadReceipt: (ctx, fixture, request, idempotencyKey) => multipart(
`${PLATFORM}/documents/${encodeURIComponent(id(request.documentId))}/receipt/upload?expectedVersion=${Number(request.expectedVersion)}`,
{}, 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',
`${PLATFORM}/${encodeURIComponent(id(fixture.loadingPlanId))}/complete-all-buy`, request, ctx.token,
{ 'Idempotency-Key': idempotencyKey }),
platformDownload: (ctx, fixture, request) => binary('GET',
`${PLATFORM}/documents/${encodeURIComponent(id(request.documentId))}/${request.completion ? 'completion/' : ''}download`),
merchantDownload: (ctx, fixture, customer, request) => binary('GET',
`${MERCHANT}/documents/${encodeURIComponent(id(request.documentId))}/${request.completion ? 'completion/' : ''}download`,
platformUploadFinal: (ctx, fixture, request, idempotencyKey) => multipart(
`${PLATFORM}/documents/${encodeURIComponent(id(request.documentId))}/final/upload?expectedVersion=${Number(request.expectedVersion)}`,
{}, request, ctx.token, idempotencyKey),
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),
buyDeclarationXlsx: async (ctx, request) => {
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),
`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'))
context.assert(MODULE, '全买单柜开始报关', started,
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'))
context.assert(MODULE, '全买单柜完成并触发自动离仓意图', completed,
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 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'))
context.assert(MODULE, '纯退税柜资料齐全后开始并冻结', started,
success(started) && started.body?.data?.status === 'DECLARING'
@@ -183,45 +183,71 @@ async function runRefund(context, routes, fixture) {
const documents = frozenSnapshot?.documents || []
for (const [index, document] of documents.entries()) {
const completion = await routes.platformUploadCompletion(context, fixture, {
documentId: id(document.documentId), expectedVersion: Number(document.source?.version),
fileName: `${context.marker}-completion-${index + 1}.done`,
contentType: 'application/x-customs-completion', content: Uint8Array.from([0x43, index + 1]),
}, key(context, `completion-${index + 1}`))
context.assert(MODULE, `逐份上传完成文件${index + 1}`, completion,
success(completion)
&& zeroDrift(completion.body?.data?.inboundIds, document.inboundIds),
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 receipt = await routes.platformUploadReceipt(context, fixture, {
documentId: id(document.documentId), expectedVersion: 0,
fileName: `${context.marker}-broker-receipt-${index + 1}.pdf`,
contentType: 'application/pdf', content: Uint8Array.from([0x52, index + 1]),
}, key(context, `receipt-${index + 1}`))
context.assert(MODULE, `逐份上传报关行回执${index + 1}`, receipt,
success(receipt) && zeroDrift(receipt.body?.data?.inboundIds, document.inboundIds),
evidence(receipt, `sourceDocumentId=${id(document.documentId)}`), OPTIONS)
}
const completedReady = await platformDetail(context, routes, fixture)
const completed = await routes.platformComplete(context, fixture, {
expectedVersion: Number(completedReady.body?.data?.version), remark: context.marker,
}, key(context, 'refund-complete'))
context.assert(MODULE, '最后一份完成文件后纯退税柜完成', completed,
const beforeConfirm = await platformDetail(context, routes, fixture)
const earlyFinal = await routes.platformUploadFinal(context, fixture, {
documentId: id(documents[0]?.documentId), expectedVersion: 0,
fileName: `${context.marker}-early-final.pdf`, contentType: 'application/pdf', content: Uint8Array.from([0x45]),
}, 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)
const firstDocument = documents[0]
if (firstDocument) {
const platformDownload = await routes.platformDownload(context, fixture, {
documentId: id(firstDocument.documentId), completion: true,
})
context.assert(MODULE, 'Admin下载完成文件窗口', platformDownload,
platformDownload.http === 200 && platformDownload.bytes?.length > 0,
`HTTP=${platformDownload.http}, bytes=${platformDownload.bytes?.length ?? 0}`, OPTIONS)
const merchantDownload = await routes.merchantDownload(context, fixture, mutable, {
documentId: id(firstDocument.documentId), completion: true,
})
context.assert(MODULE, 'Client仅下载本客户完成文件窗口', merchantDownload,
merchantDownload.http === 200 && merchantDownload.bytes?.length > 0,
`HTTP=${merchantDownload.http}, bytes=${merchantDownload.bytes?.length ?? 0}`, OPTIONS)
const platformReceipt = await routes.platformDownloadReceipt(context, fixture, { documentId: id(firstDocument.documentId) })
context.assert(MODULE, 'Admin下载报关行回执', platformReceipt,
platformReceipt.http === 200 && platformReceipt.bytes?.length > 0, evidence(platformReceipt), OPTIONS)
const platformFinal = await routes.platformDownloadFinal(context, fixture, { documentId: id(firstDocument.documentId) })
context.assert(MODULE, 'Admin下载真实报关完税文件', platformFinal,
platformFinal.http === 200 && platformFinal.bytes?.length > 0, evidence(platformFinal), OPTIONS)
const merchantFinal = await routes.merchantDownloadFinal(context, fixture, mutable, { documentId: id(firstDocument.documentId) })
context.assert(MODULE, 'Client仅下载本客户真实报关完税文件', merchantFinal,
merchantFinal.http === 200 && merchantFinal.bytes?.length > 0, evidence(merchantFinal), OPTIONS)
}
}
async function runMixedConcurrency(context, routes, fixture) {
@@ -309,31 +335,58 @@ async function runMixedConcurrency(context, routes, fixture) {
}, key(context, 'locked-scope'))
await assertZeroDrift(context, '开始后资料范围锁定A0409零漂移', routes, fixture, { body: { data: frozen } }, locked)
const completionRequest = {
const receiptRequest = {
documentId: customer.documentId, expectedVersion: 0,
finalConfirmation: true, fileName: `${context.marker}-mixed-result.done`,
contentType: 'application/x-customs-completion', content: Uint8Array.from([0x52, 0x45, 0x53]),
fileName: `${context.marker}-mixed-broker-receipt.pdf`,
contentType: 'application/pdf', content: Uint8Array.from([0x52, 0x45, 0x43]),
}
const completionKey = key(context, 'mixed-completion')
const completion = await routes.platformUploadCompletion(context, fixture, completionRequest, completionKey)
const completionReplay = success(completion)
? await routes.platformUploadCompletion(context, fixture, completionRequest, completionKey) : null
const completionConverged = success(completion) && success(completionReplay)
&& id(completion.body?.data?.documentId) === id(completionReplay.body?.data?.documentId)
&& id(completion.body?.data?.completion?.fileId) === id(completionReplay.body?.data?.completion?.fileId)
context.assert(MODULE, '最后一份TAX完成文件二次确认并同键重放', completionReplay || completion,
completionConverged,
[completion, completionReplay].filter(Boolean).map((response) => evidence(response)).join('; '), OPTIONS)
if (!completionConverged) {
context.addCoverageGap(MODULE, OPTIONS.scenario, '最后一份完成文件二次确认',
'完成文件未成功或同键重放未收敛,已停止后续自动离仓写入',
'completion-idempotency', { deploymentBlocker: true })
return
const receiptKey = key(context, 'mixed-receipt-upload')
const receipt = await routes.platformUploadReceipt(context, fixture, receiptRequest, receiptKey)
const receiptReplay = success(receipt)
? await routes.platformUploadReceipt(context, fixture, receiptRequest, receiptKey) : null
const receiptConverged = success(receipt) && success(receiptReplay)
&& id(receipt.body?.data?.documentId) === id(receiptReplay.body?.data?.documentId)
&& id(receipt.body?.data?.receipt?.fileId) === id(receiptReplay.body?.data?.receipt?.fileId)
context.assert(MODULE, '报关行回执同键重放收敛', receiptReplay || receipt, receiptConverged,
[receipt, receiptReplay].filter(Boolean).map((response) => evidence(response)).join('; '), OPTIONS)
const receiptDifferent = await routes.platformUploadReceipt(context, fixture,
{ ...receiptRequest, content: Uint8Array.from([0x44, 0x49, 0x46]) }, receiptKey)
context.assert(MODULE, '回执同键异载荷A0409', receiptDifferent, a0409(receiptDifferent), evidence(receiptDifferent), OPTIONS)
const receiptVersion = Number(receipt.body?.data?.receipt?.version || 0)
const receiptConfirm = await routes.merchantConfirmReceipt(context, fixture, customer, {
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,
{ ...completionRequest, content: Uint8Array.from([0x44, 0x49, 0x46]) }, completionKey)
context.assert(MODULE, '完成文件同键异载荷A0409', completionDifferent,
a0409(completionDifferent), evidence(completionDifferent), OPTIONS)
const finalKey = key(context, 'mixed-final')
const finalDocument = await routes.platformUploadFinal(context, fixture, finalRequest, finalKey)
const finalReplay = success(finalDocument)
? 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)
context.assert(MODULE, '混合柜最后一份完成文件后整柜COMPLETED', completed,
@@ -372,10 +425,10 @@ async function runMixedConcurrency(context, routes, fixture) {
evidence(queryDetail), OPTIONS)
}
const completionDownload = await routes.merchantDownload(context, fixture, customer, {
documentId: customer.documentId, completion: true,
const completionDownload = await routes.merchantDownloadFinal(context, fixture, customer, {
documentId: customer.documentId,
})
context.assert(MODULE, '整柜完成后Client可下载自身TAX完成文件', completionDownload,
context.assert(MODULE, '整柜完成后Client可下载自身真实报关完税文件', completionDownload,
completionDownload.http === 200 && completionDownload.bytes?.length > 0,
`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 { diffPushPreloadStability, projectPushPreloadStability } from './push-preload-stability.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',
`/api/platform/v1/iam/users/domestic-pda-operators?warehouseId=${encodeURIComponent(warehouseId)}`)
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,
Boolean(pdaOperator?.userId), `operatorId=${id(pdaOperator?.userId) || 'missing'}`, OPTIONS)
if (!pdaOperator?.userId) return
@@ -1,3 +1,4 @@
import { selectSessionPdaOperator } from './domestic-pda-operator.mjs'
import { diffPushPreloadStability, projectPushPreloadStability } from './push-preload-stability.mjs'
import { isPureStatusNoop, visibleLoadingHistories } from './loading-history-noop.mjs'
import { ensureMatsonCarrierPartner } from './carrier-partner-fixture.mjs'
@@ -672,7 +673,7 @@ export async function run(context) {
const pdaOperatorsResponse = await context.request('GET',
`/api/platform/v1/iam/users/domestic-pda-operators?warehouseId=${encodeURIComponent(warehouseId)}`)
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,
Boolean(pdaOperator?.userId), `operatorId=${id(pdaOperator?.userId) || 'missing'}`, OPTIONS)
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, /计划确认前不生成 PDA 任务/)
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, /readiness\?\.readyForPda === true/)
assert.match(source, /taskId === confirmedTaskId/)
@@ -1,3 +1,4 @@
import { selectSessionPdaOperator } from './domestic-pda-operator.mjs'
const MODULE = 'phase3-order-splitting'
const SCENARIO = 'source-loaded-single-remainder-chain'
const OPTIONS = { scenario: SCENARIO, deploymentBlocker: true }
@@ -339,7 +340,7 @@ async function loadOneFragment(context, state) {
const pdaOperatorsResponse = await context.request('GET',
`/api/platform/v1/iam/users/domestic-pda-operators?warehouseId=${encodeURIComponent(warehouseId)}`)
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,
Boolean(pdaOperator?.userId), `operatorId=${id(pdaOperator?.userId) || 'missing'}`, OPTIONS)
if (!pdaOperator?.userId) return null
@@ -172,6 +172,9 @@ export async function run(context) {
const initialVersion = Number(created.portSchedule.schedule.version)
const expiredData = scheduleData(context, suffix, ocean.id, knownAllocation, master.customerId, true)
expiredData.sourceRef = payload.data.sourceRef
// 甩柜只修改原柜的船信息;柜次、订舱关联和船期任务身份保持不变。
expiredData.vesselName = `甩柜改配船-${suffix}`
expiredData.voyageNo = `R${suffix}`
const updateResponse = await request(context, 'PUT', `${BASE}/${scheduleId}`,
{ expectedVersion: initialVersion, data: expiredData, reason: '来源刷新并过期' },
{ 'Idempotency-Key': `${context.marker}-port-update`.slice(0, 128) })
@@ -180,6 +183,13 @@ export async function run(context) {
updated?.portSchedule?.expired === true
&& Number(updated?.portSchedule?.schedule?.version) === initialVersion + 1,
`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}`,
{ expectedVersion: initialVersion, data: expiredData, reason: '旧版本覆盖' },
+3 -1
View File
@@ -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/documents/{documentId}/inbounds:' '客户资料入仓码范围契约'
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 'enum: [TAX_REFUND, BUY_DECLARATION]' '整柜报关税务模式'
Assert-Contains $adminContainerCustomsApi "const basePath = '/platform/v1/customs/loading-plans'" 'Admin 整柜报关真实调用'
Assert-Contains $clientContainerCustomsApi '/merchant/v1/customs/orders/' 'Client 订单报关真实调用'
Assert-Contains $clientContainerCustomsApi 'Idempotency-Key' 'Client 报关写入幂等'
Assert-Contains $customsFileOpenApi 'LOADING_LIST_CUSTOMS_SOURCE' 'File 报关源文件类别'
Assert-Contains $customsFileOpenApi 'LOADING_LIST_CUSTOMS_RECEIPT' 'File 报关行回执类别'
Assert-Contains $customsFileOpenApi 'LOADING_LIST_CUSTOMS_RESULT' 'File 报关完成文件类别'
# 平台商品图片和海外 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 双分流
-- ============================================================================
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(
NOT EXISTS (
SELECT 1 FROM information_schema.columns