优化
This commit is contained in:
@@ -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` |
|
||||
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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`。
|
||||
|
||||
@@ -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$$
|
||||
|
||||
@@ -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',
|
||||
|
||||
+6
-1
@@ -14,7 +14,12 @@
|
||||
<dependency><groupId>com.alibaba.cloud</groupId><artifactId>spring-cloud-starter-alibaba-nacos-discovery</artifactId></dependency>
|
||||
<dependency><groupId>com.alibaba.cloud</groupId><artifactId>spring-cloud-starter-alibaba-nacos-config</artifactId></dependency>
|
||||
<dependency><groupId>com.alibaba.cloud</groupId><artifactId>spring-cloud-starter-alibaba-sentinel</artifactId></dependency>
|
||||
<dependency><groupId>org.springframework.cloud</groupId><artifactId>spring-cloud-starter-openfeign</artifactId></dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.cloud</groupId><artifactId>spring-cloud-starter-openfeign</artifactId>
|
||||
<exclusions>
|
||||
<exclusion><groupId>io.github.openfeign.form</groupId><artifactId>feign-form-spring</artifactId></exclusion>
|
||||
</exclusions>
|
||||
</dependency>
|
||||
<dependency><groupId>org.springframework.cloud</groupId><artifactId>spring-cloud-starter-loadbalancer</artifactId></dependency>
|
||||
<dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-actuator</artifactId></dependency>
|
||||
<dependency><groupId>org.apache.rocketmq</groupId><artifactId>rocketmq-spring-boot-starter</artifactId></dependency>
|
||||
|
||||
@@ -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<Void> 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))) {
|
||||
|
||||
@@ -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<FileObjectEntity> {
|
||||
@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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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<FileObjectEntity>()
|
||||
|
||||
+11
-3
@@ -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() {
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<byte[]> 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<Void> 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) {}
|
||||
}
|
||||
|
||||
@@ -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<CustomsCaseEntity> {}
|
||||
import org.apache.ibatis.annotations.Param;
|
||||
import org.apache.ibatis.annotations.Select;
|
||||
@Mapper public interface CustomsCaseMapper extends BaseMapper<CustomsCaseEntity> {
|
||||
@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);
|
||||
}
|
||||
|
||||
+2
-5
@@ -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<CustomsCaseEntity>()
|
||||
.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<LoadingPlanEntity>()
|
||||
|
||||
+3
-5
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<Long, List<Customer>> customerMap(List<AggregateRow> rows) {
|
||||
|
||||
@@ -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<String> 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)) {
|
||||
|
||||
@@ -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$$
|
||||
|
||||
@@ -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$$
|
||||
|
||||
@@ -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'}
|
||||
|
||||
+30
-12
@@ -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); }
|
||||
}
|
||||
|
||||
+2
-1
@@ -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")
|
||||
|
||||
+15
@@ -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))
|
||||
|
||||
+4
-4
@@ -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 = '装柜完成后货柜全部只读");
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 不兼容或解释国内工作流状态。
|
||||
|
||||
+3
-2
@@ -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<InboundPlanResponse> convert(@AuthenticationPrincipal AuthenticatedUser user,
|
||||
@Valid @RequestBody ConvertInboundPlanRequest request) {
|
||||
return ApiResponse.success(service.convert(scope(user, request.customerId()), request.orderId()));
|
||||
|
||||
+4
@@ -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<InboundPlanListResponse> 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);
|
||||
}
|
||||
|
||||
+10
@@ -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<InventoryBalance> {
|
||||
@@ -29,6 +31,14 @@ public interface InventoryBalanceMapper extends BaseMapper<InventoryBalance> {
|
||||
@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("<script>SELECT * FROM ll_inventory_balance WHERE customer_id=#{customerId}" +
|
||||
"<if test='overseasWarehouseId != null and overseasWarehouseId != \"\"'> AND overseas_warehouse_id=#{overseasWarehouseId}</if>" +
|
||||
"<if test='destinationWarehouseCode != null and destinationWarehouseCode != \"\"'> AND destination_warehouse_code=#{destinationWarehouseCode}</if>" +
|
||||
|
||||
+4
-8
@@ -7,8 +7,6 @@ import com.baiyue.lastleg.dropship.dto.DropshipRequests;
|
||||
import com.baiyue.lastleg.dropship.service.DropshipOrderService;
|
||||
import com.baiyue.lastleg.inboundplan.entity.InboundPlanCarton;
|
||||
import com.baiyue.lastleg.inboundplan.mapper.InboundPlanCartonMapper;
|
||||
import com.baiyue.lastleg.inboundplan.service.InboundPlanService;
|
||||
import com.baiyue.lastleg.inboundplan.dto.InboundPlanResponse;
|
||||
import com.baiyue.lastleg.inventory.command.dto.InventoryCommandRequests;
|
||||
import com.baiyue.lastleg.inventory.command.service.InventoryCommandService;
|
||||
import com.baiyue.lastleg.shipping.mapper.ContainerShippingGateMapper;
|
||||
@@ -37,7 +35,6 @@ public class LoadingCompletedConsumer {
|
||||
@Resource private LastlegOrderDetailMapper detailMapper;
|
||||
@Resource private LastlegOrderStatusHistoryMapper historyMapper;
|
||||
@Resource private InboundPlanCartonMapper cartonMapper;
|
||||
@Resource private InboundPlanService inboundPlans;
|
||||
@Resource private ObjectMapper objectMapper;
|
||||
@Resource private LastlegOrderNoGenerator orderNoGenerator;
|
||||
@Resource private DropshipOrderService dropshipOrders;
|
||||
@@ -225,11 +222,10 @@ public class LoadingCompletedConsumer {
|
||||
LastlegOrderStatusHistory history=new LastlegOrderStatusHistory(); history.setCustomerId(customerId);
|
||||
history.setOrderId(order.getId()); history.setToStatus("PENDING_ASSIGNMENT"); history.setEventId(event.eventId());
|
||||
history.setRemark("头程装柜完成自动生成"); history.setCreatedAt(LocalDateTime.now()); historyMapper.insert(history);
|
||||
if ("FIRST_LEG".equals(orderType) || "ONE_CLICK_DROPSHIP".equals(orderType)) {
|
||||
InboundPlanResponse plan = inboundPlans.convert(customerId, order.getId());
|
||||
if ("FIRST_LEG".equals(orderType)) {
|
||||
registerInTransit(event, payload, source, order, plan.planNo());
|
||||
}
|
||||
if ("FIRST_LEG".equals(orderType)) {
|
||||
// The cross-region handover freezes the source inbound code only. An inbound plan is
|
||||
// created later by an explicit platform conversion, which replaces this display snapshot.
|
||||
registerInTransit(event, payload, source, order, source.inboundCode());
|
||||
}
|
||||
if ("ONE_CLICK_DROPSHIP".equals(orderType)) {
|
||||
long warehouseId;
|
||||
|
||||
+7
@@ -11,6 +11,7 @@ import com.baiyue.lastleg.inboundplan.mapper.InboundPlanOrderMapper;
|
||||
import com.baiyue.lastleg.inboundplan.mapper.InboundPlanPalletAllocationMapper;
|
||||
import com.baiyue.lastleg.inboundplan.mapper.InboundPlanPalletMapper;
|
||||
import com.baiyue.lastleg.inboundplan.model.SourceOrderSnapshot;
|
||||
import com.baiyue.lastleg.inventory.mapper.InventoryBalanceMapper;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
@@ -41,6 +42,7 @@ class InboundPlanServiceTest {
|
||||
@Mock InboundPlanPalletMapper palletMapper;
|
||||
@Mock InboundPlanPalletAllocationMapper allocationMapper;
|
||||
@Mock InboundPlanOrderMapper orderMapper;
|
||||
@Mock InventoryBalanceMapper inventoryBalanceMapper;
|
||||
|
||||
private InboundPlanService service;
|
||||
|
||||
@@ -52,6 +54,7 @@ class InboundPlanServiceTest {
|
||||
ReflectionTestUtils.setField(service, "palletMapper", palletMapper);
|
||||
ReflectionTestUtils.setField(service, "allocationMapper", allocationMapper);
|
||||
ReflectionTestUtils.setField(service, "orderMapper", orderMapper);
|
||||
ReflectionTestUtils.setField(service, "inventoryBalanceMapper", inventoryBalanceMapper);
|
||||
org.mockito.Mockito.lenient().when(lineMapper.selectList(any())).thenReturn(List.of());
|
||||
org.mockito.Mockito.lenient().when(palletMapper.selectList(any())).thenReturn(List.of());
|
||||
org.mockito.Mockito.lenient().when(allocationMapper.selectList(any())).thenReturn(List.of());
|
||||
@@ -89,6 +92,9 @@ class InboundPlanServiceTest {
|
||||
assertEquals(1, allocations.getAllValues().stream()
|
||||
.filter(item -> "PENDING_COUNT".equals(item.getStatus())).count());
|
||||
verify(orderMapper).markPlanned(any(), org.mockito.ArgumentMatchers.eq(2L), any(), org.mockito.ArgumentMatchers.anyInt(), any());
|
||||
verify(inventoryBalanceMapper).bindInboundPlan(org.mockito.ArgumentMatchers.eq("100"),
|
||||
org.mockito.ArgumentMatchers.eq(2L), org.mockito.ArgumentMatchers.eq("RK-2"),
|
||||
org.mockito.ArgumentMatchers.eq(result.planNo()), any());
|
||||
verify(orderMapper).insertHistory(any(), org.mockito.ArgumentMatchers.eq(2L), any(), any(), any());
|
||||
verify(orderMapper).lockOrder("100", 2L);
|
||||
assertEquals(2L, result.sourceOrderId());
|
||||
@@ -133,6 +139,7 @@ class InboundPlanServiceTest {
|
||||
assertEquals(1L, result.sourceOrderId());
|
||||
verify(planMapper, never()).insert(any(InboundPlan.class));
|
||||
verify(orderMapper, never()).markPlanned(any(), any(), any(), org.mockito.ArgumentMatchers.anyInt(), any());
|
||||
verify(inventoryBalanceMapper, never()).bindInboundPlan(any(), any(), any(), any(), any());
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
+5
-5
@@ -1,10 +1,10 @@
|
||||
package com.baiyue.lastleg.service;
|
||||
import com.baiyue.common.event.*; import com.baiyue.lastleg.entity.*; import com.baiyue.lastleg.mapper.*; import com.baiyue.lastleg.dropship.dto.DropshipRequests; import com.baiyue.lastleg.dropship.service.DropshipOrderService; import com.baiyue.lastleg.inboundplan.dto.InboundPlanResponse; import com.baiyue.lastleg.inboundplan.entity.InboundPlanCarton; import com.baiyue.lastleg.inboundplan.mapper.InboundPlanCartonMapper; import com.baiyue.lastleg.inboundplan.service.InboundPlanService; import com.baiyue.lastleg.inventory.command.dto.InventoryCommandRequests; import com.baiyue.lastleg.inventory.command.service.InventoryCommandService; import com.fasterxml.jackson.databind.*; import org.junit.jupiter.api.*; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.*; import org.springframework.test.util.ReflectionTestUtils; import java.math.BigDecimal; import java.time.Instant; import java.util.*; import static org.junit.jupiter.api.Assertions.*; import static org.mockito.ArgumentMatchers.*; import static org.mockito.Mockito.*;
|
||||
import com.baiyue.common.event.*; import com.baiyue.lastleg.entity.*; import com.baiyue.lastleg.mapper.*; import com.baiyue.lastleg.dropship.dto.DropshipRequests; import com.baiyue.lastleg.dropship.service.DropshipOrderService; import com.baiyue.lastleg.inboundplan.entity.InboundPlanCarton; import com.baiyue.lastleg.inboundplan.mapper.InboundPlanCartonMapper; import com.baiyue.lastleg.inventory.command.dto.InventoryCommandRequests; import com.baiyue.lastleg.inventory.command.service.InventoryCommandService; import com.fasterxml.jackson.databind.*; import org.junit.jupiter.api.*; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.*; import org.springframework.test.util.ReflectionTestUtils; import java.math.BigDecimal; import java.time.Instant; import java.util.*; import static org.junit.jupiter.api.Assertions.*; import static org.mockito.ArgumentMatchers.*; import static org.mockito.Mockito.*;
|
||||
@ExtendWith(org.mockito.junit.jupiter.MockitoExtension.class)
|
||||
class LoadingCompletedConsumerTest {
|
||||
@Mock LastlegEventInboxMapper inbox; @Mock LastlegOrderMapper orders; @Mock LastlegInboundOrderMapper inboundOrders; @Mock LastlegOrderDetailMapper details; @Mock LastlegOrderStatusHistoryMapper histories; @Mock InboundPlanCartonMapper cartons; @Mock DropshipOrderService dropshipOrders; @Mock InboundPlanService inboundPlans; @Mock InventoryCommandService inventory; @Mock com.baiyue.lastleg.shipping.mapper.ContainerShippingGateMapper shippingGates; ObjectMapper json=new ObjectMapper().findAndRegisterModules(); LoadingCompletedConsumer service;
|
||||
@BeforeEach void setup(){ service=new LoadingCompletedConsumer(); ReflectionTestUtils.setField(service,"inboxMapper",inbox); ReflectionTestUtils.setField(service,"orderMapper",orders); ReflectionTestUtils.setField(service,"inboundOrderMapper",inboundOrders); ReflectionTestUtils.setField(service,"detailMapper",details); ReflectionTestUtils.setField(service,"historyMapper",histories); ReflectionTestUtils.setField(service,"cartonMapper",cartons); ReflectionTestUtils.setField(service,"dropshipOrders",dropshipOrders); ReflectionTestUtils.setField(service,"inboundPlans",inboundPlans); ReflectionTestUtils.setField(service,"inventory",inventory); ReflectionTestUtils.setField(service,"shippingGates",shippingGates); ReflectionTestUtils.setField(service,"objectMapper",json); ReflectionTestUtils.setField(service,"orderNoGenerator",new LastlegOrderNoGenerator()); lenient().doAnswer(i->{ LastlegOrder o=i.getArgument(0); o.setId(Math.abs(o.getSourceInboundId().hashCode())+1L); return 1; }).when(orders).insert(any(LastlegOrder.class)); lenient().when(inboundPlans.convert(anyString(),anyLong())).thenReturn(new InboundPlanResponse(1L,"LIP-1","100",1L,"MSCU1","PENDING_ASSIGNMENT",1,1,BigDecimal.ONE,List.of(),List.of(),List.of())); }
|
||||
@Test void createsOneOrderAndOneInboundPlanPerInbound(){ EventEnvelope event=event(); assertEquals(2,service.consume(event)); verify(orders,times(2)).insert(any(LastlegOrder.class)); verify(inboundPlans,times(2)).convert(eq("100"),anyLong()); verify(inventory,times(2)).registerInTransit(eq("100"),eq("SYSTEM"),any(InventoryCommandRequests.InTransit.class)); verify(details,times(2)).insert(any(LastlegOrderDetail.class)); verify(histories,times(2)).insert(any(LastlegOrderStatusHistory.class)); ArgumentCaptor<LastlegOrder> c=ArgumentCaptor.forClass(LastlegOrder.class); verify(orders,times(2)).insert(c.capture()); assertEquals(Set.of("in-1","in-2"),c.getAllValues().stream().map(LastlegOrder::getSourceInboundId).collect(java.util.stream.Collectors.toSet())); assertTrue(c.getAllValues().stream().allMatch(o->"PENDING_ASSIGNMENT".equals(o.getCurrentStatus()))); assertTrue(c.getAllValues().stream().allMatch(o->o.getId()!=null)); assertTrue(c.getAllValues().stream().allMatch(o->"MSCU1".equals(o.getContainerNo()))); assertTrue(c.getAllValues().stream().allMatch(o->"FBA".equals(o.getDestinationType()))); assertTrue(c.getAllValues().stream().allMatch(o->"ow-1".equals(o.getSourceWarehouseId())&&o.getOverseasWarehouseId()==null)); ArgumentCaptor<LastlegInboundOrder> snapshots=ArgumentCaptor.forClass(LastlegInboundOrder.class); verify(inboundOrders,times(2)).insert(snapshots.capture()); assertTrue(snapshots.getAllValues().stream().allMatch(o->"100".equals(o.getCustomerId())&&"FBA".equals(o.getDestinationType()))); assertEquals(1,c.getAllValues().stream().map(LastlegOrder::getOrderNo).distinct().count()); assertEquals(new LastlegOrderNoGenerator().forContainerNo("MSCU1"),c.getAllValues().get(0).getOrderNo()); }
|
||||
@Mock LastlegEventInboxMapper inbox; @Mock LastlegOrderMapper orders; @Mock LastlegInboundOrderMapper inboundOrders; @Mock LastlegOrderDetailMapper details; @Mock LastlegOrderStatusHistoryMapper histories; @Mock InboundPlanCartonMapper cartons; @Mock DropshipOrderService dropshipOrders; @Mock InventoryCommandService inventory; @Mock com.baiyue.lastleg.shipping.mapper.ContainerShippingGateMapper shippingGates; ObjectMapper json=new ObjectMapper().findAndRegisterModules(); LoadingCompletedConsumer service;
|
||||
@BeforeEach void setup(){ service=new LoadingCompletedConsumer(); ReflectionTestUtils.setField(service,"inboxMapper",inbox); ReflectionTestUtils.setField(service,"orderMapper",orders); ReflectionTestUtils.setField(service,"inboundOrderMapper",inboundOrders); ReflectionTestUtils.setField(service,"detailMapper",details); ReflectionTestUtils.setField(service,"historyMapper",histories); ReflectionTestUtils.setField(service,"cartonMapper",cartons); ReflectionTestUtils.setField(service,"dropshipOrders",dropshipOrders); ReflectionTestUtils.setField(service,"inventory",inventory); ReflectionTestUtils.setField(service,"shippingGates",shippingGates); ReflectionTestUtils.setField(service,"objectMapper",json); ReflectionTestUtils.setField(service,"orderNoGenerator",new LastlegOrderNoGenerator()); lenient().doAnswer(i->{ LastlegOrder o=i.getArgument(0); o.setId(Math.abs(o.getSourceInboundId().hashCode())+1L); return 1; }).when(orders).insert(any(LastlegOrder.class)); }
|
||||
@Test void createsPendingOrdersAndInTransitSnapshotsWithoutInboundPlans(){ EventEnvelope event=event(); assertEquals(2,service.consume(event)); assertThrows(NoSuchFieldException.class,()->LoadingCompletedConsumer.class.getDeclaredField("inboundPlans")); verify(orders,times(2)).insert(any(LastlegOrder.class)); ArgumentCaptor<InventoryCommandRequests.InTransit> inTransit=ArgumentCaptor.forClass(InventoryCommandRequests.InTransit.class); verify(inventory,times(2)).registerInTransit(eq("100"),eq("SYSTEM"),inTransit.capture()); assertEquals(Set.of("RK1","RK2"),inTransit.getAllValues().stream().map(InventoryCommandRequests.InTransit::inboundOrderNo).collect(java.util.stream.Collectors.toSet())); verify(details,times(2)).insert(any(LastlegOrderDetail.class)); verify(histories,times(2)).insert(any(LastlegOrderStatusHistory.class)); ArgumentCaptor<LastlegOrder> c=ArgumentCaptor.forClass(LastlegOrder.class); verify(orders,times(2)).insert(c.capture()); assertEquals(Set.of("in-1","in-2"),c.getAllValues().stream().map(LastlegOrder::getSourceInboundId).collect(java.util.stream.Collectors.toSet())); assertTrue(c.getAllValues().stream().allMatch(o->"PENDING_ASSIGNMENT".equals(o.getCurrentStatus()))); assertTrue(c.getAllValues().stream().allMatch(o->o.getInboundOrderNo()==null)); assertTrue(c.getAllValues().stream().allMatch(o->o.getId()!=null)); assertTrue(c.getAllValues().stream().allMatch(o->"MSCU1".equals(o.getContainerNo()))); assertTrue(c.getAllValues().stream().allMatch(o->"FBA".equals(o.getDestinationType()))); assertTrue(c.getAllValues().stream().allMatch(o->"ow-1".equals(o.getSourceWarehouseId())&&o.getOverseasWarehouseId()==null)); ArgumentCaptor<LastlegInboundOrder> snapshots=ArgumentCaptor.forClass(LastlegInboundOrder.class); verify(inboundOrders,times(2)).insert(snapshots.capture()); assertTrue(snapshots.getAllValues().stream().allMatch(o->"100".equals(o.getCustomerId())&&"FBA".equals(o.getDestinationType()))); assertEquals(1,c.getAllValues().stream().map(LastlegOrder::getOrderNo).distinct().count()); assertEquals(new LastlegOrderNoGenerator().forContainerNo("MSCU1"),c.getAllValues().get(0).getOrderNo()); }
|
||||
@Test void loadingCreatesAwaitingPickupReadyGateWithoutPollutingShippingVersion(){ service.consume(event()); verify(shippingGates).initialize("book-1","100","","MSCU1",0L,1L); }
|
||||
@Test void repeatedEventReturnsExistingEffect(){ EventEnvelope event=event(); LastlegEventInbox row=new LastlegEventInbox(); row.setPayloadHash(event.payloadHash()); when(inbox.selectById(event.eventId())).thenReturn(row); when(orders.countContainer("100","MSCU1")).thenReturn(2L); assertEquals(2,service.consume(event)); verify(orders).countContainer("100","MSCU1"); verify(orders,never()).insert(any(LastlegOrder.class)); verify(details,never()).insert(any(LastlegOrderDetail.class)); }
|
||||
@Test void sameEventWithDifferentHashIsRejected(){ EventEnvelope event=event(); LastlegEventInbox row=new LastlegEventInbox(); row.setPayloadHash("sha256:"+"0".repeat(64)); when(inbox.selectById(event.eventId())).thenReturn(row); assertThrows(IllegalArgumentException.class,()->service.consume(event)); }
|
||||
@@ -17,7 +17,7 @@ class LoadingCompletedConsumerTest {
|
||||
assertTrue(table.contains("customer_id")); assertFalse(table.contains("tenant_id"));
|
||||
}
|
||||
@Test void rejectsEventFromWrongRegion(){ EventEnvelope source=event(); EventEnvelope invalid=new EventEnvelope(source.eventId(),source.eventType(),source.eventVersion(),source.occurredAt(),RegionCode.OVERSEAS,RegionCode.CN,source.tenantId(),source.aggregateType(),source.aggregateId(),source.aggregateVersion(),source.handoverVersion(),source.traceId(),source.correlationId(),source.causationId(),source.payload(),source.payloadHash()); assertThrows(IllegalArgumentException.class,()->service.consume(invalid)); }
|
||||
@Test void consumesFormalDropshipPalletSnapshots(){ var sku=new FirstLegLoadingPlanCompletedV1.SkuPackageSnapshot("SKU-A","Product A",2,20,List.of("BOX-1","BOX-2")); var pallet=new FirstLegLoadingPlanCompletedV1.PalletSnapshot("PLT-1",List.of(sku)); var item=new FirstLegLoadingPlanCompletedV1.Item("d-1","SKU-A","Product A","Product A",2,20,1,BigDecimal.ONE,BigDecimal.ONE,BigDecimal.ONE,BigDecimal.ONE,"wh-1","ONT8","ONT8",null,null,"MARK",null,List.of()); var order=new FirstLegLoadingPlanCompletedV1.Order("in-d","root-d","ord-d","FL-D","root-od","RK-D","wh-1","ONT8","ONT8",2,20,1,BigDecimal.ONE,BigDecimal.ONE,BigDecimal.ONE,List.of(item),"100","ONE_CLICK_DROPSHIP",List.of(pallet),"FBA"); var payload=new FirstLegLoadingPlanCompletedV1("plan-d","LP-D","book-d","CONT-D","CAB-D","92","Warehouse",List.of(order)); JsonNode node=json.valueToTree(payload); String hash=EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(node)); var event=new EventEnvelope("123e4567-e89b-12d3-a456-426614174010",EventTypes.FIRST_LEG_LOADING_PLAN_COMPLETED,1,Instant.now(),RegionCode.CN,RegionCode.OVERSEAS,"100","LOADING_PLAN","plan-d",2,1L,"123e4567-e89b-12d3-a456-426614174011","FIRSTLEG_LOADING_COMPLETED:plan-d:100:1",null,node,hash); assertEquals(1,service.consume(event)); ArgumentCaptor<DropshipRequests.Receive> captor=ArgumentCaptor.forClass(DropshipRequests.Receive.class); verify(dropshipOrders).receive(captor.capture()); verify(inboundPlans).convert(eq("100"),anyLong()); verify(inventory,never()).registerInTransit(anyString(),anyString(),any()); assertEquals("100",captor.getValue().customerId()); assertEquals(1,captor.getValue().pallets().size()); assertEquals("PLT-1",captor.getValue().pallets().get(0).palletNo()); assertEquals(List.of("BOX-1","BOX-2"),captor.getValue().pallets().get(0).skuPackages().get(0).cartonMarks()); }
|
||||
@Test void consumesFormalDropshipPalletSnapshots(){ var sku=new FirstLegLoadingPlanCompletedV1.SkuPackageSnapshot("SKU-A","Product A",2,20,List.of("BOX-1","BOX-2")); var pallet=new FirstLegLoadingPlanCompletedV1.PalletSnapshot("PLT-1",List.of(sku)); var item=new FirstLegLoadingPlanCompletedV1.Item("d-1","SKU-A","Product A","Product A",2,20,1,BigDecimal.ONE,BigDecimal.ONE,BigDecimal.ONE,BigDecimal.ONE,"wh-1","ONT8","ONT8",null,null,"MARK",null,List.of()); var order=new FirstLegLoadingPlanCompletedV1.Order("in-d","root-d","ord-d","FL-D","root-od","RK-D","wh-1","ONT8","ONT8",2,20,1,BigDecimal.ONE,BigDecimal.ONE,BigDecimal.ONE,List.of(item),"100","ONE_CLICK_DROPSHIP",List.of(pallet),"FBA"); var payload=new FirstLegLoadingPlanCompletedV1("plan-d","LP-D","book-d","CONT-D","CAB-D","92","Warehouse",List.of(order)); JsonNode node=json.valueToTree(payload); String hash=EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(node)); var event=new EventEnvelope("123e4567-e89b-12d3-a456-426614174010",EventTypes.FIRST_LEG_LOADING_PLAN_COMPLETED,1,Instant.now(),RegionCode.CN,RegionCode.OVERSEAS,"100","LOADING_PLAN","plan-d",2,1L,"123e4567-e89b-12d3-a456-426614174011","FIRSTLEG_LOADING_COMPLETED:plan-d:100:1",null,node,hash); assertEquals(1,service.consume(event)); ArgumentCaptor<DropshipRequests.Receive> captor=ArgumentCaptor.forClass(DropshipRequests.Receive.class); verify(dropshipOrders).receive(captor.capture()); verify(inventory,never()).registerInTransit(anyString(),anyString(),any()); assertEquals("100",captor.getValue().customerId()); assertEquals(1,captor.getValue().pallets().size()); assertEquals("PLT-1",captor.getValue().pallets().get(0).palletNo()); assertEquals(List.of("BOX-1","BOX-2"),captor.getValue().pallets().get(0).skuPackages().get(0).cartonMarks()); }
|
||||
@Test void oneDropshipInboundWithMultipleSkusCreatesOneOrderAndMultipleSkuDetails(){
|
||||
var itemA=new FirstLegLoadingPlanCompletedV1.Item("d-a","SKU-A","产品A","Product A",2,20,1,BigDecimal.ONE,BigDecimal.ONE,BigDecimal.ONE,BigDecimal.ONE,"wh-1","ONT8","ONT8",null,"SHIP-A","MARK-A",null,List.of("BOX-A1","BOX-A2"));
|
||||
var itemB=new FirstLegLoadingPlanCompletedV1.Item("d-b","SKU-B","产品B","Product B",3,15,1,BigDecimal.ONE,BigDecimal.ONE,BigDecimal.ONE,BigDecimal.ONE,"wh-1","ONT8","ONT8",null,"SHIP-B","MARK-B",null,List.of("BOX-B1","BOX-B2","BOX-B3"));
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
|
||||
总入口是 `../admin-test-env-acceptance.mjs`。每个业务模块使用独立的同名 `.mjs` 文件,避免多个对话同时修改总入口。
|
||||
|
||||
需要为人工联调批量准备同一客户、同一国内仓和同一渠道的 `WAIT_SPLIT` 数据时,使用[《批量待分柜数据生成指南》](批量待分柜数据生成指南.md)。该入口通过正式下单、入库、称重和测量接口生成数据,支持 FBA、海外仓、私人地址及一件代发等稳定随机组合;不得直接写库或用于生产环境。
|
||||
|
||||
例如 `--modules system,inbound-weight-ocr` 会依次加载:
|
||||
|
||||
- `system.mjs`
|
||||
|
||||
@@ -32,6 +32,9 @@ test('统一初始化 SQL 提供人工验收所需公共仓储主数据', () =>
|
||||
"'CAR-001'", "'CARRIER'", "'SHIPPER'", "'EXP-TEST'", "'IT 测试快递'",
|
||||
]) assert.ok(sql.includes(marker), `missing initialization marker: ${marker}`)
|
||||
assert.match(sql, /'EXP-TEST', 'IT 测试快递'[\s\S]*?'ACTIVE'/)
|
||||
assert.match(sql, /193000000000400003,0,193000000000200003,'RECEIVING','海外收货',1,'16:00:00',[\s\S]*?'洛杉矶仓海外接柜入仓收货能力'/)
|
||||
assert.match(sql, /193000000000400004,0,193000000000200004,'RECEIVING','海外收货',1,'16:00:00',[\s\S]*?'纽约仓海外接柜入仓收货能力'/)
|
||||
assert.match(sql, /固定平台作业仓收货能力写入校验失败/)
|
||||
|
||||
const palletSeed = sql.match(/INSERT INTO wh_pallet \([\s\S]*?remark = VALUES\(remark\);/)?.[0]
|
||||
assert.ok(palletSeed, 'missing pallet initialization seed')
|
||||
|
||||
@@ -0,0 +1,155 @@
|
||||
# 批量待分柜数据生成指南
|
||||
|
||||
本文说明如何复用 `scripts/admin-test-env-acceptance.mjs`,通过正式 HTTP 业务接口批量生成停在“测量完成待分柜”的头程订单。生成链路包含客户登录、下单、平台审核、入库、称重、测量、测量审核,不直接写业务数据库,也不模拟成功。
|
||||
|
||||
## 适用范围
|
||||
|
||||
- 用于开发、联调或经明确授权的验收环境准备人工操作数据。
|
||||
- 生成结果固定为入库单 `processStatus=WAIT_SPLIT`、测量审核 `APPROVED`。
|
||||
- 单次支持 `1-100` 条;更大批量分多次执行并分别保存报告。
|
||||
- 不负责后续分柜、装柜、订舱、国内出仓、海外入仓或 PDA 收货。
|
||||
- 禁止用于生产环境。对任何共享环境执行前,必须在当前对话明确获得该环境的写数据授权。
|
||||
|
||||
## 固定项与随机项
|
||||
|
||||
批量模式可固定以下三个线路字段:
|
||||
|
||||
1. 国内起运仓名称:`--origin-warehouse-name`
|
||||
2. 渠道名称:`--channel-name`
|
||||
3. 渠道编码:`--channel-code`
|
||||
|
||||
客户通过复用已审核商户账号固定;服务端从登录身份解析客户,脚本不会手填客户 ID。
|
||||
|
||||
使用 `--order-profile-mode randomized` 后,脚本在服务端允许的真实组合中轮换:
|
||||
|
||||
- 目的类型:`FBA`、`OVERSEAS`、`PRIVATE`
|
||||
- 履约类型:`STANDARD`、`ONE_CLICK_DROPSHIP`
|
||||
- 提货方式:`PICKUP`、`SELF_EXPRESS`、`SELF_TRUCK`
|
||||
- 买单/退税:`BUY_DECLARATION`、`TAX_REFUND`
|
||||
- 报价税制:`TAX_INCLUDED`、`TAX_EXCLUDED`,以固定渠道实际支持项为准
|
||||
- 装载方式:`LCL`、`FULL_CONTAINER`
|
||||
- 是否查验
|
||||
- SKU 数、每 SKU 箱数、托数、重量和测量箱型
|
||||
- FBA/海外仓目的仓:只从固定渠道当前价格簿真实可计价仓库中选择
|
||||
|
||||
随机档案是可重复的稳定轮换,不是不可复现的纯随机数。每六条覆盖一次“3 种目的类型 × 2 种履约类型”的核心组合,便于复核覆盖率。
|
||||
|
||||
特殊规则:
|
||||
|
||||
- `PRIVATE` 不引用公共目的仓 ID,必须携带收件人地址;当前契约不对私人地址做自动报价。
|
||||
- `FBA` 必须使用真实可计价 FBA 目的仓,并冻结 Amazon 仓码、Shipment ID 和 Reference ID。
|
||||
- `OVERSEAS` 必须使用真实可计价海外仓;固定渠道只有一个可用海外仓时允许重复使用,不伪造第二个仓。
|
||||
- 默认 `--order-profile-mode fixed` 仍生成 FBA + STANDARD,不启用上述随机组合。
|
||||
|
||||
## 执行前置
|
||||
|
||||
执行前确认:
|
||||
|
||||
- 目标 Gateway 和服务已部署当前接口。
|
||||
- 平台管理员账号可用。
|
||||
- 复用客户已审核通过,商户账号可登录。
|
||||
- 客户固定箱型初始化完成。
|
||||
- 固定国内仓为 `ACTIVE` 且具备收货能力。
|
||||
- 固定渠道存在当前 `ACTIVE` 价格簿和进仓码前缀。
|
||||
- FBA/OVERSEAS 至少存在一个与固定渠道价格明细匹配的目的仓。
|
||||
- File、Order、Firstleg、Warehouse、IAM、Auth 等链路可用。
|
||||
|
||||
账号、密码、环境域名和 Token 不得写入本文、源码、提交记录或报告。建议由本地未提交环境文件或当前终端环境变量提供。
|
||||
|
||||
## PowerShell 命令模板
|
||||
|
||||
先在当前终端提供敏感执行参数:
|
||||
|
||||
```powershell
|
||||
$env:ADMIN_TEST_GATEWAY = '<目标环境国内 Gateway>'
|
||||
$env:ADMIN_TEST_USERNAME = '<平台管理员账号>'
|
||||
$env:ADMIN_TEST_PASSWORD = '<平台管理员密码>'
|
||||
$env:MERCHANT_TEST_USERNAME = '<已审核商户账号>'
|
||||
$env:MERCHANT_TEST_PASSWORD = '<已审核商户密码>'
|
||||
```
|
||||
|
||||
从 `baiyue-firstleg/` 执行:
|
||||
|
||||
```powershell
|
||||
node scripts/admin-test-env-acceptance.mjs `
|
||||
--modules inbound-measurement `
|
||||
--merchant-username $env:MERCHANT_TEST_USERNAME `
|
||||
--merchant-password $env:MERCHANT_TEST_PASSWORD `
|
||||
--origin-warehouse-name '<固定国内仓名称>' `
|
||||
--channel-name '<固定渠道名称>' `
|
||||
--channel-code '<固定渠道编码>' `
|
||||
--order-profile-mode randomized `
|
||||
--wait-split-count 100 `
|
||||
--output '<本地报告绝对路径>.json'
|
||||
```
|
||||
|
||||
`inbound-measurement` 会自动展开 `orders` 前置。提供完整商户账号和密码后,总入口会复用该客户并移除自动创建客户的 `merchant-qualification-fixture`;两个商户参数必须同时提供。
|
||||
|
||||
如果需要自动新建并审核一个隔离客户,不传复用商户账号,改用:
|
||||
|
||||
```powershell
|
||||
node scripts/admin-test-env-acceptance.mjs `
|
||||
--modules merchant-qualification-fixture,orders,inbound-measurement `
|
||||
--customer-name '<本轮客户名称>' `
|
||||
--origin-warehouse-name '<固定国内仓名称>' `
|
||||
--channel-name '<固定渠道名称>' `
|
||||
--channel-code '<固定渠道编码>' `
|
||||
--order-profile-mode randomized `
|
||||
--wait-split-count 100 `
|
||||
--output '<本地报告绝对路径>.json'
|
||||
```
|
||||
|
||||
## 报告核验
|
||||
|
||||
成功报告中的 `createdResources.waitSplitFixtures` 每条至少应核对:
|
||||
|
||||
- `tenantId`、`orderId`、`inboundId`、`inboundCode`
|
||||
- `processStatus=WAIT_SPLIT`
|
||||
- `reviewStatus=APPROVED`
|
||||
- `receivingWarehouseName`、`channelCode`、`channelName`
|
||||
- `destinationType`、目的仓字段、`fulfillmentType`
|
||||
- `pickupMethod`、`taxMode`、`pricingTaxMode`、`containerMode`、`customsInspection`
|
||||
- SKU 数、总箱数、托数、毛重、净重和测量档案
|
||||
|
||||
建议同时检查:
|
||||
|
||||
- fixture 数量等于本轮 `--wait-split-count`
|
||||
- `inboundCode` 去重数等于 fixture 数量
|
||||
- 固定客户、国内仓和渠道只有一个唯一值
|
||||
- `failed=0` 且报告顶层 `success=true`
|
||||
- 随机批次包含预期目的类型和履约类型
|
||||
|
||||
报告生成前会扫描密码、Token、文件二进制、base64 和 OCR 原文等敏感内容。报告仍属于本地验收证据,不应提交到源码仓库。
|
||||
|
||||
## 失败、重跑与批量拆分
|
||||
|
||||
- 批次遇到第一条失败即停止,已经成功创建的订单不会自动删除或回滚。
|
||||
- 先查看日志和 JSON 报告中最后一个成功 fixture、失败接口的 `code/requestId/traceId`,修复前置后再运行剩余数量。
|
||||
- 重跑会生成新业务单号和入仓码,不覆盖旧订单;不要假设跨多次运行的入仓码一定连续。
|
||||
- 每次运行的随机序列从 1 重新开始;分批执行时组合可能重复,但仍保持固定三项和真实契约。
|
||||
- 大于 100 条时建议按 50-100 条拆批,并为每批使用独立报告文件名。
|
||||
- 若固定渠道只有少量目的仓,目的仓重复是合法结果;脚本只保证从真实可计价集合选择,不保证每批目的仓均匀分布。
|
||||
|
||||
## 常见阻断
|
||||
|
||||
| 现象 | 核对项 |
|
||||
|---|---|
|
||||
| 未找到固定起运仓 | 仓库名称是否精确一致、状态是否 ACTIVE、类型是否 DOMESTIC |
|
||||
| 未找到固定物流渠道 | 渠道编码/名称是否精确一致、价格簿是否 ACTIVE、起运仓是否匹配 |
|
||||
| 没有可计价目的仓 | 价格簿目的类型、目的仓映射、报价税制和仓库状态 |
|
||||
| 私人地址创建失败 | `destinationWarehouseId` 必须为空,收件人字段必须完整 |
|
||||
| 测量审核未到 WAIT_SPLIT | 称重图片、托数、净重、总箱数和测量箱数是否精确核平 |
|
||||
| 报告数量不足 | 查看首个失败模块;已成功数据不会因后续失败回滚 |
|
||||
|
||||
## 离线验证
|
||||
|
||||
修改生成器或本文关联参数后,至少运行:
|
||||
|
||||
```powershell
|
||||
node --check scripts/admin-test-env-acceptance.mjs
|
||||
node --check scripts/admin-test-env-modules/orders.mjs
|
||||
node --test scripts/admin-test-env-modules/merchant-qualification-fixture-contract.test.mjs scripts/admin-test-env-modules/inbound-measurement-box-code-contract.test.mjs
|
||||
git diff --check
|
||||
```
|
||||
|
||||
离线检查不访问环境,也不代表 HTTP 批量生成已经通过。真实生成必须另行获得目标环境写数据授权。
|
||||
@@ -100,6 +100,9 @@ export const SYNCHRONOUS_CLIENTS = Object.freeze([
|
||||
'lastleg-service', 'event-bridge-service', ['BACKGROUND'], 'WRITE', 'EVENT_ID', '卸柜完成 Outbox 按 eventId 投递'),
|
||||
feign('lastleg-service/src/main/java/com/baiyue/lastleg/masterdata/quote/client/DomesticPartnerWarehouseClient.java',
|
||||
'lastleg-service', 'warehouse-service', ['REQUEST'], 'READ', 'SAFE_READ', '报价创建时读取国内合作仓快照'),
|
||||
feign('warehouse-overseas-service/src/main/java/com/baiyue/warehouse/client/DomesticWarehouseDirectoryClient.java',
|
||||
'warehouse-overseas-service', 'warehouse-service', ['REQUEST'], 'READ', 'SAFE_READ',
|
||||
'海外库位、托盘和资源锁写入前读取国内共享收货仓权威目录'),
|
||||
|
||||
feign('order/src/main/java/com/baiyue/order/client/FirstlegInboundClient.java',
|
||||
'order-service', 'firstleg-service', ['REQUEST', 'BACKGROUND'], 'READ_WRITE', 'BUSINESS_KEY',
|
||||
|
||||
@@ -39,7 +39,8 @@ const expectedOnlineEdges = [
|
||||
'order-service->file-service',
|
||||
'order-service->firstleg-service',
|
||||
'order-service->iam-service',
|
||||
'order-service->warehouse-service'
|
||||
'order-service->warehouse-service',
|
||||
'warehouse-overseas-service->warehouse-service'
|
||||
]
|
||||
|
||||
const expectedBackgroundEdges = [
|
||||
@@ -95,11 +96,11 @@ async function discoverFeignEdges() {
|
||||
return clients.sort((left, right) => left.path.localeCompare(right.path))
|
||||
}
|
||||
|
||||
test('all 44 Feign clients have reviewed context, access and idempotency metadata', async () => {
|
||||
test('all 45 Feign clients have reviewed context, access and idempotency metadata', async () => {
|
||||
const reviewed = SYNCHRONOUS_CLIENTS.filter(client => client.transport === 'FEIGN')
|
||||
.map(({ path, source, target }) => ({ path, source, target }))
|
||||
.sort((left, right) => left.path.localeCompare(right.path))
|
||||
assert.equal(reviewed.length, 44)
|
||||
assert.equal(reviewed.length, 45)
|
||||
assert.deepEqual(await discoverFeignEdges(), reviewed)
|
||||
})
|
||||
|
||||
@@ -258,7 +259,9 @@ test('known user entries expose every longest simple REQUEST path over the two-h
|
||||
'lastleg-service->file-service->iam-service->warehouse-service'
|
||||
] },
|
||||
'warehouse-service': { hops: 0, paths: ['warehouse-service'] },
|
||||
'warehouse-overseas-service': { hops: 0, paths: ['warehouse-overseas-service'] }
|
||||
'warehouse-overseas-service': { hops: 1, paths: [
|
||||
'warehouse-overseas-service->warehouse-service'
|
||||
] }
|
||||
})
|
||||
for (const entry of USER_ENTRIES) {
|
||||
const result = summary[entry.service]
|
||||
|
||||
@@ -2,20 +2,19 @@
|
||||
|
||||
## 职责与边界
|
||||
|
||||
- 负责海外仓库、地址、服务能力、库位、托盘及业务资源锁定/释放。
|
||||
- 尾程入仓计划通过内部网关请求资源;本模块不读取尾程订单库或头程数据库。
|
||||
- 负责海外库位、托盘及业务资源锁定/释放;只保存国内仓库 ID 引用,不保存仓库、地址或能力主档。
|
||||
- 国内 `warehouse-service` 是仓库 ID、类型、状态、地址和能力的唯一权威;本服务通过内部目录校验平台共享 `OVERSEAS + ACTIVE + RECEIVING` 仓。尾程入仓计划通过内部网关请求资源;本模块不读取尾程订单库或头程数据库。
|
||||
|
||||
## 快速索引
|
||||
|
||||
| 功能 | 入口 |
|
||||
|---|---|
|
||||
| 平台仓库管理 | `src/main/java/com/baiyue/warehouse/controller/PlatformWarehouseController.java` |
|
||||
| 商户仓库查询 | `controller/MerchantWarehouseController.java` |
|
||||
| 国内仓库只读目录 | `client/DomesticWarehouseDirectoryClient.java`、`service/DomesticWarehouseDirectory.java`;正结果缓存 30 分钟,空值和不存在不得长缓存 |
|
||||
| 平台/商户库位与托盘 | `controller/PlatformWarehouseResourceController.java`、`MerchantWarehouseResourceController.java` |
|
||||
| 内部资源锁定、释放、报废 | `controller/InternalWarehouseResourceLockController.java` |
|
||||
| PDA 托盘状态流转与补偿 | `controller/InternalPdaPalletTransitionController.java`、`service/WarehousePalletTransitionService.java`、`mapper/WarehousePalletTransitionMapper.java`;内部前缀 `/internal/v1/pda-pallet-transitions` |
|
||||
| 托盘解析与业务绑定 | `controller/InternalWarehousePalletController.java`、`dto/WarehousePalletResolveResponse.java`、`entity/WarehousePalletBindingEntity.java` |
|
||||
| 仓库与资源服务 | `service/WarehouseApplicationService.java`、`WarehouseResourceService.java`、`WarehouseResourceLockService.java` |
|
||||
| 仓储资源服务 | `service/WarehouseResourceService.java`、`WarehouseResourceLockService.java` |
|
||||
| 内部服务令牌 | `security/InternalServiceTokenGuard.java` |
|
||||
| 数据库基线与中文注释契约 | `src/main/resources/db/warehouse.sql`、`src/test/java/com/baiyue/warehouse/schema/SchemaChineseCommentContractTest.java` |
|
||||
| 测试 | `src/test/java/com/baiyue/warehouse/` |
|
||||
@@ -26,6 +25,7 @@
|
||||
- 平台维护的海外库位与实物托盘是公共资源,固定使用 `customer_id=0`;平台资源 API 不接收目标客户参数。资源锁记录仍使用真实业务客户 ID 隔离,但其托盘、库位查找和状态更新必须落到公共资源作用域。
|
||||
- 资源锁定以业务幂等键和 reservation 标识防重;释放/报废操作必须验证当前状态。
|
||||
- 内部资源接口必须校验服务令牌;普通用户不得直接调用。
|
||||
- 本服务不得注册仓库主档创建、编辑、状态或商户仓库查询 Controller;所有资源写入先以相同 `warehouseId` 校验国内只读目录。
|
||||
|
||||
## 数据库表索引与注释约束
|
||||
|
||||
@@ -34,9 +34,6 @@
|
||||
| 库位 | `wh_location` | 海外仓库位资源 |
|
||||
| 托盘 | `wh_pallet` | 海外仓实物托盘资源 |
|
||||
| 资源锁 | `wh_resource_lock` | 仓储资源锁定记录 |
|
||||
| 仓库 | `wh_warehouse` | 海外仓档案 |
|
||||
| 仓库地址 | `wh_warehouse_address` | 海外仓地址 |
|
||||
| 服务能力 | `wh_service_capability` | 海外仓服务能力 |
|
||||
| 托盘绑定 | `wh_pallet_business_binding` | 托盘业务绑定关系 |
|
||||
| 托盘操作 | `wh_pallet_operation` | PDA 托盘物理状态流转、幂等与补偿记录 |
|
||||
|
||||
@@ -45,6 +42,6 @@
|
||||
|
||||
## 修改与验证
|
||||
|
||||
- 字段、注释和索引同步维护基线 SQL 与统一增量 SQL。
|
||||
- 字段、注释和索引同步维护基线 SQL 与统一增量 SQL;海外数据库禁止新增 `wh_warehouse`、`wh_warehouse_address`、`wh_service_capability` 或指向它们的外键。
|
||||
- PDA 托盘流转必须校验内部服务令牌、仓库范围、当前物理状态和幂等键;跨服务业务失败只能通过显式补偿接口恢复,不得由 Lastleg 直接修改 `wh_` 资源表。
|
||||
- 定向验证:`mvn -pl warehouse-overseas-service -am test`。
|
||||
|
||||
@@ -40,6 +40,10 @@
|
||||
<groupId>com.alibaba.cloud</groupId>
|
||||
<artifactId>spring-cloud-starter-alibaba-nacos-discovery</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.cloud</groupId>
|
||||
<artifactId>spring-cloud-starter-openfeign</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.alibaba.cloud</groupId>
|
||||
<artifactId>spring-cloud-starter-alibaba-nacos-config</artifactId>
|
||||
|
||||
+2
@@ -3,11 +3,13 @@ package com.baiyue.warehouse;
|
||||
import org.mybatis.spring.annotation.MapperScan;
|
||||
import org.springframework.boot.SpringApplication;
|
||||
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
||||
import org.springframework.cloud.openfeign.EnableFeignClients;
|
||||
|
||||
/**
|
||||
* 仓库微服务启动入口,启用 Spring Boot 自动配置并扫描仓库服务的 MyBatis Mapper。
|
||||
*/
|
||||
@MapperScan("com.baiyue.warehouse.mapper")
|
||||
@EnableFeignClients(basePackages = "com.baiyue.warehouse.client")
|
||||
@SpringBootApplication(scanBasePackages = "com.baiyue")
|
||||
public class BaiyueWarehouseOverseasApplication {
|
||||
public static void main(String[] args) { SpringApplication.run(BaiyueWarehouseOverseasApplication.class, args); }
|
||||
|
||||
+25
@@ -0,0 +1,25 @@
|
||||
package com.baiyue.warehouse.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.PathVariable;
|
||||
import org.springframework.web.bind.annotation.RequestHeader;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
@FeignClient(name = "warehouse-service", contextId = "sharedOverseasWarehouseDirectoryClient",
|
||||
url = "${baiyue.clients.domestic-warehouse.url:}")
|
||||
public interface DomesticWarehouseDirectoryClient {
|
||||
@GetMapping("/internal/warehouses/shared-overseas")
|
||||
ApiResponse<List<WarehouseReference>> activeSharedWarehouses(
|
||||
@RequestHeader("X-Internal-Service-Token") String internalToken);
|
||||
|
||||
@GetMapping("/internal/warehouses/shared-overseas/{warehouseId}")
|
||||
ApiResponse<WarehouseReference> activeSharedWarehouse(
|
||||
@RequestHeader("X-Internal-Service-Token") String internalToken,
|
||||
@PathVariable long warehouseId);
|
||||
|
||||
record WarehouseReference(Long warehouseId, String warehouseCode, String warehouseName,
|
||||
String countryCode, String status) { }
|
||||
}
|
||||
+3
-3
@@ -10,7 +10,7 @@ import com.baiyue.warehouse.entity.WarehouseLocationEntity;
|
||||
import com.baiyue.warehouse.security.InternalServiceTokenGuard;
|
||||
import com.baiyue.warehouse.service.WarehouseResourceLockService;
|
||||
import com.baiyue.warehouse.service.WarehouseResourceService;
|
||||
import com.baiyue.warehouse.service.WarehouseApplicationService;
|
||||
import com.baiyue.warehouse.service.DomesticWarehouseDirectory;
|
||||
import jakarta.validation.Valid;
|
||||
import jakarta.annotation.Resource;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
@@ -27,7 +27,7 @@ import org.springframework.web.bind.annotation.RestController;
|
||||
public class InternalWarehouseResourceLockController {
|
||||
@Resource private WarehouseResourceLockService service;
|
||||
@Resource private WarehouseResourceService resourceService;
|
||||
@Resource private WarehouseApplicationService warehouses;
|
||||
@Resource private DomesticWarehouseDirectory warehouses;
|
||||
@Resource private InternalServiceTokenGuard tokenGuard;
|
||||
|
||||
@PostMapping
|
||||
@@ -79,7 +79,7 @@ public class InternalWarehouseResourceLockController {
|
||||
@RequestHeader(value = "X-Internal-Service-Token", required = false) String token,
|
||||
@PathVariable long warehouseId) {
|
||||
tokenGuard.verify(token);
|
||||
return ApiResponse.success(warehouses.activeSharedReference(warehouseId));
|
||||
return ApiResponse.success(warehouses.required(warehouseId));
|
||||
}
|
||||
|
||||
@GetMapping("/locations/resolve/{locationCode}")
|
||||
|
||||
-67
@@ -1,67 +0,0 @@
|
||||
package com.baiyue.warehouse.controller;
|
||||
|
||||
import com.baiyue.common.core.api.ApiResponse;
|
||||
import com.baiyue.common.core.api.PageResult;
|
||||
import com.baiyue.common.security.AuthenticatedUser;
|
||||
import com.baiyue.warehouse.dto.WarehouseDetailResponse;
|
||||
import com.baiyue.warehouse.dto.WarehouseRequests;
|
||||
import com.baiyue.warehouse.entity.WarehouseEntity;
|
||||
import com.baiyue.warehouse.service.WarehouseApplicationService;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.Parameter;
|
||||
import io.swagger.v3.oas.annotations.responses.ApiResponses;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import jakarta.annotation.Resource;
|
||||
import jakarta.validation.Valid;
|
||||
import org.springframework.security.core.annotation.AuthenticationPrincipal;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
/**
|
||||
* 商户端仓库查询接口,仅允许在认证令牌所属租户范围内读取仓库列表和详情。
|
||||
*/
|
||||
@Tag(
|
||||
name = "Merchant warehouses / 商户仓库",
|
||||
description = "Merchant read APIs restricted to the tenant in the authenticated JWT. / 商户端只读接口,租户范围取自认证 JWT。"
|
||||
)
|
||||
@RestController
|
||||
@RequestMapping("/api/merchant/v1/warehouses")
|
||||
public class MerchantWarehouseController {
|
||||
@Resource
|
||||
private WarehouseApplicationService service;
|
||||
|
||||
@Operation(
|
||||
summary = "List available warehouses / 查询可用仓库",
|
||||
description = "Customer scope comes only from the authenticated JWT; any customerId query value is ignored. / 客户范围仅从认证 JWT 获取,查询中的 customerId 不生效。"
|
||||
)
|
||||
@ApiResponses({
|
||||
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "Query succeeded / 查询成功"),
|
||||
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "400", description = "Invalid query / 查询参数无效"),
|
||||
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "401", description = "Authentication required / 需要登录")
|
||||
})
|
||||
@GetMapping
|
||||
public ApiResponse<PageResult<WarehouseEntity>> page(
|
||||
@AuthenticationPrincipal AuthenticatedUser user,
|
||||
@Valid WarehouseRequests.QueryRequest query) {
|
||||
return ApiResponse.success(service.page(user.tenantId(), query));
|
||||
}
|
||||
|
||||
@Operation(
|
||||
summary = "Get warehouse details / 查询仓库详情",
|
||||
description = "Returns warehouse details only within the authenticated merchant tenant. / 仅返回当前认证商户租户内的仓库详情。"
|
||||
)
|
||||
@ApiResponses({
|
||||
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "Query succeeded / 查询成功"),
|
||||
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "401", description = "Authentication required / 需要登录"),
|
||||
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "404", description = "Warehouse not found in current tenant / 当前租户下仓库不存在")
|
||||
})
|
||||
@GetMapping("/{id}")
|
||||
public ApiResponse<WarehouseDetailResponse> detail(
|
||||
@AuthenticationPrincipal AuthenticatedUser user,
|
||||
@Parameter(description = "Warehouse ID / 仓库 ID", example = "190000000000000001", required = true)
|
||||
@PathVariable long id) {
|
||||
return ApiResponse.success(service.detail(user.tenantId(), id));
|
||||
}
|
||||
}
|
||||
-171
@@ -1,171 +0,0 @@
|
||||
package com.baiyue.warehouse.controller;
|
||||
|
||||
import com.baiyue.common.core.api.ApiResponse;
|
||||
import com.baiyue.common.core.api.PageResult;
|
||||
import com.baiyue.common.core.api.ResultCode;
|
||||
import com.baiyue.common.core.exception.BizException;
|
||||
import com.baiyue.common.security.AuthenticatedUser;
|
||||
import com.baiyue.warehouse.dto.WarehouseDetailResponse;
|
||||
import com.baiyue.warehouse.dto.WarehouseRequests;
|
||||
import com.baiyue.warehouse.entity.WarehouseEntity;
|
||||
import com.baiyue.warehouse.service.WarehouseApplicationService;
|
||||
import com.baiyue.warehouse.security.OverseasWarehousePermissions;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.Parameter;
|
||||
import io.swagger.v3.oas.annotations.responses.ApiResponses;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import jakarta.annotation.Resource;
|
||||
import jakarta.validation.Valid;
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
import org.springframework.security.core.annotation.AuthenticationPrincipal;
|
||||
import org.springframework.security.access.prepost.PreAuthorize;
|
||||
import org.springframework.validation.annotation.Validated;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PatchMapping;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.PutMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
/**
|
||||
* 平台端仓库管理接口,提供跨租户管理场景下的仓库创建、修改、查询和状态维护能力。
|
||||
*/
|
||||
@Validated
|
||||
@Tag(
|
||||
name = "Platform warehouse administration / 平台仓库管理",
|
||||
description = "Platform APIs for managing warehouses across tenants. / 平台端按租户管理仓库的接口。"
|
||||
)
|
||||
@RestController
|
||||
@RequestMapping("/api/platform/v1/warehouse-admin")
|
||||
public class PlatformWarehouseController {
|
||||
@Resource
|
||||
private WarehouseApplicationService service;
|
||||
|
||||
@Operation(
|
||||
summary = "Create warehouse / 新建仓库",
|
||||
description = "Required authority: " + OverseasWarehousePermissions.WAREHOUSE_CREATE + ". Creates a warehouse, address and service capabilities for the target customer."
|
||||
)
|
||||
@ApiResponses({
|
||||
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "Created successfully / 创建成功"),
|
||||
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "400", description = "Invalid request / 请求参数无效"),
|
||||
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "403", description = "Platform access required / 需要平台端权限"),
|
||||
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "409", description = "Warehouse code already exists / 仓库编码已存在")
|
||||
})
|
||||
@PostMapping
|
||||
@PreAuthorize("hasAuthority('" + OverseasWarehousePermissions.WAREHOUSE_CREATE + "')")
|
||||
public ApiResponse<Long> create(@AuthenticationPrincipal AuthenticatedUser user,
|
||||
@Valid @RequestBody WarehouseRequests.SaveRequest request) {
|
||||
requirePlatform(user);
|
||||
requireTenant(request.customerId());
|
||||
return ApiResponse.success(service.create(request));
|
||||
}
|
||||
|
||||
@Operation(
|
||||
summary = "Update warehouse / 更新仓库",
|
||||
description = "Required authority: " + OverseasWarehousePermissions.WAREHOUSE_EDIT + ". Updates the warehouse and replaces its address and capabilities."
|
||||
)
|
||||
@ApiResponses({
|
||||
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "Updated successfully / 更新成功"),
|
||||
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "400", description = "Invalid request / 请求参数无效"),
|
||||
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "403", description = "Platform access or tenant validation failed / 平台权限或租户校验失败"),
|
||||
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "404", description = "Warehouse not found / 仓库不存在"),
|
||||
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "409", description = "Warehouse code already exists / 仓库编码已存在")
|
||||
})
|
||||
@PutMapping("/{id}")
|
||||
@PreAuthorize("hasAuthority('" + OverseasWarehousePermissions.WAREHOUSE_EDIT + "')")
|
||||
public ApiResponse<Void> update(
|
||||
@AuthenticationPrincipal AuthenticatedUser user,
|
||||
@Parameter(description = "Target tenant ID / 目标租户 ID", example = "10001", required = true)
|
||||
@RequestParam @NotNull Long customerId,
|
||||
@Parameter(description = "Warehouse ID / 仓库 ID", example = "190000000000000001", required = true)
|
||||
@PathVariable long id,
|
||||
@Valid @RequestBody WarehouseRequests.SaveRequest request) {
|
||||
requirePlatform(user);
|
||||
requireTenant(customerId);
|
||||
service.update(customerId, id, request);
|
||||
return ApiResponse.success(null);
|
||||
}
|
||||
|
||||
@Operation(
|
||||
summary = "List warehouses / 查询仓库",
|
||||
description = "Required authority: " + OverseasWarehousePermissions.WAREHOUSE_LIST
|
||||
+ ". Returns warehouses across customers, or only the optional customerId scope when supplied."
|
||||
)
|
||||
@ApiResponses({
|
||||
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "Query succeeded / 查询成功"),
|
||||
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "400", description = "Invalid query / 查询参数无效"),
|
||||
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "403", description = "Platform access required / 需要平台端权限")
|
||||
})
|
||||
@GetMapping
|
||||
@PreAuthorize("hasAnyAuthority('" + OverseasWarehousePermissions.WAREHOUSE_LIST + "', '"
|
||||
+ OverseasWarehousePermissions.PALLET_LIST + "', '" + OverseasWarehousePermissions.LOCATION_LIST + "')")
|
||||
public ApiResponse<PageResult<WarehouseEntity>> page(
|
||||
@AuthenticationPrincipal AuthenticatedUser user,
|
||||
@Valid WarehouseRequests.QueryRequest request) {
|
||||
requirePlatform(user);
|
||||
return ApiResponse.success(service.pageAll(request));
|
||||
}
|
||||
|
||||
@Operation(
|
||||
summary = "Get tenant warehouse details / 查询租户仓库详情",
|
||||
description = "Required authority: " + OverseasWarehousePermissions.WAREHOUSE_LIST + ". Returns the warehouse, addresses and service capabilities for the target customer."
|
||||
)
|
||||
@ApiResponses({
|
||||
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "Query succeeded / 查询成功"),
|
||||
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "403", description = "Platform access required / 需要平台端权限"),
|
||||
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "404", description = "Warehouse not found / 仓库不存在")
|
||||
})
|
||||
@GetMapping("/{id}")
|
||||
@PreAuthorize("hasAuthority('" + OverseasWarehousePermissions.WAREHOUSE_LIST + "')")
|
||||
public ApiResponse<WarehouseDetailResponse> detail(
|
||||
@AuthenticationPrincipal AuthenticatedUser user,
|
||||
@Parameter(description = "Target tenant ID / 目标租户 ID", example = "10001", required = true)
|
||||
@RequestParam @NotNull Long customerId,
|
||||
@Parameter(description = "Warehouse ID / 仓库 ID", example = "190000000000000001", required = true)
|
||||
@PathVariable long id) {
|
||||
requirePlatform(user);
|
||||
requireTenant(customerId);
|
||||
return ApiResponse.success(service.detail(customerId, id));
|
||||
}
|
||||
|
||||
@Operation(
|
||||
summary = "Enable or disable warehouse / 启用或停用仓库",
|
||||
description = "Required authority: " + OverseasWarehousePermissions.WAREHOUSE_STATUS + ". Changes warehouse status to ACTIVE or INACTIVE."
|
||||
)
|
||||
@ApiResponses({
|
||||
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "Status changed / 状态变更成功"),
|
||||
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "400", description = "Invalid status / 状态无效"),
|
||||
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "403", description = "Platform access required / 需要平台端权限"),
|
||||
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "404", description = "Warehouse not found / 仓库不存在")
|
||||
})
|
||||
@PatchMapping("/{id}/status")
|
||||
@PreAuthorize("hasAuthority('" + OverseasWarehousePermissions.WAREHOUSE_STATUS + "')")
|
||||
public ApiResponse<Void> status(
|
||||
@AuthenticationPrincipal AuthenticatedUser user,
|
||||
@Parameter(description = "Target tenant ID / 目标租户 ID", example = "10001", required = true)
|
||||
@RequestParam @NotNull Long customerId,
|
||||
@Parameter(description = "Warehouse ID / 仓库 ID", example = "190000000000000001", required = true)
|
||||
@PathVariable long id,
|
||||
@Parameter(description = "Target status: ACTIVE or INACTIVE / 目标状态:ACTIVE 或 INACTIVE", example = "ACTIVE", required = true)
|
||||
@RequestParam String status) {
|
||||
requirePlatform(user);
|
||||
requireTenant(customerId);
|
||||
service.changeStatus(customerId, id, status);
|
||||
return ApiResponse.success(null);
|
||||
}
|
||||
|
||||
private static void requirePlatform(AuthenticatedUser user) {
|
||||
if (user == null || !"PLATFORM".equals(user.appCode()) || user.tenantId() == null || user.tenantId() != 0L) {
|
||||
throw new BizException(ResultCode.FORBIDDEN, "Platform application required / 仅允许平台端访问");
|
||||
}
|
||||
}
|
||||
|
||||
private static void requireTenant(Long customerId) {
|
||||
if (customerId == null || customerId < 0) {
|
||||
throw new BizException(ResultCode.BAD_REQUEST, "Target customer scope is required / 必须指定有效客户或平台共享范围");
|
||||
}
|
||||
}
|
||||
}
|
||||
-19
@@ -1,19 +0,0 @@
|
||||
package com.baiyue.warehouse.dto;
|
||||
|
||||
import com.baiyue.warehouse.entity.ServiceCapabilityEntity;
|
||||
import com.baiyue.warehouse.entity.WarehouseAddressEntity;
|
||||
import com.baiyue.warehouse.entity.WarehouseEntity;
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 仓库聚合详情响应,组合仓库主数据、地址列表和服务能力列表。
|
||||
*/
|
||||
@Schema(name = "WarehouseDetailResponse", description = "Warehouse aggregate details / 仓库聚合详情")
|
||||
public record WarehouseDetailResponse(
|
||||
@Schema(description = "Warehouse master data / 仓库主数据") WarehouseEntity warehouse,
|
||||
@Schema(description = "Warehouse addresses / 仓库地址列表") List<WarehouseAddressEntity> addresses,
|
||||
@Schema(description = "Warehouse service capabilities / 仓库服务能力列表") List<ServiceCapabilityEntity> capabilities
|
||||
) {
|
||||
}
|
||||
-120
@@ -1,120 +0,0 @@
|
||||
package com.baiyue.warehouse.dto;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import jakarta.validation.Valid;
|
||||
import jakarta.validation.constraints.Email;
|
||||
import jakarta.validation.constraints.Max;
|
||||
import jakarta.validation.constraints.Min;
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
import jakarta.validation.constraints.Pattern;
|
||||
import jakarta.validation.constraints.PositiveOrZero;
|
||||
import jakarta.validation.constraints.Size;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalTime;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 仓库接口请求模型容器,集中定义仓库保存、地址、服务能力、查询和状态变更请求契约。
|
||||
*/
|
||||
public final class WarehouseRequests {
|
||||
private WarehouseRequests() {
|
||||
}
|
||||
|
||||
@Schema(name = "WarehouseSaveRequest", description = "Warehouse create or update request / 仓库新增或更新请求")
|
||||
public record SaveRequest(
|
||||
@Schema(description = "Target tenant ID / 目标租户 ID", example = "10001", requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
@NotNull Long customerId,
|
||||
@Schema(description = "Tenant-unique warehouse code / 租户内唯一仓库编码", example = "US-LAX-01", maxLength = 50, requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
@NotBlank @Size(max = 50) String warehouseCode,
|
||||
@Schema(description = "Warehouse name / 仓库名称", example = "Los Angeles Main Warehouse / 洛杉矶主仓", maxLength = 120, requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
@NotBlank @Size(max = 120) String warehouseName,
|
||||
@Schema(description = "Warehouse type / 仓库类型", example = "OVERSEAS", requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
@NotBlank String warehouseType,
|
||||
@Schema(description = "ISO 3166-1 alpha-2 country code / ISO 两位国家代码", example = "US", pattern = "[A-Z]{2}", requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
@NotBlank @Pattern(regexp = "[A-Z]{2}") String countryCode,
|
||||
@Schema(description = "IANA timezone / IANA 时区", example = "America/Los_Angeles", requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
@NotBlank String timezone,
|
||||
@Schema(description = "Contact name / 联系人姓名", example = "Alex Chen", maxLength = 64)
|
||||
@Size(max = 64) String contactName,
|
||||
@Schema(description = "Contact phone / 联系电话", example = "+1-213-555-0100", maxLength = 32)
|
||||
@Size(max = 32) String contactPhone,
|
||||
@Schema(description = "Contact email / 联系邮箱", example = "warehouse@example.com")
|
||||
@Email String contactEmail,
|
||||
@Schema(description = "Warehouse status / 仓库状态", example = "ACTIVE", allowableValues = {"ACTIVE", "INACTIVE"}, requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
@NotBlank String status,
|
||||
@Schema(description = "Remark / 备注", example = "Supports appointment receiving / 支持预约入库", maxLength = 500)
|
||||
@Size(max = 500) String remark,
|
||||
@Schema(description = "Primary warehouse address / 仓库主地址", requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
@Valid @NotNull AddressRequest address,
|
||||
@Schema(description = "Supported service capabilities / 支持的服务能力")
|
||||
@Valid List<CapabilityRequest> capabilities
|
||||
) {
|
||||
}
|
||||
|
||||
@Schema(name = "WarehouseAddressRequest", description = "Warehouse address / 仓库地址")
|
||||
public record AddressRequest(
|
||||
@Schema(description = "Address type / 地址类型", example = "WAREHOUSE", requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
@NotBlank String addressType,
|
||||
@Schema(description = "ISO two-letter country code / ISO 两位国家代码", example = "US", requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
@NotBlank @Pattern(regexp = "[A-Z]{2}") String countryCode,
|
||||
@Schema(description = "State or province / 州或省", example = "California", maxLength = 100)
|
||||
@Size(max = 100) String stateProvince,
|
||||
@Schema(description = "City / 城市", example = "Los Angeles", maxLength = 100, requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
@NotBlank @Size(max = 100) String city,
|
||||
@Schema(description = "District / 区县", example = "Commerce", maxLength = 100)
|
||||
@Size(max = 100) String district,
|
||||
@Schema(description = "Postal code / 邮政编码", example = "90040", maxLength = 20)
|
||||
@Size(max = 20) String postalCode,
|
||||
@Schema(description = "Address line 1 / 地址第一行", example = "123 Logistics Ave", maxLength = 255, requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
@NotBlank @Size(max = 255) String addressLine1,
|
||||
@Schema(description = "Address line 2 / 地址第二行", example = "Building A", maxLength = 255)
|
||||
@Size(max = 255) String addressLine2,
|
||||
@Schema(description = "Latitude / 纬度", example = "34.052235")
|
||||
BigDecimal latitude,
|
||||
@Schema(description = "Longitude / 经度", example = "-118.243683")
|
||||
BigDecimal longitude
|
||||
) {
|
||||
}
|
||||
|
||||
@Schema(name = "WarehouseCapabilityRequest", description = "Warehouse service capability / 仓库服务能力")
|
||||
public record CapabilityRequest(
|
||||
@Schema(description = "Capability code / 能力编码", example = "STORAGE", maxLength = 50, requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
@NotBlank @Size(max = 50) String capabilityCode,
|
||||
@Schema(description = "Capability name / 能力名称", example = "Storage / 仓储", maxLength = 100, requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
@NotBlank @Size(max = 100) String capabilityName,
|
||||
@Schema(description = "Whether enabled / 是否启用", example = "true", defaultValue = "true")
|
||||
Boolean enabled,
|
||||
@Schema(description = "Daily cutoff time / 每日截单时间", example = "16:00:00", type = "string", format = "time")
|
||||
LocalTime cutoffTime,
|
||||
@Schema(description = "Capability description / 能力说明", example = "Standard pallet storage / 标准托盘仓储", maxLength = 500)
|
||||
@Size(max = 500) String description
|
||||
) {
|
||||
}
|
||||
|
||||
@Schema(name = "WarehouseQueryRequest", description = "Warehouse page query / 仓库分页查询条件")
|
||||
public record QueryRequest(
|
||||
@Schema(description = "Optional target customer ID for platform queries; merchant queries always use the authenticated customer / 平台查询可选目标客户 ID,商户查询始终使用认证客户", example = "10001", nullable = true)
|
||||
@PositiveOrZero Long customerId,
|
||||
@Schema(description = "Page number, starting at 1 / 页码,从 1 开始", example = "1", defaultValue = "1", minimum = "1")
|
||||
@Min(1) long page,
|
||||
@Schema(description = "Page size / 每页条数", example = "20", defaultValue = "20", minimum = "1", maximum = "200")
|
||||
@Min(1) @Max(200) long size,
|
||||
@Schema(description = "Country filter / 国家筛选", example = "US")
|
||||
String countryCode,
|
||||
@Schema(description = "Enabled capability code filter / 已启用能力编码筛选", example = "STORAGE")
|
||||
String capabilityCode,
|
||||
@Schema(description = "Status filter / 状态筛选", example = "ACTIVE", allowableValues = {"ACTIVE", "INACTIVE"})
|
||||
String status
|
||||
) {
|
||||
public QueryRequest {
|
||||
if (page == 0) {
|
||||
page = 1;
|
||||
}
|
||||
if (size == 0) {
|
||||
size = 20;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
-57
@@ -1,57 +0,0 @@
|
||||
package com.baiyue.warehouse.entity;
|
||||
|
||||
import lombok.Getter;
|
||||
import lombok.Setter;
|
||||
|
||||
import com.baomidou.mybatisplus.annotation.IdType;
|
||||
import com.baomidou.mybatisplus.annotation.TableId;
|
||||
import com.baomidou.mybatisplus.annotation.TableName;
|
||||
import com.baiyue.common.core.model.BaseEntity;
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
|
||||
import java.time.LocalTime;
|
||||
|
||||
/**
|
||||
* 仓库服务能力持久化实体,对应 {@code wh_service_capability} 表。
|
||||
* 用于记录租户仓库可提供的能力、启用状态和每日截单时间。
|
||||
*/
|
||||
@Schema(name = "WarehouseServiceCapability", description = "仓库服务能力")
|
||||
@Getter
|
||||
@Setter
|
||||
@TableName("wh_service_capability")
|
||||
public class ServiceCapabilityEntity extends BaseEntity {
|
||||
@Schema(description = "能力记录编号", example = "190000000000000021")
|
||||
@TableId(type = IdType.ASSIGN_ID)
|
||||
private Long id;
|
||||
@Schema(description = "租户编号", example = "10001")
|
||||
private Long customerId;
|
||||
@Schema(description = "仓库编号", example = "190000000000000001")
|
||||
private Long warehouseId;
|
||||
@Schema(description = "能力编码", example = "STORAGE")
|
||||
private String capabilityCode;
|
||||
@Schema(description = "能力名称", example = "仓储")
|
||||
private String capabilityName;
|
||||
@Schema(description = "是否启用", example = "true")
|
||||
private Boolean enabled;
|
||||
@Schema(description = "每日截单时间", example = "16:00:00", type = "string", format = "time")
|
||||
private LocalTime cutoffTime;
|
||||
@Schema(description = "能力说明", example = "标准托盘仓储")
|
||||
private String description;
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
}
|
||||
-77
@@ -1,77 +0,0 @@
|
||||
package com.baiyue.warehouse.entity;
|
||||
|
||||
import lombok.Getter;
|
||||
import lombok.Setter;
|
||||
|
||||
import com.baomidou.mybatisplus.annotation.IdType;
|
||||
import com.baomidou.mybatisplus.annotation.TableId;
|
||||
import com.baomidou.mybatisplus.annotation.TableName;
|
||||
import com.baiyue.common.core.model.BaseEntity;
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
|
||||
/**
|
||||
* 仓库地址持久化实体,对应 {@code wh_warehouse_address} 表。
|
||||
* 用于保存租户仓库的结构化地址与经纬度信息。
|
||||
*/
|
||||
@Schema(name = "WarehouseAddress", description = "仓库地址数据")
|
||||
@Getter
|
||||
@Setter
|
||||
@TableName("wh_warehouse_address")
|
||||
public class WarehouseAddressEntity extends BaseEntity {
|
||||
@Schema(description = "地址编号", example = "190000000000000011")
|
||||
@TableId(type = IdType.ASSIGN_ID)
|
||||
private Long id;
|
||||
@Schema(description = "租户编号", example = "10001")
|
||||
private Long customerId;
|
||||
@Schema(description = "仓库编号", example = "190000000000000001")
|
||||
private Long warehouseId;
|
||||
@Schema(description = "地址类型", example = "WAREHOUSE")
|
||||
private String addressType;
|
||||
@Schema(description = "ISO 两位国家代码", example = "US")
|
||||
private String countryCode;
|
||||
@Schema(description = "州或省", example = "California")
|
||||
private String stateProvince;
|
||||
@Schema(description = "城市", example = "Los Angeles")
|
||||
private String city;
|
||||
@Schema(description = "区县", example = "Commerce")
|
||||
private String district;
|
||||
@Schema(description = "邮政编码", example = "90040")
|
||||
private String postalCode;
|
||||
@Schema(description = "地址第一行", example = "123 Logistics Ave")
|
||||
private String addressLine1;
|
||||
@Schema(description = "地址第二行", example = "Building A")
|
||||
private String addressLine2;
|
||||
@Schema(description = "纬度", example = "34.052235")
|
||||
private BigDecimal latitude;
|
||||
@Schema(description = "经度", example = "-118.243683")
|
||||
private BigDecimal longitude;
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
}
|
||||
-71
@@ -1,71 +0,0 @@
|
||||
package com.baiyue.warehouse.entity;
|
||||
|
||||
import lombok.Getter;
|
||||
import lombok.Setter;
|
||||
|
||||
import com.baomidou.mybatisplus.annotation.IdType;
|
||||
import com.baomidou.mybatisplus.annotation.TableId;
|
||||
import com.baomidou.mybatisplus.annotation.TableName;
|
||||
import com.baiyue.common.core.model.BaseEntity;
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
|
||||
/**
|
||||
* 仓库主数据持久化实体,对应 {@code wh_warehouse} 表。
|
||||
* 用于记录租户下仓库的编码、类型、国家、联系方式和启停状态。
|
||||
*/
|
||||
@Schema(name = "Warehouse", description = "仓库主数据")
|
||||
@Getter
|
||||
@Setter
|
||||
@TableName("wh_warehouse")
|
||||
public class WarehouseEntity extends BaseEntity {
|
||||
@Schema(description = "仓库编号", example = "190000000000000001")
|
||||
@TableId(type = IdType.ASSIGN_ID)
|
||||
private Long id;
|
||||
@Schema(description = "租户编号", example = "10001")
|
||||
private Long customerId;
|
||||
@Schema(description = "仓库编码", example = "US-LAX-01")
|
||||
private String warehouseCode;
|
||||
@Schema(description = "仓库名称", example = "洛杉矶主仓")
|
||||
private String warehouseName;
|
||||
@Schema(description = "仓库类型", example = "OVERSEAS")
|
||||
private String warehouseType;
|
||||
@Schema(description = "ISO 两位国家代码", example = "US")
|
||||
private String countryCode;
|
||||
@Schema(description = "IANA 时区", example = "America/Los_Angeles")
|
||||
private String timezone;
|
||||
@Schema(description = "联系人", example = "陈先生")
|
||||
private String contactName;
|
||||
@Schema(description = "联系电话", example = "+1-213-555-0100")
|
||||
private String contactPhone;
|
||||
@Schema(description = "联系邮箱", example = "warehouse@example.com")
|
||||
private String contactEmail;
|
||||
@Schema(description = "仓库状态", example = "ACTIVE", allowableValues = {"ACTIVE", "INACTIVE"})
|
||||
private String status;
|
||||
@Schema(description = "备注", example = "支持预约入库")
|
||||
private String remark;
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
}
|
||||
-16
@@ -1,16 +0,0 @@
|
||||
package com.baiyue.warehouse.mapper;
|
||||
|
||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||
import com.baiyue.warehouse.entity.ServiceCapabilityEntity;
|
||||
import org.apache.ibatis.annotations.Delete;
|
||||
import org.apache.ibatis.annotations.Param;
|
||||
|
||||
/**
|
||||
* 仓库服务能力 MyBatis-Plus Mapper,负责 {@code wh_service_capability} 表访问及仓库子记录的物理清理。
|
||||
*/
|
||||
public interface ServiceCapabilityMapper extends BaseMapper<ServiceCapabilityEntity> {
|
||||
|
||||
@Delete("DELETE FROM wh_service_capability WHERE customer_id = #{customerId} AND warehouse_id = #{warehouseId}")
|
||||
int physicallyDeleteByWarehouse(@Param("customerId") long customerId,
|
||||
@Param("warehouseId") long warehouseId);
|
||||
}
|
||||
-16
@@ -1,16 +0,0 @@
|
||||
package com.baiyue.warehouse.mapper;
|
||||
|
||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||
import com.baiyue.warehouse.entity.WarehouseAddressEntity;
|
||||
import org.apache.ibatis.annotations.Delete;
|
||||
import org.apache.ibatis.annotations.Param;
|
||||
|
||||
/**
|
||||
* 仓库地址 MyBatis-Plus Mapper,负责 {@code wh_warehouse_address} 表访问及仓库子记录的物理清理。
|
||||
*/
|
||||
public interface WarehouseAddressMapper extends BaseMapper<WarehouseAddressEntity> {
|
||||
|
||||
@Delete("DELETE FROM wh_warehouse_address WHERE customer_id = #{customerId} AND warehouse_id = #{warehouseId}")
|
||||
int physicallyDeleteByWarehouse(@Param("customerId") long customerId,
|
||||
@Param("warehouseId") long warehouseId);
|
||||
}
|
||||
-6
@@ -1,6 +0,0 @@
|
||||
package com.baiyue.warehouse.mapper; import com.baomidou.mybatisplus.core.mapper.BaseMapper; import com.baiyue.warehouse.entity.WarehouseEntity;
|
||||
|
||||
/**
|
||||
* 仓库主数据 MyBatis-Plus Mapper,提供 {@code wh_warehouse} 表的单表持久化访问。
|
||||
*/
|
||||
public interface WarehouseMapper extends BaseMapper<WarehouseEntity> {}
|
||||
-137
@@ -1,137 +0,0 @@
|
||||
package com.baiyue.warehouse.repository;
|
||||
|
||||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
|
||||
import com.baiyue.common.core.api.PageResult;
|
||||
import com.baiyue.warehouse.dto.WarehouseRequests;
|
||||
import com.baiyue.warehouse.entity.ServiceCapabilityEntity;
|
||||
import com.baiyue.warehouse.entity.WarehouseAddressEntity;
|
||||
import com.baiyue.warehouse.entity.WarehouseEntity;
|
||||
import com.baiyue.warehouse.entity.WarehouseLocationEntity;
|
||||
import com.baiyue.warehouse.mapper.ServiceCapabilityMapper;
|
||||
import com.baiyue.warehouse.mapper.WarehouseAddressMapper;
|
||||
import com.baiyue.warehouse.mapper.WarehouseMapper;
|
||||
import com.baiyue.warehouse.mapper.WarehouseLocationMapper;
|
||||
import org.springframework.stereotype.Repository;
|
||||
import org.springframework.util.StringUtils;
|
||||
import jakarta.annotation.Resource;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 仓库聚合持久化仓储,组合仓库、地址和服务能力 Mapper,并强制按租户范围执行查询与写入。
|
||||
*/
|
||||
@Repository
|
||||
public class WarehouseRepository {
|
||||
@Resource private WarehouseMapper warehouses;
|
||||
@Resource private WarehouseLocationMapper locations;
|
||||
@Resource private WarehouseAddressMapper addresses;
|
||||
@Resource private ServiceCapabilityMapper capabilities;
|
||||
|
||||
public void insertWarehouse(WarehouseEntity warehouse) {
|
||||
warehouses.insert(warehouse);
|
||||
}
|
||||
|
||||
public void insertLocation(WarehouseLocationEntity location) {
|
||||
locations.insert(location);
|
||||
}
|
||||
|
||||
public boolean existsLocationCode(long customerId, long warehouseId, String locationCode) {
|
||||
return locations.selectCount(new LambdaQueryWrapper<WarehouseLocationEntity>()
|
||||
.eq(WarehouseLocationEntity::getCustomerId, customerId)
|
||||
.eq(WarehouseLocationEntity::getWarehouseId, warehouseId)
|
||||
.eq(WarehouseLocationEntity::getLocationCode, locationCode)) > 0;
|
||||
}
|
||||
|
||||
public void saveWarehouse(WarehouseEntity warehouse) {
|
||||
warehouses.updateById(warehouse);
|
||||
}
|
||||
|
||||
public WarehouseEntity findOwned(long customerId, long id) {
|
||||
return warehouses.selectOne(new LambdaQueryWrapper<WarehouseEntity>()
|
||||
.eq(WarehouseEntity::getCustomerId, customerId)
|
||||
.eq(WarehouseEntity::getId, id));
|
||||
}
|
||||
|
||||
public boolean existsCode(long customerId, String code, Long excludedId) {
|
||||
return warehouses.selectCount(new LambdaQueryWrapper<WarehouseEntity>()
|
||||
.eq(WarehouseEntity::getCustomerId, customerId)
|
||||
.eq(WarehouseEntity::getWarehouseCode, code)
|
||||
.ne(excludedId != null, WarehouseEntity::getId, excludedId)) > 0;
|
||||
}
|
||||
|
||||
public PageResult<WarehouseEntity> page(long customerId, WarehouseRequests.QueryRequest query) {
|
||||
LambdaQueryWrapper<WarehouseEntity> wrapper = new LambdaQueryWrapper<WarehouseEntity>()
|
||||
.eq(WarehouseEntity::getCustomerId, customerId)
|
||||
.eq(StringUtils.hasText(query.status()), WarehouseEntity::getStatus, query.status())
|
||||
.eq(StringUtils.hasText(query.countryCode()), WarehouseEntity::getCountryCode, query.countryCode())
|
||||
.orderByDesc(WarehouseEntity::getCreatedAt);
|
||||
|
||||
if (StringUtils.hasText(query.capabilityCode())) {
|
||||
List<Long> ids = capabilities.selectList(new LambdaQueryWrapper<ServiceCapabilityEntity>()
|
||||
.select(ServiceCapabilityEntity::getWarehouseId)
|
||||
.eq(ServiceCapabilityEntity::getCustomerId, customerId)
|
||||
.eq(ServiceCapabilityEntity::getCapabilityCode, query.capabilityCode())
|
||||
.eq(ServiceCapabilityEntity::getEnabled, true))
|
||||
.stream()
|
||||
.map(ServiceCapabilityEntity::getWarehouseId)
|
||||
.distinct()
|
||||
.toList();
|
||||
if (ids.isEmpty()) {
|
||||
return new PageResult<>(List.of(), query.page(), query.size(), 0, 0);
|
||||
}
|
||||
wrapper.in(WarehouseEntity::getId, ids);
|
||||
}
|
||||
return PageResult.from(warehouses.selectPage(new Page<>(query.page(), query.size()), wrapper));
|
||||
}
|
||||
|
||||
public PageResult<WarehouseEntity> pageAll(WarehouseRequests.QueryRequest query) {
|
||||
LambdaQueryWrapper<WarehouseEntity> wrapper = new LambdaQueryWrapper<WarehouseEntity>()
|
||||
.eq(query.customerId() != null, WarehouseEntity::getCustomerId, query.customerId())
|
||||
.eq(StringUtils.hasText(query.status()), WarehouseEntity::getStatus, query.status())
|
||||
.eq(StringUtils.hasText(query.countryCode()), WarehouseEntity::getCountryCode, query.countryCode())
|
||||
.orderByDesc(WarehouseEntity::getCreatedAt);
|
||||
|
||||
if (StringUtils.hasText(query.capabilityCode())) {
|
||||
List<Long> ids = capabilities.selectList(new LambdaQueryWrapper<ServiceCapabilityEntity>()
|
||||
.select(ServiceCapabilityEntity::getWarehouseId)
|
||||
.eq(query.customerId() != null, ServiceCapabilityEntity::getCustomerId, query.customerId())
|
||||
.eq(ServiceCapabilityEntity::getCapabilityCode, query.capabilityCode())
|
||||
.eq(ServiceCapabilityEntity::getEnabled, true))
|
||||
.stream()
|
||||
.map(ServiceCapabilityEntity::getWarehouseId)
|
||||
.distinct()
|
||||
.toList();
|
||||
if (ids.isEmpty()) {
|
||||
return new PageResult<>(List.of(), query.page(), query.size(), 0, 0);
|
||||
}
|
||||
wrapper.in(WarehouseEntity::getId, ids);
|
||||
}
|
||||
return PageResult.from(warehouses.selectPage(new Page<>(query.page(), query.size()), wrapper));
|
||||
}
|
||||
|
||||
public List<WarehouseAddressEntity> findAddresses(long customerId, long warehouseId) {
|
||||
return addresses.selectList(new LambdaQueryWrapper<WarehouseAddressEntity>()
|
||||
.eq(WarehouseAddressEntity::getCustomerId, customerId)
|
||||
.eq(WarehouseAddressEntity::getWarehouseId, warehouseId));
|
||||
}
|
||||
|
||||
public List<ServiceCapabilityEntity> findCapabilities(long customerId, long warehouseId) {
|
||||
return capabilities.selectList(new LambdaQueryWrapper<ServiceCapabilityEntity>()
|
||||
.eq(ServiceCapabilityEntity::getCustomerId, customerId)
|
||||
.eq(ServiceCapabilityEntity::getWarehouseId, warehouseId));
|
||||
}
|
||||
|
||||
public void physicallyDeleteChildren(long customerId, long warehouseId) {
|
||||
addresses.physicallyDeleteByWarehouse(customerId, warehouseId);
|
||||
capabilities.physicallyDeleteByWarehouse(customerId, warehouseId);
|
||||
}
|
||||
|
||||
public void insertAddress(WarehouseAddressEntity address) {
|
||||
addresses.insert(address);
|
||||
}
|
||||
|
||||
public void insertCapability(ServiceCapabilityEntity capability) {
|
||||
capabilities.insert(capability);
|
||||
}
|
||||
}
|
||||
-19
@@ -5,11 +5,9 @@ import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
|
||||
import com.baiyue.common.core.api.PageResult;
|
||||
import com.baiyue.warehouse.dto.LocationRequests;
|
||||
import com.baiyue.warehouse.dto.PalletRequests;
|
||||
import com.baiyue.warehouse.entity.WarehouseEntity;
|
||||
import com.baiyue.warehouse.entity.WarehouseLocationEntity;
|
||||
import com.baiyue.warehouse.entity.WarehousePalletEntity;
|
||||
import com.baiyue.warehouse.mapper.WarehouseLocationMapper;
|
||||
import com.baiyue.warehouse.mapper.WarehouseMapper;
|
||||
import com.baiyue.warehouse.mapper.WarehousePalletMapper;
|
||||
import com.baiyue.warehouse.mapper.WarehousePalletBindingMapper;
|
||||
import com.baiyue.warehouse.entity.WarehousePalletBindingEntity;
|
||||
@@ -21,7 +19,6 @@ import java.util.List;
|
||||
|
||||
@Repository
|
||||
public class WarehouseResourceRepository {
|
||||
@Resource private WarehouseMapper warehouses;
|
||||
@Resource private WarehouseLocationMapper locations;
|
||||
@Resource private WarehousePalletMapper pallets;
|
||||
@Resource private WarehousePalletBindingMapper bindings;
|
||||
@@ -35,22 +32,6 @@ public class WarehouseResourceRepository {
|
||||
bindings.insert(entity);
|
||||
}
|
||||
|
||||
public boolean warehouseOwned(long customerId, long warehouseId) {
|
||||
return warehouses.selectCount(new LambdaQueryWrapper<WarehouseEntity>()
|
||||
.eq(WarehouseEntity::getCustomerId, customerId)
|
||||
.eq(WarehouseEntity::getId, warehouseId)) > 0;
|
||||
}
|
||||
|
||||
public WarehouseEntity findWarehouse(long customerId, long warehouseId) {
|
||||
return warehouses.selectOne(new LambdaQueryWrapper<WarehouseEntity>()
|
||||
.eq(WarehouseEntity::getCustomerId, customerId)
|
||||
.eq(WarehouseEntity::getId, warehouseId));
|
||||
}
|
||||
|
||||
public WarehouseEntity findWarehouseById(long warehouseId) {
|
||||
return warehouses.selectById(warehouseId);
|
||||
}
|
||||
|
||||
public WarehouseLocationEntity findDefaultVirtualLocation(long customerId, long warehouseId) {
|
||||
return locations.selectOne(new LambdaQueryWrapper<WarehouseLocationEntity>()
|
||||
.eq(WarehouseLocationEntity::getCustomerId, customerId)
|
||||
|
||||
+69
@@ -0,0 +1,69 @@
|
||||
package com.baiyue.warehouse.service;
|
||||
|
||||
import com.baiyue.common.core.api.ResultCode;
|
||||
import com.baiyue.common.core.exception.BizException;
|
||||
import com.baiyue.warehouse.client.DomesticWarehouseDirectoryClient;
|
||||
import com.baiyue.warehouse.dto.SharedWarehouseReference;
|
||||
import jakarta.annotation.Resource;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.util.StringUtils;
|
||||
|
||||
import java.time.Clock;
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
|
||||
@Component
|
||||
public class DomesticWarehouseDirectory {
|
||||
@Resource private DomesticWarehouseDirectoryClient client;
|
||||
@Value("${baiyue.security.internal-service-token:${baiyue.internal-service.token}}")
|
||||
private String internalToken;
|
||||
@Value("${baiyue.clients.domestic-warehouse.cache-ttl:30m}") private Duration ttl;
|
||||
private final ConcurrentHashMap<Long, CachedWarehouse> cache = new ConcurrentHashMap<>();
|
||||
private Clock clock = Clock.systemUTC();
|
||||
|
||||
public SharedWarehouseReference required(long warehouseId) {
|
||||
CachedWarehouse current = cache.get(warehouseId);
|
||||
Instant now = clock.instant();
|
||||
if (current != null && now.isBefore(current.expiresAt())) return current.warehouse();
|
||||
var response = client.activeSharedWarehouse(internalToken, warehouseId);
|
||||
if (response == null || !response.successful() || response.data() == null
|
||||
|| !valid(response.data())) {
|
||||
cache.remove(warehouseId);
|
||||
throw new BizException(ResultCode.NOT_FOUND,
|
||||
"启用的公共海外收货仓不存在");
|
||||
}
|
||||
SharedWarehouseReference warehouse = toReference(response.data());
|
||||
cache.put(warehouseId, new CachedWarehouse(warehouse, now.plus(ttl)));
|
||||
return warehouse;
|
||||
}
|
||||
|
||||
public List<SharedWarehouseReference> options() {
|
||||
var response = client.activeSharedWarehouses(internalToken);
|
||||
if (response == null || !response.successful() || response.data() == null) {
|
||||
throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "国内仓库目录暂不可用");
|
||||
}
|
||||
List<SharedWarehouseReference> warehouses = response.data().stream()
|
||||
.filter(this::valid).map(this::toReference).toList();
|
||||
if (!warehouses.isEmpty()) {
|
||||
Instant expiresAt = clock.instant().plus(ttl);
|
||||
warehouses.forEach(value -> cache.put(value.warehouseId(), new CachedWarehouse(value, expiresAt)));
|
||||
}
|
||||
return warehouses;
|
||||
}
|
||||
|
||||
private boolean valid(DomesticWarehouseDirectoryClient.WarehouseReference value) {
|
||||
return value.warehouseId() != null && value.warehouseId() > 0
|
||||
&& StringUtils.hasText(value.warehouseCode())
|
||||
&& StringUtils.hasText(value.warehouseName()) && "ACTIVE".equals(value.status());
|
||||
}
|
||||
|
||||
private SharedWarehouseReference toReference(DomesticWarehouseDirectoryClient.WarehouseReference value) {
|
||||
return new SharedWarehouseReference(value.warehouseId(), value.warehouseCode().trim(),
|
||||
value.warehouseName().trim(), value.status());
|
||||
}
|
||||
|
||||
private record CachedWarehouse(SharedWarehouseReference warehouse, Instant expiresAt) { }
|
||||
}
|
||||
-206
@@ -1,206 +0,0 @@
|
||||
package com.baiyue.warehouse.service;
|
||||
import jakarta.annotation.Resource;
|
||||
|
||||
import com.baiyue.common.audit.BusinessAudit;
|
||||
import com.baiyue.common.core.api.PageResult;
|
||||
import com.baiyue.common.core.api.ResultCode;
|
||||
import com.baiyue.common.core.exception.BizException;
|
||||
import com.baiyue.warehouse.dto.WarehouseDetailResponse;
|
||||
import com.baiyue.warehouse.dto.WarehouseRequests;
|
||||
import com.baiyue.warehouse.dto.SharedWarehouseReference;
|
||||
import com.baiyue.warehouse.entity.ServiceCapabilityEntity;
|
||||
import com.baiyue.warehouse.entity.WarehouseAddressEntity;
|
||||
import com.baiyue.warehouse.entity.WarehouseEntity;
|
||||
import com.baiyue.warehouse.entity.WarehouseLocationEntity;
|
||||
import com.baiyue.warehouse.repository.WarehouseRepository;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.util.List;
|
||||
import java.math.BigDecimal;
|
||||
|
||||
/**
|
||||
* 仓库应用服务,负责仓库主数据、地址和服务能力的租户级用例编排、事务控制与业务校验。
|
||||
*/
|
||||
@Service
|
||||
public class WarehouseApplicationService {
|
||||
@Resource
|
||||
private WarehouseRepository repository;
|
||||
|
||||
|
||||
@Transactional
|
||||
@BusinessAudit(
|
||||
event = "WAREHOUSE_CREATE",
|
||||
resourceType = "WAREHOUSE",
|
||||
resourceId = "#r.warehouseCode",
|
||||
description = "新建仓库",
|
||||
captureBefore = false
|
||||
)
|
||||
public Long create(WarehouseRequests.SaveRequest request) {
|
||||
validateSharedWarehouse(request);
|
||||
ensureCodeUnique(request.customerId(), request.warehouseCode(), null);
|
||||
WarehouseEntity warehouse = toEntity(request);
|
||||
repository.insertWarehouse(warehouse);
|
||||
ensureDefaultVirtualLocation(warehouse);
|
||||
insertChildren(warehouse.getCustomerId(), warehouse.getId(), request.address(), request.capabilities());
|
||||
return warehouse.getId();
|
||||
}
|
||||
|
||||
private void ensureDefaultVirtualLocation(WarehouseEntity warehouse) {
|
||||
String locationCode = "LOC-" + warehouse.getWarehouseCode() + "-DEFAULT";
|
||||
if (repository.existsLocationCode(warehouse.getCustomerId(), warehouse.getId(), locationCode)) {
|
||||
return;
|
||||
}
|
||||
WarehouseLocationEntity location = new WarehouseLocationEntity();
|
||||
location.setCustomerId(warehouse.getCustomerId());
|
||||
location.setWarehouseId(warehouse.getId());
|
||||
location.setZoneCode("DEFAULT");
|
||||
location.setLocationCode(locationCode);
|
||||
location.setLocationType("VIRTUAL");
|
||||
location.setMaxLoadKg(new BigDecimal("999999999.000"));
|
||||
location.setStatus("ACTIVE");
|
||||
location.setRemark("System default virtual location");
|
||||
repository.insertLocation(location);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
@BusinessAudit(
|
||||
event = "WAREHOUSE_UPDATE",
|
||||
resourceType = "WAREHOUSE",
|
||||
resourceId = "#id",
|
||||
description = "更新仓库"
|
||||
)
|
||||
public void update(long customerId, long id, WarehouseRequests.SaveRequest request) {
|
||||
if (request.customerId() == null || customerId != request.customerId()) {
|
||||
throw new BizException(ResultCode.FORBIDDEN, "租户不匹配");
|
||||
}
|
||||
validateSharedWarehouse(request);
|
||||
WarehouseEntity old = owned(customerId, id);
|
||||
ensureCodeUnique(customerId, request.warehouseCode(), id);
|
||||
|
||||
WarehouseEntity warehouse = toEntity(request);
|
||||
warehouse.setId(old.getId());
|
||||
repository.saveWarehouse(warehouse);
|
||||
|
||||
repository.physicallyDeleteChildren(customerId, id);
|
||||
insertChildren(customerId, id, request.address(), request.capabilities());
|
||||
}
|
||||
|
||||
public PageResult<WarehouseEntity> page(long customerId, WarehouseRequests.QueryRequest query) {
|
||||
return repository.page(customerId, query);
|
||||
}
|
||||
|
||||
public PageResult<WarehouseEntity> pageAll(WarehouseRequests.QueryRequest query) {
|
||||
return repository.pageAll(query);
|
||||
}
|
||||
|
||||
public WarehouseDetailResponse detail(long customerId, long id) {
|
||||
WarehouseEntity warehouse = owned(customerId, id);
|
||||
List<WarehouseAddressEntity> addresses = repository.findAddresses(customerId, id);
|
||||
List<ServiceCapabilityEntity> capabilities = repository.findCapabilities(customerId, id);
|
||||
return new WarehouseDetailResponse(warehouse, addresses, capabilities);
|
||||
}
|
||||
|
||||
public SharedWarehouseReference activeSharedReference(long warehouseId) {
|
||||
WarehouseEntity warehouse = repository.findOwned(0L, warehouseId);
|
||||
boolean receiving = warehouse != null && repository.findCapabilities(0L, warehouseId).stream()
|
||||
.anyMatch(value -> "RECEIVING".equals(value.getCapabilityCode()) && Boolean.TRUE.equals(value.getEnabled()));
|
||||
if (warehouse == null || !"ACTIVE".equals(warehouse.getStatus())
|
||||
|| !"OVERSEAS".equals(warehouse.getWarehouseType()) || !receiving) {
|
||||
throw new BizException(ResultCode.NOT_FOUND, "Active shared overseas warehouse not found");
|
||||
}
|
||||
return new SharedWarehouseReference(warehouse.getId(), warehouse.getWarehouseCode(),
|
||||
warehouse.getWarehouseName(), warehouse.getStatus());
|
||||
}
|
||||
|
||||
private void validateSharedWarehouse(WarehouseRequests.SaveRequest request) {
|
||||
if (request.customerId() == null || request.customerId() != 0L) return;
|
||||
boolean receiving = request.capabilities() != null && request.capabilities().stream()
|
||||
.anyMatch(value -> "RECEIVING".equals(value.capabilityCode())
|
||||
&& !Boolean.FALSE.equals(value.enabled()));
|
||||
if (!"OVERSEAS".equals(request.warehouseType()) || !receiving) {
|
||||
throw new BizException(ResultCode.BAD_REQUEST,
|
||||
"Shared physical warehouse must be OVERSEAS with RECEIVING enabled");
|
||||
}
|
||||
}
|
||||
|
||||
@Transactional
|
||||
@BusinessAudit(
|
||||
event = "WAREHOUSE_STATUS_CHANGE",
|
||||
resourceType = "WAREHOUSE",
|
||||
resourceId = "#id",
|
||||
description = "启用或停用仓库"
|
||||
)
|
||||
public void changeStatus(long customerId, long id, String status) {
|
||||
if (!"ACTIVE".equals(status) && !"INACTIVE".equals(status)) {
|
||||
throw new BizException("无效状态");
|
||||
}
|
||||
WarehouseEntity warehouse = owned(customerId, id);
|
||||
warehouse.setStatus(status);
|
||||
repository.saveWarehouse(warehouse);
|
||||
}
|
||||
|
||||
private WarehouseEntity owned(long customerId, long id) {
|
||||
WarehouseEntity warehouse = repository.findOwned(customerId, id);
|
||||
if (warehouse == null) {
|
||||
throw new BizException(ResultCode.NOT_FOUND, "仓库不存在");
|
||||
}
|
||||
return warehouse;
|
||||
}
|
||||
|
||||
private void ensureCodeUnique(long customerId, String code, Long excludedId) {
|
||||
if (repository.existsCode(customerId, code, excludedId)) {
|
||||
throw new BizException(ResultCode.CONFLICT, "仓库编码已存在");
|
||||
}
|
||||
}
|
||||
|
||||
private WarehouseEntity toEntity(WarehouseRequests.SaveRequest request) {
|
||||
WarehouseEntity warehouse = new WarehouseEntity();
|
||||
warehouse.setCustomerId(request.customerId());
|
||||
warehouse.setWarehouseCode(request.warehouseCode());
|
||||
warehouse.setWarehouseName(request.warehouseName());
|
||||
warehouse.setWarehouseType(request.warehouseType());
|
||||
warehouse.setCountryCode(request.countryCode());
|
||||
warehouse.setTimezone(request.timezone());
|
||||
warehouse.setContactName(request.contactName());
|
||||
warehouse.setContactPhone(request.contactPhone());
|
||||
warehouse.setContactEmail(request.contactEmail());
|
||||
warehouse.setStatus(request.status());
|
||||
warehouse.setRemark(request.remark());
|
||||
return warehouse;
|
||||
}
|
||||
|
||||
private void insertChildren(long customerId, long warehouseId,
|
||||
WarehouseRequests.AddressRequest address,
|
||||
List<WarehouseRequests.CapabilityRequest> requestedCapabilities) {
|
||||
WarehouseAddressEntity addressEntity = new WarehouseAddressEntity();
|
||||
addressEntity.setCustomerId(customerId);
|
||||
addressEntity.setWarehouseId(warehouseId);
|
||||
addressEntity.setAddressType(address.addressType());
|
||||
addressEntity.setCountryCode(address.countryCode());
|
||||
addressEntity.setStateProvince(address.stateProvince());
|
||||
addressEntity.setCity(address.city());
|
||||
addressEntity.setDistrict(address.district());
|
||||
addressEntity.setPostalCode(address.postalCode());
|
||||
addressEntity.setAddressLine1(address.addressLine1());
|
||||
addressEntity.setAddressLine2(address.addressLine2());
|
||||
addressEntity.setLatitude(address.latitude());
|
||||
addressEntity.setLongitude(address.longitude());
|
||||
repository.insertAddress(addressEntity);
|
||||
|
||||
if (requestedCapabilities == null) {
|
||||
return;
|
||||
}
|
||||
for (WarehouseRequests.CapabilityRequest capability : requestedCapabilities) {
|
||||
ServiceCapabilityEntity entity = new ServiceCapabilityEntity();
|
||||
entity.setCustomerId(customerId);
|
||||
entity.setWarehouseId(warehouseId);
|
||||
entity.setCapabilityCode(capability.capabilityCode());
|
||||
entity.setCapabilityName(capability.capabilityName());
|
||||
entity.setEnabled(capability.enabled() == null || capability.enabled());
|
||||
entity.setCutoffTime(capability.cutoffTime());
|
||||
entity.setDescription(capability.description());
|
||||
repository.insertCapability(entity);
|
||||
}
|
||||
}
|
||||
}
|
||||
+9
-21
@@ -7,9 +7,9 @@ import com.baiyue.warehouse.dto.LocationRequests;
|
||||
import com.baiyue.warehouse.dto.PalletRequests;
|
||||
import com.baiyue.warehouse.dto.WarehousePalletResolveResponse;
|
||||
import com.baiyue.warehouse.dto.WarehouseLocationResolveResponse;
|
||||
import com.baiyue.warehouse.dto.SharedWarehouseReference;
|
||||
import com.baiyue.warehouse.entity.WarehouseLocationEntity;
|
||||
import com.baiyue.warehouse.entity.WarehousePalletEntity;
|
||||
import com.baiyue.warehouse.entity.WarehouseEntity;
|
||||
import com.baiyue.warehouse.entity.WarehousePalletBindingEntity;
|
||||
import com.baiyue.warehouse.repository.WarehouseResourceRepository;
|
||||
import com.baomidou.mybatisplus.core.toolkit.IdWorker;
|
||||
@@ -29,6 +29,7 @@ public class WarehouseResourceService {
|
||||
private static final Set<String> PALLET_STATUSES = Set.of("IDLE", "OCCUPIED", "SCRAPPED");
|
||||
|
||||
@Resource private WarehouseResourceRepository repository;
|
||||
@Resource private DomesticWarehouseDirectory warehouseDirectory;
|
||||
|
||||
@Transactional
|
||||
public Long createLocation(long customerId, long warehouseId, LocationRequests.SaveRequest request) {
|
||||
@@ -78,11 +79,11 @@ public class WarehouseResourceService {
|
||||
|
||||
@Transactional
|
||||
public Long createPallet(long customerId, long warehouseId, PalletRequests.SaveRequest request) {
|
||||
WarehouseEntity warehouse = requireWarehouseEntity(customerId, warehouseId);
|
||||
SharedWarehouseReference warehouse = requireWarehouseReference(warehouseId);
|
||||
validatePalletStatus(request.status());
|
||||
Long palletId = IdWorker.getId();
|
||||
String palletCode = StringUtils.hasText(request.palletCode()) ? request.palletCode().trim()
|
||||
: generatedPalletCode(warehouse.getWarehouseCode(), palletId);
|
||||
: generatedPalletCode(warehouse.warehouseCode(), palletId);
|
||||
Long locationId = request.locationId();
|
||||
if (locationId == null) {
|
||||
locationId = ensureDefaultVirtualLocation(customerId, warehouseId).getId();
|
||||
@@ -121,8 +122,8 @@ public class WarehouseResourceService {
|
||||
*/
|
||||
@Transactional
|
||||
public WarehouseLocationEntity ensureDefaultVirtualLocation(long customerId, long warehouseId) {
|
||||
WarehouseEntity warehouse = requireWarehouseEntity(customerId, warehouseId);
|
||||
String locationCode = defaultLocationCode(warehouse.getWarehouseCode());
|
||||
SharedWarehouseReference warehouse = requireWarehouseReference(warehouseId);
|
||||
String locationCode = defaultLocationCode(warehouse.warehouseCode());
|
||||
WarehouseLocationEntity existing = repository.findLocationByCode(customerId, warehouseId, locationCode);
|
||||
if (existing != null) {
|
||||
if (!"VIRTUAL".equals(existing.getLocationType()) || !"ACTIVE".equals(existing.getStatus())) {
|
||||
@@ -230,24 +231,11 @@ public class WarehouseResourceService {
|
||||
}
|
||||
|
||||
private void requireWarehouse(long customerId, long warehouseId) {
|
||||
if (customerId == PUBLIC_RESOURCE_SCOPE) {
|
||||
if (repository.findWarehouseById(warehouseId) == null) {
|
||||
throw new BizException(ResultCode.NOT_FOUND, "Warehouse not found");
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (!repository.warehouseOwned(customerId, warehouseId)) {
|
||||
throw new BizException(ResultCode.NOT_FOUND, "当前租户下仓库不存在");
|
||||
}
|
||||
warehouseDirectory.required(warehouseId);
|
||||
}
|
||||
|
||||
private WarehouseEntity requireWarehouseEntity(long customerId, long warehouseId) {
|
||||
WarehouseEntity warehouse = customerId == PUBLIC_RESOURCE_SCOPE
|
||||
? repository.findWarehouseById(warehouseId) : repository.findWarehouse(customerId, warehouseId);
|
||||
if (warehouse == null) {
|
||||
throw new BizException(ResultCode.NOT_FOUND, "Tenant warehouse not found");
|
||||
}
|
||||
return warehouse;
|
||||
private SharedWarehouseReference requireWarehouseReference(long warehouseId) {
|
||||
return warehouseDirectory.required(warehouseId);
|
||||
}
|
||||
|
||||
private WarehouseLocationEntity requireLocation(long customerId, long warehouseId, long id) {
|
||||
|
||||
@@ -38,6 +38,10 @@ spring:
|
||||
management.endpoints.web.exposure.include: health,info,prometheus
|
||||
|
||||
baiyue:
|
||||
clients:
|
||||
domestic-warehouse:
|
||||
url: ${DOMESTIC_WAREHOUSE_URL:}
|
||||
cache-ttl: ${DOMESTIC_WAREHOUSE_CACHE_TTL:30m}
|
||||
internal-service:
|
||||
token: ${INTERNAL_SERVICE_TOKEN:phase1-local-internal-token-at-least-32-bytes}
|
||||
security:
|
||||
|
||||
@@ -79,77 +79,8 @@ CREATE TABLE IF NOT EXISTS wh_resource_lock (
|
||||
KEY idx_resource_lock_business (customer_id, business_type, business_id)
|
||||
) ENGINE=InnoDB COMMENT='仓储资源锁定记录';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS wh_warehouse (
|
||||
id BIGINT UNSIGNED NOT NULL COMMENT '仓库ID(雪花ID)',
|
||||
customer_id BIGINT UNSIGNED NOT NULL COMMENT '仓库所属租户ID',
|
||||
warehouse_code VARCHAR(50) NOT NULL COMMENT '租户内唯一仓库编码',
|
||||
warehouse_name VARCHAR(120) NOT NULL COMMENT '仓库名称',
|
||||
warehouse_type VARCHAR(32) NOT NULL DEFAULT 'OVERSEAS' COMMENT '仓库类型',
|
||||
country_code CHAR(2) NOT NULL COMMENT '所在国家二字码',
|
||||
timezone VARCHAR(64) NOT NULL COMMENT '仓库所在地IANA时区',
|
||||
contact_name VARCHAR(64) DEFAULT NULL COMMENT '仓库联系人',
|
||||
contact_phone VARCHAR(32) DEFAULT NULL COMMENT '联系电话',
|
||||
contact_email VARCHAR(128) DEFAULT NULL COMMENT '联系邮箱',
|
||||
status VARCHAR(20) NOT NULL DEFAULT 'ACTIVE' COMMENT '仓库状态',
|
||||
remark VARCHAR(500) DEFAULT NULL COMMENT '仓库备注',
|
||||
created_by BIGINT UNSIGNED DEFAULT NULL COMMENT '创建人ID',
|
||||
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间',
|
||||
updated_by BIGINT UNSIGNED DEFAULT NULL COMMENT '最后修改人ID',
|
||||
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间',
|
||||
deleted TINYINT(1) NOT NULL DEFAULT 0 COMMENT '逻辑删除标记',
|
||||
version INT NOT NULL DEFAULT 0 COMMENT '乐观锁版本号',
|
||||
PRIMARY KEY (id),
|
||||
UNIQUE KEY uk_wh_tenant_code (customer_id, warehouse_code, deleted),
|
||||
KEY idx_wh_tenant_status (customer_id, status, deleted),
|
||||
KEY idx_wh_country (customer_id, country_code, deleted)
|
||||
) ENGINE=InnoDB COMMENT='仓库主数据表:以租户为边界维护仓库编码、名称、国家、时区和启停状态';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS wh_warehouse_address (
|
||||
id BIGINT UNSIGNED NOT NULL COMMENT '仓库地址ID',
|
||||
customer_id BIGINT UNSIGNED NOT NULL COMMENT '数据所属租户ID',
|
||||
warehouse_id BIGINT UNSIGNED NOT NULL COMMENT '所属仓库ID',
|
||||
address_type VARCHAR(20) NOT NULL DEFAULT 'OPERATING' COMMENT '地址类型,例如OPERATING或RETURN',
|
||||
country_code CHAR(2) NOT NULL COMMENT '国家二字码',
|
||||
state_province VARCHAR(100) DEFAULT NULL COMMENT '省或州',
|
||||
city VARCHAR(100) NOT NULL COMMENT '城市',
|
||||
district VARCHAR(100) DEFAULT NULL COMMENT '区县',
|
||||
postal_code VARCHAR(20) DEFAULT NULL COMMENT '邮政编码',
|
||||
address_line1 VARCHAR(255) NOT NULL COMMENT '主地址行',
|
||||
address_line2 VARCHAR(255) DEFAULT NULL COMMENT '补充地址行',
|
||||
latitude DECIMAL(10,7) DEFAULT NULL COMMENT '纬度',
|
||||
longitude DECIMAL(10,7) DEFAULT NULL COMMENT '经度',
|
||||
created_by BIGINT UNSIGNED DEFAULT NULL COMMENT '创建人ID',
|
||||
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间',
|
||||
updated_by BIGINT UNSIGNED DEFAULT NULL COMMENT '最后修改人ID',
|
||||
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间',
|
||||
deleted TINYINT(1) NOT NULL DEFAULT 0 COMMENT '逻辑删除标记',
|
||||
version INT NOT NULL DEFAULT 0 COMMENT '乐观锁版本号',
|
||||
PRIMARY KEY (id),
|
||||
UNIQUE KEY uk_address_type (customer_id, warehouse_id, address_type, deleted),
|
||||
KEY idx_address_warehouse (customer_id, warehouse_id, deleted)
|
||||
) ENGINE=InnoDB COMMENT='仓库地址表:保存运营、退货等不同用途的地址及可选经纬度';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS wh_service_capability (
|
||||
id BIGINT UNSIGNED NOT NULL COMMENT '服务能力ID',
|
||||
customer_id BIGINT UNSIGNED NOT NULL COMMENT '数据所属租户ID',
|
||||
warehouse_id BIGINT UNSIGNED NOT NULL COMMENT '所属仓库ID',
|
||||
capability_code VARCHAR(50) NOT NULL COMMENT '能力编码:RECEIVING、STORAGE、PICK_PACK、RETURN等',
|
||||
capability_name VARCHAR(100) NOT NULL COMMENT '能力名称',
|
||||
enabled TINYINT(1) NOT NULL DEFAULT 1 COMMENT '是否启用',
|
||||
cutoff_time TIME DEFAULT NULL COMMENT '当日作业截单时间',
|
||||
description VARCHAR(500) DEFAULT NULL COMMENT '能力说明',
|
||||
created_by BIGINT UNSIGNED DEFAULT NULL COMMENT '创建人ID',
|
||||
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间',
|
||||
updated_by BIGINT UNSIGNED DEFAULT NULL COMMENT '最后修改人ID',
|
||||
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间',
|
||||
deleted TINYINT(1) NOT NULL DEFAULT 0 COMMENT '逻辑删除标记',
|
||||
version INT NOT NULL DEFAULT 0 COMMENT '乐观锁版本号',
|
||||
PRIMARY KEY (id),
|
||||
UNIQUE KEY uk_capability (customer_id, warehouse_id, capability_code, deleted),
|
||||
KEY idx_capability_query (customer_id, warehouse_id, enabled, deleted)
|
||||
) ENGINE=InnoDB COMMENT='仓库服务能力表:声明仓库可提供的收货、仓储、拣包、退货等能力及截单时间';
|
||||
|
||||
-- 不使用跨服务数据库外键;仓库聚合内的归属由服务代码通过 customer_id 与 warehouse_id 共同校验。
|
||||
-- warehouse_id references the domestic warehouse-service authority. This overseas database does
|
||||
-- not duplicate warehouse master, address or capability tables and has no cross-database FK.
|
||||
|
||||
-- 修复既有数据库中因历史脚本编码错误或英文初始化产生的表与字段注释。
|
||||
ALTER TABLE wh_location COMMENT='仓库库位表:按租户和仓库维护库位及承重信息';
|
||||
@@ -192,18 +123,6 @@ CREATE TABLE IF NOT EXISTS wh_pallet_operation (
|
||||
KEY idx_wh_pallet_operation_pallet (warehouse_id,pallet_id,created_at)
|
||||
) ENGINE=InnoDB COMMENT='实物托盘状态幂等变更与补偿记录';
|
||||
|
||||
-- Every warehouse has one non-occupiable virtual location used for inventory history.
|
||||
INSERT INTO wh_location (id, customer_id, warehouse_id, zone_code, location_code, location_type,
|
||||
max_load_kg, status, remark)
|
||||
SELECT UUID_SHORT(), w.customer_id, w.id, 'DEFAULT', CONCAT('LOC-', w.warehouse_code, '-DEFAULT'),
|
||||
'VIRTUAL', 999999999.000, 'ACTIVE', 'System default virtual location'
|
||||
FROM wh_warehouse w
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM wh_location l
|
||||
WHERE l.customer_id = w.customer_id AND l.warehouse_id = w.id
|
||||
AND l.location_code = CONCAT('LOC-', w.warehouse_code, '-DEFAULT') AND l.deleted = 0
|
||||
);
|
||||
|
||||
ALTER TABLE wh_pallet COMMENT='仓库托盘表:按租户和仓库维护托盘、容量、单价及状态';
|
||||
ALTER TABLE wh_pallet
|
||||
MODIFY COLUMN id BIGINT UNSIGNED NOT NULL COMMENT '托盘ID(雪花ID)',
|
||||
@@ -223,63 +142,3 @@ ALTER TABLE wh_pallet
|
||||
MODIFY COLUMN updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间',
|
||||
MODIFY COLUMN deleted TINYINT(1) NOT NULL DEFAULT 0 COMMENT '逻辑删除标记',
|
||||
MODIFY COLUMN version INT NOT NULL DEFAULT 0 COMMENT '乐观锁版本号';
|
||||
|
||||
ALTER TABLE wh_warehouse COMMENT='仓库主数据表:以租户为边界维护仓库编码、名称、国家、时区和启停状态';
|
||||
ALTER TABLE wh_warehouse
|
||||
MODIFY COLUMN id BIGINT UNSIGNED NOT NULL COMMENT '仓库ID(雪花ID)',
|
||||
MODIFY COLUMN customer_id BIGINT UNSIGNED NOT NULL COMMENT '仓库所属租户ID',
|
||||
MODIFY COLUMN warehouse_code VARCHAR(50) NOT NULL COMMENT '租户内唯一仓库编码',
|
||||
MODIFY COLUMN warehouse_name VARCHAR(120) NOT NULL COMMENT '仓库名称',
|
||||
MODIFY COLUMN warehouse_type VARCHAR(32) NOT NULL DEFAULT 'OVERSEAS' COMMENT '仓库类型',
|
||||
MODIFY COLUMN country_code CHAR(2) NOT NULL COMMENT '所在国家二字码',
|
||||
MODIFY COLUMN timezone VARCHAR(64) NOT NULL COMMENT '仓库所在地IANA时区',
|
||||
MODIFY COLUMN contact_name VARCHAR(64) NULL COMMENT '仓库联系人',
|
||||
MODIFY COLUMN contact_phone VARCHAR(32) NULL COMMENT '联系电话',
|
||||
MODIFY COLUMN contact_email VARCHAR(128) NULL COMMENT '联系邮箱',
|
||||
MODIFY COLUMN status VARCHAR(20) NOT NULL DEFAULT 'ACTIVE' COMMENT '仓库状态',
|
||||
MODIFY COLUMN remark VARCHAR(500) NULL COMMENT '仓库备注',
|
||||
MODIFY COLUMN created_by BIGINT UNSIGNED NULL COMMENT '创建人ID',
|
||||
MODIFY COLUMN created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间',
|
||||
MODIFY COLUMN updated_by BIGINT UNSIGNED NULL COMMENT '最后修改人ID',
|
||||
MODIFY COLUMN updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间',
|
||||
MODIFY COLUMN deleted TINYINT(1) NOT NULL DEFAULT 0 COMMENT '逻辑删除标记',
|
||||
MODIFY COLUMN version INT NOT NULL DEFAULT 0 COMMENT '乐观锁版本号';
|
||||
|
||||
ALTER TABLE wh_warehouse_address COMMENT='仓库地址表:保存运营、退货等不同用途的地址及可选经纬度';
|
||||
ALTER TABLE wh_warehouse_address
|
||||
MODIFY COLUMN id BIGINT UNSIGNED NOT NULL COMMENT '仓库地址ID',
|
||||
MODIFY COLUMN customer_id BIGINT UNSIGNED NOT NULL COMMENT '数据所属租户ID',
|
||||
MODIFY COLUMN warehouse_id BIGINT UNSIGNED NOT NULL COMMENT '所属仓库ID',
|
||||
MODIFY COLUMN address_type VARCHAR(20) NOT NULL DEFAULT 'OPERATING' COMMENT '地址类型:OPERATING运营、RETURN退货',
|
||||
MODIFY COLUMN country_code CHAR(2) NOT NULL COMMENT '国家二字码',
|
||||
MODIFY COLUMN state_province VARCHAR(100) NULL COMMENT '省或州',
|
||||
MODIFY COLUMN city VARCHAR(100) NOT NULL COMMENT '城市',
|
||||
MODIFY COLUMN district VARCHAR(100) NULL COMMENT '区县',
|
||||
MODIFY COLUMN postal_code VARCHAR(20) NULL COMMENT '邮政编码',
|
||||
MODIFY COLUMN address_line1 VARCHAR(255) NOT NULL COMMENT '主地址行',
|
||||
MODIFY COLUMN address_line2 VARCHAR(255) NULL COMMENT '补充地址行',
|
||||
MODIFY COLUMN latitude DECIMAL(10,7) NULL COMMENT '纬度',
|
||||
MODIFY COLUMN longitude DECIMAL(10,7) NULL COMMENT '经度',
|
||||
MODIFY COLUMN created_by BIGINT UNSIGNED NULL COMMENT '创建人ID',
|
||||
MODIFY COLUMN created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间',
|
||||
MODIFY COLUMN updated_by BIGINT UNSIGNED NULL COMMENT '最后修改人ID',
|
||||
MODIFY COLUMN updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间',
|
||||
MODIFY COLUMN deleted TINYINT(1) NOT NULL DEFAULT 0 COMMENT '逻辑删除标记',
|
||||
MODIFY COLUMN version INT NOT NULL DEFAULT 0 COMMENT '乐观锁版本号';
|
||||
|
||||
ALTER TABLE wh_service_capability COMMENT='仓库服务能力表:声明仓库可提供的收货、仓储、拣包、退货等能力及截单时间';
|
||||
ALTER TABLE wh_service_capability
|
||||
MODIFY COLUMN id BIGINT UNSIGNED NOT NULL COMMENT '服务能力ID',
|
||||
MODIFY COLUMN customer_id BIGINT UNSIGNED NOT NULL COMMENT '数据所属租户ID',
|
||||
MODIFY COLUMN warehouse_id BIGINT UNSIGNED NOT NULL COMMENT '所属仓库ID',
|
||||
MODIFY COLUMN capability_code VARCHAR(50) NOT NULL COMMENT '能力编码:RECEIVING收货、STORAGE仓储、PICK_PACK拣包、RETURN退货',
|
||||
MODIFY COLUMN capability_name VARCHAR(100) NOT NULL COMMENT '能力名称',
|
||||
MODIFY COLUMN enabled TINYINT(1) NOT NULL DEFAULT 1 COMMENT '是否启用',
|
||||
MODIFY COLUMN cutoff_time TIME NULL COMMENT '当日作业截单时间',
|
||||
MODIFY COLUMN description VARCHAR(500) NULL COMMENT '能力说明',
|
||||
MODIFY COLUMN created_by BIGINT UNSIGNED NULL COMMENT '创建人ID',
|
||||
MODIFY COLUMN created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间',
|
||||
MODIFY COLUMN updated_by BIGINT UNSIGNED NULL COMMENT '最后修改人ID',
|
||||
MODIFY COLUMN updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间',
|
||||
MODIFY COLUMN deleted TINYINT(1) NOT NULL DEFAULT 0 COMMENT '逻辑删除标记',
|
||||
MODIFY COLUMN version INT NOT NULL DEFAULT 0 COMMENT '乐观锁版本号';
|
||||
|
||||
-36
@@ -1,36 +0,0 @@
|
||||
package com.baiyue.warehouse.controller;
|
||||
|
||||
import com.baiyue.common.core.api.PageResult;
|
||||
import com.baiyue.common.security.AuthenticatedUser;
|
||||
import com.baiyue.warehouse.dto.WarehouseRequests;
|
||||
import com.baiyue.warehouse.service.WarehouseApplicationService;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.test.util.ReflectionTestUtils;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
class MerchantWarehouseControllerTest {
|
||||
@Test
|
||||
void merchantPageIgnoresRequestedCustomerAndUsesAuthenticatedCustomer() {
|
||||
WarehouseApplicationService service = mock(WarehouseApplicationService.class);
|
||||
MerchantWarehouseController controller = new MerchantWarehouseController();
|
||||
ReflectionTestUtils.setField(controller, "service", service);
|
||||
WarehouseRequests.QueryRequest query = new WarehouseRequests.QueryRequest(
|
||||
99999L, 1, 20, "US", null, "ACTIVE");
|
||||
when(service.page(10001L, query)).thenReturn(new PageResult<>(List.of(), 1, 20, 0, 0));
|
||||
|
||||
controller.page(merchant(10001L), query);
|
||||
|
||||
verify(service).page(10001L, query);
|
||||
}
|
||||
|
||||
private static AuthenticatedUser merchant(long customerId) {
|
||||
return new AuthenticatedUser(1L, customerId, customerId, 1L,
|
||||
"merchant", "MERCHANT", "WEB", Set.of());
|
||||
}
|
||||
}
|
||||
-83
@@ -1,83 +0,0 @@
|
||||
package com.baiyue.warehouse.controller;
|
||||
|
||||
import com.baiyue.common.core.api.PageResult;
|
||||
import com.baiyue.common.security.AuthenticatedUser;
|
||||
import com.baiyue.warehouse.dto.WarehouseRequests;
|
||||
import com.baiyue.warehouse.security.OverseasWarehousePermissions;
|
||||
import com.baiyue.warehouse.service.WarehouseApplicationService;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.security.access.prepost.PreAuthorize;
|
||||
import org.springframework.test.util.ReflectionTestUtils;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
class PlatformWarehouseControllerTest {
|
||||
@Test
|
||||
void platformPageAllowsWarehousePalletAndLocationListAuthorities() throws Exception {
|
||||
PreAuthorize preAuthorize = PlatformWarehouseController.class
|
||||
.getMethod("page", AuthenticatedUser.class, WarehouseRequests.QueryRequest.class)
|
||||
.getAnnotation(PreAuthorize.class);
|
||||
|
||||
assertEquals("hasAnyAuthority('" + OverseasWarehousePermissions.WAREHOUSE_LIST + "', '"
|
||||
+ OverseasWarehousePermissions.PALLET_LIST + "', '" + OverseasWarehousePermissions.LOCATION_LIST + "')",
|
||||
preAuthorize.value());
|
||||
}
|
||||
|
||||
@Test
|
||||
void platformPagePassesOptionalCustomerFilterToApplicationService() {
|
||||
WarehouseApplicationService service = mock(WarehouseApplicationService.class);
|
||||
PlatformWarehouseController controller = new PlatformWarehouseController();
|
||||
ReflectionTestUtils.setField(controller, "service", service);
|
||||
WarehouseRequests.QueryRequest query = new WarehouseRequests.QueryRequest(10001L, 1, 20, null, null, "ACTIVE");
|
||||
when(service.pageAll(query)).thenReturn(new PageResult<>(List.of(), 1, 20, 0, 0));
|
||||
|
||||
controller.page(platform(), query);
|
||||
|
||||
verify(service).pageAll(query);
|
||||
}
|
||||
|
||||
@Test
|
||||
void platformPageOpenApiExposesOptionalCustomerScope() throws Exception {
|
||||
var customerId = WarehouseRequests.QueryRequest.class.getRecordComponents()[0];
|
||||
Schema schema = WarehouseRequests.QueryRequest.class.getDeclaredMethod("customerId")
|
||||
.getAnnotation(Schema.class);
|
||||
Operation operation = PlatformWarehouseController.class
|
||||
.getMethod("page", AuthenticatedUser.class, WarehouseRequests.QueryRequest.class)
|
||||
.getAnnotation(Operation.class);
|
||||
|
||||
assertEquals("customerId", customerId.getName());
|
||||
assertEquals(Long.class, customerId.getType());
|
||||
assertTrue(schema.nullable());
|
||||
assertTrue(schema.description().contains("platform"));
|
||||
assertTrue(operation.description().contains("customerId"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void platformCanCreateSharedWarehouseInCustomerZeroScope() {
|
||||
WarehouseApplicationService service = mock(WarehouseApplicationService.class);
|
||||
PlatformWarehouseController controller = new PlatformWarehouseController();
|
||||
ReflectionTestUtils.setField(controller, "service", service);
|
||||
WarehouseRequests.AddressRequest address = new WarehouseRequests.AddressRequest(
|
||||
"WAREHOUSE", "US", "CA", "Los Angeles", null, "90001", "1 Shared Ave", null, null, null);
|
||||
WarehouseRequests.SaveRequest request = new WarehouseRequests.SaveRequest(0L, "SHARED-LAX", "Shared LAX",
|
||||
"OVERSEAS", "US", "America/Los_Angeles", null, null, null, "ACTIVE", null, address,
|
||||
List.of(new WarehouseRequests.CapabilityRequest("RECEIVING", "收货", true, null, null)));
|
||||
when(service.create(request)).thenReturn(900L);
|
||||
|
||||
assertEquals(900L, controller.create(platform(), request).data());
|
||||
verify(service).create(request);
|
||||
}
|
||||
|
||||
private static AuthenticatedUser platform() {
|
||||
return new AuthenticatedUser(1L, 0L, 0L, 1L, "operator", "PLATFORM", "WEB", Set.of());
|
||||
}
|
||||
}
|
||||
-42
@@ -1,42 +0,0 @@
|
||||
package com.baiyue.warehouse.repository;
|
||||
|
||||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||
import com.baomidou.mybatisplus.core.MybatisConfiguration;
|
||||
import com.baomidou.mybatisplus.core.metadata.TableInfoHelper;
|
||||
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
|
||||
import com.baiyue.warehouse.dto.WarehouseRequests;
|
||||
import com.baiyue.warehouse.entity.WarehouseEntity;
|
||||
import com.baiyue.warehouse.mapper.ServiceCapabilityMapper;
|
||||
import com.baiyue.warehouse.mapper.WarehouseMapper;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.apache.ibatis.builder.MapperBuilderAssistant;
|
||||
import org.springframework.test.util.ReflectionTestUtils;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
class WarehouseRepositoryTest {
|
||||
@SuppressWarnings({"unchecked", "rawtypes"})
|
||||
@Test
|
||||
void platformPageAllFiltersByOptionalCustomerId() {
|
||||
WarehouseMapper warehouses = mock(WarehouseMapper.class);
|
||||
WarehouseRepository repository = new WarehouseRepository();
|
||||
ReflectionTestUtils.setField(repository, "warehouses", warehouses);
|
||||
ReflectionTestUtils.setField(repository, "capabilities", mock(ServiceCapabilityMapper.class));
|
||||
TableInfoHelper.initTableInfo(
|
||||
new MapperBuilderAssistant(new MybatisConfiguration(), "warehouse-overseas-query-test"),
|
||||
WarehouseEntity.class);
|
||||
when(warehouses.selectPage(any(Page.class), any(LambdaQueryWrapper.class)))
|
||||
.thenReturn(new Page<WarehouseEntity>(1, 20));
|
||||
|
||||
repository.pageAll(new WarehouseRequests.QueryRequest(10001L, 1, 20, "US", null, "ACTIVE"));
|
||||
|
||||
var queryCaptor = org.mockito.ArgumentCaptor.forClass(LambdaQueryWrapper.class);
|
||||
org.mockito.Mockito.verify(warehouses).selectPage(any(Page.class), queryCaptor.capture());
|
||||
LambdaQueryWrapper<WarehouseEntity> query = queryCaptor.getValue();
|
||||
assertThat(query.getSqlSegment()).contains("customer_id");
|
||||
assertThat(query.getParamNameValuePairs()).containsValue(10001L);
|
||||
}
|
||||
}
|
||||
+4
-1
@@ -29,7 +29,10 @@ class SchemaChineseCommentContractTest {
|
||||
Path.of("..", "scripts", "db", "incremental.sql"), StandardCharsets.UTF_8);
|
||||
|
||||
List<TableDefinition> tables = parseAndAssert(baseline, "仓储基线 SQL");
|
||||
assertEquals(8, tables.size(), "仓储表数量变化时必须同步表索引和备注迁移");
|
||||
assertEquals(5, tables.size(), "海外仓资源表数量变化时必须同步表索引和备注迁移");
|
||||
assertFalse(baseline.contains("CREATE TABLE IF NOT EXISTS wh_warehouse ("));
|
||||
assertFalse(baseline.contains("CREATE TABLE IF NOT EXISTS wh_warehouse_address ("));
|
||||
assertFalse(baseline.contains("CREATE TABLE IF NOT EXISTS wh_service_capability ("));
|
||||
parseAndAssert(incremental, "统一增量 SQL");
|
||||
|
||||
int marker = incremental.indexOf("2026-08-18 / 025 Chinese table and column comments");
|
||||
|
||||
+3
@@ -16,6 +16,9 @@ class WarehouseUnifiedIncrementalContractTest {
|
||||
Path.of("..", "deploy", "sql", "海外区域统一增量-MySQL8.sql"), StandardCharsets.UTF_8);
|
||||
|
||||
assertTrue(sql.contains("USE baiyue_lastlag_warehouse"));
|
||||
assertTrue(sql.contains("DROP TABLE IF EXISTS wh_service_capability"));
|
||||
assertTrue(sql.contains("DROP TABLE IF EXISTS wh_warehouse_address"));
|
||||
assertTrue(sql.contains("DROP TABLE IF EXISTS wh_warehouse"));
|
||||
assertTrue(sql.contains("ALTER TABLE wh_pallet ADD COLUMN fba_warehouse_code VARCHAR(128) NULL"));
|
||||
assertTrue(sql.contains("@warehouse_pallet_fba_ready"));
|
||||
assertTrue(sql.contains("wh_pallet.fba_warehouse_code migration is incomplete"));
|
||||
|
||||
+63
@@ -0,0 +1,63 @@
|
||||
package com.baiyue.warehouse.service;
|
||||
|
||||
import com.baiyue.common.core.api.ApiResponse;
|
||||
import com.baiyue.common.core.exception.BizException;
|
||||
import com.baiyue.warehouse.client.DomesticWarehouseDirectoryClient;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import org.springframework.test.util.ReflectionTestUtils;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.util.List;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
import static org.mockito.Mockito.times;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class DomesticWarehouseDirectoryTest {
|
||||
@Mock DomesticWarehouseDirectoryClient client;
|
||||
private DomesticWarehouseDirectory directory;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
directory = new DomesticWarehouseDirectory();
|
||||
ReflectionTestUtils.setField(directory, "client", client);
|
||||
ReflectionTestUtils.setField(directory, "internalToken", "internal-token");
|
||||
ReflectionTestUtils.setField(directory, "ttl", Duration.ofMinutes(30));
|
||||
}
|
||||
|
||||
@Test
|
||||
void cachesPositiveWarehouseForThirtyMinutes() {
|
||||
var warehouse = new DomesticWarehouseDirectoryClient.WarehouseReference(
|
||||
92L, "US-LAX-01", "Los Angeles", "US", "ACTIVE");
|
||||
when(client.activeSharedWarehouse("internal-token", 92L))
|
||||
.thenReturn(ApiResponse.success(warehouse));
|
||||
|
||||
assertThat(directory.required(92L)).isEqualTo(directory.required(92L));
|
||||
verify(client, times(1)).activeSharedWarehouse("internal-token", 92L);
|
||||
}
|
||||
|
||||
@Test
|
||||
void doesNotCacheMissingWarehouse() {
|
||||
when(client.activeSharedWarehouse("internal-token", 93L))
|
||||
.thenReturn(ApiResponse.success(null));
|
||||
|
||||
assertThatThrownBy(() -> directory.required(93L)).isInstanceOf(BizException.class);
|
||||
assertThatThrownBy(() -> directory.required(93L)).isInstanceOf(BizException.class);
|
||||
verify(client, times(2)).activeSharedWarehouse("internal-token", 93L);
|
||||
}
|
||||
|
||||
@Test
|
||||
void emptyOptionDirectoryDoesNotCreatePositiveCacheEntries() {
|
||||
when(client.activeSharedWarehouses("internal-token"))
|
||||
.thenReturn(ApiResponse.success(List.of()));
|
||||
|
||||
assertThat(directory.options()).isEmpty();
|
||||
}
|
||||
}
|
||||
-109
@@ -1,109 +0,0 @@
|
||||
package com.baiyue.warehouse.service;
|
||||
|
||||
import com.baiyue.warehouse.dto.WarehouseRequests;
|
||||
import com.baiyue.warehouse.entity.WarehouseEntity;
|
||||
import com.baiyue.warehouse.entity.WarehouseLocationEntity;
|
||||
import com.baiyue.warehouse.entity.ServiceCapabilityEntity;
|
||||
import com.baiyue.common.core.exception.BizException;
|
||||
import com.baiyue.warehouse.repository.WarehouseRepository;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.ArgumentCaptor;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import org.springframework.test.util.ReflectionTestUtils;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.Mockito.doAnswer;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class WarehouseApplicationServiceTest {
|
||||
@Mock WarehouseRepository repository;
|
||||
private WarehouseApplicationService service;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
service = new WarehouseApplicationService();
|
||||
ReflectionTestUtils.setField(service, "repository", repository);
|
||||
}
|
||||
|
||||
@Test
|
||||
void warehouseCreationAlsoCreatesActiveDefaultVirtualLocation() {
|
||||
doAnswer(call -> {
|
||||
((WarehouseEntity) call.getArgument(0)).setId(20001L);
|
||||
return null;
|
||||
}).when(repository).insertWarehouse(any());
|
||||
|
||||
service.create(request());
|
||||
|
||||
ArgumentCaptor<WarehouseLocationEntity> location = ArgumentCaptor.forClass(WarehouseLocationEntity.class);
|
||||
verify(repository).insertLocation(location.capture());
|
||||
assertThat(location.getValue().getCustomerId()).isEqualTo(10001L);
|
||||
assertThat(location.getValue().getWarehouseId()).isEqualTo(20001L);
|
||||
assertThat(location.getValue().getLocationCode()).isEqualTo("LOC-US-LAX-01-DEFAULT");
|
||||
assertThat(location.getValue().getLocationType()).isEqualTo("VIRTUAL");
|
||||
assertThat(location.getValue().getStatus()).isEqualTo("ACTIVE");
|
||||
}
|
||||
|
||||
@Test
|
||||
void platformWarehousePagePassesCustomerScopedQueryToRepository() {
|
||||
WarehouseRequests.QueryRequest query = new WarehouseRequests.QueryRequest(10001L, 1, 20, "US", null, "ACTIVE");
|
||||
when(repository.pageAll(query)).thenReturn(new com.baiyue.common.core.api.PageResult<>(List.of(), 1, 20, 0, 0));
|
||||
|
||||
service.pageAll(query);
|
||||
|
||||
verify(repository).pageAll(query);
|
||||
}
|
||||
|
||||
@Test
|
||||
void sharedReceivingWarehouseMustBeOverseasAndResolvesCanonicalReference() {
|
||||
WarehouseRequests.SaveRequest shared = sharedRequest("OVERSEAS", true);
|
||||
doAnswer(call -> { ((WarehouseEntity) call.getArgument(0)).setId(900L); return null; })
|
||||
.when(repository).insertWarehouse(any());
|
||||
|
||||
assertThat(service.create(shared)).isEqualTo(900L);
|
||||
|
||||
WarehouseEntity warehouse = new WarehouseEntity(); warehouse.setId(900L); warehouse.setCustomerId(0L);
|
||||
warehouse.setWarehouseCode("SHARED-LAX"); warehouse.setWarehouseName("Shared LAX");
|
||||
warehouse.setWarehouseType("OVERSEAS"); warehouse.setStatus("ACTIVE");
|
||||
ServiceCapabilityEntity receiving = new ServiceCapabilityEntity();
|
||||
receiving.setCapabilityCode("RECEIVING"); receiving.setEnabled(true);
|
||||
when(repository.findOwned(0L, 900L)).thenReturn(warehouse);
|
||||
when(repository.findCapabilities(0L, 900L)).thenReturn(List.of(receiving));
|
||||
|
||||
assertThat(service.activeSharedReference(900L).warehouseName()).isEqualTo("Shared LAX");
|
||||
}
|
||||
|
||||
@Test
|
||||
void sharedWarehouseRejectsNonOverseasOrMissingReceivingCapability() {
|
||||
assertThatThrownBy(() -> service.create(sharedRequest("FBA", true))).isInstanceOf(BizException.class);
|
||||
assertThatThrownBy(() -> service.create(sharedRequest("OVERSEAS", false))).isInstanceOf(BizException.class);
|
||||
}
|
||||
|
||||
private static WarehouseRequests.SaveRequest request() {
|
||||
WarehouseRequests.AddressRequest address = new WarehouseRequests.AddressRequest(
|
||||
"WAREHOUSE", "US", "CA", "Los Angeles", null, "90001",
|
||||
"1 Logistics Ave", null, null, null);
|
||||
return new WarehouseRequests.SaveRequest(10001L, "US-LAX-01", "LAX Warehouse",
|
||||
"OVERSEAS", "US", "America/Los_Angeles", null, null, null,
|
||||
"ACTIVE", null, address, List.of());
|
||||
}
|
||||
|
||||
private static WarehouseRequests.SaveRequest sharedRequest(String type, boolean receiving) {
|
||||
WarehouseRequests.AddressRequest address = new WarehouseRequests.AddressRequest(
|
||||
"WAREHOUSE", "US", "CA", "Los Angeles", null, "90001",
|
||||
"1 Logistics Ave", null, null, null);
|
||||
List<WarehouseRequests.CapabilityRequest> capabilities = receiving
|
||||
? List.of(new WarehouseRequests.CapabilityRequest("RECEIVING", "收货", true, null, null))
|
||||
: List.of();
|
||||
return new WarehouseRequests.SaveRequest(0L, "SHARED-LAX", "Shared LAX", type,
|
||||
"US", "America/Los_Angeles", null, null, null, "ACTIVE", null, address, capabilities);
|
||||
}
|
||||
}
|
||||
+10
-28
@@ -7,7 +7,7 @@ import com.baiyue.warehouse.dto.LocationRequests;
|
||||
import com.baiyue.warehouse.dto.PalletRequests;
|
||||
import com.baiyue.warehouse.entity.WarehouseLocationEntity;
|
||||
import com.baiyue.warehouse.entity.WarehousePalletEntity;
|
||||
import com.baiyue.warehouse.entity.WarehouseEntity;
|
||||
import com.baiyue.warehouse.dto.SharedWarehouseReference;
|
||||
import com.baiyue.warehouse.repository.WarehouseResourceRepository;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
@@ -33,6 +33,8 @@ class WarehouseResourceServiceTest {
|
||||
|
||||
@Mock
|
||||
private WarehouseResourceRepository repository;
|
||||
@Mock
|
||||
private DomesticWarehouseDirectory warehouseDirectory;
|
||||
|
||||
private WarehouseResourceService service;
|
||||
|
||||
@@ -40,6 +42,9 @@ class WarehouseResourceServiceTest {
|
||||
void setUp() {
|
||||
service = new WarehouseResourceService();
|
||||
ReflectionTestUtils.setField(service, "repository", repository);
|
||||
ReflectionTestUtils.setField(service, "warehouseDirectory", warehouseDirectory);
|
||||
org.mockito.Mockito.lenient().when(warehouseDirectory.required(WAREHOUSE))
|
||||
.thenReturn(warehouse());
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -65,7 +70,6 @@ class WarehouseResourceServiceTest {
|
||||
|
||||
@Test
|
||||
void createsLocationWithSnowflakeAssignedByMapper() {
|
||||
when(repository.warehouseOwned(TENANT, WAREHOUSE)).thenReturn(true);
|
||||
doAnswer(invocation -> {
|
||||
((WarehouseLocationEntity) invocation.getArgument(0)).setId(2089000000000000001L);
|
||||
return null;
|
||||
@@ -82,8 +86,6 @@ class WarehouseResourceServiceTest {
|
||||
|
||||
@Test
|
||||
void platformPalletUsesPublicScopeAgainstAnyExistingWarehouse() {
|
||||
WarehouseEntity warehouse = warehouse();
|
||||
when(repository.findWarehouseById(WAREHOUSE)).thenReturn(warehouse);
|
||||
WarehouseLocationEntity location = new WarehouseLocationEntity();
|
||||
location.setId(30001L);
|
||||
when(repository.findLocation(0L, WAREHOUSE, 30001L)).thenReturn(location);
|
||||
@@ -99,7 +101,8 @@ class WarehouseResourceServiceTest {
|
||||
|
||||
@Test
|
||||
void rejectsResourceAccessWhenWarehouseDoesNotBelongToTenant() {
|
||||
when(repository.warehouseOwned(TENANT, WAREHOUSE)).thenReturn(false);
|
||||
when(warehouseDirectory.required(WAREHOUSE)).thenThrow(new BizException(
|
||||
com.baiyue.common.core.api.ResultCode.NOT_FOUND, "仓库不存在"));
|
||||
|
||||
assertThatThrownBy(() -> service.createLocation(TENANT, WAREHOUSE, locationRequest("A-01")))
|
||||
.isInstanceOf(BizException.class)
|
||||
@@ -109,7 +112,6 @@ class WarehouseResourceServiceTest {
|
||||
|
||||
@Test
|
||||
void rejectsDuplicateLocationCodeWithinWarehouse() {
|
||||
when(repository.warehouseOwned(TENANT, WAREHOUSE)).thenReturn(true);
|
||||
when(repository.locationCodeExists(TENANT, WAREHOUSE, "A-01", null)).thenReturn(true);
|
||||
|
||||
assertThatThrownBy(() -> service.createLocation(TENANT, WAREHOUSE, locationRequest("A-01")))
|
||||
@@ -119,8 +121,6 @@ class WarehouseResourceServiceTest {
|
||||
|
||||
@Test
|
||||
void palletDefaultsToTwoCubicMetresAndBindsOwnedLocation() {
|
||||
when(repository.warehouseOwned(TENANT, WAREHOUSE)).thenReturn(true);
|
||||
when(repository.findWarehouse(TENANT, WAREHOUSE)).thenReturn(warehouse());
|
||||
WarehouseLocationEntity location = new WarehouseLocationEntity();
|
||||
location.setId(30001L);
|
||||
when(repository.findLocation(TENANT, WAREHOUSE, 30001L)).thenReturn(location);
|
||||
@@ -142,8 +142,6 @@ class WarehouseResourceServiceTest {
|
||||
|
||||
@Test
|
||||
void rejectsLocationFromAnotherWarehouseWhenBindingPallet() {
|
||||
when(repository.warehouseOwned(TENANT, WAREHOUSE)).thenReturn(true);
|
||||
when(repository.findWarehouse(TENANT, WAREHOUSE)).thenReturn(warehouse());
|
||||
when(repository.findLocation(TENANT, WAREHOUSE, 30002L)).thenReturn(null);
|
||||
|
||||
assertThatThrownBy(() -> service.createPallet(TENANT, WAREHOUSE,
|
||||
@@ -156,7 +154,6 @@ class WarehouseResourceServiceTest {
|
||||
|
||||
@Test
|
||||
void preventsDeletingLocationThatStillHasPallets() {
|
||||
when(repository.warehouseOwned(TENANT, WAREHOUSE)).thenReturn(true);
|
||||
when(repository.findLocation(TENANT, WAREHOUSE, 30001L)).thenReturn(new WarehouseLocationEntity());
|
||||
when(repository.locationHasPallets(TENANT, WAREHOUSE, 30001L)).thenReturn(true);
|
||||
|
||||
@@ -168,7 +165,6 @@ class WarehouseResourceServiceTest {
|
||||
|
||||
@Test
|
||||
void preventsDeletingOccupiedPallet() {
|
||||
when(repository.warehouseOwned(TENANT, WAREHOUSE)).thenReturn(true);
|
||||
WarehousePalletEntity pallet = new WarehousePalletEntity();
|
||||
pallet.setStatus("OCCUPIED");
|
||||
when(repository.findPallet(TENANT, WAREHOUSE, 40001L)).thenReturn(pallet);
|
||||
@@ -181,7 +177,6 @@ class WarehouseResourceServiceTest {
|
||||
|
||||
@Test
|
||||
void preventsDeletingScrappedPalletSoItsIdentityCannotBeReused() {
|
||||
when(repository.warehouseOwned(TENANT, WAREHOUSE)).thenReturn(true);
|
||||
WarehousePalletEntity pallet = new WarehousePalletEntity();
|
||||
pallet.setStatus("SCRAPPED");
|
||||
when(repository.findPallet(TENANT, WAREHOUSE, 40003L)).thenReturn(pallet);
|
||||
@@ -194,7 +189,6 @@ class WarehouseResourceServiceTest {
|
||||
|
||||
@Test
|
||||
void rejectsUnknownPalletStatus() {
|
||||
when(repository.findWarehouse(TENANT, WAREHOUSE)).thenReturn(warehouse());
|
||||
|
||||
assertThatThrownBy(() -> service.createPallet(TENANT, WAREHOUSE,
|
||||
new PalletRequests.SaveRequest("PLT-01", "STANDARD", null,
|
||||
@@ -210,14 +204,12 @@ class WarehouseResourceServiceTest {
|
||||
|
||||
@Test
|
||||
void generatesRestartSafePalletCodeAndUsesDefaultVirtualLocation() {
|
||||
when(repository.findWarehouse(TENANT, WAREHOUSE)).thenReturn(warehouse());
|
||||
WarehouseLocationEntity location = new WarehouseLocationEntity();
|
||||
location.setId(30003L);
|
||||
location.setLocationType("VIRTUAL");
|
||||
location.setStatus("ACTIVE");
|
||||
when(repository.findLocationByCode(TENANT, WAREHOUSE, "LOC-US-LAX-01-DEFAULT"))
|
||||
.thenReturn(location);
|
||||
when(repository.warehouseOwned(TENANT, WAREHOUSE)).thenReturn(true);
|
||||
when(repository.findLocation(TENANT, WAREHOUSE, 30003L)).thenReturn(location);
|
||||
|
||||
service.createPallet(TENANT, WAREHOUSE,
|
||||
@@ -232,7 +224,6 @@ class WarehouseResourceServiceTest {
|
||||
|
||||
@Test
|
||||
void defaultVirtualLocationIsInsertedIdempotentlyForAnExistingWarehouse() {
|
||||
when(repository.findWarehouse(TENANT, WAREHOUSE)).thenReturn(warehouse());
|
||||
WarehouseLocationEntity resolved = new WarehouseLocationEntity();
|
||||
resolved.setId(30004L);
|
||||
resolved.setLocationType("VIRTUAL");
|
||||
@@ -252,7 +243,6 @@ class WarehouseResourceServiceTest {
|
||||
|
||||
@Test
|
||||
void rejectsAConflictingDefaultLocationCode() {
|
||||
when(repository.findWarehouse(TENANT, WAREHOUSE)).thenReturn(warehouse());
|
||||
WarehouseLocationEntity conflict = new WarehouseLocationEntity();
|
||||
conflict.setLocationType("STORAGE");
|
||||
conflict.setStatus("ACTIVE");
|
||||
@@ -267,7 +257,6 @@ class WarehouseResourceServiceTest {
|
||||
|
||||
@Test
|
||||
void scrappedPalletCannotBeReactivated() {
|
||||
when(repository.warehouseOwned(TENANT, WAREHOUSE)).thenReturn(true);
|
||||
WarehousePalletEntity existing = new WarehousePalletEntity();
|
||||
existing.setId(40002L);
|
||||
existing.setPalletCode("PLT-OLD");
|
||||
@@ -283,7 +272,6 @@ class WarehouseResourceServiceTest {
|
||||
|
||||
@Test
|
||||
void updatePalletChangesCodeAndFbaWarehouseCode() {
|
||||
when(repository.warehouseOwned(TENANT, WAREHOUSE)).thenReturn(true);
|
||||
WarehousePalletEntity existing = new WarehousePalletEntity();
|
||||
existing.setId(40003L);
|
||||
existing.setPalletCode("PLT-OLD");
|
||||
@@ -306,7 +294,6 @@ class WarehouseResourceServiceTest {
|
||||
|
||||
@Test
|
||||
void updatePalletRejectsDuplicateCodeInSameWarehouse() {
|
||||
when(repository.warehouseOwned(TENANT, WAREHOUSE)).thenReturn(true);
|
||||
WarehousePalletEntity existing = new WarehousePalletEntity();
|
||||
existing.setId(40004L);
|
||||
existing.setPalletCode("PLT-OLD");
|
||||
@@ -325,7 +312,6 @@ class WarehouseResourceServiceTest {
|
||||
|
||||
@Test
|
||||
void updatePalletRejectsStaleVersionBeforeMutation() {
|
||||
when(repository.warehouseOwned(TENANT, WAREHOUSE)).thenReturn(true);
|
||||
WarehousePalletEntity existing = new WarehousePalletEntity();
|
||||
existing.setId(40005L);
|
||||
existing.setPalletCode("PLT-OLD");
|
||||
@@ -344,11 +330,7 @@ class WarehouseResourceServiceTest {
|
||||
verify(repository, never()).updatePallet(org.mockito.ArgumentMatchers.any());
|
||||
}
|
||||
|
||||
private static WarehouseEntity warehouse() {
|
||||
WarehouseEntity warehouse = new WarehouseEntity();
|
||||
warehouse.setId(WAREHOUSE);
|
||||
warehouse.setCustomerId(TENANT);
|
||||
warehouse.setWarehouseCode("US-LAX-01");
|
||||
return warehouse;
|
||||
private static SharedWarehouseReference warehouse() {
|
||||
return new SharedWarehouseReference(WAREHOUSE, "US-LAX-01", "Los Angeles", "ACTIVE");
|
||||
}
|
||||
}
|
||||
|
||||
+16
@@ -5,6 +5,7 @@ import com.baiyue.common.core.api.ResultCode;
|
||||
import com.baiyue.common.core.exception.BizException;
|
||||
import com.baiyue.warehouse.dto.WarehouseDetailResponse;
|
||||
import com.baiyue.warehouse.dto.OverseasPartnerWarehouseResponse;
|
||||
import com.baiyue.warehouse.dto.SharedOverseasWarehouseResponse;
|
||||
import com.baiyue.warehouse.service.WarehouseApplicationService;
|
||||
import jakarta.annotation.Resource;
|
||||
import jakarta.annotation.PostConstruct;
|
||||
@@ -48,6 +49,21 @@ public class InternalWarehouseController {
|
||||
return ApiResponse.success(service.activeOverseasPartners());
|
||||
}
|
||||
|
||||
@GetMapping("/shared-overseas")
|
||||
public ApiResponse<List<SharedOverseasWarehouseResponse>> sharedOverseasWarehouses(
|
||||
@RequestHeader(value="X-Internal-Service-Token", required=false) String token) {
|
||||
verifyToken(token);
|
||||
return ApiResponse.success(service.activeSharedOverseasWarehouses());
|
||||
}
|
||||
|
||||
@GetMapping("/shared-overseas/{warehouseId}")
|
||||
public ApiResponse<SharedOverseasWarehouseResponse> sharedOverseasWarehouse(
|
||||
@RequestHeader(value="X-Internal-Service-Token", required=false) String token,
|
||||
@PathVariable long warehouseId) {
|
||||
verifyToken(token);
|
||||
return ApiResponse.success(service.activeSharedOverseasWarehouse(warehouseId));
|
||||
}
|
||||
|
||||
private void verifyToken(String token) {
|
||||
byte[] supplied = token == null ? new byte[0] : token.getBytes(StandardCharsets.UTF_8);
|
||||
if (!MessageDigest.isEqual(expected, supplied)) {
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
package com.baiyue.warehouse.dto;
|
||||
|
||||
import com.baiyue.warehouse.entity.WarehouseEntity;
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
|
||||
@Schema(name = "SharedOverseasWarehouseResponse",
|
||||
description = "Domestic-authoritative active shared overseas receiving warehouse")
|
||||
public record SharedOverseasWarehouseResponse(
|
||||
Long warehouseId,
|
||||
String warehouseCode,
|
||||
String warehouseName,
|
||||
String countryCode,
|
||||
String status
|
||||
) {
|
||||
public static SharedOverseasWarehouseResponse of(WarehouseEntity warehouse) {
|
||||
return new SharedOverseasWarehouseResponse(warehouse.getId(), warehouse.getWarehouseCode(),
|
||||
warehouse.getWarehouseName(), warehouse.getCountryCode(), warehouse.getStatus());
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import com.baiyue.warehouse.dto.WarehouseDetailResponse;
|
||||
import com.baiyue.warehouse.dto.WarehouseRequests;
|
||||
import com.baiyue.warehouse.dto.OverseasPartnerWarehouseResponse;
|
||||
import com.baiyue.warehouse.dto.WarehouseSummaryResponse;
|
||||
import com.baiyue.warehouse.dto.SharedOverseasWarehouseResponse;
|
||||
import com.baiyue.warehouse.entity.ServiceCapabilityEntity;
|
||||
import com.baiyue.warehouse.entity.WarehouseAddressEntity;
|
||||
import com.baiyue.warehouse.entity.WarehouseEntity;
|
||||
@@ -95,6 +96,32 @@ public class WarehouseApplicationService {
|
||||
.toList();
|
||||
}
|
||||
|
||||
public List<SharedOverseasWarehouseResponse> activeSharedOverseasWarehouses() {
|
||||
return repository.findActiveByType(WarehouseSaveNormalizer.PUBLIC_TENANT_ID,
|
||||
WarehouseSaveNormalizer.TYPE_OVERSEAS)
|
||||
.stream()
|
||||
.filter(warehouse -> hasReceivingCapability(warehouse.getId()))
|
||||
.map(SharedOverseasWarehouseResponse::of)
|
||||
.toList();
|
||||
}
|
||||
|
||||
public SharedOverseasWarehouseResponse activeSharedOverseasWarehouse(long warehouseId) {
|
||||
WarehouseEntity warehouse = repository.findOwned(WarehouseSaveNormalizer.PUBLIC_TENANT_ID, warehouseId);
|
||||
if (warehouse == null || !"ACTIVE".equals(warehouse.getStatus())
|
||||
|| !WarehouseSaveNormalizer.TYPE_OVERSEAS.equals(warehouse.getWarehouseType())
|
||||
|| !hasReceivingCapability(warehouseId)) {
|
||||
throw new BizException(ResultCode.NOT_FOUND,
|
||||
"启用的公共海外收货仓不存在");
|
||||
}
|
||||
return SharedOverseasWarehouseResponse.of(warehouse);
|
||||
}
|
||||
|
||||
private boolean hasReceivingCapability(long warehouseId) {
|
||||
return repository.findCapabilities(WarehouseSaveNormalizer.PUBLIC_TENANT_ID, warehouseId).stream()
|
||||
.anyMatch(capability -> "RECEIVING".equals(capability.getCapabilityCode())
|
||||
&& Boolean.TRUE.equals(capability.getEnabled()));
|
||||
}
|
||||
|
||||
public WarehouseDetailResponse activeDetail(long tenantId, long id) {
|
||||
WarehouseEntity warehouse = repository.findOwned(tenantId, id);
|
||||
if (warehouse == null) {
|
||||
|
||||
@@ -162,7 +162,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
|
||||
@@ -171,7 +171,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),
|
||||
@@ -677,11 +681,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 ;
|
||||
|
||||
+14
@@ -4,6 +4,7 @@ import com.baiyue.common.core.api.ResultCode;
|
||||
import com.baiyue.common.core.exception.BizException;
|
||||
import com.baiyue.warehouse.dto.WarehouseDetailResponse;
|
||||
import com.baiyue.warehouse.dto.OverseasPartnerWarehouseResponse;
|
||||
import com.baiyue.warehouse.dto.SharedOverseasWarehouseResponse;
|
||||
import com.baiyue.warehouse.entity.WarehouseEntity;
|
||||
import com.baiyue.warehouse.service.WarehouseApplicationService;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
@@ -83,4 +84,17 @@ class InternalWarehouseControllerTest {
|
||||
assertThat(response.data()).containsExactly(option);
|
||||
verify(service).activeOverseasPartners();
|
||||
}
|
||||
|
||||
@Test
|
||||
void exposesSharedOverseasReceivingDirectoryOnlyWithInternalToken() {
|
||||
var warehouse = new SharedOverseasWarehouseResponse(200003L, "US-LAX-01",
|
||||
"洛杉矶海外仓", "US", "ACTIVE");
|
||||
when(service.activeSharedOverseasWarehouse(200003L)).thenReturn(warehouse);
|
||||
|
||||
assertThat(controller.sharedOverseasWarehouse(INTERNAL_TOKEN, 200003L).data())
|
||||
.isEqualTo(warehouse);
|
||||
assertThatThrownBy(() -> controller.sharedOverseasWarehouse("bad", 200003L))
|
||||
.isInstanceOf(BizException.class);
|
||||
verify(service).activeSharedOverseasWarehouse(200003L);
|
||||
}
|
||||
}
|
||||
|
||||
+6
-2
@@ -109,7 +109,7 @@ class WarehouseSchemaContractTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldSeedReceivingCapabilityForDomesticOperatingWarehouses() throws IOException {
|
||||
void shouldSeedReceivingCapabilityForFixedOperatingWarehouses() throws IOException {
|
||||
String masterData = read("warehouse-public-master-data.sql");
|
||||
String unified = Files.readString(Path.of("..", "deploy", "sql", "头程数据库统一增量-MySQL8.sql"));
|
||||
|
||||
@@ -117,7 +117,11 @@ class WarehouseSchemaContractTest {
|
||||
assertThat(sql)
|
||||
.contains("193000000000200001,'RECEIVING','头程收货',1")
|
||||
.contains("193000000000200002,'RECEIVING','头程收货',1")
|
||||
.contains("国内固定作业仓收货能力写入校验失败")
|
||||
.contains("193000000000400003,0,193000000000200003,'RECEIVING','海外收货',1")
|
||||
.contains("'洛杉矶仓海外接柜入仓收货能力'")
|
||||
.contains("193000000000400004,0,193000000000200004,'RECEIVING','海外收货',1")
|
||||
.contains("'纽约仓海外接柜入仓收货能力'")
|
||||
.contains("固定平台作业仓收货能力写入校验失败")
|
||||
.contains("warehouse.warehouse_type = 'FBA'")
|
||||
.contains("FBA公共地址不得创建平台作业能力");
|
||||
}
|
||||
|
||||
+19
@@ -149,4 +149,23 @@ class WarehouseApplicationServiceTest {
|
||||
.containsExactly(tuple(2L, "US-PARTNER-LAX", "洛杉矶合作仓", "US"));
|
||||
verify(repository).findActiveByType(0L, "OVERSEAS_PARTNER");
|
||||
}
|
||||
|
||||
@Test
|
||||
void exposesOnlyActiveSharedOverseasWarehousesWithReceivingCapability() {
|
||||
WarehouseEntity receiving = new WarehouseEntity();
|
||||
receiving.setId(3L); receiving.setWarehouseCode("US-LAX-01");
|
||||
receiving.setWarehouseName("洛杉矶海外仓"); receiving.setCountryCode("US");
|
||||
receiving.setWarehouseType("OVERSEAS"); receiving.setStatus("ACTIVE");
|
||||
ServiceCapabilityEntity capability = new ServiceCapabilityEntity();
|
||||
capability.setCapabilityCode("RECEIVING"); capability.setEnabled(true);
|
||||
when(repository.findActiveByType(0L, "OVERSEAS")).thenReturn(java.util.List.of(receiving));
|
||||
when(repository.findOwned(0L, 3L)).thenReturn(receiving);
|
||||
when(repository.findCapabilities(0L, 3L)).thenReturn(java.util.List.of(capability));
|
||||
|
||||
assertThat(service.activeSharedOverseasWarehouses())
|
||||
.extracting("warehouseId", "warehouseCode", "status")
|
||||
.containsExactly(tuple(3L, "US-LAX-01", "ACTIVE"));
|
||||
assertThat(service.activeSharedOverseasWarehouse(3L).warehouseName())
|
||||
.isEqualTo("洛杉矶海外仓");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,6 +19,38 @@ SELECT COUNT(*) AS fixed_platform_address_count
|
||||
AND address.deleted = 0
|
||||
AND warehouse.deleted = 0;
|
||||
|
||||
SELECT COUNT(*) AS fixed_platform_receiving_capability_count
|
||||
FROM wh_service_capability capability
|
||||
WHERE capability.tenant_id = 0
|
||||
AND capability.warehouse_id IN (
|
||||
193000000000200001, 193000000000200002,
|
||||
193000000000200003, 193000000000200004
|
||||
)
|
||||
AND capability.capability_code = 'RECEIVING'
|
||||
AND capability.enabled = 1
|
||||
AND capability.deleted = 0;
|
||||
|
||||
SELECT COUNT(*) AS overseas_receiving_capability_violations
|
||||
FROM wh_service_capability capability
|
||||
WHERE capability.tenant_id = 0
|
||||
AND (
|
||||
(capability.id = 193000000000400003
|
||||
AND (capability.warehouse_id <> 193000000000200003
|
||||
OR capability.capability_code <> 'RECEIVING'
|
||||
OR capability.capability_name <> '海外收货'
|
||||
OR capability.description <> '洛杉矶仓海外接柜入仓收货能力'
|
||||
OR capability.enabled <> 1
|
||||
OR capability.deleted <> 0))
|
||||
OR
|
||||
(capability.id = 193000000000400004
|
||||
AND (capability.warehouse_id <> 193000000000200004
|
||||
OR capability.capability_code <> 'RECEIVING'
|
||||
OR capability.capability_name <> '海外收货'
|
||||
OR capability.description <> '纽约仓海外接柜入仓收货能力'
|
||||
OR capability.enabled <> 1
|
||||
OR capability.deleted <> 0))
|
||||
);
|
||||
|
||||
SELECT COUNT(*) AS fba_public_warehouse_count
|
||||
FROM wh_warehouse
|
||||
WHERE tenant_id = 0
|
||||
|
||||
@@ -111,8 +111,11 @@ assert.doesNotMatch(sql, /contact_name\s*=\s*VALUES\(contact_name\)/)
|
||||
assert.doesNotMatch(sql, /contact_phone\s*=\s*VALUES\(contact_phone\)/)
|
||||
assert.doesNotMatch(sql, /contact_email\s*=\s*VALUES\(contact_email\)/)
|
||||
assert.doesNotMatch(sql, /remark\s*=\s*VALUES\(remark\)/)
|
||||
assert.match(sql, /国内固定作业仓必须启用收货能力/)
|
||||
assert.match(sql, /四个固定平台作业仓必须启用收货能力/)
|
||||
assert.match(sql, /INSERT INTO wh_service_capability[\s\S]+?'RECEIVING'/i)
|
||||
assert.match(sql, /193000000000400003,0,193000000000200003,'RECEIVING','海外收货',1,'16:00:00',[\s\S]*?'洛杉矶仓海外接柜入仓收货能力'/)
|
||||
assert.match(sql, /193000000000400004,0,193000000000200004,'RECEIVING','海外收货',1,'16:00:00',[\s\S]*?'纽约仓海外接柜入仓收货能力'/)
|
||||
assert.match(sql, /warehouse_id IN \(193000000000200001,193000000000200002,193000000000200003,193000000000200004\)[\s\S]*?<> 4 THEN[\s\S]*?固定平台作业仓收货能力写入校验失败/)
|
||||
const fbaBlock = sql.slice(sql.indexOf('CREATE TEMPORARY TABLE tmp_wh_fba_public_master_data'))
|
||||
assert.doesNotMatch(fbaBlock, /INSERT INTO wh_service_capability/i)
|
||||
assert.match(sql, /FBA公共主数据地址种子ID与既有数据冲突/)
|
||||
|
||||
Reference in New Issue
Block a user