fix(lastleg): restore dispatch returns from immutable pick source and expose customer receipts

This commit is contained in:
2026-09-30 12:09:19 +08:00
parent c7f880300c
commit 4a14001a09
11 changed files with 293 additions and 9 deletions
@@ -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;
}
@@ -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)); }
}
@@ -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
@@ -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"));
}
}
}
@@ -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());