This commit is contained in:
2026-08-26 13:58:33 +08:00
parent fc0a6ea7ca
commit 4398e8b347
78 changed files with 920 additions and 1549 deletions
+6 -9
View File
@@ -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`。
+4
View File
@@ -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>
@@ -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); }
@@ -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) { }
}
@@ -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}")
@@ -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));
}
}
@@ -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 / 必须指定有效客户或平台共享范围");
}
}
}
@@ -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
) {
}
@@ -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;
}
}
}
}
@@ -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;
}
@@ -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;
}
@@ -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;
}
@@ -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);
}
@@ -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);
}
@@ -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> {}
@@ -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);
}
}
@@ -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)
@@ -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) { }
}
@@ -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);
}
}
}
@@ -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 '乐观锁版本号';
@@ -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());
}
}
@@ -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());
}
}
@@ -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);
}
}
@@ -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");
@@ -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"));
@@ -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();
}
}
@@ -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);
}
}
@@ -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");
}
}