This commit is contained in:
2026-08-18 15:43:58 +08:00
parent 4e4d047862
commit 4b0b3e9172
71 changed files with 523 additions and 724 deletions
+2 -2
View File
@@ -10,5 +10,5 @@ baiyue.clients:
warehouse.url: ${WAREHOUSE_SERVICE_URL:}
baiyue.order:
# 两套系统并跑期间统一使用旧系统入仓码;正式切换后改为 AUTO。
inbound-code-mode: ${ORDER_INBOUND_CODE_MODE:LEGACY_MANUAL}
# 默认由新系统自动生成;仅在两套系统并跑期间显式改为 LEGACY_MANUAL。
inbound-code-mode: ${ORDER_INBOUND_CODE_MODE:AUTO}
+2 -2
View File
@@ -7,5 +7,5 @@ baiyue.clients:
warehouse.url: ${WAREHOUSE_SERVICE_URL:}
baiyue.order:
# 两套系统并跑期间统一使用旧系统入仓码;正式切换后改为 AUTO。
inbound-code-mode: ${ORDER_INBOUND_CODE_MODE:LEGACY_MANUAL}
# 默认由新系统自动生成;仅在两套系统并跑期间显式改为 LEGACY_MANUAL。
inbound-code-mode: ${ORDER_INBOUND_CODE_MODE:AUTO}
@@ -8921,7 +8921,7 @@ USE baiyue_firstleg;
CREATE TABLE IF NOT EXISTS fl_ocean_booking (
id BIGINT UNSIGNED NOT NULL COMMENT '海运订舱ID',
booking_no VARCHAR(40) NOT NULL COMMENT '平台海运订舱编号',
status VARCHAR(24) NOT NULL DEFAULT 'DRAFT' COMMENT '订舱状态:DRAFT仅限创建事务内部、CONFIRMED已确认、RELEASED已放舱、CHANGE_PENDING船期待变更、CANCELLED已取消',
status VARCHAR(24) NOT NULL DEFAULT 'DRAFT' COMMENT '订舱状态:DRAFT草稿、RELEASED已发布且永久只读',
customer_id BIGINT UNSIGNED NULL COMMENT '可选客户ID快照',
customer_name VARCHAR(128) NULL COMMENT '可选客户名称快照',
bill_of_lading_no VARCHAR(128) NULL COMMENT '提单号',
@@ -8936,8 +8936,8 @@ CREATE TABLE IF NOT EXISTS fl_ocean_booking (
destination_port_code VARCHAR(32) NULL COMMENT '目的港编码快照',
destination_port_name VARCHAR(128) NULL COMMENT '目的港名称快照',
place_of_delivery VARCHAR(128) NULL COMMENT '最终交货地快照',
service_term VARCHAR(32) NOT NULL COMMENT '运输服务条款快照',
freight_term VARCHAR(32) NOT NULL COMMENT '海运费支付条款快照',
service_term VARCHAR(32) NULL COMMENT '运输服务条款快照;发布前必填',
freight_term VARCHAR(32) NULL COMMENT '海运费支付条款快照;发布前必填',
estimated_sailing_at DATETIME(3) NULL COMMENT '当前确认预计开船时间',
estimated_arrival_at DATETIME(3) NULL COMMENT '当前确认预计到港时间',
pending_vessel_name VARCHAR(128) NULL COMMENT '待确认变更船名',
@@ -8960,7 +8960,7 @@ CREATE TABLE IF NOT EXISTS fl_ocean_booking (
KEY idx_ocean_booking_voyage (vessel_name,voyage_no,updated_at),
CONSTRAINT chk_ocean_booking_customer CHECK (customer_id > 0),
CONSTRAINT chk_ocean_booking_status CHECK (status IN
('DRAFT','CONFIRMED','RELEASED','CHANGE_PENDING','CANCELLED')),
('DRAFT','RELEASED')),
CONSTRAINT chk_ocean_booking_schedule CHECK
(estimated_sailing_at IS NULL OR estimated_arrival_at IS NULL OR estimated_arrival_at >= estimated_sailing_at),
CONSTRAINT chk_ocean_booking_pending_schedule CHECK
@@ -8985,66 +8985,6 @@ DROP TRIGGER IF EXISTS trg_ocean_requirement_before_insert;
DROP TRIGGER IF EXISTS trg_ocean_requirement_before_update;
DROP TRIGGER IF EXISTS trg_ocean_requirement_before_delete;
DROP TRIGGER IF EXISTS trg_ocean_history_before_delete;
DELIMITER $$
DROP PROCEDURE IF EXISTS reconcile_ocean_booking_legacy$$
CREATE PROCEDURE reconcile_ocean_booking_legacy()
BEGIN
DECLARE v_blank_terms INT DEFAULT 0;
DECLARE v_legacy INT DEFAULT 0;
DECLARE v_external INT DEFAULT 0;
SELECT COUNT(*) INTO v_blank_terms
FROM fl_ocean_booking
WHERE NULLIF(TRIM(service_term),'') IS NULL
OR NULLIF(TRIM(freight_term),'') IS NULL;
IF v_blank_terms > 0 THEN
SIGNAL SQLSTATE '45000'
SET MESSAGE_TEXT = '订舱结构收敛暂停:存在 service_term/freight_term 为空的真实行,请按原始业务资料补齐后重跑,禁止猜值';
END IF;
SELECT COUNT(*) INTO v_legacy
FROM fl_ocean_booking
WHERE status IN ('DRAFT','SUBMITTED','FAILED');
IF v_legacy > 0 THEN
-- Tables from later phase blocks may not exist on a module-only run. Use
-- dynamic SQL so a clean initialization remains repeatable.
IF EXISTS (SELECT 1 FROM information_schema.tables
WHERE table_schema=DATABASE() AND table_name='fl_port_schedule') THEN
SET @ocean_legacy_external := 0;
SET @ocean_legacy_sql = 'SELECT COUNT(*) INTO @ocean_legacy_external FROM fl_port_schedule p JOIN fl_ocean_booking b ON b.id=p.ocean_booking_id WHERE b.status IN (''DRAFT'',''SUBMITTED'',''FAILED'')';
PREPARE stmt FROM @ocean_legacy_sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET v_external = v_external + COALESCE(@ocean_legacy_external,0);
END IF;
IF EXISTS (SELECT 1 FROM information_schema.tables
WHERE table_schema=DATABASE() AND table_name='fl_carrier_schedule_query') THEN
SET @ocean_legacy_external := 0;
SET @ocean_legacy_sql = 'SELECT COUNT(*) INTO @ocean_legacy_external FROM fl_carrier_schedule_query q JOIN fl_ocean_booking b ON b.id=q.ocean_booking_id WHERE b.status IN (''DRAFT'',''SUBMITTED'',''FAILED'')';
PREPARE stmt FROM @ocean_legacy_sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET v_external = v_external + COALESCE(@ocean_legacy_external,0);
END IF;
IF v_external > 0 THEN
SIGNAL SQLSTATE '45000'
SET MESSAGE_TEXT = '订舱结构收敛暂停:旧订舱仍被港务船期/船司查询引用,请先按子表历史->主表归档顺序清理引用后重跑;不会伪造 CONFIRMED';
END IF;
DELETE FROM fl_ocean_booking_party
WHERE ocean_booking_id IN (SELECT id FROM fl_ocean_booking WHERE status IN ('DRAFT','SUBMITTED','FAILED'));
DELETE FROM fl_ocean_booking_container_allocation
WHERE ocean_booking_id IN (SELECT id FROM fl_ocean_booking WHERE status IN ('DRAFT','SUBMITTED','FAILED'));
DELETE FROM fl_ocean_booking_container_requirement
WHERE ocean_booking_id IN (SELECT id FROM fl_ocean_booking WHERE status IN ('DRAFT','SUBMITTED','FAILED'));
DELETE FROM fl_ocean_booking_operation
WHERE ocean_booking_id IN (SELECT id FROM fl_ocean_booking WHERE status IN ('DRAFT','SUBMITTED','FAILED'));
DELETE FROM fl_ocean_booking_status_history
WHERE ocean_booking_id IN (SELECT id FROM fl_ocean_booking WHERE status IN ('DRAFT','SUBMITTED','FAILED'));
DELETE FROM fl_ocean_booking
WHERE status IN ('DRAFT','SUBMITTED','FAILED');
END IF;
END$$
CALL reconcile_ocean_booking_legacy()$$
DROP PROCEDURE reconcile_ocean_booking_legacy$$
DELIMITER ;
-- Existing CHECK constraints are not reconciled by CREATE TABLE IF NOT EXISTS.
SET @ocean_drop_status_check = (
SELECT IF(COUNT(*) > 0,
@@ -9058,10 +8998,10 @@ PREPARE ocean_drop_status_stmt FROM @ocean_drop_status_check;
EXECUTE ocean_drop_status_stmt;
DEALLOCATE PREPARE ocean_drop_status_stmt;
ALTER TABLE fl_ocean_booking
MODIFY COLUMN service_term VARCHAR(32) NOT NULL COMMENT '运输服务条款快照',
MODIFY COLUMN freight_term VARCHAR(32) NOT NULL COMMENT '海运费支付条款快照',
MODIFY COLUMN service_term VARCHAR(32) NULL COMMENT '运输服务条款快照;发布前必填',
MODIFY COLUMN freight_term VARCHAR(32) NULL COMMENT '海运费支付条款快照;发布前必填',
ADD CONSTRAINT chk_ocean_booking_status CHECK
(status IN ('DRAFT','CONFIRMED','RELEASED','CHANGE_PENDING','CANCELLED'));
(status IN ('DRAFT','RELEASED'));
SET @ocean_drop_failure_reason = (
SELECT IF(COUNT(*) > 0,
@@ -9160,13 +9100,11 @@ DROP TRIGGER IF EXISTS trg_ocean_booking_before_update;
DELIMITER $$
CREATE TRIGGER trg_ocean_booking_before_update BEFORE UPDATE ON fl_ocean_booking FOR EACH ROW
BEGIN
IF OLD.status IN ('RELEASED','CANCELLED') THEN
IF OLD.status = 'RELEASED' THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='已放舱或已取消订舱不可修改';
END IF;
IF OLD.status <> NEW.status AND NOT (
(OLD.status='DRAFT' AND NEW.status='CONFIRMED')
OR (OLD.status='CONFIRMED' AND NEW.status IN ('RELEASED','CHANGE_PENDING','CANCELLED'))
OR (OLD.status='CHANGE_PENDING' AND NEW.status IN ('CONFIRMED','CANCELLED'))) THEN
(OLD.status='DRAFT' AND NEW.status='RELEASED')) THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='海运订舱状态迁移非法';
END IF;
END$$
@@ -9277,7 +9215,7 @@ CREATE TABLE IF NOT EXISTS fl_truck_appointment (
id BIGINT UNSIGNED NOT NULL COMMENT '拖车预约ID', appointment_no VARCHAR(40) NOT NULL COMMENT '平台拖车预约编号', outbound_order_id BIGINT UNSIGNED NULL COMMENT '关联头程订单逻辑ID;不跨库建外键',
ocean_booking_id BIGINT UNSIGNED NULL COMMENT '关联海运订舱逻辑ID', container_booking_id BIGINT UNSIGNED NULL COMMENT '关联柜次逻辑ID;拖车完成供装柜门禁校验', loading_plan_id BIGINT UNSIGNED NULL COMMENT '关联装柜计划逻辑ID',
warehouse_id BIGINT UNSIGNED NULL COMMENT '预约到仓的国内收货仓逻辑ID', customer_id BIGINT UNSIGNED NULL COMMENT '客户ID和数据隔离键', customer_name VARCHAR(128) NULL COMMENT '客户名称快照',
carrier_id BIGINT UNSIGNED NULL COMMENT '拖车服务商逻辑ID', carrier_name VARCHAR(128) NULL COMMENT '拖车服务商名称快照', fleet_contact VARCHAR(64) NULL COMMENT '车队联系人', fleet_phone VARCHAR(32) NULL COMMENT '车队联系电话',
carrier_id BIGINT UNSIGNED NULL COMMENT '拖车服务商逻辑ID', carrier_name VARCHAR(128) NULL COMMENT '拖车服务商名称快照', bill_of_lading_no VARCHAR(128) NULL COMMENT '派车提单号快照;关联RELEASED订舱时由订舱事实源冻结', fleet_contact VARCHAR(64) NULL COMMENT '车队联系人', fleet_phone VARCHAR(32) NULL COMMENT '车队联系电话',
driver_name VARCHAR(64) NULL COMMENT '司机姓名', driver_phone VARCHAR(32) NULL COMMENT '司机联系电话', driver_id_card VARCHAR(32) NULL COMMENT '司机身份证号;平台内部敏感信息', vehicle_no VARCHAR(32) NULL COMMENT '车辆车牌号',
store_name VARCHAR(128) NULL COMMENT '到仓装柜地点名称快照', store_contact VARCHAR(64) NULL COMMENT '到仓地点联系人快照', store_phone VARCHAR(32) NULL COMMENT '到仓地点联系电话快照', store_address VARCHAR(255) NULL COMMENT '到仓装柜地点地址快照',
store_city VARCHAR(64) NULL COMMENT '到仓地点城市快照', store_zipcode VARCHAR(16) NULL COMMENT '到仓地点邮编快照', package_count INT UNSIGNED NULL COMMENT '预约货物包装件数快照', gross_weight_kg DECIMAL(18,3) NULL COMMENT '预约货物毛重,单位千克',
@@ -9295,9 +9233,9 @@ CREATE TABLE IF NOT EXISTS fl_truck_appointment_history (
operator_name VARCHAR(64) NULL COMMENT '操作人姓名快照', request_id VARCHAR(128) NULL COMMENT '请求链路中的请求ID', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '历史记录创建时间', PRIMARY KEY(id), KEY idx_truck_history(appointment_id,revision)
) ENGINE=InnoDB COMMENT='拖车状态与改约历史';
CREATE TABLE IF NOT EXISTS fl_truck_appointment_attachment (
id BIGINT UNSIGNED NOT NULL COMMENT '拖车附件关系ID', appointment_id BIGINT UNSIGNED NOT NULL COMMENT '所属拖车预约ID', file_id BIGINT UNSIGNED NOT NULL COMMENT 'File服务文件逻辑ID', attachment_type VARCHAR(32) NOT NULL COMMENT '附件类型编码;由业务动作约定',
id BIGINT UNSIGNED NOT NULL COMMENT '拖车附件关系ID', appointment_id BIGINT UNSIGNED NOT NULL COMMENT '所属拖车预约ID', file_id BIGINT UNSIGNED NOT NULL COMMENT 'File服务文件逻辑ID', attachment_type VARCHAR(32) NOT NULL COMMENT '附件类型:BILL_OF_LADING提单、DISPATCH_ORDER派车单、DRIVER_DOCUMENT司机证件、VEHICLE_DOCUMENT车辆证件、OTHER其他',
file_name VARCHAR(255) NULL COMMENT '上传时文件名快照', file_hash CHAR(64) NULL COMMENT '文件内容SHA-256摘要', visibility VARCHAR(24) NOT NULL DEFAULT 'INTERNAL' COMMENT '可见范围:INTERNAL仅平台内部、CLIENT客户可见', uploaded_by BIGINT UNSIGNED NOT NULL COMMENT '上传操作人ID',
uploaded_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '附件上传时间', PRIMARY KEY(id), UNIQUE KEY uk_truck_attachment(appointment_id,file_id), KEY idx_truck_attachment(appointment_id)
uploaded_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '附件上传时间', PRIMARY KEY(id), UNIQUE KEY uk_truck_attachment(appointment_id,file_id), KEY idx_truck_attachment(appointment_id), CONSTRAINT chk_truck_attachment_type CHECK(attachment_type IN ('BILL_OF_LADING','DISPATCH_ORDER','DRIVER_DOCUMENT','VEHICLE_DOCUMENT','OTHER'))
) ENGINE=InnoDB COMMENT='拖车附件文件引用';
CREATE TABLE IF NOT EXISTS fl_truck_appointment_operation (
id BIGINT UNSIGNED NOT NULL COMMENT '拖车写操作幂等记录ID', appointment_id BIGINT UNSIGNED NOT NULL COMMENT '所属拖车预约ID', idempotency_key VARCHAR(128) NOT NULL COMMENT '客户端写操作幂等键', request_hash CHAR(64) NOT NULL COMMENT '规范化请求内容SHA-256摘要;用于识别同键不同请求',
@@ -13286,7 +13224,7 @@ CALL queue_column_comment('baiyue_firstleg', 'fl_ocean_booking', 'remark', '平
CALL queue_column_comment('baiyue_firstleg', 'fl_ocean_booking', 'service_term', '运输服务条款快照');
CALL queue_column_comment('baiyue_firstleg', 'fl_ocean_booking', 'shipping_company_id', '船公司逻辑ID快照');
CALL queue_column_comment('baiyue_firstleg', 'fl_ocean_booking', 'shipping_company_name', '船公司名称快照');
CALL queue_column_comment('baiyue_firstleg', 'fl_ocean_booking', 'status', '订舱状态:DRAFT仅限创建事务内部、CONFIRMED已确认、RELEASED已放舱、CHANGE_PENDING船期待变更、CANCELLED已取消');
CALL queue_column_comment('baiyue_firstleg', 'fl_ocean_booking', 'status', '订舱状态:DRAFT草稿、RELEASED已发布且永久只读');
CALL queue_column_comment('baiyue_firstleg', 'fl_ocean_booking', 'updated_at', '订舱记录最后更新时间');
CALL queue_column_comment('baiyue_firstleg', 'fl_ocean_booking', 'updated_by', '最后修改操作人ID');
CALL queue_column_comment('baiyue_firstleg', 'fl_ocean_booking', 'updated_by_name', '最后修改操作人姓名快照');
@@ -13541,6 +13479,7 @@ CALL queue_column_comment('baiyue_firstleg', 'fl_truck_appointment', 'actual_arr
CALL queue_column_comment('baiyue_firstleg', 'fl_truck_appointment', 'actual_departed_at', '车辆实际离仓时间;DEPARTED后供LOADED门禁使用');
CALL queue_column_comment('baiyue_firstleg', 'fl_truck_appointment', 'actual_loading_at', '实际开始装柜时间');
CALL queue_column_comment('baiyue_firstleg', 'fl_truck_appointment', 'appointment_no', '平台拖车预约编号');
CALL queue_column_comment('baiyue_firstleg', 'fl_truck_appointment', 'bill_of_lading_no', '派车提单号快照;关联RELEASED订舱时由订舱事实源冻结');
CALL queue_column_comment('baiyue_firstleg', 'fl_truck_appointment', 'carrier_id', '拖车服务商逻辑ID');
CALL queue_column_comment('baiyue_firstleg', 'fl_truck_appointment', 'carrier_name', '拖车服务商名称快照');
CALL queue_column_comment('baiyue_firstleg', 'fl_truck_appointment', 'container_booking_id', '关联柜次逻辑ID;拖车完成供装柜门禁校验');
@@ -13580,7 +13519,7 @@ CALL queue_column_comment('baiyue_firstleg', 'fl_truck_appointment', 'version',
CALL queue_column_comment('baiyue_firstleg', 'fl_truck_appointment', 'volume_cbm', '预约货物体积,单位立方米');
CALL queue_column_comment('baiyue_firstleg', 'fl_truck_appointment', 'warehouse_id', '预约到仓的国内收货仓逻辑ID');
CALL queue_column_comment('baiyue_firstleg', 'fl_truck_appointment_attachment', 'appointment_id', '所属拖车预约ID');
CALL queue_column_comment('baiyue_firstleg', 'fl_truck_appointment_attachment', 'attachment_type', '附件类型编码;由业务动作约定');
CALL queue_column_comment('baiyue_firstleg', 'fl_truck_appointment_attachment', 'attachment_type', '附件类型:BILL_OF_LADING提单、DISPATCH_ORDER派车单、DRIVER_DOCUMENT司机证件、VEHICLE_DOCUMENT车辆证件、OTHER其他');
CALL queue_column_comment('baiyue_firstleg', 'fl_truck_appointment_attachment', 'file_hash', '文件内容SHA-256摘要');
CALL queue_column_comment('baiyue_firstleg', 'fl_truck_appointment_attachment', 'file_id', 'File服务文件逻辑ID');
CALL queue_column_comment('baiyue_firstleg', 'fl_truck_appointment_attachment', 'file_name', '上传时文件名快照');
+2 -1
View File
@@ -10,7 +10,7 @@
| 目标 | 入口 |
|---|---|
| 商户/平台资质 | `controller/QualificationFileController.java`、`PlatformQualificationFileController.java` |
| 导出中心 | `controller/PlatformExportTaskController.java`、`service/ExportStateService.java`、`mq/` |
| 导出中心 | `controller/PlatformExportTaskController.java`、`service/ExportTaskService.java`、`service/ExportStateService.java`、`service/GenericXlsxExportRunner.java`、`config/ExportDefinitionRegistry.java`、`client/ExportProviderClient.java`、`mq/` |
| 测量图片/证据 | `controller/PlatformMeasurementImageController.java`、`InternalMeasurementImageController.java`、`service/MeasurementImageService.java` |
| 装柜与业务文件证据 | `controller/PlatformLoadingEvidenceController.java`、`InternalLoadingEvidenceController.java`、`InternalBusinessFileController.java`、`service/InternalBusinessFileService.java` |
| 存储与实体 | `service/LocalQualificationStorage.java`、`MeasurementImageStorage.java`、`entity/FileObjectEntity.java` |
@@ -22,6 +22,7 @@
- 上传先写真实文件再登记 IAM 元数据;异常结果未知时保留文件供重试,禁止伪造 URL。
- 所有文件按客户、入库单、文件类型校验归属;重量图片可 OCR,第三方尺寸证据不得触发重量 OCR。
- 公共导出只接受 `ExportDefinitionRegistry` 明确登记的 `exportType`,由 File 调用所属业务服务的内部 Provider 分批取数并生成文件;业务页面不得直连 Provider。新增或修改导出类型时同步检查业务服务 Provider、File Feign 客户端/响应上限、权限、Admin 下载中心标签与重试映射及契约测试。
- 生产环境显式配置 `FILE_STORAGE_ROOT`;磁盘故障返回 `S0503`,不得包装成业务错误。
## 全量验收导航
+2
View File
@@ -16,6 +16,7 @@
| 客户财务账户汇总 | `service/FinanceCustomerAccountService.java`、`client/IamCustomerClient.java`;客户主档事实仍归 IAM |
| 客户对账单 | `controller/ReconciliationStatementController.java`、`service/ReconciliationStatementService.java` |
| 应收/应付结算单 | `controller/FinanceSettlementController.java`、`service/FinanceSettlementService.java` |
| Finance 导出数据 Provider | `controller/FinanceExportProviderController.java`、`service/FinanceExportProviderService.java`;由 File `ExportProviderClient` 按已登记 `exportType` 分批拉取,不从 Admin 直接生成文件 |
| 五页产品 OpenAPI | `../../docs/财务管理/openapi/finance-phase1-five-pages-v1.yaml`;人工财务补充契约 `finance-f1-manual-v1.yaml` |
| Firstleg 费用来源事件入口 | `controller/InternalFinanceFeeSourceController.java`、`service/FinanceFeeSourceEventService.java` |
| 查询模型与客户可见性 | `service/FinanceQueryService.java`、`dto/FinanceReadModels.java` |
@@ -38,6 +39,7 @@
- 整柜成本分摊使用 Firstleg 返回的真实客户归属与订单体积快照;不得信任前端提交的订单归属或体积。凭证必须由 File 元数据校验客户和业务类型,依赖不可用时不得静默跳过。
- 应付费用来源、成本登记和应付结算单引用的供应商必须按费用项目映射到单一供应商类别,并通过 Warehouse 校验 ID、`ACTIVE` 状态和类别;名称快照只能取 Warehouse 返回值,不能接受自由文本替代。Warehouse 不可用、供应商停用或类别不匹配时必须失败关闭;修改该链路时同步检查事件消费、人工成本、结算创建/付款、Nacos 客户端地址和双方契约测试。
- 修改平台或客户 DTO 时同步检查 Admin `../../apps/admin-web/src/modules/finance/`、Client `../../apps/client-web/src/modules/finance/`、Gateway 路由、权限码和契约测试;客户响应不得暴露供应商成本、内部备注、平台凭证或其他客户数据。
- 修改 Finance 导出字段、筛选或 `exportType` 时,同步检查 `FinanceExportProviderService`、File `ExportDefinitionRegistry`/`ExportProviderClient`、Admin `modules/finance/` 与 `modules/downloads/`、导出权限和双方契约测试;页面只创建异步下载任务,不绕过 File 导出中心直接拼装业务文件。
## 验证
+5 -4
View File
@@ -12,8 +12,8 @@
|---|---|
| 履约任务/分配 | `controller/PlatformFulfillmentAssignmentController.java`、`service/FulfillmentTaskService.java` |
| 入库单查询/聚合 | `controller/PlatformInboundOrderController.java`、`service/InboundOrderQueryService.java` |
| 平台测量/OCR | `controller/PlatformInboundMeasurementController.java`、`service/InboundMeasurementService.java`、`service/CartonMeasurementService.java` |
| 箱唛箱码生成与版本化 | `repository/InboundCartonLabelRepository.java`、`service/InboundCartonLabelService.java`;基线/升级 SQL 为 `src/main/resources/db/phase2-inbound-carton-label.sql`、`phase2-carton-label-versioning-migration.sql` |
| 平台测量/OCR | `controller/PlatformInboundMeasurementController.java`、`service/InboundMeasurementService.java`、`service/CartonMeasurementService.java`;手工单箱测量也从该 Controller 与 `CartonMeasurementService` 进入 |
| 箱唛箱码生成、打印与测量绑定 | 审核建单生成钩子 `controller/InternalInboundOrderController.java`;生成/解析 `service/InboundCartonLabelService.java`、`repository/InboundCartonLabelRepository.java`;平台与客户打印 `controller/PlatformInboundOrderController.java`、`MerchantInboundCartonLabelController.java`、`service/InboundCartonLabelPrintService.java`;基线/升级 SQL 为 `src/main/resources/db/phase2-inbound-carton-label.sql`、`phase2-carton-label-versioning-migration.sql` |
| 墨家设备测量上传 | `controller/OpenApiInboundMeasurementController.java`、`dto/MojiaMeasurementDtos.java`、`service/MojiaMeasurementOpenApiService.java`、`security/MeasurementRequestAuthenticator.java`;接口契约见 `src/main/resources/openapi/firstleg-v1.yaml` 与 `../../docs/墨家设备测量上传接口说明-V3.md` |
| 审核与初始计价 | `controller/PlatformInboundMeasurementReviewController.java`、`service/MeasurementReviewService.java`、`service/InitialInboundPricingService.java`、`dto/MeasurementApprovalResponse.java` |
| FBA 主数据/计费 | `controller/PlatformFbaMasterDataController.java`、`PlatformFbaPricingController.java`、`service/FbaPricingService.java` |
@@ -40,7 +40,8 @@
## 修改约束
- 平台接口必须校验目标客户、权限、乐观锁、幂等键、历史和审计;商户查询只能使用登录态客户。
- 第三方回传必须签名、时间窗、防重放、请求哈希,并绑定订单货物和 File 证据;不得信任外部客户字段。
- 第三方回传必须签名、时间窗、防重放、请求哈希,并绑定订单货物和 File 证据;不得信任外部客户字段。
- 稳定箱码映射以入库快照和 Order 货物明细为事实;订单审核创建入库单时必须幂等生成,打印接口只复用或补齐同一映射,用户是否下载箱唛不得决定箱码有效性。手工测量、墨家设备和 PDA 扫码统一通过 `InboundCartonLabelService` 校验客户、入库单、货物明细、总箱数/下标和有效批次,不得在测量时临时伪造箱码或接受跨客户、跨入库箱码。
- 测量审核通过后直接进入 `WAIT_SPLIT`,费用仅作为独立初算和人工维护结果,不得阻塞或回退订单主流程;计费量、单价和附加费调整必须版本化并由服务端重算金额。状态或 DTO 变更时逐个检查 Order/Warehouse/File/Integration 消费者。
- 报关、订舱和拖车引用的供应商必须通过 `WarehouseSupplierSnapshotService` 按 ID 向 Warehouse 校验:报关使用 `CUSTOMS`,订舱使用 `BOOKING`(不区分国内/国外 scope),拖车使用 `TRUCKING`;供应商必须为 `ACTIVE`。请求中的名称只能作为一致性提示,持久化名称必须来自 Warehouse 快照,禁止用自由文本绕过 ID、类别或启用状态校验。修改任一供应商引用 DTO、OpenAPI 或内部客户端时,必须同步检查三条业务链、Warehouse `supplier-v1.yaml`、数据库快照字段和对应契约测试。
- Firstleg 只拥有业务费用及履约事实;Finance 拥有费用入池、账期、账单、收款、成本分摊结果和财务状态。修改费用来源版本、发布条件、柜次归属或体积快照时同步检查 `../finance/src/main/java/com/baiyue/finance/client/FirstlegFeeSourceClient.java`、同目录 `FirstlegCostSnapshotClient.java` 及双方契约测试,不在 Firstleg 复制财务状态机。
@@ -48,7 +49,7 @@
## 三期装柜与 PDA 资源结算
- 智能装柜候选只能按同一国内中转仓合柜;推荐顺序按入库时间先进先出。装柜计划状态为 `EDITING -> LOADING -> COMPLETED`,PDA 全部明细处理完成后自动完成。
- PDA 拣货以 `fl_inbound_carton_label.box_code` 对应的稳定箱唛箱码为扫描身份;首次打印冻结箱码映射,重复打印和墨家测量复用同一箱码,版本字段只用于并发控制,不进入扫码文本。一个有效且未重复的箱码扫描表示拣出一箱,不得以 SKU、商品件数或页面展示字段替代箱码。任务下发与提交契约见 `src/main/resources/openapi/phase3-pda-convergence-v1.yaml`。
- PDA 拣货以 `fl_inbound_carton_label.box_code` 对应的稳定箱唛箱码为扫描身份;审核建单、重复打印、手工测量和墨家测量复用同一映射,版本字段只用于并发控制,不进入扫码文本。一个有效且未重复的箱码扫描表示拣出一箱,不得以 SKU、商品件数或页面展示字段替代箱码。任务下发与提交契约见 `src/main/resources/openapi/phase3-pda-convergence-v1.yaml`。
- PDA 部分拣货生成二级子入库单时,数量必须守恒:`已拣数量 + 子单数量 = 拣货前主单可用数量`。子单只允许一层,不能继续拆分。
- `releaseMode=AUTO` 时,释放资源数为 `ceil(实际拣货箱数 × 原资源数 / 原可用箱数)`;`MANUAL` 按 `locationIds` 选择,空数组表示明确释放 0 个。当前契约中未传模式且 `locationIds=null` 时按 AUTO 处理。
- 头程快照必须保存仓库 `reservation_id`。A0 是共享临时库位,多个托盘可具有相同 `location_id`,跨服务结算、转移、释放必须使用 `reservationId`,不得把 `locationId` 当作唯一资源标识。
@@ -129,7 +129,7 @@ public class PlatformInboundMeasurementController {
@RequestPart("image") MultipartFile image) {
requirePlatformTenant(user, tenantId);
CartonMeasurementCommand command = new CartonMeasurementCommand(
request.boxCode(), request.length(), request.width(), request.height(), request.weight(),
request.boxCode(), request.length(), request.width(), request.height(),
request.machineNo(), request.measuredAt(), image, request.scannedShipmentId());
return ApiResponse.success(cartonMeasurements.upsertForInbound(
tenantId, user.userId(), inboundId, command));
@@ -149,7 +149,7 @@ public class PlatformInboundMeasurementController {
@RequestPart("image") MultipartFile image) {
requirePlatformTenant(user, tenantId);
CartonMeasurementCommand command = new CartonMeasurementCommand(
request.boxCode(), request.length(), request.width(), request.height(), request.weight(),
request.boxCode(), request.length(), request.width(), request.height(),
request.machineNo(), request.measuredAt(), image, request.scannedShipmentId());
return ApiResponse.success(cartonMeasurements.upsertManualForInbound(
tenantId, user.userId(), inboundId, command));
@@ -1,12 +1,12 @@
package com.baiyue.firstleg.controller;
import com.baiyue.common.audit.BusinessAudit; import com.baiyue.common.core.api.*; import com.baiyue.common.security.AuthenticatedUser; import com.baiyue.firstleg.dto.TruckAppointmentDtos.*; import com.baiyue.firstleg.entity.TruckAppointmentEntity; import com.baiyue.firstleg.service.*; import io.swagger.v3.oas.annotations.tags.Tag; 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.time.LocalDateTime;
import com.baiyue.common.audit.BusinessAudit; import com.baiyue.common.core.api.*; import com.baiyue.common.security.AuthenticatedUser; import com.baiyue.firstleg.dto.TruckAppointmentDtos.*; import com.baiyue.firstleg.entity.TruckAppointmentEntity; import com.baiyue.firstleg.service.*; import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.annotation.Resource; import jakarta.validation.Valid; import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.Pattern; 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.time.LocalDateTime;
@Tag(name="拖车预约") @Validated @RestController @RequestMapping("/api/platform/v1/truck-appointments") public class PlatformTruckAppointmentController { @Resource TruckAppointmentService service;
@GetMapping @PreAuthorize("hasAuthority('platform:firstleg:truck-appointment:list')") public ApiResponse<PageResult<TruckAppointmentEntity>> page(@AuthenticationPrincipal AuthenticatedUser u,@Valid Query q){FirstlegScopeGuard.platform(u);return ApiResponse.success(service.page(q));}
@GetMapping("/{id}") @PreAuthorize("hasAuthority('platform:firstleg:truck-appointment:list')") public ApiResponse<TruckAppointmentEntity> detail(@AuthenticationPrincipal AuthenticatedUser u,@PathVariable @Positive long id){FirstlegScopeGuard.platform(u);return ApiResponse.success(service.detail(id));}
@GetMapping("/{id}/history") @PreAuthorize("hasAuthority('platform:firstleg:truck-appointment:list')") public ApiResponse<java.util.List<com.baiyue.firstleg.entity.TruckAppointmentHistoryEntity>> history(@AuthenticationPrincipal AuthenticatedUser u,@PathVariable @Positive long id){FirstlegScopeGuard.platform(u);return ApiResponse.success(service.history(id));}
@GetMapping("/{id}/attachments") @PreAuthorize("hasAuthority('platform:firstleg:truck-appointment:list')") public ApiResponse<java.util.List<com.baiyue.firstleg.entity.TruckAppointmentAttachmentEntity>> attachments(@AuthenticationPrincipal AuthenticatedUser u,@PathVariable @Positive long id){FirstlegScopeGuard.platform(u);return ApiResponse.success(service.attachments(id));}
@PostMapping("/{id}/attachments") @PreAuthorize("hasAuthority('platform:firstleg:truck-appointment:edit')") public ApiResponse<com.baiyue.firstleg.entity.TruckAppointmentAttachmentEntity> attach(@AuthenticationPrincipal AuthenticatedUser u,@PathVariable @Positive long id,@Valid @RequestBody AttachmentRequest r){FirstlegScopeGuard.platform(u);return ApiResponse.success(service.attach(id,r,u.userId()));}
@PostMapping(value="/{id}/attachments/upload",consumes="multipart/form-data") @PreAuthorize("hasAuthority('platform:firstleg:truck-appointment:edit')") public ApiResponse<com.baiyue.firstleg.entity.TruckAppointmentAttachmentEntity> upload(@AuthenticationPrincipal AuthenticatedUser u,@PathVariable @Positive long id,@RequestParam(required=false) String attachmentType,@RequestPart("file") org.springframework.web.multipart.MultipartFile file){FirstlegScopeGuard.platform(u);return ApiResponse.success(service.upload(id,attachmentType,file,u.userId()));}
@PostMapping(value="/{id}/attachments/upload",consumes="multipart/form-data") @PreAuthorize("hasAuthority('platform:firstleg:truck-appointment:edit')") public ApiResponse<com.baiyue.firstleg.entity.TruckAppointmentAttachmentEntity> upload(@AuthenticationPrincipal AuthenticatedUser u,@PathVariable @Positive long id,@RequestParam @NotBlank @Pattern(regexp="BILL_OF_LADING|DISPATCH_ORDER|DRIVER_DOCUMENT|VEHICLE_DOCUMENT|OTHER") String attachmentType,@RequestPart("file") org.springframework.web.multipart.MultipartFile file){FirstlegScopeGuard.platform(u);return ApiResponse.success(service.upload(id,attachmentType,file,u.userId()));}
@DeleteMapping("/{id}/attachments/{attachmentId}") @PreAuthorize("hasAuthority('platform:firstleg:truck-appointment:edit')") public ApiResponse<Void> deleteAttachment(@AuthenticationPrincipal AuthenticatedUser u,@PathVariable @Positive long id,@PathVariable @Positive long attachmentId){FirstlegScopeGuard.platform(u);service.deleteAttachment(id,attachmentId);return ApiResponse.success(null);}
@PostMapping @PreAuthorize("hasAuthority('platform:firstleg:truck-appointment:create')") @BusinessAudit(event="TRUCK_APPOINTMENT_CREATE",resourceType="TRUCK_APPOINTMENT",captureBefore=false) public ApiResponse<TruckAppointmentEntity> create(@AuthenticationPrincipal AuthenticatedUser u,@Valid @RequestBody SaveRequest r){FirstlegScopeGuard.platform(u);return ApiResponse.success(service.create(u.userId(),u.username(),r));}
@PutMapping("/{id}") @PreAuthorize("hasAuthority('platform:firstleg:truck-appointment:edit')") public ApiResponse<TruckAppointmentEntity> edit(@AuthenticationPrincipal AuthenticatedUser u,@PathVariable long id,@Valid @RequestBody SaveRequest r){FirstlegScopeGuard.platform(u);return ApiResponse.success(service.edit(id,u.userId(),u.username(),r));}
@@ -11,7 +11,6 @@ public record CartonMeasurementCommand(
BigDecimal lengthCm,
BigDecimal widthCm,
BigDecimal heightCm,
BigDecimal unitWeightKg,
String machineNo,
LocalDateTime measuredAt,
MultipartFile image,
@@ -14,7 +14,6 @@ public record CartonMeasurementResult(
BigDecimal lengthCm,
BigDecimal widthCm,
BigDecimal heightCm,
BigDecimal unitWeightKg,
Long imageFileId,
String machineNo,
LocalDateTime measuredAt,
@@ -100,9 +100,12 @@ public final class ContainerDtos {
@NotNull @DecimalMin(value = "0", inclusive = false) BigDecimal capacityCbm,
@NotNull @DecimalMin(value = "0", inclusive = false) BigDecimal maxWeightKg) {}
@Schema(name = "ContainerOceanBookingLinkRequest", description = "将内部模拟柜原地合并为指定真实订舱柜")
public record OceanBookingLinkRequest(@NotNull @Positive Long realContainerBookingId,
@NotNull @PositiveOrZero Integer expectedVersion) {}
@Schema(name = "ContainerOceanBookingLinkRequest", description = "将现有模拟柜原地关联到已发布订舱箱型需求并补录真实柜号")
public record OceanBookingLinkRequest(@NotNull @Positive Long oceanBookingId,
@NotNull @Positive Long requirementId, @NotBlank @Size(max = 64) String containerNo,
@NotNull @PositiveOrZero Integer expectedVersion) {
@Deprecated public OceanBookingLinkRequest(Long legacyRealContainerBookingId, Integer expectedVersion) { this(null, null, null, expectedVersion); }
}
@Schema(name = "ContainerOceanBookingUnlinkRequest", description = "解除空模拟柜的真实订舱关联")
public record OceanBookingUnlinkRequest(@NotNull @PositiveOrZero Integer expectedVersion,
@@ -44,7 +44,6 @@ public final class InboundMeasurementRequests {
@NotNull @DecimalMin("0.001") @Digits(integer = 9, fraction = 3) BigDecimal lengthCm,
@NotNull @DecimalMin("0.001") @Digits(integer = 9, fraction = 3) BigDecimal widthCm,
@NotNull @DecimalMin("0.001") @Digits(integer = 9, fraction = 3) BigDecimal heightCm,
@NotNull @DecimalMin("0.001") @Digits(integer = 15, fraction = 3) BigDecimal unitWeightKg,
@Positive Long intelligentImageFileId,
@Size(max = 500) String overLimitWarning,
@DecimalMin("0.000") @Digits(integer = 9, fraction = 3) BigDecimal outerLengthCm,
@@ -72,7 +71,6 @@ public final class InboundMeasurementRequests {
@NotNull @DecimalMin("0.001") @Digits(integer = 9, fraction = 3) BigDecimal length,
@NotNull @DecimalMin("0.001") @Digits(integer = 9, fraction = 3) BigDecimal width,
@NotNull @DecimalMin("0.001") @Digits(integer = 9, fraction = 3) BigDecimal height,
@DecimalMin("0.001") @Digits(integer = 15, fraction = 3) BigDecimal weight,
@Size(max = 64) String scannedShipmentId,
@NotBlank @Size(max = 64) String machineNo,
@NotNull LocalDateTime measuredAt
@@ -59,9 +59,7 @@ public final class InboundMeasurementResponses {
BigDecimal widthCm,
BigDecimal heightCm,
BigDecimal girthCm,
BigDecimal unitWeightKg,
BigDecimal unitVolumeCbm,
BigDecimal totalWeightKg,
BigDecimal totalVolumeCbm,
BigDecimal volumeWeightKg,
Long intelligentImageFileId,
@@ -35,7 +35,6 @@ public record MeasurementBatchCommand(
BigDecimal lengthCm,
BigDecimal widthCm,
BigDecimal heightCm,
BigDecimal unitWeightKg,
Long intelligentImageFileId,
String overLimitWarning,
BigDecimal outerLengthCm,
@@ -31,7 +31,6 @@ public record MerchantInboundBillingResponse(InboundOrderDetail inbound,
public record MeasurementItem(
Long id, String itemNo, String boxSpecification, Integer packageCount,
BigDecimal lengthCm, BigDecimal widthCm, BigDecimal heightCm,
BigDecimal unitWeightKg, BigDecimal totalWeightKg,
BigDecimal unitVolumeCbm, BigDecimal totalVolumeCbm,
BigDecimal volumeWeightKg) {
}
@@ -23,7 +23,6 @@ public final class MojiaMeasurementDtos {
@NotNull @DecimalMin("0.001") @DecimalMax("999999999.999") BigDecimal length,
@NotNull @DecimalMin("0.001") @DecimalMax("999999999.999") BigDecimal width,
@NotNull @DecimalMin("0.001") @DecimalMax("999999999.999") BigDecimal height,
@DecimalMin("0.001") @DecimalMax("999999999999999.999") BigDecimal weight,
@NotBlank @Size(max = 64) String machineNo,
LocalDateTime measuredAt,
@NotBlank @Size(max = 7_100_000) String picture
@@ -47,7 +47,6 @@ public final class OpenApiMeasurementDtos {
@NotNull @DecimalMin(value = "0", inclusive = false) @DecimalMax("999999999.999") BigDecimal widthCm,
@NotNull @DecimalMin(value = "0", inclusive = false) @DecimalMax("999999999.999") BigDecimal heightCm,
@NotNull @Min(1) @Max(1000000) Integer packageCount,
@DecimalMin(value = "0", inclusive = false) @DecimalMax("999999999.999") BigDecimal unitWeightKg,
@NotBlank @Size(max = 64) String boxSpecification,
@Size(max = 500) String remark) {
}
@@ -3,17 +3,17 @@ import com.baiyue.firstleg.entity.LoadingPlanItemEntity; import jakarta.validati
public final class SmartLoadingDtos { private SmartLoadingDtos(){}
public record Candidate(Long accountId, Long inboundId, Long inboundDetailId, Long orderId, String orderNo, String inboundCode,
Long warehouseId, String channelCode, String channelName, Long destinationWarehouseId, String destinationWarehouseCode, String destinationWarehouseName, String customerName, String goodsName, Integer availablePackageCount,
BigDecimal unitWeightKg, BigDecimal unitVolumeCbm) {
BigDecimal unitGrossWeightKg, BigDecimal unitNetWeightKg, BigDecimal unitVolumeCbm) {
public Candidate(Long accountId, Long inboundId, Long inboundDetailId, Long orderId, String orderNo, String inboundCode,
Long warehouseId, String destinationWarehouseCode, String customerName, String goodsName, Integer availablePackageCount,
BigDecimal unitWeightKg, BigDecimal unitVolumeCbm) {
this(accountId,inboundId,inboundDetailId,orderId,orderNo,inboundCode,warehouseId,null,null,null,destinationWarehouseCode,null,customerName,goodsName,availablePackageCount,unitWeightKg,unitVolumeCbm);
BigDecimal unitGrossWeightKg, BigDecimal unitNetWeightKg, BigDecimal unitVolumeCbm) {
this(accountId,inboundId,inboundDetailId,orderId,orderNo,inboundCode,warehouseId,null,null,null,destinationWarehouseCode,null,customerName,goodsName,availablePackageCount,unitGrossWeightKg,unitNetWeightKg,unitVolumeCbm);
}
public Candidate(Long accountId, Long inboundId, Long inboundDetailId, Long orderId, String orderNo, String inboundCode,
Long warehouseId, Long destinationWarehouseId, String destinationWarehouseCode, String destinationWarehouseName,
String customerName, String goodsName, Integer availablePackageCount, BigDecimal unitWeightKg, BigDecimal unitVolumeCbm) {
String customerName, String goodsName, Integer availablePackageCount, BigDecimal unitGrossWeightKg, BigDecimal unitNetWeightKg, BigDecimal unitVolumeCbm) {
this(accountId,inboundId,inboundDetailId,orderId,orderNo,inboundCode,warehouseId,null,null,destinationWarehouseId,
destinationWarehouseCode,destinationWarehouseName,customerName,goodsName,availablePackageCount,unitWeightKg,unitVolumeCbm);
destinationWarehouseCode,destinationWarehouseName,customerName,goodsName,availablePackageCount,unitGrossWeightKg,unitNetWeightKg,unitVolumeCbm);
}
}
public record CreateRequest(@NotNull @Positive Long bookingId, @Size(max=500) String remark) {}
@@ -2,7 +2,7 @@ package com.baiyue.firstleg.dto;
import jakarta.validation.constraints.*; import java.math.BigDecimal; import java.time.LocalDateTime;
public final class TruckAppointmentDtos { private TruckAppointmentDtos(){}
public record Query(@Size(max=64) String keyword, com.baiyue.firstleg.domain.TruckAppointmentStatus status, @Positive Long customerId, @Positive Long containerBookingId, @Min(1) long page, @Min(1) @Max(200) long size){ public Query{if(page==0)page=1;if(size==0)size=20;} }
public record SaveRequest(@Positive Long outboundOrderId,@Positive Long oceanBookingId,@Positive Long containerBookingId,@Positive Long loadingPlanId,@Positive Long warehouseId,@NotNull @Positive Long customerId,@Size(max=128) String customerName,@NotNull @Positive Long carrierId,@Size(max=128) String carrierName,@Size(max=64) String fleetContact,@Size(max=32) String fleetPhone,@Size(max=64) String driverName,@Size(max=32) String driverPhone,@Size(max=32) String driverIdCard,@Size(max=32) String vehicleNo,@Size(max=128) String storeName,@Size(max=64) String storeContact,@Size(max=32) String storePhone,@Size(max=255) String storeAddress,@Size(max=64) String storeCity,@Size(max=16) String storeZipcode,@PositiveOrZero Integer packageCount,@DecimalMin("0") BigDecimal grossWeightKg,@DecimalMin("0") BigDecimal volumeCbm,@PositiveOrZero Integer palletCount,@Size(max=255) String poNo,@NotNull LocalDateTime plannedLoadingAt,@PositiveOrZero Integer transitMinutes,@DecimalMin("0") BigDecimal payableAmount,@DecimalMin("0") BigDecimal receivableAmount,@Size(max=8) String currency,@Size(max=1000) String remark,@NotNull @PositiveOrZero Integer expectedVersion){}
public record SaveRequest(@Positive Long outboundOrderId,@Positive Long oceanBookingId,@Positive Long containerBookingId,@Positive Long loadingPlanId,@Positive Long warehouseId,@NotNull @Positive Long customerId,@Size(max=128) String customerName,@NotNull @Positive Long carrierId,@Size(max=128) String carrierName,@Size(max=128) String billOfLadingNo,@Size(max=64) String fleetContact,@Size(max=32) String fleetPhone,@Size(max=64) String driverName,@Size(max=32) String driverPhone,@Size(max=32) String driverIdCard,@Size(max=32) String vehicleNo,@Size(max=128) String storeName,@Size(max=64) String storeContact,@Size(max=32) String storePhone,@Size(max=255) String storeAddress,@Size(max=64) String storeCity,@Size(max=16) String storeZipcode,@PositiveOrZero Integer packageCount,@DecimalMin("0") BigDecimal grossWeightKg,@DecimalMin("0") BigDecimal volumeCbm,@PositiveOrZero Integer palletCount,@Size(max=255) String poNo,@NotNull LocalDateTime plannedLoadingAt,@PositiveOrZero Integer transitMinutes,@DecimalMin("0") BigDecimal payableAmount,@DecimalMin("0") BigDecimal receivableAmount,@Size(max=8) String currency,@Size(max=1000) String remark,@NotNull @PositiveOrZero Integer expectedVersion){}
public record ActionRequest(@NotNull @PositiveOrZero Integer expectedVersion,@Size(max=500) String reason){}
public record AttachmentRequest(@NotNull @Positive Long fileId,@Size(max=64) String attachmentType,@Size(max=255) String fileName,@Size(max=128) String fileHash){ }
public record AttachmentRequest(@NotNull @Positive Long fileId,@NotBlank @Pattern(regexp="BILL_OF_LADING|DISPATCH_ORDER|DRIVER_DOCUMENT|VEHICLE_DOCUMENT|OTHER") String attachmentType,@Size(max=255) String fileName,@Size(max=128) String fileHash){ }
}
@@ -29,11 +29,9 @@ public class InboundMeasurementItemEntity {
private BigDecimal widthCm;
private BigDecimal heightCm;
private BigDecimal girthCm;
private BigDecimal unitWeightKg;
private BigDecimal unitVolumeCbm;
private BigDecimal volumeRawCbm;
private BigDecimal volumeCbm;
private BigDecimal totalWeightKg;
private BigDecimal totalVolumeCbm;
private BigDecimal volumeWeightKg;
private String calculationRuleVersion;
@@ -23,7 +23,6 @@ public class MojiaMeasurementReceiptEntity {
private BigDecimal lengthCm;
private BigDecimal widthCm;
private BigDecimal heightCm;
private BigDecimal weightKg;
private String machineNo;
private LocalDateTime measuredAt;
private Long imageFileId;
@@ -23,6 +23,7 @@ public class TruckAppointmentEntity {
private String customerName;
private Long carrierId;
private String carrierName;
private String billOfLadingNo;
private String fleetContact;
private String fleetPhone;
private String driverName;
@@ -11,6 +11,8 @@ import org.apache.ibatis.annotations.Update;
@Mapper
public interface ContainerBookingMapper extends BaseMapper<ContainerBookingEntity> {
@Update("UPDATE fl_container_booking SET container_no=#{containerNo},ocean_booking_id=#{oceanBookingId},ocean_booking_requirement_id=#{requirementId},record_status='ACTIVE',bill_of_lading_no=#{billOfLadingNo},vessel_name=#{vesselName},voyage_no=#{voyageNo},shipping_company_id=#{shippingCompanyId},shipping_company_name=#{shippingCompanyName},departure_port_code=#{departurePortCode},departure_port_name=#{departurePortName},destination_port_code=#{destinationPortCode},destination_port_name=#{destinationPortName},channel_code=#{channelCode},channel_name=#{channelName},customer_id=#{customerId},customer_name=#{customerName},estimated_sailing_at=#{estimatedSailingAt},estimated_port_arrival_at=#{estimatedArrivalAt},updated_by=#{operatorId},updated_by_name=#{operatorName},updated_at=NOW(),version=version+1 WHERE id=#{id} AND version=#{version} AND source_type='SIMULATION' AND record_status='ACTIVE' AND ocean_booking_id IS NULL")
int linkReleasedBooking(@Param("id") long id, @Param("oceanBookingId") long oceanBookingId, @Param("requirementId") long requirementId, @Param("containerNo") String containerNo, @Param("billOfLadingNo") String billOfLadingNo, @Param("vesselName") String vesselName, @Param("voyageNo") String voyageNo, @Param("shippingCompanyId") Long shippingCompanyId, @Param("shippingCompanyName") String shippingCompanyName, @Param("departurePortCode") String departurePortCode, @Param("departurePortName") String departurePortName, @Param("destinationPortCode") String destinationPortCode, @Param("destinationPortName") String destinationPortName, @Param("channelCode") String channelCode, @Param("channelName") String channelName, @Param("customerId") Long customerId, @Param("customerName") String customerName, @Param("estimatedSailingAt") java.time.LocalDateTime estimatedSailingAt, @Param("estimatedPortArrivalAt") java.time.LocalDateTime estimatedPortArrivalAt, @Param("version") int version, @Param("operatorId") long operatorId, @Param("operatorName") String operatorName);
@Select("""
<script>
SELECT b.*, p.id AS plan_id, p.plan_no AS plan_no,
@@ -51,8 +51,12 @@ public interface InboundSplitSettlementMapper {
company_id,company_name,warehouse_id,warehouse_name,delivery_warehouse_id,delivery_warehouse_name,business_type,
channel_code,channel_name,carrier_id,carrier_name,tax_mode,pricing_tax_mode,container_mode,customs_inspection,
destination_type,destination_warehouse_id,destination_code,destination_name,hold_flag,current_hold_reason,
#{packageCount},#{packageCount},actual_gross_weight_kg,chargeable_weight_kg,actual_volume_cbm,chargeable_volume_cbm,
pallet_count,volume_weight_kg,actual_net_weight_kg,measure_completed_at,inbounded_at,express_company_code,
#{packageCount},#{packageCount},
actual_gross_weight_kg * #{packageCount} / NULLIF(total_package_count, 0),
chargeable_weight_kg,actual_volume_cbm,chargeable_volume_cbm,
pallet_count,volume_weight_kg,
actual_net_weight_kg * #{packageCount} / NULLIF(total_package_count, 0),
measure_completed_at,inbounded_at,express_company_code,
express_company_name,express_tracking_no,public_remark,#{operatorId},#{operatorName},CURRENT_TIMESTAMP(3),
#{operatorId},CURRENT_TIMESTAMP(3),0,0,'CHILD',#{rootInboundId},#{rootInboundId},#{splitSequence},'NOT_SPLIT',#{splitPurpose}
FROM fl_inbound_order WHERE id=#{parentInboundId} AND tenant_id=#{accountId} AND deleted=0
@@ -124,14 +128,13 @@ public interface InboundSplitSettlementMapper {
INSERT INTO fl_inbound_measurement_item
(id, tenant_id, measurement_id, split_item_id, source_measurement_item_id, inbound_detail_id,
external_detail_no, box_code, item_no, box_specification, package_count, length_cm, width_cm,
height_cm, girth_cm, unit_weight_kg, unit_volume_cbm, volume_raw_cbm, volume_cbm,
total_weight_kg, total_volume_cbm, volume_weight_kg, calculation_rule_version,
height_cm, girth_cm, unit_volume_cbm, volume_raw_cbm, volume_cbm,
total_volume_cbm, volume_weight_kg, calculation_rule_version,
source_image_file_id, machine_no, device_measured_at, over_limit_warning,
outer_length_cm, outer_width_cm, outer_height_cm, remark, created_at, updated_at)
SELECT #{childMeasurementItemId}, tenant_id, #{childMeasurementId}, #{splitItemId}, id, inbound_detail_id,
NULL, NULL, CAST(#{lineNo} AS CHAR), box_specification, #{packageCount}, length_cm, width_cm,
height_cm, girth_cm, unit_weight_kg, unit_volume_cbm, volume_raw_cbm, volume_cbm,
CASE WHEN unit_weight_kg IS NULL THEN NULL ELSE unit_weight_kg * #{packageCount} END,
height_cm, girth_cm, unit_volume_cbm, volume_raw_cbm, volume_cbm,
unit_volume_cbm * #{packageCount}, volume_weight_kg / NULLIF(package_count, 0) * #{packageCount},
calculation_rule_version, source_image_file_id, machine_no, device_measured_at,
over_limit_warning, outer_length_cm, outer_width_cm, outer_height_cm, remark,
@@ -175,7 +178,6 @@ public interface InboundSplitSettlementMapper {
@Update("""
UPDATE fl_inbound_measurement_item
SET package_count=#{packageCount},
total_weight_kg=CASE WHEN unit_weight_kg IS NULL THEN NULL ELSE unit_weight_kg*#{packageCount} END,
total_volume_cbm=unit_volume_cbm*#{packageCount},
volume_weight_kg=volume_weight_kg/NULLIF(package_count,0)*#{packageCount},
updated_at=CURRENT_TIMESTAMP(3)
@@ -278,14 +280,12 @@ public interface InboundSplitSettlementMapper {
UPDATE fl_inbound_order child
JOIN fl_inbound_measurement measurement ON measurement.tenant_id=child.tenant_id
AND measurement.inbound_id=child.id AND measurement.id=#{measurementId}
SET child.actual_gross_weight_kg=(SELECT COALESCE(SUM(item.total_weight_kg),0)
FROM fl_inbound_measurement_item item WHERE item.measurement_id=measurement.id),
child.actual_volume_cbm=(SELECT COALESCE(SUM(item.total_volume_cbm),0)
SET child.actual_volume_cbm=(SELECT COALESCE(SUM(item.total_volume_cbm),0)
FROM fl_inbound_measurement_item item WHERE item.measurement_id=measurement.id),
child.chargeable_weight_kg=NULL,child.chargeable_volume_cbm=NULL,
child.volume_weight_kg=(SELECT COALESCE(SUM(item.volume_weight_kg),0)
FROM fl_inbound_measurement_item item WHERE item.measurement_id=measurement.id),
child.actual_net_weight_kg=NULL,child.pallet_count=0,child.version=child.version+1
child.pallet_count=0,child.version=child.version+1
WHERE child.id=#{childInboundId} AND child.tenant_id=#{accountId} AND child.deleted=0
""")
int normalizeChildMetrics(@Param("accountId") long accountId, @Param("childInboundId") long childInboundId,
@@ -12,10 +12,12 @@ import com.baiyue.firstleg.dto.SmartLoadingDtos.Candidate; import org.apache.iba
AND li.inbound_detail_id=i.inbound_detail_id AND li.deleted=0),0)
-COALESCE((SELECT SUM(split_item.package_count)
FROM fl_inbound_split_item split_item
WHERE o.inbound_role='MASTER'
WHERE o.inbound_role='MASTER'
AND split_item.tenant_id=o.tenant_id
AND split_item.parent_inbound_id=o.id
AND split_item.source_measurement_item_id=i.id),0) availablePackageCount,COALESCE(i.unit_weight_kg,0) unitWeightKg,
AND split_item.source_measurement_item_id=i.id),0) availablePackageCount,
COALESCE(o.actual_gross_weight_kg / NULLIF(o.total_package_count, 0), 0) unitGrossWeightKg,
COALESCE(o.actual_net_weight_kg / NULLIF(o.total_package_count, 0), 0) unitNetWeightKg,
COALESCE(i.unit_volume_cbm,0) unitVolumeCbm
FROM fl_inbound_order o
JOIN fl_inbound_measurement m ON m.tenant_id=o.tenant_id AND m.inbound_id=o.id
@@ -24,6 +26,7 @@ import com.baiyue.firstleg.dto.SmartLoadingDtos.Candidate; import org.apache.iba
WHERE m2.tenant_id=o.tenant_id AND m2.inbound_id=o.id AND m2.status='APPROVED')
JOIN fl_inbound_measurement_item i ON i.tenant_id=o.tenant_id AND i.measurement_id=m.id
WHERE o.warehouse_id=#{warehouseId} AND o.process_status='WAIT_SPLIT'
AND o.actual_gross_weight_kg IS NOT NULL AND o.actual_net_weight_kg IS NOT NULL
AND (o.inbound_role='MASTER' OR (o.split_purpose='REMAINDER' AND o.split_status='NOT_SPLIT'))
AND i.inbound_detail_id IS NOT NULL
AND i.package_count>COALESCE((SELECT SUM(li.package_count) FROM fl_loading_plan_item li
@@ -53,7 +56,9 @@ import com.baiyue.firstleg.dto.SmartLoadingDtos.Candidate; import org.apache.iba
WHERE o.inbound_role='MASTER'
AND split_item.tenant_id=o.tenant_id
AND split_item.parent_inbound_id=o.id
AND split_item.source_measurement_item_id=i.id),0) availablePackageCount,COALESCE(i.unit_weight_kg,0) unitWeightKg,
AND split_item.source_measurement_item_id=i.id),0) availablePackageCount,
COALESCE(o.actual_gross_weight_kg / NULLIF(o.total_package_count, 0), 0) unitGrossWeightKg,
COALESCE(o.actual_net_weight_kg / NULLIF(o.total_package_count, 0), 0) unitNetWeightKg,
COALESCE(i.unit_volume_cbm,0) unitVolumeCbm
FROM fl_inbound_order o
JOIN fl_inbound_measurement m ON m.tenant_id=o.tenant_id AND m.inbound_id=o.id
@@ -62,6 +67,7 @@ import com.baiyue.firstleg.dto.SmartLoadingDtos.Candidate; import org.apache.iba
WHERE m2.tenant_id=o.tenant_id AND m2.inbound_id=o.id AND m2.status='APPROVED')
JOIN fl_inbound_measurement_item i ON i.tenant_id=o.tenant_id AND i.measurement_id=m.id
WHERE o.warehouse_id=#{warehouseId} AND o.id=#{inboundId} AND o.process_status='WAIT_SPLIT'
AND o.actual_gross_weight_kg IS NOT NULL AND o.actual_net_weight_kg IS NOT NULL
AND (o.inbound_role='MASTER' OR (o.split_purpose='REMAINDER' AND o.split_status='NOT_SPLIT'))
AND i.inbound_detail_id=#{detailId}
ORDER BY m.id DESC LIMIT 1 FOR UPDATE
@@ -4,7 +4,8 @@ import com.baomidou.mybatisplus.core.mapper.BaseMapper; import com.baomidou.myba
@Select("SELECT * FROM fl_truck_appointment WHERE container_booking_id=#{containerBookingId} AND status NOT IN ('COMPLETED','CANCELLED','EXCEPTION') ORDER BY id FOR UPDATE") java.util.List<TruckAppointmentEntity> activeForUpdate(@Param("containerBookingId") long containerBookingId);
@Select("<script>SELECT a.*,o.booking_no ocean_booking_no,COALESCE(o.warehouse_name,c.warehouse_name) warehouse_name,o.shipping_company_id,o.shipping_company_name,o.booking_agent_id,o.booking_agent_name,c.cabinet_no,c.container_no,(SELECT COUNT(*) FROM fl_truck_appointment_attachment x WHERE x.appointment_id=a.id) attachment_count FROM fl_truck_appointment a LEFT JOIN fl_ocean_booking o ON o.id=a.ocean_booking_id LEFT JOIN fl_container_booking c ON c.id=a.container_booking_id AND c.record_status='ACTIVE' WHERE (#{q.keyword} IS NULL OR a.appointment_no LIKE CONCAT('%',#{q.keyword},'%') OR a.vehicle_no LIKE CONCAT('%',#{q.keyword},'%') OR a.carrier_name LIKE CONCAT('%',#{q.keyword},'%')) AND (#{q.status} IS NULL OR a.status=#{q.status}) AND (#{q.customerId} IS NULL OR a.customer_id=#{q.customerId}) AND (#{q.containerBookingId} IS NULL OR a.container_booking_id=#{q.containerBookingId}) ORDER BY a.updated_at DESC,a.id DESC</script>") Page<TruckAppointmentEntity> page(Page<TruckAppointmentEntity> p,@Param("q") Query q);
@Update("UPDATE fl_truck_appointment SET status=#{toStatus}, actual_arrived_warehouse_at=CASE WHEN #{toStatus}='ARRIVED_WAREHOUSE' THEN CURRENT_TIMESTAMP(3) ELSE actual_arrived_warehouse_at END, actual_loading_at=CASE WHEN #{toStatus}='LOADING' THEN CURRENT_TIMESTAMP(3) ELSE actual_loading_at END, actual_departed_at=CASE WHEN #{toStatus}='DEPARTED' THEN CURRENT_TIMESTAMP(3) ELSE actual_departed_at END, revision=revision+1, version=version+1, updated_at=CURRENT_TIMESTAMP(3) WHERE id=#{id} AND status=#{fromStatus} AND version=#{version}") int transition(@Param("id") long id,@Param("fromStatus") String from,@Param("toStatus") String to,@Param("version") int version);
@Update("UPDATE fl_truck_appointment SET status='DISPATCHED',bill_of_lading_no=#{billOfLadingNo},revision=revision+1,version=version+1,updated_at=CURRENT_TIMESTAMP(3) WHERE id=#{id} AND status=#{fromStatus} AND version=#{version}") int dispatch(@Param("id") long id,@Param("fromStatus") String fromStatus,@Param("billOfLadingNo") String billOfLadingNo,@Param("version") int version);
@Update("UPDATE fl_truck_appointment SET status='COMPLETED',actual_loading_at=COALESCE(actual_loading_at,CURRENT_TIMESTAMP(3)),revision=revision+1,version=version+1,updated_at=CURRENT_TIMESTAMP(3) WHERE id=#{id} AND status=#{fromStatus} AND version=#{version}") int completeLoadingFromPda(@Param("id") long id,@Param("fromStatus") String fromStatus,@Param("version") int version);
@Update("UPDATE fl_truck_appointment SET planned_loading_at=#{at}, revision=revision+1, version=version+1, status='RESCHEDULED', remark=#{reason}, updated_at=CURRENT_TIMESTAMP(3) WHERE id=#{id} AND version=#{version}") int reschedule(@Param("id") long id,@Param("at") java.time.LocalDateTime at,@Param("reason") String reason,@Param("version") int version);
@Update("UPDATE fl_truck_appointment SET outbound_order_id=#{e.outboundOrderId},ocean_booking_id=#{e.oceanBookingId},container_booking_id=#{e.containerBookingId},loading_plan_id=#{e.loadingPlanId},warehouse_id=#{e.warehouseId},customer_id=#{e.customerId},customer_name=#{e.customerName},carrier_id=#{e.carrierId},carrier_name=#{e.carrierName},fleet_contact=#{e.fleetContact},fleet_phone=#{e.fleetPhone},driver_name=#{e.driverName},driver_phone=#{e.driverPhone},driver_id_card=#{e.driverIdCard},vehicle_no=#{e.vehicleNo},store_name=#{e.storeName},store_contact=#{e.storeContact},store_phone=#{e.storePhone},store_address=#{e.storeAddress},store_city=#{e.storeCity},store_zipcode=#{e.storeZipcode},package_count=#{e.packageCount},gross_weight_kg=#{e.grossWeightKg},volume_cbm=#{e.volumeCbm},pallet_count=#{e.palletCount},po_no=#{e.poNo},planned_loading_at=#{e.plannedLoadingAt},transit_minutes=#{e.transitMinutes},payable_amount=#{e.payableAmount},receivable_amount=#{e.receivableAmount},currency=#{e.currency},remark=#{e.remark},version=version+1,revision=revision+1,updated_at=CURRENT_TIMESTAMP(3) WHERE id=#{e.id} AND version=#{version}") int updateEdit(@Param("e") TruckAppointmentEntity e,@Param("version") int version);
@Update("UPDATE fl_truck_appointment SET outbound_order_id=#{e.outboundOrderId},ocean_booking_id=#{e.oceanBookingId},container_booking_id=#{e.containerBookingId},loading_plan_id=#{e.loadingPlanId},warehouse_id=#{e.warehouseId},customer_id=#{e.customerId},customer_name=#{e.customerName},carrier_id=#{e.carrierId},carrier_name=#{e.carrierName},bill_of_lading_no=#{e.billOfLadingNo},fleet_contact=#{e.fleetContact},fleet_phone=#{e.fleetPhone},driver_name=#{e.driverName},driver_phone=#{e.driverPhone},driver_id_card=#{e.driverIdCard},vehicle_no=#{e.vehicleNo},store_name=#{e.storeName},store_contact=#{e.storeContact},store_phone=#{e.storePhone},store_address=#{e.storeAddress},store_city=#{e.storeCity},store_zipcode=#{e.storeZipcode},package_count=#{e.packageCount},gross_weight_kg=#{e.grossWeightKg},volume_cbm=#{e.volumeCbm},pallet_count=#{e.palletCount},po_no=#{e.poNo},planned_loading_at=#{e.plannedLoadingAt},transit_minutes=#{e.transitMinutes},payable_amount=#{e.payableAmount},receivable_amount=#{e.receivableAmount},currency=#{e.currency},remark=#{e.remark},version=version+1,revision=revision+1,updated_at=CURRENT_TIMESTAMP(3) WHERE id=#{e.id} AND version=#{version}") int updateEdit(@Param("e") TruckAppointmentEntity e,@Param("version") int version);
}
@@ -36,19 +36,16 @@ public class PlatformOceanBookingController {
public ApiResponse<Detail> detail(@AuthenticationPrincipal AuthenticatedUser user, @PathVariable @Positive long oceanBookingId) { FirstlegScopeGuard.platform(user); return ApiResponse.success(service.detail(oceanBookingId)); }
@PostMapping @PreAuthorize("hasAuthority('platform:firstleg:ocean-booking:create')")
@BusinessAudit(event="OCEAN_BOOKING_CREATE", resourceType="OCEAN_BOOKING", description="登记已确认海运订舱", captureBefore=false)
@BusinessAudit(event="OCEAN_BOOKING_CREATE", resourceType="OCEAN_BOOKING", description="创建海运订舱草稿", captureBefore=false)
public ApiResponse<Detail> create(@AuthenticationPrincipal AuthenticatedUser user, @RequestHeader("Idempotency-Key") String key, @Valid @RequestBody CreateRequest request) { validateOptionalCustomer(user, request.data().customerId()); return ApiResponse.success(service.create(user.userId(), user.username(), key, request)); }
@PutMapping("/{oceanBookingId}") @PreAuthorize("hasAuthority('platform:firstleg:ocean-booking:edit')")
@BusinessAudit(event="OCEAN_BOOKING_UPDATE_DRAFT", resourceType="OCEAN_BOOKING", resourceId="#oceanBookingId", description="编辑海运订舱草稿")
public ApiResponse<Detail> updateDraft(@AuthenticationPrincipal AuthenticatedUser user, @PathVariable @Positive long oceanBookingId, @RequestHeader("Idempotency-Key") String key, @Valid @RequestBody UpdateRequest request) { validateOptionalCustomer(user, request.data().customerId()); return ApiResponse.success(service.updateDraft(oceanBookingId, user.userId(), user.username(), key, request)); }
@DeleteMapping("/{oceanBookingId}") @PreAuthorize("hasAuthority('platform:firstleg:ocean-booking:delete')")
@BusinessAudit(event="OCEAN_BOOKING_DELETE_DRAFT", resourceType="OCEAN_BOOKING", resourceId="#oceanBookingId", description="删除海运订舱草稿")
public ApiResponse<Void> deleteDraft(@AuthenticationPrincipal AuthenticatedUser user, @PathVariable @Positive long oceanBookingId, @RequestHeader("Idempotency-Key") String key, @Valid @RequestBody ActionRequest request) { FirstlegScopeGuard.platform(user); service.deleteDraft(oceanBookingId, user.userId(), user.username(), key, request); return ApiResponse.success(null); }
@PostMapping("/{oceanBookingId}/release") @PreAuthorize("hasAuthority('platform:firstleg:ocean-booking:release')")
@BusinessAudit(event="OCEAN_BOOKING_RELEASE", resourceType="OCEAN_BOOKING", resourceId="#oceanBookingId", description="确认放舱")
public ApiResponse<Detail> release(@AuthenticationPrincipal AuthenticatedUser user, @PathVariable @Positive long oceanBookingId, @RequestHeader("Idempotency-Key") String key, @Valid @RequestBody ReleaseRequest request) { FirstlegScopeGuard.platform(user); return ApiResponse.success(service.release(oceanBookingId, user.userId(), user.username(), key, request)); }
@PostMapping("/{oceanBookingId}/cancel") @PreAuthorize("hasAuthority('platform:firstleg:ocean-booking:cancel')")
@BusinessAudit(event="OCEAN_BOOKING_CANCEL", resourceType="OCEAN_BOOKING", resourceId="#oceanBookingId", description="取消海运订舱")
public ApiResponse<Detail> cancel(@AuthenticationPrincipal AuthenticatedUser user, @PathVariable @Positive long oceanBookingId, @RequestHeader("Idempotency-Key") String key, @Valid @RequestBody ActionRequest request) { FirstlegScopeGuard.platform(user); return ApiResponse.success(service.cancel(oceanBookingId, user.userId(), user.username(), key, request)); }
@PostMapping("/{oceanBookingId}/change-request") @PreAuthorize("hasAuthority('platform:firstleg:ocean-booking:change')")
@BusinessAudit(event="OCEAN_BOOKING_CHANGE_REQUEST", resourceType="OCEAN_BOOKING", resourceId="#oceanBookingId", description="申请变更船期")
public ApiResponse<Detail> requestChange(@AuthenticationPrincipal AuthenticatedUser user, @PathVariable @Positive long oceanBookingId, @RequestHeader("Idempotency-Key") String key, @Valid @RequestBody ChangeRequest request) { FirstlegScopeGuard.platform(user); return ApiResponse.success(service.requestChange(oceanBookingId, user.userId(), user.username(), key, request)); }
@PostMapping("/{oceanBookingId}/change-confirm") @PreAuthorize("hasAuthority('platform:firstleg:ocean-booking:change')")
@BusinessAudit(event="OCEAN_BOOKING_CHANGE_CONFIRM", resourceType="OCEAN_BOOKING", resourceId="#oceanBookingId", description="确认变更船期")
public ApiResponse<Detail> confirmChange(@AuthenticationPrincipal AuthenticatedUser user, @PathVariable @Positive long oceanBookingId, @RequestHeader("Idempotency-Key") String key, @Valid @RequestBody ActionRequest request) { FirstlegScopeGuard.platform(user); return ApiResponse.success(service.confirmChange(oceanBookingId, user.userId(), user.username(), key, request)); }
private static void validateOptionalCustomer(AuthenticatedUser user, Long customerId) { FirstlegScopeGuard.platform(user); if (customerId != null) FirstlegScopeGuard.platform(user, customerId); }
}
@@ -2,8 +2,5 @@ package com.baiyue.firstleg.oceanbooking.domain;
public enum OceanBookingStatus {
DRAFT,
CONFIRMED,
RELEASED,
CHANGE_PENDING,
CANCELLED
RELEASED
}
@@ -2,6 +2,7 @@ package com.baiyue.firstleg.oceanbooking.dto;
import com.baiyue.firstleg.oceanbooking.entity.OceanBookingEntity;
import com.baiyue.firstleg.oceanbooking.entity.OceanBookingPartyEntity;
import com.baiyue.firstleg.oceanbooking.entity.OceanBookingContainerRequirementEntity;
import com.baiyue.firstleg.oceanbooking.entity.OceanBookingStatusHistoryEntity;
import jakarta.validation.Valid;
import jakarta.validation.constraints.Max;
@@ -31,53 +32,67 @@ public final class OceanBookingDtos {
@Size(max = 128) String contactName,
@Size(max = 64) String contactPhone) {}
public record ContainerRequest(@NotBlank @Size(max = 64) String containerNo,
@NotBlank @Size(max = 32) String containerType,
public record ContainerRequirementRequest(@NotBlank @Size(max = 32) String containerType,
@NotBlank @Size(max = 32) String containerKind,
@NotNull @Min(1) @Max(100) Integer quantity,
@NotNull @jakarta.validation.constraints.DecimalMin(value = "0", inclusive = false) BigDecimal capacityCbm,
@NotNull @jakarta.validation.constraints.DecimalMin(value = "0", inclusive = false) BigDecimal maxWeightKg) {}
@Deprecated
public record ContainerRequest(String containerNo, String containerType, String containerKind,
BigDecimal capacityCbm, BigDecimal maxWeightKg) {}
public record DraftData(@Positive Long customerId,
@Size(max = 128) String customerName,
@NotBlank @Size(max = 64) String channelCode,
@NotNull @Positive Long warehouseId,
@NotBlank @Size(max = 128) String billOfLadingNo,
@Size(max = 64) String channelCode,
@Positive Long warehouseId,
@Size(max = 128) String billOfLadingNo,
@Positive Long shippingCompanyId,
@Size(max = 128) String shippingCompanyName,
@Positive Long bookingAgentId,
@Size(max = 128) String bookingAgentName,
@NotBlank @Size(max = 128) String vesselName,
@NotBlank @Size(max = 64) String voyageNo,
@Size(max = 128) String vesselName,
@Size(max = 64) String voyageNo,
@Size(max = 32) String departurePortCode,
@Size(max = 128) String departurePortName,
@Size(max = 32) String destinationPortCode,
@Size(max = 128) String destinationPortName,
@Size(max = 128) String placeOfDelivery,
@NotBlank @Size(max = 32) String serviceTerm,
@NotBlank @Size(max = 32) String freightTerm,
@NotNull LocalDateTime estimatedSailingAt,
@NotNull LocalDateTime estimatedArrivalAt,
@Size(max = 32) String serviceTerm,
@Size(max = 32) String freightTerm,
LocalDateTime estimatedSailingAt,
LocalDateTime estimatedArrivalAt,
@Size(max = 1000) String remark,
@Valid @NotEmpty @Size(max = 3) List<PartyRequest> parties,
@Valid @NotEmpty @Size(max = 20) List<ContainerRequest> containers) {}
@Valid @Size(max = 3) List<PartyRequest> parties,
@Valid @Size(max = 20) List<ContainerRequirementRequest> containerRequirements) {
public DraftData(Long customerId, String customerName, String channelCode, Long warehouseId, String billOfLadingNo,
Long shippingCompanyId, String shippingCompanyName, Long bookingAgentId, String bookingAgentName,
String vesselName, String voyageNo, String departurePortCode, String departurePortName,
String destinationPortCode, String destinationPortName, String placeOfDelivery, String serviceTerm,
String freightTerm, LocalDateTime estimatedSailingAt, LocalDateTime estimatedArrivalAt, String remark,
List<PartyRequest> parties, java.util.Collection<?> legacyContainers) {
this(customerId, customerName, channelCode, warehouseId, billOfLadingNo, shippingCompanyId, shippingCompanyName,
bookingAgentId, bookingAgentName, vesselName, voyageNo, departurePortCode, departurePortName, destinationPortCode,
destinationPortName, placeOfDelivery, serviceTerm, freightTerm, estimatedSailingAt, estimatedArrivalAt, remark,
parties, legacyContainers == null ? List.of() : legacyContainers.stream().map(v -> v instanceof ContainerRequirementRequest x ? x : v instanceof ContainerRequest x ? new ContainerRequirementRequest(x.containerType(), x.containerKind(), 1, x.capacityCbm(), x.maxWeightKg()) : null).filter(java.util.Objects::nonNull).toList());
}
public List<ContainerRequest> containers() { return containerRequirements.stream().map(v -> new ContainerRequest(null, v.containerType(), v.containerKind(), v.capacityCbm(), v.maxWeightKg())).toList(); }
}
public record CreateRequest(@NotNull @Min(0) Integer expectedVersion,
@Valid @NotNull DraftData data) {}
public record UpdateRequest(@NotNull @Min(0) Integer expectedVersion,
@Valid @NotNull DraftData data) {}
public record ActionRequest(@NotNull @Min(0) Integer expectedVersion,
@Size(max = 1000) String reason) {}
public record ReleaseRequest(@NotNull @Min(0) Integer expectedVersion,
@NotBlank @Size(max = 128) String billOfLadingNo,
@NotBlank @Size(max = 128) String vesselName,
@NotBlank @Size(max = 64) String voyageNo,
@NotNull LocalDateTime estimatedSailingAt,
@NotNull LocalDateTime estimatedArrivalAt,
@Size(max = 1000) String reason) {}
public record ChangeRequest(@NotNull @Min(0) Integer expectedVersion,
@NotBlank @Size(max = 128) String vesselName,
@NotBlank @Size(max = 64) String voyageNo,
LocalDateTime estimatedSailingAt,
LocalDateTime estimatedArrivalAt,
@NotBlank @Size(max = 1000) String reason) {}
@Size(max = 1000) String reason) {
public ReleaseRequest(int expectedVersion, String bill, String vessel, String voyage, LocalDateTime etd, LocalDateTime eta, String reason) {
this(expectedVersion, reason);
}
}
@Deprecated
public record ChangeRequest(Integer expectedVersion, String vesselName, String voyageNo,
LocalDateTime estimatedSailingAt, LocalDateTime estimatedArrivalAt, String reason) {}
public record RealContainer(Long containerBookingId, String cabinetNo, String containerNo,
Long requirementId, Long allocationId, String containerType,
String containerKind, BigDecimal capacityCbm, BigDecimal maxWeightKg,
@@ -86,6 +101,7 @@ public final class OceanBookingDtos {
public record Detail(OceanBookingEntity oceanBooking,
List<OceanBookingPartyEntity> parties,
List<OceanBookingContainerRequirementEntity> containerRequirements,
List<RealContainer> containers,
List<OceanBookingStatusHistoryEntity> histories) {}
@@ -5,6 +5,7 @@ import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.baiyue.firstleg.oceanbooking.dto.OceanBookingDtos.Query;
import com.baiyue.firstleg.oceanbooking.entity.OceanBookingEntity;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Delete;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
import org.apache.ibatis.annotations.Update;
@@ -13,6 +14,10 @@ import java.time.LocalDateTime;
@Mapper
public interface OceanBookingMapper extends BaseMapper<OceanBookingEntity> {
@Update("UPDATE fl_ocean_booking SET customer_id=#{value.customerId},customer_name=#{value.customerName},channel_code=#{value.channelCode},channel_name=#{value.channelName},warehouse_id=#{value.warehouseId},warehouse_name=#{value.warehouseName},bill_of_lading_no=#{value.billOfLadingNo},shipping_company_id=#{value.shippingCompanyId},shipping_company_name=#{value.shippingCompanyName},booking_agent_id=#{value.bookingAgentId},booking_agent_name=#{value.bookingAgentName},vessel_name=#{value.vesselName},voyage_no=#{value.voyageNo},departure_port_code=#{value.departurePortCode},departure_port_name=#{value.departurePortName},destination_port_code=#{value.destinationPortCode},destination_port_name=#{value.destinationPortName},place_of_delivery=#{value.placeOfDelivery},service_term=#{value.serviceTerm},freight_term=#{value.freightTerm},estimated_sailing_at=#{value.estimatedSailingAt},estimated_arrival_at=#{value.estimatedArrivalAt},remark=#{value.remark},updated_by=#{operatorId},updated_by_name=#{operatorName},version=version+1,updated_at=NOW() WHERE id=#{id} AND status='DRAFT' AND version=#{version}")
int updateDraft(@Param("id") long id, @Param("value") OceanBookingEntity value, @Param("version") int version, @Param("operatorId") long operatorId, @Param("operatorName") String operatorName);
@Delete("DELETE FROM fl_ocean_booking WHERE id=#{id} AND status='DRAFT' AND version=#{version}")
int deleteDraft(@Param("id") long id, @Param("version") int version);
@Select("""
<script>
SELECT booking.*,
@@ -25,7 +30,7 @@ public interface OceanBookingMapper extends BaseMapper<OceanBookingEntity> {
WHERE allocation.ocean_booking_id=booking.id AND container.record_status='ACTIVE') container_count
FROM fl_ocean_booking booking
WHERE (#{query.customerId} IS NULL OR booking.customer_id=#{query.customerId})
AND booking.status IN ('CONFIRMED','CHANGE_PENDING','RELEASED','CANCELLED')
AND booking.status IN ('DRAFT','RELEASED')
AND (#{query.status} IS NULL OR booking.status=#{query.status})
AND (#{query.keyword} IS NULL OR booking.booking_no LIKE CONCAT('%',#{query.keyword},'%')
OR booking.customer_name LIKE CONCAT('%',#{query.keyword},'%')
@@ -5,7 +5,6 @@ import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.baiyue.common.core.api.PageResult;
import com.baiyue.firstleg.oceanbooking.dto.OceanBookingDtos.Query;
import com.baiyue.firstleg.oceanbooking.dto.OceanBookingDtos.RealContainer;
import com.baiyue.firstleg.oceanbooking.dto.OceanBookingDtos.ReleaseRequest;
import com.baiyue.firstleg.oceanbooking.entity.*;
import com.baiyue.firstleg.oceanbooking.mapper.*;
import com.baiyue.firstleg.entity.ContainerBookingEntity;
@@ -32,15 +31,21 @@ public class OceanBookingRepository {
return PageResult.from(bookings.selectPage(new Page<>(query.page(), query.size()), query));
}
public OceanBookingEntity find(long id) { return bookings.selectById(id); }
@Deprecated public int release(long id, Object ignored, long operatorId, String operatorName) { return 0; }
public void insert(OceanBookingEntity value) { bookings.insert(value); }
public int transition(long id, String from, String to, String cancellationReason,
int version, long operatorId, String operatorName) {
return bookings.transition(id, from, to, cancellationReason, version, operatorId, operatorName);
}
public int release(long id, ReleaseRequest request, long operatorId, String operatorName) {
return bookings.release(id, request.billOfLadingNo().trim(), request.vesselName().trim(),
request.voyageNo().trim(), request.estimatedSailingAt(), request.estimatedArrivalAt(),
request.expectedVersion(), operatorId, operatorName);
public int updateDraft(OceanBookingEntity value, int version, long operatorId, String operatorName) {
return bookings.updateDraft(value.getId(), value, version, operatorId, operatorName);
}
public int deleteDraft(long id, int version) {
allocations.delete(new LambdaQueryWrapper<OceanBookingContainerAllocationEntity>().eq(OceanBookingContainerAllocationEntity::getOceanBookingId, id));
requirements.delete(new LambdaQueryWrapper<OceanBookingContainerRequirementEntity>().eq(OceanBookingContainerRequirementEntity::getOceanBookingId, id));
parties.delete(new LambdaQueryWrapper<OceanBookingPartyEntity>().eq(OceanBookingPartyEntity::getOceanBookingId, id));
histories.delete(new LambdaQueryWrapper<OceanBookingStatusHistoryEntity>().eq(OceanBookingStatusHistoryEntity::getOceanBookingId, id));
return bookings.deleteDraft(id, version);
}
public int requestChange(long id, OceanBookingEntity pending, String reason, int version,
long operatorId, String operatorName) {
@@ -78,10 +83,6 @@ public class OceanBookingRepository {
public void replaceParties(long id, List<OceanBookingPartyEntity> values) { parties.delete(new LambdaQueryWrapper<OceanBookingPartyEntity>().eq(OceanBookingPartyEntity::getOceanBookingId, id)); values.forEach(parties::insert); }
public void replaceRequirements(long id, List<OceanBookingContainerRequirementEntity> values) { requirements.delete(new LambdaQueryWrapper<OceanBookingContainerRequirementEntity>().eq(OceanBookingContainerRequirementEntity::getOceanBookingId, id)); values.forEach(requirements::insert); }
public OceanBookingContainerRequirementEntity requirement(long id, long requirementId) { return requirements.selectOne(new LambdaQueryWrapper<OceanBookingContainerRequirementEntity>().eq(OceanBookingContainerRequirementEntity::getOceanBookingId, id).eq(OceanBookingContainerRequirementEntity::getId, requirementId)); }
public int syncReleasedTransport(long id, ReleaseRequest request, long operatorId, String operatorName) {
return containerBookings.syncReleasedTransport(id, request.billOfLadingNo().trim(), request.vesselName().trim(),
request.voyageNo().trim(), request.estimatedSailingAt(), operatorId, operatorName);
}
public boolean containerNoExists(String containerNo) {
return containerBookings.containerNoExists(containerNo, null);
}
@@ -6,7 +6,6 @@ import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.common.core.api.ResultCode;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.firstleg.client.WarehouseMasterDataClient;
import com.baiyue.firstleg.entity.ContainerBookingEntity;
import com.baiyue.firstleg.oceanbooking.domain.OceanBookingStatus;
import com.baiyue.firstleg.domain.ContainerSpecificationCatalog;
import com.baiyue.firstleg.oceanbooking.dto.OceanBookingDtos.*;
@@ -15,14 +14,12 @@ import com.baiyue.firstleg.oceanbooking.repository.OceanBookingRepository;
import com.baiyue.firstleg.repository.PriceBookQueryRepository;
import com.baiyue.firstleg.repository.PriceBookQueryRows.ChannelRow;
import com.baiyue.firstleg.repository.PriceBookQueryRows.ChannelTaxModeRow;
import com.baiyue.firstleg.service.LoadingPickService;
import com.baiyue.firstleg.service.WarehouseSupplierSnapshotService;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import jakarta.annotation.Resource;
import org.springframework.stereotype.Service;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.dao.DuplicateKeyException;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.util.StringUtils;
@@ -39,12 +36,10 @@ import java.util.Set;
public class OceanBookingService {
private static final Set<String> PARTY_TYPES = Set.of("SHIPPER", "CONSIGNEE", "NOTIFY_PARTY");
private static final Set<OceanBookingStatus> OPERATIONAL_LIST_STATUSES = Set.of(
OceanBookingStatus.CONFIRMED, OceanBookingStatus.CHANGE_PENDING,
OceanBookingStatus.RELEASED, OceanBookingStatus.CANCELLED);
OceanBookingStatus.DRAFT, OceanBookingStatus.RELEASED);
@Resource private OceanBookingRepository repository;
@Resource private PriceBookQueryRepository priceBooks;
@Resource private ObjectMapper objectMapper;
@Resource private LoadingPickService loadingPickService;
@Resource private WarehouseSupplierSnapshotService suppliers;
@Resource private WarehouseMasterDataClient warehouses;
@Value("${baiyue.security.internal-service-token:}") private String internalToken;
@@ -53,7 +48,7 @@ public class OceanBookingService {
public PageResult<OceanBookingEntity> page(Query query) {
String status = trim(query.status());
if (status != null && !OPERATIONAL_LIST_STATUSES.contains(parseStatus(status))) {
throw badRequest("订舱列表仅支持已确认、变更中、已放舱或已取消状态");
throw badRequest("订舱列表仅支持草稿或已放舱状态");
}
return repository.page(new Query(query.customerId(), status, trim(query.keyword()), query.page(), query.size()));
}
@@ -78,112 +73,74 @@ public class OceanBookingService {
public Detail create(long operatorId, String operatorName, String key, CreateRequest request) {
if (request.expectedVersion() != 0) throw concurrent();
validateDraftStructure(request.data());
validateRange(request.data().estimatedSailingAt(), request.data().estimatedArrivalAt());
String action = "CREATE";
String hash = hash(action, null, request);
Detail replay = claimOrReplay(null, operatorId, action, key, hash);
if (replay != null) return replay;
WarehouseMasterDataClient.Warehouse warehouse = requireDomesticTransitWarehouse(request.data().warehouseId());
ChannelOption channel = requireActiveChannel(warehouse.id(), request.data().channelCode());
var bookingAgent = suppliers.booking(request.data().bookingAgentId(), request.data().bookingAgentName());
request.data().containers().forEach(container -> {
if (repository.containerNoExists(container.containerNo().trim())) {
throw conflict("集装箱号已存在:" + container.containerNo().trim());
}
});
ResolvedDraft resolved = resolveDraft(request.data());
OceanBookingEntity value = new OceanBookingEntity();
value.setId(IdWorker.getId());
value.setBookingNo("OB" + value.getId());
// Child-row triggers still require DRAFT while the aggregate is assembled.
// The status transition is in the same transaction, so callers only observe CONFIRMED.
value.setStatus(OceanBookingStatus.DRAFT.name());
copy(value, request.data(), warehouse.warehouseName(), channel.channelName(),
bookingAgent == null ? null : bookingAgent.name());
copy(value, request.data(), resolved);
value.setCreatedBy(operatorId); value.setCreatedByName(operatorName);
value.setUpdatedBy(operatorId); value.setUpdatedByName(operatorName); value.setVersion(0);
repository.insert(value);
repository.replaceParties(value.getId(), parties(value.getId(), request.data().parties()));
List<OceanBookingContainerRequirementEntity> requirements = requirements(
value.getId(), request.data().containers());
value.getId(), request.data().containerRequirements());
repository.replaceRequirements(value.getId(), requirements);
if (repository.transition(value.getId(), OceanBookingStatus.DRAFT.name(),
OceanBookingStatus.CONFIRMED.name(), null, 0, operatorId, operatorName) != 1) {
throw concurrent();
}
createRealContainers(value, requirements, request.data().containers(), operatorId, operatorName);
OceanBookingEntity confirmed = required(value.getId());
addHistory(value.getId(), OceanBookingStatus.DRAFT.name(), OceanBookingStatus.CONFIRMED.name(), action,
null, value, confirmed, operatorId, operatorName);
return complete(value.getId(), operatorId, action, key, detail(confirmed));
OceanBookingEntity draft = required(value.getId());
addHistory(value.getId(), null, OceanBookingStatus.DRAFT.name(), action, null, null, draft, operatorId, operatorName);
return complete(value.getId(), operatorId, action, key, detail(draft));
}
@Transactional
public Detail updateDraft(long id, long operatorId, String operatorName, String key, UpdateRequest request) {
String action = "UPDATE_DRAFT";
Detail replay = claimOrReplay(id, operatorId, action, key, hash(action, id, request));
if (replay != null) return replay;
OceanBookingEntity before = requiredVersion(id, request.expectedVersion());
requireStatus(before, OceanBookingStatus.DRAFT);
validateDraftStructure(request.data());
ResolvedDraft resolved = resolveDraft(request.data());
copy(before, request.data(), resolved);
if (repository.updateDraft(before, request.expectedVersion(), operatorId, operatorName) != 1) throw concurrent();
repository.replaceParties(id, parties(id, request.data().parties()));
repository.replaceRequirements(id, requirements(id, request.data().containerRequirements()));
OceanBookingEntity after = required(id);
addHistory(id, OceanBookingStatus.DRAFT.name(), OceanBookingStatus.DRAFT.name(), action, null, before, after, operatorId, operatorName);
return complete(id, operatorId, action, key, detail(after));
}
@Deprecated public Detail cancel(long id, long operatorId, String name, String key, ActionRequest request) { throw conflict("订舱仅支持DRAFT和RELEASED,不能取消"); }
@Deprecated public Detail requestChange(long id, long operatorId, String name, String key, ChangeRequest request) { throw conflict("RELEASED订舱不可变更"); }
@Transactional
public void deleteDraft(long id, long operatorId, String operatorName, String key, ActionRequest request) {
String action = "DELETE_DRAFT";
Detail replay = claimOrReplay(id, operatorId, action, key, hash(action, id, request));
if (replay != null) return;
OceanBookingEntity before = requiredVersion(id, request.expectedVersion());
requireStatus(before, OceanBookingStatus.DRAFT);
if (repository.deleteDraft(id, request.expectedVersion()) != 1) throw concurrent();
complete(id, operatorId, action, key, new Detail(before, List.of(), List.of(), List.of(), List.of()));
}
@Transactional public Detail release(long id, long operatorId, String name, String key, ReleaseRequest request) {
validateRange(request.estimatedSailingAt(), request.estimatedArrivalAt());
String action = "RELEASE";
Detail replay = claimOrReplay(id, operatorId, action, key, hash(action, id, request));
if (replay != null) return replay;
OceanBookingEntity before = requiredVersion(id, request.expectedVersion());
requireStatus(before, OceanBookingStatus.CONFIRMED);
validateRegisteredTransport(before, request);
if (repository.release(id, request, operatorId, name) != 1) throw concurrent();
repository.syncReleasedTransport(id, request, operatorId, name);
repository.allocations(id).forEach(allocation ->
loadingPickService.beginIfReady(allocation.getContainerBookingId(), operatorId, name));
requireStatus(before, OceanBookingStatus.DRAFT);
validateRelease(before, repository.parties(id), repository.requirements(id));
if (repository.transition(id, OceanBookingStatus.DRAFT.name(), OceanBookingStatus.RELEASED.name(),
null, request.expectedVersion(), operatorId, name) != 1) throw concurrent();
OceanBookingEntity after = required(id);
addHistory(id, before.getStatus(), after.getStatus(), action, request.reason(), before, after,
operatorId, name);
return complete(id, operatorId, action, key, detail(after));
}
@Transactional
public Detail cancel(long id, long operatorId, String name, String key, ActionRequest request) {
if (!StringUtils.hasText(request.reason())) throw badRequest("取消原因不能为空");
String action = "CANCEL";
Detail replay = claimOrReplay(id, operatorId, action, key, hash(action, id, request));
if (replay != null) return replay;
OceanBookingEntity before = requiredVersion(id, request.expectedVersion());
OceanBookingStatus from = parseStatus(before.getStatus());
if (from == OceanBookingStatus.RELEASED || from == OceanBookingStatus.CANCELLED) {
throw conflict("订舱已越过不可逆节点或已经取消");
}
if (repository.transition(id, from.name(), OceanBookingStatus.CANCELLED.name(),
trim(request.reason()), request.expectedVersion(), operatorId, name) != 1) throw concurrent();
OceanBookingEntity after = required(id);
addHistory(id, from.name(), after.getStatus(), action, request.reason(), before, after, operatorId, name);
return complete(id, operatorId, action, key, detail(after));
}
@Transactional
public Detail requestChange(long id, long operatorId, String name, String key, ChangeRequest request) {
validateRange(request.estimatedSailingAt(), request.estimatedArrivalAt());
String action = "REQUEST_CHANGE";
Detail replay = claimOrReplay(id, operatorId, action, key, hash(action, id, request));
if (replay != null) return replay;
OceanBookingEntity before = requiredVersion(id, request.expectedVersion());
requireStatus(before, OceanBookingStatus.CONFIRMED);
OceanBookingEntity pending = new OceanBookingEntity();
pending.setPendingVesselName(request.vesselName().trim());
pending.setPendingVoyageNo(request.voyageNo().trim());
pending.setPendingEstimatedSailingAt(request.estimatedSailingAt());
pending.setPendingEstimatedArrivalAt(request.estimatedArrivalAt());
if (repository.requestChange(id, pending, request.reason().trim(), request.expectedVersion(), operatorId, name) != 1) throw concurrent();
OceanBookingEntity after = required(id);
addHistory(id, before.getStatus(), after.getStatus(), action, request.reason(), before, after, operatorId, name);
return complete(id, operatorId, action, key, detail(after));
}
@Transactional
public Detail confirmChange(long id, long operatorId, String name, String key, ActionRequest request) {
String action = "CONFIRM_CHANGE";
Detail replay = claimOrReplay(id, operatorId, action, key, hash(action, id, request));
if (replay != null) return replay;
OceanBookingEntity before = requiredVersion(id, request.expectedVersion());
requireStatus(before, OceanBookingStatus.CHANGE_PENDING);
if (repository.confirmChange(id, request.expectedVersion(), operatorId, name) != 1) throw concurrent();
OceanBookingEntity after = required(id);
addHistory(id, before.getStatus(), after.getStatus(), action, request.reason(), before, after, operatorId, name);
return complete(id, operatorId, action, key, detail(after));
}
private Detail claimOrReplay(Long id, long operatorId, String action, String key, String hash) {
@@ -214,7 +171,7 @@ public class OceanBookingService {
history.setOperatorId(operatorId); history.setOperatorName(operatorName); repository.insert(history);
}
private Detail detail(OceanBookingEntity value) { return new Detail(value, repository.parties(value.getId()), repository.containers(value.getId()), repository.histories(value.getId())); }
private Detail detail(OceanBookingEntity value) { return new Detail(value, repository.parties(value.getId()), repository.requirements(value.getId()), repository.containers(value.getId()), repository.histories(value.getId())); }
private OceanBookingEntity required(long id) { OceanBookingEntity value = repository.find(id); if (value == null) throw new BizException(ResultCode.NOT_FOUND, "订舱单不存在"); return value; }
private OceanBookingEntity requiredVersion(long id, int version) { OceanBookingEntity value = required(id); if (!Integer.valueOf(version).equals(value.getVersion())) throw concurrent(); return value; }
private static void requireStatus(OceanBookingEntity value, OceanBookingStatus status) { if (!status.name().equals(value.getStatus())) throw conflict("订舱状态不允许当前操作"); }
@@ -223,37 +180,31 @@ public class OceanBookingService {
boolean hasCustomerId = data.customerId() != null;
boolean hasCustomerName = StringUtils.hasText(data.customerName());
if (hasCustomerId != hasCustomerName) throw badRequest("客户ID和客户名称必须同时填写或同时为空");
if (!StringUtils.hasText(data.serviceTerm()) || !StringUtils.hasText(data.freightTerm())) {
throw badRequest("Service Term和Freight Term不能为空");
}
if (!StringUtils.hasText(data.billOfLadingNo()) || !StringUtils.hasText(data.vesselName())
|| !StringUtils.hasText(data.voyageNo()) || data.estimatedSailingAt() == null
|| data.estimatedArrivalAt() == null) {
throw badRequest("提单号、船名、航次、ETD和ETA不能为空");
}
Set<String> types = new HashSet<>();
for (PartyRequest party : data.parties()) {
if (data.parties() != null) {
Set<String> types = new HashSet<>();
for (PartyRequest party : data.parties()) {
String type = party.partyType().trim().toUpperCase();
if (!PARTY_TYPES.contains(type) || !types.add(type)) throw badRequest("订舱方必须各包含一条SHIPPER、CONSIGNEE和NOTIFY_PARTY");
}
}
if (!types.equals(PARTY_TYPES)) throw badRequest("订舱方必须各包含一条SHIPPER、CONSIGNEE和NOTIFY_PARTY");
Set<String> containerNos = new HashSet<>();
for (ContainerRequest container : data.containers()) {
String containerNo = container.containerNo().trim();
if (!containerNos.add(containerNo)) throw badRequest("同一订舱内集装箱号不能重复");
ContainerSpecificationCatalog.requireMatch(container.containerType(),
container.capacityCbm(), container.maxWeightKg());
if (data.containerRequirements() != null) {
for (ContainerRequirementRequest container : data.containerRequirements()) {
ContainerSpecificationCatalog.requireMatch(container.containerType(), container.capacityCbm(), container.maxWeightKg());
}
}
}
private static void validateRange(LocalDateTime from, LocalDateTime to) { if (from != null && to != null && to.isBefore(from)) throw badRequest("预计到港时间不能早于预计开船时间"); }
private static void validateRegisteredTransport(OceanBookingEntity booking, ReleaseRequest request) {
if (!java.util.Objects.equals(trim(booking.getBillOfLadingNo()), trim(request.billOfLadingNo()))
|| !java.util.Objects.equals(trim(booking.getVesselName()), trim(request.vesselName()))
|| !java.util.Objects.equals(trim(booking.getVoyageNo()), trim(request.voyageNo()))
|| !java.util.Objects.equals(booking.getEstimatedSailingAt(), request.estimatedSailingAt())
|| !java.util.Objects.equals(booking.getEstimatedArrivalAt(), request.estimatedArrivalAt())) {
throw conflict("放舱运输字段必须与已登记的提单、船名、航次、ETD和ETA一致");
}
private static void validateRelease(OceanBookingEntity booking, List<OceanBookingPartyEntity> parties,
List<OceanBookingContainerRequirementEntity> requirements) {
if (booking.getCustomerId() == null || !StringUtils.hasText(booking.getCustomerName())
|| !StringUtils.hasText(booking.getChannelCode()) || booking.getWarehouseId() == null
|| !StringUtils.hasText(booking.getBillOfLadingNo()) || !StringUtils.hasText(booking.getVesselName())
|| !StringUtils.hasText(booking.getVoyageNo()) || !StringUtils.hasText(booking.getServiceTerm())
|| !StringUtils.hasText(booking.getFreightTerm()) || booking.getEstimatedSailingAt() == null
|| booking.getEstimatedArrivalAt() == null || booking.getShippingCompanyId() == null
|| booking.getBookingAgentId() == null || parties == null || parties.size() != 3
|| requirements == null || requirements.isEmpty()) throw badRequest("订舱资料尚未填写完整,无法发布");
validateRange(booking.getEstimatedSailingAt(), booking.getEstimatedArrivalAt());
}
private ChannelOption requireActiveChannel(Long originWarehouseId, String channelCode) {
if (originWarehouseId == null || originWarehouseId <= 0) throw badRequest("请选择有效起运仓");
@@ -277,15 +228,28 @@ public class OceanBookingService {
return value.trim();
}
private static void copy(OceanBookingEntity v, DraftData d, String resolvedWarehouseName,
String resolvedChannelName, String resolvedBookingAgentName) {
private record ResolvedDraft(String warehouseName, String channelName, String bookingAgentName) {}
private ResolvedDraft resolveDraft(DraftData d) {
String warehouseName = null; String channelName = null; String bookingAgentName = trim(d.bookingAgentName());
if (d.warehouseId() != null && StringUtils.hasText(d.channelCode())) {
WarehouseMasterDataClient.Warehouse warehouse = requireDomesticTransitWarehouse(d.warehouseId());
warehouseName = warehouse.warehouseName();
channelName = requireActiveChannel(warehouse.id(), d.channelCode()).channelName();
}
if (d.bookingAgentId() != null) {
var snapshot = suppliers.booking(d.bookingAgentId(), d.bookingAgentName());
bookingAgentName = snapshot == null ? null : snapshot.name();
} else if (StringUtils.hasText(d.bookingAgentName())) throw badRequest("订舱代理名称不能脱离供应商ID提交");
return new ResolvedDraft(warehouseName, channelName, bookingAgentName);
}
private static void copy(OceanBookingEntity v, DraftData d, ResolvedDraft resolved) {
v.setCustomerId(d.customerId()); v.setCustomerName(trim(d.customerName()));
v.setChannelCode(d.channelCode().trim()); v.setChannelName(resolvedChannelName);
v.setWarehouseId(d.warehouseId()); v.setWarehouseName(resolvedWarehouseName);
v.setBillOfLadingNo(d.billOfLadingNo().trim());
v.setChannelCode(trim(d.channelCode())); v.setChannelName(resolved.channelName());
v.setWarehouseId(d.warehouseId()); v.setWarehouseName(resolved.warehouseName());
v.setBillOfLadingNo(trim(d.billOfLadingNo()));
v.setShippingCompanyId(d.shippingCompanyId()); v.setShippingCompanyName(trim(d.shippingCompanyName()));
v.setBookingAgentId(d.bookingAgentId()); v.setBookingAgentName(trim(resolvedBookingAgentName));
v.setVesselName(d.vesselName().trim()); v.setVoyageNo(d.voyageNo().trim());
v.setBookingAgentId(d.bookingAgentId()); v.setBookingAgentName(resolved.bookingAgentName());
v.setVesselName(trim(d.vesselName())); v.setVoyageNo(trim(d.voyageNo()));
v.setDeparturePortCode(trim(d.departurePortCode())); v.setDeparturePortName(trim(d.departurePortName()));
v.setDestinationPortCode(trim(d.destinationPortCode())); v.setDestinationPortName(trim(d.destinationPortName()));
v.setPlaceOfDelivery(trim(d.placeOfDelivery())); v.setServiceTerm(trim(d.serviceTerm())); v.setFreightTerm(trim(d.freightTerm()));
@@ -293,15 +257,12 @@ public class OceanBookingService {
v.setRemark(trim(d.remark()));
}
private static List<OceanBookingPartyEntity> parties(long id, List<PartyRequest> requests) { return requests.stream().map(r -> { OceanBookingPartyEntity v = new OceanBookingPartyEntity(); v.setId(IdWorker.getId()); v.setOceanBookingId(id); v.setPartyType(r.partyType().trim().toUpperCase()); v.setPartyName(r.partyName().trim()); v.setAddress(trim(r.address())); v.setContactName(trim(r.contactName())); v.setContactPhone(trim(r.contactPhone())); return v; }).toList(); }
private static List<OceanBookingContainerRequirementEntity> requirements(long id, List<ContainerRequest> requests) { int[] sort = {0}; return requests.stream().map(r -> { var specification = ContainerSpecificationCatalog.requireMatch(r.containerType(), r.capacityCbm(), r.maxWeightKg()); OceanBookingContainerRequirementEntity v = new OceanBookingContainerRequirementEntity(); v.setId(IdWorker.getId()); v.setOceanBookingId(id); v.setContainerType(specification.containerType()); v.setContainerKind(r.containerKind().trim().toUpperCase()); v.setCapacityCbm(specification.capacityCbm()); v.setMaxWeightKg(specification.maxWeightKg()); v.setQuantity(1); v.setSortNo(sort[0]++); return v; }).toList(); }
private void createRealContainers(OceanBookingEntity booking,
List<OceanBookingContainerRequirementEntity> requirements,
List<ContainerRequest> requests, long operatorId,
String operatorName) {
private static List<OceanBookingPartyEntity> parties(long id, List<PartyRequest> requests) { if (requests == null) return List.of(); return requests.stream().map(r -> { OceanBookingPartyEntity v = new OceanBookingPartyEntity(); v.setId(IdWorker.getId()); v.setOceanBookingId(id); v.setPartyType(r.partyType().trim().toUpperCase()); v.setPartyName(r.partyName().trim()); v.setAddress(trim(r.address())); v.setContactName(trim(r.contactName())); v.setContactPhone(trim(r.contactPhone())); return v; }).toList(); }
private static List<OceanBookingContainerRequirementEntity> requirements(long id, List<ContainerRequirementRequest> requests) { if (requests == null) return List.of(); int[] sort = {0}; return requests.stream().map(r -> { var specification = ContainerSpecificationCatalog.requireMatch(r.containerType(), r.capacityCbm(), r.maxWeightKg()); OceanBookingContainerRequirementEntity v = new OceanBookingContainerRequirementEntity(); v.setId(IdWorker.getId()); v.setOceanBookingId(id); v.setContainerType(specification.containerType()); v.setContainerKind(r.containerKind().trim().toUpperCase()); v.setCapacityCbm(specification.capacityCbm()); v.setMaxWeightKg(specification.maxWeightKg()); v.setQuantity(r.quantity()); v.setSortNo(sort[0]++); return v; }).toList(); }
/* real container rows are created only by direct container linking after release. */
/*
for (int index = 0; index < requests.size(); index++) {
ContainerRequest request = requests.get(index);
ContainerRequirementRequest request = requests.get(index);
OceanBookingContainerRequirementEntity requirement = requirements.get(index);
ContainerBookingEntity container = new ContainerBookingEntity();
container.setId(IdWorker.getId()); container.setBookingNo("CB" + container.getId());
@@ -340,6 +301,7 @@ public class OceanBookingService {
repository.insert(allocation);
}
}
*/
private WarehouseMasterDataClient.Warehouse requireDomesticTransitWarehouse(long warehouseId) {
try {
@@ -47,6 +47,11 @@ public class ContainerRepository {
long operatorId, String operatorName) {
return mapper.restoreMergedSource(sourceId, targetId, containerNo, operatorId, operatorName);
}
public int linkReleasedBooking(long id, long oceanBookingId, long requirementId, String containerNo,
com.baiyue.firstleg.oceanbooking.entity.OceanBookingEntity booking,
int version, long operatorId, String operatorName) {
return mapper.linkReleasedBooking(id, oceanBookingId, requirementId, containerNo, booking.getBillOfLadingNo(), booking.getVesselName(), booking.getVoyageNo(), booking.getShippingCompanyId(), booking.getShippingCompanyName(), booking.getDeparturePortCode(), booking.getDeparturePortName(), booking.getDestinationPortCode(), booking.getDestinationPortName(), booking.getChannelCode(), booking.getChannelName(), booking.getCustomerId(), booking.getCustomerName(), booking.getEstimatedSailingAt(), booking.getEstimatedArrivalAt(), version, operatorId, operatorName);
}
public int updateDraft(ContainerBookingEntity value, int version, long operatorId, String operatorName) {
return mapper.updateDraft(value, version, operatorId, operatorName);
}
@@ -1,7 +1,7 @@
package com.baiyue.firstleg.repository;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page; import com.baiyue.common.core.api.PageResult; import com.baiyue.firstleg.dto.TruckAppointmentDtos.Query; import com.baiyue.firstleg.entity.*; import com.baiyue.firstleg.mapper.*; import jakarta.annotation.Resource; import org.springframework.stereotype.Repository;
@Repository public class TruckAppointmentRepository { @Resource TruckAppointmentMapper mapper; @Resource TruckAppointmentHistoryMapper history; @Resource TruckAppointmentAttachmentMapper attachments; @Resource TruckAppointmentOperationMapper operations;
public PageResult<TruckAppointmentEntity> page(Query q){return PageResult.from(mapper.page(new Page<>(q.page(),q.size()),q));} public TruckAppointmentEntity find(long id){return mapper.selectById(id);} public void insert(TruckAppointmentEntity e){mapper.insert(e);} public int transition(long id,String from,String to,int v){return mapper.transition(id,from,to,v);} public int reschedule(long id,java.time.LocalDateTime at,String reason,int v){return mapper.reschedule(id,at,reason,v);} public void history(TruckAppointmentHistoryEntity e){history.insert(e);} public void operation(TruckAppointmentOperationEntity e){operations.insert(e);} public void attachment(TruckAppointmentAttachmentEntity e){attachments.insert(e);}
public PageResult<TruckAppointmentEntity> page(Query q){return PageResult.from(mapper.page(new Page<>(q.page(),q.size()),q));} public TruckAppointmentEntity find(long id){return mapper.selectById(id);} public void insert(TruckAppointmentEntity e){mapper.insert(e);} public int transition(long id,String from,String to,int v){return mapper.transition(id,from,to,v);} public int dispatch(long id,String from,String billOfLadingNo,int v){return mapper.dispatch(id,from,billOfLadingNo,v);} public int reschedule(long id,java.time.LocalDateTime at,String reason,int v){return mapper.reschedule(id,at,reason,v);} public void history(TruckAppointmentHistoryEntity e){history.insert(e);} public void operation(TruckAppointmentOperationEntity e){operations.insert(e);} public void attachment(TruckAppointmentAttachmentEntity e){attachments.insert(e);}
public boolean claimOperation(long operationId,long appointmentId,String action,String key,String hash){return operations.claim(operationId,appointmentId,action,key,hash)==1;} public TruckAppointmentOperationEntity operation(String key){return operations.find(key);} public int completeOperation(long appointmentId,String action,String key,String response){return operations.complete(appointmentId,action,key,response);}
public int updateEdit(TruckAppointmentEntity e,int v){return mapper.updateEdit(e,v);} public java.util.List<TruckAppointmentHistoryEntity> histories(long id){return history.selectList(new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper<TruckAppointmentHistoryEntity>().eq(TruckAppointmentHistoryEntity::getAppointmentId,id).orderByAsc(TruckAppointmentHistoryEntity::getCreatedAt));} public java.util.List<TruckAppointmentAttachmentEntity> attachments(long id){return attachments.selectList(new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper<TruckAppointmentAttachmentEntity>().eq(TruckAppointmentAttachmentEntity::getAppointmentId,id).orderByAsc(TruckAppointmentAttachmentEntity::getUploadedAt));} public int deleteAttachment(long appointmentId,long attachmentId){return attachments.delete(new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper<TruckAppointmentAttachmentEntity>().eq(TruckAppointmentAttachmentEntity::getAppointmentId,appointmentId).eq(TruckAppointmentAttachmentEntity::getId,attachmentId));}
public java.util.List<TruckAppointmentEntity> activeForUpdate(long containerBookingId){return mapper.activeForUpdate(containerBookingId);} public int completeLoadingFromPda(long id,String fromStatus,int version){return mapper.completeLoadingFromPda(id,fromStatus,version);}
@@ -224,7 +224,6 @@ public class CartonMeasurementService {
item.setLengthCm(command.lengthCm());
item.setWidthCm(command.widthCm());
item.setHeightCm(command.heightCm());
item.setUnitWeightKg(command.unitWeightKg());
item.setSourceImageFileId(imageFileId);
item.setMachineNo(command.machineNo().trim());
item.setDeviceMeasuredAt(command.measuredAt());
@@ -277,13 +276,9 @@ public class CartonMeasurementService {
BigDecimal totalVolume = unitVolume.multiply(count).setScale(4, RoundingMode.UNNECESSARY);
item.setGirthCm(item.getLengthCm().add(item.getWidthCm().add(item.getHeightCm())
.multiply(BigDecimal.valueOf(2))).setScale(3, RoundingMode.CEILING));
item.setUnitWeightKg(item.getUnitWeightKg() == null ? null
: item.getUnitWeightKg().setScale(3, RoundingMode.CEILING));
item.setUnitVolumeCbm(unitVolume);
item.setVolumeRawCbm(totalRaw);
item.setVolumeCbm(totalVolume);
item.setTotalWeightKg(item.getUnitWeightKg() == null ? null
: item.getUnitWeightKg().multiply(count).setScale(3, RoundingMode.CEILING));
item.setTotalVolumeCbm(totalVolume);
item.setVolumeWeightKg(totalVolume.multiply(VOLUME_WEIGHT_FACTOR).setScale(3, RoundingMode.CEILING));
item.setCalculationRuleVersion(weighted.calculationRuleVersion());
@@ -307,8 +302,6 @@ public class CartonMeasurementService {
.setScale(4, RoundingMode.UNNECESSARY);
item.setVolumeRawCbm(totalRaw);
item.setVolumeCbm(totalVolume);
item.setTotalWeightKg(item.getUnitWeightKg() == null ? null
: item.getUnitWeightKg().multiply(count).setScale(3, RoundingMode.CEILING));
item.setTotalVolumeCbm(totalVolume);
item.setVolumeWeightKg(totalVolume.multiply(VOLUME_WEIGHT_FACTOR).setScale(3, RoundingMode.CEILING));
}
@@ -416,9 +409,6 @@ public class CartonMeasurementService {
if (!positive(command.lengthCm()) || !positive(command.widthCm()) || !positive(command.heightCm())) {
throw new BizException(ResultCode.VALIDATION_FAILED, "长、宽、高必须大于0");
}
if (command.unitWeightKg() != null && !positive(command.unitWeightKg())) {
throw new BizException(ResultCode.VALIDATION_FAILED, "单箱重量必须大于0");
}
if (!StringUtils.hasText(command.machineNo()) || command.machineNo().trim().length() > 64) {
throw new BizException(ResultCode.VALIDATION_FAILED, "设备编号不能为空且不能超过64个字符");
}
@@ -452,7 +442,7 @@ public class CartonMeasurementService {
return new CartonMeasurementResult(measurement.getId(), measurement.getVersion(), item.getId(),
item.getBoxCode(), item.getScannedShipmentId(), item.getPackageCount(),
item.getLengthCm(), item.getWidthCm(),
item.getHeightCm(), item.getUnitWeightKg(), item.getSourceImageFileId(), item.getMachineNo(),
item.getHeightCm(), item.getSourceImageFileId(), item.getMachineNo(),
item.getDeviceMeasuredAt(), created);
}
@@ -27,6 +27,7 @@ import com.baiyue.firstleg.repository.PriceBookQueryRows.ChannelRow;
import com.baiyue.firstleg.oceanbooking.repository.OceanBookingRepository;
import com.baiyue.firstleg.oceanbooking.entity.OceanBookingEntity;
import com.baiyue.firstleg.oceanbooking.entity.OceanBookingContainerRequirementEntity;
import com.baiyue.firstleg.oceanbooking.entity.OceanBookingContainerAllocationEntity;
import jakarta.annotation.Resource;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
@@ -79,27 +80,13 @@ public class ContainerService {
public ContainerBookingEntity linkOceanBooking(long id, long operatorId, String operatorName, OceanBookingLinkRequest request) {
ContainerBookingEntity container = required(id); requireVersion(container, request.expectedVersion()); requireFirstLinkMutable(container);
if (container.getOceanBookingId() != null) throw conflict("模拟柜已关联真实订舱柜");
ContainerBookingEntity source = repository.findForUpdate(request.realContainerBookingId());
if (source == null || !"OCEAN_BOOKING".equals(source.getSourceType())
|| !"ACTIVE".equals(source.getRecordStatus()) || source.getMergedIntoContainerBookingId() != null
|| source.getContainerNo() == null || source.getOceanBookingId() == null
|| source.getOceanBookingRequirementId() == null) {
throw conflict("真实订舱柜不存在、已被消费或不可关联");
}
if (repository.hasLoadingPlan(source.getId()) || repository.hasPickTask(source.getId())
|| repository.hasEvidence(source.getId()) || repository.hasDocuments(source.getId())) {
throw conflict("真实订舱柜已有装柜计划、PDA、证据或单证事实,不能被模拟柜消费");
}
OceanBookingEntity booking = oceanBookings.find(source.getOceanBookingId());
if (booking == null || !("CONFIRMED".equals(booking.getStatus()) || "RELEASED".equals(booking.getStatus()))) throw conflict("只能关联已确认的真实订舱柜");
OceanBookingContainerRequirementEntity requirement = oceanBookings.requirement(
booking.getId(), source.getOceanBookingRequirementId());
if (requirement == null) throw new BizException(ResultCode.NOT_FOUND, "真实订舱柜箱型分配不存在");
var allocation = oceanBookings.allocationByContainer(source.getId());
if (allocation == null || !java.util.Objects.equals(allocation.getOceanBookingId(), booking.getId())
|| !java.util.Objects.equals(allocation.getRequirementId(), requirement.getId())) {
throw conflict("真实订舱柜分配关系不完整");
}
OceanBookingEntity booking = oceanBookings.find(request.oceanBookingId());
if (booking == null || !"RELEASED".equals(booking.getStatus())) throw conflict("只能关联已发布订舱");
OceanBookingContainerRequirementEntity requirement = oceanBookings.requirement(request.oceanBookingId(), request.requirementId());
if (requirement == null) throw new BizException(ResultCode.NOT_FOUND, "订舱箱型需求不存在");
long allocated = oceanBookings.allocations(request.oceanBookingId()).stream().filter(a -> request.requirementId().equals(a.getRequirementId())).count();
if (allocated >= requirement.getQuantity()) throw conflict("该订舱箱型需求已达到数量上限");
if (repository.containerNoExists(request.containerNo().trim(), id)) throw conflict("集装箱号已存在");
if (!java.util.Objects.equals(container.getWarehouseId(), booking.getWarehouseId())
|| !java.util.Objects.equals(container.getChannelCode(), booking.getChannelCode())
|| !java.util.Objects.equals(container.getContainerType(), requirement.getContainerType())
@@ -107,21 +94,12 @@ public class ContainerService {
|| container.getMaxWeightKg() == null || requirement.getMaxWeightKg() == null
|| container.getCapacityCbm().compareTo(requirement.getCapacityCbm()) != 0
|| container.getMaxWeightKg().compareTo(requirement.getMaxWeightKg()) != 0) throw conflict("模拟柜与真实订舱柜仓库、渠道、箱型或容量不一致");
if (!java.util.Objects.equals(source.getWarehouseId(), booking.getWarehouseId())
|| !java.util.Objects.equals(source.getChannelCode(), booking.getChannelCode())
|| !java.util.Objects.equals(source.getContainerType(), requirement.getContainerType())
|| source.getCapacityCbm() == null || source.getMaxWeightKg() == null
|| source.getCapacityCbm().compareTo(requirement.getCapacityCbm()) != 0
|| source.getMaxWeightKg().compareTo(requirement.getMaxWeightKg()) != 0) {
throw conflict("真实订舱柜仓库、渠道或规格快照不一致");
}
if (booking.getCustomerId() != null && repository.hasMismatchedCustomerItems(id, booking.getCustomerId())) throw conflict("模拟柜已排货客户与真实订舱客户范围不一致");
if (repository.consumeRealSource(source.getId(), id, operatorId, operatorName) != 1
|| repository.mergeRealSource(id, source.getId(), source.getContainerNo(),
request.expectedVersion(), operatorId, operatorName) != 1
|| oceanBookings.reassignAllocation(source.getId(), id) != 1) {
if (repository.linkReleasedBooking(id, request.oceanBookingId(), request.requirementId(), request.containerNo().trim(), booking,
request.expectedVersion(), operatorId, operatorName) != 1) {
throw conflict("真实订舱柜已被消费或货柜版本发生变化,请刷新后重试");
}
OceanBookingContainerAllocationEntity allocation = new OceanBookingContainerAllocationEntity(); allocation.setId(IdWorker.getId()); allocation.setOceanBookingId(request.oceanBookingId()); allocation.setRequirementId(request.requirementId()); allocation.setContainerBookingId(id); allocation.setAllocatedBy(operatorId); allocation.setAllocatedByName(operatorName); allocation.setAllocatedAt(LocalDateTime.now()); oceanBookings.insert(allocation);
return required(id);
}
@@ -259,13 +259,9 @@ public class InboundMeasurementOpenApiService {
item.setHeightCm(source.heightCm());
item.setGirthCm(source.lengthCm().add(source.widthCm().add(source.heightCm())
.multiply(BigDecimal.valueOf(2))).setScale(3, RoundingMode.CEILING));
item.setUnitWeightKg(source.unitWeightKg() == null ? null
: source.unitWeightKg().setScale(3, RoundingMode.CEILING));
item.setUnitVolumeCbm(unitVolume);
item.setVolumeRawCbm(totalRaw);
item.setVolumeCbm(totalVolume);
item.setTotalWeightKg(source.unitWeightKg() == null ? null
: source.unitWeightKg().multiply(count).setScale(3, RoundingMode.CEILING));
item.setTotalVolumeCbm(totalVolume);
item.setVolumeWeightKg(totalVolume.multiply(VOLUME_WEIGHT_FACTOR).setScale(3, RoundingMode.CEILING));
item.setCalculationRuleVersion(CALCULATION_RULE_VERSION);
@@ -108,7 +108,7 @@ public class InboundMeasurementService {
actualGoodsName,
operatorId, request.items().stream().map(item -> new MeasurementBatchCommand.Item(
item.inboundDetailId(), item.itemNo(), item.boxSpecification(), item.packageCount(), item.lengthCm(),
item.widthCm(), item.heightCm(), item.unitWeightKg(), item.intelligentImageFileId(),
item.widthCm(), item.heightCm(), item.intelligentImageFileId(),
item.overLimitWarning(), item.outerLengthCm(), item.outerWidthCm(), item.outerHeightCm(),
item.remark())).toList());
InboundMeasurementEntity saved = batchService.saveBatch(command);
@@ -488,7 +488,7 @@ public class InboundMeasurementService {
return new InboundMeasurementResponses.Item(
item.getId(), item.getInboundDetailId(), item.getItemNo(), item.getBoxSpecification(), item.getPackageCount(),
item.getLengthCm(), item.getWidthCm(), item.getHeightCm(), item.getGirthCm(),
item.getUnitWeightKg(), item.getUnitVolumeCbm(), item.getTotalWeightKg(),
item.getUnitVolumeCbm(),
item.getTotalVolumeCbm(), item.getVolumeWeightKg(), item.getSourceImageFileId(),
item.getOverLimitWarning(), item.getOuterLengthCm(), item.getOuterWidthCm(),
item.getOuterHeightCm(), item.getRemark(), item.getBoxCode(), item.getScannedShipmentId(),
@@ -152,13 +152,9 @@ public class MeasurementBatchService {
item.setHeightCm(source.heightCm());
item.setGirthCm(source.lengthCm().add(source.widthCm().add(source.heightCm())
.multiply(BigDecimal.valueOf(2))).setScale(3, RoundingMode.CEILING));
item.setUnitWeightKg(source.unitWeightKg() == null ? null
: source.unitWeightKg().setScale(3, RoundingMode.CEILING));
item.setUnitVolumeCbm(unitVolume);
item.setVolumeRawCbm(totalVolumeRaw);
item.setVolumeCbm(totalVolume);
item.setTotalWeightKg(source.unitWeightKg() == null ? null
: source.unitWeightKg().multiply(packageCount).setScale(3, RoundingMode.CEILING));
item.setTotalVolumeCbm(totalVolume);
item.setVolumeWeightKg(totalVolume.multiply(VOLUME_WEIGHT_FACTOR).setScale(3, RoundingMode.CEILING));
item.setCalculationRuleVersion(weighted.calculationRuleVersion());
@@ -69,8 +69,8 @@ public class MerchantInboundBillingService {
var measurementItems = measurement.items().stream()
.map(item -> new MerchantInboundBillingResponse.MeasurementItem(
item.id(), item.itemNo(), item.boxSpecification(), item.packageCount(),
item.lengthCm(), item.widthCm(), item.heightCm(), item.unitWeightKg(),
item.totalWeightKg(), item.unitVolumeCbm(), item.totalVolumeCbm(), item.volumeWeightKg()))
item.lengthCm(), item.widthCm(), item.heightCm(), item.unitVolumeCbm(),
item.totalVolumeCbm(), item.volumeWeightKg()))
.toList();
var weightResults = measurement.weightResults().stream()
.map(weight -> new MerchantInboundBillingResponse.WeightResult(
@@ -77,7 +77,7 @@ public class MojiaMeasurementOpenApiService {
try {
result = cartonMeasurements.upsert(
app.tenantId(), app.subjectId(), new CartonMeasurementCommand(
request.boxCode(), request.length(), request.width(), request.height(), request.weight(),
request.boxCode(), request.length(), request.width(), request.height(),
request.machineNo(), request.measuredAt() == null ? LocalDateTime.now() : request.measuredAt(),
image, optionalTrim(request.shipmentId())));
} catch (BizException exception) {
@@ -87,10 +87,10 @@ public class MojiaMeasurementOpenApiService {
throw exception;
}
log.info("MOJIA_MEASUREMENT accepted requestId={} traceId={} boxCode={} machineNo={} "
+ "lengthCm={} widthCm={} heightCm={} weightKg={} imageBytes={} measurementId={} "
+ "lengthCm={} widthCm={} heightCm={} imageBytes={} measurementId={} "
+ "itemId={} created={}",
correlation("requestId"), correlation("traceId"), safe(request.boxCode()),
safe(request.machineNo()), request.length(), request.width(), request.height(), request.weight(),
safe(request.machineNo()), request.length(), request.width(), request.height(),
image.getSize(), result.measurementId(), result.itemId(), result.created());
return new UploadResponse(true, true, null, result.measurementId(), result.itemId(),
result.boxCode(), result.imageFileId(), result.created());
@@ -121,7 +121,6 @@ public class MojiaMeasurementOpenApiService {
receipt.setLengthCm(request.length());
receipt.setWidthCm(request.width());
receipt.setHeightCm(request.height());
receipt.setWeightKg(request.weight());
receipt.setMachineNo(request.machineNo().trim());
receipt.setMeasuredAt(request.measuredAt() == null ? LocalDateTime.now() : request.measuredAt());
receipt.setImageFileId(uploaded.fileId());
@@ -124,7 +124,8 @@ public class SmartLoadingService {
try { repository.insert(item); } catch (DuplicateKeyException e) { throw conflict("货物明细已被并发锁定"); }
changed = item;
} else {
existing.setPackageCount(total); existing.setGrossWeightKg(unit(candidate.unitWeightKg()).multiply(BigDecimal.valueOf(total)));
existing.setPackageCount(total); existing.setGrossWeightKg(unit(candidate.unitGrossWeightKg()).multiply(BigDecimal.valueOf(total)));
existing.setNetWeightKg(unit(candidate.unitNetWeightKg()).multiply(BigDecimal.valueOf(total)));
existing.setVolumeCbm(unit(candidate.unitVolumeCbm()).multiply(BigDecimal.valueOf(total))); applyCargo(existing, cargo, total);
existing.setUpdatedBy(operatorId); existing.setUpdatedByName(operatorName);
if (repository.update(existing) != 1) throw conflict("装柜草稿已被并发修改");
@@ -197,7 +198,8 @@ public class SmartLoadingService {
checkRouteAndCapacity(booking, others, candidate, request.packageCount());
touch(plan, operatorId, operatorName);
item.setPackageCount(request.packageCount());
item.setGrossWeightKg(unit(candidate.unitWeightKg()).multiply(BigDecimal.valueOf(request.packageCount())));
item.setGrossWeightKg(unit(candidate.unitGrossWeightKg()).multiply(BigDecimal.valueOf(request.packageCount())));
item.setNetWeightKg(unit(candidate.unitNetWeightKg()).multiply(BigDecimal.valueOf(request.packageCount())));
item.setVolumeCbm(unit(candidate.unitVolumeCbm()).multiply(BigDecimal.valueOf(request.packageCount()))); applyCargo(item, cargo, request.packageCount());
item.setUpdatedBy(operatorId); item.setUpdatedByName(operatorName);
if (repository.update(item) != 1) throw conflict("装柜明细已被并发修改");
@@ -292,7 +294,9 @@ public class SmartLoadingService {
i.setInboundId(c.inboundId()); i.setInboundDetailId(c.inboundDetailId()); i.setOrderId(c.orderId()); i.setOrderNo(c.orderNo()); i.setInboundCode(c.inboundCode());
i.setDestinationWarehouseId(c.destinationWarehouseId()); i.setDestinationWarehouseCode(c.destinationWarehouseCode());
i.setDestinationWarehouseName(c.destinationWarehouseName()); i.setProductNameZh(cargo.cargoName()); i.setPackageCount(count);
i.setGrossWeightKg(unit(c.unitWeightKg()).multiply(BigDecimal.valueOf(count))); i.setVolumeCbm(unit(c.unitVolumeCbm()).multiply(BigDecimal.valueOf(count)));
i.setGrossWeightKg(unit(c.unitGrossWeightKg()).multiply(BigDecimal.valueOf(count)));
i.setNetWeightKg(unit(c.unitNetWeightKg()).multiply(BigDecimal.valueOf(count)));
i.setVolumeCbm(unit(c.unitVolumeCbm()).multiply(BigDecimal.valueOf(count)));
applyCargo(i, cargo, count); i.setLoadSequence(seq); i.setDeleted(false); i.setCreatedBy(op); i.setCreatedByName(name); i.setUpdatedBy(op); i.setUpdatedByName(name); i.setVersion(0); return i;
}
private void applyCargo(LoadingPlanItemEntity item, OrderCargoClient.CargoBinding cargo, int boxes) {
@@ -309,10 +313,6 @@ public class SmartLoadingService {
.multiply(BigDecimal.valueOf(boxes))
.divide(BigDecimal.valueOf(original), 0, RoundingMode.HALF_UP).intValueExact());
}
if (cargo.netWeightKg() != null) {
item.setNetWeightKg(cargo.netWeightKg().multiply(BigDecimal.valueOf(boxes))
.divide(BigDecimal.valueOf(original), 6, RoundingMode.HALF_UP));
}
}
private OrderCargoClient.CargoBinding loadCargo(Candidate candidate) {
@@ -344,7 +344,7 @@ public class SmartLoadingService {
if (!Objects.equals(b.getWarehouseId(), c.warehouseId())) throw validation("装柜仓库不一致");
if (StringUtils.hasText(b.getDestinationWarehouseCode()) && !Objects.equals(b.getDestinationWarehouseCode(), c.destinationWarehouseCode())) throw validation("目的仓/港不一致");
BigDecimal v=lines.stream().map(x->unit(x.getVolumeCbm())).reduce(BigDecimal.ZERO,BigDecimal::add).add(unit(c.unitVolumeCbm()).multiply(BigDecimal.valueOf(count)));
BigDecimal w=lines.stream().map(x->unit(x.getGrossWeightKg())).reduce(BigDecimal.ZERO,BigDecimal::add).add(unit(c.unitWeightKg()).multiply(BigDecimal.valueOf(count)));
BigDecimal w=lines.stream().map(x->unit(x.getGrossWeightKg())).reduce(BigDecimal.ZERO,BigDecimal::add).add(unit(c.unitGrossWeightKg()).multiply(BigDecimal.valueOf(count)));
if (b.getCapacityCbm()==null || b.getMaxWeightKg()==null) throw validation("货柜容量和最大承重未配置");
if (v.compareTo(b.getCapacityCbm().multiply(CAPACITY_LIMIT_RATIO))>0
|| w.compareTo(b.getMaxWeightKg().multiply(CAPACITY_LIMIT_RATIO))>0) {
@@ -373,7 +373,7 @@ public class SmartLoadingService {
BigDecimal leftV=b.getCapacityCbm().multiply(CAPACITY_LIMIT_RATIO).subtract(usedV);
BigDecimal leftW=b.getMaxWeightKg().multiply(CAPACITY_LIMIT_RATIO).subtract(usedW);
int byV=unit(c.unitVolumeCbm()).signum()==0?c.availablePackageCount():leftV.max(BigDecimal.ZERO).divideToIntegralValue(c.unitVolumeCbm()).intValue();
int byW=unit(c.unitWeightKg()).signum()==0?c.availablePackageCount():leftW.max(BigDecimal.ZERO).divideToIntegralValue(c.unitWeightKg()).intValue();
int byW=unit(c.unitGrossWeightKg()).signum()==0?c.availablePackageCount():leftW.max(BigDecimal.ZERO).divideToIntegralValue(c.unitGrossWeightKg()).intValue();
return Math.max(0,Math.min(c.availablePackageCount(),Math.min(byV,byW)));
}
private Plan view(LoadingPlanEntity p,ContainerBookingEntity b){
@@ -27,10 +27,12 @@ import java.security.MessageDigest;
import java.time.LocalDateTime;
import java.util.HexFormat;
import java.util.Objects;
import java.util.Set;
import org.springframework.web.multipart.MultipartFile;
@Service
public class TruckAppointmentService {
private static final Set<String> ATTACHMENT_TYPES = Set.of("BILL_OF_LADING", "DISPATCH_ORDER", "DRIVER_DOCUMENT", "VEHICLE_DOCUMENT", "OTHER");
@Resource TruckAppointmentRepository repo;
@Resource OceanBookingRepository oceanBookings;
@Resource SmartLoadingRepository loadingPlans;
@@ -42,27 +44,31 @@ public class TruckAppointmentService {
@Value("${baiyue.security.internal-service-token}") String internalToken;
public PageResult<TruckAppointmentEntity> page(Query q){return repo.page(q);} public TruckAppointmentEntity detail(long id){return enrich(required(id));}
public java.util.List<TruckAppointmentHistoryEntity> history(long id){required(id);return repo.histories(id);} public java.util.List<TruckAppointmentAttachmentEntity> attachments(long id){required(id);return repo.attachments(id);}
@Transactional public TruckAppointmentAttachmentEntity upload(long id,String type,MultipartFile file,long uid){TruckAppointmentEntity appointment=required(id);if(appointment.getCustomerId()==null||appointment.getCustomerId()<=0)throw bad("预约缺少客户归属,无法上传附件");try{var response=files.upload(internalToken,uid,appointment.getCustomerId(),"TRUCK_APPOINTMENT_ATTACHMENT",Long.toString(id),file);if(response==null||!response.successful()||response.data()==null)throw bad("附件上传失败");var m=response.data();TruckAppointmentAttachmentEntity a=new TruckAppointmentAttachmentEntity();a.setId(IdWorker.getId());a.setAppointmentId(id);a.setFileId(m.fileId());a.setAttachmentType(type);a.setFileName(m.fileName());a.setFileHash(m.sha256());a.setVisibility("PLATFORM");a.setUploadedBy(uid);a.setUploadedAt(LocalDateTime.now());repo.attachment(a);return a;}catch(FeignException ex){throw new BizException(ResultCode.SERVICE_UNAVAILABLE,"文件服务暂不可用");}}
@Transactional public TruckAppointmentAttachmentEntity attach(long id,AttachmentRequest r,long uid){TruckAppointmentEntity appointment=required(id); if(appointment.getCustomerId()==null||appointment.getCustomerId()<=0)throw bad("预约缺少客户归属,无法引用附件"); TruckAppointmentFileClient.Metadata metadata;try{var response=files.metadata(internalToken,appointment.getCustomerId(),"TRUCK_APPOINTMENT_ATTACHMENT",Long.toString(id),r.fileId());if(response==null||!response.successful()||response.data()==null)throw bad("附件不存在或归属不匹配");metadata=response.data();}catch(FeignException ex){throw new BizException(ResultCode.SERVICE_UNAVAILABLE,"文件服务暂不可用");} TruckAppointmentAttachmentEntity a=new TruckAppointmentAttachmentEntity();a.setId(IdWorker.getId());a.setAppointmentId(id);a.setFileId(metadata.fileId());a.setAttachmentType(r.attachmentType());a.setFileName(metadata.fileName());a.setFileHash(metadata.sha256());a.setVisibility("PLATFORM");a.setUploadedBy(uid);a.setUploadedAt(LocalDateTime.now());repo.attachment(a);return a;}
@Transactional public TruckAppointmentAttachmentEntity upload(long id,String type,MultipartFile file,long uid){String attachmentType=attachmentType(type);TruckAppointmentEntity appointment=required(id);if(appointment.getCustomerId()==null||appointment.getCustomerId()<=0)throw bad("预约缺少客户归属,无法上传附件");try{var response=files.upload(internalToken,uid,appointment.getCustomerId(),"TRUCK_APPOINTMENT_ATTACHMENT",Long.toString(id),file);if(response==null||!response.successful()||response.data()==null)throw bad("附件上传失败");var m=response.data();TruckAppointmentAttachmentEntity a=new TruckAppointmentAttachmentEntity();a.setId(IdWorker.getId());a.setAppointmentId(id);a.setFileId(m.fileId());a.setAttachmentType(attachmentType);a.setFileName(m.fileName());a.setFileHash(m.sha256());a.setVisibility("PLATFORM");a.setUploadedBy(uid);a.setUploadedAt(LocalDateTime.now());repo.attachment(a);return a;}catch(FeignException ex){throw new BizException(ResultCode.SERVICE_UNAVAILABLE,"文件服务暂不可用");}}
@Transactional public TruckAppointmentAttachmentEntity attach(long id,AttachmentRequest r,long uid){String attachmentType=attachmentType(r.attachmentType());TruckAppointmentEntity appointment=required(id); if(appointment.getCustomerId()==null||appointment.getCustomerId()<=0)throw bad("预约缺少客户归属,无法引用附件"); TruckAppointmentFileClient.Metadata metadata;try{var response=files.metadata(internalToken,appointment.getCustomerId(),"TRUCK_APPOINTMENT_ATTACHMENT",Long.toString(id),r.fileId());if(response==null||!response.successful()||response.data()==null)throw bad("附件不存在或归属不匹配");metadata=response.data();}catch(FeignException ex){throw new BizException(ResultCode.SERVICE_UNAVAILABLE,"文件服务暂不可用");} TruckAppointmentAttachmentEntity a=new TruckAppointmentAttachmentEntity();a.setId(IdWorker.getId());a.setAppointmentId(id);a.setFileId(metadata.fileId());a.setAttachmentType(attachmentType);a.setFileName(metadata.fileName());a.setFileHash(metadata.sha256());a.setVisibility("PLATFORM");a.setUploadedBy(uid);a.setUploadedAt(LocalDateTime.now());repo.attachment(a);return a;}
@Transactional public void deleteAttachment(long id,long attachmentId){required(id);if(repo.deleteAttachment(id,attachmentId)!=1)throw new BizException(ResultCode.NOT_FOUND,"附件引用不存在");}
@Transactional public TruckAppointmentEntity create(long uid,String name,SaveRequest r){if(r.expectedVersion()==null||r.expectedVersion()!=0)throw bad("新增 expectedVersion 必须为0");validateRequired(r); var customer=customers.active(r.customerId(),r.customerName()); AssociationContext links=validateAssociations(r,customer.id()); var carrier=suppliers.trucking(r.carrierId(),r.carrierName()); TruckAppointmentEntity e=new TruckAppointmentEntity(); e.setId(IdWorker.getId()); e.setAppointmentNo("TA"+e.getId()); e.setStatus(TruckAppointmentStatus.DRAFT); e.setRevision(0); e.setVersion(0); e.setCreatedAt(LocalDateTime.now()); e.setUpdatedAt(e.getCreatedAt()); copy(e,r,links,customer.tenantName(),carrier.name()); repo.insert(e); record(e,null,e.getStatus().name(),"CREATE",null,uid,name); return e;}
@Transactional public TruckAppointmentEntity edit(long id,long uid,String name,SaveRequest r){validateRequired(r);TruckAppointmentEntity e=required(id); checkVersion(e,r.expectedVersion()); if(!(e.getStatus()==TruckAppointmentStatus.DRAFT||e.getStatus()==TruckAppointmentStatus.PENDING_DISPATCH||e.getStatus()==TruckAppointmentStatus.RESCHEDULED))throw conflict("当前状态不可编辑"); var customer=customers.active(r.customerId(),r.customerName()); AssociationContext links=validateAssociations(r,customer.id()); var carrier=suppliers.trucking(r.carrierId(),r.carrierName()); copy(e,r,links,customer.tenantName(),carrier.name()); if(repo.updateEdit(e,r.expectedVersion())!=1)throw conflict("版本已变化,请刷新后重试"); record(e,e.getStatus().name(),e.getStatus().name(),"EDIT",null,uid,name); return required(id);}
@Transactional public TruckAppointmentEntity action(long id,String action,ActionRequest r,String idem,String clientHash,long uid,String name){String requestHash=hash(action,id,r);verifyClientHash(clientHash,requestHash);TruckAppointmentEntity replay=claimOrReplay(id,action,idem,requestHash);if(replay!=null)return replay;TruckAppointmentEntity e=required(id); checkVersion(e,r.expectedVersion()); if("RESCHEDULE".equals(action))throw conflict("改约请使用专用接口"); TruckAppointmentStatus from=e.getStatus(),to=next(from,action); if(repo.transition(id,from.name(),to.name(),e.getVersion())!=1)throw conflict("状态或版本已变化,请刷新后重试"); TruckAppointmentEntity after=required(id);record(after,from.name(),to.name(),action,r.reason(),uid,name);return completeOperation(id,action,idem,after);}
@Transactional public TruckAppointmentEntity action(long id,String action,ActionRequest r,String idem,String clientHash,long uid,String name){String requestHash=hash(action,id,r);verifyClientHash(clientHash,requestHash);TruckAppointmentEntity replay=claimOrReplay(id,action,idem,requestHash);if(replay!=null)return replay;TruckAppointmentEntity e=required(id); checkVersion(e,r.expectedVersion()); if("RESCHEDULE".equals(action))throw conflict("改约请使用专用接口"); TruckAppointmentStatus from=e.getStatus(),to=next(from,action); String dispatchBill="DISPATCH".equals(action)?requireDispatchReady(e):null; int changed="DISPATCH".equals(action)?repo.dispatch(id,from.name(),dispatchBill,e.getVersion()):repo.transition(id,from.name(),to.name(),e.getVersion()); if(changed!=1)throw conflict("状态或版本已变化,请刷新后重试"); TruckAppointmentEntity after=required(id);record(after,from.name(),to.name(),action,r.reason(),uid,name);return completeOperation(id,action,idem,after);}
@Transactional public TruckAppointmentEntity reschedule(long id,LocalDateTime at,ActionRequest r,String idem,String clientHash,long uid,String name){String action="RESCHEDULE";String requestHash=hash(action,id,new ReschedulePayload(at,r));verifyClientHash(clientHash,requestHash);TruckAppointmentEntity replay=claimOrReplay(id,action,idem,requestHash);if(replay!=null)return replay;TruckAppointmentEntity e=required(id);checkVersion(e,r.expectedVersion()); if(e.getStatus()==TruckAppointmentStatus.COMPLETED||e.getStatus()==TruckAppointmentStatus.CANCELLED)throw conflict("当前状态不可改约"); String from=e.getStatus().name(); if(repo.reschedule(id,at,r.reason(),e.getVersion())!=1)throw conflict("状态或版本已变化,请刷新后重试");TruckAppointmentEntity after=required(id);record(after,from,"RESCHEDULED",action,r.reason(),uid,name);return completeOperation(id,action,idem,after);}
@Transactional(propagation=Propagation.REQUIRES_NEW) public void completeLoadingFromPda(long containerBookingId,long taskId,long uid,String name){java.util.List<TruckAppointmentEntity> active=repo.activeForUpdate(containerBookingId);if(active.isEmpty())return;if(active.size()!=1)throw conflict("同一柜次存在多条有效拖车预约,跳过自动完成");TruckAppointmentEntity appointment=active.get(0);String from=appointment.getStatus().name();if(repo.completeLoadingFromPda(appointment.getId(),from,appointment.getVersion())!=1)throw conflict("拖车预约已被并发修改,跳过自动完成");TruckAppointmentEntity after=required(appointment.getId());record(after,from,TruckAppointmentStatus.COMPLETED.name(),"PDA_COMPLETE_LOADING","PDA task "+taskId+" 确认装柜完成",uid,name);}
private AssociationContext validateAssociations(SaveRequest r,long customerId){OceanBookingEntity booking=null;ContainerBookingEntity container=null;Long warehouseId=r.warehouseId();if(r.oceanBookingId()!=null){booking=oceanBookings.find(r.oceanBookingId());if(booking==null)throw new BizException(ResultCode.NOT_FOUND,"关联订舱不存在");if(booking.getCustomerId()!=null&&!Objects.equals(customerId,booking.getCustomerId()))throw bad("customerId与订舱客户不一致");warehouseId=mergeWarehouse(warehouseId,booking.getWarehouseId(),"订舱");}if(r.containerBookingId()!=null){container=containers.find(r.containerBookingId());if(container==null)throw new BizException(ResultCode.NOT_FOUND,"关联柜次不存在");if(container.getCustomerId()!=null&&!Objects.equals(customerId,container.getCustomerId()))throw bad("customerId与柜次客户不一致");warehouseId=mergeWarehouse(warehouseId,container.getWarehouseId(),"柜次");}if(booking!=null&&container!=null){long containerId=container.getId();boolean allocated=oceanBookings.allocations(booking.getId()).stream().anyMatch(a->Objects.equals(a.getContainerBookingId(),containerId));if(!allocated)throw bad("containerBookingId未分配至该订舱");}if(r.loadingPlanId()!=null){if(container==null)throw bad("选择loadingPlanId时必须同时选择containerBookingId");LoadingPlanEntity plan=loadingPlans.planByBooking(container.getId());if(plan==null||!Objects.equals(r.loadingPlanId(),plan.getId()))throw bad("loadingPlanId与柜次当前装柜计划不一致");}return new AssociationContext(booking,container,warehouseId);}
private AssociationContext validateAssociations(SaveRequest r,long customerId){OceanBookingEntity booking=null;ContainerBookingEntity container=null;Long warehouseId=r.warehouseId();String billOfLadingNo=trim(r.billOfLadingNo());if(r.oceanBookingId()!=null){booking=oceanBookings.find(r.oceanBookingId());if(booking==null)throw new BizException(ResultCode.NOT_FOUND,"关联订舱不存在");if(booking.getCustomerId()!=null&&!Objects.equals(customerId,booking.getCustomerId()))throw bad("customerId与订舱客户不一致");warehouseId=mergeWarehouse(warehouseId,booking.getWarehouseId(),"订舱");if("RELEASED".equals(booking.getStatus()))billOfLadingNo=releasedBookingBill(booking,billOfLadingNo);}if(r.containerBookingId()!=null){container=containers.find(r.containerBookingId());if(container==null)throw new BizException(ResultCode.NOT_FOUND,"关联柜次不存在");if(container.getCustomerId()!=null&&!Objects.equals(customerId,container.getCustomerId()))throw bad("customerId与柜次客户不一致");warehouseId=mergeWarehouse(warehouseId,container.getWarehouseId(),"柜次");}if(booking!=null&&container!=null){long containerId=container.getId();boolean allocated=oceanBookings.allocations(booking.getId()).stream().anyMatch(a->Objects.equals(a.getContainerBookingId(),containerId));if(!allocated)throw bad("containerBookingId未分配至该订舱");}if(r.loadingPlanId()!=null){if(container==null)throw bad("选择loadingPlanId时必须同时选择containerBookingId");LoadingPlanEntity plan=loadingPlans.planByBooking(container.getId());if(plan==null||!Objects.equals(r.loadingPlanId(),plan.getId()))throw bad("loadingPlanId与柜次当前装柜计划不一致");}return new AssociationContext(booking,container,warehouseId,billOfLadingNo);}
private static void validateRequired(SaveRequest r){if(r.customerId()==null||r.customerId()<=0)throw bad("customerId不能为空");if(r.carrierId()==null||r.carrierId()<=0)throw bad("carrierId不能为空");if(r.plannedLoadingAt()==null)throw bad("plannedLoadingAt不能为空");if(r.expectedVersion()==null||r.expectedVersion()<0)throw bad("expectedVersion不合法");}
private static Long mergeWarehouse(Long requested,Long linked,String source){if(requested!=null&&linked!=null&&!Objects.equals(requested,linked))throw bad("warehouseId与"+source+"不一致");return requested!=null?requested:linked;}
private TruckAppointmentStatus next(TruckAppointmentStatus s,String a){return switch(a){case"SUBMIT"->s==TruckAppointmentStatus.DRAFT?TruckAppointmentStatus.PENDING_DISPATCH:badStatus();case"DISPATCH"->s==TruckAppointmentStatus.PENDING_DISPATCH||s==TruckAppointmentStatus.RESCHEDULED?TruckAppointmentStatus.DISPATCHED:badStatus();case"PICKUP"->s==TruckAppointmentStatus.DISPATCHED?TruckAppointmentStatus.PICKUP_IN_PROGRESS:badStatus();case"ARRIVE"->s==TruckAppointmentStatus.PICKUP_IN_PROGRESS?TruckAppointmentStatus.ARRIVED_WAREHOUSE:badStatus();case"LOAD"->s==TruckAppointmentStatus.ARRIVED_WAREHOUSE?TruckAppointmentStatus.LOADING:badStatus();case"DEPART"->s==TruckAppointmentStatus.LOADING?TruckAppointmentStatus.DEPARTED:badStatus();case"COMPLETE"->s==TruckAppointmentStatus.DEPARTED?TruckAppointmentStatus.COMPLETED:badStatus();case"EXCEPTION"->s!=TruckAppointmentStatus.COMPLETED&&s!=TruckAppointmentStatus.CANCELLED?TruckAppointmentStatus.EXCEPTION:badStatus();case"CANCEL"->s!=TruckAppointmentStatus.COMPLETED?TruckAppointmentStatus.CANCELLED:badStatus();default->badStatus();};}
private TruckAppointmentStatus badStatus(){throw conflict("不允许的状态动作");} private TruckAppointmentEntity required(long id){TruckAppointmentEntity e=repo.find(id);if(e==null)throw new BizException(ResultCode.NOT_FOUND,"拖车预约不存在");return e;} private void checkVersion(TruckAppointmentEntity e,int v){if(e.getVersion()!=v)throw conflict("版本已变化,请刷新后重试");} private static BizException conflict(String m){return new BizException(ResultCode.CONFLICT,m);} private static BizException bad(String m){return new BizException(ResultCode.BAD_REQUEST,m);}
private TruckAppointmentEntity enrich(TruckAppointmentEntity value){if(value.getOceanBookingId()!=null){OceanBookingEntity booking=oceanBookings.find(value.getOceanBookingId());if(booking!=null){value.setOceanBookingNo(booking.getBookingNo());value.setWarehouseName(booking.getWarehouseName());value.setShippingCompanyId(booking.getShippingCompanyId());value.setShippingCompanyName(booking.getShippingCompanyName());value.setBookingAgentId(booking.getBookingAgentId());value.setBookingAgentName(booking.getBookingAgentName());}}if(value.getContainerBookingId()!=null){ContainerBookingEntity container=containers.find(value.getContainerBookingId());if(container!=null){value.setCabinetNo(container.getCabinetNo());value.setContainerNo(container.getContainerNo());if(!StringUtils.hasText(value.getWarehouseName()))value.setWarehouseName(container.getWarehouseName());}}value.setAttachmentCount((long)repo.attachments(value.getId()).size());return value;}
private void copy(TruckAppointmentEntity e,SaveRequest r,AssociationContext links,String customerName,String carrierName){e.setOutboundOrderId(r.outboundOrderId());e.setOceanBookingId(r.oceanBookingId());e.setContainerBookingId(r.containerBookingId());e.setLoadingPlanId(r.loadingPlanId());e.setWarehouseId(links.warehouseId());e.setCustomerId(r.customerId());e.setCustomerName(customerName);e.setCarrierId(r.carrierId());e.setCarrierName(carrierName);e.setFleetContact(r.fleetContact());e.setFleetPhone(r.fleetPhone());e.setDriverName(r.driverName());e.setDriverPhone(r.driverPhone());e.setDriverIdCard(r.driverIdCard());e.setVehicleNo(r.vehicleNo());e.setStoreName(r.storeName());e.setStoreContact(r.storeContact());e.setStorePhone(r.storePhone());e.setStoreAddress(r.storeAddress());e.setStoreCity(r.storeCity());e.setStoreZipcode(r.storeZipcode());e.setPackageCount(r.packageCount());e.setGrossWeightKg(r.grossWeightKg());e.setVolumeCbm(r.volumeCbm());e.setPalletCount(r.palletCount());e.setPoNo(r.poNo());e.setPlannedLoadingAt(r.plannedLoadingAt());e.setTransitMinutes(r.transitMinutes());e.setPayableAmount(r.payableAmount());e.setReceivableAmount(r.receivableAmount());e.setCurrency(r.currency()==null?"RMB":r.currency());e.setRemark(r.remark());}
private void copy(TruckAppointmentEntity e,SaveRequest r,AssociationContext links,String customerName,String carrierName){e.setOutboundOrderId(r.outboundOrderId());e.setOceanBookingId(r.oceanBookingId());e.setContainerBookingId(r.containerBookingId());e.setLoadingPlanId(r.loadingPlanId());e.setWarehouseId(links.warehouseId());e.setCustomerId(r.customerId());e.setCustomerName(customerName);e.setCarrierId(r.carrierId());e.setCarrierName(carrierName);e.setBillOfLadingNo(links.billOfLadingNo());e.setFleetContact(r.fleetContact());e.setFleetPhone(r.fleetPhone());e.setDriverName(r.driverName());e.setDriverPhone(r.driverPhone());e.setDriverIdCard(r.driverIdCard());e.setVehicleNo(r.vehicleNo());e.setStoreName(r.storeName());e.setStoreContact(r.storeContact());e.setStorePhone(r.storePhone());e.setStoreAddress(r.storeAddress());e.setStoreCity(r.storeCity());e.setStoreZipcode(r.storeZipcode());e.setPackageCount(r.packageCount());e.setGrossWeightKg(r.grossWeightKg());e.setVolumeCbm(r.volumeCbm());e.setPalletCount(r.palletCount());e.setPoNo(r.poNo());e.setPlannedLoadingAt(r.plannedLoadingAt());e.setTransitMinutes(r.transitMinutes());e.setPayableAmount(r.payableAmount());e.setReceivableAmount(r.receivableAmount());e.setCurrency(r.currency()==null?"RMB":r.currency());e.setRemark(r.remark());}
private TruckAppointmentEntity claimOrReplay(long id,String action,String key,String hash){validateKey(key);String normalized=key.trim();if(repo.claimOperation(IdWorker.getId(),id,action,normalized,hash))return null;TruckAppointmentOperationEntity existing=repo.operation(normalized);if(existing==null||!Objects.equals(existing.getAppointmentId(),id)||!action.equals(existing.getAction())||!hash.equals(existing.getRequestHash()))throw conflict("Idempotency-Key已用于不同请求");if(!StringUtils.hasText(existing.getResponseSnapshot()))throw conflict("相同请求正在处理中,请稍后重试");try{return objectMapper.readValue(existing.getResponseSnapshot(),TruckAppointmentEntity.class);}catch(JsonProcessingException ex){throw new IllegalStateException("无法读取拖车预约幂等结果",ex);}}
private TruckAppointmentEntity completeOperation(long id,String action,String key,TruckAppointmentEntity result){try{String normalized=key.trim();if(repo.completeOperation(id,action,normalized,objectMapper.writeValueAsString(result))!=1)throw conflict("拖车预约幂等请求完成状态冲突");return result;}catch(JsonProcessingException ex){throw new IllegalStateException("无法保存拖车预约幂等结果",ex);}}
private String hash(String action,long id,Object request){try{return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest((action+'|'+id+'|'+objectMapper.writeValueAsString(request)).getBytes(StandardCharsets.UTF_8)));}catch(Exception ex){throw new IllegalStateException("无法计算拖车预约请求摘要",ex);}}
private static void verifyClientHash(String clientHash,String serverHash){if(StringUtils.hasText(clientHash)&&!serverHash.equalsIgnoreCase(clientHash.trim()))throw bad("X-Request-Hash与请求内容不一致");}
private static void validateKey(String key){if(!StringUtils.hasText(key)||key.trim().length()>128)throw bad("Idempotency-Key不能为空且不能超过128个字符");}
private record ReschedulePayload(LocalDateTime plannedLoadingAt,ActionRequest request){}
private record AssociationContext(OceanBookingEntity booking,ContainerBookingEntity container,Long warehouseId){}
private String requireDispatchReady(TruckAppointmentEntity appointment){String bill=trim(appointment.getBillOfLadingNo());if(appointment.getOceanBookingId()!=null){OceanBookingEntity booking=oceanBookings.find(appointment.getOceanBookingId());if(booking==null)throw new BizException(ResultCode.NOT_FOUND,"关联订舱不存在");if("RELEASED".equals(booking.getStatus()))bill=releasedBookingBill(booking,bill);}if(!StringUtils.hasText(bill))throw conflict("派车前必须填写提单号");boolean active=false;java.util.List<TruckAppointmentAttachmentEntity> references=repo.attachments(appointment.getId());if(references!=null)for(TruckAppointmentAttachmentEntity attachment:references){if(!"BILL_OF_LADING".equals(attachment.getAttachmentType()))continue;try{var response=files.metadata(internalToken,appointment.getCustomerId(),"TRUCK_APPOINTMENT_ATTACHMENT",Long.toString(appointment.getId()),attachment.getFileId());if(response!=null&&response.successful()&&response.data()!=null){active=true;break;}}catch(FeignException.NotFound ignored){}catch(FeignException ex){throw new BizException(ResultCode.SERVICE_UNAVAILABLE,"文件服务暂不可用");}}if(!active)throw conflict("派车前必须上传有效提单附件");return bill;}
private static String releasedBookingBill(OceanBookingEntity booking,String requested){String authoritative=trim(booking.getBillOfLadingNo());if(!StringUtils.hasText(authoritative))throw conflict("关联已放舱订舱缺少提单号");if(StringUtils.hasText(requested)&&!authoritative.equals(requested))throw bad("billOfLadingNo与关联订舱不一致");return authoritative;}
private static String attachmentType(String value){String normalized=trim(value);if(!ATTACHMENT_TYPES.contains(normalized))throw bad("不支持的拖车附件类型");return normalized;}
private static String trim(String value){return StringUtils.hasText(value)?value.trim():null;}
private record AssociationContext(OceanBookingEntity booking,ContainerBookingEntity container,Long warehouseId,String billOfLadingNo){}
private void record(TruckAppointmentEntity e,String from,String to,String action,String reason,long uid,String name){TruckAppointmentHistoryEntity h=new TruckAppointmentHistoryEntity();h.setId(IdWorker.getId());h.setAppointmentId(e.getId());h.setRevision(e.getRevision());h.setAction(action);h.setFromStatus(from);h.setToStatus(to);h.setReason(reason);h.setOperatorId(uid);h.setOperatorName(name);h.setCreatedAt(LocalDateTime.now());repo.history(h);}
}
@@ -45,14 +45,10 @@ CALL phase2_measure_add_column('fl_inbound_measurement_item', 'box_specification
'VARCHAR(64) NOT NULL DEFAULT '''' COMMENT ''箱规标识'' AFTER item_no');
CALL phase2_measure_add_column('fl_inbound_measurement_item', 'girth_cm',
'DECIMAL(12,3) NOT NULL DEFAULT 0 COMMENT ''周长cm,规则为长+2*(宽+高)'' AFTER height_cm');
CALL phase2_measure_add_column('fl_inbound_measurement_item', 'unit_weight_kg',
'DECIMAL(18,3) NULL COMMENT ''单箱重量kg'' AFTER girth_cm');
CALL phase2_measure_add_column('fl_inbound_measurement_item', 'unit_volume_cbm',
'DECIMAL(18,4) NOT NULL DEFAULT 0 COMMENT ''单箱业务体积m3'' AFTER unit_weight_kg');
CALL phase2_measure_add_column('fl_inbound_measurement_item', 'total_weight_kg',
'DECIMAL(18,3) NULL COMMENT ''本箱规总重量kg'' AFTER volume_cbm');
'DECIMAL(18,4) NOT NULL DEFAULT 0 COMMENT ''单箱业务体积m3'' AFTER volume_cbm');
CALL phase2_measure_add_column('fl_inbound_measurement_item', 'total_volume_cbm',
'DECIMAL(18,4) NOT NULL DEFAULT 0 COMMENT ''本箱规总体积m3'' AFTER total_weight_kg');
'DECIMAL(18,4) NOT NULL DEFAULT 0 COMMENT ''本箱规总体积m3'' AFTER volume_cbm');
CALL phase2_measure_add_column('fl_inbound_measurement_item', 'volume_weight_kg',
'DECIMAL(18,3) NOT NULL DEFAULT 0 COMMENT ''本箱规材积重kg'' AFTER total_volume_cbm');
CALL phase2_measure_add_column('fl_inbound_measurement_item', 'over_limit_warning',
@@ -242,11 +242,9 @@ CREATE TABLE IF NOT EXISTS fl_inbound_measurement_item (
width_cm DECIMAL(12,3) NOT NULL COMMENT '原始宽度cm',
height_cm DECIMAL(12,3) NOT NULL COMMENT '原始高度cm',
girth_cm DECIMAL(12,3) NOT NULL COMMENT '周长cm,规则为长+2*(宽+高)',
unit_weight_kg DECIMAL(18,3) NULL COMMENT '单箱重量kg,第三方仅回传尺寸时可为空',
unit_volume_cbm DECIMAL(18,4) NOT NULL COMMENT '单箱业务体积m3',
volume_raw_cbm DECIMAL(18,6) NOT NULL COMMENT '原始体积m3',
volume_cbm DECIMAL(18,4) NOT NULL COMMENT '业务体积m3',
total_weight_kg DECIMAL(18,3) NULL COMMENT '本箱规总重量kg',
total_volume_cbm DECIMAL(18,4) NOT NULL COMMENT '本箱规总体积m3',
volume_weight_kg DECIMAL(18,3) NOT NULL COMMENT '本箱规材积重kg',
calculation_rule_version VARCHAR(64) NOT NULL COMMENT '体积计算和舍入规则版本',
@@ -276,7 +274,6 @@ CREATE TABLE IF NOT EXISTS fl_mojia_measurement_receipt (
length_cm DECIMAL(12,3) NOT NULL COMMENT '设备实测长度cm',
width_cm DECIMAL(12,3) NOT NULL COMMENT '设备实测宽度cm',
height_cm DECIMAL(12,3) NOT NULL COMMENT '设备实测高度cm',
weight_kg DECIMAL(18,3) NULL COMMENT '设备实测重量kg',
machine_no VARCHAR(64) NOT NULL COMMENT '设备编号',
measured_at DATETIME(3) NOT NULL COMMENT '设备测量时间或服务接收时间',
image_file_id BIGINT UNSIGNED NOT NULL COMMENT 'File服务中的墨家联调图片ID',
@@ -4,13 +4,13 @@ USE baiyue_firstleg;
CREATE TABLE IF NOT EXISTS fl_ocean_booking (
id BIGINT UNSIGNED NOT NULL COMMENT '海运订舱ID',
booking_no VARCHAR(40) NOT NULL COMMENT '平台海运订舱编号',
status VARCHAR(24) NOT NULL DEFAULT 'DRAFT' COMMENT '订舱状态:DRAFT仅限创建事务内部、CONFIRMED已确认、RELEASED已放舱、CHANGE_PENDING船期待变更、CANCELLED已取消',
status VARCHAR(24) NOT NULL DEFAULT 'DRAFT' COMMENT '订舱状态:DRAFT草稿、RELEASED已发布且永久只读',
customer_id BIGINT UNSIGNED NULL COMMENT '可选客户ID快照',
customer_name VARCHAR(128) NULL COMMENT '可选客户名称快照',
channel_code VARCHAR(64) NOT NULL COMMENT '订舱渠道编码快照',
channel_name VARCHAR(128) NOT NULL COMMENT '订舱渠道名称快照',
warehouse_id BIGINT UNSIGNED NOT NULL COMMENT '国内中转仓ID快照',
warehouse_name VARCHAR(128) NOT NULL COMMENT '国内中转仓名称快照',
channel_code VARCHAR(64) NULL COMMENT '订舱渠道编码快照',
channel_name VARCHAR(128) NULL COMMENT '订舱渠道名称快照',
warehouse_id BIGINT UNSIGNED NULL COMMENT '国内中转仓ID快照',
warehouse_name VARCHAR(128) NULL COMMENT '国内中转仓名称快照',
bill_of_lading_no VARCHAR(128) NULL COMMENT '提单号',
shipping_company_id BIGINT UNSIGNED NULL COMMENT '船公司逻辑ID快照',
shipping_company_name VARCHAR(128) NULL COMMENT '船公司名称快照',
@@ -23,8 +23,8 @@ CREATE TABLE IF NOT EXISTS fl_ocean_booking (
destination_port_code VARCHAR(32) NULL COMMENT '目的港编码快照',
destination_port_name VARCHAR(128) NULL COMMENT '目的港名称快照',
place_of_delivery VARCHAR(128) NULL COMMENT '最终交货地快照',
service_term VARCHAR(32) NOT NULL COMMENT '运输服务条款快照',
freight_term VARCHAR(32) NOT NULL COMMENT '海运费支付条款快照',
service_term VARCHAR(32) NULL COMMENT '运输服务条款快照;发布前必填',
freight_term VARCHAR(32) NULL COMMENT '海运费支付条款快照;发布前必填',
estimated_sailing_at DATETIME(3) NULL COMMENT '当前确认预计开船时间',
estimated_arrival_at DATETIME(3) NULL COMMENT '当前确认预计到港时间',
pending_vessel_name VARCHAR(128) NULL COMMENT '待确认变更船名',
@@ -47,7 +47,7 @@ CREATE TABLE IF NOT EXISTS fl_ocean_booking (
KEY idx_ocean_booking_voyage (vessel_name,voyage_no,updated_at),
CONSTRAINT chk_ocean_booking_customer CHECK (customer_id > 0),
CONSTRAINT chk_ocean_booking_status CHECK (status IN
('DRAFT','CONFIRMED','RELEASED','CHANGE_PENDING','CANCELLED')),
('DRAFT','RELEASED')),
CONSTRAINT chk_ocean_booking_schedule CHECK
(estimated_sailing_at IS NULL OR estimated_arrival_at IS NULL OR estimated_arrival_at >= estimated_sailing_at),
CONSTRAINT chk_ocean_booking_pending_schedule CHECK
@@ -72,67 +72,6 @@ DROP TRIGGER IF EXISTS trg_ocean_requirement_before_insert;
DROP TRIGGER IF EXISTS trg_ocean_requirement_before_update;
DROP TRIGGER IF EXISTS trg_ocean_requirement_before_delete;
DROP TRIGGER IF EXISTS trg_ocean_history_before_delete;
DELIMITER $$
DROP PROCEDURE IF EXISTS reconcile_ocean_booking_legacy$$
CREATE PROCEDURE reconcile_ocean_booking_legacy()
BEGIN
DECLARE v_blank_terms INT DEFAULT 0;
DECLARE v_legacy INT DEFAULT 0;
DECLARE v_external INT DEFAULT 0;
DECLARE v_sql TEXT;
SELECT COUNT(*) INTO v_blank_terms
FROM fl_ocean_booking
WHERE NULLIF(TRIM(service_term),'') IS NULL
OR NULLIF(TRIM(freight_term),'') IS NULL;
IF v_blank_terms > 0 THEN
SIGNAL SQLSTATE '45000'
SET MESSAGE_TEXT = '订舱结构收敛暂停:存在 service_term/freight_term 为空的真实行,请按原始业务资料补齐后重跑,禁止猜值';
END IF;
SELECT COUNT(*) INTO v_legacy
FROM fl_ocean_booking
WHERE status IN ('DRAFT','SUBMITTED','FAILED');
IF v_legacy > 0 THEN
-- Tables from later phase blocks may not exist on a module-only run. Use
-- dynamic SQL so a clean initialization remains repeatable.
IF EXISTS (SELECT 1 FROM information_schema.tables
WHERE table_schema=DATABASE() AND table_name='fl_port_schedule') THEN
SET @ocean_legacy_external := 0;
SET v_sql = 'SELECT COUNT(*) INTO @ocean_legacy_external FROM fl_port_schedule p JOIN fl_ocean_booking b ON b.id=p.ocean_booking_id WHERE b.status IN (''DRAFT'',''SUBMITTED'',''FAILED'')';
PREPARE stmt FROM v_sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET v_external = v_external + COALESCE(@ocean_legacy_external,0);
END IF;
IF EXISTS (SELECT 1 FROM information_schema.tables
WHERE table_schema=DATABASE() AND table_name='fl_carrier_schedule_query') THEN
SET @ocean_legacy_external := 0;
SET v_sql = 'SELECT COUNT(*) INTO @ocean_legacy_external FROM fl_carrier_schedule_query q JOIN fl_ocean_booking b ON b.id=q.ocean_booking_id WHERE b.status IN (''DRAFT'',''SUBMITTED'',''FAILED'')';
PREPARE stmt FROM v_sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET v_external = v_external + COALESCE(@ocean_legacy_external,0);
END IF;
IF v_external > 0 THEN
SIGNAL SQLSTATE '45000'
SET MESSAGE_TEXT = '订舱结构收敛暂停:旧订舱仍被港务船期/船司查询引用,请先按子表历史->主表归档顺序清理引用后重跑;不会伪造 CONFIRMED';
END IF;
DELETE FROM fl_ocean_booking_party
WHERE ocean_booking_id IN (SELECT id FROM fl_ocean_booking WHERE status IN ('DRAFT','SUBMITTED','FAILED'));
DELETE FROM fl_ocean_booking_container_allocation
WHERE ocean_booking_id IN (SELECT id FROM fl_ocean_booking WHERE status IN ('DRAFT','SUBMITTED','FAILED'));
DELETE FROM fl_ocean_booking_container_requirement
WHERE ocean_booking_id IN (SELECT id FROM fl_ocean_booking WHERE status IN ('DRAFT','SUBMITTED','FAILED'));
DELETE FROM fl_ocean_booking_operation
WHERE ocean_booking_id IN (SELECT id FROM fl_ocean_booking WHERE status IN ('DRAFT','SUBMITTED','FAILED'));
DELETE FROM fl_ocean_booking_status_history
WHERE ocean_booking_id IN (SELECT id FROM fl_ocean_booking WHERE status IN ('DRAFT','SUBMITTED','FAILED'));
DELETE FROM fl_ocean_booking
WHERE status IN ('DRAFT','SUBMITTED','FAILED');
END IF;
END$$
CALL reconcile_ocean_booking_legacy()$$
DROP PROCEDURE reconcile_ocean_booking_legacy$$
DELIMITER ;
-- Existing CHECK constraints are not reconciled by CREATE TABLE IF NOT EXISTS.
SET @ocean_drop_status_check = (
SELECT IF(COUNT(*) > 0,
@@ -146,10 +85,10 @@ PREPARE ocean_drop_status_stmt FROM @ocean_drop_status_check;
EXECUTE ocean_drop_status_stmt;
DEALLOCATE PREPARE ocean_drop_status_stmt;
ALTER TABLE fl_ocean_booking
MODIFY COLUMN service_term VARCHAR(32) NOT NULL COMMENT '运输服务条款快照',
MODIFY COLUMN freight_term VARCHAR(32) NOT NULL COMMENT '海运费支付条款快照',
MODIFY COLUMN service_term VARCHAR(32) NULL COMMENT '运输服务条款快照;发布前必填',
MODIFY COLUMN freight_term VARCHAR(32) NULL COMMENT '海运费支付条款快照;发布前必填',
ADD CONSTRAINT chk_ocean_booking_status CHECK
(status IN ('DRAFT','CONFIRMED','RELEASED','CHANGE_PENDING','CANCELLED'));
(status IN ('DRAFT','RELEASED'));
SET @ocean_drop_failure_reason = (
SELECT IF(COUNT(*) > 0,
@@ -248,13 +187,11 @@ DROP TRIGGER IF EXISTS trg_ocean_booking_before_update;
DELIMITER $$
CREATE TRIGGER trg_ocean_booking_before_update BEFORE UPDATE ON fl_ocean_booking FOR EACH ROW
BEGIN
IF OLD.status IN ('RELEASED','CANCELLED') THEN
IF OLD.status = 'RELEASED' THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='已放舱或已取消订舱不可修改';
END IF;
IF OLD.status <> NEW.status AND NOT (
(OLD.status='DRAFT' AND NEW.status='CONFIRMED')
OR (OLD.status='CONFIRMED' AND NEW.status IN ('RELEASED','CHANGE_PENDING','CANCELLED'))
OR (OLD.status='CHANGE_PENDING' AND NEW.status IN ('CONFIRMED','CANCELLED'))) THEN
(OLD.status='DRAFT' AND NEW.status='RELEASED')) THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='海运订舱状态迁移非法';
END IF;
END$$
@@ -11,6 +11,7 @@ CREATE TABLE IF NOT EXISTS fl_truck_appointment (
customer_name VARCHAR(128) NULL COMMENT '客户名称快照',
carrier_id BIGINT UNSIGNED NULL COMMENT '拖车服务商逻辑ID',
carrier_name VARCHAR(128) NULL COMMENT '拖车服务商名称快照',
bill_of_lading_no VARCHAR(128) NULL COMMENT '派车提单号快照;关联RELEASED订舱时由订舱事实源冻结',
fleet_contact VARCHAR(64) NULL COMMENT '车队联系人',
fleet_phone VARCHAR(32) NULL COMMENT '车队联系电话',
driver_name VARCHAR(64) NULL COMMENT '司机姓名',
@@ -52,9 +53,9 @@ CREATE TABLE IF NOT EXISTS fl_truck_appointment_history (
operator_name VARCHAR(64) NULL COMMENT '操作人姓名快照', request_id VARCHAR(128) NULL COMMENT '请求链路中的请求ID', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '历史记录创建时间', PRIMARY KEY(id), KEY idx_truck_history(appointment_id,revision)
) ENGINE=InnoDB COMMENT='拖车状态与改约历史';
CREATE TABLE IF NOT EXISTS fl_truck_appointment_attachment (
id BIGINT UNSIGNED NOT NULL COMMENT '拖车附件关系ID', appointment_id BIGINT UNSIGNED NOT NULL COMMENT '所属拖车预约ID', file_id BIGINT UNSIGNED NOT NULL COMMENT 'File服务文件逻辑ID', attachment_type VARCHAR(32) NOT NULL COMMENT '附件类型编码;由业务动作约定',
id BIGINT UNSIGNED NOT NULL COMMENT '拖车附件关系ID', appointment_id BIGINT UNSIGNED NOT NULL COMMENT '所属拖车预约ID', file_id BIGINT UNSIGNED NOT NULL COMMENT 'File服务文件逻辑ID', attachment_type VARCHAR(32) NOT NULL COMMENT '附件类型:BILL_OF_LADING提单、DISPATCH_ORDER派车单、DRIVER_DOCUMENT司机证件、VEHICLE_DOCUMENT车辆证件、OTHER其他',
file_name VARCHAR(255) NULL COMMENT '上传时文件名快照', file_hash CHAR(64) NULL COMMENT '文件内容SHA-256摘要', visibility VARCHAR(24) NOT NULL DEFAULT 'INTERNAL' COMMENT '可见范围:INTERNAL仅平台内部、CLIENT客户可见', uploaded_by BIGINT UNSIGNED NOT NULL COMMENT '上传操作人ID',
uploaded_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '附件上传时间', PRIMARY KEY(id), UNIQUE KEY uk_truck_attachment(appointment_id,file_id), KEY idx_truck_attachment(appointment_id)
uploaded_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '附件上传时间', PRIMARY KEY(id), UNIQUE KEY uk_truck_attachment(appointment_id,file_id), KEY idx_truck_attachment(appointment_id), CONSTRAINT chk_truck_attachment_type CHECK(attachment_type IN ('BILL_OF_LADING','DISPATCH_ORDER','DRIVER_DOCUMENT','VEHICLE_DOCUMENT','OTHER'))
) ENGINE=InnoDB COMMENT='拖车附件文件引用';
CREATE TABLE IF NOT EXISTS fl_truck_appointment_operation (
id BIGINT UNSIGNED NOT NULL COMMENT '拖车写操作幂等记录ID', appointment_id BIGINT UNSIGNED NOT NULL COMMENT '所属拖车预约ID', idempotency_key VARCHAR(128) NOT NULL COMMENT '客户端写操作幂等键', request_hash CHAR(64) NOT NULL COMMENT '规范化请求内容SHA-256摘要;用于识别同键不同请求',
@@ -462,7 +462,6 @@ paths:
length: {type: number, format: decimal, minimum: 0.001}
width: {type: number, format: decimal, minimum: 0.001}
height: {type: number, format: decimal, minimum: 0.001}
weight: {type: number, format: decimal, minimum: 0.001, nullable: true}
machineNo: {type: string, maxLength: 64}
measuredAt: {type: string, format: date-time, nullable: true}
picture: {type: string, description: 'data:image/jpeg;base64,...'}
@@ -621,7 +620,6 @@ paths:
length: {type: number, format: double, minimum: 0.001}
width: {type: number, format: double, minimum: 0.001}
height: {type: number, format: double, minimum: 0.001}
weight: {type: number, format: double, minimum: 0.001, nullable: true}
machineNo: {type: string, maxLength: 64}
measuredAt: {type: string, format: date-time}
image: {type: string, format: binary}
@@ -653,7 +651,6 @@ paths:
length: {type: number, format: double, minimum: 0.001}
width: {type: number, format: double, minimum: 0.001}
height: {type: number, format: double, minimum: 0.001}
weight: {type: number, format: double, minimum: 0.001, nullable: true}
machineNo: {type: string, maxLength: 64}
measuredAt: {type: string, format: date-time}
image: {type: string, format: binary}
@@ -1245,9 +1242,7 @@ components:
widthCm: {type: number, format: double, nullable: true}
heightCm: {type: number, format: double, nullable: true}
girthCm: {type: number, format: double, nullable: true}
unitWeightKg: {type: number, format: double, nullable: true}
unitVolumeCbm: {type: number, format: double, nullable: true}
totalWeightKg: {type: number, format: double, nullable: true}
totalVolumeCbm: {type: number, format: double, nullable: true}
volumeWeightKg: {type: number, format: double, nullable: true}
intelligentImageFileId:
@@ -1331,7 +1326,7 @@ components:
items: {$ref: '#/components/schemas/InboundMeasurementItemRequest'}
InboundMeasurementItemRequest:
type: object
required: [inboundDetailId, itemNo, boxSpecification, packageCount, lengthCm, widthCm, heightCm, unitWeightKg]
required: [inboundDetailId, itemNo, boxSpecification, packageCount, lengthCm, widthCm, heightCm]
additionalProperties: false
properties:
inboundDetailId: {type: string, pattern: '^[1-9][0-9]*$'}
@@ -1341,7 +1336,6 @@ components:
lengthCm: {type: number, minimum: 0.001}
widthCm: {type: number, minimum: 0.001}
heightCm: {type: number, minimum: 0.001}
unitWeightKg: {type: number, minimum: 0.001}
intelligentImageFileId: {type: string, pattern: '^[1-9][0-9]*$', nullable: true}
overLimitWarning: {type: string, maxLength: 500, nullable: true}
outerLengthCm: {type: number, minimum: 0, nullable: true}
@@ -1729,7 +1723,6 @@ components:
widthCm: {type: number, exclusiveMinimum: true, minimum: 0, example: 40.10}
heightCm: {type: number, exclusiveMinimum: true, minimum: 0, example: 30.00}
packageCount: {type: integer, minimum: 1, maximum: 1000000, example: 50}
unitWeightKg: {type: number, exclusiveMinimum: true, minimum: 0, nullable: true, example: 20}
remark: {type: string, maxLength: 500, nullable: true}
InboundMeasurementEventResponse:
type: object
@@ -1761,9 +1754,11 @@ components:
maxWeightKg: {type: number, minimum: 0, exclusiveMinimum: true, description: 必须等于柜型固定限重}
ContainerOceanBookingLinkRequest:
type: object
required: [realContainerBookingId, expectedVersion]
required: [oceanBookingId, requirementId, containerNo, expectedVersion]
properties:
realContainerBookingId: {type: integer, format: int64, minimum: 1, description: 订舱创建的 OCEAN_BOOKING/ACTIVE 真实柜ID;成功合并后该来源记录转为 MERGED}
oceanBookingId: {type: integer, format: int64, minimum: 1, description: RELEASED订舱ID}
requirementId: {type: integer, format: int64, minimum: 1, description: 未满配的订舱柜型需求ID}
containerNo: {type: string, maxLength: 64, description: 港口提柜后取得的全局唯一真实集装箱号}
expectedVersion: {type: integer, minimum: 0}
ContainerOceanBookingUnlinkRequest:
type: object
@@ -1,218 +1,71 @@
openapi: 3.0.3
info: {title: Phase 3 Ocean Booking API, version: 1.1.0}
info: {title: Ocean Booking API, version: 2.0.0}
paths:
/api/platform/v1/ocean-bookings/channels:
get:
summary: List logistics channels from the current ACTIVE price book for an origin warehouse
description: 'Requires platform:firstleg:ocean-booking:list. Returns only channels with an ACTIVE price row; the selected channel name is frozen server-side when the offline-confirmed booking is registered.'
parameters:
- {name: originWarehouseId, in: query, required: true, schema: {type: integer, format: int64, minimum: 1}}
responses: {'200': {description: Available channel options, content: {application/json: {schema: {type: array, items: {$ref: '#/components/schemas/ChannelOption'}}}}}}
/api/platform/v1/ocean-bookings:
get:
summary: List only real confirmed booking records
description: 'Requires platform:firstleg:ocean-booking:list. The operational list exposes only CONFIRMED, CHANGE_PENDING, RELEASED and CANCELLED bookings; DRAFT is internal to the create transaction and legacy SUBMITTED/FAILED values are invalid.'
parameters:
- {name: customerId, in: query, schema: {type: integer, format: int64, minimum: 1}}
- {name: status, in: query, schema: {type: string, enum: [CONFIRMED, CHANGE_PENDING, RELEASED, CANCELLED]}}
- {name: keyword, in: query, schema: {type: string, maxLength: 128}}
- {name: page, in: query, schema: {type: integer, minimum: 1, default: 1}}
- {name: size, in: query, schema: {type: integer, minimum: 1, maximum: 200, default: 20}}
responses:
'200':
description: Operational real booking page; each record follows OceanBookingSummary
content:
application/json:
schema:
type: object
properties:
data:
type: object
properties:
records: {type: array, items: {$ref: '#/components/schemas/OceanBookingSummary'}}
get: {summary: List DRAFT and RELEASED bookings, responses: {'200': {description: Booking page}}}
post:
summary: Register an offline-confirmed ocean booking with complete transport snapshots
description: 'Requires platform:firstleg:ocean-booking:create. Registers every real container unit atomically, creates one stable fl_container_booking plus one requirement/allocation per container, and persists the booking directly as CONFIRMED. RELEASED remains a separate later action.'
summary: Create an editable DRAFT
description: Creates only the booking aggregate and container requirements; no containerNo, container row, or allocation is created.
parameters: [{$ref: '#/components/parameters/IdempotencyKey'}]
requestBody: {required: true, content: {application/json: {schema: {$ref: '#/components/schemas/CreateRequest'}}}}
responses: {'200': {description: CONFIRMED real booking detail, content: {application/json: {schema: {$ref: '#/components/schemas/Detail'}}}}}
responses: {'200': {description: DRAFT detail}}
/api/platform/v1/ocean-bookings/{oceanBookingId}:
get: {summary: Get booking detail, parameters: [{$ref: '#/components/parameters/OceanBookingId'}], responses: {'200': {description: Detail}}}
put:
summary: Replace a DRAFT
parameters: [{$ref: '#/components/parameters/OceanBookingId'}, {$ref: '#/components/parameters/IdempotencyKey'}]
requestBody: {required: true, content: {application/json: {schema: {$ref: '#/components/schemas/UpdateRequest'}}}}
responses: {'200': {description: Updated DRAFT}}
delete:
summary: Delete a DRAFT; RELEASED is immutable
parameters: [{$ref: '#/components/parameters/OceanBookingId'}, {$ref: '#/components/parameters/IdempotencyKey'}]
requestBody: {required: true, content: {application/json: {schema: {$ref: '#/components/schemas/ActionRequest'}}}}
responses: {'200': {description: Deleted}}
/api/platform/v1/ocean-bookings/{oceanBookingId}/release:
post:
summary: Release an already registered booking and synchronize associated cabinet transport snapshots
description: 'The transport fields were registered at create time. The request must repeat the same bill of lading, vessel, voyage, ETD and ETA values; mismatches are rejected. This action does not create cabinets, change channels, replace cabinetNo, or recreate loading plans.'
summary: Validate and permanently release a DRAFT
description: Release validates the persisted aggregate and never requires or creates a real container number.
parameters: [{$ref: '#/components/parameters/OceanBookingId'}, {$ref: '#/components/parameters/IdempotencyKey'}]
requestBody: {required: true, content: {application/json: {schema: {$ref: '#/components/schemas/ReleaseRequest'}}}}
responses: {'200': {description: RELEASED detail}}
/api/platform/v1/ocean-bookings/{oceanBookingId}:
get:
summary: Get an ocean booking with its real container units
description: Requires platform:firstleg:ocean-booking:list.
parameters: [{$ref: '#/components/parameters/OceanBookingId'}]
responses: {'200': {description: Booking detail, content: {application/json: {schema: {$ref: '#/components/schemas/Detail'}}}}}
/api/platform/v1/ocean-bookings/{oceanBookingId}/cancel:
post:
summary: Cancel a confirmed or change-pending real booking
description: Requires platform:firstleg:ocean-booking:cancel. A non-blank reason is required; RELEASED bookings are irreversible.
parameters: [{$ref: '#/components/parameters/OceanBookingId'}, {$ref: '#/components/parameters/IdempotencyKey'}]
requestBody: {required: true, content: {application/json: {schema: {$ref: '#/components/schemas/ActionRequest'}}}}
responses: {'200': {description: CANCELLED detail}}
/api/platform/v1/ocean-bookings/{oceanBookingId}/change-request:
post:
summary: Request a voyage change for a confirmed booking
description: Requires platform:firstleg:ocean-booking:change. Current transport snapshots remain active until change-confirm succeeds.
parameters: [{$ref: '#/components/parameters/OceanBookingId'}, {$ref: '#/components/parameters/IdempotencyKey'}]
requestBody: {required: true, content: {application/json: {schema: {$ref: '#/components/schemas/ChangeRequest'}}}}
responses: {'200': {description: CHANGE_PENDING detail}}
/api/platform/v1/ocean-bookings/{oceanBookingId}/change-confirm:
post:
summary: Confirm a pending voyage change
description: Requires platform:firstleg:ocean-booking:change.
parameters: [{$ref: '#/components/parameters/OceanBookingId'}, {$ref: '#/components/parameters/IdempotencyKey'}]
requestBody: {required: true, content: {application/json: {schema: {$ref: '#/components/schemas/ActionRequest'}}}}
responses: {'200': {description: CONFIRMED detail with the pending voyage snapshots applied}}
/api/platform/v1/ocean-bookings/channels:
get: {summary: List ACTIVE channels for an origin warehouse, responses: {'200': {description: Channel options}}}
components:
parameters:
OceanBookingId: {name: oceanBookingId, in: path, required: true, schema: {type: integer, format: int64, minimum: 1}}
IdempotencyKey: {name: Idempotency-Key, in: header, required: true, schema: {type: string, maxLength: 128}}
schemas:
OceanBookingSummary:
CreateRequest: {type: object, required: [expectedVersion, data], properties: {expectedVersion: {type: integer, enum: [0]}, data: {$ref: '#/components/schemas/DraftData'}}}
UpdateRequest: {type: object, required: [expectedVersion, data], properties: {expectedVersion: {type: integer, minimum: 0}, data: {$ref: '#/components/schemas/DraftData'}}}
DraftData:
type: object
required: [id, bookingNo, status, billOfLadingNo, vesselName, voyageNo, estimatedSailingAt, estimatedArrivalAt, containerCount, version]
description: Fields may be incomplete in DRAFT. RELEASE validates the complete persisted aggregate.
properties:
id: {type: integer, format: int64}
bookingNo: {type: string}
status: {type: string, enum: [CONFIRMED, CHANGE_PENDING, RELEASED, CANCELLED]}
customerId: {type: integer, format: int64, nullable: true}
customerName: {type: string, nullable: true}
billOfLadingNo: {type: string}
shipperName: {type: string, nullable: true, description: SHIPPER party snapshot}
consigneeName: {type: string, nullable: true, description: CONSIGNEE party snapshot}
channelCode: {type: string, nullable: true}
warehouseId: {type: integer, format: int64, nullable: true}
billOfLadingNo: {type: string, nullable: true}
shippingCompanyId: {type: integer, format: int64, nullable: true}
shippingCompanyName: {type: string, nullable: true}
bookingAgentId: {type: integer, format: int64, nullable: true}
bookingAgentName: {type: string, nullable: true}
vesselName: {type: string}
voyageNo: {type: string}
vesselName: {type: string, nullable: true}
voyageNo: {type: string, nullable: true}
departurePortCode: {type: string, nullable: true}
departurePortName: {type: string, nullable: true}
destinationPortCode: {type: string, nullable: true}
destinationPortName: {type: string, nullable: true}
estimatedSailingAt: {type: string, format: date-time}
estimatedArrivalAt: {type: string, format: date-time}
containerCount: {type: integer, format: int64, minimum: 0, description: ACTIVE real container units allocated to the booking}
createdByName: {type: string, nullable: true}
updatedAt: {type: string, format: date-time}
version: {type: integer, minimum: 0}
CreateRequest:
type: object
required: [expectedVersion, data]
properties:
expectedVersion: {type: integer, enum: [0]}
data: {$ref: '#/components/schemas/DraftData'}
DraftData:
type: object
required: [channelCode, warehouseId, billOfLadingNo, vesselName, voyageNo, serviceTerm, freightTerm, estimatedSailingAt, estimatedArrivalAt, parties, containers]
properties:
customerId: {type: integer, format: int64, minimum: 1, nullable: true, description: Optional customer snapshot; must be supplied together with customerName.}
customerName: {type: string, maxLength: 128, nullable: true, description: Optional customer snapshot; must be supplied together with customerId.}
channelCode: {type: string, maxLength: 64, description: Selected from GET /api/platform/v1/ocean-bookings/channels; server validates it against the current ACTIVE price book and freezes channelName.}
warehouseId: {type: integer, format: int64, minimum: 1, description: ACTIVE domestic transit warehouse ID; warehouseName is resolved and frozen server-side.}
shippingCompanyId: {type: integer, format: int64, minimum: 1, nullable: true, description: Optional shipping-company master-data ID; the carrier is independent from the logistics channel and booking-agent Supplier.}
shippingCompanyName: {type: string, maxLength: 128, nullable: true, description: Optional shipping-company name snapshot.}
bookingAgentId: {type: integer, format: int64, minimum: 1, nullable: true, description: Optional ACTIVE Warehouse supplier ID validated only as category BOOKING; booking suppliers do not use domestic/international scope.}
bookingAgentName: {type: string, maxLength: 128, nullable: true, description: Optional consistency hint only; the persisted snapshot always comes from Warehouse and cannot be supplied without bookingAgentId.}
serviceTerm: {type: string, maxLength: 32, description: Required free-text Service Term snapshot, for example CY-CY.}
freightTerm: {type: string, maxLength: 32, description: Required free-text Freight Term snapshot, for example NINGBO.}
billOfLadingNo: {type: string, maxLength: 128, description: Required offline-confirmed bill of lading snapshot.}
vesselName: {type: string, maxLength: 128, description: Required offline-confirmed vessel snapshot.}
voyageNo: {type: string, maxLength: 64, description: Required offline-confirmed voyage snapshot.}
departurePortCode: {type: string, maxLength: 32, nullable: true}
departurePortName: {type: string, maxLength: 128, nullable: true}
destinationPortCode: {type: string, maxLength: 32, nullable: true}
destinationPortName: {type: string, maxLength: 128, nullable: true}
placeOfDelivery: {type: string, maxLength: 128, nullable: true}
estimatedSailingAt: {type: string, format: date-time, description: Required offline-confirmed ETD snapshot.}
estimatedArrivalAt: {type: string, format: date-time, description: Required offline-confirmed ETA snapshot; cannot be earlier than ETD.}
remark: {type: string, maxLength: 1000, nullable: true}
parties: {type: array, minItems: 3, maxItems: 3, items: {$ref: '#/components/schemas/PartyRequest'}}
containers: {type: array, minItems: 1, maxItems: 20, items: {$ref: '#/components/schemas/ContainerRequest'}}
PartyRequest:
type: object
required: [partyType, partyName]
properties:
partyType: {type: string, enum: [SHIPPER, CONSIGNEE, NOTIFY_PARTY]}
partyName: {type: string, maxLength: 255}
address: {type: string, maxLength: 1000, nullable: true}
contactName: {type: string, maxLength: 128, nullable: true}
contactPhone: {type: string, maxLength: 64, nullable: true}
ContainerRequest:
type: object
required: [containerNo, containerType, containerKind, capacityCbm, maxWeightKg]
additionalProperties: false
properties:
containerNo: {type: string, minLength: 1, maxLength: 64, description: Required real container number; unique across all ACTIVE and unmerged container records.}
containerType: {type: string, enum: [20GP, 40GP, 40HC, 40NR, 45HC], description: Platform-controlled container type}
containerKind: {type: string, maxLength: 32}
capacityCbm: {type: number, exclusiveMinimum: true, minimum: 0, description: Must match the fixed platform specification for containerType}
maxWeightKg: {type: number, exclusiveMinimum: true, minimum: 0, description: Must match the fixed platform specification for containerType}
Detail:
type: object
required: [oceanBooking, parties, containers, histories]
properties:
oceanBooking: {type: object, description: Ocean booking aggregate and frozen transport snapshots.}
parties: {type: array, items: {type: object}}
containers: {type: array, items: {$ref: '#/components/schemas/RealContainer'}}
histories: {type: array, items: {type: object}}
RealContainer:
type: object
required: [containerBookingId, cabinetNo, containerNo, requirementId, allocationId, containerType, containerKind, capacityCbm, maxWeightKg, warehouseId, warehouseName, channelCode, channelName]
properties:
containerBookingId: {type: integer, format: int64, minimum: 1, description: Current ACTIVE operational container identity.}
cabinetNo: {type: string, maxLength: 64, description: Stable server-generated internal cabinet number.}
containerNo: {type: string, maxLength: 64, description: Unique real container number.}
requirementId: {type: integer, format: int64, minimum: 1}
allocationId: {type: integer, format: int64, minimum: 1}
containerType: {type: string, enum: [20GP, 40GP, 40HC, 40NR, 45HC]}
containerKind: {type: string, maxLength: 32}
capacityCbm: {type: number}
maxWeightKg: {type: number}
warehouseId: {type: integer, format: int64, minimum: 1}
warehouseName: {type: string, maxLength: 128}
channelCode: {type: string, maxLength: 64}
channelName: {type: string, maxLength: 128}
customerId: {type: integer, format: int64, minimum: 1, nullable: true}
customerName: {type: string, maxLength: 128, nullable: true}
ChannelOption:
type: object
required: [priceBookVersionId, channelCode, channelName, priceModel, pricingTaxModes]
properties:
priceBookVersionId: {type: integer, format: int64}
channelCode: {type: string, maxLength: 64}
channelName: {type: string, maxLength: 128}
priceModel: {type: string}
pricingTaxModes: {type: array, items: {type: string}}
ActionRequest:
type: object
required: [expectedVersion]
properties: {expectedVersion: {type: integer, minimum: 0}, reason: {type: string, maxLength: 1000, nullable: true}}
ChangeRequest:
type: object
required: [expectedVersion, vesselName, voyageNo, reason]
properties:
expectedVersion: {type: integer, minimum: 0}
vesselName: {type: string, maxLength: 128}
voyageNo: {type: string, maxLength: 64}
placeOfDelivery: {type: string, nullable: true}
serviceTerm: {type: string, nullable: true}
freightTerm: {type: string, nullable: true}
estimatedSailingAt: {type: string, format: date-time, nullable: true}
estimatedArrivalAt: {type: string, format: date-time, nullable: true}
reason: {type: string, maxLength: 1000}
ReleaseRequest:
type: object
required: [expectedVersion, billOfLadingNo, vesselName, voyageNo, estimatedSailingAt, estimatedArrivalAt]
properties:
expectedVersion: {type: integer, minimum: 0}
billOfLadingNo: {type: string, maxLength: 128}
vesselName: {type: string, maxLength: 128}
voyageNo: {type: string, maxLength: 64}
estimatedSailingAt: {type: string, format: date-time}
estimatedArrivalAt: {type: string, format: date-time}
reason: {type: string, maxLength: 1000}
remark: {type: string, nullable: true}
parties: {type: array, nullable: true, items: {$ref: '#/components/schemas/PartyRequest'}}
containerRequirements: {type: array, nullable: true, items: {$ref: '#/components/schemas/ContainerRequirementRequest'}}
PartyRequest: {type: object, required: [partyType, partyName], properties: {partyType: {type: string, enum: [SHIPPER, CONSIGNEE, NOTIFY_PARTY]}, partyName: {type: string}, address: {type: string, nullable: true}, contactName: {type: string, nullable: true}, contactPhone: {type: string, nullable: true}}}
ContainerRequirementRequest: {type: object, required: [containerType, containerKind, quantity, capacityCbm, maxWeightKg], properties: {containerType: {type: string}, containerKind: {type: string}, quantity: {type: integer, minimum: 1}, capacityCbm: {type: number, exclusiveMinimum: true, minimum: 0}, maxWeightKg: {type: number, exclusiveMinimum: true, minimum: 0}}}
ReleaseRequest: {type: object, required: [expectedVersion], properties: {expectedVersion: {type: integer, minimum: 0}, reason: {type: string, nullable: true}}}
ActionRequest: {type: object, required: [expectedVersion], properties: {expectedVersion: {type: integer, minimum: 0}, reason: {type: string, nullable: true}}}
Detail: {type: object, required: [oceanBooking, parties, containerRequirements, containers, histories], properties: {oceanBooking: {type: object}, parties: {type: array}, containerRequirements: {type: array}, containers: {type: array, description: Existing containers allocated after port pickup linking}, histories: {type: array}}}
@@ -2,7 +2,7 @@ openapi: 3.0.3
info:
title: Truck Appointment API
version: 1.2.0
description: Truck appointments are independent operational records. Ocean booking, container booking and loading plan are optional links. After a PDA loading transaction commits, the server best-effort completes the unique active appointment explicitly linked to that container in an isolated transaction; absence, duplicates or concurrent truck edits never roll back loading completion.
description: Truck appointments are independent operational records. Ocean booking, container booking and loading plan are optional links. Draft creation/editing does not require a bill of lading or attachment; DISPATCH requires a nonblank billOfLadingNo and at least one active BILL_OF_LADING attachment owned by the appointment customer. When linked to a RELEASED booking, the server freezes and validates the booking bill of lading snapshot. After a PDA loading transaction commits, the server best-effort completes the unique active appointment explicitly linked to that container in an isolated transaction; absence, duplicates or concurrent truck edits never roll back loading completion.
paths:
/api/platform/v1/truck-appointments:
get:
@@ -109,9 +109,9 @@ paths:
multipart/form-data:
schema:
type: object
required: [file]
required: [attachmentType, file]
properties:
attachmentType: {type: string, maxLength: 64}
attachmentType: {type: string, enum: [BILL_OF_LADING, DISPATCH_ORDER, DRIVER_DOCUMENT, VEHICLE_DOCUMENT, OTHER], description: Attachment classification; BILL_OF_LADING is required before DISPATCH}
file: {type: string, format: binary}
responses:
'200': {description: Uploaded attachment reference}
@@ -140,10 +140,10 @@ components:
schemas:
AttachmentRequest:
type: object
required: [fileId]
required: [fileId, attachmentType]
properties:
fileId: {type: integer, format: int64, minimum: 1}
attachmentType: {type: string, maxLength: 64}
attachmentType: {type: string, enum: [BILL_OF_LADING, DISPATCH_ORDER, DRIVER_DOCUMENT, VEHICLE_DOCUMENT, OTHER], description: Attachment classification; BILL_OF_LADING is required before DISPATCH}
fileName: {type: string, maxLength: 255, description: Ignored; authoritative metadata comes from File service}
fileHash: {type: string, maxLength: 128, description: Ignored; authoritative metadata comes from File service}
Attachment:
@@ -152,7 +152,7 @@ components:
id: {type: integer, format: int64}
appointmentId: {type: integer, format: int64}
fileId: {type: integer, format: int64}
attachmentType: {type: string}
attachmentType: {type: string, enum: [BILL_OF_LADING, DISPATCH_ORDER, DRIVER_DOCUMENT, VEHICLE_DOCUMENT, OTHER]}
fileName: {type: string}
fileHash: {type: string}
visibility: {type: string, enum: [PLATFORM]}
@@ -177,6 +177,7 @@ components:
customerName: {type: string, maxLength: 128, nullable: true, description: Optional consistency hint only; persisted value is resolved from IAM customer master data.}
carrierId: {type: integer, format: int64, minimum: 1, description: Required ACTIVE Warehouse supplier ID validated as TRUCKING.}
carrierName: {type: string, maxLength: 128, nullable: true, description: Optional consistency hint only; the persisted snapshot comes from Warehouse supplier master data.}
billOfLadingNo: {type: string, maxLength: 128, nullable: true, description: Optional on draft; required before DISPATCH and server-frozen from a linked RELEASED booking when present.}
plannedLoadingAt: {type: string, format: date-time}
expectedVersion: {type: integer, minimum: 0, description: Must be 0 on create; current version on edit.}
DetailStableReferences:
@@ -200,4 +201,5 @@ components:
bookingAgentName: {type: string, nullable: true}
carrierId: {type: integer, format: int64}
carrierName: {type: string}
billOfLadingNo: {type: string, maxLength: 128, nullable: true, description: Frozen bill of lading snapshot used by dispatch readiness.}
attachmentCount: {type: integer, format: int64, minimum: 0, description: Number of attachment references owned by this appointment}
@@ -39,7 +39,7 @@ class InboundMeasurementEvidenceOpenApiContractTest {
.containsEntry("$ref", "#/components/schemas/InboundMeasurementItemRequest");
assertThat(properties(schemas, "InboundMeasurementItemRequest")).containsKeys(
"inboundDetailId", "itemNo", "boxSpecification", "packageCount", "lengthCm",
"widthCm", "heightCm", "unitWeightKg", "intelligentImageFileId", "remark");
"widthCm", "heightCm", "intelligentImageFileId", "remark");
Map<String, Object> itemProperties = properties(schemas, "InboundMeasurementItem");
assertThat(itemProperties).containsKeys("id", "intelligentImageFileId");
@@ -51,12 +51,12 @@ class PlatformCartonScanControllerTest {
.thenReturn(new CartonMeasurementResult(30001L, 1, 40001L, "RK-001#5-1",
"FBA-SCAN-001", 1,
new BigDecimal("60.1"), new BigDecimal("40.2"), new BigDecimal("30.3"),
new BigDecimal("12.5"), 99001L, "SIM-01",
99001L, "SIM-01",
LocalDateTime.parse("2026-08-07T10:30:00"), true));
MockPart data = new MockPart("data", "data", ("""
{"boxCode":"RK-001#5-1","scannedShipmentId":"FBA-SCAN-001",
"length":60.1,"width":40.2,"height":30.3,
"weight":12.5,"machineNo":"SIM-01","measuredAt":"2026-08-07T10:30:00"}
"machineNo":"SIM-01","measuredAt":"2026-08-07T10:30:00"}
""").getBytes(java.nio.charset.StandardCharsets.UTF_8));
data.getHeaders().setContentType(MediaType.APPLICATION_JSON);
MockMultipartFile image = new MockMultipartFile(
@@ -91,11 +91,11 @@ class PlatformCartonScanControllerTest {
when(cartonMeasurements.upsertManualForInbound(eq(10001L), eq(90001L), eq(70001L), any()))
.thenReturn(new CartonMeasurementResult(30001L, 1, 40001L, "RK-001#5-1",
null, 1, new BigDecimal("60.1"), new BigDecimal("40.2"), new BigDecimal("30.3"),
new BigDecimal("12.5"), 99001L, "MANUAL-ADMIN",
99001L, "MANUAL-ADMIN",
LocalDateTime.parse("2026-08-12T14:00:00"), true));
MockPart data = new MockPart("data", "data", ("""
{"boxCode":"RK-001#5-1","length":60.1,"width":40.2,"height":30.3,
"weight":12.5,"machineNo":"MANUAL-ADMIN","measuredAt":"2026-08-12T14:00:00"}
"machineNo":"MANUAL-ADMIN","measuredAt":"2026-08-12T14:00:00"}
""").getBytes(java.nio.charset.StandardCharsets.UTF_8));
data.getHeaders().setContentType(MediaType.APPLICATION_JSON);
MockMultipartFile image = new MockMultipartFile(
@@ -17,10 +17,10 @@ import static org.assertj.core.api.Assertions.assertThat;
class TruckAppointmentControllerContractTest {
@Test void exposesDedicatedEndpointAndPermissions() {String p=PlatformTruckAppointmentController.class.getAnnotation(org.springframework.web.bind.annotation.RequestMapping.class).value()[0];assertThat(p).isEqualTo("/api/platform/v1/truck-appointments"); for(String n:new String[]{"page","detail","history","attachments","attach","upload","deleteAttachment","create","edit","action","reschedule"}) assertThat(java.util.Arrays.stream(PlatformTruckAppointmentController.class.getDeclaredMethods()).anyMatch(m->m.getName().equals(n))).isTrue();}
@Test void schemaHasAggregateTablesStatesAndActualEventTimes() throws Exception {String s=new ClassPathResource("db/phase3-truck-appointment-schema.sql").getContentAsString(StandardCharsets.UTF_8);assertThat(s).contains("fl_truck_appointment","fl_truck_appointment_history","fl_truck_appointment_attachment","fl_truck_appointment_operation","PENDING_DISPATCH","PICKUP_IN_PROGRESS","ARRIVED_WAREHOUSE","RESCHEDULED","actual_arrived_warehouse_at","actual_loading_at","actual_departed_at","CALL phase3_truck_add_column");}
@Test void schemaHasAggregateTablesStatesAndActualEventTimes() throws Exception {String s=new ClassPathResource("db/phase3-truck-appointment-schema.sql").getContentAsString(StandardCharsets.UTF_8);assertThat(s).contains("fl_truck_appointment","fl_truck_appointment_history","fl_truck_appointment_attachment","fl_truck_appointment_operation","bill_of_lading_no VARCHAR(128)","chk_truck_attachment_type","BILL_OF_LADING","PENDING_DISPATCH","PICKUP_IN_PROGRESS","ARRIVED_WAREHOUSE","RESCHEDULED","actual_arrived_warehouse_at","actual_loading_at","actual_departed_at","CALL phase3_truck_add_column");}
@Test void openApiDocumentsLifecycleIdempotencyAndActualTimes() throws Exception {String s=new ClassPathResource("openapi/truck-appointments-v1.yaml").getContentAsString(StandardCharsets.UTF_8);assertThat(s).contains("/api/platform/v1/truck-appointments/{id}/{action}:","/api/platform/v1/truck-appointments/{id}/reschedule:","name: Idempotency-Key","required: true","actual event timestamps");}
@Test void openApiFreezesIndependentCreateAndOptionalAssociations() throws Exception {String s=new ClassPathResource("openapi/truck-appointments-v1.yaml").getContentAsString(StandardCharsets.UTF_8);assertThat(s).contains("required: [customerId, carrierId, plannedLoadingAt, expectedVersion]","independent operational records","no RELEASED-state prerequisite","It may be supplied only with containerBookingId","ACTIVE IAM customer","ACTIVE Warehouse supplier ID validated as TRUCKING","Fulfillment associations remain null");}
@Test void openApiFreezesAttachmentRequestAndOwnershipContract() throws Exception {String s=new ClassPathResource("openapi/truck-appointments-v1.yaml").getContentAsString(StandardCharsets.UTF_8);assertThat(s).contains("AttachmentRequest:","required: [fileId]","maxLength: 64","TRUCK_APPOINTMENT_ATTACHMENT","Deletes the appointment reference only","DetailStableReferences:","oceanBookingNo:","shippingCompanyName:","bookingAgentName:","cabinetNo:");}
@Test void openApiFreezesAttachmentRequestAndOwnershipContract() throws Exception {String s=new ClassPathResource("openapi/truck-appointments-v1.yaml").getContentAsString(StandardCharsets.UTF_8);assertThat(s).contains("AttachmentRequest:","required: [fileId, attachmentType]","required: [attachmentType, file]","BILL_OF_LADING","TRUCK_APPOINTMENT_ATTACHMENT","Deletes the appointment reference only","DetailStableReferences:","oceanBookingNo:","shippingCompanyName:","bookingAgentName:","cabinetNo:","billOfLadingNo:");}
@Test void methodsHavePlatformPermissionsAndAuthenticatedScope() {
Map<String,String> expected=Map.ofEntries(
Map.entry("page","platform:firstleg:truck-appointment:list"), Map.entry("detail","platform:firstleg:truck-appointment:list"),
@@ -76,7 +76,14 @@ class PdaSplitSettlementSqlContractTest {
.getAnnotation(Insert.class).value()[0];
assertThat(clone)
.contains("#{childOrderId},#{childOrderNo}")
.contains("CASE WHEN #{splitPurpose}='LOADED' THEN 'LOADING' ELSE 'WAIT_SPLIT' END");
.contains("CASE WHEN #{splitPurpose}='LOADED' THEN 'LOADING' ELSE 'WAIT_SPLIT' END")
.contains("actual_gross_weight_kg * #{packageCount}")
.contains("actual_net_weight_kg * #{packageCount}");
String cloneItem = InboundSplitSettlementMapper.class.getMethod("cloneMeasurementItem",
long.class, long.class, long.class, long.class, long.class, long.class, int.class, int.class)
.getAnnotation(Insert.class).value()[0];
assertThat(cloneItem).doesNotContain("unit_weight_kg").doesNotContain("total_weight_kg");
}
@Test
@@ -22,6 +22,11 @@ class SmartLoadingCandidateMapperContractTest {
.contains("i.inbound_detail_id IS NOT NULL")
.contains("o.warehouse_id=#{warehouseId}")
.contains("o.process_status='WAIT_SPLIT'")
.contains("unitGrossWeightKg")
.contains("unitNetWeightKg")
.contains("o.actual_gross_weight_kg")
.contains("o.actual_net_weight_kg")
.doesNotContain("i.unit_weight_kg")
.contains("SELECT SUM(li.package_count)")
.contains("li.deleted=0")
.contains("ORDER BY o.inbounded_at ASC, o.id ASC, i.id ASC");
@@ -39,6 +44,9 @@ class SmartLoadingCandidateMapperContractTest {
.contains("SELECT MAX(m2.id)")
.contains("o.warehouse_id=#{warehouseId}")
.contains("o.process_status='WAIT_SPLIT'")
.contains("unitGrossWeightKg")
.contains("unitNetWeightKg")
.doesNotContain("i.unit_weight_kg")
.contains("SELECT SUM(li.package_count)")
.contains("FOR UPDATE");
}
@@ -17,6 +17,8 @@ class TruckAppointmentMapperContractTest {
"fl_ocean_booking", "fl_container_booking", "record_status='ACTIVE'", "cabinet_no", "container_no", "fl_truck_appointment_attachment", "attachment_count");
String transition = TruckAppointmentMapper.class.getMethod("transition", long.class, String.class, String.class, int.class).getAnnotation(Update.class).value()[0];
assertThat(transition).contains("status=#{toStatus}", "status=#{fromStatus}", "version=#{version}", "revision=revision+1", "version=version+1");
String dispatch = TruckAppointmentMapper.class.getMethod("dispatch", long.class, String.class, String.class, int.class).getAnnotation(Update.class).value()[0];
assertThat(dispatch).contains("status='DISPATCHED'", "bill_of_lading_no=#{billOfLadingNo}", "status=#{fromStatus}", "version=#{version}", "revision=revision+1");
String active = TruckAppointmentMapper.class.getMethod("activeForUpdate", long.class).getAnnotation(Select.class).value()[0];
assertThat(active).contains("container_booking_id=#{containerBookingId}", "FOR UPDATE")
.contains("'COMPLETED','CANCELLED','EXCEPTION'");
@@ -28,7 +30,7 @@ class TruckAppointmentMapperContractTest {
String edit = TruckAppointmentMapper.class.getMethod("updateEdit", TruckAppointmentEntity.class, int.class).getAnnotation(Update.class).value()[0];
assertThat(edit).contains("ocean_booking_id=#{e.oceanBookingId}", "container_booking_id=#{e.containerBookingId}",
"loading_plan_id=#{e.loadingPlanId}", "customer_id=#{e.customerId}", "carrier_id=#{e.carrierId}",
"planned_loading_at=#{e.plannedLoadingAt}", "WHERE id=#{e.id} AND version=#{version}",
"planned_loading_at=#{e.plannedLoadingAt}", "bill_of_lading_no=#{e.billOfLadingNo}", "WHERE id=#{e.id} AND version=#{version}",
"version=version+1", "revision=revision+1");
}
}
@@ -172,7 +172,7 @@ class CartonMeasurementServiceTest {
when(repository.updateMeasurement(measurement)).thenReturn(true);
CartonMeasurementCommand oversize = new CartonMeasurementCommand(
"RK-001#5-1", new BigDecimal("60.100"), new BigDecimal("50.100"),
new BigDecimal("50.100"), new BigDecimal("12.500"), "MJ-01",
new BigDecimal("50.100"), "MJ-01",
LocalDateTime.parse("2026-08-07T10:30:00"),
new MockMultipartFile("image", "measure.jpg", "image/jpeg", "image".getBytes()),
"FBA-SCAN-001");
@@ -282,7 +282,6 @@ class CartonMeasurementServiceTest {
measurement.setTotalVolumeCbm(new BigDecimal("0.0600"));
measurement.setVolumeWeightKg(new BigDecimal("10.020"));
InboundMeasurementItemEntity item = item(40001L, 30001L, 1, 99001L);
item.setUnitWeightKg(new BigDecimal("10.880"));
item.setOuterLengthCm(new BigDecimal("50"));
item.setOuterWidthCm(new BigDecimal("40"));
item.setOuterHeightCm(new BigDecimal("30"));
@@ -317,7 +316,6 @@ class CartonMeasurementServiceTest {
measurement.setTotalVolumeCbm(new BigDecimal("0.0600"));
measurement.setVolumeWeightKg(new BigDecimal("10.020"));
InboundMeasurementItemEntity item = item(40001L, 30001L, 1, 99001L);
item.setUnitWeightKg(new BigDecimal("10.880"));
item.setOuterLengthCm(new BigDecimal("50"));
item.setOuterWidthCm(new BigDecimal("40"));
item.setOuterHeightCm(new BigDecimal("30"));
@@ -347,7 +345,7 @@ class CartonMeasurementServiceTest {
private CartonMeasurementCommand command() {
return new CartonMeasurementCommand("RK-001#5-1", new BigDecimal("59.100"),
new BigDecimal("40.200"), new BigDecimal("30.300"), new BigDecimal("12.500"),
new BigDecimal("40.200"), new BigDecimal("30.300"),
"MJ-01", LocalDateTime.parse("2026-08-07T10:30:00"),
new MockMultipartFile("image", "measure.jpg", "image/jpeg", "image".getBytes()),
"FBA-SCAN-001");
@@ -356,7 +354,7 @@ class CartonMeasurementServiceTest {
private CartonMeasurementCommand acceptanceCommand() {
return new CartonMeasurementCommand(
"RK-001#5-1", new BigDecimal("59.500"), new BigDecimal("41.200"),
new BigDecimal("31.300"), new BigDecimal("13.600"), "MJ-ADMIN-E2E-02",
new BigDecimal("31.300"), "MJ-ADMIN-E2E-02",
LocalDateTime.parse("2026-08-08T10:01:00"),
new MockMultipartFile("image", "measure.jpg", "image/jpeg", "image".getBytes()),
"FBA-SCAN-002");
@@ -381,7 +379,7 @@ class CartonMeasurementServiceTest {
item.setId(id); item.setTenantId(10001L); item.setMeasurementId(measurementId);
item.setBoxCode("RK-001#5-1"); item.setPackageCount(packageCount);
item.setLengthCm(new BigDecimal("50")); item.setWidthCm(new BigDecimal("40"));
item.setHeightCm(new BigDecimal("30")); item.setUnitWeightKg(new BigDecimal("10"));
item.setHeightCm(new BigDecimal("30"));
item.setSourceImageFileId(imageId); item.setItemNo("B1"); item.setBoxSpecification("50x40x30cm");
return item;
}
@@ -56,7 +56,7 @@ class InboundMeasurementOpenApiServiceTest {
{"eventNo":"M202608010001","warehouseCode":"CN-YW-01","inboundCode":"RK202608010001",
"externalDetailNo":"BOX-001","inboundDetailId":"80001","deviceCode":"DEVICE-01",
"measuredAt":"2026-08-01T10:30:00+08:00","lengthCm":50.20,"widthCm":40.10,
"heightCm":30.00,"packageCount":5,"unitWeightKg":12.5,
"heightCm":30.00,"packageCount":5,
"boxSpecification":"50.2x40.1x30cm"}
""";
private static final byte[] IMAGE = "image-content".getBytes(java.nio.charset.StandardCharsets.UTF_8);
@@ -783,7 +783,7 @@ class InboundMeasurementServiceTest {
new InboundMeasurementRequests.SaveDraftRequest(null, 0, null,
List.of(new InboundMeasurementRequests.ItemRequest(80001L, "001", "A", 2,
new BigDecimal("40"), new BigDecimal("30"), new BigDecimal("20"),
new BigDecimal("3.250"), null, null, null, null, null, null))));
null, null, null, null, null, null))));
assertThat(result.measurementId()).isEqualTo(30L);
ArgumentCaptor<MeasurementBatchCommand> command = ArgumentCaptor.forClass(MeasurementBatchCommand.class);
@@ -801,7 +801,7 @@ class InboundMeasurementServiceTest {
InboundOrderEntity inbound = inbound("WAIT_MEASURE");
InboundMeasurementRequests.ItemRequest item = new InboundMeasurementRequests.ItemRequest(
80001L, "001", "A", 2, new BigDecimal("40"), new BigDecimal("30"),
new BigDecimal("20"), new BigDecimal("3.250"), null, null, null, null, null, null);
new BigDecimal("20"), null, null, null, null, null, null);
when(repository.findInbound(1L, 10L)).thenReturn(inbound);
when(orderCargoClient.detail("internal-token", 1L, 80L, 80001L)).thenReturn(
ApiResponse.success(new OrderCargoClient.CargoBinding(
@@ -70,12 +70,11 @@ class MeasurementBatchServiceTest {
assertThat(items.getValue().get(0).getCalculationRuleVersion())
.isEqualTo("CUSTOMER_DIMENSION_MEDIUM_V0");
assertThat(items.getValue().get(0).getGirthCm()).isEqualByComparingTo("140.000");
assertThat(items.getValue().get(0).getUnitWeightKg()).isNull();
verify(repository, never()).updateInbound(any());
}
@Test
void manualDraftCalculatesWeightVolumeAndVolumeWeightOnServer() {
void manualDraftCalculatesVolumeAndVolumeWeightOnServer() {
MeasurementBatchCommand command = new MeasurementBatchCommand(
1L, 10L, null, 0, "MANUAL", "PLATFORM", null, null, null,
LocalDateTime.now(), null, "CKW-1", new BigDecimal("20"),
@@ -90,7 +89,6 @@ class MeasurementBatchServiceTest {
verify(repository).replaceItems(org.mockito.ArgumentMatchers.eq(1L),
org.mockito.ArgumentMatchers.eq(100L), items.capture());
assertThat(items.getValue().get(0).getInboundDetailId()).isEqualTo(80001L);
assertThat(items.getValue().get(0).getTotalWeightKg()).isEqualByComparingTo("6.500");
}
@Test
@@ -143,7 +141,7 @@ class MeasurementBatchServiceTest {
private MeasurementBatchCommand.Item item(BigDecimal unitWeight) {
return new MeasurementBatchCommand.Item(
80001L, "001", "A", 2, new BigDecimal("40"), new BigDecimal("30"),
new BigDecimal("20"), unitWeight, 500L, null,
new BigDecimal("20"), 500L, null,
new BigDecimal("41"), new BigDecimal("31"), new BigDecimal("21"), "备注");
}
@@ -222,7 +222,6 @@ class MeasurementReviewServiceTest {
measurement.setStatus("SUBMITTED");
InboundMeasurementItemEntity item = new InboundMeasurementItemEntity();
item.setPackageCount(5);
item.setTotalWeightKg(new BigDecimal("54.400"));
item.setTotalVolumeCbm(new BigDecimal("0.7480"));
item.setVolumeWeightKg(new BigDecimal("124.916"));
when(repository.findOwned(10001L, 10L)).thenReturn(order);
@@ -64,7 +64,7 @@ class MojiaMeasurementOpenApiServiceTest {
when(cartons.upsert(eq(10001L), eq(90001L), any())).thenReturn(new CartonMeasurementResult(
1L, 1, 2L, "PCHK2608140001#5-1", "FBA19L5KMY2CU000002", 1,
new BigDecimal("60.1"), new BigDecimal("40.2"),
new BigDecimal("30.3"), new BigDecimal("12.5"), 3L, "MJ-01", LocalDateTime.now(), true));
new BigDecimal("30.3"), 3L, "MJ-01", LocalDateTime.now(), true));
UploadResponse result = service.receive(headers, json());
@@ -119,7 +119,7 @@ class MojiaMeasurementOpenApiServiceTest {
return """
{"boxCode":"PCHK2608140001#5-1","shipment_id":"FBA19L5KMY2CU000002",
"length":60.1,"width":40.2,"height":30.3,
"weight":12.5,"machineNo":"MJ-01","picture":"data:image/jpeg;base64,/9j/2Q=="}
"machineNo":"MJ-01","picture":"data:image/jpeg;base64,/9j/2Q=="}
""";
}
}
@@ -676,7 +676,7 @@ class SmartLoadingServiceTest {
private static Candidate candidate(int boxes) {
return new Candidate(9L, 10L, 20L, 30L, "O", "I", 100L, "FBA", "FBA渠道", 200L,
"FBA1", "FBA仓", "客户", "货物", boxes, new BigDecimal("2"), new BigDecimal("0.5"));
"FBA1", "FBA仓", "客户", "货物", boxes, new BigDecimal("2"), new BigDecimal("1.5"), new BigDecimal("0.5"));
}
private static ContainerBookingEntity booking() {
@@ -5,6 +5,7 @@ import com.baiyue.firstleg.domain.TruckAppointmentStatus;
import com.baiyue.firstleg.dto.TruckAppointmentDtos.ActionRequest;
import com.baiyue.firstleg.dto.TruckAppointmentDtos.SaveRequest;
import com.baiyue.firstleg.entity.TruckAppointmentEntity;
import com.baiyue.firstleg.entity.TruckAppointmentAttachmentEntity;
import com.baiyue.firstleg.entity.TruckAppointmentHistoryEntity;
import com.baiyue.firstleg.entity.TruckAppointmentOperationEntity;
import com.baiyue.firstleg.entity.LoadingPlanEntity;
@@ -16,7 +17,9 @@ import com.baiyue.firstleg.oceanbooking.repository.OceanBookingRepository;
import com.baiyue.firstleg.oceanbooking.entity.OceanBookingEntity;
import com.baiyue.firstleg.oceanbooking.entity.OceanBookingContainerAllocationEntity;
import com.baiyue.firstleg.client.IamCustomerSnapshotClient;
import com.baiyue.firstleg.client.TruckAppointmentFileClient;
import com.baiyue.firstleg.client.WarehouseSupplierClient;
import com.baiyue.common.core.api.ApiResponse;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;
@@ -43,10 +46,12 @@ class TruckAppointmentServiceTest {
@Mock ContainerRepository containers;
@Mock WarehouseSupplierSnapshotService suppliers;
@Mock IamCustomerSnapshotService customers;
@Mock TruckAppointmentFileClient files;
@Spy ObjectMapper objectMapper = JsonMapper.builder().findAndAddModules().build();
@InjectMocks TruckAppointmentService service;
@BeforeEach void setUp() { MockitoAnnotations.openMocks(this);
service.internalToken = "test-internal-token";
when(customers.active(anyLong(), any())).thenAnswer(invocation ->
new IamCustomerSnapshotClient.Customer(invocation.getArgument(0), "C-7",
"Canonical Customer", "ACTIVE", null, null));
@@ -105,6 +110,38 @@ class TruckAppointmentServiceTest {
verifyNoInteractions(oceanBookings, containers, loadingPlans);
}
@Test void createAllowsDraftWithoutBillOfLadingOrAttachment() {
SaveRequest request = validRequest();
service.create(7L, "admin", request);
ArgumentCaptor<TruckAppointmentEntity> entity = ArgumentCaptor.forClass(TruckAppointmentEntity.class);
verify(repository).insert(entity.capture());
assertThat(entity.getValue().getBillOfLadingNo()).isNull();
verifyNoInteractions(files);
}
@Test void createFreezesReleasedBookingBillAndRejectsClientMismatch() {
SaveRequest request = validRequest();
when(request.oceanBookingId()).thenReturn(99L);
OceanBookingEntity booking = new OceanBookingEntity();
booking.setId(99L); booking.setStatus("RELEASED"); booking.setCustomerId(7L);
booking.setBillOfLadingNo(" BL-BOOKING-1 ");
when(oceanBookings.find(99L)).thenReturn(booking);
service.create(7L, "admin", request);
ArgumentCaptor<TruckAppointmentEntity> entity = ArgumentCaptor.forClass(TruckAppointmentEntity.class);
verify(repository).insert(entity.capture());
assertThat(entity.getValue().getBillOfLadingNo()).isEqualTo("BL-BOOKING-1");
SaveRequest mismatch = validRequest();
when(mismatch.oceanBookingId()).thenReturn(99L);
when(mismatch.billOfLadingNo()).thenReturn("BL-OTHER");
assertThatThrownBy(() -> service.create(7L, "admin", mismatch))
.isInstanceOf(BizException.class).hasMessageContaining("billOfLadingNo").hasMessageContaining("不一致");
}
@Test void createAcceptsConfirmedBookingWithoutContainerOrPlan() {
SaveRequest request = mock(SaveRequest.class);
when(request.expectedVersion()).thenReturn(0);
@@ -251,6 +288,57 @@ class TruckAppointmentServiceTest {
verify(repository, never()).history(any());
}
@Test void dispatchRequiresBillOfLadingNumberAndActiveOwnedAttachment() {
TruckAppointmentEntity missingBill = entity(TruckAppointmentStatus.PENDING_DISPATCH, 1);
when(repository.claimOperation(anyLong(), eq(1L), eq("DISPATCH"), eq("dispatch-no-bill"), anyString())).thenReturn(true);
when(repository.find(1L)).thenReturn(missingBill);
assertThatThrownBy(() -> service.action(1L, "DISPATCH", new ActionRequest(1, null),
"dispatch-no-bill", null, 7L, "admin"))
.isInstanceOf(BizException.class).hasMessageContaining("提单号");
TruckAppointmentEntity missingAttachment = entity(TruckAppointmentStatus.PENDING_DISPATCH, 1);
missingAttachment.setBillOfLadingNo("BL-1");
missingAttachment.setCustomerId(7L);
when(repository.claimOperation(anyLong(), eq(1L), eq("DISPATCH"), eq("dispatch-no-file"), anyString())).thenReturn(true);
when(repository.find(1L)).thenReturn(missingAttachment);
when(repository.attachments(1L)).thenReturn(List.of());
assertThatThrownBy(() -> service.action(1L, "DISPATCH", new ActionRequest(1, null),
"dispatch-no-file", null, 7L, "admin"))
.isInstanceOf(BizException.class).hasMessageContaining("有效提单附件");
verify(repository, never()).dispatch(anyLong(), anyString(), anyString(), anyInt());
}
@Test void dispatchFreezesReleasedBookingBillAndTransitionsOnceWithActiveAttachment() {
TruckAppointmentEntity before = entity(TruckAppointmentStatus.PENDING_DISPATCH, 1);
before.setCustomerId(7L); before.setOceanBookingId(99L); before.setBillOfLadingNo(null);
TruckAppointmentEntity after = entity(TruckAppointmentStatus.DISPATCHED, 2);
after.setCustomerId(7L); after.setOceanBookingId(99L); after.setBillOfLadingNo("BL-BOOKING-1");
OceanBookingEntity booking = new OceanBookingEntity();
booking.setId(99L); booking.setStatus("RELEASED"); booking.setBillOfLadingNo("BL-BOOKING-1");
TruckAppointmentAttachmentEntity attachment = new TruckAppointmentAttachmentEntity();
attachment.setFileId(88L); attachment.setAttachmentType("BILL_OF_LADING");
when(repository.claimOperation(anyLong(), eq(1L), eq("DISPATCH"), eq("dispatch-ok"), anyString())).thenReturn(true);
when(repository.find(1L)).thenReturn(before, after);
when(oceanBookings.find(99L)).thenReturn(booking);
when(repository.attachments(1L)).thenReturn(List.of(attachment));
when(files.metadata("test-internal-token", 7L, "TRUCK_APPOINTMENT_ATTACHMENT", "1", 88L))
.thenReturn(ApiResponse.success(new TruckAppointmentFileClient.Metadata(88L, 7L,
"TRUCK_APPOINTMENT_ATTACHMENT", "1", "bill.pdf", "hash", "application/pdf", 10L)));
when(repository.dispatch(1L, "PENDING_DISPATCH", "BL-BOOKING-1", 1)).thenReturn(1);
when(repository.completeOperation(eq(1L), eq("DISPATCH"), eq("dispatch-ok"), anyString())).thenReturn(1);
TruckAppointmentEntity result = service.action(1L, "DISPATCH", new ActionRequest(1, null),
"dispatch-ok", null, 7L, "admin");
assertThat(result.getStatus()).isEqualTo(TruckAppointmentStatus.DISPATCHED);
assertThat(result.getBillOfLadingNo()).isEqualTo("BL-BOOKING-1");
verify(repository).dispatch(1L, "PENDING_DISPATCH", "BL-BOOKING-1", 1);
verify(repository, never()).transition(anyLong(), anyString(), anyString(), anyInt());
verify(repository).history(any(TruckAppointmentHistoryEntity.class));
}
@Test void actionRejectsMissingKeyAndMismatchedClientHash() {
var request = new ActionRequest(0, "提交");
assertThatThrownBy(() -> service.action(1L, "SUBMIT", request, null, null, 7L, "admin"))
+3 -2
View File
@@ -13,11 +13,11 @@
| 平台订单 API | `controller/PlatformFirstLegOrderController.java` |
| 订单编排/状态/幂等 | `service/OrderApplicationService.java`、`entity/OrderStatus.java` |
| 商户订单列表与批量草稿 | `service/MerchantInboundOrderListService.java`、`service/MerchantBatchDraftService.java`、`service/MerchantBatchDraftItemService.java` |
| 入库号生成 | `entity/InboundCodeSequenceEntity.java`、`mapper/InboundCodeSequenceMapper.java`、`repository/OrderRepository.java` |
| 入库号生成 | `config/InboundCodeModeProvider.java`、`mapper/InboundCodeSequenceMapper.java`、`repository/OrderRepository.java`、`service/OrderApplicationService.java` |
| 下单计价渠道校验 | `service/OrderPricingChannelGuard.java`、`client/FirstlegPricingClient.java` |
| 下单仓库主数据守卫 | `service/OrderMasterDataGuard.java`、`client/WarehouseClient.java`;校验公共收货仓、目的仓类型、状态、地址与收货能力,并保存服务端快照 |
| 统一订单七状态与操作映射 | `../../docs/统一订单主状态与操作契约.md`;`LOADED` 硬门禁和 `DELIVERED` 可信回传同时核对三期需求确认表与开工门禁 |
| 货物内部查询 | `controller/InternalOrderCargoController.java` |
| 货物内部查询与箱唛绑定来源 | `controller/InternalOrderCargoController.java`;Firstleg 消费端为 `../firstleg/src/main/java/com/baiyue/firstleg/client/OrderCargoClient.java` |
| 快照/Outbox | `service/`、`repository/`、`event/` |
| DTO/持久化 | `dto/`、`entity/`、`mapper/`、`repository/` |
| SQL/OpenAPI | `src/main/resources/db/`、`src/main/resources/openapi/` |
@@ -26,6 +26,7 @@
## 修改约束
- 所有客户查询在 Repository/Mapper 显式带 `tenant_id`;状态变更保留幂等、历史、审计和并发控制。
- `inbound_code` 是跨客户全局唯一业务码;生成模式由 `InboundCodeModeProvider` 控制,序列与唯一约束不得退化为客户内唯一。修改格式、长度或生成时机时同步检查 Order SQL、Firstleg 入库快照、箱唛箱码、前端展示和全局唯一性契约测试。
- 下单和后续关键状态操作不得信任客户端仓库名称、类型或启用状态;公共仓库必须通过 Warehouse 内部接口按 ID 实时校验。私人地址不得伪装为公共目的仓,非私人目的地必须引用类型匹配的公共仓库;Warehouse 不可用时失败关闭,不回退到旧快照或模拟数据。
- Warehouse 往来单位、Firstleg 入库协作只通过客户端 DTO/内部令牌;修改契约需同步消费者。
@@ -16,7 +16,7 @@ public class InboundCodeModeProvider {
private final Mode mode;
public InboundCodeModeProvider(
@Value("${baiyue.order.inbound-code-mode:LEGACY_MANUAL}") String configuredMode) {
@Value("${baiyue.order.inbound-code-mode:AUTO}") String configuredMode) {
if (!StringUtils.hasText(configuredMode)) {
throw new IllegalStateException("baiyue.order.inbound-code-mode 必须显式配置");
}
+2
View File
@@ -35,6 +35,8 @@ baiyue:
url: ${WAREHOUSE_SERVICE_URL:}
file:
url: ${FILE_SERVICE_URL:}
order:
inbound-code-mode: ${ORDER_INBOUND_CODE_MODE:AUTO}
logging:
file:
name: "${LOGGING_FILE_NAME:${LOG_PATH:./logs}/${spring.application.name}.log}"
@@ -3,21 +3,35 @@ package com.baiyue.order.config;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Value;
import java.io.IOException;
import java.lang.reflect.Constructor;
import java.nio.charset.StandardCharsets;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
class InboundCodeModeProviderTest {
@Test
void springPlaceholderDefaultsToLegacyManualWhenEnvironmentPropertyIsMissing() {
void springPlaceholderDefaultsToAutoWhenEnvironmentPropertyIsMissing() {
Constructor<?> constructor = InboundCodeModeProvider.class.getDeclaredConstructors()[0];
Value value = constructor.getParameters()[0].getAnnotation(Value.class);
assertThat(value.value())
.isEqualTo("${baiyue.order.inbound-code-mode:LEGACY_MANUAL}");
assertThat(new InboundCodeModeProvider("LEGACY_MANUAL").mode())
.isEqualTo(InboundCodeModeProvider.Mode.LEGACY_MANUAL);
.isEqualTo("${baiyue.order.inbound-code-mode:AUTO}");
assertThat(new InboundCodeModeProvider("AUTO").mode())
.isEqualTo(InboundCodeModeProvider.Mode.AUTO);
}
@Test
void applicationConfigurationSupportsOrderModeEnvironmentVariableWithoutNacos() throws IOException {
String applicationYaml;
try (var stream = InboundCodeModeProviderTest.class.getResourceAsStream("/application.yml")) {
assertThat(stream).isNotNull();
applicationYaml = new String(stream.readAllBytes(), StandardCharsets.UTF_8);
}
assertThat(applicationYaml)
.contains("inbound-code-mode: ${ORDER_INBOUND_CODE_MODE:AUTO}");
}
@Test
@@ -104,6 +104,7 @@ async function createConfirmedWeightEvidence(context, { inboundId, tenantId, pal
const confirmed = context.apiSuccess(MODULE, '人工确认称重图片净重', confirmResponse,
`resultId=${created.id}, palletId=${palletId}`, OPTIONS)
const netWeightKg = Number(confirmed?.netWeightKg)
const grossWeightKg = Number(confirmed?.grossWeightKg)
if (confirmed?.recognitionStatus !== 'CONFIRMED' || !Number.isFinite(netWeightKg) || netWeightKg <= 0) {
return null
}
@@ -117,11 +118,12 @@ async function createConfirmedWeightEvidence(context, { inboundId, tenantId, pal
const persisted = confirmedRows.length === WEIGHING_IMAGE_COUNT
&& asId(confirmedRows[0]?.id) === asId(created.id)
&& asId(confirmedRows[0]?.palletId) === palletId
&& closeTo(workspace?.actualGrossWeightKg, netWeightKg, 0.001)
&& closeTo(workspace?.actualGrossWeightKg, grossWeightKg, 0.001)
&& closeTo(workspace?.actualNetWeightKg, netWeightKg, 0.001)
context.assert(MODULE, '图片张数决定托数且净重汇总已落地', workspaceResponse, persisted,
`confirmedImages=${confirmedRows.length}, expectedPallets=${WEIGHING_IMAGE_COUNT}, netWeight=${netWeightKg}, actual=${workspace?.actualGrossWeightKg ?? 'missing'}`,
`confirmedImages=${confirmedRows.length}, expectedPallets=${WEIGHING_IMAGE_COUNT}, grossWeight=${grossWeightKg}, netWeight=${netWeightKg}, actualGross=${workspace?.actualGrossWeightKg ?? 'missing'}, actualNet=${workspace?.actualNetWeightKg ?? 'missing'}`,
OPTIONS)
return persisted ? { count: confirmedRows.length, netWeightKg } : null
return persisted ? { count: confirmedRows.length, grossWeightKg, netWeightKg } : null
}
function findIsolatedInbound(context) {
@@ -279,7 +281,7 @@ export async function run(context) {
const scanPath = `/api/platform/v1/first-leg/inbounds/${inboundId}/measurement/carton-scans?${query(tenantId)}`
// Keep the isolated sample inside the customer's largest configured inner box (60x50x50cm).
const firstDimensions = { length: 59.1, width: 40.2, height: 30.3, weight: 12.5 }
const firstDimensions = { length: 59.1, width: 40.2, height: 30.3 }
const firstShipmentId = `FBA-${context.marker.slice(-12)}-01`
const firstScanResponse = await multipartRequest(context, scanPath, {
boxCode, ...firstDimensions, scannedShipmentId: firstShipmentId,
@@ -323,7 +325,7 @@ export async function run(context) {
&& Number(countResult?.measurementVersion) === Number(firstScan.measurementVersion) + 1,
`version=${countResult?.measurementVersion ?? 'missing'}`, OPTIONS)
const replacement = { length: 59.5, width: 41.2, height: 31.3, weight: 13.6 }
const replacement = { length: 59.5, width: 41.2, height: 31.3 }
const replacementShipmentId = `FBA-${context.marker.slice(-12)}-02`
const rescanResponse = await multipartRequest(context, scanPath, {
boxCode, ...replacement, scannedShipmentId: replacementShipmentId,
@@ -387,13 +389,12 @@ export async function run(context) {
const draftPayload = {
measurementId: measured.measurementId, version: Number(measured.version),
originalDocumentNo: context.marker, originalGrossWeightKg: replacement.weight * originalPackageCount,
originalVolumeCbm: totalVolume, actualGoodsName: `墨家测量验收-${context.marker.slice(-12)}`,
actualGoodsName: `墨家测量验收-${context.marker.slice(-12)}`,
items: [{
inboundDetailId, itemNo: measuredItem.itemNo,
boxSpecification: measuredItem.boxSpecification, packageCount: originalPackageCount,
lengthCm: replacement.length, widthCm: replacement.width, heightCm: replacement.height,
unitWeightKg: replacement.weight, intelligentImageFileId: measuredItem.intelligentImageFileId,
intelligentImageFileId: measuredItem.intelligentImageFileId,
overLimitWarning: measuredItem.overLimitWarning ?? null,
outerLengthCm: measuredItem.outerLengthCm ?? null, outerWidthCm: measuredItem.outerWidthCm ?? null,
outerHeightCm: measuredItem.outerHeightCm ?? null, remark: context.marker,
@@ -479,16 +480,17 @@ export async function run(context) {
finalMeasurement?.processStatus === 'WAIT_SPLIT' && finalMeasurement?.measureReviewStatus === 'APPROVED'
&& finalMeasurement?.measurementStatus === 'APPROVED'
&& Number(finalMeasurement?.actualPackageCount) === originalPackageCount
&& closeTo(finalMeasurement?.actualGrossWeightKg, weightEvidence.netWeightKg, 0.001)
&& closeTo(finalMeasurement?.actualGrossWeightKg, weightEvidence.grossWeightKg, 0.001)
&& closeTo(finalMeasurement?.actualNetWeightKg, weightEvidence.netWeightKg, 0.001)
&& closeTo(finalMeasurement?.actualVolumeCbm, totalVolume)
&& closeTo(finalMeasurement?.volumeWeightKg, volumeWeight),
`processStatus=${finalMeasurement?.processStatus ?? 'missing'}, measurementStatus=${finalMeasurement?.measurementStatus ?? 'missing'}, packages=${finalMeasurement?.actualPackageCount ?? 'missing'}, pallets=${weightEvidence.count}, cargoWeight=${finalMeasurement?.actualGrossWeightKg ?? 'missing'}`, OPTIONS)
`processStatus=${finalMeasurement?.processStatus ?? 'missing'}, measurementStatus=${finalMeasurement?.measurementStatus ?? 'missing'}, packages=${finalMeasurement?.actualPackageCount ?? 'missing'}, pallets=${weightEvidence.count}, grossWeight=${finalMeasurement?.actualGrossWeightKg ?? 'missing'}, netWeight=${finalMeasurement?.actualNetWeightKg ?? 'missing'}`, OPTIONS)
context.createdResources.inboundMeasurement = {
marker: context.marker, tenantId, inboundId, measurementId: asId(draft.measurementId),
boxCode, finalStatus: finalMeasurement?.measurementStatus || null,
processStatus: finalMeasurement?.processStatus || null,
palletCount: weightEvidence.count, actualGrossWeightKg: weightEvidence.netWeightKg,
palletCount: weightEvidence.count, actualGrossWeightKg: weightEvidence.grossWeightKg,
}
finishScenario(context, start)
}
+15 -7
View File
@@ -285,10 +285,13 @@ async function fixtureDiscoveryModule() {
assertStringId(weight.fileId, 'weight image file id')
assertEqual(weight.recognitionStatus, 'CONFIRMED', 'weight recognition status')
assertPresent(weight.confirmedAt, 'weight confirmation time')
assertTrue(Number(weight.finalWeightKg) > 0, 'final weight must be positive')
assertTrue(Number(weight.grossWeightKg) > 0, 'gross weight must be positive')
assertTrue(Number(weight.netWeightKg) > 0, 'net weight must be positive')
}
assertApproxEqual(sum(platformMeasurement.weightResults, 'finalWeightKg'),
assertApproxEqual(sum(platformMeasurement.weightResults, 'grossWeightKg'),
platformMeasurement.actualGrossWeightKg, 0.001, 'measurement gross weight aggregate')
assertApproxEqual(sum(platformMeasurement.weightResults, 'netWeightKg'),
platformMeasurement.actualNetWeightKg, 0.001, 'measurement net weight aggregate')
}, {
actualGrossWeightKg: platformMeasurement.actualGrossWeightKg,
weightResultCount: platformMeasurement.weightResults.length,
@@ -468,6 +471,8 @@ async function terminalBillingModule() {
'measurement package count')
assertApproxEqual(summary.actualGrossWeightKg, platformMeasurement.actualGrossWeightKg, 0.001,
'measurement gross weight')
assertApproxEqual(summary.actualNetWeightKg, platformMeasurement.actualNetWeightKg, 0.001,
'measurement net weight')
assertApproxEqual(summary.actualVolumeCbm, platformMeasurement.actualVolumeCbm, 0.000001,
'measurement volume')
assertApproxEqual(summary.volumeWeightKg, platformMeasurement.volumeWeightKg, 0.001,
@@ -488,8 +493,8 @@ async function terminalBillingModule() {
assertStringId(item.id, 'published measurement item id')
const expected = platformById.get(String(item.id))
assertPresent(expected, `platform measurement item ${item.id}`)
for (const field of ['packageCount', 'lengthCm', 'widthCm', 'heightCm', 'unitWeightKg',
'totalWeightKg', 'unitVolumeCbm', 'totalVolumeCbm', 'volumeWeightKg']) {
for (const field of ['packageCount', 'lengthCm', 'widthCm', 'heightCm',
'unitVolumeCbm', 'totalVolumeCbm', 'volumeWeightKg']) {
assertApproxEqual(item[field], expected[field], 0.000001, `${item.itemNo} ${field}`)
}
}
@@ -521,10 +526,13 @@ async function terminalBillingModule() {
assertPresent(expected, `platform weight result ${weight.id}`)
assertEqual(String(weight.fileId), String(expected.fileId), 'weight image file id')
assertEqual(weight.recognitionStatus, 'CONFIRMED', 'published weight status')
assertApproxEqual(weight.finalWeightKg, expected.finalWeightKg, 0.001, 'published final weight')
assertApproxEqual(weight.grossWeightKg, expected.grossWeightKg, 0.001, 'published gross weight')
assertApproxEqual(weight.netWeightKg, expected.netWeightKg, 0.001, 'published net weight')
}
assertApproxEqual(sum(publishedWeights, 'finalWeightKg'),
assertApproxEqual(sum(publishedWeights, 'grossWeightKg'),
merchantBilling.measurementSummary.actualGrossWeightKg, 0.001, 'published weight aggregate')
assertApproxEqual(sum(publishedWeights, 'netWeightKg'),
merchantBilling.measurementSummary.actualNetWeightKg, 0.001, 'published net weight aggregate')
}, { weightResultCount: arrayData(merchantBilling.weightResults).length })
await softLocalStep('phase2-client-billing',
@@ -532,7 +540,7 @@ async function terminalBillingModule() {
const summary = merchantBilling.measurementSummary
const basis = merchantBilling.billingBasis
const fee = merchantBilling.latestFee
assertNumberEqual(basis.actualWeightKg, summary.actualGrossWeightKg, 'billing actual weight')
assertNumberEqual(basis.actualWeightKg, summary.actualNetWeightKg, 'billing actual weight')
assertNumberEqual(basis.actualVolumeCbm, summary.actualVolumeCbm, 'billing actual volume')
assertNumberEqual(basis.actualWeightKg, fee.actualWeightKg, 'fee actual weight')
assertNumberEqual(basis.actualVolumeCbm, fee.actualVolumeCbm, 'fee actual volume')
+3 -3
View File
@@ -126,7 +126,7 @@ export async function runMojiaMeasurementScenario(run, options = {}) {
const boxCode = `${afterPrint.summary.inboundCode}#${printedTotal}-1`
const scanPath = `/api/platform/v1/first-leg/inbounds/${inboundId}/measurement/carton-scans?tenantId=${resolvedTenantId}`
const firstFields = {
boxCode, length: 59.1, width: 40.2, height: 30.3, weight: 12.5,
boxCode, length: 59.1, width: 40.2, height: 30.3,
machineNo: 'MJ-ADMIN-E2E-01', measuredAt: '2026-08-08T10:00:00',
}
const firstResponse = await multipartRequest(run, scanPath, firstFields, imagePath)
@@ -149,7 +149,7 @@ export async function runMojiaMeasurementScenario(run, options = {}) {
}
const rescanResponse = await multipartRequest(run, scanPath, {
boxCode, length: 59.5, width: 41.2, height: 31.3, weight: 13.5,
boxCode, length: 59.5, width: 41.2, height: 31.3,
machineNo: 'MJ-ADMIN-E2E-02', measuredAt: '2026-08-08T10:01:00',
}, imagePath)
const rescan = requireData(run, '墨家同箱复测覆盖', rescanResponse)
@@ -174,7 +174,7 @@ export async function runMojiaMeasurementScenario(run, options = {}) {
if (!submittedPassed) throw new Error('墨家测量提交后状态不正确')
const blockedResponse = await multipartRequest(run, scanPath, {
boxCode, length: 99.1, width: 99.2, height: 99.3, weight: 99.4,
boxCode, length: 99.1, width: 99.2, height: 99.3,
machineNo: 'MJ-ADMIN-E2E-BLOCKED', measuredAt: '2026-08-08T10:02:00',
}, imagePath)
const blockedPassed = blockedResponse.body?.code === 'A0409'