This commit is contained in:
2026-08-13 11:57:33 +08:00
parent 5a48992d78
commit 16dc03a2e2
102 changed files with 3656 additions and 120 deletions
@@ -7,7 +7,7 @@ import org.springframework.boot.autoconfigure.SpringBootApplication;
/**
* 仓库微服务启动入口,启用 Spring Boot 自动配置并扫描仓库服务的 MyBatis Mapper。
*/
@MapperScan("com.baiyue.warehouse.mapper")
@MapperScan({"com.baiyue.warehouse.mapper", "com.baiyue.warehouse.domesticcontainer.mapper"})
@SpringBootApplication(scanBasePackages = "com.baiyue")
public class BaiyueWarehouseApplication {
public static void main(String[] args) { SpringApplication.run(BaiyueWarehouseApplication.class, args); }
@@ -15,11 +15,11 @@ import org.springframework.web.bind.annotation.*;
public class PlatformCustomerOverseasStorageFeeController {
@Resource private OverseasStorageFeeService service;
@GetMapping
@PreAuthorize("hasAuthority('platform:customer:view')")
@PreAuthorize("hasAnyAuthority('platform:customer:list','platform:customer:view')")
public ApiResponse<OverseasStorageFeeDtos.View> get(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable long tenantId) { return ApiResponse.success(service.detailByTenant(tenantId)); }
@PutMapping
@PreAuthorize("hasAuthority('platform:customer:edit')")
@PreAuthorize("hasAnyAuthority('platform:overseas-storage-fee:edit','platform:customer:edit')")
public ApiResponse<Void> put(@AuthenticationPrincipal AuthenticatedUser user, @PathVariable long tenantId,
@Valid @RequestBody OverseasStorageFeeDtos.UpdateRequest request) {
if (request.customerId() != tenantId) {
@@ -0,0 +1,43 @@
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.SupplierDtos.*;
import com.baiyue.warehouse.entity.SupplierEntity;
import com.baiyue.warehouse.service.SupplierService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.annotation.Resource;
import jakarta.validation.Valid;
import jakarta.validation.constraints.Min;
import org.springdoc.core.annotations.ParameterObject;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.*;
@Validated
@RestController
@Tag(name = "平台供应商")
@RequestMapping("/api/platform/v1/suppliers")
public class PlatformSupplierController {
@Resource private SupplierService service;
@GetMapping @PreAuthorize("hasAuthority('platform:supplier:list')")
@Operation(summary = "分页查询供应商")
public ApiResponse<PageResult<SupplierEntity>> page(@AuthenticationPrincipal AuthenticatedUser user, @RequestParam(required=false) @Min(0) Long tenantId, @Valid @ParameterObject PageQuery query) { requirePlatform(user); requireScope(tenantId); return ApiResponse.success(service.page(query)); }
@GetMapping("/stats") @PreAuthorize("hasAuthority('platform:supplier:list')")
public ApiResponse<Stats> stats(@AuthenticationPrincipal AuthenticatedUser user, @RequestParam(required=false) @Min(0) Long tenantId) { requirePlatform(user); requireScope(tenantId); return ApiResponse.success(service.stats()); }
@GetMapping("/{id}") @PreAuthorize("hasAuthority('platform:supplier:list')")
public ApiResponse<SupplierEntity> detail(@AuthenticationPrincipal AuthenticatedUser user, @RequestParam @Min(0) long tenantId, @PathVariable long id) { requirePlatform(user); requireScope(tenantId); return ApiResponse.success(service.detail(id)); }
@PostMapping @PreAuthorize("hasAuthority('platform:supplier:create')")
public ApiResponse<Long> create(@AuthenticationPrincipal AuthenticatedUser user, @RequestParam @Min(0) long tenantId, @Valid @RequestBody SaveRequest request) { requirePlatform(user); requireScope(tenantId); return ApiResponse.success(service.create(request)); }
@PutMapping("/{id}") @PreAuthorize("hasAuthority('platform:supplier:edit')")
public ApiResponse<Void> update(@AuthenticationPrincipal AuthenticatedUser user, @RequestParam @Min(0) long tenantId, @PathVariable long id, @Valid @RequestBody SaveRequest request) { requirePlatform(user); requireScope(tenantId); service.update(id, request); return ApiResponse.success(null); }
@PatchMapping("/{id}/status") @PreAuthorize("hasAuthority('platform:supplier:status')")
public ApiResponse<Void> status(@AuthenticationPrincipal AuthenticatedUser user, @RequestParam @Min(0) long tenantId, @PathVariable long id, @Valid @RequestBody StatusRequest request) { requirePlatform(user); requireScope(tenantId); service.changeStatus(id, request); return ApiResponse.success(null); }
private static void requirePlatform(AuthenticatedUser u) { if (u == null || !"PLATFORM".equalsIgnoreCase(u.appCode()) || !Long.valueOf(0L).equals(u.tenantId())) throw new BizException(ResultCode.FORBIDDEN, "仅允许平台端访问"); }
private static void requireScope(Long id) { if (id != null && id != 0L) throw new BizException(ResultCode.BAD_REQUEST, "平台公共供应商作用域必须为0"); }
}
@@ -0,0 +1,68 @@
package com.baiyue.warehouse.domesticcontainer.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.domesticcontainer.dto.DomesticContainerDtos;
import com.baiyue.warehouse.domesticcontainer.service.DomesticContainerService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.annotation.Resource;
import jakarta.validation.Valid;
import jakarta.validation.constraints.Min;
import org.springdoc.core.annotations.ParameterObject;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.*;
@Validated
@RestController
@RequestMapping("/api/platform/v1/domestic-containers")
@Tag(name = "Platform domestic full-container business / 平台国内仓整柜业务")
public class PlatformDomesticContainerController {
@Resource private DomesticContainerService service;
@GetMapping("/inbounds")
@PreAuthorize("hasAuthority('platform:domestic-container:list')")
@Operation(summary = "分页查询整柜入库单")
public ApiResponse<PageResult<DomesticContainerDtos.OrderView>> inbounds(@AuthenticationPrincipal AuthenticatedUser user,
@Valid @ParameterObject DomesticContainerDtos.OrderQuery query) { requirePlatform(user); return ApiResponse.success(service.inboundPage(query)); }
@PostMapping("/inbounds")
@PreAuthorize("hasAuthority('platform:domestic-container:inbound:create')")
@Operation(summary = "新建整柜入库单", description = "COMPLETED 同事务增加库存并写入不可变交易流水;PENDING 仅创建待入库单。")
public ApiResponse<DomesticContainerDtos.CreateResult> createInbound(@AuthenticationPrincipal AuthenticatedUser user,
@Valid @RequestBody DomesticContainerDtos.CreateInboundRequest request) { requirePlatform(user); return ApiResponse.success(service.createInbound(request)); }
@GetMapping("/outbounds")
@PreAuthorize("hasAuthority('platform:domestic-container:list')")
@Operation(summary = "分页查询整柜出库单")
public ApiResponse<PageResult<DomesticContainerDtos.OrderView>> outbounds(@AuthenticationPrincipal AuthenticatedUser user,
@Valid @ParameterObject DomesticContainerDtos.OrderQuery query) { requirePlatform(user); return ApiResponse.success(service.outboundPage(query)); }
@PostMapping("/outbounds")
@PreAuthorize("hasAuthority('platform:domestic-container:outbound:create')")
@Operation(summary = "新建整柜出库单", description = "COMPLETED 锁定库存并原子扣减,支持部分出库和全量出库;PENDING 不扣减库存。")
public ApiResponse<DomesticContainerDtos.CreateResult> createOutbound(@AuthenticationPrincipal AuthenticatedUser user,
@Valid @RequestBody DomesticContainerDtos.CreateOutboundRequest request) { requirePlatform(user); return ApiResponse.success(service.createOutbound(request)); }
@GetMapping("/inventories")
@PreAuthorize("hasAuthority('platform:domestic-container:list')")
@Operation(summary = "分页查询整柜库存和筛选范围汇总")
public ApiResponse<DomesticContainerDtos.InventoryPage> inventories(@AuthenticationPrincipal AuthenticatedUser user,
@Valid @ParameterObject DomesticContainerDtos.InventoryQuery query) { requirePlatform(user); return ApiResponse.success(service.inventoryPageWithSummary(query)); }
@GetMapping("/inventories/{inventoryId}")
@PreAuthorize("hasAuthority('platform:domestic-container:list')")
@Operation(summary = "查询整柜库存详情和交易记录")
public ApiResponse<DomesticContainerDtos.DetailView> detail(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable @Min(1) long inventoryId) { requirePlatform(user); return ApiResponse.success(service.detail(inventoryId)); }
private static void requirePlatform(AuthenticatedUser user) {
if (user == null || !"PLATFORM".equalsIgnoreCase(user.appCode()) || user.tenantId() == null || user.tenantId() != 0L)
throw new BizException(ResultCode.FORBIDDEN, "仅允许平台端访问");
}
}
@@ -0,0 +1,95 @@
package com.baiyue.warehouse.domesticcontainer.dto;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.DecimalMin;
import jakarta.validation.constraints.Digits;
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.Positive;
import jakarta.validation.constraints.Size;
import java.math.BigDecimal;
import java.time.LocalDateTime;
import java.util.List;
public final class DomesticContainerDtos {
private DomesticContainerDtos() {
}
public record OrderQuery(@Min(1) long page, @Min(1) @Max(200) long size,
@Positive Long warehouseId, @Size(max = 40) String orderNo,
@Size(max = 32) String containerNo, @Size(max = 128) String cargoName,
@Pattern(regexp = "PENDING|COMPLETED") String status) {
public OrderQuery {
if (page == 0) page = 1;
if (size == 0) size = 20;
}
}
public record InventoryQuery(@Min(1) long page, @Min(1) @Max(200) long size,
@Positive Long warehouseId, @Size(max = 32) String containerNo,
@Size(max = 128) String cargoName,
@Pattern(regexp = "IN_STOCK|PARTIALLY_OUTBOUND|OUTBOUND") String status) {
public InventoryQuery {
if (page == 0) page = 1;
if (size == 0) size = 20;
}
}
public record CreateInboundRequest(
@NotNull @Positive Long warehouseId,
@NotBlank @Size(max = 128) String cargoName,
@NotBlank @Pattern(regexp = "[A-Za-z0-9-]{4,32}") String containerNo,
@NotNull @Min(1) @Max(999999999) Integer boxCount,
@NotNull @DecimalMin("0.0001") @Digits(integer = 12, fraction = 4) BigDecimal volumeCbm,
@NotNull LocalDateTime inboundTime,
@NotBlank @Pattern(regexp = "PENDING|COMPLETED") String status,
@Size(max = 500) String remark,
@NotBlank @Size(max = 128) String idempotencyKey) {
}
public record CreateOutboundRequest(
@NotNull @Positive Long warehouseId,
@NotBlank @Pattern(regexp = "[A-Za-z0-9-]{4,32}") String containerNo,
@NotNull @Min(1) @Max(999999999) Integer boxCount,
@NotNull LocalDateTime outboundTime,
@NotBlank @Pattern(regexp = "PENDING|COMPLETED") String status,
@Size(max = 500) String remark,
@NotBlank @Size(max = 128) String idempotencyKey,
@Schema(description = "库存乐观锁版本;待出库不扣减时可不传") @Min(0) Integer inventoryVersion) {
}
public record CreateResult(Long id, String orderNo, boolean replayed) {
}
public record OrderView(Long id, String businessType, String orderNo, Long warehouseId,
String warehouseName, String containerNo, String cargoName, Integer boxCount,
BigDecimal volumeCbm, LocalDateTime operationTime, String status, String remark,
Integer version, LocalDateTime createdAt) {
}
public record InventoryView(Long id, Long warehouseId, String warehouseName, String containerNo,
String cargoName, Integer inboundBoxes, Integer outboundBoxes,
Integer availableBoxes, BigDecimal inboundVolumeCbm, String inventoryStatus,
Integer version, LocalDateTime updatedAt) {
}
public record InventorySummary(long inboundBoxes, long outboundBoxes, long availableBoxes,
BigDecimal inboundVolumeCbm) {
}
public record InventoryPage(List<InventoryView> records, long page, long size, long total, long pages,
InventorySummary summary) {
}
public record TransactionView(Long id, String businessType, String orderNo, Integer quantity,
LocalDateTime operationTime, String remark) {
}
public record DetailView(InventoryView inventory, int inboundOrderCount, int outboundOrderCount,
List<TransactionView> transactions) {
}
}
@@ -0,0 +1,26 @@
package com.baiyue.warehouse.domesticcontainer.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_domestic_container_idempotency")
public class DomesticContainerIdempotencyEntity {
@TableId(type = IdType.INPUT)
private Long id;
private Long tenantId;
private String operationType;
private String idempotencyKey;
private String requestHash;
private Long resourceId;
private String resourceNo;
private String operationStatus;
private LocalDateTime createdAt;
private LocalDateTime completedAt;
}
@@ -0,0 +1,28 @@
package com.baiyue.warehouse.domesticcontainer.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_domestic_container_inventory")
public class DomesticContainerInventoryEntity extends BaseEntity {
@TableId(type = IdType.INPUT)
private Long id;
private Long tenantId;
private Long warehouseId;
private String warehouseName;
private String containerNo;
private String cargoName;
private Integer inboundBoxes;
private Integer outboundBoxes;
private Integer availableBoxes;
private BigDecimal inboundVolumeCbm;
private String inventoryStatus;
}
@@ -0,0 +1,31 @@
package com.baiyue.warehouse.domesticcontainer.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;
import java.time.LocalDateTime;
@Getter
@Setter
@TableName("wh_domestic_container_order")
public class DomesticContainerOrderEntity extends BaseEntity {
@TableId(type = IdType.INPUT)
private Long id;
private Long tenantId;
private String businessType;
private String orderNo;
private Long warehouseId;
private String warehouseName;
private String containerNo;
private String cargoName;
private Integer boxCount;
private BigDecimal volumeCbm;
private LocalDateTime operationTime;
private String status;
private String remark;
}
@@ -0,0 +1,28 @@
package com.baiyue.warehouse.domesticcontainer.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.time.LocalDateTime;
@Getter
@Setter
@TableName("wh_domestic_container_transaction")
public class DomesticContainerTransactionEntity extends BaseEntity {
@TableId(type = IdType.INPUT)
private Long id;
private Long tenantId;
private Long inventoryId;
private Long warehouseId;
private String containerNo;
private String businessType;
private Long orderId;
private String orderNo;
private Integer quantity;
private LocalDateTime operationTime;
private String remark;
}
@@ -0,0 +1,23 @@
package com.baiyue.warehouse.domesticcontainer.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baiyue.warehouse.domesticcontainer.entity.DomesticContainerIdempotencyEntity;
import org.apache.ibatis.annotations.Insert;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
import org.apache.ibatis.annotations.Update;
public interface DomesticContainerIdempotencyMapper extends BaseMapper<DomesticContainerIdempotencyEntity> {
@Insert("INSERT IGNORE INTO wh_domestic_container_idempotency (id,tenant_id,operation_type,idempotency_key,request_hash,operation_status,created_at) VALUES (#{id},0,#{operationType},#{idempotencyKey},#{requestHash},'PROCESSING',CURRENT_TIMESTAMP(3))")
int claim(@Param("id") long id, @Param("operationType") String operationType,
@Param("idempotencyKey") String idempotencyKey, @Param("requestHash") String requestHash);
@Select("SELECT * FROM wh_domestic_container_idempotency WHERE tenant_id=0 AND operation_type=#{operationType} AND idempotency_key=#{idempotencyKey} LIMIT 1")
DomesticContainerIdempotencyEntity find(@Param("operationType") String operationType,
@Param("idempotencyKey") String idempotencyKey);
@Update("UPDATE wh_domestic_container_idempotency SET resource_id=#{resourceId},resource_no=#{resourceNo},operation_status='COMPLETED',completed_at=CURRENT_TIMESTAMP(3) WHERE tenant_id=0 AND operation_type=#{operationType} AND idempotency_key=#{idempotencyKey} AND request_hash=#{requestHash} AND operation_status='PROCESSING'")
int complete(@Param("operationType") String operationType, @Param("idempotencyKey") String idempotencyKey,
@Param("requestHash") String requestHash, @Param("resourceId") long resourceId,
@Param("resourceNo") String resourceNo);
}
@@ -0,0 +1,15 @@
package com.baiyue.warehouse.domesticcontainer.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baiyue.warehouse.domesticcontainer.entity.DomesticContainerInventoryEntity;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
public interface DomesticContainerInventoryMapper extends BaseMapper<DomesticContainerInventoryEntity> {
@Select("SELECT id FROM wh_warehouse WHERE tenant_id=0 AND id=#{warehouseId} AND warehouse_type='DOMESTIC' AND status='ACTIVE' AND deleted=0 FOR UPDATE")
Long lockWarehouse(@Param("warehouseId") long warehouseId);
@Select("SELECT * FROM wh_domestic_container_inventory WHERE tenant_id=0 AND warehouse_id=#{warehouseId} AND container_no=#{containerNo} AND deleted=0 FOR UPDATE")
DomesticContainerInventoryEntity lock(@Param("warehouseId") long warehouseId,
@Param("containerNo") String containerNo);
}
@@ -0,0 +1,7 @@
package com.baiyue.warehouse.domesticcontainer.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baiyue.warehouse.domesticcontainer.entity.DomesticContainerOrderEntity;
public interface DomesticContainerOrderMapper extends BaseMapper<DomesticContainerOrderEntity> {
}
@@ -0,0 +1,7 @@
package com.baiyue.warehouse.domesticcontainer.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baiyue.warehouse.domesticcontainer.entity.DomesticContainerTransactionEntity;
public interface DomesticContainerTransactionMapper extends BaseMapper<DomesticContainerTransactionEntity> {
}
@@ -0,0 +1,108 @@
package com.baiyue.warehouse.domesticcontainer.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.domesticcontainer.dto.DomesticContainerDtos;
import com.baiyue.warehouse.domesticcontainer.entity.DomesticContainerIdempotencyEntity;
import com.baiyue.warehouse.domesticcontainer.entity.DomesticContainerInventoryEntity;
import com.baiyue.warehouse.domesticcontainer.entity.DomesticContainerOrderEntity;
import com.baiyue.warehouse.domesticcontainer.entity.DomesticContainerTransactionEntity;
import com.baiyue.warehouse.domesticcontainer.mapper.DomesticContainerIdempotencyMapper;
import com.baiyue.warehouse.domesticcontainer.mapper.DomesticContainerInventoryMapper;
import com.baiyue.warehouse.domesticcontainer.mapper.DomesticContainerOrderMapper;
import com.baiyue.warehouse.domesticcontainer.mapper.DomesticContainerTransactionMapper;
import jakarta.annotation.Resource;
import org.springframework.stereotype.Repository;
import org.springframework.util.StringUtils;
import java.util.List;
@Repository
public class DomesticContainerRepository {
@Resource private DomesticContainerOrderMapper orders;
@Resource private DomesticContainerInventoryMapper inventories;
@Resource private DomesticContainerTransactionMapper transactions;
@Resource private DomesticContainerIdempotencyMapper idempotencies;
public PageResult<DomesticContainerOrderEntity> pageOrders(String type, DomesticContainerDtos.OrderQuery query) {
var wrapper = new LambdaQueryWrapper<DomesticContainerOrderEntity>()
.eq(DomesticContainerOrderEntity::getTenantId, 0L)
.eq(DomesticContainerOrderEntity::getBusinessType, type)
.eq(query.warehouseId() != null, DomesticContainerOrderEntity::getWarehouseId, query.warehouseId())
.like(StringUtils.hasText(query.orderNo()), DomesticContainerOrderEntity::getOrderNo, trim(query.orderNo()))
.like(StringUtils.hasText(query.containerNo()), DomesticContainerOrderEntity::getContainerNo, upper(query.containerNo()))
.like(StringUtils.hasText(query.cargoName()), DomesticContainerOrderEntity::getCargoName, trim(query.cargoName()))
.eq(StringUtils.hasText(query.status()), DomesticContainerOrderEntity::getStatus, query.status())
.orderByDesc(DomesticContainerOrderEntity::getOperationTime)
.orderByDesc(DomesticContainerOrderEntity::getId);
return PageResult.from(orders.selectPage(new Page<>(query.page(), query.size()), wrapper));
}
public PageResult<DomesticContainerInventoryEntity> pageInventories(DomesticContainerDtos.InventoryQuery query) {
var wrapper = inventoryWrapper(query).orderByDesc(DomesticContainerInventoryEntity::getUpdatedAt)
.orderByDesc(DomesticContainerInventoryEntity::getId);
return PageResult.from(inventories.selectPage(new Page<>(query.page(), query.size()), wrapper));
}
public List<DomesticContainerInventoryEntity> findInventories(DomesticContainerDtos.InventoryQuery query) {
return inventories.selectList(inventoryWrapper(query));
}
public DomesticContainerInventoryEntity findInventory(long id) {
return inventories.selectOne(new LambdaQueryWrapper<DomesticContainerInventoryEntity>()
.eq(DomesticContainerInventoryEntity::getTenantId, 0L)
.eq(DomesticContainerInventoryEntity::getId, id));
}
public DomesticContainerInventoryEntity lockInventory(long warehouseId, String containerNo) {
return inventories.lock(warehouseId, containerNo);
}
public void lockWarehouse(long warehouseId) { inventories.lockWarehouse(warehouseId); }
public void insertOrder(DomesticContainerOrderEntity entity) { orders.insert(entity); }
public void insertInventory(DomesticContainerInventoryEntity entity) { inventories.insert(entity); }
public int updateInventory(DomesticContainerInventoryEntity entity) { return inventories.updateById(entity); }
public void insertTransaction(DomesticContainerTransactionEntity entity) { transactions.insert(entity); }
public long countOrders(String type, long warehouseId, String containerNo) {
return orders.selectCount(new LambdaQueryWrapper<DomesticContainerOrderEntity>()
.eq(DomesticContainerOrderEntity::getTenantId, 0L)
.eq(DomesticContainerOrderEntity::getBusinessType, type)
.eq(DomesticContainerOrderEntity::getWarehouseId, warehouseId)
.eq(DomesticContainerOrderEntity::getContainerNo, containerNo));
}
public List<DomesticContainerTransactionEntity> transactions(long inventoryId) {
return transactions.selectList(new LambdaQueryWrapper<DomesticContainerTransactionEntity>()
.eq(DomesticContainerTransactionEntity::getTenantId, 0L)
.eq(DomesticContainerTransactionEntity::getInventoryId, inventoryId)
.orderByDesc(DomesticContainerTransactionEntity::getOperationTime)
.orderByDesc(DomesticContainerTransactionEntity::getId));
}
public int claim(long id, String operationType, String key, String hash) {
return idempotencies.claim(id, operationType, key, hash);
}
public DomesticContainerIdempotencyEntity findClaim(String operationType, String key) {
return idempotencies.find(operationType, key);
}
public int completeClaim(String operationType, String key, String hash, long id, String no) {
return idempotencies.complete(operationType, key, hash, id, no);
}
private LambdaQueryWrapper<DomesticContainerInventoryEntity> inventoryWrapper(DomesticContainerDtos.InventoryQuery query) {
return new LambdaQueryWrapper<DomesticContainerInventoryEntity>()
.eq(DomesticContainerInventoryEntity::getTenantId, 0L)
.eq(query.warehouseId() != null, DomesticContainerInventoryEntity::getWarehouseId, query.warehouseId())
.like(StringUtils.hasText(query.containerNo()), DomesticContainerInventoryEntity::getContainerNo, upper(query.containerNo()))
.like(StringUtils.hasText(query.cargoName()), DomesticContainerInventoryEntity::getCargoName, trim(query.cargoName()))
.eq(StringUtils.hasText(query.status()), DomesticContainerInventoryEntity::getInventoryStatus, query.status());
}
private static String trim(String value) { return value == null ? null : value.trim(); }
private static String upper(String value) { return trim(value).toUpperCase(java.util.Locale.ROOT); }
}
@@ -0,0 +1,115 @@
package com.baiyue.warehouse.domesticcontainer.service;
import com.baomidou.mybatisplus.core.toolkit.IdWorker;
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.domesticcontainer.dto.DomesticContainerDtos;
import com.baiyue.warehouse.domesticcontainer.entity.*;
import com.baiyue.warehouse.domesticcontainer.repository.DomesticContainerRepository;
import com.baiyue.warehouse.entity.WarehouseEntity;
import com.baiyue.warehouse.repository.WarehouseRepository;
import jakarta.annotation.Resource;
import org.springframework.dao.DuplicateKeyException;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.math.BigDecimal;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.time.LocalDateTime;
import java.util.HexFormat;
import java.util.List;
@Service
public class DomesticContainerService {
private static final long PLATFORM_TENANT = 0L;
@Resource private DomesticContainerRepository repository;
@Resource private WarehouseRepository warehouseRepository;
public PageResult<DomesticContainerDtos.OrderView> inboundPage(DomesticContainerDtos.OrderQuery query) {
return mapOrders(repository.pageOrders("INBOUND", query));
}
public PageResult<DomesticContainerDtos.OrderView> outboundPage(DomesticContainerDtos.OrderQuery query) {
return mapOrders(repository.pageOrders("OUTBOUND", query));
}
public PageResult<DomesticContainerDtos.InventoryView> inventoryPage(DomesticContainerDtos.InventoryQuery query) {
return mapInventories(repository.pageInventories(query));
}
public DomesticContainerDtos.InventoryPage inventoryPageWithSummary(DomesticContainerDtos.InventoryQuery query) {
PageResult<DomesticContainerInventoryEntity> page = repository.pageInventories(query);
var summary = repository.findInventories(query).stream().reduce(new DomesticContainerDtos.InventorySummary(0,0,0,BigDecimal.ZERO),
(s, e) -> new DomesticContainerDtos.InventorySummary(s.inboundBoxes()+e.getInboundBoxes(), s.outboundBoxes()+e.getOutboundBoxes(),
s.availableBoxes()+e.getAvailableBoxes(), s.inboundVolumeCbm().add(e.getInboundVolumeCbm())),
(a,b) -> new DomesticContainerDtos.InventorySummary(a.inboundBoxes()+b.inboundBoxes(),a.outboundBoxes()+b.outboundBoxes(),a.availableBoxes()+b.availableBoxes(),a.inboundVolumeCbm().add(b.inboundVolumeCbm())));
return new DomesticContainerDtos.InventoryPage(mapInventories(page).records(), page.page(), page.size(), page.total(), page.pages(), summary);
}
@Transactional
@BusinessAudit(event = "DOMESTIC_CONTAINER_INBOUND_CREATE", resourceType = "DOMESTIC_CONTAINER_ORDER", description = "新增整柜入库单", captureBefore = false)
public DomesticContainerDtos.CreateResult createInbound(DomesticContainerDtos.CreateInboundRequest request) {
String hash = hash(request.toString());
DomesticContainerDtos.CreateResult concurrentReplay = claim("INBOUND", request.idempotencyKey(), hash);
if (concurrentReplay != null) return concurrentReplay;
WarehouseEntity warehouse = domesticWarehouse(request.warehouseId());
repository.lockWarehouse(warehouse.getId());
String container = normalize(request.containerNo());
DomesticContainerOrderEntity order = new DomesticContainerOrderEntity();
order.setId(IdWorker.getId()); order.setTenantId(PLATFORM_TENANT); order.setBusinessType("INBOUND");
order.setOrderNo(nextNo("ZGRK")); order.setWarehouseId(warehouse.getId()); order.setWarehouseName(warehouse.getWarehouseName());
order.setContainerNo(container); order.setCargoName(request.cargoName().trim()); order.setBoxCount(request.boxCount());
order.setVolumeCbm(request.volumeCbm()); order.setOperationTime(request.inboundTime()); order.setStatus(request.status()); order.setRemark(request.remark());
if ("COMPLETED".equals(request.status())) {
DomesticContainerInventoryEntity inventory = repository.lockInventory(warehouse.getId(), container);
if (inventory == null) { inventory = newInventory(warehouse, container, order); repository.insertInventory(inventory); }
else { if (!inventory.getCargoName().equals(order.getCargoName())) throw new BizException(ResultCode.CONFLICT, "同一国内仓的集装箱货物名称不一致"); inventory.setInboundBoxes(inventory.getInboundBoxes()+order.getBoxCount()); inventory.setAvailableBoxes(inventory.getAvailableBoxes()+order.getBoxCount()); inventory.setInboundVolumeCbm(inventory.getInboundVolumeCbm().add(order.getVolumeCbm())); inventory.setInventoryStatus(status(inventory)); requireUpdated(repository.updateInventory(inventory)); }
repository.insertOrder(order); transaction(inventory, order, "INBOUND");
} else repository.insertOrder(order);
requireClaimCompleted(repository.completeClaim("INBOUND", request.idempotencyKey(), hash, order.getId(), order.getOrderNo()));
return new DomesticContainerDtos.CreateResult(order.getId(), order.getOrderNo(), false);
}
@Transactional
@BusinessAudit(event = "DOMESTIC_CONTAINER_OUTBOUND_CREATE", resourceType = "DOMESTIC_CONTAINER_ORDER", description = "新增整柜出库单", captureBefore = false)
public DomesticContainerDtos.CreateResult createOutbound(DomesticContainerDtos.CreateOutboundRequest request) {
String hash = hash(request.toString());
DomesticContainerDtos.CreateResult concurrentReplay = claim("OUTBOUND", request.idempotencyKey(), hash);
if (concurrentReplay != null) return concurrentReplay;
WarehouseEntity warehouse = domesticWarehouse(request.warehouseId());
repository.lockWarehouse(warehouse.getId());
DomesticContainerInventoryEntity inventory = repository.lockInventory(warehouse.getId(), normalize(request.containerNo()));
if (inventory == null) throw new BizException(ResultCode.NOT_FOUND, "整柜库存不存在");
if ("COMPLETED".equals(request.status())) {
if (request.inventoryVersion() != null && !request.inventoryVersion().equals(inventory.getVersion())) throw new BizException(ResultCode.CONFLICT, "库存已被修改,请刷新后重试");
if (request.boxCount() > inventory.getAvailableBoxes()) throw new BizException(ResultCode.CONFLICT, "出库箱数超过可用库存");
}
DomesticContainerOrderEntity order = new DomesticContainerOrderEntity(); order.setId(IdWorker.getId()); order.setTenantId(PLATFORM_TENANT); order.setBusinessType("OUTBOUND");
order.setOrderNo(nextNo("ZGCK")); order.setWarehouseId(warehouse.getId()); order.setWarehouseName(warehouse.getWarehouseName()); order.setContainerNo(inventory.getContainerNo());
order.setCargoName(inventory.getCargoName()); order.setBoxCount(request.boxCount()); order.setVolumeCbm(BigDecimal.ZERO); order.setOperationTime(request.outboundTime()); order.setStatus(request.status()); order.setRemark(request.remark());
repository.insertOrder(order);
if ("COMPLETED".equals(request.status())) { inventory.setOutboundBoxes(inventory.getOutboundBoxes()+order.getBoxCount()); inventory.setAvailableBoxes(inventory.getAvailableBoxes()-order.getBoxCount()); inventory.setInventoryStatus(status(inventory)); requireUpdated(repository.updateInventory(inventory)); transaction(inventory, order, "OUTBOUND"); }
requireClaimCompleted(repository.completeClaim("OUTBOUND", request.idempotencyKey(), hash, order.getId(), order.getOrderNo()));
return new DomesticContainerDtos.CreateResult(order.getId(), order.getOrderNo(), false);
}
public DomesticContainerDtos.DetailView detail(long inventoryId) {
DomesticContainerInventoryEntity inventory = repository.findInventory(inventoryId);
if (inventory == null) throw new BizException(ResultCode.NOT_FOUND, "整柜库存不存在");
return new DomesticContainerDtos.DetailView(toInventory(inventory), (int) repository.countOrders("INBOUND", inventory.getWarehouseId(), inventory.getContainerNo()), (int) repository.countOrders("OUTBOUND", inventory.getWarehouseId(), inventory.getContainerNo()), repository.transactions(inventoryId).stream().map(t -> new DomesticContainerDtos.TransactionView(t.getId(), t.getBusinessType(), t.getOrderNo(), t.getQuantity(), t.getOperationTime(), t.getRemark())).toList());
}
private WarehouseEntity domesticWarehouse(long id) { WarehouseEntity w = warehouseRepository.findActiveOwned(0L, id); if (w == null || !"DOMESTIC".equals(w.getWarehouseType())) throw new BizException(ResultCode.NOT_FOUND, "平台国内仓不存在或未启用"); return w; }
private DomesticContainerInventoryEntity newInventory(WarehouseEntity w, String c, DomesticContainerOrderEntity o) { DomesticContainerInventoryEntity i = new DomesticContainerInventoryEntity(); i.setId(IdWorker.getId()); i.setTenantId(0L); i.setWarehouseId(w.getId()); i.setWarehouseName(w.getWarehouseName()); i.setContainerNo(c); i.setCargoName(o.getCargoName()); i.setInboundBoxes(o.getBoxCount()); i.setOutboundBoxes(0); i.setAvailableBoxes(o.getBoxCount()); i.setInboundVolumeCbm(o.getVolumeCbm()); i.setInventoryStatus("IN_STOCK"); return i; }
private void transaction(DomesticContainerInventoryEntity i, DomesticContainerOrderEntity o, String type) { DomesticContainerTransactionEntity t = new DomesticContainerTransactionEntity(); t.setId(IdWorker.getId()); t.setTenantId(0L); t.setInventoryId(i.getId()); t.setWarehouseId(i.getWarehouseId()); t.setContainerNo(i.getContainerNo()); t.setBusinessType(type); t.setOrderId(o.getId()); t.setOrderNo(o.getOrderNo()); t.setQuantity(o.getBoxCount()); t.setOperationTime(o.getOperationTime()); t.setRemark(o.getRemark()); repository.insertTransaction(t); }
private static String status(DomesticContainerInventoryEntity i) { return i.getAvailableBoxes() <= 0 ? "OUTBOUND" : i.getOutboundBoxes() > 0 ? "PARTIALLY_OUTBOUND" : "IN_STOCK"; }
private void requireUpdated(int count) { if (count != 1) throw new BizException(ResultCode.CONFLICT, "库存已被并发修改,请刷新后重试"); }
private void requireClaimCompleted(int count) { if (count != 1) throw new BizException(ResultCode.CONFLICT, "幂等操作状态已变化,请使用原键重试"); }
private DomesticContainerDtos.CreateResult claim(String type, String key, String hash) { try { if (repository.claim(IdWorker.getId(), type, key, hash) == 1) return null; } catch (DuplicateKeyException ignored) {} DomesticContainerIdempotencyEntity e = repository.findClaim(type, key); if (e == null || !hash.equals(e.getRequestHash())) throw new BizException(ResultCode.CONFLICT, "幂等键已用于其他请求"); if ("COMPLETED".equals(e.getOperationStatus())) return new DomesticContainerDtos.CreateResult(e.getResourceId(), e.getResourceNo(), true); throw new BizException(ResultCode.CONFLICT, "幂等请求正在处理中"); }
private String nextNo(String prefix) { return prefix + java.time.format.DateTimeFormatter.ofPattern("yyyyMMddHHmmssSSS").format(LocalDateTime.now()) + IdWorker.getIdStr().substring(10); }
private static String normalize(String s) { return s.trim().toUpperCase(java.util.Locale.ROOT); }
private static String hash(String value) { try { return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(value.getBytes(StandardCharsets.UTF_8))); } catch (Exception e) { throw new IllegalStateException(e); } }
private PageResult<DomesticContainerDtos.OrderView> mapOrders(PageResult<DomesticContainerOrderEntity> p) { return new PageResult<>(p.records().stream().map(o -> new DomesticContainerDtos.OrderView(o.getId(),o.getBusinessType(),o.getOrderNo(),o.getWarehouseId(),o.getWarehouseName(),o.getContainerNo(),o.getCargoName(),o.getBoxCount(),o.getVolumeCbm(),o.getOperationTime(),o.getStatus(),o.getRemark(),o.getVersion(),o.getCreatedAt())).toList(),p.page(),p.size(),p.total(),p.pages()); }
private PageResult<DomesticContainerDtos.InventoryView> mapInventories(PageResult<DomesticContainerInventoryEntity> p) { return new PageResult<>(p.records().stream().map(this::toInventory).toList(),p.page(),p.size(),p.total(),p.pages()); }
private DomesticContainerDtos.InventoryView toInventory(DomesticContainerInventoryEntity i) { return new DomesticContainerDtos.InventoryView(i.getId(),i.getWarehouseId(),i.getWarehouseName(),i.getContainerNo(),i.getCargoName(),i.getInboundBoxes(),i.getOutboundBoxes(),i.getAvailableBoxes(),i.getInboundVolumeCbm(),i.getInventoryStatus(),i.getVersion(),i.getUpdatedAt()); }
}
@@ -0,0 +1,45 @@
package com.baiyue.warehouse.dto;
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;
public final class SupplierDtos {
private SupplierDtos() {}
public record PageQuery(@Min(1) Integer page, @Min(1) @Max(200) Integer size,
@Pattern(regexp = "BOOKING|CUSTOMS|TRUCKING|CERTIFICATE") String category,
@Pattern(regexp = "DOMESTIC|INTERNATIONAL") String scope,
@Size(max = 80) String region,
@Pattern(regexp = "ACTIVE|INACTIVE") String status,
String keyword) {
public PageQuery {
page = page == null ? 1 : page;
size = size == null ? 20 : size;
}
}
public record SaveRequest(
@NotBlank @Size(max = 160) String name,
@NotBlank @Pattern(regexp = "BOOKING|CUSTOMS|TRUCKING|CERTIFICATE") String category,
@Pattern(regexp = "DOMESTIC|INTERNATIONAL") String scope,
@NotBlank @Size(max = 80) String region,
@NotBlank @Size(max = 80) String legalRepresentative,
@NotBlank @Size(max = 80) String contactName,
@NotBlank @Size(max = 40) String phone,
@Email @Size(max = 160) String email,
@Size(max = 255) String address,
@Size(max = 64) String businessLicenseNo,
@Pattern(regexp = "\\d+") @Size(max = 128) String licenseFileId,
@NotBlank @Pattern(regexp = "ACTIVE|INACTIVE") String status,
@NotBlank @Pattern(regexp = "MONTHLY_30|MONTHLY_45|MONTHLY_60|MONTHLY_90|PER_TRANSACTION|IMMEDIATE") String settlementPeriod,
@Size(max = 500) String remark) {}
public record StatusRequest(@NotNull @Pattern(regexp = "ACTIVE|INACTIVE") String status) {}
public record Stats(long booking, long customs, long trucking, long certificate) {}
}
@@ -0,0 +1,34 @@
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 io.swagger.v3.oas.annotations.media.Schema;
import lombok.Getter;
import lombok.Setter;
/** 平台公共供应商业务档案,与往来单位严格分离。 */
@Getter
@Setter
@TableName("wh_supplier")
@Schema(name = "Supplier", description = "平台公共供应商档案")
public class SupplierEntity extends BaseEntity {
@TableId(type = IdType.ASSIGN_ID)
private Long id;
private Long tenantId;
private String name;
private String category;
private String scope;
private String region;
private String legalRepresentative;
private String contactName;
private String phone;
private String email;
private String address;
private String businessLicenseNo;
private String licenseFileId;
private String status;
private String settlementPeriod;
private String remark;
}
@@ -0,0 +1,6 @@
package com.baiyue.warehouse.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baiyue.warehouse.entity.SupplierEntity;
public interface SupplierMapper extends BaseMapper<SupplierEntity> {}
@@ -0,0 +1,38 @@
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.SupplierDtos.PageQuery;
import com.baiyue.warehouse.entity.SupplierEntity;
import com.baiyue.warehouse.mapper.SupplierMapper;
import jakarta.annotation.Resource;
import org.springframework.stereotype.Repository;
import org.springframework.util.StringUtils;
@Repository
public class SupplierRepository {
@Resource private SupplierMapper mapper;
public PageResult<SupplierEntity> page(long tenantId, PageQuery query) {
LambdaQueryWrapper<SupplierEntity> w = base(tenantId)
.eq(StringUtils.hasText(query.category()), SupplierEntity::getCategory, query.category())
.eq(StringUtils.hasText(query.scope()), SupplierEntity::getScope, query.scope())
.eq(StringUtils.hasText(query.region()), SupplierEntity::getRegion, query.region())
.eq(StringUtils.hasText(query.status()), SupplierEntity::getStatus, query.status())
.and(StringUtils.hasText(query.keyword()), c -> c.like(SupplierEntity::getName, query.keyword())
.or().like(SupplierEntity::getContactName, query.keyword()))
.orderByAsc(SupplierEntity::getName).orderByAsc(SupplierEntity::getId);
return PageResult.from(mapper.selectPage(new Page<>(query.page(), query.size()), w));
}
public SupplierEntity find(long tenantId, long id) { return mapper.selectOne(base(tenantId).eq(SupplierEntity::getId, id)); }
public boolean existsName(long tenantId, String name, Long excludedId) {
return mapper.selectCount(base(tenantId).eq(SupplierEntity::getName, name)
.ne(excludedId != null, SupplierEntity::getId, excludedId)) > 0;
}
public long count(long tenantId, String category) { return mapper.selectCount(base(tenantId).eq(SupplierEntity::getCategory, category)); }
public void insert(SupplierEntity entity) { mapper.insert(entity); }
public void save(SupplierEntity entity) { mapper.updateById(entity); }
private LambdaQueryWrapper<SupplierEntity> base(long tenantId) { return new LambdaQueryWrapper<SupplierEntity>().eq(SupplierEntity::getTenantId, tenantId); }
}
@@ -0,0 +1,49 @@
package com.baiyue.warehouse.service;
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.SupplierDtos.*;
import com.baiyue.warehouse.entity.SupplierEntity;
import com.baiyue.warehouse.repository.SupplierRepository;
import jakarta.annotation.Resource;
import org.springframework.dao.DataIntegrityViolationException;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
public class SupplierService {
private static final long PUBLIC_TENANT_ID = 0L;
@Resource private SupplierRepository repository;
public PageResult<SupplierEntity> page(PageQuery query) { return repository.page(PUBLIC_TENANT_ID, query); }
public SupplierEntity detail(long id) { return owned(id); }
public Stats stats() { return new Stats(repository.count(0, "BOOKING"), repository.count(0, "CUSTOMS"), repository.count(0, "TRUCKING"), repository.count(0, "CERTIFICATE")); }
@Transactional
@BusinessAudit(event = "SUPPLIER_CREATE", resourceType = "SUPPLIER", resourceId = "#request.name", description = "新增供应商", captureBefore = false)
public Long create(SaveRequest request) {
validateScope(request);
ensureName(request.name(), null);
SupplierEntity entity = new SupplierEntity(); entity.setTenantId(0L); copy(entity, request);
try { repository.insert(entity); } catch (DataIntegrityViolationException e) { throw new BizException(ResultCode.CONFLICT, "供应商名称已存在"); }
return entity.getId();
}
@Transactional
@BusinessAudit(event = "SUPPLIER_UPDATE", resourceType = "SUPPLIER", resourceId = "#id", description = "编辑供应商")
public void update(long id, SaveRequest request) {
SupplierEntity e = owned(id); validateScope(request); ensureName(request.name(), id); copy(e, request);
try { repository.save(e); } catch (DataIntegrityViolationException exception) { throw new BizException(ResultCode.CONFLICT, "供应商名称已存在"); }
}
@Transactional
@BusinessAudit(event = "SUPPLIER_STATUS_CHANGE", resourceType = "SUPPLIER", resourceId = "#id", description = "启停供应商")
public void changeStatus(long id, StatusRequest request) { SupplierEntity e = owned(id); e.setStatus(request.status()); repository.save(e); }
private SupplierEntity owned(long id) { SupplierEntity e = repository.find(0L, id); if (e == null) throw new BizException(ResultCode.NOT_FOUND, "供应商不存在"); return e; }
private void ensureName(String name, Long id) { if (repository.existsName(0L, name, id)) throw new BizException(ResultCode.CONFLICT, "供应商名称已存在"); }
private static void validateScope(SaveRequest r) { if (r.category().equals("BOOKING") != (r.scope() != null)) throw new BizException(ResultCode.VALIDATION_FAILED, "国内/国外范围仅适用于订舱供应商"); }
private static void copy(SupplierEntity e, SaveRequest r) { e.setName(r.name()); e.setCategory(r.category()); e.setScope(r.scope()); e.setRegion(r.region()); e.setLegalRepresentative(r.legalRepresentative()); e.setContactName(r.contactName()); e.setPhone(r.phone()); e.setEmail(r.email()); e.setAddress(r.address()); e.setBusinessLicenseNo(r.businessLicenseNo()); e.setLicenseFileId(r.licenseFileId()); e.setStatus(r.status()); e.setSettlementPeriod(r.settlementPeriod()); e.setRemark(r.remark()); }
}
@@ -24,7 +24,11 @@ public class WarehouseSchemaReadinessService {
"SELECT id, tenant_id, warehouse_id, pallet_id, location_id, shared_overflow, reservation_status, expires_at, version FROM wh_location_reservation LIMIT 0",
"SELECT id, tenant_id, warehouse_id, pallet_id, location_id, shared_overflow, occupancy_status, active_location_occupancy, active_pallet_occupancy, version FROM wh_location_occupancy LIMIT 0",
"SELECT id, tenant_id, customer_name, currency, version FROM wh_overseas_storage_fee_rule LIMIT 0",
"SELECT id, tenant_id, rule_id, start_day, end_day, unit_price, sort_no, version FROM wh_overseas_storage_fee_tier LIMIT 0"
"SELECT id, tenant_id, rule_id, start_day, end_day, unit_price, sort_no, version FROM wh_overseas_storage_fee_tier LIMIT 0",
"SELECT id, tenant_id, business_type, order_no, warehouse_id, container_no, box_count, status, version FROM wh_domestic_container_order LIMIT 0",
"SELECT id, tenant_id, warehouse_id, container_no, inbound_boxes, outbound_boxes, available_boxes, inventory_status, version FROM wh_domestic_container_inventory LIMIT 0",
"SELECT id, tenant_id, inventory_id, business_type, order_id, quantity, operation_time FROM wh_domestic_container_transaction LIMIT 0",
"SELECT id, tenant_id, operation_type, idempotency_key, request_hash, operation_status FROM wh_domestic_container_idempotency LIMIT 0"
);
@Resource
@@ -0,0 +1,65 @@
SET NAMES utf8mb4;
CREATE DATABASE IF NOT EXISTS baiyue_warehouse DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci;
USE baiyue_warehouse;
CREATE TABLE IF NOT EXISTS wh_domestic_container_order (
id BIGINT UNSIGNED NOT NULL, tenant_id BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '平台公共作用域,固定为0',
business_type VARCHAR(16) NOT NULL COMMENT 'INBOUND或OUTBOUND', order_no VARCHAR(40) NOT NULL,
warehouse_id BIGINT UNSIGNED NOT NULL COMMENT '平台国内仓ID', warehouse_name VARCHAR(120) NOT NULL,
container_no VARCHAR(32) NOT NULL, cargo_name VARCHAR(128) NOT NULL, box_count INT UNSIGNED NOT NULL,
volume_cbm DECIMAL(16,4) NOT NULL DEFAULT 0, operation_time DATETIME(3) NOT NULL,
status VARCHAR(16) NOT NULL COMMENT 'PENDING或COMPLETED', remark VARCHAR(500) NULL,
created_by BIGINT UNSIGNED NULL, created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
updated_by BIGINT UNSIGNED NULL, updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
deleted TINYINT(1) NOT NULL DEFAULT 0, version INT NOT NULL DEFAULT 0,
PRIMARY KEY (id), UNIQUE KEY uk_dc_order_no (tenant_id, order_no, deleted),
KEY idx_dc_order_list (tenant_id, business_type, warehouse_id, status, operation_time),
KEY idx_dc_order_container (tenant_id, warehouse_id, container_no, business_type),
CONSTRAINT chk_dc_order_scope CHECK (tenant_id=0), CONSTRAINT chk_dc_order_type CHECK (business_type IN ('INBOUND','OUTBOUND')),
CONSTRAINT chk_dc_order_status CHECK (status IN ('PENDING','COMPLETED')),
CONSTRAINT chk_dc_order_quantity CHECK (box_count>0 AND volume_cbm>=0)
) ENGINE=InnoDB COMMENT='国内仓独立整柜入出库单,不复用头程装柜货柜模型';
CREATE TABLE IF NOT EXISTS wh_domestic_container_inventory (
id BIGINT UNSIGNED NOT NULL, tenant_id BIGINT UNSIGNED NOT NULL DEFAULT 0,
warehouse_id BIGINT UNSIGNED NOT NULL, warehouse_name VARCHAR(120) NOT NULL,
container_no VARCHAR(32) NOT NULL, cargo_name VARCHAR(128) NOT NULL,
inbound_boxes INT UNSIGNED NOT NULL, outbound_boxes INT UNSIGNED NOT NULL DEFAULT 0,
available_boxes INT UNSIGNED NOT NULL, inbound_volume_cbm DECIMAL(16,4) NOT NULL,
inventory_status VARCHAR(24) NOT NULL COMMENT 'IN_STOCK、PARTIALLY_OUTBOUND或OUTBOUND',
created_by BIGINT UNSIGNED NULL, created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
updated_by BIGINT UNSIGNED NULL, updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
deleted TINYINT(1) NOT NULL DEFAULT 0, version INT NOT NULL DEFAULT 0,
PRIMARY KEY (id), UNIQUE KEY uk_dc_inventory_container (tenant_id, warehouse_id, container_no, deleted),
KEY idx_dc_inventory_list (tenant_id, warehouse_id, inventory_status, updated_at),
CONSTRAINT chk_dc_inventory_scope CHECK (tenant_id=0),
CONSTRAINT chk_dc_inventory_balance CHECK (inbound_boxes=outbound_boxes+available_boxes),
CONSTRAINT chk_dc_inventory_status CHECK (inventory_status IN ('IN_STOCK','PARTIALLY_OUTBOUND','OUTBOUND'))
) ENGINE=InnoDB COMMENT='平台国内仓整柜库存台账';
CREATE TABLE IF NOT EXISTS wh_domestic_container_transaction (
id BIGINT UNSIGNED NOT NULL, tenant_id BIGINT UNSIGNED NOT NULL DEFAULT 0,
inventory_id BIGINT UNSIGNED NOT NULL, warehouse_id BIGINT UNSIGNED NOT NULL, container_no VARCHAR(32) NOT NULL,
business_type VARCHAR(16) NOT NULL, order_id BIGINT UNSIGNED NOT NULL, order_no VARCHAR(40) NOT NULL,
quantity INT UNSIGNED NOT NULL, operation_time DATETIME(3) NOT NULL, remark VARCHAR(500) NULL,
created_by BIGINT UNSIGNED NULL, created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
updated_by BIGINT UNSIGNED NULL, updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
deleted TINYINT(1) NOT NULL DEFAULT 0, version INT NOT NULL DEFAULT 0,
PRIMARY KEY (id), UNIQUE KEY uk_dc_transaction_order (tenant_id, business_type, order_id),
KEY idx_dc_transaction_detail (tenant_id, inventory_id, operation_time),
CONSTRAINT chk_dc_transaction_scope CHECK (tenant_id=0), CONSTRAINT chk_dc_transaction_type CHECK (business_type IN ('INBOUND','OUTBOUND')),
CONSTRAINT chk_dc_transaction_quantity CHECK (quantity>0)
) ENGINE=InnoDB COMMENT='国内仓整柜不可变进销存交易流水';
CREATE TABLE IF NOT EXISTS wh_domestic_container_idempotency (
id BIGINT UNSIGNED NOT NULL, tenant_id BIGINT UNSIGNED NOT NULL DEFAULT 0,
operation_type VARCHAR(16) NOT NULL, idempotency_key VARCHAR(128) NOT NULL, request_hash CHAR(64) NOT NULL,
resource_id BIGINT UNSIGNED NULL, resource_no VARCHAR(40) NULL, operation_status VARCHAR(16) NOT NULL,
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), completed_at DATETIME(3) NULL,
PRIMARY KEY (id), UNIQUE KEY uk_dc_idempotency (tenant_id, operation_type, idempotency_key),
CONSTRAINT chk_dc_idempotency_scope CHECK (tenant_id=0), CONSTRAINT chk_dc_idempotency_type CHECK (operation_type IN ('INBOUND','OUTBOUND')),
CONSTRAINT chk_dc_idempotency_status CHECK (operation_status IN ('PROCESSING','COMPLETED'))
) ENGINE=InnoDB COMMENT='国内仓整柜写操作幂等记录';
-- IAM 权限由统一部署脚本合并:platform:domestic-container:list、
-- platform:domestic-container:inbound:create、platform:domestic-container:outbound:create。
@@ -0,0 +1,91 @@
-- 平台公共供应商业务档案。可用于空库初始化,也可重复执行升级存量结构。
USE baiyue_warehouse;
SET NAMES utf8mb4;
CREATE TABLE IF NOT EXISTS wh_supplier (
id BIGINT UNSIGNED NOT NULL COMMENT '供应商ID',
tenant_id BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '平台公共作用域,固定为0',
name VARCHAR(160) NOT NULL COMMENT '供应商名称',
category VARCHAR(20) NOT NULL COMMENT 'BOOKING/CUSTOMS/TRUCKING/CERTIFICATE',
scope VARCHAR(20) NULL COMMENT '订舱分类范围:DOMESTIC/INTERNATIONAL',
region VARCHAR(80) NOT NULL COMMENT '地区',
legal_representative VARCHAR(80) NOT NULL COMMENT '法人代表',
contact_name VARCHAR(80) NOT NULL COMMENT '联系人',
phone VARCHAR(40) NOT NULL COMMENT '联系电话',
email VARCHAR(160) NULL COMMENT '电子邮箱',
address VARCHAR(255) NULL COMMENT '公司地址',
business_license_no VARCHAR(64) NULL COMMENT '营业执照号',
license_file_id VARCHAR(128) NULL COMMENT 'File服务营业执照文件ID',
status VARCHAR(20) NOT NULL DEFAULT 'ACTIVE' COMMENT 'ACTIVE/INACTIVE',
settlement_period VARCHAR(32) NOT NULL COMMENT '结算周期',
remark VARCHAR(500) NULL COMMENT '备注',
created_by BIGINT UNSIGNED NULL COMMENT '创建人ID',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间',
updated_by BIGINT UNSIGNED NULL COMMENT '最后修改人ID',
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '最后修改时间',
deleted TINYINT NOT NULL DEFAULT 0 COMMENT '逻辑删除标记',
version INT NOT NULL DEFAULT 0 COMMENT '乐观锁版本',
PRIMARY KEY (id),
UNIQUE KEY uk_wh_supplier_name (tenant_id,name),
KEY idx_wh_supplier_filter (tenant_id,category,status,region),
CONSTRAINT chk_wh_supplier_category CHECK (category IN ('BOOKING','CUSTOMS','TRUCKING','CERTIFICATE')),
CONSTRAINT chk_wh_supplier_scope CHECK ((category='BOOKING' AND scope IN ('DOMESTIC','INTERNATIONAL')) OR (category<>'BOOKING' AND scope IS NULL)),
CONSTRAINT chk_wh_supplier_status CHECK (status IN ('ACTIVE','INACTIVE')),
CONSTRAINT chk_wh_supplier_settlement CHECK (settlement_period IN ('MONTHLY_30','MONTHLY_45','MONTHLY_60','MONTHLY_90','PER_TRANSACTION','IMMEDIATE'))
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='平台公共供应商业务档案';
DROP PROCEDURE IF EXISTS supplier_add_column_if_missing;
DELIMITER $$
CREATE PROCEDURE supplier_add_column_if_missing(IN p_name VARCHAR(64), IN p_definition TEXT)
BEGIN
IF NOT EXISTS (SELECT 1 FROM information_schema.columns
WHERE table_schema='baiyue_warehouse' AND table_name='wh_supplier' AND column_name=p_name) THEN
SET @supplier_ddl=CONCAT('ALTER TABLE wh_supplier ADD COLUMN ',p_name,' ',p_definition);
PREPARE supplier_statement FROM @supplier_ddl; EXECUTE supplier_statement; DEALLOCATE PREPARE supplier_statement;
END IF;
END$$
DELIMITER ;
CALL supplier_add_column_if_missing('scope', "VARCHAR(20) NULL COMMENT '订舱分类范围:DOMESTIC/INTERNATIONAL' AFTER category");
CALL supplier_add_column_if_missing('license_file_id', "VARCHAR(128) NULL COMMENT 'File服务营业执照文件ID' AFTER business_license_no");
CALL supplier_add_column_if_missing('created_by', "BIGINT UNSIGNED NULL COMMENT '创建人ID'");
CALL supplier_add_column_if_missing('created_at', "DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间'");
CALL supplier_add_column_if_missing('updated_by', "BIGINT UNSIGNED NULL COMMENT '最后修改人ID'");
CALL supplier_add_column_if_missing('updated_at', "DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '最后修改时间'");
CALL supplier_add_column_if_missing('deleted', "TINYINT NOT NULL DEFAULT 0 COMMENT '逻辑删除标记'");
CALL supplier_add_column_if_missing('version', "INT NOT NULL DEFAULT 0 COMMENT '乐观锁版本'");
DROP PROCEDURE supplier_add_column_if_missing;
SET @supplier_index_ddl=IF(EXISTS(SELECT 1 FROM information_schema.statistics WHERE table_schema='baiyue_warehouse' AND table_name='wh_supplier' AND index_name='uk_wh_supplier_name'),'SELECT 1','ALTER TABLE wh_supplier ADD UNIQUE KEY uk_wh_supplier_name (tenant_id,name)');
PREPARE supplier_statement FROM @supplier_index_ddl; EXECUTE supplier_statement; DEALLOCATE PREPARE supplier_statement;
SET @supplier_index_ddl=IF(EXISTS(SELECT 1 FROM information_schema.statistics WHERE table_schema='baiyue_warehouse' AND table_name='wh_supplier' AND index_name='idx_wh_supplier_filter'),'SELECT 1','ALTER TABLE wh_supplier ADD KEY idx_wh_supplier_filter (tenant_id,category,status,region)');
PREPARE supplier_statement FROM @supplier_index_ddl; EXECUTE supplier_statement; DEALLOCATE PREPARE supplier_statement;
DROP PROCEDURE IF EXISTS supplier_add_check_if_missing;
DELIMITER $$
CREATE PROCEDURE supplier_add_check_if_missing(IN p_name VARCHAR(64), IN p_expression TEXT)
BEGIN
IF NOT EXISTS (SELECT 1 FROM information_schema.table_constraints WHERE table_schema='baiyue_warehouse' AND table_name='wh_supplier' AND constraint_name=p_name AND constraint_type='CHECK') THEN
SET @supplier_ddl=CONCAT('ALTER TABLE wh_supplier ADD CONSTRAINT ',p_name,' CHECK (',p_expression,')');
PREPARE supplier_statement FROM @supplier_ddl; EXECUTE supplier_statement; DEALLOCATE PREPARE supplier_statement;
END IF;
END$$
DELIMITER ;
CALL supplier_add_check_if_missing('chk_wh_supplier_category', "category IN ('BOOKING','CUSTOMS','TRUCKING','CERTIFICATE')");
CALL supplier_add_check_if_missing('chk_wh_supplier_scope', "(category='BOOKING' AND scope IN ('DOMESTIC','INTERNATIONAL')) OR (category<>'BOOKING' AND scope IS NULL)");
CALL supplier_add_check_if_missing('chk_wh_supplier_status', "status IN ('ACTIVE','INACTIVE')");
CALL supplier_add_check_if_missing('chk_wh_supplier_settlement', "settlement_period IN ('MONTHLY_30','MONTHLY_45','MONTHLY_60','MONTHLY_90','PER_TRANSACTION','IMMEDIATE')");
DROP PROCEDURE supplier_add_check_if_missing;
DROP PROCEDURE IF EXISTS assert_supplier_schema;
DELIMITER $$
CREATE PROCEDURE assert_supplier_schema()
BEGIN
IF (SELECT COUNT(*) FROM information_schema.tables WHERE table_schema='baiyue_warehouse' AND table_name='wh_supplier') <> 1
OR (SELECT COUNT(DISTINCT index_name) FROM information_schema.statistics WHERE table_schema='baiyue_warehouse' AND table_name='wh_supplier' AND index_name IN ('PRIMARY','uk_wh_supplier_name','idx_wh_supplier_filter')) <> 3
OR (SELECT COUNT(*) FROM information_schema.table_constraints WHERE table_schema='baiyue_warehouse' AND table_name='wh_supplier' AND constraint_type='CHECK' AND constraint_name IN ('chk_wh_supplier_category','chk_wh_supplier_scope','chk_wh_supplier_status','chk_wh_supplier_settlement')) <> 4 THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='供应商表、索引或约束初始化不完整';
END IF;
END$$
DELIMITER ;
CALL assert_supplier_schema();
DROP PROCEDURE assert_supplier_schema;
@@ -0,0 +1,183 @@
openapi: 3.0.3
info:
title: Domestic Container Business API
version: 1.0.0
description: 平台国内仓独立整柜业务契约,不复用 Firstleg 装柜或货柜模型。
paths:
/api/platform/v1/domestic-containers/inbounds:
get:
summary: 查询整柜入库单
x-permission: platform:domestic-container:list
responses:
'200': { description: OK, content: { application/json: { schema: { $ref: '#/components/schemas/OrderPageResponse' } } } }
post:
summary: 新建整柜入库单
description: COMPLETED 原子增加库存并写交易流水;PENDING 只建单。
x-permission: platform:domestic-container:inbound:create
requestBody:
required: true
content: { application/json: { schema: { $ref: '#/components/schemas/CreateInboundRequest' } } }
responses:
'200': { description: OK, content: { application/json: { schema: { $ref: '#/components/schemas/CreateResponse' } } } }
/api/platform/v1/domestic-containers/outbounds:
get:
summary: 查询整柜出库单
x-permission: platform:domestic-container:list
responses:
'200': { description: OK, content: { application/json: { schema: { $ref: '#/components/schemas/OrderPageResponse' } } } }
post:
summary: 新建整柜出库单
description: COMPLETED 锁库存后扣减,支持部分/全部出库;PENDING 不扣减。
x-permission: platform:domestic-container:outbound:create
requestBody:
required: true
content: { application/json: { schema: { $ref: '#/components/schemas/CreateOutboundRequest' } } }
responses:
'200': { description: OK, content: { application/json: { schema: { $ref: '#/components/schemas/CreateResponse' } } } }
/api/platform/v1/domestic-containers/inventories:
get:
summary: 查询整柜库存与当前筛选汇总
x-permission: platform:domestic-container:list
responses:
'200': { description: OK, content: { application/json: { schema: { $ref: '#/components/schemas/InventoryPageResponse' } } } }
/api/platform/v1/domestic-containers/inventories/{inventoryId}:
get:
summary: 查询库存详情及不可变交易流水
x-permission: platform:domestic-container:list
parameters:
- { in: path, name: inventoryId, required: true, schema: { type: integer, format: int64 } }
responses:
'200': { description: OK, content: { application/json: { schema: { $ref: '#/components/schemas/DetailResponse' } } } }
components:
schemas:
CreateInboundRequest:
type: object
required: [warehouseId, cargoName, containerNo, boxCount, volumeCbm, inboundTime, status, idempotencyKey]
properties:
warehouseId: { type: integer, format: int64, description: 平台公共且启用的 DOMESTIC 仓库 ID }
cargoName: { type: string, maxLength: 128 }
containerNo: { type: string, maxLength: 32 }
boxCount: { type: integer, minimum: 1 }
volumeCbm: { type: number, minimum: 0.0001 }
inboundTime: { type: string, format: date-time }
status: { type: string, enum: [PENDING, COMPLETED] }
remark: { type: string, maxLength: 500 }
idempotencyKey: { type: string, maxLength: 128 }
CreateOutboundRequest:
type: object
required: [warehouseId, containerNo, boxCount, outboundTime, status, idempotencyKey]
properties:
warehouseId: { type: integer, format: int64 }
containerNo: { type: string, maxLength: 32 }
boxCount: { type: integer, minimum: 1 }
outboundTime: { type: string, format: date-time }
status: { type: string, enum: [PENDING, COMPLETED] }
remark: { type: string, maxLength: 500 }
idempotencyKey: { type: string, maxLength: 128 }
inventoryVersion: { type: integer, minimum: 0 }
ApiEnvelope:
type: object
required: [code, message, timestamp]
properties:
code: { type: string, example: '200' }
message: { type: string }
requestId: { type: string, nullable: true }
traceId: { type: string, nullable: true }
timestamp: { type: string, format: date-time }
CreateResponse:
allOf:
- { $ref: '#/components/schemas/ApiEnvelope' }
- type: object
properties:
data:
type: object
properties:
id: { type: integer, format: int64 }
orderNo: { type: string }
replayed: { type: boolean }
OrderView:
type: object
properties:
id: { type: integer, format: int64 }
businessType: { type: string, enum: [INBOUND, OUTBOUND] }
orderNo: { type: string }
warehouseId: { type: integer, format: int64 }
warehouseName: { type: string }
containerNo: { type: string }
cargoName: { type: string }
boxCount: { type: integer }
volumeCbm: { type: number }
operationTime: { type: string, format: date-time }
status: { type: string, enum: [PENDING, COMPLETED] }
remark: { type: string, nullable: true }
version: { type: integer }
createdAt: { type: string, format: date-time }
InventoryView:
type: object
properties:
id: { type: integer, format: int64 }
warehouseId: { type: integer, format: int64 }
warehouseName: { type: string }
containerNo: { type: string }
cargoName: { type: string }
inboundBoxes: { type: integer }
outboundBoxes: { type: integer }
availableBoxes: { type: integer }
inboundVolumeCbm: { type: number }
inventoryStatus: { type: string, enum: [IN_STOCK, PARTIALLY_OUTBOUND, OUTBOUND] }
version: { type: integer }
updatedAt: { type: string, format: date-time }
TransactionView:
type: object
properties:
id: { type: integer, format: int64 }
businessType: { type: string, enum: [INBOUND, OUTBOUND] }
orderNo: { type: string }
quantity: { type: integer }
operationTime: { type: string, format: date-time }
remark: { type: string, nullable: true }
OrderPageResponse:
allOf:
- { $ref: '#/components/schemas/ApiEnvelope' }
- type: object
properties:
data:
type: object
properties:
records: { type: array, items: { $ref: '#/components/schemas/OrderView' } }
page: { type: integer }
size: { type: integer }
total: { type: integer }
pages: { type: integer }
InventoryPageResponse:
allOf:
- { $ref: '#/components/schemas/ApiEnvelope' }
- type: object
properties:
data:
type: object
properties:
records: { type: array, items: { $ref: '#/components/schemas/InventoryView' } }
page: { type: integer }
size: { type: integer }
total: { type: integer }
pages: { type: integer }
summary:
type: object
properties:
inboundBoxes: { type: integer }
outboundBoxes: { type: integer }
availableBoxes: { type: integer }
inboundVolumeCbm: { type: number }
DetailResponse:
allOf:
- { $ref: '#/components/schemas/ApiEnvelope' }
- type: object
properties:
data:
type: object
properties:
inventory: { $ref: '#/components/schemas/InventoryView' }
inboundOrderCount: { type: integer }
outboundOrderCount: { type: integer }
transactions: { type: array, items: { $ref: '#/components/schemas/TransactionView' } }
@@ -0,0 +1,238 @@
# 平台供应商前端接口交接单
## 接入约定
- Base path:`/api/platform/v1/suppliers`
- 浏览器请求继续使用 Gateway `/api` 前缀,不在前端硬编码服务地址。
- 仅平台端账号可访问,供应商固定属于平台公共作用域 `tenantId=0`。
- 供应商是独立业务档案,不复用或修改往来单位 Partner。
- 所有接口使用统一 `ApiResponse<T>`:
```json
{
"code": "200",
"message": "success",
"data": {},
"requestId": null,
"traceId": null,
"timestamp": "2026-08-13T03:00:00Z"
}
```
- 普通业务失败、参数校验失败通常仍返回 HTTP 200,前端必须以 body `code === "200"` 判断业务成功。认证、网关或基础设施拒绝仍可能返回真实 HTTP 401、403、503。
- Java `Long` 标识在 JSON 中按字符串消费,包括 `id`、`tenantId`、`createdBy`、`updatedBy`,不得转成 JavaScript `number`。
## 接口清单
| 功能 | 方法与路径 | 权限码 |
|---|---|---|
| 列表 | `GET /api/platform/v1/suppliers` | `platform:supplier:list` |
| 分类统计 | `GET /api/platform/v1/suppliers/stats` | `platform:supplier:list` |
| 详情 | `GET /api/platform/v1/suppliers/{id}?tenantId=0` | `platform:supplier:list` |
| 新增 | `POST /api/platform/v1/suppliers?tenantId=0` | `platform:supplier:create` |
| 编辑 | `PUT /api/platform/v1/suppliers/{id}?tenantId=0` | `platform:supplier:edit` |
| 启停 | `PATCH /api/platform/v1/suppliers/{id}/status?tenantId=0` | `platform:supplier:status` |
列表和统计的 `tenantId` 可省略;传入时只能是 `0`。详情、新增、编辑、启停当前要求显式传 `tenantId=0`。
## 列表查询
查询参数:
| 参数 | 类型 | 必填 | 说明 |
|---|---|---|---|
| `tenantId` | string | 否 | 只能为 `0` |
| `page` | integer | 否 | 默认 `1`,最小 `1` |
| `size` | integer | 否 | 默认 `20`,范围 `1..200` |
| `category` | enum | 否 | 供应商分类 |
| `scope` | enum | 否 | 仅订舱分类适用 |
| `region` | string | 否 | 地区精确匹配,最长 80 |
| `status` | enum | 否 | 合作状态 |
| `keyword` | string | 否 | 按供应商名称或联系人模糊匹配 |
分页响应 `data`:
```json
{
"records": [],
"page": 1,
"size": 20,
"total": 0,
"pages": 0
}
```
`records` 元素使用下述 Supplier 字段。`page/size/total/pages` 是整数分页元数据。
## Supplier 字段
| 字段 | 类型 | 说明 |
|---|---|---|
| `id` | string | 供应商 ID |
| `tenantId` | string | 固定为 `0` |
| `name` | string | 供应商名称,平台内唯一 |
| `category` | enum | 分类 |
| `scope` | enum/null | 订舱供应商必填;其他分类为 `null` |
| `region` | string | 地区 |
| `legalRepresentative` | string | 法人代表 |
| `contactName` | string | 联系人 |
| `phone` | string | 联系电话 |
| `email` | string/null | 电子邮箱 |
| `address` | string/null | 公司地址 |
| `businessLicenseNo` | string/null | 营业执照号/统一社会信用代码 |
| `licenseFileId` | string/null | File 服务返回的营业执照文件 ID,只允许十进制数字字符串 |
| `status` | enum | 合作状态 |
| `settlementPeriod` | enum | 结算周期 |
| `remark` | string/null | 备注 |
| `createdBy` | string/null | 创建人 ID |
| `createdAt` | datetime | 创建时间 |
| `updatedBy` | string/null | 最后修改人 ID |
| `updatedAt` | datetime | 最后修改时间 |
| `deleted` | boolean | 逻辑删除标记;正常响应为 `false` |
| `version` | integer | 乐观锁版本 |
营业执照上传由 File 服务完成。Supplier 新增或编辑只提交 `licenseFileId`;查看操作使用该 ID获取文件,不把 URL、base64 或文件二进制提交到 Supplier 接口。原型要求营业执照查看由前端使用居中弹框承载。
## 新增与编辑
新增和编辑使用相同请求体:
```json
{
"name": "示例供应商有限公司",
"category": "BOOKING",
"scope": "DOMESTIC",
"region": "宁波",
"legalRepresentative": "张三",
"contactName": "李经理",
"phone": "13800000000",
"email": "supplier@example.com",
"address": "宁波市示例路 1 号",
"businessLicenseNo": "91330000XXXXXXXXXX",
"licenseFileId": "192000000000000001",
"status": "ACTIVE",
"settlementPeriod": "MONTHLY_30",
"remark": ""
}
```
必填字段:`name`、`category`、`region`、`legalRepresentative`、`contactName`、`phone`、`status`、`settlementPeriod`。`BOOKING` 时 `scope` 必填;其他分类 `scope` 必须传 `null` 或省略。
新增成功的 `data` 是供应商 ID 字符串。编辑成功的 `data` 为 `null`。
## 启停
```json
{ "status": "INACTIVE" }
```
只接受 `ACTIVE` 或 `INACTIVE`。接口不物理删除供应商。
## 分类统计
`GET /stats` 的响应 `data`:
```json
{
"booking": 8,
"customs": 5,
"trucking": 6,
"certificate": 3
}
```
## 枚举
供应商分类:
| 值 | 展示文案 |
|---|---|
| `BOOKING` | 订舱 |
| `CUSTOMS` | 报关 |
| `TRUCKING` | 拖车 |
| `CERTIFICATE` | 证书 |
订舱范围:
| 值 | 展示文案 |
|---|---|
| `DOMESTIC` | 国内 |
| `INTERNATIONAL` | 国外 |
合作状态:
| 值 | 展示文案 |
|---|---|
| `ACTIVE` | 合作中 |
| `INACTIVE` | 停用 |
结算周期:
| 值 | 展示文案 |
|---|---|
| `MONTHLY_30` | 月结30天 |
| `MONTHLY_45` | 月结45天 |
| `MONTHLY_60` | 月结60天 |
| `MONTHLY_90` | 月结90天 |
| `PER_TRANSACTION` | 票结 |
| `IMMEDIATE` | 现结 |
## 错误码
| `code` | 场景 | 前端处理建议 |
|---|---|---|
| `200` | 成功 | 使用 `data` |
| `A0400` | `tenantId` 非 0、请求格式或查询参数非法 | 显示参数错误,不重试 |
| `A0401` | 未登录或登录态失效 | 进入统一登录失效流程 |
| `A0403` | 非平台用户或缺少对应权限 | 显示无权限 |
| `A0404` | 供应商不存在 | 提示记录不存在并刷新列表 |
| `A0409` | 供应商名称重复或并发唯一键冲突 | 保留表单并提示名称已存在 |
| `A0422` | 字段校验失败、订舱范围规则不满足 | 使用 `message`/`data` 显示字段错误 |
| `S0500` | 未预期服务异常 | 展示通用错误并记录 `requestId/traceId` |
| `S0503` | Warehouse 数据库不可用 | 展示稍后重试并记录 `requestId/traceId` |
校验错误时 `data` 可能为字段到错误信息的映射,例如:
```json
{
"code": "A0422",
"message": "name: must not be blank",
"data": { "name": "must not be blank" },
"requestId": "...",
"traceId": "...",
"timestamp": "..."
}
```
## 契约来源
- OpenAPI:`warehouse/src/main/resources/openapi/supplier-v1.yaml`
- Controller:`warehouse/src/main/java/com/baiyue/warehouse/controller/PlatformSupplierController.java`
- DTO:`warehouse/src/main/java/com/baiyue/warehouse/dto/SupplierDtos.java`
权限码已在接口层冻结,但 IAM 权限初始化不属于本次 warehouse 文件所有权,联调前需确认 IAM 已落库并授予目标平台角色。
## 初始化与验收
- Warehouse 模块表迁移:`warehouse/src/main/resources/db/warehouse-supplier.sql`
- IAM 权限迁移:`iam/src/main/resources/db/platform-supplier-permissions.sql`
- 测试/存量环境唯一执行入口:`deploy/sql/头程数据库统一增量-MySQL8.sql`
- 权限固定 ID:`5103080..5103083`,依次对应 `list/create/edit/status`;`PLATFORM_ADMIN` 默认获得全部四项权限。
- Admin 测试环境验收模块:`scripts/admin-test-env-modules/suppliers.mjs`
- 验收模块静态契约:`scripts/admin-test-env-modules/suppliers-contract.test.mjs`
离线验证:
```powershell
mvn -pl warehouse,iam -am test
node --test scripts/admin-test-env-modules/suppliers-contract.test.mjs
pwsh scripts/local-db-full-acceptance.ps1 -DryRun
```
仅在当前对话已明确授权测试环境 API 验收后,才能运行下列命令。该模块会新增一条带本轮 marker 的供应商档案,并在结束时停用;由于供应商契约不提供删除接口,记录会保留为 `INACTIVE`。
```powershell
node scripts/admin-test-env-acceptance.mjs --modules suppliers
```
模块覆盖列表、统计、新增、详情、编辑、启停、重复名称、非法 scope、客户账号权限拒绝、`tenantId != 0` 和错误响应 `requestId/traceId`。本轮代码交付不执行该测试环境命令。
@@ -0,0 +1,84 @@
openapi: 3.0.3
info: { title: Platform Supplier API, version: 1.0.0 }
paths:
/api/platform/v1/suppliers:
get:
summary: 分页查询平台公共供应商
description: 权限 platform:supplier:list;tenantId 仅兼容 0。
security: [{ bearerAuth: [] }]
parameters:
- { $ref: '#/components/parameters/TenantId' }
- { name: page, in: query, schema: { type: integer, minimum: 1, default: 1 } }
- { name: size, in: query, schema: { type: integer, minimum: 1, maximum: 200, default: 20 } }
- { name: category, in: query, schema: { $ref: '#/components/schemas/Category' } }
- { name: scope, in: query, description: 仅订舱供应商适用, schema: { $ref: '#/components/schemas/Scope' } }
- { name: region, in: query, schema: { type: string } }
- { name: status, in: query, schema: { $ref: '#/components/schemas/Status' } }
- { name: keyword, in: query, description: 供应商名称或联系人, schema: { type: string } }
responses: { '200': { description: 统一 ApiResponse 包装的分页结果 } }
post:
summary: 新增供应商
description: 权限 platform:supplier:create。
security: [{ bearerAuth: [] }]
parameters: [{ $ref: '#/components/parameters/RequiredTenantId' }]
requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/SupplierSave' } } } }
responses: { '200': { description: 统一 ApiResponse 包装的供应商 ID } }
/api/platform/v1/suppliers/stats:
get:
summary: 查询四类供应商统计
description: 权限 platform:supplier:list。
security: [{ bearerAuth: [] }]
parameters: [{ $ref: '#/components/parameters/TenantId' }]
responses: { '200': { description: booking/customs/trucking/certificate 计数 } }
/api/platform/v1/suppliers/{id}:
parameters: [{ name: id, in: path, required: true, schema: { type: integer, format: int64 } }]
get:
summary: 查询供应商详情
description: 权限 platform:supplier:list。
security: [{ bearerAuth: [] }]
parameters: [{ $ref: '#/components/parameters/RequiredTenantId' }]
responses: { '200': { description: 统一 ApiResponse 包装的 Supplier } }
put:
summary: 编辑供应商
description: 权限 platform:supplier:edit。
security: [{ bearerAuth: [] }]
parameters: [{ $ref: '#/components/parameters/RequiredTenantId' }]
requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/SupplierSave' } } } }
responses: { '200': { description: 成功 } }
/api/platform/v1/suppliers/{id}/status:
parameters: [{ name: id, in: path, required: true, schema: { type: integer, format: int64 } }]
patch:
summary: 启用或停用供应商
description: 权限 platform:supplier:status;不物理删除。
security: [{ bearerAuth: [] }]
parameters: [{ $ref: '#/components/parameters/RequiredTenantId' }]
requestBody: { required: true, content: { application/json: { schema: { type: object, required: [status], properties: { status: { $ref: '#/components/schemas/Status' } } } } } }
responses: { '200': { description: 成功 } }
components:
parameters:
TenantId: { name: tenantId, in: query, required: false, schema: { type: integer, format: int64, enum: [0] } }
RequiredTenantId: { name: tenantId, in: query, required: true, schema: { type: integer, format: int64, enum: [0] } }
schemas:
Category: { type: string, enum: [BOOKING, CUSTOMS, TRUCKING, CERTIFICATE] }
Scope: { type: string, enum: [DOMESTIC, INTERNATIONAL] }
Status: { type: string, enum: [ACTIVE, INACTIVE] }
SupplierSave:
type: object
required: [name, category, region, legalRepresentative, contactName, phone, status, settlementPeriod]
properties:
name: { type: string, maxLength: 160 }
category: { $ref: '#/components/schemas/Category' }
scope: { $ref: '#/components/schemas/Scope', description: BOOKING 必填,其他分类必须为空 }
region: { type: string, maxLength: 80 }
legalRepresentative: { type: string, maxLength: 80 }
contactName: { type: string, maxLength: 80 }
phone: { type: string, maxLength: 40 }
email: { type: string, format: email, maxLength: 160 }
address: { type: string, maxLength: 255 }
businessLicenseNo: { type: string, maxLength: 64 }
licenseFileId: { type: string, pattern: '^\d+$', maxLength: 128, description: File 服务文件引用 }
status: { $ref: '#/components/schemas/Status' }
settlementPeriod: { type: string, enum: [MONTHLY_30, MONTHLY_45, MONTHLY_60, MONTHLY_90, PER_TRANSACTION, IMMEDIATE] }
remark: { type: string, maxLength: 500 }
securitySchemes:
bearerAuth: { type: http, scheme: bearer, bearerFormat: JWT }
@@ -0,0 +1,25 @@
package com.baiyue.warehouse.controller;
import org.junit.jupiter.api.Test;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.security.access.prepost.PreAuthorize;
import java.lang.reflect.Method;
import static org.assertj.core.api.Assertions.assertThat;
class PlatformSupplierControllerContractTest {
@Test void exposesPublicScopeAndSeparatedRoutes() {
Method page = method("page");
assertThat(page.getParameters()[1].getAnnotation(RequestParam.class).required()).isFalse();
assertThat(PlatformSupplierController.class.getAnnotation(RequestMapping.class).value()).containsExactly("/api/platform/v1/suppliers");
}
@Test void freezesSupplierPermissions() {
assertThat(method("page").getAnnotation(PreAuthorize.class).value()).isEqualTo("hasAuthority('platform:supplier:list')");
assertThat(method("stats").getAnnotation(PreAuthorize.class).value()).isEqualTo("hasAuthority('platform:supplier:list')");
assertThat(method("detail").getAnnotation(PreAuthorize.class).value()).isEqualTo("hasAuthority('platform:supplier:list')");
assertThat(method("create").getAnnotation(PreAuthorize.class).value()).contains("platform:supplier:create");
assertThat(method("update").getAnnotation(PreAuthorize.class).value()).contains("platform:supplier:edit");
assertThat(method("status").getAnnotation(PreAuthorize.class).value()).contains("platform:supplier:status");
}
private static Method method(String n) { return java.util.Arrays.stream(PlatformSupplierController.class.getDeclaredMethods()).filter(m -> m.getName().equals(n)).findFirst().orElseThrow(); }
}
@@ -0,0 +1,34 @@
package com.baiyue.warehouse.domesticcontainer.controller;
import org.junit.jupiter.api.Test;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import java.lang.annotation.Annotation;
import java.lang.reflect.Method;
import java.util.Arrays;
import static org.assertj.core.api.Assertions.assertThat;
class PlatformDomesticContainerControllerContractTest {
@Test
void exposesOnlyDedicatedPlatformContract() {
assertThat(PlatformDomesticContainerController.class.getAnnotation(RequestMapping.class).value())
.containsExactly("/api/platform/v1/domestic-containers");
assertMethod("inbounds", GetMapping.class, "platform:domestic-container:list");
assertMethod("outbounds", GetMapping.class, "platform:domestic-container:list");
assertMethod("inventories", GetMapping.class, "platform:domestic-container:list");
assertMethod("detail", GetMapping.class, "platform:domestic-container:list");
assertMethod("createInbound", PostMapping.class, "platform:domestic-container:inbound:create");
assertMethod("createOutbound", PostMapping.class, "platform:domestic-container:outbound:create");
}
private static void assertMethod(String name, Class<? extends Annotation> mapping, String permission) {
Method method = Arrays.stream(PlatformDomesticContainerController.class.getDeclaredMethods())
.filter(candidate -> candidate.getName().equals(name)).findFirst().orElseThrow();
assertThat(method.getAnnotation(mapping)).isNotNull();
assertThat(method.getAnnotation(PreAuthorize.class).value()).isEqualTo("hasAuthority('" + permission + "')");
}
}
@@ -0,0 +1,24 @@
package com.baiyue.warehouse.domesticcontainer.repository;
import org.junit.jupiter.api.Test;
import java.nio.file.Files;
import java.nio.file.Path;
import static org.assertj.core.api.Assertions.assertThat;
class DomesticContainerSqlContractTest {
@Test
void migrationKeepsDedicatedInventoryAndIdempotencyBoundaries() throws Exception {
String sql = Files.readString(Path.of("src/main/resources/db/domestic-container-business.sql"));
assertThat(sql).contains("wh_domestic_container_order", "wh_domestic_container_inventory",
"wh_domestic_container_transaction", "wh_domestic_container_idempotency");
assertThat(sql).contains("uk_dc_inventory_container", "chk_dc_inventory_balance", "uk_dc_idempotency");
assertThat(sql).contains("CREATE TABLE IF NOT EXISTS", "version INT NOT NULL DEFAULT 0",
"uk_dc_order_no", "uk_dc_transaction_order", "request_hash CHAR(64)",
"UNIQUE KEY uk_dc_idempotency (tenant_id, operation_type, idempotency_key)",
"chk_dc_order_status", "chk_dc_idempotency_status");
assertThat(sql).doesNotContain("DROP TABLE", "TRUNCATE TABLE", "DELETE FROM");
assertThat(sql).doesNotContain("fl_container", "fl_loading", "fl_pda", "pda_");
}
}
@@ -0,0 +1,96 @@
package com.baiyue.warehouse.domesticcontainer.service;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.warehouse.domesticcontainer.dto.DomesticContainerDtos;
import com.baiyue.warehouse.domesticcontainer.entity.DomesticContainerIdempotencyEntity;
import com.baiyue.warehouse.domesticcontainer.entity.DomesticContainerInventoryEntity;
import com.baiyue.warehouse.domesticcontainer.repository.DomesticContainerRepository;
import com.baiyue.warehouse.entity.WarehouseEntity;
import com.baiyue.warehouse.repository.WarehouseRepository;
import jakarta.annotation.Resource;
import org.junit.jupiter.api.Test;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.test.context.junit.jupiter.SpringJUnitConfig;
import java.math.BigDecimal;
import java.time.LocalDateTime;
import static org.assertj.core.api.Assertions.*;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.*;
@SpringJUnitConfig(DomesticContainerService.class)
class DomesticContainerServiceTest {
@MockBean DomesticContainerRepository repository;
@MockBean WarehouseRepository warehouses;
@Resource DomesticContainerService service;
@Test
void completedInboundCreatesInventoryAndTransaction() {
when(warehouses.findActiveOwned(0L, 10L)).thenReturn(warehouse());
when(repository.claim(anyLong(), eq("INBOUND"), eq("in-1"), any())).thenReturn(1);
when(repository.completeClaim(eq("INBOUND"), eq("in-1"), any(), anyLong(), any())).thenReturn(1);
when(repository.lockInventory(10L, "MSCU1234567")).thenReturn(null);
var result = service.createInbound(new DomesticContainerDtos.CreateInboundRequest(10L, "家居用品", "mscu1234567", 120,
new BigDecimal("58.6"), LocalDateTime.of(2026,8,12,9,30), "COMPLETED", "整柜到仓", "in-1"));
assertThat(result.replayed()).isFalse();
verify(repository).insertInventory(any());
verify(repository).insertTransaction(any());
verify(repository).lockWarehouse(10L);
}
@Test
void pendingInboundDoesNotChangeInventory() {
when(warehouses.findActiveOwned(0L, 10L)).thenReturn(warehouse());
when(repository.claim(anyLong(), eq("INBOUND"), eq("in-2"), any())).thenReturn(1);
when(repository.completeClaim(eq("INBOUND"), eq("in-2"), any(), anyLong(), any())).thenReturn(1);
service.createInbound(new DomesticContainerDtos.CreateInboundRequest(10L, "家居用品", "MSCU1234567", 120,
new BigDecimal("58.6"), LocalDateTime.now(), "PENDING", null, "in-2"));
verify(repository, never()).insertInventory(any());
verify(repository, never()).insertTransaction(any());
}
@Test
void completedOutboundCreatesPartialOutboundBalance() {
when(warehouses.findActiveOwned(0L, 10L)).thenReturn(warehouse());
when(repository.claim(anyLong(), eq("OUTBOUND"), eq("out-1"), any())).thenReturn(1);
when(repository.completeClaim(eq("OUTBOUND"), eq("out-1"), any(), anyLong(), any())).thenReturn(1);
DomesticContainerInventoryEntity inventory = inventory(120, 0, 120, 3);
when(repository.lockInventory(10L, "MSCU1234567")).thenReturn(inventory);
when(repository.updateInventory(inventory)).thenReturn(1);
service.createOutbound(new DomesticContainerDtos.CreateOutboundRequest(10L, "MSCU1234567", 30,
LocalDateTime.now(), "COMPLETED", "客户提货", "out-1", 3));
assertThat(inventory.getOutboundBoxes()).isEqualTo(30);
assertThat(inventory.getAvailableBoxes()).isEqualTo(90);
assertThat(inventory.getInventoryStatus()).isEqualTo("PARTIALLY_OUTBOUND");
verify(repository).insertTransaction(any());
}
@Test
void rejectsOutboundBeyondAvailableInventory() {
when(warehouses.findActiveOwned(0L, 10L)).thenReturn(warehouse());
when(repository.claim(anyLong(), eq("OUTBOUND"), eq("out-2"), any())).thenReturn(1);
when(repository.lockInventory(10L, "MSCU1234567")).thenReturn(inventory(120, 100, 20, 4));
assertThatThrownBy(() -> service.createOutbound(new DomesticContainerDtos.CreateOutboundRequest(10L,
"MSCU1234567", 21, LocalDateTime.now(), "COMPLETED", null, "out-2", 4))).isInstanceOf(BizException.class);
verify(repository, never()).insertOrder(any());
}
@Test
void replaysCompletedIdempotentRequestWithoutWriting() {
DomesticContainerIdempotencyEntity replay = new DomesticContainerIdempotencyEntity();
replay.setRequestHash(hashForInbound()); replay.setOperationStatus("COMPLETED"); replay.setResourceId(99L); replay.setResourceNo("ZGRK1");
when(repository.claim(anyLong(), eq("INBOUND"), eq("same"), any())).thenReturn(0);
when(repository.findClaim("INBOUND", "same")).thenReturn(replay);
var result = service.createInbound(inboundForReplay());
assertThat(result).isEqualTo(new DomesticContainerDtos.CreateResult(99L, "ZGRK1", true));
verifyNoInteractions(warehouses);
}
private DomesticContainerDtos.CreateInboundRequest inboundForReplay() { return new DomesticContainerDtos.CreateInboundRequest(10L,"家居用品","MSCU1234567",120,new BigDecimal("58.6"),LocalDateTime.of(2026,8,12,9,30),"COMPLETED","整柜到仓","same"); }
private String hashForInbound() {
try { var digest=java.security.MessageDigest.getInstance("SHA-256"); return java.util.HexFormat.of().formatHex(digest.digest(inboundForReplay().toString().getBytes(java.nio.charset.StandardCharsets.UTF_8))); } catch(Exception e) { throw new RuntimeException(e); }
}
private static WarehouseEntity warehouse() { WarehouseEntity w=new WarehouseEntity(); w.setId(10L); w.setTenantId(0L); w.setWarehouseName("宁波国内仓"); w.setWarehouseType("DOMESTIC"); w.setStatus("ACTIVE"); return w; }
private static DomesticContainerInventoryEntity inventory(int in,int out,int available,int version) { DomesticContainerInventoryEntity i=new DomesticContainerInventoryEntity(); i.setId(20L); i.setWarehouseId(10L); i.setWarehouseName("宁波国内仓"); i.setContainerNo("MSCU1234567"); i.setCargoName("家居用品"); i.setInboundBoxes(in); i.setOutboundBoxes(out); i.setAvailableBoxes(available); i.setInboundVolumeCbm(new BigDecimal("58.6")); i.setInventoryStatus("IN_STOCK"); i.setVersion(version); return i; }
}
@@ -0,0 +1,35 @@
package com.baiyue.warehouse.repository;
import org.junit.jupiter.api.Test;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import static org.assertj.core.api.Assertions.assertThat;
class SupplierSqlContractTest {
@Test void freezesPublicSupplierSchemaAndBookingScope() throws IOException {
try (var stream = getClass().getResourceAsStream("/db/warehouse-supplier.sql")) {
assertThat(stream).isNotNull();
String sql = new String(stream.readAllBytes(), StandardCharsets.UTF_8);
assertThat(sql).contains("CREATE TABLE IF NOT EXISTS wh_supplier", "tenant_id BIGINT UNSIGNED NOT NULL DEFAULT 0",
"category='BOOKING' AND scope IN ('DOMESTIC','INTERNATIONAL')", "license_file_id VARCHAR(128)",
"MONTHLY_30','MONTHLY_45','MONTHLY_60','MONTHLY_90','PER_TRANSACTION','IMMEDIATE",
"created_by BIGINT UNSIGNED", "updated_by BIGINT UNSIGNED",
"supplier_add_column_if_missing", "uk_wh_supplier_name", "idx_wh_supplier_filter",
"assert_supplier_schema");
}
}
@Test void unifiedMigrationContainsSupplierSchemaAndPermissionContract() throws IOException {
String sql = Files.readString(resolveUnifiedSql(), StandardCharsets.UTF_8);
assertThat(sql).contains("CREATE TABLE IF NOT EXISTS wh_supplier", "uk_wh_supplier_name",
"idx_wh_supplier_filter", "platform:supplier:list", "platform:supplier:create",
"platform:supplier:edit", "platform:supplier:status", "assert_platform_supplier_permissions");
}
private static Path resolveUnifiedSql() {
Path direct = Path.of("deploy", "sql", "头程数据库统一增量-MySQL8.sql");
return Files.exists(direct) ? direct : Path.of("..", "deploy", "sql", "头程数据库统一增量-MySQL8.sql");
}
}
@@ -0,0 +1,49 @@
package com.baiyue.warehouse.service;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.warehouse.dto.SupplierDtos.SaveRequest;
import com.baiyue.warehouse.entity.SupplierEntity;
import com.baiyue.warehouse.repository.SupplierRepository;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class)
class SupplierServiceTest {
@Mock private SupplierRepository repository;
@InjectMocks private SupplierService service;
private SaveRequest booking;
@BeforeEach void setUp() {
booking = new SaveRequest("测试供应商", "BOOKING", "DOMESTIC", "宁波", "法人", "联系人",
"13800000000", "supplier@example.com", "地址", "91330000TEST", "192000000000000001",
"ACTIVE", "MONTHLY_30", null);
}
@Test void createsPlatformPublicBookingSupplier() {
when(repository.existsName(0L, "测试供应商", null)).thenReturn(false);
service.create(booking);
verify(repository).insert(any(SupplierEntity.class));
}
@Test void rejectsScopeForNonBookingSupplier() {
SaveRequest customs = new SaveRequest("报关供应商", "CUSTOMS", "DOMESTIC", "宁波", "法人", "联系人",
"13800000000", null, null, null, null, "ACTIVE", "PER_TRANSACTION", null);
assertThatThrownBy(() -> service.create(customs)).isInstanceOf(BizException.class)
.hasMessageContaining("仅适用于订舱供应商");
}
@Test void requiresScopeForBookingSupplier() {
SaveRequest missingScope = new SaveRequest("订舱供应商", "BOOKING", null, "宁波", "法人", "联系人",
"13800000000", null, null, null, null, "ACTIVE", "PER_TRANSACTION", null);
assertThatThrownBy(() -> service.create(missingScope)).isInstanceOf(BizException.class);
}
}