feat(lastleg): redispatch returned cargo and reconcile source signatures
This commit is contained in:
+60
@@ -0,0 +1,60 @@
|
||||
package com.baiyue.lastleg.delivery;
|
||||
|
||||
import com.baiyue.lastleg.delivery.mapper.DeliverySourceEvidenceMapper;
|
||||
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.util.*;
|
||||
import static org.assertj.core.api.Assertions.*;
|
||||
|
||||
/** Executes the production annotation SQL against real H2 tables, not mocked mapper results. */
|
||||
class DeliverySourceEvidenceMapperTest {
|
||||
@Test void historySurvivesCurrentDeliveryRebindingAndKeepsAllCoShippedSources() throws Exception {
|
||||
var ds = new DriverManagerDataSource("jdbc:h2:mem:"+UUID.randomUUID()+";MODE=MySQL;DB_CLOSE_DELAY=-1", "sa", "");
|
||||
var db = new JdbcTemplate(ds); var named = new NamedParameterJdbcTemplate(ds);
|
||||
db.execute("CREATE TABLE ll_order(id BIGINT,customer_id VARCHAR(20),source_region VARCHAR(20),source_inbound_id VARCHAR(30),package_count BIGINT,delivery_order_no VARCHAR(30))");
|
||||
db.execute("CREATE TABLE ll_dispatch_order(id BIGINT,customer_id VARCHAR(20),depart_at TIMESTAMP)");
|
||||
db.execute("CREATE TABLE ll_delivery_order(id BIGINT,customer_id VARCHAR(20),dispatch_order_id BIGINT,delivery_group_key VARCHAR(100),package_count BIGINT,signed_packages BIGINT,signed_at TIMESTAMP,outbound_no VARCHAR(30))");
|
||||
db.execute("CREATE TABLE ll_dispatch_pda_task(id BIGINT,customer_id VARCHAR(20),dispatch_order_id BIGINT)");
|
||||
db.execute("CREATE TABLE ll_dispatch_pda_pick(id BIGINT,customer_id VARCHAR(20),task_id BIGINT,dispatch_item_id BIGINT)");
|
||||
db.execute("CREATE TABLE ll_dispatch_pda_pick_order(id BIGINT,customer_id VARCHAR(20),pick_id BIGINT,order_id BIGINT,packages BIGINT,receipt_id BIGINT,return_receipt_id BIGINT)");
|
||||
db.execute("CREATE TABLE ll_dispatch_item(id BIGINT,customer_id VARCHAR(20),dispatch_order_id BIGINT,destination_type VARCHAR(20),delivery_address VARCHAR(100),postal_code VARCHAR(20),destination_warehouse_code VARCHAR(30),fba_warehouse_code VARCHAR(30),destination_warehouse_id VARCHAR(30))");
|
||||
db.execute("CREATE TABLE ll_exception_order(id BIGINT,customer_id VARCHAR(20),delivery_order_id BIGINT,exception_no VARCHAR(30),last_mile_order_id BIGINT)");
|
||||
db.execute("CREATE TABLE ll_inventory_ledger(id BIGINT,customer_id VARCHAR(20),business_order_no VARCHAR(30),outbound_order_no VARCHAR(30),business_type VARCHAR(30),delta_packages BIGINT,occurred_at TIMESTAMP,source_event_id VARCHAR(128))");
|
||||
db.execute("CREATE TABLE ll_return_stock_receipt(id BIGINT,customer_id VARCHAR(20),order_id BIGINT,inventory_ledger_id BIGINT,received_packages BIGINT,exception_order_id BIGINT,source_outbound_no VARCHAR(30),source_outbound_pallet_item_id BIGINT,source_inventory_position_id BIGINT)");
|
||||
db.update("INSERT INTO ll_order VALUES(101,'C','FIRSTLEG','S-A',4,'NEW-DELIVERY'),(102,'C','FIRSTLEG','S-B',6,'OTHER-DELIVERY')");
|
||||
db.update("INSERT INTO ll_dispatch_order VALUES(10,'C','2026-10-03 01:00:00')");
|
||||
db.update("INSERT INTO ll_delivery_order VALUES(20,'C',10,'CODE:A',10,8,'2026-10-03 03:00:00','OUT-1')");
|
||||
db.update("INSERT INTO ll_dispatch_pda_task VALUES(1,'C',10)");
|
||||
db.update("INSERT INTO ll_dispatch_item VALUES(7,'C',10,'FBA',NULL,NULL,'a',NULL,NULL)");
|
||||
db.update("INSERT INTO ll_dispatch_pda_pick VALUES(2,'C',1,7)");
|
||||
db.update("INSERT INTO ll_dispatch_pda_pick_order VALUES(3,'C',2,101,4,30,NULL),(4,'C',2,102,6,31,NULL)");
|
||||
db.update("INSERT INTO ll_exception_order VALUES(30,'C',20,'EX-1',101)");
|
||||
db.update("INSERT INTO ll_inventory_ledger VALUES(40,'C','EX-1','OUT-1','RETURN_STOCK_IN',2,'2026-10-03 02:00:00','RETURN_SOURCE:7:8'),(41,'C','EX-1','OUT-1','RETURN_STOCK_IN',1,'2026-10-03 04:00:00','RETURN_SOURCE:7:8')");
|
||||
var args = Map.<String,Object>of("customerId","C","sourceInboundId","S-A","orderId",101L,"dispatchId",10L);
|
||||
assertThat(named.queryForList(sql("source"),args)).singleElement().satisfies(r -> assertThat(((Number)r.get("packages")).longValue()).isEqualTo(4));
|
||||
assertThat(named.queryForList(sql("dispatchIds"),args)).hasSize(1);
|
||||
assertThat(named.queryForList(sql("deliveries"),args)).singleElement().satisfies(r -> assertThat(r.get("departed_at")).isNotNull());
|
||||
var picks = named.queryForList(sql("picks"),args);
|
||||
assertThat(picks).hasSize(2); assertThat(picks.stream().mapToLong(r -> ((Number)r.get("packages")).longValue()).sum()).isEqualTo(10);
|
||||
assertThat(picks).allSatisfy(r -> assertThat(r.get("group_key")).isEqualTo("CODE:A"));
|
||||
assertThat(named.queryForList(sql("returns"),args)).hasSize(2);
|
||||
db.update("INSERT INTO ll_return_stock_receipt VALUES(90,'C',101,40,2,30,'OUT-1',7,8)");
|
||||
db.update("UPDATE ll_dispatch_pda_pick_order SET receipt_id=NULL,return_receipt_id=90,packages=2 WHERE id=3");
|
||||
var linked=named.queryForList(sql("picks"),args).get(0);
|
||||
assertThat(((Number)linked.get("return_ledger_id")).longValue()).isEqualTo(40);
|
||||
assertThat(((Number)linked.get("return_packages")).longValue()).isEqualTo(2);
|
||||
db.update("UPDATE ll_return_stock_receipt SET order_id=102 WHERE id=90");
|
||||
assertThat(named.queryForList(sql("picks"),args).get(0).get("return_ledger_id")).isNull();
|
||||
for (var method : List.of("source","dispatchIds","deliveries","picks","returns")) {
|
||||
var other = new HashMap<>(args); other.put("customerId","OTHER");
|
||||
assertThat(named.queryForList(sql(method),other)).isEmpty();
|
||||
}
|
||||
}
|
||||
private String sql(String name) {
|
||||
var method = Arrays.stream(DeliverySourceEvidenceMapper.class.getMethods()).filter(m -> m.getName().equals(name)).findFirst().orElseThrow();
|
||||
return String.join(" ",method.getAnnotation(Select.class).value()).replaceAll("#\\{(\\w+)\\}", ":$1");
|
||||
}
|
||||
}
|
||||
+228
@@ -0,0 +1,228 @@
|
||||
package com.baiyue.lastleg.delivery;
|
||||
import com.baiyue.lastleg.delivery.mapper.*;
|
||||
import com.baiyue.lastleg.delivery.model.SourceDeliveryCompletionPolicy.*;
|
||||
import com.baiyue.lastleg.delivery.model.*;
|
||||
import com.baiyue.lastleg.delivery.entity.*;
|
||||
import com.baiyue.lastleg.delivery.dto.DeliveryDocumentDtos.*;
|
||||
import com.baiyue.lastleg.delivery.service.*;
|
||||
import com.baiyue.lastleg.delivery.gateway.DeliveryDocumentFileGateway;
|
||||
import com.baiyue.lastleg.customervisit.service.CustomerVisitService;
|
||||
import com.baiyue.lastleg.entity.*;
|
||||
import com.baiyue.lastleg.mapper.*;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import org.apache.ibatis.annotations.Select;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.jdbc.core.*;
|
||||
import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate;
|
||||
import org.springframework.jdbc.datasource.*;
|
||||
import org.springframework.transaction.TransactionDefinition;
|
||||
import org.springframework.transaction.support.TransactionTemplate;
|
||||
import org.springframework.test.util.ReflectionTestUtils;
|
||||
import java.lang.reflect.Proxy;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.*;
|
||||
import java.util.concurrent.*;
|
||||
import static org.assertj.core.api.Assertions.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
/** Production document/query/policy/outbox flow, with actual SQL state and transactions.
|
||||
* Document BaseMapper plumbing is implemented with JDBC answers; source annotation SQL is executed verbatim.
|
||||
*/
|
||||
class DeliverySourcePublicationPersistenceTest {
|
||||
static class Fixture {
|
||||
final DriverManagerDataSource ds = new DriverManagerDataSource("jdbc:h2:mem:"+UUID.randomUUID()+";MODE=MySQL;DB_CLOSE_DELAY=-1;LOCK_TIMEOUT=5000","sa","");
|
||||
final JdbcTemplate db=new JdbcTemplate(ds);
|
||||
final NamedParameterJdbcTemplate named=new NamedParameterJdbcTemplate(ds);
|
||||
final DataSourceTransactionManager manager=new DataSourceTransactionManager(ds);
|
||||
DeliveryDocumentService service=new DeliveryDocumentService();
|
||||
volatile boolean failOutbox, failRevision;
|
||||
DeliverySourceEvidenceMapper mapper;
|
||||
volatile Runnable beforeFirstSourceRead = () -> {};
|
||||
final Map<String,List<Long>> sourceLocks=new ConcurrentHashMap<>();
|
||||
Fixture() {
|
||||
db.execute("CREATE TABLE ll_order(id BIGINT PRIMARY KEY,customer_id VARCHAR(20),source_region VARCHAR(20),source_inbound_id VARCHAR(30),package_count BIGINT,UNIQUE(customer_id,source_region,source_inbound_id))");
|
||||
db.execute("CREATE TABLE ll_dispatch_order(id BIGINT,customer_id VARCHAR(20),depart_at TIMESTAMP)");
|
||||
db.execute("CREATE TABLE ll_delivery_order(id BIGINT PRIMARY KEY,customer_id VARCHAR(20),dispatch_order_id BIGINT,delivery_group_key VARCHAR(100),package_count BIGINT,signed_packages BIGINT,signed_at TIMESTAMP,outbound_no VARCHAR(30))");
|
||||
db.execute("CREATE TABLE ll_dispatch_pda_task(id BIGINT,customer_id VARCHAR(20),dispatch_order_id BIGINT)");
|
||||
db.execute("CREATE TABLE ll_dispatch_pda_pick(id BIGINT,customer_id VARCHAR(20),task_id BIGINT,dispatch_item_id BIGINT)");
|
||||
db.execute("CREATE TABLE ll_dispatch_pda_pick_order(id BIGINT,customer_id VARCHAR(20),pick_id BIGINT,order_id BIGINT,packages BIGINT,receipt_id BIGINT,return_receipt_id BIGINT)");
|
||||
db.execute("CREATE TABLE ll_dispatch_item(id BIGINT,customer_id VARCHAR(20),dispatch_order_id BIGINT,destination_type VARCHAR(20),delivery_address VARCHAR(100),postal_code VARCHAR(20),destination_warehouse_code VARCHAR(30),fba_warehouse_code VARCHAR(30),destination_warehouse_id VARCHAR(30))");
|
||||
db.execute("CREATE TABLE ll_exception_order(id BIGINT,customer_id VARCHAR(20),delivery_order_id BIGINT,exception_no VARCHAR(30),last_mile_order_id BIGINT)");
|
||||
db.execute("CREATE TABLE ll_inventory_ledger(id BIGINT,customer_id VARCHAR(20),business_order_no VARCHAR(30),outbound_order_no VARCHAR(30),business_type VARCHAR(30),delta_packages BIGINT,occurred_at TIMESTAMP,source_event_id VARCHAR(128))");
|
||||
db.execute("CREATE TABLE ll_return_stock_receipt(id BIGINT,customer_id VARCHAR(20),order_id BIGINT,inventory_ledger_id BIGINT,received_packages BIGINT,exception_order_id BIGINT,source_outbound_no VARCHAR(30),source_outbound_pallet_item_id BIGINT,source_inventory_position_id BIGINT)");
|
||||
db.execute("ALTER TABLE ll_order ADD sub_order_status VARCHAR(128)");
|
||||
db.execute("ALTER TABLE ll_delivery_order ADD version BIGINT DEFAULT 0");
|
||||
db.execute("ALTER TABLE ll_delivery_order ADD status VARCHAR(32) DEFAULT 'DELIVERING'");
|
||||
db.execute("ALTER TABLE ll_delivery_order ADD documents_json CLOB");
|
||||
db.execute("CREATE TABLE proof_revision(delivery_id BIGINT,command_key VARCHAR(128),request_hash VARCHAR(128),snapshot CLOB, UNIQUE(delivery_id,command_key))");
|
||||
db.execute("CREATE TABLE proof_outbox(event_key VARCHAR(255) PRIMARY KEY,aggregate_id VARCHAR(128),customer_id VARCHAR(128),payload CLOB)");
|
||||
db.execute("CREATE TABLE proof_operation(delivery_id BIGINT,node VARCHAR(80),result CLOB)");
|
||||
mapper = (DeliverySourceEvidenceMapper)Proxy.newProxyInstance(getClass().getClassLoader(),new Class[]{DeliverySourceEvidenceMapper.class},(proxy,method,args) -> {
|
||||
if(method.getName().equals("source")) beforeFirstSourceRead.run();
|
||||
if(method.getName().equals("lockSource")) sourceLocks.computeIfAbsent(Thread.currentThread().getName(),k->new CopyOnWriteArrayList<>()).add((Long)args[1]);
|
||||
String sql=String.join(" ",method.getAnnotation(Select.class).value()).replaceAll("#\\{(\\w+)\\}",":$1");
|
||||
String name=method.getName(); String parameter=name.equals("source")?"sourceInboundId":name.equals("dispatchIds")||name.equals("lockSource")?"orderId":"dispatchId";
|
||||
var rows=named.query(sql,Map.of("customerId",args[0],parameter,args[1]),(r,n)-> switch(name) {
|
||||
case "source","lockSource" -> new Source(r.getString("source_inbound_id"),r.getLong("order_id"),r.getLong("packages"));
|
||||
case "dispatchIds" -> r.getLong(1);
|
||||
case "deliveries" -> new Delivery(r.getLong("delivery_id"),r.getLong("dispatch_id"),r.getString("group_key"),r.getLong("packages"),r.getObject("signed_packages",Long.class),r.getObject("signed_at",LocalDateTime.class),r.getObject("departed_at",LocalDateTime.class));
|
||||
case "picks" -> new Pick(r.getLong("attribution_id"),r.getLong("dispatch_id"),r.getString("group_key"),r.getLong("order_id"),r.getLong("packages"),r.getObject("receipt_id",Long.class),r.getObject("return_receipt_id",Long.class),r.getObject("return_ledger_id",Long.class),r.getObject("return_packages",Long.class));
|
||||
case "returns" -> new Returned(r.getLong("ledger_id"),r.getLong("delivery_id"),r.getLong("order_id"),r.getLong("packages"),r.getObject("received_at",LocalDateTime.class));
|
||||
default -> throw new IllegalStateException(name);
|
||||
});
|
||||
return name.equals("source")||name.equals("lockSource") ? rows.isEmpty()?null:rows.get(0) : rows;
|
||||
});
|
||||
var orders=mock(DeliveryOrderMapper.class);
|
||||
when(orders.lockByNo(anyString(),anyString())).thenAnswer(a -> delivery(Long.parseLong(a.getArgument(1)),true));
|
||||
when(orders.updateById(any(DeliveryOrder.class))).thenAnswer(a -> {
|
||||
DeliveryOrder d=a.getArgument(0);
|
||||
return db.update("UPDATE ll_delivery_order SET signed_packages=?,signed_at=?,version=?,status=?,documents_json=? WHERE id=?",d.getSignedPackages(),d.getSignedAt(),d.getVersion(),d.getStatus(),d.getDocumentsJson(),d.getId());
|
||||
});
|
||||
var tails=mock(LastlegOrderMapper.class);
|
||||
when(tails.selectList(any())).thenReturn(List.of()); // Current delivery relation deliberately unavailable.
|
||||
when(tails.selectBatchIds(anyCollection())).thenAnswer(a -> {
|
||||
Collection<?> ids=a.getArgument(0); var result=new ArrayList<LastlegOrder>();
|
||||
for(Object id:ids) result.add(tail(((Number)id).longValue())); return result;
|
||||
});
|
||||
when(tails.selectById(anyLong())).thenAnswer(a -> tail(a.getArgument(0)));
|
||||
when(tails.updateById(any(LastlegOrder.class))).thenAnswer(a -> {
|
||||
LastlegOrder t=a.getArgument(0); return db.update("UPDATE ll_order SET sub_order_status=? WHERE id=?",t.getSubOrderStatus(),t.getId());
|
||||
});
|
||||
var revisions=mock(DeliveryDocumentRevisionMapper.class);
|
||||
when(revisions.findCommand(anyString(),anyLong(),anyString())).thenAnswer(a -> {
|
||||
var rows=db.query("SELECT * FROM proof_revision WHERE delivery_id=? AND command_key=?",(r,n)-> {
|
||||
var v=new DeliveryDocumentRevision();v.setRequestHash(r.getString("request_hash"));v.setSnapshotJson(r.getString("snapshot"));return v;
|
||||
},a.getArgument(1),a.getArgument(2)); return rows.isEmpty()?null:rows.get(0);
|
||||
});
|
||||
when(revisions.insert(any(DeliveryDocumentRevision.class))).thenAnswer(a->{
|
||||
DeliveryDocumentRevision v=a.getArgument(0);if(failRevision)throw new IllegalStateException("Injected revision failure");return db.update("INSERT INTO proof_revision VALUES(?,?,?,?)",v.getDeliveryOrderId(),v.getIdempotencyKey(),v.getRequestHash(),v.getSnapshotJson());
|
||||
});
|
||||
var operations=mock(DeliveryOperationMapper.class);
|
||||
when(operations.returnReceipts(anyString(),anyLong())).thenAnswer(a -> db.query(
|
||||
"SELECT l.id,l.delta_packages,l.occurred_at FROM ll_inventory_ledger l JOIN ll_exception_order e ON e.exception_no=l.business_order_no WHERE e.delivery_order_id=?",
|
||||
(r,n)->new DeliveryReturnReceiptFact(r.getLong(1),1L,1L,r.getLong(2),r.getObject(3,LocalDateTime.class)),new Object[]{a.getArgument(1)}));
|
||||
when(operations.insert(any(DeliveryOperation.class))).thenAnswer(a->{DeliveryOperation o=a.getArgument(0);return db.update("INSERT INTO proof_operation VALUES(?,?,?)",o.getDeliveryOrderId(),o.getOperationNode(),o.getResult());});
|
||||
var outbox=mock(LastlegEventOutboxMapper.class);
|
||||
when(outbox.insert(any(LastlegEventOutbox.class))).thenAnswer(a->{LastlegEventOutbox o=a.getArgument(0);if(failOutbox)throw new IllegalStateException("Injected outbox failure");return db.update("INSERT INTO proof_outbox VALUES(?,?,?,?)",o.getEventKey(),o.getAggregateId(),o.getCustomerId(),o.getPayloadJson());});
|
||||
when(outbox.findByEventKey(anyString())).thenAnswer(a->{var rows=db.query("SELECT * FROM proof_outbox WHERE event_key=?",(r,n)->{
|
||||
var o=new LastlegEventOutbox();o.setAggregateId(r.getString("aggregate_id"));o.setCustomerId(r.getString("customer_id"));return o;},new Object[]{a.getArgument(0)});return rows.isEmpty()?null:rows.get(0);});
|
||||
var publisher=new DeliveryCompletedOutboxService(); var json=new ObjectMapper().findAndRegisterModules();
|
||||
ReflectionTestUtils.setField(publisher,"outbox",outbox);ReflectionTestUtils.setField(publisher,"objectMapper",json);
|
||||
var outbound=mock(OutboundOrderMapper.class);var shipped=new OutboundOrder();shipped.setStatus("OUTBOUNDED");
|
||||
when(outbound.findByNo(anyString(),anyString())).thenReturn(shipped);
|
||||
var files=mock(DeliveryDocumentFileGateway.class);
|
||||
when(files.metadata(anyString(),anyLong(),any(),anyLong())).thenReturn(new com.baiyue.lastleg.delivery.dto.DeliveryDocumentDtos.File(1L,"receipt.png","image/png",1,LocalDateTime.of(2026,1,1,0,0)));
|
||||
Map<String,Object> fields=Map.ofEntries(Map.entry("orders",orders),Map.entry("outboundOrders",outbound),Map.entry("revisions",revisions),Map.entry("operations",operations),Map.entry("files",files),Map.entry("objectMapper",json),Map.entry("lastlegOrders",tails),Map.entry("deliveryCompletedOutbox",publisher),Map.entry("customerVisits",mock(CustomerVisitService.class)),Map.entry("sourceEvidence",mapper),Map.entry("sourceCompletion",new SourceDeliveryCompletionQuery(mapper)));
|
||||
fields.forEach((name,value)->ReflectionTestUtils.setField(service,name,value));
|
||||
var proxyFactory=new org.springframework.aop.framework.ProxyFactory(service);proxyFactory.setProxyTargetClass(true);
|
||||
proxyFactory.addAdvice(new org.springframework.transaction.interceptor.TransactionInterceptor(manager,new org.springframework.transaction.annotation.AnnotationTransactionAttributeSource()));
|
||||
service=(DeliveryDocumentService)proxyFactory.getProxy();
|
||||
}
|
||||
DeliveryOrder delivery(long id,boolean lock) {
|
||||
return db.queryForObject("SELECT * FROM ll_delivery_order WHERE id=?"+(lock?" FOR UPDATE":""),(r,n)->{
|
||||
var d=new DeliveryOrder();d.setId(id);d.setCustomerId("C");d.setDeliveryNo(String.valueOf(id));d.setOutboundNo(r.getString("outbound_no"));d.setDispatchOrderId(r.getLong("dispatch_order_id"));d.setDeliveryGroupKey(r.getString("delivery_group_key"));d.setPackageCount(r.getInt("package_count"));d.setSignedPackages(r.getObject("signed_packages",Integer.class));d.setSignedAt(r.getObject("signed_at",LocalDateTime.class));d.setVersion(r.getLong("version"));d.setStatus(r.getString("status"));d.setDocumentsJson(r.getString("documents_json"));return d;
|
||||
},id);
|
||||
}
|
||||
LastlegOrder tail(long id) {
|
||||
return db.queryForObject("SELECT * FROM ll_order WHERE id=?",(r,n)->{var o=new LastlegOrder();o.setId(id);o.setCustomerId("C");o.setSourceRegion("FIRSTLEG");o.setSourceInboundId(r.getString("source_inbound_id"));o.setPackageCount(r.getInt("package_count"));o.setSubOrderStatus(r.getString("sub_order_status"));o.setOrderNo("LM-"+id);o.setInboundCode("IN-"+id);o.setSourceOrderId("SOURCE-"+id);o.setSourceOrderNo("FL-"+id);o.setRootOrderId("ROOT-"+id);return o;},id);
|
||||
}
|
||||
TransactionTemplate tx() { var tx=new TransactionTemplate(manager);tx.setIsolationLevel(TransactionDefinition.ISOLATION_READ_COMMITTED);return tx; }
|
||||
Save request(String key) { return new Save(0L,key,null,List.of(new Binding(DeliveryDocumentCategory.RECEIPT,List.of(1L)))); }
|
||||
void sign(long id,String key) { service.save("C",String.valueOf(id),"TEST",request(key)); }
|
||||
void source(long id,int packages) { db.update("INSERT INTO ll_order(id,customer_id,source_region,source_inbound_id,package_count) VALUES(?,'C','FIRSTLEG',?,?)",id,"S-"+id,packages); }
|
||||
void shipment(long id,int packages) {
|
||||
db.update("INSERT INTO ll_dispatch_order VALUES(?,'C','2026-01-01 01:00:00')",id);
|
||||
db.update("INSERT INTO ll_delivery_order(id,customer_id,dispatch_order_id,delivery_group_key,package_count,outbound_no) VALUES(?,'C',?,'CODE:A',?,?)",id,id,packages,"OUT-"+id);
|
||||
db.update("INSERT INTO ll_dispatch_pda_task VALUES(?,'C',?)",id,id);
|
||||
db.update("INSERT INTO ll_dispatch_item VALUES(?,'C',?,'FBA',NULL,NULL,'A',NULL,NULL)",id,id);
|
||||
db.update("INSERT INTO ll_dispatch_pda_pick VALUES(?,'C',?,?)",id,id,id);
|
||||
}
|
||||
void pick(long id,long dispatch,long order,int packages,Long receipt) {
|
||||
db.update("INSERT INTO ll_dispatch_pda_pick_order VALUES(?,'C',?,?,?, ?,?)",id,dispatch,order,packages,receipt==null?id:null,receipt);
|
||||
}
|
||||
void returned(long id,long delivery,long order,int packages,LocalDateTime at) {
|
||||
db.update("INSERT INTO ll_exception_order VALUES(?,'C',?,?,?)",id,delivery,"EX-"+id,order);
|
||||
db.update("INSERT INTO ll_inventory_ledger VALUES(?,'C',?,?,'RETURN_STOCK_IN',?,?,?)",id,"EX-"+id,"OUT-"+delivery,packages,at,"RETURN_SOURCE:7:8");
|
||||
db.update("INSERT INTO ll_return_stock_receipt VALUES(?,'C',?,?,?,?,?,7,8)",id,order,id,packages,id,"OUT-"+delivery);
|
||||
}
|
||||
List<String> published() { return db.queryForList("SELECT aggregate_id FROM proof_outbox ORDER BY aggregate_id",String.class); }
|
||||
}
|
||||
@Test void mixedDeliveryPublishesOnlyFullySignedSourceAndReplayDoesNotRepublish() {
|
||||
var f=new Fixture();f.source(101,4);f.source(102,6);f.shipment(1,10);f.pick(1,1,101,4,null);f.pick(2,1,102,6,null);
|
||||
f.returned(30,1,101,2,LocalDateTime.of(2026,1,2,0,0));f.sign(1,"same-key");
|
||||
assertThat(f.published()).containsExactly("S-102");assertThat(f.tail(101).getSubOrderStatus()).contains("WAIT_DELIVERY");
|
||||
assertThat(f.tail(102).getSubOrderStatus()).contains("DELIVERY:DELIVERED");
|
||||
f.sign(1,"same-key");assertThat(f.published()).containsExactly("S-102");
|
||||
assertThat(f.db.queryForObject("SELECT COUNT(*) FROM proof_revision",Integer.class)).isEqualTo(1);
|
||||
// Re-dispatch only the two previously unsigned boxes. Original current delivery pointers are absent.
|
||||
f.shipment(2,2);f.db.update("UPDATE ll_dispatch_order SET depart_at=? WHERE id=2",LocalDateTime.now().plusSeconds(1));
|
||||
f.pick(3,2,101,2,30L);
|
||||
// Keep deterministic chronology without sleeping or depending on wall clock granularity.
|
||||
f.db.update("UPDATE ll_dispatch_order SET depart_at='2026-01-03 00:00:00' WHERE id=2");
|
||||
f.sign(2,"redelivery");assertThat(f.published()).containsExactly("S-101","S-102");
|
||||
}
|
||||
@Test void postSignatureRedeliveryDoesNotFillNeverShippedFourBoxes() {
|
||||
var f=new Fixture();f.source(101,10);f.shipment(1,6);f.pick(1,1,101,6,null);f.sign(1,"first");
|
||||
f.db.update("UPDATE ll_delivery_order SET signed_at='2026-01-02 00:00:00' WHERE id=1");
|
||||
f.returned(30,1,101,4,LocalDateTime.of(2026,1,3,0,0));
|
||||
f.shipment(2,4);f.db.update("UPDATE ll_dispatch_order SET depart_at='2026-01-04 00:00:00' WHERE id=2");
|
||||
f.pick(2,2,101,4,30L);f.sign(2,"repeat-four");
|
||||
assertThat(f.published()).isEmpty();assertThat(f.tail(101).getSubOrderStatus()).contains("WAIT_DELIVERY");
|
||||
}
|
||||
@Test void concurrentSignaturesPublishOnlyAfterLastSourceLockOwnerSeesBoth() throws Exception {
|
||||
var f=new Fixture();f.source(101,10);f.source(102,10);f.shipment(1,10);f.shipment(2,10);
|
||||
f.pick(1,1,102,5,null);f.pick(2,1,101,5,null); // B,A input
|
||||
f.pick(3,2,101,5,null);f.pick(4,2,102,5,null); // A,B input
|
||||
var firstLocked=new CountDownLatch(1);var secondUpdated=new CountDownLatch(1);var pool=Executors.newFixedThreadPool(2);
|
||||
f.beforeFirstSourceRead=()->{if(Thread.currentThread().getName().equals("second-signer"))secondUpdated.countDown();};
|
||||
try {
|
||||
var first=pool.submit(()->f.tx().execute(s->{
|
||||
Thread.currentThread().setName("first-signer");
|
||||
f.delivery(1,true);f.mapper.lockSource("C",101);firstLocked.countDown();await(secondUpdated);
|
||||
var result=f.service.save("C","1","TEST",f.request("one"));assertThat(f.published()).isEmpty();return result;
|
||||
}));
|
||||
var second=pool.submit(()->{Thread.currentThread().setName("second-signer");await(firstLocked);return f.tx().execute(s->{
|
||||
assertThat(f.db.queryForObject("SELECT COUNT(*) FROM ll_delivery_order WHERE signed_at IS NOT NULL",Integer.class)).isZero();
|
||||
return f.service.save("C","2","TEST",f.request("two"));
|
||||
});});
|
||||
first.get(10,TimeUnit.SECONDS);second.get(10,TimeUnit.SECONDS);
|
||||
assertThat(f.published()).containsExactly("S-101","S-102");f.sign(2,"two");assertThat(f.published()).containsExactly("S-101","S-102");
|
||||
assertThat(f.sourceLocks.get("first-signer")).containsExactly(101L,101L,102L);
|
||||
assertThat(f.sourceLocks.get("second-signer")).containsExactly(101L,102L);
|
||||
} finally {pool.shutdownNow();}
|
||||
}
|
||||
@Test void springProxyAppliesReadCommittedAndFailureRollsBackSignatureSourceAndOutbox() {
|
||||
for(boolean revisionFailure:List.of(false,true)) {
|
||||
var f=new Fixture();f.source(101,4);f.shipment(1,4);f.pick(1,1,101,4,null);
|
||||
assertThat(org.springframework.aop.support.AopUtils.isAopProxy(f.service)).isTrue();
|
||||
f.beforeFirstSourceRead=()->assertThat(org.springframework.transaction.support.TransactionSynchronizationManager.getCurrentTransactionIsolationLevel())
|
||||
.isEqualTo(TransactionDefinition.ISOLATION_READ_COMMITTED);
|
||||
f.failRevision=revisionFailure;f.failOutbox=!revisionFailure;
|
||||
assertThatThrownBy(()->f.sign(1,"failure")).isInstanceOf(IllegalStateException.class).hasMessageContaining("Injected");
|
||||
assertThat(f.delivery(1,false).getSignedAt()).isNull();assertThat(f.delivery(1,false).getSignedPackages()).isNull();
|
||||
assertThat(f.delivery(1,false).getVersion()).isZero();assertThat(f.tail(101).getSubOrderStatus()).isNull();
|
||||
assertThat(f.published()).isEmpty();
|
||||
assertThat(f.db.queryForObject("SELECT COUNT(*) FROM proof_revision",Integer.class)).isZero();
|
||||
assertThat(f.db.queryForObject("SELECT COUNT(*) FROM proof_operation",Integer.class)).isZero();
|
||||
f.failRevision=false;f.failOutbox=false;f.sign(1,"failure");assertThat(f.published()).containsExactly("S-101");
|
||||
}
|
||||
}
|
||||
@Test void multiHopPreSignatureRedeliveryPublishesWhenLastUnsignedBoxIsSigned() {
|
||||
var f=new Fixture();f.source(101,4);f.shipment(1,4);f.pick(1,1,101,4,null);
|
||||
f.returned(30,1,101,2,LocalDateTime.of(2026,1,2,0,0));f.sign(1,"first");assertThat(f.published()).isEmpty();
|
||||
f.shipment(2,2);f.db.update("UPDATE ll_dispatch_order SET depart_at='2026-01-03 00:00:00' WHERE id=2");f.pick(2,2,101,2,30L);
|
||||
f.returned(40,2,101,1,LocalDateTime.of(2026,1,4,0,0));f.sign(2,"second");assertThat(f.published()).isEmpty();
|
||||
f.shipment(3,1);f.db.update("UPDATE ll_dispatch_order SET depart_at='2026-01-05 00:00:00' WHERE id=3");f.pick(3,3,101,1,40L);
|
||||
f.sign(3,"third");assertThat(f.published()).containsExactly("S-101");
|
||||
}
|
||||
@Test void unrelatedUnattributedDestinationDoesNotBlockSignedSource() {
|
||||
var f=new Fixture();f.source(101,4);f.shipment(1,4);f.pick(1,1,101,4,null);
|
||||
f.db.update("INSERT INTO ll_delivery_order(id,customer_id,dispatch_order_id,delivery_group_key,package_count,outbound_no) VALUES(2,'C',1,'CODE:B',6,'OUT-1')");
|
||||
f.sign(1,"source-a");assertThat(f.published()).containsExactly("S-101");
|
||||
assertThat(f.delivery(2,false).getSignedAt()).isNull();
|
||||
}
|
||||
private static void await(CountDownLatch latch) {
|
||||
try {if(!latch.await(6,TimeUnit.SECONDS))throw new IllegalStateException("Timed out");}
|
||||
catch(InterruptedException e){Thread.currentThread().interrupt();throw new IllegalStateException(e);}
|
||||
}
|
||||
}
|
||||
+82
@@ -0,0 +1,82 @@
|
||||
package com.baiyue.lastleg.delivery;
|
||||
|
||||
import com.baiyue.lastleg.delivery.mapper.DeliverySourceEvidenceMapper;
|
||||
import com.baiyue.lastleg.delivery.model.SourceDeliveryCompletionPolicy.*;
|
||||
import com.baiyue.lastleg.delivery.service.SourceDeliveryCompletionQuery;
|
||||
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.*;
|
||||
import org.springframework.transaction.TransactionDefinition;
|
||||
import org.springframework.transaction.support.TransactionTemplate;
|
||||
import java.lang.reflect.Proxy;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.*;
|
||||
import java.util.concurrent.*;
|
||||
import static org.assertj.core.api.Assertions.*;
|
||||
|
||||
/** Two real database transactions execute the production mapper SQL, with bounded waits. */
|
||||
class SourceDeliveryCompletionConcurrencyTest {
|
||||
@Test void lastSignerSeesFirstCommitEvenAfterAnEarlyReadWithoutLockingOtherDelivery() throws Exception {
|
||||
var ds = new DriverManagerDataSource("jdbc:h2:mem:"+UUID.randomUUID()+";MODE=MySQL;DB_CLOSE_DELAY=-1;LOCK_TIMEOUT=4000","sa","");
|
||||
var db = new JdbcTemplate(ds); var named = new NamedParameterJdbcTemplate(ds);
|
||||
db.execute("CREATE TABLE ll_order(id BIGINT PRIMARY KEY,customer_id VARCHAR(20),source_region VARCHAR(20),source_inbound_id VARCHAR(30),package_count BIGINT,UNIQUE(customer_id,source_region,source_inbound_id))");
|
||||
db.execute("CREATE TABLE ll_dispatch_order(id BIGINT,customer_id VARCHAR(20),depart_at TIMESTAMP)");
|
||||
db.execute("CREATE TABLE ll_delivery_order(id BIGINT PRIMARY KEY,customer_id VARCHAR(20),dispatch_order_id BIGINT,delivery_group_key VARCHAR(100),package_count BIGINT,signed_packages BIGINT,signed_at TIMESTAMP,outbound_no VARCHAR(30))");
|
||||
db.execute("CREATE TABLE ll_dispatch_pda_task(id BIGINT,customer_id VARCHAR(20),dispatch_order_id BIGINT)");
|
||||
db.execute("CREATE TABLE ll_dispatch_pda_pick(id BIGINT,customer_id VARCHAR(20),task_id BIGINT,dispatch_item_id BIGINT)");
|
||||
db.execute("CREATE TABLE ll_dispatch_pda_pick_order(id BIGINT,customer_id VARCHAR(20),pick_id BIGINT,order_id BIGINT,packages BIGINT,receipt_id BIGINT,return_receipt_id BIGINT)");
|
||||
db.execute("CREATE TABLE ll_dispatch_item(id BIGINT,customer_id VARCHAR(20),dispatch_order_id BIGINT,destination_type VARCHAR(20),delivery_address VARCHAR(100),postal_code VARCHAR(20),destination_warehouse_code VARCHAR(30),fba_warehouse_code VARCHAR(30),destination_warehouse_id VARCHAR(30))");
|
||||
db.execute("CREATE TABLE ll_exception_order(id BIGINT,customer_id VARCHAR(20),delivery_order_id BIGINT,exception_no VARCHAR(30),last_mile_order_id BIGINT)");
|
||||
db.execute("CREATE TABLE ll_inventory_ledger(id BIGINT,customer_id VARCHAR(20),business_order_no VARCHAR(30),outbound_order_no VARCHAR(30),business_type VARCHAR(30),delta_packages BIGINT,occurred_at TIMESTAMP,source_event_id VARCHAR(128))");
|
||||
db.execute("CREATE TABLE ll_return_stock_receipt(id BIGINT,customer_id VARCHAR(20),order_id BIGINT,inventory_ledger_id BIGINT,received_packages BIGINT,exception_order_id BIGINT,source_outbound_no VARCHAR(30),source_outbound_pallet_item_id BIGINT,source_inventory_position_id BIGINT)");
|
||||
db.update("INSERT INTO ll_order VALUES(101,'C','FIRSTLEG','S',10)");
|
||||
for (int i=1;i<=2;i++) {
|
||||
db.update("INSERT INTO ll_dispatch_order VALUES(?,'C','2026-10-03 01:00:00')",i);
|
||||
db.update("INSERT INTO ll_delivery_order VALUES(?,'C',?,'CODE:A',5,NULL,NULL,?)",i,i,"OUT-"+i);
|
||||
db.update("INSERT INTO ll_dispatch_pda_task VALUES(?,'C',?)",i,i);
|
||||
db.update("INSERT INTO ll_dispatch_item VALUES(?,'C',?,'FBA',NULL,NULL,'A',NULL,NULL)",i,i);
|
||||
db.update("INSERT INTO ll_dispatch_pda_pick VALUES(?,'C',?,?)",i,i,i);
|
||||
db.update("INSERT INTO ll_dispatch_pda_pick_order VALUES(?,'C',?,101,5,?,NULL)",i,i,i);
|
||||
}
|
||||
var mapper = (DeliverySourceEvidenceMapper)Proxy.newProxyInstance(getClass().getClassLoader(),new Class[]{DeliverySourceEvidenceMapper.class},(proxy,method,args) -> {
|
||||
String sql=String.join(" ",method.getAnnotation(Select.class).value()).replaceAll("#\\{(\\w+)\\}",":$1");
|
||||
String name=method.getName(); String parameter=name.equals("source")?"sourceInboundId":name.equals("dispatchIds")||name.equals("lockSource")?"orderId":"dispatchId";
|
||||
var rows=named.query(sql,Map.of("customerId",args[0],parameter,args[1]),(r,n)-> switch(name) {
|
||||
case "source","lockSource" -> new Source(r.getString("source_inbound_id"),r.getLong("order_id"),r.getLong("packages"));
|
||||
case "dispatchIds" -> r.getLong(1);
|
||||
case "deliveries" -> new Delivery(r.getLong("delivery_id"),r.getLong("dispatch_id"),r.getString("group_key"),r.getLong("packages"),r.getObject("signed_packages",Long.class),r.getObject("signed_at",LocalDateTime.class),r.getObject("departed_at",LocalDateTime.class));
|
||||
case "picks" -> new Pick(r.getLong("attribution_id"),r.getLong("dispatch_id"),r.getString("group_key"),r.getLong("order_id"),r.getLong("packages"),r.getObject("receipt_id",Long.class),r.getObject("return_receipt_id",Long.class),r.getObject("return_ledger_id",Long.class),r.getObject("return_packages",Long.class));
|
||||
case "returns" -> new Returned(r.getLong("ledger_id"),r.getLong("delivery_id"),r.getLong("order_id"),r.getLong("packages"),r.getObject("received_at",LocalDateTime.class));
|
||||
default -> throw new IllegalStateException(name);
|
||||
});
|
||||
return name.equals("source")||name.equals("lockSource") ? rows.isEmpty()?null:rows.get(0) : rows;
|
||||
});
|
||||
var query=new SourceDeliveryCompletionQuery(mapper);
|
||||
var manager=new DataSourceTransactionManager(ds);
|
||||
var sourceLocked=new CountDownLatch(1); var secondDeliveryLocked=new CountDownLatch(1);
|
||||
var pool=Executors.newFixedThreadPool(2);
|
||||
try {
|
||||
var first=pool.submit(()->transaction(manager).execute(status->{
|
||||
db.update("UPDATE ll_delivery_order SET signed_packages=5,signed_at='2026-10-03 03:00:00' WHERE id=1");
|
||||
mapper.lockSource("C",101); sourceLocked.countDown(); await(secondDeliveryLocked);
|
||||
return query.prove("C","S");
|
||||
}));
|
||||
var second=pool.submit(()->{ await(sourceLocked); return transaction(manager).execute(status->{
|
||||
assertThat(db.queryForObject("SELECT COUNT(*) FROM ll_delivery_order WHERE signed_at IS NOT NULL",Integer.class)).isZero();
|
||||
db.update("UPDATE ll_delivery_order SET signed_packages=5,signed_at='2026-10-03 03:01:00' WHERE id=2");
|
||||
secondDeliveryLocked.countDown(); return query.prove("C","S");
|
||||
}); });
|
||||
assertThat(first.get(8,TimeUnit.SECONDS).fullySigned()).isFalse();
|
||||
assertThat(second.get(8,TimeUnit.SECONDS).fullySigned()).isTrue();
|
||||
} finally { pool.shutdownNow(); }
|
||||
}
|
||||
private static TransactionTemplate transaction(DataSourceTransactionManager manager) {
|
||||
var tx=new TransactionTemplate(manager); tx.setIsolationLevel(TransactionDefinition.ISOLATION_READ_COMMITTED); return tx;
|
||||
}
|
||||
private static void await(CountDownLatch latch) {
|
||||
try { if(!latch.await(5,TimeUnit.SECONDS)) throw new IllegalStateException("Concurrency checkpoint timed out"); }
|
||||
catch(InterruptedException e) { Thread.currentThread().interrupt(); throw new IllegalStateException(e); }
|
||||
}
|
||||
}
|
||||
+91
@@ -0,0 +1,91 @@
|
||||
package com.baiyue.lastleg.delivery;
|
||||
|
||||
import com.baiyue.lastleg.delivery.model.SourceDeliveryCompletionPolicy;
|
||||
import com.baiyue.lastleg.delivery.model.SourceDeliveryCompletionPolicy.*;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
import static org.assertj.core.api.Assertions.*;
|
||||
|
||||
class SourceDeliveryCompletionPolicyTest {
|
||||
private final LocalDateTime departure = LocalDateTime.of(2026, 10, 3, 1, 0);
|
||||
private final LocalDateTime signature = departure.plusHours(2);
|
||||
private final List<Pick> picks = List.of(new Pick(1,10,"CODE:A",101,4,1L,null,null,null), new Pick(2,10,"CODE:A",102,6,2L,null,null,null));
|
||||
private Delivery delivery(long signed) { return new Delivery(20,10,"CODE:A",10,signed,signature,departure); }
|
||||
private Result evaluate(long order, long original, List<Delivery> deliveries, List<Pick> facts, List<Returned> returns) {
|
||||
return SourceDeliveryCompletionPolicy.evaluate(new Source("SOURCE-" + order, order, original), deliveries, facts, returns, true);
|
||||
}
|
||||
@Test void onlyReturnedSourceWaitsWhileOtherSourceIsProvenFullySigned() {
|
||||
var returns = List.of(new Returned(30,20,101,2,departure.plusHours(1)));
|
||||
assertThat(evaluate(101,4,List.of(delivery(8)),picks,returns)).satisfies(r -> {
|
||||
assertThat(r.provenSignedPackages()).isEqualTo(2); assertThat(r.fullySigned()).isFalse(); assertThat(r.unresolvedReasons()).isEmpty(); });
|
||||
assertThat(evaluate(102,6,List.of(delivery(8)),picks,returns).fullySigned()).isTrue();
|
||||
}
|
||||
@Test void postSignatureReturnDoesNotEraseHistoricSignature() {
|
||||
assertThat(evaluate(101,4,List.of(delivery(10)),picks,List.of(new Returned(30,20,101,2,signature.plusHours(1)))).fullySigned()).isTrue();
|
||||
}
|
||||
@Test void laterRedeliveryOfPreviouslyUnsignedBoxesCompletesSource() {
|
||||
var deliveries = List.of(delivery(8), new Delivery(21,11,"CODE:A",2,2L,signature.plusDays(1),signature.plusHours(3)));
|
||||
var allPicks = List.of(picks.get(0),picks.get(1),new Pick(3,11,"CODE:A",101,2,null,300L,30L,2L));
|
||||
assertThat(evaluate(101,4,deliveries,allPicks,List.of(new Returned(30,20,101,2,departure.plusHours(1)))).fullySigned()).isTrue();
|
||||
}
|
||||
@Test void totalSignatureCannotBeProportionallyAssignedWhenReturnSourceIsMissing() {
|
||||
var r = evaluate(102,6,List.of(delivery(8)),picks,List.of());
|
||||
assertThat(r.fullySigned()).isFalse(); assertThat(r.unresolvedReasons()).contains("PRE_SIGNATURE_RETURN_UNRESOLVED:20");
|
||||
}
|
||||
@Test void equalTimestampRequiresReconciliation() {
|
||||
assertThat(evaluate(102,6,List.of(delivery(8)),picks,List.of(new Returned(30,20,101,2,signature))).unresolvedReasons()).isNotEmpty();
|
||||
}
|
||||
@Test void duplicateLedgerAndIncompleteHistoryNeverProveFullSignature() {
|
||||
var returned = new Returned(30,20,101,1,departure.plusHours(1));
|
||||
assertThat(evaluate(102,6,List.of(delivery(8)),picks,List.of(returned,returned)).fullySigned()).isFalse();
|
||||
assertThat(SourceDeliveryCompletionPolicy.evaluate(new Source("S",102,6),List.of(delivery(10)),picks,List.of(),false).fullySigned()).isFalse();
|
||||
}
|
||||
@Test void ambiguousDestinationOrMissingPickCoverageBlocksCompletion() {
|
||||
assertThat(evaluate(102,6,List.of(delivery(10),new Delivery(21,10,"CODE:A",10,10L,signature,departure)),picks,List.of()).fullySigned()).isFalse();
|
||||
assertThat(evaluate(102,6,List.of(delivery(10)),List.of(picks.get(1)),List.of()).fullySigned()).isFalse();
|
||||
}
|
||||
@Test void postSignatureRedeliveryCannotDoubleCountOriginalBoxes() {
|
||||
assertThat(evaluate(101,4,List.of(delivery(10),new Delivery(21,11,"CODE:A",2,2L,signature.plusDays(1),signature.plusHours(3))),
|
||||
List.of(picks.get(0),picks.get(1),new Pick(3,11,"CODE:A",101,2,null,300L,30L,2L)),List.of()).unresolvedReasons()).contains("RETURN_PICK_ORIGIN_UNRESOLVED:3");
|
||||
}
|
||||
@Test void postSignatureReturnRedeliveryCannotFillTheNeverShippedOriginalRemainder() {
|
||||
var deliveries=List.of(new Delivery(20,10,"CODE:A",6,6L,signature,departure),
|
||||
new Delivery(21,11,"CODE:A",4,4L,signature.plusDays(1),signature.plusHours(3)));
|
||||
var facts=List.of(new Pick(1,10,"CODE:A",101,6,1L,null,null,null),
|
||||
new Pick(2,11,"CODE:A",101,4,null,300L,30L,4L));
|
||||
var result=evaluate(101,10,deliveries,facts,List.of(new Returned(30,20,101,4,signature.plusHours(1))));
|
||||
assertThat(result.provenSignedPackages()).isEqualTo(6);
|
||||
assertThat(result.fullySigned()).isFalse(); assertThat(result.unresolvedReasons()).isEmpty();
|
||||
}
|
||||
@Test void missingReceiptOriginCannotSilentlyBecomeFreshCargo() {
|
||||
var facts=List.of(new Pick(1,10,"CODE:A",101,4,null,null,null,null),picks.get(1));
|
||||
assertThat(evaluate(101,4,List.of(delivery(10)),facts,List.of()).unresolvedReasons()).contains("PICK_RECEIPT_ORIGIN_UNRESOLVED");
|
||||
}
|
||||
@Test void unrelatedDestinationMissingEvidenceDoesNotBlockProvenSource() {
|
||||
var result=evaluate(101,4,List.of(new Delivery(20,10,"CODE:A",4,4L,signature,departure),
|
||||
new Delivery(21,10,"CODE:B",6,null,null,departure)),List.of(picks.get(0)),List.of());
|
||||
assertThat(result.fullySigned()).isTrue();assertThat(result.unresolvedReasons()).isEmpty();
|
||||
}
|
||||
@Test void completeTwoHopPreSignatureReturnsContributeOnlyNewlySignedBoxes() {
|
||||
var ds=List.of(new Delivery(20,10,"CODE:A",4,2L,departure.plusHours(2),departure),
|
||||
new Delivery(21,11,"CODE:A",2,1L,departure.plusHours(5),departure.plusHours(3)),
|
||||
new Delivery(22,12,"CODE:A",1,1L,departure.plusHours(8),departure.plusHours(6)));
|
||||
var ps=List.of(picks.get(0),new Pick(3,11,"CODE:A",101,2,null,300L,30L,2L),
|
||||
new Pick(4,12,"CODE:A",101,1,null,400L,40L,1L));
|
||||
var rs=List.of(new Returned(30,20,101,2,departure.plusHours(1)),new Returned(40,21,101,1,departure.plusHours(4)));
|
||||
var result=evaluate(101,4,ds,ps,rs);
|
||||
assertThat(result.fullySigned()).isTrue();assertThat(result.provenSignedPackages()).isEqualTo(4);
|
||||
}
|
||||
@Test void repeatedReturnOfAlreadySignedCohortRemainsNonContributing() {
|
||||
var ds=List.of(new Delivery(20,10,"CODE:A",6,6L,departure.plusHours(2),departure),
|
||||
new Delivery(21,11,"CODE:A",4,2L,departure.plusHours(6),departure.plusHours(4)),
|
||||
new Delivery(22,12,"CODE:A",2,2L,departure.plusHours(9),departure.plusHours(7)));
|
||||
var ps=List.of(new Pick(1,10,"CODE:A",101,6,1L,null,null,null),new Pick(2,11,"CODE:A",101,4,null,300L,30L,4L),
|
||||
new Pick(3,12,"CODE:A",101,2,null,400L,40L,2L));
|
||||
var rs=List.of(new Returned(30,20,101,4,departure.plusHours(3)),new Returned(40,21,101,2,departure.plusHours(5)));
|
||||
var result=evaluate(101,10,ds,ps,rs);
|
||||
assertThat(result.fullySigned()).isFalse();assertThat(result.provenSignedPackages()).isEqualTo(6);
|
||||
assertThat(result.unresolvedReasons()).isEmpty();
|
||||
}
|
||||
}
|
||||
+98
@@ -0,0 +1,98 @@
|
||||
package com.baiyue.lastleg.delivery;
|
||||
|
||||
import com.baiyue.lastleg.delivery.mapper.DeliverySourceEvidenceMapper;
|
||||
import com.baiyue.lastleg.delivery.model.SourceDeliveryCompletionPolicy.*;
|
||||
import com.baiyue.lastleg.delivery.service.SourceDeliveryCompletionQuery;
|
||||
import org.apache.ibatis.exceptions.TooManyResultsException;
|
||||
import org.junit.jupiter.api.*;
|
||||
import org.springframework.aop.framework.ProxyFactory;
|
||||
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.time.LocalDateTime;
|
||||
import java.util.*;
|
||||
import static org.assertj.core.api.Assertions.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
class SourceDeliveryCompletionQueryTest {
|
||||
private DeliverySourceEvidenceMapper mapper;
|
||||
private SourceDeliveryCompletionQuery query;
|
||||
private TransactionTemplate tx;
|
||||
private final LocalDateTime departure = LocalDateTime.of(2026,10,3,1,0);
|
||||
@BeforeEach void setup() {
|
||||
mapper = mock(DeliverySourceEvidenceMapper.class);
|
||||
var manager = new DataSourceTransactionManager(new DriverManagerDataSource("jdbc:h2:mem:"+UUID.randomUUID(),"sa",""));
|
||||
tx = new TransactionTemplate(manager); tx.setIsolationLevel(org.springframework.transaction.TransactionDefinition.ISOLATION_READ_COMMITTED);
|
||||
var factory = new ProxyFactory(new SourceDeliveryCompletionQuery(mapper)); factory.setProxyTargetClass(true);
|
||||
factory.addAdvice(new TransactionInterceptor(manager,new AnnotationTransactionAttributeSource()));
|
||||
query = (SourceDeliveryCompletionQuery)factory.getProxy();
|
||||
when(mapper.source("C","S")).thenReturn(new Source("S",101,4));
|
||||
when(mapper.lockSource("C",101)).thenReturn(new Source("S",101,4));
|
||||
when(mapper.dispatchIds("C",101)).thenReturn(List.of(11L,10L));
|
||||
when(mapper.deliveries("C",10)).thenReturn(List.of(new Delivery(20,10,"CODE:A",10,8L,departure.plusHours(2),departure)));
|
||||
when(mapper.picks("C",10)).thenReturn(List.of(new Pick(1,10,"CODE:A",101,4,1L,null,null,null),new Pick(2,10,"CODE:A",102,6,2L,null,null,null)));
|
||||
when(mapper.returns("C",10)).thenReturn(List.of(new Returned(30,20,101,2,departure.plusHours(1))));
|
||||
when(mapper.deliveries("C",11)).thenReturn(List.of(new Delivery(21,11,"CODE:A",2,2L,departure.plusDays(1),departure.plusHours(3))));
|
||||
when(mapper.picks("C",11)).thenReturn(List.of(new Pick(3,11,"CODE:A",101,2,null,300L,30L,2L)));
|
||||
when(mapper.returns("C",11)).thenReturn(List.of());
|
||||
}
|
||||
@Test void requiresExistingWriteTransaction() {
|
||||
assertThatThrownBy(() -> query.prove("C","S")).isInstanceOf(IllegalTransactionStateException.class);
|
||||
tx.setReadOnly(true);
|
||||
assertThatThrownBy(() -> tx.execute(s -> query.prove("C","S"))).isInstanceOf(IllegalStateException.class);
|
||||
verifyNoInteractions(mapper);
|
||||
}
|
||||
@Test void collectsEveryDispatchAndAllCoShippedSourcesBeforeProof() {
|
||||
var result = tx.execute(s -> query.prove("C","S"));
|
||||
assertThat(result.fullySigned()).isTrue(); assertThat(result.provenSignedPackages()).isEqualTo(4);
|
||||
var order = inOrder(mapper);
|
||||
order.verify(mapper).source("C","S"); order.verify(mapper).lockSource("C",101); order.verify(mapper).dispatchIds("C",101);
|
||||
for (long id : List.of(10L,11L)) {
|
||||
order.verify(mapper).deliveries("C",id); order.verify(mapper).picks("C",id); order.verify(mapper).returns("C",id);
|
||||
}
|
||||
order.verifyNoMoreInteractions();
|
||||
}
|
||||
@Test void missingSourceNeverMarksHistoryComplete() {
|
||||
when(mapper.source("C","S")).thenReturn(null);
|
||||
var result = tx.execute(s -> query.prove("C","S"));
|
||||
assertThat(result.fullySigned()).isFalse(); assertThat(result.unresolvedReasons()).contains("HISTORY_NOT_COMPLETE");
|
||||
verify(mapper,never()).dispatchIds(anyString(),anyLong());
|
||||
}
|
||||
@Test void laterDispatchFailurePropagatesWithoutReturningEarlierPartialProof() {
|
||||
when(mapper.returns("C",11)).thenThrow(new org.springframework.dao.DataAccessResourceFailureException("offline"));
|
||||
assertThatThrownBy(() -> tx.execute(s -> query.prove("C","S"))).isInstanceOf(org.springframework.dao.DataAccessResourceFailureException.class);
|
||||
}
|
||||
@Test void missingDispatchEvidenceIsUnresolved() {
|
||||
when(mapper.picks("C",11)).thenReturn(List.of());
|
||||
var result = tx.execute(s -> query.prove("C","S"));
|
||||
assertThat(result.fullySigned()).isFalse(); assertThat(result.unresolvedReasons()).contains("HISTORY_NOT_COMPLETE");
|
||||
}
|
||||
@Test void brokenSourceUniquenessNeverSelectsOneOrSumsRows() {
|
||||
when(mapper.source("C","S")).thenThrow(new TooManyResultsException("duplicate source"));
|
||||
assertThatThrownBy(() -> tx.execute(s -> query.prove("C","S"))).isInstanceOf(TooManyResultsException.class);
|
||||
verify(mapper,never()).dispatchIds(anyString(),anyLong());
|
||||
}
|
||||
@Test void wrongDispatchReturnIsRejected() {
|
||||
when(mapper.returns("C",11)).thenReturn(List.of(new Returned(50,20,101,1,departure.plusHours(4))));
|
||||
assertThatThrownBy(() -> tx.execute(s -> query.prove("C","S"))).isInstanceOf(IllegalStateException.class);
|
||||
}
|
||||
@Test void repeatableReadAndUnspecifiedIsolationAreRejectedBeforeReadingEvidence() {
|
||||
for(int isolation : List.of(org.springframework.transaction.TransactionDefinition.ISOLATION_REPEATABLE_READ,org.springframework.transaction.TransactionDefinition.ISOLATION_DEFAULT)) {
|
||||
tx.setIsolationLevel(isolation);
|
||||
assertThatThrownBy(() -> tx.execute(s -> query.prove("C","S"))).isInstanceOf(IllegalStateException.class).hasMessageContaining("READ_COMMITTED");
|
||||
}
|
||||
verifyNoInteractions(mapper);
|
||||
}
|
||||
@Test void multipleSourcesLockNumericOrderIdsBeforeAnyHistoryRead() {
|
||||
when(mapper.source("C","Z")).thenReturn(new Source("Z",50,2));
|
||||
when(mapper.lockSource("C",50)).thenReturn(new Source("Z",50,2));
|
||||
when(mapper.dispatchIds("C",50)).thenReturn(List.of());
|
||||
tx.execute(s -> query.proveAll("C",List.of("S","Z")));
|
||||
var order=inOrder(mapper);
|
||||
order.verify(mapper).source("C","S"); order.verify(mapper).source("C","Z");
|
||||
order.verify(mapper).lockSource("C",50); order.verify(mapper).lockSource("C",101);
|
||||
order.verify(mapper).dispatchIds("C",50); order.verify(mapper).dispatchIds("C",101);
|
||||
}
|
||||
}
|
||||
+11
-7
@@ -34,7 +34,7 @@ class DeliveryCompletedOutboxServiceTest {
|
||||
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"));
|
||||
order(12, "102", "202", "IN-2", "ACY2")), proofs(), Instant.parse("2026-09-15T08:00:00Z"));
|
||||
|
||||
ArgumentCaptor<LastlegEventOutbox> rows = ArgumentCaptor.forClass(LastlegEventOutbox.class);
|
||||
verify(mapper, times(2)).insert(rows.capture());
|
||||
@@ -62,7 +62,7 @@ class DeliveryCompletedOutboxServiceTest {
|
||||
relabel.setId(21L); relabel.setOrderType("RELABEL_APPLICATION");
|
||||
relabel.setCustomerId("88"); relabel.setSourceInboundId("RL-1:WH-1");
|
||||
|
||||
service.publish(delivery, List.of(relabel), Instant.parse("2026-09-15T08:00:00Z"));
|
||||
service.publish(delivery, List.of(relabel), java.util.Map.of(), Instant.parse("2026-09-15T08:00:00Z"));
|
||||
|
||||
verifyNoInteractions(mapper);
|
||||
}
|
||||
@@ -79,7 +79,7 @@ class DeliveryCompletedOutboxServiceTest {
|
||||
transfer.setId(22L); transfer.setOrderType("WAREHOUSE_TRANSFER");
|
||||
transfer.setCustomerId("88"); transfer.setSourceInboundId("WAREHOUSE_TRANSFER:WT-1");
|
||||
|
||||
service.publish(delivery, List.of(transfer), Instant.parse("2026-09-15T08:00:00Z"));
|
||||
service.publish(delivery, List.of(transfer), java.util.Map.of(), Instant.parse("2026-09-15T08:00:00Z"));
|
||||
|
||||
verifyNoInteractions(mapper);
|
||||
}
|
||||
@@ -96,7 +96,7 @@ class DeliveryCompletedOutboxServiceTest {
|
||||
local.setId(23L); local.setOrderType("RETURN_RELABEL"); local.setSourceRegion("CUSTOMER");
|
||||
local.setCustomerId("88"); local.setSourceInboundId("RETURN-1");
|
||||
|
||||
service.publish(delivery, List.of(local), Instant.parse("2026-09-15T08:00:00Z"));
|
||||
service.publish(delivery, List.of(local), java.util.Map.of(), Instant.parse("2026-09-15T08:00:00Z"));
|
||||
|
||||
verifyNoInteractions(mapper);
|
||||
}
|
||||
@@ -108,16 +108,20 @@ class DeliveryCompletedOutboxServiceTest {
|
||||
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);
|
||||
assertThat(service.publish(delivery, sources, java.util.Map.of(), 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);
|
||||
assertThat(service.publish(delivery, sources, java.util.Map.of(), Instant.now())).isEqualTo(DeliveryCompletedOutboxService.Publication.MIXED_RETURN_REQUIRES_SOURCE_RECONCILIATION);
|
||||
verifyNoInteractions(mapper);
|
||||
}
|
||||
private java.util.Map<String,com.baiyue.lastleg.delivery.model.SourceDeliveryCompletionPolicy.Result> proofs() {
|
||||
return java.util.Map.of("101",new com.baiyue.lastleg.delivery.model.SourceDeliveryCompletionPolicy.Result("101",5,5,true,List.of()),
|
||||
"102",new com.baiyue.lastleg.delivery.model.SourceDeliveryCompletionPolicy.Result("102",5,5,true,List.of()));
|
||||
}
|
||||
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");
|
||||
value.setSourceInboundId(inboundId); value.setInboundCode(inboundCode);
|
||||
value.setSourceRegion("FIRSTLEG"); value.setPackageCount(5); value.setSourceInboundId(inboundId); value.setInboundCode(inboundCode);
|
||||
value.setSourceOrderId(orderId); value.setSourceOrderNo("FL-" + orderId);
|
||||
value.setRootOrderId(orderId); value.setFbaWarehouseCode(fba);
|
||||
return value;
|
||||
|
||||
+22
-2
@@ -33,6 +33,8 @@ class DeliveryDocumentServiceTest {
|
||||
@Mock LastlegOrderMapper lastlegOrders;
|
||||
@Mock DeliveryCompletedOutboxService deliveryCompletedOutbox;
|
||||
@Mock CustomerVisitService customerVisits;
|
||||
@Mock SourceDeliveryCompletionQuery sourceCompletion;
|
||||
@Mock DeliverySourceEvidenceMapper sourceEvidence;
|
||||
DeliveryDocumentService service;
|
||||
DeliveryOrder order;
|
||||
@BeforeEach void setUp() {
|
||||
@@ -46,6 +48,8 @@ class DeliveryDocumentServiceTest {
|
||||
ReflectionTestUtils.setField(service,"lastlegOrders",lastlegOrders);
|
||||
ReflectionTestUtils.setField(service,"deliveryCompletedOutbox",deliveryCompletedOutbox);
|
||||
ReflectionTestUtils.setField(service,"customerVisits",customerVisits);
|
||||
ReflectionTestUtils.setField(service,"sourceCompletion",sourceCompletion);
|
||||
ReflectionTestUtils.setField(service,"sourceEvidence",sourceEvidence);
|
||||
order = new DeliveryOrder(); order.setId(10L); order.setCustomerId("100");
|
||||
order.setDeliveryNo("DL-1"); order.setVersion(0L); order.setStatus("DELIVERED");
|
||||
order.setOutboundNo("OUT-1"); order.setPackageCount(10);
|
||||
@@ -142,6 +146,7 @@ class DeliveryDocumentServiceTest {
|
||||
when(orders.lockByNo("100","DL-1")).thenReturn(order);
|
||||
when(files.metadata("100",10,RECEIPT,13)).thenReturn(file(13));
|
||||
when(lastlegOrders.selectList(any())).thenReturn(List.of(related));
|
||||
when(lastlegOrders.selectById(20L)).thenReturn(related);
|
||||
|
||||
service.save("100","DL-1","9",request(0,"receipt",List.of(new Binding(RECEIPT,List.of(13L)))));
|
||||
|
||||
@@ -149,7 +154,7 @@ class DeliveryDocumentServiceTest {
|
||||
assertThat(order.getSignedAt()).isNotNull();
|
||||
assertThat(related.getSubOrderStatus()).isEqualTo("OUTBOUND:OUTBOUNDED;DELIVERY:DELIVERED");
|
||||
verify(lastlegOrders).updateById(related);
|
||||
verify(deliveryCompletedOutbox).publish(eq(order), eq(List.of(related)), any());
|
||||
verify(deliveryCompletedOutbox).publish(eq(order), eq(List.of(related)), anyMap(), any());
|
||||
verify(customerVisits).ensureForCompletedDelivery(eq(order), any());
|
||||
ArgumentCaptor<DeliveryOperation> operation = ArgumentCaptor.forClass(DeliveryOperation.class);
|
||||
verify(operations, times(2)).insert(operation.capture());
|
||||
@@ -215,6 +220,20 @@ class DeliveryDocumentServiceTest {
|
||||
verify(files,never()).download(anyString(),anyLong(),any(),anyLong());
|
||||
verifyNoInteractions(revisions,operations);
|
||||
}
|
||||
@Test void pendingSourceAuditUsesBusinessLabelAndChineseReasonWithoutInternalIdentifiers() {
|
||||
order.setStatus("DELIVERING");order.setDispatchOrderId(50L);order.setDeliveryGroupKey("CODE:A");
|
||||
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);
|
||||
var tail=new LastlegOrder();tail.setId(20L);tail.setCustomerId("100");tail.setSourceRegion("FIRSTLEG");tail.setSourceInboundId("S");
|
||||
when(sourceEvidence.picks("100",50L)).thenReturn(List.of(new SourceDeliveryCompletionPolicy.Pick(123456,50,"CODE:A",20,10,1L,null,null,null)));
|
||||
when(lastlegOrders.selectBatchIds(anyCollection())).thenReturn(List.of(tail));when(lastlegOrders.selectById(20L)).thenReturn(tail);
|
||||
when(sourceCompletion.proveAll(eq("100"),anyCollection())).thenReturn(java.util.Map.of("S",new SourceDeliveryCompletionPolicy.Result("S",10,6,false,List.of("DELIVERY_SOURCE_COVERAGE_UNRESOLVED:987654"))));
|
||||
service.save("100","DL-1","9",request(0,"audit",List.of(new Binding(RECEIPT,List.of(13L)))));
|
||||
var operationsSaved=ArgumentCaptor.forClass(DeliveryOperation.class);verify(operations,times(3)).insert(operationsSaved.capture());
|
||||
var text=operationsSaved.getAllValues().stream().filter(o->"FIRSTLEG_COMPLETION_PENDING".equals(o.getOperationNode())).findFirst().orElseThrow().getResult();
|
||||
assertThat(text).contains("—","6/10","来源证据尚未核齐").doesNotContain("987654","123456","UNRESOLVED","null");
|
||||
}
|
||||
@Test void remainingSignatureFreezesActualQuantityAndPersistsDomesticWritebackBlocker() {
|
||||
order.setStatus("RETURN_PENDING");
|
||||
when(orders.lockByNo("100", "DL-1")).thenReturn(order);
|
||||
@@ -222,7 +241,8 @@ class DeliveryDocumentServiceTest {
|
||||
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);
|
||||
var tail=new LastlegOrder(); tail.setId(20L); tail.setCustomerId("100"); tail.setSourceRegion("FIRSTLEG"); tail.setSourceInboundId("S"); tail.setInboundCode("IN-S");
|
||||
when(lastlegOrders.selectList(any())).thenReturn(List.of(tail));
|
||||
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();
|
||||
|
||||
Reference in New Issue
Block a user