优化
This commit is contained in:
+4
-1
@@ -44,7 +44,10 @@ public class InternalSupplierController {
|
||||
@RequestHeader(value="X-Internal-Service-Token", required=false) String token,
|
||||
@PathVariable long supplierId, @RequestParam(defaultValue="true") boolean requireActive) {
|
||||
requireToken(token);
|
||||
service.internalSnapshot(supplierId, requireActive, "TRUCKING");
|
||||
var snapshot = service.internalSnapshot(supplierId, requireActive, "TRUCKING");
|
||||
if (!"OVERSEAS".equals(snapshot.truckingScope())) {
|
||||
throw new BizException(ResultCode.CONFLICT, "清关提柜只能使用海外车队");
|
||||
}
|
||||
var supplier = service.detail(supplierId);
|
||||
return ApiResponse.success(new TruckingContact(supplierId, supplier.name(), supplier.email()));
|
||||
}
|
||||
|
||||
+1
-1
@@ -38,7 +38,7 @@ public class PlatformSupplierController {
|
||||
@GetMapping("/import-template") @PreAuthorize("hasAuthority('platform:supplier:create')")
|
||||
public ResponseEntity<byte[]> importTemplate(@AuthenticationPrincipal AuthenticatedUser user) {
|
||||
requirePlatform(user); HttpHeaders headers = new HttpHeaders();
|
||||
headers.setContentDisposition(ContentDisposition.attachment().filename("supplier-import-template-v2.xlsx", StandardCharsets.UTF_8).build());
|
||||
headers.setContentDisposition(ContentDisposition.attachment().filename("supplier-import-template-v3.xlsx", StandardCharsets.UTF_8).build());
|
||||
headers.setContentType(MediaType.parseMediaType("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"));
|
||||
return ResponseEntity.ok().headers(headers).body(transferService.template());
|
||||
}
|
||||
|
||||
@@ -19,7 +19,8 @@ public final class SupplierDtos {
|
||||
@Size(max = 80) String region,
|
||||
@Pattern(regexp = "ACTIVE|INACTIVE") String status,
|
||||
String keyword,
|
||||
@Pattern(regexp = "MATSON|SML|ZIM|COSCO|EMC|HEDE|OTHER") String carrierCode) {
|
||||
@Pattern(regexp = "MATSON|SML|ZIM|COSCO|EMC|HEDE|OTHER") String carrierCode,
|
||||
@Pattern(regexp = "DOMESTIC|OVERSEAS") String truckingScope) {
|
||||
public PageQuery {
|
||||
page = page == null ? 1 : page;
|
||||
size = size == null ? 20 : size;
|
||||
@@ -40,7 +41,8 @@ public final class SupplierDtos {
|
||||
@Pattern(regexp = "\\d+") @Size(max = 128) String licenseFileId,
|
||||
@NotBlank @Pattern(regexp = "ACTIVE|INACTIVE") String status,
|
||||
@NotBlank @Pattern(regexp = "MONTHLY_30|MONTHLY_45|MONTHLY_60|MONTHLY_90|PER_TRANSACTION|IMMEDIATE") String settlementPeriod,
|
||||
@Size(max = 500) String remark) {}
|
||||
@Size(max = 500) String remark,
|
||||
@Pattern(regexp = "DOMESTIC|OVERSEAS") String truckingScope) {}
|
||||
|
||||
public record StatusRequest(@NotNull @Pattern(regexp = "ACTIVE|INACTIVE") String status) {}
|
||||
|
||||
@@ -51,9 +53,9 @@ public final class SupplierDtos {
|
||||
String businessLicenseNo, String licenseFileId, String status,
|
||||
String settlementPeriod, String remark, Long createdBy,
|
||||
LocalDateTime createdAt, Long updatedBy, LocalDateTime updatedAt,
|
||||
Boolean deleted, Integer version) {}
|
||||
Boolean deleted, Integer version, String truckingScope) {}
|
||||
public record InternalSnapshot(long supplierId, String name, String category, String status,
|
||||
List<String> carrierCodes) {}
|
||||
List<String> carrierCodes, String truckingScope) {}
|
||||
public record ImportError(int row, String field, String message) {}
|
||||
public record ImportResult(String fileName, int totalRows, int importedRows, boolean success,
|
||||
List<ImportError> errors) {}
|
||||
|
||||
@@ -19,6 +19,8 @@ public class SupplierEntity extends BaseEntity {
|
||||
private Long tenantId;
|
||||
private String name;
|
||||
private String category;
|
||||
@com.baomidou.mybatisplus.annotation.TableField(updateStrategy = com.baomidou.mybatisplus.annotation.FieldStrategy.ALWAYS)
|
||||
private String truckingScope;
|
||||
private String region;
|
||||
private String legalRepresentative;
|
||||
private String contactName;
|
||||
|
||||
@@ -25,6 +25,7 @@ public class SupplierRepository {
|
||||
}
|
||||
LambdaQueryWrapper<SupplierEntity> w = base(tenantId)
|
||||
.eq(StringUtils.hasText(query.category()), SupplierEntity::getCategory, query.category())
|
||||
.eq(StringUtils.hasText(query.truckingScope()), SupplierEntity::getTruckingScope, query.truckingScope())
|
||||
.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")
|
||||
|
||||
+12
-7
@@ -32,11 +32,11 @@ import java.util.Set;
|
||||
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 String TEMPLATE_VERSION = "3";
|
||||
private static final Set<String> CATEGORIES = Set.of("BOOKING", "CUSTOMS", "TRUCKING", "CERTIFICATE");
|
||||
private static final Set<String> CARRIER_CODES = Set.of("MATSON", "SML", "ZIM", "COSCO", "EMC", "HEDE", "OTHER");
|
||||
private static final String[] HEADERS = {"供应商名称*", "供应商类别*", "船司标准编码(多值)", "地区*", "法人代表*",
|
||||
"联系人*", "联系电话*", "电子邮箱", "公司地址", "营业执照号", "营业执照文件ID", "状态*", "结算周期*", "备注"};
|
||||
"联系人*", "联系电话*", "电子邮箱", "公司地址", "营业执照号", "营业执照文件ID", "状态*", "结算周期*", "备注", "拖车类型(TRUCKING必填)"};
|
||||
|
||||
@Resource private SupplierService supplierService;
|
||||
@Resource private SupplierRepository repository;
|
||||
@@ -47,7 +47,7 @@ public class SupplierImportExportService {
|
||||
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必须留空");
|
||||
instruction.createCell(1).setCellValue("BOOKING至少填写一个船司标准编码,多个编码使用英文逗号分隔;非BOOKING的船司编码必须留空;TRUCKING的拖车类型必填DOMESTIC(国内拖车)或OVERSEAS(海外车队),其他类别留空");
|
||||
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();
|
||||
@@ -87,10 +87,10 @@ public class SupplierImportExportService {
|
||||
}
|
||||
|
||||
public byte[] exportCsv(PageQuery query) {
|
||||
StringBuilder csv = new StringBuilder("\uFEFF供应商ID,供应商名称,供应商类别,船司标准编码,地区,法人代表,联系人,联系电话,电子邮箱,公司地址,营业执照号,营业执照文件ID,状态,结算周期,备注\r\n");
|
||||
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()));
|
||||
var result = supplierService.page(new PageQuery(page, 200, query.category(), query.region(), query.status(), query.keyword(), query.carrierCode(), query.truckingScope()));
|
||||
for (SupplierResource value : result.records()) csv.append(row(value));
|
||||
if (page >= result.pages()) break;
|
||||
page++;
|
||||
@@ -103,6 +103,11 @@ public class SupplierImportExportService {
|
||||
errors.add(new ImportError(row.number(), violation.getPropertyPath().toString(), violation.getMessage()));
|
||||
}
|
||||
if (!CATEGORIES.contains(row.request().category())) errors.add(new ImportError(row.number(), "category", "供应商类别无效"));
|
||||
try {
|
||||
SupplierService.validateTruckingScope(row.request().category(), row.request().truckingScope());
|
||||
} catch (com.baiyue.common.core.exception.BizException exception) {
|
||||
errors.add(new ImportError(row.number(), "truckingScope", exception.getMessage()));
|
||||
}
|
||||
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必须留空"));
|
||||
@@ -117,7 +122,7 @@ public class SupplierImportExportService {
|
||||
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)));
|
||||
nullable(value(row, 13, formatter)), nullable(value(row, 14, formatter).toUpperCase(Locale.ROOT)));
|
||||
}
|
||||
private static List<String> carrierCodes(String value) {
|
||||
LinkedHashSet<String> result = new LinkedHashSet<>();
|
||||
@@ -127,7 +132,7 @@ public class SupplierImportExportService {
|
||||
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()))
|
||||
text(v.businessLicenseNo()), text(v.licenseFileId()), v.status(), v.settlementPeriod(), text(v.remark()), text(v.truckingScope()))
|
||||
.stream().map(SupplierImportExportService::csv).collect(java.util.stream.Collectors.joining(",")) + "\r\n";
|
||||
}
|
||||
private static String csv(String value) { return "\"" + value.replace("\"", "\"\"") + "\""; }
|
||||
|
||||
@@ -40,6 +40,7 @@ public class SupplierService {
|
||||
@Transactional
|
||||
@BusinessAudit(event = "SUPPLIER_CREATE", resourceType = "SUPPLIER", resourceId = "#id", description = "新增供应商", captureBefore = false)
|
||||
public Long create(long id, SaveRequest request) {
|
||||
validateTruckingScope(request.category(), request.truckingScope());
|
||||
List<String> carrierCodes = normalizeCarrierCodes(request.category(), request.carrierCodes());
|
||||
ensureName(request.name(), null);
|
||||
SupplierEntity entity = new SupplierEntity(); entity.setId(id); entity.setTenantId(0L); copy(entity, request);
|
||||
@@ -53,6 +54,7 @@ public class SupplierService {
|
||||
@Transactional
|
||||
@BusinessAudit(event = "SUPPLIER_UPDATE", resourceType = "SUPPLIER", resourceId = "#id", description = "编辑供应商")
|
||||
public void update(long id, SaveRequest request) {
|
||||
validateTruckingScope(request.category(), request.truckingScope());
|
||||
List<String> carrierCodes = normalizeCarrierCodes(request.category(), request.carrierCodes());
|
||||
SupplierEntity e = owned(id); ensureName(request.name(), id); copy(e, request);
|
||||
try {
|
||||
@@ -74,9 +76,9 @@ public class SupplierService {
|
||||
|
||||
public List<InternalSnapshot> internalBookingCandidates(String carrierCode) {
|
||||
return page(new PageQuery(1, 200, "BOOKING", null, "ACTIVE", null,
|
||||
normalizeCarrierCode(carrierCode))).records().stream()
|
||||
normalizeCarrierCode(carrierCode), null)).records().stream()
|
||||
.map(value -> new InternalSnapshot(value.id(), value.name(), value.category(), value.status(),
|
||||
value.carrierCodes())).toList();
|
||||
value.carrierCodes(), value.truckingScope())).toList();
|
||||
}
|
||||
|
||||
private SupplierEntity owned(long id) { SupplierEntity e = repository.find(0L, id); if (e == null) throw new BizException(ResultCode.NOT_FOUND, "供应商不存在"); return e; }
|
||||
@@ -84,6 +86,16 @@ public class SupplierService {
|
||||
private List<String> carrierCodes(long id) {
|
||||
return repository.carrierCodes(PUBLIC_TENANT_ID, List.of(id)).getOrDefault(id, List.of());
|
||||
}
|
||||
public static void validateTruckingScope(String category, String scope) {
|
||||
if ("TRUCKING".equals(category)) {
|
||||
if (!"DOMESTIC".equals(scope) && !"OVERSEAS".equals(scope)) {
|
||||
throw new BizException(ResultCode.BAD_REQUEST, "拖车供应商必须选择国内拖车或海外车队");
|
||||
}
|
||||
} else if (scope != null) {
|
||||
throw new BizException(ResultCode.BAD_REQUEST, "非拖车供应商不能设置拖车类型");
|
||||
}
|
||||
}
|
||||
|
||||
private static List<String> normalizeCarrierCodes(String category, List<String> values) {
|
||||
LinkedHashSet<String> normalized = new LinkedHashSet<>();
|
||||
if (values != null) values.forEach(value -> {
|
||||
@@ -110,14 +122,14 @@ public class SupplierService {
|
||||
}
|
||||
private static InternalSnapshot snapshot(SupplierEntity e, List<String> carrierCodes) {
|
||||
return new InternalSnapshot(e.getId(), e.getName(), e.getCategory(), e.getStatus(),
|
||||
List.copyOf(carrierCodes));
|
||||
List.copyOf(carrierCodes), e.getTruckingScope());
|
||||
}
|
||||
private static SupplierResource resource(SupplierEntity e, List<String> carrierCodes) {
|
||||
return new SupplierResource(e.getId(), e.getTenantId(), e.getName(), e.getCategory(),
|
||||
List.copyOf(carrierCodes), e.getRegion(), e.getLegalRepresentative(), e.getContactName(),
|
||||
e.getPhone(), e.getEmail(), e.getAddress(), e.getBusinessLicenseNo(), e.getLicenseFileId(),
|
||||
e.getStatus(), e.getSettlementPeriod(), e.getRemark(), e.getCreatedBy(), e.getCreatedAt(),
|
||||
e.getUpdatedBy(), e.getUpdatedAt(), e.getDeleted(), e.getVersion());
|
||||
e.getUpdatedBy(), e.getUpdatedAt(), e.getDeleted(), e.getVersion(), e.getTruckingScope());
|
||||
}
|
||||
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()); }
|
||||
private static void copy(SupplierEntity e, SaveRequest r) { e.setName(r.name()); e.setCategory(r.category()); e.setTruckingScope(r.truckingScope()); 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()); }
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
-- 平台公共供应商业务档案。可用于空库初始化,也可重复执行升级存量结构。
|
||||
-- 平台公共供应商最新初始化基线。拖车类型必填,不推断或回填测试存量。
|
||||
USE baiyue_warehouse;
|
||||
SET NAMES utf8mb4;
|
||||
|
||||
@@ -7,6 +7,7 @@ CREATE TABLE IF NOT EXISTS wh_supplier (
|
||||
tenant_id BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '平台公共作用域,固定为0',
|
||||
name VARCHAR(160) NOT NULL COMMENT '供应商名称',
|
||||
category VARCHAR(20) NOT NULL COMMENT '供应商类别:BOOKING订舱、CUSTOMS报关、TRUCKING拖车、CERTIFICATE认证',
|
||||
trucking_scope VARCHAR(16) NULL COMMENT '拖车类型:DOMESTIC国内拖车、OVERSEAS海外车队;非拖车为空',
|
||||
region VARCHAR(80) NOT NULL COMMENT '地区',
|
||||
legal_representative VARCHAR(80) NOT NULL COMMENT '法人代表',
|
||||
contact_name VARCHAR(80) NOT NULL COMMENT '联系人',
|
||||
@@ -26,7 +27,8 @@ CREATE TABLE IF NOT EXISTS wh_supplier (
|
||||
version INT NOT NULL DEFAULT 0 COMMENT '乐观锁版本',
|
||||
PRIMARY KEY (id),
|
||||
UNIQUE KEY uk_wh_supplier_name (tenant_id,name),
|
||||
KEY idx_wh_supplier_filter (tenant_id,category,status,region),
|
||||
KEY idx_wh_supplier_filter (tenant_id,category,trucking_scope,status,region),
|
||||
CONSTRAINT chk_wh_supplier_trucking_scope CHECK ((category = 'TRUCKING' AND trucking_scope IS NOT NULL AND trucking_scope IN ('DOMESTIC','OVERSEAS')) OR (category <> 'TRUCKING' AND trucking_scope IS NULL)),
|
||||
CONSTRAINT chk_wh_supplier_category CHECK (category IN ('BOOKING','CUSTOMS','TRUCKING','CERTIFICATE')),
|
||||
CONSTRAINT chk_wh_supplier_tenant CHECK (tenant_id = 0),
|
||||
CONSTRAINT chk_wh_supplier_status CHECK (status IN ('ACTIVE','INACTIVE')),
|
||||
@@ -67,7 +69,7 @@ DROP PROCEDURE supplier_add_column_if_missing;
|
||||
|
||||
SET @supplier_index_ddl=IF(EXISTS(SELECT 1 FROM information_schema.statistics WHERE table_schema='baiyue_warehouse' AND table_name='wh_supplier' AND index_name='uk_wh_supplier_name'),'SELECT 1','ALTER TABLE wh_supplier ADD UNIQUE KEY uk_wh_supplier_name (tenant_id,name)');
|
||||
PREPARE supplier_statement FROM @supplier_index_ddl; EXECUTE supplier_statement; DEALLOCATE PREPARE supplier_statement;
|
||||
SET @supplier_index_ddl=IF(EXISTS(SELECT 1 FROM information_schema.statistics WHERE table_schema='baiyue_warehouse' AND table_name='wh_supplier' AND index_name='idx_wh_supplier_filter'),'SELECT 1','ALTER TABLE wh_supplier ADD KEY idx_wh_supplier_filter (tenant_id,category,status,region)');
|
||||
SET @supplier_index_ddl=IF(EXISTS(SELECT 1 FROM information_schema.statistics WHERE table_schema='baiyue_warehouse' AND table_name='wh_supplier' AND index_name='idx_wh_supplier_filter'),'SELECT 1','ALTER TABLE wh_supplier ADD KEY idx_wh_supplier_filter (tenant_id,category,trucking_scope,status,region)');
|
||||
PREPARE supplier_statement FROM @supplier_index_ddl; EXECUTE supplier_statement; DEALLOCATE PREPARE supplier_statement;
|
||||
|
||||
DROP PROCEDURE IF EXISTS supplier_add_check_if_missing;
|
||||
@@ -95,7 +97,7 @@ BEGIN
|
||||
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
|
||||
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','chk_wh_supplier_trucking_scope')) <> 5 THEN
|
||||
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='供应商表、索引或约束初始化不完整';
|
||||
END IF;
|
||||
END$$
|
||||
|
||||
@@ -48,6 +48,7 @@
|
||||
| `page` | integer | 否 | 默认 `1`,最小 `1` |
|
||||
| `size` | integer | 否 | 默认 `20`,范围 `1..200` |
|
||||
| `category` | enum | 否 | 供应商分类 |
|
||||
| `truckingScope` | enum | 否 | DOMESTIC 国内拖车或 OVERSEAS 海外车队;拖车业务候选必须按对应类型筛选 |
|
||||
| `region` | string | 否 | 地区精确匹配,最长 80 |
|
||||
| `status` | enum | 否 | 合作状态 |
|
||||
| `keyword` | string | 否 | 按供应商名称或联系人模糊匹配 |
|
||||
@@ -119,7 +120,7 @@
|
||||
}
|
||||
```
|
||||
|
||||
必填字段:`name`、`category`、`region`、`legalRepresentative`、`contactName`、`phone`、`status`、`settlementPeriod`。`BOOKING` 必须提交至少一个去重后的 `carrierCodes`;非 `BOOKING` 可不提交该字段或提交空数组,提交非空数组返回 `A0400`。订舱供应商不区分国内或国外。
|
||||
必填字段:`name`、`category`、`region`、`legalRepresentative`、`contactName`、`phone`、`status`、`settlementPeriod`。`BOOKING` 必须提交至少一个去重后的 `carrierCodes`;非 `BOOKING` 可不提交该字段或提交空数组,提交非空数组返回 `A0400`。订舱供应商不区分国内或国外。拖车供应商必须单选 `truckingScope=DOMESTIC`(国内拖车)或 `OVERSEAS`(海外车队),非拖车类别为空。列表、详情和内部快照返回该字段,国内拖车预约仅可用 DOMESTIC,清关提柜仅可用 OVERSEAS;导入模板版本3的“拖车类型(TRUCKING必填)”列填写相同枚举。
|
||||
|
||||
Admin 订舱供应商候选固定复用分页接口:`GET /api/platform/v1/suppliers?category=BOOKING&status=ACTIVE&carrierCode=MATSON&page=1&size=200`,权限为 `platform:supplier:list`,响应为 `PageResult<Supplier>`。服务端完成关联过滤,前端不得先拉全量再自行过滤。
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@ paths:
|
||||
- { name: page, in: query, schema: { type: integer, minimum: 1, default: 1 } }
|
||||
- { name: size, in: query, schema: { type: integer, minimum: 1, maximum: 200, default: 20 } }
|
||||
- { name: category, in: query, schema: { $ref: '#/components/schemas/Category' } }
|
||||
- { name: truckingScope, in: query, schema: { type: string, enum: [DOMESTIC, OVERSEAS] }, description: 国内拖车或海外车队 }
|
||||
- { name: region, in: query, schema: { type: string } }
|
||||
- { name: status, in: query, schema: { $ref: '#/components/schemas/Status' } }
|
||||
- { name: keyword, in: query, description: 供应商名称或联系人, schema: { type: string } }
|
||||
@@ -32,7 +33,7 @@ paths:
|
||||
/api/platform/v1/suppliers/import:
|
||||
post:
|
||||
summary: 原子批量导入供应商
|
||||
description: 权限 platform:supplier:create;整批校验通过后才写入。BOOKING的多值船司编码在文件中使用逗号分隔,数据库始终保存为关联行。
|
||||
description: 权限 platform:supplier:create;使用版本3模板,TRUCKING必须填写DOMESTIC或OVERSEAS拖车类型,其他类别留空;整批校验通过后才写入。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 } } } } } }
|
||||
@@ -45,6 +46,7 @@ paths:
|
||||
parameters:
|
||||
- { $ref: '#/components/parameters/TenantId' }
|
||||
- { name: category, in: query, schema: { $ref: '#/components/schemas/Category' } }
|
||||
- { name: truckingScope, in: query, schema: { type: string, enum: [DOMESTIC, OVERSEAS] }, description: 国内拖车或海外车队 }
|
||||
- { name: region, in: query, schema: { type: string } }
|
||||
- { name: status, in: query, schema: { $ref: '#/components/schemas/Status' } }
|
||||
- { name: keyword, in: query, schema: { type: string } }
|
||||
@@ -84,7 +86,7 @@ paths:
|
||||
/internal/warehouse/v1/suppliers/{supplierId}/trucking-contact:
|
||||
get:
|
||||
summary: 内部查询码头车队收件资料
|
||||
description: 固定校验TRUCKING类别与平台归属;取消旧车队请求允许requireActive=false。
|
||||
description: 固定校验TRUCKING类别、OVERSEAS拖车类型与平台归属;取消旧车队请求允许requireActive=false。
|
||||
security: [{internalServiceToken: []}]
|
||||
parameters:
|
||||
- {name: supplierId, in: path, required: true, schema: {type: integer, format: int64, minimum: 1}}
|
||||
@@ -134,6 +136,7 @@ components:
|
||||
RequiredTenantId: { name: tenantId, in: query, required: true, schema: { type: integer, format: int64, enum: [0] } }
|
||||
schemas:
|
||||
Category: { type: string, enum: [BOOKING, CUSTOMS, TRUCKING, CERTIFICATE] }
|
||||
TruckingScope: { type: string, nullable: true, enum: [DOMESTIC, OVERSEAS], description: TRUCKING必选国内拖车DOMESTIC或海外车队OVERSEAS;非TRUCKING必须为空,不支持兼营 }
|
||||
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] }
|
||||
@@ -143,6 +146,7 @@ components:
|
||||
properties:
|
||||
name: { type: string, maxLength: 160 }
|
||||
category: { $ref: '#/components/schemas/Category' }
|
||||
truckingScope: { $ref: '#/components/schemas/TruckingScope' }
|
||||
carrierCodes: { $ref: '#/components/schemas/CarrierCodes' }
|
||||
region: { type: string, maxLength: 80 }
|
||||
legalRepresentative: { type: string, maxLength: 80 }
|
||||
@@ -202,6 +206,7 @@ components:
|
||||
supplierId: { type: integer, format: int64 }
|
||||
name: { type: string }
|
||||
category: { $ref: '#/components/schemas/Category' }
|
||||
truckingScope: { $ref: '#/components/schemas/TruckingScope' }
|
||||
status: { $ref: '#/components/schemas/Status' }
|
||||
carrierCodes: { $ref: '#/components/schemas/CarrierCodes' }
|
||||
InternalSnapshotResponse:
|
||||
|
||||
+7
@@ -20,8 +20,15 @@ class InternalSupplierContactTest {
|
||||
var supplier = mock(SupplierResource.class);
|
||||
when(supplier.name()).thenReturn("Fleet"); when(supplier.email()).thenReturn("fleet@example.com");
|
||||
when(service.detail(1L)).thenReturn(supplier);
|
||||
when(service.internalSnapshot(1L,false,"TRUCKING")).thenReturn(
|
||||
new com.baiyue.warehouse.dto.SupplierDtos.InternalSnapshot(1L,"Fleet","TRUCKING","ACTIVE",java.util.List.of(),"OVERSEAS"));
|
||||
var result = controller.truckingContact("test-token",1L,false).data();
|
||||
assertThat(result.email()).isEqualTo("fleet@example.com");
|
||||
verify(service).internalSnapshot(1L,false,"TRUCKING");
|
||||
when(service.internalSnapshot(2L,true,"TRUCKING")).thenReturn(
|
||||
new com.baiyue.warehouse.dto.SupplierDtos.InternalSnapshot(2L,"Domestic","TRUCKING","ACTIVE",java.util.List.of(),"DOMESTIC"));
|
||||
assertThatThrownBy(() -> controller.truckingContact("test-token",2L,true))
|
||||
.isInstanceOf(BizException.class).hasMessageContaining("海外车队");
|
||||
verify(service, never()).detail(2L);
|
||||
}
|
||||
}
|
||||
|
||||
+2
-2
@@ -14,7 +14,7 @@ class SupplierSqlContractTest {
|
||||
String sql = new String(stream.readAllBytes(), StandardCharsets.UTF_8);
|
||||
assertThat(sql).contains("CREATE TABLE IF NOT EXISTS wh_supplier", "tenant_id BIGINT UNSIGNED NOT NULL DEFAULT 0",
|
||||
"chk_wh_supplier_tenant CHECK (tenant_id = 0)",
|
||||
"license_file_id VARCHAR(128)",
|
||||
"license_file_id VARCHAR(128)", "trucking_scope VARCHAR(16)", "chk_wh_supplier_trucking_scope", "trucking_scope IS NOT NULL", "trucking_scope IS NULL", "DOMESTIC", "OVERSEAS",
|
||||
"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",
|
||||
@@ -23,7 +23,7 @@ class SupplierSqlContractTest {
|
||||
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");
|
||||
assertThat(sql).doesNotContain("\n scope VARCHAR", "chk_wh_supplier_scope", "DOMESTIC/INTERNATIONAL");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+45
-4
@@ -21,14 +21,16 @@ import static org.mockito.Mockito.when;
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class SupplierImportExportServiceTest {
|
||||
@Mock private SupplierService supplierService;
|
||||
@Mock private com.baiyue.warehouse.repository.SupplierRepository repository;
|
||||
@InjectMocks private SupplierImportExportService service;
|
||||
private PageQuery query;
|
||||
|
||||
@BeforeEach void setUp() { query = new PageQuery(1, 20, "BOOKING", null, "ACTIVE", null, "MATSON"); }
|
||||
@BeforeEach void setUp() { query = new PageQuery(1, 20, "BOOKING", null, "ACTIVE", null, "MATSON", null);
|
||||
org.springframework.test.util.ReflectionTestUtils.setField(service, "validator", jakarta.validation.Validation.buildDefaultValidatorFactory().getValidator()); }
|
||||
|
||||
@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(0).getCell(1).getStringCellValue()).isEqualTo("3");
|
||||
assertThat(workbook.getSheetAt(0).getRow(2).getCell(2).getStringCellValue()).isEqualTo("船司标准编码(多值)");
|
||||
}
|
||||
}
|
||||
@@ -36,10 +38,49 @@ class SupplierImportExportServiceTest {
|
||||
@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")))
|
||||
null, null, null, null, false, 0, null);
|
||||
when(supplierService.page(new PageQuery(1, 200, "BOOKING", null, "ACTIVE", null, "MATSON", null)))
|
||||
.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\"");
|
||||
}
|
||||
|
||||
@Test void importRequiresScopeBeforeAnyRowIsWritten() throws Exception {
|
||||
var invalid = service.importFile("suppliers.xlsx", truckingWorkbook(null, "OVERSEAS"));
|
||||
assertThat(invalid.success()).isFalse();
|
||||
assertThat(invalid.errors()).anySatisfy(error -> {
|
||||
assertThat(error.row()).isEqualTo(4);
|
||||
assertThat(error.field()).isEqualTo("truckingScope");
|
||||
});
|
||||
org.mockito.Mockito.verifyNoInteractions(supplierService);
|
||||
var valid = service.importFile("suppliers.xlsx", truckingWorkbook("DOMESTIC", "OVERSEAS"));
|
||||
assertThat(valid.success()).isTrue();
|
||||
var captor = org.mockito.ArgumentCaptor.forClass(com.baiyue.warehouse.dto.SupplierDtos.SaveRequest.class);
|
||||
org.mockito.Mockito.verify(supplierService, org.mockito.Mockito.times(2)).create(org.mockito.ArgumentMatchers.anyLong(), captor.capture());
|
||||
assertThat(captor.getAllValues()).extracting(com.baiyue.warehouse.dto.SupplierDtos.SaveRequest::truckingScope)
|
||||
.containsExactly("DOMESTIC", "OVERSEAS");
|
||||
}
|
||||
|
||||
@Test void exportPreservesScopeFilterAndTypeColumn() {
|
||||
var query = new PageQuery(1, 20, "TRUCKING", null, "ACTIVE", null, null, "OVERSEAS");
|
||||
var resource = new SupplierResource(8L, 0L, "海外车队", "TRUCKING", List.of(),
|
||||
"洛杉矶", "法人", "联系人", "13800000000", null, null, null, null, "ACTIVE", "MONTHLY_30", null,
|
||||
null, null, null, null, false, 0, "OVERSEAS");
|
||||
when(supplierService.page(new PageQuery(1, 200, "TRUCKING", null, "ACTIVE", null, null, "OVERSEAS")))
|
||||
.thenReturn(new PageResult<>(List.of(resource), 1, 200, 1, 1));
|
||||
assertThat(new String(service.exportCsv(query), StandardCharsets.UTF_8)).contains("拖车类型", "\"OVERSEAS\"");
|
||||
}
|
||||
|
||||
private byte[] truckingWorkbook(String... scopes) throws Exception {
|
||||
try (XSSFWorkbook workbook = new XSSFWorkbook(new ByteArrayInputStream(service.template()));
|
||||
var output = new java.io.ByteArrayOutputStream()) {
|
||||
for (int i = 0; i < scopes.length; i++) {
|
||||
var row = workbook.getSheetAt(0).createRow(3 + i);
|
||||
String[] values = {"车队" + i, "TRUCKING", "", "宁波", "法人", "联系人", "13800000000", "", "", "", "", "ACTIVE", "MONTHLY_30", "", scopes[i]};
|
||||
for (int c = 0; c < values.length; c++) if (values[c] != null) row.createCell(c).setCellValue(values[c]);
|
||||
}
|
||||
workbook.write(output);
|
||||
return output.toByteArray();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,7 +29,7 @@ class SupplierServiceTest {
|
||||
@BeforeEach void setUp() {
|
||||
booking = new SaveRequest("测试供应商", "BOOKING", java.util.List.of("MATSON"), "宁波", "法人", "联系人",
|
||||
"13800000000", "supplier@example.com", "地址", "91330000TEST", "192000000000000001",
|
||||
"ACTIVE", "MONTHLY_30", null);
|
||||
"ACTIVE", "MONTHLY_30", null, null);
|
||||
}
|
||||
|
||||
@Test void createsPlatformPublicBookingSupplier() {
|
||||
@@ -42,26 +42,26 @@ class SupplierServiceTest {
|
||||
@Test void deduplicatesBookingCarrierCodesAndRejectsNonBookingAssociations() {
|
||||
SaveRequest duplicated = new SaveRequest("测试供应商", "BOOKING", java.util.List.of("MATSON", "MATSON", "ZIM"),
|
||||
"宁波", "法人", "联系人", "13800000000", null, null, null, null,
|
||||
"ACTIVE", "MONTHLY_30", null);
|
||||
"ACTIVE", "MONTHLY_30", null, 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);
|
||||
"联系人", "13800000000", null, null, null, null, "ACTIVE", "MONTHLY_30", null, 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);
|
||||
"13800000000", null, null, null, null, "ACTIVE", "MONTHLY_30", null, 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);
|
||||
"13800000000", null, null, null, null, "ACTIVE", "MONTHLY_30", null, null);
|
||||
service.create(105L, customs);
|
||||
verify(repository).replaceCarrierCodes(eq(0L), eq(105L), eq(java.util.List.of()), any());
|
||||
}
|
||||
@@ -105,4 +105,42 @@ class SupplierServiceTest {
|
||||
assertThatThrownBy(() -> service.internalSnapshot(101L, true, "CUSTOMS"))
|
||||
.isInstanceOf(BizException.class).hasMessageContaining("类别不匹配");
|
||||
}
|
||||
|
||||
@Test void truckingRequiresExactlyOneScopeAndNonTruckingRejectsScope() {
|
||||
for (String scope : new String[] {null, "", "BOTH", "DOMESTIC,OVERSEAS"}) {
|
||||
assertThatThrownBy(() -> service.create(201L, trucking(scope)))
|
||||
.isInstanceOf(BizException.class).hasMessageContaining("国内拖车或海外车队");
|
||||
}
|
||||
assertThatThrownBy(() -> SupplierService.validateTruckingScope("BOOKING", "DOMESTIC"))
|
||||
.isInstanceOf(BizException.class).hasMessageContaining("非拖车");
|
||||
verify(repository, never()).insert(any());
|
||||
}
|
||||
|
||||
@Test void persistsScopeAndReturnsItInResourcesAndInternalSnapshot() {
|
||||
for (String scope : java.util.List.of("DOMESTIC", "OVERSEAS")) {
|
||||
long id = "DOMESTIC".equals(scope) ? 201L : 202L;
|
||||
service.create(id, trucking(scope));
|
||||
var captor = org.mockito.ArgumentCaptor.forClass(SupplierEntity.class);
|
||||
verify(repository, org.mockito.Mockito.atLeastOnce()).insert(captor.capture());
|
||||
var entity = captor.getValue();
|
||||
assertThat(entity.getTruckingScope()).isEqualTo(scope);
|
||||
when(repository.find(0L, id)).thenReturn(entity);
|
||||
assertThat(service.detail(id).truckingScope()).isEqualTo(scope);
|
||||
assertThat(service.internalSnapshot(id, true, "TRUCKING").truckingScope()).isEqualTo(scope);
|
||||
}
|
||||
}
|
||||
|
||||
@Test void changingCategoryClearsTruckingScope() {
|
||||
SupplierEntity entity = new SupplierEntity(); entity.setId(201L); entity.setTenantId(0L);
|
||||
entity.setCategory("TRUCKING"); entity.setTruckingScope("DOMESTIC");
|
||||
when(repository.find(0L, 201L)).thenReturn(entity);
|
||||
service.update(201L, booking);
|
||||
assertThat(entity.getTruckingScope()).isNull();
|
||||
verify(repository).save(entity);
|
||||
}
|
||||
|
||||
private SaveRequest trucking(String scope) {
|
||||
return new SaveRequest("车队", "TRUCKING", java.util.List.of(), "宁波", "法人", "联系人",
|
||||
"13800000000", null, null, null, null, "ACTIVE", "MONTHLY_30", null, scope);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user