This commit is contained in:
2026-08-03 13:16:19 +08:00
parent 481747d9dc
commit 9eb03d4bce
54 changed files with 1925 additions and 52 deletions
@@ -6,10 +6,12 @@ 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.MasterDataQueries.ExpressCompanyPageQuery;
import com.baiyue.warehouse.dto.MasterDataQueries.ExpressCompanyImportResult;
import com.baiyue.warehouse.dto.MasterDataQueries.ExpressCompanySaveRequest;
import com.baiyue.warehouse.dto.MasterDataQueries.StatusRequest;
import com.baiyue.warehouse.entity.ExpressCompanyEntity;
import com.baiyue.warehouse.service.ExpressCompanyQueryService;
import com.baiyue.warehouse.service.ExpressCompanyImportService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.responses.ApiResponses;
import io.swagger.v3.oas.annotations.tags.Tag;
@@ -19,6 +21,10 @@ import org.springdoc.core.annotations.ParameterObject;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.validation.annotation.Validated;
import org.springframework.http.ContentDisposition;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PatchMapping;
import org.springframework.web.bind.annotation.PathVariable;
@@ -28,6 +34,11 @@ import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.bind.annotation.RequestPart;
import org.springframework.web.multipart.MultipartFile;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
/** 平台快递公司管理接口。 */
@Validated
@@ -36,9 +47,11 @@ import org.springframework.web.bind.annotation.RestController;
@RequestMapping("/api/platform/v1/express-companies")
public class PlatformExpressCompanyController {
private final ExpressCompanyQueryService service;
private final ExpressCompanyImportService importService;
public PlatformExpressCompanyController(ExpressCompanyQueryService service) {
public PlatformExpressCompanyController(ExpressCompanyQueryService service, ExpressCompanyImportService importService) {
this.service = service;
this.importService = importService;
}
@GetMapping
@@ -57,6 +70,33 @@ public class PlatformExpressCompanyController {
return ApiResponse.success(service.page(tenantId, query));
}
@GetMapping("/import-template")
@PreAuthorize("hasAuthority('platform:express:import')")
@Operation(summary = "下载快递公司导入模板", description = "下载当前版本的 XLSX 标准模板")
public ResponseEntity<byte[]> importTemplate(@AuthenticationPrincipal AuthenticatedUser user) {
requirePlatform(user);
HttpHeaders headers = new HttpHeaders();
headers.setContentDisposition(ContentDisposition.attachment()
.filename("express-company-import-template-v1.xlsx", StandardCharsets.UTF_8).build());
headers.setContentType(MediaType.parseMediaType("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"));
return ResponseEntity.ok().headers(headers).body(importService.template());
}
@PostMapping(value = "/import", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
@PreAuthorize("hasAuthority('platform:express:import')")
@Operation(summary = "批量导入快递公司", description = "使用标准 XLSX 模板整批校验并新增快递公司")
public ApiResponse<ExpressCompanyImportResult> importCompanies(
@AuthenticationPrincipal AuthenticatedUser user,
@RequestParam @Min(1) long tenantId,
@RequestPart("file") MultipartFile file) {
requirePlatform(user);
try {
return ApiResponse.success(importService.importFile(tenantId, file.getOriginalFilename(), file.getBytes()));
} catch (IOException exception) {
throw new BizException(ResultCode.BAD_REQUEST, "导入文件读取失败");
}
}
@GetMapping("/{id}")
@PreAuthorize("hasAuthority('platform:express:list')")
@Operation(summary = "查询快递公司详情", description = "查询目标租户内的快递公司详情")
@@ -7,9 +7,11 @@ import com.baiyue.common.core.exception.BizException;
import com.baiyue.common.security.AuthenticatedUser;
import com.baiyue.warehouse.dto.MasterDataQueries.PartnerPageQuery;
import com.baiyue.warehouse.dto.MasterDataQueries.PartnerSaveRequest;
import com.baiyue.warehouse.dto.MasterDataQueries.PartnerImportResult;
import com.baiyue.warehouse.dto.MasterDataQueries.StatusRequest;
import com.baiyue.warehouse.entity.PartnerEntity;
import com.baiyue.warehouse.service.PlatformPartnerQueryService;
import com.baiyue.warehouse.service.PartnerImportService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.responses.ApiResponses;
import io.swagger.v3.oas.annotations.tags.Tag;
@@ -28,6 +30,15 @@ import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.bind.annotation.RequestPart;
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
@@ -36,9 +47,38 @@ import org.springframework.web.bind.annotation.RestController;
@RequestMapping("/api/platform/v1/partners")
public class PlatformPartnerController {
private final PlatformPartnerQueryService service;
private final PartnerImportService importService;
public PlatformPartnerController(PlatformPartnerQueryService service) {
public PlatformPartnerController(PlatformPartnerQueryService service, PartnerImportService importService) {
this.service = service;
this.importService = importService;
}
@GetMapping("/import-template")
@PreAuthorize("hasAuthority('platform:counterparty:import')")
@Operation(summary = "下载往来单位导入模板", description = "下载当前版本的 XLSX 标准模板")
public ResponseEntity<byte[]> importTemplate(@AuthenticationPrincipal AuthenticatedUser user) {
requirePlatform(user);
HttpHeaders headers = new HttpHeaders();
headers.setContentDisposition(ContentDisposition.attachment()
.filename("partner-import-template-v1.xlsx", StandardCharsets.UTF_8).build());
headers.setContentType(MediaType.parseMediaType("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"));
return ResponseEntity.ok().headers(headers).body(importService.template());
}
@PostMapping(value = "/import", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
@PreAuthorize("hasAuthority('platform:counterparty:import')")
@Operation(summary = "批量导入往来单位", description = "使用标准 XLSX 模板整批校验并新增往来单位")
public ApiResponse<PartnerImportResult> importPartners(
@AuthenticationPrincipal AuthenticatedUser user,
@RequestParam @Min(1) long tenantId,
@RequestPart("file") MultipartFile file) {
requirePlatform(user);
try {
return ApiResponse.success(importService.importFile(tenantId, file.getOriginalFilename(), file.getBytes()));
} catch (IOException exception) {
throw new BizException(ResultCode.BAD_REQUEST, "导入文件读取失败");
}
}
@GetMapping
@@ -8,6 +8,8 @@ import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Pattern;
import jakarta.validation.constraints.Size;
import java.util.List;
/** 平台基础资料请求契约。 */
public final class MasterDataQueries {
private MasterDataQueries() {
@@ -63,8 +65,8 @@ public final class MasterDataQueries {
@NotBlank @Size(max = 50) String partnerCode,
@Schema(description = "往来单位名称", example = "深圳示例贸易有限公司")
@NotBlank @Size(max = 128) String partnerName,
@Schema(description = "往来单位类型", allowableValues = {"CUSTOMER", "SUPPLIER", "BOTH"})
@NotBlank @Pattern(regexp = "^(CUSTOMER|SUPPLIER|BOTH)$") String partnerType,
@Schema(description = "兼容旧客户端的单值类型", allowableValues = {"CUSTOMER", "SUPPLIER", "BOTH", "CARRIER", "CUSTOMS_BROKER", "SHIPPER", "CONSIGNEE"})
@Pattern(regexp = "^(CUSTOMER|SUPPLIER|BOTH|CARRIER|CUSTOMS_BROKER|SHIPPER|CONSIGNEE)$") String partnerType,
@Schema(description = "联系人", example = "张三")
@NotBlank @Size(max = 64) String contactName,
@Schema(description = "联系电话", example = "13800138000")
@@ -84,7 +86,24 @@ public final class MasterDataQueries {
@Schema(description = "邮政编码", example = "518000")
@Size(max = 20) String postalCode,
@Schema(description = "状态", allowableValues = {"ACTIVE", "INACTIVE"})
@NotBlank @Pattern(regexp = "^(ACTIVE|INACTIVE)$") String status
@NotBlank @Pattern(regexp = "^(ACTIVE|INACTIVE)$") String status,
@Schema(description = "单位类型集合", allowableValues = {"CUSTOMER", "SUPPLIER", "CARRIER", "CUSTOMS_BROKER", "SHIPPER", "CONSIGNEE"})
@Size(max = 6) List<@Pattern(regexp = "^(CUSTOMER|SUPPLIER|CARRIER|CUSTOMS_BROKER|SHIPPER|CONSIGNEE)$") String> types,
@Schema(description = "备注") @Size(max = 500) String remark
) {
}
@Schema(name = "PartnerImportError", description = "往来单位导入错误")
public record PartnerImportError(int row, String field, String message) {
}
@Schema(name = "PartnerImportResult", description = "往来单位批量导入结果")
public record PartnerImportResult(
String fileName,
int totalRows,
int importedRows,
boolean success,
List<PartnerImportError> errors
) {
}
@@ -107,6 +126,24 @@ public final class MasterDataQueries {
) {
}
@Schema(name = "ExpressCompanyImportError", description = "快递公司导入错误")
public record ExpressCompanyImportError(
@Schema(description = "数据行号,表头后的第一行数据为 4") int row,
@Schema(description = "字段名称") String field,
@Schema(description = "错误原因") String message
) {
}
@Schema(name = "ExpressCompanyImportResult", description = "快递公司批量导入结果")
public record ExpressCompanyImportResult(
@Schema(description = "原始文件名") String fileName,
@Schema(description = "数据行总数") int totalRows,
@Schema(description = "成功写入行数") int importedRows,
@Schema(description = "是否成功") boolean success,
@Schema(description = "逐行错误") List<ExpressCompanyImportError> errors
) {
}
@Schema(name = "MasterDataStatusRequest", description = "基础资料状态变更请求")
public record StatusRequest(
@Schema(description = "目标状态", allowableValues = {"ACTIVE", "INACTIVE"})
@@ -1,6 +1,7 @@
package com.baiyue.warehouse.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import com.baiyue.common.core.model.BaseEntity;
@@ -8,6 +9,8 @@ import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Getter;
import lombok.Setter;
import java.util.List;
/** 仓储域内的租户往来单位配置。 */
@Schema(name = "Partner", description = "租户维度往来单位主数据")
@Getter @Setter
@@ -17,12 +20,17 @@ public class PartnerEntity extends BaseEntity {
@TableId(type = IdType.ASSIGN_ID) private Long id;
@Schema(description = "所属租户 ID", example = "10001")
private Long tenantId;
@Schema(description = "所属业务数据范围 ID,由服务端维护", example = "10001")
private Long dataScopeId;
@Schema(description = "租户内唯一往来单位编码", example = "CUS-001")
private String partnerCode;
@Schema(description = "往来单位名称", example = "深圳示例贸易有限公司")
private String partnerName;
@Schema(description = "类型", example = "CUSTOMER", allowableValues = {"CUSTOMER", "SUPPLIER", "BOTH"})
private String partnerType;
@TableField(exist = false)
@Schema(description = "单位类型集合", allowableValues = {"CUSTOMER", "SUPPLIER", "CARRIER", "CUSTOMS_BROKER", "SHIPPER", "CONSIGNEE"})
private List<String> types;
@Schema(description = "联系人")
private String contactName;
@Schema(description = "联系电话")
@@ -43,4 +51,6 @@ public class PartnerEntity extends BaseEntity {
private String postalCode;
@Schema(description = "状态", example = "ACTIVE", allowableValues = {"ACTIVE", "INACTIVE"})
private String status;
@Schema(description = "备注")
private String remark;
}
@@ -0,0 +1,12 @@
package com.baiyue.warehouse.entity;
import lombok.Getter;
import lombok.Setter;
/** 往来单位与稳定业务类型的关联。 */
@Getter
@Setter
public class PartnerTypeEntity {
private Long partnerId;
private String partnerType;
}
@@ -1,4 +1,42 @@
package com.baiyue.warehouse.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baiyue.warehouse.entity.PartnerEntity;
public interface PartnerMapper extends BaseMapper<PartnerEntity> { }
import com.baiyue.warehouse.entity.PartnerTypeEntity;
import org.apache.ibatis.annotations.Delete;
import org.apache.ibatis.annotations.Insert;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
import java.util.Collection;
import java.util.List;
public interface PartnerMapper extends BaseMapper<PartnerEntity> {
@Select("""
<script>
SELECT partner_id AS partnerId, partner_type AS partnerType
FROM wh_partner_type
WHERE partner_id IN
<foreach collection="partnerIds" item="id" open="(" separator="," close=")">#{id}</foreach>
ORDER BY partner_id, partner_type
</script>
""")
List<PartnerTypeEntity> selectTypes(@Param("partnerIds") Collection<Long> partnerIds);
@Select("SELECT partner_id FROM wh_partner_type WHERE tenant_id = #{tenantId} AND partner_type = #{type}")
List<Long> selectPartnerIdsByType(@Param("tenantId") long tenantId, @Param("type") String type);
@Delete("DELETE FROM wh_partner_type WHERE partner_id = #{partnerId}")
void deleteTypes(@Param("partnerId") long partnerId);
@Insert("""
<script>
INSERT INTO wh_partner_type(partner_id, tenant_id, data_scope_id, partner_type)
VALUES
<foreach collection="types" item="type" separator=",">
(#{partnerId}, #{tenantId}, #{dataScopeId}, #{type})
</foreach>
</script>
""")
void insertTypes(@Param("partnerId") long partnerId, @Param("tenantId") long tenantId,
@Param("dataScopeId") long dataScopeId, @Param("types") Collection<String> types);
}
@@ -44,6 +44,13 @@ public class ExpressCompanyRepository {
.ne(excludedId != null, ExpressCompanyEntity::getId, excludedId)) > 0;
}
public boolean existsName(long tenantId, String name, Long excludedId) {
return mapper.selectCount(new LambdaQueryWrapper<ExpressCompanyEntity>()
.eq(ExpressCompanyEntity::getTenantId, tenantId)
.eq(ExpressCompanyEntity::getCompanyName, name)
.ne(excludedId != null, ExpressCompanyEntity::getId, excludedId)) > 0;
}
public void insert(ExpressCompanyEntity entity) {
mapper.insert(entity);
}
@@ -9,29 +9,46 @@ import com.baiyue.warehouse.mapper.PartnerMapper;
import org.springframework.stereotype.Repository;
import org.springframework.util.StringUtils;
import java.util.List;
import java.util.Collection;
import java.util.LinkedHashMap;
import java.util.Map;
@Repository
public class PartnerRepository {
private final PartnerMapper mapper;
public PartnerRepository(PartnerMapper mapper) { this.mapper = mapper; }
public List<PartnerEntity> activeCandidates(long tenantId, String keyword, String type) {
return mapper.selectList(new LambdaQueryWrapper<PartnerEntity>()
List<Long> typedIds = StringUtils.hasText(type)
? typedIds(tenantId, type)
: java.util.stream.Stream.concat(
typedIds(tenantId, "SHIPPER").stream(), typedIds(tenantId, "CONSIGNEE").stream())
.distinct().toList();
if (typedIds.isEmpty()) return List.of();
List<PartnerEntity> records = mapper.selectList(new LambdaQueryWrapper<PartnerEntity>()
.eq(PartnerEntity::getTenantId, tenantId).eq(PartnerEntity::getStatus, "ACTIVE")
.eq(StringUtils.hasText(type), PartnerEntity::getPartnerType, type)
.in(PartnerEntity::getId, typedIds)
.and(StringUtils.hasText(keyword), q -> q.like(PartnerEntity::getPartnerCode, keyword)
.or().like(PartnerEntity::getPartnerName, keyword).or().like(PartnerEntity::getContactName, keyword))
.orderByAsc(PartnerEntity::getPartnerName));
hydrateTypes(records);
return records;
}
public PartnerEntity findActive(long tenantId, long id) {
return mapper.selectOne(new LambdaQueryWrapper<PartnerEntity>()
PartnerEntity entity = mapper.selectOne(new LambdaQueryWrapper<PartnerEntity>()
.eq(PartnerEntity::getTenantId, tenantId).eq(PartnerEntity::getId, id)
.eq(PartnerEntity::getStatus, "ACTIVE"));
hydrateTypes(entity == null ? List.of() : List.of(entity));
return entity;
}
public PageResult<PartnerEntity> page(long tenantId, PartnerPageQuery request) {
List<Long> typedIds = typedIds(tenantId, request.type());
if (StringUtils.hasText(request.type()) && typedIds.isEmpty()) {
return new PageResult<>(List.of(), request.page(), request.size(), 0, 0);
}
LambdaQueryWrapper<PartnerEntity> wrapper = new LambdaQueryWrapper<PartnerEntity>()
.eq(PartnerEntity::getTenantId, tenantId)
.eq(StringUtils.hasText(request.type()), PartnerEntity::getPartnerType, request.type())
.in(StringUtils.hasText(request.type()), PartnerEntity::getId, typedIds)
.eq(StringUtils.hasText(request.status()), PartnerEntity::getStatus, request.status())
.and(StringUtils.hasText(request.keyword()), query -> query
.like(PartnerEntity::getPartnerCode, request.keyword())
@@ -40,13 +57,17 @@ public class PartnerRepository {
.or().like(PartnerEntity::getContactPhone, request.keyword()))
.orderByAsc(PartnerEntity::getPartnerName)
.orderByAsc(PartnerEntity::getId);
return PageResult.from(mapper.selectPage(new Page<>(request.page(), request.size()), wrapper));
PageResult<PartnerEntity> result = PageResult.from(mapper.selectPage(new Page<>(request.page(), request.size()), wrapper));
hydrateTypes(result.records());
return result;
}
public PartnerEntity findOwned(long tenantId, long id) {
return mapper.selectOne(new LambdaQueryWrapper<PartnerEntity>()
PartnerEntity entity = mapper.selectOne(new LambdaQueryWrapper<PartnerEntity>()
.eq(PartnerEntity::getTenantId, tenantId)
.eq(PartnerEntity::getId, id));
hydrateTypes(entity == null ? List.of() : List.of(entity));
return entity;
}
public boolean existsCode(long tenantId, String code, Long excludedId) {
@@ -58,9 +79,40 @@ public class PartnerRepository {
public void insert(PartnerEntity entity) {
mapper.insert(entity);
replaceTypes(entity);
}
public void save(PartnerEntity entity) {
mapper.updateById(entity);
replaceTypes(entity);
}
private List<Long> typedIds(long tenantId, String type) {
return StringUtils.hasText(type) ? mapper.selectPartnerIdsByType(tenantId, type) : List.of();
}
private void replaceTypes(PartnerEntity entity) {
mapper.deleteTypes(entity.getId());
Collection<String> types = entity.getTypes();
if (types != null && !types.isEmpty()) {
mapper.insertTypes(entity.getId(), entity.getTenantId(), entity.getDataScopeId(), types);
}
}
private void hydrateTypes(List<PartnerEntity> records) {
if (records.isEmpty()) return;
Map<Long, List<String>> grouped = new LinkedHashMap<>();
mapper.selectTypes(records.stream().map(PartnerEntity::getId).toList())
.forEach(item -> grouped.computeIfAbsent(item.getPartnerId(), ignored -> new java.util.ArrayList<>())
.add(item.getPartnerType()));
records.forEach(record -> {
List<String> types = grouped.get(record.getId());
record.setTypes(types == null || types.isEmpty() ? legacyTypes(record.getPartnerType()) : List.copyOf(types));
});
}
private static List<String> legacyTypes(String type) {
if ("BOTH".equals(type)) return List.of("CUSTOMER", "SUPPLIER");
return StringUtils.hasText(type) ? List.of(type) : List.of();
}
}
@@ -0,0 +1,193 @@
package com.baiyue.warehouse.service;
import com.baiyue.common.audit.BusinessAudit;
import com.baiyue.warehouse.dto.MasterDataQueries.ExpressCompanyImportError;
import com.baiyue.warehouse.dto.MasterDataQueries.ExpressCompanyImportResult;
import com.baiyue.warehouse.entity.ExpressCompanyEntity;
import com.baiyue.warehouse.repository.ExpressCompanyRepository;
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.dao.DataIntegrityViolationException;
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.net.URI;
import java.net.URISyntaxException;
import java.util.ArrayList;
import java.util.List;
import java.util.Locale;
import org.springframework.transaction.interceptor.TransactionAspectSupport;
/** 标准模板和快递公司批量导入。 */
@Service
public class ExpressCompanyImportService {
private static final int MAX_FILE_BYTES = 5 * 1024 * 1024;
private static final int MAX_ROWS = 1000;
private static final String TEMPLATE_VERSION = "1";
private static final String[] HEADERS = {
"快递公司编码*", "快递公司名称*", "客服电话", "官方网站", "轨迹查询地址模板", "状态*", "备注"
};
private final ExpressCompanyRepository repository;
public ExpressCompanyImportService(ExpressCompanyRepository repository) {
this.repository = repository;
}
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("仅新增;编码、名称、状态必填;状态填写 ACTIVE 或 INACTIVE");
Row header = sheet.createRow(2);
for (int i = 0; i < HEADERS.length; i++) header.createCell(i).setCellValue(HEADERS[i]);
for (int i = 0; i < HEADERS.length; i++) sheet.autoSizeColumn(i);
workbook.write(output);
return output.toByteArray();
} catch (IOException exception) {
throw new IllegalStateException("生成快递公司导入模板失败", exception);
}
}
@Transactional
@BusinessAudit(event = "EXPRESS_COMPANY_IMPORT", resourceType = "EXPRESS_COMPANY_IMPORT", description = "批量导入快递公司", captureBefore = false)
public ExpressCompanyImportResult importFile(long tenantId, String fileName, byte[] content) {
String safeFileName = fileName == null || fileName.isBlank() ? "未命名文件.xlsx" : fileName;
if (content == null || content.length == 0) {
return failure(safeFileName, 0, new ExpressCompanyImportError(0, "文件", "文件不能为空"));
}
if (content.length > MAX_FILE_BYTES) {
return failure(safeFileName, 0, new ExpressCompanyImportError(0, "文件", "文件不能超过 5MB"));
}
if (!safeFileName.toLowerCase(Locale.ROOT).endsWith(".xlsx")) {
return failure(safeFileName, 0, new ExpressCompanyImportError(0, "文件", "只支持 .xlsx 文件"));
}
List<ImportRow> rows = new ArrayList<>();
List<ExpressCompanyImportError> errors = new ArrayList<>();
try (XSSFWorkbook workbook = new XSSFWorkbook(new ByteArrayInputStream(content))) {
if (workbook.getNumberOfSheets() == 0) {
return failure(safeFileName, 0, new ExpressCompanyImportError(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 ExpressCompanyImportError(1, "模板版本", "模板版本不匹配,请下载最新模板"));
}
for (int i = 0; i < HEADERS.length; i++) {
if (!HEADERS[i].equals(value(sheet.getRow(2), i, formatter))) {
errors.add(new ExpressCompanyImportError(3, "表头", "第 " + (i + 1) + " 列应为“" + HEADERS[i] + "”"));
}
}
if (!errors.isEmpty()) return new ExpressCompanyImportResult(safeFileName, 0, 0, false, errors);
for (int rowIndex = 3; rowIndex <= sheet.getLastRowNum(); rowIndex++) {
Row row = sheet.getRow(rowIndex);
if (isBlank(row, formatter)) continue;
if (rows.size() >= MAX_ROWS) {
errors.add(new ExpressCompanyImportError(rowIndex + 1, "文件", "单次最多导入 1000 行"));
break;
}
ImportRow importRow = new ImportRow(rowIndex + 1,
value(row, 0, formatter), value(row, 1, formatter), value(row, 2, formatter),
value(row, 3, formatter), value(row, 4, formatter), value(row, 5, formatter), value(row, 6, formatter));
rows.add(importRow);
}
} catch (IOException | RuntimeException exception) {
return failure(safeFileName, rows.size(), new ExpressCompanyImportError(0, "文件", "文件格式无法读取,请使用标准 XLSX 模板"));
}
if (rows.isEmpty() && errors.isEmpty()) {
errors.add(new ExpressCompanyImportError(0, "文件", "文件中没有可导入的数据行"));
}
for (ImportRow row : rows) validate(tenantId, row, rows, errors);
if (!errors.isEmpty()) return new ExpressCompanyImportResult(safeFileName, rows.size(), 0, false, errors);
try {
for (ImportRow row : rows) repository.insert(toEntity(tenantId, row));
} catch (DataIntegrityViolationException exception) {
TransactionAspectSupport.currentTransactionStatus().setRollbackOnly();
return failure(safeFileName, rows.size(), new ExpressCompanyImportError(0, "编码或名称", "与现有快递公司重复,整批数据未写入"));
}
return new ExpressCompanyImportResult(safeFileName, rows.size(), rows.size(), true, List.of());
}
private void validate(long tenantId, ImportRow row, List<ImportRow> rows, List<ExpressCompanyImportError> errors) {
if (row.code.isBlank()) errors.add(error(row, "快递公司编码", "不能为空"));
if (row.code.length() > 50) errors.add(error(row, "快递公司编码", "不能超过 50 个字符"));
if (row.name.isBlank()) errors.add(error(row, "快递公司名称", "不能为空"));
if (row.name.length() > 128) errors.add(error(row, "快递公司名称", "不能超过 128 个字符"));
if (row.phone.length() > 32) errors.add(error(row, "客服电话", "不能超过 32 个字符"));
if (row.website.length() > 255 || !validUrl(row.website)) errors.add(error(row, "官方网站", "必须是 http:// 或 https:// 开头的网址"));
if (row.trackingTemplate.length() > 500) errors.add(error(row, "轨迹查询地址模板", "不能超过 500 个字符"));
if (!"ACTIVE".equals(row.status) && !"INACTIVE".equals(row.status)) errors.add(error(row, "状态", "只能填写 ACTIVE 或 INACTIVE"));
if (row.remark.length() > 500) errors.add(error(row, "备注", "不能超过 500 个字符"));
for (ImportRow previous : rows) {
if (previous == row) break;
if (!row.code.isBlank() && previous.code.equalsIgnoreCase(row.code)) {
errors.add(error(row, "快递公司编码", "与文件内其他行重复"));
}
if (!row.name.isBlank() && previous.name.equalsIgnoreCase(row.name)) {
errors.add(error(row, "快递公司名称", "与文件内其他行重复"));
}
}
if (!row.code.isBlank() && repository.existsCode(tenantId, row.code, null)) errors.add(error(row, "快递公司编码", "与现有档案重复"));
if (!row.name.isBlank() && repository.existsName(tenantId, row.name, null)) errors.add(error(row, "快递公司名称", "与现有档案重复"));
}
private static ExpressCompanyEntity toEntity(long tenantId, ImportRow row) {
ExpressCompanyEntity entity = new ExpressCompanyEntity();
entity.setTenantId(tenantId);
entity.setCompanyCode(row.code);
entity.setCompanyName(row.name);
entity.setServicePhone(nullable(row.phone));
entity.setWebsite(nullable(row.website));
entity.setTrackingUrlTemplate(nullable(row.trackingTemplate));
entity.setStatus(row.status);
entity.setRemark(nullable(row.remark));
return entity;
}
private static String value(Row row, int index, DataFormatter formatter) {
if (row == null) return "";
Cell cell = row.getCell(index);
return cell == null ? "" : formatter.formatCellValue(cell).trim();
}
private static boolean isBlank(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 boolean validUrl(String value) {
if (value.isBlank()) return true;
try {
URI uri = new URI(value);
return ("http".equalsIgnoreCase(uri.getScheme()) || "https".equalsIgnoreCase(uri.getScheme())) && uri.getHost() != null;
} catch (URISyntaxException exception) {
return false;
}
}
private static String nullable(String value) { return value.isBlank() ? null : value; }
private static ExpressCompanyImportError error(ImportRow row, String field, String message) {
return new ExpressCompanyImportError(row.row, field, message);
}
private static ExpressCompanyImportResult failure(String fileName, int rows, ExpressCompanyImportError error) {
return new ExpressCompanyImportResult(fileName, rows, 0, false, List.of(error));
}
private record ImportRow(int row, String code, String name, String phone, String website,
String trackingTemplate, String status, String remark) { }
}
@@ -33,13 +33,14 @@ public class ExpressCompanyQueryService {
@BusinessAudit(event = "EXPRESS_COMPANY_CREATE", resourceType = "EXPRESS_COMPANY", description = "新增快递公司", captureBefore = false)
public Long create(long tenantId, ExpressCompanySaveRequest request) {
ensureCodeUnique(tenantId, request.companyCode(), null);
ensureNameUnique(tenantId, request.companyName(), null);
ExpressCompanyEntity entity = new ExpressCompanyEntity();
entity.setTenantId(tenantId);
copy(entity, request);
try {
repository.insert(entity);
} catch (DataIntegrityViolationException exception) {
throw new BizException(ResultCode.CONFLICT, "快递公司编码已存在");
throw new BizException(ResultCode.CONFLICT, "快递公司编码或名称已存在");
}
return entity.getId();
}
@@ -49,11 +50,12 @@ public class ExpressCompanyQueryService {
public void update(long tenantId, long id, ExpressCompanySaveRequest request) {
ExpressCompanyEntity entity = owned(tenantId, id);
ensureCodeUnique(tenantId, request.companyCode(), id);
ensureNameUnique(tenantId, request.companyName(), id);
copy(entity, request);
try {
repository.save(entity);
} catch (DataIntegrityViolationException exception) {
throw new BizException(ResultCode.CONFLICT, "快递公司编码已存在");
throw new BizException(ResultCode.CONFLICT, "快递公司编码或名称已存在");
}
}
@@ -80,6 +82,12 @@ public class ExpressCompanyQueryService {
}
}
private void ensureNameUnique(long tenantId, String name, Long excludedId) {
if (repository.existsName(tenantId, name, excludedId)) {
throw new BizException(ResultCode.CONFLICT, "快递公司名称已存在");
}
}
private static void requireStatus(String status) {
if (!"ACTIVE".equals(status) && !"INACTIVE".equals(status)) {
throw new BizException(ResultCode.BAD_REQUEST, "状态只能设置为 ACTIVE 或 INACTIVE");
@@ -0,0 +1,218 @@
package com.baiyue.warehouse.service;
import com.baiyue.common.audit.BusinessAudit;
import com.baiyue.warehouse.dto.MasterDataQueries.PartnerImportError;
import com.baiyue.warehouse.dto.MasterDataQueries.PartnerImportResult;
import com.baiyue.warehouse.entity.PartnerEntity;
import com.baiyue.warehouse.repository.PartnerRepository;
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.dao.DataIntegrityViolationException;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.transaction.interceptor.TransactionAspectSupport;
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.util.ArrayList;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Locale;
import java.util.Set;
import java.util.regex.Pattern;
/** 往来单位标准模板生成与原子批量导入。 */
@Service
public class PartnerImportService {
private static final int MAX_FILE_BYTES = 5 * 1024 * 1024;
private static final int MAX_ROWS = 1000;
private static final String TEMPLATE_VERSION = "1";
private static final Set<String> ALLOWED_TYPES = Set.of(
"CUSTOMER", "SUPPLIER", "CARRIER", "CUSTOMS_BROKER", "SHIPPER", "CONSIGNEE");
private static final Pattern EMAIL = Pattern.compile("^[^\\s@]+@[^\\s@]+\\.[^\\s@]+$");
private static final String[] HEADERS = {
"单位编码*", "单位名称*", "单位类型*", "联系人*", "联系电话*", "联系邮箱",
"国家二字码*", "省/州", "城市*", "区/县", "详细地址*", "邮政编码", "状态*", "备注"
};
private final PartnerRepository repository;
public PartnerImportService(PartnerRepository repository) {
this.repository = repository;
}
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(
"仅新增;类型可用中文逗号或英文逗号分隔:CUSTOMER、SUPPLIER、CARRIER、CUSTOMS_BROKER、SHIPPER、CONSIGNEE;状态为 ACTIVE 或 INACTIVE");
Row header = sheet.createRow(2);
for (int i = 0; i < HEADERS.length; i++) {
header.createCell(i).setCellValue(HEADERS[i]);
sheet.setColumnWidth(i, Math.min(48, Math.max(14, HEADERS[i].length() * 2 + 4)) * 256);
}
workbook.write(output);
return output.toByteArray();
} catch (IOException exception) {
throw new IllegalStateException("生成往来单位导入模板失败", exception);
}
}
@Transactional
@BusinessAudit(event = "PARTNER_IMPORT", resourceType = "PARTNER_IMPORT", description = "批量导入往来单位", captureBefore = false)
public PartnerImportResult importFile(long tenantId, String fileName, byte[] content) {
String safeFileName = safeFileName(fileName);
if (content == null || content.length == 0) return failure(safeFileName, 0, 0, "文件", "文件不能为空");
if (content.length > MAX_FILE_BYTES) return failure(safeFileName, 0, 0, "文件", "文件不能超过 5MB");
if (!safeFileName.toLowerCase(Locale.ROOT).endsWith(".xlsx")) {
return failure(safeFileName, 0, 0, "文件", "只支持 .xlsx 文件");
}
List<ImportRow> rows = new ArrayList<>();
List<PartnerImportError> errors = new ArrayList<>();
try (XSSFWorkbook workbook = new XSSFWorkbook(new ByteArrayInputStream(content))) {
if (workbook.getNumberOfSheets() == 0) return failure(safeFileName, 0, 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 PartnerImportError(1, "模板版本", "模板版本不匹配,请下载最新模板"));
}
for (int i = 0; i < HEADERS.length; i++) {
if (!HEADERS[i].equals(value(sheet.getRow(2), i, formatter))) {
errors.add(new PartnerImportError(3, "表头", "第 " + (i + 1) + " 列应为“" + HEADERS[i] + "”"));
}
}
if (!errors.isEmpty()) return result(safeFileName, 0, 0, errors);
for (int index = 3; index <= sheet.getLastRowNum(); index++) {
Row row = sheet.getRow(index);
if (isBlank(row, formatter)) continue;
if (rows.size() >= MAX_ROWS) {
errors.add(new PartnerImportError(index + 1, "文件", "单次最多导入 1000 行"));
break;
}
ImportRow item = new ImportRow(index + 1,
value(row, 0, formatter), value(row, 1, formatter), parseTypes(value(row, 2, formatter)),
value(row, 3, formatter), value(row, 4, formatter), value(row, 5, formatter),
value(row, 6, formatter).toUpperCase(Locale.ROOT), value(row, 7, formatter),
value(row, 8, formatter), value(row, 9, formatter), value(row, 10, formatter),
value(row, 11, formatter), value(row, 12, formatter).toUpperCase(Locale.ROOT), value(row, 13, formatter));
validate(tenantId, item, rows, errors);
rows.add(item);
}
} catch (IOException | RuntimeException exception) {
return failure(safeFileName, rows.size(), 0, "文件", "文件格式无法读取,请使用标准 XLSX 模板");
}
if (rows.isEmpty() && errors.isEmpty()) errors.add(new PartnerImportError(0, "文件", "文件中没有可导入的数据行"));
if (!errors.isEmpty()) return result(safeFileName, rows.size(), 0, errors);
try {
for (ImportRow row : rows) repository.insert(toEntity(tenantId, row));
} catch (DataIntegrityViolationException exception) {
TransactionAspectSupport.currentTransactionStatus().setRollbackOnly();
return failure(safeFileName, rows.size(), 0, "单位编码", "与现有档案重复,整批数据未写入");
}
return new PartnerImportResult(safeFileName, rows.size(), rows.size(), true, List.of());
}
private void validate(long tenantId, ImportRow row, List<ImportRow> previousRows, List<PartnerImportError> errors) {
required(row, errors, "单位编码", row.code, 50);
required(row, errors, "单位名称", row.name, 128);
if (row.types.isEmpty()) errors.add(error(row, "单位类型", "至少填写一个类型"));
row.types.stream().filter(type -> !ALLOWED_TYPES.contains(type))
.forEach(type -> errors.add(error(row, "单位类型", "不支持类型 " + type)));
required(row, errors, "联系人", row.contactName, 64);
required(row, errors, "联系电话", row.contactPhone, 32);
if (row.email.length() > 128 || (!row.email.isBlank() && !EMAIL.matcher(row.email).matches())) {
errors.add(error(row, "联系邮箱", "请输入有效的邮箱地址,且不超过 128 个字符"));
}
if (!row.countryCode.matches("^[A-Z]{2}$")) errors.add(error(row, "国家二字码", "必须是两个英文字母"));
optionalLength(row, errors, "省/州", row.province, 64);
required(row, errors, "城市", row.city, 64);
optionalLength(row, errors, "区/县", row.district, 64);
required(row, errors, "详细地址", row.address, 255);
optionalLength(row, errors, "邮政编码", row.postalCode, 20);
if (!"ACTIVE".equals(row.status) && !"INACTIVE".equals(row.status)) errors.add(error(row, "状态", "只能填写 ACTIVE 或 INACTIVE"));
optionalLength(row, errors, "备注", row.remark, 500);
if (previousRows.stream().anyMatch(previous -> previous.code.equalsIgnoreCase(row.code))) {
errors.add(error(row, "单位编码", "与文件内其他行重复"));
}
if (!row.code.isBlank() && repository.existsCode(tenantId, row.code, null)) {
errors.add(error(row, "单位编码", "与现有档案重复"));
}
}
private static PartnerEntity toEntity(long tenantId, ImportRow row) {
PartnerEntity entity = new PartnerEntity();
entity.setTenantId(tenantId);
entity.setDataScopeId(tenantId);
entity.setPartnerCode(row.code);
entity.setPartnerName(row.name);
entity.setTypes(row.types);
entity.setPartnerType(row.types.contains("CUSTOMER") && row.types.contains("SUPPLIER") ? "BOTH" : row.types.get(0));
entity.setContactName(row.contactName);
entity.setContactPhone(row.contactPhone);
entity.setContactEmail(nullable(row.email));
entity.setCountryCode(row.countryCode);
entity.setProvince(nullable(row.province));
entity.setCity(row.city);
entity.setDistrict(nullable(row.district));
entity.setAddressLine(row.address);
entity.setPostalCode(nullable(row.postalCode));
entity.setStatus(row.status);
entity.setRemark(nullable(row.remark));
return entity;
}
private static List<String> parseTypes(String value) {
LinkedHashSet<String> types = new LinkedHashSet<>();
for (String type : value.toUpperCase(Locale.ROOT).split("[,,、]")) if (!type.isBlank()) types.add(type.trim());
return List.copyOf(types);
}
private static void required(ImportRow row, List<PartnerImportError> errors, String field, String value, int max) {
if (value.isBlank()) errors.add(error(row, field, "不能为空"));
else if (value.length() > max) errors.add(error(row, field, "不能超过 " + max + " 个字符"));
}
private static void optionalLength(ImportRow row, List<PartnerImportError> errors, String field, String value, int max) {
if (value.length() > max) errors.add(error(row, field, "不能超过 " + max + " 个字符"));
}
private static String value(Row row, int index, DataFormatter formatter) {
if (row == null) return "";
Cell cell = row.getCell(index);
return cell == null ? "" : formatter.formatCellValue(cell).trim();
}
private static boolean isBlank(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 PartnerImportError error(ImportRow row, String field, String message) {
return new PartnerImportError(row.row, field, message);
}
private static PartnerImportResult failure(String fileName, int total, int imported, String field, String message) {
return result(fileName, total, imported, List.of(new PartnerImportError(0, field, message)));
}
private static PartnerImportResult result(String fileName, int total, int imported, List<PartnerImportError> errors) {
return new PartnerImportResult(fileName, total, imported, errors.isEmpty(), errors);
}
private record ImportRow(int row, String code, String name, List<String> types, String contactName,
String contactPhone, String email, String countryCode, String province, String city,
String district, String address, String postalCode, String status, String remark) { }
}
@@ -12,6 +12,9 @@ public class PartnerQueryService {
private final PartnerRepository repository;
public PartnerQueryService(PartnerRepository repository) { this.repository = repository; }
public List<PartnerEntity> candidates(long tenantId, String keyword, String type) {
if (type != null && !type.isBlank() && !"SHIPPER".equals(type) && !"CONSIGNEE".equals(type)) {
throw new BizException(ResultCode.BAD_REQUEST, "候选类型只能是 SHIPPER 或 CONSIGNEE");
}
return repository.activeCandidates(tenantId, keyword, type);
}
public PartnerEntity active(long tenantId, long id) {
@@ -12,6 +12,10 @@ import org.springframework.stereotype.Service;
import org.springframework.dao.DataIntegrityViolationException;
import org.springframework.transaction.annotation.Transactional;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Set;
/** 平台往来单位管理服务。 */
@Service
public class PlatformPartnerQueryService {
@@ -35,6 +39,7 @@ public class PlatformPartnerQueryService {
ensureCodeUnique(tenantId, request.partnerCode(), null);
PartnerEntity entity = new PartnerEntity();
entity.setTenantId(tenantId);
entity.setDataScopeId(tenantId);
copy(entity, request);
try {
repository.insert(entity);
@@ -87,9 +92,11 @@ public class PlatformPartnerQueryService {
}
private static void copy(PartnerEntity entity, PartnerSaveRequest request) {
List<String> types = normalizeTypes(request.types(), request.partnerType());
entity.setPartnerCode(request.partnerCode());
entity.setPartnerName(request.partnerName());
entity.setPartnerType(request.partnerType());
entity.setTypes(types);
entity.setPartnerType(legacyType(types));
entity.setContactName(request.contactName());
entity.setContactPhone(request.contactPhone());
entity.setContactEmail(request.contactEmail());
@@ -100,5 +107,26 @@ public class PlatformPartnerQueryService {
entity.setAddressLine(request.addressLine());
entity.setPostalCode(request.postalCode());
entity.setStatus(request.status());
entity.setRemark(request.remark());
}
static List<String> normalizeTypes(List<String> requested, String legacyType) {
Set<String> normalized = new LinkedHashSet<>();
if (requested != null) requested.forEach(type -> {
if (type != null && !type.isBlank()) normalized.add(type);
});
if (normalized.isEmpty() && "BOTH".equals(legacyType)) {
normalized.add("CUSTOMER");
normalized.add("SUPPLIER");
} else if (normalized.isEmpty() && legacyType != null && !legacyType.isBlank()) {
normalized.add(legacyType);
}
if (normalized.isEmpty()) throw new BizException(ResultCode.BAD_REQUEST, "请至少选择一个单位类型");
return List.copyOf(normalized);
}
private static String legacyType(List<String> types) {
if (types.contains("CUSTOMER") && types.contains("SUPPLIER")) return "BOTH";
return types.get(0);
}
}
@@ -16,7 +16,8 @@ public class WarehouseSchemaReadinessService {
"SELECT id, tenant_id, warehouse_code, warehouse_name, warehouse_type, country_code, timezone, status, deleted FROM wh_warehouse LIMIT 0",
"SELECT id, tenant_id, warehouse_id, address_type, country_code, city, address_line1, deleted FROM wh_warehouse_address LIMIT 0",
"SELECT id, tenant_id, warehouse_id, capability_code, capability_name, enabled, deleted FROM wh_service_capability LIMIT 0",
"SELECT id, tenant_id, partner_code, partner_name, partner_type, status, deleted FROM wh_partner LIMIT 0",
"SELECT id, tenant_id, data_scope_id, partner_code, partner_name, partner_type, status, remark, deleted FROM wh_partner LIMIT 0",
"SELECT partner_id, tenant_id, data_scope_id, partner_type FROM wh_partner_type LIMIT 0",
"SELECT id, tenant_id, company_code, company_name, status, deleted FROM wh_express_company LIMIT 0"
);
@@ -145,3 +145,6 @@ PREPARE statement FROM @sql; EXECUTE statement; DEALLOCATE PREPARE statement;
SET @sql = IF((SELECT COUNT(*) FROM information_schema.statistics WHERE table_schema = @schema_name AND table_name = 'wh_express_company' AND index_name = 'uk_wh_express_tenant_code') = 0,
'ALTER TABLE wh_express_company ADD UNIQUE KEY uk_wh_express_tenant_code (tenant_id, company_code, deleted)', 'SELECT 1');
PREPARE statement FROM @sql; EXECUTE statement; DEALLOCATE PREPARE statement;
SET @sql = IF((SELECT COUNT(*) FROM information_schema.statistics WHERE table_schema = @schema_name AND table_name = 'wh_express_company' AND index_name = 'uk_wh_express_tenant_name') = 0,
'ALTER TABLE wh_express_company ADD UNIQUE KEY uk_wh_express_tenant_name (tenant_id, company_name, deleted)', 'SELECT 1');
PREPARE statement FROM @sql; EXECUTE statement; DEALLOCATE PREPARE statement;
@@ -19,5 +19,6 @@ CREATE TABLE IF NOT EXISTS wh_express_company (
version INT NOT NULL DEFAULT 0 COMMENT '乐观锁版本号',
PRIMARY KEY (id),
UNIQUE KEY uk_wh_express_tenant_code (tenant_id, company_code, deleted),
UNIQUE KEY uk_wh_express_tenant_name (tenant_id, company_name, deleted),
KEY idx_wh_express_tenant_status (tenant_id, status, deleted)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='租户维度快递公司主数据';
@@ -2,19 +2,30 @@ SET NAMES utf8mb4;
USE baiyue_warehouse;
CREATE TABLE IF NOT EXISTS wh_partner (
id BIGINT UNSIGNED NOT NULL, tenant_id BIGINT UNSIGNED NOT NULL,
data_scope_id BIGINT UNSIGNED NOT NULL COMMENT '所属业务数据范围',
partner_code VARCHAR(50) NOT NULL, partner_name VARCHAR(128) NOT NULL,
partner_type VARCHAR(20) NOT NULL COMMENT 'CUSTOMER、SUPPLIER或BOTH',
contact_name VARCHAR(64) NOT NULL, contact_phone VARCHAR(32) NOT NULL,
contact_email VARCHAR(128) NULL, country_code CHAR(2) NOT NULL,
province VARCHAR(64) NULL, city VARCHAR(64) NOT NULL, district VARCHAR(64) NULL,
address_line VARCHAR(255) NOT NULL, postal_code VARCHAR(20) NULL,
status VARCHAR(20) NOT NULL DEFAULT 'ACTIVE', created_by BIGINT UNSIGNED NULL,
status VARCHAR(20) NOT NULL DEFAULT 'ACTIVE', remark VARCHAR(500) NULL,
created_by BIGINT UNSIGNED NULL,
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), updated_by BIGINT UNSIGNED NULL,
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
deleted TINYINT(1) NOT NULL DEFAULT 0, version INT NOT NULL DEFAULT 0,
PRIMARY KEY(id), UNIQUE KEY uk_wh_partner_tenant_code(tenant_id,partner_code,deleted),
KEY idx_wh_partner_tenant_status(tenant_id,status,deleted)
) ENGINE=InnoDB COMMENT='仓储域内的租户往来单位配置';
INSERT INTO wh_partner(id,tenant_id,partner_code,partner_name,partner_type,contact_name,contact_phone,contact_email,country_code,province,city,district,address_line,postal_code,status)
VALUES(191000000000000001,10001,'CUS-001','深圳示例贸易有限公司','CUSTOMER','张三','13800138000','contact@example.com','CN','广东省','深圳市','宝安区','航城街道示例路1号','518000','ACTIVE')
CREATE TABLE IF NOT EXISTS wh_partner_type (
partner_id BIGINT UNSIGNED NOT NULL, tenant_id BIGINT UNSIGNED NOT NULL,
data_scope_id BIGINT UNSIGNED NOT NULL, partner_type VARCHAR(32) NOT NULL,
PRIMARY KEY(partner_id,partner_type),
KEY idx_wh_partner_type_scope(tenant_id,data_scope_id,partner_type,partner_id)
) ENGINE=InnoDB COMMENT='往来单位稳定业务类型';
INSERT INTO wh_partner(id,tenant_id,data_scope_id,partner_code,partner_name,partner_type,contact_name,contact_phone,contact_email,country_code,province,city,district,address_line,postal_code,status)
VALUES(191000000000000001,10001,10001,'CUS-001','深圳示例贸易有限公司','CUSTOMER','张三','13800138000','contact@example.com','CN','广东省','深圳市','宝安区','航城街道示例路1号','518000','ACTIVE')
ON DUPLICATE KEY UPDATE partner_name=VALUES(partner_name),status='ACTIVE';
INSERT INTO wh_partner_type(partner_id,tenant_id,data_scope_id,partner_type)
VALUES(191000000000000001,10001,10001,'CUSTOMER')
ON DUPLICATE KEY UPDATE tenant_id=VALUES(tenant_id),data_scope_id=VALUES(data_scope_id);
@@ -24,6 +24,8 @@ class PlatformMasterDataControllerContractTest {
assertMethod(PlatformPartnerController.class, "create", PostMapping.class, "platform:counterparty:create");
assertMethod(PlatformPartnerController.class, "update", PutMapping.class, "platform:counterparty:edit");
assertMethod(PlatformPartnerController.class, "changeStatus", PatchMapping.class, "platform:counterparty:status");
assertMethod(PlatformPartnerController.class, "importTemplate", GetMapping.class, "platform:counterparty:import");
assertMethod(PlatformPartnerController.class, "importPartners", PostMapping.class, "platform:counterparty:import");
}
@Test
@@ -34,6 +36,8 @@ class PlatformMasterDataControllerContractTest {
assertMethod(PlatformExpressCompanyController.class, "create", PostMapping.class, "platform:express:create");
assertMethod(PlatformExpressCompanyController.class, "update", PutMapping.class, "platform:express:edit");
assertMethod(PlatformExpressCompanyController.class, "changeStatus", PatchMapping.class, "platform:express:status");
assertMethod(PlatformExpressCompanyController.class, "importTemplate", GetMapping.class, "platform:express:import");
assertMethod(PlatformExpressCompanyController.class, "importCompanies", PostMapping.class, "platform:express:import");
}
private static void assertContract(Class<?> controller, String authority, String path) throws Exception {
@@ -26,10 +26,28 @@ class PartnerRepositoryTest {
@DisplayName("候选查询强制租户和启用状态")
void shouldEnforceTenantAndActiveStatusForCandidateQuery() {
PartnerMapper mapper=mock(PartnerMapper.class); when(mapper.selectList(any())).thenReturn(List.of());
new PartnerRepository(mapper).activeCandidates(10001,"示例","CUSTOMER");
when(mapper.selectPartnerIdsByType(10001,"SHIPPER")).thenReturn(List.of(1L));
new PartnerRepository(mapper).activeCandidates(10001,"示例","SHIPPER");
@SuppressWarnings("unchecked") ArgumentCaptor<LambdaQueryWrapper<PartnerEntity>> c=ArgumentCaptor.forClass(LambdaQueryWrapper.class);
verify(mapper).selectList(c.capture()); c.getValue().getSqlSegment();
assertThat(c.getValue().getParamNameValuePairs().values()).contains(10001L,"ACTIVE","CUSTOMER");
assertThat(c.getValue().getParamNameValuePairs().values()).contains(10001L,"ACTIVE",1L);
verify(mapper).selectPartnerIdsByType(10001L, "SHIPPER");
}
@Test
@DisplayName("默认候选只包含发货单位和收货单位")
void shouldLimitDefaultCandidatesToOrderPartyTypes() {
PartnerMapper mapper=mock(PartnerMapper.class);
when(mapper.selectPartnerIdsByType(10001L,"SHIPPER")).thenReturn(List.of(1L));
when(mapper.selectPartnerIdsByType(10001L,"CONSIGNEE")).thenReturn(List.of(2L,1L));
when(mapper.selectList(any())).thenReturn(List.of());
new PartnerRepository(mapper).activeCandidates(10001L,null,null);
@SuppressWarnings("unchecked") ArgumentCaptor<LambdaQueryWrapper<PartnerEntity>> wrapper=ArgumentCaptor.forClass(LambdaQueryWrapper.class);
verify(mapper).selectList(wrapper.capture());
wrapper.getValue().getSqlSegment();
assertThat(wrapper.getValue().getParamNameValuePairs().values()).contains(10001L,"ACTIVE",1L,2L);
}
@Test
@DisplayName("详情查询强制租户和启用状态")
@@ -45,6 +63,7 @@ class PartnerRepositoryTest {
@DisplayName("平台分页强制租户并支持筛选")
void shouldEnforceTenantAndSupportFilteringForPlatformPage() {
PartnerMapper mapper=mock(PartnerMapper.class);
when(mapper.selectPartnerIdsByType(10001L, "CUSTOMER")).thenReturn(List.of(9L));
when(mapper.selectPage(any(Page.class), any())).thenAnswer(invocation -> invocation.getArgument(0));
new PartnerRepository(mapper).page(10001L,
@@ -55,6 +74,6 @@ class PartnerRepositoryTest {
verify(mapper).selectPage(any(Page.class), wrapper.capture());
wrapper.getValue().getSqlSegment();
assertThat(wrapper.getValue().getParamNameValuePairs().values())
.contains(10001L, "CUSTOMER", "ACTIVE", "%示例%");
.contains(10001L, 9L, "ACTIVE", "%示例%");
}
}
@@ -28,9 +28,11 @@ class WarehouseSchemaContractTest {
@DisplayName("往来单位表包含实体全部持久化字段")
void shouldContainAllPersistentFieldsInPartnerTable() throws IOException {
String sql = read("warehouse-partner.sql");
assertColumns(sql, "wh_partner", "partner_code", "partner_name", "partner_type", "contact_name",
assertColumns(sql, "wh_partner", "data_scope_id", "partner_code", "partner_name", "partner_type", "contact_name",
"contact_phone", "contact_email", "country_code", "province", "city", "district", "address_line",
"postal_code", "status");
"postal_code", "status", "remark");
assertThat(sql).contains("CREATE TABLE IF NOT EXISTS wh_partner_type")
.contains("idx_wh_partner_type_scope");
assertBaseColumns(sql);
}
@@ -0,0 +1,71 @@
package com.baiyue.warehouse.service;
import com.baiyue.warehouse.entity.ExpressCompanyEntity;
import com.baiyue.warehouse.repository.ExpressCompanyRepository;
import org.apache.poi.xssf.usermodel.XSSFWorkbook;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
@DisplayName("快递公司批量导入")
class ExpressCompanyImportServiceTest {
@Test
@DisplayName("标准模板通过整批校验后写入全部数据")
void shouldImportValidWorkbookAfterFullValidation() throws Exception {
ExpressCompanyRepository repository = mock(ExpressCompanyRepository.class);
ExpressCompanyImportService service = new ExpressCompanyImportService(repository);
byte[] workbook = withRows(service.template(), new String[][] {
{"SF", "顺丰速运", "95338", "https://www.sf-express.com", "https://example.com/{trackingNo}", "ACTIVE", "国内件"}
});
var result = service.importFile(10001L, "express.xlsx", workbook);
assertThat(result.success()).isTrue();
assertThat(result.importedRows()).isEqualTo(1);
var captor = org.mockito.ArgumentCaptor.forClass(ExpressCompanyEntity.class);
verify(repository).insert(captor.capture());
assertThat(captor.getValue().getTenantId()).isEqualTo(10001L);
assertThat(captor.getValue().getCompanyCode()).isEqualTo("SF");
}
@Test
@DisplayName("文件内重复名称时整批拒绝且不写入")
void shouldRejectWholeWorkbookWhenAnyRowIsInvalid() throws Exception {
ExpressCompanyRepository repository = mock(ExpressCompanyRepository.class);
ExpressCompanyImportService service = new ExpressCompanyImportService(repository);
byte[] workbook = withRows(service.template(), new String[][] {
{"SF", "顺丰速运", "", "", "", "ACTIVE", ""},
{"SF2", "顺丰速运", "", "", "", "ACTIVE", ""}
});
var result = service.importFile(10001L, "express.xlsx", workbook);
assertThat(result.success()).isFalse();
assertThat(result.importedRows()).isZero();
assertThat(result.errors()).anyMatch(error -> error.row() == 5 && error.field().contains("名称"));
verify(repository, never()).insert(any());
}
private static byte[] withRows(byte[] template, String[][] rows) throws Exception {
try (XSSFWorkbook workbook = new XSSFWorkbook(new ByteArrayInputStream(template));
ByteArrayOutputStream output = new ByteArrayOutputStream()) {
var sheet = workbook.getSheetAt(0);
for (int rowIndex = 0; rowIndex < rows.length; rowIndex++) {
var row = sheet.createRow(rowIndex + 3);
for (int column = 0; column < rows[rowIndex].length; column++) {
row.createCell(column).setCellValue(rows[rowIndex][column]);
}
}
workbook.write(output);
return output.toByteArray();
}
}
}
@@ -0,0 +1,75 @@
package com.baiyue.warehouse.service;
import com.baiyue.warehouse.dto.MasterDataQueries.PartnerImportResult;
import com.baiyue.warehouse.entity.PartnerEntity;
import com.baiyue.warehouse.repository.PartnerRepository;
import org.apache.poi.ss.usermodel.Row;
import org.apache.poi.xssf.usermodel.XSSFWorkbook;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
@DisplayName("往来单位批量导入")
class PartnerImportServiceTest {
@Test
@DisplayName("标准模板支持多类型并写入服务端数据范围")
void shouldImportValidMultiTypeRow() throws Exception {
PartnerRepository repository = mock(PartnerRepository.class);
PartnerImportService service = new PartnerImportService(repository);
byte[] file = withRows(service.template(), new String[][] {
{"CUS-100", "测试单位", "CUSTOMER,SHIPPER", "张三", "13800138000", "test@example.com",
"CN", "广东省", "深圳市", "南山区", "测试路 1 号", "518000", "ACTIVE", "测试备注"}
});
PartnerImportResult result = service.importFile(10001L, "partners.xlsx", file);
assertThat(result.success()).isTrue();
assertThat(result.importedRows()).isEqualTo(1);
var captor = org.mockito.ArgumentCaptor.forClass(PartnerEntity.class);
verify(repository).insert(captor.capture());
assertThat(captor.getValue().getDataScopeId()).isEqualTo(10001L);
assertThat(captor.getValue().getTypes()).containsExactly("CUSTOMER", "SHIPPER");
assertThat(captor.getValue().getRemark()).isEqualTo("测试备注");
}
@Test
@DisplayName("文件内任一错误时整批零写入并返回行号")
void shouldRejectWholeBatchWhenAnyRowIsInvalid() throws Exception {
PartnerRepository repository = mock(PartnerRepository.class);
PartnerImportService service = new PartnerImportService(repository);
byte[] file = withRows(service.template(), new String[][] {
{"CUS-100", "测试单位一", "CUSTOMER", "张三", "13800138000", "", "CN", "", "深圳市", "", "测试路 1 号", "", "ACTIVE", ""},
{"CUS-100", "测试单位二", "UNKNOWN", "李四", "13900139000", "bad-email", "C", "", "", "", "", "", "ENABLED", ""}
});
PartnerImportResult result = service.importFile(10001L, "partners.xlsx", file);
assertThat(result.success()).isFalse();
assertThat(result.importedRows()).isZero();
assertThat(result.errors()).anyMatch(error -> error.row() == 5 && error.field().equals("单位编码"));
assertThat(result.errors()).anyMatch(error -> error.row() == 5 && error.field().equals("单位类型"));
verify(repository, never()).insert(any());
}
private static byte[] withRows(byte[] template, String[][] values) throws Exception {
try (XSSFWorkbook workbook = new XSSFWorkbook(new ByteArrayInputStream(template));
ByteArrayOutputStream output = new ByteArrayOutputStream()) {
for (int rowIndex = 0; rowIndex < values.length; rowIndex++) {
Row row = workbook.getSheetAt(0).createRow(rowIndex + 3);
for (int column = 0; column < values[rowIndex].length; column++) {
row.createCell(column).setCellValue(values[rowIndex][column]);
}
}
workbook.write(output);
return output.toByteArray();
}
}
}
@@ -12,6 +12,11 @@ import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
class PartnerQueryServiceTest {
@Test void unsupportedCandidateTypeReturnsBadRequest() {
PartnerRepository repository = mock(PartnerRepository.class);
assertThatThrownBy(() -> new PartnerQueryService(repository).candidates(10001L, null, "CUSTOMER"))
.isInstanceOf(BizException.class).hasMessageContaining("SHIPPER");
}
@Test void missingOrCrossTenantPartnerReturnsNotFound() {
PartnerRepository repository = mock(PartnerRepository.class);
@@ -72,8 +72,11 @@ class PlatformMasterDataManagementServiceTest {
org.mockito.ArgumentCaptor<PartnerEntity> entity = org.mockito.ArgumentCaptor.forClass(PartnerEntity.class);
verify(repository).insert(entity.capture());
assertThat(entity.getValue().getTenantId()).isEqualTo(10001L);
assertThat(entity.getValue().getDataScopeId()).isEqualTo(10001L);
assertThat(entity.getValue().getPartnerCode()).isEqualTo("CUS-001");
assertThat(entity.getValue().getPartnerName()).isEqualTo("示例客户");
assertThat(entity.getValue().getTypes()).containsExactly("CUSTOMER", "SHIPPER");
assertThat(entity.getValue().getRemark()).isEqualTo("测试备注");
}
@Test
@@ -132,6 +135,18 @@ class PlatformMasterDataManagementServiceTest {
verify(repository, never()).insert(any());
}
@Test
@DisplayName("快递公司名称在租户内必须唯一")
void shouldRequireUniqueExpressCompanyNameWithinTenant() {
ExpressCompanyRepository repository = mock(ExpressCompanyRepository.class);
when(repository.existsName(10001L, "顺丰速运", null)).thenReturn(true);
ExpressCompanyQueryService service = new ExpressCompanyQueryService(repository);
assertThatThrownBy(() -> service.create(10001L, expressRequest()))
.isInstanceOf(BizException.class).hasMessageContaining("名称");
verify(repository, never()).insert(any());
}
@Test
@DisplayName("快递公司并发重复编码统一返回业务冲突")
void shouldReturnBusinessConflictForConcurrentDuplicateExpressCompanyCode() {
@@ -200,7 +215,8 @@ class PlatformMasterDataManagementServiceTest {
private static PartnerSaveRequest partnerRequest() {
return new PartnerSaveRequest("CUS-001", "示例客户", "CUSTOMER", "张三", "13800138000",
"test@example.com", "CN", "广东省", "深圳市", "南山区", "示例地址", "518000", "ACTIVE");
"test@example.com", "CN", "广东省", "深圳市", "南山区", "示例地址", "518000", "ACTIVE",
java.util.List.of("CUSTOMER", "SHIPPER"), "测试备注");
}
private static ExpressCompanySaveRequest expressRequest() {