feat(fleet): enforce reviewed trips and original fee coverage for dispatch
This commit is contained in:
+103
@@ -0,0 +1,103 @@
|
||||
package com.baiyue.lastleg.delivery;
|
||||
|
||||
import com.baiyue.common.core.api.ApiResponse;
|
||||
import com.baiyue.common.security.AuthenticatedUser;
|
||||
import com.baiyue.lastleg.client.FinanceFeeCoverageClient;
|
||||
import com.baiyue.lastleg.delivery.controller.PlatformDeliveryFeeCoverageController;
|
||||
import com.baiyue.lastleg.delivery.dto.DeliveryFeeCoverageModels;
|
||||
import com.baiyue.lastleg.delivery.dto.DeliveryRequests;
|
||||
import com.baiyue.lastleg.delivery.mapper.DispatchFeeCargoMapper;
|
||||
import com.baiyue.lastleg.delivery.service.DeliveryFeeCoverageService;
|
||||
import com.baiyue.lastleg.dispatch.entity.DispatchItem;
|
||||
import com.baiyue.lastleg.dispatch.entity.DispatchOrder;
|
||||
import com.baiyue.lastleg.dispatch.mapper.DispatchItemMapper;
|
||||
import com.baiyue.lastleg.dispatch.mapper.DispatchOrderMapper;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.security.access.prepost.PreAuthorize;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
import java.math.BigDecimal;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
import static org.assertj.core.api.Assertions.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
class DeliveryFeeCoverageServiceTest {
|
||||
final DispatchOrderMapper dispatches = mock(DispatchOrderMapper.class);
|
||||
final DispatchItemMapper items = mock(DispatchItemMapper.class);
|
||||
final DispatchFeeCargoMapper cargo = mock(DispatchFeeCargoMapper.class);
|
||||
final FinanceFeeCoverageClient finance = mock(FinanceFeeCoverageClient.class);
|
||||
final DeliveryFeeCoverageService service = new DeliveryFeeCoverageService(dispatches, items, cargo, finance,
|
||||
new ObjectMapper().findAndRegisterModules(), "coverage-test-token");
|
||||
@BeforeEach void setup() {
|
||||
var dispatch = new DispatchOrder(); dispatch.setId(1L); dispatch.setCustomerId("101"); dispatch.setDispatchNo("DP-1");
|
||||
when(dispatches.findGlobalByDispatchNo("DP-1")).thenReturn(dispatch);
|
||||
var selected = new DispatchItem(); selected.setId(201L); selected.setPackageCount(20);
|
||||
when(items.findByDispatchOrder("101", 1L)).thenReturn(List.of(selected));
|
||||
when(cargo.list("101", 1)).thenReturn(List.of(source("11", "IN-A", 10), source("12", "IN-B", 10)));
|
||||
}
|
||||
@Test void batchesBothActualInboundSourcesInsteadOfUsingOneDisplayCode() {
|
||||
var query = new FinanceFeeCoverageClient.Query(101L, List.of(11L, 12L));
|
||||
when(finance.sources("coverage-test-token", query)).thenReturn(ApiResponse.success(List.of(fee(101, 11))));
|
||||
var result = service.preview("DP-1");
|
||||
assertThat(result.sourceComplete()).isTrue(); assertThat(result.cargo()).hasSize(2); assertThat(result.feeSources()).hasSize(1);
|
||||
assertThat(result.cargoHash()).hasSize(64);
|
||||
verify(finance).sources("coverage-test-token", query); verifyNoMoreInteractions(finance);
|
||||
}
|
||||
@Test void incompletePhysicalSourcesNeverProduceAFalseCoverageOption() {
|
||||
when(cargo.list("101", 1)).thenReturn(List.of(source("11", "IN-A", 10)));
|
||||
var result = service.preview("DP-1");
|
||||
assertThat(result.sourceComplete()).isFalse(); assertThat(result.blockReason()).contains("箱数不一致");
|
||||
assertThat(result.feeSources()).isEmpty(); verifyNoInteractions(finance);
|
||||
}
|
||||
@Test void unavailableOrWrongScopeFinanceResultsFailClosed() {
|
||||
when(finance.sources(anyString(), any())).thenReturn(ApiResponse.success(List.of(fee(202, 11))));
|
||||
assertThatThrownBy(() -> service.preview("DP-1")).isInstanceOf(ResponseStatusException.class).hasMessageContaining("503");
|
||||
when(finance.sources(anyString(), any())).thenReturn(ApiResponse.success(List.of(fee(101, 999))));
|
||||
assertThatThrownBy(() -> service.preview("DP-1")).isInstanceOf(ResponseStatusException.class);
|
||||
when(finance.sources(anyString(), any())).thenReturn(null);
|
||||
assertThatThrownBy(() -> service.preview("DP-1")).isInstanceOf(ResponseStatusException.class);
|
||||
}
|
||||
@Test void platformReadonlyEndpointDoesNotAcceptMerchantIdentityOrAClientSuppliedCustomer() throws Exception {
|
||||
var controller = new PlatformDeliveryFeeCoverageController(service);
|
||||
var merchant = new AuthenticatedUser(7L, 101L, 101L, 8L, "merchant", "MERCHANT", "WEB", Set.of());
|
||||
assertThatThrownBy(() -> controller.preview(merchant, "DP-1")).isInstanceOf(ResponseStatusException.class).hasMessageContaining("403");
|
||||
assertThatThrownBy(() -> controller.preview(null, "DP-1")).isInstanceOf(ResponseStatusException.class).hasMessageContaining("401");
|
||||
var method = PlatformDeliveryFeeCoverageController.class.getMethod("preview", AuthenticatedUser.class, String.class);
|
||||
assertThat(method.getAnnotation(PreAuthorize.class).value()).contains("platform:overseas-warehouse:dispatch:list");
|
||||
verifyNoInteractions(dispatches, items, cargo, finance);
|
||||
}
|
||||
@Test void onlyFullyCoveredServicesCanRemoveANewChargeAndTheSnapshotKeepsEveryActualSource() {
|
||||
when(finance.sources(anyString(), any())).thenReturn(ApiResponse.success(List.of(fee(101,11), fee(101,12))));
|
||||
var preview = service.preview("DP-1");
|
||||
var a = new DeliveryFeeCoverageModels.Selection(711L, 1, fee(101,11).sourceHash(), List.of("DELIVERY_FEE", "LOADING_FEE"));
|
||||
var partial = service.confirm("DP-1", coverageRequest(preview.cargoHash(), List.of(a)), "OP-1", Set.of());
|
||||
assertThat(partial.includedServices()).isEmpty();
|
||||
assertThat(partial.uncovered("DELIVERY_FEE")).extracting(DeliveryFeeCoverageModels.Cargo::sourceInboundId).containsExactly("12");
|
||||
var b = new DeliveryFeeCoverageModels.Selection(712L, 1, fee(101,12).sourceHash(), List.of("DELIVERY_FEE", "LOADING_FEE"));
|
||||
var complete = service.confirm("DP-1", coverageRequest(preview.cargoHash(), List.of(a,b)), "OP-1", Set.of());
|
||||
assertThat(complete.includedServices()).containsExactlyInAnyOrder("DELIVERY_FEE", "LOADING_FEE");
|
||||
assertThat(complete.evidence().cargo()).hasSize(2); assertThat(complete.evidence().references()).hasSize(2);
|
||||
assertThat(complete.evidence().operatorId()).isEqualTo("OP-1");
|
||||
}
|
||||
@Test void staleCargoFeeVersionAndRepeatedCoverageAreRejected() {
|
||||
when(finance.sources(anyString(), any())).thenReturn(ApiResponse.success(List.of(fee(101,11))));
|
||||
var preview = service.preview("DP-1");
|
||||
var selected = new DeliveryFeeCoverageModels.Selection(711L, 1, fee(101,11).sourceHash(), List.of("DELIVERY_FEE"));
|
||||
assertThatThrownBy(() -> service.confirm("DP-1", coverageRequest("old-cargo", List.of(selected)), "OP-1", Set.of())).hasMessageContaining("货物已变化");
|
||||
var old = new DeliveryFeeCoverageModels.Selection(711L, 2, selected.sourceHash(), selected.services());
|
||||
assertThatThrownBy(() -> service.confirm("DP-1", coverageRequest(preview.cargoHash(), List.of(old)), "OP-1", Set.of())).hasMessageContaining("版本已变化");
|
||||
assertThatThrownBy(() -> service.confirm("DP-1", coverageRequest(preview.cargoHash(), List.of(selected, selected)), "OP-1", Set.of())).hasMessageContaining("只能引用一项");
|
||||
}
|
||||
static DeliveryRequests.Transfer coverageRequest(String hash, List<DeliveryFeeCoverageModels.Selection> selections) {
|
||||
return new ObjectMapper().findAndRegisterModules().convertValue(java.util.Map.of("feeCoverageCargoHash", hash, "feeCoverage", selections), DeliveryRequests.Transfer.class);
|
||||
}
|
||||
static DeliveryFeeCoverageModels.Cargo source(String id, String code, long packages) {
|
||||
return new DeliveryFeeCoverageModels.Cargo(201L, 501L, "PLT-501", Long.valueOf(id), "FIRSTLEG", id, code, packages, "IN-A");
|
||||
}
|
||||
static FinanceFeeCoverageClient.Fee fee(long customer, long inbound) {
|
||||
return new FinanceFeeCoverageClient.Fee(700L + inbound, 800L + inbound, "INBOUND_FEE", 1, customer, inbound,
|
||||
"IN-A", "FREIGHT", "原运费", "CNY", BigDecimal.TEN, "sha256:" + "a".repeat(64), "AVAILABLE");
|
||||
}
|
||||
}
|
||||
+3
-3
@@ -34,9 +34,9 @@ class DeliveryLongIdJsonContractTest {
|
||||
deliveryRow(),
|
||||
List.of(),
|
||||
List.of(new DeliveryResponses.DeliveryFeeRow(
|
||||
FEE_ID, "RECEIVABLE", "DELIVERY_FEE", "USD", null, null)),
|
||||
FEE_ID, "RECEIVABLE", "DELIVERY_FEE", "USD", null, null, "APPROVED", "ACTIVE", "NOT_REQUESTED")),
|
||||
List.of(new DeliveryResponses.DeliveryOperationRow(
|
||||
OPERATION_ID, null, "CREATED", "tester", "success")));
|
||||
OPERATION_ID, null, "CREATED", "tester", "success")), null);
|
||||
|
||||
JsonNode json = objectMapper.readTree(objectMapper.writeValueAsString(detail));
|
||||
|
||||
@@ -67,7 +67,7 @@ class DeliveryLongIdJsonContractTest {
|
||||
null, null, "PS-1", null, null, null, null, "CREATED", null,
|
||||
null, null, null, null, null, null, null, null, null, null, null,
|
||||
0, 0, null, null, null, null, null, null, null, null, 0, java.util.List.of(), null,
|
||||
com.baiyue.lastleg.delivery.model.DeliveryDocumentSnapshot.summary(null), null, null, null,
|
||||
com.baiyue.lastleg.delivery.service.DeliveryDocumentSnapshot.summary(null), null, null, null,
|
||||
null, null, null, null, null);
|
||||
}
|
||||
}
|
||||
|
||||
+12
@@ -17,6 +17,18 @@ import static org.junit.jupiter.api.Assertions.assertNotNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
class DeliverySchemaContractTest {
|
||||
@Test
|
||||
void shipmentSnapshotsKeepUnknownPhysicalWeightNullableAcrossBothBaselines() throws Exception {
|
||||
for (Path path : java.util.List.of(Path.of("src/main/resources/db/lastleg.sql"), Path.of("../deploy/sql/海外区域统一增量-MySQL8.sql"))) {
|
||||
String sql = Files.readString(path, StandardCharsets.UTF_8);
|
||||
for (String table : java.util.List.of("ll_dispatch_item", "ll_outbound_order", "ll_outbound_pallet_item", "ll_delivery_order")) {
|
||||
var matcher = java.util.regex.Pattern.compile("(?is)CREATE TABLE(?: IF NOT EXISTS)? " + table + " \\(.*?;").matcher(sql);
|
||||
assertTrue(matcher.find(), table);
|
||||
String column = "ll_delivery_order".equals(table) ? "total_weight_kg" : "gross_weight_kg";
|
||||
assertTrue(java.util.regex.Pattern.compile("(?i)" + column + " DECIMAL\\(18,[34]\\) NULL COMMENT").matcher(matcher.group()).find(), table + " keeps unknown weight NULL");
|
||||
}
|
||||
}
|
||||
}
|
||||
@Test
|
||||
void baselineDefinesOutboundPalletsAndDeliveryContracts() throws Exception {
|
||||
String schema = Files.readString(Path.of("src/main/resources/db/lastleg.sql"), StandardCharsets.UTF_8)
|
||||
|
||||
+50
@@ -0,0 +1,50 @@
|
||||
package com.baiyue.lastleg.delivery;
|
||||
|
||||
import com.baomidou.mybatisplus.core.MybatisConfiguration;
|
||||
import com.baomidou.mybatisplus.extension.spring.MybatisSqlSessionFactoryBean;
|
||||
import com.baiyue.lastleg.delivery.mapper.DispatchFeeCargoMapper;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.mybatis.spring.SqlSessionTemplate;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.jdbc.datasource.DriverManagerDataSource;
|
||||
import java.util.UUID;
|
||||
import static org.assertj.core.api.Assertions.*;
|
||||
|
||||
class DispatchFeeCargoMapperTest {
|
||||
JdbcTemplate jdbc; DispatchFeeCargoMapper mapper;
|
||||
@BeforeEach void setup() throws Exception {
|
||||
var dataSource = new DriverManagerDataSource("jdbc:h2:mem:" + UUID.randomUUID() + ";MODE=MySQL;DB_CLOSE_DELAY=-1", "sa", "");
|
||||
jdbc = new JdbcTemplate(dataSource);
|
||||
jdbc.execute("CREATE TABLE ll_dispatch_item(id BIGINT,dispatch_order_id BIGINT,customer_id VARCHAR(64),warehouse_pallet_id BIGINT,pallet_no VARCHAR(64),overseas_warehouse_id VARCHAR(64),plan_pallet_id BIGINT,inbound_code VARCHAR(64))");
|
||||
jdbc.execute("CREATE TABLE ll_inventory_position(id BIGINT,customer_id VARCHAR(64),pallet_id BIGINT,overseas_warehouse_id VARCHAR(64),balance_id BIGINT,available_packages BIGINT)");
|
||||
jdbc.execute("CREATE TABLE ll_inventory_balance(id BIGINT,customer_id VARCHAR(64),inbound_order_id BIGINT,inbound_code VARCHAR(64))");
|
||||
jdbc.execute("CREATE TABLE ll_order(id BIGINT,customer_id VARCHAR(64),source_region VARCHAR(32),source_inbound_id VARCHAR(64))");
|
||||
jdbc.execute("CREATE TABLE ll_dispatch_pda_task(id BIGINT,customer_id VARCHAR(64),dispatch_order_id BIGINT)");
|
||||
jdbc.execute("CREATE TABLE ll_dispatch_pda_pick(task_id BIGINT,customer_id VARCHAR(64),dispatch_item_id BIGINT,inventory_position_id BIGINT,packages BIGINT)");
|
||||
jdbc.execute("CREATE TABLE ll_inbound_plan_pallet_allocation(pallet_id BIGINT,line_id BIGINT)");
|
||||
jdbc.execute("CREATE TABLE ll_inbound_plan_line(id BIGINT,customer_id VARCHAR(64),order_id BIGINT)");
|
||||
jdbc.update("INSERT INTO ll_dispatch_item VALUES(201,1,'101',501,'PLT-501','WH-1',NULL,'IN-A')");
|
||||
jdbc.update("INSERT INTO ll_inventory_position VALUES(101,'101',501,'WH-1',301,8),(102,'101',501,'WH-1',302,6),(103,'202',501,'WH-1',303,50),(104,'101',501,'WH-2',301,50)");
|
||||
jdbc.update("INSERT INTO ll_inventory_balance VALUES(301,'101',401,'IN-A'),(302,'101',402,'IN-B'),(303,'202',403,'IN-OTHER')");
|
||||
jdbc.update("INSERT INTO ll_order VALUES(401,'101','FIRSTLEG','11'),(402,'101','FIRSTLEG','12'),(403,'202','FIRSTLEG','13')");
|
||||
jdbc.update("INSERT INTO ll_dispatch_pda_task VALUES(601,'101',1),(602,'101',2)");
|
||||
jdbc.update("INSERT INTO ll_dispatch_pda_pick VALUES(601,'101',201,101,2),(601,'101',201,102,4),(602,'101',201,101,50)");
|
||||
var config = new MybatisConfiguration(); config.addMapper(DispatchFeeCargoMapper.class);
|
||||
var factory = new MybatisSqlSessionFactoryBean(); factory.setDataSource(dataSource); factory.setConfiguration(config); factory.afterPropertiesSet();
|
||||
mapper = new SqlSessionTemplate(factory.getObject()).getMapper(DispatchFeeCargoMapper.class);
|
||||
}
|
||||
@Test void preservesEveryInboundOnSharedPalletAndIncludesOnlyThisDispatchsPickedPackages() {
|
||||
var rows = mapper.list("101", 1);
|
||||
assertThat(rows).hasSize(2);
|
||||
assertThat(rows).extracting(row -> row.sourceInboundId()).containsExactly("11", "12");
|
||||
assertThat(rows).extracting(row -> row.packages()).containsExactly(10L, 10L);
|
||||
assertThat(mapper.list("202", 1)).isEmpty();
|
||||
}
|
||||
@Test void aSelectedPlanPalletCannotBorrowOtherInboundStockOnTheSamePhysicalPallet() {
|
||||
jdbc.update("UPDATE ll_dispatch_item SET plan_pallet_id=801");
|
||||
jdbc.update("INSERT INTO ll_inbound_plan_pallet_allocation VALUES(801,901)");
|
||||
jdbc.update("INSERT INTO ll_inbound_plan_line VALUES(901,'101',401)");
|
||||
assertThat(mapper.list("101", 1)).extracting(row -> row.sourceInboundId()).containsExactly("11");
|
||||
}
|
||||
}
|
||||
+3
-1
@@ -94,6 +94,8 @@ class PlatformDispatchOutboundContractTest {
|
||||
Method cancel = DispatchController.class.getMethod("cancel", AuthenticatedUser.class, String.class);
|
||||
Method transfer = DeliveryTransferController.class.getMethod("transfer", AuthenticatedUser.class,
|
||||
String.class, DeliveryRequests.Transfer.class);
|
||||
Method externalFreight = DeliveryTransferController.class.getMethod("externalFreight", AuthenticatedUser.class, String.class);
|
||||
assertPermission(externalFreight, OverseasPermissions.DISPATCH_LIST);
|
||||
Method outboundDetail = DeliveryTransferController.class.getMethod("outboundDetail",
|
||||
AuthenticatedUser.class, String.class);
|
||||
Method outboundCancel = DeliveryTransferController.class.getMethod("cancelOutbound",
|
||||
@@ -105,7 +107,7 @@ class PlatformDispatchOutboundContractTest {
|
||||
assertPermission(outboundDetail, OverseasPermissions.OUTBOUND_LIST);
|
||||
assertEquals(2, outboundDetail.getParameterCount());
|
||||
assertPermission(outboundCancel, OverseasPermissions.OUTBOUND_CANCEL);
|
||||
for (Method method : Set.of(update, cancel, transfer, outboundDetail, outboundCancel)) {
|
||||
for (Method method : Set.of(update, cancel, transfer, externalFreight, outboundDetail, outboundCancel)) {
|
||||
assertTrue(Arrays.stream(method.getParameters())
|
||||
.noneMatch(parameter -> parameter.isAnnotationPresent(RequestParam.class)));
|
||||
}
|
||||
|
||||
+2
-2
@@ -125,7 +125,7 @@ class MerchantDeliveryControllerTest {
|
||||
@Test
|
||||
void invalidCreatePayloadIsRejectedByBeanValidationContract() {
|
||||
DeliveryRequests.Transfer invalid = new DeliveryRequests.Transfer(" ", null, " ", null, null,
|
||||
" ", " ", null, null, null, null, null, null, null, null, null);
|
||||
" ", " ", null, null, null, null, null, null, null, null, null, null, null, null);
|
||||
try (var factory = Validation.buildDefaultValidatorFactory()) {
|
||||
assertTrue(factory.getValidator().validate(invalid).size() >= 6);
|
||||
}
|
||||
@@ -186,6 +186,6 @@ class MerchantDeliveryControllerTest {
|
||||
"ONE_PICKUP_ONE_DROP", "PALLET", null, null, null, null, null, null, null, null,
|
||||
List.of(new DeliveryRequests.FeeItem("DELIVERY_FEE", "派送费", "RECEIVABLE", "CNY",
|
||||
new BigDecimal("10.00"), null, null, null, null, null, null, null, null,
|
||||
null, null, null, null, null, null)));
|
||||
null, null, null, null, null, null)), null, null, null);
|
||||
}
|
||||
}
|
||||
|
||||
+26
@@ -26,6 +26,7 @@ import static com.baiyue.lastleg.delivery.model.DeliveryDocumentCategory.*;
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class DeliveryDocumentServiceTest {
|
||||
@Mock DeliveryOrderMapper orders;
|
||||
@Mock OutboundOrderMapper outboundOrders;
|
||||
@Mock DeliveryDocumentRevisionMapper revisions;
|
||||
@Mock DeliveryOperationMapper operations;
|
||||
@Mock DeliveryDocumentFileGateway files;
|
||||
@@ -37,6 +38,7 @@ class DeliveryDocumentServiceTest {
|
||||
@BeforeEach void setUp() {
|
||||
service = new DeliveryDocumentService();
|
||||
ReflectionTestUtils.setField(service,"orders",orders);
|
||||
ReflectionTestUtils.setField(service,"outboundOrders",outboundOrders);
|
||||
ReflectionTestUtils.setField(service,"revisions",revisions);
|
||||
ReflectionTestUtils.setField(service,"operations",operations);
|
||||
ReflectionTestUtils.setField(service,"files",files);
|
||||
@@ -46,6 +48,7 @@ class DeliveryDocumentServiceTest {
|
||||
ReflectionTestUtils.setField(service,"customerVisits",customerVisits);
|
||||
order = new DeliveryOrder(); order.setId(10L); order.setCustomerId("100");
|
||||
order.setDeliveryNo("DL-1"); order.setVersion(0L); order.setStatus("DELIVERED");
|
||||
order.setOutboundNo("OUT-1");
|
||||
}
|
||||
@Test void savedCategoryCountCountsCategoriesNotFilesAndSurvivesReload() {
|
||||
when(orders.lockByNo("100","DL-1")).thenReturn(order);
|
||||
@@ -132,6 +135,8 @@ class DeliveryDocumentServiceTest {
|
||||
}
|
||||
@Test void savingReceiptAutomaticallyCompletesOnlyThisDeliveryAndItsBoundOrders() {
|
||||
order.setStatus("WAIT_DELIVERY");
|
||||
var outbound = new OutboundOrder(); outbound.setStatus("OUTBOUNDED");
|
||||
when(outboundOrders.findByNo("100", "OUT-1")).thenReturn(outbound);
|
||||
LastlegOrder related = new LastlegOrder(); related.setId(20L); related.setCustomerId("100");
|
||||
related.setDeliveryOrderNo("DL-1"); related.setSubOrderStatus("DELIVERY:WAIT_DELIVERY");
|
||||
when(orders.lockByNo("100","DL-1")).thenReturn(order);
|
||||
@@ -151,6 +156,27 @@ class DeliveryDocumentServiceTest {
|
||||
assertThat(operation.getAllValues()).extracting(DeliveryOperation::getOperationNode)
|
||||
.containsExactly("SAVE_DOCUMENTS", "RECEIPT_AUTO_COMPLETE");
|
||||
}
|
||||
@Test void receiptCannotCompleteDeliveryBeforeItsActualOutbound() {
|
||||
order.setStatus("WAIT_DELIVERY");
|
||||
var outbound = new OutboundOrder(); outbound.setStatus("WAIT_OUTBOUND");
|
||||
when(orders.lockByNo("100","DL-1")).thenReturn(order);
|
||||
when(files.metadata("100",10,RECEIPT,13)).thenReturn(file(13));
|
||||
when(outboundOrders.findByNo("100", "OUT-1")).thenReturn(outbound);
|
||||
assertThatThrownBy(() -> service.save("100","DL-1","9",request(0,"too-early",List.of(new Binding(RECEIPT,List.of(13L))))))
|
||||
.hasMessageContaining("先完成实际出库");
|
||||
verify(orders, never()).updateById(any(DeliveryOrder.class));
|
||||
verifyNoInteractions(lastlegOrders, deliveryCompletedOutbox, customerVisits, operations);
|
||||
assertThat(order.getStatus()).isEqualTo("WAIT_DELIVERY");
|
||||
assertThat(order.getVersion()).isZero();
|
||||
}
|
||||
@Test void bolMayBePreparedBeforeOutboundWithoutCompletingDelivery() {
|
||||
order.setStatus("WAIT_DELIVERY");
|
||||
when(orders.lockByNo("100","DL-1")).thenReturn(order);
|
||||
when(files.metadata("100",10,BOL,11)).thenReturn(file(11));
|
||||
service.save("100","DL-1","9",request(0,"prepare-bol",List.of(new Binding(BOL,List.of(11L)))));
|
||||
assertThat(order.getStatus()).isEqualTo("WAIT_DELIVERY");
|
||||
verifyNoInteractions(outboundOrders, deliveryCompletedOutbox, customerVisits);
|
||||
}
|
||||
@Test void merchantReadsOnlyCurrentReceiptFilesAndDownloadsWithAggregateOwnershipChecks() throws Exception {
|
||||
order.setDocumentsJson(new ObjectMapper().findAndRegisterModules().writeValueAsString(List.of(
|
||||
new Category(BOL,"UPLOADED",1,List.of(file(11))),
|
||||
|
||||
+256
-8
@@ -53,6 +53,8 @@ import org.springframework.web.server.ResponseStatusException;
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
import java.util.Map;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
||||
@@ -60,6 +62,7 @@ import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.Mockito.inOrder;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
@@ -111,11 +114,19 @@ class DeliveryTransferServiceTest {
|
||||
@Mock WarehouseTransferPalletMapper warehouseTransferPallets;
|
||||
|
||||
private DeliveryTransferService service;
|
||||
@Mock com.baiyue.lastleg.fleet.trip.FleetDispatchBindingGuard fleetBindings;
|
||||
@Mock com.baiyue.lastleg.fleet.trip.FleetBookingFreight bookingFreight;
|
||||
@Mock DeliveryFeeCoverageService feeCoverage;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
service = new DeliveryTransferService();
|
||||
ReflectionTestUtils.setField(service, "objectMapper", new com.fasterxml.jackson.databind.ObjectMapper());
|
||||
ReflectionTestUtils.setField(service, "fleetBindings", fleetBindings);
|
||||
ReflectionTestUtils.setField(service, "bookingFreight", bookingFreight);
|
||||
ReflectionTestUtils.setField(service, "feeCoverage", feeCoverage);
|
||||
org.mockito.Mockito.lenient().when(bookingFreight.preview(org.mockito.ArgumentMatchers.anyLong(), any(), any(), any()))
|
||||
.thenReturn(new com.baiyue.lastleg.fleet.trip.FleetBookingFreight.Preview("test-hash", true, List.of(), List.of()));
|
||||
ReflectionTestUtils.setField(service, "objectMapper", new com.fasterxml.jackson.databind.ObjectMapper().findAndRegisterModules());
|
||||
ReflectionTestUtils.setField(service, "dispatchOrders", dispatchOrders);
|
||||
ReflectionTestUtils.setField(service, "dispatchItems", dispatchItems);
|
||||
ReflectionTestUtils.setField(service, "outboundOrders", outboundOrders);
|
||||
@@ -141,6 +152,35 @@ class DeliveryTransferServiceTest {
|
||||
ReflectionTestUtils.setField(service, "warehouseTransferPallets", warehouseTransferPallets);
|
||||
}
|
||||
|
||||
@Test
|
||||
void transferPreservesUnknownGrossWeightThroughPalletAndDeliverySnapshots() {
|
||||
when(dispatchOrders.lockGlobalByDispatchNo("DSP-101")).thenReturn(dispatch(101L, "DSP-101", "TENANT-A"));
|
||||
DispatchItem known = item(201L, 501L, "PLT-501", "ONT8", "Ontario", "IN-A", 12);
|
||||
DispatchItem unknown = item(202L, 502L, "PLT-502", "ONT8", "Ontario", "IN-B", 8);
|
||||
unknown.setGrossWeightKg(null); unknown.setVolumeWeightKg(new BigDecimal("999"));
|
||||
when(dispatchItems.findByDispatchOrder("TENANT-A", 101L)).thenReturn(List.of(known, unknown));
|
||||
service.platformTransfer("DSP-101", "U-1", transferRequest("unknown-gross", null));
|
||||
var outbound = ArgumentCaptor.forClass(OutboundOrder.class);
|
||||
var delivery = ArgumentCaptor.forClass(DeliveryOrder.class);
|
||||
var pallets = ArgumentCaptor.forClass(OutboundPalletItem.class);
|
||||
verify(outboundOrders).insert(outbound.capture());
|
||||
verify(deliveryOrders).insert(delivery.capture());
|
||||
verify(outboundPallets, org.mockito.Mockito.times(2)).insert(pallets.capture());
|
||||
assertEquals(null, outbound.getValue().getGrossWeightKg());
|
||||
assertEquals(null, delivery.getValue().getTotalWeightKg());
|
||||
assertEquals(null, pallets.getAllValues().get(1).getGrossWeightKg());
|
||||
assertEquals(new BigDecimal("999"), pallets.getAllValues().get(1).getVolumeWeightKg());
|
||||
}
|
||||
|
||||
@Test void transferCannotOverrideAnAssignmentOwnedByTheTrip() {
|
||||
when(dispatchOrders.lockGlobalByDispatchNo("DSP-101")).thenReturn(dispatch(101L, "DSP-101", "TENANT-A"));
|
||||
org.mockito.Mockito.doThrow(new ResponseStatusException(org.springframework.http.HttpStatus.CONFLICT, "车辆司机由车次维护"))
|
||||
.when(fleetBindings).requireMatchingAssignment(org.mockito.ArgumentMatchers.eq(101L), any(), any());
|
||||
assertThrows(ResponseStatusException.class, () -> service.platformTransfer("DSP-101", "U-1", transferRequest("bound-transfer", null)));
|
||||
org.mockito.Mockito.verifyNoInteractions(deliveryOrders, feeSources, dispatchItems);
|
||||
verify(outboundOrders, never()).insert((OutboundOrder) any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void firstTransferCreatesOneOutboundAndOneDeliveryPerDestinationFbaWarehouse() {
|
||||
DispatchOrder dispatch = dispatch(101L, "DSP-101", "TENANT-A");
|
||||
@@ -220,26 +260,78 @@ class DeliveryTransferServiceTest {
|
||||
assertEquals("LX-1", outbound.getValue().getLxCodes());
|
||||
}
|
||||
|
||||
@org.junit.jupiter.params.ParameterizedTest
|
||||
@org.junit.jupiter.params.provider.ValueSource(strings = {"SAME_KEY", "NEW_KEY", "INSERT_RACE"})
|
||||
void changedFeesCannotReplayAnEarlierTransferAsSuccessful(String path) throws Exception {
|
||||
var dispatch = dispatch(101L, "DSP-101", "TENANT-A");
|
||||
when(dispatchOrders.lockGlobalByDispatchNo("DSP-101")).thenReturn(dispatch);
|
||||
when(dispatchItems.findByDispatchOrder("TENANT-A", 101L)).thenReturn(List.of(
|
||||
item(201L, 501L, "PLT-501", "ONT8", "Ontario", "IN-A", 12)));
|
||||
var request = transferRequest("transfer-original", null);
|
||||
service.platformTransfer("DSP-101", "U-1", request);
|
||||
var created = ArgumentCaptor.forClass(OutboundOrder.class);
|
||||
var delivered = ArgumentCaptor.forClass(DeliveryOrder.class);
|
||||
verify(outboundOrders).insert(created.capture()); verify(deliveryOrders).insert(delivered.capture());
|
||||
var mapper = new com.fasterxml.jackson.databind.ObjectMapper().findAndRegisterModules();
|
||||
com.fasterxml.jackson.databind.node.ObjectNode changed = mapper.valueToTree(request);
|
||||
((com.fasterxml.jackson.databind.node.ObjectNode) changed.withArray("fees").get(0)).put("amount", new BigDecimal("20.00"));
|
||||
if ("NEW_KEY".equals(path)) changed.put("idempotencyKey", "another-key");
|
||||
var retry = mapper.treeToValue(changed, DeliveryRequests.Transfer.class);
|
||||
if ("NEW_KEY".equals(path)) when(outboundOrders.lockByDispatch("TENANT-A", 101L)).thenReturn(created.getValue());
|
||||
else if ("INSERT_RACE".equals(path)) {
|
||||
when(outboundOrders.findByIdempotency("TENANT-A", "transfer-original")).thenReturn(null, created.getValue());
|
||||
when(outboundOrders.insert(any(OutboundOrder.class))).thenThrow(new org.springframework.dao.DuplicateKeyException("race"));
|
||||
} else when(outboundOrders.findByIdempotency("TENANT-A", "transfer-original")).thenReturn(created.getValue());
|
||||
org.mockito.Mockito.lenient().when(deliveryOrders.lockByOutbound("TENANT-A", created.getValue().getId())).thenReturn(List.of(delivered.getValue()));
|
||||
var failure = assertThrows(ResponseStatusException.class, () -> service.platformTransfer("DSP-101", "U-1", retry));
|
||||
assertEquals(409, failure.getStatusCode().value());
|
||||
assertTrue(failure.getReason().contains("内容不一致"));
|
||||
verify(feeSources).publishApproved(any(), any(), any());
|
||||
verify(deliveryOrders).insert(any(DeliveryOrder.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
void sameIdempotencyKeyReturnsExistingDocumentsWithoutWrites() {
|
||||
DispatchOrder dispatch = dispatch(101L, "DSP-101", "TENANT-A");
|
||||
OutboundOrder outbound = outbound(301L, 101L, "DSP-101", "OUT-301", "transfer-101");
|
||||
var request = transferRequest(" transfer-101 ", null);
|
||||
outbound.setRequestHash(service.transferRequestHash(request));
|
||||
DeliveryOrder delivery = delivery(401L, outbound, "DEL-401");
|
||||
when(dispatchOrders.lockGlobalByDispatchNo("DSP-101")).thenReturn(dispatch);
|
||||
when(outboundOrders.findByIdempotency("TENANT-A", "transfer-101")).thenReturn(outbound);
|
||||
when(deliveryOrders.lockByOutbound("TENANT-A", 301L)).thenReturn(List.of(delivery));
|
||||
|
||||
DeliveryResponses.TransferResult result = service.platformTransfer("DSP-101", "U-1",
|
||||
transferRequest(" transfer-101 ", null));
|
||||
request);
|
||||
|
||||
assertTrue(result.replayed());
|
||||
assertEquals("OUT-301", result.outboundNo());
|
||||
assertEquals("DEL-401", result.deliveries().get(0).deliveryNo());
|
||||
org.mockito.Mockito.verifyNoInteractions(fleetBindings);
|
||||
verify(outboundOrders, never()).insert((OutboundOrder) any());
|
||||
verify(outboundPallets, never()).insert((OutboundPalletItem) any());
|
||||
verify(deliveryOrders, never()).insert((DeliveryOrder) any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void identicalBusinessContentWithAnotherCommandKeyReturnsExistingDocumentsWithoutNewFees() throws Exception {
|
||||
var dispatch = dispatch(101L, "DSP-101", "TENANT-A");
|
||||
var original = transferRequest("first-key", null);
|
||||
var outbound = outbound(301L, 101L, "DSP-101", "OUT-301", "first-key");
|
||||
outbound.setRequestHash(service.transferRequestHash(original));
|
||||
var mapper = new com.fasterxml.jackson.databind.ObjectMapper().findAndRegisterModules();
|
||||
com.fasterxml.jackson.databind.node.ObjectNode content = mapper.valueToTree(original);
|
||||
content.put("idempotencyKey", "another-key");
|
||||
var retry = mapper.treeToValue(content, DeliveryRequests.Transfer.class);
|
||||
when(dispatchOrders.lockGlobalByDispatchNo("DSP-101")).thenReturn(dispatch);
|
||||
when(outboundOrders.lockByDispatch("TENANT-A", 101L)).thenReturn(outbound);
|
||||
when(deliveryOrders.lockByOutbound("TENANT-A", 301L)).thenReturn(List.of(delivery(401L, outbound, "DEL-401")));
|
||||
var replay = service.platformTransfer("DSP-101", "U-1", retry);
|
||||
assertTrue(replay.replayed()); assertEquals("OUT-301", replay.outboundNo());
|
||||
org.mockito.Mockito.verifyNoInteractions(feeSources, fleetBindings);
|
||||
verify(outboundOrders, never()).insert(any(OutboundOrder.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
void idempotencyKeyCannotBeReusedForAnotherDispatch() {
|
||||
DispatchOrder dispatch = dispatch(102L, "DSP-102", "TENANT-A");
|
||||
@@ -258,6 +350,8 @@ class DeliveryTransferServiceTest {
|
||||
void concurrentDuplicateInsertReturnsWinningDocuments() {
|
||||
DispatchOrder dispatch = dispatch(101L, "DSP-101", "TENANT-A");
|
||||
OutboundOrder winner = outbound(301L, 101L, "DSP-101", "OUT-301", "transfer-101");
|
||||
var request = transferRequest("transfer-101", null);
|
||||
winner.setRequestHash(service.transferRequestHash(request));
|
||||
DeliveryOrder delivery = delivery(401L, winner, "DEL-401");
|
||||
when(dispatchOrders.lockGlobalByDispatchNo("DSP-101")).thenReturn(dispatch);
|
||||
when(dispatchItems.findByDispatchOrder("TENANT-A", 101L)).thenReturn(List.of(
|
||||
@@ -267,7 +361,7 @@ class DeliveryTransferServiceTest {
|
||||
when(deliveryOrders.lockByOutbound("TENANT-A", 301L)).thenReturn(List.of(delivery));
|
||||
|
||||
DeliveryResponses.TransferResult result = service.platformTransfer("DSP-101", "U-1",
|
||||
transferRequest("transfer-101", null));
|
||||
request);
|
||||
|
||||
assertTrue(result.replayed());
|
||||
assertEquals("OUT-301", result.outboundNo());
|
||||
@@ -391,6 +485,8 @@ class DeliveryTransferServiceTest {
|
||||
void merchantTransferReplaysExistingDocumentsWithinAuthenticatedCustomer() {
|
||||
DispatchOrder dispatch = dispatch(101L, "DSP-101", "TENANT-A");
|
||||
OutboundOrder outbound = outbound(301L, 101L, "DSP-101", "OUT-301", "merchant-key");
|
||||
var request = transferRequest(" merchant-key ", null);
|
||||
outbound.setRequestHash(service.transferRequestHash(request));
|
||||
DeliveryOrder delivery = delivery(401L, outbound, "DEL-401");
|
||||
when(dispatchOrders.findByDispatchNo("TENANT-A", "DSP-101")).thenReturn(dispatch);
|
||||
when(dispatchOrders.lockById("TENANT-A", 101L)).thenReturn(dispatch);
|
||||
@@ -398,7 +494,7 @@ class DeliveryTransferServiceTest {
|
||||
when(deliveryOrders.lockByOutbound("TENANT-A", 301L)).thenReturn(List.of(delivery));
|
||||
|
||||
DeliveryResponses.TransferResult result = service.merchantTransfer("TENANT-A", "DSP-101", "U-2",
|
||||
transferRequest(" merchant-key ", null));
|
||||
request);
|
||||
|
||||
assertTrue(result.replayed());
|
||||
assertEquals("DEL-401", result.deliveries().get(0).deliveryNo());
|
||||
@@ -457,11 +553,12 @@ class DeliveryTransferServiceTest {
|
||||
OutboundPdaTask task = new OutboundPdaTask();
|
||||
task.setId(701L); task.setCustomerId("TENANT-A"); task.setOutboundOrderId(301L); task.setStatus("PENDING");
|
||||
when(outboundOrders.lockGlobalByNo("OUT-301")).thenReturn(outbound);
|
||||
when(outboundOrders.findGlobalByNo("OUT-301")).thenReturn(outbound);
|
||||
when(outboundPallets.findByOutbound("TENANT-A", 301L)).thenReturn(List.of(item));
|
||||
when(pdaTasks.lockByOutbound("TENANT-A", 301L)).thenReturn(task);
|
||||
|
||||
service.cancelOutbound("OUT-301", new DeliveryRequests.Cancel("cancelled by platform"));
|
||||
service.cancelOutbound("OUT-301", new DeliveryRequests.Cancel("duplicate command"));
|
||||
service.cancelOutbound("OUT-301", "OP-1", new DeliveryRequests.Cancel("cancelled by platform"));
|
||||
service.cancelOutbound("OUT-301", "OP-2", new DeliveryRequests.Cancel("duplicate command"));
|
||||
|
||||
assertEquals("CANCELLED", outbound.getStatus());
|
||||
assertEquals("CANCELLED", item.getStatus());
|
||||
@@ -473,10 +570,11 @@ class DeliveryTransferServiceTest {
|
||||
void globalOutboundCancelRejectsAnExecutedOrderWithoutDrift() {
|
||||
OutboundOrder outbound = outbound(301L, 101L, "DSP-101", "OUT-301", "transfer-key");
|
||||
outbound.setStatus("PICKED");
|
||||
when(outboundOrders.findGlobalByNo("OUT-301")).thenReturn(outbound);
|
||||
when(outboundOrders.lockGlobalByNo("OUT-301")).thenReturn(outbound);
|
||||
|
||||
ResponseStatusException exception = assertThrows(ResponseStatusException.class, () ->
|
||||
service.cancelOutbound("OUT-301", new DeliveryRequests.Cancel("too late")));
|
||||
service.cancelOutbound("OUT-301", "OP-1", new DeliveryRequests.Cancel("too late")));
|
||||
|
||||
assertEquals(409, exception.getStatusCode().value());
|
||||
assertEquals("PICKED", outbound.getStatus());
|
||||
@@ -484,6 +582,66 @@ class DeliveryTransferServiceTest {
|
||||
verify(outboundPallets, never()).findByOutbound(any(), any());
|
||||
}
|
||||
|
||||
@Test @SuppressWarnings({"rawtypes", "unchecked"})
|
||||
void cancellingDeliveryRequestsOnlyUnperformedFreightReceivablesAndPreservesOtherCosts() {
|
||||
com.baomidou.mybatisplus.core.metadata.TableInfoHelper.initTableInfo(new org.apache.ibatis.builder.MapperBuilderAssistant(
|
||||
new com.baomidou.mybatisplus.core.MybatisConfiguration(), "cancellation-test"), LastlegOrder.class);
|
||||
var outbound = outbound(301L, 101L, "DSP-101", "OUT-301", "key");
|
||||
var dispatch = dispatch(101L, "DSP-101", "TENANT-A"); dispatch.setStatus("PENDING_DEPARTURE");
|
||||
when(outboundOrders.findGlobalByNo("OUT-301")).thenReturn(outbound);
|
||||
when(dispatchOrders.lockById("TENANT-A", 101L)).thenReturn(dispatch);
|
||||
when(outboundOrders.lockGlobalByNo("OUT-301")).thenReturn(outbound);
|
||||
var pallet = pallet(501L, "P-1", "ONT8", "Ontario", "IN-1", 12); pallet.setStatus("WAIT_PICK"); pallet.setPickedPackageCount(0);
|
||||
when(outboundPallets.findByOutbound("TENANT-A", 301L)).thenReturn(List.of(pallet));
|
||||
var delivery = new DeliveryOrder(); delivery.setId(401L); delivery.setDeliveryNo("DEL-1"); delivery.setStatus("WAIT_DELIVERY");
|
||||
when(deliveryOrders.lockByOutbound("TENANT-A", 301L)).thenReturn(List.of(delivery));
|
||||
var freight = cancellationFee(701, "DELIVERY_FEE", "RECEIVABLE");
|
||||
when(feeSources.listForBusiness("TENANT-A", "DELIVERY_ORDER", 401L)).thenReturn(List.of(freight,
|
||||
cancellationFee(702, "APPOINTMENT_FEE", "RECEIVABLE"), cancellationFee(703, "DELIVERY_FEE", "PAYABLE")));
|
||||
var order = new LastlegOrder(); order.setId(901L); order.setCustomerId("TENANT-A"); order.setOutboundOrderNo("OUT-301"); order.setCurrentStatus("WAIT_OUTBOUND"); order.setVersion(3);
|
||||
when(tailOrders.selectList(any())).thenReturn(List.of(order)); when(tailOrders.lockById("TENANT-A", 901L)).thenReturn(order);
|
||||
var previous = new LastlegOrderStatusHistory(); previous.setFromStatus("INBOUND_COMPLETED");
|
||||
when(histories.selectOne(any())).thenReturn(previous);
|
||||
when(tailOrders.update(org.mockito.ArgumentMatchers.isNull(), any(com.baomidou.mybatisplus.core.conditions.Wrapper.class))).thenReturn(1);
|
||||
String longReason = "取消运输".repeat(200);
|
||||
service.cancelOutbound("OUT-301", "OP-1", new DeliveryRequests.Cancel(longReason));
|
||||
var request = ArgumentCaptor.forClass(com.baiyue.lastleg.financefee.dto.OverseasFeeSourceDtos.VoidRequest.class);
|
||||
verify(feeSources).voidSource(org.mockito.ArgumentMatchers.eq(701L), org.mockito.ArgumentMatchers.eq("OP-1"), request.capture());
|
||||
assertEquals(1, request.getValue().expectedVersion());
|
||||
assertEquals("CANCELLED", delivery.getStatus()); assertEquals("CANCELLED", outbound.getStatus());
|
||||
var restored = ArgumentCaptor.forClass(com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper.class);
|
||||
verify(tailOrders).update(org.mockito.ArgumentMatchers.isNull(), restored.capture());
|
||||
assertTrue(restored.getValue().getSqlSet().contains("outbound_order_no="));
|
||||
assertTrue(restored.getValue().getSqlSet().contains("delivery_order_no="));
|
||||
assertTrue(restored.getValue().getParamNameValuePairs().containsValue("INBOUND_COMPLETED"));
|
||||
assertTrue(restored.getValue().getParamNameValuePairs().containsValue(null));
|
||||
var operation = ArgumentCaptor.forClass(com.baiyue.lastleg.delivery.entity.DeliveryOperation.class);
|
||||
verify(deliveryOperations, org.mockito.Mockito.times(3)).insert(operation.capture());
|
||||
assertTrue(operation.getAllValues().stream().allMatch(row -> row.getResult().length() <= 500));
|
||||
assertEquals(longReason, operation.getAllValues().stream().filter(row -> "CANCEL_OUTBOUND_REASON".equals(row.getOperationNode()))
|
||||
.map(row -> row.getResult().replaceFirst("^\\(\\d+\\) ", "")).collect(java.util.stream.Collectors.joining()));
|
||||
verify(feeSources, never()).voidSource(org.mockito.ArgumentMatchers.eq(702L), any(), any());
|
||||
verify(feeSources, never()).voidSource(org.mockito.ArgumentMatchers.eq(703L), any(), any());
|
||||
var ordered = org.mockito.Mockito.inOrder(outboundOrders, dispatchOrders, fleetBindings);
|
||||
ordered.verify(outboundOrders).findGlobalByNo("OUT-301"); ordered.verify(dispatchOrders).lockById("TENANT-A", 101L);
|
||||
ordered.verify(outboundOrders).lockGlobalByNo("OUT-301"); ordered.verify(fleetBindings).requireUnbound(101L);
|
||||
service.cancelOutbound("OUT-301", "OP-2", new DeliveryRequests.Cancel("重试"));
|
||||
verify(feeSources, org.mockito.Mockito.times(1)).voidSource(org.mockito.ArgumentMatchers.anyLong(), any(), any());
|
||||
}
|
||||
@Test void dispatchPickingCannotBeCancelledWhileOutboundSnapshotStillLooksUnexecuted() {
|
||||
var outbound = outbound(301L, 101L, "DSP-101", "OUT-301", "key");
|
||||
var dispatch = dispatch(101L, "DSP-101", "TENANT-A"); dispatch.setStatus("PICKING"); dispatch.setPickedPackageCount(1);
|
||||
when(outboundOrders.findGlobalByNo("OUT-301")).thenReturn(outbound);
|
||||
when(dispatchOrders.lockById("TENANT-A", 101L)).thenReturn(dispatch);
|
||||
when(outboundOrders.lockGlobalByNo("OUT-301")).thenReturn(outbound);
|
||||
assertThrows(ResponseStatusException.class, () -> service.cancelOutbound("OUT-301", "OP-1", new DeliveryRequests.Cancel("取消")));
|
||||
org.mockito.Mockito.verifyNoInteractions(feeSources, outboundPallets, deliveryOrders);
|
||||
}
|
||||
private static com.baiyue.lastleg.financefee.entity.OverseasFeeSource cancellationFee(long id, String type, String direction) {
|
||||
var fee = new com.baiyue.lastleg.financefee.entity.OverseasFeeSource(); fee.setId(id); fee.setSourceFeeId(id); fee.setSourceVersion(1);
|
||||
fee.setFeeItemCode(type); fee.setDirection(direction); fee.setSourceStatus("ACTIVE"); fee.setReviewStatus("APPROVED"); return fee;
|
||||
}
|
||||
|
||||
@Test
|
||||
void sharedPalletOutboundFreezesEachInboundAndCountsOnePhysicalPallet() {
|
||||
for (long i = 1; i <= 2; i++) {
|
||||
@@ -967,6 +1125,96 @@ class DeliveryTransferServiceTest {
|
||||
.getStatusCode().value());
|
||||
}
|
||||
|
||||
@Test void externalFreightPublishesOnlyTheConfirmedQuoteOnItsOwnDestination() {
|
||||
when(dispatchOrders.lockGlobalByDispatchNo("DSP-101")).thenReturn(dispatch(101L, "DSP-101", "TENANT-A"));
|
||||
when(dispatchItems.findByDispatchOrder("TENANT-A", 101L)).thenReturn(List.of(
|
||||
item(201L, 501L, "PLT-501", "ONT8", "Ontario", "WT-1", 12),
|
||||
item(202L, 502L, "PLT-502", "LAX9", "LA", "WT-2", 8)));
|
||||
var q1 = new com.baiyue.lastleg.fleet.trip.FleetBookingFreight.Quote("FB-1", 4, "WT-1", "IN-1", "客户甲", "FBA:ONT8", "CNY", new BigDecimal("60.00"), new BigDecimal("2"), "托位", new BigDecimal("30.00"));
|
||||
var q2 = new com.baiyue.lastleg.fleet.trip.FleetBookingFreight.Quote("FB-2", 4, "WT-2", "IN-2", "客户甲", "FBA:LAX9", "CNY", BigDecimal.ZERO, BigDecimal.ONE, "单", BigDecimal.ZERO);
|
||||
when(bookingFreight.preview(org.mockito.ArgumentMatchers.anyLong(), any(), any(), any()))
|
||||
.thenReturn(new com.baiyue.lastleg.fleet.trip.FleetBookingFreight.Preview("external-hash", false, List.of(q1, q2), List.of("IN-1", "IN-2")));
|
||||
var request = externalRequest("external-hash", List.of());
|
||||
service.platformTransfer("DSP-101", "OP-1", request);
|
||||
var deliveries = ArgumentCaptor.forClass(DeliveryOrder.class);
|
||||
verify(deliveryOrders, org.mockito.Mockito.times(2)).insert(deliveries.capture());
|
||||
var fee = ArgumentCaptor.forClass(com.baiyue.lastleg.financefee.dto.OverseasFeeSourceDtos.SaveRequest.class);
|
||||
verify(feeSources).publishApproved(org.mockito.ArgumentMatchers.eq("OP-1"), org.mockito.ArgumentMatchers.eq("FLEET:FB-1:DSP-101:FREIGHT"), fee.capture());
|
||||
verify(feeSources).requireNoActiveDeliveryReceivables("TENANT-A", List.of("IN-1", "IN-2"));
|
||||
assertEquals(new BigDecimal("60.00"), fee.getValue().amount());
|
||||
assertEquals("IN-1", fee.getValue().inboundCode());
|
||||
assertEquals(deliveries.getAllValues().stream().filter(d -> "ONT8".equals(d.getDestinationWarehouseCode())).findFirst().orElseThrow().getId(), fee.getValue().businessId());
|
||||
var outbound = ArgumentCaptor.forClass(OutboundOrder.class);
|
||||
verify(outboundOrders).insert(outbound.capture());
|
||||
when(outboundOrders.findByIdempotency("TENANT-A", "external-1")).thenReturn(outbound.getValue());
|
||||
when(deliveryOrders.lockByOutbound("TENANT-A", outbound.getValue().getId())).thenReturn(deliveries.getAllValues());
|
||||
assertTrue(service.platformTransfer("DSP-101", "OP-OTHER", request).replayed());
|
||||
org.mockito.Mockito.verifyNoMoreInteractions(feeSources);
|
||||
}
|
||||
|
||||
@Test void externalFreightRejectsStalePreviewAndDuplicateDeliveryChargeBeforeWriting() {
|
||||
when(dispatchOrders.lockGlobalByDispatchNo("DSP-101")).thenReturn(dispatch(101L, "DSP-101", "TENANT-A"));
|
||||
when(dispatchItems.findByDispatchOrder("TENANT-A", 101L)).thenReturn(List.of(item(201L, 501L, "PLT-501", "ONT8", "Ontario", "WT-1", 12)));
|
||||
var q = new com.baiyue.lastleg.fleet.trip.FleetBookingFreight.Quote("FB-1", 4, "WT-1", "IN-1", "客户甲", "FBA:ONT8", "CNY", BigDecimal.TEN, BigDecimal.ONE, "单", BigDecimal.TEN);
|
||||
when(bookingFreight.preview(org.mockito.ArgumentMatchers.anyLong(), any(), any(), any()))
|
||||
.thenReturn(new com.baiyue.lastleg.fleet.trip.FleetBookingFreight.Preview("current-hash", false, List.of(q), List.of("IN-1")));
|
||||
assertThrows(ResponseStatusException.class, () -> service.platformTransfer("DSP-101", "OP-1", externalRequest("old-hash", List.of())));
|
||||
assertThrows(ResponseStatusException.class, () -> service.platformTransfer("DSP-101", "OP-1", externalRequest("current-hash", List.of(receivableDeliveryFee()))));
|
||||
verify(outboundOrders, never()).insert((OutboundOrder) any());
|
||||
org.mockito.Mockito.verifyNoInteractions(feeSources, deliveryOrders);
|
||||
}
|
||||
@Test void rebookingAfterCancellationCannotChargeAgainBeforeTheOriginalReceivableIsVoided() {
|
||||
when(dispatchOrders.lockGlobalByDispatchNo("DSP-101")).thenReturn(dispatch(101L, "DSP-101", "TENANT-A"));
|
||||
when(dispatchItems.findByDispatchOrder("TENANT-A", 101L)).thenReturn(List.of(item(201L, 501L, "P-1", "ONT8", "Ontario", "WT-1", 12)));
|
||||
when(bookingFreight.preview(org.mockito.ArgumentMatchers.anyLong(), any(), any(), any()))
|
||||
.thenReturn(new com.baiyue.lastleg.fleet.trip.FleetBookingFreight.Preview("cancelled-source", true, List.of(), List.of("IN-1")));
|
||||
org.mockito.Mockito.doThrow(new ResponseStatusException(org.springframework.http.HttpStatus.CONFLICT, "原运费待财务作废"))
|
||||
.when(feeSources).requireNoActiveDeliveryReceivables("TENANT-A", List.of("IN-1"));
|
||||
assertThrows(ResponseStatusException.class, () -> service.platformTransfer("DSP-101", "OP-NEW", transferRequest("new-trip", null)));
|
||||
verify(outboundOrders, never()).insert((OutboundOrder) any());
|
||||
verify(feeSources, never()).publishApproved(any(), any(), any());
|
||||
}
|
||||
@Test void confirmedOriginalFeeCoverageSavesEvidenceWithoutPublishingAFabricatedNewFee() throws Exception {
|
||||
var json = new com.fasterxml.jackson.databind.ObjectMapper().findAndRegisterModules();
|
||||
var input = json.valueToTree(externalRequest(null, List.of()));
|
||||
((com.fasterxml.jackson.databind.node.ObjectNode) input).set("feeCoverage", json.valueToTree(List.of(
|
||||
new com.baiyue.lastleg.delivery.dto.DeliveryFeeCoverageModels.Selection(711L, 1, "original-hash", List.of("DELIVERY_FEE")))));
|
||||
((com.fasterxml.jackson.databind.node.ObjectNode) input).put("feeCoverageCargoHash", "cargo-hash");
|
||||
var request = json.treeToValue(input, DeliveryRequests.Transfer.class);
|
||||
var evidence = new com.baiyue.lastleg.delivery.dto.DeliveryFeeCoverageModels.Evidence("cargo-hash", List.of(), List.of(), "OP-1", java.time.Instant.parse("2026-10-03T00:00:00Z"));
|
||||
when(feeCoverage.confirm(org.mockito.ArgumentMatchers.eq("DSP-101"), org.mockito.ArgumentMatchers.eq(request), org.mockito.ArgumentMatchers.eq("OP-1"), any()))
|
||||
.thenReturn(new DeliveryFeeCoverageService.Decision(Set.of("DELIVERY_FEE"), evidence, Set.of()));
|
||||
when(dispatchOrders.lockGlobalByDispatchNo("DSP-101")).thenReturn(dispatch(101L, "DSP-101", "TENANT-A"));
|
||||
when(dispatchItems.findByDispatchOrder("TENANT-A", 101L)).thenReturn(List.of(item(201L, 501L, "P-1", "ONT8", "Ontario", "IN-A", 12)));
|
||||
service.platformTransfer("DSP-101", "OP-1", request);
|
||||
var outbound = ArgumentCaptor.forClass(OutboundOrder.class); verify(outboundOrders).insert(outbound.capture());
|
||||
assertEquals("cargo-hash", json.readTree(outbound.getValue().getFeeCoverageJson()).get("cargoHash").asText());
|
||||
org.mockito.Mockito.verifyNoInteractions(feeSources);
|
||||
var delivery = ArgumentCaptor.forClass(DeliveryOrder.class); verify(deliveryOrders).insert(delivery.capture());
|
||||
when(outboundOrders.findByIdempotency("TENANT-A", request.idempotencyKey())).thenReturn(outbound.getValue());
|
||||
when(deliveryOrders.lockByOutbound("TENANT-A", outbound.getValue().getId())).thenReturn(List.of(delivery.getValue()));
|
||||
assertTrue(service.platformTransfer("DSP-101", "OP-1", request).replayed());
|
||||
verify(feeCoverage, org.mockito.Mockito.times(1)).confirm(any(), any(), any(), any());
|
||||
}
|
||||
@Test void emptyCoverageRepresentationsDoNotCreateFalseIdempotencyConflicts() {
|
||||
var original = transferRequest("key", null);
|
||||
var json = new com.fasterxml.jackson.databind.ObjectMapper().findAndRegisterModules();
|
||||
com.fasterxml.jackson.databind.node.ObjectNode body = json.valueToTree(original);
|
||||
body.putArray("feeCoverage"); body.put("feeCoverageCargoHash", "unused-hash");
|
||||
var empty = json.convertValue(body, DeliveryRequests.Transfer.class);
|
||||
assertEquals(service.transferRequestHash(original), service.transferRequestHash(empty));
|
||||
}
|
||||
@Test void merchantCannotDeclareOriginalFeeCoverageForItself() {
|
||||
var request = new com.fasterxml.jackson.databind.ObjectMapper().convertValue(Map.of("feeCoverage", List.of(
|
||||
new com.baiyue.lastleg.delivery.dto.DeliveryFeeCoverageModels.Selection(711L, 1, "original-hash", List.of("DELIVERY_FEE")))), DeliveryRequests.Transfer.class);
|
||||
var error = assertThrows(ResponseStatusException.class, () -> service.merchantTransfer("TENANT-A", "DSP-101", "OP-1", request));
|
||||
assertEquals(403, error.getStatusCode().value()); org.mockito.Mockito.verifyNoInteractions(dispatchOrders, feeCoverage, feeSources);
|
||||
}
|
||||
private static DeliveryRequests.Transfer externalRequest(String hash, List<DeliveryRequests.FeeItem> fees) {
|
||||
return new DeliveryRequests.Transfer("external-1", LocalDateTime.of(2026,10,3,12,0), "TRUCK_PALLET", null, null,
|
||||
"WAREHOUSE_PICKUP", "PALLET", null, null, null, null, null, null, null, null, fees, hash, null, null);
|
||||
}
|
||||
|
||||
private static DispatchOrder dispatch(Long id, String dispatchNo, String customerId) {
|
||||
DispatchOrder value = new DispatchOrder();
|
||||
value.setId(id); value.setCustomerId(customerId); value.setDispatchNo(dispatchNo);
|
||||
@@ -977,7 +1225,7 @@ class DeliveryTransferServiceTest {
|
||||
private static DeliveryRequests.Transfer transferRequest(String idempotencyKey, String remark) {
|
||||
return new DeliveryRequests.Transfer(idempotencyKey, LocalDateTime.now(), "SELF_DELIVERY", null, null,
|
||||
"ONE_PICKUP_ONE_DROP", "PALLET", null, null, null, null, null, null, null, remark,
|
||||
List.of(receivableDeliveryFee()));
|
||||
List.of(receivableDeliveryFee()), null, null, null);
|
||||
}
|
||||
|
||||
private static DeliveryRequests.FeeItem receivableDeliveryFee() {
|
||||
|
||||
Reference in New Issue
Block a user