feat(warehouse): support supplier carrier associations

This commit is contained in:
2026-08-27 10:29:28 +08:00
parent 554b6c93b7
commit 079ff43f73
17 changed files with 562 additions and 19 deletions
@@ -11,6 +11,7 @@ import org.springframework.beans.factory.annotation.Value;
import org.springframework.web.bind.annotation.*;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.util.List;
@RestController
@RequestMapping("/internal/warehouse/v1/suppliers")
@@ -23,8 +24,18 @@ public class InternalSupplierController {
public ApiResponse<InternalSnapshot> get(@RequestHeader(value="X-Internal-Service-Token", required=false) String token,
@PathVariable long supplierId, @RequestParam(defaultValue="false") boolean requireActive,
@RequestParam(required=false) String category) {
if (token == null || !MessageDigest.isEqual(expected, token.getBytes(StandardCharsets.UTF_8))) throw new BizException(ResultCode.FORBIDDEN, "内部服务凭证无效");
requireToken(token);
if (category != null && !category.matches("BOOKING|CUSTOMS|TRUCKING|CERTIFICATE")) throw new BizException(ResultCode.VALIDATION_FAILED, "供应商类别无效");
return ApiResponse.success(service.internalSnapshot(supplierId, requireActive, category));
}
@GetMapping
public ApiResponse<List<InternalSnapshot>> candidates(@RequestHeader(value="X-Internal-Service-Token", required=false) String token,
@RequestParam String category, @RequestParam String carrierCode) {
requireToken(token);
if (!"BOOKING".equals(category)) throw new BizException(ResultCode.VALIDATION_FAILED, "内部候选仅支持BOOKING供应商");
return ApiResponse.success(service.internalBookingCandidates(carrierCode));
}
private void requireToken(String token) { if (token == null || !MessageDigest.isEqual(expected, token.getBytes(StandardCharsets.UTF_8))) throw new BizException(ResultCode.FORBIDDEN, "内部服务凭证无效"); }
}
@@ -6,7 +6,7 @@ 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.SupplierImportExportService;
import com.baiyue.warehouse.service.SupplierService;
import com.baomidou.mybatisplus.core.toolkit.IdWorker;
import io.swagger.v3.oas.annotations.Operation;
@@ -19,6 +19,14 @@ 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.*;
import org.springframework.web.multipart.MultipartFile;
import org.springframework.http.ContentDisposition;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
@Validated
@RestController
@@ -26,13 +34,40 @@ import org.springframework.web.bind.annotation.*;
@RequestMapping("/api/platform/v1/suppliers")
public class PlatformSupplierController {
@Resource private SupplierService service;
@Resource private SupplierImportExportService transferService;
@GetMapping("/import-template") @PreAuthorize("hasAuthority('platform:supplier:create')")
public ResponseEntity<byte[]> importTemplate(@AuthenticationPrincipal AuthenticatedUser user) {
requirePlatform(user); HttpHeaders headers = new HttpHeaders();
headers.setContentDisposition(ContentDisposition.attachment().filename("supplier-import-template-v2.xlsx", StandardCharsets.UTF_8).build());
headers.setContentType(MediaType.parseMediaType("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"));
return ResponseEntity.ok().headers(headers).body(transferService.template());
}
@PostMapping(value="/import", consumes=MediaType.MULTIPART_FORM_DATA_VALUE) @PreAuthorize("hasAuthority('platform:supplier:create')")
public ApiResponse<ImportResult> importSuppliers(@AuthenticationPrincipal AuthenticatedUser user,
@RequestParam @Min(0) long tenantId, @RequestPart("file") MultipartFile file) {
requirePlatform(user); requireScope(tenantId);
try {
ImportResult result = transferService.importFile(file.getOriginalFilename(), file.getBytes());
if (!result.success()) throw new BizException(ResultCode.VALIDATION_FAILED,
result.errors().isEmpty() ? "供应商导入文件校验失败" : result.errors().get(0).message());
return ApiResponse.success(result);
} catch (IOException exception) { throw new BizException(ResultCode.BAD_REQUEST, "导入文件读取失败"); }
}
@GetMapping(value="/export", produces="text/csv;charset=UTF-8") @PreAuthorize("hasAuthority('platform:supplier:list')")
public ResponseEntity<byte[]> export(@AuthenticationPrincipal AuthenticatedUser user,
@RequestParam(required=false) @Min(0) Long tenantId, @Valid @ParameterObject PageQuery query) {
requirePlatform(user); requireScope(tenantId); HttpHeaders headers = new HttpHeaders();
headers.setContentDisposition(ContentDisposition.attachment().filename("suppliers.csv", StandardCharsets.UTF_8).build());
headers.setContentType(MediaType.parseMediaType("text/csv;charset=UTF-8"));
return ResponseEntity.ok().headers(headers).body(transferService.exportCsv(query));
}
@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)); }
public ApiResponse<PageResult<SupplierResource>> 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)); }
public ApiResponse<SupplierResource> 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); long id = IdWorker.getId(); return ApiResponse.success(service.create(id, request)); }
@PutMapping("/{id}") @PreAuthorize("hasAuthority('platform:supplier:edit')")
@@ -8,6 +8,9 @@ import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Pattern;
import jakarta.validation.constraints.Size;
import java.time.LocalDateTime;
import java.util.List;
public final class SupplierDtos {
private SupplierDtos() {}
@@ -15,7 +18,8 @@ public final class SupplierDtos {
@Pattern(regexp = "BOOKING|CUSTOMS|TRUCKING|CERTIFICATE") String category,
@Size(max = 80) String region,
@Pattern(regexp = "ACTIVE|INACTIVE") String status,
String keyword) {
String keyword,
@Pattern(regexp = "MATSON|SML|ZIM|COSCO|EMC|HEDE|OTHER") String carrierCode) {
public PageQuery {
page = page == null ? 1 : page;
size = size == null ? 20 : size;
@@ -25,6 +29,7 @@ public final class SupplierDtos {
public record SaveRequest(
@NotBlank @Size(max = 160) String name,
@NotBlank @Pattern(regexp = "BOOKING|CUSTOMS|TRUCKING|CERTIFICATE") String category,
@Size(max = 7) List<@Pattern(regexp = "MATSON|SML|ZIM|COSCO|EMC|HEDE|OTHER") String> carrierCodes,
@NotBlank @Size(max = 80) String region,
@NotBlank @Size(max = 80) String legalRepresentative,
@NotBlank @Size(max = 80) String contactName,
@@ -40,5 +45,16 @@ public final class SupplierDtos {
public record StatusRequest(@NotNull @Pattern(regexp = "ACTIVE|INACTIVE") String status) {}
public record Stats(long booking, long customs, long trucking, long certificate) {}
public record InternalSnapshot(long supplierId, String name, String category, String status) {}
public record SupplierResource(long id, long tenantId, String name, String category,
List<String> carrierCodes, String region, String legalRepresentative,
String contactName, String phone, String email, String address,
String businessLicenseNo, String licenseFileId, String status,
String settlementPeriod, String remark, Long createdBy,
LocalDateTime createdAt, Long updatedBy, LocalDateTime updatedAt,
Boolean deleted, Integer version) {}
public record InternalSnapshot(long supplierId, String name, String category, String status,
List<String> carrierCodes) {}
public record ImportError(int row, String field, String message) {}
public record ImportResult(String fileName, int totalRows, int importedRows, boolean success,
List<ImportError> errors) {}
}
@@ -2,5 +2,45 @@ package com.baiyue.warehouse.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baiyue.warehouse.entity.SupplierEntity;
import org.apache.ibatis.annotations.Delete;
import org.apache.ibatis.annotations.Insert;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
public interface SupplierMapper extends BaseMapper<SupplierEntity> {}
import java.util.List;
public interface SupplierMapper extends BaseMapper<SupplierEntity> {
@Select("""
<script>
SELECT supplier_id AS supplierId, carrier_code AS carrierCode
FROM wh_supplier_carrier
WHERE tenant_id=#{tenantId}
AND supplier_id IN
<foreach collection='supplierIds' item='supplierId' open='(' separator=',' close=')'>#{supplierId}</foreach>
ORDER BY supplier_id, carrier_code
</script>
""")
List<SupplierCarrierRow> selectCarrierRows(@Param("tenantId") long tenantId,
@Param("supplierIds") List<Long> supplierIds);
@Select("""
SELECT supplier_id
FROM wh_supplier_carrier
WHERE tenant_id=#{tenantId} AND carrier_code=#{carrierCode}
ORDER BY supplier_id
""")
List<Long> selectSupplierIdsByCarrierCode(@Param("tenantId") long tenantId,
@Param("carrierCode") String carrierCode);
@Delete("DELETE FROM wh_supplier_carrier WHERE tenant_id=#{tenantId} AND supplier_id=#{supplierId}")
int deleteCarrierCodes(@Param("tenantId") long tenantId, @Param("supplierId") long supplierId);
@Insert("""
INSERT INTO wh_supplier_carrier(supplier_id,tenant_id,carrier_code,created_by,created_at)
VALUES(#{supplierId},#{tenantId},#{carrierCode},#{operatorId},CURRENT_TIMESTAMP(3))
""")
int insertCarrierCode(@Param("tenantId") long tenantId, @Param("supplierId") long supplierId,
@Param("carrierCode") String carrierCode, @Param("operatorId") Long operatorId);
record SupplierCarrierRow(long supplierId, String carrierCode) {}
}
@@ -9,16 +9,27 @@ import com.baiyue.warehouse.mapper.SupplierMapper;
import jakarta.annotation.Resource;
import org.springframework.stereotype.Repository;
import org.springframework.util.StringUtils;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
@Repository
public class SupplierRepository {
@Resource private SupplierMapper mapper;
public PageResult<SupplierEntity> page(long tenantId, PageQuery query) {
List<Long> carrierSupplierIds = StringUtils.hasText(query.carrierCode())
? mapper.selectSupplierIdsByCarrierCode(tenantId, query.carrierCode()) : List.of();
if (StringUtils.hasText(query.carrierCode()) && carrierSupplierIds.isEmpty()) {
return new PageResult<>(List.of(), query.page(), query.size(), 0, 0);
}
LambdaQueryWrapper<SupplierEntity> w = base(tenantId)
.eq(StringUtils.hasText(query.category()), SupplierEntity::getCategory, query.category())
.eq(StringUtils.hasText(query.region()), SupplierEntity::getRegion, query.region())
.eq(StringUtils.hasText(query.status()), SupplierEntity::getStatus, query.status())
.eq(StringUtils.hasText(query.carrierCode()), SupplierEntity::getCategory, "BOOKING")
.eq(StringUtils.hasText(query.carrierCode()), SupplierEntity::getStatus, "ACTIVE")
.in(StringUtils.hasText(query.carrierCode()), SupplierEntity::getId, carrierSupplierIds)
.and(StringUtils.hasText(query.keyword()), c -> c.like(SupplierEntity::getName, query.keyword())
.or().like(SupplierEntity::getContactName, query.keyword()))
.orderByAsc(SupplierEntity::getName).orderByAsc(SupplierEntity::getId);
@@ -33,5 +44,17 @@ public class SupplierRepository {
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); }
public Map<Long, List<String>> carrierCodes(long tenantId, List<Long> supplierIds) {
if (supplierIds == null || supplierIds.isEmpty()) return Map.of();
Map<Long, List<String>> result = new LinkedHashMap<>();
for (SupplierMapper.SupplierCarrierRow row : mapper.selectCarrierRows(tenantId, supplierIds)) {
result.computeIfAbsent(row.supplierId(), ignored -> new java.util.ArrayList<>()).add(row.carrierCode());
}
return result;
}
public void replaceCarrierCodes(long tenantId, long supplierId, List<String> carrierCodes, Long operatorId) {
mapper.deleteCarrierCodes(tenantId, supplierId);
carrierCodes.forEach(code -> mapper.insertCarrierCode(tenantId, supplierId, code, operatorId));
}
private LambdaQueryWrapper<SupplierEntity> base(long tenantId) { return new LambdaQueryWrapper<SupplierEntity>().eq(SupplierEntity::getTenantId, tenantId); }
}
@@ -0,0 +1,141 @@
package com.baiyue.warehouse.service;
import com.baomidou.mybatisplus.core.toolkit.IdWorker;
import com.baiyue.warehouse.dto.SupplierDtos.ImportError;
import com.baiyue.warehouse.dto.SupplierDtos.ImportResult;
import com.baiyue.warehouse.dto.SupplierDtos.PageQuery;
import com.baiyue.warehouse.dto.SupplierDtos.SaveRequest;
import com.baiyue.warehouse.dto.SupplierDtos.SupplierResource;
import com.baiyue.warehouse.repository.SupplierRepository;
import jakarta.annotation.Resource;
import jakarta.validation.ConstraintViolation;
import jakarta.validation.Validator;
import org.apache.poi.ss.usermodel.Cell;
import org.apache.poi.ss.usermodel.DataFormatter;
import org.apache.poi.ss.usermodel.Row;
import org.apache.poi.xssf.usermodel.XSSFSheet;
import org.apache.poi.xssf.usermodel.XSSFWorkbook;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Locale;
import java.util.Set;
@Service
public class SupplierImportExportService {
private static final int MAX_FILE_BYTES = 5 * 1024 * 1024;
private static final int MAX_ROWS = 1000;
private static final String TEMPLATE_VERSION = "2";
private static final Set<String> CATEGORIES = Set.of("BOOKING", "CUSTOMS", "TRUCKING", "CERTIFICATE");
private static final Set<String> CARRIER_CODES = Set.of("MATSON", "SML", "ZIM", "COSCO", "EMC", "HEDE", "OTHER");
private static final String[] HEADERS = {"供应商名称*", "供应商类别*", "船司标准编码(多值)", "地区*", "法人代表*",
"联系人*", "联系电话*", "电子邮箱", "公司地址", "营业执照号", "营业执照文件ID", "状态*", "结算周期*", "备注"};
@Resource private SupplierService supplierService;
@Resource private SupplierRepository repository;
@Resource private Validator validator;
public byte[] template() {
try (XSSFWorkbook workbook = new XSSFWorkbook(); ByteArrayOutputStream output = new ByteArrayOutputStream()) {
XSSFSheet sheet = workbook.createSheet("供应商");
Row version = sheet.createRow(0); version.createCell(0).setCellValue("模板版本"); version.createCell(1).setCellValue(TEMPLATE_VERSION);
Row instruction = sheet.createRow(1); instruction.createCell(0).setCellValue("说明");
instruction.createCell(1).setCellValue("BOOKING至少填写一个船司标准编码,多个编码使用英文逗号分隔;非BOOKING必须留空");
Row header = sheet.createRow(2);
for (int i = 0; i < HEADERS.length; i++) { header.createCell(i).setCellValue(HEADERS[i]); sheet.setColumnWidth(i, 22 * 256); }
workbook.write(output); return output.toByteArray();
} catch (IOException exception) { throw new IllegalStateException("生成供应商导入模板失败", exception); }
}
@Transactional
public ImportResult importFile(String fileName, byte[] content) {
String safeName = safeFileName(fileName);
if (content == null || content.length == 0) return failure(safeName, 0, "文件", "文件不能为空");
if (content.length > MAX_FILE_BYTES) return failure(safeName, 0, "文件", "文件不能超过5MB");
if (!safeName.toLowerCase(Locale.ROOT).endsWith(".xlsx")) return failure(safeName, 0, "文件", "只支持.xlsx文件");
List<ImportRow> rows = new ArrayList<>(); List<ImportError> errors = new ArrayList<>();
try (XSSFWorkbook workbook = new XSSFWorkbook(new ByteArrayInputStream(content))) {
if (workbook.getNumberOfSheets() == 0) return failure(safeName, 0, "模板", "缺少供应商工作表");
XSSFSheet sheet = workbook.getSheetAt(0); DataFormatter formatter = new DataFormatter();
if (!"模板版本".equals(value(sheet.getRow(0), 0, formatter)) || !TEMPLATE_VERSION.equals(value(sheet.getRow(0), 1, formatter))) {
errors.add(new ImportError(1, "模板版本", "模板版本不匹配,请下载最新模板"));
}
for (int i = 0; i < HEADERS.length; i++) if (!HEADERS[i].equals(value(sheet.getRow(2), i, formatter))) {
errors.add(new ImportError(3, "表头", "第" + (i + 1) + "列应为“" + HEADERS[i] + "”"));
}
if (!errors.isEmpty()) return new ImportResult(safeName, 0, 0, false, errors);
for (int i = 3; i <= sheet.getLastRowNum(); i++) {
Row row = sheet.getRow(i); if (blank(row, formatter)) continue;
if (rows.size() >= MAX_ROWS) { errors.add(new ImportError(i + 1, "文件", "单次最多导入1000行")); break; }
ImportRow item = new ImportRow(i + 1, request(row, formatter));
rows.add(item);
}
} catch (IOException | RuntimeException exception) { return failure(safeName, rows.size(), "文件", "文件格式无法读取,请使用标准XLSX模板"); }
List<ImportRow> validated = new ArrayList<>();
for (ImportRow row : rows) { validate(row, validated, errors); validated.add(row); }
if (rows.isEmpty() && errors.isEmpty()) errors.add(new ImportError(0, "文件", "文件中没有可导入的数据行"));
if (!errors.isEmpty()) return new ImportResult(safeName, rows.size(), 0, false, errors);
rows.forEach(row -> supplierService.create(IdWorker.getId(), row.request()));
return new ImportResult(safeName, rows.size(), rows.size(), true, List.of());
}
public byte[] exportCsv(PageQuery query) {
StringBuilder csv = new StringBuilder("\uFEFF供应商ID,供应商名称,供应商类别,船司标准编码,地区,法人代表,联系人,联系电话,电子邮箱,公司地址,营业执照号,营业执照文件ID,状态,结算周期,备注\r\n");
int page = 1;
while (true) {
var result = supplierService.page(new PageQuery(page, 200, query.category(), query.region(), query.status(), query.keyword(), query.carrierCode()));
for (SupplierResource value : result.records()) csv.append(row(value));
if (page >= result.pages()) break;
page++;
}
return csv.toString().getBytes(StandardCharsets.UTF_8);
}
private void validate(ImportRow row, List<ImportRow> previous, List<ImportError> errors) {
for (ConstraintViolation<SaveRequest> violation : validator.validate(row.request())) {
errors.add(new ImportError(row.number(), violation.getPropertyPath().toString(), violation.getMessage()));
}
if (!CATEGORIES.contains(row.request().category())) errors.add(new ImportError(row.number(), "category", "供应商类别无效"));
boolean booking = "BOOKING".equals(row.request().category());
if (booking && row.request().carrierCodes().isEmpty()) errors.add(new ImportError(row.number(), "carrierCodes", "BOOKING至少关联一个船司编码"));
if (!booking && !row.request().carrierCodes().isEmpty()) errors.add(new ImportError(row.number(), "carrierCodes", "非BOOKING必须留空"));
if (!CARRIER_CODES.containsAll(row.request().carrierCodes())) errors.add(new ImportError(row.number(), "carrierCodes", "存在无效船司编码"));
if (previous.stream().anyMatch(value -> value.request().name().equalsIgnoreCase(row.request().name()))) errors.add(new ImportError(row.number(), "name", "与文件内其他行重复"));
if (!row.request().name().isBlank() && repository.existsName(0L, row.request().name(), null)) errors.add(new ImportError(row.number(), "name", "与现有供应商重复"));
}
private static SaveRequest request(Row row, DataFormatter formatter) {
return new SaveRequest(value(row, 0, formatter), value(row, 1, formatter).toUpperCase(Locale.ROOT),
carrierCodes(value(row, 2, formatter)), value(row, 3, formatter), value(row, 4, formatter),
value(row, 5, formatter), value(row, 6, formatter), nullable(value(row, 7, formatter)),
nullable(value(row, 8, formatter)), nullable(value(row, 9, formatter)), nullable(value(row, 10, formatter)),
value(row, 11, formatter).toUpperCase(Locale.ROOT), value(row, 12, formatter).toUpperCase(Locale.ROOT),
nullable(value(row, 13, formatter)));
}
private static List<String> carrierCodes(String value) {
LinkedHashSet<String> result = new LinkedHashSet<>();
for (String code : value.toUpperCase(Locale.ROOT).split("[,,、|;;]")) if (!code.isBlank()) result.add(code.trim());
return List.copyOf(result);
}
private static String row(SupplierResource v) {
return List.of(Long.toString(v.id()), v.name(), v.category(), String.join("|", v.carrierCodes()), v.region(),
v.legalRepresentative(), v.contactName(), v.phone(), text(v.email()), text(v.address()),
text(v.businessLicenseNo()), text(v.licenseFileId()), v.status(), v.settlementPeriod(), text(v.remark()))
.stream().map(SupplierImportExportService::csv).collect(java.util.stream.Collectors.joining(",")) + "\r\n";
}
private static String csv(String value) { return "\"" + value.replace("\"", "\"\"") + "\""; }
private static String text(String value) { return value == null ? "" : value; }
private static String value(Row row, int index, DataFormatter formatter) { Cell cell = row == null ? null : row.getCell(index); return cell == null ? "" : formatter.formatCellValue(cell).trim(); }
private static boolean blank(Row row, DataFormatter formatter) { if (row == null) return true; for (int i = 0; i < HEADERS.length; i++) if (!value(row, i, formatter).isBlank()) return false; return true; }
private static String nullable(String value) { return value.isBlank() ? null : value; }
private static String safeFileName(String value) { if (value == null || value.isBlank()) return "未命名文件.xlsx"; String normalized = value.replace('\\', '/'); return normalized.substring(normalized.lastIndexOf('/') + 1); }
private static ImportResult failure(String file, int total, String field, String message) { return new ImportResult(file, total, 0, false, List.of(new ImportError(0, field, message))); }
private record ImportRow(int number, SaveRequest request) {}
}
@@ -11,32 +11,54 @@ import jakarta.annotation.Resource;
import org.springframework.dao.DataIntegrityViolationException;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Set;
@Service
public class SupplierService {
private static final long PUBLIC_TENANT_ID = 0L;
private static final Set<String> CARRIER_CODES = Set.of("MATSON", "SML", "ZIM", "COSCO", "EMC", "HEDE", "OTHER");
@Resource private SupplierRepository repository;
public PageResult<SupplierEntity> page(PageQuery query) {
return repository.page(PUBLIC_TENANT_ID, query);
public PageResult<SupplierResource> page(PageQuery query) {
PageResult<SupplierEntity> source = repository.page(PUBLIC_TENANT_ID, query);
Map<Long, List<String>> carrierCodes = repository.carrierCodes(PUBLIC_TENANT_ID,
source.records().stream().map(SupplierEntity::getId).toList());
return new PageResult<>(source.records().stream().map(value -> resource(value,
carrierCodes.getOrDefault(value.getId(), List.of()))).toList(), source.page(), source.size(),
source.total(), source.pages());
}
public SupplierResource detail(long id) {
SupplierEntity value = owned(id);
return resource(value, carrierCodes(id));
}
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 = "#id", description = "新增供应商", captureBefore = false)
public Long create(long id, SaveRequest request) {
List<String> carrierCodes = normalizeCarrierCodes(request.category(), request.carrierCodes());
ensureName(request.name(), null);
SupplierEntity entity = new SupplierEntity(); entity.setId(id); entity.setTenantId(0L); copy(entity, request);
try { repository.insert(entity); } catch (DataIntegrityViolationException e) { throw new BizException(ResultCode.CONFLICT, "供应商名称已存在"); }
try {
repository.insert(entity);
repository.replaceCarrierCodes(PUBLIC_TENANT_ID, id, carrierCodes, entity.getCreatedBy());
} 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) {
List<String> carrierCodes = normalizeCarrierCodes(request.category(), request.carrierCodes());
SupplierEntity e = owned(id); ensureName(request.name(), id); copy(e, request);
try { repository.save(e); } catch (DataIntegrityViolationException exception) { throw new BizException(ResultCode.CONFLICT, "供应商名称已存在"); }
try {
repository.save(e);
repository.replaceCarrierCodes(PUBLIC_TENANT_ID, id, carrierCodes, e.getUpdatedBy());
} catch (DataIntegrityViolationException exception) { throw new BizException(ResultCode.CONFLICT, "供应商名称已存在"); }
}
@Transactional
@@ -47,10 +69,55 @@ public class SupplierService {
SupplierEntity e = owned(id);
if (requireActive && !"ACTIVE".equals(e.getStatus())) throw new BizException(ResultCode.CONFLICT, "供应商未启用");
if (category != null && !category.equals(e.getCategory())) throw new BizException(ResultCode.VALIDATION_FAILED, "供应商类别不匹配");
return new InternalSnapshot(e.getId(), e.getName(), e.getCategory(), e.getStatus());
return snapshot(e, carrierCodes(id));
}
public List<InternalSnapshot> internalBookingCandidates(String carrierCode) {
return page(new PageQuery(1, 200, "BOOKING", null, "ACTIVE", null,
normalizeCarrierCode(carrierCode))).records().stream()
.map(value -> new InternalSnapshot(value.id(), value.name(), value.category(), value.status(),
value.carrierCodes())).toList();
}
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 List<String> carrierCodes(long id) {
return repository.carrierCodes(PUBLIC_TENANT_ID, List.of(id)).getOrDefault(id, List.of());
}
private static List<String> normalizeCarrierCodes(String category, List<String> values) {
LinkedHashSet<String> normalized = new LinkedHashSet<>();
if (values != null) values.forEach(value -> {
if (value != null) normalized.add(value.trim().toUpperCase(Locale.ROOT));
});
if (!"BOOKING".equals(category)) {
if (!normalized.isEmpty()) throw new BizException(ResultCode.BAD_REQUEST,
"非BOOKING供应商禁止携带carrierCodes");
return List.of();
}
if (normalized.isEmpty()) throw new BizException(ResultCode.BAD_REQUEST,
"BOOKING供应商至少关联一个船司标准编码");
if (!CARRIER_CODES.containsAll(normalized)) throw new BizException(ResultCode.BAD_REQUEST,
"carrierCodes仅支持MATSON、SML、ZIM、COSCO、EMC、HEDE或OTHER");
return List.copyOf(normalized);
}
private static String normalizeCarrierCode(String value) {
if (value == null || value.isBlank()) throw new BizException(ResultCode.BAD_REQUEST,
"carrierCode不能为空");
String normalized = value.trim().toUpperCase(Locale.ROOT);
if (!CARRIER_CODES.contains(normalized)) throw new BizException(ResultCode.BAD_REQUEST,
"carrierCode仅支持MATSON、SML、ZIM、COSCO、EMC、HEDE或OTHER");
return normalized;
}
private static InternalSnapshot snapshot(SupplierEntity e, List<String> carrierCodes) {
return new InternalSnapshot(e.getId(), e.getName(), e.getCategory(), e.getStatus(),
List.copyOf(carrierCodes));
}
private static SupplierResource resource(SupplierEntity e, List<String> carrierCodes) {
return new SupplierResource(e.getId(), e.getTenantId(), e.getName(), e.getCategory(),
List.copyOf(carrierCodes), e.getRegion(), e.getLegalRepresentative(), e.getContactName(),
e.getPhone(), e.getEmail(), e.getAddress(), e.getBusinessLicenseNo(), e.getLicenseFileId(),
e.getStatus(), e.getSettlementPeriod(), e.getRemark(), e.getCreatedBy(), e.getCreatedAt(),
e.getUpdatedBy(), e.getUpdatedAt(), e.getDeleted(), e.getVersion());
}
private static void copy(SupplierEntity e, SaveRequest r) { e.setName(r.name()); e.setCategory(r.category()); 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()); }
}
@@ -33,6 +33,18 @@ CREATE TABLE IF NOT EXISTS wh_supplier (
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='平台公共供应商业务档案';
CREATE TABLE IF NOT EXISTS wh_supplier_carrier (
supplier_id BIGINT UNSIGNED NOT NULL COMMENT 'BOOKING供应商ID',
tenant_id BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '平台公共作用域,固定为0',
carrier_code VARCHAR(16) NOT NULL COMMENT '船司标准编码',
created_by BIGINT UNSIGNED NULL COMMENT '创建人ID',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间',
PRIMARY KEY (supplier_id,carrier_code),
KEY idx_wh_supplier_carrier_code (tenant_id,carrier_code,supplier_id),
CONSTRAINT chk_wh_supplier_carrier_tenant CHECK (tenant_id = 0),
CONSTRAINT chk_wh_supplier_carrier_code CHECK (carrier_code IN ('MATSON','SML','ZIM','COSCO','EMC','HEDE','OTHER'))
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='BOOKING供应商与标准船司编码多对多关联';
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)
@@ -79,7 +91,10 @@ 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(*) FROM information_schema.tables WHERE table_schema='baiyue_warehouse' AND table_name='wh_supplier_carrier') <> 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(DISTINCT index_name) FROM information_schema.statistics WHERE table_schema='baiyue_warehouse' AND table_name='wh_supplier_carrier' AND index_name IN ('PRIMARY','idx_wh_supplier_carrier_code')) <> 2
OR (SELECT COUNT(*) FROM information_schema.table_constraints WHERE table_schema='baiyue_warehouse' AND table_name='wh_supplier_carrier' AND constraint_type='CHECK' AND constraint_name IN ('chk_wh_supplier_carrier_tenant','chk_wh_supplier_carrier_code')) <> 2
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_tenant','chk_wh_supplier_status','chk_wh_supplier_settlement')) <> 4 THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='供应商表、索引或约束初始化不完整';
END IF;
@@ -27,6 +27,9 @@
| 功能 | 方法与路径 | 权限码 |
|---|---|---|
| 列表 | `GET /api/platform/v1/suppliers` | `platform:supplier:list` |
| 导出 | `GET /api/platform/v1/suppliers/export` | `platform:supplier:list` |
| 导入模板 | `GET /api/platform/v1/suppliers/import-template` | `platform:supplier:create` |
| 导入 | `POST /api/platform/v1/suppliers/import` | `platform:supplier:create` |
| 分类统计 | `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` |
@@ -48,6 +51,7 @@
| `region` | string | 否 | 地区精确匹配,最长 80 |
| `status` | enum | 否 | 合作状态 |
| `keyword` | string | 否 | 按供应商名称或联系人模糊匹配 |
| `carrierCode` | enum | 否 | 船司标准编码;传入后服务端只返回 `ACTIVE + BOOKING` 且存在有效关联的供应商 |
分页响应 `data`:
@@ -71,6 +75,7 @@
| `tenantId` | string | 固定为 `0` |
| `name` | string | 供应商名称,平台内唯一 |
| `category` | enum | 分类 |
| `carrierCodes` | string[] | BOOKING关联的标准船司编码数组;响应始终返回数组 |
| `region` | string | 地区 |
| `legalRepresentative` | string | 法人代表 |
| `contactName` | string | 联系人 |
@@ -99,6 +104,7 @@
{
"name": "示例供应商有限公司",
"category": "BOOKING",
"carrierCodes": ["MATSON", "ZIM"],
"region": "宁波",
"legalRepresentative": "张三",
"contactName": "李经理",
@@ -113,7 +119,9 @@
}
```
必填字段:`name`、`category`、`region`、`legalRepresentative`、`contactName`、`phone`、`status`、`settlementPeriod`。订舱供应商不区分国内或国外。
必填字段:`name`、`category`、`region`、`legalRepresentative`、`contactName`、`phone`、`status`、`settlementPeriod`。`BOOKING` 必须提交至少一个去重后的 `carrierCodes`;非 `BOOKING` 可不提交该字段或提交空数组,提交非空数组返回 `A0400`。订舱供应商不区分国内或国外。
Admin 订舱供应商候选固定复用分页接口:`GET /api/platform/v1/suppliers?category=BOOKING&status=ACTIVE&carrierCode=MATSON&page=1&size=200`,权限为 `platform:supplier:list`,响应为 `PageResult<Supplier>`。服务端完成关联过滤,前端不得先拉全量再自行过滤。
新增成功的 `data` 是供应商 ID 字符串。编辑成功的 `data` 为 `null`。
@@ -14,6 +14,7 @@ paths:
- { name: region, in: query, schema: { type: string } }
- { name: status, in: query, schema: { $ref: '#/components/schemas/Status' } }
- { name: keyword, in: query, description: 供应商名称或联系人, schema: { type: string } }
- { name: carrierCode, in: query, description: 按船司编码筛选时强制只返回ACTIVE且BOOKING的有效关联供应商, schema: { $ref: '#/components/schemas/CarrierCode' } }
responses: { '200': { description: 成功, content: { application/json: { schema: { $ref: '#/components/schemas/SupplierPageResponse' } } } }, '400': { $ref: '#/components/responses/BadRequest' } }
post:
summary: 新增供应商
@@ -22,6 +23,33 @@ paths:
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/import-template:
get:
summary: 下载供应商XLSX导入模板
description: 权限 platform:supplier:create。
security: [{ bearerAuth: [] }]
responses: { '200': { description: XLSX模板, content: { application/vnd.openxmlformats-officedocument.spreadsheetml.sheet: { schema: { type: string, format: binary } } } } }
/api/platform/v1/suppliers/import:
post:
summary: 原子批量导入供应商
description: 权限 platform:supplier:create;整批校验通过后才写入。BOOKING的多值船司编码在文件中使用逗号分隔,数据库始终保存为关联行。
security: [{ bearerAuth: [] }]
parameters: [{ $ref: '#/components/parameters/RequiredTenantId' }]
requestBody: { required: true, content: { multipart/form-data: { schema: { type: object, required: [file], properties: { file: { type: string, format: binary } } } } } }
responses: { '200': { description: 导入结果 } }
/api/platform/v1/suppliers/export:
get:
summary: 按当前查询条件导出供应商CSV
description: 权限 platform:supplier:list;carrierCodes在CSV中使用竖线分隔,不改变数据库多对多结构。
security: [{ bearerAuth: [] }]
parameters:
- { $ref: '#/components/parameters/TenantId' }
- { name: category, in: query, schema: { $ref: '#/components/schemas/Category' } }
- { name: region, in: query, schema: { type: string } }
- { name: status, in: query, schema: { $ref: '#/components/schemas/Status' } }
- { name: keyword, in: query, schema: { type: string } }
- { name: carrierCode, in: query, schema: { $ref: '#/components/schemas/CarrierCode' } }
responses: { '200': { description: CSV文件, content: { text/csv: { schema: { type: string, format: binary } } } } }
/api/platform/v1/suppliers/stats:
get:
summary: 查询四类供应商统计
@@ -66,12 +94,25 @@ paths:
'200': { description: 成功, content: { application/json: { schema: { $ref: '#/components/schemas/InternalSnapshotResponse' } } } }
'403': { $ref: '#/components/responses/Forbidden' }
'404': { $ref: '#/components/responses/NotFound' }
/internal/warehouse/v1/suppliers:
get:
summary: 内部查询有效订舱供应商候选
description: 仅服务间调用;服务端固定返回ACTIVE、BOOKING且存在指定carrierCode关联的供应商。
security: [{ internalServiceToken: [] }]
parameters:
- { name: category, in: query, required: true, schema: { type: string, enum: [BOOKING] } }
- { name: carrierCode, in: query, required: true, schema: { $ref: '#/components/schemas/CarrierCode' } }
responses:
'200': { description: 成功, content: { application/json: { schema: { $ref: '#/components/schemas/InternalSnapshotListResponse' } } } }
'403': { $ref: '#/components/responses/Forbidden' }
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] }
CarrierCode: { type: string, enum: [MATSON, SML, ZIM, COSCO, EMC, HEDE, OTHER] }
CarrierCodes: { type: array, uniqueItems: true, maxItems: 7, items: { $ref: '#/components/schemas/CarrierCode' }, description: BOOKING必填且至少一个;非BOOKING可省略或为空,提交非空数组返回A0400 }
Status: { type: string, enum: [ACTIVE, INACTIVE] }
SupplierSave:
type: object
@@ -79,6 +120,7 @@ components:
properties:
name: { type: string, maxLength: 160 }
category: { $ref: '#/components/schemas/Category' }
carrierCodes: { $ref: '#/components/schemas/CarrierCodes' }
region: { type: string, maxLength: 80 }
legalRepresentative: { type: string, maxLength: 80 }
contactName: { type: string, maxLength: 80 }
@@ -94,10 +136,11 @@ components:
allOf:
- $ref: '#/components/schemas/SupplierSave'
- type: object
required: [id, tenantId]
required: [id, tenantId, carrierCodes]
properties:
id: { type: integer, format: int64 }
tenantId: { type: integer, format: int64, enum: [0] }
carrierCodes: { $ref: '#/components/schemas/CarrierCodes' }
PageResult:
type: object
required: [records, total, current, size]
@@ -131,14 +174,17 @@ components:
allOf: [{ $ref: '#/components/schemas/ApiResponse' }, { type: object, properties: { data: { $ref: '#/components/schemas/Stats' } } }]
InternalSnapshot:
type: object
required: [supplierId, name, category, status]
required: [supplierId, name, category, status, carrierCodes]
properties:
supplierId: { type: integer, format: int64 }
name: { type: string }
category: { $ref: '#/components/schemas/Category' }
status: { $ref: '#/components/schemas/Status' }
carrierCodes: { $ref: '#/components/schemas/CarrierCodes' }
InternalSnapshotResponse:
allOf: [{ $ref: '#/components/schemas/ApiResponse' }, { type: object, properties: { data: { $ref: '#/components/schemas/InternalSnapshot' } } }]
InternalSnapshotListResponse:
allOf: [{ $ref: '#/components/schemas/ApiResponse' }, { type: object, properties: { data: { type: array, items: { $ref: '#/components/schemas/InternalSnapshot' } } } }]
Error:
type: object
properties: { code: { type: string }, message: { type: string }, requestId: { type: string }, traceId: { type: string } }
@@ -17,5 +17,9 @@ class InternalSupplierControllerContractTest {
.findFirst().orElseThrow();
assertThat(Arrays.stream(get.getParameters()).map(parameter -> parameter.getName()))
.containsExactly("token", "supplierId", "requireActive", "category");
Method candidates = Arrays.stream(InternalSupplierController.class.getDeclaredMethods())
.filter(method -> method.getName().equals("candidates")).findFirst().orElseThrow();
assertThat(Arrays.stream(candidates.getParameters()).map(parameter -> parameter.getName()))
.containsExactly("token", "category", "carrierCode");
}
}
@@ -20,6 +20,9 @@ class PlatformSupplierControllerContractTest {
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");
assertThat(method("importTemplate").getAnnotation(PreAuthorize.class).value()).contains("platform:supplier:create");
assertThat(method("importSuppliers").getAnnotation(PreAuthorize.class).value()).contains("platform:supplier:create");
assertThat(method("export").getAnnotation(PreAuthorize.class).value()).contains("platform:supplier:list");
}
private static Method method(String n) { return java.util.Arrays.stream(PlatformSupplierController.class.getDeclaredMethods()).filter(m -> m.getName().equals(n)).findFirst().orElseThrow(); }
}
@@ -0,0 +1,33 @@
package com.baiyue.warehouse.controller;
import com.baiyue.warehouse.dto.SupplierDtos.SaveRequest;
import jakarta.validation.constraints.NotNull;
import org.junit.jupiter.api.Test;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.Arrays;
import static org.assertj.core.api.Assertions.assertThat;
class SupplierCarrierContractTest {
@Test void carrierCodesMayBeOmittedAndSingleCarrierCodeIsRemoved() {
var component = Arrays.stream(SaveRequest.class.getRecordComponents())
.filter(value -> value.getName().equals("carrierCodes")).findFirst().orElseThrow();
assertThat(component.getAnnotation(NotNull.class)).isNull();
assertThat(Arrays.stream(SaveRequest.class.getRecordComponents()).map(java.lang.reflect.RecordComponent::getName))
.contains("carrierCodes").doesNotContain("carrierCode");
}
@Test void openApiFreezesPageCandidatesResourceImportAndExport() throws Exception {
String yaml = Files.readString(Path.of("src/main/resources/openapi/supplier-v1.yaml"), StandardCharsets.UTF_8);
assertThat(yaml).contains("name: carrierCode", "按船司编码筛选时强制只返回ACTIVE且BOOKING",
"carrierCodes: { $ref: '#/components/schemas/CarrierCodes' }",
"/internal/warehouse/v1/suppliers:", "/api/platform/v1/suppliers/import-template:",
"/api/platform/v1/suppliers/import:", "/api/platform/v1/suppliers/export:",
"权限 platform:supplier:list");
String required = yaml.substring(yaml.indexOf("SupplierSave:"), yaml.indexOf("Supplier:", yaml.indexOf("SupplierSave:")));
assertThat(required).doesNotContain("required: [name, category, carrierCodes");
}
}
@@ -18,14 +18,19 @@ class SupplierSqlContractTest {
"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");
"CREATE TABLE IF NOT EXISTS wh_supplier_carrier", "PRIMARY KEY (supplier_id,carrier_code)",
"idx_wh_supplier_carrier_code", "chk_wh_supplier_carrier_code", "assert_supplier_schema");
String supplierTable = sql.substring(sql.indexOf("CREATE TABLE IF NOT EXISTS wh_supplier ("),
sql.indexOf("CREATE TABLE IF NOT EXISTS wh_supplier_carrier"));
assertThat(supplierTable).doesNotContain("carrier_code", "carrier_codes");
assertThat(sql).doesNotContain("scope VARCHAR", "chk_wh_supplier_scope", "DOMESTIC/INTERNATIONAL");
}
}
@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",
assertThat(sql).contains("CREATE TABLE IF NOT EXISTS wh_supplier", "CREATE TABLE IF NOT EXISTS wh_supplier_carrier",
"PRIMARY KEY(supplier_id,carrier_code)", "idx_wh_supplier_carrier_code", "uk_wh_supplier_name",
"idx_wh_supplier_filter", "platform:supplier:list", "platform:supplier:create",
"platform:supplier:edit", "platform:supplier:status", "assert_platform_supplier_permissions");
String supplierSection = sql.substring(sql.indexOf("-- 平台公共供应商业务档案及权限"));
@@ -0,0 +1,45 @@
package com.baiyue.warehouse.service;
import com.baiyue.common.core.api.PageResult;
import com.baiyue.warehouse.dto.SupplierDtos.PageQuery;
import com.baiyue.warehouse.dto.SupplierDtos.SupplierResource;
import org.apache.poi.xssf.usermodel.XSSFWorkbook;
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 java.io.ByteArrayInputStream;
import java.nio.charset.StandardCharsets;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class)
class SupplierImportExportServiceTest {
@Mock private SupplierService supplierService;
@InjectMocks private SupplierImportExportService service;
private PageQuery query;
@BeforeEach void setUp() { query = new PageQuery(1, 20, "BOOKING", null, "ACTIVE", null, "MATSON"); }
@Test void templateUsesMultiCarrierCodesColumn() throws Exception {
try (XSSFWorkbook workbook = new XSSFWorkbook(new ByteArrayInputStream(service.template()))) {
assertThat(workbook.getSheetAt(0).getRow(0).getCell(1).getStringCellValue()).isEqualTo("2");
assertThat(workbook.getSheetAt(0).getRow(2).getCell(2).getStringCellValue()).isEqualTo("船司标准编码(多值)");
}
}
@Test void csvExportsCarrierCodesWithoutChangingPersistenceShape() {
SupplierResource resource = new SupplierResource(7L, 0L, "订舱代理", "BOOKING", List.of("MATSON", "ZIM"),
"宁波", "法人", "联系人", "13800000000", null, null, null, null, "ACTIVE", "MONTHLY_30", null,
null, null, null, null, false, 0);
when(supplierService.page(new PageQuery(1, 200, "BOOKING", null, "ACTIVE", null, "MATSON")))
.thenReturn(new PageResult<>(List.of(resource), 1, 200, 1, 1));
String csv = new String(service.exportCsv(query), StandardCharsets.UTF_8);
assertThat(csv).contains("船司标准编码", "\"MATSON|ZIM\"");
}
}
@@ -13,6 +13,8 @@ import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
@@ -25,7 +27,7 @@ class SupplierServiceTest {
private SaveRequest booking;
@BeforeEach void setUp() {
booking = new SaveRequest("测试供应商", "BOOKING", "宁波", "法人", "联系人",
booking = new SaveRequest("测试供应商", "BOOKING", java.util.List.of("MATSON"), "宁波", "法人", "联系人",
"13800000000", "supplier@example.com", "地址", "91330000TEST", "192000000000000001",
"ACTIVE", "MONTHLY_30", null);
}
@@ -34,6 +36,34 @@ class SupplierServiceTest {
when(repository.existsName(0L, "测试供应商", null)).thenReturn(false);
service.create(101L, booking);
verify(repository).insert(any(SupplierEntity.class));
verify(repository).replaceCarrierCodes(eq(0L), eq(101L), eq(java.util.List.of("MATSON")), any());
}
@Test void deduplicatesBookingCarrierCodesAndRejectsNonBookingAssociations() {
SaveRequest duplicated = new SaveRequest("测试供应商", "BOOKING", java.util.List.of("MATSON", "MATSON", "ZIM"),
"宁波", "法人", "联系人", "13800000000", null, null, null, null,
"ACTIVE", "MONTHLY_30", null);
service.create(102L, duplicated);
verify(repository).replaceCarrierCodes(eq(0L), eq(102L), eq(java.util.List.of("MATSON", "ZIM")), any());
SaveRequest customs = new SaveRequest("报关行", "CUSTOMS", java.util.List.of("MATSON"), "宁波", "法人",
"联系人", "13800000000", null, null, null, null, "ACTIVE", "MONTHLY_30", null);
assertThatThrownBy(() -> service.create(103L, customs)).isInstanceOf(BizException.class)
.hasMessageContaining("非BOOKING");
}
@Test void bookingRequiresAtLeastOneCarrierCode() {
SaveRequest empty = new SaveRequest("测试供应商", "BOOKING", java.util.List.of(), "宁波", "法人", "联系人",
"13800000000", null, null, null, null, "ACTIVE", "MONTHLY_30", null);
assertThatThrownBy(() -> service.create(104L, empty)).isInstanceOf(BizException.class)
.hasMessageContaining("至少关联一个");
}
@Test void nonBookingMayOmitCarrierCodes() {
SaveRequest customs = new SaveRequest("报关行", "CUSTOMS", null, "宁波", "法人", "联系人",
"13800000000", null, null, null, null, "ACTIVE", "MONTHLY_30", null);
service.create(105L, customs);
verify(repository).replaceCarrierCodes(eq(0L), eq(105L), eq(java.util.List.of()), any());
}
@Test void repeatedStatusChangeIsIdempotent() {
@@ -46,11 +76,22 @@ class SupplierServiceTest {
@Test void validatesInternalSupplierSnapshot() {
SupplierEntity entity = new SupplierEntity(); entity.setId(101L); entity.setTenantId(0L); entity.setName("测试供应商");
entity.setCategory("BOOKING"); entity.setStatus("ACTIVE");
when(repository.carrierCodes(0L, java.util.List.of(101L))).thenReturn(java.util.Map.of(101L, java.util.List.of("MATSON")));
when(repository.find(0L, 101L)).thenReturn(entity);
var snapshot = service.internalSnapshot(101L, true, "BOOKING");
org.assertj.core.api.Assertions.assertThat(snapshot.supplierId()).isEqualTo(101L);
org.assertj.core.api.Assertions.assertThat(snapshot.category()).isEqualTo("BOOKING");
org.assertj.core.api.Assertions.assertThat(snapshot.status()).isEqualTo("ACTIVE");
org.assertj.core.api.Assertions.assertThat(snapshot.carrierCodes()).containsExactly("MATSON");
}
@Test void detailReturnsCarrierCodesAsResourceArray() {
SupplierEntity entity = new SupplierEntity(); entity.setId(101L); entity.setTenantId(0L);
entity.setName("测试供应商"); entity.setCategory("BOOKING"); entity.setStatus("ACTIVE");
when(repository.find(0L, 101L)).thenReturn(entity);
when(repository.carrierCodes(0L, java.util.List.of(101L)))
.thenReturn(java.util.Map.of(101L, java.util.List.of("MATSON", "ZIM")));
assertThat(service.detail(101L).carrierCodes()).containsExactly("MATSON", "ZIM");
}
@Test void internalSnapshotRequiresActiveSupplierAndMatchingCategory() {