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 精确校验 |
| 公司车辆付款凭证 | `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 |
| 平台司机驾驶证 | 通用内部业务文件入口,契约 `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-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 校验申请/明细关系、客户归属、权限和业务状态 |
@@ -9,7 +9,7 @@ import java.util.Arrays;
enum BusinessFileCategory {
FLEET_VEHICLE_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),
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),
@@ -5,7 +5,7 @@ info:
description: >-
复用InternalBusinessFileController,固定平台tenantId=0、businessType=FLEET_DRIVER_LICENSE、
businessId=司机内部ID。Lastleg负责司机存在性、列表/上传权限、关联和历史可见性;
File校验内部服务令牌、平台复合归属、真实非空文件、MIME与签名。最大10MiB,仅PDF/JPG/PNG。
File校验内部服务令牌、平台复合归属、真实非空文件、MIME与签名。上传最大10MiB,仅JPG/PNG。
上传只生成不可变对象,不自动通过资质审核,不生成公开URL,旧附件通过Lastleg历史引用读取。
security:
- InternalServiceToken: []
@@ -24,15 +24,22 @@ class DriverLicenseFileTest {
assertThatThrownBy(() -> category.storageBusinessId("0")).isInstanceOf(BizException.class);
}
@Test void storesRealPdfAndRejectsWrongMimeAndSpoofedPdf() throws Exception {
/** 驾驶证上传接受有效 JPG/PNG,其他格式和伪装的图片均被拒绝。 */
@Test void storesImagesAndRejectsPdfWrongMimeAndSpoofedImages() throws Exception {
var storage = storage();
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);
assertThat(storage.read(result.objectKey())).isEqualTo(pdf.getBytes());
for (var format : new String[]{"jpg", "png"}) {
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))
.isInstanceOf(BizException.class).hasMessageContaining("仅支持");
assertThatThrownBy(() -> storage.store(0, "71", new MockMultipartFile("file", "license.pdf", "application/pdf", "not pdf".getBytes()), BusinessFileCategory.FLEET_DRIVER_LICENSE))
.isInstanceOf(BizException.class).hasMessageContaining("不是有效PDF");
assertThatThrownBy(() -> storage.store(0, "71", new MockMultipartFile("file", "license.png", "image/png", pdf.getBytes()), BusinessFileCategory.FLEET_DRIVER_LICENSE))
.isInstanceOf(BizException.class).hasMessageContaining("图片");
}
@Test void rejectsMoreThanTenMibBeforeReadingBytes() {
@@ -43,6 +50,14 @@ class DriverLicenseFileTest {
.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() {
var storage = new BusinessFileStorage();
ReflectionTestUtils.setField(storage, "configuredRoot", root);
@@ -19,7 +19,8 @@ import java.util.Set;
public class HttpDriverFileGateway implements DriverFileGateway {
static final String TYPE = "FLEET_DRIVER_LICENSE";
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 final RestClient client;
private final String token;
@@ -31,10 +32,11 @@ public class HttpDriverFileGateway implements DriverFileGateway {
this.token = token;
}
/** 新上传的驾驶证只接受 JPG/PNG,读取历史附件继续按原文件元数据校验。 */
@Override
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(), "")))
throw bad("驾驶证附件须为非空的 PDF、JPG 或 PNG,最大 10MiB");
if (file == null || file.isEmpty() || file.getSize() > MAX_SIZE || !UPLOAD_CONTENT_TYPES.contains(Objects.toString(file.getContentType(), "")))
throw bad("驾驶证附件须为非空的 JPG 或 PNG,最大 10MiB");
var body = new MultipartBodyBuilder();
try {
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()
.onStatus(HttpStatusCode::isError, (request, response) -> { throw unavailable(response.getStatusCode()); })
.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
@@ -72,7 +76,7 @@ public class HttpDriverFileGateway implements DriverFileGateway {
if (data == null || data.fileId() == null || data.fileId() <= 0 || !Objects.equals(data.tenantId(), 0L)
|| !TYPE.equals(data.businessType()) || !Long.toString(driverId).equals(data.businessId())
|| (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)
throw bad("文件不属于当前司机或不是有效驾驶证附件");
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,
int totalPackages, int receivedPackages,
boolean operable, boolean allPlansReady, String blockReason,
LocalDateTime plannedArrivalAt, boolean currentAccount) {
LocalDateTime plannedArrivalAt, boolean currentAccount, String cabinetNo) {
/** 保留不含接柜仓与柜次号的既有响应构造方式。 */
public Candidate(String sourceContainerBookingId, String containerNo, String status,
int totalOrderCount, int inboundCodeCount, int missingInboundCodeCount,
int planCount, int plannedInboundCodeCount, int pendingPlanCount,
@@ -23,7 +24,7 @@ public final class ReceivingContainerResponses {
this(sourceContainerBookingId, containerNo, null, null, status, totalOrderCount,
inboundCodeCount, missingInboundCodeCount, planCount, plannedInboundCodeCount,
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 receivingAt, LocalDateTime completedAt,
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,
String status, String operatorName, int version, List<SignedPhoto> photos,
LocalDateTime preparedAt, LocalDateTime signedAt, LocalDateTime receivingAt,
@@ -44,9 +46,10 @@ public final class ReceivingContainerResponses {
int pendingPackages, List<Inbound> inbounds) {
this(containerNo, overseasWarehouseId, null, overseasWarehouseName, status, operatorName,
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,
String overseasWarehouseName, String status, String operatorName, int version,
List<SignedPhoto> photos, LocalDateTime preparedAt, LocalDateTime signedAt,
@@ -54,7 +57,7 @@ public final class ReceivingContainerResponses {
int receivedPackages, int pendingPackages, List<Inbound> inbounds) {
this(containerNo, overseasWarehouseId, overseasWarehouseCode, overseasWarehouseName,
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) { }
/** 按稳定柜次身份批量读取本库装柜事件中的正式柜次号,不从柜号或内部 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 " +
"AND o.id=p.source_order_id WHERE p.id IN " +
@@ -41,6 +41,14 @@ public class ReceivingCandidateRepository {
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 Facts(List<LastlegOrder> orders, List<InboundPlan> plans, List<InboundPlanLine> lines,
Set<Long> availableOrderIds, Set<Long> unconfirmedPlans, Map<LineKey,Integer> received) { }
@@ -139,7 +139,7 @@ public class ReceivingContainerService {
Set<String> reservedByOthers = candidateRepository.activeSessions().stream()
.filter(session -> !operatorLockKey.equals(session.getActiveOperatorKey()))
.map(ReceivingContainerSession::getActiveContainerKey).filter(Objects::nonNull).collect(Collectors.toSet());
return grouped.entrySet().stream()
List<ContainerReadiness> visible = grouped.entrySet().stream()
.map(entry -> {
var localLines = entry.getValue().stream().flatMap(order ->
linesByOrder.getOrDefault(order.getId(), List.of()).stream()).toList();
@@ -155,7 +155,11 @@ public class ReceivingContainerService {
.thenComparing(ContainerReadiness::plannedArrivalAt,
Comparator.nullsLast(Comparator.naturalOrder()))
.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,
@@ -783,8 +787,9 @@ public class ReceivingContainerService {
blockReason, arrival, factHash, totals);
}
/** 候选展示批量读取的正式柜次号,继续以稳定柜身份执行接柜门禁。 */
private ReceivingContainerResponses.Candidate candidate(ContainerReadiness value,
ReceivingContainerSession current) {
ReceivingContainerSession current, String cabinetNo) {
boolean active = current != null && value.containerNo().equals(current.getContainerNo());
Totals totals = value.totals();
return new ReceivingContainerResponses.Candidate(value.sourceContainerBookingId(), value.containerNo(),
@@ -793,7 +798,7 @@ public class ReceivingContainerService {
value.missingInboundCodeCount(), value.planCount(), value.plannedInboundCodeCount(),
value.pendingPlanCount(), totals.destinationCodes(), totals.plannedPalletCount(), totals.total(),
totals.received(), value.operable(), value.allPlansReady(), value.blockReason(),
value.plannedArrivalAt(), active);
value.plannedArrivalAt(), active, cabinetNo);
}
private ReceivingContainerSession requireSigned(String operatorKey) {
@@ -1083,18 +1088,22 @@ public class ReceivingContainerService {
private record DropshipPalletRef(InboundRef ref, InboundPlanPallet pallet,
InboundPlanPalletAllocation allocation) { }
/** 当前柜摘要从本地冻结快照补充柜次号,覆盖详情、签收、恢复及完成响应。 */
private ReceivingContainerResponses.Session snapshot(ReceivingContainerSession session, List<InboundPlan> containerPlans) {
List<ReceivingContainerResponses.Inbound> inbounds = refs(
session.getSourceContainerBookingId(), session.getContainerNo()).stream()
.map(this::inbound).toList();
int total = inbounds.stream().mapToInt(ReceivingContainerResponses.Inbound::totalPackages).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(),
session.getOverseasWarehouseCode(), session.getOverseasWarehouseName(), session.getStatus(), session.getOperatorName(),
value(session.getVersion()), signPhotos(session), domesticLoadingPhotos(session),
session.getPreparedAt(), session.getSignedAt(),
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(
@@ -185,7 +185,7 @@ paths:
parameters: [{$ref: '#/components/parameters/Id'}]
post:
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:
required: true
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]
properties:
sourceContainerBookingId: { type: string, description: 头程与尾程共享的稳定柜身份 }
cabinetNo: { type: string, nullable: true, description: 头程装柜事件快照中的正式柜次号,缺失时为空 }
containerNo: { type: string }
overseasWarehouseCode: { type: string }
overseasWarehouseName: { type: string }
@@ -4047,6 +4048,7 @@ components:
type: object
required: [containerNo, overseasWarehouseId, overseasWarehouseCode, overseasWarehouseName, status, version, photos, domesticLoadingPhotos, totalPackages, receivedPackages, pendingPackages, hasDifference, inbounds]
properties:
cabinetNo: { type: string, nullable: true, description: 当前柜次对应的头程装柜事件快照中的正式柜次号,缺失时为空 }
containerNo: { type: string }
overseasWarehouseId: { type: string, pattern: '^\d+$', description: 平台预分配并与PDA Token匹配的海外接柜仓ID }
overseasWarehouseCode: { type: string, description: 海外接柜仓业务编码快照 }
@@ -111,10 +111,11 @@ class DriverServiceTest {
assertThat(service.update(71, "e", new DriverModels.Update(3L, changedExpiry)).reviewStatus()).isEqualTo("PENDING");
}
/** 绑定新图片后,司机证照恢复待审并保留旧附件的历史关联。 */
@Test void replacingLicenseBindsNewFileAndResetsApproval() {
when(drivers.lockById(71)).thenReturn(row("APPROVED"));
var file = new MockMultipartFile("file", "license.pdf", "application/pdf", "%PDF-test".getBytes());
when(files.upload(71, "e", file)).thenReturn(new DriverModels.File("82", "license.pdf", "application/pdf", 9));
var file = new MockMultipartFile("file", "license.png", "image/png", "image".getBytes());
when(files.upload(71, "e", file)).thenReturn(new DriverModels.File("82", "license.png", "image/png", 5));
var response = service.uploadLicense(71, 2, "e", file);
assertThat(response.licenseFileId()).isEqualTo("82");
assertThat(response.reviewStatus()).isEqualTo("PENDING");
@@ -1,10 +1,27 @@
package com.baiyue.lastleg.fleet.driver;
import org.junit.jupiter.api.Test;
import org.springframework.mock.web.MockMultipartFile;
import static org.assertj.core.api.Assertions.*;
import static org.mockito.Mockito.*;
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() {
assertThat(HttpDriverFileGateway.verified(envelope(0L, "FLEET_DRIVER_LICENSE", "71", 81L, "application/pdf", 12L), 71, 81L).fileId())
.isEqualTo("81");
@@ -37,11 +37,12 @@ class ReceivingContainerControllerTest {
ReflectionTestUtils.setField(controller, "service", service);
}
/** 候选包含正式柜次号,操作账号仍由已鉴权的平台作业员身份确定。 */
@Test
void candidatesDerivesOperatorFromPlatformWarehousePickerIdentity() {
when(service.candidates("42", 900L)).thenReturn(List.of(new ReceivingContainerResponses.Candidate(
"BOOKING-1", "CONT-1", "WAIT_SIGN", 2, 2, 0, 2, 2, 0,
List.of("ONT8"), 4, 20, 0, true, true, null, null, false)));
"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, "GC202611060001")));
controller.candidates(picker());
@@ -80,6 +80,66 @@ class ReceivingCandidateRepositoryTest {
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 汇总不会把其他客户或其他计划的同号行混入实收量。 */
@Test
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("OTHER", first.getId(), firstLine.getId()), 99));
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);
@@ -163,7 +165,9 @@ class ReceivingContainerServiceBehaviorTest {
assertThat(result.get(0).totalPackages()).isEqualTo(20);
assertThat(result.get(0).receivedPackages()).isEqualTo(8);
assertThat(result.get(0).operable()).isTrue();
assertThat(result.get(0).cabinetNo()).isEqualTo("GC202611060001");
verify(candidateRepository).load();
verify(candidateRepository).cabinetNumbers(List.of("BOOKING-A"));
verifyNoInteractions(orders, plans, lines, allocations, shippingGates, warehouse);
// 不可提前按仓过滤同柜另一客户,否则会把冲突柜错误显示为本仓可收货。
secondOrder.setOverseasWarehouseId("200");
@@ -316,6 +320,7 @@ class ReceivingContainerServiceBehaviorTest {
.containsExactly("CONT-A", "CONT-B");
}
/** 同仓候选可打开详情,两个入口返回同一正式柜次号。 */
@Test
void visibleCandidateWithUnassignedPlanCanOpenDetailInTheSameWarehouse() {
InboundPlan unassigned = plan("TSTU0000204");
@@ -328,12 +333,16 @@ class ReceivingContainerServiceBehaviorTest {
when(orders.selectById(1L)).thenReturn(order);
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 detail = service.detail("7", 100L, "TSTU0000204", "BOOKING-204");
assertThat(candidate.operable()).isTrue();
assertThat(detail.overseasWarehouseId()).isEqualTo(100L);
assertThat(detail.overseasWarehouseCode()).isEqualTo("SHARED-LAX");
assertThat(candidate.cabinetNo()).isEqualTo("GC202611060204");
assertThat(detail.cabinetNo()).isEqualTo(candidate.cabinetNo());
}
@Test