fix(lastleg): restore dispatch returns from immutable pick source and expose customer receipts
This commit is contained in:
+26
-2
@@ -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<DeliveryDocumentDtos.MerchantReceipts> 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<byte[]> 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()); }
|
||||
|
||||
+1
@@ -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<File> files) { }
|
||||
public record Category(DeliveryDocumentCategory category, String status, int fileCount, List<File> files) { }
|
||||
public record CategorySummary(DeliveryDocumentCategory category, String status, int fileCount) { }
|
||||
public record Summary(int uploadedCategoryCount, int totalCategoryCount, List<CategorySummary> categories) { }
|
||||
|
||||
+11
@@ -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);
|
||||
|
||||
+5
-2
@@ -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");
|
||||
|
||||
+15
-3
@@ -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")
|
||||
|
||||
@@ -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]
|
||||
|
||||
+50
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
+38
@@ -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<Binding> 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)); }
|
||||
}
|
||||
|
||||
+5
@@ -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
|
||||
|
||||
+103
@@ -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"));
|
||||
}
|
||||
}
|
||||
}
|
||||
+3
-1
@@ -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());
|
||||
|
||||
Reference in New Issue
Block a user