feat(warehouse): support supplier carrier associations
This commit is contained in:
+12
-1
@@ -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, "内部服务凭证无效"); }
|
||||
}
|
||||
|
||||
+38
-3
@@ -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); }
|
||||
}
|
||||
|
||||
+141
@@ -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 } }
|
||||
|
||||
+4
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
+3
@@ -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(); }
|
||||
}
|
||||
|
||||
+33
@@ -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");
|
||||
}
|
||||
}
|
||||
+7
-2
@@ -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("-- 平台公共供应商业务档案及权限"));
|
||||
|
||||
+45
@@ -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() {
|
||||
|
||||
Reference in New Issue
Block a user