From 079ff43f737313cfc5b0a711f632fdc583e6a501 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Thu, 27 Aug 2026 10:29:28 +0800 Subject: [PATCH] feat(warehouse): support supplier carrier associations --- deploy/sql/头程数据库统一增量-MySQL8.sql | 10 ++ .../InternalSupplierController.java | 13 +- .../PlatformSupplierController.java | 41 ++++- .../baiyue/warehouse/dto/SupplierDtos.java | 20 ++- .../warehouse/mapper/SupplierMapper.java | 42 +++++- .../repository/SupplierRepository.java | 23 +++ .../service/SupplierImportExportService.java | 141 ++++++++++++++++++ .../warehouse/service/SupplierService.java | 79 +++++++++- .../main/resources/db/warehouse-supplier.sql | 15 ++ .../openapi/supplier-frontend-handoff.md | 10 +- .../main/resources/openapi/supplier-v1.yaml | 50 ++++++- ...nternalSupplierControllerContractTest.java | 4 + ...latformSupplierControllerContractTest.java | 3 + .../SupplierCarrierContractTest.java | 33 ++++ .../repository/SupplierSqlContractTest.java | 9 +- .../SupplierImportExportServiceTest.java | 45 ++++++ .../service/SupplierServiceTest.java | 43 +++++- 17 files changed, 562 insertions(+), 19 deletions(-) create mode 100644 warehouse/src/main/java/com/baiyue/warehouse/service/SupplierImportExportService.java create mode 100644 warehouse/src/test/java/com/baiyue/warehouse/controller/SupplierCarrierContractTest.java create mode 100644 warehouse/src/test/java/com/baiyue/warehouse/service/SupplierImportExportServiceTest.java diff --git a/deploy/sql/头程数据库统一增量-MySQL8.sql b/deploy/sql/头程数据库统一增量-MySQL8.sql index c4f033832..66ecc0028 100644 --- a/deploy/sql/头程数据库统一增量-MySQL8.sql +++ b/deploy/sql/头程数据库统一增量-MySQL8.sql @@ -15960,6 +15960,14 @@ CREATE TABLE IF NOT EXISTS wh_supplier ( CONSTRAINT chk_wh_supplier_status CHECK(status IN ('ACTIVE','INACTIVE')), CONSTRAINT chk_wh_supplier_settlement CHECK(settlement_period IN ('MONTHLY_30','MONTHLY_45','MONTHLY_60','MONTHLY_90','PER_TRANSACTION','IMMEDIATE')) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='平台公共供应商业务档案'; +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) @@ -16021,6 +16029,8 @@ CREATE PROCEDURE assert_platform_supplier_permissions() BEGIN IF (SELECT COUNT(*) FROM baiyue_warehouse.wh_supplier WHERE tenant_id<>0)<>0 THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='供应商存在非平台作用域数据'; END IF; IF (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 THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='供应商表索引不完整'; END IF; + IF (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 THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='供应商船司关联表索引不完整'; END IF; + IF (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 THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='供应商船司关联表约束不完整'; END IF; IF (SELECT COUNT(*) FROM iam_permission WHERE tenant_id=0 AND app_code='PLATFORM' AND status='ACTIVE' AND deleted=0 AND permission_code IN('platform:supplier:list','platform:supplier:create','platform:supplier:edit','platform:supplier:status'))<>4 THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='供应商权限资源初始化不完整'; END IF; IF (SELECT COUNT(*) FROM iam_role_permission rp JOIN iam_role r ON r.id=rp.role_id AND r.tenant_id=rp.tenant_id JOIN iam_permission p ON p.id=rp.permission_id AND p.tenant_id=rp.tenant_id WHERE r.role_code='PLATFORM_ADMIN' AND p.permission_code IN('platform:supplier:list','platform:supplier:create','platform:supplier:edit','platform:supplier:status'))<>4 THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='PLATFORM_ADMIN供应商权限授权不完整'; END IF; END$$ diff --git a/warehouse/src/main/java/com/baiyue/warehouse/controller/InternalSupplierController.java b/warehouse/src/main/java/com/baiyue/warehouse/controller/InternalSupplierController.java index 380cda465..be193cc9c 100644 --- a/warehouse/src/main/java/com/baiyue/warehouse/controller/InternalSupplierController.java +++ b/warehouse/src/main/java/com/baiyue/warehouse/controller/InternalSupplierController.java @@ -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 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> 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, "内部服务凭证无效"); } } diff --git a/warehouse/src/main/java/com/baiyue/warehouse/controller/PlatformSupplierController.java b/warehouse/src/main/java/com/baiyue/warehouse/controller/PlatformSupplierController.java index 302e94de8..be6be0b2f 100644 --- a/warehouse/src/main/java/com/baiyue/warehouse/controller/PlatformSupplierController.java +++ b/warehouse/src/main/java/com/baiyue/warehouse/controller/PlatformSupplierController.java @@ -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 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 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 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> 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> 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(@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 detail(@AuthenticationPrincipal AuthenticatedUser user, @RequestParam @Min(0) long tenantId, @PathVariable long id) { requirePlatform(user); requireScope(tenantId); return ApiResponse.success(service.detail(id)); } + public ApiResponse 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 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')") diff --git a/warehouse/src/main/java/com/baiyue/warehouse/dto/SupplierDtos.java b/warehouse/src/main/java/com/baiyue/warehouse/dto/SupplierDtos.java index 7b512c60b..c3b6e25ba 100644 --- a/warehouse/src/main/java/com/baiyue/warehouse/dto/SupplierDtos.java +++ b/warehouse/src/main/java/com/baiyue/warehouse/dto/SupplierDtos.java @@ -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 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 carrierCodes) {} + public record ImportError(int row, String field, String message) {} + public record ImportResult(String fileName, int totalRows, int importedRows, boolean success, + List errors) {} } diff --git a/warehouse/src/main/java/com/baiyue/warehouse/mapper/SupplierMapper.java b/warehouse/src/main/java/com/baiyue/warehouse/mapper/SupplierMapper.java index 5f7b9b61a..184f706c1 100644 --- a/warehouse/src/main/java/com/baiyue/warehouse/mapper/SupplierMapper.java +++ b/warehouse/src/main/java/com/baiyue/warehouse/mapper/SupplierMapper.java @@ -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 {} +import java.util.List; + +public interface SupplierMapper extends BaseMapper { + @Select(""" + + """) + List selectCarrierRows(@Param("tenantId") long tenantId, + @Param("supplierIds") List supplierIds); + + @Select(""" + SELECT supplier_id + FROM wh_supplier_carrier + WHERE tenant_id=#{tenantId} AND carrier_code=#{carrierCode} + ORDER BY supplier_id + """) + List 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) {} +} diff --git a/warehouse/src/main/java/com/baiyue/warehouse/repository/SupplierRepository.java b/warehouse/src/main/java/com/baiyue/warehouse/repository/SupplierRepository.java index 6ef7f5164..08e523ae8 100644 --- a/warehouse/src/main/java/com/baiyue/warehouse/repository/SupplierRepository.java +++ b/warehouse/src/main/java/com/baiyue/warehouse/repository/SupplierRepository.java @@ -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 page(long tenantId, PageQuery query) { + List 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 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> carrierCodes(long tenantId, List supplierIds) { + if (supplierIds == null || supplierIds.isEmpty()) return Map.of(); + Map> 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 carrierCodes, Long operatorId) { + mapper.deleteCarrierCodes(tenantId, supplierId); + carrierCodes.forEach(code -> mapper.insertCarrierCode(tenantId, supplierId, code, operatorId)); + } private LambdaQueryWrapper base(long tenantId) { return new LambdaQueryWrapper().eq(SupplierEntity::getTenantId, tenantId); } } diff --git a/warehouse/src/main/java/com/baiyue/warehouse/service/SupplierImportExportService.java b/warehouse/src/main/java/com/baiyue/warehouse/service/SupplierImportExportService.java new file mode 100644 index 000000000..b7fadc6e9 --- /dev/null +++ b/warehouse/src/main/java/com/baiyue/warehouse/service/SupplierImportExportService.java @@ -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 CATEGORIES = Set.of("BOOKING", "CUSTOMS", "TRUCKING", "CERTIFICATE"); + private static final Set 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 rows = new ArrayList<>(); List 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 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 previous, List errors) { + for (ConstraintViolation 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 carrierCodes(String value) { + LinkedHashSet 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) {} +} diff --git a/warehouse/src/main/java/com/baiyue/warehouse/service/SupplierService.java b/warehouse/src/main/java/com/baiyue/warehouse/service/SupplierService.java index 5094afbe9..e49e7f6a0 100644 --- a/warehouse/src/main/java/com/baiyue/warehouse/service/SupplierService.java +++ b/warehouse/src/main/java/com/baiyue/warehouse/service/SupplierService.java @@ -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 CARRIER_CODES = Set.of("MATSON", "SML", "ZIM", "COSCO", "EMC", "HEDE", "OTHER"); @Resource private SupplierRepository repository; - public PageResult page(PageQuery query) { - return repository.page(PUBLIC_TENANT_ID, query); + public PageResult page(PageQuery query) { + PageResult source = repository.page(PUBLIC_TENANT_ID, query); + Map> 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 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 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 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 carrierCodes(long id) { + return repository.carrierCodes(PUBLIC_TENANT_ID, List.of(id)).getOrDefault(id, List.of()); + } + private static List normalizeCarrierCodes(String category, List values) { + LinkedHashSet 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 carrierCodes) { + return new InternalSnapshot(e.getId(), e.getName(), e.getCategory(), e.getStatus(), + List.copyOf(carrierCodes)); + } + private static SupplierResource resource(SupplierEntity e, List 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()); } } diff --git a/warehouse/src/main/resources/db/warehouse-supplier.sql b/warehouse/src/main/resources/db/warehouse-supplier.sql index 524af64a3..f3035f436 100644 --- a/warehouse/src/main/resources/db/warehouse-supplier.sql +++ b/warehouse/src/main/resources/db/warehouse-supplier.sql @@ -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; diff --git a/warehouse/src/main/resources/openapi/supplier-frontend-handoff.md b/warehouse/src/main/resources/openapi/supplier-frontend-handoff.md index 00577b9ac..1ea3f6a8e 100644 --- a/warehouse/src/main/resources/openapi/supplier-frontend-handoff.md +++ b/warehouse/src/main/resources/openapi/supplier-frontend-handoff.md @@ -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`。服务端完成关联过滤,前端不得先拉全量再自行过滤。 新增成功的 `data` 是供应商 ID 字符串。编辑成功的 `data` 为 `null`。 diff --git a/warehouse/src/main/resources/openapi/supplier-v1.yaml b/warehouse/src/main/resources/openapi/supplier-v1.yaml index 141bafd47..2d760ac77 100644 --- a/warehouse/src/main/resources/openapi/supplier-v1.yaml +++ b/warehouse/src/main/resources/openapi/supplier-v1.yaml @@ -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 } } diff --git a/warehouse/src/test/java/com/baiyue/warehouse/controller/InternalSupplierControllerContractTest.java b/warehouse/src/test/java/com/baiyue/warehouse/controller/InternalSupplierControllerContractTest.java index d4e935580..8f10759ef 100644 --- a/warehouse/src/test/java/com/baiyue/warehouse/controller/InternalSupplierControllerContractTest.java +++ b/warehouse/src/test/java/com/baiyue/warehouse/controller/InternalSupplierControllerContractTest.java @@ -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"); } } diff --git a/warehouse/src/test/java/com/baiyue/warehouse/controller/PlatformSupplierControllerContractTest.java b/warehouse/src/test/java/com/baiyue/warehouse/controller/PlatformSupplierControllerContractTest.java index c5b3ed9df..cea73d7ff 100644 --- a/warehouse/src/test/java/com/baiyue/warehouse/controller/PlatformSupplierControllerContractTest.java +++ b/warehouse/src/test/java/com/baiyue/warehouse/controller/PlatformSupplierControllerContractTest.java @@ -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(); } } diff --git a/warehouse/src/test/java/com/baiyue/warehouse/controller/SupplierCarrierContractTest.java b/warehouse/src/test/java/com/baiyue/warehouse/controller/SupplierCarrierContractTest.java new file mode 100644 index 000000000..37dfacbf8 --- /dev/null +++ b/warehouse/src/test/java/com/baiyue/warehouse/controller/SupplierCarrierContractTest.java @@ -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"); + } +} diff --git a/warehouse/src/test/java/com/baiyue/warehouse/repository/SupplierSqlContractTest.java b/warehouse/src/test/java/com/baiyue/warehouse/repository/SupplierSqlContractTest.java index abe4f7e40..c5083cd66 100644 --- a/warehouse/src/test/java/com/baiyue/warehouse/repository/SupplierSqlContractTest.java +++ b/warehouse/src/test/java/com/baiyue/warehouse/repository/SupplierSqlContractTest.java @@ -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("-- 平台公共供应商业务档案及权限")); diff --git a/warehouse/src/test/java/com/baiyue/warehouse/service/SupplierImportExportServiceTest.java b/warehouse/src/test/java/com/baiyue/warehouse/service/SupplierImportExportServiceTest.java new file mode 100644 index 000000000..0337cb0b0 --- /dev/null +++ b/warehouse/src/test/java/com/baiyue/warehouse/service/SupplierImportExportServiceTest.java @@ -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\""); + } +} diff --git a/warehouse/src/test/java/com/baiyue/warehouse/service/SupplierServiceTest.java b/warehouse/src/test/java/com/baiyue/warehouse/service/SupplierServiceTest.java index 02d36189f..e52c3f844 100644 --- a/warehouse/src/test/java/com/baiyue/warehouse/service/SupplierServiceTest.java +++ b/warehouse/src/test/java/com/baiyue/warehouse/service/SupplierServiceTest.java @@ -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() {