fix(pda): 返回拆柜柜次号并限制司机证照上传格式
This commit was merged in pull request #47.
This commit is contained in:
+1
-1
@@ -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);
|
||||
|
||||
+9
-5
@@ -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());
|
||||
|
||||
+8
-5
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+15
@@ -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 " +
|
||||
|
||||
+8
@@ -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) { }
|
||||
|
||||
+14
-5
@@ -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: 海外接柜仓业务编码快照 }
|
||||
|
||||
+3
-2
@@ -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");
|
||||
|
||||
+17
@@ -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");
|
||||
|
||||
+3
-2
@@ -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());
|
||||
|
||||
|
||||
+60
@@ -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 {
|
||||
|
||||
+9
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user