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();
}
}