This commit is contained in:
2026-08-26 15:58:54 +08:00
parent 57a2f6ef28
commit fd1a81594a
21 changed files with 406 additions and 20 deletions
+1 -1
View File
@@ -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,不含出库和派送 |
@@ -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) {
@@ -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,
@@ -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) {}
@@ -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) {}
}
@@ -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: 状态或照片校验失败 } }
@@ -624,6 +643,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]
@@ -688,7 +714,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 }
@@ -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"));
@@ -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",
@@ -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();
}
}
@@ -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");