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() {
|
||||
|
||||
+3
-2
@@ -10,6 +10,7 @@ import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
|
||||
class DispatchCandidateMapperContractTest {
|
||||
@@ -131,7 +132,7 @@ class DispatchCandidateMapperContractTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
void candidateGrossWeightIsAllocatedByPalletAndFallsBackToVolumeWeight() throws Exception {
|
||||
void candidateGrossWeightUsesPhysicalSourcesAndNeverBillingVolumeWeight() throws Exception {
|
||||
for (Method method : List.of(
|
||||
DispatchCandidateMapper.class.getMethod("available", String.class, List.class, String.class,
|
||||
String.class, String.class, String.class),
|
||||
@@ -142,7 +143,7 @@ class DispatchCandidateMapperContractTest {
|
||||
assertTrue(sql.contains("ll_inbound_order inbound"));
|
||||
assertTrue(sql.contains("a.allocated_volume_cbm"));
|
||||
assertTrue(sql.contains("a2.line_id=a.line_id"));
|
||||
assertTrue(sql.contains("MIN(o.volume_weight_kg)) AS grossWeightKg"));
|
||||
assertFalse(sql.contains("MIN(o.volume_weight_kg)) AS grossWeightKg"));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+45
@@ -0,0 +1,45 @@
|
||||
package com.baiyue.lastleg.dispatch;
|
||||
|
||||
import com.baiyue.lastleg.dispatch.mapper.DispatchCandidateMapper;
|
||||
import org.apache.ibatis.annotations.Select;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.jdbc.datasource.DriverManagerDataSource;
|
||||
import java.math.BigDecimal;
|
||||
import java.util.UUID;
|
||||
import static org.assertj.core.api.Assertions.*;
|
||||
|
||||
class DispatchPhysicalWeightTest {
|
||||
@Test void billingVolumeWeightCannotFillMissingPhysicalWeight() {
|
||||
var jdbc = new JdbcTemplate(new DriverManagerDataSource("jdbc:h2:mem:" + UUID.randomUUID() + ";MODE=MySQL;DB_CLOSE_DELAY=-1", "sa", ""));
|
||||
jdbc.execute("CREATE TABLE ll_order_detail(customer_id VARCHAR(64),order_id BIGINT,gross_weight_kg DECIMAL(12,3))");
|
||||
jdbc.execute("CREATE TABLE ll_inbound_order(id BIGINT,customer_id VARCHAR(64),gross_weight_kg DECIMAL(12,3))");
|
||||
jdbc.execute("CREATE TABLE ll_inbound_plan_pallet_allocation(line_id BIGINT)");
|
||||
jdbc.update("INSERT INTO ll_inbound_plan_pallet_allocation VALUES(1)");
|
||||
int tested = 0;
|
||||
for (var method : DispatchCandidateMapper.class.getDeclaredMethods()) {
|
||||
var annotation = method.getAnnotation(Select.class);
|
||||
if (annotation == null) continue;
|
||||
String sql = String.join(" ", annotation.value()).replace(">", ">").replace("<", "<");
|
||||
int from = sql.indexOf("AS volumeWeightKg,");
|
||||
int to = sql.indexOf(" AS grossWeightKg,");
|
||||
if (from < 0 || to < 0) continue;
|
||||
String projection = sql.substring(from + "AS volumeWeightKg,".length(), to);
|
||||
String query = "SELECT " + projection + " FROM (SELECT '10' AS customer_id) ip "
|
||||
+ "CROSS JOIN (SELECT 1 AS order_id,10 AS volume_cbm) l "
|
||||
+ "CROSS JOIN (SELECT 1 AS line_id,5 AS allocated_volume_cbm) a "
|
||||
+ "CROSS JOIN (SELECT 999 AS volume_weight_kg) o";
|
||||
assertThat(jdbc.queryForObject(query, BigDecimal.class)).as(method.getName() + " missing gross weight").isNull();
|
||||
if (projection.trim().equals("NULL")) continue; // Relabel candidates have no measured pallet weight source yet.
|
||||
jdbc.update("INSERT INTO ll_order_detail VALUES('10',1,40)");
|
||||
assertThat(jdbc.queryForObject(query, BigDecimal.class)).as(method.getName() + " physical weight allocation").isEqualByComparingTo("20");
|
||||
String mixed = query.replace("SELECT 1 AS order_id,10 AS volume_cbm", "SELECT 1 AS order_id,10 AS volume_cbm UNION ALL SELECT 2,10");
|
||||
assertThat(jdbc.queryForObject(mixed, BigDecimal.class)).as(method.getName() + " incomplete aggregate").isNull();
|
||||
jdbc.update("INSERT INTO ll_order_detail VALUES('10',2,40)");
|
||||
assertThat(jdbc.queryForObject(mixed, BigDecimal.class)).as(method.getName() + " complete aggregate").isEqualByComparingTo("40");
|
||||
jdbc.update("DELETE FROM ll_order_detail");
|
||||
tested++;
|
||||
}
|
||||
assertThat(tested).isEqualTo(2);
|
||||
}
|
||||
}
|
||||
+2
-1
@@ -32,7 +32,8 @@ class SmartDispatchOpenApiContractTest {
|
||||
assertTrue(yaml.contains("SmartDispatchPreArrange"));
|
||||
assertTrue(yaml.contains("SmartDispatchCandidate"));
|
||||
assertTrue(yaml.contains("overseasWarehouseCode: { type: string, nullable: true, description: 实际海外接柜仓编码"));
|
||||
assertTrue(yaml.contains("grossWeightKg: { type: number, nullable: true, description: 候选托盘实际毛重"));
|
||||
assertTrue(yaml.contains("grossWeightKg: { type: number, nullable: true, description: 候选托盘分摊毛重"));
|
||||
assertTrue(yaml.contains("禁止使用体积重替代"));
|
||||
String preArrangeSchema = yaml.substring(yaml.indexOf(" SmartDispatchPreArrange:"),
|
||||
yaml.indexOf(" SmartDispatchDraftPallet:"));
|
||||
String updateSchema = yaml.substring(yaml.indexOf(" DispatchUpdate:"),
|
||||
|
||||
+32
-3
@@ -40,6 +40,8 @@ import static org.junit.jupiter.api.Assertions.assertNull;
|
||||
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.ArgumentMatchers.anyLong;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
@@ -57,12 +59,14 @@ class DispatchPdaWorkflowServiceTest {
|
||||
@Mock InventoryPositionMapper positions;
|
||||
@Mock InventoryCommandService inventory;
|
||||
@Mock DispatchPickOrderAttributionService attribution;
|
||||
@Mock com.baiyue.lastleg.fleet.trip.FleetDispatchBindingGuard fleetBindings;
|
||||
private DispatchPdaWorkflowService service;
|
||||
private ObjectMapper objectMapper;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
service = new DispatchPdaWorkflowService();
|
||||
ReflectionTestUtils.setField(service, "fleetBindings", fleetBindings);
|
||||
objectMapper = new ObjectMapper();
|
||||
ReflectionTestUtils.setField(service, "orders", orders);
|
||||
ReflectionTestUtils.setField(service, "outboundOrders", outboundOrders);
|
||||
@@ -167,6 +171,7 @@ class DispatchPdaWorkflowServiceTest {
|
||||
DispatchPdaResponses.Picked result = service.pick("WH-1", "DSP-1", 21L, "7", "Picker", request);
|
||||
|
||||
assertEquals(2, result.packages());
|
||||
verify(fleetBindings, never()).requirePickingApproved(anyLong());
|
||||
verify(inventory, never()).allocate(any(), any(), any());
|
||||
verify(picks, never()).insert((DispatchPdaPick) any());
|
||||
verify(items, never()).bindReceivedPallets(any(), any());
|
||||
@@ -231,12 +236,36 @@ class DispatchPdaWorkflowServiceTest {
|
||||
when(outboundOrders.lockByDispatch("100", 1L)).thenReturn(outbound);
|
||||
|
||||
ResponseStatusException exception = assertThrows(ResponseStatusException.class,
|
||||
() -> service.depart("WH-1", "DSP-1", "7", new DispatchPdaRequests.Depart("depart-1", null)));
|
||||
() -> service.depart("WH-1", "DSP-1", "7", new DispatchPdaRequests.Depart("depart-1", null), java.time.LocalDateTime.now()));
|
||||
|
||||
assertEquals(409, exception.getStatusCode().value());
|
||||
verify(outboundOrders, never()).updateById((OutboundOrder) any());
|
||||
}
|
||||
|
||||
@Test void pendingDispatchReviewBlocksPickingAndCompletionBeforeInventoryChanges() {
|
||||
DispatchOrder order = order("100", "DSP-1", "WH-1", "PENDING_DEPARTURE"); order.setVehiclePlate("CA-100");
|
||||
when(orders.findGlobalByDispatchNo("DSP-1")).thenReturn(order);
|
||||
when(orders.lockById("100", 1L)).thenReturn(order);
|
||||
when(tasks.lockByDispatch("100", 1L)).thenReturn(task(11L, "100", "WH-1", "PDA_PENDING"));
|
||||
org.mockito.Mockito.doThrow(new ResponseStatusException(org.springframework.http.HttpStatus.CONFLICT, "请先通过调度审核再拣货"))
|
||||
.when(fleetBindings).requirePickingApproved(1L);
|
||||
assertThrows(ResponseStatusException.class, () -> service.pick("WH-1", "DSP-1", 21L, "7", "作业员", new DispatchPdaRequests.Pick(31L, 2, 0, "pending-pick", null)));
|
||||
assertThrows(ResponseStatusException.class, () -> service.completePicking("WH-1", "DSP-1", new DispatchPdaRequests.CompletePicking("pending-complete")));
|
||||
org.mockito.Mockito.verifyNoInteractions(inventory, picks, attribution);
|
||||
verify(orders, never()).updateById((DispatchOrder) any());
|
||||
}
|
||||
|
||||
@Test void boundDispatchCannotDepartBeforeTripCoordination() {
|
||||
DispatchOrder order = order("100", "DSP-1", "WH-1", "READY_TO_DEPART"); order.setVehiclePlate("CA-100");
|
||||
when(orders.findGlobalByDispatchNo("DSP-1")).thenReturn(order);
|
||||
when(orders.lockById("100", 1L)).thenReturn(order);
|
||||
org.mockito.Mockito.doThrow(new ResponseStatusException(org.springframework.http.HttpStatus.CONFLICT, "按车次统一发车"))
|
||||
.when(fleetBindings).requireCoordinatedDeparture(1L);
|
||||
assertThrows(ResponseStatusException.class, () -> service.depart("WH-1", "DSP-1", "7", new DispatchPdaRequests.Depart("depart-1", null), java.time.LocalDateTime.now()));
|
||||
org.mockito.Mockito.verifyNoInteractions(attribution, inventory, outboundOrders);
|
||||
verify(orders, never()).updateById((DispatchOrder) any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void successfulDeparturePersistsBusinessDepartureTime() {
|
||||
DispatchOrder order = order("100", "DSP-1", "WH-1", "READY_TO_DEPART");
|
||||
@@ -262,7 +291,7 @@ class DispatchPdaWorkflowServiceTest {
|
||||
when(palletLocks.selectList(any())).thenReturn(List.of());
|
||||
|
||||
DispatchPdaResponses.Departed result = service.depart(
|
||||
"WH-1", "DSP-1", "7", new DispatchPdaRequests.Depart("depart-1", null));
|
||||
"WH-1", "DSP-1", "7", new DispatchPdaRequests.Depart("depart-1", null), java.time.LocalDateTime.now());
|
||||
|
||||
assertEquals("DEPARTED", result.status());
|
||||
assertNotNull(order.getDepartAt());
|
||||
@@ -284,7 +313,7 @@ class DispatchPdaWorkflowServiceTest {
|
||||
when(orders.lockById("100", 1L)).thenReturn(order);
|
||||
when(operations.findKey("100", "depart-1")).thenReturn(operation("DEPART", request, persisted));
|
||||
|
||||
DispatchPdaResponses.Departed result = service.depart("WH-1", "DSP-1", "7", request);
|
||||
DispatchPdaResponses.Departed result = service.depart("WH-1", "DSP-1", "7", request, java.time.LocalDateTime.now());
|
||||
|
||||
assertEquals("DEPARTED", result.status());
|
||||
assertTrue(result.replayed());
|
||||
|
||||
+16
@@ -43,6 +43,7 @@ import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
import static org.mockito.Mockito.lenient;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
@@ -59,11 +60,13 @@ class DispatchServiceTest {
|
||||
@Mock DispatchPdaTaskMapper pdaTasks;
|
||||
@Mock LastlegOrderMapper tailOrders;
|
||||
@Mock OutboundOrderMapper outboundOrders;
|
||||
@Mock com.baiyue.lastleg.fleet.trip.FleetDispatchBindingGuard fleetBindings;
|
||||
private DispatchService service;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
service = new DispatchService();
|
||||
ReflectionTestUtils.setField(service, "fleetBindings", fleetBindings);
|
||||
ReflectionTestUtils.setField(service, "orders", orders);
|
||||
ReflectionTestUtils.setField(service, "items", items);
|
||||
ReflectionTestUtils.setField(service, "bolLines", bolLines);
|
||||
@@ -383,9 +386,22 @@ class DispatchServiceTest {
|
||||
assertEquals("ONT8,ABE4", result.route());
|
||||
assertEquals("ONT8,ABE4", dispatch.getRoute());
|
||||
verify(orders).updateById(dispatch);
|
||||
verify(fleetBindings).requireUnbound(1L);
|
||||
verify(items).findByDispatchOrder("100", 1L);
|
||||
}
|
||||
|
||||
@Test void boundDispatchCannotEditRemoveOrCancelIndependently() {
|
||||
DispatchOrder dispatch = new DispatchOrder(); dispatch.setId(1L); dispatch.setStatus("PENDING_DEPARTURE");
|
||||
when(orders.lockGlobalByDispatchNo("PC1")).thenReturn(dispatch);
|
||||
org.mockito.Mockito.doThrow(new ResponseStatusException(org.springframework.http.HttpStatus.CONFLICT, "请先解除车次关联"))
|
||||
.when(fleetBindings).requireUnbound(1L);
|
||||
assertThrows(ResponseStatusException.class, () -> service.update("PC1", null));
|
||||
assertThrows(ResponseStatusException.class, () -> service.removeItem("PC1", 2L));
|
||||
assertThrows(ResponseStatusException.class, () -> service.cancel("PC1"));
|
||||
verify(orders, never()).updateById((DispatchOrder) any());
|
||||
org.mockito.Mockito.verifyNoInteractions(items, locks, outboundOrders);
|
||||
}
|
||||
|
||||
@Test
|
||||
void platformEditRejectsAnExecutedDispatchWithoutWriting() {
|
||||
DispatchOrder dispatch = new DispatchOrder();
|
||||
|
||||
+23
@@ -0,0 +1,23 @@
|
||||
package com.baiyue.lastleg.financefee;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.yaml.snakeyaml.Yaml;
|
||||
import org.yaml.snakeyaml.LoaderOptions;
|
||||
import org.yaml.snakeyaml.constructor.SafeConstructor;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.Map;
|
||||
import static org.assertj.core.api.Assertions.*;
|
||||
|
||||
class OverseasFeeCancellationContractTest {
|
||||
@Test void changedContractsHaveNoDuplicateKeysAndSeparateTheRequestFromFinancialConfirmation() throws Exception {
|
||||
var options = new LoaderOptions(); options.setAllowDuplicateKeys(false);
|
||||
var yaml = new Yaml(new SafeConstructor(options));
|
||||
for (var name : new String[]{"overseas-business-v1.yaml", "overseas-finance-fees-v1.yaml"}) {
|
||||
String source = Files.readString(Path.of("src/main/resources/openapi", name));
|
||||
Map<String,Object> parsed = yaml.load(source);
|
||||
assertThat(parsed).containsKeys("paths", "components");
|
||||
assertThat(source).contains("PENDING_FINANCE", "FINANCE_ATTENTION", "CONFIRMED");
|
||||
}
|
||||
}
|
||||
}
|
||||
+126
@@ -0,0 +1,126 @@
|
||||
package com.baiyue.lastleg.financefee;
|
||||
|
||||
import com.baomidou.mybatisplus.core.MybatisConfiguration;
|
||||
import com.baomidou.mybatisplus.extension.spring.MybatisSqlSessionFactoryBean;
|
||||
import com.baiyue.common.core.outbox.EventDeliveryJournal;
|
||||
import com.baiyue.common.core.outbox.EventDeliveryJournal.Outcome;
|
||||
import com.baiyue.lastleg.entity.LastlegEventOutbox;
|
||||
import com.baiyue.lastleg.financefee.entity.OverseasFeeSource;
|
||||
import com.baiyue.lastleg.financefee.mapper.OverseasFeeSourceMapper;
|
||||
import com.baiyue.lastleg.financefee.repository.OverseasFeeOutboxRepository;
|
||||
import com.baiyue.lastleg.financefee.service.OverseasFeeOutboxService;
|
||||
import com.baiyue.lastleg.financefee.state.OverseasFeeCancellation;
|
||||
import com.baiyue.lastleg.mapper.LastlegEventOutboxMapper;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
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.DataSourceTransactionManager;
|
||||
import org.springframework.jdbc.datasource.DriverManagerDataSource;
|
||||
import org.springframework.test.util.ReflectionTestUtils;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
import static org.assertj.core.api.Assertions.*;
|
||||
|
||||
class OverseasFeeCancellationPersistenceTest {
|
||||
JdbcTemplate jdbc;
|
||||
LastlegEventOutboxMapper events;
|
||||
OverseasFeeSourceMapper fees;
|
||||
OverseasFeeCancellation cancellation;
|
||||
OverseasFeeOutboxRepository repository;
|
||||
EventDeliveryJournal journal;
|
||||
OverseasFeeSource source;
|
||||
LastlegEventOutbox request;
|
||||
final LocalDateTime now = LocalDateTime.of(2026, 10, 3, 1, 0);
|
||||
@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_event_outbox(id BIGINT PRIMARY KEY,customer_id VARCHAR(64),aggregate_type VARCHAR(64),aggregate_id VARCHAR(64),event_version INT,event_type VARCHAR(96),event_key VARCHAR(200) UNIQUE,payload_json TEXT,status VARCHAR(32),retry_count INT NOT NULL DEFAULT 0,next_retry_at TIMESTAMP,published_at TIMESTAMP,locked_at TIMESTAMP,locked_by VARCHAR(128),last_error VARCHAR(1000),created_at TIMESTAMP)");
|
||||
jdbc.execute("CREATE TABLE event_delivery_attempt(attempt_id VARCHAR(36) PRIMARY KEY,service_name VARCHAR(32) NOT NULL,event_type VARCHAR(96) NOT NULL,entry_id VARCHAR(128) NOT NULL,event_id VARCHAR(128),target_name VARCHAR(64) NOT NULL,started_at TIMESTAMP(3) NOT NULL,finished_at TIMESTAMP(3),duration_ms BIGINT,outcome VARCHAR(24) NOT NULL,error_code VARCHAR(64),next_retry_at TIMESTAMP(3))");
|
||||
jdbc.execute("CREATE TABLE ll_finance_fee_source(id BIGINT PRIMARY KEY,source_fee_id BIGINT,source_version INT,customer_id VARCHAR(64),source_type VARCHAR(64),source_status VARCHAR(32),updated_at TIMESTAMP,business_type VARCHAR(64),direction VARCHAR(32),fee_item_code VARCHAR(64),inbound_code VARCHAR(64),review_status VARCHAR(32))");
|
||||
jdbc.update("INSERT INTO ll_finance_fee_source(id,source_fee_id,source_version,customer_id,source_type,source_status) VALUES(10,701,1,'10001','OVERSEAS_DELIVERY_FEE','ACTIVE')");
|
||||
var config = new MybatisConfiguration(); config.addMapper(LastlegEventOutboxMapper.class); config.addMapper(OverseasFeeSourceMapper.class);
|
||||
var factory = new MybatisSqlSessionFactoryBean(); factory.setDataSource(dataSource); factory.setConfiguration(config); factory.afterPropertiesSet();
|
||||
var session = new SqlSessionTemplate(factory.getObject());
|
||||
events = session.getMapper(LastlegEventOutboxMapper.class); fees = session.getMapper(OverseasFeeSourceMapper.class);
|
||||
var json = new ObjectMapper().findAndRegisterModules();
|
||||
cancellation = new OverseasFeeCancellation(events, fees, json);
|
||||
repository = new OverseasFeeOutboxRepository(events, cancellation);
|
||||
journal = new EventDeliveryJournal(jdbc, new DataSourceTransactionManager(dataSource));
|
||||
source = fees.lockCurrent(701);
|
||||
var producer = new OverseasFeeOutboxService();
|
||||
ReflectionTestUtils.setField(producer, "mapper", events); ReflectionTestUtils.setField(producer, "objectMapper", json);
|
||||
producer.publish(source, "PUBLISHED");
|
||||
var copy = new OverseasFeeSource(); org.springframework.beans.BeanUtils.copyProperties(source, copy); copy.setSourceStatus("VOIDED");
|
||||
producer.publish(copy, "VOIDED");
|
||||
request = events.findByEventKey(OverseasFeeCancellation.eventKey(source, "VOIDED"));
|
||||
}
|
||||
@Test void waitsForOriginalPublicationAndDoesNotCallPendingARequestAVoid() {
|
||||
assertThat(cancellation.prerequisitePublished(request)).isFalse();
|
||||
assertThat(cancellation.describe(List.of(source)).get(0).getVoidStatus()).isEqualTo("PENDING_FINANCE");
|
||||
jdbc.update("UPDATE ll_event_outbox SET status='DEAD' WHERE event_key=?", OverseasFeeCancellation.eventKey(source, "PUBLISHED"));
|
||||
assertThat(cancellation.describe(List.of(source)).get(0).getVoidStatus()).isEqualTo("FINANCE_ATTENTION");
|
||||
assertThat(cancellation.prerequisitePublished(request)).isFalse();
|
||||
publishedOriginal(); assertThat(cancellation.prerequisitePublished(request)).isTrue();
|
||||
assertThat(status()).isEqualTo("ACTIVE");
|
||||
}
|
||||
@Test void waitingCancellationsCannotStarveLaterReadyPublications() {
|
||||
for (int i = 0; i < 60; i++) jdbc.update("INSERT INTO ll_event_outbox(id,event_type,event_key,status,created_at) VALUES(?,? ,?,'PENDING',?)",
|
||||
1000 + i, request.getEventType(), "BusinessFeeSourceChanged:LASTLEG:OVERSEAS_DELIVERY_FEE:" + (800 + i) + ":1:VOIDED", now.minusDays(1));
|
||||
var ready = repository.pending(50, now.plusDays(1), now.minusSeconds(60));
|
||||
assertThat(ready).hasSize(1);
|
||||
assertThat(ready.get(0).getEventKey()).isEqualTo(OverseasFeeCancellation.eventKey(source, "PUBLISHED"));
|
||||
}
|
||||
@Test void journalFailureRollsBackBothLocalVoidAndOutboxCompletionThenSameAckCanCommit() {
|
||||
publishedOriginal(); claim();
|
||||
jdbc.execute("ALTER TABLE event_delivery_attempt ADD CONSTRAINT reject_applied CHECK(outcome <> 'APPLIED')");
|
||||
assertThatThrownBy(() -> complete(Outcome.APPLIED)).isInstanceOf(RuntimeException.class);
|
||||
assertThat(status()).isEqualTo("ACTIVE");
|
||||
assertThat(events.selectById(request.getId()).getStatus()).isEqualTo("PROCESSING");
|
||||
jdbc.execute("ALTER TABLE event_delivery_attempt DROP CONSTRAINT reject_applied");
|
||||
assertThat(complete(Outcome.DUPLICATE)).isTrue();
|
||||
assertThat(status()).isEqualTo("VOIDED");
|
||||
assertThat(cancellation.describe(List.of(fees.lockCurrent(701))).get(0).getVoidStatus()).isEqualTo("CONFIRMED");
|
||||
assertThat(complete(Outcome.DUPLICATE)).isFalse();
|
||||
}
|
||||
@Test void rejectedOrUnknownConsumerOutcomeKeepsTheFinancialSourceActive() {
|
||||
publishedOriginal(); claim();
|
||||
assertThat(journal.complete("worker", Outcome.DEAD, now, "PERMANENT_FAILURE", null,
|
||||
() -> repository.dead(request.getId(), "worker", 1, "来源已进入有效账单"))).isTrue();
|
||||
assertThat(status()).isEqualTo("ACTIVE");
|
||||
assertThat(cancellation.describe(List.of(source)).get(0).getVoidStatus()).isEqualTo("FINANCE_ATTENTION");
|
||||
assertThat(repository.published(request.getId(), "stale-worker", now)).isZero();
|
||||
assertThat(status()).isEqualTo("ACTIVE");
|
||||
}
|
||||
@Test void wrongSourceVersionCannotAcknowledgeOrCompleteTheEvent() {
|
||||
publishedOriginal(); claim();
|
||||
jdbc.update("UPDATE ll_finance_fee_source SET source_version=2 WHERE id=10");
|
||||
assertThatThrownBy(() -> complete(Outcome.APPLIED)).hasMessageContaining("当前业务版本不一致");
|
||||
assertThat(status()).isEqualTo("ACTIVE");
|
||||
assertThat(events.selectById(request.getId()).getStatus()).isEqualTo("PROCESSING");
|
||||
}
|
||||
@Test void aNewShipmentWaitsForThePreviousActualFreightToBeVoidedAndUsesTheLatestVersion() {
|
||||
var sourceService = new com.baiyue.lastleg.financefee.service.OverseasFeeSourceService(fees,
|
||||
org.mockito.Mockito.mock(com.baiyue.lastleg.financefee.mapper.OverseasFeeCommandMapper.class),
|
||||
org.mockito.Mockito.mock(com.baiyue.lastleg.financefee.service.OverseasFeeBusinessGuard.class),
|
||||
org.mockito.Mockito.mock(OverseasFeeOutboxService.class),
|
||||
org.mockito.Mockito.mock(com.baiyue.lastleg.financefee.service.OverseasFeeCurrencyConverter.class),
|
||||
new ObjectMapper().findAndRegisterModules(), cancellation);
|
||||
jdbc.update("UPDATE ll_finance_fee_source SET business_type='DELIVERY_ORDER',direction='RECEIVABLE',fee_item_code='DELIVERY_FEE',inbound_code='IN-1',review_status='APPROVED' WHERE id=10");
|
||||
assertThatThrownBy(() -> sourceService.requireNoActiveDeliveryReceivables("10001", List.of("IN-1"))).hasMessageContaining("尚未确认作废");
|
||||
assertThatCode(() -> sourceService.requireNoActiveDeliveryReceivables("10002", List.of("IN-1"))).doesNotThrowAnyException();
|
||||
assertThatCode(() -> sourceService.requireNoActiveDeliveryReceivables("10001", List.of("IN-OTHER"))).doesNotThrowAnyException();
|
||||
jdbc.update("UPDATE ll_finance_fee_source SET source_status='SUPERSEDED' WHERE id=10");
|
||||
jdbc.update("INSERT INTO ll_finance_fee_source(id,source_fee_id,source_version,customer_id,source_type,source_status,business_type,direction,fee_item_code,inbound_code,review_status) VALUES(11,701,2,'10001','OVERSEAS_DELIVERY_FEE','ACTIVE','DELIVERY_ORDER','RECEIVABLE','DELIVERY_FEE','IN-1','PENDING')");
|
||||
assertThatThrownBy(() -> sourceService.requireNoActiveDeliveryReceivables("10001", List.of("IN-1"))).hasMessageContaining("尚未确认作废");
|
||||
jdbc.update("UPDATE ll_finance_fee_source SET source_status='VOIDED' WHERE id=11");
|
||||
assertThatCode(() -> sourceService.requireNoActiveDeliveryReceivables("10001", List.of("IN-1"))).doesNotThrowAnyException();
|
||||
}
|
||||
void publishedOriginal() { jdbc.update("UPDATE ll_event_outbox SET status='PUBLISHED' WHERE event_key=?", OverseasFeeCancellation.eventKey(source, "PUBLISHED")); }
|
||||
void claim() { assertThat(journal.begin("worker", "lastleg-service", request.getEventType(), String.valueOf(request.getId()), null, "finance", now,
|
||||
() -> repository.claim(request.getId(), "worker", now))).isTrue(); }
|
||||
boolean complete(Outcome outcome) { return journal.complete("worker", outcome, now, null, null, () -> repository.published(request.getId(), "worker", now)); }
|
||||
String status() { return jdbc.queryForObject("SELECT source_status FROM ll_finance_fee_source WHERE id=10", String.class); }
|
||||
}
|
||||
+1
@@ -19,6 +19,7 @@ import static org.mockito.Mockito.*;
|
||||
class OverseasFeeOutboxRetryTest {
|
||||
@Test void failedPayloadAtLimitGoesDeadAndExhaustedLeaseNeverCallsClient() {
|
||||
var repository = mock(OverseasFeeOutboxRepository.class);
|
||||
when(repository.prerequisitePublished(any())).thenReturn(true);
|
||||
var client = mock(DirectEventClient.class);
|
||||
var relay = new OverseasFeeOutboxRelay();
|
||||
var dataSource = new DriverManagerDataSource("jdbc:h2:mem:" + UUID.randomUUID() + ";MODE=MySQL;DB_CLOSE_DELAY=-1", "sa", "");
|
||||
|
||||
+2
-1
@@ -4,6 +4,7 @@ import com.baiyue.lastleg.financefee.dto.OverseasFeeSourceDtos.SaveRequest;
|
||||
import com.baiyue.lastleg.financefee.entity.OverseasFeeSource;
|
||||
import com.baiyue.lastleg.financefee.mapper.OverseasFeeCommandMapper;
|
||||
import com.baiyue.lastleg.financefee.mapper.OverseasFeeSourceMapper;
|
||||
import com.baiyue.lastleg.financefee.state.OverseasFeeCancellation;
|
||||
import com.baiyue.lastleg.masterdata.exchange.mapper.MonthlyExchangeRateMapper;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import org.junit.jupiter.api.Test;
|
||||
@@ -25,7 +26,7 @@ class OverseasContainerFeeSourceServiceTest {
|
||||
private final OverseasFeeSourceService service = new OverseasFeeSourceService(
|
||||
mapper, commands, guard, outbox,
|
||||
new OverseasFeeCurrencyConverter(mock(MonthlyExchangeRateMapper.class)),
|
||||
new ObjectMapper().findAndRegisterModules());
|
||||
new ObjectMapper().findAndRegisterModules(), mock(OverseasFeeCancellation.class));
|
||||
|
||||
@Test
|
||||
void createsContainerFeeUsingTheRealContainerAsItsStableBusinessReference() {
|
||||
|
||||
+45
@@ -0,0 +1,45 @@
|
||||
package com.baiyue.lastleg.financefee.service;
|
||||
|
||||
import com.baiyue.lastleg.financefee.dto.OverseasFeeSourceDtos;
|
||||
import com.baiyue.lastleg.financefee.entity.OverseasFeeSource;
|
||||
import com.baiyue.lastleg.financefee.mapper.OverseasFeeCommandMapper;
|
||||
import com.baiyue.lastleg.financefee.mapper.OverseasFeeSourceMapper;
|
||||
import com.baiyue.lastleg.financefee.state.OverseasFeeCancellation;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.mockito.ArgumentCaptor;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
import static org.assertj.core.api.Assertions.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
class OverseasFeeCancellationRequestTest {
|
||||
final OverseasFeeSourceMapper fees = mock(OverseasFeeSourceMapper.class);
|
||||
final OverseasFeeCommandMapper commands = mock(OverseasFeeCommandMapper.class);
|
||||
final OverseasFeeOutboxService outbox = mock(OverseasFeeOutboxService.class);
|
||||
final OverseasFeeCancellation cancellation = mock(OverseasFeeCancellation.class);
|
||||
final OverseasFeeSourceService service = new OverseasFeeSourceService(fees, commands,
|
||||
mock(OverseasFeeBusinessGuard.class), outbox, mock(OverseasFeeCurrencyConverter.class),
|
||||
new ObjectMapper().findAndRegisterModules(), cancellation);
|
||||
@Test void requestPreservesActiveSourceAndPublishesASeparateVoidSnapshot() {
|
||||
var fee = approved(); when(fees.lockCurrent(701)).thenReturn(fee);
|
||||
var result = service.voidSource(701, "operator", new OverseasFeeSourceDtos.VoidRequest(1, "取消未履行运输", "cancel-1"));
|
||||
assertThat(result.getSourceStatus()).isEqualTo("ACTIVE");
|
||||
assertThat(result.getVoidStatus()).isEqualTo("PENDING_FINANCE");
|
||||
var emitted = ArgumentCaptor.forClass(OverseasFeeSource.class);
|
||||
verify(outbox).publish(emitted.capture(), eq("VOIDED"));
|
||||
assertThat(emitted.getValue()).isNotSameAs(result);
|
||||
assertThat(emitted.getValue().getSourceStatus()).isEqualTo("VOIDED");
|
||||
verify(fees).updateById(result); verify(commands).insert(any(com.baiyue.lastleg.financefee.entity.OverseasFeeCommand.class));
|
||||
}
|
||||
@Test void aPendingVoidCannotBeReplacedByAnAdjustment() {
|
||||
var fee = approved(); when(fees.lockCurrent(701)).thenReturn(fee); when(cancellation.requested(fee)).thenReturn(true);
|
||||
assertThatThrownBy(() -> service.adjust(701, "operator", new OverseasFeeSourceDtos.AdjustRequest(1, null, "adjust-1")))
|
||||
.isInstanceOf(ResponseStatusException.class).hasMessageContaining("已申请作废");
|
||||
verifyNoInteractions(outbox); verify(fees, never()).updateById(any(OverseasFeeSource.class));
|
||||
}
|
||||
static OverseasFeeSource approved() {
|
||||
var fee = new OverseasFeeSource(); fee.setId(10L); fee.setSourceFeeId(701L); fee.setSourceVersion(1);
|
||||
fee.setCustomerId("10001"); fee.setSourceType("OVERSEAS_DELIVERY_FEE"); fee.setSourceStatus("ACTIVE");
|
||||
fee.setReviewStatus("APPROVED"); fee.setPublicationStatus("PUBLISHED"); return fee;
|
||||
}
|
||||
}
|
||||
+23
@@ -0,0 +1,23 @@
|
||||
package com.baiyue.lastleg.fleet.customer;
|
||||
import com.baiyue.common.core.api.ApiResponse;
|
||||
import com.baiyue.common.core.api.PageResult;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import java.util.List;
|
||||
import static org.assertj.core.api.Assertions.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
class FleetCustomerDirectoryTest {
|
||||
@Test void exactActiveCustomerAndCurrentEligibilityAreRequired() {
|
||||
var client=mock(FleetCustomerClient.class); var directory=new FleetCustomerDirectory(client,"internal-test-token");
|
||||
when(client.customers("internal-test-token",1,30,null,"ACTIVE",9L)).thenReturn(ApiResponse.success(new PageResult<>(List.of(new FleetCustomerClient.Customer(9L,"00009","客户甲","ACTIVE",null,null)),1,30,1,1)));
|
||||
when(client.eligibility("internal-test-token",9L)).thenReturn(ApiResponse.success(new FleetCustomerClient.Eligibility(true,"ACTIVE",null)));
|
||||
assertThat(directory.required(9).code()).isEqualTo("00009");
|
||||
when(client.eligibility("internal-test-token",9L)).thenReturn(ApiResponse.success(new FleetCustomerClient.Eligibility(false,"PENDING","待审核")));
|
||||
assertThatThrownBy(()->directory.required(9)).hasMessageContaining("下单资格");
|
||||
}
|
||||
@Test void failedDirectoryDoesNotInventCustomerOrPermission() {
|
||||
var client=mock(FleetCustomerClient.class); var directory=new FleetCustomerDirectory(client,"internal-test-token");
|
||||
when(client.customers(anyString(),anyLong(),anyLong(),any(),anyString(),any())).thenThrow(new IllegalStateException("network"));
|
||||
assertThatThrownBy(()->directory.required(9)).hasMessageContaining("暂不可用");
|
||||
verify(client,never()).eligibility(anyString(),anyLong());
|
||||
}
|
||||
}
|
||||
+60
@@ -0,0 +1,60 @@
|
||||
package com.baiyue.lastleg.fleet.domain;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import java.time.Instant;
|
||||
import java.time.LocalDate;
|
||||
import java.time.ZoneId;
|
||||
import static com.baiyue.lastleg.fleet.domain.DriverQualificationPolicy.*;
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
class DriverQualificationPolicyTest {
|
||||
private final Instant now = Instant.parse("2026-10-02T16:00:00Z");
|
||||
private final ZoneId zone = ZoneId.of("America/Los_Angeles");
|
||||
|
||||
private Driver driver(LocalDate expiry) { return new Driver(true, ReviewStatus.APPROVED, true, expiry, zone); }
|
||||
|
||||
@Test void expiringWithinThirtyDaysAllowsTripEndingBeforeExpiry() {
|
||||
var result = evaluate(driver(LocalDate.of(2026, 10, 5)), now, now.plusSeconds(3600));
|
||||
assertThat(result.canDispatch()).isTrue();
|
||||
assertThat(result.expiringSoon()).isTrue();
|
||||
}
|
||||
|
||||
@Test void thirtyDayWindowIncludesBoundaryButNotThirtyOneDays() {
|
||||
assertThat(evaluate(driver(LocalDate.of(2026, 11, 1)), now, now.plusSeconds(60)).expiringSoon()).isTrue();
|
||||
assertThat(evaluate(driver(LocalDate.of(2026, 11, 2)), now, now.plusSeconds(60)).expiringSoon()).isFalse();
|
||||
}
|
||||
|
||||
@Test void usesLicenceZoneAndIncludesEntireExpiryDate() {
|
||||
var d = driver(LocalDate.of(2026, 10, 2));
|
||||
assertThat(evaluate(d, now, Instant.parse("2026-10-03T06:59:59Z")).canDispatch()).isTrue();
|
||||
assertThat(evaluate(d, now, Instant.parse("2026-10-03T07:00:00Z")).reason()).isEqualTo(Reason.EXPIRES_DURING_TRIP);
|
||||
}
|
||||
|
||||
@Test void daylightSavingBoundaryUsesCalendarDayInsteadOfFixedHours() {
|
||||
var d = driver(LocalDate.of(2026, 11, 1));
|
||||
Instant start = Instant.parse("2026-11-01T07:00:00Z");
|
||||
assertThat(evaluate(d, start, Instant.parse("2026-11-02T07:59:59Z")).canDispatch()).isTrue();
|
||||
assertThat(evaluate(d, start, Instant.parse("2026-11-02T08:00:00Z")).canDispatch()).isFalse();
|
||||
}
|
||||
|
||||
@Test void rejectsExpiredAndUnknownDatesOrEndTimes() {
|
||||
assertThat(evaluate(driver(LocalDate.of(2026, 10, 1)), now, now.plusSeconds(60)).reason()).isEqualTo(Reason.EXPIRED);
|
||||
assertThat(evaluate(driver(null), now, now.plusSeconds(60)).reason()).isEqualTo(Reason.MISSING_EXPIRY);
|
||||
assertThat(evaluate(driver(LocalDate.of(2027, 1, 1)), now, null).reason()).isEqualTo(Reason.MISSING_TRIP_END);
|
||||
assertThat(evaluate(driver(LocalDate.of(2027, 1, 1)), now, now).reason()).isEqualTo(Reason.INVALID_TRIP_END);
|
||||
}
|
||||
|
||||
@Test void reviewAndDocumentGatesCannotBeBypassedWithValidDate() {
|
||||
LocalDate expiry = LocalDate.of(2027, 1, 1);
|
||||
assertThat(evaluate(new Driver(false, ReviewStatus.APPROVED, true, expiry, zone), now, now.plusSeconds(60)).reason()).isEqualTo(Reason.INACTIVE);
|
||||
assertThat(evaluate(new Driver(true, ReviewStatus.APPROVED, false, expiry, zone), now, now.plusSeconds(60)).reason()).isEqualTo(Reason.MISSING_LICENSE);
|
||||
assertThat(evaluate(new Driver(true, ReviewStatus.PENDING, true, expiry, zone), now, now.plusSeconds(60)).reason()).isEqualTo(Reason.PENDING_REVIEW);
|
||||
assertThat(evaluate(new Driver(true, ReviewStatus.REJECTED, true, expiry, zone), now, now.plusSeconds(60)).reason()).isEqualTo(Reason.REJECTED);
|
||||
}
|
||||
|
||||
@Test void delayedTripIsReevaluatedRatherThanReusingAnEarlierApproval() {
|
||||
var d = driver(LocalDate.of(2026, 10, 5));
|
||||
assertThat(evaluate(d, now, now.plusSeconds(3600)).canDispatch()).isTrue();
|
||||
assertThat(evaluate(d, now, Instant.parse("2026-10-06T09:00:00Z")).reason()).isEqualTo(Reason.EXPIRES_DURING_TRIP);
|
||||
}
|
||||
}
|
||||
+56
@@ -0,0 +1,56 @@
|
||||
package com.baiyue.lastleg.fleet.domain;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import java.math.BigDecimal;
|
||||
import java.util.List;
|
||||
import static com.baiyue.lastleg.fleet.domain.TripCapacityPolicy.*;
|
||||
import static org.assertj.core.api.Assertions.*;
|
||||
|
||||
class TripCapacityPolicyTest {
|
||||
private Load load(int pallets, String kg, String cbm) { return new Load(pallets, new BigDecimal(kg), new BigDecimal(cbm)); }
|
||||
private final Load capacity = new Load(30, new BigDecimal("18000"), new BigDecimal("70"));
|
||||
|
||||
@Test void combinesOwnedAndExternalCargoWithinAllCapacityLimits() {
|
||||
var result = calculate(2, capacity, List.of(new Booking("OWN-1", 0, 1, load(20, "10000", "40")),
|
||||
new Booking("EXT-1", 0, 1, load(10, "8000", "30"))));
|
||||
assertThat(result.get(0).remaining()).isEqualTo(load(0, "0", "0"));
|
||||
}
|
||||
|
||||
@Test void releasesUnloadedCargoBeforeNextPickupAtSameStop() {
|
||||
var result = calculate(3, capacity, List.of(new Booking("A", 0, 1, load(30, "18000", "70")),
|
||||
new Booking("B", 1, 2, load(30, "18000", "70"))));
|
||||
assertThat(result).hasSize(2);
|
||||
assertThat(result).allSatisfy(segment -> assertThat(segment.occupied().palletSpaces()).isEqualTo(30));
|
||||
}
|
||||
|
||||
@Test void rejectsOverlappingSegmentsEvenWhenSomeStopsHaveSpareCapacity() {
|
||||
assertThatThrownBy(() -> calculate(4, capacity, List.of(new Booking("A", 0, 2, load(20, "8000", "20")),
|
||||
new Booking("B", 1, 3, load(15, "5000", "20")))))
|
||||
.isInstanceOf(IllegalArgumentException.class).hasMessageContaining("第2段");
|
||||
}
|
||||
|
||||
@Test void rejectsOverweightOrOversizeEvenWithFreePalletSpaces() {
|
||||
assertThatThrownBy(() -> calculate(2, capacity, List.of(new Booking("A", 0, 1, load(1, "18001", "1")))))
|
||||
.isInstanceOf(IllegalArgumentException.class);
|
||||
assertThatThrownBy(() -> calculate(2, capacity, List.of(new Booking("A", 0, 1, load(1, "1", "70.01")))))
|
||||
.isInstanceOf(IllegalArgumentException.class);
|
||||
}
|
||||
|
||||
@Test void rejectsDuplicateBookingAndStopsOutsideRoute() {
|
||||
var booking = new Booking("A", 0, 1, load(1, "10", "1"));
|
||||
assertThatThrownBy(() -> calculate(2, capacity, List.of(booking, booking))).hasMessageContaining("重复");
|
||||
assertThatThrownBy(() -> calculate(2, capacity, List.of(new Booking("B", 0, 2, load(1, "10", "1")))))
|
||||
.hasMessageContaining("不属于");
|
||||
}
|
||||
|
||||
@Test void refusesUnknownCapacityAndMissingCargoMeasurements() {
|
||||
assertThatThrownBy(() -> calculate(2, load(30, "0", "70"), List.of())).hasMessageContaining("大于零");
|
||||
assertThatThrownBy(() -> new Booking("A", 0, 1, load(1, "0", "1"))).hasMessageContaining("有效");
|
||||
assertThatThrownBy(() -> new Booking("A", 1, 1, load(1, "10", "1"))).hasMessageContaining("之后");
|
||||
assertThatThrownBy(() -> load(1, "-1", "1")).hasMessageContaining("非负");
|
||||
}
|
||||
|
||||
@Test void emptyTripRetainsCapacityOnEachSegment() {
|
||||
assertThat(calculate(3, capacity, List.of())).allSatisfy(segment -> assertThat(segment.remaining()).isEqualTo(capacity));
|
||||
}
|
||||
}
|
||||
+46
@@ -0,0 +1,46 @@
|
||||
package com.baiyue.lastleg.fleet.domain;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import java.math.BigDecimal;
|
||||
import java.util.List;
|
||||
import static com.baiyue.lastleg.fleet.domain.TripCostAllocationPolicy.*;
|
||||
import static org.assertj.core.api.Assertions.*;
|
||||
|
||||
class TripCostAllocationPolicyTest {
|
||||
@Test void allocatesTwentyOwnedAndTenExternalPalletsWithoutCreatingRevenue() {
|
||||
assertThat(allocate(new BigDecimal("600.00"), "USD", List.of(new Share("OWN-1", 20), new Share("EXT-1", 10))))
|
||||
.containsExactly(new Allocation("EXT-1", new BigDecimal("200.00")), new Allocation("OWN-1", new BigDecimal("400.00")));
|
||||
}
|
||||
|
||||
@Test void roundingConservesTotalAndIsIndependentOfRequestOrder() {
|
||||
var first = allocate(new BigDecimal("10.00"), "USD", List.of(new Share("C", 1), new Share("A", 1), new Share("B", 1)));
|
||||
var second = allocate(new BigDecimal("10.00"), "USD", List.of(new Share("B", 1), new Share("C", 1), new Share("A", 1)));
|
||||
assertThat(first).isEqualTo(second);
|
||||
assertThat(first).containsExactly(new Allocation("A", new BigDecimal("3.34")),
|
||||
new Allocation("B", new BigDecimal("3.33")), new Allocation("C", new BigDecimal("3.33")));
|
||||
assertThat(first.stream().map(Allocation::amount).reduce(BigDecimal.ZERO, BigDecimal::add)).isEqualByComparingTo("10.00");
|
||||
}
|
||||
|
||||
@Test void singleCentAndZeroCostRemainExact() {
|
||||
var shares = List.of(new Share("A", 1), new Share("B", 2));
|
||||
assertThat(allocate(new BigDecimal("0.01"), "CNY", shares))
|
||||
.containsExactly(new Allocation("A", new BigDecimal("0.00")), new Allocation("B", new BigDecimal("0.01")));
|
||||
assertThat(allocate(BigDecimal.ZERO, "CNY", shares)).allSatisfy(a -> assertThat(a.amount()).isZero());
|
||||
}
|
||||
|
||||
@Test void rejectsUnmeasuredDuplicatedAndInvalidCostInputs() {
|
||||
var shares = List.of(new Share("A", 1));
|
||||
assertThatThrownBy(() -> allocate(new BigDecimal("1.001"), "USD", shares)).hasMessageContaining("两位");
|
||||
assertThatThrownBy(() -> allocate(BigDecimal.ONE, "EUR", shares)).hasMessageContaining("币种");
|
||||
assertThatThrownBy(() -> allocate(BigDecimal.ONE, null, shares)).hasMessageContaining("币种");
|
||||
assertThatThrownBy(() -> allocate(BigDecimal.ONE.negate(), "USD", shares)).hasMessageContaining("负数");
|
||||
assertThatThrownBy(() -> allocate(BigDecimal.ONE, "USD", List.of())).hasMessageContaining("没有");
|
||||
assertThatThrownBy(() -> allocate(BigDecimal.ONE, "USD", List.of(new Share("A", 1), new Share("A", 2)))).hasMessageContaining("重复");
|
||||
}
|
||||
|
||||
@Test void handlesLargeValuesWithoutIntegerOverflow() {
|
||||
var amount = new BigDecimal("99999999999999.99");
|
||||
var result = allocate(amount, "USD", List.of(new Share("A", Long.MAX_VALUE), new Share("B", Long.MAX_VALUE)));
|
||||
assertThat(result.stream().map(Allocation::amount).reduce(BigDecimal.ZERO, BigDecimal::add)).isEqualByComparingTo(amount);
|
||||
}
|
||||
}
|
||||
+71
@@ -0,0 +1,71 @@
|
||||
package com.baiyue.lastleg.fleet.driver;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.security.access.prepost.PreAuthorize;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
import org.yaml.snakeyaml.Yaml;
|
||||
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.Map;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
import static org.assertj.core.api.Assertions.*;
|
||||
|
||||
class DriverApiContractTest {
|
||||
@Test void everyControllerOperationHasOpenApiAndRegisteredPermission() throws Exception {
|
||||
String yaml = Files.readString(Path.of("src/main/resources/openapi/fleet-drivers-v1.yaml"));
|
||||
Map<String, Object> document = new Yaml().load(yaml);
|
||||
Map<?, ?> paths = (Map<?, ?>) document.get("paths");
|
||||
String seeds = Files.readString(Path.of("../iam/src/main/resources/db/lastleg-permissions.sql"));
|
||||
String unified = Files.readString(Path.of("../deploy/sql/头程数据库统一增量-MySQL8.sql"));
|
||||
String base = PlatformDriverController.class.getAnnotation(RequestMapping.class).value()[0];
|
||||
int operationCount = 0;
|
||||
for (var method : PlatformDriverController.class.getDeclaredMethods()) {
|
||||
String verb = null;
|
||||
String[] relative = null;
|
||||
if (method.isAnnotationPresent(GetMapping.class)) { verb = "get"; relative = method.getAnnotation(GetMapping.class).value(); }
|
||||
if (method.isAnnotationPresent(PostMapping.class)) { verb = "post"; relative = method.getAnnotation(PostMapping.class).value(); }
|
||||
if (method.isAnnotationPresent(PutMapping.class)) { verb = "put"; relative = method.getAnnotation(PutMapping.class).value(); }
|
||||
if (method.isAnnotationPresent(PatchMapping.class)) { verb = "patch"; relative = method.getAnnotation(PatchMapping.class).value(); }
|
||||
if (verb == null) continue;
|
||||
operationCount++;
|
||||
String path = base + (relative.length == 0 ? "" : relative[0]);
|
||||
assertThat(paths.containsKey(path)).as(path).isTrue();
|
||||
Map<?, ?> operation = (Map<?, ?>) ((Map<?, ?>) paths.get(path)).get(verb);
|
||||
assertThat(operation).as(verb + " " + path).isNotNull();
|
||||
String expression = method.getAnnotation(PreAuthorize.class).value();
|
||||
String permission = expression.substring(expression.indexOf('\'') + 1, expression.lastIndexOf('\''));
|
||||
assertThat(operation.get("description").toString()).contains(permission);
|
||||
assertThat(seeds).contains("'" + permission + "'");
|
||||
assertThat(unified).contains("'" + permission + "'");
|
||||
}
|
||||
assertThat(operationCount).isEqualTo(12);
|
||||
verifyRefs(document, document);
|
||||
}
|
||||
|
||||
@Test void driverTablesMatchDeploymentBaselineAndKeepUniqueHistoryVersions() throws Exception {
|
||||
String module = Files.readString(Path.of("src/main/resources/db/lastleg.sql"));
|
||||
String deployed = Files.readString(Path.of("../deploy/sql/海外区域统一增量-MySQL8.sql"));
|
||||
for (String table : new String[]{"ll_fleet_driver", "ll_fleet_driver_history"}) {
|
||||
var pattern = Pattern.compile("(?s)CREATE TABLE " + table + " \\(.*?;");
|
||||
var a = pattern.matcher(module); var b = pattern.matcher(deployed);
|
||||
assertThat(a.find()).isTrue(); assertThat(b.find()).isTrue();
|
||||
assertThat(a.group().replace("\r\n", "\n")).isEqualTo(b.group().replace("\r\n", "\n"));
|
||||
}
|
||||
assertThat(deployed).contains("UNIQUE KEY uk_fleet_driver_history_version (driver_id,record_version)")
|
||||
.contains("FOREIGN KEY (driver_id) REFERENCES ll_fleet_driver(id)");
|
||||
}
|
||||
|
||||
private static void verifyRefs(Object node, Map<String, Object> document) {
|
||||
if (node instanceof Map<?, ?> map) {
|
||||
if (map.get("$ref") instanceof String ref) {
|
||||
assertThat(ref).startsWith("#/");
|
||||
Object value = document;
|
||||
for (String key : ref.substring(2).split("/")) value = ((Map<?, ?>) value).get(key);
|
||||
assertThat(value).as(ref).isNotNull();
|
||||
}
|
||||
map.values().forEach(value -> verifyRefs(value, document));
|
||||
} else if (node instanceof Iterable<?> values) values.forEach(value -> verifyRefs(value, document));
|
||||
}
|
||||
}
|
||||
+61
@@ -0,0 +1,61 @@
|
||||
package com.baiyue.lastleg.fleet.driver;
|
||||
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.context.annotation.AnnotationConfigApplicationContext;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.security.access.AccessDeniedException;
|
||||
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
|
||||
import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity;
|
||||
import org.springframework.security.core.authority.SimpleGrantedAuthority;
|
||||
import org.springframework.security.core.context.SecurityContextHolder;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import static org.assertj.core.api.Assertions.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
class DriverAuthorizationTest {
|
||||
@AfterEach void clearIdentity() { SecurityContextHolder.clearContext(); }
|
||||
|
||||
@Test void listPermissionReadsDetailsButCannotModifyOrApprove() {
|
||||
try (var context = new AnnotationConfigApplicationContext(Config.class)) {
|
||||
var controller = context.getBean(PlatformDriverController.class);
|
||||
var service = context.getBean(DriverService.class);
|
||||
authenticate("platform:overseas-master-data:driver:list");
|
||||
controller.detail(71, null);
|
||||
verify(service).detail(71, null);
|
||||
assertThatThrownBy(() -> controller.create(null, null)).isInstanceOf(AccessDeniedException.class);
|
||||
assertThatThrownBy(() -> controller.update(null, 71, null)).isInstanceOf(AccessDeniedException.class);
|
||||
assertThatThrownBy(() -> controller.review(null, 71, null)).isInstanceOf(AccessDeniedException.class);
|
||||
assertThatThrownBy(() -> controller.status(null, 71, null)).isInstanceOf(AccessDeniedException.class);
|
||||
assertThatThrownBy(() -> controller.submit(null, 71, null)).isInstanceOf(AccessDeniedException.class);
|
||||
assertThatThrownBy(() -> controller.upload(null, 71, 0, null)).isInstanceOf(AccessDeniedException.class);
|
||||
verifyNoMoreInteractions(service);
|
||||
}
|
||||
}
|
||||
|
||||
@Test void unrelatedPermissionCannotReadSensitiveDriverDetailsOrAttachments() {
|
||||
try (var context = new AnnotationConfigApplicationContext(Config.class)) {
|
||||
var controller = context.getBean(PlatformDriverController.class);
|
||||
authenticate("platform:overseas-master-data:vehicle:list");
|
||||
assertThatThrownBy(() -> controller.detail(71, null)).isInstanceOf(AccessDeniedException.class);
|
||||
assertThatThrownBy(() -> controller.history(71, 1, 20)).isInstanceOf(AccessDeniedException.class);
|
||||
assertThatThrownBy(() -> controller.download(71, 81)).isInstanceOf(AccessDeniedException.class);
|
||||
verifyNoInteractions(context.getBean(DriverService.class));
|
||||
}
|
||||
}
|
||||
|
||||
private static void authenticate(String permission) {
|
||||
SecurityContextHolder.getContext().setAuthentication(new UsernamePasswordAuthenticationToken("operator", "unused",
|
||||
List.of(new SimpleGrantedAuthority(permission))));
|
||||
}
|
||||
|
||||
@Configuration
|
||||
@EnableMethodSecurity
|
||||
static class Config {
|
||||
@Bean DriverService service() { return mock(DriverService.class); }
|
||||
@Bean PlatformDriverController controller(DriverService service) { return new PlatformDriverController(service); }
|
||||
}
|
||||
}
|
||||
+130
@@ -0,0 +1,130 @@
|
||||
package com.baiyue.lastleg.fleet.driver;
|
||||
|
||||
import com.baomidou.mybatisplus.core.MybatisConfiguration;
|
||||
import com.baomidou.mybatisplus.extension.spring.MybatisSqlSessionFactoryBean;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.mybatis.spring.SqlSessionTemplate;
|
||||
import org.springframework.aop.framework.ProxyFactory;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.jdbc.datasource.DataSourceTransactionManager;
|
||||
import org.springframework.jdbc.datasource.DriverManagerDataSource;
|
||||
import org.springframework.transaction.annotation.AnnotationTransactionAttributeSource;
|
||||
import org.springframework.transaction.interceptor.TransactionInterceptor;
|
||||
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.time.Clock;
|
||||
import java.time.Instant;
|
||||
import java.time.ZoneOffset;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.*;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
import static org.assertj.core.api.Assertions.*;
|
||||
import static org.mockito.Mockito.mock;
|
||||
|
||||
class DriverPersistenceTest {
|
||||
private JdbcTemplate jdbc;
|
||||
private DriverService service;
|
||||
|
||||
@BeforeEach void createDatabaseAndTransactionalService() throws Exception {
|
||||
var ds = new DriverManagerDataSource("jdbc:h2:mem:" + UUID.randomUUID() + ";MODE=MySQL;DB_CLOSE_DELAY=-1", "sa", "");
|
||||
jdbc = new JdbcTemplate(ds);
|
||||
String schema = Files.readString(Path.of("src/main/resources/db/lastleg.sql"));
|
||||
for (String table : new String[]{"ll_fleet_driver", "ll_fleet_driver_history"}) {
|
||||
var match = Pattern.compile("(?s)CREATE TABLE " + table + " \\(.*?;", Pattern.MULTILINE).matcher(schema);
|
||||
assertThat(match.find()).isTrue();
|
||||
// H2 JSON encodes bound strings differently; production schema is checked separately against MySQL baseline.
|
||||
jdbc.execute(match.group().replace("snapshot_json JSON", "snapshot_json LONGTEXT"));
|
||||
}
|
||||
var configuration = new MybatisConfiguration();
|
||||
var pagination = new com.baomidou.mybatisplus.extension.plugins.MybatisPlusInterceptor();
|
||||
pagination.addInnerInterceptor(new com.baomidou.mybatisplus.extension.plugins.inner.PaginationInnerInterceptor(com.baomidou.mybatisplus.annotation.DbType.H2));
|
||||
configuration.addInterceptor(pagination);
|
||||
configuration.addMapper(DriverMapper.class);
|
||||
configuration.addMapper(DriverHistoryMapper.class);
|
||||
var factory = new MybatisSqlSessionFactoryBean();
|
||||
factory.setDataSource(ds);
|
||||
factory.setConfiguration(configuration);
|
||||
var session = new SqlSessionTemplate(factory.getObject());
|
||||
var target = new DriverService(session.getMapper(DriverMapper.class), session.getMapper(DriverHistoryMapper.class),
|
||||
mock(DriverFileGateway.class), new ObjectMapper().registerModule(new JavaTimeModule()),
|
||||
Clock.fixed(Instant.parse("2026-10-02T12:00:00Z"), ZoneOffset.UTC), mock(FleetWarehouseDirectory.class));
|
||||
var proxy = new ProxyFactory(target);
|
||||
proxy.setProxyTargetClass(true);
|
||||
proxy.addAdvice(new TransactionInterceptor(new DataSourceTransactionManager(ds), new AnnotationTransactionAttributeSource()));
|
||||
service = (DriverService) proxy.getProxy();
|
||||
}
|
||||
|
||||
@Test void historyFailureRollsBackDriverStatusAndVersion() {
|
||||
var driver = service.create("operator", draft("初始备注"));
|
||||
jdbc.execute("ALTER TABLE ll_fleet_driver_history ADD CONSTRAINT reject_deactivate CHECK(action <> 'DEACTIVATE')");
|
||||
assertThatThrownBy(() -> service.changeStatus(Long.parseLong(driver.id()), "e", new DriverModels.ChangeStatus(0L, false, "暂停")))
|
||||
.isInstanceOf(RuntimeException.class);
|
||||
var stored = service.detail(Long.parseLong(driver.id()), null);
|
||||
assertThat(stored.active()).isTrue();
|
||||
assertThat(stored.recordVersion()).isZero();
|
||||
assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM ll_fleet_driver_history", Integer.class)).isEqualTo(1);
|
||||
}
|
||||
|
||||
@Test void clearingOptionalFieldsPersistsAndHistoryRemainsImmutable() {
|
||||
var driver = service.create("operator", draft("原始备注"));
|
||||
long id = Long.parseLong(driver.id());
|
||||
service.update(id, "e", new DriverModels.Update(0L, draft(null)));
|
||||
assertThat(service.detail(id, null).details().remark()).isNull();
|
||||
assertThat(service.history(id, 1, 20).records()).hasSize(2);
|
||||
assertThat(service.history(id, 1, 20).records().get(1).snapshot().details().remark()).isEqualTo("原始备注");
|
||||
}
|
||||
|
||||
@Test void concurrentSameVersionWritesProduceOneWinnerAndOneConflict() throws Exception {
|
||||
var driver = service.create("operator", draft(null));
|
||||
long id = Long.parseLong(driver.id());
|
||||
var executor = Executors.newFixedThreadPool(2);
|
||||
var start = new CountDownLatch(1);
|
||||
Callable<Boolean> operation = () -> {
|
||||
start.await(5, TimeUnit.SECONDS);
|
||||
try { service.changeStatus(id, "e", new DriverModels.ChangeStatus(0L, false, "暂停")); return true; }
|
||||
catch (org.springframework.web.server.ResponseStatusException e) {
|
||||
assertThat(e.getStatusCode().value()).isEqualTo(409);
|
||||
return false;
|
||||
}
|
||||
};
|
||||
try {
|
||||
var first = executor.submit(operation);
|
||||
var second = executor.submit(operation);
|
||||
start.countDown();
|
||||
assertThat(java.util.List.of(first.get(10, TimeUnit.SECONDS), second.get(10, TimeUnit.SECONDS))).containsExactlyInAnyOrder(true, false);
|
||||
assertThat(service.detail(id, null).recordVersion()).isEqualTo(1);
|
||||
assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM ll_fleet_driver_history", Integer.class)).isEqualTo(2);
|
||||
} finally { executor.shutdownNow(); }
|
||||
}
|
||||
|
||||
@Test void summaryAndExpiryFilteringUseLicenseZoneAcrossPages() {
|
||||
var eastern = new DriverModels.Draft("东侧日期", "123", "OWNED", "OVERSEAS", null, null, null,
|
||||
java.time.LocalDate.of(2026, 10, 2), "Pacific/Kiritimati", null, null);
|
||||
var western = new DriverModels.Draft("西侧日期", "123", "DISPATCHED", "OVERSEAS", null, null, null,
|
||||
java.time.LocalDate.of(2026, 10, 2), "America/Los_Angeles", null, null);
|
||||
service.create("operator", eastern);
|
||||
service.create("operator", western);
|
||||
service.create("operator", draft(null));
|
||||
var all = service.page(new DriverModels.Query(1, 1, null, null, null, null, null, null, null));
|
||||
assertThat(all.total()).isEqualTo(3);
|
||||
assertThat(all.records()).hasSize(1);
|
||||
assertThat(all.summary()).isEqualTo(new DriverModels.Summary(3, 2, 1, 1, 1));
|
||||
var expired = service.page(new DriverModels.Query(1, 20, null, null, null, null, null, "EXPIRED", null));
|
||||
assertThat(expired.records()).extracting(row -> row.details().name()).containsExactly("东侧日期");
|
||||
assertThat(expired.summary().expired()).isEqualTo(1);
|
||||
var expiring = service.page(new DriverModels.Query(1, 20, null, null, null, null, null, "EXPIRING", null));
|
||||
assertThat(expiring.records()).extracting(DriverModels.Response::licenseState).containsExactly("EXPIRING");
|
||||
var missing = service.page(new DriverModels.Query(1, 20, null, null, null, null, null, "MISSING", null));
|
||||
assertThat(missing.total()).isEqualTo(1);
|
||||
assertThat(missing.summary().expiringSoon()).isZero();
|
||||
}
|
||||
|
||||
private static DriverModels.Draft draft(String remark) {
|
||||
return new DriverModels.Draft("测试司机", "123", "OWNED", "OVERSEAS", null, null, null, null, "UTC", remark, null);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,196 @@
|
||||
package com.baiyue.lastleg.fleet.driver;
|
||||
|
||||
import com.baiyue.lastleg.fleet.domain.DriverQualificationPolicy.Reason;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.mockito.ArgumentCaptor;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.mock.web.MockMultipartFile;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
|
||||
import java.time.*;
|
||||
|
||||
import static org.assertj.core.api.Assertions.*;
|
||||
import static org.mockito.ArgumentMatchers.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
class DriverServiceTest {
|
||||
private final DriverMapper drivers = mock(DriverMapper.class);
|
||||
private final DriverHistoryMapper history = mock(DriverHistoryMapper.class);
|
||||
private final DriverFileGateway files = mock(DriverFileGateway.class);
|
||||
private final Clock clock = Clock.fixed(Instant.parse("2026-10-02T12:00:00Z"), ZoneOffset.UTC);
|
||||
private final ObjectMapper json = new ObjectMapper().registerModule(new JavaTimeModule());
|
||||
private final FleetWarehouseDirectory warehouses = mock(FleetWarehouseDirectory.class);
|
||||
private final DriverService service = new DriverService(drivers, history, files, json, clock, warehouses);
|
||||
|
||||
@BeforeEach void writesSucceed() {
|
||||
when(drivers.insert(any(DriverEntity.class))).thenReturn(1);
|
||||
when(drivers.update(any(DriverEntity.class), any())).thenReturn(1);
|
||||
when(history.insert(any(DriverHistoryEntity.class))).thenReturn(1);
|
||||
}
|
||||
|
||||
@Test void draftCanBeSavedWithoutLicenseButCannotDispatchOrBeApproved() throws Exception {
|
||||
var response = service.create("operator", draft(null));
|
||||
assertThat(response.driverNo()).startsWith("DR-").isNotEqualTo(response.id());
|
||||
assertThat(response.qualification().reason()).isEqualTo(Reason.MISSING_LICENSE);
|
||||
assertThat(response.reviewStatus()).isEqualTo("PENDING");
|
||||
var captured = ArgumentCaptor.forClass(DriverEntity.class);
|
||||
verify(drivers).insert(captured.capture());
|
||||
when(drivers.lockById(captured.getValue().getId())).thenReturn(captured.getValue());
|
||||
assertThatThrownBy(() -> service.review(captured.getValue().getId(), "reviewer", new DriverModels.Review(0L, "APPROVED", null)))
|
||||
.hasMessageContaining("补齐驾驶证");
|
||||
var event = ArgumentCaptor.forClass(DriverHistoryEntity.class);
|
||||
verify(history).insert(event.capture());
|
||||
assertThat(json.readValue(event.getValue().getSnapshotJson(), DriverModels.Response.class).driverNo()).isEqualTo(response.driverNo());
|
||||
verifyNoInteractions(files);
|
||||
}
|
||||
|
||||
@Test void approvedDriverMayDepartWithReminderOnlyWhenTaskEndsBeforeExpiry() {
|
||||
var row = row("APPROVED");
|
||||
when(drivers.lockById(71)).thenReturn(row);
|
||||
var response = service.requireDispatchable(71, Instant.parse("2026-10-20T12:00:00Z"));
|
||||
assertThat(response.qualification().canDispatch()).isTrue();
|
||||
assertThat(response.qualification().expiringSoon()).isTrue();
|
||||
verify(files).metadata(71, 81);
|
||||
assertThatThrownBy(() -> service.requireDispatchable(71, Instant.parse("2026-10-23T00:00:00Z")))
|
||||
.hasMessageContaining("预计任务结束前到期");
|
||||
assertThatThrownBy(() -> service.requireDispatchable(71, null)).hasMessageContaining("预计任务结束时间");
|
||||
}
|
||||
|
||||
@Test void approvalVerifiesAttachmentAndPersistsReviewerAndSnapshot() throws Exception {
|
||||
when(drivers.lockById(71)).thenReturn(row("PENDING"));
|
||||
var response = service.review(71, "reviewer", new DriverModels.Review(2L, "APPROVED", "已核对"));
|
||||
assertThat(response.reviewStatus()).isEqualTo("APPROVED");
|
||||
assertThat(response.recordVersion()).isEqualTo(3);
|
||||
verify(files).metadata(71, 81);
|
||||
var event = ArgumentCaptor.forClass(DriverHistoryEntity.class);
|
||||
verify(history).insert(event.capture());
|
||||
assertThat(event.getValue().getOperatorId()).isEqualTo("reviewer");
|
||||
assertThat(event.getValue().getReason()).isEqualTo("已核对");
|
||||
assertThat(json.readValue(event.getValue().getSnapshotJson(), DriverModels.Response.class).reviewStatus()).isEqualTo("APPROVED");
|
||||
}
|
||||
|
||||
@Test void failedFileVerificationCannotWriteApproval() {
|
||||
when(drivers.lockById(71)).thenReturn(row("PENDING"));
|
||||
when(files.metadata(71, 81)).thenThrow(new ResponseStatusException(HttpStatus.BAD_REQUEST, "错误文件归属"));
|
||||
assertThatThrownBy(() -> service.review(71, "r", new DriverModels.Review(2L, "APPROVED", null))).hasMessageContaining("错误文件归属");
|
||||
verify(drivers, never()).update(any(DriverEntity.class), any());
|
||||
verify(history, never()).insert(any(DriverHistoryEntity.class));
|
||||
}
|
||||
|
||||
@Test void rejectsExpiredAndNotYetIssuedLicenseApproval() {
|
||||
var row = row("PENDING");
|
||||
row.setLicenseExpiresOn(LocalDate.of(2026, 10, 1));
|
||||
when(drivers.lockById(71)).thenReturn(row);
|
||||
assertThatThrownBy(() -> service.review(71, "r", new DriverModels.Review(2L, "APPROVED", null))).hasMessageContaining("已过期");
|
||||
row.setLicenseExpiresOn(LocalDate.of(2027, 10, 1));
|
||||
row.setLicenseIssuedOn(LocalDate.of(2026, 10, 3));
|
||||
assertThatThrownBy(() -> service.review(71, "r", new DriverModels.Review(2L, "APPROVED", null))).hasMessageContaining("尚未生效");
|
||||
verifyNoInteractions(files);
|
||||
}
|
||||
|
||||
@Test void rejectRequiresReasonAndCanBeExplicitlyResubmitted() {
|
||||
var row = row("PENDING");
|
||||
when(drivers.lockById(71)).thenReturn(row);
|
||||
assertThatThrownBy(() -> service.review(71, "r", new DriverModels.Review(2L, "REJECTED", " "))).hasMessageContaining("打回原因");
|
||||
assertThat(service.review(71, "r", new DriverModels.Review(2L, "REJECTED", "附件不清楚")).reviewStatus()).isEqualTo("REJECTED");
|
||||
assertThat(service.submit(71, "editor", new DriverModels.Submit(3L)).reviewStatus()).isEqualTo("PENDING");
|
||||
verify(files).metadata(71, 81);
|
||||
}
|
||||
|
||||
@Test void credentialEditResetsApprovalButPhoneCorrectionDoesNot() {
|
||||
var row = row("APPROVED");
|
||||
when(drivers.lockById(71)).thenReturn(row);
|
||||
var phoneOnly = new DriverModels.Draft("司机", "new-phone", "OWNED", "OVERSEAS", "LIC-1", "C",
|
||||
null, row.getLicenseExpiresOn(), "UTC", null, null);
|
||||
assertThat(service.update(71, "e", new DriverModels.Update(2L, phoneOnly)).reviewStatus()).isEqualTo("APPROVED");
|
||||
var changedExpiry = new DriverModels.Draft("司机", "new-phone", "OWNED", "OVERSEAS", "LIC-1", "C",
|
||||
null, LocalDate.of(2027, 10, 22), "UTC", null, null);
|
||||
assertThat(service.update(71, "e", new DriverModels.Update(3L, changedExpiry)).reviewStatus()).isEqualTo("PENDING");
|
||||
}
|
||||
|
||||
@Test void replacingLicenseBindsNewFileAndResetsApproval() {
|
||||
when(drivers.lockById(71)).thenReturn(row("APPROVED"));
|
||||
var file = new MockMultipartFile("file", "license.pdf", "application/pdf", "%PDF-test".getBytes());
|
||||
when(files.upload(71, "e", file)).thenReturn(new DriverModels.File("82", "license.pdf", "application/pdf", 9));
|
||||
var response = service.uploadLicense(71, 2, "e", file);
|
||||
assertThat(response.licenseFileId()).isEqualTo("82");
|
||||
assertThat(response.reviewStatus()).isEqualTo("PENDING");
|
||||
var event = ArgumentCaptor.forClass(DriverHistoryEntity.class);
|
||||
verify(history).insert(event.capture());
|
||||
assertThat(event.getValue().getLicenseFileId()).isEqualTo(82);
|
||||
}
|
||||
|
||||
@Test void staleVersionFailsBeforeUploadOrReviewAndCasFailureHasNoHistory() {
|
||||
when(drivers.lockById(71)).thenReturn(row("PENDING"));
|
||||
assertThatThrownBy(() -> service.uploadLicense(71, 1, "e", null)).hasMessageContaining("已被修改");
|
||||
assertThatThrownBy(() -> service.review(71, "r", new DriverModels.Review(1L, "APPROVED", null))).hasMessageContaining("已被修改");
|
||||
verifyNoInteractions(files);
|
||||
when(drivers.update(any(DriverEntity.class), any())).thenReturn(0);
|
||||
assertThatThrownBy(() -> service.changeStatus(71, "e", new DriverModels.ChangeStatus(2L, false, "暂停驾驶"))).hasMessageContaining("已被修改");
|
||||
verify(history, never()).insert(any(DriverHistoryEntity.class));
|
||||
}
|
||||
|
||||
@Test void unlinkedFileCannotBeReadEvenIfItExistsInFileService() {
|
||||
when(drivers.selectById(71L)).thenReturn(row("APPROVED"));
|
||||
when(history.selectCount(any())).thenReturn(0L);
|
||||
assertThatThrownBy(() -> service.download(71, 99)).hasMessageContaining("未关联");
|
||||
verifyNoInteractions(files);
|
||||
}
|
||||
|
||||
@Test void historicalLinkedFileIsReadableWithExactDriverScope() {
|
||||
when(drivers.selectById(71L)).thenReturn(row("APPROVED"));
|
||||
when(history.selectCount(any())).thenReturn(1L);
|
||||
when(files.metadata(71, 80)).thenReturn(new DriverModels.File("80", "old.pdf", "application/pdf", 8));
|
||||
when(files.download(71, 80)).thenReturn(new byte[]{1, 2});
|
||||
assertThat(service.download(71, 80).content()).containsExactly(1, 2);
|
||||
}
|
||||
|
||||
@Test void invalidTimeZoneAndReversedDatesCannotBeSaved() {
|
||||
var badZone = new DriverModels.Draft("司机", "123", "OWNED", "OVERSEAS", null, null, null, null, "wrong-zone", null, null);
|
||||
assertThatThrownBy(() -> service.create("e", badZone)).hasMessageContaining("时区");
|
||||
var badDates = new DriverModels.Draft("司机", "123", "OWNED", "OVERSEAS", null, null,
|
||||
LocalDate.of(2027, 1, 1), LocalDate.of(2026, 1, 1), "UTC", null, null);
|
||||
assertThatThrownBy(() -> service.create("e", badDates)).hasMessageContaining("不能早于");
|
||||
verify(drivers, never()).insert(any(DriverEntity.class));
|
||||
}
|
||||
|
||||
@Test void optionalProfileUsesAuthoritativeWarehouseSnapshotAndDoesNotInventQualificationRequirements() {
|
||||
when(warehouses.required(92, "OVERSEAS")).thenReturn(new DriverModels.WarehouseOption("92", "WH-US", "公共海外仓", "OVERSEAS"));
|
||||
var profile = new DriverModels.Profile(92L, "53尺拖车", "中文 / 洛杉矶", LocalDate.of(2020, 1, 1), null, null);
|
||||
var details = new DriverModels.Draft("司机", "123", "OWNED", "OVERSEAS", "LIC-1", "C", null,
|
||||
LocalDate.of(2026, 10, 22), "UTC", null, profile);
|
||||
var response = service.create("e", details);
|
||||
assertThat(response.warehouseCode()).isEqualTo("WH-US");
|
||||
assertThat(response.warehouseName()).isEqualTo("公共海外仓");
|
||||
assertThat(response.details().profile().medicalExpiresOn()).isEqualTo(LocalDate.of(2020, 1, 1));
|
||||
var captured = ArgumentCaptor.forClass(DriverEntity.class);
|
||||
verify(drivers).insert(captured.capture());
|
||||
var row = captured.getValue(); row.setLicenseFileId(81L);
|
||||
when(drivers.lockById(row.getId())).thenReturn(row);
|
||||
assertThat(service.review(row.getId(), "r", new DriverModels.Review(0L, "APPROVED", null)).reviewStatus()).isEqualTo("APPROVED");
|
||||
}
|
||||
|
||||
@Test void invalidWarehouseCannotBePersistedAsFreeText() {
|
||||
when(warehouses.required(92, "OVERSEAS")).thenThrow(new ResponseStatusException(HttpStatus.BAD_REQUEST, "仓库已停用"));
|
||||
var details = new DriverModels.Draft("司机", "123", "OWNED", "OVERSEAS", null, null, null, null, "UTC", null,
|
||||
new DriverModels.Profile(92L, null, null, null, null, null));
|
||||
assertThatThrownBy(() -> service.create("e", details)).hasMessageContaining("仓库已停用");
|
||||
verify(drivers, never()).insert(any(DriverEntity.class));
|
||||
}
|
||||
|
||||
private static DriverModels.Draft draft(LocalDate expiry) {
|
||||
return new DriverModels.Draft("司机", "123", "OWNED", "OVERSEAS", null, null, null, expiry, "UTC", null, null);
|
||||
}
|
||||
private static DriverEntity row(String review) {
|
||||
var row = new DriverEntity();
|
||||
row.setId(71L); row.setDriverNo("DR-TEST"); row.setName("司机"); row.setPhone("123");
|
||||
row.setOwnership("OWNED"); row.setRegion("OVERSEAS"); row.setActive(true); row.setReviewStatus(review);
|
||||
row.setLicenseNumber("LIC-1"); row.setLicenseType("C"); row.setLicenseExpiresOn(LocalDate.of(2026, 10, 22));
|
||||
row.setLicenseZone("UTC"); row.setLicenseFileId(81L); row.setRecordVersion(2L);
|
||||
row.setUpdatedAt(LocalDateTime.of(2026, 10, 1, 0, 0)); row.setUpdatedBy("editor");
|
||||
return row;
|
||||
}
|
||||
}
|
||||
+32
@@ -0,0 +1,32 @@
|
||||
package com.baiyue.lastleg.fleet.driver;
|
||||
|
||||
import com.baiyue.common.core.api.ApiResponse;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import java.util.List;
|
||||
import static org.assertj.core.api.Assertions.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
class FleetWarehouseDirectoryTest {
|
||||
private final FleetWarehouseClient client = mock(FleetWarehouseClient.class);
|
||||
private final FleetWarehouseDirectory directory = new FleetWarehouseDirectory(client, "test-token");
|
||||
|
||||
@Test void onlyActiveMatchingOperatingWarehousesCanBeSelected() {
|
||||
when(client.activeWarehouses("test-token")).thenReturn(ApiResponse.success(List.of(
|
||||
new FleetWarehouseClient.Warehouse(1L, "CN", "国内仓", "DOMESTIC", "CN"),
|
||||
new FleetWarehouseClient.Warehouse(2L, "US", "海外仓", "OVERSEAS", "US"),
|
||||
new FleetWarehouseClient.Warehouse(3L, "FBA", "目的仓", "FBA", "US"),
|
||||
new FleetWarehouseClient.Warehouse(4L, "PARTNER", "合作仓", "OVERSEAS_PARTNER", "US"))));
|
||||
assertThat(directory.options("OVERSEAS")).extracting(DriverModels.WarehouseOption::id).containsExactly("2", "4");
|
||||
assertThat(directory.required(1, "DOMESTIC").name()).isEqualTo("国内仓");
|
||||
assertThatThrownBy(() -> directory.required(1, "OVERSEAS")).hasMessageContaining("区域不符");
|
||||
assertThatThrownBy(() -> directory.required(3, "OVERSEAS")).hasMessageContaining("不存在");
|
||||
assertThatThrownBy(() -> directory.required(99, "OVERSEAS")).hasMessageContaining("不存在");
|
||||
}
|
||||
|
||||
@Test void failureOrInvalidRegionNeverProducesInventedWarehouseOptions() {
|
||||
when(client.activeWarehouses("test-token")).thenThrow(new IllegalStateException("unavailable"));
|
||||
assertThatThrownBy(() -> directory.options("OVERSEAS")).hasMessageContaining("暂不可用");
|
||||
assertThatThrownBy(() -> directory.options("unknown")).hasMessageContaining("区域");
|
||||
verify(client, times(1)).activeWarehouses("test-token");
|
||||
}
|
||||
}
|
||||
+26
@@ -0,0 +1,26 @@
|
||||
package com.baiyue.lastleg.fleet.driver;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import static org.assertj.core.api.Assertions.*;
|
||||
|
||||
class HttpDriverFileGatewayTest {
|
||||
@Test void acceptsOnlyMatchingPlatformDriverLicenseMetadata() {
|
||||
assertThat(HttpDriverFileGateway.verified(envelope(0L, "FLEET_DRIVER_LICENSE", "71", 81L, "application/pdf", 12L), 71, 81L).fileId())
|
||||
.isEqualTo("81");
|
||||
assertThatThrownBy(() -> HttpDriverFileGateway.verified(envelope(5L, "FLEET_DRIVER_LICENSE", "71", 81L, "application/pdf", 12L), 71, 81L)).hasMessageContaining("不属于");
|
||||
assertThatThrownBy(() -> HttpDriverFileGateway.verified(envelope(0L, "DELIVERY_BOL", "71", 81L, "application/pdf", 12L), 71, 81L)).hasMessageContaining("不属于");
|
||||
assertThatThrownBy(() -> HttpDriverFileGateway.verified(envelope(0L, "FLEET_DRIVER_LICENSE", "72", 81L, "application/pdf", 12L), 71, 81L)).hasMessageContaining("不属于");
|
||||
assertThatThrownBy(() -> HttpDriverFileGateway.verified(envelope(0L, "FLEET_DRIVER_LICENSE", "71", 82L, "application/pdf", 12L), 71, 81L)).hasMessageContaining("不属于");
|
||||
}
|
||||
@Test void rejectsMissingOversizeOrUnsafeMetadataAndFailedEnvelope() {
|
||||
assertThatThrownBy(() -> HttpDriverFileGateway.verified(envelope(0L, "FLEET_DRIVER_LICENSE", "71", 81L, "text/html", 12L), 71, 81L)).hasMessageContaining("不是有效");
|
||||
assertThatThrownBy(() -> HttpDriverFileGateway.verified(envelope(0L, "FLEET_DRIVER_LICENSE", "71", 81L, "application/pdf", 0L), 71, 81L)).hasMessageContaining("不是有效");
|
||||
assertThatThrownBy(() -> HttpDriverFileGateway.verified(envelope(0L, "FLEET_DRIVER_LICENSE", "71", 81L, "application/pdf", 10485761L), 71, 81L)).hasMessageContaining("不是有效");
|
||||
assertThatThrownBy(() -> HttpDriverFileGateway.verified(null, 71, 81L)).hasMessageContaining("不可用");
|
||||
assertThatThrownBy(() -> HttpDriverFileGateway.verified(new HttpDriverFileGateway.Envelope("A0400", null), 71, 81L)).hasMessageContaining("不可用");
|
||||
}
|
||||
private static HttpDriverFileGateway.Envelope envelope(Long owner, String type, String driver, Long id, String contentType, Long size) {
|
||||
return new HttpDriverFileGateway.Envelope("200", new HttpDriverFileGateway.Metadata(id, owner, type, driver, "license.pdf", contentType, size));
|
||||
}
|
||||
}
|
||||
+24
@@ -0,0 +1,24 @@
|
||||
package com.baiyue.lastleg.fleet.maintenance;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import static org.assertj.core.api.Assertions.*;
|
||||
|
||||
class HttpVehicleCareFileGatewayTest {
|
||||
@Test void aSuccessfulHttpEnvelopeCannotBeReturnedAsImageBytes() {
|
||||
var json = org.springframework.http.ResponseEntity.ok().contentType(org.springframework.http.MediaType.APPLICATION_JSON).body("{\"code\":\"A0404\"}".getBytes());
|
||||
assertThatThrownBy(() -> HttpVehicleCareFileGateway.imageContent(json)).hasMessageContaining("不可用");
|
||||
var image = org.springframework.http.ResponseEntity.ok().contentType(org.springframework.http.MediaType.IMAGE_PNG).body(new byte[]{1, 2, 3});
|
||||
assertThat(HttpVehicleCareFileGateway.imageContent(image)).containsExactly(1, 2, 3);
|
||||
}
|
||||
@Test void metadataMustMatchPlatformRecordCategoryAndFileIdentity() {
|
||||
assertThat(HttpVehicleCareFileGateway.verified(envelope(0, "FLEET_VEHICLE_RECORD_PHOTO", "71", 81, "image/png"), 71, 81L).fileId()).isEqualTo("81");
|
||||
assertThatThrownBy(() -> HttpVehicleCareFileGateway.verified(envelope(9, "FLEET_VEHICLE_RECORD_PHOTO", "71", 81, "image/png"), 71, 81L)).hasMessageContaining("不属于");
|
||||
assertThatThrownBy(() -> HttpVehicleCareFileGateway.verified(envelope(0, "FLEET_DRIVER_LICENSE", "71", 81, "image/png"), 71, 81L)).hasMessageContaining("不属于");
|
||||
assertThatThrownBy(() -> HttpVehicleCareFileGateway.verified(envelope(0, "FLEET_VEHICLE_RECORD_PHOTO", "72", 81, "image/png"), 71, 81L)).hasMessageContaining("不属于");
|
||||
assertThatThrownBy(() -> HttpVehicleCareFileGateway.verified(envelope(0, "FLEET_VEHICLE_RECORD_PHOTO", "71", 82, "image/png"), 71, 81L)).hasMessageContaining("不属于");
|
||||
assertThatThrownBy(() -> HttpVehicleCareFileGateway.verified(envelope(0, "FLEET_VEHICLE_RECORD_PHOTO", "71", 81, "text/html"), 71, 81L)).hasMessageContaining("有效照片");
|
||||
}
|
||||
private HttpVehicleCareFileGateway.Envelope envelope(long owner, String type, String record, long file, String mime) {
|
||||
return new HttpVehicleCareFileGateway.Envelope("200", new HttpVehicleCareFileGateway.Metadata(file, owner, type, record, "meter.png", mime, 8L));
|
||||
}
|
||||
}
|
||||
+43
@@ -0,0 +1,43 @@
|
||||
package com.baiyue.lastleg.fleet.maintenance;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.security.access.prepost.PreAuthorize;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
import org.yaml.snakeyaml.Yaml;
|
||||
import java.nio.file.*;
|
||||
import java.util.*;
|
||||
import java.util.regex.Pattern;
|
||||
import static org.assertj.core.api.Assertions.*;
|
||||
|
||||
class VehicleCareApiContractTest {
|
||||
@Test void operationsPermissionsAndSchemasMatchTheirPublishedContracts() throws Exception {
|
||||
Map<String,Object> document = new Yaml().load(Files.readString(Path.of("src/main/resources/openapi/fleet-vehicle-records-v1.yaml")));
|
||||
Map<?,?> paths = (Map<?,?>)document.get("paths");
|
||||
String permissionSql = Files.readString(Path.of("../iam/src/main/resources/db/lastleg-permissions.sql"));
|
||||
String initialization = Files.readString(Path.of("../deploy/sql/头程数据库统一增量-MySQL8.sql"));
|
||||
String base = PlatformVehicleCareController.class.getAnnotation(RequestMapping.class).value()[0];
|
||||
int count = 0;
|
||||
for (var method : PlatformVehicleCareController.class.getDeclaredMethods()) {
|
||||
String verb = null; String[] suffix = null;
|
||||
if (method.isAnnotationPresent(GetMapping.class)) { verb = "get"; suffix = method.getAnnotation(GetMapping.class).value(); }
|
||||
if (method.isAnnotationPresent(PostMapping.class)) { verb = "post"; suffix = method.getAnnotation(PostMapping.class).value(); }
|
||||
if (method.isAnnotationPresent(PutMapping.class)) { verb = "put"; suffix = method.getAnnotation(PutMapping.class).value(); }
|
||||
if (verb == null) continue;
|
||||
String path = base + (suffix.length == 0 ? "" : suffix[0]);
|
||||
assertThat(paths.containsKey(path)).as(path).isTrue();
|
||||
Map<?,?> operation = (Map<?,?>)((Map<?,?>)paths.get(path)).get(verb);
|
||||
String expression = method.getAnnotation(PreAuthorize.class).value();
|
||||
String permission = expression.substring(expression.indexOf('\'') + 1, expression.lastIndexOf('\''));
|
||||
assertThat(operation.get("description").toString()).contains(permission);
|
||||
assertThat(permissionSql).contains("'" + permission + "'"); assertThat(initialization).contains("'" + permission + "'"); count++;
|
||||
}
|
||||
assertThat(count).isEqualTo(7);
|
||||
for (String table : List.of("ll_fleet_vehicle_record", "ll_fleet_vehicle_record_history")) {
|
||||
var pattern = Pattern.compile("(?s)CREATE TABLE " + table + " \\(.*?;");
|
||||
var module = pattern.matcher(Files.readString(Path.of("src/main/resources/db/lastleg.sql")));
|
||||
var deployed = pattern.matcher(Files.readString(Path.of("../deploy/sql/海外区域统一增量-MySQL8.sql")));
|
||||
assertThat(module.find()).isTrue(); assertThat(deployed.find()).isTrue();
|
||||
assertThat(module.group().replace("\r\n", "\n")).isEqualTo(deployed.group().replace("\r\n", "\n"));
|
||||
}
|
||||
}
|
||||
}
|
||||
+36
@@ -0,0 +1,36 @@
|
||||
package com.baiyue.lastleg.fleet.maintenance;
|
||||
|
||||
import org.junit.jupiter.api.*;
|
||||
import org.springframework.context.annotation.*;
|
||||
import org.springframework.security.access.AccessDeniedException;
|
||||
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
|
||||
import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity;
|
||||
import org.springframework.security.core.authority.SimpleGrantedAuthority;
|
||||
import org.springframework.security.core.context.SecurityContextHolder;
|
||||
import java.util.List;
|
||||
import static org.assertj.core.api.Assertions.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
class VehicleCareAuthorizationTest {
|
||||
@AfterEach void clear() { SecurityContextHolder.clearContext(); }
|
||||
@Test void vehicleListReadsRecordsButDoesNotAuthorizeCreateEditOrPhotoUpload() {
|
||||
try (var context = new AnnotationConfigApplicationContext(Config.class)) {
|
||||
var controller = context.getBean(PlatformVehicleCareController.class);
|
||||
var service = context.getBean(VehicleCareService.class);
|
||||
authenticate("platform:overseas-master-data:vehicle:list");
|
||||
controller.detail(1, "VR-1"); verify(service).detail(1, "VR-1");
|
||||
assertThatThrownBy(() -> controller.create(1, null, null)).isInstanceOf(AccessDeniedException.class);
|
||||
assertThatThrownBy(() -> controller.update(1, "VR-1", null, null)).isInstanceOf(AccessDeniedException.class);
|
||||
assertThatThrownBy(() -> controller.upload(1, "VR-1", null, 0, "key", null)).isInstanceOf(AccessDeniedException.class);
|
||||
authenticate("platform:overseas-master-data:driver:list");
|
||||
assertThatThrownBy(() -> controller.detail(1, "VR-1")).isInstanceOf(AccessDeniedException.class);
|
||||
assertThatThrownBy(() -> controller.download(1, "VR-1", 1)).isInstanceOf(AccessDeniedException.class);
|
||||
verifyNoMoreInteractions(service);
|
||||
}
|
||||
}
|
||||
private void authenticate(String code) { SecurityContextHolder.getContext().setAuthentication(new UsernamePasswordAuthenticationToken("user", "unused", List.of(new SimpleGrantedAuthority(code)))); }
|
||||
@Configuration @EnableMethodSecurity static class Config {
|
||||
@Bean VehicleCareService service() { return mock(VehicleCareService.class); }
|
||||
@Bean PlatformVehicleCareController controller(VehicleCareService service) { return new PlatformVehicleCareController(service); }
|
||||
}
|
||||
}
|
||||
+166
@@ -0,0 +1,166 @@
|
||||
package com.baiyue.lastleg.fleet.maintenance;
|
||||
|
||||
import com.baomidou.mybatisplus.core.MybatisConfiguration;
|
||||
import com.baomidou.mybatisplus.extension.spring.MybatisSqlSessionFactoryBean;
|
||||
import com.baiyue.lastleg.masterdata.vehicle.adapter.out.persistence.*;
|
||||
import com.baiyue.lastleg.masterdata.vehicle.application.port.out.VehicleRepository;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
|
||||
import org.junit.jupiter.api.*;
|
||||
import org.mybatis.spring.SqlSessionTemplate;
|
||||
import org.springframework.aop.framework.ProxyFactory;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.jdbc.datasource.*;
|
||||
import org.springframework.mock.web.MockMultipartFile;
|
||||
import org.springframework.transaction.annotation.AnnotationTransactionAttributeSource;
|
||||
import org.springframework.transaction.interceptor.TransactionInterceptor;
|
||||
import java.math.BigDecimal;
|
||||
import java.nio.file.*;
|
||||
import java.time.*;
|
||||
import java.util.*;
|
||||
import java.util.concurrent.*;
|
||||
import java.util.regex.Pattern;
|
||||
import static org.assertj.core.api.Assertions.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
import static org.mockito.ArgumentMatchers.*;
|
||||
|
||||
class VehicleCarePersistenceTest {
|
||||
private JdbcTemplate jdbc;
|
||||
private VehicleCareService service;
|
||||
private VehicleCareFileGateway files;
|
||||
private long vehicleA, vehicleB;
|
||||
private final Instant occurred = Instant.parse("2026-01-01T00:00:00Z");
|
||||
@BeforeEach void setup() throws Exception {
|
||||
var source = new DriverManagerDataSource("jdbc:h2:mem:care-" + UUID.randomUUID() + ";MODE=MySQL;DB_CLOSE_DELAY=-1", "sa", "");
|
||||
jdbc = new JdbcTemplate(source);
|
||||
String schema = Files.readString(Path.of("src/main/resources/db/lastleg.sql"));
|
||||
for (String table : List.of("ll_vehicle", "ll_fleet_vehicle_record", "ll_fleet_vehicle_record_history")) {
|
||||
var match = Pattern.compile("(?s)CREATE TABLE(?: IF NOT EXISTS)? " + table + " \\(.*?;").matcher(schema);
|
||||
assertThat(match.find()).as(table).isTrue(); jdbc.execute(match.group().replace(" JSON", " LONGTEXT"));
|
||||
}
|
||||
var config = new MybatisConfiguration();
|
||||
config.addMapper(VehicleMapper.class); config.addMapper(VehicleCareMapper.class); config.addMapper(VehicleCareHistoryMapper.class);
|
||||
var interceptor = new com.baomidou.mybatisplus.extension.plugins.MybatisPlusInterceptor();
|
||||
interceptor.addInnerInterceptor(new com.baomidou.mybatisplus.extension.plugins.inner.PaginationInnerInterceptor(com.baomidou.mybatisplus.annotation.DbType.H2));
|
||||
config.addInterceptor(interceptor);
|
||||
var factory = new MybatisSqlSessionFactoryBean(); factory.setDataSource(source); factory.setConfiguration(config);
|
||||
var session = new SqlSessionTemplate(factory.getObject());
|
||||
var vehicles = new VehiclePersistenceAdapter(session.getMapper(VehicleMapper.class));
|
||||
vehicleA = vehicles.create(vehicle("VEH-A")); vehicleB = vehicles.create(vehicle("VEH-B"));
|
||||
files = mock(VehicleCareFileGateway.class);
|
||||
when(files.upload(anyLong(), anyString(), any())).thenReturn(new VehicleCareModels.File("9001", "photo.png", "image/png", 8));
|
||||
var target = new VehicleCareService(session.getMapper(VehicleCareMapper.class), session.getMapper(VehicleCareHistoryMapper.class), vehicles, files,
|
||||
new ObjectMapper().registerModule(new JavaTimeModule()));
|
||||
var proxy = new ProxyFactory(target); proxy.setProxyTargetClass(true);
|
||||
proxy.addAdvice(new TransactionInterceptor(new DataSourceTransactionManager(source), new AnnotationTransactionAttributeSource()));
|
||||
service = (VehicleCareService) proxy.getProxy();
|
||||
}
|
||||
@Test void recordsAreScopedToVehicleAndNeverRewriteMasterOdometerOrCreateAnotherRecordOnRetry() {
|
||||
var request = new VehicleCareModels.Create("key", maintenance());
|
||||
var a = service.create(vehicleA, "1", request);
|
||||
assertThat(service.create(vehicleA, "1", request).recordNo()).isEqualTo(a.recordNo());
|
||||
var b = service.create(vehicleB, "1", request);
|
||||
assertThat(b.recordNo()).isNotEqualTo(a.recordNo());
|
||||
assertThat(service.page(vehicleA, new VehicleCareModels.Query(1, 20, "MAINTENANCE")).records()).extracting(VehicleCareModels.Response::recordNo).containsExactly(a.recordNo());
|
||||
assertThatThrownBy(() -> service.detail(vehicleB, a.recordNo())).hasMessageContaining("不存在");
|
||||
assertThatThrownBy(() -> service.update(vehicleB, a.recordNo(), "1", new VehicleCareModels.Update(0L, "错误车辆", maintenance()))).hasMessageContaining("不存在");
|
||||
assertThatThrownBy(() -> service.download(vehicleB, a.recordNo(), 9001)).hasMessageContaining("不存在");
|
||||
assertThat(jdbc.queryForList("SELECT mileage_km FROM ll_vehicle", Long.class)).containsOnly(1000L);
|
||||
assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM ll_fleet_vehicle_record_history", Integer.class)).isEqualTo(2);
|
||||
}
|
||||
@Test void correctionKeepsImmutableHistoryAndRejectsStaleVersionOrChangedType() {
|
||||
var row = service.create(vehicleA, "1", new VehicleCareModels.Create("key", maintenance()));
|
||||
assertThatThrownBy(() -> service.create(vehicleA, "1", new VehicleCareModels.Create("key", fuel()))).hasMessageContaining("其他运维记录");
|
||||
assertThatThrownBy(() -> service.update(vehicleA, row.recordNo(), "1", new VehicleCareModels.Update(0L, "变更类型", fuel()))).hasMessageContaining("不能改变");
|
||||
service.update(vehicleA, row.recordNo(), "2", new VehicleCareModels.Update(0L, "补充核对", maintenance()));
|
||||
assertThatThrownBy(() -> service.update(vehicleA, row.recordNo(), "1", new VehicleCareModels.Update(0L, "旧页面", maintenance()))).hasMessageContaining("已变化");
|
||||
var history = service.history(vehicleA, row.recordNo(), 1, 20).records();
|
||||
assertThat(history).extracting(VehicleCareModels.History::action).containsExactly("UPDATE", "CREATE");
|
||||
assertThat(history.get(1).snapshot().updatedBy()).isEqualTo("1");
|
||||
}
|
||||
@Test void failedHistoryWriteRollsBackUpdate() {
|
||||
var row = service.create(vehicleA, "1", new VehicleCareModels.Create("key", fuel()));
|
||||
jdbc.execute("ALTER TABLE ll_fleet_vehicle_record_history ADD CONSTRAINT fail_update CHECK(action <> 'UPDATE')");
|
||||
assertThatThrownBy(() -> service.update(vehicleA, row.recordNo(), "2", new VehicleCareModels.Update(0L, "更正", fuel()))).isInstanceOf(RuntimeException.class);
|
||||
assertThat(service.detail(vehicleA, row.recordNo()).recordVersion()).isZero();
|
||||
assertThat(service.detail(vehicleA, row.recordNo()).updatedBy()).isEqualTo("1");
|
||||
}
|
||||
@Test void uploadRetryReturnsOriginalAssociationAndRejectsAnotherImageOrForeignRecord() {
|
||||
var row = service.create(vehicleA, "1", new VehicleCareModels.Create("key", fuel()));
|
||||
var image = new MockMultipartFile("file", "photo.png", "image/png", new byte[]{1,2,3});
|
||||
var uploaded = service.upload(vehicleA, row.recordNo(), 0, "upload-key", "1", image);
|
||||
assertThat(uploaded.files()).hasSize(1);
|
||||
assertThat(service.upload(vehicleA, row.recordNo(), 0, "upload-key", "1", image).recordVersion()).isEqualTo(1);
|
||||
verify(files, times(1)).upload(anyLong(), eq("1"), any());
|
||||
assertThatThrownBy(() -> service.upload(vehicleA, row.recordNo(), 1, "upload-key", "1",
|
||||
new MockMultipartFile("file", "other.png", "image/png", new byte[]{4}))).hasMessageContaining("不同图片");
|
||||
assertThatThrownBy(() -> service.upload(vehicleB, row.recordNo(), 1, "new-key", "1", image)).hasMessageContaining("不存在");
|
||||
assertThatThrownBy(() -> service.download(vehicleA, row.recordNo(), 9999)).hasMessageContaining("不存在");
|
||||
verify(files, never()).metadata(anyLong(), anyLong());
|
||||
}
|
||||
@Test void explicitUnitsAndTypedFieldsAreRequiredButHistoricalOdometersMayBeBelowTheMaster() {
|
||||
var row = service.create(vehicleA, "1", new VehicleCareModels.Create("fuel", fuel()));
|
||||
assertThat(row.details().distanceUnit()).isEqualTo("MI");
|
||||
assertThat(row.details().fuelUnit()).isEqualTo("US_GAL");
|
||||
assertThat(row.details().odometer()).isEqualByComparingTo("800");
|
||||
var bad = new VehicleCareModels.Draft("FUEL", occurred, "MI", new BigDecimal("800"), null, null, new BigDecimal("10"), "GAL", "PENDING", null, null);
|
||||
assertThatThrownBy(() -> service.create(vehicleA, "1", new VehicleCareModels.Create("bad", bad))).hasMessageContaining("明确");
|
||||
assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM ll_fleet_vehicle_record", Integer.class)).isEqualTo(1);
|
||||
}
|
||||
@Test void concurrentDuplicateCreationCreatesOneBusinessRecordAndOneHistory() throws Exception {
|
||||
var request = new VehicleCareModels.Create("concurrent", fuel());
|
||||
var start = new CountDownLatch(1); var executor = Executors.newFixedThreadPool(2);
|
||||
Callable<String> task = () -> { start.await(5, TimeUnit.SECONDS); return service.create(vehicleA, "1", request).recordNo(); };
|
||||
try {
|
||||
var first = executor.submit(task); var second = executor.submit(task); start.countDown();
|
||||
assertThat(first.get(15, TimeUnit.SECONDS)).isEqualTo(second.get(15, TimeUnit.SECONDS));
|
||||
assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM ll_fleet_vehicle_record", Integer.class)).isEqualTo(1);
|
||||
assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM ll_fleet_vehicle_record_history", Integer.class)).isEqualTo(1);
|
||||
} finally { executor.shutdownNow(); }
|
||||
}
|
||||
@Test void incidentTypesKeepProjectsPlacesAndProcessingFactsIsolatedPerVehicle() {
|
||||
for (String type : List.of("REPAIR", "BREAKDOWN", "RESCUE", "EXCEPTION")) {
|
||||
var row = service.create(vehicleA, "1", new VehicleCareModels.Create(type, incident(type, "PENDING", null, null, null)));
|
||||
assertThat(service.detail(vehicleA, row.recordNo()).details().incident()).isEqualTo(row.details().incident());
|
||||
assertThat(service.page(vehicleA, new VehicleCareModels.Query(1, 20, type)).records()).hasSize(1);
|
||||
assertThat(service.page(vehicleB, new VehicleCareModels.Query(1, 20, type)).total()).isZero();
|
||||
assertThatThrownBy(() -> service.update(vehicleB, row.recordNo(), "1", new VehicleCareModels.Update(0L, "更新处理", row.details()))).hasMessageContaining("不存在");
|
||||
if (List.of("REPAIR", "RESCUE").contains(type)) {
|
||||
assertThat(row.details().incident().item()).isEqualTo("更换轮胎");
|
||||
assertThat(row.details().incident().place()).isEqualTo("实际作业地点");
|
||||
assertThat(row.paymentStatus()).isEqualTo("UNLINKED");
|
||||
}
|
||||
}
|
||||
}
|
||||
@Test void completedRepairDoesNotInventPaymentOrReleaseAndChangingCostKeepsHistory() {
|
||||
var created = service.create(vehicleA, "1", new VehicleCareModels.Create("repair", incident("REPAIR", "PENDING", null, null, null)));
|
||||
var started = service.update(vehicleA, created.recordNo(), "2", new VehicleCareModels.Update(0L, "已安排维修", incident("REPAIR", "IN_PROGRESS", "维修人员", new BigDecimal("120.50"), "USD")));
|
||||
var completed = service.update(vehicleA, created.recordNo(), "2", new VehicleCareModels.Update(started.recordVersion(), "现场处理完成", incident("REPAIR", "COMPLETED", "维修人员", BigDecimal.ZERO, "USD")));
|
||||
assertThat(completed.details().incident().reportedCost()).isEqualByComparingTo("0");
|
||||
assertThat(completed.paymentStatus()).isEqualTo("UNLINKED");
|
||||
assertThat(jdbc.queryForObject("SELECT status FROM ll_vehicle WHERE id=?", String.class, vehicleA)).isEqualTo("IN_SERVICE");
|
||||
var history = service.history(vehicleA, created.recordNo(), 1, 20).records();
|
||||
assertThat(history).hasSize(3);
|
||||
assertThat(history.get(1).snapshot().details().incident().reportedCost()).isEqualByComparingTo("120.50");
|
||||
assertThat(history.get(2).snapshot().details().incident().reportedCost()).isNull();
|
||||
assertThatThrownBy(() -> service.update(vehicleA, created.recordNo(), "1", new VehicleCareModels.Update(0L, "旧请求", created.details()))).hasMessageContaining("已变化");
|
||||
}
|
||||
@Test void incidentValidationRejectsMissingHandlersCurrencyAndMixedFuelFields() {
|
||||
assertThatThrownBy(() -> service.create(vehicleA, "1", new VehicleCareModels.Create("missing-handler", incident("RESCUE", "COMPLETED", null, null, null)))).hasMessageContaining("处理人");
|
||||
assertThatThrownBy(() -> service.create(vehicleA, "1", new VehicleCareModels.Create("missing-currency", incident("RESCUE", "PENDING", null, BigDecimal.ZERO, null)))).hasMessageContaining("币种");
|
||||
assertThatThrownBy(() -> service.create(vehicleA, "1", new VehicleCareModels.Create("invalid-currency", incident("RESCUE", "PENDING", null, BigDecimal.ONE, "ZZZ")))).hasMessageContaining("币种无效");
|
||||
var detail = incident("EXCEPTION", "PENDING", null, null, null);
|
||||
var mixed = new VehicleCareModels.Draft(detail.recordType(), occurred, "KM", BigDecimal.ONE, null, null, null, null, null, null, detail.incident());
|
||||
assertThatThrownBy(() -> service.create(vehicleA, "1", new VehicleCareModels.Create("mixed", mixed))).hasMessageContaining("不能混入");
|
||||
assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM ll_fleet_vehicle_record", Integer.class)).isZero();
|
||||
}
|
||||
private VehicleCareModels.Draft incident(String type, String status, String handler, BigDecimal cost, String currency) {
|
||||
boolean priced = List.of("REPAIR", "RESCUE").contains(type);
|
||||
return new VehicleCareModels.Draft(type, occurred, null, null, null, null, null, null, null, null,
|
||||
new VehicleCareModels.Incident("现场轮胎故障及处理情况", priced ? "更换轮胎" : null, priced ? "实际作业地点" : null,
|
||||
handler, status, "EXCEPTION".equals(type) ? "VEHICLE_BREAKDOWN" : null, cost, currency));
|
||||
}
|
||||
private VehicleCareModels.Draft maintenance() { return new VehicleCareModels.Draft("MAINTENANCE", occurred, "KM", new BigDecimal("800"), new BigDecimal("500"), new BigDecimal("700"), null, null, "PENDING", "已超过计划保养里程", null); }
|
||||
private VehicleCareModels.Draft fuel() { return new VehicleCareModels.Draft("FUEL", occurred, "MI", new BigDecimal("800"), null, null, new BigDecimal("28.6"), "US_GAL", "PENDING", null, null); }
|
||||
private VehicleRepository.NewVehicle vehicle(String plate) { return new VehicleRepository.NewVehicle(plate, "Box Truck", new BigDecimal("12.5"), 1000L, null, null, "OVERSEAS", "OWNED", "IN_SERVICE", null, "1", LocalDateTime.now(), 30, new BigDecimal("18000"), new BigDecimal("90")); }
|
||||
}
|
||||
+69
@@ -0,0 +1,69 @@
|
||||
package com.baiyue.lastleg.fleet.trip;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
import java.math.BigDecimal;
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
class FleetBookingFreightTest {
|
||||
final FleetBookingMapper bookings = mock(FleetBookingMapper.class);
|
||||
final FleetTripDispatchMapper bindings = mock(FleetTripDispatchMapper.class);
|
||||
final ObjectMapper json = new ObjectMapper().findAndRegisterModules();
|
||||
final FleetBookingReservations reservations = new FleetBookingReservations(bookings, json);
|
||||
final FleetBookingFreight service = new FleetBookingFreight(bookings, bindings, reservations, json);
|
||||
FleetBookingEntity booking;
|
||||
final FleetDispatchGateway.SourceCargo source = new FleetDispatchGateway.SourceCargo("WT-1", "501", "PLT-501", 12, "FBA:ONT8");
|
||||
@BeforeEach void setup() throws Exception {
|
||||
booking = new FleetBookingEntity();
|
||||
booking.setId(1L); booking.setTripId(2L); booking.setBookingNo("FB-1"); booking.setIncomingNo("WT-1");
|
||||
booking.setDispatchNo("DP-1"); booking.setCustomerId("C-1"); booking.setCustomerName("客户甲");
|
||||
booking.setDestinationKey("FBA:ONT8"); booking.setStatus("BOUND"); booking.setRecordVersion(4L);
|
||||
booking.setAgreedAmount(new BigDecimal("60.00"));
|
||||
booking.setCargoJson(json.writeValueAsString(new FleetBookingModels.Cargo("FBA:ONT8", "测试货物", 12, 2,
|
||||
BigDecimal.TEN, BigDecimal.ONE, "PALLET", new BigDecimal("30.00"), "CNY", null, null, null)));
|
||||
booking.setReceiptJson(json.writeValueAsString(new FleetBookingModels.AcceptedReceipt(
|
||||
new FleetBookingIncomingGateway.Evidence("WT-1", "IN-1", "WH-1", "FBA:ONT8", "READY_FOR_DISPATCH", "receipt-1",
|
||||
List.of(new FleetBookingIncomingGateway.Pallet("501", "PLT-501", 12, BigDecimal.TEN, BigDecimal.ONE)), 12),
|
||||
List.of(new FleetBookingModels.PalletSpaces("501", 2)), "OP-1", Instant.parse("2026-10-01T00:00:00Z"))));
|
||||
var binding = new FleetTripDispatch(); binding.setTripId(2L); binding.setDispatchId(3L);
|
||||
when(bindings.findByDispatchNo("DP-1")).thenReturn(binding);
|
||||
when(bookings.incomingForFreight("WT-1")).thenReturn(booking);
|
||||
}
|
||||
@Test void quoteUsesConfirmedActualPriceAndSeparatesOtherCargo() {
|
||||
var preview = service.preview(3, "DP-1", "C-1", List.of(source));
|
||||
assertFalse(preview.requiresDeliveryFee()); assertEquals(new BigDecimal("60.00"), preview.quotes().get(0).amount());
|
||||
assertEquals("IN-1", preview.quotes().get(0).inboundCode());
|
||||
var other = new FleetDispatchGateway.SourceCargo("IN-OTHER", "502", "PLT-502", 8, "FBA:LAX9");
|
||||
var mixed = service.preview(3, "DP-1", "C-1", List.of(source, other));
|
||||
assertTrue(mixed.requiresDeliveryFee()); assertEquals(1, mixed.quotes().size());
|
||||
assertEquals(mixed.snapshotHash(), service.preview(3, "DP-1", "C-1", List.of(other, source)).snapshotHash());
|
||||
}
|
||||
@Test void rejectsUnboundDifferentCustomerOrWrongTrip() {
|
||||
assertThrows(ResponseStatusException.class, () -> service.preview(3, "DP-1", "C-2", List.of(source)));
|
||||
booking.setStatus("RECEIVED");
|
||||
assertThrows(ResponseStatusException.class, () -> service.preview(3, "DP-1", "C-1", List.of(source)));
|
||||
booking.setStatus("BOUND"); booking.setTripId(99L);
|
||||
assertThrows(ResponseStatusException.class, () -> service.preview(3, "DP-1", "C-1", List.of(source)));
|
||||
}
|
||||
@Test void rejectsPartialOrChangedDestinationAndDetectsNewQuoteVersion() {
|
||||
var before = service.preview(3, "DP-1", "C-1", List.of(source));
|
||||
assertThrows(ResponseStatusException.class, () -> service.preview(3, "DP-1", "C-1", List.of(
|
||||
new FleetDispatchGateway.SourceCargo("WT-1", "501", "PLT-501", 11, "FBA:ONT8"))));
|
||||
assertThrows(ResponseStatusException.class, () -> service.preview(3, "DP-1", "C-1", List.of(
|
||||
new FleetDispatchGateway.SourceCargo("WT-1", "501", "PLT-501", 12, "FBA:LAX9"))));
|
||||
booking.setRecordVersion(5L);
|
||||
assertNotEquals(before.snapshotHash(), service.preview(3, "DP-1", "C-1", List.of(source)).snapshotHash());
|
||||
}
|
||||
@Test void cancellingTheBookingDoesNotEraseThePreviousFinancialSource() throws Exception {
|
||||
booking.setStatus("CANCELLED"); booking.setDispatchNo(null);
|
||||
var preview = service.preview(9, "DP-NEW", "C-1", List.of(source));
|
||||
assertTrue(preview.requiresDeliveryFee()); assertTrue(preview.quotes().isEmpty());
|
||||
assertEquals(List.of("IN-1"), preview.priorInboundCodes());
|
||||
assertFalse(json.writeValueAsString(preview).contains("priorInboundCodes"));
|
||||
}
|
||||
}
|
||||
+74
@@ -0,0 +1,74 @@
|
||||
package com.baiyue.lastleg.fleet.trip;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.security.access.prepost.PreAuthorize;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
import org.yaml.snakeyaml.Yaml;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.Map;
|
||||
import java.util.regex.Pattern;
|
||||
import static org.assertj.core.api.Assertions.*;
|
||||
|
||||
class FleetTripApiContractTest {
|
||||
@Test void allTripOperationsHaveOpenApiAndRealPermissions() throws Exception {
|
||||
Map<String, Object> document = new Yaml().load(Files.readString(Path.of("src/main/resources/openapi/fleet-trips-v1.yaml")));
|
||||
Map<?, ?> paths = (Map<?, ?>) document.get("paths");
|
||||
String module = Files.readString(Path.of("../iam/src/main/resources/db/lastleg-permissions.sql"));
|
||||
String deployed = Files.readString(Path.of("../deploy/sql/头程数据库统一增量-MySQL8.sql"));
|
||||
int count = 0;
|
||||
for (var controller : java.util.List.of(PlatformFleetTripController.class, PlatformFleetBookingController.class)) {
|
||||
String base = controller.getAnnotation(RequestMapping.class).value()[0];
|
||||
for (var method : controller.getDeclaredMethods()) {
|
||||
String verb = null; String[] suffix = null;
|
||||
if (method.isAnnotationPresent(GetMapping.class)) { verb = "get"; suffix = method.getAnnotation(GetMapping.class).value(); }
|
||||
if (method.isAnnotationPresent(PostMapping.class)) { verb = "post"; suffix = method.getAnnotation(PostMapping.class).value(); }
|
||||
if (method.isAnnotationPresent(PutMapping.class)) { verb = "put"; suffix = method.getAnnotation(PutMapping.class).value(); }
|
||||
if (verb == null) continue;
|
||||
String path = base + (suffix.length == 0 ? "" : suffix[0]);
|
||||
assertThat(paths.containsKey(path)).as(path).isTrue();
|
||||
Map<?, ?> operation = (Map<?, ?>) ((Map<?, ?>) paths.get(path)).get(verb);
|
||||
String expression = method.getAnnotation(PreAuthorize.class).value();
|
||||
var authorities = Pattern.compile("'([^']+)'").matcher(expression);
|
||||
int permissionCount=0;
|
||||
while(authorities.find()) {
|
||||
String permission=authorities.group(1);
|
||||
assertThat(operation.get("description").toString()).contains(permission);
|
||||
assertThat(module).contains("'" + permission + "'");
|
||||
assertThat(deployed).contains("'" + permission + "'");
|
||||
permissionCount++;
|
||||
}
|
||||
assertThat(permissionCount).isPositive();
|
||||
count++;
|
||||
}
|
||||
}
|
||||
assertThat(count).isEqualTo(28);
|
||||
verifyRefs(document, document);
|
||||
}
|
||||
|
||||
@Test void schemasMatchAndUseUniqueResourceClaimsAndVersionedHistory() throws Exception {
|
||||
String module = Files.readString(Path.of("src/main/resources/db/lastleg.sql"));
|
||||
String deployed = Files.readString(Path.of("../deploy/sql/海外区域统一增量-MySQL8.sql"));
|
||||
for (String table : java.util.List.of("ll_fleet_trip", "ll_fleet_resource_assignment", "ll_fleet_trip_history", "ll_fleet_trip_dispatch", "ll_fleet_external_booking")) {
|
||||
var pattern = Pattern.compile("(?s)CREATE TABLE " + table + " \\(.*?;");
|
||||
var a = pattern.matcher(module); var b = pattern.matcher(deployed);
|
||||
assertThat(a.find()).isTrue(); assertThat(b.find()).isTrue();
|
||||
assertThat(a.group().replace("\r\n", "\n")).isEqualTo(b.group().replace("\r\n", "\n"));
|
||||
}
|
||||
assertThat(deployed).contains("PRIMARY KEY (resource_kind,resource_id)")
|
||||
.contains("UNIQUE KEY uk_fleet_trip_create (created_by,idempotency_key)")
|
||||
.contains("UNIQUE KEY uk_fleet_trip_history_version (trip_id,record_version)");
|
||||
}
|
||||
|
||||
private static void verifyRefs(Object node, Map<String, Object> root) {
|
||||
if (node instanceof Map<?, ?> map) {
|
||||
if (map.get("$ref") instanceof String reference) {
|
||||
assertThat(reference).startsWith("#/");
|
||||
Object value = root;
|
||||
for (String key : reference.substring(2).split("/")) value = ((Map<?, ?>) value).get(key);
|
||||
assertThat(value).as(reference).isNotNull();
|
||||
}
|
||||
map.values().forEach(value -> verifyRefs(value, root));
|
||||
} else if (node instanceof Iterable<?> values) values.forEach(value -> verifyRefs(value, root));
|
||||
}
|
||||
}
|
||||
+55
@@ -0,0 +1,55 @@
|
||||
package com.baiyue.lastleg.fleet.trip;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.context.annotation.*;
|
||||
import org.springframework.security.access.AccessDeniedException;
|
||||
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
|
||||
import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity;
|
||||
import org.springframework.security.core.authority.SimpleGrantedAuthority;
|
||||
import org.springframework.security.core.context.SecurityContextHolder;
|
||||
import java.util.List;
|
||||
import static org.assertj.core.api.Assertions.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
class FleetTripAuthorizationTest {
|
||||
@Test void listPermissionCannotReserveOrReleaseResources() {
|
||||
try (var context = new AnnotationConfigApplicationContext(Config.class)) {
|
||||
SecurityContextHolder.getContext().setAuthentication(new UsernamePasswordAuthenticationToken("operator", "unused", List.of(new SimpleGrantedAuthority("platform:fleet:trip:list"))));
|
||||
var controller = context.getBean(PlatformFleetTripController.class);
|
||||
var service = context.getBean(FleetTripService.class);
|
||||
controller.detail("TR-TEST"); verify(service).detail("TR-TEST");
|
||||
assertThatThrownBy(() -> controller.create(null, null)).isInstanceOf(AccessDeniedException.class);
|
||||
assertThatThrownBy(() -> controller.assign(null, "TR-TEST", null)).isInstanceOf(AccessDeniedException.class);
|
||||
assertThatThrownBy(() -> controller.cancel(null, "TR-TEST", null)).isInstanceOf(AccessDeniedException.class);
|
||||
assertThatThrownBy(() -> controller.plan(null, "TR-TEST", null)).isInstanceOf(AccessDeniedException.class);
|
||||
assertThatThrownBy(() -> controller.bind(null, "TR-TEST", null)).isInstanceOf(AccessDeniedException.class);
|
||||
assertThatThrownBy(() -> controller.unbind(null, "TR-TEST", null)).isInstanceOf(AccessDeniedException.class);
|
||||
assertThatThrownBy(() -> controller.confirmLoading(null, "TR-TEST", null)).isInstanceOf(AccessDeniedException.class);
|
||||
assertThatThrownBy(() -> controller.reviewDispatch(null, "TR-TEST", "DP-1", null)).isInstanceOf(AccessDeniedException.class);
|
||||
assertThatThrownBy(() -> controller.reviewLoading(null, "TR-TEST", null)).isInstanceOf(AccessDeniedException.class);
|
||||
assertThatThrownBy(() -> controller.complete(null, "TR-TEST", null)).isInstanceOf(AccessDeniedException.class);
|
||||
assertThatThrownBy(() -> controller.release(null, "TR-TEST", null)).isInstanceOf(AccessDeniedException.class);
|
||||
assertThatThrownBy(() -> controller.confirmCapacityPlan(null, "TR-TEST", null)).isInstanceOf(AccessDeniedException.class);
|
||||
var booking = context.getBean(PlatformFleetBookingController.class);
|
||||
booking.list("TR-TEST"); verify(context.getBean(FleetBookingService.class)).list("TR-TEST");
|
||||
assertThatThrownBy(() -> booking.create(null,"TR-TEST",null)).isInstanceOf(AccessDeniedException.class);
|
||||
assertThatThrownBy(() -> booking.update(null,"TR-TEST","FB-1",null)).isInstanceOf(AccessDeniedException.class);
|
||||
assertThatThrownBy(() -> booking.confirm(null,"TR-TEST","FB-1",null)).isInstanceOf(AccessDeniedException.class);
|
||||
assertThatThrownBy(() -> booking.cancel(null,"TR-TEST","FB-1",null)).isInstanceOf(AccessDeniedException.class);
|
||||
assertThatThrownBy(() -> booking.incoming(null,"TR-TEST","FB-1",null)).isInstanceOf(AccessDeniedException.class);
|
||||
assertThatThrownBy(() -> booking.receipt(null,"TR-TEST","FB-1",null)).isInstanceOf(AccessDeniedException.class);
|
||||
assertThatThrownBy(() -> booking.bind(null,"TR-TEST","FB-1",null)).isInstanceOf(AccessDeniedException.class);
|
||||
verifyNoMoreInteractions(service);
|
||||
} finally { SecurityContextHolder.clearContext(); }
|
||||
}
|
||||
@Configuration @EnableMethodSecurity
|
||||
static class Config {
|
||||
@Bean FleetBookingService bookingService() { return mock(FleetBookingService.class); }
|
||||
@Bean com.baiyue.lastleg.fleet.customer.FleetCustomerDirectory customers() { return mock(com.baiyue.lastleg.fleet.customer.FleetCustomerDirectory.class); }
|
||||
@Bean PlatformFleetBookingController booking(FleetBookingService service, com.baiyue.lastleg.fleet.customer.FleetCustomerDirectory customers) { return new PlatformFleetBookingController(service,customers); }
|
||||
@Bean FleetTripService service() { return mock(FleetTripService.class); }
|
||||
@Bean FleetTripExecutionService execution() { return mock(FleetTripExecutionService.class); }
|
||||
@Bean FleetTripCompletionService completion() { return mock(FleetTripCompletionService.class); }
|
||||
@Bean PlatformFleetTripController controller(FleetTripService service, FleetTripExecutionService execution, FleetTripCompletionService completion) { return new PlatformFleetTripController(service, execution, completion); }
|
||||
}
|
||||
}
|
||||
+866
@@ -0,0 +1,866 @@
|
||||
package com.baiyue.lastleg.fleet.trip;
|
||||
|
||||
import com.baomidou.mybatisplus.core.MybatisConfiguration;
|
||||
import com.baomidou.mybatisplus.extension.spring.MybatisSqlSessionFactoryBean;
|
||||
import com.baiyue.lastleg.fleet.driver.*;
|
||||
import com.baiyue.lastleg.dispatch.fleet.DispatchFleetAdapter;
|
||||
import com.baiyue.lastleg.dispatch.mapper.DispatchOrderMapper;
|
||||
import com.baiyue.lastleg.dispatch.pda.mapper.DispatchPdaTaskMapper;
|
||||
import com.baiyue.lastleg.delivery.mapper.OutboundOrderMapper;
|
||||
import com.baiyue.lastleg.delivery.mapper.DeliveryOrderMapper;
|
||||
import com.baiyue.lastleg.delivery.mapper.DeliveryOperationMapper;
|
||||
import com.baiyue.lastleg.dispatch.mapper.DispatchItemMapper;
|
||||
import com.baiyue.lastleg.dispatch.mapper.DispatchPalletLockMapper;
|
||||
import com.baiyue.lastleg.dispatch.pda.mapper.DispatchPdaPickMapper;
|
||||
import com.baiyue.lastleg.dispatch.pda.mapper.DispatchPdaOperationMapper;
|
||||
import com.baiyue.lastleg.dispatch.pda.service.DispatchPdaWorkflowService;
|
||||
import com.baiyue.lastleg.dispatch.pda.service.DispatchPickOrderAttributionService;
|
||||
import org.springframework.test.util.ReflectionTestUtils;
|
||||
import com.baiyue.lastleg.masterdata.vehicle.adapter.out.persistence.*;
|
||||
import com.baiyue.lastleg.masterdata.vehicle.application.VehicleApplicationService;
|
||||
import com.baiyue.lastleg.masterdata.vehicle.application.model.VehicleModels;
|
||||
import com.baiyue.lastleg.masterdata.vehicle.application.port.out.VehicleRepository;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.mybatis.spring.SqlSessionTemplate;
|
||||
import org.springframework.aop.framework.ProxyFactory;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.jdbc.datasource.DataSourceTransactionManager;
|
||||
import org.springframework.jdbc.datasource.DriverManagerDataSource;
|
||||
import org.springframework.mock.web.MockMultipartFile;
|
||||
import org.springframework.transaction.annotation.AnnotationTransactionAttributeSource;
|
||||
import org.springframework.transaction.interceptor.TransactionInterceptor;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.time.*;
|
||||
import java.time.temporal.ChronoUnit;
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.*;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
import static org.assertj.core.api.Assertions.*;
|
||||
import static org.mockito.ArgumentMatchers.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
class FleetTripPersistenceTest {
|
||||
private JdbcTemplate jdbc;
|
||||
private FleetTripService service;
|
||||
private DriverService drivers;
|
||||
private VehicleRepository vehicles;
|
||||
private VehicleApplicationService vehicleService;
|
||||
private DataSourceTransactionManager transactions;
|
||||
private FleetDispatchBindingGuard bindingGuard;
|
||||
private FleetTripExecutionService execution;
|
||||
private FleetTripCompletionService completion;
|
||||
private FleetBookingService bookingService;
|
||||
private FleetBookingIncomingGateway incoming;
|
||||
private Instant departure, end;
|
||||
|
||||
@BeforeEach void initialize() throws Exception {
|
||||
departure = Instant.now().plus(1, ChronoUnit.HOURS).truncatedTo(ChronoUnit.MILLIS);
|
||||
end = departure.plus(12, ChronoUnit.HOURS);
|
||||
var dataSource = new DriverManagerDataSource("jdbc:h2:mem:" + UUID.randomUUID() + ";MODE=MySQL;DB_CLOSE_DELAY=-1", "sa", "");
|
||||
jdbc = new JdbcTemplate(dataSource); transactions = new DataSourceTransactionManager(dataSource);
|
||||
String schema = Files.readString(Path.of("src/main/resources/db/lastleg.sql"));
|
||||
for (String table : List.of("ll_fleet_driver", "ll_fleet_driver_history", "ll_vehicle", "ll_fleet_trip", "ll_fleet_resource_assignment", "ll_fleet_trip_history",
|
||||
"ll_fleet_external_booking", "ll_fleet_trip_dispatch", "ll_dispatch_order", "ll_dispatch_pda_task", "ll_outbound_order", "ll_delivery_order",
|
||||
"ll_dispatch_item", "ll_dispatch_pda_pick", "ll_dispatch_pda_operation", "ll_dispatch_pallet_lock", "ll_delivery_operation")) {
|
||||
var match = Pattern.compile("(?s)CREATE TABLE(?: IF NOT EXISTS)? " + table + " \\(.*?;").matcher(schema);
|
||||
assertThat(match.find()).as(table).isTrue();
|
||||
jdbc.execute(match.group().replace(" JSON", " LONGTEXT"));
|
||||
}
|
||||
var configuration = new MybatisConfiguration();
|
||||
for (var mapper : List.of(DriverMapper.class, DriverHistoryMapper.class, VehicleMapper.class, FleetTripMapper.class, FleetResourceMapper.class,
|
||||
FleetTripHistoryMapper.class, FleetTripDispatchMapper.class, FleetBookingMapper.class, DispatchOrderMapper.class, DispatchPdaTaskMapper.class, OutboundOrderMapper.class, DeliveryOrderMapper.class,
|
||||
DispatchItemMapper.class, DispatchPdaPickMapper.class, DispatchPdaOperationMapper.class, DispatchPalletLockMapper.class, DeliveryOperationMapper.class)) configuration.addMapper(mapper);
|
||||
var pagination = new com.baomidou.mybatisplus.extension.plugins.MybatisPlusInterceptor();
|
||||
pagination.addInnerInterceptor(new com.baomidou.mybatisplus.extension.plugins.inner.PaginationInnerInterceptor(com.baomidou.mybatisplus.annotation.DbType.H2));
|
||||
configuration.addInterceptor(pagination);
|
||||
var factory = new MybatisSqlSessionFactoryBean(); factory.setDataSource(dataSource); factory.setConfiguration(configuration);
|
||||
var session = new SqlSessionTemplate(factory.getObject());
|
||||
var directory = mock(FleetWarehouseDirectory.class);
|
||||
when(directory.required(92, "OVERSEAS")).thenReturn(new DriverModels.WarehouseOption("92", "WH-US", "公共海外仓", "OVERSEAS"));
|
||||
var files = mock(DriverFileGateway.class);
|
||||
when(files.upload(anyLong(), anyString(), any())).thenAnswer(call -> new DriverModels.File(Long.toString(call.getArgument(0, Long.class) + 1000), "license.pdf", "application/pdf", 8));
|
||||
when(files.metadata(anyLong(), anyLong())).thenAnswer(call -> new DriverModels.File(Long.toString(call.getArgument(1, Long.class)), "license.pdf", "application/pdf", 8));
|
||||
var json = new ObjectMapper().registerModule(new JavaTimeModule());
|
||||
drivers = transactional(new DriverService(session.getMapper(DriverMapper.class), session.getMapper(DriverHistoryMapper.class), files, json, directory), DriverService.class);
|
||||
vehicles = new VehiclePersistenceAdapter(session.getMapper(VehicleMapper.class));
|
||||
var resources = session.getMapper(FleetResourceMapper.class);
|
||||
vehicleService = transactional(new VehicleApplicationService(vehicles, new FleetVehicleAssignmentGuard(resources)), VehicleApplicationService.class);
|
||||
var bookingMapper = session.getMapper(FleetBookingMapper.class);
|
||||
var reservations = new FleetBookingReservations(bookingMapper, json);
|
||||
var bindingMapper = session.getMapper(FleetTripDispatchMapper.class);
|
||||
bindingGuard = transactional(new FleetDispatchBindingGuard(bindingMapper, json), FleetDispatchBindingGuard.class);
|
||||
var workflow = new DispatchPdaWorkflowService();
|
||||
ReflectionTestUtils.setField(workflow, "orders", session.getMapper(DispatchOrderMapper.class));
|
||||
ReflectionTestUtils.setField(workflow, "outboundOrders", session.getMapper(OutboundOrderMapper.class));
|
||||
ReflectionTestUtils.setField(workflow, "items", session.getMapper(DispatchItemMapper.class));
|
||||
ReflectionTestUtils.setField(workflow, "tasks", session.getMapper(DispatchPdaTaskMapper.class));
|
||||
ReflectionTestUtils.setField(workflow, "picks", session.getMapper(DispatchPdaPickMapper.class));
|
||||
ReflectionTestUtils.setField(workflow, "operations", session.getMapper(DispatchPdaOperationMapper.class));
|
||||
ReflectionTestUtils.setField(workflow, "palletLocks", session.getMapper(DispatchPalletLockMapper.class));
|
||||
ReflectionTestUtils.setField(workflow, "fleetBindings", bindingGuard);
|
||||
ReflectionTestUtils.setField(workflow, "objectMapper", json);
|
||||
ReflectionTestUtils.setField(workflow, "pickAttribution", mock(DispatchPickOrderAttributionService.class));
|
||||
var dispatchGateway = transactional(new DispatchFleetAdapter(session.getMapper(DispatchOrderMapper.class), session.getMapper(DispatchPdaTaskMapper.class),
|
||||
session.getMapper(OutboundOrderMapper.class), session.getMapper(DeliveryOrderMapper.class), session.getMapper(DispatchItemMapper.class), session.getMapper(DispatchPdaPickMapper.class),
|
||||
transactional(workflow, DispatchPdaWorkflowService.class), session.getMapper(DeliveryOperationMapper.class)), DispatchFleetAdapter.class);
|
||||
service = transactional(new FleetTripService(session.getMapper(FleetTripMapper.class), resources, session.getMapper(FleetTripHistoryMapper.class), vehicles, drivers, directory, json,
|
||||
bindingMapper, dispatchGateway, reservations), FleetTripService.class);
|
||||
execution = transactional(new FleetTripExecutionService(session.getMapper(FleetTripMapper.class), bindingMapper, resources, dispatchGateway, service, drivers, vehicles, json, reservations), FleetTripExecutionService.class);
|
||||
var customerDirectory = mock(com.baiyue.lastleg.fleet.customer.FleetCustomerDirectory.class);
|
||||
when(customerDirectory.required(anyLong())).thenAnswer(call -> new com.baiyue.lastleg.fleet.customer.FleetCustomerDirectory.Customer(
|
||||
Long.toString(call.getArgument(0,Long.class)), "00001", "外接客户", null, null));
|
||||
incoming=mock(FleetBookingIncomingGateway.class);
|
||||
bookingService = transactional(new FleetBookingService(session.getMapper(FleetTripMapper.class), bookingMapper, reservations, service, execution, customerDirectory, json, incoming), FleetBookingService.class);
|
||||
completion = transactional(new FleetTripCompletionService(session.getMapper(FleetTripMapper.class), bindingMapper, resources, dispatchGateway, service, vehicles, vehicleService, json), FleetTripCompletionService.class);
|
||||
}
|
||||
|
||||
@Test void creationReplayUsesSameBusinessNumberAndRejectsChangedPayload() {
|
||||
var request = createRequest("creation-one");
|
||||
var first = service.create("1", request);
|
||||
assertThat(service.create("1", request).trip().tripNo()).isEqualTo(first.trip().tripNo());
|
||||
assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM ll_fleet_trip", Integer.class)).isEqualTo(1);
|
||||
assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM ll_fleet_trip_history", Integer.class)).isEqualTo(1);
|
||||
assertThatThrownBy(() -> service.create("1", new FleetTripModels.Create(92L, departure, end.plusSeconds(1), request.stops(), null, request.idempotencyKey())))
|
||||
.hasMessageContaining("不同车次内容");
|
||||
assertThat(service.create("2", request).trip().tripNo()).isNotEqualTo(first.trip().tripNo());
|
||||
}
|
||||
|
||||
@Test void assignmentLocksBothResourcesAndCancellationReleasesThemWithoutRemovingHistory() {
|
||||
long vehicle = vehicle(true), driver = driver(true);
|
||||
String first = trip(), second = trip();
|
||||
var assigned = service.assign(first, "1", new FleetTripModels.Assign(0L, vehicle, driver, "安排运输"));
|
||||
assertThat(assigned.driverQualification().canDispatch()).isTrue();
|
||||
assertThat(assigned.trip().maxPalletSpaces()).isEqualTo(30);
|
||||
assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM ll_fleet_resource_assignment", Integer.class)).isEqualTo(2);
|
||||
assertThatThrownBy(() -> service.assign(second, "1", new FleetTripModels.Assign(0L, vehicle, driver, "重复分配"))).hasMessageContaining("占用");
|
||||
assertThat(service.cancel(first, "1", new FleetTripModels.Cancel(1L, "取消安排")).trip().status()).isEqualTo("CANCELLED");
|
||||
assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM ll_fleet_resource_assignment", Integer.class)).isZero();
|
||||
assertThat(service.history(first, 1, 20).records()).extracting(FleetTripModels.History::action).containsExactly("CANCEL", "ASSIGN", "CREATE");
|
||||
assertThat(service.assign(second, "1", new FleetTripModels.Assign(0L, vehicle, driver, "重新安排")).trip().vehicleId()).isEqualTo(Long.toString(vehicle));
|
||||
}
|
||||
|
||||
@Test void failedReassignmentKeepsOldResourcesAndRollsBackTheNewVehicleClaim() {
|
||||
long oldVehicle = vehicle(true), occupiedVehicle = vehicle(true), replacement = vehicle(true);
|
||||
long oldDriver = driver(true), occupiedDriver = driver(true);
|
||||
String first = trip(), second = trip();
|
||||
service.assign(first, "1", new FleetTripModels.Assign(0L, oldVehicle, oldDriver, "安排"));
|
||||
service.assign(second, "1", new FleetTripModels.Assign(0L, occupiedVehicle, occupiedDriver, "安排"));
|
||||
assertThatThrownBy(() -> service.assign(first, "1", new FleetTripModels.Assign(1L, replacement, occupiedDriver, "改派"))).hasMessageContaining("占用");
|
||||
var retained = service.detail(first).trip();
|
||||
assertThat(retained.vehicleId()).isEqualTo(Long.toString(oldVehicle));
|
||||
assertThat(retained.recordVersion()).isEqualTo(1);
|
||||
assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM ll_fleet_resource_assignment WHERE resource_id=?", Integer.class, replacement)).isZero();
|
||||
assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM ll_fleet_resource_assignment", Integer.class)).isEqualTo(4);
|
||||
}
|
||||
|
||||
@Test void missingCapacityOrUnapprovedDriverCannotBeAssigned() {
|
||||
String number = trip();
|
||||
long unknownCapacity = vehicle(false), validDriver = driver(true);
|
||||
assertThatThrownBy(() -> service.assign(number, "1", new FleetTripModels.Assign(0L, unknownCapacity, validDriver, "安排"))).hasMessageContaining("托位、载重和容积");
|
||||
long validVehicle = vehicle(true), missingLicense = driver(false);
|
||||
assertThatThrownBy(() -> service.assign(number, "1", new FleetTripModels.Assign(0L, validVehicle, missingLicense, "安排"))).hasMessageContaining("驾驶证");
|
||||
assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM ll_fleet_resource_assignment", Integer.class)).isZero();
|
||||
assertThat(service.detail(number).trip().recordVersion()).isZero();
|
||||
}
|
||||
|
||||
@Test void extendingPlanPastLicenseExpiryFailsAndKeepsTheOldPlan() {
|
||||
String number = trip();
|
||||
service.assign(number, "1", new FleetTripModels.Assign(0L, vehicle(true), driver(true), "安排"));
|
||||
assertThatThrownBy(() -> service.updatePlan(number, "1", new FleetTripModels.Plan(1L, departure, end.plus(60, ChronoUnit.DAYS), stops(), null, "延期")))
|
||||
.hasMessageContaining("预计任务结束前到期");
|
||||
assertThat(service.detail(number).trip().expectedDriverEndAt()).isEqualTo(end);
|
||||
assertThat(service.detail(number).trip().recordVersion()).isEqualTo(1);
|
||||
}
|
||||
|
||||
@Test void failedHistoryWriteRollsBackTripAndAllResourceClaims() {
|
||||
String number = trip(); long vehicle = vehicle(true), driver = driver(true);
|
||||
jdbc.execute("ALTER TABLE ll_fleet_trip_history ADD CONSTRAINT reject_assignment CHECK(action <> 'ASSIGN')");
|
||||
assertThatThrownBy(() -> service.assign(number, "1", new FleetTripModels.Assign(0L, vehicle, driver, "安排"))).isInstanceOf(RuntimeException.class);
|
||||
assertThat(service.detail(number).trip().vehicleId()).isNull();
|
||||
assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM ll_fleet_resource_assignment", Integer.class)).isZero();
|
||||
assertThat(service.detail(number).trip().recordVersion()).isZero();
|
||||
}
|
||||
|
||||
@Test void activeTripPreventsMasterDataFromChangingPlateCapacityOrStatus() {
|
||||
long vehicle = vehicle(true); String number = trip();
|
||||
service.assign(number, "1", new FleetTripModels.Assign(0L, vehicle, driver(true), "安排"));
|
||||
assertThatThrownBy(() -> vehicleService.changeStatus(vehicle, "1", new VehicleModels.ChangeStatus("MAINTENANCE", 0L))).hasMessageContaining(number);
|
||||
var original = vehicles.findById(vehicle).orElseThrow();
|
||||
assertThatThrownBy(() -> vehicleService.update(vehicle, "1", new VehicleModels.Update(0L, "CHANGED", original.model(), original.fuelConsumptionPer100Km(),
|
||||
original.mileageKm(), null, null, original.region(), original.ownership(), null, 30, new BigDecimal("18000"), new BigDecimal("90")))).hasMessageContaining(number);
|
||||
assertThatThrownBy(() -> vehicleService.update(vehicle, "1", new VehicleModels.Update(0L, original.plateNumber(), original.model(), original.fuelConsumptionPer100Km(),
|
||||
original.mileageKm(), null, null, original.region(), original.ownership(), null, 20, new BigDecimal("18000"), new BigDecimal("90")))).hasMessageContaining(number);
|
||||
service.cancel(number, "1", new FleetTripModels.Cancel(1L, "取消"));
|
||||
assertThat(vehicleService.changeStatus(vehicle, "1", new VehicleModels.ChangeStatus("MAINTENANCE", 0L)).status()).isEqualTo("MAINTENANCE");
|
||||
}
|
||||
|
||||
@Test void simultaneousVehicleAssignmentHasExactlyOneWinner() throws Exception {
|
||||
String first = trip(), second = trip(); long vehicle = vehicle(true), driver1 = driver(true), driver2 = driver(true);
|
||||
var start = new CountDownLatch(1); var executor = Executors.newFixedThreadPool(2);
|
||||
try {
|
||||
var a = executor.submit(() -> attempt(start, first, vehicle, driver1));
|
||||
var b = executor.submit(() -> attempt(start, second, vehicle, driver2));
|
||||
start.countDown();
|
||||
assertThat(List.of(a.get(15, TimeUnit.SECONDS), b.get(15, TimeUnit.SECONDS))).containsExactlyInAnyOrder(true, false);
|
||||
assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM ll_fleet_resource_assignment WHERE resource_kind='VEHICLE'", Integer.class)).isEqualTo(1);
|
||||
} finally { executor.shutdownNow(); }
|
||||
}
|
||||
|
||||
@Test void concurrentCreationWithTheSameKeyCreatesOneTripAndOneHistory() throws Exception {
|
||||
var create = createRequest("concurrent-create");
|
||||
var start = new CountDownLatch(1); var executor = Executors.newFixedThreadPool(2);
|
||||
Callable<String> command = () -> { start.await(5, TimeUnit.SECONDS); return service.create("1", create).trip().tripNo(); };
|
||||
try {
|
||||
var first = executor.submit(command); var second = executor.submit(command); start.countDown();
|
||||
assertThat(first.get(15, TimeUnit.SECONDS)).isEqualTo(second.get(15, TimeUnit.SECONDS));
|
||||
assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM ll_fleet_trip", Integer.class)).isEqualTo(1);
|
||||
assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM ll_fleet_trip_history", Integer.class)).isEqualTo(1);
|
||||
} finally { executor.shutdownNow(); }
|
||||
}
|
||||
|
||||
@Test void tripCombinesCustomersAndSynchronizesSnapshotsWithoutChangingTheirOwnershipOrArrivalTime() {
|
||||
String number = assignedTrip(); dispatch(11, "CLIENT-A", "92"); dispatch(12, "CLIENT-B", "92");
|
||||
delivery(11, "CLIENT-A"); delivery(12, "CLIENT-B");
|
||||
service.bindDispatch(number, "1", new FleetTripModels.BindingCommand(1L, "DP-11", "加入本车"));
|
||||
var bound = service.bindDispatch(number, "1", new FleetTripModels.BindingCommand(2L, "DP-12", "加入本车"));
|
||||
assertThat(bound.dispatches()).extracting(FleetTripModels.DispatchBinding::customerId).containsExactly("CLIENT-A", "CLIENT-B");
|
||||
long replacement = vehicle(true);
|
||||
var reassigned = service.assign(number, "1", new FleetTripModels.Assign(3L, replacement, driver(true), "更换车辆司机"));
|
||||
assertThat(jdbc.queryForList("SELECT vehicle_plate FROM ll_dispatch_order", String.class)).containsOnly(reassigned.trip().vehiclePlate());
|
||||
assertThat(jdbc.queryForList("SELECT vehicle_plate FROM ll_delivery_order", String.class)).containsOnly(reassigned.trip().vehiclePlate());
|
||||
assertThat(jdbc.queryForList("SELECT customer_id FROM ll_delivery_order ORDER BY id", String.class)).containsExactly("CLIENT-A", "CLIENT-B");
|
||||
assertThat(jdbc.queryForList("SELECT version FROM ll_delivery_order", Long.class)).containsOnly(2L);
|
||||
assertThat(jdbc.queryForObject("SELECT expected_arrival_at FROM ll_delivery_order WHERE id=2011", LocalDateTime.class)).isEqualTo(LocalDateTime.of(2030, 1, 1, 12, 0));
|
||||
assertThat(service.history(number, 1, 20).records().get(0).snapshot().dispatches()).hasSize(2);
|
||||
}
|
||||
|
||||
@Test void unbindClearsCurrentSnapshotsButKeepsHistoryAndRequiresUnpickedCargo() {
|
||||
String number = assignedTrip(); dispatch(11, "CLIENT-A", "92"); delivery(11, "CLIENT-A");
|
||||
service.bindDispatch(number, "1", new FleetTripModels.BindingCommand(1L, "DP-11", "加入"));
|
||||
assertThatThrownBy(() -> service.cancel(number, "1", new FleetTripModels.Cancel(2L, "取消"))).hasMessageContaining("先解除");
|
||||
var tx = new org.springframework.transaction.support.TransactionTemplate(transactions);
|
||||
assertThatThrownBy(() -> tx.execute(status -> { bindingGuard.requireUnbound(11); return null; })).hasMessageContaining(number);
|
||||
assertThatThrownBy(() -> tx.execute(status -> { bindingGuard.requireCoordinatedDeparture(11); return null; })).hasMessageContaining("统一发车");
|
||||
assertThat(execution.gate("92", "DP-11").tripNo()).isEqualTo(number);
|
||||
var assigned = service.detail(number).trip();
|
||||
var actual = new FleetDispatchGateway.Assignment(assigned.vehiclePlate(), assigned.driverName(), assigned.driverPhone());
|
||||
assertThatThrownBy(() -> tx.execute(status -> { bindingGuard.requireMatchingAssignment(11, actual,
|
||||
new FleetDispatchGateway.Assignment("OTHER-PLATE", null, null)); return null; })).hasMessageContaining("统一维护");
|
||||
tx.execute(status -> { bindingGuard.requireMatchingAssignment(11, actual, new FleetDispatchGateway.Assignment(null, null, null)); return null; });
|
||||
jdbc.update("UPDATE ll_dispatch_order SET picked_package_count=1 WHERE id=11");
|
||||
assertThatThrownBy(() -> service.unbindDispatch(number, "1", new FleetTripModels.BindingCommand(2L, "DP-11", "撤下"))).hasMessageContaining("已开始执行");
|
||||
jdbc.update("UPDATE ll_dispatch_order SET picked_package_count=0 WHERE id=11");
|
||||
service.unbindDispatch(number, "1", new FleetTripModels.BindingCommand(2L, "DP-11", "撤下"));
|
||||
assertThat(jdbc.queryForObject("SELECT vehicle_plate FROM ll_dispatch_order WHERE id=11", String.class)).isNull();
|
||||
assertThat(jdbc.queryForObject("SELECT driver_name FROM ll_delivery_order WHERE id=2011", String.class)).isNull();
|
||||
assertThat(service.history(number, 1, 20).records().get(1).snapshot().dispatches()).hasSize(1);
|
||||
assertThat(service.detail(number).dispatches()).isEmpty();
|
||||
service.cancel(number, "1", new FleetTripModels.Cancel(3L, "取消"));
|
||||
}
|
||||
|
||||
@Test void wrongWarehouseAndStaleVersionCannotBindOrMutateCustomerDocuments() {
|
||||
String number = assignedTrip(); dispatch(11, "CLIENT-A", "93");
|
||||
assertThatThrownBy(() -> service.bindDispatch(number, "1", new FleetTripModels.BindingCommand(1L, "DP-11", "加入"))).hasMessageContaining("同一起运仓");
|
||||
jdbc.update("UPDATE ll_dispatch_order SET overseas_warehouse_id='92' WHERE id=11");
|
||||
assertThatThrownBy(() -> service.bindDispatch(number, "1", new FleetTripModels.BindingCommand(0L, "DP-11", "加入"))).hasMessageContaining("已被修改");
|
||||
assertThat(service.detail(number).dispatches()).isEmpty();
|
||||
assertThat(jdbc.queryForObject("SELECT vehicle_plate FROM ll_dispatch_order WHERE id=11", String.class)).isNull();
|
||||
}
|
||||
|
||||
@Test void bindingHistoryFailureRollsBackAssociationAndEveryDownstreamSnapshot() {
|
||||
String number = assignedTrip(); dispatch(11, "CLIENT-A", "92"); delivery(11, "CLIENT-A");
|
||||
jdbc.execute("ALTER TABLE ll_fleet_trip_history ADD CONSTRAINT reject_binding CHECK(action <> 'BIND_DISPATCH')");
|
||||
assertThatThrownBy(() -> service.bindDispatch(number, "1", new FleetTripModels.BindingCommand(1L, "DP-11", "加入"))).isInstanceOf(RuntimeException.class);
|
||||
assertThat(service.detail(number).dispatches()).isEmpty();
|
||||
assertThat(service.detail(number).trip().recordVersion()).isEqualTo(1L);
|
||||
assertThat(jdbc.queryForObject("SELECT vehicle_plate FROM ll_dispatch_order WHERE id=11", String.class)).isNull();
|
||||
assertThat(jdbc.queryForObject("SELECT vehicle_plate FROM ll_delivery_order WHERE id=2011", String.class)).isNull();
|
||||
assertThat(jdbc.queryForObject("SELECT version FROM ll_delivery_order WHERE id=2011", Long.class)).isZero();
|
||||
}
|
||||
|
||||
@Test void failedReassignmentOfOneExecutedMemberRollsBackOtherMembersAndResourceClaims() {
|
||||
String number = assignedTrip(); dispatch(11, "CLIENT-A", "92"); dispatch(12, "CLIENT-B", "92");
|
||||
service.bindDispatch(number, "1", new FleetTripModels.BindingCommand(1L, "DP-11", "加入"));
|
||||
service.bindDispatch(number, "1", new FleetTripModels.BindingCommand(2L, "DP-12", "加入"));
|
||||
String original = service.detail(number).trip().vehiclePlate();
|
||||
jdbc.update("UPDATE ll_dispatch_order SET status='DEPARTED',shipped_package_count=10 WHERE id=12");
|
||||
assertThatThrownBy(() -> service.assign(number, "1", new FleetTripModels.Assign(3L, vehicle(true), driver(true), "改派"))).hasMessageContaining("已开始执行");
|
||||
assertThat(jdbc.queryForList("SELECT vehicle_plate FROM ll_dispatch_order", String.class)).containsOnly(original);
|
||||
assertThat(service.detail(number).trip().recordVersion()).isEqualTo(3L);
|
||||
assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM ll_fleet_resource_assignment", Integer.class)).isEqualTo(2);
|
||||
assertThatThrownBy(() -> service.updatePlan(number, "1", new FleetTripModels.Plan(3L, departure, end.plusSeconds(60), stops(), null, "延后")))
|
||||
.hasMessageContaining("已开始执行");
|
||||
}
|
||||
|
||||
@Test void concurrentBindingToDifferentTripsHasExactlyOneWinner() throws Exception {
|
||||
String first = assignedTrip(), second = assignedTrip(); dispatch(11, "CLIENT-A", "92");
|
||||
var start = new CountDownLatch(1); var executor = Executors.newFixedThreadPool(2);
|
||||
try {
|
||||
var a = executor.submit(() -> bindAttempt(start, first)); var b = executor.submit(() -> bindAttempt(start, second)); start.countDown();
|
||||
assertThat(List.of(a.get(15, TimeUnit.SECONDS), b.get(15, TimeUnit.SECONDS))).containsExactlyInAnyOrder(true, false);
|
||||
assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM ll_fleet_trip_dispatch", Integer.class)).isEqualTo(1);
|
||||
assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM ll_fleet_trip_history WHERE action='BIND_DISPATCH'", Integer.class)).isEqualTo(1);
|
||||
} finally { executor.shutdownNow(); }
|
||||
}
|
||||
|
||||
@Test void externalReceiptChangesReservationOnlyAfterExplicitActualQuantityAndPriceConfirmation() {
|
||||
String number=readyTrip();var basis=execution.capacityPlan(number);
|
||||
execution.confirmCapacityPlan(number,"1",new FleetExecutionModels.Confirm(basis.recordVersion(),basis.manifestHash(),List.of(measurement()),"计划"));
|
||||
var draft=bookingService.create(number,"1",new FleetBookingModels.Create(service.detail(number).trip().recordVersion(),9L,externalCargo(2),"receipt-create"));
|
||||
var confirmed=bookingService.confirm(number,draft.booking().bookingNo(),"1",new FleetBookingModels.Command(draft.tripRecordVersion(),0L,"receipt-reserve",null));
|
||||
var awaiting=new FleetBookingIncomingGateway.Evidence("WT-EXT","WT-EXT","92","FBA:ONT8","WAIT_PDA_RECEIVING","empty",List.of(),0);
|
||||
when(incoming.create(eq("1"),any())).thenReturn(awaiting);
|
||||
var request=new FleetBookingModels.StartIncoming(confirmed.tripRecordVersion(),confirmed.booking().recordVersion(),null,null,null,null,null,null,"incoming-start");
|
||||
var started=bookingService.startIncoming(number,draft.booking().bookingNo(),"1",request);
|
||||
assertThat(bookingService.startIncoming(number,draft.booking().bookingNo(),"1",request).replayed()).isTrue();
|
||||
verify(incoming,times(1)).create(eq("1"),any());
|
||||
assertThat(started.booking().status()).isEqualTo("CONFIRMED");
|
||||
var actual=new FleetBookingIncomingGateway.Evidence("WT-EXT","WT-EXT","92","FBA:ONT8","WAIT_CUSTOMER_CONFIRMATION","actual-receipt",
|
||||
List.of(new FleetBookingIncomingGateway.Pallet("902","PALLET-902",22,new BigDecimal("150"),new BigDecimal("1.5"))),22);
|
||||
when(incoming.inspect("9","WT-EXT",true)).thenReturn(actual);
|
||||
when(incoming.confirm(eq("9"),eq("WT-EXT"),eq("1"),anyString())).thenReturn(actual);
|
||||
assertThatThrownBy(()->bookingService.acceptReceipt(number,draft.booking().bookingNo(),"1",new FleetBookingModels.AcceptReceipt(started.tripRecordVersion(),started.booking().recordVersion(),actual.receiptHash(),
|
||||
List.of(new FleetBookingModels.PalletSpaces("902",1)),new BigDecimal("55.00"),"accept-receipt"))).hasMessageContaining("实收确认未完成");
|
||||
assertThat(bookingService.list(number).records().get(0).agreedAmount()).isEqualByComparingTo("100.00");
|
||||
when(incoming.confirm(eq("9"),eq("WT-EXT"),eq("1"),anyString())).thenReturn(new FleetBookingIncomingGateway.Evidence(actual.incomingNo(),actual.inboundCode(),actual.warehouseId(),actual.destinationKey(),"READY_FOR_DISPATCH",actual.receiptHash(),actual.pallets(),actual.receivedPackages()));
|
||||
var accepted=bookingService.acceptReceipt(number,draft.booking().bookingNo(),"1",new FleetBookingModels.AcceptReceipt(started.tripRecordVersion(),started.booking().recordVersion(),actual.receiptHash(),
|
||||
List.of(new FleetBookingModels.PalletSpaces("902",1)),new BigDecimal("55.00"),"accept-receipt"));
|
||||
assertThat(accepted.booking().status()).isEqualTo("RECEIVED");assertThat(accepted.booking().cargo().packageCount()).isEqualTo(22);
|
||||
assertThat(accepted.booking().agreedAmount()).isEqualByComparingTo("55.00");
|
||||
assertThat(execution.capacityPlan(number).availableSegments().get(0).remaining().palletSpaces()).isEqualTo(28);
|
||||
dispatch(13,"9","92");
|
||||
jdbc.update("INSERT INTO ll_dispatch_item(id,dispatch_order_id,customer_id,warehouse_pallet_id,pallet_no,package_count,source_stage,destination_warehouse_code,overseas_warehouse_id,inbound_code) VALUES(3013,13,'9',902,'PALLET-902',22,'RECEIVED','ONT8','92','WT-EXT')");
|
||||
var linked=bookingService.bindDispatch(number,draft.booking().bookingNo(),"1",new FleetBookingModels.BindDispatch(accepted.tripRecordVersion(),accepted.booking().recordVersion(),"DP-13","link-external"));
|
||||
assertThat(linked.booking().status()).isEqualTo("BOUND");assertThat(linked.booking().dispatchNo()).isEqualTo("DP-13");
|
||||
assertThat(execution.capacityPlan(number).planValid()).isFalse();
|
||||
assertThat(service.detail(number).dispatches()).hasSize(3);
|
||||
service.unbindDispatch(number,"1",new FleetTripModels.BindingCommand(linked.tripRecordVersion(),"DP-13","撤销未开工关联"));
|
||||
assertThat(bookingService.list(number).records().get(0).status()).isEqualTo("RECEIVED");
|
||||
assertThat(bookingService.list(number).records().get(0).dispatchNo()).isNull();
|
||||
}
|
||||
|
||||
@Test void externalInquiryDoesNotReserveUntilConfirmedAndCancellationReleasesExactlyOnce() {
|
||||
String number=readyTrip(); var actual=confirm(number); var basis=execution.capacityPlan(number);
|
||||
execution.confirmCapacityPlan(number,"1",new FleetExecutionModels.Confirm(basis.recordVersion(),basis.manifestHash(),List.of(measurement()),"计划"));
|
||||
var create=new FleetBookingModels.Create(service.detail(number).trip().recordVersion(),9L,externalCargo(10),"external-create-one");
|
||||
var draft=bookingService.create(number,"1",create);
|
||||
assertThat(bookingService.create(number,"1",create).booking().bookingNo()).isEqualTo(draft.booking().bookingNo());
|
||||
assertThat(execution.capacityPlan(number).availableSegments().get(0).remaining().palletSpaces()).isEqualTo(29);
|
||||
var command=new FleetBookingModels.Command(draft.tripRecordVersion(),draft.booking().recordVersion(),"external-confirm-one",null);
|
||||
var accepted=bookingService.confirm(number,draft.booking().bookingNo(),"1",command);
|
||||
assertThat(accepted.booking().agreedAmount()).isEqualByComparingTo("500");
|
||||
assertThat(bookingService.confirm(number,draft.booking().bookingNo(),"1",command).replayed()).isTrue();
|
||||
assertThat(execution.capacityPlan(number).availableSegments().get(0).remaining().palletSpaces()).isEqualTo(19);
|
||||
assertThat(execution.gate("92","DP-11").blockReason()).contains("外接预订");
|
||||
assertThat(execution.loading(number).confirmedLoad()).isEqualTo(actual.confirmedLoad());
|
||||
var cancel=new FleetBookingModels.Command(accepted.tripRecordVersion(),accepted.booking().recordVersion(),"external-cancel-one",null);
|
||||
var cancelled=bookingService.cancel(number,draft.booking().bookingNo(),"1",cancel);
|
||||
assertThat(bookingService.cancel(number,draft.booking().bookingNo(),"1",cancel).tripRecordVersion()).isEqualTo(cancelled.tripRecordVersion());
|
||||
assertThat(execution.capacityPlan(number).availableSegments().get(0).remaining().palletSpaces()).isEqualTo(29);
|
||||
assertThat(execution.gate("92","DP-11").canDepart()).isTrue();
|
||||
assertThat(service.history(number,1,20).records().stream().filter(row->row.action().equals("BOOKING_CONFIRM")).findFirst().orElseThrow()
|
||||
.snapshot().externalBookings().get(0).status()).isEqualTo("CONFIRMED");
|
||||
}
|
||||
|
||||
@Test void secondExternalBookingCannotOversellAndOwnPlanCannotConsumeCommittedExternalSpace() {
|
||||
String number=readyTrip();var basis=execution.capacityPlan(number);
|
||||
execution.confirmCapacityPlan(number,"1",new FleetExecutionModels.Confirm(basis.recordVersion(),basis.manifestHash(),List.of(measurement()),"计划"));
|
||||
var first=bookingService.create(number,"1",new FleetBookingModels.Create(service.detail(number).trip().recordVersion(),9L,externalCargo(20),"external-first"));
|
||||
bookingService.confirm(number,first.booking().bookingNo(),"1",new FleetBookingModels.Command(first.tripRecordVersion(),0L,"confirm-first",null));
|
||||
var second=bookingService.create(number,"1",new FleetBookingModels.Create(service.detail(number).trip().recordVersion(),10L,externalCargo(10),"external-second"));
|
||||
assertThatThrownBy(()->bookingService.confirm(number,second.booking().bookingNo(),"1",new FleetBookingModels.Command(second.tripRecordVersion(),0L,"confirm-second",null))).hasMessageContaining("超过车辆");
|
||||
assertThat(bookingService.list(number).records().get(1).status()).isEqualTo("DRAFT");
|
||||
var excessive=new FleetExecutionModels.Measurement("901",11,new BigDecimal("100"),new BigDecimal("1"),1);
|
||||
assertThatThrownBy(()->execution.confirmCapacityPlan(number,"1",new FleetExecutionModels.Confirm(second.tripRecordVersion(),basis.manifestHash(),List.of(excessive),"增加自有货计划"))).hasMessageContaining("超过车辆");
|
||||
assertThat(execution.capacityPlan(number).availableSegments().get(0).remaining().palletSpaces()).isEqualTo(9);
|
||||
var changed = service.updatePlan(number,"1",new FleetTripModels.Plan(second.tripRecordVersion(),departure,end.plusSeconds(60),stops(),null,"调整时间"));
|
||||
assertThat(execution.capacityPlan(number).capacityPlan()).isNull();
|
||||
service.assign(number,"1",new FleetTripModels.Assign(changed.trip().recordVersion(),Long.valueOf(changed.trip().vehicleId()),driver(true),"同车换司机"));
|
||||
assertThat(bookingService.list(number).records().get(0).status()).isEqualTo("CONFIRMED");
|
||||
}
|
||||
private static FleetBookingModels.Cargo externalCargo(int spaces){return new FleetBookingModels.Cargo("FBA:ONT8","普通货",20,spaces,new BigDecimal("1000"),new BigDecimal("10"),"PALLET",new BigDecimal("50.00"),"USD",null,null,null);}
|
||||
|
||||
@Test void capacityCanBePlannedBeforePickingWithoutCreatingActualLoadOrApprovals() {
|
||||
String number = readyTrip();
|
||||
jdbc.update("DELETE FROM ll_dispatch_pda_pick");
|
||||
jdbc.update("UPDATE ll_dispatch_order SET status='PENDING_DEPARTURE',picked_package_count=0");
|
||||
jdbc.update("UPDATE ll_dispatch_pda_task SET status='PDA_PENDING'");
|
||||
jdbc.update("UPDATE ll_fleet_trip_dispatch SET review_json=NULL");
|
||||
var basis = execution.capacityPlan(number);
|
||||
assertThat(basis.planValid()).isFalse(); assertThat(basis.blockReason()).isNull();
|
||||
var plan = execution.confirmCapacityPlan(number, "1", new FleetExecutionModels.Confirm(basis.recordVersion(), basis.manifestHash(), List.of(measurement()), "计划载荷"));
|
||||
assertThat(plan.planValid()).isTrue();
|
||||
assertThat(plan.capacityPlan().pallets()).hasSize(1);
|
||||
assertThat(plan.capacityPlan().segments().get(0).remaining().palletSpaces()).isEqualTo(29);
|
||||
assertThat(execution.loading(number).confirmedLoad()).isNull();
|
||||
assertThat(execution.gate("92", "DP-11").canPick()).isFalse();
|
||||
assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM ll_dispatch_pda_pick", Integer.class)).isZero();
|
||||
assertThat(service.history(number, 1, 20).records().get(0).snapshot().capacityPlan()).isEqualTo(plan.capacityPlan());
|
||||
var unchanged = service.updatePlan(number, "1", new FleetTripModels.Plan(plan.recordVersion(), departure, end, stops(), "备注", "补充备注"));
|
||||
assertThat(execution.capacityPlan(number).planValid()).isTrue();
|
||||
service.assign(number, "1", new FleetTripModels.Assign(unchanged.trip().recordVersion(), vehicle(true), driver(true), "更换资源"));
|
||||
assertThat(execution.capacityPlan(number).capacityPlan()).isNull();
|
||||
}
|
||||
|
||||
@Test void capacityPlanRejectsStaleCargoAndOverbookingWithoutAffectingConfirmedActualLoad() {
|
||||
String number = readyTrip(); var actual = confirm(number); var basis = execution.capacityPlan(number);
|
||||
var tooHeavy = new FleetExecutionModels.Measurement("901", 1, new BigDecimal("19000"), new BigDecimal("1"), 1);
|
||||
assertThatThrownBy(() -> execution.confirmCapacityPlan(number, "1", new FleetExecutionModels.Confirm(basis.recordVersion(), basis.manifestHash(), List.of(tooHeavy), "预估"))).hasMessageContaining("超过车辆");
|
||||
var planned = execution.confirmCapacityPlan(number, "1", new FleetExecutionModels.Confirm(basis.recordVersion(), basis.manifestHash(), List.of(measurement()), "预估"));
|
||||
assertThat(execution.loading(number).confirmedLoad()).isEqualTo(actual.confirmedLoad());
|
||||
assertThat(service.detail(number).loadingReview().status()).isEqualTo("APPROVED");
|
||||
jdbc.update("UPDATE ll_dispatch_item SET package_count=11 WHERE id=3011");
|
||||
assertThat(execution.capacityPlan(number).planValid()).isFalse();
|
||||
assertThatThrownBy(() -> execution.confirmCapacityPlan(number, "1", new FleetExecutionModels.Confirm(planned.recordVersion(), basis.manifestHash(), List.of(measurement()), "旧页面"))).hasMessageContaining("货物明细已变化");
|
||||
assertThat(service.detail(number).trip().recordVersion()).isEqualTo(planned.recordVersion());
|
||||
}
|
||||
|
||||
@Test void dispatchReviewsAreIndependentAndRejectionBlocksOnlyNewPickingForThatCustomer() {
|
||||
String number = assignedTrip(); dispatch(11, "CLIENT-A", "92"); dispatch(12, "CLIENT-B", "92");
|
||||
service.bindDispatch(number, "1", new FleetTripModels.BindingCommand(1L, "DP-11", "加入"));
|
||||
service.bindDispatch(number, "1", new FleetTripModels.BindingCommand(2L, "DP-12", "加入"));
|
||||
var tx = new org.springframework.transaction.support.TransactionTemplate(transactions);
|
||||
assertThatThrownBy(() -> tx.execute(status -> { bindingGuard.requirePickingApproved(11); return null; })).hasMessageContaining("调度审核");
|
||||
service.reviewDispatch(number, "DP-11", "1", new FleetTripModels.Review(3L, "APPROVED", null));
|
||||
var gates = execution.gates("92", List.of("DP-11", "DP-12"));
|
||||
assertThat(gates.get("DP-11").canPick()).isTrue();
|
||||
assertThat(gates.get("DP-12").canPick()).isFalse();
|
||||
tx.execute(status -> { bindingGuard.requirePickingApproved(11); return null; });
|
||||
assertThatThrownBy(() -> service.reviewDispatch(number, "DP-12", "1", new FleetTripModels.Review(4L, "REJECTED", " "))).hasMessageContaining("打回原因");
|
||||
service.reviewDispatch(number, "DP-12", "1", new FleetTripModels.Review(4L, "REJECTED", "核对预约时间"));
|
||||
assertThatThrownBy(() -> tx.execute(status -> { bindingGuard.requirePickingApproved(12); return null; })).hasMessageContaining("核对预约时间");
|
||||
service.reviewDispatch(number, "DP-12", "1", new FleetTripModels.Review(5L, "APPROVED", null));
|
||||
assertThat(service.detail(number).dispatches()).allMatch(row -> "APPROVED".equals(row.review().status()));
|
||||
}
|
||||
|
||||
@Test void loadingReviewRequiresActualLoadAndRemarkOnlyEditPreservesApprovals() {
|
||||
String number = readyTrip(); var loading = execution.loading(number);
|
||||
assertThatThrownBy(() -> execution.reviewLoading(number, "1", new FleetTripModels.Review(loading.recordVersion(), "APPROVED", null))).hasMessageContaining("确认本车实际装载");
|
||||
var confirmed = execution.confirmLoad(number, "1", new FleetExecutionModels.Confirm(loading.recordVersion(), loading.manifestHash(), List.of(measurement()), "核对装载"));
|
||||
assertThat(execution.gate("92", "DP-11").blockReason()).contains("理货审核");
|
||||
assertThatThrownBy(() -> execution.depart("92", "DP-11", "1", new FleetExecutionModels.Depart(confirmed.recordVersion(), "before-review", null))).hasMessageContaining("理货审核");
|
||||
var rejected = execution.reviewLoading(number, "1", new FleetTripModels.Review(confirmed.recordVersion(), "REJECTED", "重新核对重量"));
|
||||
assertThat(execution.gate("92", "DP-11").blockReason()).contains("重新核对重量");
|
||||
var approved = execution.reviewLoading(number, "1", new FleetTripModels.Review(rejected.trip().recordVersion(), "APPROVED", null));
|
||||
var changed = service.updatePlan(number, "1", new FleetTripModels.Plan(approved.trip().recordVersion(), departure, end, stops(), "补充备注", "更新备注"));
|
||||
assertThat(changed.loadingReview().status()).isEqualTo("APPROVED");
|
||||
assertThat(execution.gate("92", "DP-11").canDepart()).isTrue();
|
||||
execution.confirmLoad(number, "1", new FleetExecutionModels.Confirm(changed.trip().recordVersion(), loading.manifestHash(), List.of(measurement()), "重新测量"));
|
||||
assertThat(service.detail(number).loadingReview().status()).isEqualTo("PENDING");
|
||||
assertThat(execution.gate("92", "DP-11").canDepart()).isFalse();
|
||||
}
|
||||
|
||||
@Test void actualLoadingCountsSharedPhysicalPalletOnceAndDepartureShipsAllCustomersAtomically() {
|
||||
String number = readyTrip();
|
||||
assertThat(execution.gate("92", "DP-11").canDepart()).isFalse();
|
||||
var loading = confirm(number);
|
||||
assertThat(loading.confirmedLoad().cargo()).hasSize(2);
|
||||
assertThat(loading.confirmedLoad().segments().get(0).occupied().palletSpaces()).isEqualTo(1);
|
||||
assertThat(loading.confirmedLoad().segments().get(0).occupied().weightKg()).isEqualByComparingTo("100");
|
||||
assertThat(execution.gates("92", List.of("DP-11", "DP-12")).values()).allMatch(FleetExecutionModels.Gate::canDepart);
|
||||
assertThat(execution.gate("92", "DP-11").driverWarning()).contains("有效期覆盖本次预计任务");
|
||||
var request = new FleetExecutionModels.Depart(loading.recordVersion(), "departure-first", "整车发车");
|
||||
var result = execution.depart("92", "DP-11", "1", request);
|
||||
assertThat(result.shippedPackages()).isEqualTo(20);
|
||||
assertThat(result.dispatches()).extracting(FleetExecutionModels.DepartedDispatch::dispatchNo).containsExactly("DP-11", "DP-12");
|
||||
assertThat(jdbc.queryForList("SELECT status FROM ll_dispatch_order", String.class)).containsOnly("DEPARTED");
|
||||
assertThat(jdbc.queryForList("SELECT status FROM ll_outbound_order", String.class)).containsOnly("OUTBOUNDED");
|
||||
assertThat(jdbc.queryForObject("SELECT COUNT(DISTINCT depart_at) FROM ll_dispatch_order", Integer.class)).isEqualTo(1);
|
||||
assertThat(service.detail(number).trip().status()).isEqualTo("IN_TRANSIT");
|
||||
assertThat(execution.depart("92", "DP-12", "1", request).replayed()).isTrue();
|
||||
assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM ll_dispatch_pda_operation WHERE operation_type='DEPART'", Integer.class)).isEqualTo(2);
|
||||
assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM ll_fleet_trip_history WHERE action='DEPART'", Integer.class)).isEqualTo(1);
|
||||
assertThatThrownBy(() -> execution.depart("92", "DP-11", "1", new FleetExecutionModels.Depart(loading.recordVersion(), "departure-first", "changed")))
|
||||
.hasMessageContaining("内容不一致");
|
||||
assertThat(service.history(number, 1, 20).records().get(0).snapshot().confirmedLoad().cargo()).hasSize(2);
|
||||
}
|
||||
|
||||
@Test void reassigningPickedTripPreservesWorkAndRequiresFreshLoadingOnTheReplacement() {
|
||||
String number = readyTrip(); var loaded = confirm(number); var original = service.detail(number).trip();
|
||||
var picksBefore = jdbc.queryForList("SELECT * FROM ll_dispatch_pda_pick ORDER BY id");
|
||||
var tasksBefore = jdbc.queryForList("SELECT * FROM ll_dispatch_pda_task ORDER BY id");
|
||||
var arrivals = jdbc.queryForList("SELECT expected_arrival_at FROM ll_delivery_order ORDER BY id");
|
||||
long replacement = vehicle(true), replacementDriver = driver(true, "替班司机", "456");
|
||||
var changed = service.assign(number, "1", new FleetTripModels.Assign(loaded.recordVersion(), replacement, replacementDriver, "发车前换车及司机,重新核对装载"));
|
||||
assertThat(changed.trip().status()).isEqualTo("PLANNED");
|
||||
assertThat(changed.trip().vehicleId()).isEqualTo(Long.toString(replacement));
|
||||
assertThat(jdbc.queryForList("SELECT vehicle_plate FROM ll_dispatch_order", String.class)).containsOnly(changed.trip().vehiclePlate());
|
||||
assertThat(jdbc.queryForList("SELECT driver_name FROM ll_delivery_order", String.class)).containsOnly("替班司机");
|
||||
assertThat(jdbc.queryForList("SELECT driver_phone FROM ll_delivery_order", String.class)).containsOnly("456");
|
||||
assertThat(jdbc.queryForList("SELECT * FROM ll_dispatch_pda_pick ORDER BY id")).isEqualTo(picksBefore);
|
||||
assertThat(jdbc.queryForList("SELECT * FROM ll_dispatch_pda_task ORDER BY id")).isEqualTo(tasksBefore);
|
||||
assertThat(jdbc.queryForList("SELECT expected_arrival_at FROM ll_delivery_order ORDER BY id")).isEqualTo(arrivals);
|
||||
assertThat(execution.loading(number).confirmedLoad()).isNull();
|
||||
assertThat(changed.dispatches()).allMatch(row -> "PENDING".equals(row.review().status()));
|
||||
assertThat(changed.loadingReview().status()).isEqualTo("PENDING");
|
||||
assertThat(execution.gate("92", "DP-11").blockReason()).contains("调度审核");
|
||||
approveDispatches(number);
|
||||
assertThat(execution.gate("92", "DP-11").blockReason()).contains("确认本车实际装载");
|
||||
assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM ll_fleet_resource_assignment WHERE resource_id IN (?,?)", Integer.class,
|
||||
Long.parseLong(original.vehicleId()), Long.parseLong(original.driverId()))).isZero();
|
||||
var oldLoad = service.history(number, 1, 20).records().stream().filter(row -> row.action().equals("CONFIRM_LOAD")).findFirst().orElseThrow();
|
||||
assertThat(oldLoad.snapshot().trip().vehicleId()).isEqualTo(original.vehicleId());
|
||||
assertThat(oldLoad.snapshot().confirmedLoad()).isNotNull();
|
||||
assertThatThrownBy(() -> execution.depart("92", "DP-11", "1", new FleetExecutionModels.Depart(loaded.recordVersion(), "old-before-reassign", null))).hasMessageContaining("已被修改");
|
||||
var reconfirmed = confirm(number);
|
||||
assertThat(execution.depart("92", "DP-11", "1", new FleetExecutionModels.Depart(reconfirmed.recordVersion(), "after-reassign", null)).shippedPackages()).isEqualTo(20);
|
||||
assertThat(jdbc.queryForList("SELECT * FROM ll_dispatch_pda_pick ORDER BY id")).isEqualTo(picksBefore);
|
||||
}
|
||||
|
||||
@Test void partialPickingAllowsResourceAndTimeChangesButKeepsRouteAndBindingsFrozen() {
|
||||
String number = readyTrip(); var original = service.detail(number).trip();
|
||||
jdbc.update("UPDATE ll_dispatch_order SET status='PENDING_DEPARTURE',picked_package_count=5 WHERE id=12");
|
||||
jdbc.update("UPDATE ll_dispatch_pda_task SET status='PICKING',completed_at=NULL WHERE dispatch_order_id=12");
|
||||
jdbc.update("UPDATE ll_dispatch_pda_pick SET packages=5 WHERE id=12");
|
||||
var picksBefore = jdbc.queryForList("SELECT * FROM ll_dispatch_pda_pick ORDER BY id");
|
||||
var changed = service.assign(number, "1", new FleetTripModels.Assign(original.recordVersion(), vehicle(true), driver(true), "发车前更换资源")).trip();
|
||||
var delayed = service.updatePlan(number, "1", new FleetTripModels.Plan(changed.recordVersion(), departure.plusSeconds(60), end.plusSeconds(60), stops(), null, "延后任务时间")).trip();
|
||||
assertThat(delayed.expectedDriverEndAt()).isEqualTo(end.plusSeconds(60));
|
||||
assertThat(jdbc.queryForList("SELECT * FROM ll_dispatch_pda_pick ORDER BY id")).isEqualTo(picksBefore);
|
||||
assertThat(jdbc.queryForObject("SELECT status FROM ll_dispatch_pda_task WHERE dispatch_order_id=12", String.class)).isEqualTo("PICKING");
|
||||
assertThatThrownBy(() -> service.unbindDispatch(number, "1", new FleetTripModels.BindingCommand(delayed.recordVersion(), "DP-12", "移走已拣单"))).hasMessageContaining("已开始");
|
||||
var differentStops = List.of(stops().get(0), new FleetTripModels.Stop("另一目的地", "新地址", "FBA:LAX9"));
|
||||
assertThatThrownBy(() -> service.updatePlan(number, "1", new FleetTripModels.Plan(delayed.recordVersion(), departure, end, differentStops, null, "更改路线"))).hasMessageContaining("已开始");
|
||||
assertThat(service.detail(number).trip().stops()).isEqualTo(stops());
|
||||
assertThat(service.detail(number).dispatches()).hasSize(2);
|
||||
}
|
||||
|
||||
@Test void replacingConfirmedLoadWithInsufficientCapacityOrExpiredTaskCoverageChangesNothing() {
|
||||
String number = readyTrip(); var loaded = confirm(number); var original = service.detail(number).trip();
|
||||
long small = vehicle(true); var current = vehicles.findById(small).orElseThrow();
|
||||
vehicleService.update(small, "1", new VehicleModels.Update(0L, current.plateNumber(), current.model(), current.fuelConsumptionPer100Km(), current.mileageKm(), null, null,
|
||||
current.region(), current.ownership(), null, 30, new BigDecimal("50"), new BigDecimal("90")));
|
||||
assertThatThrownBy(() -> service.assign(number, "1", new FleetTripModels.Assign(loaded.recordVersion(), small, driver(true), "容量不足换车"))).hasMessageContaining("已确认装载");
|
||||
assertThatThrownBy(() -> service.updatePlan(number, "1", new FleetTripModels.Plan(loaded.recordVersion(), departure, end.plus(60, ChronoUnit.DAYS), stops(), null, "延期跨过证照到期"))).hasMessageContaining("预计任务结束前到期");
|
||||
assertThat(service.detail(number).trip().vehicleId()).isEqualTo(original.vehicleId());
|
||||
assertThat(service.detail(number).trip().recordVersion()).isEqualTo(loaded.recordVersion());
|
||||
assertThat(execution.loading(number).confirmedLoad()).isNotNull();
|
||||
assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM ll_fleet_resource_assignment WHERE resource_id=?", Integer.class, small)).isZero();
|
||||
}
|
||||
|
||||
@Test void reassignmentHistoryFailureRestoresOriginalClaimsSnapshotsAndLoad() {
|
||||
String number = readyTrip(); var loaded = confirm(number); var original = service.detail(number).trip();
|
||||
long replacement = vehicle(true), replacementDriver = driver(true);
|
||||
jdbc.execute("ALTER TABLE ll_fleet_trip_history ADD CONSTRAINT reject_reassignment CHECK(operator_id <> 'reject-operator')");
|
||||
assertThatThrownBy(() -> service.assign(number, "reject-operator", new FleetTripModels.Assign(loaded.recordVersion(), replacement, replacementDriver, "更换车辆司机")))
|
||||
.isInstanceOf(org.springframework.dao.DataIntegrityViolationException.class);
|
||||
assertThat(service.detail(number).trip().vehicleId()).isEqualTo(original.vehicleId());
|
||||
assertThat(service.detail(number).trip().recordVersion()).isEqualTo(loaded.recordVersion());
|
||||
assertThat(jdbc.queryForList("SELECT vehicle_plate FROM ll_delivery_order", String.class)).containsOnly(original.vehiclePlate());
|
||||
assertThat(execution.loading(number).confirmedLoad()).isNotNull();
|
||||
assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM ll_fleet_resource_assignment WHERE resource_id IN (?,?)", Integer.class, replacement, replacementDriver)).isZero();
|
||||
assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM ll_fleet_resource_assignment", Integer.class)).isEqualTo(2);
|
||||
}
|
||||
|
||||
@Test void concurrentReassignmentAndDepartureCannotMixOldAndNewResources() throws Exception {
|
||||
String number = readyTrip(); var loaded = confirm(number); var original = service.detail(number).trip();
|
||||
long replacement = vehicle(true), replacementDriver = driver(true, "替班司机", "456");
|
||||
var start = new CountDownLatch(1); var executor = Executors.newFixedThreadPool(2);
|
||||
try {
|
||||
var assign = executor.submit(() -> {
|
||||
start.await(5, TimeUnit.SECONDS);
|
||||
try {
|
||||
service.assign(number, "1", new FleetTripModels.Assign(loaded.recordVersion(), replacement, replacementDriver, "临时改派"));
|
||||
return true;
|
||||
} catch (org.springframework.web.server.ResponseStatusException stale) {
|
||||
assertThat(stale.getStatusCode().value()).isEqualTo(409); return false;
|
||||
}
|
||||
});
|
||||
var depart = executor.submit(() -> {
|
||||
start.await(5, TimeUnit.SECONDS);
|
||||
try {
|
||||
execution.depart("92", "DP-11", "1", new FleetExecutionModels.Depart(loaded.recordVersion(), "race-with-reassign", null));
|
||||
return true;
|
||||
} catch (org.springframework.web.server.ResponseStatusException stale) {
|
||||
assertThat(stale.getStatusCode().value()).isEqualTo(409); return false;
|
||||
}
|
||||
});
|
||||
start.countDown();
|
||||
boolean assigned = assign.get(15, TimeUnit.SECONDS), departed = depart.get(15, TimeUnit.SECONDS);
|
||||
assertThat(List.of(assigned, departed)).containsExactlyInAnyOrder(true, false);
|
||||
var result = service.detail(number).trip();
|
||||
assertThat(result.vehicleId()).isEqualTo(assigned ? Long.toString(replacement) : original.vehicleId());
|
||||
assertThat(result.status()).isEqualTo(departed ? "IN_TRANSIT" : "PLANNED");
|
||||
assertThat(jdbc.queryForList("SELECT vehicle_plate FROM ll_dispatch_order", String.class)).containsOnly(result.vehiclePlate());
|
||||
assertThat(jdbc.queryForList("SELECT vehicle_plate FROM ll_delivery_order", String.class)).containsOnly(result.vehiclePlate());
|
||||
assertThat(jdbc.queryForObject("SELECT SUM(shipped_package_count) FROM ll_dispatch_order", Integer.class)).isEqualTo(departed ? 20 : 0);
|
||||
assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM ll_fleet_resource_assignment", Integer.class)).isEqualTo(2);
|
||||
assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM ll_fleet_resource_assignment WHERE resource_id=?", Integer.class,
|
||||
Long.parseLong(result.vehicleId()))).isEqualTo(1);
|
||||
if (assigned) assertThat(execution.loading(number).confirmedLoad()).isNull();
|
||||
} finally { executor.shutdownNow(); }
|
||||
}
|
||||
|
||||
@Test void physicalLoadingRejectsDuplicateMissingOverweightAndWrongDestinationMeasurements() {
|
||||
String number = readyTrip(); var loading = execution.loading(number); var measurement = measurement();
|
||||
assertThatThrownBy(() -> execution.confirmLoad(number, "1", new FleetExecutionModels.Confirm(loading.recordVersion(), loading.manifestHash(), List.of(measurement, measurement), "核对")))
|
||||
.hasMessageContaining("重复计量");
|
||||
assertThatThrownBy(() -> execution.confirmLoad(number, "1", new FleetExecutionModels.Confirm(loading.recordVersion(), loading.manifestHash(), List.of(), "核对")))
|
||||
.hasMessageContaining("每个实物托盘");
|
||||
assertThatThrownBy(() -> execution.confirmLoad(number, "1", new FleetExecutionModels.Confirm(loading.recordVersion(), loading.manifestHash(),
|
||||
List.of(new FleetExecutionModels.Measurement("901", 1, new BigDecimal("18001"), new BigDecimal("1"), 1)), "核对"))).hasMessageContaining("超过车辆");
|
||||
assertThatThrownBy(() -> execution.confirmLoad(number, "1", new FleetExecutionModels.Confirm(loading.recordVersion(), loading.manifestHash(),
|
||||
List.of(new FleetExecutionModels.Measurement("901", 1, new BigDecimal("100"), new BigDecimal("1"), 2)), "核对"))).hasMessageContaining("卸货站");
|
||||
jdbc.update("UPDATE ll_dispatch_item SET destination_warehouse_code='LAX9' WHERE dispatch_order_id=12");
|
||||
var mixed = execution.loading(number);
|
||||
assertThatThrownBy(() -> execution.confirmLoad(number, "1", new FleetExecutionModels.Confirm(mixed.recordVersion(), mixed.manifestHash(), List.of(measurement), "核对")))
|
||||
.hasMessageContaining("不同卸货目的地");
|
||||
}
|
||||
|
||||
@Test void changedCargoAndUnreadyMemberBlockTheEntireVehicleBeforeAnyDepartureWrite() {
|
||||
String number = readyTrip(); var confirmed = confirm(number);
|
||||
jdbc.update("UPDATE ll_dispatch_item SET destination_warehouse_code='LAX9' WHERE dispatch_order_id=12");
|
||||
assertThat(execution.gate("92", "DP-11").blockReason()).contains("货物发生变化");
|
||||
assertThatThrownBy(() -> execution.depart("92", "DP-11", "1", new FleetExecutionModels.Depart(confirmed.recordVersion(), "changed-cargo", null)))
|
||||
.hasMessageContaining("货物发生变化");
|
||||
jdbc.update("UPDATE ll_dispatch_item SET destination_warehouse_code='ONT8' WHERE dispatch_order_id=12");
|
||||
jdbc.update("UPDATE ll_dispatch_pda_task SET status='PICKING' WHERE dispatch_order_id=12");
|
||||
assertThatThrownBy(() -> execution.depart("92", "DP-11", "1", new FleetExecutionModels.Depart(confirmed.recordVersion(), "unready-member", null)))
|
||||
.hasMessageContaining("DP-12");
|
||||
assertThat(jdbc.queryForList("SELECT status FROM ll_dispatch_order", String.class)).containsOnly("READY_TO_DEPART");
|
||||
assertThat(service.detail(number).trip().status()).isEqualTo("PLANNED");
|
||||
}
|
||||
|
||||
@Test void secondCustomerFailureRollsBackFirstCustomerOutboundAndAllReplayRecords() {
|
||||
String number = readyTrip(); var confirmed = confirm(number);
|
||||
jdbc.execute("ALTER TABLE ll_dispatch_order ADD CONSTRAINT fail_second_depart CHECK(id <> 12 OR status <> 'DEPARTED')");
|
||||
assertThatThrownBy(() -> execution.depart("92", "DP-11", "1", new FleetExecutionModels.Depart(confirmed.recordVersion(), "rollback-whole-trip", null)))
|
||||
.isInstanceOf(RuntimeException.class);
|
||||
assertThat(jdbc.queryForList("SELECT status FROM ll_dispatch_order", String.class)).containsOnly("READY_TO_DEPART");
|
||||
assertThat(jdbc.queryForList("SELECT status FROM ll_outbound_order", String.class)).containsOnly("WAIT_OUTBOUND");
|
||||
assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM ll_dispatch_pda_operation", Integer.class)).isZero();
|
||||
assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM ll_fleet_trip_history WHERE action='DEPART'", Integer.class)).isZero();
|
||||
assertThat(service.detail(number).trip().status()).isEqualTo("PLANNED");
|
||||
assertThat(execution.gate("92", "DP-11").canDepart()).isTrue();
|
||||
}
|
||||
|
||||
@Test void dispatchDepartureRechecksDriverAndRejectsWrongWarehouseAndStaleVersion() {
|
||||
String number = readyTrip(); var confirmed = confirm(number);
|
||||
assertThatThrownBy(() -> execution.depart("93", "DP-11", "1", new FleetExecutionModels.Depart(confirmed.recordVersion(), "wrong-warehouse", null)))
|
||||
.hasMessageContaining("当前绑定仓");
|
||||
assertThatThrownBy(() -> execution.depart("92", "DP-11", "1", new FleetExecutionModels.Depart(0L, "stale-version", null)))
|
||||
.hasMessageContaining("已被修改");
|
||||
jdbc.update("UPDATE ll_fleet_driver SET license_expires_on=?", java.sql.Date.valueOf(LocalDate.now(ZoneOffset.UTC).minusDays(1)));
|
||||
assertThatThrownBy(() -> execution.depart("92", "DP-11", "1", new FleetExecutionModels.Depart(confirmed.recordVersion(), "expired-driver", null)))
|
||||
.isInstanceOf(ResponseStatusException.class);
|
||||
assertThat(service.detail(number).trip().status()).isEqualTo("PLANNED");
|
||||
assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM ll_dispatch_pda_operation", Integer.class)).isZero();
|
||||
}
|
||||
|
||||
@Test void simultaneousWholeTripDepartureReplaysOnceWithoutDoubleShipping() throws Exception {
|
||||
String number = readyTrip(); var confirmed = confirm(number);
|
||||
var request = new FleetExecutionModels.Depart(confirmed.recordVersion(), "concurrent-departure", null);
|
||||
var start = new CountDownLatch(1); var executor = Executors.newFixedThreadPool(2);
|
||||
Callable<Boolean> action = () -> { start.await(5, TimeUnit.SECONDS); return execution.depart("92", "DP-11", "1", request).replayed(); };
|
||||
try {
|
||||
var a = executor.submit(action); var b = executor.submit(action); start.countDown();
|
||||
assertThat(List.of(a.get(15, TimeUnit.SECONDS), b.get(15, TimeUnit.SECONDS))).containsExactlyInAnyOrder(false, true);
|
||||
assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM ll_dispatch_pda_operation", Integer.class)).isEqualTo(2);
|
||||
} finally { executor.shutdownNow(); }
|
||||
}
|
||||
|
||||
@Test void changingAssignedDriverToDomesticBlocksPreviewAndDepartureUntilCorrected() {
|
||||
String number = readyTrip(); var confirmed = confirm(number);
|
||||
long driverId = Long.parseLong(service.detail(number).trip().driverId());
|
||||
var current = drivers.detail(driverId, end);
|
||||
var original = current.details();
|
||||
var domestic = new DriverModels.Draft(original.name(), original.phone(), original.ownership(), "DOMESTIC",
|
||||
original.licenseNumber(), original.licenseType(), original.licenseIssuedOn(), original.licenseExpiresOn(),
|
||||
original.licenseZone(), original.remark(), original.profile());
|
||||
var changed = drivers.update(driverId, "1", new DriverModels.Update(current.recordVersion(), domestic));
|
||||
assertThat(drivers.detail(driverId, end).qualification().canDispatch()).isTrue();
|
||||
|
||||
var gate = execution.gate("92", "DP-11");
|
||||
assertThat(gate.canDepart()).isFalse();
|
||||
assertThat(gate.blockReason()).contains("海外司机");
|
||||
var request = new FleetExecutionModels.Depart(confirmed.recordVersion(), "driver-region-check", null);
|
||||
assertThatThrownBy(() -> execution.depart("92", "DP-11", "1", request)).hasMessageContaining("海外司机");
|
||||
assertThat(service.detail(number).trip().recordVersion()).isEqualTo(confirmed.recordVersion());
|
||||
assertThat(service.detail(number).trip().status()).isEqualTo("PLANNED");
|
||||
assertThat(jdbc.queryForList("SELECT status FROM ll_outbound_order", String.class)).containsOnly("WAIT_OUTBOUND");
|
||||
assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM ll_dispatch_pda_operation", Integer.class)).isZero();
|
||||
|
||||
drivers.update(driverId, "1", new DriverModels.Update(changed.recordVersion(), original));
|
||||
assertThat(execution.gate("92", "DP-11").canDepart()).isTrue();
|
||||
assertThat(execution.depart("92", "DP-11", "1", request).status()).isEqualTo("IN_TRANSIT");
|
||||
}
|
||||
|
||||
@Test void cargoCompletionDoesNotReleaseResourcesAndEachTaskEndsIndependently() {
|
||||
String number = departedTrip(); markDeliveriesSigned();
|
||||
var before = service.detail(number).trip();
|
||||
var request = new FleetCompletionModels.Complete(before.recordVersion(), "complete-cargo", "全部货物已处理完");
|
||||
var completed = completion.complete(number, "1", request).trip();
|
||||
assertThat(completed.status()).isEqualTo("COMPLETED");
|
||||
assertThat(completed.completedAt()).isNotNull();
|
||||
assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM ll_fleet_resource_assignment", Integer.class)).isEqualTo(2);
|
||||
assertThat(completion.complete(number, "1", request).trip().recordVersion()).isEqualTo(completed.recordVersion());
|
||||
assertThat(service.history(number, 1, 20).records().get(0).snapshot().completion().outcomes()).hasSize(2);
|
||||
var vehicleEnded = completion.release(number, "1", new FleetCompletionModels.Release(completed.recordVersion(), "VEHICLE", Instant.now(), "维修点", "MAINTENANCE", "release-vehicle", "车辆检修")).trip();
|
||||
assertThat(vehicleEnded.vehicleRelease().vehicleStatus()).isEqualTo("MAINTENANCE");
|
||||
assertThat(vehicleEnded.driverRelease()).isNull();
|
||||
assertThat(vehicles.findById(Long.parseLong(vehicleEnded.vehicleId())).orElseThrow().status()).isEqualTo("MAINTENANCE");
|
||||
assertThat(jdbc.queryForObject("SELECT resource_kind FROM ll_fleet_resource_assignment", String.class)).isEqualTo("DRIVER");
|
||||
jdbc.update("UPDATE ll_fleet_driver SET license_expires_on=?", java.sql.Date.valueOf(LocalDate.now(ZoneOffset.UTC).minusDays(1)));
|
||||
var driverEnded = completion.release(number, "1", new FleetCompletionModels.Release(vehicleEnded.recordVersion(), "DRIVER", Instant.now(), "交接点", null, "release-driver", "返程任务结束")).trip();
|
||||
assertThat(driverEnded.driverRelease()).isNotNull();
|
||||
assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM ll_fleet_resource_assignment", Integer.class)).isZero();
|
||||
}
|
||||
|
||||
@Test void incompleteOrCancelledDeliveryDoesNotCountAsCargoCompletion() {
|
||||
String number = departedTrip();
|
||||
assertThat(completion.inspection(number).canComplete()).isFalse();
|
||||
markDeliveriesSigned();
|
||||
jdbc.update("UPDATE ll_delivery_order SET status='CANCELLED' WHERE id=2012");
|
||||
assertThatThrownBy(() -> completion.complete(number, "1", new FleetCompletionModels.Complete(service.detail(number).trip().recordVersion(), "incomplete-cargo", "核对"))).hasMessageContaining("DP-12");
|
||||
assertThat(service.detail(number).trip().status()).isEqualTo("IN_TRANSIT");
|
||||
jdbc.update("UPDATE ll_delivery_order SET status='DELIVERED', package_count=9 WHERE id=2012");
|
||||
assertThat(completion.inspection(number).blockReason()).contains("数量未覆盖");
|
||||
assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM ll_fleet_resource_assignment", Integer.class)).isEqualTo(2);
|
||||
}
|
||||
|
||||
@Test void returnedStatusRequiresImmutableReturnCompletionEvidence() {
|
||||
String number = departedTrip(); markDeliveriesSigned();
|
||||
jdbc.update("UPDATE ll_delivery_order SET status='RETURNED',signed_at=NULL WHERE id=2012");
|
||||
assertThat(completion.inspection(number).canComplete()).isFalse();
|
||||
LocalDateTime returnedAt = LocalDateTime.now().truncatedTo(ChronoUnit.MILLIS);
|
||||
jdbc.update("INSERT INTO ll_delivery_operation(id,customer_id,delivery_order_id,operation_node,result,created_at) VALUES(1,'CLIENT-B',2012,'RETURN_COMPLETED','退回已完成',?)", returnedAt);
|
||||
jdbc.update("UPDATE ll_delivery_order SET updated_at=? WHERE id=2012", returnedAt.plusDays(1));
|
||||
assertThat(completion.inspection(number).canComplete()).isTrue();
|
||||
var completed = completion.complete(number, "1", new FleetCompletionModels.Complete(service.detail(number).trip().recordVersion(), "return-completion", "签收及退回已核对")).trip();
|
||||
assertThat(completed.completedAt()).isEqualTo(returnedAt.atZone(ZoneId.systemDefault()).toInstant());
|
||||
}
|
||||
|
||||
@Test void laterReturnRequestDoesNotReopenAlreadySignedOriginalTransport() {
|
||||
String number = departedTrip(); markDeliveriesSigned();
|
||||
var signedAt = jdbc.queryForObject("SELECT signed_at FROM ll_delivery_order WHERE id=2012", LocalDateTime.class);
|
||||
jdbc.update("UPDATE ll_delivery_order SET status='RETURN_PENDING' WHERE id=2012");
|
||||
var inspection = completion.inspection(number);
|
||||
assertThat(inspection.canComplete()).isTrue();
|
||||
assertThat(inspection.outcomes().get(1).status()).isEqualTo("DELIVERED");
|
||||
assertThat(inspection.outcomes().get(1).terminalAt()).isEqualTo(signedAt.atZone(ZoneId.systemDefault()).toInstant());
|
||||
completion.complete(number, "1", new FleetCompletionModels.Complete(service.detail(number).trip().recordVersion(), "original-transport-complete", "原运输签收已完成"));
|
||||
assertThat(jdbc.queryForObject("SELECT status FROM ll_delivery_order WHERE id=2012", String.class)).isEqualTo("RETURN_PENDING");
|
||||
}
|
||||
|
||||
@Test void releaseChecksCompletionTimeAndRollsBackVehicleStatusAndClaimWhenHistoryFails() {
|
||||
String number = departedTrip();
|
||||
assertThatThrownBy(() -> completion.release(number, "1", new FleetCompletionModels.Release(service.detail(number).trip().recordVersion(), "VEHICLE", Instant.now(), "仓库", "IN_SERVICE", "premature-release", "结束")))
|
||||
.hasMessageContaining("先核对全部货物");
|
||||
markDeliveriesSigned();
|
||||
var completed = completion.complete(number, "1", new FleetCompletionModels.Complete(service.detail(number).trip().recordVersion(), "ready-completion", "核对")).trip();
|
||||
assertThatThrownBy(() -> completion.release(number, "1", new FleetCompletionModels.Release(completed.recordVersion(), "VEHICLE", completed.completedAt().minusSeconds(1), "仓库", "IN_SERVICE", "early-release", "结束")))
|
||||
.hasMessageContaining("实际任务结束时间");
|
||||
assertThatThrownBy(() -> completion.release(number, "1", new FleetCompletionModels.Release(completed.recordVersion(), "DRIVER", Instant.now().plusSeconds(60), "仓库", null, "future-release", "结束")))
|
||||
.hasMessageContaining("实际任务结束时间");
|
||||
jdbc.execute("ALTER TABLE ll_fleet_trip_history ADD CONSTRAINT fail_release CHECK(action <> 'RELEASE_VEHICLE')");
|
||||
assertThatThrownBy(() -> completion.release(number, "1", new FleetCompletionModels.Release(completed.recordVersion(), "VEHICLE", Instant.now(), "维修点", "MAINTENANCE", "failed-release", "维修")))
|
||||
.isInstanceOf(RuntimeException.class);
|
||||
assertThat(vehicles.findById(Long.parseLong(completed.vehicleId())).orElseThrow().status()).isEqualTo("IN_SERVICE");
|
||||
assertThat(service.detail(number).trip().vehicleRelease()).isNull();
|
||||
assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM ll_fleet_resource_assignment", Integer.class)).isEqualTo(2);
|
||||
}
|
||||
|
||||
@Test void oldReleaseRetryCannotReleaseAResourceAssignedToTheNextTrip() {
|
||||
String number = departedTrip(); markDeliveriesSigned();
|
||||
var completed = completion.complete(number, "1", new FleetCompletionModels.Complete(service.detail(number).trip().recordVersion(), "completion-replay", "核对")).trip();
|
||||
var release = new FleetCompletionModels.Release(completed.recordVersion(), "VEHICLE", Instant.now(), "下一起运点", "IN_SERVICE", "vehicle-release-replay", "结束本次车辆任务");
|
||||
completion.release(number, "1", release);
|
||||
String next = trip(); service.assign(next, "1", new FleetTripModels.Assign(0L, Long.parseLong(completed.vehicleId()), driver(true), "下一任务"));
|
||||
completion.release(number, "1", release);
|
||||
assertThat(jdbc.queryForObject("SELECT trip_no FROM ll_fleet_resource_assignment WHERE resource_kind='VEHICLE' AND resource_id=?", String.class, Long.parseLong(completed.vehicleId()))).isEqualTo(next);
|
||||
assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM ll_fleet_trip_history WHERE action='RELEASE_VEHICLE'", Integer.class)).isEqualTo(1);
|
||||
}
|
||||
|
||||
private String departedTrip() {
|
||||
String number = readyTrip(); var loading = confirm(number);
|
||||
execution.depart("92", "DP-11", "1", new FleetExecutionModels.Depart(loading.recordVersion(), "departure-for-completion", null));
|
||||
return number;
|
||||
}
|
||||
private void markDeliveriesSigned() {
|
||||
jdbc.update("UPDATE ll_outbound_order SET package_count=10");
|
||||
jdbc.update("UPDATE ll_delivery_order SET package_count=10,status='DELIVERED',signed_at=?", LocalDateTime.now().truncatedTo(ChronoUnit.MILLIS));
|
||||
}
|
||||
|
||||
private String readyTrip() {
|
||||
String number = assignedTrip(); dispatch(11, "CLIENT-A", "92"); dispatch(12, "CLIENT-B", "92");
|
||||
delivery(11, "CLIENT-A"); delivery(12, "CLIENT-B");
|
||||
service.bindDispatch(number, "1", new FleetTripModels.BindingCommand(1L, "DP-11", "加入"));
|
||||
service.bindDispatch(number, "1", new FleetTripModels.BindingCommand(2L, "DP-12", "加入"));
|
||||
approveDispatches(number);
|
||||
for (long id : List.of(11L, 12L)) {
|
||||
String customer = id == 11 ? "CLIENT-A" : "CLIENT-B";
|
||||
jdbc.update("UPDATE ll_dispatch_order SET status='READY_TO_DEPART',picked_package_count=10 WHERE id=?", id);
|
||||
jdbc.update("UPDATE ll_dispatch_pda_task SET status='COMPLETED' WHERE dispatch_order_id=?", id);
|
||||
jdbc.update("INSERT INTO ll_dispatch_item(id,dispatch_order_id,customer_id,warehouse_pallet_id,pallet_no,package_count,source_stage,destination_warehouse_code,overseas_warehouse_id) VALUES(?,?,?,901,'PALLET-901',10,'RECEIVED','ONT8','92')", id + 3000, id, customer);
|
||||
jdbc.update("INSERT INTO ll_dispatch_pda_pick(id,customer_id,task_id,dispatch_item_id,inventory_position_id,packages,product_quantity,idempotency_key,picked_at) VALUES(?,?,?,?,?,10,0,?,CURRENT_TIMESTAMP)", id, customer, id, id + 3000, id + 4000, "pick-" + id);
|
||||
}
|
||||
return number;
|
||||
}
|
||||
private FleetExecutionModels.Loading confirm(String number) {
|
||||
var loading = execution.loading(number);
|
||||
var confirmed = execution.confirmLoad(number, "1", new FleetExecutionModels.Confirm(loading.recordVersion(), loading.manifestHash(), List.of(measurement()), "实际装载核对"));
|
||||
execution.reviewLoading(number, "1", new FleetTripModels.Review(confirmed.recordVersion(), "APPROVED", null));
|
||||
return execution.loading(number);
|
||||
}
|
||||
private void approveDispatches(String number) {
|
||||
for (var binding : service.detail(number).dispatches()) service.reviewDispatch(number, binding.dispatchNo(), "1",
|
||||
new FleetTripModels.Review(service.detail(number).trip().recordVersion(), "APPROVED", null));
|
||||
}
|
||||
private static FleetExecutionModels.Measurement measurement() { return new FleetExecutionModels.Measurement("901", 1, new BigDecimal("100"), new BigDecimal("1"), 1); }
|
||||
|
||||
private boolean bindAttempt(CountDownLatch start, String number) throws Exception {
|
||||
start.await(5, TimeUnit.SECONDS);
|
||||
try { service.bindDispatch(number, "1", new FleetTripModels.BindingCommand(1L, "DP-11", "并发关联")); return true; }
|
||||
catch (ResponseStatusException conflict) { assertThat(conflict.getStatusCode().value()).isEqualTo(409); return false; }
|
||||
}
|
||||
private String assignedTrip() {
|
||||
String number = trip(); service.assign(number, "1", new FleetTripModels.Assign(0L, vehicle(true), driver(true), "安排")); return number;
|
||||
}
|
||||
private void dispatch(long id, String customer, String warehouse) {
|
||||
jdbc.update("INSERT INTO ll_dispatch_order(id, customer_id, dispatch_no, overseas_warehouse_id, package_count) VALUES(?,?,?,?,10)", id, customer, "DP-" + id, warehouse);
|
||||
jdbc.update("INSERT INTO ll_dispatch_pda_task(id,customer_id,dispatch_order_id,task_no,status,warehouse_id,pushed_at) VALUES(?,?,?,?,'PDA_PENDING',?,CURRENT_TIMESTAMP)",
|
||||
id, customer, id, "TASK-" + id, warehouse);
|
||||
}
|
||||
private void delivery(long dispatchId, String customer) {
|
||||
jdbc.update("INSERT INTO ll_outbound_order(id,customer_id,outbound_no,dispatch_order_id,idempotency_key) VALUES(?,?,?,?,?)",
|
||||
dispatchId + 1000, customer, "OUT-" + dispatchId, dispatchId, "out-key-" + dispatchId);
|
||||
jdbc.update("INSERT INTO ll_delivery_order(id,customer_id,delivery_no,outbound_order_id,outbound_no,dispatch_order_id,delivery_group_key,idempotency_key,expected_arrival_at) VALUES(?,?,?,?,?,?,?,?,?)",
|
||||
dispatchId + 2000, customer, "DL-" + dispatchId, dispatchId + 1000, "OUT-" + dispatchId, dispatchId, "DEST", "dl-key-" + dispatchId, LocalDateTime.of(2030, 1, 1, 12, 0));
|
||||
}
|
||||
|
||||
private boolean attempt(CountDownLatch start, String number, long vehicle, long driver) throws Exception {
|
||||
start.await(5, TimeUnit.SECONDS);
|
||||
try { service.assign(number, "1", new FleetTripModels.Assign(0L, vehicle, driver, "并发安排")); return true; }
|
||||
catch (ResponseStatusException conflict) { assertThat(conflict.getStatusCode().value()).isEqualTo(409); return false; }
|
||||
}
|
||||
private String trip() { return service.create("1", createRequest(UUID.randomUUID().toString())).trip().tripNo(); }
|
||||
private FleetTripModels.Create createRequest(String key) { return new FleetTripModels.Create(92L, departure, end, stops(), null, key); }
|
||||
private static List<FleetTripModels.Stop> stops() { return List.of(new FleetTripModels.Stop("起运仓", "提货地址", null), new FleetTripModels.Stop("派送仓", "送货地址", "FBA:ONT8")); }
|
||||
private long vehicle(boolean capacity) {
|
||||
return vehicles.create(new VehicleRepository.NewVehicle("TEST-" + UUID.randomUUID(), "Box Truck", new BigDecimal("12.5"), 1000L, null, null, "OVERSEAS", "OWNED", "IN_SERVICE", null, "1", LocalDateTime.now(), capacity ? 30 : null, capacity ? new BigDecimal("18000") : null, capacity ? new BigDecimal("90") : null));
|
||||
}
|
||||
private long driver(boolean approve) { return driver(approve, "司机", "123"); }
|
||||
private long driver(boolean approve, String name, String phone) {
|
||||
var draft = new DriverModels.Draft(name, phone, "OWNED", "OVERSEAS", "LIC-" + UUID.randomUUID(), "Class A", null, LocalDate.now(ZoneOffset.UTC).plusDays(30), "UTC", null, null);
|
||||
var driver = drivers.create("1", draft); long id = Long.parseLong(driver.id());
|
||||
if (approve) {
|
||||
drivers.uploadLicense(id, 0, "1", new MockMultipartFile("file", "license.pdf", "application/pdf", "%PDF-1.7".getBytes()));
|
||||
drivers.review(id, "1", new DriverModels.Review(1L, "APPROVED", null));
|
||||
}
|
||||
return id;
|
||||
}
|
||||
private <T> T transactional(T target, Class<T> type) {
|
||||
var proxy = new ProxyFactory(target); proxy.setProxyTargetClass(true);
|
||||
proxy.addAdvice(new TransactionInterceptor(transactions, new AnnotationTransactionAttributeSource()));
|
||||
return type.cast(proxy.getProxy());
|
||||
}
|
||||
}
|
||||
+21
-3
@@ -4,6 +4,7 @@ import com.baiyue.lastleg.masterdata.vehicle.application.VehicleApplicationServi
|
||||
import com.baiyue.lastleg.masterdata.vehicle.application.VehicleApplicationService.VehicleFailure;
|
||||
import com.baiyue.lastleg.masterdata.vehicle.application.model.VehicleModels;
|
||||
import com.baiyue.lastleg.masterdata.vehicle.application.port.out.VehicleRepository;
|
||||
import com.baiyue.lastleg.masterdata.vehicle.application.port.out.VehicleAssignmentGuard;
|
||||
import com.baiyue.lastleg.masterdata.vehicle.application.port.out.VehicleRepository.VehicleSnapshot;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
@@ -23,7 +24,7 @@ class VehicleApplicationServiceTest {
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
repository = mock(VehicleRepository.class);
|
||||
service = new VehicleApplicationService(repository);
|
||||
service = new VehicleApplicationService(repository, mock(VehicleAssignmentGuard.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -52,13 +53,30 @@ class VehicleApplicationServiceTest {
|
||||
failure -> assertThat(failure.kind()).isEqualTo(FailureKind.BAD_REQUEST));
|
||||
}
|
||||
|
||||
@Test void rejectsPartialOrNonPositiveCapacityWithoutGuessingFromModel() {
|
||||
var partial = new VehicleModels.Create("ABC-001", "大车30托", new BigDecimal("12.50"), 1000L,
|
||||
null, null, "OVERSEAS", "OWNED", "IN_SERVICE", null, 30, null, new BigDecimal("90"));
|
||||
assertThatThrownBy(() -> service.create("9", partial)).hasMessageContaining("同时填写");
|
||||
var zero = new VehicleModels.Create("ABC-001", "大车30托", new BigDecimal("12.50"), 1000L,
|
||||
null, null, "OVERSEAS", "OWNED", "IN_SERVICE", null, 30, BigDecimal.ZERO, new BigDecimal("90"));
|
||||
assertThatThrownBy(() -> service.create("9", zero)).hasMessageContaining("大于零");
|
||||
org.mockito.Mockito.verify(repository, org.mockito.Mockito.never()).create(org.mockito.ArgumentMatchers.any());
|
||||
}
|
||||
|
||||
@Test void exportUsesPlateAsBusinessIdentifierAndIncludesAllCapacityDimensions() {
|
||||
when(repository.list(org.mockito.ArgumentMatchers.any())).thenReturn(java.util.List.of(vehicle(3, 1000)));
|
||||
String csv = new String(service.exportCsv(new VehicleModels.Query(1, 20, null, null, null, null)), java.nio.charset.StandardCharsets.UTF_8);
|
||||
assertThat(csv).contains("最大托位,最大载重(kg),可用容积(m³)").contains("\"30\",\"18000\",\"90\"")
|
||||
.doesNotContain("车辆ID");
|
||||
}
|
||||
|
||||
private static VehicleModels.Update update(long version, long mileage) {
|
||||
return new VehicleModels.Update(version, "ABC-001", "Box Truck", new BigDecimal("12.50"), mileage,
|
||||
"Driver", "123", "OVERSEAS", "OWNED", null);
|
||||
"Driver", "123", "OVERSEAS", "OWNED", null, 30, new BigDecimal("18000"), new BigDecimal("90"));
|
||||
}
|
||||
|
||||
private static VehicleSnapshot vehicle(long version, long mileage) {
|
||||
return new VehicleSnapshot(1L, "ABC-001", "Box Truck", new BigDecimal("12.50"), mileage,
|
||||
"Driver", "123", "OVERSEAS", "OWNED", "IN_SERVICE", null, version, "9", null);
|
||||
"Driver", "123", "OVERSEAS", "OWNED", "IN_SERVICE", null, version, "9", null, 30, new BigDecimal("18000"), new BigDecimal("90"));
|
||||
}
|
||||
}
|
||||
|
||||
+1
@@ -60,6 +60,7 @@ class PdaOutboundDispatchContractTest {
|
||||
DispatchPdaController dispatch = new DispatchPdaController();
|
||||
OutboundPdaController outbound = new OutboundPdaController();
|
||||
ReflectionTestUtils.setField(dispatch, "service", dispatchService);
|
||||
ReflectionTestUtils.setField(dispatch, "trips", mock(com.baiyue.lastleg.fleet.trip.FleetTripExecutionService.class));
|
||||
ReflectionTestUtils.setField(outbound, "service", outboundService);
|
||||
AuthenticatedUser user = pda(Set.of("lastleg:pda:operate", "ROLE_OVERSEAS_WAREHOUSE_PDA_OPERATOR"), 900L);
|
||||
|
||||
|
||||
+2
-1
@@ -64,7 +64,8 @@ class OutboxLeaseDatabaseTest {
|
||||
var factory = new MybatisSqlSessionFactoryBean();
|
||||
factory.setDataSource(dataSource); factory.setConfiguration(configuration);
|
||||
var mapper = new SqlSessionTemplate(factory.getObject()).getMapper(LastlegEventOutboxMapper.class);
|
||||
var repository = new com.baiyue.lastleg.financefee.repository.OverseasFeeOutboxRepository(mapper);
|
||||
var repository = new com.baiyue.lastleg.financefee.repository.OverseasFeeOutboxRepository(mapper,
|
||||
org.mockito.Mockito.mock(com.baiyue.lastleg.financefee.state.OverseasFeeCancellation.class));
|
||||
var journal = new EventDeliveryJournal(jdbc, new DataSourceTransactionManager(dataSource));
|
||||
LocalDateTime now = LocalDateTime.of(2026, 9, 29, 12, 0);
|
||||
jdbc.update("INSERT INTO ll_event_outbox(id,event_type,status,retry_count,next_retry_at,created_at) VALUES(1,?,'PENDING',0,?,?)", "BusinessFeeSourceChanged", now.minusSeconds(1), now.minusMinutes(3));
|
||||
|
||||
+2
-1
@@ -23,7 +23,8 @@ class UnloadCompletedOutboxRepositoryTest {
|
||||
void feeClaimAlsoRechecksRetryWindow() {
|
||||
TableInfoHelper.initTableInfo(new MapperBuilderAssistant(new MybatisConfiguration(), "test"), LastlegEventOutbox.class);
|
||||
LastlegEventOutboxMapper mapper = mock(LastlegEventOutboxMapper.class);
|
||||
var repository = new com.baiyue.lastleg.financefee.repository.OverseasFeeOutboxRepository(mapper);
|
||||
var repository = new com.baiyue.lastleg.financefee.repository.OverseasFeeOutboxRepository(mapper,
|
||||
mock(com.baiyue.lastleg.financefee.state.OverseasFeeCancellation.class));
|
||||
ArgumentCaptor<LambdaUpdateWrapper> wrapper = ArgumentCaptor.forClass(LambdaUpdateWrapper.class);
|
||||
when(mapper.update(isNull(LastlegEventOutbox.class), wrapper.capture())).thenReturn(1);
|
||||
LocalDateTime now = LocalDateTime.of(2026, 9, 29, 12, 0);
|
||||
|
||||
+37
@@ -0,0 +1,37 @@
|
||||
package com.baiyue.lastleg.transfer.fleet;
|
||||
|
||||
import com.baiyue.lastleg.fleet.trip.*;
|
||||
import com.baiyue.lastleg.transfer.dto.*;
|
||||
import com.baiyue.lastleg.transfer.mapper.WarehouseTransferOrderMapper;
|
||||
import com.baiyue.lastleg.transfer.service.WarehouseTransferService;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.mockito.ArgumentCaptor;
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
import static org.mockito.Mockito.*;
|
||||
import static org.assertj.core.api.Assertions.*;
|
||||
class FleetIncomingAdapterTest {
|
||||
@Test void createsRealCustomerDeliveryWithoutClaimingStockWasReceived() {
|
||||
var service=mock(WarehouseTransferService.class);var adapter=new FleetIncomingAdapter(service,mock(WarehouseTransferOrderMapper.class),new ObjectMapper());
|
||||
var detail=mock(WarehouseTransferResponses.Detail.class);
|
||||
when(detail.transferNo()).thenReturn("WT-1");when(detail.inboundCode()).thenReturn("WT-1");when(detail.operatingWarehouseId()).thenReturn("92");when(detail.fbaWarehouseCode()).thenReturn("ONT8");
|
||||
when(detail.status()).thenReturn("WAIT_PDA_RECEIVING");when(detail.pallets()).thenReturn(List.of());
|
||||
when(service.createAndSubmit(eq("9"),eq("1"),any())).thenReturn(detail);when(service.review(eq("9"),eq("WT-1"),eq("1"),any())).thenReturn(detail);
|
||||
var cargo=new FleetBookingModels.Cargo("FBA:ONT8","普通货",20,2,new BigDecimal("100"),new BigDecimal("1"),"PALLET",new BigDecimal("50"),"USD",null,null,null);
|
||||
var result=adapter.create("1",new FleetBookingIncomingGateway.Incoming("9","92","起运仓","FB-1",cargo,LocalDateTime.of(2030,1,1,12,0),null,null,null,null,null));
|
||||
assertThat(result.receivedPackages()).isZero();assertThat(result.pallets()).isEmpty();assertThat(result.status()).isEqualTo("WAIT_PDA_RECEIVING");
|
||||
var captor=ArgumentCaptor.forClass(WarehouseTransferRequests.Create.class);verify(service).createAndSubmit(eq("9"),eq("1"),captor.capture());
|
||||
assertThat(captor.getValue().inboundCode()).isNull();assertThat(captor.getValue().sourceLastlegOrderIds()).isEmpty();
|
||||
assertThat(captor.getValue().deliveryMethod()).isEqualTo("CUSTOMER_DELIVERY");assertThat(captor.getValue().operatingWarehouseId()).isEqualTo("92");
|
||||
assertThat(captor.getValue().idempotencyKey()).isEqualTo("FLEET:FB-1:INCOMING");
|
||||
verify(service,never()).confirmMeasurement(any(),any(),any(),any());
|
||||
}
|
||||
@Test void privateAddressCannotSilentlyChangeTheBookedDestination() {
|
||||
var service=mock(WarehouseTransferService.class);var adapter=new FleetIncomingAdapter(service,mock(WarehouseTransferOrderMapper.class),new ObjectMapper());
|
||||
var cargo=new FleetBookingModels.Cargo(FleetDispatchGateway.destinationKey("PRIVATE",null,null,"Road 1","90001"),null,1,1,BigDecimal.ONE,BigDecimal.ONE,"ORDER",BigDecimal.TEN,"USD",null,null,null);
|
||||
assertThatThrownBy(()->adapter.create("1",new FleetBookingIncomingGateway.Incoming("9","92","仓","FB-1",cargo,LocalDateTime.now(),null,"Road 2","90001","A","123"))).hasMessageContaining("卸货站不一致");
|
||||
verifyNoInteractions(service);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user