fix(pda): 返回拆柜柜次号并限制司机证照上传格式

This commit is contained in:
2026-11-06 21:02:53 +08:00
parent e132c3c80a
commit c71a065dda
16 changed files with 172 additions and 28 deletions
+1 -1
View File
@@ -22,7 +22,7 @@
| 财务付款凭证、海外卸柜照片 | 通用业务文件入口 `InternalBusinessFileController.java`、`InternalBusinessFileService.java`;`FINANCE_VOUCHER` 按客户与业务 ID 精确校验;`OVERSEAS_UNLOAD_PHOTO` 是允许同柜多客户的柜级物理证据,固定平台范围 `tenantId=0` 并按接柜会话 ID 精确校验 | | 财务付款凭证、海外卸柜照片 | 通用业务文件入口 `InternalBusinessFileController.java`、`InternalBusinessFileService.java`;`FINANCE_VOUCHER` 按客户与业务 ID 精确校验;`OVERSEAS_UNLOAD_PHOTO` 是允许同柜多客户的柜级物理证据,固定平台范围 `tenantId=0` 并按接柜会话 ID 精确校验 |
| 公司车辆付款凭证 | `src/main/resources/openapi/finance-vehicle-voucher-v1.yaml`;类型`FINANCE_VEHICLE_VOUCHER`,固定平台0和Finance费用ID,仅非空JPG/PNG、10MiB及签名校验;不复用客户凭证或维修照片 | | 公司车辆付款凭证 | `src/main/resources/openapi/finance-vehicle-voucher-v1.yaml`;类型`FINANCE_VEHICLE_VOUCHER`,固定平台0和Finance费用ID,仅非空JPG/PNG、10MiB及签名校验;不复用客户凭证或维修照片 |
| 海外异常订单证据 | 通用内部业务文件入口;固定 `businessType=OVERSEAS_EXCEPTION_EVIDENCE`、`tenantId=异常订单所属客户ID`、`businessId=异常订单ID`,File 只校验文件复合归属,异常状态、完成门禁和证据引用归 Lastleg | | 海外异常订单证据 | 通用内部业务文件入口;固定 `businessType=OVERSEAS_EXCEPTION_EVIDENCE`、`tenantId=异常订单所属客户ID`、`businessId=异常订单ID`,File 只校验文件复合归属,异常状态、完成门禁和证据引用归 Lastleg |
| 平台司机驾驶证 | 通用内部业务文件入口,契约 `src/main/resources/openapi/fleet-driver-license-v1.yaml`;`FLEET_DRIVER_LICENSE` 固定平台 `tenantId=0`、`businessId=driverId`,仅非空 PDF/JPG/PNG、最大 10MiB并校验签名;Lastleg维护审核、当前/历史附件关联及读取权限 | | 平台司机驾驶证 | 通用内部业务文件入口,契约 `src/main/resources/openapi/fleet-driver-license-v1.yaml`;`FLEET_DRIVER_LICENSE` 固定平台 `tenantId=0`、`businessId=driverId`,上传仅非空 JPG/PNG、最大 10MiB并校验签名;Lastleg维护审核、当前/历史附件关联及读取权限 |
| 车辆保养加油照片 | 通用内部业务文件入口,契约 `src/main/resources/openapi/fleet-vehicle-record-photo-v1.yaml`;`FLEET_VEHICLE_RECORD_PHOTO` 固定平台0及运维记录ID,仅非空JPG/PNG、最大10MiB并校验签名;Lastleg校验车辆/记录/附件关联与权限,不使用车牌作为文件归属键 | | 车辆保养加油照片 | 通用内部业务文件入口,契约 `src/main/resources/openapi/fleet-vehicle-record-photo-v1.yaml`;`FLEET_VEHICLE_RECORD_PHOTO` 固定平台0及运维记录ID,仅非空JPG/PNG、最大10MiB并校验签名;Lastleg校验车辆/记录/附件关联与权限,不使用车牌作为文件归属键 |
| 出车实测凭证 | 通用内部业务文件入口,契约 `src/main/resources/openapi/fleet-trip-operation-evidence-v1.yaml`;`FLEET_TRIP_OPERATION_EVIDENCE`固定平台0及车次ID,PDF/JPG/PNG、10MiB与签名校验;Lastleg管理实测版本引用及读写权限,附件选填 | | 出车实测凭证 | 通用内部业务文件入口,契约 `src/main/resources/openapi/fleet-trip-operation-evidence-v1.yaml`;`FLEET_TRIP_OPERATION_EVIDENCE`固定平台0及车次ID,PDF/JPG/PNG、10MiB与签名校验;Lastleg管理实测版本引用及读写权限,附件选填 |
| 海外换标 PDF 资料 | 通用内部业务文件入口,契约 `src/main/resources/openapi/overseas-relabel-document-v1.yaml`;`OVERSEAS_RELABEL_TOTAL_DOCUMENT` 按 `application.id`、`OVERSEAS_RELABEL_ITEM_DOCUMENT` 按 `item.id` 归属,必须指定明确客户与正数业务 ID;仅非空 PDF、最大 10MiB,校验 MIME 和文件签名。File 按客户、类型、业务 ID、文件 ID 复合校验,Lastleg 校验申请/明细关系、客户归属、权限和业务状态 | | 海外换标 PDF 资料 | 通用内部业务文件入口,契约 `src/main/resources/openapi/overseas-relabel-document-v1.yaml`;`OVERSEAS_RELABEL_TOTAL_DOCUMENT` 按 `application.id`、`OVERSEAS_RELABEL_ITEM_DOCUMENT` 按 `item.id` 归属,必须指定明确客户与正数业务 ID;仅非空 PDF、最大 10MiB,校验 MIME 和文件签名。File 按客户、类型、业务 ID、文件 ID 复合校验,Lastleg 校验申请/明细关系、客户归属、权限和业务状态 |
@@ -9,7 +9,7 @@ import java.util.Arrays;
enum BusinessFileCategory { enum BusinessFileCategory {
FLEET_VEHICLE_PHOTO(false, FormatPolicy.IMAGE, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG), FLEET_VEHICLE_PHOTO(false, FormatPolicy.IMAGE, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG),
FLEET_DRIVER_PHOTO(false, FormatPolicy.IMAGE, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG), FLEET_DRIVER_PHOTO(false, FormatPolicy.IMAGE, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG),
FLEET_DRIVER_LICENSE(false, FormatPolicy.PDF_IMAGE, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG), FLEET_DRIVER_LICENSE(false, FormatPolicy.IMAGE, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG),
FLEET_TRIP_OPERATION_EVIDENCE(false, FormatPolicy.PDF_IMAGE, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG), FLEET_TRIP_OPERATION_EVIDENCE(false, FormatPolicy.PDF_IMAGE, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG),
OVERSEAS_DISPATCH_VEHICLE_EVIDENCE(false, FormatPolicy.PDF_IMAGE, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG), OVERSEAS_DISPATCH_VEHICLE_EVIDENCE(false, FormatPolicy.PDF_IMAGE, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG),
OVERSEAS_DISPATCH_DRIVER_EVIDENCE(false, FormatPolicy.PDF_IMAGE, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG), OVERSEAS_DISPATCH_DRIVER_EVIDENCE(false, FormatPolicy.PDF_IMAGE, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG),
@@ -5,7 +5,7 @@ info:
description: >- description: >-
复用InternalBusinessFileController,固定平台tenantId=0、businessType=FLEET_DRIVER_LICENSE、 复用InternalBusinessFileController,固定平台tenantId=0、businessType=FLEET_DRIVER_LICENSE、
businessId=司机内部ID。Lastleg负责司机存在性、列表/上传权限、关联和历史可见性; businessId=司机内部ID。Lastleg负责司机存在性、列表/上传权限、关联和历史可见性;
File校验内部服务令牌、平台复合归属、真实非空文件、MIME与签名。最大10MiB,仅PDF/JPG/PNG。 File校验内部服务令牌、平台复合归属、真实非空文件、MIME与签名。上传最大10MiB,仅JPG/PNG。
上传只生成不可变对象,不自动通过资质审核,不生成公开URL,旧附件通过Lastleg历史引用读取。 上传只生成不可变对象,不自动通过资质审核,不生成公开URL,旧附件通过Lastleg历史引用读取。
security: security:
- InternalServiceToken: [] - InternalServiceToken: []
@@ -24,15 +24,22 @@ class DriverLicenseFileTest {
assertThatThrownBy(() -> category.storageBusinessId("0")).isInstanceOf(BizException.class); assertThatThrownBy(() -> category.storageBusinessId("0")).isInstanceOf(BizException.class);
} }
@Test void storesRealPdfAndRejectsWrongMimeAndSpoofedPdf() throws Exception { /** 驾驶证上传接受有效 JPG/PNG,其他格式和伪装的图片均被拒绝。 */
@Test void storesImagesAndRejectsPdfWrongMimeAndSpoofedImages() throws Exception {
var storage = storage(); var storage = storage();
var pdf = new MockMultipartFile("file", "license.pdf", "application/pdf", "%PDF-1.7\nlicense".getBytes()); var pdf = new MockMultipartFile("file", "license.pdf", "application/pdf", "%PDF-1.7\nlicense".getBytes());
var result = storage.store(0, "71", pdf, BusinessFileCategory.FLEET_DRIVER_LICENSE); for (var format : new String[]{"jpg", "png"}) {
assertThat(storage.read(result.objectKey())).isEqualTo(pdf.getBytes()); var type = format.equals("jpg") ? "image/jpeg" : "image/png";
var file = new MockMultipartFile("file", "license." + format, type, image(format));
var result = storage.store(0, "71", file, BusinessFileCategory.FLEET_DRIVER_LICENSE);
assertThat(storage.read(result.objectKey())).isEqualTo(file.getBytes());
}
assertThatThrownBy(() -> storage.store(0, "71", pdf, BusinessFileCategory.FLEET_DRIVER_LICENSE))
.isInstanceOf(BizException.class).hasMessageContaining("仅支持JPG、PNG");
assertThatThrownBy(() -> storage.store(0, "71", new MockMultipartFile("file", "license.txt", "text/plain", "text".getBytes()), BusinessFileCategory.FLEET_DRIVER_LICENSE)) assertThatThrownBy(() -> storage.store(0, "71", new MockMultipartFile("file", "license.txt", "text/plain", "text".getBytes()), BusinessFileCategory.FLEET_DRIVER_LICENSE))
.isInstanceOf(BizException.class).hasMessageContaining("仅支持"); .isInstanceOf(BizException.class).hasMessageContaining("仅支持");
assertThatThrownBy(() -> storage.store(0, "71", new MockMultipartFile("file", "license.pdf", "application/pdf", "not pdf".getBytes()), BusinessFileCategory.FLEET_DRIVER_LICENSE)) assertThatThrownBy(() -> storage.store(0, "71", new MockMultipartFile("file", "license.png", "image/png", pdf.getBytes()), BusinessFileCategory.FLEET_DRIVER_LICENSE))
.isInstanceOf(BizException.class).hasMessageContaining("不是有效PDF"); .isInstanceOf(BizException.class).hasMessageContaining("图片");
} }
@Test void rejectsMoreThanTenMibBeforeReadingBytes() { @Test void rejectsMoreThanTenMibBeforeReadingBytes() {
@@ -43,6 +50,14 @@ class DriverLicenseFileTest {
.isInstanceOf(BizException.class).hasMessageContaining("10MB"); .isInstanceOf(BizException.class).hasMessageContaining("10MB");
} }
/** 生成可解码的图片,覆盖文件格式与实际内容一致的上传路径。 */
private static byte[] image(String format) throws java.io.IOException {
var bytes = new java.io.ByteArrayOutputStream();
var image = new java.awt.image.BufferedImage(1, 1, java.awt.image.BufferedImage.TYPE_INT_RGB);
assertThat(javax.imageio.ImageIO.write(image, format, bytes)).isTrue();
return bytes.toByteArray();
}
private BusinessFileStorage storage() { private BusinessFileStorage storage() {
var storage = new BusinessFileStorage(); var storage = new BusinessFileStorage();
ReflectionTestUtils.setField(storage, "configuredRoot", root); ReflectionTestUtils.setField(storage, "configuredRoot", root);
@@ -19,7 +19,8 @@ import java.util.Set;
public class HttpDriverFileGateway implements DriverFileGateway { public class HttpDriverFileGateway implements DriverFileGateway {
static final String TYPE = "FLEET_DRIVER_LICENSE"; static final String TYPE = "FLEET_DRIVER_LICENSE";
static final long MAX_SIZE = 10L * 1024 * 1024; static final long MAX_SIZE = 10L * 1024 * 1024;
private static final Set<String> CONTENT_TYPES = Set.of("application/pdf", "image/jpeg", "image/png"); private static final Set<String> READ_CONTENT_TYPES = Set.of("application/pdf", "image/jpeg", "image/png");
private static final Set<String> UPLOAD_CONTENT_TYPES = Set.of("image/jpeg", "image/png");
private static final String PATH = "/internal/file/v1/business-files/0/FLEET_DRIVER_LICENSE/{driver}"; private static final String PATH = "/internal/file/v1/business-files/0/FLEET_DRIVER_LICENSE/{driver}";
private final RestClient client; private final RestClient client;
private final String token; private final String token;
@@ -31,10 +32,11 @@ public class HttpDriverFileGateway implements DriverFileGateway {
this.token = token; this.token = token;
} }
/** 新上传的驾驶证只接受 JPG/PNG,读取历史附件继续按原文件元数据校验。 */
@Override @Override
public DriverModels.File upload(long driverId, String operatorId, MultipartFile file) { public DriverModels.File upload(long driverId, String operatorId, MultipartFile file) {
if (file == null || file.isEmpty() || file.getSize() > MAX_SIZE || !CONTENT_TYPES.contains(Objects.toString(file.getContentType(), ""))) if (file == null || file.isEmpty() || file.getSize() > MAX_SIZE || !UPLOAD_CONTENT_TYPES.contains(Objects.toString(file.getContentType(), "")))
throw bad("驾驶证附件须为非空的 PDF、JPG 或 PNG,最大 10MiB"); throw bad("驾驶证附件须为非空的 JPG 或 PNG,最大 10MiB");
var body = new MultipartBodyBuilder(); var body = new MultipartBodyBuilder();
try { try {
body.part("file", new ByteArrayResource(file.getBytes()) { body.part("file", new ByteArrayResource(file.getBytes()) {
@@ -45,7 +47,9 @@ public class HttpDriverFileGateway implements DriverFileGateway {
.header("X-Operator-Id", operatorId).contentType(MediaType.MULTIPART_FORM_DATA).body(body.build()).retrieve() .header("X-Operator-Id", operatorId).contentType(MediaType.MULTIPART_FORM_DATA).body(body.build()).retrieve()
.onStatus(HttpStatusCode::isError, (request, response) -> { throw unavailable(response.getStatusCode()); }) .onStatus(HttpStatusCode::isError, (request, response) -> { throw unavailable(response.getStatusCode()); })
.body(Envelope.class); .body(Envelope.class);
return verified(result, driverId, null); DriverModels.File uploaded = verified(result, driverId, null);
if (!UPLOAD_CONTENT_TYPES.contains(uploaded.contentType())) throw bad("驾驶证附件须为 JPG 或 PNG");
return uploaded;
} }
@Override @Override
@@ -72,7 +76,7 @@ public class HttpDriverFileGateway implements DriverFileGateway {
if (data == null || data.fileId() == null || data.fileId() <= 0 || !Objects.equals(data.tenantId(), 0L) if (data == null || data.fileId() == null || data.fileId() <= 0 || !Objects.equals(data.tenantId(), 0L)
|| !TYPE.equals(data.businessType()) || !Long.toString(driverId).equals(data.businessId()) || !TYPE.equals(data.businessType()) || !Long.toString(driverId).equals(data.businessId())
|| (fileId != null && !fileId.equals(data.fileId())) || data.fileName() == null || data.fileName().isBlank() || (fileId != null && !fileId.equals(data.fileId())) || data.fileName() == null || data.fileName().isBlank()
|| !CONTENT_TYPES.contains(Objects.toString(data.contentType(), "")) || data.sizeBytes() == null || !READ_CONTENT_TYPES.contains(Objects.toString(data.contentType(), "")) || data.sizeBytes() == null
|| data.sizeBytes() <= 0 || data.sizeBytes() > MAX_SIZE) || data.sizeBytes() <= 0 || data.sizeBytes() > MAX_SIZE)
throw bad("文件不属于当前司机或不是有效驾驶证附件"); throw bad("文件不属于当前司机或不是有效驾驶证附件");
return new DriverModels.File(data.fileId().toString(), data.fileName(), data.contentType(), data.sizeBytes()); return new DriverModels.File(data.fileId().toString(), data.fileName(), data.contentType(), data.sizeBytes());
@@ -13,7 +13,8 @@ public final class ReceivingContainerResponses {
List<String> destinationCodes, int plannedPalletCount, List<String> destinationCodes, int plannedPalletCount,
int totalPackages, int receivedPackages, int totalPackages, int receivedPackages,
boolean operable, boolean allPlansReady, String blockReason, boolean operable, boolean allPlansReady, String blockReason,
LocalDateTime plannedArrivalAt, boolean currentAccount) { LocalDateTime plannedArrivalAt, boolean currentAccount, String cabinetNo) {
/** 保留不含接柜仓与柜次号的既有响应构造方式。 */
public Candidate(String sourceContainerBookingId, String containerNo, String status, public Candidate(String sourceContainerBookingId, String containerNo, String status,
int totalOrderCount, int inboundCodeCount, int missingInboundCodeCount, int totalOrderCount, int inboundCodeCount, int missingInboundCodeCount,
int planCount, int plannedInboundCodeCount, int pendingPlanCount, int planCount, int plannedInboundCodeCount, int pendingPlanCount,
@@ -23,7 +24,7 @@ public final class ReceivingContainerResponses {
this(sourceContainerBookingId, containerNo, null, null, status, totalOrderCount, this(sourceContainerBookingId, containerNo, null, null, status, totalOrderCount,
inboundCodeCount, missingInboundCodeCount, planCount, plannedInboundCodeCount, inboundCodeCount, missingInboundCodeCount, planCount, plannedInboundCodeCount,
pendingPlanCount, destinationCodes, plannedPalletCount, totalPackages, pendingPlanCount, destinationCodes, plannedPalletCount, totalPackages,
receivedPackages, operable, allPlansReady, blockReason, plannedArrivalAt, currentAccount); receivedPackages, operable, allPlansReady, blockReason, plannedArrivalAt, currentAccount, null);
} }
} }
@@ -36,7 +37,8 @@ public final class ReceivingContainerResponses {
LocalDateTime preparedAt, LocalDateTime signedAt, LocalDateTime preparedAt, LocalDateTime signedAt,
LocalDateTime receivingAt, LocalDateTime completedAt, LocalDateTime receivingAt, LocalDateTime completedAt,
int totalPackages, int receivedPackages, int pendingPackages, int totalPackages, int receivedPackages, int pendingPackages,
boolean hasDifference, List<Inbound> inbounds) { boolean hasDifference, List<Inbound> inbounds, String cabinetNo) {
/** 旧会话构造缺少接柜仓编码及柜次号时保持空值。 */
public Session(String containerNo, Long overseasWarehouseId, String overseasWarehouseName, public Session(String containerNo, Long overseasWarehouseId, String overseasWarehouseName,
String status, String operatorName, int version, List<SignedPhoto> photos, String status, String operatorName, int version, List<SignedPhoto> photos,
LocalDateTime preparedAt, LocalDateTime signedAt, LocalDateTime receivingAt, LocalDateTime preparedAt, LocalDateTime signedAt, LocalDateTime receivingAt,
@@ -44,9 +46,10 @@ public final class ReceivingContainerResponses {
int pendingPackages, List<Inbound> inbounds) { int pendingPackages, List<Inbound> inbounds) {
this(containerNo, overseasWarehouseId, null, overseasWarehouseName, status, operatorName, this(containerNo, overseasWarehouseId, null, overseasWarehouseName, status, operatorName,
version, photos, List.of(), preparedAt, signedAt, receivingAt, completedAt, totalPackages, version, photos, List.of(), preparedAt, signedAt, receivingAt, completedAt, totalPackages,
receivedPackages, pendingPackages, false, inbounds); receivedPackages, pendingPackages, false, inbounds, null);
} }
/** 保留不含柜次号及国内照片的既有会话构造方式。 */
public Session(String containerNo, Long overseasWarehouseId, String overseasWarehouseCode, public Session(String containerNo, Long overseasWarehouseId, String overseasWarehouseCode,
String overseasWarehouseName, String status, String operatorName, int version, String overseasWarehouseName, String status, String operatorName, int version,
List<SignedPhoto> photos, LocalDateTime preparedAt, LocalDateTime signedAt, List<SignedPhoto> photos, LocalDateTime preparedAt, LocalDateTime signedAt,
@@ -54,7 +57,7 @@ public final class ReceivingContainerResponses {
int receivedPackages, int pendingPackages, List<Inbound> inbounds) { int receivedPackages, int pendingPackages, List<Inbound> inbounds) {
this(containerNo, overseasWarehouseId, overseasWarehouseCode, overseasWarehouseName, this(containerNo, overseasWarehouseId, overseasWarehouseCode, overseasWarehouseName,
status, operatorName, version, photos, List.of(), preparedAt, signedAt, receivingAt, status, operatorName, version, photos, List.of(), preparedAt, signedAt, receivingAt,
completedAt, totalPackages, receivedPackages, pendingPackages, false, inbounds); completedAt, totalPackages, receivedPackages, pendingPackages, false, inbounds, null);
} }
} }
@@ -17,6 +17,21 @@ public interface ReceivingCandidateMapper {
record AvailableOrder(Long orderId, String bookingId) { } record AvailableOrder(Long orderId, String bookingId) { }
/** 按稳定柜次身份批量读取本库装柜事件中的正式柜次号,不从柜号或内部 ID 推导。 */
@Select("""
<script>
SELECT o.source_container_booking_id AS booking_id,
MAX(NULLIF(NULLIF(TRIM(JSON_UNQUOTE(JSON_EXTRACT(e.payload_json,'$.cabinetNo'))),'null'),'')) AS cabinet_no
FROM ll_order o LEFT JOIN ll_loading_event_inbox e ON e.event_id=o.source_event_id
WHERE o.source_container_booking_id IN
<foreach collection="ids" item="id" open="(" separator="," close=")">#{id}</foreach>
GROUP BY o.source_container_booking_id
</script>
""")
List<CabinetNumber> cabinetNumbers(@Param("ids") List<String> ids);
record CabinetNumber(String bookingId, String cabinetNo) { }
/** 保留原转换命令的客户和计划精确匹配,一批读取未确认计划。 */ /** 保留原转换命令的客户和计划精确匹配,一批读取未确认计划。 */
@Select("<script>SELECT p.id FROM ll_inbound_plan p JOIN ll_order o ON o.customer_id=p.customer_id " + @Select("<script>SELECT p.id FROM ll_inbound_plan p JOIN ll_order o ON o.customer_id=p.customer_id " +
"AND o.id=p.source_order_id WHERE p.id IN " + "AND o.id=p.source_order_id WHERE p.id IN " +
@@ -41,6 +41,14 @@ public class ReceivingCandidateRepository {
return sessions.findActiveContainers(); return sessions.findActiveContainers();
} }
/** 柜次号按批次读取,缺失快照时保持为空,避免候选列表逐柜查询。 */
public Map<String, String> cabinetNumbers(List<String> bookingIds) {
return batch(bookingIds.stream().distinct().toList(), candidates::cabinetNumbers).stream()
.filter(row -> row.cabinetNo() != null && !row.cabinetNo().isBlank())
.collect(Collectors.toMap(ReceivingCandidateMapper.CabinetNumber::bookingId,
row -> row.cabinetNo().trim()));
}
public record LineKey(String customerId, Long planId, Long lineId) { } public record LineKey(String customerId, Long planId, Long lineId) { }
public record Facts(List<LastlegOrder> orders, List<InboundPlan> plans, List<InboundPlanLine> lines, public record Facts(List<LastlegOrder> orders, List<InboundPlan> plans, List<InboundPlanLine> lines,
Set<Long> availableOrderIds, Set<Long> unconfirmedPlans, Map<LineKey,Integer> received) { } Set<Long> availableOrderIds, Set<Long> unconfirmedPlans, Map<LineKey,Integer> received) { }
@@ -139,7 +139,7 @@ public class ReceivingContainerService {
Set<String> reservedByOthers = candidateRepository.activeSessions().stream() Set<String> reservedByOthers = candidateRepository.activeSessions().stream()
.filter(session -> !operatorLockKey.equals(session.getActiveOperatorKey())) .filter(session -> !operatorLockKey.equals(session.getActiveOperatorKey()))
.map(ReceivingContainerSession::getActiveContainerKey).filter(Objects::nonNull).collect(Collectors.toSet()); .map(ReceivingContainerSession::getActiveContainerKey).filter(Objects::nonNull).collect(Collectors.toSet());
return grouped.entrySet().stream() List<ContainerReadiness> visible = grouped.entrySet().stream()
.map(entry -> { .map(entry -> {
var localLines = entry.getValue().stream().flatMap(order -> var localLines = entry.getValue().stream().flatMap(order ->
linesByOrder.getOrDefault(order.getId(), List.of()).stream()).toList(); linesByOrder.getOrDefault(order.getId(), List.of()).stream()).toList();
@@ -155,7 +155,11 @@ public class ReceivingContainerService {
.thenComparing(ContainerReadiness::plannedArrivalAt, .thenComparing(ContainerReadiness::plannedArrivalAt,
Comparator.nullsLast(Comparator.naturalOrder())) Comparator.nullsLast(Comparator.naturalOrder()))
.thenComparing(ContainerReadiness::sourceContainerBookingId)) .thenComparing(ContainerReadiness::sourceContainerBookingId))
.map(value -> candidate(value, current)).toList(); .toList();
Map<String, String> cabinetNumbers = candidateRepository.cabinetNumbers(
visible.stream().map(ContainerReadiness::sourceContainerBookingId).toList());
return visible.stream().map(value -> candidate(value, current,
cabinetNumbers.get(value.sourceContainerBookingId()))).toList();
} }
public ReceivingContainerResponses.Session detail(String operatorKey, long overseasWarehouseId, String containerNo, public ReceivingContainerResponses.Session detail(String operatorKey, long overseasWarehouseId, String containerNo,
@@ -783,8 +787,9 @@ public class ReceivingContainerService {
blockReason, arrival, factHash, totals); blockReason, arrival, factHash, totals);
} }
/** 候选展示批量读取的正式柜次号,继续以稳定柜身份执行接柜门禁。 */
private ReceivingContainerResponses.Candidate candidate(ContainerReadiness value, private ReceivingContainerResponses.Candidate candidate(ContainerReadiness value,
ReceivingContainerSession current) { ReceivingContainerSession current, String cabinetNo) {
boolean active = current != null && value.containerNo().equals(current.getContainerNo()); boolean active = current != null && value.containerNo().equals(current.getContainerNo());
Totals totals = value.totals(); Totals totals = value.totals();
return new ReceivingContainerResponses.Candidate(value.sourceContainerBookingId(), value.containerNo(), return new ReceivingContainerResponses.Candidate(value.sourceContainerBookingId(), value.containerNo(),
@@ -793,7 +798,7 @@ public class ReceivingContainerService {
value.missingInboundCodeCount(), value.planCount(), value.plannedInboundCodeCount(), value.missingInboundCodeCount(), value.planCount(), value.plannedInboundCodeCount(),
value.pendingPlanCount(), totals.destinationCodes(), totals.plannedPalletCount(), totals.total(), value.pendingPlanCount(), totals.destinationCodes(), totals.plannedPalletCount(), totals.total(),
totals.received(), value.operable(), value.allPlansReady(), value.blockReason(), totals.received(), value.operable(), value.allPlansReady(), value.blockReason(),
value.plannedArrivalAt(), active); value.plannedArrivalAt(), active, cabinetNo);
} }
private ReceivingContainerSession requireSigned(String operatorKey) { private ReceivingContainerSession requireSigned(String operatorKey) {
@@ -1083,18 +1088,22 @@ public class ReceivingContainerService {
private record DropshipPalletRef(InboundRef ref, InboundPlanPallet pallet, private record DropshipPalletRef(InboundRef ref, InboundPlanPallet pallet,
InboundPlanPalletAllocation allocation) { } InboundPlanPalletAllocation allocation) { }
/** 当前柜摘要从本地冻结快照补充柜次号,覆盖详情、签收、恢复及完成响应。 */
private ReceivingContainerResponses.Session snapshot(ReceivingContainerSession session, List<InboundPlan> containerPlans) { private ReceivingContainerResponses.Session snapshot(ReceivingContainerSession session, List<InboundPlan> containerPlans) {
List<ReceivingContainerResponses.Inbound> inbounds = refs( List<ReceivingContainerResponses.Inbound> inbounds = refs(
session.getSourceContainerBookingId(), session.getContainerNo()).stream() session.getSourceContainerBookingId(), session.getContainerNo()).stream()
.map(this::inbound).toList(); .map(this::inbound).toList();
int total = inbounds.stream().mapToInt(ReceivingContainerResponses.Inbound::totalPackages).sum(); int total = inbounds.stream().mapToInt(ReceivingContainerResponses.Inbound::totalPackages).sum();
int received = inbounds.stream().mapToInt(ReceivingContainerResponses.Inbound::receivedPackages).sum(); int received = inbounds.stream().mapToInt(ReceivingContainerResponses.Inbound::receivedPackages).sum();
String bookingId = session.getSourceContainerBookingId();
String cabinetNo = bookingId == null ? null
: candidateRepository.cabinetNumbers(List.of(bookingId)).get(bookingId);
return new ReceivingContainerResponses.Session(session.getContainerNo(), session.getOverseasWarehouseId(), return new ReceivingContainerResponses.Session(session.getContainerNo(), session.getOverseasWarehouseId(),
session.getOverseasWarehouseCode(), session.getOverseasWarehouseName(), session.getStatus(), session.getOperatorName(), session.getOverseasWarehouseCode(), session.getOverseasWarehouseName(), session.getStatus(), session.getOperatorName(),
value(session.getVersion()), signPhotos(session), domesticLoadingPhotos(session), value(session.getVersion()), signPhotos(session), domesticLoadingPhotos(session),
session.getPreparedAt(), session.getSignedAt(), session.getPreparedAt(), session.getSignedAt(),
session.getReceivingAt(), session.getCompletedAt(), total, received, Math.max(0, total - received), session.getReceivingAt(), session.getCompletedAt(), total, received, Math.max(0, total - received),
hasFbaOverReceipt(session, containerPlans), inbounds); hasFbaOverReceipt(session, containerPlans), inbounds, cabinetNo);
} }
private List<ReceivingContainerResponses.DomesticLoadingPhoto> domesticLoadingPhotos( private List<ReceivingContainerResponses.DomesticLoadingPhoto> domesticLoadingPhotos(
@@ -185,7 +185,7 @@ paths:
parameters: [{$ref: '#/components/parameters/Id'}] parameters: [{$ref: '#/components/parameters/Id'}]
post: post:
summary: 上传并绑定驾驶证,重置为待审 summary: 上传并绑定驾驶证,重置为待审
description: '权限:platform:overseas-master-data:driver:upload;复合归属固定平台0、FLEET_DRIVER_LICENSE、当前司机;非空PDF/JPG/PNG不超过10MiB,校验MIME和签名,旧版本附件保留在历史中。' description: '权限:platform:overseas-master-data:driver:upload;复合归属固定平台0、FLEET_DRIVER_LICENSE、当前司机;上传仅接受非空JPG/PNG且不超过10MiB,校验MIME和签名,旧版本附件按历史引用读取。'
requestBody: requestBody:
required: true required: true
content: content:
@@ -4024,6 +4024,7 @@ components:
required: [sourceContainerBookingId, containerNo, overseasWarehouseCode, overseasWarehouseName, status, totalOrderCount, inboundCodeCount, missingInboundCodeCount, planCount, plannedInboundCodeCount, pendingPlanCount, destinationCodes, plannedPalletCount, totalPackages, receivedPackages, operable, allPlansReady, currentAccount] required: [sourceContainerBookingId, containerNo, overseasWarehouseCode, overseasWarehouseName, status, totalOrderCount, inboundCodeCount, missingInboundCodeCount, planCount, plannedInboundCodeCount, pendingPlanCount, destinationCodes, plannedPalletCount, totalPackages, receivedPackages, operable, allPlansReady, currentAccount]
properties: properties:
sourceContainerBookingId: { type: string, description: 头程与尾程共享的稳定柜身份 } sourceContainerBookingId: { type: string, description: 头程与尾程共享的稳定柜身份 }
cabinetNo: { type: string, nullable: true, description: 头程装柜事件快照中的正式柜次号,缺失时为空 }
containerNo: { type: string } containerNo: { type: string }
overseasWarehouseCode: { type: string } overseasWarehouseCode: { type: string }
overseasWarehouseName: { type: string } overseasWarehouseName: { type: string }
@@ -4047,6 +4048,7 @@ components:
type: object type: object
required: [containerNo, overseasWarehouseId, overseasWarehouseCode, overseasWarehouseName, status, version, photos, domesticLoadingPhotos, totalPackages, receivedPackages, pendingPackages, hasDifference, inbounds] required: [containerNo, overseasWarehouseId, overseasWarehouseCode, overseasWarehouseName, status, version, photos, domesticLoadingPhotos, totalPackages, receivedPackages, pendingPackages, hasDifference, inbounds]
properties: properties:
cabinetNo: { type: string, nullable: true, description: 当前柜次对应的头程装柜事件快照中的正式柜次号,缺失时为空 }
containerNo: { type: string } containerNo: { type: string }
overseasWarehouseId: { type: string, pattern: '^\d+$', description: 平台预分配并与PDA Token匹配的海外接柜仓ID } overseasWarehouseId: { type: string, pattern: '^\d+$', description: 平台预分配并与PDA Token匹配的海外接柜仓ID }
overseasWarehouseCode: { type: string, description: 海外接柜仓业务编码快照 } overseasWarehouseCode: { type: string, description: 海外接柜仓业务编码快照 }
@@ -111,10 +111,11 @@ class DriverServiceTest {
assertThat(service.update(71, "e", new DriverModels.Update(3L, changedExpiry)).reviewStatus()).isEqualTo("PENDING"); assertThat(service.update(71, "e", new DriverModels.Update(3L, changedExpiry)).reviewStatus()).isEqualTo("PENDING");
} }
/** 绑定新图片后,司机证照恢复待审并保留旧附件的历史关联。 */
@Test void replacingLicenseBindsNewFileAndResetsApproval() { @Test void replacingLicenseBindsNewFileAndResetsApproval() {
when(drivers.lockById(71)).thenReturn(row("APPROVED")); when(drivers.lockById(71)).thenReturn(row("APPROVED"));
var file = new MockMultipartFile("file", "license.pdf", "application/pdf", "%PDF-test".getBytes()); var file = new MockMultipartFile("file", "license.png", "image/png", "image".getBytes());
when(files.upload(71, "e", file)).thenReturn(new DriverModels.File("82", "license.pdf", "application/pdf", 9)); when(files.upload(71, "e", file)).thenReturn(new DriverModels.File("82", "license.png", "image/png", 5));
var response = service.uploadLicense(71, 2, "e", file); var response = service.uploadLicense(71, 2, "e", file);
assertThat(response.licenseFileId()).isEqualTo("82"); assertThat(response.licenseFileId()).isEqualTo("82");
assertThat(response.reviewStatus()).isEqualTo("PENDING"); assertThat(response.reviewStatus()).isEqualTo("PENDING");
@@ -1,10 +1,27 @@
package com.baiyue.lastleg.fleet.driver; package com.baiyue.lastleg.fleet.driver;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import org.springframework.mock.web.MockMultipartFile;
import static org.assertj.core.api.Assertions.*; import static org.assertj.core.api.Assertions.*;
import static org.mockito.Mockito.*;
class HttpDriverFileGatewayTest { class HttpDriverFileGatewayTest {
/** 上传入口在访问 File 服务前拒绝 PDF、其他图片格式及空图片。 */
@Test void rejectsNonImageDriverUploadsBeforeCallingFileService() {
var client = mock(org.springframework.web.client.RestClient.class);
var factory = mock(com.baiyue.lastleg.config.LastlegRestClientFactory.class);
when(factory.build(any(), anyString(), any())).thenReturn(client);
var gateway = new HttpDriverFileGateway(org.springframework.web.client.RestClient.builder(), factory,
new com.baiyue.lastleg.config.LastlegRestClientProperties(), "http://file.test", "test-token");
for (var file : new MockMultipartFile[]{
new MockMultipartFile("file", "license.pdf", "application/pdf", "%PDF-1.7".getBytes()),
new MockMultipartFile("file", "license.gif", "image/gif", "GIF89a".getBytes()),
new MockMultipartFile("file", "license.png", "image/png", new byte[0])
}) assertThatThrownBy(() -> gateway.upload(71, "7", file)).hasMessageContaining("JPG 或 PNG");
verifyNoInteractions(client);
}
@Test void acceptsOnlyMatchingPlatformDriverLicenseMetadata() { @Test void acceptsOnlyMatchingPlatformDriverLicenseMetadata() {
assertThat(HttpDriverFileGateway.verified(envelope(0L, "FLEET_DRIVER_LICENSE", "71", 81L, "application/pdf", 12L), 71, 81L).fileId()) assertThat(HttpDriverFileGateway.verified(envelope(0L, "FLEET_DRIVER_LICENSE", "71", 81L, "application/pdf", 12L), 71, 81L).fileId())
.isEqualTo("81"); .isEqualTo("81");
@@ -37,11 +37,12 @@ class ReceivingContainerControllerTest {
ReflectionTestUtils.setField(controller, "service", service); ReflectionTestUtils.setField(controller, "service", service);
} }
/** 候选包含正式柜次号,操作账号仍由已鉴权的平台作业员身份确定。 */
@Test @Test
void candidatesDerivesOperatorFromPlatformWarehousePickerIdentity() { void candidatesDerivesOperatorFromPlatformWarehousePickerIdentity() {
when(service.candidates("42", 900L)).thenReturn(List.of(new ReceivingContainerResponses.Candidate( when(service.candidates("42", 900L)).thenReturn(List.of(new ReceivingContainerResponses.Candidate(
"BOOKING-1", "CONT-1", "WAIT_SIGN", 2, 2, 0, 2, 2, 0, "BOOKING-1", "CONT-1", "US-LAX-01", "洛杉矶一号仓", "WAIT_SIGN", 2, 2, 0, 2, 2, 0,
List.of("ONT8"), 4, 20, 0, true, true, null, null, false))); List.of("ONT8"), 4, 20, 0, true, true, null, null, false, "GC202611060001")));
controller.candidates(picker()); controller.candidates(picker());
@@ -80,6 +80,66 @@ class ReceivingCandidateRepositoryTest {
verify(orders,times(2)).selectList(any());verifyNoInteractions(plans,lines);verify(mapper).availableOrders();verifyNoMoreInteractions(mapper); verify(orders,times(2)).selectList(any());verifyNoInteractions(plans,lines);verify(mapper).availableOrders();verifyNoMoreInteractions(mapper);
} }
/** 柜次号按稳定来源身份分批读取,缺失的业务快照不能被内部 ID 补位。 */
@Test
void cabinetNumbersAreBatchedAndMissingSnapshotsStayEmpty() {
var mapper = mock(ReceivingCandidateMapper.class);
var repository = new ReceivingCandidateRepository(mock(LastlegOrderMapper.class),
mock(InboundPlanMapper.class), mock(InboundPlanLineMapper.class), mapper,
mock(ReceivingContainerSessionMapper.class));
var ids = LongStream.rangeClosed(1, 501).mapToObj(id -> "BOOKING-" + id).toList();
when(mapper.cabinetNumbers(any())).thenAnswer(invocation -> {
List<String> batch = invocation.getArgument(0);
assertThat(batch).hasSizeLessThanOrEqualTo(500);
return batch.stream().map(id -> new ReceivingCandidateMapper.CabinetNumber(id,
id.equals("BOOKING-501") ? null : " GC-" + id + " ")).toList();
});
var numbers = repository.cabinetNumbers(ids);
assertThat(numbers).hasSize(500).containsEntry("BOOKING-1", "GC-BOOKING-1");
assertThat(numbers).doesNotContainKey("BOOKING-501");
assertThat(repository.cabinetNumbers(List.of())).isEmpty();
verify(mapper, times(2)).cabinetNumbers(any());
verifyNoMoreInteractions(mapper);
}
/** 同一物理柜的不同柜次分别显示冻结业务号,旧快照缺号时保持为空。 */
@Test
void cabinetNumberMapperReadsSnapshotsForReusedContainersAndMissingValues() {
var dataSource = new org.h2.jdbcx.JdbcDataSource();
dataSource.setURL("jdbc:h2:mem:receiving_cabinet_" + UUID.randomUUID() + ";MODE=MySQL;DB_CLOSE_DELAY=-1");
var jdbc = new org.springframework.jdbc.core.JdbcTemplate(dataSource);
jdbc.execute("CREATE ALIAS JSON_EXTRACT FOR 'com.baiyue.lastleg.calendar.WarehouseCalendarMapperTest.jsonExtract'");
jdbc.execute("CREATE ALIAS JSON_UNQUOTE FOR 'com.baiyue.lastleg.calendar.WarehouseCalendarMapperTest.jsonUnquote'");
jdbc.execute("CREATE TABLE ll_order(source_container_booking_id VARCHAR(128),container_no VARCHAR(128),source_event_id VARCHAR(128))");
jdbc.execute("CREATE TABLE ll_loading_event_inbox(event_id VARCHAR(128),payload_json VARCHAR(1000))");
jdbc.update("INSERT INTO ll_order VALUES ('BOOKING-A','SAME-CONTAINER','E1'),('BOOKING-A','SAME-CONTAINER','E2'),"
+ "('BOOKING-B','SAME-CONTAINER','E3'),('BOOKING-C','OLD-CONTAINER','MISSING'),"
+ "('BOOKING-D','OLD-CONTAINER','E4'),('BOOKING-E','OLD-CONTAINER','E5')");
jdbc.update("INSERT INTO ll_loading_event_inbox VALUES (?,?),(?,?),(?,?),(?,?),(?,?)",
"E1", "{\"cabinetNo\":\" GC-A \"}", "E2", "{\"cabinetNo\":\"GC-A\"}",
"E3", "{\"cabinetNo\":\"GC-B\"}", "E4", "{\"cabinetNo\":null}", "E5", "{\"cabinetNo\":\" \"}");
var configuration = new org.apache.ibatis.session.Configuration(new org.apache.ibatis.mapping.Environment(
"test", new org.apache.ibatis.transaction.jdbc.JdbcTransactionFactory(), dataSource));
configuration.setMapUnderscoreToCamelCase(true);
configuration.addMapper(ReceivingCandidateMapper.class);
try (var session = new org.apache.ibatis.session.SqlSessionFactoryBuilder().build(configuration).openSession()) {
var repository = new ReceivingCandidateRepository(mock(LastlegOrderMapper.class),
mock(InboundPlanMapper.class), mock(InboundPlanLineMapper.class),
session.getMapper(ReceivingCandidateMapper.class), mock(ReceivingContainerSessionMapper.class));
assertThat(repository.cabinetNumbers(List.of("BOOKING-A", "BOOKING-B", "BOOKING-C", "BOOKING-D", "BOOKING-E")))
.containsExactlyInAnyOrderEntriesOf(Map.of("BOOKING-A", "GC-A", "BOOKING-B", "GC-B"));
}
}
/** 柜次号只读取尾程已接收的装柜事件快照,查询按稳定柜身份分组。 */
@Test
void cabinetNumberSqlUsesTheLoadingSnapshotAndBookingOccurrence() throws Exception {
var sql = ReceivingCandidateMapper.class.getMethod("cabinetNumbers", List.class)
.getAnnotation(org.apache.ibatis.annotations.Select.class).value()[0];
assertThat(sql).contains("$.cabinetNo", "ll_loading_event_inbox", "e.event_id=o.source_event_id",
"o.source_container_booking_id IN", "GROUP BY o.source_container_booking_id", "#{id}");
}
/** 真实 Mapper 汇总不会把其他客户或其他计划的同号行混入实收量。 */ /** 真实 Mapper 汇总不会把其他客户或其他计划的同号行混入实收量。 */
@Test @Test
void receivedSqlKeepsCustomerPlanAndLineIdentity() throws Exception { void receivedSqlKeepsCustomerPlanAndLineIdentity() throws Exception {
@@ -155,6 +155,8 @@ class ReceivingContainerServiceBehaviorTest {
new com.baiyue.lastleg.inboundplan.pda.repository.ReceivingCandidateRepository.LineKey("200", second.getId(), secondLine.getId()), 5, new com.baiyue.lastleg.inboundplan.pda.repository.ReceivingCandidateRepository.LineKey("200", second.getId(), secondLine.getId()), 5,
new com.baiyue.lastleg.inboundplan.pda.repository.ReceivingCandidateRepository.LineKey("OTHER", first.getId(), firstLine.getId()), 99)); new com.baiyue.lastleg.inboundplan.pda.repository.ReceivingCandidateRepository.LineKey("OTHER", first.getId(), firstLine.getId()), 99));
org.mockito.Mockito.doReturn(facts).when(candidateRepository).load(); org.mockito.Mockito.doReturn(facts).when(candidateRepository).load();
when(candidateRepository.cabinetNumbers(List.of("BOOKING-A")))
.thenReturn(java.util.Map.of("BOOKING-A", "GC202611060001"));
var result = service.candidates("7", 100L); var result = service.candidates("7", 100L);
@@ -163,7 +165,9 @@ class ReceivingContainerServiceBehaviorTest {
assertThat(result.get(0).totalPackages()).isEqualTo(20); assertThat(result.get(0).totalPackages()).isEqualTo(20);
assertThat(result.get(0).receivedPackages()).isEqualTo(8); assertThat(result.get(0).receivedPackages()).isEqualTo(8);
assertThat(result.get(0).operable()).isTrue(); assertThat(result.get(0).operable()).isTrue();
assertThat(result.get(0).cabinetNo()).isEqualTo("GC202611060001");
verify(candidateRepository).load(); verify(candidateRepository).load();
verify(candidateRepository).cabinetNumbers(List.of("BOOKING-A"));
verifyNoInteractions(orders, plans, lines, allocations, shippingGates, warehouse); verifyNoInteractions(orders, plans, lines, allocations, shippingGates, warehouse);
// 不可提前按仓过滤同柜另一客户,否则会把冲突柜错误显示为本仓可收货。 // 不可提前按仓过滤同柜另一客户,否则会把冲突柜错误显示为本仓可收货。
secondOrder.setOverseasWarehouseId("200"); secondOrder.setOverseasWarehouseId("200");
@@ -316,6 +320,7 @@ class ReceivingContainerServiceBehaviorTest {
.containsExactly("CONT-A", "CONT-B"); .containsExactly("CONT-A", "CONT-B");
} }
/** 同仓候选可打开详情,两个入口返回同一正式柜次号。 */
@Test @Test
void visibleCandidateWithUnassignedPlanCanOpenDetailInTheSameWarehouse() { void visibleCandidateWithUnassignedPlanCanOpenDetailInTheSameWarehouse() {
InboundPlan unassigned = plan("TSTU0000204"); InboundPlan unassigned = plan("TSTU0000204");
@@ -328,12 +333,16 @@ class ReceivingContainerServiceBehaviorTest {
when(orders.selectById(1L)).thenReturn(order); when(orders.selectById(1L)).thenReturn(order);
when(warehouse.requireActiveSharedWarehouse(100L)).thenReturn(warehouseReference()); when(warehouse.requireActiveSharedWarehouse(100L)).thenReturn(warehouseReference());
when(candidateRepository.cabinetNumbers(List.of("BOOKING-204")))
.thenReturn(java.util.Map.of("BOOKING-204", "GC202611060204"));
var candidate = service.candidates("7", 100L).get(0); var candidate = service.candidates("7", 100L).get(0);
var detail = service.detail("7", 100L, "TSTU0000204", "BOOKING-204"); var detail = service.detail("7", 100L, "TSTU0000204", "BOOKING-204");
assertThat(candidate.operable()).isTrue(); assertThat(candidate.operable()).isTrue();
assertThat(detail.overseasWarehouseId()).isEqualTo(100L); assertThat(detail.overseasWarehouseId()).isEqualTo(100L);
assertThat(detail.overseasWarehouseCode()).isEqualTo("SHARED-LAX"); assertThat(detail.overseasWarehouseCode()).isEqualTo("SHARED-LAX");
assertThat(candidate.cabinetNo()).isEqualTo("GC202611060204");
assertThat(detail.cabinetNo()).isEqualTo(candidate.cabinetNo());
} }
@Test @Test