暂存提交

This commit is contained in:
2026-08-19 16:24:02 +08:00
parent f5c4bf130a
commit 6acb6178da
354 changed files with 25000 additions and 198 deletions
@@ -0,0 +1,14 @@
package com.baiyue.warehouse;
import org.mybatis.spring.annotation.MapperScan;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
/**
* 仓库微服务启动入口,启用 Spring Boot 自动配置并扫描仓库服务的 MyBatis Mapper。
*/
@MapperScan("com.baiyue.warehouse.mapper")
@SpringBootApplication(scanBasePackages = "com.baiyue")
public class BaiyueWarehouseOverseasApplication {
public static void main(String[] args) { SpringApplication.run(BaiyueWarehouseOverseasApplication.class, args); }
}
@@ -0,0 +1,27 @@
package com.baiyue.warehouse.config;
import org.springframework.beans.factory.config.BeanFactoryPostProcessor;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.core.env.Environment;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
/** Prevents the warehouse schema migration from ever running against another database. */
@Configuration(proxyBeanMethods = false)
public class WarehouseDatabaseGuardConfiguration {
private static final Pattern MYSQL_SCHEMA = Pattern.compile(
"^jdbc:mysql://[^/]+/([^?;]+)(?:[?;].*)?$", Pattern.CASE_INSENSITIVE);
@Bean
static BeanFactoryPostProcessor warehouseDatabaseGuard(Environment environment) {
String url = environment.getProperty("spring.datasource.url", "").trim();
Matcher matcher = MYSQL_SCHEMA.matcher(url);
if (!matcher.matches() || !"baiyue_warehouse_overseas".equalsIgnoreCase(matcher.group(1))) {
throw new IllegalStateException(
"warehouse-overseas-service requires spring.datasource.url to target the baiyue_warehouse_overseas database");
}
return beanFactory -> { };
}
}
@@ -0,0 +1,25 @@
package com.baiyue.warehouse.controller;
import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.warehouse.dto.DropshipPalletRequests;
import com.baiyue.warehouse.entity.WarehousePalletEntity;
import com.baiyue.warehouse.security.InternalServiceTokenGuard;
import com.baiyue.warehouse.service.WarehouseResourceService;
import jakarta.annotation.Resource;
import jakarta.validation.Valid;
import org.springframework.web.bind.annotation.*;
@RestController
@RequestMapping("/internal/v1/warehouse-pallets")
public class InternalWarehousePalletController {
@Resource private WarehouseResourceService service;
@Resource private InternalServiceTokenGuard tokenGuard;
@PostMapping("/ensure-dropship")
public ApiResponse<WarehousePalletEntity> ensure(
@RequestHeader(value = "X-Internal-Service-Token", required = false) String token,
@Valid @RequestBody DropshipPalletRequests.Ensure request) {
tokenGuard.verify(token);
return ApiResponse.success(service.ensureDropshipPallet(request.customerId(), request.warehouseId(), request.businessId()));
}
}
@@ -0,0 +1,70 @@
package com.baiyue.warehouse.controller;
import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.warehouse.dto.WarehouseResourceLockRequests;
import com.baiyue.warehouse.dto.WarehouseResourceLockResponse;
import com.baiyue.warehouse.dto.WarehousePalletScrapResponse;
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 jakarta.validation.Valid;
import jakarta.annotation.Resource;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestHeader;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
@RequestMapping("/internal/v1/warehouse-resource-locks")
public class InternalWarehouseResourceLockController {
@Resource private WarehouseResourceLockService service;
@Resource private WarehouseResourceService resourceService;
@Resource private InternalServiceTokenGuard tokenGuard;
@PostMapping
public ApiResponse<WarehouseResourceLockResponse> reserve(
@RequestHeader(value = "X-Internal-Service-Token", required = false) String token,
@Valid @RequestBody WarehouseResourceLockRequests.ReserveRequest request) {
tokenGuard.verify(token);
return ApiResponse.success(service.reserve(request));
}
@PostMapping("/{lockId}/release")
public ApiResponse<WarehouseResourceLockResponse> release(
@RequestHeader(value = "X-Internal-Service-Token", required = false) String token,
@PathVariable long lockId,
@Valid @RequestBody WarehouseResourceLockRequests.CloseRequest request) {
tokenGuard.verify(token);
return ApiResponse.success(service.release(request.customerId(), lockId));
}
@PostMapping("/{lockId}/scrap")
public ApiResponse<WarehouseResourceLockResponse> scrap(
@RequestHeader(value = "X-Internal-Service-Token", required = false) String token,
@PathVariable long lockId,
@Valid @RequestBody WarehouseResourceLockRequests.CloseRequest request) {
tokenGuard.verify(token);
return ApiResponse.success(service.scrap(request.customerId(), lockId));
}
@PostMapping("/pallets/{palletId}/scrap")
public ApiResponse<WarehousePalletScrapResponse> scrapPallet(
@RequestHeader(value = "X-Internal-Service-Token", required = false) String token,
@PathVariable long palletId,
@Valid @RequestBody WarehouseResourceLockRequests.CloseRequest request) {
tokenGuard.verify(token);
return ApiResponse.success(service.scrapByPallet(request.customerId(), palletId));
}
@PostMapping("/default-locations/ensure")
public ApiResponse<WarehouseLocationEntity> ensureDefaultLocation(
@RequestHeader(value = "X-Internal-Service-Token", required = false) String token,
@Valid @RequestBody WarehouseResourceLockRequests.DefaultLocationRequest request) {
tokenGuard.verify(token);
return ApiResponse.success(resourceService.ensureDefaultVirtualLocation(
request.customerId(), request.warehouseId()));
}
}
@@ -0,0 +1,67 @@
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 = "Tenant comes only from the authenticated JWT; clients cannot override it. / 租户仅从认证 JWT 获取,客户端不可覆盖。"
)
@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));
}
}
@@ -0,0 +1,109 @@
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.LocationRequests;
import com.baiyue.warehouse.dto.PalletRequests;
import com.baiyue.warehouse.entity.WarehouseLocationEntity;
import com.baiyue.warehouse.entity.WarehousePalletEntity;
import com.baiyue.warehouse.service.WarehouseResourceService;
import jakarta.validation.Valid;
import jakarta.annotation.Resource;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
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.RestController;
@Validated
@RestController
@RequestMapping("/api/merchant/v1/warehouses/{warehouseId}")
public class MerchantWarehouseResourceController {
@Resource private WarehouseResourceService service;
@PostMapping("/locations")
public ApiResponse<Long> createLocation(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable long warehouseId,
@Valid @RequestBody LocationRequests.SaveRequest request) {
return ApiResponse.success(service.createLocation(tenant(user), warehouseId, request));
}
@PutMapping("/locations/{id}")
public ApiResponse<Void> updateLocation(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable long warehouseId, @PathVariable long id,
@Valid @RequestBody LocationRequests.SaveRequest request) {
service.updateLocation(tenant(user), warehouseId, id, request);
return ApiResponse.success(null);
}
@GetMapping("/locations")
public ApiResponse<PageResult<WarehouseLocationEntity>> locations(
@AuthenticationPrincipal AuthenticatedUser user, @PathVariable long warehouseId,
@Valid LocationRequests.QueryRequest query) {
return ApiResponse.success(service.locations(tenant(user), warehouseId, query));
}
@GetMapping("/locations/{id}")
public ApiResponse<WarehouseLocationEntity> location(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable long warehouseId,
@PathVariable long id) {
return ApiResponse.success(service.location(tenant(user), warehouseId, id));
}
@DeleteMapping("/locations/{id}")
public ApiResponse<Void> deleteLocation(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable long warehouseId, @PathVariable long id) {
service.deleteLocation(tenant(user), warehouseId, id);
return ApiResponse.success(null);
}
@PostMapping("/pallets")
public ApiResponse<Long> createPallet(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable long warehouseId,
@Valid @RequestBody PalletRequests.SaveRequest request) {
return ApiResponse.success(service.createPallet(tenant(user), warehouseId, request));
}
@PutMapping("/pallets/{id}")
public ApiResponse<Void> updatePallet(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable long warehouseId, @PathVariable long id,
@Valid @RequestBody PalletRequests.SaveRequest request) {
service.updatePallet(tenant(user), warehouseId, id, request);
return ApiResponse.success(null);
}
@GetMapping("/pallets")
public ApiResponse<PageResult<WarehousePalletEntity>> pallets(
@AuthenticationPrincipal AuthenticatedUser user, @PathVariable long warehouseId,
@Valid PalletRequests.QueryRequest query) {
return ApiResponse.success(service.pallets(tenant(user), warehouseId, query));
}
@GetMapping("/pallets/{id}")
public ApiResponse<WarehousePalletEntity> pallet(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable long warehouseId, @PathVariable long id) {
return ApiResponse.success(service.pallet(tenant(user), warehouseId, id));
}
@DeleteMapping("/pallets/{id}")
public ApiResponse<Void> deletePallet(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable long warehouseId, @PathVariable long id) {
service.deletePallet(tenant(user), warehouseId, id);
return ApiResponse.success(null);
}
private static long tenant(AuthenticatedUser user) {
if (user == null || user.tenantId() == null || user.tenantId() <= 0) {
throw new BizException(ResultCode.UNAUTHORIZED, "认证租户缺失");
}
return user.tenantId();
}
}
@@ -0,0 +1,166 @@
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 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.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 = "Creates a warehouse, address and service capabilities for the target tenant. / 为目标租户创建仓库、地址和服务能力。"
)
@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
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 = "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}")
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 tenant warehouses / 查询租户仓库",
description = "Returns a tenant-scoped warehouse page with optional country, capability and status filters. / 按租户分页查询仓库,可按国家、能力和状态筛选。"
)
@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
public ApiResponse<PageResult<WarehouseEntity>> page(
@AuthenticationPrincipal AuthenticatedUser user,
@Parameter(description = "Target tenant ID / 目标租户 ID", example = "10001", required = true)
@RequestParam @NotNull Long customerId,
@Valid WarehouseRequests.QueryRequest request) {
requirePlatform(user);
requireTenant(customerId);
return ApiResponse.success(service.page(customerId, request));
}
@Operation(
summary = "Get tenant warehouse details / 查询租户仓库详情",
description = "Returns the warehouse, addresses and service capabilities in the target tenant. / 返回目标租户下的仓库、地址及服务能力。"
)
@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}")
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 = "Changes warehouse status to ACTIVE or INACTIVE. / 将仓库状态切换为 ACTIVE 或 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")
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())) {
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 tenant is required / 必须指定有效目标租户");
}
}
}
@@ -0,0 +1,131 @@
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.LocationRequests;
import com.baiyue.warehouse.dto.PalletRequests;
import com.baiyue.warehouse.entity.WarehouseLocationEntity;
import com.baiyue.warehouse.entity.WarehousePalletEntity;
import com.baiyue.warehouse.service.WarehouseResourceService;
import jakarta.validation.Valid;
import jakarta.annotation.Resource;
import jakarta.validation.constraints.NotNull;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
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
@RestController
@RequestMapping("/api/platform/v1/warehouse-admin/{warehouseId}")
public class PlatformWarehouseResourceController {
@Resource private WarehouseResourceService service;
@PostMapping("/locations")
public ApiResponse<Long> createLocation(@AuthenticationPrincipal AuthenticatedUser user,
@RequestParam @NotNull Long customerId,
@PathVariable long warehouseId,
@Valid @RequestBody LocationRequests.SaveRequest request) {
requirePlatform(user, customerId);
return ApiResponse.success(service.createLocation(customerId, warehouseId, request));
}
@PutMapping("/locations/{id}")
public ApiResponse<Void> updateLocation(@AuthenticationPrincipal AuthenticatedUser user,
@RequestParam @NotNull Long customerId,
@PathVariable long warehouseId, @PathVariable long id,
@Valid @RequestBody LocationRequests.SaveRequest request) {
requirePlatform(user, customerId);
service.updateLocation(customerId, warehouseId, id, request);
return ApiResponse.success(null);
}
@GetMapping("/locations")
public ApiResponse<PageResult<WarehouseLocationEntity>> locations(
@AuthenticationPrincipal AuthenticatedUser user, @RequestParam @NotNull Long customerId,
@PathVariable long warehouseId, @Valid LocationRequests.QueryRequest query) {
requirePlatform(user, customerId);
return ApiResponse.success(service.locations(customerId, warehouseId, query));
}
@GetMapping("/locations/{id}")
public ApiResponse<WarehouseLocationEntity> location(@AuthenticationPrincipal AuthenticatedUser user,
@RequestParam @NotNull Long customerId,
@PathVariable long warehouseId,
@PathVariable long id) {
requirePlatform(user, customerId);
return ApiResponse.success(service.location(customerId, warehouseId, id));
}
@DeleteMapping("/locations/{id}")
public ApiResponse<Void> deleteLocation(@AuthenticationPrincipal AuthenticatedUser user,
@RequestParam @NotNull Long customerId,
@PathVariable long warehouseId, @PathVariable long id) {
requirePlatform(user, customerId);
service.deleteLocation(customerId, warehouseId, id);
return ApiResponse.success(null);
}
@PostMapping("/pallets")
public ApiResponse<Long> createPallet(@AuthenticationPrincipal AuthenticatedUser user,
@RequestParam @NotNull Long customerId,
@PathVariable long warehouseId,
@Valid @RequestBody PalletRequests.SaveRequest request) {
requirePlatform(user, customerId);
return ApiResponse.success(service.createPallet(customerId, warehouseId, request));
}
@PutMapping("/pallets/{id}")
public ApiResponse<Void> updatePallet(@AuthenticationPrincipal AuthenticatedUser user,
@RequestParam @NotNull Long customerId,
@PathVariable long warehouseId, @PathVariable long id,
@Valid @RequestBody PalletRequests.SaveRequest request) {
requirePlatform(user, customerId);
service.updatePallet(customerId, warehouseId, id, request);
return ApiResponse.success(null);
}
@GetMapping("/pallets")
public ApiResponse<PageResult<WarehousePalletEntity>> pallets(
@AuthenticationPrincipal AuthenticatedUser user, @RequestParam @NotNull Long customerId,
@PathVariable long warehouseId, @Valid PalletRequests.QueryRequest query) {
requirePlatform(user, customerId);
return ApiResponse.success(service.pallets(customerId, warehouseId, query));
}
@GetMapping("/pallets/{id}")
public ApiResponse<WarehousePalletEntity> pallet(@AuthenticationPrincipal AuthenticatedUser user,
@RequestParam @NotNull Long customerId,
@PathVariable long warehouseId, @PathVariable long id) {
requirePlatform(user, customerId);
return ApiResponse.success(service.pallet(customerId, warehouseId, id));
}
@DeleteMapping("/pallets/{id}")
public ApiResponse<Void> deletePallet(@AuthenticationPrincipal AuthenticatedUser user,
@RequestParam @NotNull Long customerId,
@PathVariable long warehouseId, @PathVariable long id) {
requirePlatform(user, customerId);
service.deletePallet(customerId, warehouseId, id);
return ApiResponse.success(null);
}
private static void requirePlatform(AuthenticatedUser user, Long customerId) {
if (user == null || !"PLATFORM".equals(user.appCode())) {
throw new BizException(ResultCode.FORBIDDEN, "仅允许平台端访问");
}
if (customerId == null || customerId <= 0) {
throw new BizException(ResultCode.BAD_REQUEST, "必须指定有效目标租户");
}
}
}
@@ -0,0 +1,26 @@
package com.baiyue.warehouse.controller;
import com.baiyue.common.core.api.ApiResponse;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.Map;
/**
* 仓库微服务可用性接口,向平台端暴露服务逻辑状态。
*/
@Tag(name = "Warehouse service status / 仓储服务状态", description = "Warehouse service availability API. / 仓储服务可用性接口。")
@RestController
@RequestMapping("/api/platform/v1/warehouses")
public class WarehouseHealthController {
@Operation(summary = "Get service status / 查询服务状态", description = "Returns the logical status of warehouse-overseas-service. / 返回 warehouse-overseas-service 的逻辑运行状态。")
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "Service is available / 服务可用")
@GetMapping("/status")
public ApiResponse<Map<String, String>> status() {
return ApiResponse.success(Map.of("service", "warehouse-overseas-service", "status", "UP"));
}
}
@@ -0,0 +1,9 @@
package com.baiyue.warehouse.dto;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Positive;
public final class DropshipPalletRequests {
private DropshipPalletRequests() {}
public record Ensure(@Positive long customerId, @Positive long warehouseId, @NotBlank String businessId) {}
}
@@ -0,0 +1,39 @@
package com.baiyue.warehouse.dto;
import jakarta.validation.constraints.DecimalMin;
import jakarta.validation.constraints.Max;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Size;
import java.math.BigDecimal;
public final class LocationRequests {
private LocationRequests() {
}
public record SaveRequest(
@NotBlank @Size(max = 50) String zoneCode,
@NotBlank @Size(max = 64) String locationCode,
@NotBlank @Size(max = 32) String locationType,
@NotNull @DecimalMin(value = "0.000", inclusive = false) BigDecimal maxLoadKg,
@NotBlank String status,
@Size(max = 500) String remark
) {
}
public record QueryRequest(
@Min(1) long page,
@Min(1) @Max(200) long size,
String zoneCode,
String locationType,
String status,
String keyword
) {
public QueryRequest {
if (page == 0) page = 1;
if (size == 0) size = 20;
}
}
}
@@ -0,0 +1,45 @@
package com.baiyue.warehouse.dto;
import jakarta.validation.constraints.DecimalMin;
import jakarta.validation.constraints.Max;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Size;
import java.math.BigDecimal;
public final class PalletRequests {
private static final BigDecimal DEFAULT_CAPACITY = new BigDecimal("2.0000");
private PalletRequests() {
}
public record SaveRequest(
@Size(max = 128) String palletCode,
@NotBlank @Size(max = 32) String palletType,
@DecimalMin(value = "0.0000", inclusive = false) BigDecimal capacityCbm,
@NotNull @DecimalMin("0.00") BigDecimal unitPrice,
@NotBlank String status,
Long locationId,
@Size(max = 500) String remark
) {
public SaveRequest {
if (capacityCbm == null) capacityCbm = DEFAULT_CAPACITY;
}
}
public record QueryRequest(
@Min(1) long page,
@Min(1) @Max(200) long size,
String palletType,
String status,
Long locationId,
String keyword
) {
public QueryRequest {
if (page == 0) page = 1;
if (size == 0) size = 20;
}
}
}
@@ -0,0 +1,19 @@
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
) {
}
@@ -0,0 +1,4 @@
package com.baiyue.warehouse.dto;
public record WarehousePalletScrapResponse(long palletId, long warehouseId, Long locationId, String status) {
}
@@ -0,0 +1,117 @@
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.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 = "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;
}
}
}
}
@@ -0,0 +1,25 @@
package com.baiyue.warehouse.dto;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Positive;
public final class WarehouseResourceLockRequests {
private WarehouseResourceLockRequests() {}
public record ReserveRequest(
@Positive long customerId,
@Positive long warehouseId,
@Positive long palletId,
@Positive long locationId,
@NotBlank String businessType,
@NotBlank String businessId,
@NotBlank String reservationKey
) {}
public record CloseRequest(@Positive long customerId) {}
public record DefaultLocationRequest(
@Positive long customerId,
@Positive long warehouseId
) {}
}
@@ -0,0 +1,25 @@
package com.baiyue.warehouse.dto;
import com.baiyue.warehouse.entity.WarehouseResourceLockEntity;
import java.time.LocalDateTime;
public record WarehouseResourceLockResponse(
long lockId,
long customerId,
long warehouseId,
long palletId,
long locationId,
String businessType,
String businessId,
String reservationKey,
String status,
LocalDateTime lockedAt,
LocalDateTime releasedAt
) {
public static WarehouseResourceLockResponse from(WarehouseResourceLockEntity entity) {
return new WarehouseResourceLockResponse(entity.getId(), entity.getCustomerId(), entity.getWarehouseId(),
entity.getPalletId(), entity.getLocationId(), entity.getBusinessType(), entity.getBusinessId(),
entity.getReservationKey(), entity.getStatus(), entity.getLockedAt(), entity.getReleasedAt());
}
}
@@ -0,0 +1,57 @@
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;
}
@@ -0,0 +1,77 @@
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;
}
@@ -0,0 +1,71 @@
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;
}
@@ -0,0 +1,26 @@
package com.baiyue.warehouse.entity;
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 lombok.Getter;
import lombok.Setter;
import java.math.BigDecimal;
@Getter
@Setter
@TableName("wh_location")
public class WarehouseLocationEntity extends BaseEntity {
@TableId(type = IdType.ASSIGN_ID)
private Long id;
private Long customerId;
private Long warehouseId;
private String zoneCode;
private String locationCode;
private String locationType;
private BigDecimal maxLoadKg;
private String status;
private String remark;
}
@@ -0,0 +1,19 @@
package com.baiyue.warehouse.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import java.time.LocalDateTime;
@Data
@TableName("wh_pallet_business_binding")
public class WarehousePalletBindingEntity {
@TableId(type = IdType.ASSIGN_ID) private Long id;
private Long customerId;
private Long warehouseId;
private String businessType;
private String businessId;
private Long palletId;
private LocalDateTime createdAt;
}
@@ -0,0 +1,27 @@
package com.baiyue.warehouse.entity;
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 lombok.Getter;
import lombok.Setter;
import java.math.BigDecimal;
@Getter
@Setter
@TableName("wh_pallet")
public class WarehousePalletEntity extends BaseEntity {
@TableId(type = IdType.ASSIGN_ID)
private Long id;
private Long customerId;
private Long warehouseId;
private Long locationId;
private String palletCode;
private String palletType;
private BigDecimal capacityCbm;
private BigDecimal unitPrice;
private String status;
private String remark;
}
@@ -0,0 +1,29 @@
package com.baiyue.warehouse.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Getter;
import lombok.Setter;
import java.time.LocalDateTime;
@Getter
@Setter
@TableName("wh_resource_lock")
public class WarehouseResourceLockEntity {
@TableId(type = IdType.ASSIGN_ID)
private Long id;
private Long customerId;
private Long warehouseId;
private Long palletId;
private Long locationId;
private String businessType;
private String businessId;
private String reservationKey;
private String status;
private LocalDateTime lockedAt;
private LocalDateTime releasedAt;
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
}
@@ -0,0 +1,16 @@
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);
}
@@ -0,0 +1,16 @@
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);
}
@@ -0,0 +1,22 @@
package com.baiyue.warehouse.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baiyue.warehouse.entity.WarehouseLocationEntity;
import org.apache.ibatis.annotations.Insert;
import org.apache.ibatis.annotations.Param;
public interface WarehouseLocationMapper extends BaseMapper<WarehouseLocationEntity> {
/**
* The unique location-code index makes this safe for concurrent first use of an older warehouse.
*/
@Insert("""
INSERT IGNORE INTO wh_location
(id, customer_id, warehouse_id, zone_code, location_code, location_type,
max_load_kg, status, remark, deleted, version)
VALUES
(#{location.id}, #{location.customerId}, #{location.warehouseId}, #{location.zoneCode},
#{location.locationCode}, #{location.locationType}, #{location.maxLoadKg},
#{location.status}, #{location.remark}, 0, 0)
""")
int insertDefaultVirtualLocationIgnore(@Param("location") WarehouseLocationEntity location);
}
@@ -0,0 +1,6 @@
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> {}
@@ -0,0 +1,13 @@
package com.baiyue.warehouse.mapper;
import com.baiyue.warehouse.entity.WarehousePalletBindingEntity;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
public interface WarehousePalletBindingMapper extends BaseMapper<WarehousePalletBindingEntity> {
@Select("SELECT * FROM wh_pallet_business_binding WHERE customer_id=#{customerId} AND warehouse_id=#{warehouseId} " +
"AND business_type=#{businessType} AND business_id=#{businessId} LIMIT 1")
WarehousePalletBindingEntity find(@Param("customerId") long customerId, @Param("warehouseId") long warehouseId,
@Param("businessType") String businessType, @Param("businessId") String businessId);
}
@@ -0,0 +1,7 @@
package com.baiyue.warehouse.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baiyue.warehouse.entity.WarehousePalletEntity;
public interface WarehousePalletMapper extends BaseMapper<WarehousePalletEntity> {
}
@@ -0,0 +1,100 @@
package com.baiyue.warehouse.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baiyue.warehouse.entity.WarehouseResourceLockEntity;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
import org.apache.ibatis.annotations.Update;
public interface WarehouseResourceLockMapper extends BaseMapper<WarehouseResourceLockEntity> {
@Select("""
SELECT * FROM wh_resource_lock
WHERE customer_id = #{customerId} AND reservation_key = #{reservationKey}
LIMIT 1
""")
WarehouseResourceLockEntity findByReservationKey(@Param("customerId") long customerId,
@Param("reservationKey") String reservationKey);
@Select("SELECT * FROM wh_resource_lock WHERE customer_id=#{customerId} AND pallet_id=#{palletId} " +
"AND status='ACTIVE' ORDER BY locked_at DESC LIMIT 1")
WarehouseResourceLockEntity findActiveByPallet(@Param("customerId") long customerId,
@Param("palletId") long palletId);
@Update("""
UPDATE wh_pallet
SET status = 'OCCUPIED', location_id = #{locationId}, updated_at = CURRENT_TIMESTAMP(3), version = version + 1
WHERE id = #{palletId} AND customer_id = #{customerId} AND warehouse_id = #{warehouseId}
AND status = 'IDLE' AND deleted = 0
""")
int occupyPallet(@Param("customerId") long customerId, @Param("warehouseId") long warehouseId,
@Param("palletId") long palletId, @Param("locationId") long locationId);
@Update("""
UPDATE wh_location
SET status = 'OCCUPIED', updated_at = CURRENT_TIMESTAMP(3), version = version + 1
WHERE id = #{locationId} AND customer_id = #{customerId} AND warehouse_id = #{warehouseId}
AND status = 'IDLE' AND deleted = 0
""")
int occupyLocation(@Param("customerId") long customerId, @Param("warehouseId") long warehouseId,
@Param("locationId") long locationId);
@Update("""
UPDATE wh_pallet
SET status = 'IDLE', location_id = NULL, updated_at = CURRENT_TIMESTAMP(3), version = version + 1
WHERE id = #{palletId} AND customer_id = #{customerId} AND warehouse_id = #{warehouseId}
AND location_id = #{locationId} AND status = 'OCCUPIED' AND deleted = 0
""")
int releasePallet(@Param("customerId") long customerId, @Param("warehouseId") long warehouseId,
@Param("palletId") long palletId, @Param("locationId") long locationId);
@Update("""
UPDATE wh_pallet
SET status = 'IDLE', updated_at = CURRENT_TIMESTAMP(3), version = version + 1
WHERE id = #{palletId} AND customer_id = #{customerId} AND warehouse_id = #{warehouseId}
AND location_id = #{locationId} AND status = 'OCCUPIED' AND deleted = 0
""")
int releasePalletAtVirtualLocation(@Param("customerId") long customerId,
@Param("warehouseId") long warehouseId,
@Param("palletId") long palletId,
@Param("locationId") long locationId);
@Update("""
UPDATE wh_location
SET status = 'IDLE', updated_at = CURRENT_TIMESTAMP(3), version = version + 1
WHERE id = #{locationId} AND customer_id = #{customerId} AND warehouse_id = #{warehouseId}
AND status = 'OCCUPIED' AND deleted = 0
""")
int releaseLocation(@Param("customerId") long customerId, @Param("warehouseId") long warehouseId,
@Param("locationId") long locationId);
@Update("""
UPDATE wh_pallet
SET status = 'SCRAPPED', location_id = NULL, updated_at = CURRENT_TIMESTAMP(3), version = version + 1
WHERE id = #{palletId} AND customer_id = #{customerId} AND warehouse_id = #{warehouseId}
AND location_id = #{locationId} AND status = 'OCCUPIED' AND deleted = 0
""")
int scrapPallet(@Param("customerId") long customerId, @Param("warehouseId") long warehouseId,
@Param("palletId") long palletId, @Param("locationId") long locationId);
@Update("""
UPDATE wh_pallet
SET status = 'SCRAPPED', updated_at = CURRENT_TIMESTAMP(3), version = version + 1
WHERE id = #{palletId} AND customer_id = #{customerId} AND warehouse_id = #{warehouseId}
AND location_id = #{locationId} AND status = 'OCCUPIED' AND deleted = 0
""")
int scrapPalletAtVirtualLocation(@Param("customerId") long customerId,
@Param("warehouseId") long warehouseId,
@Param("palletId") long palletId,
@Param("locationId") long locationId);
@Update("UPDATE wh_pallet SET status='SCRAPPED',updated_at=CURRENT_TIMESTAMP(3),version=version+1 " +
"WHERE customer_id=#{customerId} AND id=#{palletId} AND status='OCCUPIED' AND deleted=0")
int scrapPalletWithoutLock(@Param("customerId") long customerId, @Param("palletId") long palletId);
@Update("""
UPDATE wh_resource_lock
SET status = #{status}, released_at = CURRENT_TIMESTAMP(3), updated_at = CURRENT_TIMESTAMP(3)
WHERE id = #{id} AND status = 'ACTIVE'
""")
int closeLock(@Param("id") long id, @Param("status") String status);
}
@@ -0,0 +1,112 @@
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 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);
}
}
@@ -0,0 +1,90 @@
package com.baiyue.warehouse.repository;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baiyue.warehouse.entity.WarehouseLocationEntity;
import com.baiyue.warehouse.entity.WarehousePalletEntity;
import com.baiyue.warehouse.entity.WarehouseResourceLockEntity;
import com.baiyue.warehouse.mapper.WarehouseLocationMapper;
import com.baiyue.warehouse.mapper.WarehousePalletMapper;
import com.baiyue.warehouse.mapper.WarehouseResourceLockMapper;
import org.springframework.stereotype.Repository;
import jakarta.annotation.Resource;
@Repository
public class WarehouseResourceLockRepository {
@Resource private WarehouseResourceLockMapper locks;
@Resource private WarehousePalletMapper pallets;
@Resource private WarehouseLocationMapper locations;
public WarehouseResourceLockEntity findByReservationKey(long customerId, String reservationKey) {
return locks.findByReservationKey(customerId, reservationKey);
}
public WarehouseResourceLockEntity find(long customerId, long lockId) {
return locks.selectOne(new LambdaQueryWrapper<WarehouseResourceLockEntity>()
.eq(WarehouseResourceLockEntity::getCustomerId, customerId)
.eq(WarehouseResourceLockEntity::getId, lockId));
}
public WarehouseResourceLockEntity findActiveByPallet(long customerId, long palletId) {
return locks.findActiveByPallet(customerId, palletId);
}
public WarehousePalletEntity findPallet(long customerId, long palletId) {
return pallets.selectOne(new LambdaQueryWrapper<WarehousePalletEntity>()
.eq(WarehousePalletEntity::getCustomerId, customerId)
.eq(WarehousePalletEntity::getId, palletId));
}
public WarehousePalletEntity findPallet(long customerId, long warehouseId, long palletId) {
return pallets.selectOne(new LambdaQueryWrapper<WarehousePalletEntity>()
.eq(WarehousePalletEntity::getCustomerId, customerId)
.eq(WarehousePalletEntity::getWarehouseId, warehouseId)
.eq(WarehousePalletEntity::getId, palletId));
}
public WarehouseLocationEntity findLocation(long customerId, long warehouseId, long locationId) {
return locations.selectOne(new LambdaQueryWrapper<WarehouseLocationEntity>()
.eq(WarehouseLocationEntity::getCustomerId, customerId)
.eq(WarehouseLocationEntity::getWarehouseId, warehouseId)
.eq(WarehouseLocationEntity::getId, locationId));
}
public int occupyPallet(long customerId, long warehouseId, long palletId, long locationId) {
return locks.occupyPallet(customerId, warehouseId, palletId, locationId);
}
public int occupyLocation(long customerId, long warehouseId, long locationId) {
return locks.occupyLocation(customerId, warehouseId, locationId);
}
public void insert(WarehouseResourceLockEntity lock) { locks.insert(lock); }
public int releasePallet(WarehouseResourceLockEntity lock) {
return locks.releasePallet(lock.getCustomerId(), lock.getWarehouseId(), lock.getPalletId(), lock.getLocationId());
}
public int releasePalletAtVirtualLocation(WarehouseResourceLockEntity lock) {
return locks.releasePalletAtVirtualLocation(lock.getCustomerId(), lock.getWarehouseId(),
lock.getPalletId(), lock.getLocationId());
}
public int scrapPallet(WarehouseResourceLockEntity lock) {
return locks.scrapPallet(lock.getCustomerId(), lock.getWarehouseId(), lock.getPalletId(), lock.getLocationId());
}
public int scrapPalletWithoutLock(long customerId, long palletId) {
return locks.scrapPalletWithoutLock(customerId, palletId);
}
public int scrapPalletAtVirtualLocation(WarehouseResourceLockEntity lock) {
return locks.scrapPalletAtVirtualLocation(lock.getCustomerId(), lock.getWarehouseId(),
lock.getPalletId(), lock.getLocationId());
}
public int releaseLocation(WarehouseResourceLockEntity lock) {
return locks.releaseLocation(lock.getCustomerId(), lock.getWarehouseId(), lock.getLocationId());
}
public int close(WarehouseResourceLockEntity lock, String status) { return locks.closeLock(lock.getId(), status); }
}
@@ -0,0 +1,158 @@
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.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;
import org.springframework.stereotype.Repository;
import org.springframework.util.StringUtils;
import jakarta.annotation.Resource;
@Repository
public class WarehouseResourceRepository {
@Resource private WarehouseMapper warehouses;
@Resource private WarehouseLocationMapper locations;
@Resource private WarehousePalletMapper pallets;
@Resource private WarehousePalletBindingMapper bindings;
public WarehousePalletBindingEntity findPalletBinding(long customerId, long warehouseId,
String businessType, String businessId) {
return bindings.find(customerId, warehouseId, businessType, businessId);
}
public void insertPalletBinding(WarehousePalletBindingEntity entity) {
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 WarehouseLocationEntity findDefaultVirtualLocation(long customerId, long warehouseId) {
return locations.selectOne(new LambdaQueryWrapper<WarehouseLocationEntity>()
.eq(WarehouseLocationEntity::getCustomerId, customerId)
.eq(WarehouseLocationEntity::getWarehouseId, warehouseId)
.eq(WarehouseLocationEntity::getLocationType, "VIRTUAL")
.eq(WarehouseLocationEntity::getStatus, "ACTIVE")
.orderByAsc(WarehouseLocationEntity::getCreatedAt)
.last("LIMIT 1"));
}
public WarehouseLocationEntity findLocationByCode(long customerId, long warehouseId, String locationCode) {
return locations.selectOne(new LambdaQueryWrapper<WarehouseLocationEntity>()
.eq(WarehouseLocationEntity::getCustomerId, customerId)
.eq(WarehouseLocationEntity::getWarehouseId, warehouseId)
.eq(WarehouseLocationEntity::getLocationCode, locationCode));
}
public void insertLocation(WarehouseLocationEntity entity) {
locations.insert(entity);
}
public int insertDefaultVirtualLocationIgnore(WarehouseLocationEntity entity) {
return locations.insertDefaultVirtualLocationIgnore(entity);
}
public void updateLocation(WarehouseLocationEntity entity) {
locations.updateById(entity);
}
public WarehouseLocationEntity findLocation(long customerId, long warehouseId, long id) {
return locations.selectOne(new LambdaQueryWrapper<WarehouseLocationEntity>()
.eq(WarehouseLocationEntity::getCustomerId, customerId)
.eq(WarehouseLocationEntity::getWarehouseId, warehouseId)
.eq(WarehouseLocationEntity::getId, id));
}
public boolean locationCodeExists(long customerId, long warehouseId, String code, Long excludedId) {
return locations.selectCount(new LambdaQueryWrapper<WarehouseLocationEntity>()
.eq(WarehouseLocationEntity::getCustomerId, customerId)
.eq(WarehouseLocationEntity::getWarehouseId, warehouseId)
.eq(WarehouseLocationEntity::getLocationCode, code)
.ne(excludedId != null, WarehouseLocationEntity::getId, excludedId)) > 0;
}
public PageResult<WarehouseLocationEntity> pageLocations(long customerId, long warehouseId,
LocationRequests.QueryRequest query) {
var wrapper = new LambdaQueryWrapper<WarehouseLocationEntity>()
.eq(WarehouseLocationEntity::getCustomerId, customerId)
.eq(WarehouseLocationEntity::getWarehouseId, warehouseId)
.eq(StringUtils.hasText(query.zoneCode()), WarehouseLocationEntity::getZoneCode, query.zoneCode())
.eq(StringUtils.hasText(query.locationType()), WarehouseLocationEntity::getLocationType, query.locationType())
.eq(StringUtils.hasText(query.status()), WarehouseLocationEntity::getStatus, query.status())
.and(StringUtils.hasText(query.keyword()), q -> q
.like(WarehouseLocationEntity::getLocationCode, query.keyword())
.or().like(WarehouseLocationEntity::getZoneCode, query.keyword()))
.orderByAsc(WarehouseLocationEntity::getZoneCode)
.orderByAsc(WarehouseLocationEntity::getLocationCode);
return PageResult.from(locations.selectPage(new Page<>(query.page(), query.size()), wrapper));
}
public void deleteLocation(long id) {
locations.deleteById(id);
}
public boolean locationHasPallets(long customerId, long warehouseId, long locationId) {
return pallets.selectCount(new LambdaQueryWrapper<WarehousePalletEntity>()
.eq(WarehousePalletEntity::getCustomerId, customerId)
.eq(WarehousePalletEntity::getWarehouseId, warehouseId)
.eq(WarehousePalletEntity::getLocationId, locationId)) > 0;
}
public void insertPallet(WarehousePalletEntity entity) {
pallets.insert(entity);
}
public void updatePallet(WarehousePalletEntity entity) {
pallets.updateById(entity);
}
public WarehousePalletEntity findPallet(long customerId, long warehouseId, long id) {
return pallets.selectOne(new LambdaQueryWrapper<WarehousePalletEntity>()
.eq(WarehousePalletEntity::getCustomerId, customerId)
.eq(WarehousePalletEntity::getWarehouseId, warehouseId)
.eq(WarehousePalletEntity::getId, id));
}
public boolean palletCodeExists(long customerId, long warehouseId, String code, Long excludedId) {
return pallets.selectCount(new LambdaQueryWrapper<WarehousePalletEntity>()
.eq(WarehousePalletEntity::getCustomerId, customerId)
.eq(WarehousePalletEntity::getWarehouseId, warehouseId)
.eq(WarehousePalletEntity::getPalletCode, code)
.ne(excludedId != null, WarehousePalletEntity::getId, excludedId)) > 0;
}
public PageResult<WarehousePalletEntity> pagePallets(long customerId, long warehouseId,
PalletRequests.QueryRequest query) {
var wrapper = new LambdaQueryWrapper<WarehousePalletEntity>()
.eq(WarehousePalletEntity::getCustomerId, customerId)
.eq(WarehousePalletEntity::getWarehouseId, warehouseId)
.eq(StringUtils.hasText(query.palletType()), WarehousePalletEntity::getPalletType, query.palletType())
.eq(StringUtils.hasText(query.status()), WarehousePalletEntity::getStatus, query.status())
.eq(query.locationId() != null, WarehousePalletEntity::getLocationId, query.locationId())
.like(StringUtils.hasText(query.keyword()), WarehousePalletEntity::getPalletCode, query.keyword())
.orderByDesc(WarehousePalletEntity::getCreatedAt);
return PageResult.from(pallets.selectPage(new Page<>(query.page(), query.size()), wrapper));
}
public void deletePallet(long id) {
pallets.deleteById(id);
}
}
@@ -0,0 +1,26 @@
package com.baiyue.warehouse.security;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Component;
import org.springframework.web.server.ResponseStatusException;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
@Component
public class InternalServiceTokenGuard {
private final byte[] expected;
public InternalServiceTokenGuard(
@Value("${baiyue.internal-service.token:phase1-local-internal-token-at-least-32-bytes}") String token) {
this.expected = token.getBytes(StandardCharsets.UTF_8);
}
public void verify(String provided) {
byte[] actual = provided == null ? new byte[0] : provided.getBytes(StandardCharsets.UTF_8);
if (!MessageDigest.isEqual(expected, actual)) {
throw new ResponseStatusException(HttpStatus.UNAUTHORIZED, "Invalid internal service token");
}
}
}
@@ -0,0 +1,176 @@
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.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) {
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, "租户不匹配");
}
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 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);
}
@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);
}
}
}
@@ -0,0 +1,158 @@
package com.baiyue.warehouse.service;
import com.baiyue.common.core.api.ResultCode;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.warehouse.dto.WarehouseResourceLockRequests;
import com.baiyue.warehouse.dto.WarehouseResourceLockResponse;
import com.baiyue.warehouse.dto.WarehousePalletScrapResponse;
import com.baiyue.warehouse.entity.WarehouseLocationEntity;
import com.baiyue.warehouse.entity.WarehousePalletEntity;
import com.baiyue.warehouse.entity.WarehouseResourceLockEntity;
import com.baiyue.warehouse.repository.WarehouseResourceLockRepository;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Isolation;
import org.springframework.transaction.annotation.Transactional;
import jakarta.annotation.Resource;
import java.time.LocalDateTime;
import java.util.Objects;
@Service
public class WarehouseResourceLockService {
@Resource private WarehouseResourceLockRepository repository;
@Transactional(isolation = Isolation.READ_COMMITTED)
public WarehouseResourceLockResponse reserve(WarehouseResourceLockRequests.ReserveRequest request) {
WarehouseResourceLockEntity existing = repository.findByReservationKey(request.customerId(), request.reservationKey());
if (existing != null) return idempotentResult(existing, request);
WarehousePalletEntity pallet = repository.findPallet(request.customerId(), request.warehouseId(), request.palletId());
WarehouseLocationEntity location = repository.findLocation(request.customerId(), request.warehouseId(), request.locationId());
if (pallet == null || location == null) {
WarehouseResourceLockEntity concurrent =
repository.findByReservationKey(request.customerId(), request.reservationKey());
if (concurrent != null) return idempotentResult(concurrent, request);
throw new BizException(ResultCode.NOT_FOUND, "Pallet or location does not belong to the tenant warehouse");
}
boolean virtualLocation = isVirtual(location);
boolean locationAvailable = virtualLocation ? "ACTIVE".equals(location.getStatus()) : "IDLE".equals(location.getStatus());
if (!"IDLE".equals(pallet.getStatus()) || !locationAvailable) {
WarehouseResourceLockEntity concurrent =
repository.findByReservationKey(request.customerId(), request.reservationKey());
if (concurrent != null) return idempotentResult(concurrent, request);
throw new BizException(ResultCode.CONFLICT, "Only IDLE pallet and location can be reserved");
}
if (repository.occupyPallet(request.customerId(), request.warehouseId(), request.palletId(), request.locationId()) != 1) {
return concurrentIdempotentOrConflict(request);
}
if (!virtualLocation && repository.occupyLocation(request.customerId(), request.warehouseId(), request.locationId()) != 1) {
throw new BizException(ResultCode.CONFLICT, "Location is no longer IDLE");
}
WarehouseResourceLockEntity lock = new WarehouseResourceLockEntity();
lock.setCustomerId(request.customerId());
lock.setWarehouseId(request.warehouseId());
lock.setPalletId(request.palletId());
lock.setLocationId(request.locationId());
lock.setBusinessType(request.businessType());
lock.setBusinessId(request.businessId());
lock.setReservationKey(request.reservationKey());
lock.setStatus("ACTIVE");
lock.setLockedAt(LocalDateTime.now());
repository.insert(lock);
return WarehouseResourceLockResponse.from(lock);
}
@Transactional
public WarehouseResourceLockResponse release(long customerId, long lockId) {
WarehouseResourceLockEntity lock = requireLock(customerId, lockId);
if ("RELEASED".equals(lock.getStatus())) return WarehouseResourceLockResponse.from(lock);
requireActive(lock);
boolean virtualLocation = isVirtualLockLocation(lock);
int palletResult = virtualLocation ? repository.releasePalletAtVirtualLocation(lock) : repository.releasePallet(lock);
if (palletResult != 1 || (!virtualLocation && repository.releaseLocation(lock) != 1)
|| repository.close(lock, "RELEASED") != 1) {
throw new BizException(ResultCode.CONFLICT, "Resource lock state changed concurrently");
}
lock.setStatus("RELEASED");
lock.setReleasedAt(LocalDateTime.now());
return WarehouseResourceLockResponse.from(lock);
}
@Transactional
public WarehouseResourceLockResponse scrap(long customerId, long lockId) {
WarehouseResourceLockEntity lock = requireLock(customerId, lockId);
if ("SCRAPPED".equals(lock.getStatus())) return WarehouseResourceLockResponse.from(lock);
requireActive(lock);
boolean virtualLocation = isVirtualLockLocation(lock);
int palletResult = virtualLocation ? repository.scrapPalletAtVirtualLocation(lock) : repository.scrapPallet(lock);
if (palletResult != 1 || (!virtualLocation && repository.releaseLocation(lock) != 1)
|| repository.close(lock, "SCRAPPED") != 1) {
throw new BizException(ResultCode.CONFLICT, "Resource lock state changed concurrently");
}
lock.setStatus("SCRAPPED");
lock.setReleasedAt(LocalDateTime.now());
return WarehouseResourceLockResponse.from(lock);
}
@Transactional
public WarehousePalletScrapResponse scrapByPallet(long customerId, long palletId) {
WarehousePalletEntity pallet = repository.findPallet(customerId, palletId);
if (pallet == null) throw new BizException(ResultCode.NOT_FOUND, "Pallet not found");
if ("SCRAPPED".equals(pallet.getStatus())) {
return new WarehousePalletScrapResponse(palletId, pallet.getWarehouseId(), pallet.getLocationId(), "SCRAPPED");
}
if (!"OCCUPIED".equals(pallet.getStatus())) {
throw new BizException(ResultCode.CONFLICT, "Only an OCCUPIED pallet can be scrapped after shipment");
}
WarehouseResourceLockEntity lock = repository.findActiveByPallet(customerId, palletId);
if (lock != null) {
scrap(customerId, lock.getId());
} else if (repository.scrapPalletWithoutLock(customerId, palletId) != 1) {
throw new BizException(ResultCode.CONFLICT, "Pallet state changed concurrently");
}
return new WarehousePalletScrapResponse(palletId, pallet.getWarehouseId(), pallet.getLocationId(), "SCRAPPED");
}
private WarehouseResourceLockResponse concurrentIdempotentOrConflict(
WarehouseResourceLockRequests.ReserveRequest request) {
WarehouseResourceLockEntity existing = repository.findByReservationKey(request.customerId(), request.reservationKey());
if (existing != null) return idempotentResult(existing, request);
throw new BizException(ResultCode.CONFLICT, "Pallet is no longer IDLE");
}
private WarehouseResourceLockResponse idempotentResult(WarehouseResourceLockEntity existing,
WarehouseResourceLockRequests.ReserveRequest request) {
if (existing.getWarehouseId() != request.warehouseId()
|| existing.getPalletId() != request.palletId()
|| existing.getLocationId() != request.locationId()
|| !Objects.equals(existing.getBusinessType(), request.businessType())
|| !Objects.equals(existing.getBusinessId(), request.businessId())) {
throw new BizException(ResultCode.CONFLICT, "Reservation key was already used with different resources");
}
return WarehouseResourceLockResponse.from(existing);
}
private WarehouseResourceLockEntity requireLock(long customerId, long lockId) {
WarehouseResourceLockEntity lock = repository.find(customerId, lockId);
if (lock == null) throw new BizException(ResultCode.NOT_FOUND, "Resource lock not found");
return lock;
}
private static void requireActive(WarehouseResourceLockEntity lock) {
if (!"ACTIVE".equals(lock.getStatus())) {
throw new BizException(ResultCode.CONFLICT, "Resource lock is not ACTIVE");
}
}
private boolean isVirtualLockLocation(WarehouseResourceLockEntity lock) {
WarehouseLocationEntity location = repository.findLocation(lock.getCustomerId(), lock.getWarehouseId(), lock.getLocationId());
if (location == null) throw new BizException(ResultCode.NOT_FOUND, "Resource lock location not found");
return isVirtual(location);
}
private static boolean isVirtual(WarehouseLocationEntity location) {
return "VIRTUAL".equals(location.getLocationType());
}
}
@@ -0,0 +1,282 @@
package com.baiyue.warehouse.service;
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.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.entity.WarehousePalletBindingEntity;
import com.baiyue.warehouse.repository.WarehouseResourceRepository;
import com.baomidou.mybatisplus.core.toolkit.IdWorker;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import jakarta.annotation.Resource;
import java.util.Set;
import java.time.LocalDate;
import java.time.format.DateTimeFormatter;
import org.springframework.util.StringUtils;
@Service
public class WarehouseResourceService {
private static final Set<String> LOCATION_STATUSES = Set.of("ACTIVE", "IDLE", "OCCUPIED", "INACTIVE");
private static final Set<String> PALLET_STATUSES = Set.of("IDLE", "OCCUPIED", "SCRAPPED");
@Resource private WarehouseResourceRepository repository;
@Transactional
public Long createLocation(long customerId, long warehouseId, LocationRequests.SaveRequest request) {
requireWarehouse(customerId, warehouseId);
validateLocation(request, customerId, warehouseId, null);
WarehouseLocationEntity entity = locationEntity(customerId, warehouseId, request);
repository.insertLocation(entity);
return entity.getId();
}
@Transactional
public void updateLocation(long customerId, long warehouseId, long id, LocationRequests.SaveRequest request) {
WarehouseLocationEntity existing = requireLocation(customerId, warehouseId, id);
if ("VIRTUAL".equals(existing.getLocationType())
&& (!"VIRTUAL".equals(request.locationType()) || !"ACTIVE".equals(request.status())
|| !existing.getLocationCode().equals(request.locationCode()))) {
throw new BizException(ResultCode.CONFLICT, "Default virtual location must remain VIRTUAL and ACTIVE");
}
validateLocation(request, customerId, warehouseId, id);
WarehouseLocationEntity entity = locationEntity(customerId, warehouseId, request);
entity.setId(existing.getId());
repository.updateLocation(entity);
}
public WarehouseLocationEntity location(long customerId, long warehouseId, long id) {
requireWarehouse(customerId, warehouseId);
return requireLocation(customerId, warehouseId, id);
}
public PageResult<WarehouseLocationEntity> locations(long customerId, long warehouseId,
LocationRequests.QueryRequest query) {
requireWarehouse(customerId, warehouseId);
return repository.pageLocations(customerId, warehouseId, query);
}
@Transactional
public void deleteLocation(long customerId, long warehouseId, long id) {
WarehouseLocationEntity location = requireLocation(customerId, warehouseId, id);
if ("VIRTUAL".equals(location.getLocationType())) {
throw new BizException(ResultCode.CONFLICT, "Default virtual location cannot be deleted");
}
if (repository.locationHasPallets(customerId, warehouseId, id)) {
throw new BizException(ResultCode.CONFLICT, "库位仍绑定托盘,不能删除");
}
repository.deleteLocation(id);
}
@Transactional
public Long createPallet(long customerId, long warehouseId, PalletRequests.SaveRequest request) {
WarehouseEntity warehouse = requireWarehouseEntity(customerId, warehouseId);
validatePalletStatus(request.status());
Long palletId = IdWorker.getId();
String palletCode = StringUtils.hasText(request.palletCode()) ? request.palletCode().trim()
: generatedPalletCode(warehouse.getWarehouseCode(), palletId);
Long locationId = request.locationId();
if (locationId == null) {
locationId = ensureDefaultVirtualLocation(customerId, warehouseId).getId();
}
validatePallet(request, palletCode, locationId, customerId, warehouseId, null);
WarehousePalletEntity entity = palletEntity(customerId, warehouseId, request, palletCode, locationId);
entity.setId(palletId);
repository.insertPallet(entity);
return entity.getId();
}
@Transactional
public WarehousePalletEntity ensureDropshipPallet(long customerId, long warehouseId, String businessId) {
WarehousePalletBindingEntity existing = repository.findPalletBinding(customerId, warehouseId, "DROPSHIP", businessId);
if (existing != null) return requirePallet(customerId, warehouseId, existing.getPalletId());
Long palletId = createPallet(customerId, warehouseId,
new PalletRequests.SaveRequest(null, "DROPSHIP", null, java.math.BigDecimal.ZERO, "IDLE", null,
"Default dropship pallet for " + businessId));
WarehousePalletBindingEntity binding = new WarehousePalletBindingEntity();
binding.setId(IdWorker.getId()); binding.setCustomerId(customerId); binding.setWarehouseId(warehouseId);
binding.setBusinessType("DROPSHIP"); binding.setBusinessId(businessId); binding.setPalletId(palletId);
binding.setCreatedAt(java.time.LocalDateTime.now());
try {
repository.insertPalletBinding(binding);
return requirePallet(customerId, warehouseId, palletId);
} catch (org.springframework.dao.DuplicateKeyException duplicate) {
WarehousePalletBindingEntity winner = repository.findPalletBinding(customerId, warehouseId, "DROPSHIP", businessId);
if (winner == null) throw duplicate;
return requirePallet(customerId, warehouseId, winner.getPalletId());
}
}
/**
* Returns the warehouse-scoped virtual location used only as a pallet/inventory reference.
* It is deliberately never made IDLE/OCCUPIED by resource locking.
*/
@Transactional
public WarehouseLocationEntity ensureDefaultVirtualLocation(long customerId, long warehouseId) {
WarehouseEntity warehouse = requireWarehouseEntity(customerId, warehouseId);
String locationCode = defaultLocationCode(warehouse.getWarehouseCode());
WarehouseLocationEntity existing = repository.findLocationByCode(customerId, warehouseId, locationCode);
if (existing != null) {
if (!"VIRTUAL".equals(existing.getLocationType()) || !"ACTIVE".equals(existing.getStatus())) {
throw new BizException(ResultCode.CONFLICT,
"Default virtual location code is occupied by a non-active virtual location");
}
return existing;
}
WarehouseLocationEntity location = new WarehouseLocationEntity();
location.setId(IdWorker.getId());
location.setCustomerId(customerId);
location.setWarehouseId(warehouseId);
location.setZoneCode("DEFAULT");
location.setLocationCode(locationCode);
location.setLocationType("VIRTUAL");
location.setMaxLoadKg(new java.math.BigDecimal("999999999.000"));
location.setStatus("ACTIVE");
location.setRemark("System default virtual location");
repository.insertDefaultVirtualLocationIgnore(location);
WarehouseLocationEntity resolved = repository.findLocationByCode(customerId, warehouseId, locationCode);
if (resolved == null) {
throw new BizException(ResultCode.CONFLICT, "Default virtual location could not be created");
}
return resolved;
}
@Transactional
public void updatePallet(long customerId, long warehouseId, long id, PalletRequests.SaveRequest request) {
WarehousePalletEntity existing = requirePallet(customerId, warehouseId, id);
if ("SCRAPPED".equals(existing.getStatus()) && !"SCRAPPED".equals(request.status())) {
throw new BizException(ResultCode.CONFLICT, "SCRAPPED pallet cannot be reused");
}
String palletCode = StringUtils.hasText(request.palletCode()) ? request.palletCode().trim()
: existing.getPalletCode();
Long locationId = request.locationId() == null ? existing.getLocationId() : request.locationId();
validatePallet(request, palletCode, locationId, customerId, warehouseId, id);
WarehousePalletEntity entity = palletEntity(customerId, warehouseId, request, palletCode, locationId);
entity.setId(existing.getId());
repository.updatePallet(entity);
}
public WarehousePalletEntity pallet(long customerId, long warehouseId, long id) {
requireWarehouse(customerId, warehouseId);
return requirePallet(customerId, warehouseId, id);
}
public PageResult<WarehousePalletEntity> pallets(long customerId, long warehouseId,
PalletRequests.QueryRequest query) {
requireWarehouse(customerId, warehouseId);
return repository.pagePallets(customerId, warehouseId, query);
}
@Transactional
public void deletePallet(long customerId, long warehouseId, long id) {
WarehousePalletEntity pallet = requirePallet(customerId, warehouseId, id);
if ("OCCUPIED".equals(pallet.getStatus())) {
throw new BizException(ResultCode.CONFLICT, "占用中的托盘不能删除");
}
if ("SCRAPPED".equals(pallet.getStatus())) {
throw new BizException(ResultCode.CONFLICT, "Scrapped pallet must be retained and cannot be reused");
}
repository.deletePallet(id);
}
private void requireWarehouse(long customerId, long warehouseId) {
if (!repository.warehouseOwned(customerId, warehouseId)) {
throw new BizException(ResultCode.NOT_FOUND, "当前租户下仓库不存在");
}
}
private WarehouseEntity requireWarehouseEntity(long customerId, long warehouseId) {
WarehouseEntity warehouse = repository.findWarehouse(customerId, warehouseId);
if (warehouse == null) {
throw new BizException(ResultCode.NOT_FOUND, "Tenant warehouse not found");
}
return warehouse;
}
private WarehouseLocationEntity requireLocation(long customerId, long warehouseId, long id) {
requireWarehouse(customerId, warehouseId);
WarehouseLocationEntity entity = repository.findLocation(customerId, warehouseId, id);
if (entity == null) throw new BizException(ResultCode.NOT_FOUND, "当前仓库下库位不存在");
return entity;
}
private WarehousePalletEntity requirePallet(long customerId, long warehouseId, long id) {
requireWarehouse(customerId, warehouseId);
WarehousePalletEntity entity = repository.findPallet(customerId, warehouseId, id);
if (entity == null) throw new BizException(ResultCode.NOT_FOUND, "当前仓库下托盘不存在");
return entity;
}
private void validateLocation(LocationRequests.SaveRequest request, long customerId,
long warehouseId, Long excludedId) {
if (!LOCATION_STATUSES.contains(request.status())) {
throw new BizException("库位状态仅支持 ACTIVE、IDLE、OCCUPIED、INACTIVE");
}
if (repository.locationCodeExists(customerId, warehouseId, request.locationCode(), excludedId)) {
throw new BizException(ResultCode.CONFLICT, "当前仓库下库位编码已存在");
}
}
private void validatePallet(PalletRequests.SaveRequest request, String palletCode, Long locationId,
long customerId, long warehouseId, Long excludedId) {
validatePalletStatus(request.status());
if (repository.palletCodeExists(customerId, warehouseId, palletCode, excludedId)) {
throw new BizException(ResultCode.CONFLICT, "当前仓库下托盘编码已存在");
}
if (locationId != null) {
requireLocation(customerId, warehouseId, locationId);
}
}
private void validatePalletStatus(String status) {
if (!PALLET_STATUSES.contains(status)) {
throw new BizException("托盘状态仅支持 IDLE、OCCUPIED、SCRAPPED");
}
}
private WarehouseLocationEntity locationEntity(long customerId, long warehouseId,
LocationRequests.SaveRequest request) {
WarehouseLocationEntity entity = new WarehouseLocationEntity();
entity.setCustomerId(customerId);
entity.setWarehouseId(warehouseId);
entity.setZoneCode(request.zoneCode());
entity.setLocationCode(request.locationCode());
entity.setLocationType(request.locationType());
entity.setMaxLoadKg(request.maxLoadKg());
entity.setStatus(request.status());
entity.setRemark(request.remark());
return entity;
}
private WarehousePalletEntity palletEntity(long customerId, long warehouseId,
PalletRequests.SaveRequest request,
String palletCode, Long locationId) {
WarehousePalletEntity entity = new WarehousePalletEntity();
entity.setCustomerId(customerId);
entity.setWarehouseId(warehouseId);
entity.setLocationId(locationId);
entity.setPalletCode(palletCode);
entity.setPalletType(request.palletType());
entity.setCapacityCbm(request.capacityCbm());
entity.setUnitPrice(request.unitPrice());
entity.setStatus(request.status());
entity.setRemark(request.remark());
return entity;
}
private static String generatedPalletCode(String warehouseCode, long palletId) {
return "PLT-" + warehouseCode + "-" + LocalDate.now().format(DateTimeFormatter.BASIC_ISO_DATE)
+ "-" + Long.toUnsignedString(palletId);
}
private static String defaultLocationCode(String warehouseCode) {
return "LOC-" + warehouseCode + "-DEFAULT";
}
}
@@ -0,0 +1,45 @@
server:
port: ${WAREHOUSE_PORT:9305}
spring:
sql:
init:
mode: always
encoding: UTF-8
schema-locations: classpath:db/warehouse.sql
data:
redis:
host: ${REDIS_HOST:127.0.0.1}
port: ${REDIS_PORT:6380}
application:
name: warehouse-overseas-service
config:
import:
- optional:nacos:baiyue-common.yml?group=BAIYUE&refreshEnabled=true
- optional:nacos:${spring.application.name}.yml?group=BAIYUE&refreshEnabled=true
cloud:
nacos:
server-addr: ${NACOS_SERVER_ADDR:127.0.0.1:8858}
username: ${NACOS_USERNAME:nacos}
password: ${NACOS_PASSWORD:nacos}
discovery:
enabled: ${NACOS_DISCOVERY_ENABLED:true}
register-enabled: ${NACOS_REGISTER_ENABLED:true}
group: ${NACOS_GROUP:BAIYUE}
namespace: ${NACOS_NAMESPACE:public}
config.namespace: ${NACOS_NAMESPACE:public}
datasource:
driver-class-name: com.mysql.cj.jdbc.Driver
url: ${WAREHOUSE_DB_URL:jdbc:mysql://127.0.0.1:3306/baiyue_warehouse_overseas?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&rewriteBatchedStatements=true}
username: ${WAREHOUSE_DB_USERNAME:root}
password: ${WAREHOUSE_DB_PASSWORD:}
management.endpoints.web.exposure.include: health,info,prometheus
baiyue:
internal-service:
token: ${INTERNAL_SERVICE_TOKEN:phase1-local-internal-token-at-least-32-bytes}
security:
jwt-mode: jwks
session-mode: claims
issuer: ${JWT_ISSUER:auth-service}
audience: ${JWT_AUDIENCE:lastleg-api}
jwk-set-uri: ${JWT_JWK_SET_URI:http://127.0.0.1:9100/api/auth/.well-known/jwks.json}
@@ -0,0 +1,259 @@
-- This script is run through warehouse-overseas-service's datasource only.
-- The datasource guard requires the selected schema to be baiyue_warehouse_overseas;
-- database creation and USE are intentionally forbidden here so a miswired
-- service account cannot create or switch schemas during startup.
CREATE TABLE IF NOT EXISTS wh_location (
id BIGINT UNSIGNED NOT NULL COMMENT '库位ID(雪花ID)',
customer_id BIGINT UNSIGNED NOT NULL COMMENT '数据所属租户ID',
warehouse_id BIGINT UNSIGNED NOT NULL COMMENT '所属仓库ID',
zone_code VARCHAR(50) NOT NULL COMMENT '仓库分区编码',
location_code VARCHAR(64) NOT NULL COMMENT '仓库内唯一库位编码',
location_type VARCHAR(32) NOT NULL COMMENT '库位类型',
max_load_kg DECIMAL(18,3) NOT NULL COMMENT '最大承重(千克)',
status VARCHAR(20) NOT NULL DEFAULT 'ACTIVE' COMMENT '库位状态:ACTIVE启用、INACTIVE停用',
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_location_code (customer_id, warehouse_id, location_code, deleted),
KEY idx_location_query (customer_id, warehouse_id, zone_code, status, deleted)
) ENGINE=InnoDB COMMENT='仓库库位表:按租户和仓库维护库位及承重信息';
CREATE TABLE IF NOT EXISTS wh_pallet (
id BIGINT UNSIGNED NOT NULL COMMENT '托盘ID(雪花ID)',
customer_id BIGINT UNSIGNED NOT NULL COMMENT '数据所属租户ID',
warehouse_id BIGINT UNSIGNED NOT NULL COMMENT '所属仓库ID',
location_id BIGINT UNSIGNED DEFAULT NULL COMMENT '同仓库内绑定的库位ID,可为空',
pallet_code VARCHAR(128) NOT NULL COMMENT '仓库内唯一托盘编码',
pallet_type VARCHAR(32) NOT NULL COMMENT '托盘类型',
capacity_cbm DECIMAL(18,4) NOT NULL DEFAULT 2.0000 COMMENT '托盘容量(立方米)',
unit_price DECIMAL(18,2) NOT NULL DEFAULT 0.00 COMMENT '托盘单价',
status VARCHAR(20) NOT NULL DEFAULT 'IDLE' COMMENT '托盘状态:IDLE闲置、OCCUPIED占用、SCRAPPED废弃',
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_pallet_code (customer_id, warehouse_id, pallet_code, deleted),
KEY idx_pallet_query (customer_id, warehouse_id, status, deleted),
KEY idx_pallet_location (customer_id, warehouse_id, location_id, deleted)
) ENGINE=InnoDB COMMENT='仓库托盘表:按租户和仓库维护托盘、容量、单价及状态';
CREATE TABLE IF NOT EXISTS wh_resource_lock (
id BIGINT UNSIGNED NOT NULL COMMENT '主键ID',
customer_id BIGINT UNSIGNED NOT NULL COMMENT '客户租户ID',
warehouse_id BIGINT UNSIGNED NOT NULL COMMENT '仓库ID',
pallet_id BIGINT UNSIGNED NOT NULL COMMENT '托盘ID',
location_id BIGINT UNSIGNED NOT NULL COMMENT '库位ID',
business_type VARCHAR(50) NOT NULL COMMENT '业务类型',
business_id VARCHAR(100) NOT NULL COMMENT '业务记录ID',
reservation_key VARCHAR(160) NOT NULL COMMENT '资源预留幂等键',
status VARCHAR(20) NOT NULL DEFAULT 'ACTIVE' COMMENT '资源锁状态',
locked_at DATETIME(3) NOT NULL COMMENT '锁定时间',
released_at DATETIME(3) DEFAULT NULL COMMENT '释放时间',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间',
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间',
PRIMARY KEY (id),
UNIQUE KEY uk_resource_lock_reservation (customer_id, reservation_key),
KEY idx_resource_lock_pallet (customer_id, warehouse_id, pallet_id, status),
KEY idx_resource_lock_location (customer_id, warehouse_id, location_id, status),
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 共同校验。
-- 修复既有数据库中因历史脚本编码错误或英文初始化产生的表与字段注释。
ALTER TABLE wh_location COMMENT='仓库库位表:按租户和仓库维护库位及承重信息';
ALTER TABLE wh_location
MODIFY COLUMN id BIGINT UNSIGNED NOT NULL COMMENT '库位ID(雪花ID)',
MODIFY COLUMN customer_id BIGINT UNSIGNED NOT NULL COMMENT '数据所属租户ID',
MODIFY COLUMN warehouse_id BIGINT UNSIGNED NOT NULL COMMENT '所属仓库ID',
MODIFY COLUMN zone_code VARCHAR(50) NOT NULL COMMENT '仓库分区编码',
MODIFY COLUMN location_code VARCHAR(64) NOT NULL COMMENT '仓库内唯一库位编码',
MODIFY COLUMN location_type VARCHAR(32) NOT NULL COMMENT '库位类型',
MODIFY COLUMN max_load_kg DECIMAL(18,3) NOT NULL COMMENT '最大承重(千克)',
MODIFY COLUMN status VARCHAR(20) NOT NULL DEFAULT 'ACTIVE' COMMENT '库位状态:ACTIVE启用、INACTIVE停用',
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 '乐观锁版本号';
CREATE TABLE IF NOT EXISTS wh_pallet_business_binding (
id BIGINT NOT NULL COMMENT '主键ID', customer_id BIGINT NOT NULL COMMENT '客户租户ID', warehouse_id BIGINT NOT NULL COMMENT '仓库ID',
business_type VARCHAR(32) NOT NULL COMMENT '业务类型', business_id VARCHAR(128) NOT NULL COMMENT '绑定业务记录ID',
pallet_id BIGINT NOT NULL COMMENT '托盘ID', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间',
PRIMARY KEY (id), UNIQUE KEY uk_wh_pallet_binding (customer_id,warehouse_id,business_type,business_id),
UNIQUE KEY uk_wh_pallet_binding_pallet (pallet_id)
) 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)',
MODIFY COLUMN customer_id BIGINT UNSIGNED NOT NULL COMMENT '数据所属租户ID',
MODIFY COLUMN warehouse_id BIGINT UNSIGNED NOT NULL COMMENT '所属仓库ID',
MODIFY COLUMN location_id BIGINT UNSIGNED NULL COMMENT '同仓库内绑定的库位ID,可为空',
MODIFY COLUMN pallet_code VARCHAR(128) NOT NULL COMMENT '仓库内唯一托盘编码',
MODIFY COLUMN pallet_type VARCHAR(32) NOT NULL COMMENT '托盘类型',
MODIFY COLUMN capacity_cbm DECIMAL(18,4) NOT NULL DEFAULT 2.0000 COMMENT '托盘容量(立方米)',
MODIFY COLUMN unit_price DECIMAL(18,2) NOT NULL DEFAULT 0.00 COMMENT '托盘单价',
MODIFY COLUMN status VARCHAR(20) NOT NULL DEFAULT 'IDLE' COMMENT '托盘状态:IDLE闲置、OCCUPIED占用、SCRAPPED废弃',
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 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 '乐观锁版本号';
@@ -0,0 +1,29 @@
package com.baiyue.warehouse.config;
import org.junit.jupiter.api.Test;
import org.springframework.mock.env.MockEnvironment;
import static org.assertj.core.api.Assertions.assertThatCode;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
class WarehouseDatabaseGuardConfigurationTest {
@Test
void acceptsDedicatedOverseasWarehouseDatabase() {
MockEnvironment environment = new MockEnvironment()
.withProperty("spring.datasource.url",
"jdbc:mysql://127.0.0.1:3306/baiyue_warehouse_overseas?serverTimezone=Asia/Shanghai");
assertThatCode(() -> WarehouseDatabaseGuardConfiguration.warehouseDatabaseGuard(environment))
.doesNotThrowAnyException();
}
@Test
void rejectsAnyOtherDatabaseBeforeSchemaInitialization() {
MockEnvironment environment = new MockEnvironment()
.withProperty("spring.datasource.url", "jdbc:mysql://127.0.0.1:3306/baiyue_warehouse");
assertThatThrownBy(() -> WarehouseDatabaseGuardConfiguration.warehouseDatabaseGuard(environment))
.isInstanceOf(IllegalStateException.class)
.hasMessageContaining("baiyue_warehouse_overseas");
}
}
@@ -0,0 +1,69 @@
package com.baiyue.warehouse.controller;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.common.security.AuthenticatedUser;
import com.baiyue.warehouse.entity.WarehouseLocationEntity;
import com.baiyue.warehouse.service.WarehouseResourceService;
import org.junit.jupiter.api.Test;
import org.springframework.test.util.ReflectionTestUtils;
import java.util.Set;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
class WarehouseResourceControllerTest {
private final WarehouseResourceService service = mock(WarehouseResourceService.class);
@Test
void merchantTenantAlwaysComesFromAuthenticatedUser() {
MerchantWarehouseResourceController controller = merchantController();
AuthenticatedUser merchant = user(10001L, "MERCHANT");
WarehouseLocationEntity expected = new WarehouseLocationEntity();
when(service.location(10001L, 20001L, 30001L)).thenReturn(expected);
controller.location(merchant, 20001L, 30001L);
verify(service).location(10001L, 20001L, 30001L);
}
@Test
void platformApiRejectsNonPlatformIdentity() {
PlatformWarehouseResourceController controller = platformController();
assertThatThrownBy(() -> controller.location(user(10001L, "MERCHANT"),
10002L, 20001L, 30001L))
.isInstanceOf(BizException.class)
.hasMessageContaining("平台端");
}
@Test
void platformApiUsesExplicitTargetTenant() {
PlatformWarehouseResourceController controller = platformController();
WarehouseLocationEntity expected = new WarehouseLocationEntity();
when(service.location(10002L, 20001L, 30001L)).thenReturn(expected);
controller.location(user(1L, "PLATFORM"), 10002L, 20001L, 30001L);
verify(service).location(10002L, 20001L, 30001L);
}
private static AuthenticatedUser user(long customerId, String appCode) {
return new AuthenticatedUser(1L, customerId, customerId, 1L,
"tester", appCode, "WEB", Set.of());
}
private MerchantWarehouseResourceController merchantController() {
MerchantWarehouseResourceController controller = new MerchantWarehouseResourceController();
ReflectionTestUtils.setField(controller, "service", service);
return controller;
}
private PlatformWarehouseResourceController platformController() {
PlatformWarehouseResourceController controller = new PlatformWarehouseResourceController();
ReflectionTestUtils.setField(controller, "service", service);
return controller;
}
}
@@ -0,0 +1,98 @@
package com.baiyue.warehouse.schema;
import org.junit.jupiter.api.Test;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.List;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
class SchemaChineseCommentContractTest {
private static final Pattern TABLE = Pattern.compile(
"(?is)CREATE\\s+TABLE\\s+IF\\s+NOT\\s+EXISTS\\s+(wh_[a-z0-9_]+)"
+ "\\s*\\((.*?)\\)\\s*ENGINE\\s*=\\s*InnoDB\\s+COMMENT\\s*=\\s*'([^']*)'\\s*;");
private static final Pattern CHINESE = Pattern.compile("[\\u4e00-\\u9fff]");
@Test
void allWarehouseTablesAndColumnsHaveChineseCommentsAndMigrationCoverage() throws Exception {
String baseline = Files.readString(
Path.of("src", "main", "resources", "db", "warehouse.sql"), StandardCharsets.UTF_8);
String incremental = Files.readString(
Path.of("..", "scripts", "db", "incremental.sql"), StandardCharsets.UTF_8);
List<TableDefinition> tables = parseAndAssert(baseline, "仓储基线 SQL");
assertEquals(7, tables.size(), "仓储表数量变化时必须同步表索引和备注迁移");
parseAndAssert(incremental, "统一增量 SQL");
int marker = incremental.indexOf("2026-08-18 / 025 Chinese table and column comments");
assertTrue(marker >= 0, "统一增量 SQL 缺少 025 中文备注迁移");
String migration = incremental.substring(marker);
for (TableDefinition table : tables) {
assertTrue(migration.contains("table_name='" + table.name() + "'"),
"025 备注迁移缺少表: " + table.name());
for (String column : table.columns()) {
assertTrue(migration.contains("table_name='" + table.name()
+ "' AND column_name='" + column + "'"),
"025 备注迁移缺少字段: " + table.name() + "." + column);
}
}
}
private static List<TableDefinition> parseAndAssert(String sql, String source) {
List<TableDefinition> tables = new ArrayList<>();
Matcher matcher = TABLE.matcher(sql);
while (matcher.find()) {
String table = matcher.group(1);
assertTrue(CHINESE.matcher(matcher.group(3)).find(), source + " 表备注必须包含中文: " + table);
List<String> columns = new ArrayList<>();
for (String definition : splitTopLevel(matcher.group(2))) {
String normalized = definition.stripLeading().toUpperCase();
if (normalized.matches("^(PRIMARY|UNIQUE|KEY|CONSTRAINT|FOREIGN|CHECK)\\b.*")) {
continue;
}
String column = definition.stripLeading().split("\\s+", 2)[0].replace("`", "");
columns.add(column);
assertTrue(Pattern.compile("(?is)\\bCOMMENT\\s+'[^']*[\\u4e00-\\u9fff][^']*'")
.matcher(definition).find(),
source + " 字段备注必须包含中文: " + table + "." + column);
}
assertFalse(columns.isEmpty(), source + " 表定义没有字段: " + table);
tables.add(new TableDefinition(table, columns));
}
assertFalse(tables.isEmpty(), source + " 未解析到仓储表");
return tables;
}
private static List<String> splitTopLevel(String body) {
List<String> definitions = new ArrayList<>();
int start = 0;
int parentheses = 0;
boolean quoted = false;
for (int i = 0; i < body.length(); i++) {
char current = body.charAt(i);
if (current == '\'' && (i == 0 || body.charAt(i - 1) != '\\')) {
quoted = !quoted;
} else if (!quoted && current == '(') {
parentheses++;
} else if (!quoted && current == ')') {
parentheses--;
} else if (!quoted && parentheses == 0 && current == ',') {
definitions.add(body.substring(start, i).trim());
start = i + 1;
}
}
definitions.add(body.substring(start).trim());
return definitions.stream().filter(value -> !value.isEmpty()).toList();
}
private record TableDefinition(String name, List<String> columns) {
}
}
@@ -0,0 +1,24 @@
package com.baiyue.warehouse.schema;
import org.junit.jupiter.api.Test;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import static org.assertj.core.api.Assertions.assertThat;
class WarehouseResourceLockSchemaTest {
@Test
void schemaContainsIdempotentResourceLockContract() throws IOException {
String sql;
try (var stream = getClass().getResourceAsStream("/db/warehouse.sql")) {
assertThat(stream).isNotNull();
sql = new String(stream.readAllBytes(), StandardCharsets.UTF_8);
}
assertThat(sql).contains("CREATE TABLE IF NOT EXISTS wh_resource_lock");
assertThat(sql).contains("UNIQUE KEY uk_resource_lock_reservation (customer_id, reservation_key)");
assertThat(sql).contains("KEY idx_resource_lock_pallet (customer_id, warehouse_id, pallet_id, status)");
assertThat(sql).contains("KEY idx_resource_lock_location (customer_id, warehouse_id, location_id, status)");
}
}
@@ -0,0 +1,32 @@
package com.baiyue.warehouse.security;
import org.junit.jupiter.api.Test;
import org.springframework.http.HttpStatus;
import org.springframework.web.server.ResponseStatusException;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatCode;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
class InternalServiceTokenGuardTest {
private final InternalServiceTokenGuard guard =
new InternalServiceTokenGuard("phase1-local-internal-token-at-least-32-bytes");
@Test
void acceptsConfiguredToken() {
assertThatCode(() -> guard.verify("phase1-local-internal-token-at-least-32-bytes"))
.doesNotThrowAnyException();
}
@Test
void rejectsMissingOrWrongToken() {
assertUnauthorized(null);
assertUnauthorized("wrong");
}
private void assertUnauthorized(String token) {
assertThatThrownBy(() -> guard.verify(token))
.isInstanceOfSatisfying(ResponseStatusException.class,
error -> assertThat(error.getStatusCode()).isEqualTo(HttpStatus.UNAUTHORIZED));
}
}
@@ -0,0 +1,59 @@
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.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.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.doAnswer;
import static org.mockito.Mockito.verify;
@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");
}
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());
}
}
@@ -0,0 +1,263 @@
package com.baiyue.warehouse.service;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.warehouse.dto.WarehouseResourceLockRequests;
import com.baiyue.warehouse.entity.WarehouseLocationEntity;
import com.baiyue.warehouse.entity.WarehousePalletEntity;
import com.baiyue.warehouse.entity.WarehouseResourceLockEntity;
import com.baiyue.warehouse.repository.WarehouseResourceLockRepository;
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 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.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class)
class WarehouseResourceLockServiceTest {
private static final long TENANT = 101L;
private static final long WAREHOUSE = 201L;
private static final long PALLET = 301L;
private static final long LOCATION = 401L;
@Mock WarehouseResourceLockRepository repository;
private WarehouseResourceLockService service;
@BeforeEach
void setUp() {
service = new WarehouseResourceLockService();
ReflectionTestUtils.setField(service, "repository", repository);
}
@Test
void atomicallyOccupiesIdlePalletAndLocation() {
var request = request("reserve-1");
when(repository.findPallet(TENANT, WAREHOUSE, PALLET)).thenReturn(pallet("IDLE"));
when(repository.findLocation(TENANT, WAREHOUSE, LOCATION)).thenReturn(location("IDLE"));
when(repository.occupyPallet(TENANT, WAREHOUSE, PALLET, LOCATION)).thenReturn(1);
when(repository.occupyLocation(TENANT, WAREHOUSE, LOCATION)).thenReturn(1);
doAnswer(call -> {
((WarehouseResourceLockEntity) call.getArgument(0)).setId(501L);
return null;
}).when(repository).insert(any());
var result = service.reserve(request);
assertThat(result.lockId()).isEqualTo(501L);
assertThat(result.status()).isEqualTo("ACTIVE");
ArgumentCaptor<WarehouseResourceLockEntity> lock = ArgumentCaptor.forClass(WarehouseResourceLockEntity.class);
verify(repository).insert(lock.capture());
assertThat(lock.getValue().getReservationKey()).isEqualTo("reserve-1");
assertThat(lock.getValue().getPalletId()).isEqualTo(PALLET);
assertThat(lock.getValue().getLocationId()).isEqualTo(LOCATION);
}
@Test
void duplicateReservationReturnsExistingLockWithoutMutatingResources() {
WarehouseResourceLockEntity existing = activeLock();
when(repository.findByReservationKey(TENANT, "reserve-1")).thenReturn(existing);
var result = service.reserve(request("reserve-1"));
assertThat(result.lockId()).isEqualTo(existing.getId());
verify(repository, never()).occupyPallet(TENANT, WAREHOUSE, PALLET, LOCATION);
verify(repository, never()).insert(any());
}
@Test
void concurrentDuplicateReturnsExistingAfterResourcesBecomeOccupied() {
WarehouseResourceLockEntity existing = activeLock();
when(repository.findByReservationKey(TENANT, "reserve-1")).thenReturn(null, existing);
when(repository.findPallet(TENANT, WAREHOUSE, PALLET)).thenReturn(pallet("OCCUPIED"));
when(repository.findLocation(TENANT, WAREHOUSE, LOCATION)).thenReturn(location("OCCUPIED"));
var result = service.reserve(request("reserve-1"));
assertThat(result.lockId()).isEqualTo(existing.getId());
verify(repository, never()).occupyPallet(TENANT, WAREHOUSE, PALLET, LOCATION);
}
@Test
void reservationKeyCannotBeReusedForDifferentResources() {
WarehouseResourceLockEntity existing = activeLock();
existing.setPalletId(999L);
when(repository.findByReservationKey(TENANT, "reserve-1")).thenReturn(existing);
assertThatThrownBy(() -> service.reserve(request("reserve-1")))
.isInstanceOf(BizException.class)
.hasMessageContaining("different resources");
}
@Test
void rejectsResourceOutsideTenantWarehouse() {
when(repository.findPallet(TENANT, WAREHOUSE, PALLET)).thenReturn(null);
when(repository.findLocation(TENANT, WAREHOUSE, LOCATION)).thenReturn(location("IDLE"));
assertThatThrownBy(() -> service.reserve(request("reserve-1")))
.isInstanceOf(BizException.class)
.hasMessageContaining("tenant warehouse");
verify(repository, never()).occupyPallet(TENANT, WAREHOUSE, PALLET, LOCATION);
}
@Test
void rejectsNonIdleResources() {
when(repository.findPallet(TENANT, WAREHOUSE, PALLET)).thenReturn(pallet("OCCUPIED"));
when(repository.findLocation(TENANT, WAREHOUSE, LOCATION)).thenReturn(location("IDLE"));
assertThatThrownBy(() -> service.reserve(request("reserve-1")))
.isInstanceOf(BizException.class)
.hasMessageContaining("Only IDLE");
}
@Test
void releaseRestoresBothResourcesAndClosesLock() {
WarehouseResourceLockEntity lock = activeLock();
when(repository.find(TENANT, lock.getId())).thenReturn(lock);
when(repository.findLocation(TENANT, WAREHOUSE, LOCATION)).thenReturn(location("OCCUPIED"));
when(repository.releasePallet(lock)).thenReturn(1);
when(repository.releaseLocation(lock)).thenReturn(1);
when(repository.close(lock, "RELEASED")).thenReturn(1);
var result = service.release(TENANT, lock.getId());
assertThat(result.status()).isEqualTo("RELEASED");
verify(repository).releasePallet(lock);
verify(repository).releaseLocation(lock);
}
@Test
void scrapDiscardsPalletAndReleasesLocation() {
WarehouseResourceLockEntity lock = activeLock();
when(repository.find(TENANT, lock.getId())).thenReturn(lock);
when(repository.findLocation(TENANT, WAREHOUSE, LOCATION)).thenReturn(location("OCCUPIED"));
when(repository.scrapPallet(lock)).thenReturn(1);
when(repository.releaseLocation(lock)).thenReturn(1);
when(repository.close(lock, "SCRAPPED")).thenReturn(1);
var result = service.scrap(TENANT, lock.getId());
assertThat(result.status()).isEqualTo("SCRAPPED");
verify(repository).scrapPallet(lock);
verify(repository).releaseLocation(lock);
}
@Test
void concurrentLocationClaimFailsWholeOperation() {
when(repository.findPallet(TENANT, WAREHOUSE, PALLET)).thenReturn(pallet("IDLE"));
when(repository.findLocation(TENANT, WAREHOUSE, LOCATION)).thenReturn(location("IDLE"));
when(repository.occupyPallet(TENANT, WAREHOUSE, PALLET, LOCATION)).thenReturn(1);
when(repository.occupyLocation(TENANT, WAREHOUSE, LOCATION)).thenReturn(0);
assertThatThrownBy(() -> service.reserve(request("reserve-1")))
.isInstanceOf(BizException.class)
.hasMessageContaining("no longer IDLE");
verify(repository, never()).insert(any());
}
@Test
void virtualLocationIsNeverOccupiedOrReleased() {
WarehouseLocationEntity virtual = location("ACTIVE");
virtual.setLocationType("VIRTUAL");
when(repository.findPallet(TENANT, WAREHOUSE, PALLET)).thenReturn(pallet("IDLE"));
when(repository.findLocation(TENANT, WAREHOUSE, LOCATION)).thenReturn(virtual);
when(repository.occupyPallet(TENANT, WAREHOUSE, PALLET, LOCATION)).thenReturn(1);
doAnswer(call -> {
((WarehouseResourceLockEntity) call.getArgument(0)).setId(502L);
return null;
}).when(repository).insert(any());
service.reserve(request("virtual-reserve"));
verify(repository, never()).occupyLocation(TENANT, WAREHOUSE, LOCATION);
}
@Test
void scrappingAtVirtualLocationKeepsLocationUntouched() {
WarehouseResourceLockEntity lock = activeLock();
WarehouseLocationEntity virtual = location("ACTIVE");
virtual.setLocationType("VIRTUAL");
when(repository.find(TENANT, lock.getId())).thenReturn(lock);
when(repository.findLocation(TENANT, WAREHOUSE, LOCATION)).thenReturn(virtual);
when(repository.scrapPalletAtVirtualLocation(lock)).thenReturn(1);
when(repository.close(lock, "SCRAPPED")).thenReturn(1);
var result = service.scrap(TENANT, lock.getId());
assertThat(result.status()).isEqualTo("SCRAPPED");
verify(repository).scrapPalletAtVirtualLocation(lock);
verify(repository, never()).releaseLocation(lock);
}
@Test
void scrapByPalletClosesItsActiveLockAndKeepsLocationReference() {
WarehousePalletEntity pallet = pallet("OCCUPIED");
pallet.setWarehouseId(WAREHOUSE);
pallet.setLocationId(LOCATION);
WarehouseResourceLockEntity lock = activeLock();
when(repository.findPallet(TENANT, PALLET)).thenReturn(pallet);
when(repository.findActiveByPallet(TENANT, PALLET)).thenReturn(lock);
when(repository.find(TENANT, lock.getId())).thenReturn(lock);
WarehouseLocationEntity virtual = location("ACTIVE");
virtual.setLocationType("VIRTUAL");
when(repository.findLocation(TENANT, WAREHOUSE, LOCATION)).thenReturn(virtual);
when(repository.scrapPalletAtVirtualLocation(lock)).thenReturn(1);
when(repository.close(lock, "SCRAPPED")).thenReturn(1);
var result = service.scrapByPallet(TENANT, PALLET);
assertThat(result.status()).isEqualTo("SCRAPPED");
assertThat(result.locationId()).isEqualTo(LOCATION);
verify(repository).scrapPalletAtVirtualLocation(lock);
}
@Test
void scrapByPalletIsIdempotentWhenAlreadyScrapped() {
WarehousePalletEntity pallet = pallet("SCRAPPED");
pallet.setWarehouseId(WAREHOUSE);
pallet.setLocationId(LOCATION);
when(repository.findPallet(TENANT, PALLET)).thenReturn(pallet);
assertThat(service.scrapByPallet(TENANT, PALLET).status()).isEqualTo("SCRAPPED");
verify(repository, never()).findActiveByPallet(TENANT, PALLET);
}
private static WarehouseResourceLockRequests.ReserveRequest request(String key) {
return new WarehouseResourceLockRequests.ReserveRequest(TENANT, WAREHOUSE, PALLET, LOCATION,
"INBOUND_PLAN_PALLET", "plan-pallet-1", key);
}
private static WarehousePalletEntity pallet(String status) {
WarehousePalletEntity entity = new WarehousePalletEntity();
entity.setStatus(status);
return entity;
}
private static WarehouseLocationEntity location(String status) {
WarehouseLocationEntity entity = new WarehouseLocationEntity();
entity.setStatus(status);
return entity;
}
private static WarehouseResourceLockEntity activeLock() {
WarehouseResourceLockEntity lock = new WarehouseResourceLockEntity();
lock.setId(501L);
lock.setCustomerId(TENANT);
lock.setWarehouseId(WAREHOUSE);
lock.setPalletId(PALLET);
lock.setLocationId(LOCATION);
lock.setBusinessType("INBOUND_PLAN_PALLET");
lock.setBusinessId("plan-pallet-1");
lock.setReservationKey("reserve-1");
lock.setStatus("ACTIVE");
return lock;
}
}
@@ -0,0 +1,260 @@
package com.baiyue.warehouse.service;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baiyue.common.core.exception.BizException;
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.repository.WarehouseResourceRepository;
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.math.BigDecimal;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.Mockito.doAnswer;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class)
class WarehouseResourceServiceTest {
private static final long TENANT = 10001L;
private static final long WAREHOUSE = 20001L;
@Mock
private WarehouseResourceRepository repository;
private WarehouseResourceService service;
@BeforeEach
void setUp() {
service = new WarehouseResourceService();
ReflectionTestUtils.setField(service, "repository", repository);
}
@Test
void resourceIdsUseMybatisSnowflakeAssignment() throws NoSuchFieldException {
assertThat(WarehouseLocationEntity.class.getDeclaredField("id").getAnnotation(TableId.class).type())
.isEqualTo(IdType.ASSIGN_ID);
assertThat(WarehousePalletEntity.class.getDeclaredField("id").getAnnotation(TableId.class).type())
.isEqualTo(IdType.ASSIGN_ID);
}
@Test
void createsLocationWithSnowflakeAssignedByMapper() {
when(repository.warehouseOwned(TENANT, WAREHOUSE)).thenReturn(true);
doAnswer(invocation -> {
((WarehouseLocationEntity) invocation.getArgument(0)).setId(2089000000000000001L);
return null;
}).when(repository).insertLocation(org.mockito.ArgumentMatchers.any());
Long id = service.createLocation(TENANT, WAREHOUSE, locationRequest("A-01"));
assertThat(id).isEqualTo(2089000000000000001L);
ArgumentCaptor<WarehouseLocationEntity> captor = ArgumentCaptor.forClass(WarehouseLocationEntity.class);
verify(repository).insertLocation(captor.capture());
assertThat(captor.getValue().getCustomerId()).isEqualTo(TENANT);
assertThat(captor.getValue().getWarehouseId()).isEqualTo(WAREHOUSE);
}
@Test
void rejectsResourceAccessWhenWarehouseDoesNotBelongToTenant() {
when(repository.warehouseOwned(TENANT, WAREHOUSE)).thenReturn(false);
assertThatThrownBy(() -> service.createLocation(TENANT, WAREHOUSE, locationRequest("A-01")))
.isInstanceOf(BizException.class)
.hasMessageContaining("仓库不存在");
verify(repository, never()).insertLocation(org.mockito.ArgumentMatchers.any());
}
@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")))
.isInstanceOf(BizException.class)
.hasMessageContaining("库位编码已存在");
}
@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);
doAnswer(invocation -> {
((WarehousePalletEntity) invocation.getArgument(0)).setId(2089000000000000002L);
return null;
}).when(repository).insertPallet(org.mockito.ArgumentMatchers.any());
Long id = service.createPallet(TENANT, WAREHOUSE,
new PalletRequests.SaveRequest("PLT-01", "STANDARD", null,
new BigDecimal("12.50"), "IDLE", 30001L, null));
assertThat(id).isEqualTo(2089000000000000002L);
ArgumentCaptor<WarehousePalletEntity> captor = ArgumentCaptor.forClass(WarehousePalletEntity.class);
verify(repository).insertPallet(captor.capture());
assertThat(captor.getValue().getCapacityCbm()).isEqualByComparingTo("2.0000");
assertThat(captor.getValue().getLocationId()).isEqualTo(30001L);
}
@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,
new PalletRequests.SaveRequest("PLT-01", "STANDARD", null,
BigDecimal.ONE, "IDLE", 30002L, null)))
.isInstanceOf(BizException.class)
.hasMessageContaining("库位不存在");
verify(repository, never()).insertPallet(org.mockito.ArgumentMatchers.any());
}
@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);
assertThatThrownBy(() -> service.deleteLocation(TENANT, WAREHOUSE, 30001L))
.isInstanceOf(BizException.class)
.hasMessageContaining("仍绑定托盘");
verify(repository, never()).deleteLocation(30001L);
}
@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);
assertThatThrownBy(() -> service.deletePallet(TENANT, WAREHOUSE, 40001L))
.isInstanceOf(BizException.class)
.hasMessageContaining("占用中的托盘");
verify(repository, never()).deletePallet(40001L);
}
@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);
assertThatThrownBy(() -> service.deletePallet(TENANT, WAREHOUSE, 40003L))
.isInstanceOf(BizException.class)
.hasMessageContaining("cannot be reused");
verify(repository, never()).deletePallet(40003L);
}
@Test
void rejectsUnknownPalletStatus() {
when(repository.findWarehouse(TENANT, WAREHOUSE)).thenReturn(warehouse());
assertThatThrownBy(() -> service.createPallet(TENANT, WAREHOUSE,
new PalletRequests.SaveRequest("PLT-01", "STANDARD", null,
BigDecimal.ONE, "BROKEN", null, null)))
.isInstanceOf(BizException.class)
.hasMessageContaining("IDLE、OCCUPIED、SCRAPPED");
}
private static LocationRequests.SaveRequest locationRequest(String code) {
return new LocationRequests.SaveRequest("A", code, "STORAGE",
new BigDecimal("1000.000"), "ACTIVE", null);
}
@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,
new PalletRequests.SaveRequest(null, "STANDARD", null, BigDecimal.ONE, "IDLE", null, null));
ArgumentCaptor<WarehousePalletEntity> captor = ArgumentCaptor.forClass(WarehousePalletEntity.class);
verify(repository).insertPallet(captor.capture());
assertThat(captor.getValue().getPalletCode()).matches("PLT-US-LAX-01-[0-9]{8}-[0-9]+");
assertThat(captor.getValue().getLocationId()).isEqualTo(30003L);
assertThat(captor.getValue().getId()).isNotNull();
}
@Test
void defaultVirtualLocationIsInsertedIdempotentlyForAnExistingWarehouse() {
when(repository.findWarehouse(TENANT, WAREHOUSE)).thenReturn(warehouse());
WarehouseLocationEntity resolved = new WarehouseLocationEntity();
resolved.setId(30004L);
resolved.setLocationType("VIRTUAL");
resolved.setStatus("ACTIVE");
when(repository.findLocationByCode(TENANT, WAREHOUSE, "LOC-US-LAX-01-DEFAULT"))
.thenReturn(null, resolved);
WarehouseLocationEntity result = service.ensureDefaultVirtualLocation(TENANT, WAREHOUSE);
assertThat(result.getId()).isEqualTo(30004L);
ArgumentCaptor<WarehouseLocationEntity> location = ArgumentCaptor.forClass(WarehouseLocationEntity.class);
verify(repository).insertDefaultVirtualLocationIgnore(location.capture());
assertThat(location.getValue().getLocationCode()).isEqualTo("LOC-US-LAX-01-DEFAULT");
assertThat(location.getValue().getLocationType()).isEqualTo("VIRTUAL");
assertThat(location.getValue().getStatus()).isEqualTo("ACTIVE");
}
@Test
void rejectsAConflictingDefaultLocationCode() {
when(repository.findWarehouse(TENANT, WAREHOUSE)).thenReturn(warehouse());
WarehouseLocationEntity conflict = new WarehouseLocationEntity();
conflict.setLocationType("STORAGE");
conflict.setStatus("ACTIVE");
when(repository.findLocationByCode(TENANT, WAREHOUSE, "LOC-US-LAX-01-DEFAULT"))
.thenReturn(conflict);
assertThatThrownBy(() -> service.ensureDefaultVirtualLocation(TENANT, WAREHOUSE))
.isInstanceOf(BizException.class)
.hasMessageContaining("Default virtual location code");
verify(repository, never()).insertDefaultVirtualLocationIgnore(org.mockito.ArgumentMatchers.any());
}
@Test
void scrappedPalletCannotBeReactivated() {
when(repository.warehouseOwned(TENANT, WAREHOUSE)).thenReturn(true);
WarehousePalletEntity existing = new WarehousePalletEntity();
existing.setId(40002L);
existing.setPalletCode("PLT-OLD");
existing.setStatus("SCRAPPED");
when(repository.findPallet(TENANT, WAREHOUSE, 40002L)).thenReturn(existing);
assertThatThrownBy(() -> service.updatePallet(TENANT, WAREHOUSE, 40002L,
new PalletRequests.SaveRequest(null, "STANDARD", null, BigDecimal.ONE, "IDLE", null, null)))
.isInstanceOf(BizException.class)
.hasMessageContaining("cannot be reused");
}
private static WarehouseEntity warehouse() {
WarehouseEntity warehouse = new WarehouseEntity();
warehouse.setId(WAREHOUSE);
warehouse.setCustomerId(TENANT);
warehouse.setWarehouseCode("US-LAX-01");
return warehouse;
}
}