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 精确校验 |
|
| 财务付款凭证、海外卸柜照片 | 通用业务文件入口 `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);
|
||||||
|
|||||||
+9
-5
@@ -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());
|
||||||
|
|||||||
+8
-5
@@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+15
@@ -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 " +
|
||||||
|
|||||||
+8
@@ -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) { }
|
||||||
|
|||||||
+14
-5
@@ -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: 海外接柜仓业务编码快照 }
|
||||||
|
|||||||
+3
-2
@@ -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");
|
||||||
|
|||||||
+17
@@ -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");
|
||||||
|
|||||||
+3
-2
@@ -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());
|
||||||
|
|
||||||
|
|||||||
+60
@@ -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 {
|
||||||
|
|||||||
+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("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
|
||||||
|
|||||||
Reference in New Issue
Block a user