feat(lastleg): redispatch returned cargo and reconcile source signatures

This commit is contained in:
2026-10-03 16:06:54 +08:00
parent e740047e03
commit 8700fc3423
39 changed files with 1605 additions and 139 deletions
@@ -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);
@@ -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;
}
@@ -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");
}
}
@@ -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);
}
@@ -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();
}
}