diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/controller/MerchantDeliveryController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/controller/MerchantDeliveryController.java index 5adc2ebe3..9c0827c27 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/controller/MerchantDeliveryController.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/controller/MerchantDeliveryController.java @@ -5,20 +5,24 @@ import com.baiyue.common.core.api.PageResult; import com.baiyue.common.security.AuthenticatedUser; import com.baiyue.lastleg.delivery.dto.DeliveryRequests; import com.baiyue.lastleg.delivery.dto.DeliveryResponses; +import com.baiyue.lastleg.delivery.dto.DeliveryDocumentDtos; +import com.baiyue.lastleg.delivery.service.DeliveryDocumentService; import com.baiyue.lastleg.delivery.service.DeliveryTransferService; import com.baiyue.lastleg.security.OverseasPermissions; import jakarta.annotation.Resource; import jakarta.validation.Valid; -import org.springframework.http.HttpStatus; +import org.springframework.http.*; import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.web.bind.annotation.*; import org.springframework.web.server.ResponseStatusException; +import java.nio.charset.StandardCharsets; @RestController @RequestMapping("/api/merchant/v1/last-leg/delivery-orders") public class MerchantDeliveryController { @Resource private DeliveryTransferService service; + @Resource private DeliveryDocumentService documents; @GetMapping @PreAuthorize("hasAuthority('" + OverseasPermissions.MERCHANT_DELIVERY_LIST + "')") @@ -42,9 +46,29 @@ public class MerchantDeliveryController { return ApiResponse.success(service.deliveryDetail(customer(user), deliveryNo)); } + @GetMapping("/{deliveryNo}/documents/receipt") + @PreAuthorize("hasAuthority('" + OverseasPermissions.MERCHANT_DELIVERY_LIST + "')") + public ApiResponse receipts( + @AuthenticationPrincipal AuthenticatedUser user, @PathVariable String deliveryNo) { + return ApiResponse.success(documents.merchantReceipts(customer(user), deliveryNo)); + } + + @GetMapping("/{deliveryNo}/documents/receipt/files/{fileId}/download") + @PreAuthorize("hasAuthority('" + OverseasPermissions.MERCHANT_DELIVERY_LIST + "')") + public ResponseEntity receiptDownload(@AuthenticationPrincipal AuthenticatedUser user, + @PathVariable String deliveryNo, @PathVariable long fileId) { + DeliveryDocumentService.Download downloaded = documents.merchantReceiptDownload(customer(user), deliveryNo, fileId); + return ResponseEntity.ok().cacheControl(CacheControl.noStore()) + .contentType(MediaType.APPLICATION_OCTET_STREAM) + .header("X-Content-Type-Options", "nosniff") + .header(HttpHeaders.CONTENT_DISPOSITION, ContentDisposition.attachment() + .filename(downloaded.file().fileName(), StandardCharsets.UTF_8).build().toString()) + .body(downloaded.content()); + } + private String customer(AuthenticatedUser user) { if (user == null || user.tenantId() == null) throw new ResponseStatusException(HttpStatus.UNAUTHORIZED); - if ("PLATFORM".equals(user.appCode()) || user.tenantId() == 0L) throw new ResponseStatusException(HttpStatus.FORBIDDEN, "Merchant identity is required"); + if (!"MERCHANT".equals(user.appCode()) || user.tenantId() <= 0L) throw new ResponseStatusException(HttpStatus.FORBIDDEN, "Merchant identity is required"); return String.valueOf(user.tenantId()); } private String operator(AuthenticatedUser user) { return user.userId() == null ? user.username() : String.valueOf(user.userId()); } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/dto/DeliveryDocumentDtos.java b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/dto/DeliveryDocumentDtos.java index 9d3e12abf..97240b477 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/dto/DeliveryDocumentDtos.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/dto/DeliveryDocumentDtos.java @@ -16,6 +16,7 @@ public final class DeliveryDocumentDtos { @NotNull @Size(max=20) List<@NotNull @Min(1) Long> fileIds) { } public record File(Long fileId, String fileName, String contentType, long sizeBytes, LocalDateTime uploadedAt) { } + public record MerchantReceipts(String deliveryNo, List files) { } public record Category(DeliveryDocumentCategory category, String status, int fileCount, List files) { } public record CategorySummary(DeliveryDocumentCategory category, String status, int fileCount) { } public record Summary(int uploadedCategoryCount, int totalCategoryCount, List categories) { } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/service/DeliveryDocumentService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/service/DeliveryDocumentService.java index 9a2b3b6a0..600911e7d 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/service/DeliveryDocumentService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/service/DeliveryDocumentService.java @@ -37,6 +37,17 @@ public class DeliveryDocumentService { public Detail detail(String customerId, String deliveryNo) { return detail(require(customerId, deliveryNo), false); } + public MerchantReceipts merchantReceipts(String customerId, String deliveryNo) { + Detail snapshot = detail(customerId, deliveryNo); + return new MerchantReceipts(snapshot.deliveryNo(), snapshot.categories().stream() + .filter(category -> category.category() == DeliveryDocumentCategory.RECEIPT) + .flatMap(category -> category.files().stream()).toList()); + } + + public Download merchantReceiptDownload(String customerId, String deliveryNo, long fileId) { + return download(customerId, deliveryNo, DeliveryDocumentCategory.RECEIPT, fileId, null); + } + public DeliveryDocumentDtos.File upload(String customerId, String deliveryNo, DeliveryDocumentCategory category, String operatorId, MultipartFile file) { DeliveryOrder order = require(customerId, deliveryNo); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/pda/service/DispatchPdaWorkflowService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/pda/service/DispatchPdaWorkflowService.java index 9d9f22994..7b64e2c43 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/pda/service/DispatchPdaWorkflowService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/pda/service/DispatchPdaWorkflowService.java @@ -82,8 +82,11 @@ public class DispatchPdaWorkflowService { public DispatchPdaResponses.Picked pick(String warehouseId, String dispatchNo, long itemId, String operatorId, String operatorName, DispatchPdaRequests.Pick request) { - DispatchOrder order = requireWarehouseOrder(warehouseId, dispatchNo); - String customerId = order.getCustomerId(); + DispatchOrder found = requireWarehouseOrder(warehouseId, dispatchNo); + String customerId = found.getCustomerId(); + // Transfer and picking serialize on the same dispatch before taking task/inventory locks. + DispatchOrder order = orders.lockById(customerId, found.getId()); + if (order == null) throw notFound("Dispatch order not found"); DispatchPdaTask task = tasks.lockByDispatch(customerId, order.getId()); if (task == null) throw conflict("Dispatch PDA task is not available for picking"); if (!warehouseId.equals(task.getWarehouseId())) throw conflict("PDA pick warehouse does not match task warehouse"); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/command/mapper/InventoryCommandMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/command/mapper/InventoryCommandMapper.java index f5b7c5668..1438b0a7b 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/command/mapper/InventoryCommandMapper.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/command/mapper/InventoryCommandMapper.java @@ -47,12 +47,24 @@ public interface InventoryCommandMapper { "AND NOT EXISTS (SELECT 1 FROM ll_inventory_position p WHERE p.balance_id=ll_inventory_balance.id)") int deleteEmptyBalance(@Param("customerId") String customerId, @Param("balanceId") Long balanceId); - @Select("SELECT COALESCE(-SUM(delta_packages),0) FROM ll_inventory_ledger " + - "WHERE customer_id=#{customerId} AND position_id=#{positionId} AND outbound_order_no=#{outboundNo} " + - "AND sku_code=#{skuCode} AND business_type='SHIP' AND delta_packages<0") + @Select("SELECT COALESCE(-SUM(l.delta_packages),0) FROM ll_inventory_ledger l " + + "JOIN ll_outbound_order o ON o.customer_id=l.customer_id AND o.outbound_no=#{outboundNo} " + + "AND o.overseas_warehouse_id=l.overseas_warehouse_id AND o.status='OUTBOUNDED' " + + "WHERE l.customer_id=#{customerId} AND l.position_id=#{positionId} " + + "AND l.sku_code=#{skuCode} AND l.business_type='SHIP' AND l.delta_packages<0 " + + "AND ((o.outbound_type='DISPATCH_DELIVERY' AND l.business_order_no=o.dispatch_no " + + "AND l.outbound_order_no=o.dispatch_no AND EXISTS (" + + "SELECT 1 FROM ll_dispatch_pda_task t JOIN ll_dispatch_pda_pick p " + + "ON p.customer_id=t.customer_id AND p.task_id=t.id " + + "WHERE t.customer_id=o.customer_id AND t.dispatch_order_id=o.dispatch_order_id " + + "AND t.warehouse_id=o.overseas_warehouse_id AND p.inventory_position_id=l.position_id " + + "AND p.packages=-l.delta_packages AND l.idempotency_key=CONCAT('DISPATCH:',o.dispatch_no,':'," + + "p.dispatch_item_id,':',p.idempotency_key,':SHIP'))) " + + "OR (o.outbound_type<>'DISPATCH_DELIVERY' AND l.outbound_order_no=o.outbound_no))") long shippedPackages(@Param("customerId") String customerId, @Param("positionId") Long positionId, @Param("outboundNo") String outboundNo, @Param("skuCode") String skuCode); + @Select("SELECT COALESCE(SUM(delta_packages),0) FROM ll_inventory_ledger " + "WHERE customer_id=#{customerId} AND source_event_id=#{sourceIdentity} " + "AND sku_code=#{skuCode} AND business_type='RETURN_STOCK_IN' AND delta_packages>0") diff --git a/lastleg-service/src/main/resources/openapi/delivery-documents-v1.yaml b/lastleg-service/src/main/resources/openapi/delivery-documents-v1.yaml index 70ce3be2c..ad82f7431 100644 --- a/lastleg-service/src/main/resources/openapi/delivery-documents-v1.yaml +++ b/lastleg-service/src/main/resources/openapi/delivery-documents-v1.yaml @@ -3,13 +3,42 @@ info: title: 派送单三类资料 version: 1.0.0 description: | - 所有入口只允许平台,customerId 必须为真实派送所属客户;不得按 BOL 文本查找或复用其他客户/派送单文件。 + 平台资料入口的customerId必须为真实派送所属客户;客户只读入口范围只取Token,只公开当前派送单已保存的RECEIPT签收文件,不提供上传、修改、历史版本、BOL或装车照片下载。 GET /delivery-orders 及 GET /delivery-orders/{deliveryNo} 的 order 行新增 bolNo 和 documentSummary,version/allowedActions 保持原契约。 RECEIPT 签收文件只有在 PUT 保存进入权威资料快照后才自动完成当前派送单;仅上传未保存不会完成。allowedActions 不再返回人工 COMPLETE_DELIVERY。 START_DELIVERY=DELIVERING(派送中);COMPLETE_DELIVERY=DELIVERED(完成并记录签收时间、生成待回访)。同状态重放不重复副作用。 资料不设必传门禁,不以三类齐全作为派送完成条件。业务失败使用统一 ApiResponse(A0422参数、A0409冲突),通常HTTP200。 security: [{ bearerAuth: [] }] paths: + /api/merchant/v1/last-leg/delivery-orders/{deliveryNo}/documents/receipt: + parameters: + - { $ref: '#/components/parameters/DeliveryNo' } + get: + summary: 查询本企业派送单签收资料 + description: 权限merchant:warehouse:delivery:list。仅当前已绑定RECEIPT文件,尚无资料返回files空数组;其他客户派送单统一不存在,不接受customerId或历史版本。 + responses: + '200': + description: 本企业签收资料或统一业务错误 + content: + application/json: + schema: + allOf: + - { $ref: '#/components/schemas/Envelope' } + - type: object + properties: { data: { $ref: '#/components/schemas/MerchantReceipts' } } + /api/merchant/v1/last-leg/delivery-orders/{deliveryNo}/documents/receipt/files/{fileId}/download: + parameters: + - { $ref: '#/components/parameters/DeliveryNo' } + - { in: path, name: fileId, required: true, schema: { type: string, pattern: '^[1-9][0-9]*$' } } + get: + summary: 下载本企业派送单当前签收文件 + description: 权限merchant:warehouse:delivery:list。服务端校验客户、派送单、当前RECEIPT绑定及File对象归属;其他客户、其他派送单、非签收类别、仅上传未保存或已解绑文件返回不存在。文件经服务端代理,附件下载、no-store、nosniff;不返回对象存储地址。 + responses: + '200': + description: 原始文件字节;业务失败为统一JSON错误 + content: + application/octet-stream: { schema: { type: string, format: binary } } + application/json: { schema: { $ref: '#/components/schemas/Envelope' } } /api/platform/v1/last-leg/delivery-orders/{deliveryNo}/documents: parameters: - { $ref: '#/components/parameters/DeliveryNo' } @@ -103,6 +132,12 @@ components: CustomerId: { in: query, name: customerId, required: true, schema: { type: string, pattern: '^[1-9][0-9]*$' } } Category: { in: path, name: category, required: true, schema: { $ref: '#/components/schemas/CategoryCode' } } schemas: + MerchantReceipts: + type: object + required: [deliveryNo, files] + properties: + deliveryNo: { type: string } + files: { type: array, maxItems: 20, items: { $ref: '#/components/schemas/File' } } CategoryCode: type: string enum: [LOADING_PHOTO, BOL, RECEIPT] diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/controller/MerchantDeliveryControllerTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/controller/MerchantDeliveryControllerTest.java index 7ff48fee5..ff88c80c8 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/controller/MerchantDeliveryControllerTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/controller/MerchantDeliveryControllerTest.java @@ -3,6 +3,8 @@ package com.baiyue.lastleg.delivery.controller; import com.baiyue.common.security.AuthenticatedUser; import com.baiyue.lastleg.delivery.dto.DeliveryRequests; import com.baiyue.lastleg.delivery.service.DeliveryTransferService; +import com.baiyue.lastleg.delivery.service.DeliveryDocumentService; +import com.baiyue.lastleg.delivery.dto.DeliveryDocumentDtos; import com.baiyue.lastleg.security.OverseasPermissions; import jakarta.validation.Validation; import org.junit.jupiter.api.Test; @@ -29,9 +31,11 @@ import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; class MerchantDeliveryControllerTest { private final DeliveryTransferService service = mock(DeliveryTransferService.class); + private final DeliveryDocumentService documents = mock(DeliveryDocumentService.class); private final MerchantDeliveryController controller = controller(); private final DeliveryRequests.Query query = new DeliveryRequests.Query( 1, 20, null, null, null, null, null, null, null, null, null, null, null, null); @@ -46,14 +50,20 @@ class MerchantDeliveryControllerTest { Method create = MerchantDeliveryController.class.getMethod("create", AuthenticatedUser.class, String.class, DeliveryRequests.Transfer.class); Method detail = MerchantDeliveryController.class.getMethod("detail", AuthenticatedUser.class, String.class); + Method receipts = MerchantDeliveryController.class.getMethod("receipts", AuthenticatedUser.class, String.class); + Method download = MerchantDeliveryController.class.getMethod("receiptDownload", AuthenticatedUser.class, String.class, long.class); assertPermission(page, OverseasPermissions.MERCHANT_DELIVERY_LIST); assertPermission(detail, OverseasPermissions.MERCHANT_DELIVERY_LIST); + assertPermission(receipts, OverseasPermissions.MERCHANT_DELIVERY_LIST); + assertPermission(download, OverseasPermissions.MERCHANT_DELIVERY_LIST); assertPermission(create, OverseasPermissions.MERCHANT_DELIVERY_CREATE); assertNotEquals(OverseasPermissions.MERCHANT_DELIVERY_LIST, OverseasPermissions.MERCHANT_DELIVERY_CREATE); assertTrue(Arrays.stream(page.getParameters()).noneMatch(this::isCustomerParameter)); assertTrue(Arrays.stream(detail.getParameters()).noneMatch(this::isCustomerParameter)); + assertTrue(Arrays.stream(receipts.getParameters()).noneMatch(this::isCustomerParameter)); + assertTrue(Arrays.stream(download.getParameters()).noneMatch(this::isCustomerParameter)); assertTrue(Arrays.stream(create.getParameters()).noneMatch(this::isCustomerParameter)); assertTrue(Arrays.stream(DeliveryRequests.Transfer.class.getRecordComponents()) .noneMatch(component -> "customerId".equals(component.getName()))); @@ -80,6 +90,36 @@ class MerchantDeliveryControllerTest { assertThrows(ResponseStatusException.class, () -> controller.page(platform, query)); assertThrows(ResponseStatusException.class, () -> controller.detail(platform, "DEL-100")); assertThrows(ResponseStatusException.class, () -> controller.create(platform, "DSP-100", transferRequest())); + assertThrows(ResponseStatusException.class, () -> controller.receipts(platform, "DEL-100")); + assertThrows(ResponseStatusException.class, () -> controller.receiptDownload(platform, "DEL-100", 13)); + } + + @Test + void merchantReceiptDownloadKeepsTokenScopeAndReturnsSafeAttachmentBytes() { + var file = new DeliveryDocumentDtos.File(13L, "签收.pdf", "application/pdf", 2, LocalDateTime.now()); + when(documents.merchantReceiptDownload("100","DEL-100",13)) + .thenReturn(new DeliveryDocumentService.Download(file, new byte[]{3,4})); + controller.receipts(user(100L,"MERCHANT"),"DEL-100"); + var response = controller.receiptDownload(user(100L,"MERCHANT"),"DEL-100",13); + verify(documents).merchantReceipts("100","DEL-100"); + verify(documents).merchantReceiptDownload("100","DEL-100",13); + assertTrue(Arrays.equals(new byte[]{3,4},response.getBody())); + assertEquals("nosniff",response.getHeaders().getFirst("X-Content-Type-Options")); + assertEquals("no-store",response.getHeaders().getCacheControl()); + assertTrue(response.getHeaders().getContentDisposition().isAttachment()); + } + + @Test + void receiptReadsRejectMissingIdentityInvalidCustomerAndNonMerchantBeforeServiceAccess() { + assertEquals(401, assertThrows(ResponseStatusException.class, + () -> controller.receipts(null,"DEL-100")).getStatusCode().value()); + for (AuthenticatedUser invalid : List.of(user(-1L,"MERCHANT"), user(100L,"PLATFORM"), user(100L,"PDA"))) { + assertEquals(403, assertThrows(ResponseStatusException.class, + () -> controller.receipts(invalid,"DEL-100")).getStatusCode().value()); + assertEquals(403, assertThrows(ResponseStatusException.class, + () -> controller.receiptDownload(invalid,"DEL-100",13)).getStatusCode().value()); + } + org.mockito.Mockito.verifyNoInteractions(documents); } @Test @@ -101,6 +141,15 @@ class MerchantDeliveryControllerTest { assertTrue(contract.contains("merchant:warehouse:delivery:list")); assertTrue(contract.contains("merchant:warehouse:delivery:create")); assertFalse(contract.contains("name: customerId")); + String receiptContract = Files.readString(Path.of("src/main/resources/openapi/delivery-documents-v1.yaml")); + String merchantPaths = slice(receiptContract, + " /api/merchant/v1/last-leg/delivery-orders/{deliveryNo}/documents/receipt:", + " /api/platform/v1/last-leg/delivery-orders/{deliveryNo}/documents:"); + assertTrue(merchantPaths.contains("merchant:warehouse:delivery:list")); + assertTrue(merchantPaths.contains("/documents/receipt/files/{fileId}/download:")); + assertFalse(merchantPaths.contains("#/components/parameters/CustomerId")); + assertFalse(merchantPaths.contains("\n post:")); + assertFalse(merchantPaths.contains("\n put:")); } private boolean isCustomerParameter(java.lang.reflect.Parameter parameter) { @@ -128,6 +177,7 @@ class MerchantDeliveryControllerTest { private MerchantDeliveryController controller() { MerchantDeliveryController value = new MerchantDeliveryController(); ReflectionTestUtils.setField(value, "service", service); + ReflectionTestUtils.setField(value, "documents", documents); return value; } diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/service/DeliveryDocumentServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/service/DeliveryDocumentServiceTest.java index f28759248..6eadd68a1 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/service/DeliveryDocumentServiceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/service/DeliveryDocumentServiceTest.java @@ -151,6 +151,44 @@ class DeliveryDocumentServiceTest { assertThat(operation.getAllValues()).extracting(DeliveryOperation::getOperationNode) .containsExactly("SAVE_DOCUMENTS", "RECEIPT_AUTO_COMPLETE"); } + @Test void merchantReadsOnlyCurrentReceiptFilesAndDownloadsWithAggregateOwnershipChecks() throws Exception { + order.setDocumentsJson(new ObjectMapper().findAndRegisterModules().writeValueAsString(List.of( + new Category(BOL,"UPLOADED",1,List.of(file(11))), + new Category(RECEIPT,"UPLOADED",1,List.of(file(13)))))); + when(orders.findByNo("100","DL-1")).thenReturn(order); + when(files.metadata("100",10,RECEIPT,13)).thenReturn(file(13)); + when(files.download("100",10,RECEIPT,13)).thenReturn(new byte[]{3,4}); + assertThat(service.merchantReceipts("100","DL-1").files()).containsExactly(file(13)); + assertThat(service.merchantReceiptDownload("100","DL-1",13).content()).containsExactly((byte)3,(byte)4); + assertThatThrownBy(()->service.merchantReceiptDownload("100","DL-1",11)) + .isInstanceOf(ResponseStatusException.class); + assertThatThrownBy(()->service.merchantReceiptDownload("100","DL-1",99)) + .isInstanceOf(ResponseStatusException.class); + assertThatThrownBy(()->service.merchantReceipts("200","DL-1")) + .isInstanceOf(ResponseStatusException.class); + assertThatThrownBy(()->service.merchantReceiptDownload("200","DL-1",13)) + .isInstanceOf(ResponseStatusException.class); + verify(files,never()).download("100",10,RECEIPT,11); + verifyNoInteractions(revisions,operations); + } + @Test void merchantDoesNotExposeUploadedUnboundOrHistoricalRemovedReceipts() throws Exception { + when(orders.findByNo("100","DL-1")).thenReturn(order); + assertThat(service.merchantReceipts("100","DL-1").files()).isEmpty(); + assertThatThrownBy(()->service.merchantReceiptDownload("100","DL-1",13)) + .isInstanceOf(ResponseStatusException.class); + verifyNoInteractions(files,revisions,operations); + } + @Test void merchantReceiptDownloadRejectsFileOwnershipFailureBeforeFetchingBytes() throws Exception { + order.setDocumentsJson(new ObjectMapper().findAndRegisterModules().writeValueAsString(List.of( + new Category(RECEIPT,"UPLOADED",1,List.of(file(13)))))); + when(orders.findByNo("100","DL-1")).thenReturn(order); + when(files.metadata("100",10,RECEIPT,13)).thenThrow( + new ResponseStatusException(HttpStatus.BAD_REQUEST,"文件不属于当前客户、派送单或资料类别")); + assertThatThrownBy(()->service.merchantReceiptDownload("100","DL-1",13)) + .isInstanceOf(ResponseStatusException.class).hasMessageContaining("当前客户"); + verify(files,never()).download(anyString(),anyLong(),any(),anyLong()); + verifyNoInteractions(revisions,operations); + } private Save request(long version,String key,List bindings) { return new Save(version,key,"BOL-1",bindings); } private File file(long id) { return new File(id,"a.pdf","application/pdf",1,LocalDateTime.of(2026,9,9,8,0)); } } diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/pda/service/DispatchPdaWorkflowServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/pda/service/DispatchPdaWorkflowServiceTest.java index 5b0ed594c..c7945e1e8 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/pda/service/DispatchPdaWorkflowServiceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/pda/service/DispatchPdaWorkflowServiceTest.java @@ -162,6 +162,7 @@ class DispatchPdaWorkflowServiceTest { when(orders.findGlobalByDispatchNo("DSP-1")).thenReturn(order); when(tasks.lockByDispatch("100", 1L)).thenReturn(task); when(operations.findKey("100", "pick-1")).thenReturn(replay); + when(orders.lockById("100", 1L)).thenReturn(order); DispatchPdaResponses.Picked result = service.pick("WH-1", "DSP-1", 21L, "7", "Picker", request); @@ -169,6 +170,10 @@ class DispatchPdaWorkflowServiceTest { verify(inventory, never()).allocate(any(), any(), any()); verify(picks, never()).insert((DispatchPdaPick) any()); verify(items, never()).bindReceivedPallets(any(), any()); + var locks = org.mockito.Mockito.inOrder(orders, tasks); + locks.verify(orders).findGlobalByDispatchNo("DSP-1"); + locks.verify(orders).lockById("100", 1L); + locks.verify(tasks).lockByDispatch("100", 1L); } @Test diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/inventory/command/DispatchShipmentSourceQueryTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/inventory/command/DispatchShipmentSourceQueryTest.java new file mode 100644 index 000000000..f96cd1be2 --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/inventory/command/DispatchShipmentSourceQueryTest.java @@ -0,0 +1,103 @@ +package com.baiyue.lastleg.inventory.command; + +import com.baiyue.lastleg.inventory.command.mapper.InventoryCommandMapper; +import org.apache.ibatis.annotations.Select; +import org.junit.jupiter.api.Test; + +import java.sql.Connection; +import java.sql.DriverManager; +import java.util.UUID; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +/** Execute the production source query rather than mocking a successful source lookup. */ +class DispatchShipmentSourceQueryTest { + private Connection database() throws Exception { + Connection connection = DriverManager.getConnection("jdbc:h2:mem:" + UUID.randomUUID() + ";MODE=MySQL"); + execute(connection, "CREATE TABLE ll_inventory_ledger (id BIGINT PRIMARY KEY, customer_id VARCHAR, position_id BIGINT, overseas_warehouse_id VARCHAR, sku_code VARCHAR, business_type VARCHAR, delta_packages BIGINT, business_order_no VARCHAR, outbound_order_no VARCHAR, idempotency_key VARCHAR)"); + execute(connection, "CREATE TABLE ll_outbound_order (id BIGINT PRIMARY KEY, customer_id VARCHAR, outbound_no VARCHAR, overseas_warehouse_id VARCHAR, status VARCHAR, outbound_type VARCHAR, dispatch_no VARCHAR, dispatch_order_id BIGINT)"); + execute(connection, "CREATE TABLE ll_dispatch_pda_task (id BIGINT PRIMARY KEY, customer_id VARCHAR, warehouse_id VARCHAR, dispatch_order_id BIGINT)"); + execute(connection, "CREATE TABLE ll_dispatch_pda_pick (id BIGINT PRIMARY KEY, customer_id VARCHAR, task_id BIGINT, dispatch_item_id BIGINT, inventory_position_id BIGINT, packages BIGINT, idempotency_key VARCHAR)"); + execute(connection, "INSERT INTO ll_dispatch_pda_task VALUES (11,'100','WH-1',1)"); + execute(connection, "INSERT INTO ll_dispatch_pda_pick VALUES (21,'100',11,31,41,8,'pick-1')"); + return connection; + } + + private void execute(Connection connection, String sql) throws Exception { + try (var statement = connection.createStatement()) { statement.execute(sql); } + } + + private long shipped(Connection connection, String customerId, long positionId, String outboundNo, String sku) throws Exception { + String sql = String.join(" ", InventoryCommandMapper.class.getMethod("shippedPackages", + String.class, Long.class, String.class, String.class).getAnnotation(Select.class).value()) + .replace("#{outboundNo}", "?").replace("#{customerId}", "?") + .replace("#{positionId}", "?").replace("#{skuCode}", "?"); + try (var statement = connection.prepareStatement(sql)) { + statement.setString(1, outboundNo); statement.setString(2, customerId); + statement.setLong(3, positionId); statement.setString(4, sku); + try (var result = statement.executeQuery()) { result.next(); return result.getLong(1); } + } + } + + private void scan(Connection connection) throws Exception { + execute(connection, "INSERT INTO ll_inventory_ledger VALUES (51,'100',41,'WH-1','SKU-1','SHIP',-8,'DS-1','DS-1','DISPATCH:DS-1:31:pick-1:SHIP')"); + } + + private void transfer(Connection connection) throws Exception { + execute(connection, "INSERT INTO ll_outbound_order VALUES (61,'100','OB-1','WH-1','OUTBOUNDED','DISPATCH_DELIVERY','DS-1',1)"); + } + + @Test + void resolvesOriginalScanFactsForBothTransferOrdersWithoutChangingTheLedger() throws Exception { + for (boolean scanFirst : new boolean[]{true, false}) { + try (Connection connection = database()) { + if (scanFirst) { scan(connection); transfer(connection); } + else { transfer(connection); scan(connection); } + assertEquals(8, shipped(connection, "100", 41, "OB-1", "SKU-1")); + assertEquals(8, shipped(connection, "100", 41, "OB-1", "SKU-1")); + try (var statement = connection.createStatement(); var rows = statement.executeQuery("SELECT outbound_order_no, delta_packages FROM ll_inventory_ledger")) { + rows.next(); assertEquals("DS-1", rows.getString(1)); assertEquals(-8, rows.getLong(2)); + } + } + } + } + + @Test + void requiresExactCustomerWarehousePositionSkuAndPickCommandIdentity() throws Exception { + try (Connection connection = database()) { + scan(connection); transfer(connection); + assertEquals(0, shipped(connection, "200", 41, "OB-1", "SKU-1")); + assertEquals(0, shipped(connection, "100", 42, "OB-1", "SKU-1")); + assertEquals(0, shipped(connection, "100", 41, "OB-1", "SKU-2")); + assertEquals(0, shipped(connection, "100", 41, "OB-2", "SKU-1")); + execute(connection, "UPDATE ll_dispatch_pda_pick SET idempotency_key='different-command'"); + assertEquals(0, shipped(connection, "100", 41, "OB-1", "SKU-1")); + execute(connection, "UPDATE ll_dispatch_pda_pick SET idempotency_key='pick-1', packages=9"); + assertEquals(0, shipped(connection, "100", 41, "OB-1", "SKU-1")); + execute(connection, "UPDATE ll_dispatch_pda_pick SET packages=8"); + execute(connection, "UPDATE ll_dispatch_pda_task SET customer_id='200'"); + assertEquals(0, shipped(connection, "100", 41, "OB-1", "SKU-1")); + execute(connection, "UPDATE ll_dispatch_pda_task SET customer_id='100',warehouse_id='WH-2'"); + assertEquals(0, shipped(connection, "100", 41, "OB-1", "SKU-1")); + execute(connection, "UPDATE ll_dispatch_pda_task SET warehouse_id='WH-1',dispatch_order_id=2"); + assertEquals(0, shipped(connection, "100", 41, "OB-1", "SKU-1")); + execute(connection, "UPDATE ll_dispatch_pda_task SET dispatch_order_id=1"); + execute(connection, "UPDATE ll_outbound_order SET status='WAIT_OUTBOUND'"); + assertEquals(0, shipped(connection, "100", 41, "OB-1", "SKU-1")); + execute(connection, "UPDATE ll_outbound_order SET status='OUTBOUNDED'"); + execute(connection, "UPDATE ll_outbound_order SET overseas_warehouse_id='WH-2'"); + assertEquals(0, shipped(connection, "100", 41, "OB-1", "SKU-1")); + } + } + + @Test + void independentOutboundUsesItsOwnNumberAndDoesNotAcceptADispatchAlias() throws Exception { + try (Connection connection = database()) { + scan(connection); + execute(connection, "INSERT INTO ll_outbound_order VALUES (61,'100','OB-1','WH-1','OUTBOUNDED','WAREHOUSE_TRANSFER','DS-1',1)"); + assertEquals(0, shipped(connection, "100", 41, "OB-1", "SKU-1")); + execute(connection, "INSERT INTO ll_inventory_ledger VALUES (52,'100',41,'WH-1','SKU-1','SHIP',-3,'TRANSFER-1','OB-1','transfer-ship-1')"); + assertEquals(3, shipped(connection, "100", 41, "OB-1", "SKU-1")); + } + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/inventory/command/InventoryCommandServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/inventory/command/InventoryCommandServiceTest.java index 1dee12878..8184fccfd 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/inventory/command/InventoryCommandServiceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/inventory/command/InventoryCommandServiceTest.java @@ -432,7 +432,9 @@ class InventoryCommandServiceTest { String shippedSql = String.join(" ", InventoryCommandMapper.class.getMethod("shippedPackages", String.class, Long.class, String.class, String.class).getAnnotation(Select.class).value()); assertTrue(shippedSql.contains("position_id=#{positionId}")); - assertTrue(shippedSql.contains("outbound_order_no=#{outboundNo}")); + assertTrue(shippedSql.contains("o.outbound_no=#{outboundNo}")); + assertTrue(shippedSql.contains("p.inventory_position_id=l.position_id")); + assertTrue(shippedSql.contains("p.idempotency_key,':SHIP'")); assertTrue(shippedSql.contains("business_type='SHIP'")); String restoredSql = String.join(" ", InventoryCommandMapper.class.getMethod("restoredPackages", String.class, String.class, String.class).getAnnotation(Select.class).value());