Merge branch 'dev' into dev-lyhfive
This commit is contained in:
@@ -25,7 +25,7 @@
|
||||
| 托盘分配算法 | `inboundplan/service/PalletAllocator.java` |
|
||||
| FBA 预约创建、详情与编辑 | `InboundPlanController.java` 的 `/fba-appointments/**`、`inboundplan/service/InboundFbaAppointmentService.java`、`BatchFbaAppointmentRequest.java`、`UpdateFbaAppointmentRequest.java` |
|
||||
| PDA 入仓、实收、上架与完成 | `inboundplan/pda/controller/ReceivingContainerController.java`、`inboundplan/pda/service/ReceivingContainerService.java`、内部编排 `InboundPdaWorkflowService.java`;公网入口固定为 `/api/pda/v1/last-leg/receiving-containers/**`,旧 planNo/allocationId Controller 已移除 |
|
||||
| PDA 卸柜照片文件上传与归属校验 | `ReceivingContainerController.java` 的 `/current/unload-photos/files`、`inboundplan/pda/gateway/UnloadPhotoFileGateway.java`、`HttpUnloadPhotoFileGateway.java`;File 类型固定 `OVERSEAS_UNLOAD_PHOTO` |
|
||||
| PDA 卸柜照片上传、归属校验与预览 | `ReceivingContainerController.java` 的 `/current/unload-photos/files`、`/current/unload-photos/files/{fileId}/download`,网关 `inboundplan/pda/gateway/UnloadPhotoFileGateway.java`、`HttpUnloadPhotoFileGateway.java`;File 类型固定 `OVERSEAS_UNLOAD_PHOTO`,下载只允许当前会话已签收的 fileId 并返回 `no-store` |
|
||||
| 卸柜照片与海外卸柜完成 Outbox | `ReceivingContainerController.java`、`ReceivingContainerService.java`、`inboundplan/pda/service/InboundUnloadCompletedOutboxService.java`、`entity/LastlegEventOutbox.java`、`mapper/LastlegEventOutboxMapper.java`;多计划同柜只能在全柜完成后调用 `publishContainerCompleted`,按来源柜预订 ID 幂等发布一次,完成时刻与拍照时刻分离;事件模型见 `../common-event-contract/AGENTS.md` |
|
||||
| 入仓后 SKU 库存余额、库位和流水 | `inventory/`;平台只读查询与非出库库存命令分别从 `inventory/query/controller/InventoryQueryController.java`、`inventory/command/controller/InventoryCommandController.java` 进入 |
|
||||
| 海外PDA盘点、同仓整托移库、托盘组拆换绑与换标现场作业 | `pda/stocktake/`、`pda/move/`、`palletops/`、`relabel/pda/`;版本化契约 `src/main/resources/openapi/overseas-pda-operations-v1.yaml`;全部范围取自PDA Token,不含出库和派送 |
|
||||
|
||||
+17
@@ -11,6 +11,10 @@ import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import jakarta.annotation.Resource;
|
||||
import jakarta.validation.Valid;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.http.CacheControl;
|
||||
import org.springframework.http.ContentDisposition;
|
||||
import org.springframework.http.HttpHeaders;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.security.core.annotation.AuthenticationPrincipal;
|
||||
import org.springframework.security.access.prepost.PreAuthorize;
|
||||
@@ -24,6 +28,7 @@ import org.springframework.web.bind.annotation.RestController;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.List;
|
||||
|
||||
@RestController
|
||||
@@ -66,6 +71,18 @@ public class ReceivingContainerController {
|
||||
return ApiResponse.success(service.uploadPhoto(identity.operatorKey(), user.userId(), file));
|
||||
}
|
||||
|
||||
@GetMapping("/current/unload-photos/files/{fileId}/download")
|
||||
public ResponseEntity<byte[]> downloadCurrentPhoto(@AuthenticationPrincipal AuthenticatedUser user,
|
||||
@PathVariable String fileId) {
|
||||
PdaWarehouseIdentity identity = currentIdentity(user);
|
||||
var download = service.downloadCurrentPhoto(identity.operatorKey(), fileId);
|
||||
return ResponseEntity.ok().cacheControl(CacheControl.noStore())
|
||||
.header(HttpHeaders.CONTENT_DISPOSITION,
|
||||
ContentDisposition.inline().filename(download.fileName(), StandardCharsets.UTF_8).build().toString())
|
||||
.contentType(org.springframework.http.MediaType.parseMediaType(download.contentType()))
|
||||
.contentLength(download.content().length).body(download.content());
|
||||
}
|
||||
|
||||
@PostMapping("/current/sign")
|
||||
public ApiResponse<ReceivingContainerResponses.Session> sign(@AuthenticationPrincipal AuthenticatedUser user,
|
||||
@Valid @RequestBody ReceivingContainerRequests.Sign request) {
|
||||
|
||||
+1
-1
@@ -17,7 +17,7 @@ public final class ReceivingContainerResponses {
|
||||
int totalPackages, int receivedPackages, int pendingPackages,
|
||||
List<Inbound> inbounds) { }
|
||||
|
||||
public record SignedPhoto(ReceivingPhotoRole role, String fileId) { }
|
||||
public record SignedPhoto(ReceivingPhotoRole role, String fileId, String downloadPath) { }
|
||||
|
||||
public record Inbound(String inboundCode, String destinationType,
|
||||
String overseasWarehouseId, String overseasWarehouseName,
|
||||
|
||||
+45
@@ -8,6 +8,9 @@ import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.http.HttpStatusCode;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.ContentDisposition;
|
||||
import org.springframework.http.HttpHeaders;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.http.client.MultipartBodyBuilder;
|
||||
import org.springframework.core.io.ByteArrayResource;
|
||||
import org.springframework.stereotype.Component;
|
||||
@@ -85,6 +88,39 @@ public class HttpUnloadPhotoFileGateway implements UnloadPhotoFileGateway {
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public DownloadedFile download(String customerId, long taskId, long fileId) {
|
||||
long tenantId = tenantId(customerId);
|
||||
ResponseEntity<byte[]> response = client.get()
|
||||
.uri("/internal/file/v1/business-files/{tenantId}/{businessType}/{businessId}/{fileId}/content",
|
||||
tenantId, BUSINESS_TYPE, taskId, fileId)
|
||||
.header("X-Internal-Service-Token", internalToken)
|
||||
.retrieve()
|
||||
.onStatus(HttpStatusCode::is4xxClientError, (request, result) -> {
|
||||
HttpStatusCode status = result.getStatusCode();
|
||||
if (status.value() == HttpStatus.NOT_FOUND.value()) {
|
||||
throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Unload photo was not found");
|
||||
}
|
||||
if (status.value() == HttpStatus.FORBIDDEN.value()) {
|
||||
throw new ResponseStatusException(HttpStatus.FORBIDDEN,
|
||||
"Unload photo is not in current account scope");
|
||||
}
|
||||
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Unload photo download failed");
|
||||
})
|
||||
.onStatus(HttpStatusCode::is5xxServerError, (request, result) -> {
|
||||
throw new ResponseStatusException(HttpStatus.SERVICE_UNAVAILABLE, "Unload photo download failed");
|
||||
})
|
||||
.toEntity(byte[].class);
|
||||
byte[] content = response.getBody() == null ? new byte[0] : response.getBody();
|
||||
String fileName = contentDispositionFilename(response.getHeaders().getFirst(HttpHeaders.CONTENT_DISPOSITION));
|
||||
if (fileName == null || fileName.isBlank()) {
|
||||
fileName = String.valueOf(fileId) + ".bin";
|
||||
}
|
||||
String contentType = response.getHeaders().getContentType() == null
|
||||
? MediaType.APPLICATION_OCTET_STREAM_VALUE : response.getHeaders().getContentType().toString();
|
||||
return new DownloadedFile(content, fileName, contentType);
|
||||
}
|
||||
|
||||
private long tenantId(String customerId) {
|
||||
try { return Long.parseLong(customerId); }
|
||||
catch (NumberFormatException exception) { throw new ResponseStatusException(
|
||||
@@ -104,6 +140,15 @@ public class HttpUnloadPhotoFileGateway implements UnloadPhotoFileGateway {
|
||||
response.message() == null || response.message().isBlank() ? fallback : response.message());
|
||||
}
|
||||
|
||||
private static String contentDispositionFilename(String rawValue) {
|
||||
if (rawValue == null || rawValue.isBlank()) return null;
|
||||
try {
|
||||
return ContentDisposition.parse(rawValue).getFilename();
|
||||
} catch (IllegalArgumentException ignored) {
|
||||
return rawValue.replace("attachment; filename=\"", "").replace("\"", "").trim();
|
||||
}
|
||||
}
|
||||
|
||||
private record MetadataEnvelope(String code, String message, Metadata data) {}
|
||||
private record Metadata(Long fileId, Long tenantId, String businessType, String businessId,
|
||||
String fileName, String contentType, Long sizeBytes) {}
|
||||
|
||||
+2
@@ -5,6 +5,8 @@ import org.springframework.web.multipart.MultipartFile;
|
||||
public interface UnloadPhotoFileGateway {
|
||||
UploadedFile upload(String customerId, long taskId, long operatorId, MultipartFile file);
|
||||
void validate(String customerId, long taskId, long fileId);
|
||||
DownloadedFile download(String customerId, long taskId, long fileId);
|
||||
|
||||
record UploadedFile(long fileId, String fileName, String contentType, long sizeBytes) {}
|
||||
record DownloadedFile(byte[] content, String fileName, String contentType) {}
|
||||
}
|
||||
|
||||
+23
-2
@@ -17,6 +17,7 @@ import com.baiyue.lastleg.inboundplan.mapper.InboundPlanPalletMapper;
|
||||
import com.baiyue.lastleg.inboundplan.pda.dto.PdaRequests;
|
||||
import com.baiyue.lastleg.inboundplan.pda.dto.ReceivingContainerRequests;
|
||||
import com.baiyue.lastleg.inboundplan.pda.dto.ReceivingContainerResponses;
|
||||
import com.baiyue.lastleg.inboundplan.pda.dto.ReceivingPhotoRole;
|
||||
import com.baiyue.lastleg.inboundplan.pda.entity.ReceivingContainerOperation;
|
||||
import com.baiyue.lastleg.inboundplan.pda.entity.ReceivingContainerSession;
|
||||
import com.baiyue.lastleg.inboundplan.pda.entity.ReceivingSkuReceipt;
|
||||
@@ -58,6 +59,9 @@ import java.util.stream.Collectors;
|
||||
|
||||
@Service
|
||||
public class ReceivingContainerService {
|
||||
private static final String PHOTO_DOWNLOAD_PATH =
|
||||
"/api/pda/v1/last-leg/receiving-containers/current/unload-photos/files/{fileId}/download";
|
||||
|
||||
@Resource private InboundPlanMapper plans;
|
||||
@Resource private InboundPlanLineMapper lines;
|
||||
@Resource private InboundPlanPalletMapper pallets;
|
||||
@@ -198,6 +202,16 @@ public class ReceivingContainerService {
|
||||
return snapshot(session, requirePlans(session.getCustomerId(), session.getContainerNo()));
|
||||
}
|
||||
|
||||
public UnloadPhotoFileGateway.DownloadedFile downloadCurrentPhoto(String operatorKey, String fileId) {
|
||||
ReceivingContainerSession session = requireCurrent(operatorKey, false);
|
||||
long parsedFileId = positiveFileId(fileId);
|
||||
if (signedPhotos(session).stream().noneMatch(photo -> fileId.equals(photo.fileId()))) {
|
||||
throw new ResponseStatusException(HttpStatus.FORBIDDEN,
|
||||
"Unload photo is not part of the current receiving session");
|
||||
}
|
||||
return unloadPhotos.download(session.getCustomerId(), session.getId(), parsedFileId);
|
||||
}
|
||||
|
||||
public void requireCurrentWarehouse(String operatorKey, long overseasWarehouseId) {
|
||||
requireSessionWarehouse(sessions.findActiveByOperator(activeOperatorKey(operatorKey)), overseasWarehouseId);
|
||||
}
|
||||
@@ -510,7 +524,7 @@ public class ReceivingContainerService {
|
||||
throw conflict("Exactly three typed unload photos are required");
|
||||
}
|
||||
if (request.photos().stream().anyMatch(Objects::isNull)) throw conflict("Unload photo cannot be null");
|
||||
Set<com.baiyue.lastleg.inboundplan.pda.dto.ReceivingPhotoRole> roles = request.photos().stream()
|
||||
Set<ReceivingPhotoRole> roles = request.photos().stream()
|
||||
.map(ReceivingContainerRequests.SignedPhoto::role).collect(Collectors.toSet());
|
||||
if (roles.size() != 3 || roles.contains(null)) throw conflict("Each unload photo role is required exactly once");
|
||||
Set<Long> fileIds = request.photos().stream().map(value -> positiveFileId(value.fileId()))
|
||||
@@ -520,7 +534,7 @@ public class ReceivingContainerService {
|
||||
}
|
||||
return request.photos().stream()
|
||||
.sorted(Comparator.comparing(value -> value.role().ordinal()))
|
||||
.map(value -> new ReceivingContainerResponses.SignedPhoto(value.role(), value.fileId())).toList();
|
||||
.map(value -> new ReceivingContainerResponses.SignedPhoto(value.role(), value.fileId(), null)).toList();
|
||||
}
|
||||
|
||||
private long positiveFileId(String value) {
|
||||
@@ -726,6 +740,13 @@ public class ReceivingContainerService {
|
||||
}
|
||||
|
||||
private List<ReceivingContainerResponses.SignedPhoto> signPhotos(ReceivingContainerSession session) {
|
||||
return signedPhotos(session).stream()
|
||||
.map(photo -> new ReceivingContainerResponses.SignedPhoto(photo.role(), photo.fileId(),
|
||||
PHOTO_DOWNLOAD_PATH.replace("{fileId}", photo.fileId())))
|
||||
.toList();
|
||||
}
|
||||
|
||||
private List<ReceivingContainerResponses.SignedPhoto> signedPhotos(ReceivingContainerSession session) {
|
||||
if (session.getSignPhotos() == null || session.getSignPhotos().isBlank()) return List.of();
|
||||
try { return objectMapper.readValue(session.getSignPhotos(),
|
||||
new TypeReference<List<ReceivingContainerResponses.SignedPhoto>>() { }); }
|
||||
|
||||
@@ -31,10 +31,29 @@ paths:
|
||||
security: [{ bearerAuth: [] }]
|
||||
requestBody: { required: true, content: { multipart/form-data: { schema: { type: object, required: [file], properties: { file: { type: string, format: binary } } } } } }
|
||||
responses: { '200': { description: 文件标识, content: { application/json: { schema: { $ref: '#/components/schemas/ReceivingPhotoApiResponse' } } } }, '409': { description: 当前柜已签收 } }
|
||||
/api/pda/v1/last-leg/receiving-containers/current/unload-photos/files/{fileId}/download:
|
||||
get:
|
||||
summary: 下载当前柜签收照片
|
||||
description: 权限 lastleg:pda:operate;仅允许下载当前会话已签收角色绑定的照片。
|
||||
security: [{ bearerAuth: [] }]
|
||||
parameters:
|
||||
- { name: fileId, in: path, required: true, schema: { type: string, pattern: '^[1-9][0-9]{0,18}$' } }
|
||||
responses:
|
||||
'200':
|
||||
description: 签收照片字节流;Content-Type 透传文件类型,缺失时为 application/octet-stream;Content-Disposition 为 inline;Cache-Control 为 no-store。
|
||||
headers:
|
||||
Content-Disposition: { schema: { type: string }, description: inline 文件名 }
|
||||
Cache-Control: { schema: { type: string, example: no-store } }
|
||||
content:
|
||||
image/jpeg: { schema: { type: string, format: binary } }
|
||||
image/png: { schema: { type: string, format: binary } }
|
||||
application/octet-stream: { schema: { type: string, format: binary } }
|
||||
'403': { description: fileId 未绑定到当前操作员的当前柜会话三类签收照片 }
|
||||
'404': { description: 当前柜会话或照片不存在 }
|
||||
/api/pda/v1/last-leg/receiving-containers/current/sign:
|
||||
post:
|
||||
summary: 签收当前柜
|
||||
description: 权限 lastleg:pda:operate;精确校验车牌、封签、开柜三类照片各一次及文件归属后由 WAIT_SIGN 推进 SIGNED。
|
||||
description: 权限 lastleg:pda:operate;精确校验车牌、封签、开门三类照片各且仅 1 张及文件归属后由 WAIT_SIGN 推进 SIGNED;重拍由调用端在签收前替换对应槽位。
|
||||
security: [{ bearerAuth: [] }]
|
||||
requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/ReceivingSign' } } } }
|
||||
responses: { '200': { description: 已签收会话, content: { application/json: { schema: { $ref: '#/components/schemas/ReceivingSessionApiResponse' } } } }, '409': { description: 状态或照片校验失败 } }
|
||||
@@ -678,6 +697,13 @@ components:
|
||||
properties:
|
||||
role: { type: string, enum: [LICENSE_PLATE, CONTAINER_SEAL, DOOR_OPEN] }
|
||||
fileId: { type: string, pattern: '^[1-9][0-9]{0,18}$', description: Snowflake file ID kept as a decimal string }
|
||||
ReceivingSignedSessionPhoto:
|
||||
type: object
|
||||
required: [role, fileId, downloadPath]
|
||||
properties:
|
||||
role: { type: string, enum: [LICENSE_PLATE, CONTAINER_SEAL, DOOR_OPEN] }
|
||||
fileId: { type: string, pattern: '^[1-9][0-9]{0,18}$', description: Snowflake file ID kept as a decimal string }
|
||||
downloadPath: { type: string, description: 用于会话恢复时直接下载签收照片 }
|
||||
ReceivingBulk:
|
||||
type: object
|
||||
required: [inboundCode, skuCode, palletCode, packageCount, idempotencyKey]
|
||||
@@ -742,7 +768,7 @@ components:
|
||||
status: { type: string, enum: [WAIT_SIGN, SIGNED, RECEIVING, COMPLETED] }
|
||||
operatorName: { type: string, nullable: true }
|
||||
version: { type: integer }
|
||||
photos: { type: array, maxItems: 3, items: { $ref: '#/components/schemas/ReceivingSignedPhoto' } }
|
||||
photos: { type: array, maxItems: 3, items: { $ref: '#/components/schemas/ReceivingSignedSessionPhoto' } }
|
||||
preparedAt: { type: string, format: date-time, nullable: true }
|
||||
signedAt: { type: string, format: date-time, nullable: true }
|
||||
receivingAt: { type: string, format: date-time, nullable: true }
|
||||
|
||||
+3
@@ -128,6 +128,7 @@ class ReceivingContainerContractTest {
|
||||
assertTrue(yaml.contains(prefix + "/current/skus/resolve:"));
|
||||
assertTrue(yaml.contains(prefix + "/current/receipts/carton:"));
|
||||
assertTrue(yaml.contains(prefix + "/current/complete:"));
|
||||
assertTrue(yaml.contains(prefix + "/current/unload-photos/files/{fileId}/download:"));
|
||||
assertFalse(yaml.contains("/api/pda/v1/last-leg/receiving-containers/{planNo}"));
|
||||
String receivingPaths = yaml.substring(yaml.indexOf(prefix), yaml.indexOf("/api/platform/v1/last-leg/orders:"));
|
||||
List<String> successResponses = receivingPaths.lines().map(String::trim)
|
||||
@@ -139,6 +140,8 @@ class ReceivingContainerContractTest {
|
||||
assertTrue(receivingPaths.contains("仅 PLATFORM WAREHOUSE_PICKER 可用"));
|
||||
assertFalse(receivingPaths.contains("客户与操作账号均从 PDA Token 获取"));
|
||||
assertTrue(yaml.contains("enum: [LICENSE_PLATE, CONTAINER_SEAL, DOOR_OPEN]"));
|
||||
assertTrue(yaml.contains("photos: { type: array, minItems: 3, maxItems: 3"));
|
||||
assertTrue(yaml.contains("required: [role, fileId, downloadPath]"));
|
||||
assertTrue(yaml.contains("overseasWarehouseId: { type: string"));
|
||||
assertTrue(yaml.contains("overseasWarehouseName: { type: string"));
|
||||
assertFalse(receivingPaths.contains("photoFileIds"));
|
||||
|
||||
+22
@@ -2,6 +2,7 @@ package com.baiyue.lastleg.inboundplan.pda.controller;
|
||||
|
||||
import com.baiyue.common.security.AuthenticatedUser;
|
||||
import com.baiyue.lastleg.inboundplan.pda.dto.ReceivingContainerResponses;
|
||||
import com.baiyue.lastleg.inboundplan.pda.gateway.UnloadPhotoFileGateway;
|
||||
import com.baiyue.lastleg.inboundplan.pda.service.ReceivingContainerService;
|
||||
import com.baiyue.lastleg.pda.security.PdaWarehouseIdentity;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
@@ -9,12 +10,16 @@ import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import org.springframework.http.HttpHeaders;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.test.util.ReflectionTestUtils;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
@@ -55,6 +60,23 @@ class ReceivingContainerControllerTest {
|
||||
.hasMessageContaining("403");
|
||||
}
|
||||
|
||||
@Test
|
||||
void downloadCurrentUnloadPhotoDelegatesToService() {
|
||||
var user = picker();
|
||||
when(service.downloadCurrentPhoto("42", "9999")).thenReturn(
|
||||
new UnloadPhotoFileGateway.DownloadedFile("abc".getBytes(), "photo.jpg", MediaType.IMAGE_JPEG_VALUE));
|
||||
|
||||
ResponseEntity<byte[]> response = controller.downloadCurrentPhoto(user, "9999");
|
||||
|
||||
assertThat(response.getStatusCodeValue()).isEqualTo(200);
|
||||
assertThat(response.getHeaders().getContentType()).isEqualTo(MediaType.IMAGE_JPEG);
|
||||
assertThat(response.getHeaders().getFirst(HttpHeaders.CONTENT_DISPOSITION))
|
||||
.startsWith("inline").contains("photo.jpg");
|
||||
assertThat(response.getHeaders().getCacheControl()).isEqualTo("no-store");
|
||||
assertThat(response.getBody()).containsExactly("abc".getBytes());
|
||||
verify(service).downloadCurrentPhoto("42", "9999");
|
||||
}
|
||||
|
||||
@Test
|
||||
void pickerAuthorityWithoutWarehouseBindingIsForbidden() {
|
||||
AuthenticatedUser unbound = new AuthenticatedUser(42L, 0L, 0L, 8L, "receiver", "PLATFORM", "PDA",
|
||||
|
||||
+32
@@ -2,6 +2,8 @@ package com.baiyue.lastleg.inboundplan.pda.gateway;
|
||||
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.http.HttpHeaders;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.mock.web.MockMultipartFile;
|
||||
import org.springframework.test.util.ReflectionTestUtils;
|
||||
@@ -11,8 +13,10 @@ import org.springframework.web.server.ResponseStatusException;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
import static org.springframework.test.web.client.match.MockRestRequestMatchers.header;
|
||||
import static org.springframework.test.web.client.match.MockRestRequestMatchers.requestTo;
|
||||
import static org.springframework.test.web.client.response.MockRestResponseCreators.withSuccess;
|
||||
import static org.springframework.test.web.client.response.MockRestResponseCreators.withStatus;
|
||||
|
||||
class HttpUnloadPhotoFileGatewayTest {
|
||||
private HttpUnloadPhotoFileGateway gateway;
|
||||
@@ -51,4 +55,32 @@ class HttpUnloadPhotoFileGatewayTest {
|
||||
error -> assertThat(error.getStatusCode().value()).isEqualTo(400));
|
||||
server.verify();
|
||||
}
|
||||
|
||||
@Test
|
||||
void downloadUsesBusinessOwnershipPathAndPreservesBlobMetadata() {
|
||||
server.expect(requestTo(
|
||||
"http://file-service/internal/file/v1/business-files/100/OVERSEAS_UNLOAD_PHOTO/8/9/content"))
|
||||
.andExpect(header("X-Internal-Service-Token", "internal-token"))
|
||||
.andRespond(withSuccess("photo-bytes".getBytes(), MediaType.IMAGE_JPEG)
|
||||
.header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=door.jpg"));
|
||||
|
||||
UnloadPhotoFileGateway.DownloadedFile result = gateway.download("100", 8L, 9L);
|
||||
|
||||
assertThat(result.content()).containsExactly("photo-bytes".getBytes());
|
||||
assertThat(result.fileName()).isEqualTo("door.jpg");
|
||||
assertThat(result.contentType()).isEqualTo(MediaType.IMAGE_JPEG_VALUE);
|
||||
server.verify();
|
||||
}
|
||||
|
||||
@Test
|
||||
void missingOwnedPhotoMapsToNotFound() {
|
||||
server.expect(requestTo(
|
||||
"http://file-service/internal/file/v1/business-files/100/OVERSEAS_UNLOAD_PHOTO/8/9/content"))
|
||||
.andRespond(withStatus(HttpStatus.NOT_FOUND));
|
||||
|
||||
assertThatThrownBy(() -> gateway.download("100", 8L, 9L))
|
||||
.isInstanceOfSatisfying(ResponseStatusException.class,
|
||||
error -> assertThat(error.getStatusCode().value()).isEqualTo(404));
|
||||
server.verify();
|
||||
}
|
||||
}
|
||||
|
||||
+34
@@ -315,6 +315,11 @@ class ReceivingContainerServiceBehaviorTest {
|
||||
ReceivingPhotoRole.DOOR_OPEN);
|
||||
org.assertj.core.api.Assertions.assertThat(response.photos()).extracting("fileId")
|
||||
.containsExactly("2092476334483931138", "2", "3");
|
||||
org.assertj.core.api.Assertions.assertThat(response.photos()).extracting("downloadPath")
|
||||
.containsExactly(
|
||||
"/api/pda/v1/last-leg/receiving-containers/current/unload-photos/files/2092476334483931138/download",
|
||||
"/api/pda/v1/last-leg/receiving-containers/current/unload-photos/files/2/download",
|
||||
"/api/pda/v1/last-leg/receiving-containers/current/unload-photos/files/3/download");
|
||||
verify(unloadPhotos).validate("100", 8L, 2092476334483931138L);
|
||||
verify(unloadPhotos).validate("100", 8L, 2L);
|
||||
verify(unloadPhotos).validate("100", 8L, 3L);
|
||||
@@ -356,6 +361,35 @@ class ReceivingContainerServiceBehaviorTest {
|
||||
verifyNoInteractions(unloadPhotos);
|
||||
}
|
||||
|
||||
@Test
|
||||
void currentSignedPhotoCanBeDownloadedOnlyThroughItsOperatorCustomerAndSessionScope() {
|
||||
ReceivingContainerSession current = session("CONT-A", "SIGNED");
|
||||
current.setSignPhotos("[{\"role\":\"LICENSE_PLATE\",\"fileId\":\"9\"},"
|
||||
+ "{\"role\":\"CONTAINER_SEAL\",\"fileId\":\"10\"},"
|
||||
+ "{\"role\":\"DOOR_OPEN\",\"fileId\":\"11\"}]");
|
||||
when(sessions.findActiveByOperator("OPERATOR:7")).thenReturn(current);
|
||||
UnloadPhotoFileGateway.DownloadedFile expected =
|
||||
new UnloadPhotoFileGateway.DownloadedFile("image".getBytes(), "plate.jpg", "image/jpeg");
|
||||
when(unloadPhotos.download("100", 8L, 9L)).thenReturn(expected);
|
||||
|
||||
assertThat(service.downloadCurrentPhoto("7", "9")).isSameAs(expected);
|
||||
verify(unloadPhotos).download("100", 8L, 9L);
|
||||
}
|
||||
|
||||
@Test
|
||||
void photoOutsideCurrentSignedRoleBindingsIsForbiddenBeforeFileServiceCall() {
|
||||
ReceivingContainerSession current = session("CONT-A", "SIGNED");
|
||||
current.setSignPhotos("[{\"role\":\"LICENSE_PLATE\",\"fileId\":\"9\"},"
|
||||
+ "{\"role\":\"CONTAINER_SEAL\",\"fileId\":\"10\"},"
|
||||
+ "{\"role\":\"DOOR_OPEN\",\"fileId\":\"11\"}]");
|
||||
when(sessions.findActiveByOperator("OPERATOR:7")).thenReturn(current);
|
||||
|
||||
assertThatThrownBy(() -> service.downloadCurrentPhoto("7", "12"))
|
||||
.isInstanceOfSatisfying(ResponseStatusException.class,
|
||||
error -> assertThat(error.getStatusCode().value()).isEqualTo(403));
|
||||
verifyNoInteractions(unloadPhotos);
|
||||
}
|
||||
|
||||
@Test
|
||||
void bulkRejectsSkuOverReceiptBeforeWarehouseLookup() {
|
||||
stubCurrent("FBA", "CONT-A");
|
||||
|
||||
Reference in New Issue
Block a user