优化
This commit is contained in:
@@ -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); }
|
||||
|
||||
+2
-2
@@ -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) {
|
||||
|
||||
+43
@@ -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"); }
|
||||
}
|
||||
+68
@@ -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, "仅允许平台端访问");
|
||||
}
|
||||
}
|
||||
+95
@@ -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) {
|
||||
}
|
||||
}
|
||||
+26
@@ -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;
|
||||
}
|
||||
+28
@@ -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;
|
||||
}
|
||||
+31
@@ -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;
|
||||
}
|
||||
+28
@@ -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;
|
||||
}
|
||||
+23
@@ -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);
|
||||
}
|
||||
+15
@@ -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);
|
||||
}
|
||||
+7
@@ -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> {
|
||||
}
|
||||
+7
@@ -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> {
|
||||
}
|
||||
+108
@@ -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); }
|
||||
}
|
||||
+115
@@ -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()); }
|
||||
}
|
||||
+5
-1
@@ -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 }
|
||||
+25
@@ -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(); }
|
||||
}
|
||||
+34
@@ -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 + "')");
|
||||
}
|
||||
}
|
||||
+24
@@ -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_");
|
||||
}
|
||||
}
|
||||
+96
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user