diff --git a/AGENTS.md b/AGENTS.md index c35577029..2b039d350 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -45,7 +45,7 @@ | 客户海外仓贴标、转运、废弃与退货换标 | `lastleg-service/` | `relabel/`、`transfer/`、`discard/` 的 Merchant Controller/Service;Client 消费端 `../apps/client-web/src/modules/overseas-warehouse/`,统一经 `gateway-overseas/` | | 海外仓 PDA 盘点、移库、托盘、换标与查询 | `lastleg-service/`、`warehouse-overseas-service/` | Lastleg `pda/stocktake/`、`pda/move/`、`pda/warehouse/`、`palletops/`、`relabel/pda/`;契约 `lastleg-service/src/main/resources/openapi/overseas-pda-operations-v1.yaml`;物理托盘流转与补偿进入 Warehouse `InternalPdaPalletTransitionController`,设备端入口是否开放以 `../apps/pda-overseas-uniapp/AGENTS.md` 为准 | | 国内船节点向海外接柜门禁事件 | `firstleg/`、`common-event-contract/`、`lastleg-service/` | Firstleg `portschedule/service/CarrierScheduleNodeOutboxService.java` 发布 `FirstLegContainerShippingNodeRecordedV1`;Lastleg `shipping/service/ContainerShippingNodeConsumer.java` 单调消费,只有实际 `PICKUP_READY` 及以后节点允许海外 PDA 候选进入可收货 | -| 海外仓库、海外库位、海外托盘、资源锁 | `warehouse-overseas-service/` | `warehouse-overseas-service/AGENTS.md` | +| 海外仓库目录、海外库位、海外托盘、资源锁 | 国内仓库目录归 `warehouse/`,海外资源归 `warehouse-overseas-service/` | `warehouse/AGENTS.md`、`warehouse-overseas-service/AGENTS.md`;海外库只保存国内 warehouseId 引用 | | Lastleg 内部模块化与依赖基线 | `lastleg-service/` | `lastleg-service/docs/lastleg-internal-modularization-baseline.md`、`lastleg-service/scripts/analyze-lastleg-dependencies.mjs`;只用于约束服务内部依赖,不改变服务和数据库边界 | | PDF 标签、审计、跨区双向事件 | `print/`、`audit-service/`、`event-bridge-service/` | 事件模型先读 `common-event-contract/AGENTS.md`;国内真实生产验收 `firstleg/.../TaillegRealProducerAcceptanceTest.java`、海外真实消费验收 `lastleg-service/.../TaillegRealConsumerAcceptanceTest.java`;可靠投递与 CN/OVERSEAS 目标路由读 `event-bridge-service/AGENTS.md` | diff --git a/config/nacos/overseas/warehouse-overseas-service.yml b/config/nacos/overseas/warehouse-overseas-service.yml index a05d57b33..53f24467e 100644 --- a/config/nacos/overseas/warehouse-overseas-service.yml +++ b/config/nacos/overseas/warehouse-overseas-service.yml @@ -4,3 +4,8 @@ spring: username: ${WAREHOUSE_DB_USERNAME} password: ${WAREHOUSE_DB_PASSWORD} driver-class-name: com.mysql.cj.jdbc.Driver +baiyue: + clients: + domestic-warehouse: + url: ${DOMESTIC_WAREHOUSE_URL} + cache-ttl: ${DOMESTIC_WAREHOUSE_CACHE_TTL:30m} diff --git a/deploy/docker-compose.overseas.yml b/deploy/docker-compose.overseas.yml index aae9708e7..24729ca6a 100644 --- a/deploy/docker-compose.overseas.yml +++ b/deploy/docker-compose.overseas.yml @@ -80,6 +80,7 @@ services: WAREHOUSE_DB_URL: ${WAREHOUSE_OVERSEAS_DB_URL} WAREHOUSE_DB_USERNAME: ${WAREHOUSE_OVERSEAS_DB_USERNAME} WAREHOUSE_DB_PASSWORD: ${WAREHOUSE_OVERSEAS_DB_PASSWORD} + DOMESTIC_WAREHOUSE_URL: ${DOMESTIC_WAREHOUSE_URL} event-bridge-service: <<: *overseas-common diff --git a/deploy/sql/README.md b/deploy/sql/README.md index aabc7dd7d..5a946294c 100644 --- a/deploy/sql/README.md +++ b/deploy/sql/README.md @@ -10,7 +10,7 @@ - 正式上线前的数据库变更直接维护该文件中的当前基线、存量兼容处理和最终校验,不再创建按日期拆分的发布 SQL;正式上线后的迁移策略在生产发布规范中另行冻结。 - 当前测试环境尚未上线,船期价格表相关表、字段和索引只维护在现有初始化建表定义及本固定入口中,不向 `phase1-order-extension.sql`、`phase2-fba-pricing-migration.sql` 或新的增量脚本追加同一结构。 - 模块 `src/main/resources/db/` 下的 SQL 用于开发溯源;`deploy/sql/` 下已有日期文件全部冻结为历史记录,不再单独部署,也不得与固定入口重复执行。 -- 固定入口不包含 `auth/phase1-admin-accounts.sql` 和 `warehouse/warehouse-seed.sql`;仅在开发/测试基线账号缺失时创建平台管理员,重复执行不覆盖现有密码。脚本不创建固定客户、客户账号或演示订单;客户必须通过正式建档/注册流程创建。正式平台公共仓库主数据已内联到固定入口。 +- 固定入口不包含 `auth/phase1-admin-accounts.sql` 和 `warehouse/warehouse-seed.sql`;仅在开发/测试基线账号缺失时创建平台管理员,重复执行不覆盖现有密码。脚本不创建固定客户、客户账号或演示订单;客户必须通过正式建档/注册流程创建。正式平台公共仓库主数据已内联到固定入口;义乌、东莞、洛杉矶、纽约四个固定作业仓均初始化 `RECEIVING`,其中洛杉矶和纽约使用海外接柜入仓收货语义,FBA 目的地址不创建作业能力。 - `warehouse/phase2-pallet-location-schema-migration.sql` 的有效结构已被当前 `phase2-pallet-location-schema.sql` 和后续共享溢出位迁移完整覆盖,因此不重复内联,避免每次部署无条件删除并重建同名唯一索引。 - 脚本会创建 9 个独立数据库:`baiyue_auth`、`baiyue_iam`、`baiyue_order`、`baiyue_warehouse`、`baiyue_firstleg`、`baiyue_file`、`baiyue_audit`、`baiyue_event_bridge`、`baiyue_finance`。Gateway、Print、Integration Service 无数据库。 - IAM 消息中心基线已内联到固定入口,包括 5 张 `iam_notification_*` 表、任务/事件幂等约束、平台消息管理权限、企业成员收件箱权限及默认角色授权;模块溯源脚本为 `iam/src/main/resources/db/notification-center.sql`。 diff --git a/deploy/sql/头程数据库统一增量-MySQL8.sql b/deploy/sql/头程数据库统一增量-MySQL8.sql index 3943ecee3..e5963fffd 100644 --- a/deploy/sql/头程数据库统一增量-MySQL8.sql +++ b/deploy/sql/头程数据库统一增量-MySQL8.sql @@ -5899,7 +5899,7 @@ BEGIN SELECT MAX(plan_status) INTO current_plan_status FROM fl_loading_plan WHERE container_booking_id = NEW.container_booking_id; IF current_plan_status = 'COMPLETED' - AND NEW.document_type NOT IN ('DESTINATION_CLEARANCE', 'CUSTOMS_DECLARATION') THEN + AND NEW.document_type NOT IN ('DESTINATION_CLEARANCE', 'CUSTOMS_DECLARATION', 'DELIVERY_ORDER', 'PICKUP_ORDER') THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '装柜完成后货柜文件不可新增'; END IF; END$$ @@ -5915,7 +5915,7 @@ BEGIN SELECT MAX(plan_status) INTO current_plan_status FROM fl_loading_plan WHERE container_booking_id = OLD.container_booking_id; IF current_plan_status = 'COMPLETED' - AND OLD.document_type NOT IN ('DESTINATION_CLEARANCE', 'CUSTOMS_DECLARATION') THEN + AND OLD.document_type NOT IN ('DESTINATION_CLEARANCE', 'CUSTOMS_DECLARATION', 'DELIVERY_ORDER', 'PICKUP_ORDER') THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '装柜完成后货柜文件不可修改'; END IF; END$$ @@ -6356,7 +6356,7 @@ JOIN wh_warehouse warehouse ON DUPLICATE KEY UPDATE tenant_id = VALUES(tenant_id); --- 国内固定作业仓必须启用收货能力,FBA 目的地址仍不得创建作业能力。 +-- 四个固定平台作业仓必须启用收货能力,FBA 目的地址仍不得创建作业能力。 INSERT INTO wh_service_capability ( id, tenant_id, warehouse_id, capability_code, capability_name, enabled, cutoff_time, description, created_by, created_at, updated_by, updated_at, deleted, version @@ -6365,7 +6365,11 @@ VALUES (193000000000400001,0,193000000000200001,'RECEIVING','头程收货',1,'16:00:00', '义乌仓头程订单收货能力',NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0), (193000000000400002,0,193000000000200002,'RECEIVING','头程收货',1,'16:00:00', - '东莞仓头程订单收货能力',NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0) + '东莞仓头程订单收货能力',NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0), + (193000000000400003,0,193000000000200003,'RECEIVING','海外收货',1,'16:00:00', + '洛杉矶仓海外接柜入仓收货能力',NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0), + (193000000000400004,0,193000000000200004,'RECEIVING','海外收货',1,'16:00:00', + '纽约仓海外接柜入仓收货能力',NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0) ON DUPLICATE KEY UPDATE capability_name = VALUES(capability_name), enabled = VALUES(enabled), @@ -7012,11 +7016,11 @@ BEGIN IF (SELECT COUNT(*) FROM wh_service_capability capability WHERE capability.tenant_id = 0 - AND capability.warehouse_id IN (193000000000200001,193000000000200002) + AND capability.warehouse_id IN (193000000000200001,193000000000200002,193000000000200003,193000000000200004) AND capability.capability_code = 'RECEIVING' AND capability.enabled = 1 - AND capability.deleted = 0) <> 2 THEN - SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '国内固定作业仓收货能力写入校验失败'; + AND capability.deleted = 0) <> 4 THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '固定平台作业仓收货能力写入校验失败'; END IF; END// DELIMITER ; @@ -15776,7 +15780,7 @@ CREATE TRIGGER trg_container_document_before_insert BEFORE INSERT ON fl_containe BEGIN DECLARE current_plan_status VARCHAR(24); SELECT MAX(plan_status) INTO current_plan_status FROM fl_loading_plan WHERE container_booking_id=NEW.container_booking_id; - IF current_plan_status='COMPLETED' AND NEW.document_type NOT IN ('DESTINATION_CLEARANCE','CUSTOMS_DECLARATION') THEN + IF current_plan_status='COMPLETED' AND NEW.document_type NOT IN ('DESTINATION_CLEARANCE','CUSTOMS_DECLARATION','DELIVERY_ORDER','PICKUP_ORDER') THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='装柜完成后货柜物流文件不可新增'; END IF; END$$ @@ -15787,7 +15791,7 @@ CREATE TRIGGER trg_container_document_before_update BEFORE UPDATE ON fl_containe BEGIN DECLARE current_plan_status VARCHAR(24); SELECT MAX(plan_status) INTO current_plan_status FROM fl_loading_plan WHERE container_booking_id=OLD.container_booking_id; - IF current_plan_status='COMPLETED' AND OLD.document_type NOT IN ('DESTINATION_CLEARANCE','CUSTOMS_DECLARATION') THEN + IF current_plan_status='COMPLETED' AND OLD.document_type NOT IN ('DESTINATION_CLEARANCE','CUSTOMS_DECLARATION','DELIVERY_ORDER','PICKUP_ORDER') THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='装柜完成后货柜物流文件不可修改'; END IF; END$$ diff --git a/deploy/sql/海外区域统一增量-MySQL8.sql b/deploy/sql/海外区域统一增量-MySQL8.sql index 7504a0f81..f7a4db970 100644 --- a/deploy/sql/海外区域统一增量-MySQL8.sql +++ b/deploy/sql/海外区域统一增量-MySQL8.sql @@ -969,6 +969,12 @@ PREPARE phase4_discard_review_stmt FROM @ddl; EXECUTE phase4_discard_review_stmt -- Warehouse Overseas 与 Lastleg 使用同一海外区域发布入口但保持独立数据库。 -- 公共托盘详情实体读取 fba_warehouse_code;旧库缺列时创建可成功但详情会 S0500。 USE baiyue_lastlag_warehouse; +-- 仓库主档唯一权威已收口至国内 baiyue_warehouse;海外库只保留 warehouse_id 引用。 +-- 当前开发阶段不兼容旧海外主档数据,按依赖顺序移除重复表。 +DROP TABLE IF EXISTS wh_service_capability; +DROP TABLE IF EXISTS wh_warehouse_address; +DROP TABLE IF EXISTS wh_warehouse; + CREATE TABLE IF NOT EXISTS wh_pallet_operation ( id BIGINT NOT NULL COMMENT '托盘状态操作ID', operation_key VARCHAR(128) NOT NULL COMMENT '跨服务幂等操作键', request_hash VARCHAR(128) NOT NULL COMMENT '状态变更请求摘要', warehouse_id BIGINT NOT NULL COMMENT '实际海外仓ID', diff --git a/file/pom.xml b/file/pom.xml index 7c0efeae4..dabcc9edb 100644 --- a/file/pom.xml +++ b/file/pom.xml @@ -14,7 +14,12 @@ com.alibaba.cloudspring-cloud-starter-alibaba-nacos-discovery com.alibaba.cloudspring-cloud-starter-alibaba-nacos-config com.alibaba.cloudspring-cloud-starter-alibaba-sentinel - org.springframework.cloudspring-cloud-starter-openfeign + + org.springframework.cloudspring-cloud-starter-openfeign + + io.github.openfeign.formfeign-form-spring + + org.springframework.cloudspring-cloud-starter-loadbalancer org.springframework.bootspring-boot-starter-actuator org.apache.rocketmqrocketmq-spring-boot-starter diff --git a/file/src/main/java/com/baiyue/file/controller/InternalBusinessFileController.java b/file/src/main/java/com/baiyue/file/controller/InternalBusinessFileController.java index 046b1ac3a..05d90c270 100644 --- a/file/src/main/java/com/baiyue/file/controller/InternalBusinessFileController.java +++ b/file/src/main/java/com/baiyue/file/controller/InternalBusinessFileController.java @@ -13,6 +13,7 @@ import org.springframework.http.HttpHeaders; import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.DeleteMapping; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestHeader; @@ -74,6 +75,20 @@ public class InternalBusinessFileController { .body(value.content()); } + @DeleteMapping("/{tenantId}/{businessType}/{businessId}/{fileId}") + public ApiResponse delete( + @RequestHeader(value = "X-Internal-Service-Token", required = false) String token, + @RequestHeader("X-Operator-Id") long operatorId, + @PathVariable long tenantId, + @PathVariable @NotBlank String businessType, + @PathVariable @NotBlank String businessId, + @PathVariable @Positive long fileId) { + check(token); + checkTenantId(tenantId); + service.delete(tenantId, businessType, businessId, fileId, operatorId); + return ApiResponse.success(null); + } + private void check(String token) { if (token == null || !MessageDigest.isEqual( token.getBytes(StandardCharsets.UTF_8), expected.getBytes(StandardCharsets.UTF_8))) { diff --git a/file/src/main/java/com/baiyue/file/mapper/FileObjectMapper.java b/file/src/main/java/com/baiyue/file/mapper/FileObjectMapper.java index 729476614..f0faec11e 100644 --- a/file/src/main/java/com/baiyue/file/mapper/FileObjectMapper.java +++ b/file/src/main/java/com/baiyue/file/mapper/FileObjectMapper.java @@ -3,7 +3,16 @@ package com.baiyue.file.mapper; import com.baomidou.mybatisplus.core.mapper.BaseMapper; import com.baiyue.file.entity.FileObjectEntity; import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; +import org.apache.ibatis.annotations.Update; @Mapper public interface FileObjectMapper extends BaseMapper { + @Update("UPDATE file_object SET status='VOID',deleted=1,updated_by=#{operatorId}," + + "updated_at=CURRENT_TIMESTAMP(3),version=version+1 WHERE id=#{fileId} AND tenant_id=#{tenantId} " + + "AND business_type=#{businessType} AND business_id=#{businessId} AND status='ACTIVE' " + + "AND deleted=0 AND version=#{version}") + int voidOwned(@Param("tenantId") long tenantId, @Param("businessType") String businessType, + @Param("businessId") String businessId, @Param("fileId") long fileId, + @Param("version") int version, @Param("operatorId") long operatorId); } diff --git a/file/src/main/java/com/baiyue/file/service/BusinessFileStorage.java b/file/src/main/java/com/baiyue/file/service/BusinessFileStorage.java index 5087c9ae9..f528b7cda 100644 --- a/file/src/main/java/com/baiyue/file/service/BusinessFileStorage.java +++ b/file/src/main/java/com/baiyue/file/service/BusinessFileStorage.java @@ -65,6 +65,16 @@ public class BusinessFileStorage { } } + boolean delete(String objectKey) { + Path target = root.resolve(objectKey).normalize(); + if (!target.startsWith(root)) return false; + try { + return Files.deleteIfExists(target); + } catch (IOException exception) { + return false; + } + } + private void verifyContent(MultipartFile file, String type) { try (InputStream input = file.getInputStream()) { byte[] prefix = input.readNBytes(4096); diff --git a/file/src/main/java/com/baiyue/file/service/InternalBusinessFileService.java b/file/src/main/java/com/baiyue/file/service/InternalBusinessFileService.java index 86268a298..7ac68d48b 100644 --- a/file/src/main/java/com/baiyue/file/service/InternalBusinessFileService.java +++ b/file/src/main/java/com/baiyue/file/service/InternalBusinessFileService.java @@ -7,6 +7,7 @@ import com.baiyue.file.entity.FileObjectEntity; import com.baiyue.file.mapper.FileObjectMapper; import jakarta.annotation.Resource; import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; import org.springframework.web.multipart.MultipartFile; import java.time.LocalDateTime; @@ -20,7 +21,7 @@ public class InternalBusinessFileService { "CUSTOMS_RECEIPT", "FINANCE_VOUCHER", "CONTAINER_CLEARANCE_DOCUMENT", - "LOADING_CUSTOMS_DOCUMENT", + "LOADING_CUSTOMS_DECLARATION", "CONTAINER_DELIVERY_ORDER", "CONTAINER_PICKUP_ORDER", "OVERSEAS_UNLOAD_PHOTO", @@ -70,6 +71,16 @@ public class InternalBusinessFileService { return new Download(value.getOriginalName(), value.getContentType(), storage.read(value.getObjectKey())); } + @Transactional + public void delete(long tenantId, String businessType, String businessId, long fileId, long operatorId) { + if (operatorId <= 0) throw new BizException(ResultCode.BAD_REQUEST, "操作人必须有效"); + FileObjectEntity value = required(tenantId, businessType, businessId, fileId); + int changed = files.voidOwned(tenantId, businessType, businessId, fileId, + value.getVersion(), operatorId); + if (changed != 1) throw new BizException(ResultCode.CONFLICT, "业务附件已变化,请刷新后重试"); + storage.delete(value.getObjectKey()); + } + private FileObjectEntity required(long tenantId, String businessType, String businessId, long fileId) { validateScope(tenantId, businessType, businessId, fileId); FileObjectEntity value = files.selectOne(new LambdaQueryWrapper() diff --git a/file/src/test/java/com/baiyue/file/controller/InternalBusinessFileControllerTest.java b/file/src/test/java/com/baiyue/file/controller/InternalBusinessFileControllerTest.java index 96c7bb064..65ea3cbae 100644 --- a/file/src/test/java/com/baiyue/file/controller/InternalBusinessFileControllerTest.java +++ b/file/src/test/java/com/baiyue/file/controller/InternalBusinessFileControllerTest.java @@ -35,14 +35,22 @@ class InternalBusinessFileControllerTest { } @Test void zeroTenantDownloadReturnsOwnedContentWithoutCaching() { - when(service.download(0L, "LOADING_CUSTOMS_DOCUMENT", "51", 91L)) + when(service.download(0L, "LOADING_CUSTOMS_DECLARATION", "51", 91L)) .thenReturn(new InternalBusinessFileService.Download("declaration.pdf", "application/pdf", new byte[]{1, 2})); - var response = controller.download("internal-token", 0L, "LOADING_CUSTOMS_DOCUMENT", "51", 91L); + var response = controller.download("internal-token", 0L, "LOADING_CUSTOMS_DECLARATION", "51", 91L); assertThat(response.getBody()).containsExactly(1, 2); assertThat(response.getHeaders().getCacheControl()).contains("no-store"); - verify(service).download(0L, "LOADING_CUSTOMS_DOCUMENT", "51", 91L); + verify(service).download(0L, "LOADING_CUSTOMS_DECLARATION", "51", 91L); + } + + @Test void exactOwnershipCompensationDeleteRemainsBehindInternalToken() { + var response = controller.delete("internal-token", 7L, 0L, + "CONTAINER_DELIVERY_ORDER", "41", 91L); + + assertThat(response.successful()).isTrue(); + verify(service).delete(0L, "CONTAINER_DELIVERY_ORDER", "41", 91L, 7L); } @Test void rejectsInvalidTokenAndNegativeTenantBeforeServiceAccess() { diff --git a/file/src/test/java/com/baiyue/file/service/InternalBusinessFileServiceTest.java b/file/src/test/java/com/baiyue/file/service/InternalBusinessFileServiceTest.java index f0cc94a06..2840fa856 100644 --- a/file/src/test/java/com/baiyue/file/service/InternalBusinessFileServiceTest.java +++ b/file/src/test/java/com/baiyue/file/service/InternalBusinessFileServiceTest.java @@ -47,7 +47,7 @@ class InternalBusinessFileServiceTest { }); var clearance = service.upload(0L, "CONTAINER_CLEARANCE_DOCUMENT", "41", 7L, file); - var customs = service.upload(0L, "LOADING_CUSTOMS_DOCUMENT", "51", 7L, file); + var customs = service.upload(0L, "LOADING_CUSTOMS_DECLARATION", "51", 7L, file); var deliveryOrder = service.upload(0L, "CONTAINER_DELIVERY_ORDER", "41", 7L, file); var pickupOrder = service.upload(0L, "CONTAINER_PICKUP_ORDER", "41", 7L, file); var unloadPhoto = service.upload(11L, "OVERSEAS_UNLOAD_PHOTO", "51", 7L, file); @@ -57,7 +57,7 @@ class InternalBusinessFileServiceTest { InternalBusinessFileService.Metadata::businessId).containsExactly(0L, "CONTAINER_CLEARANCE_DOCUMENT", "41"); assertThat(customs).extracting(InternalBusinessFileService.Metadata::tenantId, InternalBusinessFileService.Metadata::businessType, - InternalBusinessFileService.Metadata::businessId).containsExactly(0L, "LOADING_CUSTOMS_DOCUMENT", "51"); + InternalBusinessFileService.Metadata::businessId).containsExactly(0L, "LOADING_CUSTOMS_DECLARATION", "51"); assertThat(deliveryOrder.businessType()).isEqualTo("CONTAINER_DELIVERY_ORDER"); assertThat(pickupOrder.businessType()).isEqualTo("CONTAINER_PICKUP_ORDER"); assertThat(unloadPhoto).extracting(InternalBusinessFileService.Metadata::tenantId, @@ -119,22 +119,47 @@ class InternalBusinessFileServiceTest { @Test void platformMetadataAndDownloadRequireExactCompositeOwnership() { FileObjectEntity value = new FileObjectEntity(); value.setId(91L); value.setTenantId(0L); - value.setBusinessType("LOADING_CUSTOMS_DOCUMENT"); value.setBusinessId("51"); + value.setBusinessType("LOADING_CUSTOMS_DECLARATION"); value.setBusinessId("51"); value.setOriginalName("declaration.pdf"); value.setObjectKey("business-files/0/51/declaration.pdf"); value.setContentType("application/pdf"); value.setSha256("c".repeat(64)); value.setSizeBytes(8L); when(files.selectOne(any())).thenReturn(value, value, null, null, null); when(storage.read(value.getObjectKey())).thenReturn("document".getBytes()); - assertThat(service.metadata(0L, "LOADING_CUSTOMS_DOCUMENT", "51", 91L).fileName()) + assertThat(service.metadata(0L, "LOADING_CUSTOMS_DECLARATION", "51", 91L).fileName()) .isEqualTo("declaration.pdf"); - assertThat(service.download(0L, "LOADING_CUSTOMS_DOCUMENT", "51", 91L).content()) + assertThat(service.download(0L, "LOADING_CUSTOMS_DECLARATION", "51", 91L).content()) .isEqualTo("document".getBytes()); assertThatThrownBy(() -> service.metadata(0L, "CONTAINER_CLEARANCE_DOCUMENT", "51", 91L)) .isInstanceOf(BizException.class).hasMessageContaining("归属不匹配"); - assertThatThrownBy(() -> service.metadata(0L, "LOADING_CUSTOMS_DOCUMENT", "52", 91L)) + assertThatThrownBy(() -> service.metadata(0L, "LOADING_CUSTOMS_DECLARATION", "52", 91L)) .isInstanceOf(BizException.class).hasMessageContaining("归属不匹配"); - assertThatThrownBy(() -> service.metadata(0L, "LOADING_CUSTOMS_DOCUMENT", "51", 92L)) + assertThatThrownBy(() -> service.metadata(0L, "LOADING_CUSTOMS_DECLARATION", "51", 92L)) .isInstanceOf(BizException.class).hasMessageContaining("归属不匹配"); verify(storage).read(value.getObjectKey()); } + + @Test void compensationDeleteRequiresExactOwnershipAndLogicallyVoidsBeforeStorageCleanup() { + FileObjectEntity value = new FileObjectEntity(); + value.setId(91L); value.setTenantId(0L); value.setBusinessType("CONTAINER_DELIVERY_ORDER"); + value.setBusinessId("41"); value.setStatus("ACTIVE"); value.setDeleted(false); value.setVersion(0); + value.setObjectKey("business-files/0/41/do.pdf"); + when(files.selectOne(any())).thenReturn(value); + when(files.voidOwned(0L, "CONTAINER_DELIVERY_ORDER", "41", 91L, 0, 7L)).thenReturn(1); + when(storage.delete(value.getObjectKey())).thenReturn(true); + + service.delete(0L, "CONTAINER_DELIVERY_ORDER", "41", 91L, 7L); + + verify(files).voidOwned(0L, "CONTAINER_DELIVERY_ORDER", "41", 91L, 0, 7L); + verify(storage).delete(value.getObjectKey()); + } + + @Test void compensationMapperUsesExactCompositeOwnershipAndOptimisticVersion() throws Exception { + String sql = String.join(" ", FileObjectMapper.class.getMethod("voidOwned", long.class, + String.class, String.class, long.class, int.class, long.class) + .getAnnotation(org.apache.ibatis.annotations.Update.class).value()); + assertThat(sql) + .contains("id=#{fileId}", "tenant_id=#{tenantId}", "business_type=#{businessType}") + .contains("business_id=#{businessId}", "status='ACTIVE'", "deleted=0", "version=#{version}") + .contains("status='VOID'", "deleted=1", "version=version+1"); + } } diff --git a/firstleg/src/main/java/com/baiyue/firstleg/client/LoadingDocumentFileClient.java b/firstleg/src/main/java/com/baiyue/firstleg/client/LoadingDocumentFileClient.java index e025b7d3b..307360ec6 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/client/LoadingDocumentFileClient.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/client/LoadingDocumentFileClient.java @@ -3,6 +3,7 @@ package com.baiyue.firstleg.client; import com.baiyue.common.core.api.ApiResponse; import org.springframework.cloud.openfeign.FeignClient; import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.DeleteMapping; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestHeader; @@ -29,6 +30,12 @@ public interface LoadingDocumentFileClient { ResponseEntity download(@RequestHeader("X-Internal-Service-Token") String token, @PathVariable long tenantId,@PathVariable String businessType,@PathVariable String businessId,@PathVariable long fileId); + @DeleteMapping("/{tenantId}/{businessType}/{businessId}/{fileId}") + ApiResponse delete(@RequestHeader("X-Internal-Service-Token") String token, + @RequestHeader("X-Operator-Id") long operatorId, + @PathVariable long tenantId, @PathVariable String businessType, + @PathVariable String businessId, @PathVariable long fileId); + record Metadata(Long fileId, Long tenantId, String businessType, String businessId, String fileName, String sha256, String contentType, Long sizeBytes) {} } diff --git a/firstleg/src/main/java/com/baiyue/firstleg/customs/mapper/CustomsCaseMapper.java b/firstleg/src/main/java/com/baiyue/firstleg/customs/mapper/CustomsCaseMapper.java index 8f12886f8..68a968aa4 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/customs/mapper/CustomsCaseMapper.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/customs/mapper/CustomsCaseMapper.java @@ -2,4 +2,10 @@ package com.baiyue.firstleg.customs.mapper; import com.baomidou.mybatisplus.core.mapper.BaseMapper; import com.baiyue.firstleg.customs.entity.CustomsCaseEntity; import org.apache.ibatis.annotations.Mapper; -@Mapper public interface CustomsCaseMapper extends BaseMapper {} +import org.apache.ibatis.annotations.Param; +import org.apache.ibatis.annotations.Select; +@Mapper public interface CustomsCaseMapper extends BaseMapper { + @Select("SELECT COUNT(*) FROM fl_container_document WHERE container_booking_id=#{containerBookingId} " + + "AND document_type='CUSTOMS_DECLARATION' AND completed_at IS NOT NULL") + int completedLoadingDeclaration(@Param("containerBookingId") long containerBookingId); +} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/customs/repository/CustomsRepository.java b/firstleg/src/main/java/com/baiyue/firstleg/customs/repository/CustomsRepository.java index 6bf93297d..3d8a35e42 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/customs/repository/CustomsRepository.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/customs/repository/CustomsRepository.java @@ -81,11 +81,8 @@ public class CustomsRepository { .eq(CustomsCaseEntity::getContainerBookingId, containerBookingId) .orderByDesc(CustomsCaseEntity::getUpdatedAt).last("LIMIT 1")); } - public CustomsCaseEntity clearedContainerCase(long containerBookingId) { - return cases.selectOne(new LambdaQueryWrapper() - .eq(CustomsCaseEntity::getContainerBookingId, containerBookingId) - .eq(CustomsCaseEntity::getStatus, "CLEARANCE_COMPLETED") - .orderByDesc(CustomsCaseEntity::getUpdatedAt).last("LIMIT 1")); + public boolean loadingDeclarationCompleted(long containerBookingId) { + return cases.completedLoadingDeclaration(containerBookingId) == 1; } public boolean containerCarriesOrder(long containerId, long tenantId, long orderId) { LoadingPlanEntity plan = loadingPlans.selectOne(new LambdaQueryWrapper() diff --git a/firstleg/src/main/java/com/baiyue/firstleg/customs/service/CustomsDispatchGateContributor.java b/firstleg/src/main/java/com/baiyue/firstleg/customs/service/CustomsDispatchGateContributor.java index ed2c87fc9..61a048f5a 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/customs/service/CustomsDispatchGateContributor.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/customs/service/CustomsDispatchGateContributor.java @@ -1,6 +1,5 @@ package com.baiyue.firstleg.customs.service; -import com.baiyue.firstleg.customs.entity.CustomsCaseEntity; import com.baiyue.firstleg.customs.repository.CustomsRepository; import com.baiyue.firstleg.outbound.dispatch.dto.OutboundDispatchDtos.GateItem; import com.baiyue.firstleg.outbound.dispatch.gate.OutboundDispatchGateContributor; @@ -18,12 +17,11 @@ public class CustomsDispatchGateContributor implements OutboundDispatchGateContr if (context.tenantId() <= 0 || context.customerId() <= 0) { return gate(false, "INVALID_CUSTOMER_SCOPE"); } - CustomsCaseEntity value = repository.clearedContainerCase(context.containerBookingId()); - if (value == null) return gate(false, "MISSING"); - return gate("CLEARANCE_COMPLETED".equals(value.getStatus()), value.getStatus()); + boolean completed = repository.loadingDeclarationCompleted(context.containerBookingId()); + return gate(completed, completed ? "COMPLETED" : "MISSING"); } private static GateItem gate(boolean passed, String actual) { - return new GateItem("CUSTOMS_CLEARANCE", "报关完成", passed, actual, "CLEARANCE_COMPLETED"); + return new GateItem("CUSTOMS_CLEARANCE", "报关完成", passed, actual, "COMPLETED"); } } diff --git a/firstleg/src/main/java/com/baiyue/firstleg/repository/LoadingListRepository.java b/firstleg/src/main/java/com/baiyue/firstleg/repository/LoadingListRepository.java index 86fad576e..e363c9a2d 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/repository/LoadingListRepository.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/repository/LoadingListRepository.java @@ -106,9 +106,13 @@ public class LoadingListRepository { String fromStatus, String toStatus, String beforeSnapshot, String afterSnapshot, long operatorId, String operatorName, String operationReason) { - mapper.insertHistory(new HistoryWrite(IdWorker.getId(), planId, operationId, actionType, + int changed = mapper.insertHistory(new HistoryWrite(IdWorker.getId(), planId, operationId, actionType, fromStatus, toStatus, beforeSnapshot, afterSnapshot, operatorId, operatorName, operationReason)); + if (changed != 1) { + throw new com.baiyue.common.core.exception.BizException( + com.baiyue.common.core.api.ResultCode.CONFLICT, "装柜历史已变化,请刷新后重试"); + } } private Map> customerMap(List rows) { diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingListService.java b/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingListService.java index fc96d5cf4..4d6d63f49 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingListService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingListService.java @@ -31,12 +31,15 @@ import com.baomidou.mybatisplus.core.toolkit.IdWorker; import feign.FeignException; import org.springframework.beans.factory.annotation.Value; import org.springframework.dao.DuplicateKeyException; +import org.springframework.dao.DataIntegrityViolationException; import org.springframework.stereotype.Service; import org.springframework.context.annotation.Lazy; import org.springframework.transaction.annotation.Transactional; import org.springframework.util.StringUtils; import org.springframework.web.multipart.MultipartFile; import org.springframework.http.MediaType; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; import java.nio.charset.StandardCharsets; import java.security.MessageDigest; @@ -49,6 +52,7 @@ import java.util.Set; @Service public class LoadingListService { + private static final Logger log = LoggerFactory.getLogger(LoadingListService.class); private static final Set PRELOAD_STATUSES = Set.of("EDITING", "LOADING"); public static final String DESTINATION_CLEARANCE = "DESTINATION_CLEARANCE"; public static final String CUSTOMS_DECLARATION = "CUSTOMS_DECLARATION"; @@ -317,8 +321,16 @@ public class LoadingListService { requireReplaceableDocument(current, expectedVersion); LoadingDocumentFileClient.Metadata metadata = uploadDocumentFile( businessType, businessId, operatorId, file, fileHash); - return persistDocument(plan, documentType, current, metadata, operatorId, operatorName, - operationId, "UPLOAD_" + documentType, requestHash, operationReason); + try { + return persistDocument(plan, documentType, current, metadata, operatorId, operatorName, + operationId, "UPLOAD_" + documentType, requestHash, operationReason); + } catch (RuntimeException exception) { + compensateUploadedFile(businessType, businessId, metadata.fileId(), operatorId); + if (exception instanceof DataIntegrityViolationException) { + throw conflict("资料状态或数据库约束已变化,请刷新后重试"); + } + throw exception; + } } private BusinessDocument persistDocument(AggregateRow plan, String documentType, @@ -337,12 +349,18 @@ public class LoadingListService { if (changed != 1) throw conflict("资料版本已变化,请刷新后重试"); } catch (DuplicateKeyException exception) { throw conflict("资料已被其他操作上传,请刷新后重试"); + } catch (DataIntegrityViolationException exception) { + throw conflict("资料状态或数据库约束已变化,请刷新后重试"); } ContainerDocumentEntity saved = requireDocumentEntity(plan.getContainerBookingId(), documentType); - repository.appendHistory(plan.getPlanId(), operationId, actionType, - plan.getPlanStatus(), plan.getPlanStatus(), json(beforeSnapshot), - json(values("requestHash", requestHash, "document", documentSnapshot(saved))), - operatorId, trim(operatorName), trim(operationReason)); + try { + repository.appendHistory(plan.getPlanId(), operationId, actionType, + plan.getPlanStatus(), plan.getPlanStatus(), json(beforeSnapshot), + json(values("requestHash", requestHash, "document", documentSnapshot(saved))), + operatorId, trim(operatorName), trim(operationReason)); + } catch (DataIntegrityViolationException exception) { + throw conflict("资料历史约束已变化,请刷新后重试"); + } return document(saved); } @@ -414,6 +432,19 @@ public class LoadingListService { } } + private void compensateUploadedFile(String businessType, String businessId, long fileId, long operatorId) { + try { + var response = documentFiles.delete(internalToken, operatorId, 0, businessType, businessId, fileId); + if (response == null || !response.successful()) { + log.error("Business file compensation failed: type={}, businessId={}, fileId={}", + businessType, businessId, fileId); + } + } catch (RuntimeException exception) { + log.error("Business file compensation request failed: type={}, businessId={}, fileId={}", + businessType, businessId, fileId, exception); + } + } + private static void requireReplaceableDocument(ContainerDocumentEntity current, int expectedVersion) { if (current == null && expectedVersion != 0) throw conflict("资料版本已变化,请刷新后重试"); if (current != null && !java.util.Objects.equals(current.getVersion(), expectedVersion)) { diff --git a/firstleg/src/main/resources/db/phase3-simple-customs-document-migration.sql b/firstleg/src/main/resources/db/phase3-simple-customs-document-migration.sql index 552d13600..1ee186d2b 100644 --- a/firstleg/src/main/resources/db/phase3-simple-customs-document-migration.sql +++ b/firstleg/src/main/resources/db/phase3-simple-customs-document-migration.sql @@ -70,7 +70,7 @@ BEGIN SELECT MAX(plan_status) INTO current_plan_status FROM fl_loading_plan WHERE container_booking_id = NEW.container_booking_id; IF current_plan_status = 'COMPLETED' - AND NEW.document_type NOT IN ('DESTINATION_CLEARANCE', 'CUSTOMS_DECLARATION') THEN + AND NEW.document_type NOT IN ('DESTINATION_CLEARANCE', 'CUSTOMS_DECLARATION', 'DELIVERY_ORDER', 'PICKUP_ORDER') THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '装柜完成后货柜物流文件不可新增'; END IF; END$$ @@ -84,7 +84,7 @@ BEGIN SELECT MAX(plan_status) INTO current_plan_status FROM fl_loading_plan WHERE container_booking_id = OLD.container_booking_id; IF current_plan_status = 'COMPLETED' - AND OLD.document_type NOT IN ('DESTINATION_CLEARANCE', 'CUSTOMS_DECLARATION') THEN + AND OLD.document_type NOT IN ('DESTINATION_CLEARANCE', 'CUSTOMS_DECLARATION', 'DELIVERY_ORDER', 'PICKUP_ORDER') THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '装柜完成后货柜物流文件不可修改'; END IF; END$$ diff --git a/firstleg/src/main/resources/db/phase3-smart-loading-schema.sql b/firstleg/src/main/resources/db/phase3-smart-loading-schema.sql index ab4efc31c..5033d5924 100644 --- a/firstleg/src/main/resources/db/phase3-smart-loading-schema.sql +++ b/firstleg/src/main/resources/db/phase3-smart-loading-schema.sql @@ -1122,7 +1122,7 @@ BEGIN SELECT MAX(plan_status) INTO current_plan_status FROM fl_loading_plan WHERE container_booking_id = NEW.container_booking_id; IF current_plan_status = 'COMPLETED' - AND NEW.document_type NOT IN ('DESTINATION_CLEARANCE', 'CUSTOMS_DECLARATION') THEN + AND NEW.document_type NOT IN ('DESTINATION_CLEARANCE', 'CUSTOMS_DECLARATION', 'DELIVERY_ORDER', 'PICKUP_ORDER') THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '装柜完成后货柜文件不可新增'; END IF; END$$ @@ -1138,7 +1138,7 @@ BEGIN SELECT MAX(plan_status) INTO current_plan_status FROM fl_loading_plan WHERE container_booking_id = OLD.container_booking_id; IF current_plan_status = 'COMPLETED' - AND OLD.document_type NOT IN ('DESTINATION_CLEARANCE', 'CUSTOMS_DECLARATION') THEN + AND OLD.document_type NOT IN ('DESTINATION_CLEARANCE', 'CUSTOMS_DECLARATION', 'DELIVERY_ORDER', 'PICKUP_ORDER') THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '装柜完成后货柜文件不可修改'; END IF; END$$ diff --git a/firstleg/src/main/resources/openapi/phase3-outbound-dispatch-v1.yaml b/firstleg/src/main/resources/openapi/phase3-outbound-dispatch-v1.yaml index aeeeb7b18..364c0b142 100644 --- a/firstleg/src/main/resources/openapi/phase3-outbound-dispatch-v1.yaml +++ b/firstleg/src/main/resources/openapi/phase3-outbound-dispatch-v1.yaml @@ -37,7 +37,7 @@ paths: /api/platform/v1/first-leg/outbound-dispatches/{containerBookingId}/gate: get: summary: Evaluate every inbound binding in the fail-closed container outbound gate - description: Loading evidence is evaluated once for the physical container/loading plan and the same result applies to every inbound binding. Customs clearance is also resolved once at container scope. A missing standalone sailing schedule is reference-only and never affects gate.passed. + description: Loading evidence is evaluated once for the physical container/loading plan and the same result applies to every inbound binding. The customs gate is resolved once from the current container CUSTOMS_DECLARATION whose completedAt must be non-null; upload alone remains blocked and fl_customs_case is not an alternative gate fact. A missing standalone sailing schedule is reference-only and never affects gate.passed. x-permission: platform:firstleg:outbound-dispatch:check parameters: - {$ref: '#/components/parameters/ContainerBookingIdPath'} diff --git a/firstleg/src/test/java/com/baiyue/firstleg/customs/service/CustomsDispatchGateContributorTest.java b/firstleg/src/test/java/com/baiyue/firstleg/customs/service/CustomsDispatchGateContributorTest.java index be11e6816..dc499e353 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/customs/service/CustomsDispatchGateContributorTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/customs/service/CustomsDispatchGateContributorTest.java @@ -1,7 +1,7 @@ package com.baiyue.firstleg.customs.service; -import com.baiyue.firstleg.customs.entity.CustomsCaseEntity; import com.baiyue.firstleg.customs.repository.CustomsRepository; +import com.baiyue.firstleg.customs.mapper.CustomsCaseMapper; import com.baiyue.firstleg.outbound.dispatch.gate.OutboundDispatchGateContributor.Context; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; @@ -22,19 +22,37 @@ class CustomsDispatchGateContributorTest { .isEqualTo("INVALID_CUSTOMER_SCOPE"); } - @Test void passesOnlyCanonicalClearanceCompletedCase() { - CustomsCaseEntity value = new CustomsCaseEntity(); - assertThat(contributor.assess(context()).passed()).isFalse(); - value.setStatus("CLEARANCE_COMPLETED"); - when(repository.clearedContainerCase(81L)).thenReturn(value); - assertThat(contributor.assess(context())) - .satisfies(item -> { - assertThat(item.passed()).isTrue(); - assertThat(item.requirement()).isEqualTo("CLEARANCE_COMPLETED"); - }); - verify(repository, times(2)).clearedContainerCase(81L); + @Test void uploadedButIncompleteDeclarationRemainsBlocked() { + when(repository.loadingDeclarationCompleted(81L)).thenReturn(false); + + assertThat(contributor.assess(context())).satisfies(item -> { + assertThat(item.passed()).isFalse(); + assertThat(item.actual()).isEqualTo("MISSING"); + assertThat(item.requirement()).isEqualTo("COMPLETED"); + }); + } + + @Test void completedLoadingDeclarationPassesWithoutReadingCustomsCase() { + when(repository.loadingDeclarationCompleted(81L)).thenReturn(true); + + assertThat(contributor.assess(context())).satisfies(item -> { + assertThat(item.passed()).isTrue(); + assertThat(item.actual()).isEqualTo("COMPLETED"); + assertThat(item.requirement()).isEqualTo("COMPLETED"); + }); + verify(repository).loadingDeclarationCompleted(81L); verify(repository, never()).gateCase(anyLong(), anyLong(), anyLong(), anyLong()); } + @Test void mapperUsesOnlyCompletedLoadingDeclarationDocumentFact() throws Exception { + String sql = String.join(" ", CustomsCaseMapper.class + .getMethod("completedLoadingDeclaration", long.class) + .getAnnotation(org.apache.ibatis.annotations.Select.class).value()); + assertThat(sql) + .contains("FROM fl_container_document", "container_booking_id=#{containerBookingId}") + .contains("document_type='CUSTOMS_DECLARATION'", "completed_at IS NOT NULL") + .doesNotContain("fl_customs_case", "CLEARANCE_COMPLETED"); + } + private static Context context() { return new Context(10001L, 10002L, 21L, 31L, 81L, 91L, null); } } diff --git a/firstleg/src/test/java/com/baiyue/firstleg/outbound/dispatch/OutboundDispatchSqlContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/outbound/dispatch/OutboundDispatchSqlContractTest.java index 024cefac7..0480e784e 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/outbound/dispatch/OutboundDispatchSqlContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/outbound/dispatch/OutboundDispatchSqlContractTest.java @@ -51,7 +51,8 @@ class OutboundDispatchSqlContractTest { .contains("absence never blocks manual outbound confirmation") .contains("PENDING or any non-terminal state never affects gate.passed") .contains("Loading evidence is evaluated once for the physical container/loading plan") - .contains("Customs clearance is also resolved once at container scope") + .contains("CUSTOMS_DECLARATION whose completedAt must be non-null") + .contains("fl_customs_case is not an alternative gate fact") .contains("missing standalone sailing schedule is reference-only") .contains("expectedVersion") .contains("Idempotency-Key") diff --git a/firstleg/src/test/java/com/baiyue/firstleg/repository/LoadingListQueryMapperContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/repository/LoadingListQueryMapperContractTest.java index 11e77ed09..d25d76a32 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/repository/LoadingListQueryMapperContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/repository/LoadingListQueryMapperContractTest.java @@ -116,6 +116,21 @@ class LoadingListQueryMapperContractTest { .contains("operator_id"); } + @Test + void deliveryAndPickupUseTheCanonicalDocumentInsertAndPlanHistoryMapper() throws Exception { + String documentInsert = sql("insertDocument", Insert.class); + String historyInsert = sql("insertHistory", Insert.class); + assertThat(documentInsert) + .contains("INSERT INTO fl_container_document") + .contains("#{documentType}", "#{fileId}", "#{fileHash}") + .contains("CURRENT_TIMESTAMP(3)", "version") + .doesNotContain("CUSTOMS_DECLARATION", "DESTINATION_CLEARANCE"); + assertThat(historyInsert) + .contains("INSERT INTO fl_loading_plan_history") + .contains("#{planId}", "#{operationId}", "#{actionType}") + .contains("#{beforeSnapshot}", "#{afterSnapshot}"); + } + private static String sql(String methodName, Class annotationType) throws Exception { Method method = java.util.Arrays.stream(LoadingListQueryMapper.class.getDeclaredMethods()) .filter(candidate -> candidate.getName().equals(methodName)) diff --git a/firstleg/src/test/java/com/baiyue/firstleg/repository/SmartLoadingSqlContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/repository/SmartLoadingSqlContractTest.java index 27c6c17a3..e5c1ddc08 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/repository/SmartLoadingSqlContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/repository/SmartLoadingSqlContractTest.java @@ -151,8 +151,8 @@ class SmartLoadingSqlContractTest { .contains("CREATE TRIGGER trg_container_booking_before_update") .contains("current_plan_status IN ('LOADING','COMPLETED')") .contains("装柜作业开始后仅允许修改集装箱号与放舱运输数据") - .contains("NEW.document_type NOT IN ('DESTINATION_CLEARANCE', 'CUSTOMS_DECLARATION')") - .contains("OLD.document_type NOT IN ('DESTINATION_CLEARANCE', 'CUSTOMS_DECLARATION')"); + .contains("NEW.document_type NOT IN ('DESTINATION_CLEARANCE', 'CUSTOMS_DECLARATION', 'DELIVERY_ORDER', 'PICKUP_ORDER')") + .contains("OLD.document_type NOT IN ('DESTINATION_CLEARANCE', 'CUSTOMS_DECLARATION', 'DELIVERY_ORDER', 'PICKUP_ORDER')"); } private static void assertSimpleCustomsDocumentContract(String sql) { @@ -161,8 +161,8 @@ class SmartLoadingSqlContractTest { .contains("file_hash VARCHAR(64) NULL") .contains("completed_by BIGINT UNSIGNED NULL") .contains("completed_at DATETIME(3) NULL") - .contains("NEW.document_type NOT IN ('DESTINATION_CLEARANCE', 'CUSTOMS_DECLARATION')") - .contains("OLD.document_type NOT IN ('DESTINATION_CLEARANCE', 'CUSTOMS_DECLARATION')") + .contains("NEW.document_type NOT IN ('DESTINATION_CLEARANCE', 'CUSTOMS_DECLARATION', 'DELIVERY_ORDER', 'PICKUP_ORDER')") + .contains("OLD.document_type NOT IN ('DESTINATION_CLEARANCE', 'CUSTOMS_DECLARATION', 'DELIVERY_ORDER', 'PICKUP_ORDER')") .doesNotContain("OLD.document_type NOT IN ('DESTINATION_CLEARANCE', 'CUSTOMS_DECLARATION') THEN\n SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '装柜完成后货柜全部只读"); } diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingListServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingListServiceTest.java index 8d2e33874..de1ef024c 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingListServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingListServiceTest.java @@ -36,7 +36,9 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.ArgumentMatchers.isNull; import static org.mockito.Mockito.never; import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; @@ -597,6 +599,129 @@ class LoadingListServiceTest { verify(repository, never()).aggregateByBooking(30L); } + @Test + void firstDeliveryOrderUploadPersistsDocumentAndImmutablePlanHistory() { + AggregateRow plan = row("COMPLETED", 4); + byte[] content = "delivery-order".getBytes(java.nio.charset.StandardCharsets.UTF_8); + var file = new MockMultipartFile("file", "do.pdf", "application/pdf", content); + String contentHash = sha256Bytes(content); + var metadata = new LoadingDocumentFileClient.Metadata(101L, 0L, + "CONTAINER_DELIVERY_ORDER", "30", "do.pdf", contentHash, "application/pdf", (long) content.length); + ContainerDocumentEntity saved = document(51L, LoadingListService.DELIVERY_ORDER, 0, false); + saved.setFileId(101L); saved.setFileName("do.pdf"); saved.setFileHash(contentHash); + when(repository.aggregateByBooking(30L)).thenReturn(plan); + when(repository.history(anyString())).thenReturn(null); + when(repository.document(30L, LoadingListService.DELIVERY_ORDER)).thenReturn(null, saved); + when(documentFiles.upload(anyString(), eq(9L), eq(0L), eq("CONTAINER_DELIVERY_ORDER"), + eq("30"), eq(file))).thenReturn(ApiResponse.success(metadata)); + when(documentFiles.metadata(anyString(), eq(0L), eq("CONTAINER_DELIVERY_ORDER"), + eq("30"), eq(101L))).thenReturn(ApiResponse.success(metadata)); + when(repository.createDocument(any())).thenReturn(1); + + var result = service.uploadContainerLogisticsDocument(30L, LoadingListService.DELIVERY_ORDER, + 9L, "operator", "do-first", 0, null, file); + + assertThat(result).extracting(value -> value.documentType(), value -> value.fileId(), value -> value.version()) + .containsExactly(LoadingListService.DELIVERY_ORDER, 101L, 0); + verify(repository).appendHistory(eq(10L), anyString(), eq("UPLOAD_DELIVERY_ORDER"), + eq("COMPLETED"), eq("COMPLETED"), anyString(), anyString(), eq(9L), eq("operator"), isNull()); + verify(documentFiles, never()).delete(anyString(), anyLong(), anyLong(), anyString(), anyString(), anyLong()); + } + + @Test + void pickupOrderUpdateUsesCurrentVersionAndReturnsUpdatedDocument() { + AggregateRow plan = row("COMPLETED", 4); + byte[] content = "pickup-order".getBytes(java.nio.charset.StandardCharsets.UTF_8); + var file = new MockMultipartFile("file", "pu.pdf", "application/pdf", content); + String contentHash = sha256Bytes(content); + ContainerDocumentEntity current = document(52L, LoadingListService.PICKUP_ORDER, 2, false); + ContainerDocumentEntity saved = document(52L, LoadingListService.PICKUP_ORDER, 3, false); + saved.setFileId(102L); saved.setFileName("pu.pdf"); saved.setFileHash(contentHash); + var metadata = new LoadingDocumentFileClient.Metadata(102L, 0L, + "CONTAINER_PICKUP_ORDER", "30", "pu.pdf", contentHash, "application/pdf", (long) content.length); + when(repository.aggregateByBooking(30L)).thenReturn(plan); + when(repository.history(anyString())).thenReturn(null); + when(repository.document(30L, LoadingListService.PICKUP_ORDER)).thenReturn(current, saved); + when(documentFiles.upload(anyString(), eq(9L), eq(0L), eq("CONTAINER_PICKUP_ORDER"), + eq("30"), eq(file))).thenReturn(ApiResponse.success(metadata)); + when(documentFiles.metadata(anyString(), eq(0L), eq("CONTAINER_PICKUP_ORDER"), + eq("30"), eq(102L))).thenReturn(ApiResponse.success(metadata)); + when(repository.replaceDocument(any())).thenReturn(1); + + var result = service.uploadContainerLogisticsDocument(30L, LoadingListService.PICKUP_ORDER, + 9L, "operator", "pu-update", 2, null, file); + + assertThat(result.fileId()).isEqualTo(102L); + assertThat(result.version()).isEqualTo(3); + verify(repository).replaceDocument(any()); + verify(repository).appendHistory(eq(10L), anyString(), eq("UPLOAD_PICKUP_ORDER"), + eq("COMPLETED"), eq("COMPLETED"), anyString(), anyString(), eq(9L), eq("operator"), isNull()); + } + + @Test + void staleDeliveryOrderVersionReturnsConflictBeforeUploadingAnotherFile() { + AggregateRow plan = row("COMPLETED", 4); + when(repository.aggregateByBooking(30L)).thenReturn(plan); + when(repository.history(anyString())).thenReturn(null); + when(repository.document(30L, LoadingListService.DELIVERY_ORDER)) + .thenReturn(document(51L, LoadingListService.DELIVERY_ORDER, 2, false)); + var file = new MockMultipartFile("file", "do.pdf", "application/pdf", "do".getBytes()); + + assertThatThrownBy(() -> service.uploadContainerLogisticsDocument(30L, + LoadingListService.DELIVERY_ORDER, 9L, "operator", "do-stale", 1, null, file)) + .isInstanceOf(BizException.class) + .extracting(error -> ((BizException) error).getResultCode()).isEqualTo(ResultCode.CONFLICT); + verify(documentFiles, never()).upload(anyString(), anyLong(), anyLong(), anyString(), anyString(), any()); + } + + @Test + void sameDeliveryOrderUploadKeyReplaysWithoutUploadingAgain() { + AggregateRow plan = row("COMPLETED", 4); + byte[] content = "delivery-order".getBytes(java.nio.charset.StandardCharsets.UTF_8); + var file = new MockMultipartFile("file", "do.pdf", "application/pdf", content); + String requestHash = sha256("UPLOAD|DELIVERY_ORDER|30|0|null|" + sha256Bytes(content)); + HistoryRow replay = new HistoryRow(); + replay.setAfterSnapshot("{\"requestHash\":\"" + requestHash + "\"}"); + when(repository.aggregateByBooking(30L)).thenReturn(plan); + when(repository.history(anyString())).thenReturn(replay); + when(repository.document(30L, LoadingListService.DELIVERY_ORDER)) + .thenReturn(document(51L, LoadingListService.DELIVERY_ORDER, 0, false)); + + var result = service.uploadContainerLogisticsDocument(30L, LoadingListService.DELIVERY_ORDER, + 9L, "operator", "do-replay", 0, null, file); + + assertThat(result.documentType()).isEqualTo(LoadingListService.DELIVERY_ORDER); + verify(documentFiles, never()).upload(anyString(), anyLong(), anyLong(), anyString(), anyString(), any()); + } + + @Test + void databaseFailureAfterFileUploadTriggersExactOwnershipCompensationAndBusinessConflict() { + AggregateRow plan = row("COMPLETED", 4); + byte[] content = "delivery-order".getBytes(java.nio.charset.StandardCharsets.UTF_8); + var file = new MockMultipartFile("file", "do.pdf", "application/pdf", content); + String contentHash = sha256Bytes(content); + var metadata = new LoadingDocumentFileClient.Metadata(103L, 0L, + "CONTAINER_DELIVERY_ORDER", "30", "do.pdf", contentHash, "application/pdf", (long) content.length); + when(repository.aggregateByBooking(30L)).thenReturn(plan); + when(repository.history(anyString())).thenReturn(null); + when(repository.document(30L, LoadingListService.DELIVERY_ORDER)).thenReturn(null); + when(documentFiles.upload(anyString(), eq(9L), eq(0L), eq("CONTAINER_DELIVERY_ORDER"), + eq("30"), eq(file))).thenReturn(ApiResponse.success(metadata)); + when(documentFiles.metadata(anyString(), eq(0L), eq("CONTAINER_DELIVERY_ORDER"), + eq("30"), eq(103L))).thenReturn(ApiResponse.success(metadata)); + when(repository.createDocument(any())).thenThrow( + new org.springframework.dao.DataIntegrityViolationException("trigger rejected")); + when(documentFiles.delete(anyString(), eq(9L), eq(0L), eq("CONTAINER_DELIVERY_ORDER"), + eq("30"), eq(103L))).thenReturn(ApiResponse.success(null)); + + assertThatThrownBy(() -> service.uploadContainerLogisticsDocument(30L, + LoadingListService.DELIVERY_ORDER, 9L, "operator", "do-db-fail", 0, null, file)) + .isInstanceOf(BizException.class) + .extracting(error -> ((BizException) error).getResultCode()).isEqualTo(ResultCode.CONFLICT); + verify(documentFiles).delete(anyString(), eq(9L), eq(0L), eq("CONTAINER_DELIVERY_ORDER"), + eq("30"), eq(103L)); + } + @Test void confirmsEveryLinkedPreloadAndCreatesSinglePdaTaskAtomically() { AggregateRow before = row("EDITING", 3); diff --git a/lastleg-service/AGENTS.md b/lastleg-service/AGENTS.md index 828e0e050..6a028f73e 100644 --- a/lastleg-service/AGENTS.md +++ b/lastleg-service/AGENTS.md @@ -2,7 +2,7 @@ ## 职责与边界 -- 负责消费头程装柜完成事件,保存业务 Inbox,并按最终实装入仓单建立尾程订单、明细冻结快照和初始状态历史。 +- 负责消费头程装柜完成事件,保存业务 Inbox,并按最终实装入仓单建立待分配尾程订单、明细冻结快照和初始状态历史;事件不得自动创建入仓计划,平台人工调用 `/api/platform/v1/last-leg/inbound-plans/convert` 后才生成计划并推进订单。 - 负责尾程订单列表、详情、导出和目的仓调整,以及海外入仓计划、托盘分配与 PDA 入仓作业;PDA 卸柜完成后通过本地 Outbox 向国内头程回传货柜报空事实。 - 不查询头程数据库,不调用头程接口补齐建单字段;事件快照缺失时应拒绝消费并保留可诊断错误。 @@ -49,7 +49,7 @@ - 四期在本服务内只进入尾程订单、入仓计划/PDA 入仓、换标、产品与箱唛快照、库存、海外采购和本土卡车;本土散板新增实现也归本服务的独立稳定子域。 - `dispatch/`、`delivery/`、出库单、PDA 出库拣货、派送和其他库存离仓能力虽然已存在于服务代码或表结构中,但不属于当前四期开发与验收范围,不得因代码已存在而自动纳入。 - 历史排车编辑/取消/出库、PDA 出库和 `delivery/` Controller 默认不注册;只有后续阶段明确启用 `lastleg.future-outbound-api.enabled=true` 才可恢复。平台排车列表与详情由 `lastleg.dispatch-query-api.enabled=true` 独立启用;入库单智能排车候选、草稿锁定/解锁/释放和预排创建由 `lastleg.smart-dispatch-api.enabled=true` 独立启用。这些排车平台入口均不接收 `customerId`:排车列表跨客户分页,详情按全局排车单号定位后使用记录自身客户范围加载明细;智能排车候选未传计划 ID 时跨客户查询,传入时根据计划 ID 解析范围;托盘移入草稿时立即以操作人和随机草稿标识锁定,移除或关闭时释放,草稿锁超时失效;预排创建根据所选托盘 ID 在服务端解析并校验唯一客户范围,并将当前操作人的草稿锁原子转换为正式排车锁。预排只能创建待出库排车并锁定托盘,不得借此开放编辑、取消、depart、PDA 出库或派送。四期平台库存 Controller 不映射 allocate、unallocate、pick、ship。 -- 海外仓库、库位、实物托盘和资源锁归 `warehouse-overseas-service/`;浏览器 API 统一经 `gateway-overseas/`,本服务不得复制仓储资源主数据或另建公网认证入口。 +- 仓库 ID、类型、状态、地址和能力唯一权威归国内 `warehouse-service`;海外库位、实物托盘和资源锁归 `warehouse-overseas-service/` 且只引用同一国内 `warehouseId`。Lastleg PDA 通过 Warehouse Overseas 内部资源入口校验该国内共享收货仓,不得要求海外数据库重复建仓;浏览器 API 统一经 `gateway-overseas/`。 ## 数据库表索引与注释约束 @@ -77,6 +77,7 @@ - 其他订单类型只建立入仓单维度的尾程订单与冻结明细,来源 `palletCount` 强制为 0;后续 PDA 托盘属于海外仓作业资源。 - 整个事件消费在一个事务内完成;任意订单失败时不得留下部分订单。 - 相同事件重复投递返回既有结果,不重新生成单号或插入明细。来源入仓单唯一约束是最终防线。 +- 装柜完成事件只把尾程订单建立为 `PENDING_ASSIGNMENT`。`FIRST_LEG` 在途库存使用不可变来源入仓码作为计划生成前的 `inbound_order_no` 展示快照;平台人工转换成功后,同一事务将订单推进为 `INBOUND_PLANNED`,并把对应库存余额的该快照收口为新 `planNo`。 - 内部接口先执行常量时间令牌校验;缺失或错误令牌不得进入消费事务。 - IAM 安全事件只能进入 `/internal/lastleg/security/v1/events`;通用 `/internal/lastleg/events` 只白名单接收装柜完成与实际船节点事件,禁止把 IAM 事件交给 `LoadingCompletedConsumer`。 - Lastleg 安全投影只接受 `ACTIVE`/`DISABLED` 两态安全语义;客户建档和审核的 `PROFILE_REQUIRED`/`PENDING`/`REJECTED`/`ARCHIVED` 由 IAM 在发布前统一折叠为 `DISABLED`,Lastleg 不兼容或解释国内工作流状态。 diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/controller/InboundPlanController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/controller/InboundPlanController.java index 3bdb1f82d..3f8c3da74 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/controller/InboundPlanController.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/controller/InboundPlanController.java @@ -44,8 +44,9 @@ public class InboundPlanController { @PostMapping("/convert") @PreAuthorize("hasAuthority('" + OverseasPermissions.INBOUND_ORDER_CREATE + "')") - /** 将入仓单转换为入仓计划。 */ - @Operation(summary = "入仓单转入仓计划", description = "Required authority: " + OverseasPermissions.INBOUND_ORDER_CREATE) + /** 由平台人工将待分配尾程订单转换为入仓计划。 */ + @Operation(summary = "人工创建入仓计划", description = "头程装柜完成只生成待分配尾程订单;平台确认后调用本接口创建入仓计划。Required authority: " + + OverseasPermissions.INBOUND_ORDER_CREATE) public ApiResponse convert(@AuthenticationPrincipal AuthenticatedUser user, @Valid @RequestBody ConvertInboundPlanRequest request) { return ApiResponse.success(service.convert(scope(user, request.customerId()), request.orderId())); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/service/InboundPlanService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/service/InboundPlanService.java index 5dd754553..ec121e6d7 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/service/InboundPlanService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/service/InboundPlanService.java @@ -18,6 +18,7 @@ import com.baiyue.lastleg.inboundplan.mapper.InboundPlanPalletAllocationMapper; import com.baiyue.lastleg.inboundplan.mapper.InboundPlanPalletMapper; import com.baiyue.lastleg.inboundplan.mapper.InboundPlanQueryMapper; import com.baiyue.lastleg.inboundplan.model.SourceOrderSnapshot; +import com.baiyue.lastleg.inventory.mapper.InventoryBalanceMapper; import jakarta.annotation.Resource; import org.springframework.http.HttpStatus; import org.springframework.stereotype.Service; @@ -40,6 +41,7 @@ public class InboundPlanService { @Resource private InboundPlanPalletAllocationMapper allocationMapper; @Resource private InboundPlanOrderMapper orderMapper; @Resource private InboundPlanQueryMapper queryMapper; + @Resource private InventoryBalanceMapper inventoryBalanceMapper; @Transactional(readOnly = true) public PageResult page(String customerId, InboundPlanRequests.Query query) { @@ -108,6 +110,8 @@ public class InboundPlanService { if (orderMapper.markPlanned(customerId, order.orderId(), plan.getPlanNo(), count, now) != 1) { throw new IllegalStateException("Failed to update order " + order.orderId()); } + inventoryBalanceMapper.bindInboundPlan(customerId, order.orderId(), order.inboundCode(), + plan.getPlanNo(), now); orderMapper.insertHistory(customerId, order.orderId(), order.currentStatus(), "Inbound order converted to inbound plan " + plan.getPlanNo(), now); } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/mapper/InventoryBalanceMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/mapper/InventoryBalanceMapper.java index 437a9dcab..9fddae72c 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/mapper/InventoryBalanceMapper.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/mapper/InventoryBalanceMapper.java @@ -5,7 +5,9 @@ import com.baiyue.lastleg.inventory.model.InventorySummary; import com.baomidou.mybatisplus.core.mapper.BaseMapper; import org.apache.ibatis.annotations.Param; import org.apache.ibatis.annotations.Select; +import org.apache.ibatis.annotations.Update; +import java.time.LocalDateTime; import java.util.List; public interface InventoryBalanceMapper extends BaseMapper { @@ -29,6 +31,14 @@ public interface InventoryBalanceMapper extends BaseMapper { @Param("inboundOrderId") Long inboundOrderId, @Param("inboundCode") String inboundCode); + @Update("UPDATE ll_inventory_balance SET inbound_order_no=#{planNo},updated_at=#{updatedAt} " + + "WHERE customer_id=#{customerId} AND inbound_order_id=#{inboundOrderId} AND inbound_code=#{inboundCode}") + int bindInboundPlan(@Param("customerId") String customerId, + @Param("inboundOrderId") Long inboundOrderId, + @Param("inboundCode") String inboundCode, + @Param("planNo") String planNo, + @Param("updatedAt") LocalDateTime updatedAt); + @Select("