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("