feat(fleet): bind quote versions and reconcile actual return quantities

This commit is contained in:
2026-10-03 13:32:55 +08:00
parent cb6c54ffe5
commit a6fd0ba3c5
60 changed files with 1363 additions and 75 deletions
@@ -0,0 +1,34 @@
package com.baiyue.lastleg.delivery.model;
import com.baiyue.lastleg.delivery.entity.DeliveryOrder;
import java.time.LocalDateTime;
import java.util.List;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.*;
class DeliveryCargoQuantitiesTest {
private final LocalDateTime now = LocalDateTime.of(2026, 10, 3, 12, 0);
@Test void unsignedQuantityRemainsOpenUntilAllCargoHasActualTerminalFacts() {
var row = new DeliveryOrder(); row.setPackageCount(10);
assertThat(DeliveryCargoQuantities.from(row, List.of(receipt(1, 4, now))))
.isEqualTo(new DeliveryCargoQuantities(10, 0, 4, 6));
row.setSignedAt(now.plusMinutes(1)); row.setSignedPackages(6);
assertThat(DeliveryCargoQuantities.from(row, List.of(receipt(1, 4, now), receipt(2, 2, now.plusMinutes(2)))))
.isEqualTo(new DeliveryCargoQuantities(10, 6, 4, 0));
}
@Test void missingSignatureQuantityCannotDefaultToTheWholeShipment() {
var row = new DeliveryOrder(); row.setPackageCount(10); row.setSignedAt(now);
assertThatThrownBy(() -> DeliveryCargoQuantities.from(row, List.of())).hasMessageContaining("签收数量");
}
@Test void duplicatedSourcesOrOverReturnCannotCloseCargo() {
var row = new DeliveryOrder(); row.setPackageCount(10);
assertThatThrownBy(() -> DeliveryCargoQuantities.from(row, List.of(receipt(1, 4, now), receipt(1, 4, now))))
.hasMessageContaining("重复");
assertThatThrownBy(() -> DeliveryCargoQuantities.from(row, List.of(receipt(1, 11, now)))).hasMessageContaining("超过");
}
@Test void laterReturnCannotFillAnUnprovenGapInAnEarlierSignature() {
var row = new DeliveryOrder(); row.setPackageCount(10); row.setSignedAt(now); row.setSignedPackages(6);
assertThatThrownBy(() -> DeliveryCargoQuantities.from(row, List.of(receipt(1, 4, now.plusMinutes(1)))))
.hasMessageContaining("守恒");
}
private DeliveryReturnReceiptFact receipt(long id, long packages, LocalDateTime time) { return new DeliveryReturnReceiptFact(id, 2L, 3L, packages, time); }
}
@@ -13,7 +13,7 @@ class DeliveryStatusPolicyTest {
.containsExactly("REJECT_DELIVERY", "MARK_RETURN");
assertThat(DeliveryStatusPolicy.allowedActions("DELIVERED")).containsExactly("MARK_RETURN");
assertThat(DeliveryStatusPolicy.isEditableTarget("DELIVERED")).isFalse();
assertThat(DeliveryStatusPolicy.allowedActions("RETURN_PENDING")).isEmpty();
assertThat(DeliveryStatusPolicy.allowedActions("RETURN_PENDING")).containsExactly("MARK_RETURN");
assertThat(DeliveryStatusPolicy.allowedActions("CANCELLED")).isEmpty();
}
@@ -31,6 +31,7 @@ class DeliveryCompletedOutboxServiceTest {
ReflectionTestUtils.setField(service, "objectMapper", json);
DeliveryOrder delivery = new DeliveryOrder();
delivery.setId(1L); delivery.setDeliveryNo("DL-1"); delivery.setVersion(2L);
delivery.setPackageCount(10); delivery.setSignedPackages(10);
service.publish(delivery, List.of(order(11, "101", "201", "IN-1", "ONT8"),
order(12, "102", "202", "IN-2", "ACY2")), Instant.parse("2026-09-15T08:00:00Z"));
@@ -100,6 +101,19 @@ class DeliveryCompletedOutboxServiceTest {
verifyNoInteractions(mapper);
}
@Test void unknownOrPartialSignatureCannotPublishAWholeDomesticInboundSignature() {
var mapper = mock(LastlegEventOutboxMapper.class);
var service = new DeliveryCompletedOutboxService(); ReflectionTestUtils.setField(service, "outbox", mapper);
var delivery = new DeliveryOrder(); delivery.setPackageCount(10);
var sources = List.of(order(11, "101", "201", "IN-1", "ONT8"));
for (Integer invalid : java.util.Arrays.asList(null, 0, -1, 11)) {
delivery.setSignedPackages(invalid);
assertThat(service.publish(delivery, sources, Instant.now())).isEqualTo(DeliveryCompletedOutboxService.Publication.UNKNOWN_SIGNATURE_REQUIRES_RECONCILIATION);
}
delivery.setSignedPackages(6);
assertThat(service.publish(delivery, sources, Instant.now())).isEqualTo(DeliveryCompletedOutboxService.Publication.MIXED_RETURN_REQUIRES_SOURCE_RECONCILIATION);
verifyNoInteractions(mapper);
}
private LastlegOrder order(long id, String inboundId, String orderId, String inboundCode, String fba) {
LastlegOrder value = new LastlegOrder();
value.setId(id); value.setOrderNo("LM-" + id); value.setCustomerId("88");
@@ -48,7 +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");
order.setOutboundNo("OUT-1"); order.setPackageCount(10);
}
@Test void savedCategoryCountCountsCategoriesNotFilesAndSurvivesReload() {
when(orders.lockByNo("100","DL-1")).thenReturn(order);
@@ -215,6 +215,30 @@ class DeliveryDocumentServiceTest {
verify(files,never()).download(anyString(),anyLong(),any(),anyLong());
verifyNoInteractions(revisions,operations);
}
@Test void remainingSignatureFreezesActualQuantityAndPersistsDomesticWritebackBlocker() {
order.setStatus("RETURN_PENDING");
when(orders.lockByNo("100", "DL-1")).thenReturn(order);
when(files.metadata("100", 10, RECEIPT, 13)).thenReturn(file(13));
var outbound = new OutboundOrder(); outbound.setStatus("OUTBOUNDED");
when(outboundOrders.findByNo("100", "OUT-1")).thenReturn(outbound);
when(operations.returnReceipts("100", 10L)).thenReturn(List.of(new com.baiyue.lastleg.delivery.model.DeliveryReturnReceiptFact(1L, 2L, 3L, 4, LocalDateTime.now().minusMinutes(1))));
when(deliveryCompletedOutbox.publish(eq(order), anyList(), any())).thenReturn(DeliveryCompletedOutboxService.Publication.MIXED_RETURN_REQUIRES_SOURCE_RECONCILIATION);
service.save("100", "DL-1", "9", request(0, "residual-signature", List.of(new Binding(RECEIPT, List.of(13L)))));
assertThat(order.getPackageCount()).isEqualTo(10); assertThat(order.getSignedPackages()).isEqualTo(6);
assertThat(order.getSignedAt()).isNotNull();
var captured = ArgumentCaptor.forClass(DeliveryOperation.class);
verify(operations, times(3)).insert(captured.capture());
assertThat(captured.getAllValues()).anyMatch(row -> "FIRSTLEG_COMPLETION_PENDING".equals(row.getOperationNode()) && row.getResult().contains("未发布"));
}
@Test void updatingDocumentsDuringLaterReturnNeverRewritesOriginalSignature() {
order.setStatus("RETURN_PENDING"); order.setSignedAt(LocalDateTime.of(2026, 9, 1, 12, 0)); order.setSignedPackages(10);
when(orders.lockByNo("100", "DL-1")).thenReturn(order);
when(files.metadata("100", 10, RECEIPT, 13)).thenReturn(file(13));
service.save("100", "DL-1", "9", request(0, "later-evidence", List.of(new Binding(RECEIPT, List.of(13L)))));
assertThat(order.getSignedAt()).isEqualTo(LocalDateTime.of(2026, 9, 1, 12, 0));
assertThat(order.getSignedPackages()).isEqualTo(10); assertThat(order.getStatus()).isEqualTo("RETURN_PENDING");
verifyNoInteractions(deliveryCompletedOutbox, customerVisits);
}
private Save request(long version,String key,List<Binding> bindings) { return new Save(version,key,"BOL-1",bindings); }
private File file(long id) { return new File(id,"a.pdf","application/pdf",1,LocalDateTime.of(2026,9,9,8,0)); }
}
@@ -0,0 +1,49 @@
package com.baiyue.lastleg.delivery.service;
import com.baiyue.lastleg.delivery.entity.DeliveryOrder;
import com.baiyue.lastleg.delivery.mapper.DeliveryOperationMapper;
import com.baiyue.lastleg.delivery.model.DeliveryCargoQuantities;
import org.apache.ibatis.annotations.Select;
import org.junit.jupiter.api.Test;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate;
import org.springframework.jdbc.datasource.DriverManagerDataSource;
import java.time.LocalDateTime;
import java.util.Map;
import java.util.UUID;
import static org.assertj.core.api.Assertions.*;
class DeliveryReturnEvidencePersistenceTest {
@Test void twoItemsOnTheSamePalletResolveThroughTheOriginalPickPositionAndOrder() throws Exception {
var data = new DriverManagerDataSource("jdbc:h2:mem:" + UUID.randomUUID() + ";MODE=MySQL;DB_CLOSE_DELAY=-1", "sa", "");
var jdbc = new JdbcTemplate(data);
jdbc.execute("CREATE TABLE ll_delivery_order(id BIGINT,customer_id VARCHAR(20),dispatch_order_id BIGINT,outbound_order_id BIGINT,outbound_no VARCHAR(20))");
jdbc.execute("CREATE TABLE ll_exception_order(id BIGINT,customer_id VARCHAR(20),delivery_order_id BIGINT,exception_no VARCHAR(20),last_mile_order_id BIGINT)");
jdbc.execute("CREATE TABLE ll_exception_return_item(exception_order_id BIGINT,customer_id VARCHAR(20),outbound_pallet_item_id BIGINT,inventory_position_id BIGINT)");
jdbc.execute("CREATE TABLE ll_outbound_pallet_item(id BIGINT,customer_id VARCHAR(20),outbound_order_id BIGINT,warehouse_pallet_id BIGINT)");
jdbc.execute("CREATE TABLE ll_dispatch_item(id BIGINT,customer_id VARCHAR(20),dispatch_order_id BIGINT,warehouse_pallet_id BIGINT)");
jdbc.execute("CREATE TABLE ll_dispatch_pda_pick(id BIGINT,customer_id VARCHAR(20),task_id BIGINT,dispatch_item_id BIGINT,inventory_position_id BIGINT)");
jdbc.execute("CREATE TABLE ll_dispatch_pda_pick_order(customer_id VARCHAR(20),pick_id BIGINT,order_id BIGINT)");
jdbc.execute("CREATE TABLE ll_dispatch_pda_task(id BIGINT,customer_id VARCHAR(20),dispatch_order_id BIGINT)");
jdbc.execute("CREATE TABLE ll_inventory_ledger(id BIGINT,customer_id VARCHAR(20),business_order_no VARCHAR(20),outbound_order_no VARCHAR(20),business_type VARCHAR(40),delta_packages BIGINT,source_event_id VARCHAR(100),occurred_at TIMESTAMP)");
jdbc.update("INSERT INTO ll_delivery_order VALUES(1,'A',10,20,'OUT-1')");
jdbc.update("INSERT INTO ll_exception_order VALUES(2,'A',1,'EX-1',71)");
jdbc.update("INSERT INTO ll_exception_return_item VALUES(2,'A',21,41)");
jdbc.update("INSERT INTO ll_outbound_pallet_item VALUES(21,'A',20,99)");
jdbc.update("INSERT INTO ll_dispatch_item VALUES(31,'A',10,99),(32,'A',10,99)");
jdbc.update("INSERT INTO ll_dispatch_pda_task VALUES(50,'A',10)");
jdbc.update("INSERT INTO ll_dispatch_pda_pick VALUES(51,'A',50,31,41),(52,'A',50,32,42)");
jdbc.update("INSERT INTO ll_dispatch_pda_pick_order VALUES('A',51,71),('A',52,72)");
jdbc.update("INSERT INTO ll_inventory_ledger VALUES(60,'A','EX-1','OUT-1','RETURN_STOCK_IN',4,'RETURN_SOURCE:21:41',CURRENT_TIMESTAMP)");
String sql = String.join(" ", DeliveryOperationMapper.class.getMethod("returnReceipts", String.class, Long.class)
.getAnnotation(Select.class).value()).replace("#{customerId}", ":customerId").replace("#{deliveryOrderId}", ":deliveryOrderId");
var named = new NamedParameterJdbcTemplate(data);
var facts = named.query(sql, Map.of("customerId", "A", "deliveryOrderId", 1L), (row, index) ->
new com.baiyue.lastleg.delivery.model.DeliveryReturnReceiptFact(row.getLong("ledger_id"), row.getLong("dispatch_item_id"), row.getLong("pallet_id"), row.getLong("packages"), row.getObject("received_at", LocalDateTime.class)));
assertThat(facts).hasSize(1); assertThat(facts.get(0).dispatchItemId()).isEqualTo(31);
assertThat(facts.get(0).packages()).isEqualTo(4);
var delivery = new DeliveryOrder(); delivery.setPackageCount(10);
assertThat(DeliveryCargoQuantities.from(delivery, facts).remaining()).isEqualTo(6);
assertThat(named.queryForList(sql, Map.of("customerId", "OTHER", "deliveryOrderId", 1L))).isEmpty();
}
}
@@ -34,6 +34,7 @@ import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class)
class ExceptionOrderDispatchReturnSourceTest {
@Mock private com.baiyue.lastleg.exceptionorder.mapper.ExceptionReturnItemMapper returnItems;
@Mock private DeliveryOrderMapper deliveryOrders;
@Mock private OutboundOrderMapper outboundOrders;
@Mock private OutboundPalletItemMapper outboundPalletItems;
@@ -50,6 +51,7 @@ class ExceptionOrderDispatchReturnSourceTest {
void setUp() {
service = new ExceptionOrderService();
ReflectionTestUtils.setField(service, "deliveryOrders", deliveryOrders);
ReflectionTestUtils.setField(service, "returnItems", returnItems);
ReflectionTestUtils.setField(service, "outboundOrders", outboundOrders);
ReflectionTestUtils.setField(service, "outboundPalletItems", outboundPalletItems);
ReflectionTestUtils.setField(service, "outboundPdaPicks", outboundPdaPicks);
@@ -119,11 +119,13 @@ class ExceptionOrderLifecycleTest {
order.setOutboundNo("OUT-1"); order.setOverseasWarehouseId("WH-1"); order.setInboundOrderId(44L);
order.setDeliveryNo("DL-1");
ExceptionReturnItem source = new ExceptionReturnItem();
source.setId(501L);
source.setOutboundPalletItemId(51L); source.setInventoryPositionId(61L);
source.setSkuCode("SKU-1"); source.setApprovedReturnPackages(2L);
DeliveryOrder delivery = new DeliveryOrder();
delivery.setId(71L); delivery.setCustomerId("1001"); delivery.setDeliveryNo("DL-1");
delivery.setStatus("RETURN_PENDING"); delivery.setVersion(2L);
delivery.setPackageCount(2);
LocalDateTime completedAt = LocalDateTime.of(2026, 9, 8, 18, 30);
when(commands.findByKey("1001", "COMPLETE", "complete-1")).thenReturn(null);
when(exceptionOrders.lockByNo("1001", "EX-1")).thenReturn(order);
@@ -131,6 +133,7 @@ class ExceptionOrderLifecycleTest {
when(inventoryCommands.restoreReturnedInventory(any(), any(), any(ReturnedInventoryRestoreRequest.class)))
.thenReturn(new ReturnedInventoryRestoreResponse(81L, List.of(91L), 0, 2, 2, false, completedAt));
when(deliveryOrders.lockByNo("1001", "DL-1")).thenReturn(delivery);
when(returnItems.receivedByDelivery("1001", 71L)).thenReturn(2L);
when(lastMileOrders.selectList(any())).thenReturn(List.of());
ExceptionOrderRequests.Complete request = new ExceptionOrderRequests.Complete(1, "complete-1", "已退回入仓",
List.of(101L), List.of(new ExceptionOrderRequests.ReturnReceipt(51L, 61L, 2, 201L,
@@ -146,6 +149,82 @@ class ExceptionOrderLifecycleTest {
verify(inventoryCommands).restoreReturnedInventory(any(), any(), any(ReturnedInventoryRestoreRequest.class));
}
@Test
void partialReceiptStaysProcessingAndCannotCompleteTheDelivery() {
var delivery = partialReturn(4, 10, 1);
var result = service.complete("1001", "9001", "EX-1", receipt(1));
assertThat(result.status()).isEqualTo("PROCESSING");
assertThat(result.completedAt()).isNull();
assertThat(result.restoredPackages()).isEqualTo(1);
assertThat(delivery.getStatus()).isEqualTo("RETURN_PENDING");
verify(deliveryOperations, never()).insert(any(com.baiyue.lastleg.delivery.entity.DeliveryOperation.class));
ArgumentCaptor<ExceptionReturnItem> item = ArgumentCaptor.forClass(ExceptionReturnItem.class);
verify(returnItems).updateById(item.capture());
assertThat(item.getValue().getReceivedPackages()).isEqualTo(1);
}
@Test
void fullyReceivingAPartialReturnCaseDoesNotCloseTheRemainingDeliveryCargo() {
var delivery = partialReturn(2, 10, 2);
var signedAt = LocalDateTime.of(2026, 9, 1, 12, 0);
delivery.setSignedAt(signedAt);
var result = service.complete("1001", "9001", "EX-1", receipt(2));
assertThat(result.status()).isEqualTo("COMPLETED");
assertThat(delivery.getStatus()).isEqualTo("RETURN_PENDING");
assertThat(delivery.getSignedAt()).isEqualTo(signedAt);
verify(deliveryOperations, never()).insert(any(com.baiyue.lastleg.delivery.entity.DeliveryOperation.class));
}
@Test
void closingCustomerReturnCaseDoesNotInventCargoHandoff() {
var order = order("PROCESSING", 1);
order.setExceptionType("RETURN"); order.setReturnDirection("CUSTOMER"); order.setDeliveryNo("DL-1");
when(exceptionOrders.lockByNo("1001", "EX-1")).thenReturn(order);
var result = service.complete("1001", "9001", "EX-1", new ExceptionOrderRequests.Complete(1, "other-1", "联系客户", List.of(101L), List.of()));
assertThat(result.status()).isEqualTo("COMPLETED");
verify(deliveryOrders, never()).updateById(any(DeliveryOrder.class));
verify(deliveryOperations, never()).insert(any(com.baiyue.lastleg.delivery.entity.DeliveryOperation.class));
}
@Test
void cancellingPendingReturnRestoresTheFrozenStatusAndPreservesSignature() {
var order = order("PENDING", 1);
order.setExceptionType("RETURN"); order.setDeliveryNo("DL-1");
order.setReturnContextJson("{\"deliveryStatus\":\"DELIVERED\",\"tailId\":null,\"tailStatus\":null,\"abnormal\":null}");
var delivery = new DeliveryOrder(); delivery.setStatus("RETURN_PENDING"); delivery.setVersion(3L);
var signedAt = LocalDateTime.of(2026, 9, 1, 12, 0); delivery.setSignedAt(signedAt);
when(exceptionOrders.lockByNo("1001", "EX-1")).thenReturn(order);
when(deliveryOrders.lockByNo("1001", "DL-1")).thenReturn(delivery);
when(returnItems.findByException("1001", 1L)).thenReturn(List.of());
var result = service.cancel("1001", "9001", "EX-1", new ExceptionOrderRequests.Cancel(1, "cancel-1", "无需退回"));
assertThat(result.status()).isEqualTo("CANCELLED");
assertThat(delivery.getStatus()).isEqualTo("DELIVERED");
assertThat(delivery.getVersion()).isEqualTo(4);
assertThat(delivery.getSignedAt()).isEqualTo(signedAt);
verify(deliveryOperations, never()).insert(any(com.baiyue.lastleg.delivery.entity.DeliveryOperation.class));
}
private DeliveryOrder partialReturn(long approved, int shipped, long actual) {
var order = order("PROCESSING", 1);
order.setExceptionType("RETURN"); order.setReturnDirection("OVERSEAS_WAREHOUSE"); order.setDeliveryNo("DL-1");
order.setOutboundNo("OUT-1"); order.setOverseasWarehouseId("1"); order.setInboundOrderId(44L);
var source = new ExceptionReturnItem(); source.setId(501L); source.setOutboundPalletItemId(51L);
source.setInventoryPositionId(61L); source.setApprovedReturnPackages(approved); source.setReceivedPackages(0L);
var delivery = new DeliveryOrder(); delivery.setId(71L); delivery.setStatus("RETURN_PENDING"); delivery.setPackageCount(shipped);
when(exceptionOrders.lockByNo("1001", "EX-1")).thenReturn(order);
when(returnItems.findByException("1001", 1L)).thenReturn(List.of(source));
when(deliveryOrders.lockByNo("1001", "DL-1")).thenReturn(delivery);
when(returnItems.receivedByDelivery("1001", 71L)).thenReturn(actual);
when(inventoryCommands.restoreReturnedInventory(any(), any(), any(ReturnedInventoryRestoreRequest.class)))
.thenReturn(new ReturnedInventoryRestoreResponse(81L, List.of(91L), 0, actual, actual, false, LocalDateTime.now()));
return delivery;
}
private ExceptionOrderRequests.Complete receipt(long amount) {
return new ExceptionOrderRequests.Complete(1, "batch-1", "本次实际退回", List.of(101L),
List.of(new ExceptionOrderRequests.ReturnReceipt(51L, 61L, amount, 201L, "PLT-201", 301L, "LOC-301")));
}
private String hashFor(Object request) {
return (String) ReflectionTestUtils.invokeMethod(service, "hash", request);
}
@@ -0,0 +1,68 @@
package com.baiyue.lastleg.exceptionorder.service;
import com.baomidou.mybatisplus.core.MybatisConfiguration;
import com.baomidou.mybatisplus.extension.spring.MybatisSqlSessionFactoryBean;
import com.baiyue.lastleg.delivery.mapper.DeliveryOrderMapper;
import com.baiyue.lastleg.exceptionorder.mapper.ExceptionOrderMapper;
import org.mybatis.spring.SqlSessionTemplate;
import org.junit.jupiter.api.Test;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.jdbc.datasource.DriverManagerDataSource;
import org.springframework.jdbc.datasource.DataSourceTransactionManager;
import org.springframework.test.util.ReflectionTestUtils;
import org.springframework.transaction.TransactionDefinition;
import org.springframework.transaction.support.TransactionTemplate;
import java.util.UUID;
import java.util.concurrent.*;
import static org.assertj.core.api.Assertions.*;
class ExceptionReturnConcurrencyTest {
@Test void concurrentCreatorsSerializeOnTheRealDeliveryRowAndCancelledHistoryDoesNotBlock() throws Exception {
assertThat(ExceptionOrderService.class.getMethod("markReturn", String.class, String.class, String.class, String.class,
com.baiyue.lastleg.exceptionorder.dto.ExceptionOrderRequests.MarkReturn.class)
.getAnnotation(org.springframework.transaction.annotation.Transactional.class).isolation())
.isEqualTo(org.springframework.transaction.annotation.Isolation.READ_COMMITTED);
var data = new DriverManagerDataSource("jdbc:h2:mem:" + UUID.randomUUID() + ";MODE=MySQL;DB_CLOSE_DELAY=-1;LOCK_TIMEOUT=5000", "sa", "");
var jdbc = new JdbcTemplate(data);
jdbc.execute("CREATE TABLE ll_delivery_order(id BIGINT PRIMARY KEY,customer_id VARCHAR(50),delivery_no VARCHAR(50),status VARCHAR(40))");
jdbc.execute("CREATE TABLE ll_exception_order(id BIGINT PRIMARY KEY,customer_id VARCHAR(50),delivery_order_id BIGINT,exception_type VARCHAR(30),status VARCHAR(30))");
jdbc.update("INSERT INTO ll_delivery_order VALUES(1,'A','DL-1','DELIVERING')");
jdbc.update("INSERT INTO ll_exception_order VALUES(10,'A',1,'RETURN','CANCELLED')");
var config = new MybatisConfiguration(); config.addMapper(DeliveryOrderMapper.class); config.addMapper(ExceptionOrderMapper.class);
var factory = new MybatisSqlSessionFactoryBean(); factory.setDataSource(data); factory.setConfiguration(config);
var sessions = new SqlSessionTemplate(factory.getObject());
var service = new ExceptionOrderService();
ReflectionTestUtils.setField(service, "deliveryOrders", sessions.getMapper(DeliveryOrderMapper.class));
ReflectionTestUtils.setField(service, "exceptionOrders", sessions.getMapper(ExceptionOrderMapper.class));
var tx = new TransactionTemplate(new DataSourceTransactionManager(data));
tx.setIsolationLevel(TransactionDefinition.ISOLATION_READ_COMMITTED);
var locked = new CountDownLatch(1); var secondStarted = new CountDownLatch(1); var releaseFirst = new CountDownLatch(1);
var executor = Executors.newFixedThreadPool(2);
try {
Future<?> first = executor.submit(() -> tx.executeWithoutResult(status -> {
service.lockForReturn("A", "DL-1");
jdbc.update("INSERT INTO ll_exception_order VALUES(11,'A',1,'RETURN','PENDING')");
locked.countDown();
try { assertThat(releaseFirst.await(5, TimeUnit.SECONDS)).isTrue(); }
catch (InterruptedException error) { throw new IllegalStateException(error); }
}));
Future<?> second = executor.submit(() -> {
try { assertThat(locked.await(3, TimeUnit.SECONDS)).isTrue(); }
catch (InterruptedException error) { throw new IllegalStateException(error); }
assertThatThrownBy(() -> tx.executeWithoutResult(status -> {
// Prime a query before the first transaction commits, just like the idempotency lookup.
jdbc.queryForObject("SELECT COUNT(*) FROM ll_exception_order", Long.class);
secondStarted.countDown(); service.lockForReturn("A", "DL-1");
jdbc.update("INSERT INTO ll_exception_order VALUES(12,'A',1,'RETURN','PENDING')");
})).hasMessageContaining("已存在退回异常单");
});
assertThat(secondStarted.await(3, TimeUnit.SECONDS)).isTrue();
assertThatThrownBy(() -> second.get(200, TimeUnit.MILLISECONDS)).isInstanceOf(TimeoutException.class);
releaseFirst.countDown();
first.get(8, TimeUnit.SECONDS); second.get(8, TimeUnit.SECONDS);
assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM ll_exception_order WHERE status='PENDING'", Long.class)).isEqualTo(1);
jdbc.update("UPDATE ll_exception_order SET status='COMPLETED' WHERE id=11");
tx.executeWithoutResult(status -> assertThat(service.lockForReturn("A", "DL-1").getId()).isEqualTo(1));
} finally { releaseFirst.countDown(); executor.shutdownNow(); }
}
}
@@ -0,0 +1,30 @@
package com.baiyue.lastleg.exceptionorder.service;
import com.baiyue.lastleg.exceptionorder.dto.ExceptionOrderRequests.ReturnReceipt;
import com.baiyue.lastleg.exceptionorder.entity.ExceptionReturnItem;
import java.util.List;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.*;
class ReturnReceiptPolicyTest {
@Test void cumulativeReceiptCannotExceedApprovalAcrossBatches() {
var source = source(); source.setReceivedPackages(3L);
assertThat(ReturnReceiptPolicy.quantities(List.of(source), List.of(receipt(1)))).containsEntry(1L, 1L);
assertThatThrownBy(() -> ReturnReceiptPolicy.quantities(List.of(source), List.of(receipt(2)))).hasMessageContaining("累计实收");
assertThat(ReturnReceiptPolicy.complete(List.of(source))).isFalse();
source.setReceivedPackages(4L); assertThat(ReturnReceiptPolicy.complete(List.of(source))).isTrue();
}
@Test void splitTargetsAreSummedBeforeCheckingTheSourceLimit() {
assertThatThrownBy(() -> ReturnReceiptPolicy.quantities(List.of(source()), List.of(receipt(3), receipt(2))))
.hasMessageContaining("累计实收");
}
@Test void wrongPositionCannotBorrowAnotherSourceApproval() {
var wrong = new ReturnReceipt(10L, 999L, 1, 20L, "P", 30L, "L");
assertThatThrownBy(() -> ReturnReceiptPolicy.quantities(List.of(source()), List.of(wrong))).hasMessageContaining("冻结来源");
}
private ExceptionReturnItem source() {
var value = new ExceptionReturnItem(); value.setId(1L); value.setOutboundPalletItemId(10L);
value.setInventoryPositionId(11L); value.setApprovedReturnPackages(4L); value.setReceivedPackages(0L); return value;
}
private ReturnReceipt receipt(long amount) { return new ReturnReceipt(10L, 11L, amount, 20L, "P", 30L, "L"); }
}
@@ -32,9 +32,9 @@ class FleetHandoverPdaQueryTest {
""");
when(history.selectList(any())).thenReturn(List.of(departed));
when(dispatches.transitEvidence("PC-A", false)).thenReturn(new FleetTransitEvidence("PC-A", "CUSTOMER-A", "92", 30, List.of(
new FleetTransitEvidence.Delivery(new FleetHandoverPolicy.Delivery("CUSTOMER-A", "PC-A", "DL-DONE", "FBA:ONE", 10, Instant.parse("2026-10-03T01:00:00Z")),
new FleetTransitEvidence.Delivery(new FleetHandoverPolicy.Delivery("CUSTOMER-A", "PC-A", "DL-DONE", "FBA:ONE", 10, Instant.parse("2026-10-03T01:00:00Z"), List.of()),
"DELIVERED", 2, new FleetTransitEvidence.Transport("ORIGINAL", "原司机", null)),
new FleetTransitEvidence.Delivery(new FleetHandoverPolicy.Delivery("CUSTOMER-A", "PC-A", "DL-PENDING", "FBA:TWO", 20, null),
new FleetTransitEvidence.Delivery(new FleetHandoverPolicy.Delivery("CUSTOMER-A", "PC-A", "DL-PENDING", "FBA:TWO", 20, null, List.of()),
"WAIT_DELIVERY", 3, new FleetTransitEvidence.Transport("REPLACEMENT", "接替司机", null)))));
}
@@ -41,14 +41,14 @@ class FleetHandoverPolicyTest {
}
@Test void sameDestinationCannotMixCustomerOrDispatchFacts() {
var wrong = new Delivery("A", "PC-B", "D-B", "FBA:ONE", 20, null);
var wrong = new Delivery("A", "PC-B", "D-B", "FBA:ONE", 20, null, List.of());
assertThrows(IllegalArgumentException.class, () -> inspectBoth(delivery(a, null), wrong));
}
@Test void missingDuplicateAndChangedQuantityFactsAreRejected() {
assertThrows(IllegalArgumentException.class, () -> inspectBoth(delivery(a, null)));
assertThrows(IllegalArgumentException.class, () -> inspectBoth(delivery(a, null), delivery(a, null), delivery(b, null)));
var wrong = new Delivery("B", "PC-B", "D-B", "FBA:ONE", 19, null);
var wrong = new Delivery("B", "PC-B", "D-B", "FBA:ONE", 19, null, List.of());
assertThrows(IllegalArgumentException.class, () -> inspectBoth(delivery(a, null), wrong));
}
@@ -90,7 +90,7 @@ class FleetHandoverPolicyTest {
}
private static Delivery delivery(Cargo cargo, Instant terminalAt) {
return new Delivery(cargo.customerId(), cargo.dispatchNo(), "D-" + cargo.customerId(),
cargo.destinationKey(), cargo.packages(), terminalAt);
cargo.destinationKey(), cargo.packages(), terminalAt, List.of());
}
private static Load load(int pallets, int kg, int cbm) {
return new Load(pallets, BigDecimal.valueOf(kg), BigDecimal.valueOf(cbm));
@@ -70,7 +70,7 @@ class FleetHandoverTransactionTest {
true, "APPROVED", "file", 0, "1", LocalDateTime.now(), qualification, "VALID", null, null));
var inspection = mock(FleetHandoverInspectionService.class);
var cargo = new FleetHandoverPolicy.Cargo("CUSTOMER-A", "PC-A", "item", "pallet", "FBA:ONT8", 10);
var delivery = new FleetHandoverPolicy.Delivery("CUSTOMER-A", "PC-A", "DL-A", "FBA:ONT8", 10, null);
var delivery = new FleetHandoverPolicy.Delivery("CUSTOMER-A", "PC-A", "DL-A", "FBA:ONT8", 10, null, List.of());
var evidence = new FleetTransitEvidence("PC-A", "CUSTOMER-A", "92", 10,
List.of(new FleetTransitEvidence.Delivery(delivery, "WAIT_DELIVERY", 1, new FleetTransitEvidence.Transport("PLATE-11", "司机", "phone"))));
when(inspection.prepare(anyString(), any(), any(), eq(true))).thenAnswer(call -> new FleetHandoverInspectionService.Prepared(state().getRecordVersion(),
@@ -63,10 +63,10 @@ class FleetTransitEvidenceTest {
@Test void priorSignatureRemainsTerminalAfterLaterReturnRequest() {
var signed = LocalDateTime.of(2026, 10, 3, 9, 0);
delivery.setStatus("RETURN_PENDING"); delivery.setSignedAt(signed);
delivery.setStatus("RETURN_PENDING"); delivery.setSignedAt(signed); delivery.setSignedPackages(10);
var fact = adapter.transitEvidence("PC-1", false);
assertEquals(signed.atZone(ZoneId.systemDefault()).toInstant(), fact.deliveries().get(0).cargo().terminalAt());
verifyNoInteractions(operations, workflow);
verify(operations).returnReceipts("CUSTOMER-A", 41L); verifyNoInteractions(workflow);
}
@Test void unsignedReturnRequiresImmutableReturnOperation() {
@@ -74,6 +74,7 @@ class FleetTransitEvidenceTest {
assertThrows(org.springframework.web.server.ResponseStatusException.class, () -> adapter.transitEvidence("PC-1", false));
var returned = LocalDateTime.of(2026, 10, 3, 10, 0);
when(operations.returnedAt("CUSTOMER-A", 41L)).thenReturn(returned);
when(operations.returnReceipts("CUSTOMER-A", 41L)).thenReturn(List.of(new com.baiyue.lastleg.delivery.model.DeliveryReturnReceiptFact(1L, 51L, 61L, 10, returned)));
assertEquals(returned.atZone(ZoneId.systemDefault()).toInstant(),
adapter.transitEvidence("PC-1", false).deliveries().get(0).cargo().terminalAt());
}
@@ -104,7 +105,7 @@ class FleetTransitEvidenceTest {
}
@Test void signedCargoStaysOnOriginalAssignmentAndStaleVersionCannotOverwrite() {
delivery.setStatus("DELIVERED"); delivery.setSignedAt(LocalDateTime.of(2026, 10, 3, 9, 0));
delivery.setStatus("DELIVERED"); delivery.setSignedAt(LocalDateTime.of(2026, 10, 3, 9, 0)); delivery.setSignedPackages(10);
var evidence = adapter.transitEvidence("PC-1", true);
var assignment = new com.baiyue.lastleg.fleet.trip.FleetDispatchGateway.Assignment("NEW-PLATE", "接替司机", "phone");
adapter.handover(evidence, assignment);
@@ -113,4 +114,37 @@ class FleetTransitEvidenceTest {
assertThrows(org.springframework.web.server.ResponseStatusException.class, () -> adapter.handover(evidence, assignment));
verifyNoInteractions(workflow);
}
@Test void partialReturnThenRemainingHandoverThenSignatureConservesTheOriginalShipment() {
var returnedAt = LocalDateTime.of(2026, 10, 3, 9, 0);
delivery.setStatus("RETURN_PENDING");
when(operations.returnReceipts("CUSTOMER-A", 41L)).thenReturn(List.of(new com.baiyue.lastleg.delivery.model.DeliveryReturnReceiptFact(1L, 51L, 61L, 4, returnedAt)));
var evidence = adapter.transitEvidence("PC-1", true);
var departed = returnedAt.minusHours(1).atZone(ZoneId.systemDefault()).toInstant();
var handedOver = returnedAt.plusMinutes(1).atZone(ZoneId.systemDefault()).toInstant();
var load = new com.baiyue.lastleg.fleet.domain.TripCapacityPolicy.Load(1, new java.math.BigDecimal("100"), java.math.BigDecimal.ONE);
var inspected = FleetHandoverPolicy.inspect(departed, handedOver, handedOver,
List.of(new FleetHandoverPolicy.Cargo("CUSTOMER-A", "PC-1", "51", "61", "FBA:ONT8", 10)),
List.of(evidence.deliveries().get(0).cargo()), List.of(new FleetHandoverPolicy.Pallet("61", load)), load);
assertEquals(6, inspected.remainingCargo().get(0).packages());
assertEquals(load, inspected.occupied()); // No invented proportional weight reduction.
assertNotNull(adapter.completion("PC-1", false).blockReason());
when(deliveries.lockByNo("CUSTOMER-A", "DL-1")).thenReturn(delivery);
when(deliveries.update(isNull(), any())).thenReturn(1);
adapter.handover(evidence, new com.baiyue.lastleg.fleet.trip.FleetDispatchGateway.Assignment("NEW", "司机", "phone"));
delivery.setStatus("DELIVERED"); delivery.setSignedAt(returnedAt.plusMinutes(2)); delivery.setSignedPackages(6);
var complete = adapter.completion("PC-1", false);
assertNull(complete.blockReason());
var outcome = complete.outcomes().get(0);
assertEquals(10, outcome.packages()); assertEquals(6, outcome.signedPackages()); assertEquals(4, outcome.returnedPackages());
assertEquals(0, outcome.remainingPackages());
// A later return of signed goods cannot rewrite the original 6 signed + 4 returned journey.
when(operations.returnReceipts("CUSTOMER-A", 41L)).thenReturn(List.of(
new com.baiyue.lastleg.delivery.model.DeliveryReturnReceiptFact(1L, 51L, 61L, 4, returnedAt),
new com.baiyue.lastleg.delivery.model.DeliveryReturnReceiptFact(2L, 51L, 61L, 2, returnedAt.plusMinutes(3))));
delivery.setStatus("RETURN_PENDING");
assertEquals(outcome, adapter.completion("PC-1", false).outcomes().get(0));
assertEquals(10, delivery.getPackageCount());
verifyNoInteractions(workflow);
}
}
@@ -0,0 +1,53 @@
package com.baiyue.lastleg.fleet.quote;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
import org.junit.jupiter.api.*;
import org.springframework.aop.framework.ProxyFactory;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.jdbc.datasource.*;
import org.springframework.transaction.IllegalTransactionStateException;
import org.springframework.transaction.annotation.AnnotationTransactionAttributeSource;
import org.springframework.transaction.interceptor.TransactionInterceptor;
import org.springframework.transaction.support.TransactionTemplate;
import java.math.BigDecimal;
import java.nio.file.*;
import java.time.LocalDate;
import java.util.*;
import java.util.regex.Pattern;
import static org.assertj.core.api.Assertions.*;
import static org.mockito.Mockito.*;
class FleetQuoteCandidatePersistenceTest {
private FleetQuoteCandidateService candidates;private FleetQuoteService quotes;private TransactionTemplate transaction;
private final LocalDate day=LocalDate.of(2026,10,3);
@BeforeEach void setup()throws Exception{
var source=new DriverManagerDataSource("jdbc:h2:mem:quote-candidates-"+UUID.randomUUID()+";MODE=MySQL;DB_CLOSE_DELAY=-1","sa","");
var jdbc=new JdbcTemplate(source);String schema=Files.readString(Path.of("src/main/resources/db/lastleg.sql"));
for(String table:List.of("ll_fleet_quote","ll_fleet_quote_history")){
var match=Pattern.compile("(?s)CREATE TABLE "+table+" \\(.*?;").matcher(schema);assertThat(match.find()).isTrue();jdbc.execute(match.group().replace(" JSON"," LONGTEXT"));
}
var directory=mock(FleetQuoteDirectory.class);when(directory.name(anyString(),anyLong())).thenReturn("测试客户");
var json=new ObjectMapper().registerModule(new JavaTimeModule());var manager=new DataSourceTransactionManager(source);transaction=new TransactionTemplate(manager);
quotes=new FleetQuoteService(new FleetQuoteRepository(jdbc),directory,json);
var proxy=new ProxyFactory(new FleetQuoteCandidateService(new FleetQuoteCandidateRepository(jdbc),directory,json));proxy.setProxyTargetClass(true);
proxy.addAdvice(new TransactionInterceptor(manager,new AnnotationTransactionAttributeSource()));candidates=(FleetQuoteCandidateService)proxy.getProxy();
}
private FleetQuoteModels.Draft draft(String kind,long customer,BigDecimal price){return new FleetQuoteModels.Draft(kind,customer,"LA","ONT8",BigDecimal.TEN,"USD",BigDecimal.ONE,BigDecimal.TEN,price,day,day,null);}
private FleetQuoteModels.Quote create(String key,String kind,long customer,BigDecimal price){return transaction.execute(s->quotes.create("1",new FleetQuoteModels.Create(key,draft(kind,customer,price))));}
private FleetQuoteCandidateModels.Context context(){return new FleetQuoteCandidateModels.Context(1L," LA ","ont8","USD",day,"PALLET");}
@Test void realQueryRestrictsCustomerPurposeAndDateAndDoesNotPromoteUnknownToZero(){
create("candidate-sale","SALE",1,BigDecimal.ZERO);create("candidate-other","SALE",2,BigDecimal.ONE);create("candidate-buy","PURCHASE",1,BigDecimal.ONE);
var result=candidates.candidates(new FleetQuoteCandidateModels.Query(1L," LA ","ont8","USD",day,"PALLET",1,20));
assertThat(result.total()).isEqualTo(1);assertThat(result.records().get(0).quotedUnitPrice()).isEqualByComparingTo("0");
assertThat(candidates.candidates(new FleetQuoteCandidateModels.Query(1L,"LA","ONT8","USD",day.plusDays(1),"PALLET",1,20)).total()).isZero();
}
@Test void sourceResolutionRequiresTheBookingTransactionAndKeepsOriginalVersionAfterLaterPriceChange(){
var quote=create("candidate-source","SALE",1,BigDecimal.ZERO);var ref=new FleetQuoteCandidateModels.QuoteRef(quote.quoteNo(),quote.recordVersion(),"LOOSE_DELIVERY");
assertThatThrownBy(()->candidates.lockAndResolve(context(),ref)).isInstanceOf(IllegalTransactionStateException.class);
var source=transaction.execute(s->candidates.lockAndResolve(context(),ref));
transaction.executeWithoutResult(s->quotes.update(quote.quoteNo(),"1",new FleetQuoteModels.Update(quote.recordVersion(),draft("SALE",1,new BigDecimal("15.00")))));
assertThat(source.quotedUnitPrice()).isEqualByComparingTo("0");assertThat(source.recordVersion()).isEqualTo(quote.recordVersion());
assertThatThrownBy(()->transaction.execute(s->candidates.lockAndResolve(context(),ref))).hasMessageContaining("重新选择");
}
}
@@ -0,0 +1,67 @@
package com.baiyue.lastleg.fleet.quote;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
import org.junit.jupiter.api.Test;
import org.springframework.transaction.annotation.*;
import org.springframework.security.access.prepost.PreAuthorize;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.util.*;
import static org.assertj.core.api.Assertions.*;
import static org.mockito.Mockito.*;
class FleetQuoteCandidateServiceTest {
private final ObjectMapper json=new ObjectMapper().registerModule(new JavaTimeModule());
private final FleetQuoteCandidateRepository repository=mock(FleetQuoteCandidateRepository.class);
private final FleetQuoteDirectory directory=mock(FleetQuoteDirectory.class);
private final FleetQuoteCandidateService service=new FleetQuoteCandidateService(repository,directory,json);
private final LocalDate day=LocalDate.of(2026,10,3);
private FleetQuoteCandidateModels.Context context(){return new FleetQuoteCandidateModels.Context(1L," LA ","ont8","USD",day,"PALLET");}
private FleetQuoteModels.Draft draft(String kind,long party,String currency,BigDecimal loose){return new FleetQuoteModels.Draft(kind,party,"LA","ONT8",BigDecimal.TEN,currency,BigDecimal.TEN,BigDecimal.ONE,loose,day,day,null);}
private FleetQuoteCandidateRepository.QuoteRow row(String number,String status,long version,FleetQuoteModels.Draft d)throws Exception{return new FleetQuoteCandidateRepository.QuoteRow(number,"客户名称快照",status,version,json.writeValueAsString(d));}
private FleetQuoteCandidateModels.Query query(int size){var c=context();return new FleetQuoteCandidateModels.Query(c.customerId(),c.originCity(),c.destinationCode(),c.currency(),c.effectiveOn(),c.priceBasis(),1,size);}
@Test void candidatesKeepZeroAndBothDateBoundariesButExcludeUnknownPurchaseCustomerAndCurrency()throws Exception{
when(repository.candidates(any())).thenReturn(List.of(row("FQ-ZERO","ACTIVE",7,draft("SALE",1,"USD",BigDecimal.ZERO)),
row("FQ-UNKNOWN","ACTIVE",1,draft("SALE",1,"USD",null)),row("FQ-PURCHASE","ACTIVE",1,draft("PURCHASE",1,"USD",BigDecimal.ONE)),
row("FQ-OTHER","ACTIVE",1,draft("SALE",2,"USD",BigDecimal.ONE)),row("FQ-CNY","ACTIVE",1,draft("SALE",1,"CNY",BigDecimal.ONE)),
row("FQ-OFF","INACTIVE",1,draft("SALE",1,"USD",BigDecimal.ONE)),row("FQ-NEXT","ACTIVE",3,draft("SALE",1,"USD",BigDecimal.TEN))));
var result=service.candidates(query(1));assertThat(result.total()).isEqualTo(2);assertThat(result.records()).hasSize(1);
assertThat(result.records().get(0).quotedUnitPrice()).isEqualByComparingTo("0");assertThat(result.records().get(0).recordVersion()).isEqualTo(7);
verify(directory,times(1)).name("SALE",1L);verify(repository).candidates(new FleetQuoteCandidateModels.Context(1L,"la","ONT8","USD",day,"PALLET"));
}
@Test void selectionRejectsStaleVersionsWrongBasisAndUnavailablePrice()throws Exception{
when(repository.lock("FQ")).thenReturn(row("FQ","ACTIVE",8,draft("SALE",1,"USD",BigDecimal.ZERO)));
assertThatThrownBy(()->service.lockAndResolve(context(),new FleetQuoteCandidateModels.QuoteRef("FQ",7L,"LOOSE_DELIVERY"))).hasMessageContaining("重新选择");
assertThatThrownBy(()->service.lockAndResolve(context(),new FleetQuoteCandidateModels.QuoteRef("FQ",8L,"FULL_DELIVERY"))).hasMessageContaining("计价项目");
when(repository.lock("FQ")).thenReturn(row("FQ","ACTIVE",8,draft("SALE",1,"USD",null)));
assertThatThrownBy(()->service.lockAndResolve(context(),new FleetQuoteCandidateModels.QuoteRef("FQ",8L,"LOOSE_DELIVERY"))).hasMessageContaining("尚未报价");
}
@Test void resolvedSnapshotRetainsSourcePriceInsteadOfInventingNegotiatedAmount()throws Exception{
when(repository.lock("FQ")).thenReturn(row("FQ","ACTIVE",8,draft("SALE",1,"USD",BigDecimal.ZERO)));
var result=service.lockAndResolve(context(),new FleetQuoteCandidateModels.QuoteRef("FQ",8L,"LOOSE_DELIVERY"));
assertThat(result.customerId()).isEqualTo("1");assertThat(result.customerName()).isEqualTo("客户名称快照");assertThat(result.quotedUnitPrice()).isEqualByComparingTo("0");
assertThat(result.priceItem()).isEqualTo("LOOSE_DELIVERY");assertThat(result.effectiveOn()).isEqualTo(day);
}
@Test void selectionRejectsChangedRouteCustomerCurrencyDateAndStatus()throws Exception{
var ref=new FleetQuoteCandidateModels.QuoteRef("FQ",8L,"LOOSE_DELIVERY");
when(repository.lock("FQ")).thenReturn(row("FQ","ACTIVE",8,draft("SALE",1,"USD",BigDecimal.ONE)));
for(var c:List.of(new FleetQuoteCandidateModels.Context(2L,"LA","ONT8","USD",day,"PALLET"),new FleetQuoteCandidateModels.Context(1L,"SF","ONT8","USD",day,"PALLET"),
new FleetQuoteCandidateModels.Context(1L,"LA","LAX9","USD",day,"PALLET"),new FleetQuoteCandidateModels.Context(1L,"LA","ONT8","CNY",day,"PALLET"),
new FleetQuoteCandidateModels.Context(1L,"LA","ONT8","USD",day.plusDays(1),"PALLET")))assertThatThrownBy(()->service.lockAndResolve(c,ref)).hasMessageContaining("不匹配");
when(repository.lock("FQ")).thenReturn(row("FQ","INACTIVE",8,draft("SALE",1,"USD",BigDecimal.ONE)));
assertThatThrownBy(()->service.lockAndResolve(context(),ref)).hasMessageContaining("不匹配");
}
@Test void selectionRequiresOwnersTransactionAndReadEndpointRequiresQuotePermission()throws Exception{
var method=FleetQuoteCandidateService.class.getMethod("lockAndResolve",FleetQuoteCandidateModels.Context.class,FleetQuoteCandidateModels.QuoteRef.class);
assertThat(method.getAnnotation(Transactional.class).propagation()).isEqualTo(Propagation.MANDATORY);
var query=PlatformFleetQuoteCandidateController.class.getMethod("candidates",FleetQuoteCandidateModels.Query.class);
assertThat(query.getAnnotation(PreAuthorize.class).value()).isEqualTo("hasAuthority('platform:fleet:quote:list')");
}
@Test void missingExplicitCityOrBusinessDateCannotFallBackToWarehouseNameOrSystemDate(){
var ref=new FleetQuoteCandidateModels.QuoteRef("FQ",0L,"LOOSE_DELIVERY");
assertThatThrownBy(()->service.lockAndResolve(new FleetQuoteCandidateModels.Context(1L," ","ONT8","USD",day,"PALLET"),ref)).hasMessageContaining("明确客户、路线");
assertThatThrownBy(()->service.lockAndResolve(new FleetQuoteCandidateModels.Context(1L,"LA","ONT8","USD",null,"PALLET"),ref)).hasMessageContaining("服务日期");
verifyNoInteractions(repository,directory);
}
}
@@ -0,0 +1,61 @@
package com.baiyue.lastleg.fleet.trip;
import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.lastleg.fleet.driver.FleetWarehouseClient;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import java.time.LocalDateTime;
import java.util.List;
import static org.assertj.core.api.Assertions.*;
import static org.mockito.Mockito.*;
class FleetBookingQuoteContextTest {
private final FleetWarehouseClient client = mock(FleetWarehouseClient.class);
private final FleetBookingReservations reservations = mock(FleetBookingReservations.class);
private final FleetBookingQuoteContext resolver = new FleetBookingQuoteContext(client, reservations, "test-token");
private final FleetTripEntity trip = new FleetTripEntity();
@BeforeEach void prepare() {
trip.setWarehouseId(12L); trip.setWarehouseName("不能当城市的仓名");
trip.setPlannedDepartureAt(LocalDateTime.parse("2026-07-01T07:30:00"));
when(reservations.stops(trip)).thenReturn(List.of(new FleetTripModels.Stop("起点", null, null), new FleetTripModels.Stop("ONT8", null, "FBA:ONT8")));
warehouse(12L, "America/Los_Angeles", List.of(new FleetWarehouseClient.Address(12L, 0L, " Los Angeles ")));
}
void warehouse(long id, String timezone, List<FleetWarehouseClient.Address> addresses) {
when(client.activeDetail("test-token", 12L)).thenReturn(ApiResponse.success(new FleetWarehouseClient.Detail(
new FleetWarehouseClient.Identity(id, 0L, "OVERSEAS", "ACTIVE", timezone), addresses)));
}
@Test void usesWarehouseCityAndDaylightSavingZoneForTheDepartureDate() {
var value = resolver.resolve(trip, 101, "FBA:ONT8", "USD", "PALLET");
assertThat(value.originCity()).isEqualTo("los angeles");
assertThat(value.effectiveOn()).hasToString("2026-07-01");
assertThat(value.destinationCode()).isEqualTo("ONT8");
assertThat(value.customerId()).isEqualTo(101);
verify(client).activeDetail("test-token", 12L);
}
@Test void fixedOffsetIsUsedAsConfiguredWithoutGuessingFromCountry() {
warehouse(12, "UTC-8", List.of(new FleetWarehouseClient.Address(12L, 0L, "Los Angeles")));
assertThat(resolver.resolve(trip, 101, "FBA:ONT8", "USD", "ORDER").effectiveOn()).hasToString("2026-06-30");
}
@Test void rejectsUnmatchedWarehouseAndUnknownZone() {
warehouse(13, "UTC", List.of(new FleetWarehouseClient.Address(12L, 0L, "LA")));
assertThatThrownBy(() -> resolver.resolve(trip, 101, "FBA:ONT8", "USD", "PALLET")).hasMessageContaining("资料已变化");
warehouse(12, "unknown-zone", List.of(new FleetWarehouseClient.Address(12L, 0L, "LA")));
assertThatThrownBy(() -> resolver.resolve(trip, 101, "FBA:ONT8", "USD", "PALLET")).hasMessageContaining("时区尚未明确");
}
@Test void missingOrAmbiguousCityDoesNotFallBackToWarehouseName() {
warehouse(12, "UTC", List.of(new FleetWarehouseClient.Address(13L, 0L, "wrong warehouse")));
assertThatThrownBy(() -> resolver.resolve(trip, 101, "FBA:ONT8", "USD", "PALLET")).hasMessageContaining("城市尚未明确");
warehouse(12, "UTC", List.of(new FleetWarehouseClient.Address(12L, 0L, "LA"), new FleetWarehouseClient.Address(12L, 0L, "SF")));
assertThatThrownBy(() -> resolver.resolve(trip, 101, "FBA:ONT8", "USD", "PALLET")).hasMessageContaining("城市尚未明确");
}
@Test void routeMustBelongToThisTripAndPrivateAddressesAreNotGuessed() {
assertThatThrownBy(() -> resolver.resolve(trip, 101, "FBA:OTHER", "USD", "PALLET")).hasMessageContaining("已有卸货站");
when(reservations.stops(trip)).thenReturn(List.of(new FleetTripModels.Stop("起点", null, null), new FleetTripModels.Stop("私人地址", null, "PRIVATE:hash")));
assertThatThrownBy(() -> resolver.resolve(trip, 101, "PRIVATE:hash", "USD", "PALLET")).hasMessageContaining("手填");
verifyNoInteractions(client);
}
@Test void unavailableDirectoryNeverProducesInventedCriteria() {
when(client.activeDetail("test-token", 12L)).thenThrow(new IllegalStateException("network"));
assertThatThrownBy(() -> resolver.resolve(trip, 101, "FBA:ONT8", "USD", "PALLET")).hasMessageContaining("503");
}
}
@@ -42,7 +42,7 @@ class FleetTripApiContractTest {
count++;
}
}
assertThat(count).isEqualTo(28);
assertThat(count).isEqualTo(29);
verifyRefs(document, document);
}
@@ -63,9 +63,18 @@ class FleetTripApiContractTest {
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);
String pointer=reference;
if(!reference.startsWith("#/")) {
String[] parts=reference.split("#",2);
assertThat(parts).hasSize(2);
assertThat(parts[0]).isEqualTo("fleet-quote-candidates-v1.yaml");
try { value=new Yaml().load(Files.readString(Path.of("src/main/resources/openapi",parts[0]))); }
catch(java.io.IOException error) { throw new java.io.UncheckedIOException(error); }
pointer="#"+parts[1];
}
assertThat(pointer).startsWith("#/");
for (String key : pointer.substring(2).split("/")) value = ((Map<?, ?>) value).get(key);
assertThat(value).as(reference).isNotNull();
}
map.values().forEach(value -> verifyRefs(value, root));
@@ -12,6 +12,19 @@ import static org.assertj.core.api.Assertions.*;
import static org.mockito.Mockito.*;
class FleetTripAuthorizationTest {
@Test void bookingQuoteCandidatesRequireBothTripAndQuoteReadPermissions() {
try(var context=new AnnotationConfigApplicationContext(Config.class)) {
var controller=context.getBean(PlatformFleetBookingController.class);
var query=new FleetBookingModels.QuoteCandidatesQuery(9L,"FBA:ONT8","USD","PALLET",1,20);
for(var permission:List.of("platform:fleet:trip:list","platform:fleet:quote:list")) {
SecurityContextHolder.getContext().setAuthentication(new UsernamePasswordAuthenticationToken("operator","unused",List.of(new SimpleGrantedAuthority(permission))));
assertThatThrownBy(()->controller.quoteCandidates("TR-TEST",query)).isInstanceOf(AccessDeniedException.class);
}
SecurityContextHolder.getContext().setAuthentication(new UsernamePasswordAuthenticationToken("operator","unused",List.of(new SimpleGrantedAuthority("platform:fleet:trip:list"),new SimpleGrantedAuthority("platform:fleet:quote:list"))));
controller.quoteCandidates("TR-TEST",query);
verify(context.getBean(FleetBookingService.class)).quoteCandidates("TR-TEST",query);
} finally { SecurityContextHolder.clearContext(); }
}
@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"))));
@@ -23,6 +23,11 @@ import com.baiyue.lastleg.masterdata.vehicle.application.port.out.VehicleReposit
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.AfterEach;
import com.baiyue.lastleg.fleet.quote.FleetQuoteCandidateModels;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.junit.jupiter.api.Test;
import org.mybatis.spring.SqlSessionTemplate;
import org.springframework.aop.framework.ProxyFactory;
@@ -60,7 +65,20 @@ class FleetTripPersistenceTest {
private FleetTripCompletionService completion;
private FleetBookingService bookingService;
private FleetBookingIncomingGateway incoming;
private FleetBookingQuoteContext quoteContext;
private com.baiyue.lastleg.fleet.quote.FleetQuoteCandidateService quoteCandidates;
private Instant departure, end;
@AfterEach void clearAuthentication() { SecurityContextHolder.clearContext(); }
private FleetQuoteCandidateModels.SourceSnapshot enableQuote() {
SecurityContextHolder.getContext().setAuthentication(new UsernamePasswordAuthenticationToken("1","unused",List.of(new SimpleGrantedAuthority("platform:fleet:quote:list"))));
var context=new FleetQuoteCandidateModels.Context(9L,"Los Angeles","ONT8","USD",LocalDate.of(2026,10,3),"PALLET");
when(quoteContext.resolve(any(),eq(9L),eq("FBA:ONT8"),eq("USD"),eq("PALLET"))).thenReturn(context);
var source=new FleetQuoteCandidateModels.SourceSnapshot("FQ-TEST",2L,"LOOSE_DELIVERY","9","外接客户","Los Angeles","ONT8",new BigDecimal("30"),"USD","PALLET",BigDecimal.ZERO,LocalDate.of(2026,10,1),LocalDate.of(2026,10,31),context.effectiveOn());
when(quoteCandidates.lockAndResolve(eq(context),any())).thenReturn(source);
return source;
}
private static FleetQuoteCandidateModels.QuoteRef quoteReference(){return new FleetQuoteCandidateModels.QuoteRef("FQ-TEST",2L,"LOOSE_DELIVERY");}
@BeforeEach void initialize() throws Exception {
departure = Instant.now().plus(1, ChronoUnit.HOURS).truncatedTo(ChronoUnit.MILLIS);
@@ -70,7 +88,8 @@ class FleetTripPersistenceTest {
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")) {
"ll_dispatch_item", "ll_dispatch_pda_pick", "ll_dispatch_pda_operation", "ll_dispatch_pallet_lock", "ll_delivery_operation",
"ll_outbound_pallet_item", "ll_exception_order", "ll_exception_return_item", "ll_inventory_ledger", "ll_dispatch_pda_pick_order")) {
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"));
@@ -119,7 +138,9 @@ class FleetTripPersistenceTest {
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);
quoteContext=mock(FleetBookingQuoteContext.class);
quoteCandidates=mock(com.baiyue.lastleg.fleet.quote.FleetQuoteCandidateService.class);
bookingService = transactional(new FleetBookingService(session.getMapper(FleetTripMapper.class), bookingMapper, reservations, service, execution, customerDirectory, json, incoming, quoteContext, quoteCandidates), FleetBookingService.class);
completion = transactional(new FleetTripCompletionService(session.getMapper(FleetTripMapper.class), bindingMapper, resources, dispatchGateway, service, vehicles, vehicleService, json), FleetTripCompletionService.class);
}
@@ -313,9 +334,10 @@ class FleetTripPersistenceTest {
}
@Test void externalReceiptChangesReservationOnlyAfterExplicitActualQuantityAndPriceConfirmation() {
var source=enableQuote();
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 draft=bookingService.create(number,"1",new FleetBookingModels.Create(service.detail(number).trip().recordVersion(),9L,externalCargo(2),"receipt-create",quoteReference()));
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);
@@ -336,6 +358,8 @@ class FleetTripPersistenceTest {
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(accepted.booking().quoteSource()).isEqualTo(source);
verify(quoteCandidates,times(2)).lockAndResolve(any(),eq(quoteReference()));
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')");
@@ -351,7 +375,7 @@ class FleetTripPersistenceTest {
@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 create=new FleetBookingModels.Create(service.detail(number).trip().recordVersion(),9L,externalCargo(10),"external-create-one",null);
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);
@@ -374,9 +398,9 @@ class FleetTripPersistenceTest {
@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"));
var first=bookingService.create(number,"1",new FleetBookingModels.Create(service.detail(number).trip().recordVersion(),9L,externalCargo(20),"external-first",null));
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"));
var second=bookingService.create(number,"1",new FleetBookingModels.Create(service.detail(number).trip().recordVersion(),10L,externalCargo(10),"external-second",null));
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);
@@ -389,6 +413,44 @@ class FleetTripPersistenceTest {
}
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 bookingSourceIsPersistedSeparatelyFromAgreedPriceAndCanBeExplicitlyCleared() {
var source=enableQuote();String number=assignedTrip();long version=service.detail(number).trip().recordVersion();
var request=new FleetBookingModels.Create(version,9L,externalCargo(2),"quote-create",quoteReference());
var created=bookingService.create(number,"1",request);
assertThat(bookingService.list(number).records().get(0).quoteSource()).isEqualTo(source);
assertThat(created.booking().agreedAmount()).isEqualByComparingTo("100.00");
assertThat(bookingService.create(number,"1",request).replayed()).isTrue();
verify(quoteCandidates,times(1)).lockAndResolve(any(),eq(quoteReference()));
assertThatThrownBy(()->bookingService.create(number,"1",new FleetBookingModels.Create(version,9L,externalCargo(2),"quote-create",null))).hasMessageContaining("同一登记请求");
var cleared=bookingService.update(number,created.booking().bookingNo(),"1",new FleetBookingModels.Update(created.tripRecordVersion(),0L,externalCargo(2),null));
assertThat(cleared.booking().quoteSource()).isNull();
assertThat(bookingService.list(number).records().get(0).quoteSource()).isNull();
assertThat(jdbc.queryForObject("SELECT quote_source_json FROM ll_fleet_external_booking",String.class)).isNull();
}
@Test void referencingOrClearingQuoteRequiresQuoteReadPermissionAndRollsBack() {
String number=assignedTrip();long version=service.detail(number).trip().recordVersion();
assertThatThrownBy(()->bookingService.create(number,"1",new FleetBookingModels.Create(version,9L,externalCargo(2),"denied-quote",quoteReference()))).isInstanceOf(ResponseStatusException.class).hasMessageContaining("报价读取权限");
assertThat(bookingService.list(number).records()).isEmpty();
var source=enableQuote();var created=bookingService.create(number,"1",new FleetBookingModels.Create(version,9L,externalCargo(2),"allowed-quote",quoteReference()));
SecurityContextHolder.clearContext();
assertThatThrownBy(()->bookingService.update(number,created.booking().bookingNo(),"1",new FleetBookingModels.Update(created.tripRecordVersion(),0L,externalCargo(2),null))).hasMessageContaining("报价读取权限");
assertThat(bookingService.list(number).records().get(0).quoteSource()).isEqualTo(source);
assertThat(service.detail(number).trip().recordVersion()).isEqualTo(created.tripRecordVersion());
}
@Test void confirmRevalidatesQuoteAndDoesNotReserveWhenSourceIsStale() {
var source=enableQuote();String number=readyTrip();var basis=execution.capacityPlan(number);
execution.confirmCapacityPlan(number,"1",new FleetExecutionModels.Confirm(basis.recordVersion(),basis.manifestHash(),List.of(measurement()),"计划"));
var created=bookingService.create(number,"1",new FleetBookingModels.Create(service.detail(number).trip().recordVersion(),9L,externalCargo(2),"stale-quote",quoteReference()));
when(quoteCandidates.lockAndResolve(any(),any())).thenThrow(new ResponseStatusException(org.springframework.http.HttpStatus.CONFLICT,"报价版本已变化"));
assertThatThrownBy(()->bookingService.confirm(number,created.booking().bookingNo(),"1",new FleetBookingModels.Command(created.tripRecordVersion(),0L,"confirm-stale",null))).hasMessageContaining("报价版本已变化");
var stored=bookingService.list(number).records().get(0);
assertThat(stored.status()).isEqualTo("DRAFT");assertThat(stored.recordVersion()).isZero();assertThat(stored.quoteSource()).isEqualTo(source);
assertThat(execution.capacityPlan(number).availableSegments().get(0).remaining().palletSpaces()).isEqualTo(29);
verify(quoteContext,times(2)).resolve(any(),eq(9L),eq("FBA:ONT8"),eq("USD"),eq("PALLET"));
}
@Test void capacityCanBePlannedBeforePickingWithoutCreatingActualLoadOrApprovals() {
String number = readyTrip();
jdbc.update("DELETE FROM ll_dispatch_pda_pick");
@@ -725,17 +787,18 @@ class FleetTripPersistenceTest {
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");
jdbc.update("UPDATE ll_delivery_order SET status='DELIVERED', package_count=9,signed_packages=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");
jdbc.update("UPDATE ll_delivery_order SET status='RETURNED',signed_at=NULL,signed_packages=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);
returnedCargo(10, 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();
@@ -790,7 +853,33 @@ class FleetTripPersistenceTest {
}
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));
jdbc.update("UPDATE ll_delivery_order SET package_count=10,status='DELIVERED',signed_packages=10,signed_at=?", LocalDateTime.now().truncatedTo(ChronoUnit.MILLIS));
}
private void returnedCargo(int quantity, LocalDateTime at) {
jdbc.update("INSERT INTO ll_outbound_pallet_item(id,customer_id,outbound_order_id,warehouse_pallet_id,package_count) VALUES(6001,'CLIENT-B',1012,901,10)");
jdbc.update("INSERT INTO ll_exception_order(id,customer_id,exception_no,exception_type,status,reason,delivery_order_id,last_mile_order_id,exception_at) VALUES(6002,'CLIENT-B','EX-RETURN','RETURN','COMPLETED','真实退回',2012,6102,?)", at);
jdbc.update("INSERT INTO ll_exception_return_item(id,customer_id,exception_order_id,outbound_pallet_item_id,inventory_position_id,max_return_packages,approved_return_packages,received_packages,created_at) VALUES(6003,'CLIENT-B',6002,6001,4012,10,?,?,?)", quantity, quantity, at);
jdbc.update("INSERT INTO ll_dispatch_pda_pick_order(id,customer_id,pick_id,receipt_id,order_id,packages) VALUES(6005,'CLIENT-B',12,6103,6102,10)");
jdbc.update("INSERT INTO ll_inventory_ledger(id,customer_id,customer_name,balance_id,overseas_warehouse_id,inbound_order_id,business_type,business_order_no,outbound_order_no,source_event_id,idempotency_key,before_packages,delta_packages,after_packages,occurred_at) VALUES(6004,'CLIENT-B','客户B',6101,'92',6102,'RETURN_STOCK_IN','EX-RETURN','OUT-12','RETURN_SOURCE:6001:4012','receipt-unique',0,?,?,?)", quantity, quantity, at);
}
@Test void partialReturnCannotReleaseResourcesAndResidualSignatureClosesOnlyConservedQuantities() {
String number = departedTrip(); markDeliveriesSigned();
jdbc.update("UPDATE ll_delivery_order SET status='RETURN_PENDING',signed_at=NULL,signed_packages=NULL WHERE id=2012");
returnedCargo(4, LocalDateTime.now().truncatedTo(ChronoUnit.MILLIS));
assertThat(completion.inspection(number).canComplete()).isFalse();
assertThatThrownBy(() -> completion.release(number, "1", new FleetCompletionModels.Release(service.detail(number).trip().recordVersion(),
"VEHICLE", Instant.now(), "仓库", "IN_SERVICE", "partial-no-release", "仍有余货"))).hasMessageContaining("结案");
var signedAt = LocalDateTime.now().truncatedTo(ChronoUnit.MILLIS);
jdbc.update("UPDATE ll_delivery_order SET status='DELIVERED',signed_at=?,signed_packages=6 WHERE id=2012", signedAt);
var inspection = completion.inspection(number);
assertThat(inspection.canComplete()).isTrue();
var outcome = inspection.outcomes().stream().filter(row -> row.deliveryNo().equals("DL-12")).findFirst().orElseThrow();
assertThat(outcome.packages()).isEqualTo(10); assertThat(outcome.signedPackages()).isEqualTo(6);
assertThat(outcome.returnedPackages()).isEqualTo(4); assertThat(outcome.remainingPackages()).isZero();
completion.complete(number, "1", new FleetCompletionModels.Complete(service.detail(number).trip().recordVersion(), "partial-true-completion", "实退及余货实签核平"));
assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM ll_fleet_resource_assignment", Integer.class)).isEqualTo(2);
}
private String readyTrip() {
@@ -20,11 +20,15 @@ class Phase5ExceptionVisitSchemaContractTest {
assertThat(normalized).contains(
"ll_exception_order(",
"UNIQUEKEYuk_ll_exception_no(customer_id,exception_no)",
"UNIQUEKEYuk_ll_exception_delivery_return(customer_id,delivery_order_id,exception_type)",
"KEYidx_ll_exception_delivery_return(customer_id,delivery_order_id,exception_type)",
"return_context_jsonJSONNULL",
"received_packagesBIGINTNOTNULLDEFAULT0",
"CHECK(received_packages>=0ANDreceived_packages<=approved_return_packages)",
"KEYidx_ll_exception_inbound(customer_id,inbound_code)",
"ll_customer_visit(",
"UNIQUEKEYuk_ll_customer_visit_delivery(customer_id,delivery_order_id)",
"KEYidx_ll_customer_visit_list(customer_id,status,delivery_completed_at)");
assertThat(normalized).doesNotContain("UNIQUEKEYuk_ll_exception_delivery_return");
assertThat(sql).contains(
"COMMENT='海外仓异常订单'",
"COMMENT='派送单维度客户回访记录'");
@@ -42,7 +42,8 @@ class Phase5OverseasUpgradeSqlContractTest {
"customer_id", "outbound_type", "lx_codes", "request_hash");
assertThat(createStatement(overseas, "ll_outbound_pallet_item")).contains("picked_package_count");
assertThat(createStatement(overseas, "ll_delivery_order")).contains(
"customer_id", "overseas_warehouse_name", "waybill_no");
"customer_id", "overseas_warehouse_name", "waybill_no", "signed_packages");
assertThat(normalized).contains("check(signed_packagesisnullor(signed_packages>=0andsigned_packages<=package_count))");
assertThat(createStatement(overseas, "ll_dropship_order_profile")).contains(
"source_pallet_no", "manifest_version", "manifest_package_count",
"manifest_fragment_count", "manifest_hash");