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();
|
||||
|
||||
+1
-1
@@ -54,7 +54,7 @@ class SmartDispatchControllerTest {
|
||||
void preArrangeResolvesSingleCustomerAndAuthenticatedOperator() {
|
||||
DispatchRequests.PreArrange request = new DispatchRequests.PreArrange(
|
||||
List.of(10L, 11L), "APT-1", "SUCCESS", "PENDING");
|
||||
when(service.resolveCustomerScopeForPallets(List.of(10L, 11L))).thenReturn("100");
|
||||
when(service.resolveCustomerScopeForSources(List.of(10L, 11L), List.of())).thenReturn("100");
|
||||
|
||||
controller.preArrange(platform(), request);
|
||||
|
||||
|
||||
+48
@@ -32,6 +32,7 @@ class DispatchPickOrderAttributionServiceTest {
|
||||
@Mock private DispatchPdaPickOrderMapper attributions;
|
||||
@Mock private LastlegOrderMapper orders;
|
||||
@Mock private LastlegOrderStatusHistoryMapper history;
|
||||
@Mock private com.baiyue.lastleg.inventory.mapper.ReturnStockReceiptMapper returnReceipts;
|
||||
|
||||
@Test
|
||||
void completionStatusIsCalculatedBeforeMysqlMutatesShippedPackages() throws NoSuchMethodException {
|
||||
@@ -108,11 +109,58 @@ class DispatchPickOrderAttributionServiceTest {
|
||||
verify(orders,never()).increasePicked(anyString(),anyLong(),anyLong());
|
||||
}
|
||||
|
||||
@Test void returnedTwoPackagesHaveTheirOwnReceiptAttributionAndRejectExcess() {
|
||||
var receipt = new com.baiyue.lastleg.inventory.entity.ReturnStockReceipt();
|
||||
receipt.setId(201L); receipt.setOrderId(101L); receipt.setReceivedPackages(2L);
|
||||
when(returnReceipts.available("t1", 91L)).thenReturn(List.of(receipt));
|
||||
when(returnReceipts.lock("t1", 201L)).thenReturn(receipt);
|
||||
when(orders.increasePicked("t1", 101L, 2L)).thenReturn(1);
|
||||
var service = service();
|
||||
assertEquals(List.of(new DispatchPickOrderAttributionService.OrderPackages(101L, 2L)),
|
||||
service.attribute("t1", pick(81L, 91L, 2L, 0L), null, 201L));
|
||||
var capture = ArgumentCaptor.forClass(DispatchPdaPickOrder.class);
|
||||
verify(attributions).insert(capture.capture());
|
||||
assertEquals(201L, capture.getValue().getReturnReceiptId()); assertEquals(null, capture.getValue().getReceiptId());
|
||||
when(returnReceipts.picked("t1", 201L)).thenReturn(2L);
|
||||
assertThrows(org.springframework.web.server.ResponseStatusException.class,
|
||||
() -> service.attribute("t1", pick(82L, 91L, 1L, 0L), null, 201L));
|
||||
}
|
||||
|
||||
@Test void selectedReturnCannotUseFallbackOrLeavePartialAttributionWhenOnlyTwoOfThreeAreAvailable() {
|
||||
var receipt = new com.baiyue.lastleg.inventory.entity.ReturnStockReceipt();
|
||||
receipt.setId(201L); receipt.setOrderId(101L); receipt.setReceivedPackages(2L);
|
||||
when(returnReceipts.available("t1", 91L)).thenReturn(List.of(receipt));
|
||||
when(returnReceipts.lock("t1", 201L)).thenReturn(receipt);
|
||||
assertThrows(org.springframework.web.server.ResponseStatusException.class,
|
||||
() -> service().attribute("t1", pick(81L, 91L, 3L, 0L), 101L, 201L));
|
||||
verify(attributions, never()).insert(any(DispatchPdaPickOrder.class));
|
||||
verify(orders, never()).increasePicked(anyString(), anyLong(), anyLong());
|
||||
verify(attributions, never()).availableReceipts(anyString(), anyLong());
|
||||
}
|
||||
|
||||
@Test void departureLocksSourceOrdersInNumericOrderInsteadOfPickOrder() {
|
||||
var high = new DispatchPdaPickOrder(); high.setOrderId(10L); high.setPackages(1L);
|
||||
var low = new DispatchPdaPickOrder(); low.setOrderId(2L); low.setPackages(1L);
|
||||
when(attributions.listPick("t1", 81L)).thenReturn(List.of(high, low));
|
||||
for (long id : List.of(2L, 10L)) {
|
||||
var order = new LastlegOrder(); order.setId(id); order.setPackageCount(1);
|
||||
order.setPickedPackageCount(1); order.setShippedPackageCount(0);
|
||||
when(orders.lockById("t1", id)).thenReturn(order);
|
||||
when(orders.transferPickedToShipped("t1", id, 1L, "OUT1")).thenReturn(1);
|
||||
when(orders.markDispatchOutbound("t1", id)).thenReturn(1);
|
||||
}
|
||||
service().depart("t1", "OUT1", "DP1", List.of(pick(81L, 91L, 2L, 0L)), LocalDateTime.now());
|
||||
var sequence = inOrder(orders);
|
||||
sequence.verify(orders).lockById("t1", 2L);
|
||||
sequence.verify(orders).lockById("t1", 10L);
|
||||
}
|
||||
|
||||
private DispatchPickOrderAttributionService service() {
|
||||
DispatchPickOrderAttributionService service = new DispatchPickOrderAttributionService();
|
||||
ReflectionTestUtils.setField(service, "attributions", attributions);
|
||||
ReflectionTestUtils.setField(service, "orders", orders);
|
||||
ReflectionTestUtils.setField(service, "history", history);
|
||||
ReflectionTestUtils.setField(service, "returnReceipts", returnReceipts);
|
||||
return service;
|
||||
}
|
||||
|
||||
|
||||
+67
@@ -0,0 +1,67 @@
|
||||
package com.baiyue.lastleg.dispatch.pda.service;
|
||||
|
||||
import com.baiyue.lastleg.mapper.LastlegOrderMapper;
|
||||
import org.apache.ibatis.annotations.Update;
|
||||
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 org.springframework.jdbc.datasource.DataSourceTransactionManager;
|
||||
import org.springframework.transaction.support.TransactionTemplate;
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.*;
|
||||
import static org.assertj.core.api.Assertions.*;
|
||||
|
||||
class ReturnedDispatchAllowancePersistenceTest {
|
||||
@Test void realSqlAllowsOnlyTwoReturnedPackagesToDepartAgainWithoutRewritingOriginalTotals() throws Exception {
|
||||
var db = database(); var named = new NamedParameterJdbcTemplate(db.getDataSource());
|
||||
String pick = sql("increasePicked", String.class, Long.class, long.class);
|
||||
String depart = sql("transferPickedToShipped", String.class, Long.class, long.class, String.class);
|
||||
var args = Map.<String,Object>of("customerId", "A", "id", 1L, "packages", 2L, "outboundNo", "OUT-NEW");
|
||||
assertThat(named.update(pick, args)).isZero();
|
||||
db.update("INSERT INTO ll_return_stock_receipt VALUES(10,'A',1,100,2)");
|
||||
assertThat(named.update(pick, args)).isEqualTo(1);
|
||||
assertThat(named.update(pick, args)).isZero();
|
||||
assertThat(named.update(depart, args)).isEqualTo(1);
|
||||
assertThat(named.update(depart, args)).isZero();
|
||||
assertThat(db.queryForObject("SELECT package_count FROM ll_order WHERE id=1", Integer.class)).isEqualTo(2);
|
||||
assertThat(db.queryForObject("SELECT shipped_package_count FROM ll_order WHERE id=1", Integer.class)).isEqualTo(4);
|
||||
assertThat(db.queryForObject("SELECT picked_package_count FROM ll_order WHERE id=1", Integer.class)).isZero();
|
||||
assertThatThrownBy(() -> db.update("INSERT INTO ll_return_stock_receipt VALUES(11,'A',1,100,2)"))
|
||||
.isInstanceOf(org.springframework.dao.DuplicateKeyException.class);
|
||||
assertThat(named.update(pick, Map.of("customerId", "OTHER", "id", 1L, "packages", 1L))).isZero();
|
||||
}
|
||||
|
||||
@Test void concurrentRedispatchPicksCannotSpendTheSameReturnTwice() throws Exception {
|
||||
var db = database(); db.update("INSERT INTO ll_return_stock_receipt VALUES(10,'A',1,100,2)");
|
||||
var tx = new TransactionTemplate(new DataSourceTransactionManager(db.getDataSource()));
|
||||
var named = new NamedParameterJdbcTemplate(db.getDataSource());
|
||||
String pick = sql("increasePicked", String.class, Long.class, long.class);
|
||||
var start = new CountDownLatch(1); var pool = Executors.newFixedThreadPool(2);
|
||||
Callable<Integer> attempt = () -> {
|
||||
start.await();
|
||||
return tx.execute(status -> {
|
||||
db.queryForObject("SELECT id FROM ll_order WHERE customer_id='A' AND id=1 FOR UPDATE", Long.class);
|
||||
db.queryForObject("SELECT id FROM ll_return_stock_receipt WHERE customer_id='A' AND id=10 FOR UPDATE", Long.class);
|
||||
return named.update(pick, Map.of("customerId", "A", "id", 1L, "packages", 2L));
|
||||
});
|
||||
};
|
||||
try {
|
||||
var a = pool.submit(attempt); var b = pool.submit(attempt); start.countDown();
|
||||
assertThat(a.get(5, TimeUnit.SECONDS) + b.get(5, TimeUnit.SECONDS)).isEqualTo(1);
|
||||
assertThat(db.queryForObject("SELECT picked_package_count FROM ll_order WHERE id=1", Integer.class)).isEqualTo(2);
|
||||
} finally { pool.shutdownNow(); }
|
||||
}
|
||||
private JdbcTemplate database() {
|
||||
var db = new JdbcTemplate(new DriverManagerDataSource("jdbc:h2:mem:"+UUID.randomUUID()+";MODE=MySQL;DB_CLOSE_DELAY=-1;LOCK_TIMEOUT=5000", "sa", ""));
|
||||
db.execute("CREATE TABLE ll_order(id BIGINT PRIMARY KEY, customer_id VARCHAR(20), package_count INT,shipped_package_count INT,picked_package_count INT,detained INT,current_status VARCHAR(40),outbound_order_no VARCHAR(50),updated_at TIMESTAMP)");
|
||||
db.execute("CREATE TABLE ll_return_stock_receipt(id BIGINT PRIMARY KEY,customer_id VARCHAR(20),order_id BIGINT,inventory_ledger_id BIGINT,received_packages BIGINT,UNIQUE(customer_id,inventory_ledger_id))");
|
||||
db.update("INSERT INTO ll_order(id,customer_id,package_count,shipped_package_count,picked_package_count,detained,current_status) VALUES(1,'A',2,2,0,0,'COMPLETED')");
|
||||
return db;
|
||||
}
|
||||
private String sql(String name, Class<?>... args) throws Exception {
|
||||
return String.join(" ", LastlegOrderMapper.class.getMethod(name,args).getAnnotation(Update.class).value())
|
||||
.replace("#{customerId}", ":customerId").replace("#{id}", ":id").replace("#{packages}", ":packages").replace("#{outboundNo}", ":outboundNo");
|
||||
}
|
||||
}
|
||||
+54
@@ -37,6 +37,7 @@ import java.util.List;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
@@ -55,6 +56,7 @@ class DispatchServiceTest {
|
||||
@Mock DispatchBolMapper bolLines;
|
||||
@Mock DispatchPalletLockMapper locks;
|
||||
@Mock DispatchCandidateMapper candidates;
|
||||
@Mock com.baiyue.lastleg.dispatch.mapper.ReturnDispatchCandidateMapper returnCandidates;
|
||||
@Mock DispatchDraftMapper drafts;
|
||||
@Mock DispatchDraftContainerMapper draftContainers;
|
||||
@Mock DispatchPdaTaskMapper pdaTasks;
|
||||
@@ -72,6 +74,7 @@ class DispatchServiceTest {
|
||||
ReflectionTestUtils.setField(service, "bolLines", bolLines);
|
||||
ReflectionTestUtils.setField(service, "locks", locks);
|
||||
ReflectionTestUtils.setField(service, "candidates", candidates);
|
||||
ReflectionTestUtils.setField(service, "returnCandidates", returnCandidates);
|
||||
ReflectionTestUtils.setField(service, "drafts", drafts);
|
||||
ReflectionTestUtils.setField(service, "draftContainers", draftContainers);
|
||||
ReflectionTestUtils.setField(service, "pdaTasks", pdaTasks);
|
||||
@@ -497,6 +500,57 @@ class DispatchServiceTest {
|
||||
assertEquals("PLAN:11", plannedOnly.sourcePalletKey());
|
||||
}
|
||||
|
||||
@Test void returnedReceiptPreArrangementUsesItsOwnReferenceAndTheRealTargetPalletLock() throws Exception {
|
||||
var candidate = candidate(10L, 900L, "ONT8", 2);
|
||||
candidate.planId(null); candidate.planPalletId(null); candidate.returnReceiptId(701L); candidate.returnReceiptNo("RSR-701");
|
||||
when(returnCandidates.lockSelected("100", List.of(701L))).thenReturn(List.of(candidate));
|
||||
service.preArrange("100", "7", new DispatchRequests.PreArrange(List.of(), null, null, null, null, null, List.of(701L)));
|
||||
var item = ArgumentCaptor.forClass(DispatchItem.class); verify(items).insert(item.capture());
|
||||
assertEquals(null, item.getValue().getPlanPalletId()); assertEquals(701L, item.getValue().getReturnReceiptId());
|
||||
assertEquals(2, item.getValue().getPackageCount());
|
||||
var lock = ArgumentCaptor.forClass(DispatchPalletLock.class); verify(locks).insert(lock.capture());
|
||||
assertEquals("RECEIVED:900", lock.getValue().getActiveKey()); assertEquals(701L, lock.getValue().getReturnReceiptId());
|
||||
verify(candidates, never()).lockSelected(any(), any());
|
||||
var json = new com.fasterxml.jackson.databind.ObjectMapper().valueToTree(candidate);
|
||||
assertEquals("RETURN_RECEIPT", json.path("sourceType").asText());
|
||||
assertEquals("RSR-701", json.path("returnReceiptNo").asText()); assertTrue(json.path("planPalletId").isNull());
|
||||
}
|
||||
|
||||
@Test void plannedAndReturnedSourcesCreateOneDispatchWithBothReferencesAndTheirExactQuantities() {
|
||||
var planned = candidate(10L, 900L, "ONT8", 3);
|
||||
var returned = candidate(11L, 901L, "ONT8", 2);
|
||||
returned.planId(null); returned.planPalletId(null); returned.returnReceiptId(701L); returned.returnReceiptNo("RSR-701");
|
||||
when(candidates.lockSelected("100", List.of(10L))).thenReturn(List.of(planned));
|
||||
when(returnCandidates.lockSelected("100", List.of(701L))).thenReturn(List.of(returned));
|
||||
service.preArrange("100", "7", new DispatchRequests.PreArrange(List.of(10L), null, null, null, null, null, List.of(701L)));
|
||||
var dispatch = ArgumentCaptor.forClass(DispatchOrder.class); verify(orders).insert(dispatch.capture());
|
||||
assertEquals(5, dispatch.getValue().getPackageCount()); assertEquals(2, dispatch.getValue().getPalletCount());
|
||||
var inserted = ArgumentCaptor.forClass(DispatchItem.class);
|
||||
verify(items, org.mockito.Mockito.times(2)).insert(inserted.capture());
|
||||
var normalItem = inserted.getAllValues().stream().filter(row -> row.getReturnReceiptId() == null).findFirst().orElseThrow();
|
||||
var returnItem = inserted.getAllValues().stream().filter(row -> row.getReturnReceiptId() != null).findFirst().orElseThrow();
|
||||
assertEquals(10L, normalItem.getPlanPalletId()); assertEquals(3, normalItem.getPackageCount());
|
||||
assertEquals(null, returnItem.getPlanPalletId()); assertEquals(701L, returnItem.getReturnReceiptId()); assertEquals(2, returnItem.getPackageCount());
|
||||
assertEquals(normalItem.getDispatchOrderId(), returnItem.getDispatchOrderId());
|
||||
var lock = ArgumentCaptor.forClass(DispatchPalletLock.class);
|
||||
verify(locks, org.mockito.Mockito.times(2)).insert(lock.capture());
|
||||
assertEquals(java.util.Set.of("RECEIVED:900", "RECEIVED:901"), lock.getAllValues().stream()
|
||||
.map(DispatchPalletLock::getActiveKey).collect(java.util.stream.Collectors.toSet()));
|
||||
}
|
||||
|
||||
@Test void mixedSourcesOnTheSamePhysicalPalletAreRejectedBeforeAnyDispatchOrLockWrite() {
|
||||
var planned = candidate(10L, 900L, "ONT8", 3);
|
||||
var returned = candidate(11L, 900L, "ONT8", 2);
|
||||
returned.planId(null); returned.planPalletId(null); returned.returnReceiptId(701L); returned.returnReceiptNo("RSR-701");
|
||||
when(candidates.lockSelected("100", List.of(10L))).thenReturn(List.of(planned));
|
||||
when(returnCandidates.lockSelected("100", List.of(701L))).thenReturn(List.of(returned));
|
||||
var failure = assertThrows(ResponseStatusException.class, () -> service.preArrange("100", "7",
|
||||
new DispatchRequests.PreArrange(List.of(10L), null, null, null, null, null, List.of(701L))));
|
||||
assertEquals(409, failure.getStatusCode().value()); assertTrue(failure.getReason().contains("同一实物托盘"));
|
||||
verify(orders, never()).insert(any(DispatchOrder.class)); verify(items, never()).insert(any(DispatchItem.class));
|
||||
verify(locks, never()).insert(any(DispatchPalletLock.class)); verify(pdaTasks, never()).insert(any(DispatchPdaTask.class));
|
||||
}
|
||||
|
||||
private DispatchPalletCandidate candidate(long planPalletId, Long warehousePalletId, String destination) {
|
||||
return candidate(planPalletId, warehousePalletId, destination, 12);
|
||||
}
|
||||
|
||||
+55
@@ -0,0 +1,55 @@
|
||||
package com.baiyue.lastleg.dispatch.service;
|
||||
|
||||
import com.baomidou.mybatisplus.core.MybatisConfiguration;
|
||||
import com.baomidou.mybatisplus.extension.spring.MybatisSqlSessionFactoryBean;
|
||||
import com.baiyue.lastleg.dispatch.mapper.ReturnDispatchCandidateMapper;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.mybatis.spring.SqlSessionTemplate;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.jdbc.datasource.DriverManagerDataSource;
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
import static org.assertj.core.api.Assertions.*;
|
||||
|
||||
class ReturnDispatchCandidatePersistenceTest {
|
||||
@Test void actualReturnOnANewPalletCanBeSelectedWithoutAnInboundPlanPallet() throws Exception {
|
||||
var data = new DriverManagerDataSource("jdbc:h2:mem:"+UUID.randomUUID()+";MODE=MySQL;DB_CLOSE_DELAY=-1", "sa", "");
|
||||
var db = new JdbcTemplate(data);
|
||||
db.execute("CREATE TABLE ll_return_stock_receipt(id BIGINT,receipt_no VARCHAR(50),customer_id VARCHAR(20),order_id BIGINT,inventory_position_id BIGINT,inventory_balance_id BIGINT,exception_order_id BIGINT,received_packages BIGINT,received_at TIMESTAMP)");
|
||||
db.execute("CREATE TABLE ll_order(id BIGINT,customer_id VARCHAR(20),customer_name VARCHAR(50),order_type VARCHAR(50),order_no VARCHAR(50),detained INT)");
|
||||
db.execute("CREATE TABLE ll_inventory_position(id BIGINT,customer_id VARCHAR(20),pallet_id BIGINT,pallet_no VARCHAR(50),overseas_warehouse_id VARCHAR(50),available_packages BIGINT)");
|
||||
db.execute("CREATE TABLE ll_inventory_balance(id BIGINT,customer_id VARCHAR(20),inbound_code VARCHAR(50),overseas_warehouse_name VARCHAR(50),overseas_warehouse_code VARCHAR(50))");
|
||||
db.execute("CREATE TABLE ll_exception_order(id BIGINT,customer_id VARCHAR(20),delivery_order_id BIGINT)");
|
||||
db.execute("CREATE TABLE ll_delivery_order(id BIGINT,customer_id VARCHAR(20),destination_type VARCHAR(20),destination_warehouse_code VARCHAR(50),destination_warehouse_name VARCHAR(50),delivery_address VARCHAR(100),postal_code VARCHAR(50),contact_name VARCHAR(50),contact_phone VARCHAR(50))");
|
||||
db.execute("CREATE TABLE ll_dispatch_pda_pick_order(customer_id VARCHAR(20),return_receipt_id BIGINT,packages BIGINT)");
|
||||
db.execute("CREATE TABLE ll_dispatch_pallet_lock(customer_id VARCHAR(20),source_pallet_key VARCHAR(50),status VARCHAR(20),expires_at TIMESTAMP,draft_token VARCHAR(50))");
|
||||
db.update("INSERT INTO ll_order VALUES(1,'A','客户A','FIRST_LEG','LM-1',0)");
|
||||
db.update("INSERT INTO ll_inventory_position VALUES(2,'A',900,'RETURN-PALLET','92',2)");
|
||||
db.update("INSERT INTO ll_inventory_balance VALUES(3,'A','IN-1','海外仓','WH')");
|
||||
db.update("INSERT INTO ll_exception_order VALUES(4,'A',5)");
|
||||
db.update("INSERT INTO ll_delivery_order VALUES(5,'A','FBA','ONT8','ONT8',NULL,NULL,NULL,NULL)");
|
||||
db.update("INSERT INTO ll_return_stock_receipt VALUES(6,'RSR-6','A',1,2,3,4,2,CURRENT_TIMESTAMP)");
|
||||
var config = new MybatisConfiguration(); config.addMapper(ReturnDispatchCandidateMapper.class);
|
||||
var factory = new MybatisSqlSessionFactoryBean(); factory.setDataSource(data); factory.setConfiguration(config);
|
||||
var mapper = new SqlSessionTemplate(factory.getObject()).getMapper(ReturnDispatchCandidateMapper.class);
|
||||
var rows = mapper.available("A", "RSR-6", "ONT8", null);
|
||||
assertThat(rows).hasSize(1); var row = rows.get(0);
|
||||
assertThat(row.packageCount()).isEqualTo(2); assertThat(row.planPalletId()).isNull();
|
||||
assertThat(row.sourceType()).isEqualTo("RETURN_RECEIPT"); assertThat(row.returnReceiptNo()).isEqualTo("RSR-6");
|
||||
assertThat(row.warehousePalletId()).isEqualTo(900); assertThat(row.grossWeightKg()).isNull();
|
||||
assertThat(mapper.lockSelected("A", List.of(6L))).hasSize(1);
|
||||
assertThat(mapper.lockSelected("B", List.of(6L))).isEmpty();
|
||||
db.update("INSERT INTO ll_dispatch_pallet_lock VALUES('A','RECEIVED:900','ACTIVE',NULL,'another-draft')");
|
||||
assertThat(mapper.available("A", null, null, "this-draft")).isEmpty();
|
||||
db.update("DELETE FROM ll_dispatch_pallet_lock");
|
||||
db.update("INSERT INTO ll_dispatch_pda_pick_order VALUES('A',6,1)");
|
||||
// Even if the position also has unrelated available stock, this receipt contributes only its unspent one box.
|
||||
db.update("UPDATE ll_inventory_position SET available_packages=8 WHERE id=2");
|
||||
var partial = mapper.available("A", null, null, null);
|
||||
assertThat(partial).hasSize(1); assertThat(partial.get(0).packageCount()).isEqualTo(1);
|
||||
assertThat(mapper.lockSelected("A", List.of(6L)).get(0).packageCount()).isEqualTo(1);
|
||||
db.update("INSERT INTO ll_dispatch_pda_pick_order VALUES('A',6,1)");
|
||||
assertThat(mapper.available("A", null, null, null)).isEmpty();
|
||||
assertThat(mapper.lockSelected("A", List.of(6L))).isEmpty();
|
||||
}
|
||||
}
|
||||
+11
@@ -51,6 +51,7 @@ class InventoryCommandServiceTest {
|
||||
@Mock InventoryLedgerMapper ledgers;
|
||||
@Mock InventoryOperationMapper operations;
|
||||
@Mock InventoryCommandMapper commands;
|
||||
@Mock com.baiyue.lastleg.inventory.mapper.ReturnStockReceiptMapper returnReceipts;
|
||||
@Mock WarehouseResourceGateway warehouseGateway;
|
||||
|
||||
private InventoryCommandService service;
|
||||
@@ -63,6 +64,10 @@ class InventoryCommandServiceTest {
|
||||
ReflectionTestUtils.setField(service, "ledgers", ledgers);
|
||||
ReflectionTestUtils.setField(service, "operations", operations);
|
||||
ReflectionTestUtils.setField(service, "commands", commands);
|
||||
ReflectionTestUtils.setField(service, "returnReceipts", returnReceipts);
|
||||
lenient().when(returnReceipts.owner(any(), any(), any(), any(), any())).thenReturn(
|
||||
new com.baiyue.lastleg.inventory.mapper.ReturnStockReceiptMapper.ReturnOwner(1L, 51L, 101L));
|
||||
lenient().when(returnReceipts.lockOrder(any(), any())).thenAnswer(call -> call.getArgument(1));
|
||||
ReflectionTestUtils.setField(service, "warehouseGateway", warehouseGateway);
|
||||
ReflectionTestUtils.setField(service, "objectMapper", new ObjectMapper().findAndRegisterModules());
|
||||
lenient().when(operations.findIdempotency(any(), any())).thenReturn(null);
|
||||
@@ -385,6 +390,12 @@ class InventoryCommandServiceTest {
|
||||
assertEquals("RETURN_STOCK_IN", ledger.getValue().getBusinessType());
|
||||
assertEquals("RETURN_SOURCE:301:201", ledger.getValue().getSourceEventId());
|
||||
assertEquals("OUT-1", ledger.getValue().getOutboundOrderNo());
|
||||
ArgumentCaptor<com.baiyue.lastleg.inventory.entity.ReturnStockReceipt> returned = ArgumentCaptor.forClass(com.baiyue.lastleg.inventory.entity.ReturnStockReceipt.class);
|
||||
verify(returnReceipts).insert(returned.capture());
|
||||
assertEquals(ledger.getValue().getId(), returned.getValue().getInventoryLedgerId());
|
||||
assertEquals(51L, returned.getValue().getOrderId());
|
||||
assertEquals(3L, returned.getValue().getReceivedPackages());
|
||||
assertEquals(target.getValue().getId(), returned.getValue().getInventoryPositionId());
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
+5
-2
@@ -58,7 +58,8 @@ class InventoryHoldConcurrencyPersistenceTest {
|
||||
jdbc = new JdbcTemplate(ds);
|
||||
for (Class<?> entity : List.of(InventoryBalance.class, InventoryPosition.class, InventoryLedger.class,
|
||||
InventoryOperation.class, LastlegOrder.class, LastlegOrderCommandRecord.class,
|
||||
LastlegOrderHoldHistory.class, LastlegOrderOperationLog.class, InboundStockReceipt.class, DispatchPdaPickOrder.class)) {
|
||||
LastlegOrderHoldHistory.class, LastlegOrderOperationLog.class, InboundStockReceipt.class,
|
||||
ReturnStockReceipt.class, DispatchPdaPickOrder.class)) {
|
||||
String columns = Arrays.stream(entity.getDeclaredFields())
|
||||
.filter(f -> !Modifier.isStatic(f.getModifiers()))
|
||||
.filter(f -> f.getAnnotation(TableField.class) == null || f.getAnnotation(TableField.class).exist())
|
||||
@@ -74,7 +75,8 @@ class InventoryHoldConcurrencyPersistenceTest {
|
||||
for (Class<?> mapper : List.of(InventoryBalanceMapper.class, InventoryPositionMapper.class,
|
||||
InventoryLedgerMapper.class, InventoryOperationMapper.class, InventoryCommandMapper.class,
|
||||
LastlegOrderMapper.class, LastlegOrderCommandRecordMapper.class, LastlegOrderHoldHistoryMapper.class,
|
||||
LastlegOrderOperationLogMapper.class, DispatchPdaPickOrderMapper.class)) config.addMapper(mapper);
|
||||
LastlegOrderOperationLogMapper.class, DispatchPdaPickOrderMapper.class,
|
||||
ReturnStockReceiptMapper.class)) config.addMapper(mapper);
|
||||
var factory = new MybatisSqlSessionFactoryBean(); factory.setDataSource(ds); factory.setConfiguration(config);
|
||||
var session = new SqlSessionTemplate(factory.getObject());
|
||||
var manager = new DataSourceTransactionManager(ds); tx = new TransactionTemplate(manager);
|
||||
@@ -91,6 +93,7 @@ class InventoryHoldConcurrencyPersistenceTest {
|
||||
var sourceLocks = new DispatchPickOrderAttributionService();
|
||||
ReflectionTestUtils.setField(sourceLocks,"orders",orderMapper);
|
||||
ReflectionTestUtils.setField(sourceLocks,"attributions",session.getMapper(DispatchPdaPickOrderMapper.class));
|
||||
ReflectionTestUtils.setField(sourceLocks,"returnReceipts",session.getMapper(ReturnStockReceiptMapper.class));
|
||||
attribution = proxy(sourceLocks,manager);
|
||||
var target = new LastlegOrderCommandService();
|
||||
ReflectionTestUtils.setField(target,"orders",orderMapper);
|
||||
|
||||
Reference in New Issue
Block a user