优化
This commit is contained in:
+30
@@ -0,0 +1,30 @@
|
||||
package com.baiyue.lastleg.delivery;
|
||||
|
||||
import com.baiyue.lastleg.delivery.model.DeliveryCargoGroups;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import java.util.List;
|
||||
import static com.baiyue.lastleg.delivery.DeliveryDispatchFixtures.*;
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
class DeliveryCargoGroupsTest {
|
||||
private final DeliveryCargoGroups policy=new DeliveryCargoGroups(new ObjectMapper().findAndRegisterModules());
|
||||
/** 同目的仓不同客户分开,同托多来源保留且物理托盘仅计一次。 */
|
||||
@Test void groupsByCustomerAndDestinationWithoutLosingMixedPalletSources() {
|
||||
var a=cargo(1,"101","ONT8",51,2);var b=cargo(2,"101","ONT8",51,3);
|
||||
var c=cargo(3,"101","LAX9",52,4);var d=cargo(4,"102","ONT8",53,5);
|
||||
var groups=policy.groups(context(List.of(d,c,b,a),false));
|
||||
assertEquals(3,groups.size());
|
||||
var mixed=groups.stream().filter(g->g.customerId().equals("101")&&g.destination().warehouseCode().equals("ONT8")).findFirst().orElseThrow();
|
||||
assertEquals(5,mixed.packages());assertEquals(1,mixed.palletCount());assertEquals(2,mixed.cargo().size());
|
||||
assertEquals(mixed.cargoHash(),policy.groups(context(List.of(a,b,c,d),false)).stream().filter(g->g.key().equals(mixed.key())).findFirst().orElseThrow().cargoHash());
|
||||
assertNotEquals(policy.groupKey("101",a.destination()),policy.groupKey("102",a.destination()));
|
||||
}
|
||||
/** 空车和重复归属不能靠总箱数相等冒充完整真实来源。 */
|
||||
@Test void emptyOrDuplicatedAttributionCannotBecomeArrangement() {
|
||||
assertThrows(org.springframework.web.server.ResponseStatusException.class,()->policy.groups(context(List.of(),false)));
|
||||
var row=cargo(1,"101","ONT8",51,2);
|
||||
assertThrows(org.springframework.web.server.ResponseStatusException.class,()->policy.groups(context(List.of(row,row),false)));
|
||||
assertThrows(org.springframework.web.server.ResponseStatusException.class,()->policy.groups(context(List.of(row,cargo(2,"101","LAX9",51,3)),false)));
|
||||
}
|
||||
}
|
||||
+28
@@ -0,0 +1,28 @@
|
||||
package com.baiyue.lastleg.delivery;
|
||||
|
||||
import com.baiyue.lastleg.dispatch.port.DispatchContracts.*;
|
||||
import com.baiyue.lastleg.dispatch.port.DispatchTypes.*;
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
|
||||
/** 本任务测试独占fixture,所有业务号及客户均为离线合成值。 */
|
||||
public final class DeliveryDispatchFixtures {
|
||||
public static final Instant DEPARTED=Instant.parse("2026-10-19T01:00:00Z");
|
||||
/** 工具类不允许实例化。 */
|
||||
private DeliveryDispatchFixtures() { }
|
||||
/** 构造一条真实拣货归属,允许同实物托盘有多个来源。 */
|
||||
public static CargoLine cargo(long id,String customer,String destination,long pallet,long packages) {
|
||||
return new CargoLine(id,customer,"客户"+customer,id,"LM-"+id,"SOURCE-"+id,"RECEIVED","LOCAL",null,"IN-"+id,
|
||||
null,pallet,"PALLET-"+pallet,id,id,id,id,null,null,null,null,
|
||||
new Destination("FBA:"+destination,"FBA",null,destination,destination,null,null,null,null),
|
||||
packages,0,null,null,List.of());
|
||||
}
|
||||
/** 构造同仓完整拣货的整车上下文,发车后只改变业务计数。 */
|
||||
public static Context context(List<CargoLine> cargo,boolean departed) {
|
||||
long count=cargo.stream().mapToLong(CargoLine::packages).sum();
|
||||
return new Context(10L,"DP-10",20L,"TRIP-20","1001","WH-1","测试仓",Status.ACTIVE,
|
||||
departed?Stage.DELIVERING:Stage.AFTER_LOADING,3,1,"cargo-hash","loading-hash",DEPARTED.minusSeconds(60),
|
||||
departed?DEPARTED:null,null,new Counts(cargo.size(),cargo.stream().map(CargoLine::warehousePalletId).distinct().count(),count,
|
||||
departed?0:count,departed?count:0),null,true,cargo);
|
||||
}
|
||||
}
|
||||
+100
@@ -0,0 +1,100 @@
|
||||
package com.baiyue.lastleg.delivery;
|
||||
|
||||
import com.baomidou.mybatisplus.core.MybatisConfiguration;
|
||||
import com.baomidou.mybatisplus.extension.spring.MybatisSqlSessionFactoryBean;
|
||||
import com.baiyue.lastleg.delivery.mapper.DeliveryFactsChangedMapper;
|
||||
import com.baiyue.lastleg.delivery.mapper.DeliveryOrderMapper;
|
||||
import com.baiyue.lastleg.delivery.repository.DeliveryFactsChangedRepository;
|
||||
import com.baiyue.lastleg.delivery.service.DeliveryFactsChangedService;
|
||||
import com.baiyue.lastleg.dispatch.port.DispatchDeliveryModels.Signal;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.mybatis.spring.SqlSessionTemplate;
|
||||
import org.springframework.aop.framework.ProxyFactory;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.jdbc.datasource.DataSourceTransactionManager;
|
||||
import org.springframework.jdbc.datasource.DriverManagerDataSource;
|
||||
import org.springframework.transaction.annotation.AnnotationTransactionAttributeSource;
|
||||
import org.springframework.transaction.interceptor.TransactionInterceptor;
|
||||
import org.springframework.transaction.support.TransactionTemplate;
|
||||
import java.time.Instant;
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.ZoneId;
|
||||
import java.util.UUID;
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
/** 执行真实生产Mapper与事务,验证信号不能早确认或在回滚后遗留。 */
|
||||
class DeliveryFactsChangedPersistenceTest {
|
||||
private JdbcTemplate db;
|
||||
private TransactionTemplate tx;
|
||||
private DeliveryFactsChangedService service;
|
||||
private final Instant now=Instant.parse("2026-10-19T01:00:00Z");
|
||||
|
||||
/** 每例建立隔离数据库,SQL写入仅存在于测试fixture。 */
|
||||
@BeforeEach void prepare() throws Exception {
|
||||
var ds=new DriverManagerDataSource("jdbc:h2:mem:signals-"+UUID.randomUUID()+";MODE=MySQL;DB_CLOSE_DELAY=-1","sa","");
|
||||
db=new JdbcTemplate(ds);
|
||||
db.execute("""
|
||||
CREATE TABLE ll_delivery_facts_changed(id BIGINT PRIMARY KEY,customer_id VARCHAR(30),dispatch_id BIGINT,
|
||||
dispatch_no VARCHAR(50),delivery_id BIGINT,delivery_no VARCHAR(50),delivery_version BIGINT,
|
||||
facts_hash VARCHAR(64),occurred_at TIMESTAMP,state VARCHAR(20),attempt_count INT,next_attempt_at TIMESTAMP,
|
||||
last_error_code VARCHAR(64),processed_at TIMESTAMP,UNIQUE(customer_id,delivery_id,delivery_version))
|
||||
""");
|
||||
db.execute("""
|
||||
CREATE TABLE ll_delivery_order(id BIGINT PRIMARY KEY,customer_id VARCHAR(30),dispatch_order_id BIGINT,
|
||||
dispatch_no VARCHAR(50),delivery_no VARCHAR(50),version BIGINT,signed_at TIMESTAMP,signed_packages INT)
|
||||
""");
|
||||
db.update("INSERT INTO ll_delivery_order VALUES(1,'101',10,'DP-A','DL-A',1,?,5)",local(now));
|
||||
var config=new MybatisConfiguration(); config.setMapUnderscoreToCamelCase(true);
|
||||
config.addMapper(DeliveryFactsChangedMapper.class); config.addMapper(DeliveryOrderMapper.class);
|
||||
var factory=new MybatisSqlSessionFactoryBean(); factory.setDataSource(ds); factory.setConfiguration(config);
|
||||
var session=new SqlSessionTemplate(factory.getObject());
|
||||
var repository=new DeliveryFactsChangedRepository(session.getMapper(DeliveryFactsChangedMapper.class),session.getMapper(DeliveryOrderMapper.class));
|
||||
var manager=new DataSourceTransactionManager(ds); tx=new TransactionTemplate(manager);
|
||||
tx.setIsolationLevel(org.springframework.transaction.TransactionDefinition.ISOLATION_READ_COMMITTED);
|
||||
var proxy=new ProxyFactory(new DeliveryFactsChangedService(repository,new ObjectMapper().findAndRegisterModules()));
|
||||
proxy.setProxyTargetClass(true); proxy.addAdvice(new TransactionInterceptor(manager,new AnnotationTransactionAttributeSource()));
|
||||
service=(DeliveryFactsChangedService)proxy.getProxy();
|
||||
}
|
||||
|
||||
/** 原签收事务失败则不存在候选,原请求重放只有一条事实。 */
|
||||
@Test void enqueueRollsBackAndDeduplicatesWithRealMapper() {
|
||||
assertThrows(IllegalStateException.class,()->tx.execute(s->{service.enqueue("DP-A","DL-A",1,now);throw new IllegalStateException("rollback");}));
|
||||
assertTrue(service.due(now,20).isEmpty());
|
||||
tx.executeWithoutResult(s->{service.enqueue("DP-A","DL-A",1,now);service.enqueue("DP-A","DL-A",1,now);});
|
||||
assertEquals(1,service.due(now,20).size());
|
||||
assertThrows(IllegalStateException.class,()->tx.executeWithoutResult(s->service.enqueue("DP-A","DL-A",2,now)));
|
||||
db.update("UPDATE ll_delivery_order SET signed_packages=4 WHERE id=1");
|
||||
assertThrows(IllegalStateException.class,()->tx.executeWithoutResult(s->service.enqueue("DP-A","DL-A",1,now)));
|
||||
}
|
||||
|
||||
/** 阶段写入与信号ack任一失败都回滚,失败记录保留可重试状态。 */
|
||||
@Test void acknowledgmentRollsBackAndRetryNeverReopensProcessedFact() {
|
||||
tx.executeWithoutResult(s->service.enqueue("DP-A","DL-A",1,now));
|
||||
Signal signal=service.due(now,20).get(0);
|
||||
assertThrows(IllegalStateException.class,()->tx.execute(s->{service.acknowledge(signal,now);throw new IllegalStateException("CAS failed");}));
|
||||
assertEquals(1,service.due(now,20).size());
|
||||
service.failed(signal,now.plusSeconds(30),"DISPATCH_RECONCILE_FAILED");
|
||||
assertTrue(service.due(now,20).isEmpty());
|
||||
assertEquals(1,service.due(now.plusSeconds(30),20).get(0).attempts());
|
||||
tx.executeWithoutResult(s->service.acknowledge(signal,now.plusSeconds(30)));
|
||||
service.failed(signal,now.plusSeconds(60),"DISPATCH_RECONCILE_FAILED");
|
||||
assertTrue(service.due(now.plusSeconds(60),20).isEmpty());
|
||||
assertEquals("PROCESSED",db.queryForObject("SELECT state FROM ll_delivery_facts_changed",String.class));
|
||||
}
|
||||
|
||||
/** 内部ID相同而业务身份不同也不能确认或记录别人的信号。 */
|
||||
@Test void mismatchedIdentityAndMissingTransactionAreRejected() {
|
||||
tx.executeWithoutResult(s->service.enqueue("DP-A","DL-A",1,now));
|
||||
var original=service.due(now,20).get(0);
|
||||
var other=new Signal(original.signalId(),"DP-B","DL-A",1,0,now);
|
||||
assertThrows(IllegalStateException.class,()->tx.executeWithoutResult(s->service.acknowledge(other,now)));
|
||||
assertThrows(org.springframework.transaction.IllegalTransactionStateException.class,()->service.acknowledge(original,now));
|
||||
assertThrows(org.springframework.transaction.IllegalTransactionStateException.class,()->service.enqueue("DP-A","DL-A",1,now));
|
||||
assertEquals(1,service.due(now,20).size());
|
||||
}
|
||||
|
||||
/** 与生产现有LocalDateTime字段保持服务端时区语义。 */
|
||||
private static LocalDateTime local(Instant value) { return LocalDateTime.ofInstant(value,ZoneId.systemDefault()); }
|
||||
}
|
||||
-112
@@ -1,112 +0,0 @@
|
||||
package com.baiyue.lastleg.delivery;
|
||||
|
||||
import com.baiyue.common.core.api.ApiResponse;
|
||||
import com.baiyue.common.security.AuthenticatedUser;
|
||||
import com.baiyue.lastleg.client.FinanceFeeCoverageClient;
|
||||
import com.baiyue.lastleg.delivery.controller.PlatformDeliveryFeeCoverageController;
|
||||
import com.baiyue.lastleg.delivery.dto.DeliveryFeeCoverageModels;
|
||||
import com.baiyue.lastleg.delivery.dto.DeliveryRequests;
|
||||
import com.baiyue.lastleg.delivery.mapper.DispatchFeeCargoMapper;
|
||||
import com.baiyue.lastleg.delivery.service.DeliveryFeeCoverageService;
|
||||
import com.baiyue.lastleg.dispatch.entity.DispatchItem;
|
||||
import com.baiyue.lastleg.dispatch.entity.DispatchOrder;
|
||||
import com.baiyue.lastleg.dispatch.mapper.DispatchItemMapper;
|
||||
import com.baiyue.lastleg.dispatch.mapper.DispatchOrderMapper;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.security.access.prepost.PreAuthorize;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
import java.math.BigDecimal;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
import static org.assertj.core.api.Assertions.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
class DeliveryFeeCoverageServiceTest {
|
||||
/** 同一排车明细中的外接货覆盖不能免掉普通来源的应收。 */
|
||||
@Test void externalFreightCoversOnlyItsOwnInboundEvenWhenAnItemIsShared() {
|
||||
when(finance.sources(anyString(), any())).thenReturn(ApiResponse.success(List.of(fee(101, 11))));
|
||||
var preview = service.preview("DP-1");
|
||||
var selection = new DeliveryFeeCoverageModels.Selection(711L, 1, fee(101, 11).sourceHash(), List.of("LOADING_FEE"));
|
||||
var result = service.confirm("DP-1", coverageRequest(preview.cargoHash(), List.of(selection)), "OP", Set.of("IN-A"));
|
||||
assertThat(result.includedServices()).doesNotContain("DELIVERY_FEE");
|
||||
assertThat(result.uncovered("DELIVERY_FEE")).extracting(DeliveryFeeCoverageModels.Cargo::inboundCode).containsExactly("IN-B");
|
||||
}
|
||||
final DispatchOrderMapper dispatches = mock(DispatchOrderMapper.class);
|
||||
final DispatchItemMapper items = mock(DispatchItemMapper.class);
|
||||
final DispatchFeeCargoMapper cargo = mock(DispatchFeeCargoMapper.class);
|
||||
final FinanceFeeCoverageClient finance = mock(FinanceFeeCoverageClient.class);
|
||||
final DeliveryFeeCoverageService service = new DeliveryFeeCoverageService(dispatches, items, cargo, finance,
|
||||
new ObjectMapper().findAndRegisterModules(), "coverage-test-token");
|
||||
@BeforeEach void setup() {
|
||||
var dispatch = new DispatchOrder(); dispatch.setId(1L); dispatch.setCustomerId("101"); dispatch.setDispatchNo("DP-1");
|
||||
when(dispatches.findGlobalByDispatchNo("DP-1")).thenReturn(dispatch);
|
||||
var selected = new DispatchItem(); selected.setId(201L); selected.setPackageCount(20);
|
||||
when(items.findByDispatchOrder("101", 1L)).thenReturn(List.of(selected));
|
||||
when(cargo.list("101", 1)).thenReturn(List.of(source("11", "IN-A", 10), source("12", "IN-B", 10)));
|
||||
}
|
||||
@Test void batchesBothActualInboundSourcesInsteadOfUsingOneDisplayCode() {
|
||||
var query = new FinanceFeeCoverageClient.Query(101L, List.of(11L, 12L));
|
||||
when(finance.sources("coverage-test-token", query)).thenReturn(ApiResponse.success(List.of(fee(101, 11))));
|
||||
var result = service.preview("DP-1");
|
||||
assertThat(result.sourceComplete()).isTrue(); assertThat(result.cargo()).hasSize(2); assertThat(result.feeSources()).hasSize(1);
|
||||
assertThat(result.cargoHash()).hasSize(64);
|
||||
verify(finance).sources("coverage-test-token", query); verifyNoMoreInteractions(finance);
|
||||
}
|
||||
@Test void incompletePhysicalSourcesNeverProduceAFalseCoverageOption() {
|
||||
when(cargo.list("101", 1)).thenReturn(List.of(source("11", "IN-A", 10)));
|
||||
var result = service.preview("DP-1");
|
||||
assertThat(result.sourceComplete()).isFalse(); assertThat(result.blockReason()).contains("箱数不一致");
|
||||
assertThat(result.feeSources()).isEmpty(); verifyNoInteractions(finance);
|
||||
}
|
||||
@Test void unavailableOrWrongScopeFinanceResultsFailClosed() {
|
||||
when(finance.sources(anyString(), any())).thenReturn(ApiResponse.success(List.of(fee(202, 11))));
|
||||
assertThatThrownBy(() -> service.preview("DP-1")).isInstanceOf(ResponseStatusException.class).hasMessageContaining("503");
|
||||
when(finance.sources(anyString(), any())).thenReturn(ApiResponse.success(List.of(fee(101, 999))));
|
||||
assertThatThrownBy(() -> service.preview("DP-1")).isInstanceOf(ResponseStatusException.class);
|
||||
when(finance.sources(anyString(), any())).thenReturn(null);
|
||||
assertThatThrownBy(() -> service.preview("DP-1")).isInstanceOf(ResponseStatusException.class);
|
||||
}
|
||||
@Test void platformReadonlyEndpointDoesNotAcceptMerchantIdentityOrAClientSuppliedCustomer() throws Exception {
|
||||
var controller = new PlatformDeliveryFeeCoverageController(service);
|
||||
var merchant = new AuthenticatedUser(7L, 101L, 101L, 8L, "merchant", "MERCHANT", "WEB", Set.of());
|
||||
assertThatThrownBy(() -> controller.preview(merchant, "DP-1")).isInstanceOf(ResponseStatusException.class).hasMessageContaining("403");
|
||||
assertThatThrownBy(() -> controller.preview(null, "DP-1")).isInstanceOf(ResponseStatusException.class).hasMessageContaining("401");
|
||||
var method = PlatformDeliveryFeeCoverageController.class.getMethod("preview", AuthenticatedUser.class, String.class);
|
||||
assertThat(method.getAnnotation(PreAuthorize.class).value()).contains("platform:overseas-warehouse:dispatch:list");
|
||||
verifyNoInteractions(dispatches, items, cargo, finance);
|
||||
}
|
||||
@Test void onlyFullyCoveredServicesCanRemoveANewChargeAndTheSnapshotKeepsEveryActualSource() {
|
||||
when(finance.sources(anyString(), any())).thenReturn(ApiResponse.success(List.of(fee(101,11), fee(101,12))));
|
||||
var preview = service.preview("DP-1");
|
||||
var a = new DeliveryFeeCoverageModels.Selection(711L, 1, fee(101,11).sourceHash(), List.of("DELIVERY_FEE", "LOADING_FEE"));
|
||||
var partial = service.confirm("DP-1", coverageRequest(preview.cargoHash(), List.of(a)), "OP-1", Set.of());
|
||||
assertThat(partial.includedServices()).isEmpty();
|
||||
assertThat(partial.uncovered("DELIVERY_FEE")).extracting(DeliveryFeeCoverageModels.Cargo::sourceInboundId).containsExactly("12");
|
||||
var b = new DeliveryFeeCoverageModels.Selection(712L, 1, fee(101,12).sourceHash(), List.of("DELIVERY_FEE", "LOADING_FEE"));
|
||||
var complete = service.confirm("DP-1", coverageRequest(preview.cargoHash(), List.of(a,b)), "OP-1", Set.of());
|
||||
assertThat(complete.includedServices()).containsExactlyInAnyOrder("DELIVERY_FEE", "LOADING_FEE");
|
||||
assertThat(complete.evidence().cargo()).hasSize(2); assertThat(complete.evidence().references()).hasSize(2);
|
||||
assertThat(complete.evidence().operatorId()).isEqualTo("OP-1");
|
||||
}
|
||||
@Test void staleCargoFeeVersionAndRepeatedCoverageAreRejected() {
|
||||
when(finance.sources(anyString(), any())).thenReturn(ApiResponse.success(List.of(fee(101,11))));
|
||||
var preview = service.preview("DP-1");
|
||||
var selected = new DeliveryFeeCoverageModels.Selection(711L, 1, fee(101,11).sourceHash(), List.of("DELIVERY_FEE"));
|
||||
assertThatThrownBy(() -> service.confirm("DP-1", coverageRequest("old-cargo", List.of(selected)), "OP-1", Set.of())).hasMessageContaining("货物已变化");
|
||||
var old = new DeliveryFeeCoverageModels.Selection(711L, 2, selected.sourceHash(), selected.services());
|
||||
assertThatThrownBy(() -> service.confirm("DP-1", coverageRequest(preview.cargoHash(), List.of(old)), "OP-1", Set.of())).hasMessageContaining("版本已变化");
|
||||
assertThatThrownBy(() -> service.confirm("DP-1", coverageRequest(preview.cargoHash(), List.of(selected, selected)), "OP-1", Set.of())).hasMessageContaining("只能引用一项");
|
||||
}
|
||||
static DeliveryRequests.Transfer coverageRequest(String hash, List<DeliveryFeeCoverageModels.Selection> selections) {
|
||||
return new ObjectMapper().findAndRegisterModules().convertValue(java.util.Map.of("feeCoverageCargoHash", hash, "feeCoverage", selections), DeliveryRequests.Transfer.class);
|
||||
}
|
||||
static DeliveryFeeCoverageModels.Cargo source(String id, String code, long packages) {
|
||||
return new DeliveryFeeCoverageModels.Cargo(201L, 501L, "PLT-501", Long.valueOf(id), "FIRSTLEG", id, code, packages, "IN-A");
|
||||
}
|
||||
static FinanceFeeCoverageClient.Fee fee(long customer, long inbound) {
|
||||
return new FinanceFeeCoverageClient.Fee(700L + inbound, 800L + inbound, "INBOUND_FEE", 1, customer, inbound,
|
||||
"IN-A", "FREIGHT", "原运费", "CNY", BigDecimal.TEN, "sha256:" + "a".repeat(64), "AVAILABLE");
|
||||
}
|
||||
}
|
||||
+18
-5
@@ -11,13 +11,14 @@ 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_dispatch_order(id BIGINT,departed_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_task(id BIGINT,dispatch_order_id BIGINT UNIQUE)");
|
||||
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))");
|
||||
@@ -25,12 +26,17 @@ class DeliverySourceEvidenceMapperTest {
|
||||
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_dispatch_order VALUES(10,'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_pda_task VALUES(1,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_order VALUES(103,'OTHER','FIRSTLEG','S-C',9,NULL)");
|
||||
db.update("INSERT INTO ll_delivery_order VALUES(21,'OTHER',10,'CODE:B',9,0,NULL,'OUT-2')");
|
||||
db.update("INSERT INTO ll_dispatch_item VALUES(8,'OTHER',10,'FBA',NULL,NULL,'b',NULL,NULL)");
|
||||
db.update("INSERT INTO ll_dispatch_pda_pick VALUES(5,'OTHER',1,8)");
|
||||
db.update("INSERT INTO ll_dispatch_pda_pick_order VALUES(6,'OTHER',5,103,9,32,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);
|
||||
@@ -40,6 +46,12 @@ class DeliverySourceEvidenceMapperTest {
|
||||
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"));
|
||||
var otherOwner=Map.<String,Object>of("customerId","OTHER","sourceInboundId","S-C","orderId",103L,"dispatchId",10L);
|
||||
assertThat(named.queryForList(sql("picks"),otherOwner)).singleElement().satisfies(r -> {
|
||||
assertThat(((Number)r.get("packages")).longValue()).isEqualTo(9);
|
||||
assertThat(r.get("group_key")).isEqualTo("CODE:B");
|
||||
});
|
||||
assertThat(named.queryForList(sql("deliveries"),otherOwner)).hasSize(1);
|
||||
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");
|
||||
@@ -49,10 +61,11 @@ class DeliverySourceEvidenceMapperTest {
|
||||
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");
|
||||
var other = new HashMap<>(args); other.put("customerId","UNRELATED");
|
||||
assertThat(named.queryForList(sql(method),other)).isEmpty();
|
||||
}
|
||||
}
|
||||
/** 直接执行生产注解SQL,仅转换同名绑定参数。 */
|
||||
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");
|
||||
|
||||
+1
-1
@@ -113,7 +113,7 @@ class DeliverySourcePublicationPersistenceTest {
|
||||
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)));
|
||||
Map<String,Object> fields=Map.ofEntries(Map.entry("repository",new com.baiyue.lastleg.delivery.repository.DeliveryDocumentRepository(orders,outbound,revisions,operations,tails,mapper)),Map.entry("signals",mock(com.baiyue.lastleg.dispatch.port.DispatchSignalPort.class)),Map.entry("files",files),Map.entry("objectMapper",json),Map.entry("deliveryCompletedOutbox",publisher),Map.entry("customerVisits",mock(CustomerVisitService.class)),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()));
|
||||
|
||||
+109
@@ -0,0 +1,109 @@
|
||||
package com.baiyue.lastleg.delivery;
|
||||
|
||||
import com.baomidou.mybatisplus.annotation.TableName;
|
||||
import com.baomidou.mybatisplus.core.MybatisConfiguration;
|
||||
import com.baomidou.mybatisplus.extension.spring.MybatisSqlSessionFactoryBean;
|
||||
import com.baiyue.lastleg.delivery.entity.*;
|
||||
import com.baiyue.lastleg.delivery.mapper.*;
|
||||
import com.baiyue.lastleg.delivery.model.*;
|
||||
import com.baiyue.lastleg.delivery.repository.DispatchDeliveryRepository;
|
||||
import com.baiyue.lastleg.delivery.service.*;
|
||||
import com.baiyue.lastleg.dispatch.port.*;
|
||||
import com.baiyue.lastleg.dispatch.port.DispatchContracts.*;
|
||||
import com.baiyue.lastleg.dispatch.port.DispatchDeliveryModels.*;
|
||||
import com.baiyue.lastleg.dispatch.port.DispatchTypes.*;
|
||||
import com.baiyue.lastleg.entity.*;
|
||||
import com.baiyue.lastleg.mapper.*;
|
||||
import com.baiyue.lastleg.fleet.trip.FleetBookingFreight;
|
||||
import com.baiyue.lastleg.service.LastlegOrderNoGenerator;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.mybatis.spring.SqlSessionTemplate;
|
||||
import org.springframework.aop.framework.ProxyFactory;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.jdbc.datasource.*;
|
||||
import org.springframework.transaction.annotation.AnnotationTransactionAttributeSource;
|
||||
import org.springframework.transaction.interceptor.TransactionInterceptor;
|
||||
import java.lang.reflect.Modifier;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.*;
|
||||
import java.util.stream.Collectors;
|
||||
import static com.baiyue.lastleg.delivery.DeliveryDispatchFixtures.*;
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import static org.mockito.ArgumentMatchers.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
/** 真实生产Mapper及事务验证任何客户或主单CAS失败时全车原子回滚。 */
|
||||
class DispatchDeliveryArrangementPersistenceTest {
|
||||
private JdbcTemplate db;
|
||||
private DispatchDeliveryService service;
|
||||
private final DispatchExecutionPort execution=mock(DispatchExecutionPort.class);
|
||||
private final DispatchDeliveryFees fees=mock(DispatchDeliveryFees.class);
|
||||
private final ObjectMapper json=new ObjectMapper().findAndRegisterModules();
|
||||
private final DeliveryCargoGroups grouping=new DeliveryCargoGroups(json);
|
||||
private final Context context=context(List.of(cargo(1,"101","ONT8",51,2),cargo(2,"102","ONT8",52,3)),false);
|
||||
private final Actor actor=new Actor("TEST",ActorMode.PLATFORM,null);
|
||||
|
||||
/** 隔离测试数据库只用于生产Mapper验证,不连接环境或启动服务。 */
|
||||
@BeforeEach void prepare() throws Exception {
|
||||
var ds=new DriverManagerDataSource("jdbc:h2:mem:arrangement-"+UUID.randomUUID()+";MODE=MySQL;DB_CLOSE_DELAY=-1","sa","");
|
||||
db=new JdbcTemplate(ds);
|
||||
for (var type:List.of(OutboundOrder.class,OutboundPalletItem.class,DeliveryOrder.class,LastlegOrder.class,LastlegOrderStatusHistory.class)) {
|
||||
String columns=Arrays.stream(type.getDeclaredFields()).filter(f->!Modifier.isStatic(f.getModifiers())).map(f->{
|
||||
String name=f.getName().replaceAll("([A-Z])","_$1").toLowerCase();
|
||||
String sqlType=f.getType()==String.class?"VARCHAR(8000)":f.getType()==LocalDateTime.class?"TIMESTAMP":
|
||||
f.getType()==java.math.BigDecimal.class?"DECIMAL(24,6)":"BIGINT";
|
||||
return name+" "+sqlType+(f.getName().equals("id")?(type==LastlegOrderStatusHistory.class?" AUTO_INCREMENT PRIMARY KEY":" PRIMARY KEY"):"");
|
||||
}).collect(Collectors.joining(","));
|
||||
db.execute("CREATE TABLE "+type.getAnnotation(TableName.class).value()+"("+columns+")");
|
||||
}
|
||||
db.execute("ALTER TABLE ll_outbound_order ADD UNIQUE(dispatch_order_id,customer_id)");
|
||||
db.execute("ALTER TABLE ll_delivery_order ADD UNIQUE(customer_id,outbound_order_id,delivery_group_key)");
|
||||
db.execute("ALTER TABLE ll_outbound_pallet_item ADD UNIQUE(customer_id,source_attribution_id)");
|
||||
db.execute("""
|
||||
CREATE TABLE ll_dispatch_delivery_arrangement(id BIGINT PRIMARY KEY,dispatch_order_id BIGINT UNIQUE,
|
||||
dispatch_no VARCHAR(128),arrangement_key VARCHAR(128) UNIQUE,business_hash VARCHAR(64),cargo_hash VARCHAR(64),
|
||||
loading_completion_hash VARCHAR(64),original_command_key VARCHAR(255) UNIQUE,original_operator_id VARCHAR(128),
|
||||
result_json CLOB,cargo_json CLOB,created_at TIMESTAMP)
|
||||
""");
|
||||
db.update("INSERT INTO ll_order(id,customer_id,current_status,version) VALUES(1,'101','INBOUND_COMPLETED',0),(2,'102','INBOUND_COMPLETED',0)");
|
||||
var config=new MybatisConfiguration();config.setMapUnderscoreToCamelCase(true);
|
||||
for(var mapper:List.of(DispatchDeliveryArrangementMapper.class,OutboundOrderMapper.class,OutboundPalletItemMapper.class,
|
||||
DeliveryOrderMapper.class,DeliveryOperationMapper.class,LastlegOrderMapper.class,LastlegOrderStatusHistoryMapper.class))config.addMapper(mapper);
|
||||
var factory=new MybatisSqlSessionFactoryBean();factory.setDataSource(ds);factory.setConfiguration(config);var session=new SqlSessionTemplate(factory.getObject());
|
||||
var repository=new DispatchDeliveryRepository(session.getMapper(DispatchDeliveryArrangementMapper.class),session.getMapper(OutboundOrderMapper.class),
|
||||
session.getMapper(OutboundPalletItemMapper.class),session.getMapper(DeliveryOrderMapper.class),session.getMapper(DeliveryOperationMapper.class),
|
||||
session.getMapper(LastlegOrderMapper.class),session.getMapper(LastlegOrderStatusHistoryMapper.class));
|
||||
var numbers=mock(LastlegOrderNoGenerator.class);when(numbers.next(anyString(),anyString(),anyLong())).thenAnswer(a->a.getArgument(0)+"-"+a.getArgument(2));
|
||||
var target=new DispatchDeliveryService(execution,repository,grouping,fees,new NormalDeliveryCompletionPolicy(grouping),numbers,json);
|
||||
var proxy=new ProxyFactory(target);proxy.setProxyTargetClass(true);proxy.addAdvice(new TransactionInterceptor(new DataSourceTransactionManager(ds),new AnnotationTransactionAttributeSource()));
|
||||
service=(DispatchDeliveryService)proxy.getProxy();
|
||||
when(execution.lockForArrangement(any(),eq("DP-10"))).thenReturn(context);when(execution.replay(any(),any(),any())).thenReturn(Optional.empty());
|
||||
when(fees.external(any())).thenReturn(Map.of("101",new FleetBookingFreight.Preview("freight",true,List.of(),List.of()),
|
||||
"102",new FleetBookingFreight.Preview("freight",true,List.of(),List.of())));
|
||||
when(execution.recordArrangement(any(),any(),any(),any(),anyBoolean())).thenThrow(new IllegalStateException("主单CAS失败"));
|
||||
}
|
||||
|
||||
/** 第二客户写失败会回滚首客户,主单CAS失败也不能留下两张出库或来源绑定。 */
|
||||
@Test void mainCasFailureRollsBackAllCustomersAndSources() {
|
||||
assertThrows(IllegalStateException.class,()->service.arrange(actor,"DP-10",request()));
|
||||
for(var table:List.of("ll_outbound_order","ll_outbound_pallet_item","ll_delivery_order","ll_dispatch_delivery_arrangement","ll_loading_order_status_history"))
|
||||
assertEquals(0,db.queryForObject("SELECT COUNT(*) FROM "+table,Integer.class));
|
||||
assertEquals(2,db.queryForObject("SELECT COUNT(*) FROM ll_order WHERE outbound_order_no IS NULL AND version=0",Integer.class));
|
||||
}
|
||||
|
||||
/** 一个客户费用失败不把另一客户已创建的单据单独提交。 */
|
||||
@Test void laterCustomerFeeFailureRollsBackEveryDocument() {
|
||||
doThrow(new IllegalStateException("费用发布失败")).when(fees).publish(any(),argThat(g->g.customerId().equals("102")),any(),any(),any(),any(),anyString(),anyString());
|
||||
assertThrows(IllegalStateException.class,()->service.arrange(actor,"DP-10",request()));
|
||||
assertEquals(0,db.queryForObject("SELECT COUNT(*) FROM ll_outbound_order",Integer.class));
|
||||
assertEquals(0,db.queryForObject("SELECT COUNT(*) FROM ll_delivery_order",Integer.class));
|
||||
verify(execution,never()).recordArrangement(any(),any(),any(),any(),anyBoolean());
|
||||
}
|
||||
|
||||
/** 按真实共享记录构造完整客户目的地请求。 */
|
||||
private ArrangeDelivery request() { return new ArrangeDelivery(3L,"ARRANGE-1","cargo-hash","loading-hash",grouping.groups(context).stream().map(g->
|
||||
new ArrangeGroup(g.key(),g.cargoHash(),DEPARTED,"TRUCK",null,null,"WAREHOUSE_PICKUP","PALLET",null,null,null,null,null,
|
||||
"freight",g.cargoHash(),List.of(),List.of())).toList()); }
|
||||
}
|
||||
+121
@@ -0,0 +1,121 @@
|
||||
package com.baiyue.lastleg.delivery;
|
||||
|
||||
import com.baiyue.common.core.api.ApiResponse;
|
||||
import com.baiyue.lastleg.client.FinanceFeeCoverageClient;
|
||||
import com.baiyue.lastleg.delivery.entity.DeliveryOrder;
|
||||
import com.baiyue.lastleg.delivery.model.DeliveryCargoGroups;
|
||||
import com.baiyue.lastleg.delivery.repository.DispatchDeliveryRepository;
|
||||
import com.baiyue.lastleg.delivery.service.DispatchDeliveryFees;
|
||||
import com.baiyue.lastleg.dispatch.port.DispatchContracts.CargoLine;
|
||||
import com.baiyue.lastleg.dispatch.port.DispatchDeliveryModels.*;
|
||||
import com.baiyue.lastleg.financefee.dto.OverseasFeeSourceDtos;
|
||||
import com.baiyue.lastleg.financefee.service.OverseasFeeSourceService;
|
||||
import com.baiyue.lastleg.fleet.trip.FleetBookingFreight;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.mockito.ArgumentCaptor;
|
||||
import java.math.BigDecimal;
|
||||
import java.util.List;
|
||||
import static com.baiyue.lastleg.delivery.DeliveryDispatchFixtures.*;
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import static org.mockito.ArgumentMatchers.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
/** 新整车收费逻辑直接验证真实客户、明确覆盖、零约定和逐组归属。 */
|
||||
class DispatchDeliveryFeesTest {
|
||||
private final FleetBookingFreight external=mock(FleetBookingFreight.class);
|
||||
private final FinanceFeeCoverageClient finance=mock(FinanceFeeCoverageClient.class);
|
||||
private final OverseasFeeSourceService sources=mock(OverseasFeeSourceService.class);
|
||||
private final DispatchDeliveryRepository repository=mock(DispatchDeliveryRepository.class);
|
||||
private final DeliveryCargoGroups grouping=new DeliveryCargoGroups(new ObjectMapper().findAndRegisterModules());
|
||||
private final DispatchDeliveryFees service=new DispatchDeliveryFees(external,finance,sources,grouping,repository,"offline-test-token");
|
||||
|
||||
/** A客户原费不能免除B客户;全部覆盖只禁止应收,应付仍独立。 */
|
||||
@Test void coverageRequiresCurrentCustomerVersionAndKeepsPayablesIndependent() {
|
||||
var row=firstleg(cargo(1,"101","ONT8",51,2));var group=grouping.groups(context(List.of(row),false)).get(0);
|
||||
var fee=original(101);when(finance.sources(anyString(),any())).thenReturn(ApiResponse.success(List.of(fee)));
|
||||
var input=input(group,List.of(new CoverageSelection(71L,1,"FEE-HASH",List.of("DELIVERY_FEE"))),List.of());
|
||||
var evidence=service.coverage(group,input,"OP",ordinary());
|
||||
service.validate(group,input,evidence,ordinary());
|
||||
assertThrows(org.springframework.web.server.ResponseStatusException.class,()->service.validate(group,input(group,input.feeCoverage(),List.of(fee("RECEIVABLE"))),evidence,ordinary()));
|
||||
service.validate(group,input(group,input.feeCoverage(),List.of(fee("PAYABLE"))),evidence,ordinary());
|
||||
when(finance.sources(anyString(),any())).thenReturn(ApiResponse.success(List.of(original(102))));
|
||||
assertThrows(org.springframework.web.server.ResponseStatusException.class,()->service.coverage(group,input,"OP",ordinary()));
|
||||
when(finance.sources(anyString(),any())).thenReturn(ApiResponse.success(List.of(fee)));
|
||||
assertThrows(org.springframework.web.server.ResponseStatusException.class,()->service.coverage(group,
|
||||
input(group,List.of(new CoverageSelection(71L,2,"FEE-HASH",List.of("DELIVERY_FEE"))),List.of()),"OP",ordinary()));
|
||||
}
|
||||
|
||||
/** 无引用时不强制Finance可用,已有外接约定组禁止新增派送应收。 */
|
||||
@Test void ordinaryNeedsPositiveFeeAndExternalQuoteDoesNotWaiveOtherCargo() {
|
||||
var row=cargo(1,"101","ONT8",51,2);var group=grouping.groups(context(List.of(row),false)).get(0);
|
||||
var input=input(group,List.of(),List.of());
|
||||
assertNull(service.coverage(group,input,"OP",ordinary()));verifyNoInteractions(finance);
|
||||
assertThrows(org.springframework.web.server.ResponseStatusException.class,()->service.validate(group,input,null,ordinary()));
|
||||
service.validate(group,input(group,List.of(),List.of(fee("RECEIVABLE"))),null,ordinary());
|
||||
var quote=new FleetBookingFreight.Quote("BOOK-1",1,"INC-1","IN-1","客户101","FBA:ONT8","USD",BigDecimal.ZERO,BigDecimal.ONE,"单",BigDecimal.ZERO);
|
||||
var allExternal=new FleetBookingFreight.Preview("freight",false,List.of(quote),List.of());
|
||||
service.validate(group,input,null,allExternal);
|
||||
assertThrows(org.springframework.web.server.ResponseStatusException.class,()->service.validate(group,input(group,List.of(),List.of(fee("RECEIVABLE"))),null,allExternal));
|
||||
var mixed=grouping.groups(context(List.of(row,cargo(2,"101","ONT8",51,3)),false)).get(0);
|
||||
assertThrows(org.springframework.web.server.ResponseStatusException.class,()->service.validate(mixed,input(mixed,List.of(),List.of()),null,allExternal));
|
||||
}
|
||||
|
||||
/** 正数报价发布到自己的客户目的地,零约定仅保存商业操作不造零费用。 */
|
||||
@Test void zeroAgreementRecordsFactAndPositiveFeeUsesOwnDelivery() {
|
||||
var row=cargo(1,"101","ONT8",51,2);var context=context(List.of(row),false);var group=grouping.groups(context).get(0);
|
||||
var delivery=new DeliveryOrder();delivery.setId(81L);delivery.setDeliveryNo("DL-101");delivery.setCustomerId("101");delivery.setOverseasWarehouseId("1001");
|
||||
var zero=new FleetBookingFreight.Quote("BOOK-ZERO",1,"INC-1","IN-1","客户101","FBA:ONT8","USD",BigDecimal.ZERO,BigDecimal.ONE,"单",BigDecimal.ZERO);
|
||||
service.publish(context,group,input(group,List.of(),List.of()),delivery,null,new FleetBookingFreight.Preview("freight",false,List.of(zero),List.of()),"OP","ARR-1");
|
||||
verify(repository).operation(any());verifyNoInteractions(sources);
|
||||
var positive=new FleetBookingFreight.Quote("BOOK-POSITIVE",1,"INC-1","IN-1","客户101","FBA:ONT8","USD",BigDecimal.TEN,BigDecimal.ONE,"单",BigDecimal.TEN);
|
||||
service.publish(context,group,input(group,List.of(),List.of()),delivery,null,new FleetBookingFreight.Preview("freight",false,List.of(positive),List.of()),"OP","ARR-1");
|
||||
var saved=ArgumentCaptor.forClass(OverseasFeeSourceDtos.SaveRequest.class);verify(sources).publishApproved(eq("OP"),anyString(),saved.capture());
|
||||
assertEquals("101",saved.getValue().customerId());assertEquals(81L,saved.getValue().businessId());assertEquals("DL-101",saved.getValue().businessNo());
|
||||
assertEquals(BigDecimal.TEN,saved.getValue().amount());
|
||||
}
|
||||
|
||||
/** 合托两票一笔原费只能覆盖对应来源,不得免掉另一票新增应收。 */
|
||||
@Test void mixedOriginalSourcesAreBatchedAndPartialCoverageStillRequiresFee() {
|
||||
var first=firstleg(cargo(1,"101","ONT8",51,2));var raw=firstleg(cargo(2,"101","ONT8",51,3));
|
||||
var second=new CargoLine(raw.dispatchItemId(),raw.customerId(),raw.customerName(),raw.orderId(),raw.orderNo(),raw.sourceKey(),raw.sourceStage(),
|
||||
raw.sourceRegion(),"502",raw.inboundCode(),raw.planPalletId(),raw.warehousePalletId(),raw.palletNo(),raw.pickId(),raw.attributionId(),raw.inventoryPositionId(),
|
||||
raw.receiptId(),raw.returnReceiptId(),raw.returnCartonId(),raw.cartonMark(),raw.relabelNo(),raw.destination(),raw.packages(),raw.productQuantity(),raw.grossWeightKg(),raw.volumeCbm(),raw.batchAllocations());
|
||||
var group=grouping.groups(context(List.of(first,second),false)).get(0);
|
||||
when(finance.sources(anyString(),any())).thenReturn(ApiResponse.success(List.of(original(101))));
|
||||
var input=input(group,List.of(new CoverageSelection(71L,1,"FEE-HASH",List.of("DELIVERY_FEE"))),List.of());
|
||||
var evidence=service.coverage(group,input,"OP",ordinary());
|
||||
assertEquals(2,evidence.cargo().size());
|
||||
verify(finance).sources("offline-test-token",new FinanceFeeCoverageClient.Query(101L,List.of(501L,502L)));
|
||||
assertThrows(org.springframework.web.server.ResponseStatusException.class,()->service.validate(group,input,evidence,ordinary()));
|
||||
assertThrows(org.springframework.web.server.ResponseStatusException.class,()->service.coverage(group,
|
||||
input(group,List.of(input.feeCoverage().get(0),input.feeCoverage().get(0)),List.of()),"OP",ordinary()));
|
||||
}
|
||||
|
||||
/** 只读平台候选接口仍拒绝客户身份,不能借组结果跨客户访问。 */
|
||||
@Test void readOnlyCoverageControllerRejectsMerchantBeforeQuery() throws Exception {
|
||||
var delivery=mock(com.baiyue.lastleg.delivery.service.DispatchDeliveryService.class);
|
||||
var controller=new com.baiyue.lastleg.delivery.controller.PlatformDeliveryFeeCoverageController(delivery);
|
||||
var merchant=new com.baiyue.common.security.AuthenticatedUser(1L,101L,101L,1L,"test","MERCHANT","WEB",java.util.Set.of());
|
||||
assertThrows(org.springframework.web.server.ResponseStatusException.class,()->controller.preview(merchant,"DP-10"));
|
||||
assertThrows(org.springframework.web.server.ResponseStatusException.class,()->controller.preview(null,"DP-10"));
|
||||
var method=controller.getClass().getMethod("preview",com.baiyue.common.security.AuthenticatedUser.class,String.class);
|
||||
assertTrue(method.getAnnotation(org.springframework.security.access.prepost.PreAuthorize.class).value().contains("dispatch:list"));
|
||||
verifyNoInteractions(delivery);
|
||||
}
|
||||
|
||||
/** 构造当前客户原费用证据。 */
|
||||
private FinanceFeeCoverageClient.Fee original(long customer) { return new FinanceFeeCoverageClient.Fee(71L,72L,"INBOUND_FEE",1,customer,501L,"IN-1","FIRSTLEG","原费用","CNY",BigDecimal.TEN,"FEE-HASH","AVAILABLE"); }
|
||||
/** 真实头程关联只在来源行声明,不从展示编号推断。 */
|
||||
private CargoLine firstleg(CargoLine c) { return new CargoLine(c.dispatchItemId(),c.customerId(),c.customerName(),c.orderId(),c.orderNo(),c.sourceKey(),c.sourceStage(),
|
||||
"FIRSTLEG","501",c.inboundCode(),c.planPalletId(),c.warehousePalletId(),c.palletNo(),c.pickId(),c.attributionId(),c.inventoryPositionId(),c.receiptId(),
|
||||
c.returnReceiptId(),c.returnCartonId(),c.cartonMark(),c.relabelNo(),c.destination(),c.packages(),c.productQuantity(),c.grossWeightKg(),c.volumeCbm(),c.batchAllocations()); }
|
||||
/** 输入仅使用共同ArrangeGroup,不保留旧单客户协议别名。 */
|
||||
private ArrangeGroup input(DeliveryCargoGroups.Group group,List<CoverageSelection> coverage,List<FeeItem> fees) {
|
||||
return new ArrangeGroup(group.key(),group.cargoHash(),DEPARTED,"TRUCK",null,null,"WAREHOUSE_PICKUP","PALLET",null,null,null,null,null,"freight",group.cargoHash(),coverage,fees);
|
||||
}
|
||||
/** 无外接约定仍需按普通未覆盖部分收费。 */
|
||||
private FleetBookingFreight.Preview ordinary() { return new FleetBookingFreight.Preview("freight",true,List.of(),List.of()); }
|
||||
/** 同一费用类型的应收/应付保留独立规则。 */
|
||||
private FeeItem fee(String direction) { return new FeeItem("DELIVERY_FEE","派送费",direction,"USD",BigDecimal.TEN,null,null,null,null,null,null,null,null,null,null,null,null,null,null); }
|
||||
}
|
||||
+120
@@ -0,0 +1,120 @@
|
||||
package com.baiyue.lastleg.delivery;
|
||||
|
||||
import com.baiyue.lastleg.delivery.entity.*;
|
||||
import com.baiyue.lastleg.delivery.model.*;
|
||||
import com.baiyue.lastleg.delivery.repository.DispatchDeliveryRepository;
|
||||
import com.baiyue.lastleg.delivery.service.*;
|
||||
import com.baiyue.lastleg.dispatch.port.*;
|
||||
import com.baiyue.lastleg.dispatch.port.DispatchContracts.*;
|
||||
import com.baiyue.lastleg.dispatch.port.DispatchDeliveryModels.*;
|
||||
import com.baiyue.lastleg.dispatch.port.DispatchTypes.*;
|
||||
import com.baiyue.lastleg.entity.LastlegOrder;
|
||||
import com.baiyue.lastleg.fleet.trip.FleetBookingFreight;
|
||||
import com.baiyue.lastleg.service.LastlegOrderNoGenerator;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import java.util.*;
|
||||
import static com.baiyue.lastleg.delivery.DeliveryDispatchFixtures.*;
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import static org.mockito.ArgumentMatchers.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
/** 实际整车服务配合领域策略验证分组、数组、锁点及未知结果恢复。 */
|
||||
class DispatchDeliveryServiceTest {
|
||||
private final DispatchExecutionPort execution=mock(DispatchExecutionPort.class);
|
||||
private final DispatchDeliveryRepository repository=mock(DispatchDeliveryRepository.class);
|
||||
private final DispatchDeliveryFees fees=mock(DispatchDeliveryFees.class);
|
||||
private final ObjectMapper json=new ObjectMapper().findAndRegisterModules();
|
||||
private final DeliveryCargoGroups grouping=new DeliveryCargoGroups(json);
|
||||
private final Actor actor=new Actor("OP-A",ActorMode.PLATFORM,null);
|
||||
private final List<OutboundOrder> outbounds=new ArrayList<>();
|
||||
private final List<DeliveryOrder> deliveries=new ArrayList<>();
|
||||
private final List<String> calls=new ArrayList<>();
|
||||
private DispatchDeliveryService service;
|
||||
private Context context;
|
||||
private DispatchDeliveryArrangement arrangement;
|
||||
|
||||
/** 保存真实领域结果,持久化回滚另由Mapper事务测试负责。 */
|
||||
@BeforeEach void prepare() {
|
||||
context=context(List.of(cargo(1,"101","ONT8",51,2),cargo(2,"101","LAX9",52,3),cargo(3,"102","ONT8",53,4)),false);
|
||||
var numbers=mock(LastlegOrderNoGenerator.class);
|
||||
when(numbers.next(anyString(),anyString(),anyLong())).thenAnswer(a->a.getArgument(0)+"-"+a.getArgument(2));
|
||||
service=new DispatchDeliveryService(execution,repository,grouping,fees,new NormalDeliveryCompletionPolicy(grouping),numbers,json);
|
||||
when(execution.lockForArrangement(any(),eq("DP-10"))).thenAnswer(a->context);
|
||||
when(execution.replay(any(),any(),any())).thenReturn(Optional.empty());
|
||||
when(repository.arrangement(eq(10L),anyBoolean())).thenAnswer(a->arrangement);
|
||||
when(repository.outbounds(eq(10L),anyBoolean())).thenAnswer(a->List.copyOf(outbounds));
|
||||
when(repository.deliveries(eq(10L),anyBoolean())).thenAnswer(a->List.copyOf(deliveries));
|
||||
doAnswer(a->{outbounds.add(a.getArgument(0));calls.add("OUTBOUND");return null;}).when(repository).create(any(OutboundOrder.class));
|
||||
doAnswer(a->{deliveries.add(a.getArgument(0));calls.add("DELIVERY");return null;}).when(repository).create(any(DeliveryOrder.class));
|
||||
doAnswer(a->{arrangement=a.getArgument(0);return null;}).when(repository).create(any(DispatchDeliveryArrangement.class));
|
||||
when(repository.lockSource(anyString(),anyLong())).thenAnswer(a->{calls.add("SOURCE");var o=new LastlegOrder();o.setId(a.getArgument(1));
|
||||
o.setCustomerId(a.getArgument(0));o.setCurrentStatus("INBOUND_COMPLETED");return o;});
|
||||
when(fees.external(any())).thenReturn(Map.of("101",freight(),"102",freight()));
|
||||
when(execution.recordArrangement(any(),any(),any(),any(),anyBoolean())).thenAnswer(a->{ArrangementFact f=a.getArgument(3);CommandIdentity i=a.getArgument(2);
|
||||
return new CommandResult("DP-10",Action.ARRANGE_DELIVERY,i.idempotencyKey(),4,Status.ACTIVE,Stage.AFTER_LOADING,DEPARTED,
|
||||
a.getArgument(4)?Effect.EXISTING:Effect.APPLIED,context.counts(),f.result(),false,a.getArgument(4));});
|
||||
}
|
||||
|
||||
/** 两客户三个目的地形成两出库三派送,全部子单先于任何来源锁。 */
|
||||
@Test void createsCompleteCustomerArraysAndLocksSourcesOnlyAfterEveryChild() {
|
||||
var result=service.arrange(actor,"DP-10",request("KEY-1"));
|
||||
var payload=(ArrangementResult)result.result();
|
||||
assertEquals(2,payload.outbounds().size());assertEquals(3,payload.deliveries().size());
|
||||
assertEquals(9,payload.deliveries().stream().mapToLong(DeliveryReference::packageCount).sum());
|
||||
assertEquals(2,payload.deliveries().stream().filter(d->d.destination().warehouseCode().equals("ONT8")).map(DeliveryReference::customerId).distinct().count());
|
||||
assertTrue(calls.indexOf("SOURCE")>calls.lastIndexOf("DELIVERY"));
|
||||
assertTrue(outbounds.stream().allMatch(o->"WAIT_OUTBOUND".equals(o.getStatus())));
|
||||
var items=org.mockito.ArgumentCaptor.forClass(OutboundPalletItem.class);verify(repository,times(3)).create(items.capture());
|
||||
assertEquals(java.util.Set.of(1L,2L,3L),items.getAllValues().stream().map(OutboundPalletItem::getSourceAttributionId).collect(java.util.stream.Collectors.toSet()));
|
||||
assertTrue(items.getAllValues().stream().allMatch(i->i.getSourceOrderId()!=null&&i.getSourceInventoryPositionId()!=null&&i.getDispatchItemId()!=null));
|
||||
verify(fees,times(3)).publish(any(),any(),any(),any(),isNull(),any(),eq("OP-A"),anyString());
|
||||
}
|
||||
|
||||
/** 原命令恢复先于新阶段和版本判断,不能再次写任何客户单据。 */
|
||||
@Test void strictReplayDelegatesToAuthoritativeLedgerBeforeCreatingDocuments() {
|
||||
var snapshot=new CommandResult("DP-10",Action.ARRANGE_DELIVERY,"KEY-1",4,Status.ACTIVE,Stage.AFTER_LOADING,
|
||||
DEPARTED,Effect.APPLIED,context.counts(),null,true,false);
|
||||
when(execution.replay(any(),any(),any())).thenReturn(Optional.of(snapshot));
|
||||
assertSame(snapshot,service.arrange(actor,"DP-10",request("KEY-1")));
|
||||
verify(execution,never()).requireFreshArrangement(any(),anyLong());
|
||||
verifyNoInteractions(repository,fees);
|
||||
}
|
||||
|
||||
/** 少一客户、错组或过期货物摘要均拒绝,不产生部分单据。 */
|
||||
@Test void incompleteGroupSetAndChangedCargoCannotCreatePartialTruck() {
|
||||
var full=request("KEY-1");
|
||||
assertThrows(org.springframework.web.server.ResponseStatusException.class,()->service.arrange(actor,"DP-10",
|
||||
new ArrangeDelivery(3L,"KEY-1",full.cargoHash(),full.loadingCompletionHash(),full.groups().subList(0,2))));
|
||||
assertTrue(outbounds.isEmpty());
|
||||
assertThrows(org.springframework.web.server.ResponseStatusException.class,()->service.arrange(actor,"DP-10",
|
||||
new ArrangeDelivery(3L,"KEY-2","old-hash",full.loadingCompletionHash(),full.groups())));
|
||||
assertTrue(deliveries.isEmpty());
|
||||
}
|
||||
|
||||
/** 发车只更改单据,所有客户仍共享调用方事务,不提供第二次库存扣减入口。 */
|
||||
@Test void departureMarksEveryCustomerDocumentWithoutNewFeePublication() {
|
||||
service.arrange(actor,"DP-10",request("KEY-1"));clearInvocations(fees);
|
||||
var result=service.markDeparted(context,actor,DEPARTED,"DEPART-1");
|
||||
assertEquals(9,result.shippedPackages());assertEquals(2,result.outbounds().size());
|
||||
assertTrue(outbounds.stream().allMatch(o->"OUTBOUNDED".equals(o.getStatus())));
|
||||
assertTrue(deliveries.stream().allMatch(d->"DELIVERING".equals(d.getStatus())&&d.getDispatchedAt()!=null));
|
||||
verifyNoInteractions(fees);
|
||||
}
|
||||
|
||||
/** 使用主流程已冻结的Long版本,缺字段不得被当成有效零。 */
|
||||
@Test void publicVersionRequiresPresenceAndAcceptsExplicitZero() {
|
||||
assertThrows(IllegalArgumentException.class,()->new ArrangeDelivery(null,"K","cargo","load",List.of()));
|
||||
assertThrows(IllegalArgumentException.class,()->new ArrangeDelivery(-1L,"K","cargo","load",List.of()));
|
||||
assertEquals(Long.valueOf(0),new ArrangeDelivery(0L,"K","cargo","load",List.of()).recordVersion());
|
||||
}
|
||||
|
||||
/** 请求依照服务端实际共享DTO构造,不给客户或操作者字段。 */
|
||||
private ArrangeDelivery request(String key) {
|
||||
return new ArrangeDelivery(3L,key,"cargo-hash","loading-hash",grouping.groups(context).stream().map(g->new ArrangeGroup(g.key(),g.cargoHash(),
|
||||
DEPARTED.plusSeconds(3600),"TRUCK",null,null,"WAREHOUSE_PICKUP","PALLET",null,null,null,null,null,"freight",g.cargoHash(),List.of(),List.of())).toList());
|
||||
}
|
||||
/** 本例费用域独立校验,外接报价的实际来源规则另有直接测试。 */
|
||||
private FleetBookingFreight.Preview freight() { return new FleetBookingFreight.Preview("freight",true,List.of(),List.of()); }
|
||||
}
|
||||
+47
@@ -0,0 +1,47 @@
|
||||
package com.baiyue.lastleg.delivery;
|
||||
|
||||
import com.baiyue.lastleg.delivery.model.*;
|
||||
import com.baiyue.lastleg.dispatch.port.DispatchDeliveryModels.*;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import java.util.List;
|
||||
import static com.baiyue.lastleg.delivery.DeliveryDispatchFixtures.*;
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
class NormalDeliveryCompletionPolicyTest {
|
||||
private final DeliveryCargoGroups groups=new DeliveryCargoGroups(new ObjectMapper().findAndRegisterModules());
|
||||
private final NormalDeliveryCompletionPolicy policy=new NormalDeliveryCompletionPolicy(groups);
|
||||
/** 只有全部客户原实发都正常签收才完成,取最后实际签收时间。 */
|
||||
@Test void partialAndReturnDoNotCompleteButAllNormalDoes() {
|
||||
var context=context(List.of(cargo(1,"101","ONT8",51,2),cargo(2,"102","ONT8",52,3)),true);
|
||||
var expected=new ArrangementResult("DP-10",3,"ARR-1","cargo-hash",DEPARTED.minusSeconds(30),List.of(),List.of(
|
||||
reference("101","G1","OUT-A","DL-A",2),reference("102","G2","OUT-B","DL-B",3)));
|
||||
var first=fact("101","G1","OUT-A","DL-A",2,2,0,"DELIVERED",10);
|
||||
var refused=fact("102","G2","OUT-B","DL-B",3,0,3,"RETURNED",0);
|
||||
assertFalse(policy.evaluate(context,expected,List.of(first,refused),DEPARTED.plusSeconds(100)).allNormallySigned());
|
||||
var last=fact("102","G2","OUT-B","DL-B",3,3,0,"DELIVERED",20);
|
||||
var result=policy.evaluate(context,expected,List.of(last,first),DEPARTED.plusSeconds(100));
|
||||
assertTrue(result.allNormallySigned());assertEquals(DEPARTED.plusSeconds(20),result.completedAt());
|
||||
assertFalse(policy.evaluate(context,expected,List.of(first),DEPARTED.plusSeconds(100)).allNormallySigned());
|
||||
assertFalse(policy.evaluate(context,expected,List.of(first,first),DEPARTED.plusSeconds(100)).allNormallySigned());
|
||||
}
|
||||
/** 原签收完成后另退不抹去原运输事实,签剩余则不算全签。 */
|
||||
@Test void laterReturnPreservesOriginalSignatureAndCustomerCannotBeSwapped() {
|
||||
var context=context(List.of(cargo(1,"101","ONT8",51,2)),true);
|
||||
var expected=new ArrangementResult("DP-10",3,"ARR-1","cargo-hash",DEPARTED,List.of(),List.of(reference("101","G1","OUT-A","DL-A",2)));
|
||||
assertTrue(policy.evaluate(context,expected,List.of(fact("101","G1","OUT-A","DL-A",2,2,1,"RETURN_PENDING",10)),DEPARTED.plusSeconds(100)).allNormallySigned());
|
||||
assertFalse(policy.evaluate(context,expected,List.of(fact("101","G1","OUT-A","DL-A",2,1,1,"DELIVERED",10)),DEPARTED.plusSeconds(100)).allNormallySigned());
|
||||
assertFalse(policy.evaluate(context,expected,List.of(fact("102","G1","OUT-A","DL-A",2,2,0,"DELIVERED",10)),DEPARTED.plusSeconds(100)).allNormallySigned());
|
||||
assertFalse(policy.evaluate(context,expected,List.of(fact("101","G1","OUT-A","DL-A",2,2,0,"DELIVERED",-1)),DEPARTED.plusSeconds(100)).allNormallySigned());
|
||||
}
|
||||
/** 缺少安排或者空集合不能推断正常完成。 */
|
||||
@Test void emptyNeverCompletes() { assertFalse(policy.evaluate(context(List.of(),true),null,List.of(),DEPARTED).allNormallySigned()); }
|
||||
/** 创建不可变期望派送集合。 */
|
||||
private DeliveryReference reference(String customer,String group,String outbound,String no,long packages) {
|
||||
return new DeliveryReference(group,customer,null,outbound,no,cargo(1,customer,"ONT8",51,packages).destination(),"WAIT_DELIVERY",packages,1);
|
||||
}
|
||||
/** 合成签收字段,不写真实业务环境。 */
|
||||
private NormalDeliveryFact fact(String customer,String group,String outbound,String no,long original,long signed,long returned,String status,int seconds) {
|
||||
return new NormalDeliveryFact(customer,group,outbound,no,1,original,signed,returned,0,status,true,signed==0?null:DEPARTED.plusSeconds(seconds));
|
||||
}
|
||||
}
|
||||
+49
-14
@@ -1,11 +1,14 @@
|
||||
package com.baiyue.lastleg.delivery;
|
||||
|
||||
import com.baiyue.common.security.AuthenticatedUser;
|
||||
import com.baiyue.common.core.api.ResultCode;
|
||||
import com.baiyue.common.core.exception.BizException;
|
||||
import com.baiyue.lastleg.delivery.controller.DeliveryTransferController;
|
||||
import com.baiyue.lastleg.delivery.dto.DeliveryRequests;
|
||||
import com.baiyue.lastleg.delivery.mapper.OutboundOrderMapper;
|
||||
import com.baiyue.lastleg.dispatch.controller.DispatchController;
|
||||
import com.baiyue.lastleg.dispatch.dto.DispatchRequests;
|
||||
import com.baiyue.lastleg.dispatch.controller.DispatchAggregateController;
|
||||
import com.baiyue.lastleg.dispatch.port.DispatchCommands;
|
||||
import com.baiyue.lastleg.dispatch.mapper.DispatchOrderMapper;
|
||||
import com.baiyue.lastleg.security.OverseasPermissions;
|
||||
import org.apache.ibatis.annotations.Select;
|
||||
@@ -87,13 +90,15 @@ class PlatformDispatchOutboundContractTest {
|
||||
}
|
||||
}
|
||||
|
||||
/** 正式整车动作保留独立权限,取消改为携带版本和命令键的POST。 */
|
||||
@Test
|
||||
void platformActionsHaveIndependentPermissionsAndNoCustomerParameter() throws Exception {
|
||||
Method update = DispatchController.class.getMethod("update", AuthenticatedUser.class,
|
||||
String.class, DispatchRequests.Update.class);
|
||||
Method cancel = DispatchController.class.getMethod("cancel", AuthenticatedUser.class, String.class);
|
||||
String.class, DispatchCommands.Update.class);
|
||||
Method cancel = DispatchAggregateController.class.getMethod("cancel", AuthenticatedUser.class,
|
||||
String.class, DispatchCommands.ReasonCommand.class);
|
||||
Method transfer = DeliveryTransferController.class.getMethod("transfer", AuthenticatedUser.class,
|
||||
String.class, DeliveryRequests.Transfer.class);
|
||||
String.class, com.baiyue.lastleg.dispatch.port.DispatchDeliveryModels.ArrangeDelivery.class);
|
||||
Method externalFreight = DeliveryTransferController.class.getMethod("externalFreight", AuthenticatedUser.class, String.class);
|
||||
assertPermission(externalFreight, OverseasPermissions.DISPATCH_LIST);
|
||||
Method outboundDetail = DeliveryTransferController.class.getMethod("outboundDetail",
|
||||
@@ -103,6 +108,11 @@ class PlatformDispatchOutboundContractTest {
|
||||
|
||||
assertPermission(update, OverseasPermissions.DISPATCH_EDIT);
|
||||
assertPermission(cancel, OverseasPermissions.DISPATCH_CANCEL);
|
||||
var cancelRoute = cancel.getAnnotation(org.springframework.web.bind.annotation.PostMapping.class);
|
||||
assertNotNull(cancelRoute);
|
||||
assertEquals(Set.of("/{dispatchNo}/cancel"), Set.of(cancelRoute.value()));
|
||||
assertFalse(cancel.isAnnotationPresent(org.springframework.web.bind.annotation.DeleteMapping.class));
|
||||
assertTrue(cancel.getParameters()[2].isAnnotationPresent(org.springframework.web.bind.annotation.RequestBody.class));
|
||||
assertPermission(transfer, OverseasPermissions.DISPATCH_TRANSFER_DELIVERY);
|
||||
assertPermission(outboundDetail, OverseasPermissions.OUTBOUND_LIST);
|
||||
assertEquals(2, outboundDetail.getParameterCount());
|
||||
@@ -112,10 +122,13 @@ class PlatformDispatchOutboundContractTest {
|
||||
.noneMatch(parameter -> parameter.isAnnotationPresent(RequestParam.class)));
|
||||
}
|
||||
assertTrue(Arrays.stream(DispatchController.class.getDeclaredMethods())
|
||||
.noneMatch(method -> "depart".equals(method.getName())));
|
||||
assertEquals(Set.of("vehiclePlate", "driverName", "driverPhone", "carrierName",
|
||||
"departAt", "fbaAppointmentNo", "fbaAppointmentStatus", "vehicleAppointmentStatus", "estimatedArrivalAt"),
|
||||
Arrays.stream(DispatchRequests.Update.class.getRecordComponents())
|
||||
.noneMatch(method -> Set.of("depart", "cancel").contains(method.getName())));
|
||||
assertEquals(Set.of("recordVersion", "idempotencyKey", "vehicle", "driver",
|
||||
"plannedDepartureAt", "estimatedArrivalAt", "vehicleAppointment", "remark"),
|
||||
Arrays.stream(DispatchCommands.Update.class.getRecordComponents())
|
||||
.map(component -> component.getName()).collect(java.util.stream.Collectors.toSet()));
|
||||
assertEquals(Set.of("recordVersion", "idempotencyKey", "reason"),
|
||||
Arrays.stream(DispatchCommands.ReasonCommand.class.getRecordComponents())
|
||||
.map(component -> component.getName()).collect(java.util.stream.Collectors.toSet()));
|
||||
}
|
||||
|
||||
@@ -147,21 +160,25 @@ class PlatformDispatchOutboundContractTest {
|
||||
assertFalse(deliveryCustomer.required());
|
||||
}
|
||||
|
||||
/** 即使提供完整版本化取消载荷,客户身份也必须在调用整车业务服务前被拒绝。 */
|
||||
@Test
|
||||
void platformActionsRejectMerchantIdentityBeforeBusinessLookup() {
|
||||
AuthenticatedUser merchant = new AuthenticatedUser(7L, 100L, 100L, 8L, "merchant",
|
||||
"MERCHANT", "WEB", Set.of());
|
||||
DispatchController dispatchController = new DispatchController();
|
||||
var aggregate = mock(com.baiyue.lastleg.dispatch.service.DispatchAggregateService.class);
|
||||
DispatchAggregateController dispatchController = new DispatchAggregateController(aggregate);
|
||||
DeliveryTransferController deliveryController = new DeliveryTransferController();
|
||||
|
||||
ResponseStatusException dispatchRejected = org.junit.jupiter.api.Assertions.assertThrows(
|
||||
ResponseStatusException.class,
|
||||
() -> dispatchController.cancel(merchant, "DSP-101"));
|
||||
BizException dispatchRejected = org.junit.jupiter.api.Assertions.assertThrows(
|
||||
BizException.class,
|
||||
() -> dispatchController.cancel(merchant, "DSP-101",
|
||||
new DispatchCommands.ReasonCommand(0L, "merchant-cancel", "客户不可取消整车")));
|
||||
ResponseStatusException outboundRejected = org.junit.jupiter.api.Assertions.assertThrows(
|
||||
ResponseStatusException.class,
|
||||
() -> deliveryController.outboundDetail(merchant, "OUT-101"));
|
||||
|
||||
assertEquals(403, dispatchRejected.getStatusCode().value());
|
||||
assertEquals(ResultCode.FORBIDDEN, dispatchRejected.getResultCode());
|
||||
org.mockito.Mockito.verifyNoInteractions(aggregate);
|
||||
assertEquals(403, outboundRejected.getStatusCode().value());
|
||||
}
|
||||
|
||||
@@ -185,6 +202,7 @@ class PlatformDispatchOutboundContractTest {
|
||||
assertFalse(schema.contains("uk_ll_outbound_no (customer_id,outbound_no)"));
|
||||
}
|
||||
|
||||
/** 当前整车请求按公共版本一次提交全部组,响应保留多客户出库及多目的地派送数组。 */
|
||||
@Test
|
||||
void openApiPublishesOnlyTheFrozenPlatformProtocol() throws Exception {
|
||||
String yaml = Files.readString(Path.of("src/main/resources/openapi/overseas-business-v1.yaml"),
|
||||
@@ -214,7 +232,24 @@ class PlatformDispatchOutboundContractTest {
|
||||
assertTrue(yaml.contains("platform:overseas-warehouse:outbound:list"));
|
||||
assertTrue(yaml.contains("platform:overseas-warehouse:outbound:create"));
|
||||
assertTrue(yaml.contains("platform:overseas-warehouse:outbound:cancel"));
|
||||
assertTrue(yaml.contains("vehicleSize: { type: string, nullable: true, enum: [15托, 30托] }"));
|
||||
java.util.Map<?,?> document = new org.yaml.snakeyaml.Yaml().load(yaml);
|
||||
java.util.Map<?,?> schemas = (java.util.Map<?,?>)((java.util.Map<?,?>)document.get("components")).get("schemas");
|
||||
java.util.Map<?,?> arrange = (java.util.Map<?,?>)schemas.get("DispatchArrangeDelivery");
|
||||
assertNotNull(arrange);
|
||||
assertTrue(((java.util.List<?>)arrange.get("required")).containsAll(java.util.List.of("recordVersion", "idempotencyKey")));
|
||||
java.util.Map<?,?> fields = (java.util.Map<?,?>)arrange.get("properties");
|
||||
assertTrue(fields.keySet().containsAll(java.util.Set.of("recordVersion", "idempotencyKey", "cargoHash", "loadingCompletionHash", "groups")));
|
||||
assertFalse(fields.containsKey("customerId"));
|
||||
assertFalse(fields.containsKey("expectedVersion"));
|
||||
java.util.Map<?,?> groups = (java.util.Map<?,?>)fields.get("groups");
|
||||
assertEquals("array", groups.get("type"));
|
||||
assertEquals("#/components/schemas/DispatchArrangeGroup", ((java.util.Map<?,?>)groups.get("items")).get("$ref"));
|
||||
java.util.Map<?,?> groupFields = (java.util.Map<?,?>)((java.util.Map<?,?>)schemas.get("DispatchArrangeGroup")).get("properties");
|
||||
assertTrue(groupFields.keySet().containsAll(java.util.Set.of("groupKey", "groupCargoHash", "fees", "feeCoverage")));
|
||||
java.util.Map<?,?> resultFields = (java.util.Map<?,?>)((java.util.Map<?,?>)schemas.get("DispatchArrangementResult")).get("properties");
|
||||
assertEquals("array", ((java.util.Map<?,?>)resultFields.get("outbounds")).get("type"));
|
||||
assertEquals("array", ((java.util.Map<?,?>)resultFields.get("deliveries")).get("type"));
|
||||
assertFalse(schemas.containsKey("DeliveryTransfer"));
|
||||
}
|
||||
|
||||
private static void assertPermission(Method method, String permission) {
|
||||
|
||||
+65
-40
@@ -6,7 +6,6 @@ import com.baiyue.lastleg.delivery.service.DeliveryTransferService;
|
||||
import com.baiyue.lastleg.delivery.service.DeliveryDocumentService;
|
||||
import com.baiyue.lastleg.delivery.dto.DeliveryDocumentDtos;
|
||||
import com.baiyue.lastleg.security.OverseasPermissions;
|
||||
import jakarta.validation.Validation;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.security.access.prepost.PreAuthorize;
|
||||
import org.springframework.test.util.ReflectionTestUtils;
|
||||
@@ -15,7 +14,6 @@ import org.springframework.web.bind.annotation.RequestParam;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
|
||||
import java.lang.reflect.Method;
|
||||
import java.math.BigDecimal;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
@@ -26,7 +24,6 @@ import java.util.Set;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertNotEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
import static org.mockito.Mockito.mock;
|
||||
@@ -41,14 +38,12 @@ class MerchantDeliveryControllerTest {
|
||||
1, 20, null, null, null, null, null, null, null, null, null, null, null, null);
|
||||
|
||||
@Test
|
||||
void fixedRoutesUseIndependentListAndCreatePermissionsWithoutCustomerParameter() throws Exception {
|
||||
void fixedRoutesKeepOnlyTokenScopedReads() throws Exception {
|
||||
RequestMapping mapping = MerchantDeliveryController.class.getAnnotation(RequestMapping.class);
|
||||
assertEquals(List.of("/api/merchant/v1/last-leg/delivery-orders"), List.of(mapping.value()));
|
||||
|
||||
Method page = MerchantDeliveryController.class.getMethod("page", AuthenticatedUser.class,
|
||||
DeliveryRequests.Query.class);
|
||||
Method create = MerchantDeliveryController.class.getMethod("create", AuthenticatedUser.class,
|
||||
String.class, DeliveryRequests.Transfer.class);
|
||||
Method detail = MerchantDeliveryController.class.getMethod("detail", AuthenticatedUser.class, String.class);
|
||||
Method receipts = MerchantDeliveryController.class.getMethod("receipts", AuthenticatedUser.class, String.class);
|
||||
Method download = MerchantDeliveryController.class.getMethod("receiptDownload", AuthenticatedUser.class, String.class, long.class);
|
||||
@@ -57,30 +52,23 @@ class MerchantDeliveryControllerTest {
|
||||
assertPermission(detail, OverseasPermissions.MERCHANT_DELIVERY_LIST);
|
||||
assertPermission(receipts, OverseasPermissions.MERCHANT_DELIVERY_LIST);
|
||||
assertPermission(download, OverseasPermissions.MERCHANT_DELIVERY_LIST);
|
||||
assertPermission(create, OverseasPermissions.MERCHANT_DELIVERY_CREATE);
|
||||
assertNotEquals(OverseasPermissions.MERCHANT_DELIVERY_LIST,
|
||||
OverseasPermissions.MERCHANT_DELIVERY_CREATE);
|
||||
assertTrue(Arrays.stream(page.getParameters()).noneMatch(this::isCustomerParameter));
|
||||
assertTrue(Arrays.stream(detail.getParameters()).noneMatch(this::isCustomerParameter));
|
||||
assertTrue(Arrays.stream(receipts.getParameters()).noneMatch(this::isCustomerParameter));
|
||||
assertTrue(Arrays.stream(download.getParameters()).noneMatch(this::isCustomerParameter));
|
||||
assertTrue(Arrays.stream(create.getParameters()).noneMatch(this::isCustomerParameter));
|
||||
assertTrue(Arrays.stream(DeliveryRequests.Transfer.class.getRecordComponents())
|
||||
.noneMatch(component -> "customerId".equals(component.getName())));
|
||||
assertTrue(Arrays.stream(MerchantDeliveryController.class.getDeclaredMethods())
|
||||
.noneMatch(method -> method.isAnnotationPresent(org.springframework.web.bind.annotation.PostMapping.class)));
|
||||
}
|
||||
|
||||
@Test
|
||||
void allOperationsUseOnlyAuthenticatedCustomerScope() {
|
||||
AuthenticatedUser merchant = user(100L, "MERCHANT");
|
||||
DeliveryRequests.Transfer request = transferRequest();
|
||||
|
||||
controller.page(merchant, query);
|
||||
controller.detail(merchant, "DEL-100");
|
||||
controller.create(merchant, "DSP-100", request);
|
||||
|
||||
verify(service).deliveryPage("100", query);
|
||||
verify(service).deliveryDetail("100", "DEL-100");
|
||||
verify(service).merchantTransfer("100", "DSP-100", "1", request);
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -89,7 +77,6 @@ class MerchantDeliveryControllerTest {
|
||||
|
||||
assertThrows(ResponseStatusException.class, () -> controller.page(platform, query));
|
||||
assertThrows(ResponseStatusException.class, () -> controller.detail(platform, "DEL-100"));
|
||||
assertThrows(ResponseStatusException.class, () -> controller.create(platform, "DSP-100", transferRequest()));
|
||||
assertThrows(ResponseStatusException.class, () -> controller.receipts(platform, "DEL-100"));
|
||||
assertThrows(ResponseStatusException.class, () -> controller.receiptDownload(platform, "DEL-100", 13));
|
||||
}
|
||||
@@ -122,29 +109,35 @@ class MerchantDeliveryControllerTest {
|
||||
org.mockito.Mockito.verifyNoInteractions(documents);
|
||||
}
|
||||
|
||||
/** 客户即使保留旧权限或原创建载荷,也不能通过已移除的写路由安排整车。 */
|
||||
@Test
|
||||
void invalidCreatePayloadIsRejectedByBeanValidationContract() {
|
||||
DeliveryRequests.Transfer invalid = new DeliveryRequests.Transfer(" ", null, " ", null, null,
|
||||
" ", " ", null, null, null, null, null, null, null, null, null, null, null, null);
|
||||
try (var factory = Validation.buildDefaultValidatorFactory()) {
|
||||
assertTrue(factory.getValidator().validate(invalid).size() >= 6);
|
||||
}
|
||||
void removedCreateRouteReturnsMethodNotAllowedWithoutCallingBusinessService() throws Exception {
|
||||
var mvc = org.springframework.test.web.servlet.setup.MockMvcBuilders.standaloneSetup(controller).build();
|
||||
mvc.perform(org.springframework.test.web.servlet.request.MockMvcRequestBuilders
|
||||
.post("/api/merchant/v1/last-leg/delivery-orders")
|
||||
.param("dispatchNo", "DP-1").contentType("application/json")
|
||||
.content("{\"idempotencyKey\":\"old-client\",\"fees\":[]}"))
|
||||
.andExpect(org.springframework.test.web.servlet.result.MockMvcResultMatchers.status().isMethodNotAllowed());
|
||||
org.mockito.Mockito.verifyNoInteractions(service, documents);
|
||||
}
|
||||
|
||||
/** 精确核对客户派送路径,其他业务的合法写操作不得混入本合同。 */
|
||||
@Test
|
||||
void openApiPublishesOnlyTokenScopedMerchantDeliveryProtocol() throws Exception {
|
||||
String yaml = Files.readString(Path.of("src/main/resources/openapi/overseas-business-v1.yaml"),
|
||||
StandardCharsets.UTF_8);
|
||||
String contract = slice(yaml, " /api/merchant/v1/last-leg/delivery-orders:",
|
||||
" /api/merchant/v1/last-leg/overseas-procurements:");
|
||||
String contract = pathBlock(yaml, "/api/merchant/v1/last-leg/delivery-orders")
|
||||
+ pathBlock(yaml, "/api/merchant/v1/last-leg/delivery-orders/{deliveryNo}");
|
||||
|
||||
assertTrue(contract.contains("merchant:warehouse:delivery:list"));
|
||||
assertTrue(contract.contains("merchant:warehouse:delivery:create"));
|
||||
assertFalse(contract.contains("merchant:warehouse:delivery:create"));
|
||||
assertFalse(contract.contains("\n post:"));
|
||||
assertFalse(contract.contains("name: customerId"));
|
||||
String receiptContract = Files.readString(Path.of("src/main/resources/openapi/delivery-documents-v1.yaml"));
|
||||
String merchantPaths = slice(receiptContract,
|
||||
" /api/merchant/v1/last-leg/delivery-orders/{deliveryNo}/documents/receipt:",
|
||||
" /api/platform/v1/last-leg/delivery-orders/{deliveryNo}/documents:");
|
||||
String merchantPaths = pathBlock(receiptContract,
|
||||
"/api/merchant/v1/last-leg/delivery-orders/{deliveryNo}/documents/receipt")
|
||||
+ pathBlock(receiptContract,
|
||||
"/api/merchant/v1/last-leg/delivery-orders/{deliveryNo}/documents/receipt/files/{fileId}/download");
|
||||
assertTrue(merchantPaths.contains("merchant:warehouse:delivery:list"));
|
||||
assertTrue(merchantPaths.contains("/documents/receipt/files/{fileId}/download:"));
|
||||
assertFalse(merchantPaths.contains("#/components/parameters/CustomerId"));
|
||||
@@ -152,6 +145,41 @@ class MerchantDeliveryControllerTest {
|
||||
assertFalse(merchantPaths.contains("\n put:"));
|
||||
}
|
||||
|
||||
/** 相邻仓储业务保留POST时仍只提取派送路径,派送本身的POST不能被提取器漏掉。 */
|
||||
@Test
|
||||
void exactPathBlocksExcludeUnrelatedWritesAndKeepDeliveryWritesDetectable() {
|
||||
String yaml = """
|
||||
paths:
|
||||
/api/merchant/v1/last-leg/delivery-orders:
|
||||
get:
|
||||
summary: 客户派送列表
|
||||
/api/merchant/v1/last-leg/warehouse-transfers:
|
||||
post:
|
||||
summary: 合法仓储转运创建
|
||||
/api/merchant/v1/last-leg/delivery-orders/{deliveryNo}:
|
||||
get:
|
||||
summary: 客户派送详情
|
||||
/api/merchant/v1/last-leg/cargo-discards:
|
||||
post:
|
||||
summary: 合法弃货创建
|
||||
components:
|
||||
schemas:
|
||||
post: {}
|
||||
""";
|
||||
String contract = pathBlock(yaml, "/api/merchant/v1/last-leg/delivery-orders")
|
||||
+ pathBlock(yaml, "/api/merchant/v1/last-leg/delivery-orders/{deliveryNo}");
|
||||
assertTrue(contract.contains("客户派送列表"));
|
||||
assertTrue(contract.contains("客户派送详情"));
|
||||
assertFalse(contract.contains("warehouse-transfers"));
|
||||
assertFalse(contract.contains("cargo-discards"));
|
||||
assertFalse(contract.contains("components:"));
|
||||
assertFalse(contract.contains("\n post:"));
|
||||
String illegalDeliveryWrite = yaml.replace(" get:\n summary: 客户派送列表",
|
||||
" post:\n summary: 客户派送列表");
|
||||
assertTrue(pathBlock(illegalDeliveryWrite, "/api/merchant/v1/last-leg/delivery-orders")
|
||||
.contains("\n post:"));
|
||||
}
|
||||
|
||||
private boolean isCustomerParameter(java.lang.reflect.Parameter parameter) {
|
||||
RequestParam annotation = parameter.getAnnotation(RequestParam.class);
|
||||
return annotation != null && ("customerId".equals(annotation.name())
|
||||
@@ -163,11 +191,15 @@ class MerchantDeliveryControllerTest {
|
||||
assertEquals("hasAuthority('" + permission + "')", annotation.value());
|
||||
}
|
||||
|
||||
private static String slice(String source, String start, String end) {
|
||||
int from = source.indexOf(start);
|
||||
int to = source.indexOf(end, from + start.length());
|
||||
assertTrue(from >= 0 && to > from);
|
||||
return source.substring(from, to);
|
||||
/** 按精确路径头定位,只读取到下一同级路径或根章节,避免跨业务批量截取。 */
|
||||
private static String pathBlock(String source, String path) {
|
||||
var header = java.util.regex.Pattern.compile("(?m)^ "
|
||||
+ java.util.regex.Pattern.quote(path) + ":[ \\t]*\\r?$").matcher(source);
|
||||
assertTrue(header.find(), "缺少OpenAPI路径:" + path);
|
||||
var next = java.util.regex.Pattern.compile("(?m)^(?: /[^\\r\\n]*:|[A-Za-z][A-Za-z0-9_-]*:)[ \\t]*\\r?$")
|
||||
.matcher(source);
|
||||
int to = next.find(header.end()) ? next.start() : source.length();
|
||||
return source.substring(header.start(), to);
|
||||
}
|
||||
|
||||
private static AuthenticatedUser user(long tenantId, String appCode) {
|
||||
@@ -181,11 +213,4 @@ class MerchantDeliveryControllerTest {
|
||||
return value;
|
||||
}
|
||||
|
||||
private static DeliveryRequests.Transfer transferRequest() {
|
||||
return new DeliveryRequests.Transfer("merchant-key", LocalDateTime.now(), "SELF_DELIVERY", null, null,
|
||||
"ONE_PICKUP_ONE_DROP", "PALLET", null, null, null, null, null, null, null, null,
|
||||
List.of(new DeliveryRequests.FeeItem("DELIVERY_FEE", "派送费", "RECEIVABLE", "CNY",
|
||||
new BigDecimal("10.00"), null, null, null, null, null, null, null, null,
|
||||
null, null, null, null, null, null)), null, null, null);
|
||||
}
|
||||
}
|
||||
|
||||
+6
-7
@@ -42,22 +42,20 @@ class DeliveryDocumentServiceTest {
|
||||
@Mock CustomerVisitService customerVisits;
|
||||
@Mock SourceDeliveryCompletionQuery sourceCompletion;
|
||||
@Mock DeliverySourceEvidenceMapper sourceEvidence;
|
||||
@Mock com.baiyue.lastleg.dispatch.port.DispatchSignalPort signals;
|
||||
DeliveryDocumentService service;
|
||||
DeliveryOrder order;
|
||||
@BeforeEach void setUp() {
|
||||
service = new DeliveryDocumentService();
|
||||
org.springframework.test.util.ReflectionTestUtils.setField(service, "fleetPool", org.mockito.Mockito.mock(com.baiyue.lastleg.fleet.business.FleetPoolOrderBridge.class));
|
||||
ReflectionTestUtils.setField(service,"orders",orders);
|
||||
ReflectionTestUtils.setField(service,"outboundOrders",outboundOrders);
|
||||
ReflectionTestUtils.setField(service,"revisions",revisions);
|
||||
ReflectionTestUtils.setField(service,"operations",operations);
|
||||
ReflectionTestUtils.setField(service,"repository",new com.baiyue.lastleg.delivery.repository.DeliveryDocumentRepository(
|
||||
orders,outboundOrders,revisions,operations,lastlegOrders,sourceEvidence));
|
||||
ReflectionTestUtils.setField(service,"signals",signals);
|
||||
ReflectionTestUtils.setField(service,"files",files);
|
||||
ReflectionTestUtils.setField(service,"objectMapper",new ObjectMapper().findAndRegisterModules());
|
||||
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);
|
||||
@@ -250,7 +248,7 @@ class DeliveryDocumentServiceTest {
|
||||
verifyNoInteractions(revisions,operations);
|
||||
}
|
||||
@Test void pendingSourceAuditUsesBusinessLabelAndChineseReasonWithoutInternalIdentifiers() {
|
||||
order.setStatus("DELIVERING");order.setDispatchOrderId(50L);order.setDeliveryGroupKey("CODE:A");
|
||||
order.setStatus("DELIVERING");order.setDispatchOrderId(50L);order.setDispatchNo("DP-50");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);
|
||||
@@ -259,6 +257,7 @@ class DeliveryDocumentServiceTest {
|
||||
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)))));
|
||||
verify(signals).enqueue(eq("DP-50"),eq("DL-1"),eq(1L),any());
|
||||
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");
|
||||
|
||||
+25
-412
@@ -69,6 +69,7 @@ import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.anyString;
|
||||
import static org.mockito.ArgumentMatchers.anyLong;
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
import static org.mockito.Mockito.inOrder;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.never;
|
||||
@@ -128,30 +129,18 @@ class DeliveryTransferServiceTest {
|
||||
|
||||
private DeliveryTransferService service;
|
||||
@Mock com.baiyue.lastleg.fleet.trip.FleetDispatchBindingGuard fleetBindings;
|
||||
@Mock com.baiyue.lastleg.fleet.trip.FleetBookingFreight bookingFreight;
|
||||
@Mock DeliveryFeeCoverageService feeCoverage;
|
||||
@Mock com.baiyue.lastleg.dispatch.port.DispatchExecutionPort execution;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
service = new DeliveryTransferService();
|
||||
var outboundQuantities = new com.baiyue.lastleg.delivery.repository.OutboundQuantityRepository();
|
||||
ReflectionTestUtils.setField(outboundQuantities, "pallets", outboundPallets);
|
||||
ReflectionTestUtils.setField(service, "outboundQuantities", outboundQuantities);
|
||||
ReflectionTestUtils.setField(service, "deliveryExecution", new DeliveryExecutionGuard(new com.baiyue.lastleg.delivery.repository.DeliveryExecutionRepository(outboundOrders, deliveryOrders)));
|
||||
var actualSources = mock(DispatchCargoSourceQuery.class);
|
||||
ReflectionTestUtils.setField(service, "cargoSources", actualSources);
|
||||
org.mockito.Mockito.lenient().when(actualSources.sources(any(), any())).thenAnswer(call -> {
|
||||
List<DispatchItem> selected = call.getArgument(1);
|
||||
return selected.stream().map(item -> new com.baiyue.lastleg.fleet.trip.FleetDispatchGateway.SourceCargo(
|
||||
item.getInboundCode(), item.getWarehousePalletId() == null ? null : item.getWarehousePalletId().toString(), item.getPalletNo(),
|
||||
java.util.Objects.requireNonNullElse(item.getPackageCount(), 0), com.baiyue.lastleg.fleet.trip.FleetDispatchGateway.destinationKey(
|
||||
item.getDestinationType(), item.getDestinationWarehouseCode(), item.getFbaWarehouseCode(), item.getDeliveryAddress(), item.getPostalCode()))).toList();
|
||||
});
|
||||
ReflectionTestUtils.setField(service, "fleetBindings", fleetBindings);
|
||||
ReflectionTestUtils.setField(service, "bookingFreight", bookingFreight);
|
||||
ReflectionTestUtils.setField(service, "feeCoverage", feeCoverage);
|
||||
org.mockito.Mockito.lenient().when(bookingFreight.preview(org.mockito.ArgumentMatchers.anyLong(), any(), any(), any()))
|
||||
.thenReturn(new com.baiyue.lastleg.fleet.trip.FleetBookingFreight.Preview("test-hash", true, List.of(), List.of()));
|
||||
ReflectionTestUtils.setField(service, "execution", execution);
|
||||
ReflectionTestUtils.setField(service,"cancellations",new com.baiyue.lastleg.delivery.repository.DeliveryCancellationRepository(
|
||||
outboundOrders,outboundPallets,deliveryOrders,pdaTasks,deliveryOperations,
|
||||
mock(com.baiyue.lastleg.relabel.mapper.RelabelApplicationMapper.class),returnCartons,
|
||||
mock(com.baiyue.lastleg.transfer.mapper.WarehouseTransferOrderMapper.class),tailOrders,histories));
|
||||
ReflectionTestUtils.setField(service, "objectMapper", new com.fasterxml.jackson.databind.ObjectMapper().findAndRegisterModules());
|
||||
ReflectionTestUtils.setField(service, "dispatchOrders", dispatchOrders);
|
||||
ReflectionTestUtils.setField(service, "dispatchItems", dispatchItems);
|
||||
@@ -451,261 +440,31 @@ class DeliveryTransferServiceTest {
|
||||
return carton;
|
||||
}
|
||||
|
||||
@Test
|
||||
void transferPreservesUnknownGrossWeightThroughPalletAndDeliverySnapshots() {
|
||||
when(dispatchOrders.lockGlobalByDispatchNo("DSP-101")).thenReturn(dispatch(101L, "DSP-101", "TENANT-A"));
|
||||
DispatchItem known = item(201L, 501L, "PLT-501", "ONT8", "Ontario", "IN-A", 12);
|
||||
DispatchItem unknown = item(202L, 502L, "PLT-502", "ONT8", "Ontario", "IN-B", 8);
|
||||
unknown.setGrossWeightKg(null); unknown.setVolumeWeightKg(new BigDecimal("999"));
|
||||
when(dispatchItems.findByDispatchOrder("TENANT-A", 101L)).thenReturn(List.of(known, unknown));
|
||||
service.platformTransfer("DSP-101", "U-1", transferRequest("unknown-gross", null));
|
||||
var outbound = ArgumentCaptor.forClass(OutboundOrder.class);
|
||||
var delivery = ArgumentCaptor.forClass(DeliveryOrder.class);
|
||||
var pallets = ArgumentCaptor.forClass(OutboundPalletItem.class);
|
||||
verify(outboundOrders).insert(outbound.capture());
|
||||
verify(deliveryOrders).insert(delivery.capture());
|
||||
verify(outboundPallets, org.mockito.Mockito.times(2)).insert(pallets.capture());
|
||||
assertEquals(null, outbound.getValue().getGrossWeightKg());
|
||||
assertEquals(null, delivery.getValue().getTotalWeightKg());
|
||||
assertEquals(null, pallets.getAllValues().get(1).getGrossWeightKg());
|
||||
assertEquals(new BigDecimal("999"), pallets.getAllValues().get(1).getVolumeWeightKg());
|
||||
}
|
||||
|
||||
@Test void transferCannotOverrideAnAssignmentOwnedByTheTrip() {
|
||||
when(dispatchOrders.lockGlobalByDispatchNo("DSP-101")).thenReturn(dispatch(101L, "DSP-101", "TENANT-A"));
|
||||
org.mockito.Mockito.doThrow(new ResponseStatusException(org.springframework.http.HttpStatus.CONFLICT, "车辆司机由车次维护"))
|
||||
.when(fleetBindings).requireMatchingAssignment(org.mockito.ArgumentMatchers.eq(101L), any(), any());
|
||||
assertThrows(ResponseStatusException.class, () -> service.platformTransfer("DSP-101", "U-1", transferRequest("bound-transfer", null)));
|
||||
org.mockito.Mockito.verifyNoInteractions(deliveryOrders, feeSources, dispatchItems);
|
||||
verify(outboundOrders, never()).insert((OutboundOrder) any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void firstTransferCreatesOneOutboundAndOneDeliveryPerDestinationFbaWarehouse() {
|
||||
DispatchOrder dispatch = dispatch(101L, "DSP-101", "TENANT-A");
|
||||
when(dispatchOrders.lockGlobalByDispatchNo("DSP-101")).thenReturn(dispatch);
|
||||
when(dispatchItems.findByDispatchOrder("TENANT-A", 101L)).thenReturn(List.of(
|
||||
item(201L, 501L, "PLT-501", "ONT8", "Ontario", "IN-A", 12),
|
||||
item(202L, 502L, "PLT-502", "LAX9", "Los Angeles", "IN-B", 8)
|
||||
));
|
||||
|
||||
DeliveryResponses.TransferResult result = service.platformTransfer("DSP-101", "U-1",
|
||||
transferRequest("transfer-101", "ready"));
|
||||
|
||||
ArgumentCaptor<OutboundOrder> outbound = ArgumentCaptor.forClass(OutboundOrder.class);
|
||||
ArgumentCaptor<DeliveryOrder> delivery = ArgumentCaptor.forClass(DeliveryOrder.class);
|
||||
ArgumentCaptor<OutboundPalletItem> pallets = ArgumentCaptor.forClass(OutboundPalletItem.class);
|
||||
verify(outboundOrders).insert(outbound.capture());
|
||||
verify(deliveryOrders, org.mockito.Mockito.times(2)).insert(delivery.capture());
|
||||
verify(outboundPallets, org.mockito.Mockito.times(2)).insert(pallets.capture());
|
||||
|
||||
assertEquals("WAIT_OUTBOUND", outbound.getValue().getStatus());
|
||||
assertEquals(2, outbound.getValue().getPalletCount());
|
||||
assertEquals(20, outbound.getValue().getPackageCount());
|
||||
assertEquals(new BigDecimal("20"), outbound.getValue().getGrossWeightKg());
|
||||
assertEquals(new BigDecimal("2.0"), outbound.getValue().getVolumeCbm());
|
||||
assertEquals("transfer-101", outbound.getValue().getIdempotencyKey());
|
||||
assertTrue(outbound.getValue().getOutboundNo().matches("OB-WH-\\d{8}-\\d{6}"));
|
||||
assertTrue(delivery.getAllValues().stream().allMatch(value -> outbound.getValue().getId().equals(value.getOutboundOrderId())));
|
||||
assertEquals(List.of("ONT8", "LAX9"), delivery.getAllValues().stream().map(DeliveryOrder::getDestinationWarehouseCode).toList());
|
||||
assertEquals(List.of(12, 8), delivery.getAllValues().stream().map(DeliveryOrder::getPackageCount).toList());
|
||||
assertTrue(delivery.getAllValues().stream().allMatch(value -> "WAIT_DELIVERY".equals(value.getStatus())));
|
||||
assertEquals(List.of("ONT8", "LAX9"), pallets.getAllValues().stream()
|
||||
.map(OutboundPalletItem::getDestinationWarehouseCode).toList());
|
||||
assertEquals(List.of("IN-A", "IN-B"), pallets.getAllValues().stream()
|
||||
.map(OutboundPalletItem::getInboundCode).toList());
|
||||
assertEquals(List.of(new BigDecimal("12"), new BigDecimal("8")), pallets.getAllValues().stream()
|
||||
.map(OutboundPalletItem::getGrossWeightKg).toList());
|
||||
assertEquals(List.of(new BigDecimal("12"), new BigDecimal("8")), delivery.getAllValues().stream()
|
||||
.map(DeliveryOrder::getTotalWeightKg).toList());
|
||||
assertEquals(20, result.packageCount());
|
||||
assertEquals(2, result.palletCount());
|
||||
assertEquals(2, result.deliveries().size());
|
||||
verify(feeSources, org.mockito.Mockito.times(1)).publishApproved(any(), any(), any());
|
||||
assertTrue(!result.replayed());
|
||||
}
|
||||
|
||||
@Test
|
||||
void transferBackfillsReceivedPlanPalletsBeforeCheckingDispatchItems() {
|
||||
DispatchOrder dispatch = dispatch(101L, "DSP-101", "TENANT-A");
|
||||
when(dispatchOrders.lockGlobalByDispatchNo("DSP-101")).thenReturn(dispatch);
|
||||
when(dispatchItems.bindReceivedPallets("TENANT-A", 101L)).thenReturn(1);
|
||||
when(dispatchItems.findByDispatchOrder("TENANT-A", 101L)).thenReturn(List.of(
|
||||
item(201L, 501L, "PLT-501", "ONT8", "Ontario", "IN-A", 12)));
|
||||
|
||||
service.platformTransfer("DSP-101", "U-1", transferRequest("transfer-after-receiving", null));
|
||||
|
||||
var ordered = inOrder(dispatchItems);
|
||||
ordered.verify(dispatchItems).bindReceivedPallets("TENANT-A", 101L);
|
||||
ordered.verify(dispatchItems).findByDispatchOrder("TENANT-A", 101L);
|
||||
}
|
||||
|
||||
@Test
|
||||
void transferCollectsLxCodesWhenAnInboundCodeBelongsToMultipleTailOrders() {
|
||||
DispatchOrder dispatch = dispatch(101L, "DSP-101", "TENANT-A");
|
||||
when(dispatchOrders.lockGlobalByDispatchNo("DSP-101")).thenReturn(dispatch);
|
||||
when(dispatchItems.findByDispatchOrder("TENANT-A", 101L)).thenReturn(List.of(
|
||||
item(201L, 501L, "PLT-501", "ONT8", "Ontario", "IN-A", 12)));
|
||||
LastlegOrder primary = new LastlegOrder();
|
||||
primary.setCustomerId("TENANT-A"); primary.setInboundCode("IN-A"); primary.setLxCode("LX-1");
|
||||
LastlegOrder derived = new LastlegOrder();
|
||||
derived.setCustomerId("TENANT-A"); derived.setInboundCode("IN-A"); derived.setLxCode(null);
|
||||
when(tailOrders.selectList(any())).thenReturn(List.of(primary, derived), List.of());
|
||||
|
||||
service.platformTransfer("DSP-101", "U-1", transferRequest("transfer-duplicate-inbound", null));
|
||||
|
||||
ArgumentCaptor<OutboundOrder> outbound = ArgumentCaptor.forClass(OutboundOrder.class);
|
||||
verify(outboundOrders).insert(outbound.capture());
|
||||
assertEquals("LX-1", outbound.getValue().getLxCodes());
|
||||
}
|
||||
|
||||
@org.junit.jupiter.params.ParameterizedTest
|
||||
@org.junit.jupiter.params.provider.ValueSource(strings = {"SAME_KEY", "NEW_KEY", "INSERT_RACE"})
|
||||
void changedFeesCannotReplayAnEarlierTransferAsSuccessful(String path) throws Exception {
|
||||
var dispatch = dispatch(101L, "DSP-101", "TENANT-A");
|
||||
when(dispatchOrders.lockGlobalByDispatchNo("DSP-101")).thenReturn(dispatch);
|
||||
when(dispatchItems.findByDispatchOrder("TENANT-A", 101L)).thenReturn(List.of(
|
||||
item(201L, 501L, "PLT-501", "ONT8", "Ontario", "IN-A", 12)));
|
||||
var request = transferRequest("transfer-original", null);
|
||||
service.platformTransfer("DSP-101", "U-1", request);
|
||||
var created = ArgumentCaptor.forClass(OutboundOrder.class);
|
||||
var delivered = ArgumentCaptor.forClass(DeliveryOrder.class);
|
||||
verify(outboundOrders).insert(created.capture()); verify(deliveryOrders).insert(delivered.capture());
|
||||
var mapper = new com.fasterxml.jackson.databind.ObjectMapper().findAndRegisterModules();
|
||||
com.fasterxml.jackson.databind.node.ObjectNode changed = mapper.valueToTree(request);
|
||||
((com.fasterxml.jackson.databind.node.ObjectNode) changed.withArray("fees").get(0)).put("amount", new BigDecimal("20.00"));
|
||||
if ("NEW_KEY".equals(path)) changed.put("idempotencyKey", "another-key");
|
||||
var retry = mapper.treeToValue(changed, DeliveryRequests.Transfer.class);
|
||||
if ("NEW_KEY".equals(path)) when(outboundOrders.lockByDispatch("TENANT-A", 101L)).thenReturn(created.getValue());
|
||||
else if ("INSERT_RACE".equals(path)) {
|
||||
when(outboundOrders.findByIdempotency("TENANT-A", "transfer-original")).thenReturn(null, created.getValue());
|
||||
when(outboundOrders.insert(any(OutboundOrder.class))).thenThrow(new org.springframework.dao.DuplicateKeyException("race"));
|
||||
} else when(outboundOrders.findByIdempotency("TENANT-A", "transfer-original")).thenReturn(created.getValue());
|
||||
org.mockito.Mockito.lenient().when(deliveryOrders.lockByOutbound("TENANT-A", created.getValue().getId())).thenReturn(List.of(delivered.getValue()));
|
||||
var failure = assertThrows(ResponseStatusException.class, () -> service.platformTransfer("DSP-101", "U-1", retry));
|
||||
assertEquals(409, failure.getStatusCode().value());
|
||||
assertTrue(failure.getReason().contains("内容不一致"));
|
||||
verify(feeSources).publishApproved(any(), any(), any());
|
||||
verify(deliveryOrders).insert(any(DeliveryOrder.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
void sameIdempotencyKeyReturnsExistingDocumentsWithoutWrites() {
|
||||
DispatchOrder dispatch = dispatch(101L, "DSP-101", "TENANT-A");
|
||||
OutboundOrder outbound = outbound(301L, 101L, "DSP-101", "OUT-301", "transfer-101");
|
||||
var request = transferRequest(" transfer-101 ", null);
|
||||
outbound.setRequestHash(service.transferRequestHash(request));
|
||||
DeliveryOrder delivery = delivery(401L, outbound, "DEL-401");
|
||||
when(dispatchOrders.lockGlobalByDispatchNo("DSP-101")).thenReturn(dispatch);
|
||||
when(outboundOrders.findByIdempotency("TENANT-A", "transfer-101")).thenReturn(outbound);
|
||||
when(deliveryOrders.lockByOutbound("TENANT-A", 301L)).thenReturn(List.of(delivery));
|
||||
|
||||
DeliveryResponses.TransferResult result = service.platformTransfer("DSP-101", "U-1",
|
||||
request);
|
||||
|
||||
assertTrue(result.replayed());
|
||||
assertEquals("OUT-301", result.outboundNo());
|
||||
assertEquals("DEL-401", result.deliveries().get(0).deliveryNo());
|
||||
org.mockito.Mockito.verifyNoInteractions(fleetBindings);
|
||||
verify(outboundOrders, never()).insert((OutboundOrder) any());
|
||||
verify(outboundPallets, never()).insert((OutboundPalletItem) any());
|
||||
verify(deliveryOrders, never()).insert((DeliveryOrder) any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void identicalBusinessContentWithAnotherCommandKeyReturnsExistingDocumentsWithoutNewFees() throws Exception {
|
||||
var dispatch = dispatch(101L, "DSP-101", "TENANT-A");
|
||||
var original = transferRequest("first-key", null);
|
||||
var outbound = outbound(301L, 101L, "DSP-101", "OUT-301", "first-key");
|
||||
outbound.setRequestHash(service.transferRequestHash(original));
|
||||
var mapper = new com.fasterxml.jackson.databind.ObjectMapper().findAndRegisterModules();
|
||||
com.fasterxml.jackson.databind.node.ObjectNode content = mapper.valueToTree(original);
|
||||
content.put("idempotencyKey", "another-key");
|
||||
var retry = mapper.treeToValue(content, DeliveryRequests.Transfer.class);
|
||||
when(dispatchOrders.lockGlobalByDispatchNo("DSP-101")).thenReturn(dispatch);
|
||||
when(outboundOrders.lockByDispatch("TENANT-A", 101L)).thenReturn(outbound);
|
||||
when(deliveryOrders.lockByOutbound("TENANT-A", 301L)).thenReturn(List.of(delivery(401L, outbound, "DEL-401")));
|
||||
var replay = service.platformTransfer("DSP-101", "U-1", retry);
|
||||
assertTrue(replay.replayed()); assertEquals("OUT-301", replay.outboundNo());
|
||||
org.mockito.Mockito.verifyNoInteractions(feeSources, fleetBindings);
|
||||
verify(outboundOrders, never()).insert(any(OutboundOrder.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
void idempotencyKeyCannotBeReusedForAnotherDispatch() {
|
||||
DispatchOrder dispatch = dispatch(102L, "DSP-102", "TENANT-A");
|
||||
OutboundOrder other = outbound(301L, 101L, "DSP-101", "OUT-301", "shared-key");
|
||||
when(dispatchOrders.lockGlobalByDispatchNo("DSP-102")).thenReturn(dispatch);
|
||||
when(outboundOrders.findByIdempotency("TENANT-A", "shared-key")).thenReturn(other);
|
||||
|
||||
ResponseStatusException exception = assertThrows(ResponseStatusException.class, () ->
|
||||
service.platformTransfer("DSP-102", "U-1", transferRequest("shared-key", null)));
|
||||
|
||||
assertEquals(409, exception.getStatusCode().value());
|
||||
verify(outboundOrders, never()).insert((OutboundOrder) any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void concurrentDuplicateInsertReturnsWinningDocuments() {
|
||||
DispatchOrder dispatch = dispatch(101L, "DSP-101", "TENANT-A");
|
||||
OutboundOrder winner = outbound(301L, 101L, "DSP-101", "OUT-301", "transfer-101");
|
||||
var request = transferRequest("transfer-101", null);
|
||||
winner.setRequestHash(service.transferRequestHash(request));
|
||||
DeliveryOrder delivery = delivery(401L, winner, "DEL-401");
|
||||
when(dispatchOrders.lockGlobalByDispatchNo("DSP-101")).thenReturn(dispatch);
|
||||
when(dispatchItems.findByDispatchOrder("TENANT-A", 101L)).thenReturn(List.of(
|
||||
item(201L, 501L, "PLT-501", "ONT8", "Ontario", "IN-A", 12)));
|
||||
when(outboundOrders.insert(any(OutboundOrder.class))).thenThrow(new org.springframework.dao.DuplicateKeyException("duplicate"));
|
||||
when(outboundOrders.findByIdempotency("TENANT-A", "transfer-101")).thenReturn(null, winner);
|
||||
when(deliveryOrders.lockByOutbound("TENANT-A", 301L)).thenReturn(List.of(delivery));
|
||||
|
||||
DeliveryResponses.TransferResult result = service.platformTransfer("DSP-101", "U-1",
|
||||
request);
|
||||
|
||||
assertTrue(result.replayed());
|
||||
assertEquals("OUT-301", result.outboundNo());
|
||||
assertEquals("DEL-401", result.deliveries().get(0).deliveryNo());
|
||||
verify(outboundPallets, never()).insert((OutboundPalletItem) any());
|
||||
verify(deliveryOrders, never()).insert((DeliveryOrder) any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void directDispatchOrderReferenceIsBoundToCreatedDocuments() {
|
||||
DispatchOrder dispatch = dispatch(101L, "DSP-101", "TENANT-A");
|
||||
DispatchItem source = item(201L, 501L, "PLT-501", "ONT8", "Ontario", "IN-A", 12);
|
||||
source.setOrderId(901L);
|
||||
LastlegOrder tailOrder = new LastlegOrder();
|
||||
tailOrder.setId(901L); tailOrder.setCustomerId("TENANT-A"); tailOrder.setCurrentStatus("PENDING_ASSIGNMENT");
|
||||
when(dispatchOrders.lockGlobalByDispatchNo("DSP-101")).thenReturn(dispatch);
|
||||
when(dispatchItems.findByDispatchOrder("TENANT-A", 101L)).thenReturn(List.of(source));
|
||||
when(tailOrders.selectList(any())).thenReturn(List.of(tailOrder));
|
||||
|
||||
service.platformTransfer("DSP-101", "U-1", transferRequest("direct-order", null));
|
||||
|
||||
assertEquals("WAIT_OUTBOUND", tailOrder.getCurrentStatus());
|
||||
assertNotNull(tailOrder.getOutboundOrderNo());
|
||||
assertNotNull(tailOrder.getDeliveryOrderNo());
|
||||
verify(tailOrders).updateById(tailOrder);
|
||||
ArgumentCaptor<LastlegOrderStatusHistory> history = ArgumentCaptor.forClass(LastlegOrderStatusHistory.class);
|
||||
verify(histories).insert(history.capture());
|
||||
assertEquals("TENANT-A", history.getValue().getCustomerId());
|
||||
}
|
||||
|
||||
@Test
|
||||
void transferRejectsAPlannedPalletThatHasNotBeenReceived() {
|
||||
DispatchOrder dispatch = dispatch(101L, "DSP-101", "TENANT-A");
|
||||
DispatchItem planned = item(201L, null, "PLAN-1", "ONT8", "Ontario", "IN-A", 12);
|
||||
planned.setSourceStage("PLAN");
|
||||
when(dispatchOrders.lockGlobalByDispatchNo("DSP-101")).thenReturn(dispatch);
|
||||
when(dispatchItems.findByDispatchOrder("TENANT-A", 101L)).thenReturn(List.of(planned));
|
||||
|
||||
ResponseStatusException exception = assertThrows(ResponseStatusException.class, () ->
|
||||
service.platformTransfer("DSP-101", "U-1", transferRequest("transfer-101", null)));
|
||||
|
||||
assertEquals(409, exception.getStatusCode().value());
|
||||
assertTrue(exception.getReason().contains("received"));
|
||||
verify(outboundOrders, never()).insert((OutboundOrder) any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void dedicatedOutboundDetailDoesNotRequireADeliveryDocument() {
|
||||
@@ -734,20 +493,6 @@ class DeliveryTransferServiceTest {
|
||||
assertEquals("DISCARD_OUTBOUND".equals(type) ? null : "LX-REAL-001", detail.outbound().lxCodes());
|
||||
}
|
||||
|
||||
/** 已有废弃出库即便快照误存五件,列表及详情汇总也应显示一箱。 */
|
||||
@Test
|
||||
void discardOutboundSummaryCountsReturnCartonOnceAcrossSkuLines() {
|
||||
OutboundOrder outbound = outbound(301L, null, null, "OUT-301", "outbound-301");
|
||||
outbound.setOutboundType("DISCARD_OUTBOUND"); outbound.setPackageCount(5);
|
||||
when(outboundOrders.findGlobalByNo("OUT-301")).thenReturn(outbound);
|
||||
var first = pallet(501L, "PALLET-1", null, null, "RT-1", 2);
|
||||
first.setCartonMarks("BOX-1"); first.setReturnSkuQuantity(2L);
|
||||
var second = pallet(502L, "PALLET-1", null, null, "RT-1", 3);
|
||||
second.setCartonMarks("BOX-1"); second.setReturnSkuQuantity(3L);
|
||||
when(outboundPallets.findByOutbound("TENANT-A", 301L)).thenReturn(List.of(first, second));
|
||||
assertEquals(1, service.outboundDetail("OUT-301").outbound().packageCount());
|
||||
}
|
||||
|
||||
@Test
|
||||
void dispatchOutboundStillRequiresItsDeliveryDocument() {
|
||||
OutboundOrder outbound = outbound(301L, 101L, "DSP-101", "OUT-301", "dispatch-301");
|
||||
@@ -792,52 +537,6 @@ class DeliveryTransferServiceTest {
|
||||
verify(deliveryOrders).lockByOutbound("TENANT-A", 301L);
|
||||
}
|
||||
|
||||
@Test
|
||||
void merchantTransferCannotResolveAnotherCustomersDispatch() {
|
||||
when(dispatchOrders.findByDispatchNo("TENANT-B", "DSP-101")).thenReturn(null);
|
||||
|
||||
ResponseStatusException exception = assertThrows(ResponseStatusException.class, () ->
|
||||
service.merchantTransfer("TENANT-B", "DSP-101", "U-2", transferRequest("merchant-key", null)));
|
||||
|
||||
assertEquals(404, exception.getStatusCode().value());
|
||||
verify(dispatchOrders, never()).lockGlobalByDispatchNo(any());
|
||||
verify(outboundOrders, never()).insert((OutboundOrder) any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void merchantTransferReplaysExistingDocumentsWithinAuthenticatedCustomer() {
|
||||
DispatchOrder dispatch = dispatch(101L, "DSP-101", "TENANT-A");
|
||||
OutboundOrder outbound = outbound(301L, 101L, "DSP-101", "OUT-301", "merchant-key");
|
||||
var request = transferRequest(" merchant-key ", null);
|
||||
outbound.setRequestHash(service.transferRequestHash(request));
|
||||
DeliveryOrder delivery = delivery(401L, outbound, "DEL-401");
|
||||
when(dispatchOrders.findByDispatchNo("TENANT-A", "DSP-101")).thenReturn(dispatch);
|
||||
when(dispatchOrders.lockById("TENANT-A", 101L)).thenReturn(dispatch);
|
||||
when(outboundOrders.findByIdempotency("TENANT-A", "merchant-key")).thenReturn(outbound);
|
||||
when(deliveryOrders.lockByOutbound("TENANT-A", 301L)).thenReturn(List.of(delivery));
|
||||
|
||||
DeliveryResponses.TransferResult result = service.merchantTransfer("TENANT-A", "DSP-101", "U-2",
|
||||
request);
|
||||
|
||||
assertTrue(result.replayed());
|
||||
assertEquals("DEL-401", result.deliveries().get(0).deliveryNo());
|
||||
verify(outboundOrders, never()).insert((OutboundOrder) any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void merchantTransferRejectsCancelledDispatch() {
|
||||
DispatchOrder dispatch = dispatch(101L, "DSP-101", "TENANT-A");
|
||||
dispatch.setStatus("CANCELLED");
|
||||
when(dispatchOrders.findByDispatchNo("TENANT-A", "DSP-101")).thenReturn(dispatch);
|
||||
when(dispatchOrders.lockById("TENANT-A", 101L)).thenReturn(dispatch);
|
||||
|
||||
ResponseStatusException exception = assertThrows(ResponseStatusException.class, () ->
|
||||
service.merchantTransfer("TENANT-A", "DSP-101", "U-2", transferRequest("merchant-key", null)));
|
||||
|
||||
assertEquals(409, exception.getStatusCode().value());
|
||||
verify(outboundOrders, never()).insert((OutboundOrder) any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void merchantDeliveryDetailCannotCrossCustomerBoundary() {
|
||||
when(deliveryOrders.findByNo("TENANT-B", "DEL-401")).thenReturn(null);
|
||||
@@ -1015,9 +714,8 @@ class DeliveryTransferServiceTest {
|
||||
com.baomidou.mybatisplus.core.metadata.TableInfoHelper.initTableInfo(new org.apache.ibatis.builder.MapperBuilderAssistant(
|
||||
new com.baomidou.mybatisplus.core.MybatisConfiguration(), "cancellation-test"), LastlegOrder.class);
|
||||
var outbound = outbound(301L, 101L, "DSP-101", "OUT-301", "key");
|
||||
var dispatch = dispatch(101L, "DSP-101", "TENANT-A"); dispatch.setStatus("PENDING_DEPARTURE");
|
||||
when(outboundOrders.findGlobalByNo("OUT-301")).thenReturn(outbound);
|
||||
when(dispatchOrders.lockById("TENANT-A", 101L)).thenReturn(dispatch);
|
||||
when(execution.lockForArrangement(any(),eq("DSP-101"))).thenReturn(cancellationContext(0));
|
||||
when(outboundOrders.lockGlobalByNo("OUT-301")).thenReturn(outbound);
|
||||
var pallet = pallet(501L, "P-1", "ONT8", "Ontario", "IN-1", 12); pallet.setStatus("WAIT_PICK"); pallet.setPickedPackageCount(0);
|
||||
when(outboundPallets.findByOutbound("TENANT-A", 301L)).thenReturn(List.of(pallet));
|
||||
@@ -1050,17 +748,16 @@ class DeliveryTransferServiceTest {
|
||||
.map(row -> row.getResult().replaceFirst("^\\(\\d+\\) ", "")).collect(java.util.stream.Collectors.joining()));
|
||||
verify(feeSources, never()).voidSource(org.mockito.ArgumentMatchers.eq(702L), any(), any());
|
||||
verify(feeSources, never()).voidSource(org.mockito.ArgumentMatchers.eq(703L), any(), any());
|
||||
var ordered = org.mockito.Mockito.inOrder(outboundOrders, dispatchOrders, fleetBindings);
|
||||
ordered.verify(outboundOrders).findGlobalByNo("OUT-301"); ordered.verify(dispatchOrders).lockById("TENANT-A", 101L);
|
||||
var ordered = org.mockito.Mockito.inOrder(outboundOrders, execution, fleetBindings);
|
||||
ordered.verify(outboundOrders).findGlobalByNo("OUT-301"); ordered.verify(execution).lockForArrangement(any(),eq("DSP-101"));
|
||||
ordered.verify(outboundOrders).lockGlobalByNo("OUT-301"); ordered.verify(fleetBindings).requireUnbound(101L);
|
||||
service.cancelOutbound("OUT-301", "OP-2", new DeliveryRequests.Cancel("重试"));
|
||||
verify(feeSources, org.mockito.Mockito.times(1)).voidSource(org.mockito.ArgumentMatchers.anyLong(), any(), any());
|
||||
}
|
||||
@Test void dispatchPickingCannotBeCancelledWhileOutboundSnapshotStillLooksUnexecuted() {
|
||||
var outbound = outbound(301L, 101L, "DSP-101", "OUT-301", "key");
|
||||
var dispatch = dispatch(101L, "DSP-101", "TENANT-A"); dispatch.setStatus("PICKING"); dispatch.setPickedPackageCount(1);
|
||||
when(outboundOrders.findGlobalByNo("OUT-301")).thenReturn(outbound);
|
||||
when(dispatchOrders.lockById("TENANT-A", 101L)).thenReturn(dispatch);
|
||||
when(execution.lockForArrangement(any(),eq("DSP-101"))).thenReturn(cancellationContext(1));
|
||||
when(outboundOrders.lockGlobalByNo("OUT-301")).thenReturn(outbound);
|
||||
assertThrows(ResponseStatusException.class, () -> service.cancelOutbound("OUT-301", "OP-1", new DeliveryRequests.Cancel("取消")));
|
||||
org.mockito.Mockito.verifyNoInteractions(feeSources, outboundPallets, deliveryOrders);
|
||||
@@ -1069,6 +766,12 @@ class DeliveryTransferServiceTest {
|
||||
var fee = new com.baiyue.lastleg.financefee.entity.OverseasFeeSource(); fee.setId(id); fee.setSourceFeeId(id); fee.setSourceVersion(1);
|
||||
fee.setFeeItemCode(type); fee.setDirection(direction); fee.setSourceStatus("ACTIVE"); fee.setReviewStatus("APPROVED"); return fee;
|
||||
}
|
||||
/** 取消用例使用真实公共整车上下文,不再用单客户头部推断资格。 */
|
||||
private static com.baiyue.lastleg.dispatch.port.DispatchContracts.Context cancellationContext(long picked) {
|
||||
return new com.baiyue.lastleg.dispatch.port.DispatchContracts.Context(101L,"DSP-101",20L,"TRIP-20","WH-US-1",null,null,
|
||||
com.baiyue.lastleg.dispatch.port.DispatchTypes.Status.ACTIVE,com.baiyue.lastleg.dispatch.port.DispatchTypes.Stage.BEFORE_LOADING,
|
||||
0,1,null,null,null,null,null,new com.baiyue.lastleg.dispatch.port.DispatchContracts.Counts(1,1,12,picked,0),null,false,List.of());
|
||||
}
|
||||
|
||||
@Test
|
||||
void sharedPalletOutboundFreezesEachInboundAndCountsOnePhysicalPallet() {
|
||||
@@ -1553,95 +1256,13 @@ class DeliveryTransferServiceTest {
|
||||
.getStatusCode().value());
|
||||
}
|
||||
|
||||
@Test void externalFreightPublishesOnlyTheConfirmedQuoteOnItsOwnDestination() {
|
||||
when(dispatchOrders.lockGlobalByDispatchNo("DSP-101")).thenReturn(dispatch(101L, "DSP-101", "TENANT-A"));
|
||||
when(dispatchItems.findByDispatchOrder("TENANT-A", 101L)).thenReturn(List.of(
|
||||
item(201L, 501L, "PLT-501", "ONT8", "Ontario", "WT-1", 12),
|
||||
item(202L, 502L, "PLT-502", "LAX9", "LA", "WT-2", 8)));
|
||||
var q1 = new com.baiyue.lastleg.fleet.trip.FleetBookingFreight.Quote("FB-1", 4, "WT-1", "IN-1", "客户甲", "FBA:ONT8", "CNY", new BigDecimal("60.00"), new BigDecimal("2"), "托位", new BigDecimal("30.00"));
|
||||
var q2 = new com.baiyue.lastleg.fleet.trip.FleetBookingFreight.Quote("FB-2", 4, "WT-2", "IN-2", "客户甲", "FBA:LAX9", "CNY", BigDecimal.ZERO, BigDecimal.ONE, "单", BigDecimal.ZERO);
|
||||
when(bookingFreight.preview(org.mockito.ArgumentMatchers.anyLong(), any(), any(), any()))
|
||||
.thenReturn(new com.baiyue.lastleg.fleet.trip.FleetBookingFreight.Preview("external-hash", false, List.of(q1, q2), List.of("IN-1", "IN-2")));
|
||||
var request = externalRequest("external-hash", List.of());
|
||||
service.platformTransfer("DSP-101", "OP-1", request);
|
||||
var deliveries = ArgumentCaptor.forClass(DeliveryOrder.class);
|
||||
verify(deliveryOrders, org.mockito.Mockito.times(2)).insert(deliveries.capture());
|
||||
var fee = ArgumentCaptor.forClass(com.baiyue.lastleg.financefee.dto.OverseasFeeSourceDtos.SaveRequest.class);
|
||||
verify(feeSources).publishApproved(org.mockito.ArgumentMatchers.eq("OP-1"), org.mockito.ArgumentMatchers.eq("FLEET:FB-1:DSP-101:FREIGHT"), fee.capture());
|
||||
verify(feeSources).requireNoActiveDeliveryReceivables("TENANT-A", List.of("IN-1", "IN-2"));
|
||||
assertEquals(new BigDecimal("60.00"), fee.getValue().amount());
|
||||
assertEquals("IN-1", fee.getValue().inboundCode());
|
||||
assertEquals(deliveries.getAllValues().stream().filter(d -> "ONT8".equals(d.getDestinationWarehouseCode())).findFirst().orElseThrow().getId(), fee.getValue().businessId());
|
||||
var outbound = ArgumentCaptor.forClass(OutboundOrder.class);
|
||||
verify(outboundOrders).insert(outbound.capture());
|
||||
when(outboundOrders.findByIdempotency("TENANT-A", "external-1")).thenReturn(outbound.getValue());
|
||||
when(deliveryOrders.lockByOutbound("TENANT-A", outbound.getValue().getId())).thenReturn(deliveries.getAllValues());
|
||||
assertTrue(service.platformTransfer("DSP-101", "OP-OTHER", request).replayed());
|
||||
org.mockito.Mockito.verifyNoMoreInteractions(feeSources);
|
||||
}
|
||||
|
||||
@Test void externalFreightRejectsStalePreviewAndDuplicateDeliveryChargeBeforeWriting() {
|
||||
when(dispatchOrders.lockGlobalByDispatchNo("DSP-101")).thenReturn(dispatch(101L, "DSP-101", "TENANT-A"));
|
||||
when(dispatchItems.findByDispatchOrder("TENANT-A", 101L)).thenReturn(List.of(item(201L, 501L, "PLT-501", "ONT8", "Ontario", "WT-1", 12)));
|
||||
var q = new com.baiyue.lastleg.fleet.trip.FleetBookingFreight.Quote("FB-1", 4, "WT-1", "IN-1", "客户甲", "FBA:ONT8", "CNY", BigDecimal.TEN, BigDecimal.ONE, "单", BigDecimal.TEN);
|
||||
when(bookingFreight.preview(org.mockito.ArgumentMatchers.anyLong(), any(), any(), any()))
|
||||
.thenReturn(new com.baiyue.lastleg.fleet.trip.FleetBookingFreight.Preview("current-hash", false, List.of(q), List.of("IN-1")));
|
||||
assertThrows(ResponseStatusException.class, () -> service.platformTransfer("DSP-101", "OP-1", externalRequest("old-hash", List.of())));
|
||||
assertThrows(ResponseStatusException.class, () -> service.platformTransfer("DSP-101", "OP-1", externalRequest("current-hash", List.of(receivableDeliveryFee()))));
|
||||
verify(outboundOrders, never()).insert((OutboundOrder) any());
|
||||
org.mockito.Mockito.verifyNoInteractions(feeSources, deliveryOrders);
|
||||
}
|
||||
@Test void rebookingAfterCancellationCannotChargeAgainBeforeTheOriginalReceivableIsVoided() {
|
||||
when(dispatchOrders.lockGlobalByDispatchNo("DSP-101")).thenReturn(dispatch(101L, "DSP-101", "TENANT-A"));
|
||||
when(dispatchItems.findByDispatchOrder("TENANT-A", 101L)).thenReturn(List.of(item(201L, 501L, "P-1", "ONT8", "Ontario", "WT-1", 12)));
|
||||
when(bookingFreight.preview(org.mockito.ArgumentMatchers.anyLong(), any(), any(), any()))
|
||||
.thenReturn(new com.baiyue.lastleg.fleet.trip.FleetBookingFreight.Preview("cancelled-source", true, List.of(), List.of("IN-1")));
|
||||
org.mockito.Mockito.doThrow(new ResponseStatusException(org.springframework.http.HttpStatus.CONFLICT, "原运费待财务作废"))
|
||||
.when(feeSources).requireNoActiveDeliveryReceivables("TENANT-A", List.of("IN-1"));
|
||||
assertThrows(ResponseStatusException.class, () -> service.platformTransfer("DSP-101", "OP-NEW", transferRequest("new-trip", null)));
|
||||
verify(outboundOrders, never()).insert((OutboundOrder) any());
|
||||
verify(feeSources, never()).publishApproved(any(), any(), any());
|
||||
}
|
||||
@Test void confirmedOriginalFeeCoverageSavesEvidenceWithoutPublishingAFabricatedNewFee() throws Exception {
|
||||
var json = new com.fasterxml.jackson.databind.ObjectMapper().findAndRegisterModules();
|
||||
var input = json.valueToTree(externalRequest(null, List.of()));
|
||||
((com.fasterxml.jackson.databind.node.ObjectNode) input).set("feeCoverage", json.valueToTree(List.of(
|
||||
new com.baiyue.lastleg.delivery.dto.DeliveryFeeCoverageModels.Selection(711L, 1, "original-hash", List.of("DELIVERY_FEE")))));
|
||||
((com.fasterxml.jackson.databind.node.ObjectNode) input).put("feeCoverageCargoHash", "cargo-hash");
|
||||
var request = json.treeToValue(input, DeliveryRequests.Transfer.class);
|
||||
var evidence = new com.baiyue.lastleg.delivery.dto.DeliveryFeeCoverageModels.Evidence("cargo-hash", List.of(), List.of(), "OP-1", java.time.Instant.parse("2026-10-03T00:00:00Z"));
|
||||
when(feeCoverage.confirm(org.mockito.ArgumentMatchers.eq("DSP-101"), org.mockito.ArgumentMatchers.eq(request), org.mockito.ArgumentMatchers.eq("OP-1"), any()))
|
||||
.thenReturn(new DeliveryFeeCoverageService.Decision(Set.of("DELIVERY_FEE"), evidence, Set.of()));
|
||||
when(dispatchOrders.lockGlobalByDispatchNo("DSP-101")).thenReturn(dispatch(101L, "DSP-101", "TENANT-A"));
|
||||
when(dispatchItems.findByDispatchOrder("TENANT-A", 101L)).thenReturn(List.of(item(201L, 501L, "P-1", "ONT8", "Ontario", "IN-A", 12)));
|
||||
service.platformTransfer("DSP-101", "OP-1", request);
|
||||
var outbound = ArgumentCaptor.forClass(OutboundOrder.class); verify(outboundOrders).insert(outbound.capture());
|
||||
assertEquals("cargo-hash", json.readTree(outbound.getValue().getFeeCoverageJson()).get("cargoHash").asText());
|
||||
org.mockito.Mockito.verifyNoInteractions(feeSources);
|
||||
var delivery = ArgumentCaptor.forClass(DeliveryOrder.class); verify(deliveryOrders).insert(delivery.capture());
|
||||
when(outboundOrders.findByIdempotency("TENANT-A", request.idempotencyKey())).thenReturn(outbound.getValue());
|
||||
when(deliveryOrders.lockByOutbound("TENANT-A", outbound.getValue().getId())).thenReturn(List.of(delivery.getValue()));
|
||||
assertTrue(service.platformTransfer("DSP-101", "OP-1", request).replayed());
|
||||
verify(feeCoverage, org.mockito.Mockito.times(1)).confirm(any(), any(), any(), any());
|
||||
}
|
||||
@Test void emptyCoverageRepresentationsDoNotCreateFalseIdempotencyConflicts() {
|
||||
var original = transferRequest("key", null);
|
||||
var json = new com.fasterxml.jackson.databind.ObjectMapper().findAndRegisterModules();
|
||||
com.fasterxml.jackson.databind.node.ObjectNode body = json.valueToTree(original);
|
||||
body.putArray("feeCoverage"); body.put("feeCoverageCargoHash", "unused-hash");
|
||||
var empty = json.convertValue(body, DeliveryRequests.Transfer.class);
|
||||
assertEquals(service.transferRequestHash(original), service.transferRequestHash(empty));
|
||||
}
|
||||
@Test void merchantCannotDeclareOriginalFeeCoverageForItself() {
|
||||
var request = new com.fasterxml.jackson.databind.ObjectMapper().convertValue(Map.of("feeCoverage", List.of(
|
||||
new com.baiyue.lastleg.delivery.dto.DeliveryFeeCoverageModels.Selection(711L, 1, "original-hash", List.of("DELIVERY_FEE")))), DeliveryRequests.Transfer.class);
|
||||
var error = assertThrows(ResponseStatusException.class, () -> service.merchantTransfer("TENANT-A", "DSP-101", "OP-1", request));
|
||||
assertEquals(403, error.getStatusCode().value()); org.mockito.Mockito.verifyNoInteractions(dispatchOrders, feeCoverage, feeSources);
|
||||
}
|
||||
private static DeliveryRequests.Transfer externalRequest(String hash, List<DeliveryRequests.FeeItem> fees) {
|
||||
return new DeliveryRequests.Transfer("external-1", LocalDateTime.of(2026,10,3,12,0), "TRUCK_PALLET", null, null,
|
||||
"WAREHOUSE_PICKUP", "PALLET", null, null, null, null, null, null, null, null, fees, hash, null, null);
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
private static DispatchOrder dispatch(Long id, String dispatchNo, String customerId) {
|
||||
DispatchOrder value = new DispatchOrder();
|
||||
@@ -1650,17 +1271,9 @@ class DeliveryTransferServiceTest {
|
||||
return value;
|
||||
}
|
||||
|
||||
private static DeliveryRequests.Transfer transferRequest(String idempotencyKey, String remark) {
|
||||
return new DeliveryRequests.Transfer(idempotencyKey, LocalDateTime.now(), "SELF_DELIVERY", null, null,
|
||||
"ONE_PICKUP_ONE_DROP", "PALLET", null, null, null, null, null, null, null, remark,
|
||||
List.of(receivableDeliveryFee()), null, null, null);
|
||||
}
|
||||
|
||||
private static DeliveryRequests.FeeItem receivableDeliveryFee() {
|
||||
return new DeliveryRequests.FeeItem("DELIVERY_FEE", "派送费", "RECEIVABLE", "CNY",
|
||||
new BigDecimal("10.00"), null, null, null, null, null, null, null, null,
|
||||
null, null, null, null, null, null);
|
||||
}
|
||||
|
||||
|
||||
|
||||
private static DispatchItem item(Long id, Long warehousePalletId, String palletNo, String destinationCode,
|
||||
String destinationName, String inboundCode, int packages) {
|
||||
|
||||
+1
-4
@@ -226,10 +226,7 @@ class DispatchCandidateMapperContractTest {
|
||||
|
||||
@Test
|
||||
void relabelCandidatesReadWeightFromWarehouseTransferPalletSnapshot() throws Exception {
|
||||
String source = Files.readString(Path.of("src/main/java/com/baiyue/lastleg/dispatch/mapper/DispatchCandidateMapper.java"));
|
||||
int start = source.indexOf("List<DispatchPalletCandidate> relabelAvailable");
|
||||
int end = source.indexOf("List<DispatchPalletCandidate> nativeReturnRelabelAvailable");
|
||||
String sql = source.substring(source.lastIndexOf("@Select", start), end);
|
||||
String sql=String.join(" ",DispatchCandidateMapper.class.getMethod("relabelAvailable",String.class,String.class,String.class).getAnnotation(Select.class).value());
|
||||
assertTrue(sql.contains("MAX(wtp.gross_weight_kg) AS grossWeightKg"));
|
||||
assertTrue(sql.contains("MAX(wtp.volume_cbm) AS volumeCbm"));
|
||||
assertTrue(sql.contains("wtp.warehouse_pallet_id=pos.pallet_id"));
|
||||
|
||||
+30
-1
@@ -4,8 +4,12 @@ import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.*;
|
||||
import java.util.regex.Pattern;
|
||||
import java.lang.reflect.Modifier;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
class DispatchSchemaContractTest {
|
||||
@Test
|
||||
@@ -17,7 +21,8 @@ class DispatchSchemaContractTest {
|
||||
assertTrue(sql.contains("uk_ll_dispatch_active_pallet"));
|
||||
assertTrue(sql.contains("CREATE TABLE IF NOT EXISTS ll_dispatch_pda_task"));
|
||||
assertTrue(sql.contains("CREATE TABLE IF NOT EXISTS ll_dispatch_pda_pick"));
|
||||
assertTrue(sql.contains("CREATE TABLE IF NOT EXISTS ll_dispatch_pda_operation"));
|
||||
assertTrue(sql.contains("CREATE TABLE IF NOT EXISTS ll_dispatch_command"));
|
||||
assertTrue(sql.contains("CREATE TABLE IF NOT EXISTS ll_dispatch_history"));
|
||||
assertTrue(sql.contains("active_key"));
|
||||
assertTrue(sql.contains("draft_token"));
|
||||
assertTrue(sql.contains("expires_at"));
|
||||
@@ -25,6 +30,30 @@ class DispatchSchemaContractTest {
|
||||
assertTrue(sql.contains("order_refs"));
|
||||
}
|
||||
|
||||
/** 正式初始化SQL和实际持久化模型必须同时去客户头,不能靠定制H2表掩盖。 */
|
||||
@Test void currentBaselineAndEntitiesHaveOneWholeTruckTaskAndResourceBinding() throws Exception {
|
||||
String sql=Files.readString(Path.of("src/main/resources/db/lastleg.sql"));
|
||||
var tables=Map.of("ll_dispatch_order",com.baiyue.lastleg.dispatch.entity.DispatchAggregate.class,
|
||||
"ll_fleet_trip_dispatch",com.baiyue.lastleg.fleet.trip.FleetTripDispatch.class,
|
||||
"ll_dispatch_pda_task",com.baiyue.lastleg.dispatch.pda.entity.DispatchPdaTask.class);
|
||||
for(var entry:tables.entrySet()) {
|
||||
String body=table(sql,entry.getKey());var columns=new TreeSet<String>();
|
||||
var matcher=Pattern.compile("(?:^|,)\\s*([a-z][a-z0-9_]*)\\s+(?:BIGINT|INT|CHAR|VARCHAR|DATETIME|TIMESTAMP|JSON|TEXT|LONGTEXT|DECIMAL)\\b",Pattern.MULTILINE).matcher(body);
|
||||
while(matcher.find())columns.add(matcher.group(1));
|
||||
var fields=new TreeSet<String>();for(var field:entry.getValue().getDeclaredFields())if(!Modifier.isStatic(field.getModifiers()) && !field.isSynthetic())fields.add(field.getName().replaceAll("([a-z0-9])([A-Z])","$1_$2").toLowerCase(Locale.ROOT));
|
||||
assertThat(columns).as(entry.getKey()).isEqualTo(fields).doesNotContain("customer_id");
|
||||
}
|
||||
assertThat(table(sql,"ll_fleet_trip_dispatch")).doesNotContain("review_json").containsPattern("UNIQUE KEY\\s+\\w+\\s*\\(trip_id\\)");
|
||||
assertThat(table(sql,"ll_dispatch_pda_task")).containsPattern("UNIQUE KEY\\s+\\w+\\s*\\(dispatch_order_id\\)");
|
||||
assertThat(table(sql,"ll_dispatch_order")).containsPattern("UNIQUE KEY\\s+\\w+\\s*\\(trip_id\\)");
|
||||
assertThat(table(sql,"ll_dispatch_pda_pick")).contains("customer_id");
|
||||
assertThat(table(sql,"ll_dispatch_pda_pick_order")).contains("customer_id");
|
||||
}
|
||||
private static String table(String sql,String name) {
|
||||
var matcher=Pattern.compile("CREATE TABLE(?: IF NOT EXISTS)? "+name+"\\s*\\((.*?)\\) ENGINE=",Pattern.DOTALL).matcher(sql);
|
||||
assertThat(matcher.find()).as(name).isTrue();return matcher.group(1);
|
||||
}
|
||||
|
||||
@Test
|
||||
void dispatchCandidatesExcludePalletsReservedByWarehouseTransfers() throws Exception {
|
||||
String mapper = Files.readString(Path.of(
|
||||
|
||||
+41
-49
@@ -1,57 +1,49 @@
|
||||
package com.baiyue.lastleg.dispatch;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.yaml.snakeyaml.Yaml;
|
||||
import java.util.*;
|
||||
import static org.assertj.core.api.Assertions.*;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
/** 精确解析公开草稿合同,不以全文件字符串误匹配旧客户拆单协议。 */
|
||||
class SmartDispatchOpenApiContractTest {
|
||||
@Test
|
||||
void contractDocumentsOnlyCandidateAndPreArrangeSmartDispatchWrites() throws Exception {
|
||||
String yaml;
|
||||
try (var input = getClass().getResourceAsStream("/openapi/overseas-business-v1.yaml")) {
|
||||
yaml = new String(input.readAllBytes(), StandardCharsets.UTF_8);
|
||||
/** 草稿端点、版本化请求和候选参数必须与冻结端口一致,旧standalone路由移除。 */
|
||||
@Test void contractPublishesOwnedVersionedDraftProtocol()throws Exception {
|
||||
Map<String,Object> root;
|
||||
try(var input=getClass().getResourceAsStream("/openapi/overseas-business-v1.yaml")){root=new Yaml().load(input);}
|
||||
var paths=map(root.get("paths"));String base="/api/platform/v1/last-leg/dispatches";
|
||||
assertThat(paths).doesNotContainKey(base+"/pre-arrange");
|
||||
var expected=new LinkedHashMap<String,String>();
|
||||
expected.put("/drafts","DispatchCreateDraft");expected.put("/drafts/containers","DispatchCreateContainer");
|
||||
expected.put("/drafts/snapshot","DispatchDraftSnapshot");expected.put("/draft-locks/lock","DispatchDraftSelection");
|
||||
expected.put("/draft-locks/unlock","DispatchDraftSelection");expected.put("/draft-locks/release","DispatchDraftRelease");
|
||||
expected.put("/drafts/pre-arrange","DispatchConvertDraft");expected.put("/drafts/{draftToken}/containers/{containerId}","DispatchReasonCommand");
|
||||
var businessRequired=Map.of(
|
||||
"DispatchCreateDraft",List.of("warehouseId"),"DispatchCreateContainer",List.of("draftToken","capacity"),
|
||||
"DispatchDraftSelection",List.of("draftToken","draftContainerId","source"),"DispatchDraftSnapshot",List.of("draftToken","planIds","containers"),
|
||||
"DispatchDraftRelease",List.of("draftToken"),"DispatchConvertDraft",List.of("draftToken","groups"));
|
||||
var schemas=map(map(root.get("components")).get("schemas"));
|
||||
for(var item:expected.entrySet()){
|
||||
String method=item.getKey().contains("{containerId}")?"delete":"post";
|
||||
var operation=map(map(paths.get(base+item.getKey())).get(method));
|
||||
var body=map(operation.get("requestBody"));assertThat(body.get("required")).isEqualTo(true);
|
||||
var schema=map(map(map(body.get("content")).get("application/json")).get("schema"));
|
||||
assertThat(schema.get("$ref")).isEqualTo("#/components/schemas/"+item.getValue());
|
||||
var request=map(schemas.get(item.getValue()));var fields=map(request.get("properties"));
|
||||
assertThat(fields).doesNotContainKeys("customerId","planPalletIds","returnReceiptIds","expectedVersion");
|
||||
assertThat(((List<?>)request.get("required")).contains("idempotencyKey")).isTrue();
|
||||
if(!item.getKey().equals("/drafts"))assertThat(((List<?>)request.get("required")).contains("recordVersion")).isTrue();
|
||||
assertThat(((List<?>)request.get("required")).containsAll(businessRequired.getOrDefault(item.getValue(),List.of()))).isTrue();
|
||||
}
|
||||
assertTrue(yaml.contains("/api/platform/v1/last-leg/dispatches/candidates:"));
|
||||
assertTrue(yaml.contains("/api/platform/v1/last-leg/dispatches/pre-arrange:"));
|
||||
assertTrue(yaml.contains("/api/platform/v1/last-leg/dispatches/drafts/pre-arrange:"));
|
||||
assertTrue(yaml.contains("required: [draftToken, groups]"));
|
||||
assertTrue(yaml.contains("required: [draftContainerId, planPalletIds, returnReceiptIds]"));
|
||||
assertTrue(yaml.contains("SmartDispatchDraftGroupResult:"));
|
||||
assertTrue(yaml.contains("/api/platform/v1/last-leg/dispatches/draft-locks/lock:"));
|
||||
assertTrue(yaml.contains("/api/platform/v1/last-leg/dispatches/draft-locks/unlock:"));
|
||||
assertTrue(yaml.contains("/api/platform/v1/last-leg/dispatches/draft-locks/release:"));
|
||||
assertTrue(yaml.contains("/api/platform/v1/last-leg/dispatches/drafts:"));
|
||||
assertTrue(yaml.contains("/api/platform/v1/last-leg/dispatches/drafts/snapshot:"));
|
||||
assertTrue(yaml.contains("/api/platform/v1/last-leg/dispatches/drafts/containers:"));
|
||||
assertTrue(yaml.contains("/api/platform/v1/last-leg/dispatches/drafts/{draftToken}/containers/{containerId}:"));
|
||||
assertTrue(yaml.contains("draftContainerId:"));
|
||||
assertTrue(yaml.contains("SmartDispatchDraftContainer:"));
|
||||
assertTrue(yaml.contains("capacity: { type: integer, enum: [15, 30] }"));
|
||||
assertTrue(yaml.contains("SmartDispatchDraft:"));
|
||||
assertTrue(yaml.contains("platform:overseas-warehouse:inbound-order:list"));
|
||||
assertTrue(yaml.contains("platform:overseas-warehouse:inbound-order:smart-dispatch"));
|
||||
assertTrue(yaml.contains("SmartDispatchPreArrange"));
|
||||
assertTrue(yaml.contains("SmartDispatchCandidate"));
|
||||
assertTrue(yaml.contains("overseasWarehouseCode: { type: string, nullable: true, description: 实际海外接柜仓编码"));
|
||||
assertTrue(yaml.contains("grossWeightKg: { type: number, nullable: true, description: 候选托盘分摊毛重"));
|
||||
assertTrue(yaml.contains("禁止使用体积重替代"));
|
||||
String preArrangeSchema = yaml.substring(yaml.indexOf(" SmartDispatchPreArrange:"),
|
||||
yaml.indexOf(" SmartDispatchDraftPallet:"));
|
||||
String updateSchema = yaml.substring(yaml.indexOf(" DispatchUpdate:"),
|
||||
yaml.indexOf(" DeliveryTransfer:"));
|
||||
assertFalse(preArrangeSchema.contains("route:"));
|
||||
assertFalse(updateSchema.contains("route:"));
|
||||
assertTrue(updateSchema.contains("departAt: { type: string, format: date-time, nullable: true }"));
|
||||
assertTrue(yaml.contains("name: planIds"));
|
||||
String planIdsParameter = yaml.substring(yaml.indexOf("name: planIds"),
|
||||
yaml.indexOf("name: orderNo", yaml.indexOf("name: planIds")));
|
||||
assertTrue(planIdsParameter.contains("required: false"));
|
||||
String dispatchContract = yaml.substring(yaml.indexOf("/api/platform/v1/last-leg/dispatches:"),
|
||||
yaml.indexOf("/api/platform/v1/last-leg/outbound-orders:"));
|
||||
assertFalse(dispatchContract.contains("name: customerId"));
|
||||
assertFalse(yaml.contains("/api/platform/v1/last-leg/dispatches/{dispatchNo}/depart:"));
|
||||
assertThat(map(paths.get(base+"/drafts"))).containsKey("get");
|
||||
var candidate=map(map(paths.get(base+"/candidates")).get("get"));
|
||||
var parameterNames=((List<?>)candidate.get("parameters")).stream().map(SmartDispatchOpenApiContractTest::map).map(p->p.get("name")).toList();
|
||||
assertThat(parameterNames).contains("warehouseId","destinationWarehouseCode","draftToken").doesNotContain("customerId","fbaWarehouseCode");
|
||||
var draft=map(map(schemas.get("DispatchDraft")).get("properties"));
|
||||
assertThat(draft.keySet()).containsExactlyInAnyOrder("draftToken","recordVersion","status","warehouseId","containers","sources","expiresAt");
|
||||
var vehicle=map(map(schemas.get("DispatchDraftVehicle")).get("properties"));assertThat(vehicle.keySet()).containsExactlyInAnyOrder("draftContainerId","dispatch");
|
||||
}
|
||||
/** 失败显示实际缺失节点,避免静默按空合同放过接口漂移。 */
|
||||
@SuppressWarnings("unchecked")
|
||||
private static Map<String,Object> map(Object value){assertThat(value).isInstanceOf(Map.class);return (Map<String,Object>)value;}
|
||||
}
|
||||
|
||||
+11
-8
@@ -2,8 +2,10 @@ package com.baiyue.lastleg.dispatch.controller;
|
||||
|
||||
import com.baiyue.common.core.api.PageResult;
|
||||
import com.baiyue.common.security.AuthenticatedUser;
|
||||
import com.baiyue.lastleg.dispatch.dto.DispatchRequests;
|
||||
import com.baiyue.lastleg.dispatch.service.DispatchService;
|
||||
import com.baiyue.lastleg.dispatch.port.DispatchViews;
|
||||
import com.baiyue.lastleg.dispatch.port.DispatchTypes;
|
||||
import com.baiyue.lastleg.dispatch.port.DispatchContracts;
|
||||
import com.baiyue.lastleg.dispatch.service.DispatchAggregateService;
|
||||
import com.baiyue.lastleg.dispatch.service.DispatchBolPrintService;
|
||||
import com.baiyue.lastleg.security.OverseasPermissions;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
@@ -25,25 +27,26 @@ import static org.mockito.Mockito.when;
|
||||
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class DispatchQueryControllerTest {
|
||||
@Mock DispatchService service;
|
||||
@Mock DispatchAggregateService service;
|
||||
@Mock DispatchBolPrintService printService;
|
||||
private DispatchQueryController controller;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
controller = new DispatchQueryController();
|
||||
ReflectionTestUtils.setField(controller, "service", service);
|
||||
ReflectionTestUtils.setField(controller, "aggregateService", service);
|
||||
ReflectionTestUtils.setField(controller, "printService", printService);
|
||||
}
|
||||
|
||||
@Test
|
||||
void platformPageQueriesAcrossCustomersWithoutCustomerId() {
|
||||
DispatchRequests.Query query = new DispatchRequests.Query(1, 20, null, null, null, null, null);
|
||||
when(service.platformPage(query)).thenReturn(new PageResult<>(List.of(), 1, 20, 0, 0));
|
||||
DispatchViews.Query query = new DispatchViews.Query(1,20,null,null,null,null,null,DispatchTypes.StatusFilter.ACTIVE,null,null,null);
|
||||
var actor=new DispatchContracts.Actor("7",DispatchTypes.ActorMode.PLATFORM,null);
|
||||
when(service.page(actor,query)).thenReturn(new DispatchViews.Page(List.of(),0,1,20,0,null,java.time.Instant.EPOCH));
|
||||
|
||||
var response = controller.page(platform(), query);
|
||||
|
||||
verify(service).platformPage(query);
|
||||
verify(service).page(actor,query);
|
||||
assertThat(response.code()).isEqualTo("200");
|
||||
assertThat(response.data().records()).isEmpty();
|
||||
assertThatThrownBy(() -> controller.page(null, query))
|
||||
@@ -55,7 +58,7 @@ class DispatchQueryControllerTest {
|
||||
@Test
|
||||
void listAndDetailUseDedicatedDispatchListAuthority() throws Exception {
|
||||
PreAuthorize page = DispatchQueryController.class
|
||||
.getMethod("page", AuthenticatedUser.class, DispatchRequests.Query.class)
|
||||
.getMethod("page", AuthenticatedUser.class, DispatchViews.Query.class)
|
||||
.getAnnotation(PreAuthorize.class);
|
||||
PreAuthorize detail = DispatchQueryController.class
|
||||
.getMethod("detail", AuthenticatedUser.class, String.class)
|
||||
|
||||
+86
-79
@@ -1,96 +1,103 @@
|
||||
package com.baiyue.lastleg.dispatch.controller;
|
||||
|
||||
import com.baiyue.common.core.exception.BizException;
|
||||
import com.baiyue.common.security.AuthenticatedUser;
|
||||
import com.baiyue.lastleg.dispatch.dto.DispatchRequests;
|
||||
import com.baiyue.lastleg.dispatch.service.DispatchService;
|
||||
import com.baiyue.lastleg.dispatch.port.*;
|
||||
import com.baiyue.lastleg.dispatch.service.DispatchAggregateService;
|
||||
import com.baiyue.lastleg.security.OverseasPermissions;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import org.junit.jupiter.api.*;
|
||||
import org.springframework.security.access.prepost.PreAuthorize;
|
||||
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
|
||||
import org.springframework.security.core.context.SecurityContextHolder;
|
||||
import org.springframework.security.web.method.annotation.AuthenticationPrincipalArgumentResolver;
|
||||
import org.springframework.test.util.ReflectionTestUtils;
|
||||
import org.springframework.test.web.servlet.setup.MockMvcBuilders;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
import java.util.*;
|
||||
import static com.baiyue.lastleg.dispatch.port.DispatchPlanningModels.*;
|
||||
import static org.assertj.core.api.Assertions.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class SmartDispatchControllerTest {
|
||||
@Mock DispatchService service;
|
||||
private final DispatchPlanningPort planning=mock(DispatchPlanningPort.class);
|
||||
private final DispatchAggregateService aggregate=mock(DispatchAggregateService.class);
|
||||
private SmartDispatchController controller;
|
||||
private final DispatchContracts.Actor actor=new DispatchContracts.Actor("7",DispatchTypes.ActorMode.PLATFORM,null);
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
controller = new SmartDispatchController();
|
||||
ReflectionTestUtils.setField(controller, "service", service);
|
||||
/** 使用真实Controller和认证主体工厂,隔离业务端口。 */
|
||||
@BeforeEach void setup(){controller=new SmartDispatchController();ReflectionTestUtils.setField(controller,"planning",planning);ReflectionTestUtils.setField(controller,"aggregate",aggregate);}
|
||||
/** 清理认证上下文,避免HTTP测试泄漏给其他用例。 */
|
||||
@AfterEach void clear(){SecurityContextHolder.clearContext();}
|
||||
|
||||
/** 候选仍委托实际主流程,保留仓库及全部真实筛选。 */
|
||||
@Test void candidatesKeepWarehouseMultiCustomerFilters(){
|
||||
controller.candidates(platform(),"92",List.of(1L,2L),"LMO-1","ONT8","PLAN","draft-1");
|
||||
verify(aggregate).candidates(actor,new CandidateQuery("92",List.of(1L,2L),"LMO-1","ONT8","PLAN","draft-1"));
|
||||
verifyNoInteractions(planning);
|
||||
}
|
||||
|
||||
@Test
|
||||
void candidatesResolveCustomerScopeFromSelectedPlans() {
|
||||
when(service.resolveCustomerScopeForPlans(List.of(1L, 2L))).thenReturn("100");
|
||||
when(service.candidates("100", List.of(1L, 2L), "LMO-1", "ONT8", "PLAN", "draft-1"))
|
||||
.thenReturn(List.of());
|
||||
|
||||
controller.candidates(platform(), List.of(1L, 2L), "LMO-1", "ONT8", "PLAN", "draft-1");
|
||||
|
||||
verify(service).candidates("100", List.of(1L, 2L), "LMO-1", "ONT8", "PLAN", "draft-1");
|
||||
/** HTTP实际参数采用destinationWarehouseCode,不能因旧名称使前端筛选静默丢失。 */
|
||||
@Test void candidateHttpQueryBindsFrozenDestinationParameter()throws Exception{
|
||||
var mvc=MockMvcBuilders.standaloneSetup(controller).setCustomArgumentResolvers(new AuthenticationPrincipalArgumentResolver()).build();
|
||||
SecurityContextHolder.getContext().setAuthentication(new UsernamePasswordAuthenticationToken(platform(),null,List.of()));
|
||||
mvc.perform(get("/api/platform/v1/last-leg/dispatches/candidates").param("warehouseId","92").param("destinationWarehouseCode","LAX9"))
|
||||
.andExpect(status().isOk());
|
||||
verify(aggregate).candidates(actor,new CandidateQuery("92",null,null,"LAX9",null,null));verifyNoInteractions(planning);
|
||||
}
|
||||
|
||||
@Test
|
||||
void candidatesWithoutSelectedPlansUsePlatformScope() {
|
||||
when(service.platformCandidates(null, null, "RECEIVED", "draft-2")).thenReturn(List.of());
|
||||
|
||||
controller.candidates(platform(), null, null, null, "RECEIVED", "draft-2");
|
||||
|
||||
verify(service).platformCandidates(null, null, "RECEIVED", "draft-2");
|
||||
/** 每个写入都将冻结DTO原样传递,拥有者只能来自认证主体。 */
|
||||
@Test void allDraftRoutesDelegateFrozenVersionedRequests(){
|
||||
var create=new CreateDraft("create","92");var container=new CreateContainer(0L,"container","d",15);
|
||||
var selection=new DraftSelection(1L,"select","d",1L,new DispatchCommands.CargoSelection(DispatchTypes.SourceType.INBOUND_PLAN,1L,null,null,2L,"v"));
|
||||
var snapshot=new DraftSnapshot(2L,"snapshot","d",List.of(3L),List.of());
|
||||
var release=new DraftRelease(3L,"release","d");var convert=new ConvertDraft(4L,"convert","d",List.of());
|
||||
var reason=new DispatchCommands.ReasonCommand(5L,"remove","移除空容器");
|
||||
controller.drafts(platform());controller.createDraft(platform(),create);controller.createContainer(platform(),container);
|
||||
controller.lockDraftPallet(platform(),selection);controller.unlockDraftPallet(platform(),selection);controller.saveDraft(platform(),snapshot);
|
||||
controller.releaseDraft(platform(),release);controller.preArrangeDraft(platform(),convert);controller.deleteContainer(platform(),"d",1L,reason);
|
||||
verify(planning).drafts(actor);verify(planning).createDraft(actor,create);verify(planning).createContainer(actor,container);
|
||||
verify(planning).select(actor,selection);verify(planning).unselect(actor,selection);verify(planning).saveSnapshot(actor,snapshot);
|
||||
verify(planning).release(actor,release);verify(planning).convert(actor,convert);verify(planning).removeContainer(actor,"d",1L,reason);
|
||||
}
|
||||
|
||||
@Test
|
||||
void preArrangeResolvesSingleCustomerAndAuthenticatedOperator() {
|
||||
DispatchRequests.PreArrange request = new DispatchRequests.PreArrange(
|
||||
List.of(10L, 11L), "APT-1", "SUCCESS", "PENDING");
|
||||
when(service.resolveCustomerScopeForSources(List.of(10L, 11L), List.of())).thenReturn("100");
|
||||
|
||||
controller.preArrange(platform(), request);
|
||||
|
||||
verify(service).preArrange("100", "7", request);
|
||||
/** 草稿写权限独立于候选只读,所有操作均不接收客户参数。 */
|
||||
@Test void permissionsAndRemovedStandaloneProtocolMatchFrozenRoutes()throws Exception{
|
||||
var routes=Arrays.stream(SmartDispatchController.class.getDeclaredMethods())
|
||||
.filter(method->org.springframework.core.annotation.AnnotatedElementUtils.findMergedAnnotation(method,RequestMapping.class)!=null).toList();
|
||||
assertThat(routes).hasSize(10);
|
||||
for(var method:routes){
|
||||
var permission=method.getAnnotation(PreAuthorize.class);assertThat(permission).isNotNull();
|
||||
boolean read=Arrays.asList(org.springframework.core.annotation.AnnotatedElementUtils.findMergedAnnotation(method,RequestMapping.class).method()).contains(RequestMethod.GET);
|
||||
String required=read?OverseasPermissions.DISPATCH_LIST:OverseasPermissions.DISPATCH_CREATE;
|
||||
assertThat(permission.value()).isEqualTo("hasAuthority('"+required+"')");
|
||||
assertThat(Arrays.stream(method.getParameters()).filter(p->p.isAnnotationPresent(RequestParam.class))
|
||||
.noneMatch(p->"customerId".equals(p.getAnnotation(RequestParam.class).name()))).isTrue();
|
||||
if(method.isAnnotationPresent(PostMapping.class))assertThat(method.getAnnotation(PostMapping.class).value()).doesNotContain("/pre-arrange");
|
||||
}
|
||||
var remove=SmartDispatchController.class.getMethod("deleteContainer",AuthenticatedUser.class,String.class,Long.class,DispatchCommands.ReasonCommand.class);
|
||||
assertThat(remove.getAnnotation(DeleteMapping.class).value()).containsExactly("/drafts/{draftToken}/containers/{containerId}");
|
||||
assertThat(remove.getParameters()[3].isAnnotationPresent(RequestBody.class)).isTrue();
|
||||
}
|
||||
|
||||
@Test
|
||||
void endpointsUseInboundOrderListAndDedicatedSmartDispatchAuthorities() throws Exception {
|
||||
PreAuthorize candidates = SmartDispatchController.class
|
||||
.getMethod("candidates", AuthenticatedUser.class, List.class, String.class, String.class, String.class,
|
||||
String.class)
|
||||
.getAnnotation(PreAuthorize.class);
|
||||
PreAuthorize preArrange = SmartDispatchController.class
|
||||
.getMethod("preArrange", AuthenticatedUser.class, DispatchRequests.PreArrange.class)
|
||||
.getAnnotation(PreAuthorize.class);
|
||||
|
||||
assertThat(candidates.value()).contains(OverseasPermissions.INBOUND_ORDER_LIST);
|
||||
assertThat(preArrange.value()).contains(OverseasPermissions.INBOUND_ORDER_SMART_DISPATCH);
|
||||
/** 客户即使知道token也不能进入草稿端口。 */
|
||||
@Test void customerCannotReadOrCreateOrConvertDraft(){
|
||||
var merchant=new AuthenticatedUser(7L,100L,100L,8L,"customer","MERCHANT","WEB",Set.of());
|
||||
assertThatThrownBy(()->controller.drafts(merchant)).isInstanceOf(BizException.class);
|
||||
assertThatThrownBy(()->controller.createDraft(merchant,new CreateDraft("key","92"))).isInstanceOf(BizException.class);
|
||||
assertThatThrownBy(()->controller.preArrangeDraft(merchant,new ConvertDraft(0L,"key","d",List.of()))).isInstanceOf(BizException.class);
|
||||
verifyNoInteractions(planning,aggregate);
|
||||
}
|
||||
|
||||
@Test
|
||||
void groupedDraftUsesAuthenticatedOperatorAndDedicatedWriteAuthority() throws Exception {
|
||||
var request = new DispatchRequests.PreArrangeDraft("draft-1", List.of(
|
||||
new DispatchRequests.DraftGroup(1L, List.of(10L), List.of()),
|
||||
new DispatchRequests.DraftGroup(2L, List.of(11L), List.of())));
|
||||
controller.preArrangeDraft(platform(), request);
|
||||
verify(service).preArrangeDraft("7", request);
|
||||
assertThat(SmartDispatchController.class.getMethod("preArrangeDraft", AuthenticatedUser.class,
|
||||
DispatchRequests.PreArrangeDraft.class).getAnnotation(PreAuthorize.class).value())
|
||||
.contains(OverseasPermissions.INBOUND_ORDER_SMART_DISPATCH);
|
||||
org.junit.jupiter.api.Assertions.assertThrows(org.springframework.web.server.ResponseStatusException.class,
|
||||
() -> controller.preArrangeDraft(new AuthenticatedUser(7L, 100L, 0L, 8L, "customer", "MERCHANT", "WEB", Set.of()), request));
|
||||
}
|
||||
|
||||
private static AuthenticatedUser platform() {
|
||||
return new AuthenticatedUser(7L, 0L, 0L, 8L, "operator", "PLATFORM", "WEB",
|
||||
Set.of(OverseasPermissions.INBOUND_ORDER_LIST, OverseasPermissions.INBOUND_ORDER_SMART_DISPATCH));
|
||||
/** DELETE必须实际读取版本化请求体,缺版本拒绝;旧standalone写路由不再存在。 */
|
||||
@Test void deleteReadsVersionedBodyAndLegacyPreArrangeIsMissing()throws Exception{
|
||||
var mvc=MockMvcBuilders.standaloneSetup(controller).setCustomArgumentResolvers(new AuthenticationPrincipalArgumentResolver()).build();
|
||||
SecurityContextHolder.getContext().setAuthentication(new UsernamePasswordAuthenticationToken(platform(),null,List.of()));
|
||||
mvc.perform(delete("/api/platform/v1/last-leg/dispatches/drafts/d/containers/1").contentType("application/json")
|
||||
.content("{\"recordVersion\":0,\"idempotencyKey\":\"remove\",\"reason\":\"empty\"}")).andExpect(status().isOk());
|
||||
verify(planning).removeContainer(actor,"d",1L,new DispatchCommands.ReasonCommand(0L,"remove","empty"));
|
||||
clearInvocations(planning);
|
||||
mvc.perform(delete("/api/platform/v1/last-leg/dispatches/drafts/d/containers/1").contentType("application/json")
|
||||
.content("{\"idempotencyKey\":\"missing\",\"reason\":\"empty\"}")).andExpect(status().isBadRequest());
|
||||
mvc.perform(post("/api/platform/v1/last-leg/dispatches/pre-arrange").contentType("application/json").content("{}")).andExpect(status().isNotFound());
|
||||
verifyNoInteractions(planning);
|
||||
}
|
||||
/** 平台作用域只取真实认证主体。 */
|
||||
private static AuthenticatedUser platform(){return new AuthenticatedUser(7L,0L,0L,8L,"operator","PLATFORM","WEB",Set.of(OverseasPermissions.DISPATCH_LIST,OverseasPermissions.DISPATCH_CREATE));}
|
||||
}
|
||||
|
||||
+68
@@ -0,0 +1,68 @@
|
||||
package com.baiyue.lastleg.dispatch.gateway;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
import static org.assertj.core.api.Assertions.*;
|
||||
|
||||
/** 文件复合归属直接验证,不连接File或把任意文件ID当作有效证据。 */
|
||||
class HttpDispatchEvidenceGatewayTest {
|
||||
/** 元数据存在但磁盘文件缺失的HTTP200错误JSON必须作为失败返回。 */
|
||||
@Test void downloadRejectsHttp200BusinessErrorInsteadOfReturningItAsPdf() {
|
||||
var f=new DownloadFixture();f.respond("{\"code\":\"A0404\",\"message\":\"missing\"}".getBytes(java.nio.charset.StandardCharsets.UTF_8),org.springframework.http.MediaType.APPLICATION_JSON);
|
||||
assertThatThrownBy(()->f.gateway.download(9,DispatchEvidenceGateway.Kind.DRIVER,3)).isInstanceOfSatisfying(ResponseStatusException.class,error->assertThat(error.getStatusCode().value()).isEqualTo(404));f.server.verify();
|
||||
}
|
||||
/** 实际内容必须同时符合权威长度、MIME及既有文件签名要求。 */
|
||||
@Test void downloadChecksMimeLengthAndSignatureAndAcceptsRealPdf() {
|
||||
var good=new DownloadFixture();good.respond(DownloadFixture.PDF,org.springframework.http.MediaType.APPLICATION_PDF);
|
||||
assertThat(good.gateway.download(9,DispatchEvidenceGateway.Kind.DRIVER,3)).containsExactly(DownloadFixture.PDF);good.server.verify();
|
||||
for(int variant=0;variant<3;variant++) {
|
||||
var f=new DownloadFixture();byte[] body=variant==2?java.util.Arrays.copyOf(DownloadFixture.PDF,DownloadFixture.PDF.length+1):variant==1?new byte[DownloadFixture.PDF.length]:DownloadFixture.PDF;
|
||||
f.respond(body,variant==0?org.springframework.http.MediaType.IMAGE_PNG:org.springframework.http.MediaType.APPLICATION_PDF);
|
||||
assertThatThrownBy(()->f.gateway.download(9,DispatchEvidenceGateway.Kind.DRIVER,3)).isInstanceOfSatisfying(ResponseStatusException.class,error->assertThat(error.getStatusCode().value()).isEqualTo(503));f.server.verify();
|
||||
}
|
||||
}
|
||||
/** 格式业务拒绝与真正上游故障分开,不把A0400归到503。 */
|
||||
@Test void businessRejectionRetainsClientFailureStatus() {
|
||||
assertThatThrownBy(()->HttpDispatchEvidenceGateway.verified(new HttpDispatchEvidenceGateway.Envelope("A0400",null),9,DispatchEvidenceGateway.Kind.DRIVER,null))
|
||||
.isInstanceOfSatisfying(ResponseStatusException.class,error->assertThat(error.getStatusCode().value()).isEqualTo(400));
|
||||
assertThatThrownBy(()->HttpDispatchEvidenceGateway.verified(new HttpDispatchEvidenceGateway.Envelope("S0503",null),9,DispatchEvidenceGateway.Kind.DRIVER,null))
|
||||
.isInstanceOfSatisfying(ResponseStatusException.class,error->assertThat(error.getStatusCode().value()).isEqualTo(503));
|
||||
}
|
||||
/** 平台本车本类证据才能成为可审核事实。 */
|
||||
@Test void verifiesCurrentTruckPlatformAndKind() {
|
||||
var own=new HttpDispatchEvidenceGateway.Metadata(3L,0L,"OVERSEAS_DISPATCH_DRIVER_EVIDENCE","9","license.pdf","application/pdf",128L);
|
||||
var result=HttpDispatchEvidenceGateway.verified(new HttpDispatchEvidenceGateway.Envelope("200",own),9,DispatchEvidenceGateway.Kind.DRIVER,3L);
|
||||
assertThat(result.fileId()).isEqualTo(3L);
|
||||
assertThatThrownBy(()->HttpDispatchEvidenceGateway.verified(new HttpDispatchEvidenceGateway.Envelope("200",own),10,DispatchEvidenceGateway.Kind.DRIVER,3L)).isInstanceOf(ResponseStatusException.class);
|
||||
assertThatThrownBy(()->HttpDispatchEvidenceGateway.verified(new HttpDispatchEvidenceGateway.Envelope("200",own),9,DispatchEvidenceGateway.Kind.VEHICLE,3L)).isInstanceOf(ResponseStatusException.class);
|
||||
assertThatThrownBy(()->HttpDispatchEvidenceGateway.verified(new HttpDispatchEvidenceGateway.Envelope("200",own),9,DispatchEvidenceGateway.Kind.DRIVER,4L)).isInstanceOf(ResponseStatusException.class);
|
||||
}
|
||||
/** 客户、旧主档或缺失媒体类型均拒绝,不因为文件本身存在而放行。 */
|
||||
@Test void rejectsForeignTenantMasterScopeAndMalformedMetadata() {
|
||||
for(var value:java.util.List.of(
|
||||
new HttpDispatchEvidenceGateway.Metadata(3L,101L,"OVERSEAS_DISPATCH_DRIVER_EVIDENCE","9","license.pdf","application/pdf",128L),
|
||||
new HttpDispatchEvidenceGateway.Metadata(3L,0L,"FLEET_DRIVER_LICENSE","9","license.pdf","application/pdf",128L),
|
||||
new HttpDispatchEvidenceGateway.Metadata(3L,0L,"OVERSEAS_DISPATCH_DRIVER_EVIDENCE","9","license.pdf",null,128L)))
|
||||
assertThatThrownBy(()->HttpDispatchEvidenceGateway.verified(new HttpDispatchEvidenceGateway.Envelope("200",value),9,DispatchEvidenceGateway.Kind.DRIVER,3L)).isInstanceOf(ResponseStatusException.class);
|
||||
}
|
||||
/** 使用Spring内存HTTP提供者,不连接File或其他运行环境。 */
|
||||
private static final class DownloadFixture {
|
||||
static final byte[] PDF="%PDF-1.7\nTEST\n".getBytes(java.nio.charset.StandardCharsets.US_ASCII);
|
||||
final org.springframework.test.web.client.MockRestServiceServer server;
|
||||
final HttpDispatchEvidenceGateway gateway;
|
||||
/** 固定客户端请求工厂,只有明示的模拟响应能被读取。 */
|
||||
DownloadFixture() {
|
||||
var builder=org.springframework.web.client.RestClient.builder();server=org.springframework.test.web.client.MockRestServiceServer.bindTo(builder).build();
|
||||
var factory=org.mockito.Mockito.mock(com.baiyue.lastleg.config.LastlegRestClientFactory.class);
|
||||
org.mockito.Mockito.when(factory.build(org.mockito.ArgumentMatchers.any(),org.mockito.ArgumentMatchers.anyString(),org.mockito.ArgumentMatchers.any())).thenReturn(builder.baseUrl("https://file.invalid").build());
|
||||
gateway=new HttpDispatchEvidenceGateway(builder,factory,new com.baiyue.lastleg.config.LastlegRestClientProperties(),"https://file.invalid","offline-test");
|
||||
}
|
||||
/** 先返回有效本车元数据,再精确提供要验证的内容响应。 */
|
||||
void respond(byte[] body,org.springframework.http.MediaType mime) {
|
||||
String url="https://file.invalid/internal/file/v1/business-files/0/OVERSEAS_DISPATCH_DRIVER_EVIDENCE/9/3";
|
||||
server.expect(org.springframework.test.web.client.match.MockRestRequestMatchers.requestTo(url)).andRespond(org.springframework.test.web.client.response.MockRestResponseCreators.withSuccess(
|
||||
"{\"code\":\"200\",\"data\":{\"fileId\":3,\"tenantId\":0,\"businessType\":\"OVERSEAS_DISPATCH_DRIVER_EVIDENCE\",\"businessId\":\"9\",\"fileName\":\"license.pdf\",\"contentType\":\"application/pdf\",\"sizeBytes\":"+PDF.length+"}}",org.springframework.http.MediaType.APPLICATION_JSON));
|
||||
server.expect(org.springframework.test.web.client.match.MockRestRequestMatchers.requestTo(url+"/content")).andRespond(org.springframework.test.web.client.response.MockRestResponseCreators.withSuccess(body,mime));
|
||||
}
|
||||
}
|
||||
}
|
||||
+10
-10
@@ -117,7 +117,7 @@ class DispatchPdaWorkflowServiceTest {
|
||||
() -> service.task("WH-1", "DSP-1"));
|
||||
|
||||
assertEquals(404, exception.getStatusCode().value());
|
||||
verify(tasks, never()).findByDispatch(any(), any());
|
||||
verify(tasks, never()).findByDispatch(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -128,7 +128,7 @@ class DispatchPdaWorkflowServiceTest {
|
||||
DispatchItem item = item(21L, "100", 6);
|
||||
item.setInboundCode("IN-1");
|
||||
when(orders.findGlobalByDispatchNo("DSP-1")).thenReturn(order);
|
||||
when(tasks.findByDispatch("100", 1L)).thenReturn(task);
|
||||
when(tasks.findByDispatch(1L)).thenReturn(task);
|
||||
when(items.findByDispatchOrder("100", 1L)).thenReturn(List.of(item));
|
||||
when(picks.totalItem("100", 11L, 21L)).thenReturn(6L);
|
||||
|
||||
@@ -147,7 +147,7 @@ class DispatchPdaWorkflowServiceTest {
|
||||
OutboundOrder outbound = new OutboundOrder();
|
||||
outbound.setStatus("WAIT_OUTBOUND");
|
||||
when(orders.findGlobalByDispatchNo("DSP-1")).thenReturn(order);
|
||||
when(tasks.findByDispatch("100", 1L)).thenReturn(task);
|
||||
when(tasks.findByDispatch(1L)).thenReturn(task);
|
||||
when(items.findByDispatchOrder("100", 1L)).thenReturn(List.of(item));
|
||||
when(picks.totalItem("100", 11L, 21L)).thenReturn(6L);
|
||||
when(outboundOrders.findByDispatch("100", 1L)).thenReturn(outbound);
|
||||
@@ -168,7 +168,7 @@ class DispatchPdaWorkflowServiceTest {
|
||||
DispatchPdaResponses.Picked persisted = new DispatchPdaResponses.Picked(21L, 31L, 2, 2, false, false);
|
||||
DispatchPdaOperation replay = operation("PICK", request, persisted);
|
||||
when(orders.findGlobalByDispatchNo("DSP-1")).thenReturn(order);
|
||||
when(tasks.lockByDispatch("100", 1L)).thenReturn(task);
|
||||
when(tasks.lockByDispatch(1L)).thenReturn(task);
|
||||
when(operations.findKey("100", "pick-1")).thenReturn(replay);
|
||||
when(orders.lockById("100", 1L)).thenReturn(order);
|
||||
|
||||
@@ -194,7 +194,7 @@ class DispatchPdaWorkflowServiceTest {
|
||||
DispatchPdaPick pick = new DispatchPdaPick(); pick.setPackages(3L);
|
||||
when(orders.findGlobalByDispatchNo("DSP-1")).thenReturn(order);
|
||||
when(orders.lockById("100", 1L)).thenReturn(order);
|
||||
when(tasks.lockByDispatch("100", 1L)).thenReturn(task);
|
||||
when(tasks.lockByDispatch(1L)).thenReturn(task);
|
||||
when(items.findByDispatchOrder("100", 1L)).thenReturn(List.of(item));
|
||||
when(picks.listTask("100", 11L)).thenReturn(List.of(pick));
|
||||
|
||||
@@ -216,7 +216,7 @@ class DispatchPdaWorkflowServiceTest {
|
||||
DispatchPdaRequests.CompletePicking request = new DispatchPdaRequests.CompletePicking("done-1");
|
||||
when(orders.findGlobalByDispatchNo("DSP-1")).thenReturn(order);
|
||||
when(orders.lockById("100", 1L)).thenReturn(order);
|
||||
when(tasks.lockByDispatch("100", 1L)).thenReturn(task);
|
||||
when(tasks.lockByDispatch(1L)).thenReturn(task);
|
||||
when(items.findByDispatchOrder("100", 1L)).thenReturn(List.of(item));
|
||||
when(picks.listTask("100", 11L)).thenReturn(List.of(pick));
|
||||
|
||||
@@ -250,7 +250,7 @@ class DispatchPdaWorkflowServiceTest {
|
||||
DispatchOrder order = order("100", "DSP-1", "WH-1", "PENDING_DEPARTURE"); order.setVehiclePlate("CA-100");
|
||||
when(orders.findGlobalByDispatchNo("DSP-1")).thenReturn(order);
|
||||
when(orders.lockById("100", 1L)).thenReturn(order);
|
||||
when(tasks.lockByDispatch("100", 1L)).thenReturn(task(11L, "100", "WH-1", "PDA_PENDING"));
|
||||
when(tasks.lockByDispatch(1L)).thenReturn(task(11L, "100", "WH-1", "PDA_PENDING"));
|
||||
org.mockito.Mockito.doThrow(new ResponseStatusException(org.springframework.http.HttpStatus.CONFLICT, "请先通过调度审核再拣货"))
|
||||
.when(fleetBindings).requirePickingApproved(1L);
|
||||
assertThrows(ResponseStatusException.class, () -> service.pick("WH-1", "DSP-1", 21L, "7", "作业员", new DispatchPdaRequests.Pick(31L, 2, 0, "pending-pick", null)));
|
||||
@@ -287,7 +287,7 @@ class DispatchPdaWorkflowServiceTest {
|
||||
when(orders.findGlobalByDispatchNo("DSP-1")).thenReturn(order);
|
||||
when(orders.lockById("100", 1L)).thenReturn(order);
|
||||
when(outboundOrders.lockByDispatch("100", 1L)).thenReturn(outbound);
|
||||
when(tasks.lockByDispatch("100", 1L)).thenReturn(task);
|
||||
when(tasks.lockByDispatch(1L)).thenReturn(task);
|
||||
when(items.findByDispatchOrder("100", 1L)).thenReturn(List.of(item));
|
||||
when(picks.totalItem("100", 11L, 21L)).thenReturn(6L);
|
||||
when(picks.listTask("100", 11L)).thenReturn(List.of(pick));
|
||||
@@ -340,7 +340,7 @@ class DispatchPdaWorkflowServiceTest {
|
||||
item.setSourceStage("RETURN_RELABEL"); item.setSourceBusinessType("RELABEL_ORDER"); item.setOrderId(101L);
|
||||
when(orders.findGlobalByDispatchNo("DSP-1")).thenReturn(order);
|
||||
when(orders.lockById("100", 1L)).thenReturn(order);
|
||||
when(tasks.lockByDispatch("100", 1L)).thenReturn(task);
|
||||
when(tasks.lockByDispatch(1L)).thenReturn(task);
|
||||
when(items.selectById(21L)).thenReturn(item);
|
||||
var carton = new com.baiyue.lastleg.returnreceipt.entity.ReturnInventoryCarton(); carton.setId(71L);
|
||||
when(returnStock.pick(org.mockito.ArgumentMatchers.eq(item), org.mockito.ArgumentMatchers.eq("RC1#2-1"),
|
||||
@@ -366,7 +366,7 @@ class DispatchPdaWorkflowServiceTest {
|
||||
|
||||
private static DispatchPdaTask task(Long id, String customerId, String warehouseId, String status) {
|
||||
DispatchPdaTask value = new DispatchPdaTask();
|
||||
value.setId(id); value.setCustomerId(customerId); value.setDispatchOrderId(1L);
|
||||
value.setId(id); value.setDispatchOrderId(1L);
|
||||
value.setTaskNo("DPDA-1"); value.setWarehouseId(warehouseId); value.setStatus(status);
|
||||
return value;
|
||||
}
|
||||
|
||||
+72
@@ -0,0 +1,72 @@
|
||||
package com.baiyue.lastleg.dispatch.port;
|
||||
|
||||
import java.lang.reflect.RecordComponent;
|
||||
import java.util.Arrays;
|
||||
import java.util.Set;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
import static com.baiyue.lastleg.dispatch.port.DispatchTypes.*;
|
||||
|
||||
/** 可独立用JDK17编译执行的边界合同,不启动Spring或连接环境。 */
|
||||
public final class DispatchPortCompileContract {
|
||||
/** 合同执行器不允许实例化。 */
|
||||
private DispatchPortCompileContract() { }
|
||||
|
||||
/** 检查下游使用的真实类型、版本域及单据数组,不模拟业务成功。 */
|
||||
public static void main(String[] args) throws Exception {
|
||||
require(Stage.values().length == 5, "阶段必须恰好五项");
|
||||
require(Set.of(Status.values()).equals(Set.of(Status.ACTIVE, Status.CANCELLED)), "生命周期与阶段分离");
|
||||
for (Class<?> request : new Class<?>[] {
|
||||
DispatchCommands.Update.class, DispatchCommands.AddCargo.class,
|
||||
DispatchCommands.ReasonCommand.class, DispatchCommands.SubmitReview.class,
|
||||
DispatchCommands.ReviewCommand.class, DispatchCommands.ReturnToReview.class,
|
||||
DispatchCommands.Pick.class, DispatchCommands.LoadingComplete.class,
|
||||
DispatchCommands.Depart.class, DispatchDeliveryModels.ArrangeDelivery.class,
|
||||
DispatchPlanningModels.CreateContainer.class, DispatchPlanningModels.DraftSelection.class,
|
||||
DispatchPlanningModels.DraftSnapshot.class, DispatchPlanningModels.DraftRelease.class,
|
||||
DispatchPlanningModels.ConvertDraft.class,DispatchContracts.BatchAllocation.class,DispatchContracts.BatchReference.class
|
||||
}) {
|
||||
require(request.getMethod("recordVersion").getReturnType() == Long.class, "公开版本必须保留缺字段事实");
|
||||
require(!fields(request).contains("expectedVersion"), "禁止第二个公开版本别名");
|
||||
}
|
||||
require(new DispatchCommands.Depart(0L, "contract-only", null).recordVersion() == 0, "版本零必须可表达");
|
||||
try {
|
||||
new DispatchCommands.Depart(null, "contract-only", null);
|
||||
throw new AssertionError("缺版本必须失败");
|
||||
} catch (IllegalArgumentException expected) {
|
||||
require(expected.getMessage().contains("recordVersion"), "明确说明缺失版本");
|
||||
}
|
||||
require(!fields(DispatchCommands.Create.class).contains("customerId"), "整车创建无单客户头部");
|
||||
require(!fields(DispatchContracts.Context.class).contains("customerId"), "整车上下文无伪客户");
|
||||
require(fields(DispatchContracts.CargoLine.class).contains("customerId"), "真实来源保留客户");
|
||||
require(!fields(DispatchCommands.LoadingComplete.class).contains("packages"), "装车确认不补录拣货数量");
|
||||
require(fields(DispatchDeliveryModels.ArrangementResult.class).containsAll(Set.of("outbounds", "deliveries")),
|
||||
"安排返回完整客户单据数组");
|
||||
require(!fields(DispatchDeliveryModels.ArrangementResult.class).contains("outboundNo"), "无单出库别名");
|
||||
require(fields(DispatchContracts.CommandResult.class).containsAll(Set.of("replayed", "alreadyArranged")),
|
||||
"原命令重放与引用既有安排分别表达");
|
||||
require(fields(DispatchViews.Detail.class).containsAll(Set.of("vehicleInfo", "dispatchInfo", "documents", "orderDetails")),
|
||||
"详情四对象齐备");
|
||||
require(DispatchExecutionPort.class.getMethod("recordArrangement", DispatchContracts.Context.class,
|
||||
DispatchContracts.Actor.class, DispatchContracts.CommandIdentity.class,
|
||||
DispatchDeliveryModels.ArrangementFact.class, boolean.class).getReturnType() == DispatchContracts.CommandResult.class,
|
||||
"派送写者可静态引用主流程记录端口");
|
||||
require(DispatchCompletionPort.class.getMethod("reconcile", DispatchDeliveryModels.Signal.class)
|
||||
.getReturnType() == DispatchDeliveryModels.CompletionResult.class, "汇总消费明确持久信号身份");
|
||||
require(DispatchAppointmentAmendmentPort.class.getMethod("lockExistingAppointmentDispatches",DispatchContracts.Actor.class,java.util.List.class).getReturnType()==Void.TYPE,
|
||||
"原预约消费者只取得有界全范围根锁,不再绑定客户排车");
|
||||
require(DispatchAppointmentAmendmentPort.class.getMethod("applyExistingAppointmentChange",DispatchContracts.Actor.class,String.class,DispatchCommands.ReasonCommand.class,
|
||||
java.util.List.class,java.util.List.class,java.util.List.class,boolean.class).getReturnType()==DispatchContracts.CommandResult.class,"原预约改量返回公共不可变命令结果");
|
||||
System.out.println("DispatchPortCompileContract: PASS (JDK-only public contract; no HTTP/database/Spring)");
|
||||
}
|
||||
|
||||
/** 按真实record组件检查公开字段,避免另维护一份模拟DTO。 */
|
||||
private static Set<String> fields(Class<?> type) {
|
||||
return Arrays.stream(type.getRecordComponents()).map(RecordComponent::getName).collect(Collectors.toSet());
|
||||
}
|
||||
|
||||
/** 合同不成立时直接失败,不能作为已通过编译报告。 */
|
||||
private static void require(boolean actual, String message) {
|
||||
if (!actual) throw new AssertionError(message);
|
||||
}
|
||||
}
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
package com.baiyue.lastleg.dispatch.port;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
/** Maven集成执行与独立编译使用同一真实合同。 */
|
||||
class DispatchPublicContractTest {
|
||||
/** 检查公开字段和端口静态类型。 */
|
||||
@Test void compilesConsumerContract() throws Exception { DispatchPortCompileContract.main(new String[0]); }
|
||||
|
||||
/** 验证Jackson缺字段和持久命令跨实例恢复。 */
|
||||
@Test void restoresTypedPersistentResults() throws Exception { DispatchPublicJsonContract.main(new String[0]); }
|
||||
}
|
||||
+107
@@ -0,0 +1,107 @@
|
||||
package com.baiyue.lastleg.dispatch.port;
|
||||
|
||||
import com.baiyue.lastleg.dispatch.service.DispatchCommandCodec;
|
||||
import com.fasterxml.jackson.core.JsonProcessingException;
|
||||
import com.fasterxml.jackson.databind.DeserializationFeature;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.fasterxml.jackson.databind.SerializationFeature;
|
||||
import com.fasterxml.jackson.databind.module.SimpleModule;
|
||||
import com.fasterxml.jackson.databind.ser.std.ToStringSerializer;
|
||||
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
|
||||
import static com.baiyue.lastleg.dispatch.port.DispatchContracts.*;
|
||||
import static com.baiyue.lastleg.dispatch.port.DispatchDeliveryModels.*;
|
||||
import static com.baiyue.lastleg.dispatch.port.DispatchTypes.*;
|
||||
|
||||
/** 使用真实Jackson和账本编解码器验证输入及跨实例恢复,不启动应用。 */
|
||||
public final class DispatchPublicJsonContract {
|
||||
/** 测试执行器不允许实例化。 */
|
||||
private DispatchPublicJsonContract() { }
|
||||
|
||||
/** 覆盖缺版本、显式零、预约只写引用及所有动作结果持久JSON恢复。 */
|
||||
public static void main(String[] args) throws Exception {
|
||||
ObjectMapper requests = mapper();
|
||||
for (Class<?> request : new Class<?>[] {
|
||||
DispatchCommands.Update.class, DispatchCommands.AddCargo.class,
|
||||
DispatchCommands.ReasonCommand.class, DispatchCommands.SubmitReview.class,
|
||||
DispatchCommands.ReviewCommand.class, DispatchCommands.ReturnToReview.class,
|
||||
DispatchCommands.Pick.class, DispatchCommands.LoadingComplete.class,
|
||||
DispatchCommands.Depart.class, ArrangeDelivery.class,
|
||||
DispatchPlanningModels.CreateContainer.class, DispatchPlanningModels.DraftSelection.class,
|
||||
DispatchPlanningModels.DraftSnapshot.class, DispatchPlanningModels.DraftRelease.class,
|
||||
DispatchPlanningModels.ConvertDraft.class,DispatchWorkModels.RouteCommand.class,
|
||||
BatchAllocation.class,BatchReference.class
|
||||
}) {
|
||||
rejectsMissing(requests, request, "{}");
|
||||
rejectsMissing(requests, request, "{\"recordVersion\":null}");
|
||||
rejectsMissing(requests, request, "{\"recordVersion\":-1}");
|
||||
Object zero = requests.readValue("{\"recordVersion\":0}", request);
|
||||
require(Long.valueOf(0).equals(request.getMethod("recordVersion").invoke(zero)), "显式零版本必须合法");
|
||||
}
|
||||
var reference = requests.readValue("{\"referenceNo\":\"EXISTING-AP\",\"recordVersion\":0}",
|
||||
DispatchCommands.AppointmentReference.class);
|
||||
require("EXISTING-AP".equals(reference.referenceNo()), "预约只保留权威引用");
|
||||
rejectsMissing(requests, DispatchCommands.AppointmentReference.class,
|
||||
"{\"referenceNo\":\"EXISTING-AP\",\"recordVersion\":0,\"status\":\"CONFIRMED\",\"appointmentAt\":\"2026-10-19T00:00:00Z\"}");
|
||||
|
||||
Instant now = Instant.parse("2026-10-19T00:00:00Z");
|
||||
var outbound = new OutboundReference("101", "客户甲", "OB-CONTRACT", "OUTBOUNDED", 10, 1);
|
||||
var destination = new Destination("FBA:ONT8", "FBA", "1", "ONT8", "目的仓", null, null, null, null);
|
||||
var delivery = new DeliveryReference("101:FBA:ONT8", "101", "客户甲", "OB-CONTRACT", "DL-CONTRACT",
|
||||
destination, "DELIVERING", 10, 1);
|
||||
for (Action action : Action.values()) {
|
||||
DispatchResults.Payload payload = switch (action) {
|
||||
case PICK -> new DispatchResults.Picked(1L, 2L, null, 1, 2, false);
|
||||
case LOADING_COMPLETE -> new DispatchResults.Loaded("load-hash", now,
|
||||
new ConfirmedLoad("manifest-hash", List.of(new LoadingMeasurement(7L, 1,
|
||||
new BigDecimal("10.000"), new BigDecimal("1.000"), 1)), "operator-a", now));
|
||||
case DEPART -> new DispatchResults.Departed(now, 10, List.of(outbound), List.of(delivery));
|
||||
case ARRANGE_DELIVERY -> new ArrangementResult("DP-CONTRACT", 0, "ARR-CONTRACT", "cargo-hash",
|
||||
now, List.of(outbound), List.of(delivery));
|
||||
default -> new DispatchResults.Saved("DP-CONTRACT", List.of(1L, 2L));
|
||||
};
|
||||
var original = new CommandResult("DP-CONTRACT", action, "contract-" + action, 0,
|
||||
Status.ACTIVE, Stage.AFTER_LOADING, now, Effect.APPLIED,
|
||||
new Counts(2, 1, 10, 10, 0), payload, false, false);
|
||||
String stored = new DispatchCommandCodec(mapper()).encode(original);
|
||||
// 更换全部ObjectMapper/Codec实例后只从持久JSON恢复,不能依赖内存对象类型。
|
||||
var fresh = new DispatchCommandCodec(mapper());
|
||||
require(original.equals(fresh.decode(new String(stored.toCharArray()))), "全部动作必须恢复原不可变结果:" + action);
|
||||
var replayed = fresh.replay(stored);
|
||||
require(replayed.replayed() && replayed.recordVersion() == 0 && replayed.result().equals(payload)
|
||||
&& replayed.occurredAt().equals(now), "重放不能篡改动作版本/时间/结果:" + action);
|
||||
}
|
||||
var codec = new DispatchCommandCodec(mapper());
|
||||
try {
|
||||
codec.decode("{\"action\":\"PICK\",\"recordVersion\":0,\"result\":{\"dispatchNo\":\"DP-CONTRACT\"}}");
|
||||
throw new AssertionError("损坏或错误动作结果不得恢复为成功");
|
||||
} catch (IllegalStateException expected) { require(expected.getCause() != null, "保留损坏原因"); }
|
||||
System.out.println("DispatchPublicJsonContract: PASS (16 requests and 2 batch records missing/null/negative/zero; appointment reference; all action JSON restart/replay)");
|
||||
}
|
||||
|
||||
/** 与正式Long按字符串输出约定共同验证账本恢复。 */
|
||||
private static ObjectMapper mapper() {
|
||||
SimpleModule longs = new SimpleModule();
|
||||
longs.addSerializer(Long.class, ToStringSerializer.instance);
|
||||
longs.addSerializer(Long.TYPE, ToStringSerializer.instance);
|
||||
return new ObjectMapper().registerModule(new JavaTimeModule()).registerModule(longs)
|
||||
.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS)
|
||||
.enable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES);
|
||||
}
|
||||
|
||||
/** 使用真实JSON反序列化证明缺版本不能隐式变成零。 */
|
||||
private static void rejectsMissing(ObjectMapper mapper, Class<?> type, String payload) throws Exception {
|
||||
try { mapper.readValue(payload, type); }
|
||||
catch (JsonProcessingException expected) { return; }
|
||||
throw new AssertionError("请求必须拒绝缺失/非法版本或伪造预约字段:" + type.getSimpleName());
|
||||
}
|
||||
|
||||
/** 离线合同失败时终止,不模拟接口成功。 */
|
||||
private static void require(boolean actual, String message) {
|
||||
if (!actual) throw new AssertionError(message);
|
||||
}
|
||||
}
|
||||
+331
@@ -0,0 +1,331 @@
|
||||
package com.baiyue.lastleg.dispatch.service;
|
||||
|
||||
import com.baomidou.mybatisplus.core.MybatisConfiguration;
|
||||
import com.baomidou.mybatisplus.core.metadata.TableInfoHelper;
|
||||
import com.baomidou.mybatisplus.extension.spring.MybatisSqlSessionFactoryBean;
|
||||
import com.baiyue.common.core.exception.BizException;
|
||||
import com.baiyue.lastleg.dispatch.entity.DispatchAggregate;
|
||||
import com.baiyue.lastleg.dispatch.mapper.*;
|
||||
import com.baiyue.lastleg.dispatch.model.DispatchPalletCandidate;
|
||||
import com.baiyue.lastleg.dispatch.port.*;
|
||||
import com.baiyue.lastleg.dispatch.repository.*;
|
||||
import com.baiyue.lastleg.delivery.mapper.DeliveryOrderMapper;
|
||||
import com.baiyue.lastleg.delivery.mapper.OutboundOrderMapper;
|
||||
import com.baiyue.lastleg.fleet.driver.*;
|
||||
import com.baiyue.lastleg.fleet.trip.*;
|
||||
import com.baiyue.lastleg.masterdata.vehicle.application.port.out.VehicleRepository;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.mybatis.spring.SqlSessionTemplate;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.jdbc.datasource.DataSourceTransactionManager;
|
||||
import org.springframework.jdbc.datasource.DriverManagerDataSource;
|
||||
import org.springframework.transaction.TransactionDefinition;
|
||||
import org.springframework.transaction.support.TransactionTemplate;
|
||||
|
||||
import java.lang.reflect.Modifier;
|
||||
import java.math.BigDecimal;
|
||||
import java.time.*;
|
||||
import java.util.*;
|
||||
import static org.assertj.core.api.Assertions.*;
|
||||
import static org.mockito.ArgumentMatchers.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
import static com.baiyue.lastleg.dispatch.port.DispatchTypes.*;
|
||||
|
||||
/** 主单/资源/占用/账本真实SQL回归;外部目录与选货权威提供者使用受控输入。 */
|
||||
public class DispatchAggregatePersistenceTest {
|
||||
/** 移货、取消只释放本车原占用;重排后旧键恢复不得释放新车的活动实托键。 */
|
||||
@Test void removalCancellationAndOldCommandRecoveryNeverReleaseNewTruckClaims() throws Exception {
|
||||
var f=new Fixture();var a=f.source(1,"101",501,"ONT8",3);var b=f.source(2,"202",502,"LAX9",5);
|
||||
var created=f.tx.execute(s->f.service.create(f.actor,f.configured("remove-cancel-create",List.of(a,b))));
|
||||
long item=f.db.queryForObject("SELECT id FROM ll_dispatch_item WHERE customer_id='101'",Long.class);
|
||||
var removeRequest=new DispatchCommands.ReasonCommand(created.recordVersion(),"remove-original","重新分车");
|
||||
var removed=f.tx.execute(s->f.service.removeCargo(f.actor,created.dispatchNo(),item,removeRequest));
|
||||
assertThat(removed.counts().packageCount()).isEqualTo(5);assertThat(f.db.queryForObject("SELECT COUNT(*) FROM ll_dispatch_pallet_lock WHERE warehouse_pallet_id=501 AND active_warehouse_pallet_id IS NOT NULL",Long.class)).isZero();
|
||||
var newOwner=f.tx.execute(s->f.service.create(f.actor,f.create("new-owner",List.of(a))));
|
||||
assertThat(f.tx.execute(s->f.service.removeCargo(f.actor,created.dispatchNo(),item,removeRequest)).replayed()).isTrue();
|
||||
assertThat(f.db.queryForObject("SELECT dispatch_order_id FROM ll_dispatch_pallet_lock WHERE active_warehouse_pallet_id=501",Long.class)).isEqualTo(f.repository.find(newOwner.dispatchNo()).getId());
|
||||
var cancelRequest=new DispatchCommands.ReasonCommand(removed.recordVersion(),"cancel-original","本趟取消");
|
||||
var cancelled=f.tx.execute(s->f.service.cancel(f.actor,created.dispatchNo(),cancelRequest));
|
||||
assertThat(cancelled.status()).isEqualTo(Status.CANCELLED);assertThat(cancelled.stage()).isEqualTo(Stage.BEFORE_LOADING);
|
||||
assertThat(f.db.queryForObject("SELECT status FROM ll_fleet_trip WHERE id=?",String.class,f.repository.find(created.dispatchNo()).getTripId())).isEqualTo("CANCELLED");
|
||||
f.tx.executeWithoutResult(s->f.service.create(f.actor,f.create("new-owner-two",List.of(b))));
|
||||
assertThat(f.tx.execute(s->f.service.cancel(f.actor,created.dispatchNo(),cancelRequest)).replayed()).isTrue();
|
||||
assertThat(f.db.queryForObject("SELECT COUNT(*) FROM ll_dispatch_pallet_lock WHERE status='ACTIVE'",Long.class)).isEqualTo(2);
|
||||
}
|
||||
/** 作业读取返回真实明细身份,路线保存重排实际站序且保留起运仓。 */
|
||||
@Test void operationItemsAndRouteUseActualIdsAndPersistedStopOrder() throws Exception {
|
||||
var f=new Fixture();var a=f.source(1,"101",501,"ONT8",3);var b=f.source(2,"202",502,"LAX9",5);
|
||||
var created=f.tx.execute(s->f.service.create(f.actor,f.configured("operation-reads",List.of(a,b))));
|
||||
var items=f.tx.execute(s->f.service.items(f.actor,created.dispatchNo()));
|
||||
assertThat(items.items()).hasSize(2);assertThat(items.items().get(0).dispatchItemId()).isNotEqualTo(501L);
|
||||
assertThat(items.items().get(0).sourceKey()).isEqualTo("PLAN:1");assertThat(items.recordVersion()).isEqualTo(created.recordVersion());
|
||||
var before=f.tx.execute(s->f.service.route(f.actor,created.dispatchNo()));
|
||||
var keys=new ArrayList<>(before.stops().stream().skip(1).map(DispatchWorkModels.Stop::destinationKey).toList());Collections.reverse(keys);
|
||||
var request=new DispatchWorkModels.RouteCommand(created.recordVersion(),"actual-route",keys,"调整卸货先后");
|
||||
var saved=f.tx.execute(s->f.service.reorderRoute(f.actor,created.dispatchNo(),request));
|
||||
var after=f.tx.execute(s->f.service.route(f.actor,created.dispatchNo()));
|
||||
assertThat(after.stops().get(0)).isEqualTo(before.stops().get(0));assertThat(after.stops().stream().skip(1).map(DispatchWorkModels.Stop::destinationKey)).containsExactlyElementsOf(keys);
|
||||
assertThat(after.recordVersion()).isEqualTo(saved.recordVersion());
|
||||
assertThat(f.tx.execute(s->f.service.reorderRoute(f.actor,created.dispatchNo(),request)).replayed()).isTrue();
|
||||
assertThatThrownBy(()->f.tx.execute(s->f.service.reorderRoute(f.actor,created.dispatchNo(),new DispatchWorkModels.RouteCommand(saved.recordVersion(),"invent-route",List.of("FBA:INVENTED"),"缺失实际目的地")))).isInstanceOf(org.springframework.web.server.ResponseStatusException.class);
|
||||
}
|
||||
/** 实际扫码和来源SQL核平后仍停在装车中,显式确认才进入装车后且保存原命令快照。 */
|
||||
@Test void loadingUsesActualSourcesMeasurementsAndNeverAutoCompletesFromCounters() throws Exception {
|
||||
var f=new Fixture();var a=f.source(1,"101",501,"ONT8",3);var b=f.source(2,"202",502,"LAX9",5);
|
||||
var created=f.tx.execute(s->f.service.create(f.actor,f.configured("load-create",List.of(a,b))));
|
||||
var approved=f.approve(created);
|
||||
f.picked(1,"101",501,3);f.picked(2,"202",502,5);
|
||||
f.db.update("UPDATE ll_dispatch_order SET picked_package_count=8");
|
||||
f.db.update("INSERT INTO ll_warehouse_transfer_order(id,customer_id,transfer_no)VALUES(81,'101','WT-ACTUAL')");
|
||||
f.db.update("INSERT INTO ll_warehouse_transfer_pallet(transfer_order_id,customer_id,warehouse_pallet_id,carton_count,status,completed_at,gross_weight_kg,volume_cbm)VALUES(81,'101',501,3,'COMPLETED',CURRENT_TIMESTAMP,100,1)");
|
||||
var context=f.tx.execute(s->f.service.preview(f.actor,created.dispatchNo()));
|
||||
assertThat(context.pickingReconciled()).isTrue();assertThat(context.cargo()).hasSize(2);
|
||||
assertThat(context.cargo()).extracting(DispatchContracts.CargoLine::customerId).containsExactly("101","202");
|
||||
assertThat(context.stage()).isEqualTo(Stage.LOADING);
|
||||
var preview=f.tx.execute(s->f.service.loading(f.actor,created.dispatchNo()));
|
||||
assertThat(preview.pallets().get(0).weightKg()).isEqualByComparingTo("100");
|
||||
assertThat(preview.pallets().get(0).evidence()).allMatch(DispatchPlanningModels.MeasurementEvidence::actual);
|
||||
assertThat(preview.pallets().get(1).weightKg()).isNull();assertThat(preview.pallets().get(1).missingFields()).contains("weightKg","volumeCbm","palletSpaces");
|
||||
var measurements=preview.pallets().stream().map(p->new DispatchContracts.LoadingMeasurement(p.warehousePalletId(),1,
|
||||
p.warehousePalletId()==501L?new BigDecimal("100"):new BigDecimal("200"),p.warehousePalletId()==501L?BigDecimal.ONE:new BigDecimal("2"),p.deliveryIndex())).toList();
|
||||
var request=new DispatchCommands.LoadingComplete(approved.recordVersion(),"explicit-loaded",preview.manifestHash(),measurements,"实际核对");
|
||||
var loaded=f.tx.execute(s->f.service.loadingComplete(f.actor,created.dispatchNo(),request));
|
||||
assertThat(loaded.stage()).isEqualTo(Stage.AFTER_LOADING);assertThat(loaded.result()).isInstanceOf(DispatchResults.Loaded.class);
|
||||
assertThat(f.db.queryForObject("SELECT status FROM ll_fleet_trip",String.class)).isEqualTo("PLANNED");
|
||||
var replay=f.tx.execute(s->f.service.loadingComplete(f.actor,created.dispatchNo(),request));
|
||||
assertThat(replay.replayed()).isTrue();assertThat(replay.result()).isEqualTo(loaded.result());
|
||||
assertThat(f.db.queryForObject("SELECT COUNT(*) FROM ll_dispatch_pda_pick",Long.class)).isEqualTo(2);
|
||||
}
|
||||
|
||||
/** 合计相等但某项漏拣或归属缺失时,公共端口不能报告已核平。 */
|
||||
@Test void incompleteAttributionCannotPassLoadingThroughMatchingHeaderTotals() throws Exception {
|
||||
var f=new Fixture();var a=f.source(1,"101",501,"ONT8",3);
|
||||
var created=f.tx.execute(s->f.service.create(f.actor,f.configured("missing-source",List.of(a))));var approved=f.approve(created);
|
||||
f.picked(1,"101",501,3);f.db.update("UPDATE ll_dispatch_order SET picked_package_count=3");
|
||||
f.db.update("UPDATE ll_dispatch_pda_pick_order SET packages=2");
|
||||
var preview=f.tx.execute(s->f.service.loading(f.actor,created.dispatchNo()));
|
||||
assertThat(preview.pickingReconciled()).isFalse();assertThat(preview.loadingComplete().allowed()).isFalse();
|
||||
assertThatThrownBy(()->f.tx.execute(s->f.service.loadingComplete(f.actor,created.dispatchNo(),new DispatchCommands.LoadingComplete(approved.recordVersion(),"missing-rejected",preview.manifestHash(),List.of(),null)))).isInstanceOf(BizException.class).hasMessageContaining("核平");
|
||||
assertThat(f.db.queryForObject("SELECT stage FROM ll_dispatch_order",String.class)).isEqualTo("LOADING");
|
||||
}
|
||||
/** 两级顺序、驳回重提、原结果恢复和零扫码退回均实际提交到同一整车。 */
|
||||
@Test void reviewsUseActualRootRoundAndImmutableCommandsBeforeAndAfterLoading() throws Exception {
|
||||
var f=new Fixture();var source=f.source(1,"101",501,"ONT8",3);
|
||||
var created=f.tx.execute(s->f.service.create(f.actor,f.configured("review-create",List.of(source))));
|
||||
var submit=f.tx.execute(s->f.service.submitReview(f.actor,created.dispatchNo(),new DispatchCommands.SubmitReview(created.recordVersion(),"submit-1",null)));
|
||||
assertThatThrownBy(()->f.tx.execute(s->f.service.review(f.actor,created.dispatchNo(),true,new DispatchCommands.ReviewCommand(submit.recordVersion(),"wrong-order",Decision.APPROVED,null)))).isInstanceOf(BizException.class);
|
||||
var rejected=f.tx.execute(s->f.service.review(f.actor,created.dispatchNo(),false,new DispatchCommands.ReviewCommand(submit.recordVersion(),"reject-1",Decision.REJECTED,"补核装载计划")));
|
||||
var resubmitted=f.tx.execute(s->f.service.submitReview(f.actor,created.dispatchNo(),new DispatchCommands.SubmitReview(rejected.recordVersion(),"submit-2",null)));
|
||||
var approved=f.tx.execute(s->f.service.review(f.actor,created.dispatchNo(),false,new DispatchCommands.ReviewCommand(resubmitted.recordVersion(),"dispatch-approved",Decision.APPROVED,null)));
|
||||
var loading=f.tx.execute(s->f.service.review(f.actor,created.dispatchNo(),true,new DispatchCommands.ReviewCommand(approved.recordVersion(),"loading-approved",Decision.APPROVED,null)));
|
||||
assertThat(loading.stage()).isEqualTo(Stage.LOADING);
|
||||
var replay=f.tx.execute(s->f.service.review(f.actor,created.dispatchNo(),false,new DispatchCommands.ReviewCommand(submit.recordVersion(),"reject-1",Decision.REJECTED,"补核装载计划")));
|
||||
assertThat(replay.replayed()).isTrue();assertThat(replay.stage()).isEqualTo(Stage.BEFORE_LOADING);assertThat(replay.recordVersion()).isEqualTo(rejected.recordVersion());
|
||||
var returned=f.tx.execute(s->f.service.returnToReview(f.actor,created.dispatchNo(),new DispatchCommands.ReturnToReview(loading.recordVersion(),"return-zero","重新核实资源")));
|
||||
assertThat(returned.stage()).isEqualTo(Stage.BEFORE_LOADING);
|
||||
var detail=f.tx.execute(s->f.service.detail(f.actor,created.dispatchNo()));
|
||||
assertThat(detail.reviewState()).isEqualTo(ReviewState.DRAFT);assertThat(detail.reviews().dispatchReview().invalidatedReason()).isEqualTo("重新核实资源");
|
||||
assertThat(detail.reviews().loadingReview().status()).isEqualTo(ReviewStatus.NOT_SUBMITTED);
|
||||
assertThat(f.db.queryForObject("SELECT COUNT(*) FROM ll_dispatch_order",Long.class)).isOne();
|
||||
assertThat(f.db.queryForObject("SELECT COUNT(*) FROM ll_fleet_trip",Long.class)).isOne();
|
||||
assertThat(f.db.queryForObject("SELECT review_round FROM ll_dispatch_order",Long.class)).isEqualTo(2);
|
||||
}
|
||||
|
||||
/** 主单计数即使漂移为零,已有扫码流水也不能借退回入口伪造未执行。 */
|
||||
@Test void realPickBlocksReturnEvenWhenHeaderCountersAreZero() throws Exception {
|
||||
var f=new Fixture();var source=f.source(1,"101",501,"ONT8",3);
|
||||
var created=f.tx.execute(s->f.service.create(f.actor,f.configured("picked-create",List.of(source))));
|
||||
f.db.update("UPDATE ll_dispatch_order SET stage='LOADING',review_state='APPROVED'");
|
||||
long item=f.db.queryForObject("SELECT id FROM ll_dispatch_item",Long.class);
|
||||
f.db.update("INSERT INTO ll_dispatch_pda_pick(customer_id,dispatch_item_id,packages)VALUES('101',?,1)",item);
|
||||
assertThatThrownBy(()->f.tx.execute(s->f.service.returnToReview(f.actor,created.dispatchNo(),new DispatchCommands.ReturnToReview(created.recordVersion(),"forbidden-return","不能隐去扫码")))).isInstanceOf(BizException.class).hasMessageContaining("真实扫码");
|
||||
assertThat(f.db.queryForObject("SELECT stage FROM ll_dispatch_order",String.class)).isEqualTo("LOADING");
|
||||
assertThat(f.db.queryForObject("SELECT COUNT(*) FROM ll_dispatch_command WHERE idempotency_key='forbidden-return'",Long.class)).isZero();
|
||||
}
|
||||
/** 无客户头部、未知资源与计划不会生成假事实,原键不重复建车。 */
|
||||
@Test void emptyCreateHasOneRealTruckAndNoCustomerHeader() throws Exception {
|
||||
var f=new Fixture(); var request=f.create("empty",List.of());
|
||||
var first=f.tx.execute(s->f.service.create(f.actor,request));
|
||||
var replay=f.tx.execute(s->f.service.create(f.actor,request));
|
||||
assertThat(first.recordVersion()).isZero(); assertThat(first.counts().packageCount()).isZero();
|
||||
assertThat(replay.replayed()).isTrue(); assertThat(replay.dispatchNo()).isEqualTo(first.dispatchNo());
|
||||
assertThat(f.db.queryForObject("SELECT COUNT(*) FROM ll_dispatch_order",Long.class)).isOne();
|
||||
assertThat(f.db.queryForObject("SELECT COUNT(*) FROM ll_fleet_trip",Long.class)).isOne();
|
||||
assertThat(f.db.queryForObject("SELECT COUNT(*) FROM ll_fleet_trip_dispatch",Long.class)).isOne();
|
||||
assertThat(f.db.queryForObject("SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_NAME='ll_dispatch_order' AND COLUMN_NAME='customer_id'",Long.class)).isZero();
|
||||
assertThat(f.db.queryForObject("SELECT planned_departure_at FROM ll_fleet_trip",LocalDateTime.class)).isNull();
|
||||
assertThat(f.db.queryForObject("SELECT vehicle_id FROM ll_fleet_trip",Long.class)).isNull();
|
||||
var detail=f.tx.execute(s->f.service.detail(f.actor,first.dispatchNo()));
|
||||
assertThat(detail.stage()).isEqualTo(Stage.BEFORE_LOADING);
|
||||
assertThat(detail.vehicleInfo().vehicle()).isNull(); assertThat(detail.orderDetails()).isEmpty();
|
||||
}
|
||||
|
||||
/** 同仓双客户真实明细仅一张头;SQL分页按整车去重,详情保留已有预约读取。 */
|
||||
@Test void multiCustomerSelectionCreatesOneTruckAndKeepsAllDocumentsAndBatchReads() throws Exception {
|
||||
var f=new Fixture(); var a=f.source(1,"101",501,"ONT8",3); var b=f.source(2,"202",502,"LAX9",5);
|
||||
var original=f.tx.execute(s->f.service.create(f.actor,f.create("two-customers",List.of(a,b))));
|
||||
assertThat(original.counts().itemCount()).isEqualTo(2); assertThat(original.counts().palletCount()).isEqualTo(2);
|
||||
assertThat(original.counts().packageCount()).isEqualTo(8);
|
||||
assertThat(f.db.queryForList("SELECT DISTINCT customer_id FROM ll_dispatch_item ORDER BY customer_id",String.class)).containsExactly("101","202");
|
||||
assertThat(f.db.queryForObject("SELECT COUNT(DISTINCT dispatch_order_id) FROM ll_dispatch_item",Long.class)).isOne();
|
||||
assertThat(f.db.queryForObject("SELECT package_count FROM ll_fleet_trip_dispatch",Integer.class)).isEqualTo(8);
|
||||
var query=new DispatchViews.Query(1,20,null,null,null,"101",null,StatusFilter.ACTIVE,Stage.BEFORE_LOADING,BeforeLoadingSection.DISPATCH_REVIEW,ReviewStatus.NOT_SUBMITTED);
|
||||
var page=f.tx.execute(s->f.service.page(f.actor,query));
|
||||
assertThat(page.total()).isOne(); assertThat(page.records()).hasSize(1);
|
||||
assertThat(page.records().get(0).counts().packageCount()).isEqualTo(8);
|
||||
assertThat(page.statistics().activeTotal()).isOne(); assertThat(page.statistics().stageCounts().get(Stage.BEFORE_LOADING)).isOne();
|
||||
assertThat(page.statistics().beforeLoadingReviewCounts().dispatchReview().notSubmitted()).isOne();
|
||||
|
||||
long dispatch=f.db.queryForObject("SELECT id FROM ll_dispatch_order",Long.class);
|
||||
long item=f.db.queryForObject("SELECT id FROM ll_dispatch_item WHERE customer_id='101'",Long.class);
|
||||
f.db.update("INSERT INTO ll_inbound_fba_appointment(id,appointment_no,confirmation_no,appointment_status,fba_warehouse_code,appointment_start_utc) VALUES(900,'AP-EXISTING','CONFIRM-REAL','SUCCESS','ONT8',?)",LocalDateTime.of(2026,10,19,10,0));
|
||||
f.db.update("INSERT INTO ll_dispatch_appointment_allocation(dispatch_id,dispatch_item_id,plan_pallet_id,batch_key,customer_id,source_key,destination_key,package_count) VALUES(?,?,1,'FBA_APPOINTMENT:900','101','ORDER:9001','FBA:ONT8',3)",dispatch,item);
|
||||
var detail=f.tx.execute(s->f.service.detail(f.actor,original.dispatchNo()));
|
||||
assertThat(detail.orderDetails()).hasSize(2); assertThat(detail.dispatchInfo().appointmentBatches()).hasSize(1);
|
||||
assertThat(detail.dispatchInfo().fbaAppointments().get(0).appointmentNo()).isEqualTo("CONFIRM-REAL");
|
||||
assertThat(detail.dispatchInfo().fbaAppointments().get(0).confirmedAt()).isEqualTo(Instant.parse("2026-10-19T10:00:00Z"));
|
||||
var c=f.source(3,"101",503,"ONT8",2);
|
||||
f.tx.executeWithoutResult(s->f.service.addCargo(f.actor,original.dispatchNo(),new DispatchCommands.AddCargo(original.recordVersion(),"add-third",List.of(c),List.of())));
|
||||
var restored=f.tx.execute(s->f.service.create(f.actor,f.create("two-customers",List.of(a,b))));
|
||||
assertThat(restored.replayed()).isTrue(); assertThat(restored.recordVersion()).isEqualTo(original.recordVersion());
|
||||
assertThat(restored.counts().packageCount()).isEqualTo(8);
|
||||
assertThat(f.tx.execute(s->f.service.detail(f.actor,original.dispatchNo())).counts().packageCount()).isEqualTo(10);
|
||||
}
|
||||
|
||||
/** 第N项已被另一车占用时,早先本车/来源/资源/账本全部回滚。 */
|
||||
@Test void laterCargoConflictRollsBackTruckResourcesEarlierLocksAndItems() throws Exception {
|
||||
var f=new Fixture(); var a=f.source(1,"101",501,"ONT8",3); var b=f.source(2,"202",502,"LAX9",5);
|
||||
f.tx.executeWithoutResult(s->f.service.create(f.actor,f.create("first-owner",List.of(b))));
|
||||
assertThatThrownBy(()->f.tx.execute(s->f.service.create(f.actor,f.create("conflicting-owner",List.of(a,b))))).isInstanceOf(BizException.class);
|
||||
assertThat(f.db.queryForObject("SELECT COUNT(*) FROM ll_dispatch_order",Long.class)).isOne();
|
||||
assertThat(f.db.queryForObject("SELECT COUNT(*) FROM ll_fleet_trip",Long.class)).isOne();
|
||||
assertThat(f.db.queryForObject("SELECT COUNT(*) FROM ll_fleet_trip_dispatch",Long.class)).isOne();
|
||||
assertThat(f.db.queryForObject("SELECT COUNT(*) FROM ll_dispatch_item",Long.class)).isOne();
|
||||
assertThat(f.db.queryForObject("SELECT COUNT(*) FROM ll_dispatch_pallet_lock WHERE warehouse_pallet_id=501",Long.class)).isZero();
|
||||
assertThat(f.db.queryForObject("SELECT COUNT(*) FROM ll_dispatch_command WHERE idempotency_key='conflicting-owner'",Long.class)).isZero();
|
||||
}
|
||||
|
||||
/** 实际MyBatis和Spring本机内存事务;不使用返回成功的仓储替身。 */
|
||||
public static final class Fixture {
|
||||
public final JdbcTemplate db; public final TransactionTemplate tx; public final DispatchAggregateService service;
|
||||
public final DataSourceTransactionManager transactions;public final ObjectMapper json;public final SqlSessionTemplate session;
|
||||
public final DispatchAggregateRepository repository;public final DispatchExecutionRepository executionReads;public final FleetTripExecutionService fleet;
|
||||
public final FleetTripService nativeFleetService;
|
||||
public final DispatchContracts.Actor actor=new DispatchContracts.Actor("operator-test",ActorMode.PLATFORM,null);
|
||||
final Map<Long,DispatchPalletCandidate> candidates=new HashMap<>();
|
||||
|
||||
/** 建立本切片最小表与真实Mapper,目录/选货来源明确是测试提供者。 */
|
||||
public Fixture() throws Exception {
|
||||
var data=new DriverManagerDataSource("jdbc:h2:mem:"+UUID.randomUUID()+";MODE=MySQL;DATABASE_TO_LOWER=TRUE;DB_CLOSE_DELAY=-1","sa","");
|
||||
db=new JdbcTemplate(data); transactions=new DataSourceTransactionManager(data);tx=new TransactionTemplate(transactions); tx.setIsolationLevel(TransactionDefinition.ISOLATION_READ_COMMITTED);
|
||||
var config=new MybatisConfiguration(); config.setMapUnderscoreToCamelCase(true);
|
||||
var mapperTypes=List.of(DispatchAggregateMapper.class,DispatchReadMapper.class,DispatchExecutionReadMapper.class,DispatchCommandMapper.class,DispatchSourceMapper.class,
|
||||
DispatchItemMapper.class,DispatchPalletLockMapper.class,FleetTripMapper.class,FleetTripDispatchMapper.class,FleetTripHistoryMapper.class,FleetResourceMapper.class,
|
||||
OutboundOrderMapper.class,DeliveryOrderMapper.class,DispatchCandidateMapper.class,ReturnDispatchCandidateMapper.class);
|
||||
mapperTypes.forEach(config::addMapper);
|
||||
var factory=new MybatisSqlSessionFactoryBean(); factory.setDataSource(data); factory.setConfiguration(config); factory.afterPropertiesSet();
|
||||
session=new SqlSessionTemplate(factory.getObject());
|
||||
var heads=new ArrayList<String>();
|
||||
for(var field:DispatchAggregate.class.getDeclaredFields()) if(!Modifier.isStatic(field.getModifiers())) {
|
||||
String column=field.getName().replaceAll("([a-z0-9])([A-Z])","$1_$2").toLowerCase(Locale.ROOT);
|
||||
heads.add(column+" "+type(field.getType())+(column.equals("id")?" PRIMARY KEY":""));
|
||||
}
|
||||
db.execute("CREATE TABLE ll_dispatch_order("+String.join(",",heads)+",UNIQUE(dispatch_no),UNIQUE(trip_id))");
|
||||
for(var mapper:List.of(DispatchItemMapper.class,DispatchPalletLockMapper.class,FleetTripMapper.class,FleetTripDispatchMapper.class,FleetTripHistoryMapper.class,OutboundOrderMapper.class,DeliveryOrderMapper.class))
|
||||
for(var parent:mapper.getGenericInterfaces()) if(parent instanceof java.lang.reflect.ParameterizedType generic && generic.getRawType()==com.baomidou.mybatisplus.core.mapper.BaseMapper.class) {
|
||||
var table=TableInfoHelper.getTableInfo((Class<?>)generic.getActualTypeArguments()[0]); var columns=new ArrayList<String>(); columns.add(table.getKeyColumn()+" BIGINT PRIMARY KEY");
|
||||
for(var field:table.getFieldList()) if(!table.getTableName().equals("ll_fleet_trip_dispatch") || !Set.of("customer_id","review_json").contains(field.getColumn())) columns.add(field.getColumn()+" "+type(field.getPropertyType()));
|
||||
db.execute("CREATE TABLE "+table.getTableName()+"("+String.join(",",columns)+")");
|
||||
}
|
||||
db.execute("ALTER TABLE ll_fleet_trip_dispatch ADD UNIQUE(trip_id)");
|
||||
db.execute("ALTER TABLE ll_dispatch_pallet_lock ADD active_warehouse_pallet_id BIGINT UNIQUE");
|
||||
db.execute("ALTER TABLE ll_dispatch_pallet_lock ADD UNIQUE(customer_id,active_key)");
|
||||
db.execute("CREATE TABLE ll_dispatch_command(id BIGINT PRIMARY KEY,dispatch_id BIGINT,dispatch_no VARCHAR(64),action VARCHAR(40),operator_id VARCHAR(128),idempotency_key VARCHAR(255) UNIQUE,record_version BIGINT,command_hash CHAR(64),result_json CLOB,created_at TIMESTAMP(3))");
|
||||
db.execute("CREATE TABLE ll_dispatch_history(id BIGINT PRIMARY KEY,dispatch_id BIGINT,record_version BIGINT,action VARCHAR(40),operator_id VARCHAR(128),reason VARCHAR(1000),snapshot_json CLOB,occurred_at TIMESTAMP(3))");
|
||||
db.execute("CREATE TABLE ll_dispatch_pda_pick(id BIGINT,customer_id VARCHAR(64),dispatch_item_id BIGINT,inventory_position_id BIGINT,packages BIGINT,product_quantity BIGINT)");
|
||||
db.execute("CREATE TABLE ll_dispatch_pda_pick_order(id BIGINT,customer_id VARCHAR(64),pick_id BIGINT,order_id BIGINT,receipt_id BIGINT,return_receipt_id BIGINT,return_carton_id BIGINT,packages BIGINT,product_quantity BIGINT)");
|
||||
db.execute("CREATE TABLE ll_order(id BIGINT,customer_id VARCHAR(64),order_no VARCHAR(128),source_region VARCHAR(64),source_inbound_id VARCHAR(128))");
|
||||
db.execute("CREATE TABLE ll_inbound_stock_receipt(id BIGINT,customer_id VARCHAR(64),inbound_code VARCHAR(128))");
|
||||
db.execute("CREATE TABLE ll_return_stock_receipt(id BIGINT,customer_id VARCHAR(64),inventory_balance_id BIGINT,order_id BIGINT,inventory_position_id BIGINT,received_packages BIGINT)");
|
||||
db.execute("CREATE TABLE ll_inventory_balance(id BIGINT,customer_id VARCHAR(64),inbound_code VARCHAR(128))");
|
||||
db.execute("CREATE TABLE ll_return_inventory_carton(id BIGINT,customer_id VARCHAR(64),carton_mark VARCHAR(128),pallet_code VARCHAR(128),relabel_no VARCHAR(128),overseas_warehouse_id VARCHAR(64),total_quantity BIGINT,status VARCHAR(32))");
|
||||
db.execute("CREATE TABLE ll_warehouse_transfer_order(id BIGINT,customer_id VARCHAR(64),transfer_no VARCHAR(128))");
|
||||
db.execute("CREATE TABLE ll_warehouse_transfer_pallet(transfer_order_id BIGINT,customer_id VARCHAR(64),warehouse_pallet_id BIGINT,carton_count INT,status VARCHAR(32),completed_at TIMESTAMP(3),gross_weight_kg DECIMAL(18,6),volume_cbm DECIMAL(18,6))");
|
||||
db.execute("CREATE TABLE ll_inventory_ledger(customer_id VARCHAR(64),pallet_id BIGINT,created_at TIMESTAMP(3),business_type VARCHAR(64))");
|
||||
db.execute("CREATE TABLE ll_fleet_resource_assignment(resource_kind VARCHAR(32),resource_id BIGINT,trip_id BIGINT,trip_no VARCHAR(32),PRIMARY KEY(resource_kind,resource_id))");
|
||||
db.execute("CREATE TABLE ll_dispatch_appointment_allocation(dispatch_id BIGINT,dispatch_item_id BIGINT,plan_pallet_id BIGINT,batch_key VARCHAR(128),customer_id VARCHAR(128),source_key VARCHAR(128),destination_key VARCHAR(128),package_count INT)");
|
||||
db.execute("CREATE TABLE ll_inbound_fba_appointment(id BIGINT,version INT DEFAULT 0,appointment_no VARCHAR(64),confirmation_no VARCHAR(128),appointment_status VARCHAR(32),fba_warehouse_code VARCHAR(128),appointment_start_utc TIMESTAMP(3))");
|
||||
db.execute("CREATE TABLE ll_inbound_fba_appointment_item(order_id BIGINT,customer_id VARCHAR(128),active INT)");
|
||||
db.execute("CREATE TABLE ll_inbound_plan_pallet_allocation(pallet_id BIGINT,line_id BIGINT)"); db.execute("CREATE TABLE ll_inbound_plan_line(id BIGINT,order_id BIGINT,customer_id VARCHAR(128))");
|
||||
json=new ObjectMapper().registerModule(new JavaTimeModule());
|
||||
var fleetRepo=new FleetTripRepository(session.getMapper(FleetTripMapper.class),session.getMapper(FleetResourceMapper.class),session.getMapper(FleetTripHistoryMapper.class),session.getMapper(FleetTripDispatchMapper.class));
|
||||
var warehouses=mock(FleetWarehouseDirectory.class); when(warehouses.required(9L,"OVERSEAS")).thenReturn(new DriverModels.WarehouseOption("9","WH9","测试起运仓","OVERSEAS"));
|
||||
repository=new DispatchAggregateRepository(session.getMapper(DispatchAggregateMapper.class),session.getMapper(DispatchItemMapper.class),session.getMapper(DispatchReadMapper.class));
|
||||
var gateway=new com.baiyue.lastleg.dispatch.fleet.DispatchFleetAdapter(repository,session.getMapper(OutboundOrderMapper.class),session.getMapper(DeliveryOrderMapper.class),
|
||||
mock(com.baiyue.lastleg.delivery.mapper.DeliveryOperationMapper.class),mock(com.baiyue.lastleg.delivery.repository.DispatchCargoSourceRepository.class),mock(DispatchAggregateService.class),json);
|
||||
var vehicles=mock(VehicleRepository.class); var drivers=mock(DriverService.class);var reservations=mock(FleetBookingReservations.class);
|
||||
var fleetService=new FleetTripService(fleetRepo,vehicles,drivers,warehouses,json,gateway,reservations);
|
||||
nativeFleetService=fleetService;
|
||||
org.springframework.test.util.ReflectionTestUtils.setField(fleetService,"workChanges",mock(com.baiyue.lastleg.fleet.trip.work.AppointmentWorkTripChanges.class));
|
||||
org.springframework.test.util.ReflectionTestUtils.setField(fleetService,"appointmentAllocations",mock(com.baiyue.lastleg.appointmentwork.AppointmentWorkDispatchPort.class));
|
||||
fleet=new FleetTripExecutionService(session.getMapper(FleetTripMapper.class),session.getMapper(FleetTripDispatchMapper.class),session.getMapper(FleetResourceMapper.class),gateway,fleetService,drivers,vehicles,json,reservations);
|
||||
org.springframework.test.util.ReflectionTestUtils.setField(fleet,"appointmentAllocations",mock(com.baiyue.lastleg.appointmentwork.AppointmentWorkDispatchPort.class));
|
||||
var rules=mock(DispatchService.class);
|
||||
when(rules.lockWholeSources(eq("9"),anyList())).thenAnswer(invocation -> {
|
||||
List<DispatchCommands.CargoSelection> request=invocation.getArgument(1);
|
||||
return request.stream().map(s->new DispatchService.WholeSource(candidates.get(s.planPalletId()),s,"PLAN:"+s.planPalletId(),s.sourceVersion())).toList();
|
||||
});
|
||||
service=new DispatchAggregateService(repository,
|
||||
new DispatchSourceRepository(session.getMapper(DispatchCandidateMapper.class),session.getMapper(ReturnDispatchCandidateMapper.class),session.getMapper(DispatchSourceMapper.class)),rules,fleet,
|
||||
new DispatchCommandLedger(new DispatchCommandRepository(session.getMapper(DispatchCommandMapper.class)),new DispatchCommandCodec(json)),new DispatchCommandHasher(json),json);
|
||||
org.springframework.test.util.ReflectionTestUtils.setField(service,"evidence",mock(com.baiyue.lastleg.dispatch.gateway.DispatchEvidenceGateway.class));
|
||||
org.springframework.test.util.ReflectionTestUtils.setField(gateway,"workflow",service);
|
||||
org.springframework.test.util.ReflectionTestUtils.setField(service,"appointments",new DispatchAppointmentAllocations(mock(com.baiyue.lastleg.appointmentwork.AppointmentWorkDispatchPort.class)));
|
||||
executionReads=new DispatchExecutionRepository(session.getMapper(DispatchExecutionReadMapper.class));
|
||||
org.springframework.test.util.ReflectionTestUtils.setField(service,"executionReads",executionReads);
|
||||
}
|
||||
|
||||
/** 提供者固定事实代表真实客户来源,调用请求自身没有客户字段。 */
|
||||
public DispatchCommands.CargoSelection source(long id,String customer,long pallet,String destination,int packages) {
|
||||
var row=new DispatchPalletCandidate(); row.customerId(customer); row.customerName("测试客户"+customer); row.planId(id); row.planPalletId(id); row.orderId(9000+id);
|
||||
row.warehousePalletId(pallet); row.overseasWarehouseId("9"); row.overseasWarehouseCode("WH9"); row.overseasWarehouseName("测试起运仓");
|
||||
row.orderNo("ORDER-"+id); row.orderRefs("ORDER-"+id); row.inboundCode("IN-"+id); row.palletNo("PL-"+pallet); row.containerNo("TEST-CN-"+id);
|
||||
row.destinationType("FBA"); row.destinationWarehouseCode(destination); row.destinationWarehouseName(destination); row.fbaWarehouseCode(destination);
|
||||
row.packageCount(packages); row.grossWeightKg(BigDecimal.TEN); row.volumeCbm(BigDecimal.ONE); row.volumeWeightKg(BigDecimal.ZERO); row.sourceStage("RECEIVED");
|
||||
candidates.put(id,row); return new DispatchCommands.CargoSelection(SourceType.INBOUND_PLAN,id,null,null,null,"a".repeat(64));
|
||||
}
|
||||
|
||||
/** 空计划和资源是合法待完善事实,不补假日期或主档。 */
|
||||
public DispatchCommands.Create create(String key,List<DispatchCommands.CargoSelection> selections) { return new DispatchCommands.Create(key,"9",null,null,null,null,null,selections,List.of()); }
|
||||
|
||||
/** 临时资源不创建主档;文件提供者在此夹具隔离,归属HTTP网关另行验证。 */
|
||||
public DispatchCommands.Create configured(String key,List<DispatchCommands.CargoSelection> sources) {
|
||||
var departure=Instant.now().plusSeconds(86400).truncatedTo(java.time.temporal.ChronoUnit.MILLIS);
|
||||
return new DispatchCommands.Create(key,"9",new DispatchCommands.Vehicle(ResourceMode.EXTERNAL,null,"TEST-TRUCK","测试承运方",
|
||||
new DispatchContracts.Capacity(15,new BigDecimal("10000"),new BigDecimal("50")),List.of(101L)),
|
||||
new DispatchCommands.Driver(ResourceMode.EXTERNAL,null,"测试司机","123456789",
|
||||
new DispatchContracts.DriverEvidence("TEST-LICENSE",java.time.LocalDate.of(2099,1,1),List.of(202L),departure.plusSeconds(86400))),
|
||||
departure,null,null,sources,List.of());
|
||||
}
|
||||
|
||||
/** 通过实际两级流程进入装车,测试不直接伪造通过状态。 */
|
||||
public DispatchContracts.CommandResult approve(DispatchContracts.CommandResult created) {
|
||||
var submitted=tx.execute(s->service.submitReview(actor,created.dispatchNo(),new DispatchCommands.SubmitReview(created.recordVersion(),created.dispatchNo()+"-submit",null)));
|
||||
var reviewed=tx.execute(s->service.review(actor,created.dispatchNo(),false,new DispatchCommands.ReviewCommand(submitted.recordVersion(),created.dispatchNo()+"-dispatch",Decision.APPROVED,null)));
|
||||
return tx.execute(s->service.review(actor,created.dispatchNo(),true,new DispatchCommands.ReviewCommand(reviewed.recordVersion(),created.dispatchNo()+"-loading",Decision.APPROVED,null)));
|
||||
}
|
||||
/** 只为实载端口夹具准备真实扫码/归属记录,不声称运行库存命令。 */
|
||||
public void picked(long source,String customer,long pallet,int packages) {
|
||||
long item=db.queryForObject("SELECT id FROM ll_dispatch_item WHERE plan_pallet_id=?",Long.class,source);
|
||||
db.update("INSERT INTO ll_order VALUES(?,?,?,'OVERSEAS',?)",9000+source,customer,"ORDER-"+source,"SOURCE-"+source);
|
||||
db.update("INSERT INTO ll_inbound_stock_receipt VALUES(?,?,?)",source,customer,"IN-"+source);
|
||||
db.update("INSERT INTO ll_dispatch_pda_pick VALUES(?,?,?,?,?,?)",source,customer,item,pallet*10,packages,packages*2);
|
||||
db.update("INSERT INTO ll_dispatch_pda_pick_order VALUES(?,?,?,?,?,NULL,NULL,?,?)",source,customer,source,9000+source,source,packages,packages*2);
|
||||
}
|
||||
|
||||
/** 测试建表按实际持久属性类型生成,正式DDL仍由独立主文档集成。 */
|
||||
private static String type(Class<?> type) { return type==Long.class||type==long.class?"BIGINT":type==Integer.class||type==int.class?"INT":type==LocalDateTime.class?"TIMESTAMP(3)":type==BigDecimal.class?"DECIMAL(18,6)":"VARCHAR(20000)"; }
|
||||
}
|
||||
}
|
||||
+35
@@ -0,0 +1,35 @@
|
||||
package com.baiyue.lastleg.dispatch.service;
|
||||
|
||||
import com.baiyue.lastleg.appointmentwork.AppointmentWorkDispatchPort;
|
||||
import com.baiyue.lastleg.dispatch.entity.DispatchItem;
|
||||
import com.baiyue.lastleg.dispatch.port.DispatchContracts.BatchAllocation;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import java.util.*;
|
||||
import static org.assertj.core.api.Assertions.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
class DispatchAppointmentAllocationsTest {
|
||||
@Test void explicitZeroIsPassedInProviderFieldOrderAndMixedVersionsAreRejectedBeforeLock() {
|
||||
var provider=mock(AppointmentWorkDispatchPort.class);var service=new DispatchAppointmentAllocations(provider);
|
||||
var a=new BatchAllocation(11L,"FBA_APPOINTMENT:7","101","ORDER:1","FBA:ONT8",3,0L);
|
||||
service.lock(List.of(),List.of(a));verify(provider).lockBatches(Map.of("FBA_APPOINTMENT:7",0L));
|
||||
var item=new DispatchItem();item.setId(99L);item.setCustomerId("101");item.setPlanPalletId(11L);item.setSourceStage("RECEIVED");item.setPackageCount(3);
|
||||
service.bind(6L,List.of(item),List.of(a));verify(provider).bindItems(6L,List.of(new AppointmentWorkDispatchPort.PhysicalItem(99L,"101",11L,"RECEIVED",3)),
|
||||
List.of(new AppointmentWorkDispatchPort.Allocation(11L,"FBA_APPOINTMENT:7",0L,"101","ORDER:1","FBA:ONT8",3)));
|
||||
clearInvocations(provider);
|
||||
assertThatThrownBy(()->service.lock(List.of(),List.of(a,new BatchAllocation(12L,"FBA_APPOINTMENT:7","102","ORDER:2","FBA:ONT8",2,1L)))).isInstanceOf(com.baiyue.common.core.exception.BizException.class);
|
||||
verifyNoInteractions(provider);
|
||||
assertThatThrownBy(()->new BatchAllocation(11L,"FBA_APPOINTMENT:7","101","ORDER:1","FBA:ONT8",3,null)).isInstanceOf(IllegalArgumentException.class);
|
||||
}
|
||||
@Test void draftPrelocksAllGroupsInOneCallBeforeAnySourceOrTouchedWrite() {
|
||||
var provider=mock(AppointmentWorkDispatchPort.class);var service=new DispatchAppointmentAllocations(provider);
|
||||
var a=new BatchAllocation(11L,"FBA_APPOINTMENT:9","101","ORDER:1","FBA:ONT8",3,4L);
|
||||
var b=new BatchAllocation(12L,"FBA_APPOINTMENT:2","102","ORDER:2","FBA:LAX9",2,0L);
|
||||
service.lockCreates(List.of(create("a",List.of(a)),create("b",List.of(b))));
|
||||
verify(provider).lockBatches(new TreeMap<>(Map.of("FBA_APPOINTMENT:9",4L,"FBA_APPOINTMENT:2",0L)));
|
||||
verifyNoMoreInteractions(provider);
|
||||
}
|
||||
private static com.baiyue.lastleg.dispatch.port.DispatchCommands.Create create(String key,List<BatchAllocation> allocations) {
|
||||
return new com.baiyue.lastleg.dispatch.port.DispatchCommands.Create(key,"9",null,null,null,null,null,List.of(),allocations);
|
||||
}
|
||||
}
|
||||
+115
@@ -0,0 +1,115 @@
|
||||
package com.baiyue.lastleg.dispatch.service;
|
||||
|
||||
import com.baiyue.common.core.api.ResultCode;
|
||||
import com.baiyue.common.core.exception.BizException;
|
||||
import com.baiyue.lastleg.dispatch.mapper.DispatchCommandMapper;
|
||||
import com.baiyue.lastleg.dispatch.port.DispatchCommands;
|
||||
import com.baiyue.lastleg.dispatch.port.DispatchContracts;
|
||||
import com.baiyue.lastleg.dispatch.port.DispatchDeliveryModels;
|
||||
import com.baiyue.lastleg.dispatch.port.DispatchResults;
|
||||
import com.baiyue.lastleg.dispatch.port.DispatchTypes;
|
||||
import com.baiyue.lastleg.dispatch.repository.DispatchCommandRepository;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.fasterxml.jackson.databind.SerializationFeature;
|
||||
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
|
||||
import org.apache.ibatis.mapping.Environment;
|
||||
import org.apache.ibatis.session.Configuration;
|
||||
import org.apache.ibatis.session.SqlSessionFactory;
|
||||
import org.apache.ibatis.session.SqlSessionFactoryBuilder;
|
||||
import org.apache.ibatis.transaction.jdbc.JdbcTransactionFactory;
|
||||
import org.h2.jdbcx.JdbcDataSource;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
/** 实际H2和MyBatis验证账本持久身份;不连接环境或启动应用。 */
|
||||
public class DispatchCommandLedgerPersistenceTest {
|
||||
/** 可独立执行同一组真实Mapper合同,Maven稳定后仍运行此JUnit测试。 */
|
||||
public static void main(String[] args) throws Exception {
|
||||
var test = new DispatchCommandLedgerPersistenceTest();
|
||||
test.restoresFromCommittedRowAndRejectsAnotherActorOrPayload();
|
||||
test.separatesStrictIdentityFromArrangementBusiness();
|
||||
System.out.println("DispatchCommandLedgerPersistenceTest: PASS (real MyBatis/H2 persisted replay; actor/version conflict; separate business hash)");
|
||||
}
|
||||
|
||||
/** 提交数据库后更换Session/Codec,仍只返回原版本结果并拒绝身份挪用。 */
|
||||
@Test void restoresFromCommittedRowAndRejectsAnotherActorOrPayload() throws Exception {
|
||||
SqlSessionFactory factory = factory();
|
||||
var actor = new DispatchContracts.Actor("operator-a", DispatchTypes.ActorMode.PLATFORM, null);
|
||||
var payload = new DispatchCommands.Depart(0L, "global-key", "实际发车");
|
||||
String hash = new DispatchCommandHasher(mapper()).command(actor, DispatchTypes.Action.DEPART,
|
||||
"DP-CONTRACT", 0L, payload);
|
||||
var identity = new DispatchContracts.CommandIdentity(DispatchTypes.Action.DEPART, "global-key", 0, hash);
|
||||
var result = new DispatchContracts.CommandResult("DP-CONTRACT", DispatchTypes.Action.DEPART, "global-key", 1,
|
||||
DispatchTypes.Status.ACTIVE, DispatchTypes.Stage.DELIVERING, Instant.parse("2026-10-19T00:00:00Z"),
|
||||
DispatchTypes.Effect.APPLIED, new DispatchContracts.Counts(1, 1, 10, 10, 10),
|
||||
new DispatchResults.Departed(Instant.parse("2026-10-19T00:00:00Z"), 10, List.of(), List.of()), false, false);
|
||||
try (var session = factory.openSession(false)) {
|
||||
var ledger = new DispatchCommandLedger(new DispatchCommandRepository(session.getMapper(DispatchCommandMapper.class)),
|
||||
new DispatchCommandCodec(mapper()));
|
||||
assertTrue(ledger.replay(actor, "DP-CONTRACT", identity).isEmpty());
|
||||
ledger.append(1L, actor, identity, result);
|
||||
session.commit();
|
||||
}
|
||||
try (var session = factory.openSession(false)) {
|
||||
var ledger = new DispatchCommandLedger(new DispatchCommandRepository(session.getMapper(DispatchCommandMapper.class)),
|
||||
new DispatchCommandCodec(mapper()));
|
||||
var restored = ledger.replay(actor, "DP-CONTRACT", identity).orElseThrow();
|
||||
assertTrue(restored.replayed());
|
||||
assertEquals(1, restored.recordVersion());
|
||||
assertEquals(result.result(), restored.result());
|
||||
assertEquals(result.occurredAt(), restored.occurredAt());
|
||||
var other = new DispatchContracts.Actor("operator-b", DispatchTypes.ActorMode.PLATFORM, null);
|
||||
assertEquals(ResultCode.FORBIDDEN, assertThrows(BizException.class,
|
||||
() -> ledger.replay(other, "DP-CONTRACT", identity)).getResultCode());
|
||||
var changed = new DispatchContracts.CommandIdentity(DispatchTypes.Action.DEPART, "global-key", 1, hash);
|
||||
assertEquals(ResultCode.CONFLICT, assertThrows(BizException.class,
|
||||
() -> ledger.replay(actor, "DP-CONTRACT", changed)).getResultCode());
|
||||
assertEquals(ResultCode.CONFLICT, assertThrows(BizException.class,
|
||||
() -> ledger.replay(actor, "DP-ANOTHER", identity)).getResultCode());
|
||||
}
|
||||
}
|
||||
|
||||
/** 严格版本和主体变动必须冲突,派送同业务不能因换键/版本变成另一安排。 */
|
||||
@Test void separatesStrictIdentityFromArrangementBusiness() {
|
||||
var hasher = new DispatchCommandHasher(mapper());
|
||||
var actor = new DispatchContracts.Actor("operator-a", DispatchTypes.ActorMode.PLATFORM, null);
|
||||
var other = new DispatchContracts.Actor("operator-b", DispatchTypes.ActorMode.PLATFORM, null);
|
||||
var first = new DispatchDeliveryModels.ArrangeDelivery(0L, "key-a", "cargo", "loading", List.of());
|
||||
var second = new DispatchDeliveryModels.ArrangeDelivery(1L, "key-b", "cargo", "loading", List.of());
|
||||
assertNotEquals(hasher.command(actor, DispatchTypes.Action.ARRANGE_DELIVERY, "DP", 0L, first),
|
||||
hasher.command(actor, DispatchTypes.Action.ARRANGE_DELIVERY, "DP", 1L, second));
|
||||
assertNotEquals(hasher.command(actor, DispatchTypes.Action.ARRANGE_DELIVERY, "DP", 0L, first),
|
||||
hasher.command(other, DispatchTypes.Action.ARRANGE_DELIVERY, "DP", 0L, first));
|
||||
assertEquals(hasher.arrangement("DP", first, null), hasher.arrangement("DP", second, null));
|
||||
assertNotEquals(hasher.arrangement("DP", first, null), hasher.arrangement("OTHER", first, null));
|
||||
assertEquals(hasher.source(Map.of("groups", List.of(Map.of("key", "b"), Map.of("key", "a")))),
|
||||
hasher.source(Map.of("groups", List.of(Map.of("key", "a"), Map.of("key", "b")))));
|
||||
assertNotEquals(hasher.source(Map.of("stops", List.of("A", "B"))),
|
||||
hasher.source(Map.of("stops", List.of("B", "A"))));
|
||||
}
|
||||
|
||||
/** 本机内存库只建立受影响表,生产数据访问仍走真实Mapper。 */
|
||||
private static SqlSessionFactory factory() throws Exception {
|
||||
var source = new JdbcDataSource();
|
||||
source.setURL("jdbc:h2:mem:dispatch_command_" + UUID.randomUUID() + ";MODE=MySQL;DB_CLOSE_DELAY=-1");
|
||||
try (var connection = source.getConnection(); var statement = connection.createStatement()) {
|
||||
statement.execute("CREATE TABLE ll_dispatch_command(id BIGINT PRIMARY KEY,dispatch_id BIGINT,dispatch_no VARCHAR(64),"
|
||||
+ "action VARCHAR(40),operator_id VARCHAR(128),idempotency_key VARCHAR(255) NOT NULL UNIQUE,"
|
||||
+ "record_version BIGINT NOT NULL,command_hash CHAR(64),result_json CLOB,created_at TIMESTAMP(3))");
|
||||
}
|
||||
var config = new Configuration(new Environment("contract", new JdbcTransactionFactory(), source));
|
||||
config.addMapper(DispatchCommandMapper.class);
|
||||
return new SqlSessionFactoryBuilder().build(config);
|
||||
}
|
||||
|
||||
/** 使用新实例模拟进程内缓存已经全部丢失。 */
|
||||
private static ObjectMapper mapper() {
|
||||
return new ObjectMapper().registerModule(new JavaTimeModule()).disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS);
|
||||
}
|
||||
}
|
||||
+66
@@ -0,0 +1,66 @@
|
||||
package com.baiyue.lastleg.dispatch.service;
|
||||
|
||||
import com.baiyue.lastleg.dispatch.port.*;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.aop.framework.ProxyFactory;
|
||||
import org.springframework.transaction.annotation.AnnotationTransactionAttributeSource;
|
||||
import org.springframework.transaction.interceptor.TransactionInterceptor;
|
||||
import java.time.*;
|
||||
import java.util.*;
|
||||
import static com.baiyue.lastleg.dispatch.port.DispatchDeliveryModels.*;
|
||||
import static com.baiyue.lastleg.dispatch.port.DispatchTypes.*;
|
||||
import static org.assertj.core.api.Assertions.*;
|
||||
import static org.mockito.ArgumentMatchers.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
/** 实际父锁、CAS、历史和信号事务验证,派送事实提供者在此隔离并由其本域测试覆盖。 */
|
||||
class DispatchCompletionPersistenceTest {
|
||||
/** CAS之后信号确认失败,两者及完成证据一起回滚;重试只推进一次。 */
|
||||
@Test void stageAndSignalAcknowledgeRollbackTogetherThenRetryExactlyOnce() throws Exception {
|
||||
var fixture=new CompletionFixture();fixture.allSigned();
|
||||
doAnswer(call->{fixture.f.db.update("UPDATE completion_test_signal SET state='PROCESSED' WHERE id=1");throw new IllegalStateException("模拟确认后故障");})
|
||||
.when(fixture.signals).acknowledge(any(),any());
|
||||
assertThatThrownBy(()->fixture.port.reconcile(fixture.signal)).isInstanceOf(IllegalStateException.class);
|
||||
assertThat(fixture.f.db.queryForObject("SELECT stage FROM ll_dispatch_order",String.class)).isEqualTo("DELIVERING");
|
||||
assertThat(fixture.f.db.queryForObject("SELECT state FROM completion_test_signal WHERE id=1",String.class)).isEqualTo("PENDING");
|
||||
assertThat(fixture.completedHistory()).isZero();
|
||||
fixture.acknowledges();var changed=fixture.port.reconcile(fixture.signal);var repeated=fixture.port.reconcile(fixture.signal);
|
||||
assertThat(changed.changed()).isTrue();assertThat(changed.stage()).isEqualTo(Stage.DELIVERED);
|
||||
assertThat(repeated.changed()).isFalse();assertThat(repeated.recordVersion()).isEqualTo(changed.recordVersion());
|
||||
assertThat(fixture.completedHistory()).isOne();
|
||||
assertThat(fixture.f.db.queryForObject("SELECT status FROM ll_fleet_trip",String.class)).isEqualTo("PLANNED");
|
||||
}
|
||||
/** 部分签收只确认本信号,不把异常或返回数凑作全签收;后续版本独立触发完成。 */
|
||||
@Test void partialFactsStayDeliveringUntilLaterNormallySignedSignal() throws Exception {
|
||||
var fixture=new CompletionFixture();fixture.acknowledges();fixture.normalFacts(false);
|
||||
var partial=fixture.port.reconcile(fixture.signal);assertThat(partial.changed()).isFalse();assertThat(partial.stage()).isEqualTo(Stage.DELIVERING);
|
||||
assertThat(fixture.f.db.queryForObject("SELECT state FROM completion_test_signal WHERE id=1",String.class)).isEqualTo("PROCESSED");
|
||||
fixture.allSigned();fixture.f.db.update("INSERT INTO completion_test_signal VALUES(2,'PENDING')");
|
||||
var next=new Signal(2L,fixture.number,"DELIVERY-A",2,0,Instant.now());
|
||||
assertThat(fixture.port.reconcile(next).changed()).isTrue();assertThat(fixture.completedHistory()).isOne();
|
||||
}
|
||||
|
||||
/** 构造真实主单及实际来源,模拟已发车事实;不声称此夹具执行了发车库存或派送创建。 */
|
||||
private static final class CompletionFixture {
|
||||
final DispatchAggregatePersistenceTest.Fixture f=new DispatchAggregatePersistenceTest.Fixture();
|
||||
final DispatchDeliveryPort deliveries=mock(DispatchDeliveryPort.class);final DispatchSignalPort signals=mock(DispatchSignalPort.class);
|
||||
final DispatchCompletionPort port;final String number;final Signal signal;final Instant signedAt=Instant.now().minusSeconds(5);
|
||||
/** 使用真实Spring事务代理,REQUIRES_NEW与回滚均按生产注解执行。 */
|
||||
CompletionFixture() throws Exception {
|
||||
var selected=f.source(1,"101",501,"ONT8",3);var created=f.tx.execute(s->f.service.create(f.actor,f.configured("completion-create",List.of(selected))));number=created.dispatchNo();
|
||||
f.picked(1,"101",501,3);f.db.update("UPDATE ll_dispatch_order SET stage='DELIVERING',picked_package_count=3,shipped_package_count=3,departed_at=?",LocalDateTime.ofInstant(signedAt.minusSeconds(60),ZoneOffset.UTC));
|
||||
f.db.execute("CREATE TABLE completion_test_signal(id BIGINT PRIMARY KEY,state VARCHAR(32))");f.db.update("INSERT INTO completion_test_signal VALUES(1,'PENDING')");
|
||||
signal=new Signal(1L,number,"DELIVERY-A",1,0,Instant.now());
|
||||
var target=new DispatchCompletionService(f.repository,f.executionReads,f.fleet,f.service,deliveries,signals,f.json);
|
||||
var proxy=new ProxyFactory(target);proxy.addAdvice(new TransactionInterceptor(f.transactions,new AnnotationTransactionAttributeSource()));port=(DispatchCompletionPort)proxy.getProxy();
|
||||
}
|
||||
/** 父事务中的信号SQL参与同一个DataSource事务。 */
|
||||
void acknowledges(){doAnswer(call->{Signal current=call.getArgument(0);f.db.update("UPDATE completion_test_signal SET state='PROCESSED' WHERE id=?",current.signalId());return null;}).when(signals).acknowledge(any(),any());}
|
||||
/** 正常事实策略本身由派送域直接测试,本测试只验证主阶段消费职责。 */
|
||||
void normalFacts(boolean normal){when(deliveries.normalDeliveryFacts(any())).thenReturn(new NormalDeliveryFacts(number,"test-normal-facts",normal,normal?signedAt:null,List.of("DELIVERY-A"),List.of(),normal?List.of():List.of("尚未全部正常签收")));}
|
||||
/** 全签收与部分签收使用不同的实际提供者结果。 */
|
||||
void allSigned(){normalFacts(true);}
|
||||
/** 唯一完成历史包含完整当时事实,重复提醒不得追加。 */
|
||||
long completedHistory(){return f.db.queryForObject("SELECT COUNT(*) FROM ll_dispatch_history WHERE action='NORMAL_DELIVERY_COMPLETED'",Long.class);}
|
||||
}
|
||||
}
|
||||
+23
@@ -0,0 +1,23 @@
|
||||
package com.baiyue.lastleg.dispatch.service;
|
||||
|
||||
import com.baiyue.lastleg.dispatch.port.DispatchSignalPort;
|
||||
import com.baiyue.lastleg.dispatch.port.DispatchDeliveryModels.Signal;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
import static org.mockito.Mockito.*;
|
||||
import static org.mockito.ArgumentMatchers.*;
|
||||
import static org.assertj.core.api.Assertions.*;
|
||||
|
||||
/** 调度只编排已回滚事务后的退避,不持信号锁跨入根锁。 */
|
||||
class DispatchDeliveryCompletionWorkerTest {
|
||||
/** 一车失败不吞掉后一车,且先完成失败事务调用才写退避。 */
|
||||
@Test void backsOffAfterFailureAndContinuesOtherSignals() {
|
||||
var completion=mock(DispatchCompletionService.class);var signals=mock(DispatchSignalPort.class);
|
||||
var first=new Signal(1L,"D-A","L-A",1,0,Instant.now());var second=new Signal(2L,"D-B","L-B",2,4,Instant.now());
|
||||
when(signals.due(any(),eq(100))).thenReturn(List.of(first,second));when(completion.reconcile(first)).thenThrow(new IllegalStateException("已回滚"));
|
||||
var before=Instant.now();new DispatchDeliveryCompletionWorker(completion,signals).consume();
|
||||
var order=inOrder(completion,signals);order.verify(completion).reconcile(first);order.verify(signals).failed(eq(first),argThat(t->t.isAfter(before.plusSeconds(4))&&t.isBefore(Instant.now().plusSeconds(6))),eq("DISPATCH_COMPLETION_FAILED"));order.verify(completion).reconcile(second);
|
||||
verify(signals,never()).acknowledge(any(),any());
|
||||
}
|
||||
}
|
||||
+92
@@ -0,0 +1,92 @@
|
||||
package com.baiyue.lastleg.dispatch.service;
|
||||
|
||||
import com.baiyue.lastleg.dispatch.mapper.*;
|
||||
import com.baiyue.lastleg.dispatch.pda.service.DispatchPdaWorkflowService;
|
||||
import com.baiyue.lastleg.dispatch.port.*;
|
||||
import com.baiyue.lastleg.dispatch.repository.*;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import java.math.BigDecimal;
|
||||
import java.time.*;
|
||||
import java.util.*;
|
||||
import java.util.concurrent.*;
|
||||
import static com.baiyue.lastleg.dispatch.port.DispatchContracts.*;
|
||||
import static com.baiyue.lastleg.dispatch.port.DispatchDeliveryModels.*;
|
||||
import static com.baiyue.lastleg.dispatch.port.DispatchTypes.*;
|
||||
import static org.assertj.core.api.Assertions.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
import static org.mockito.ArgumentMatchers.*;
|
||||
|
||||
/** 实际根/资源/来源锁、版本及事务;派送单据和来源应用提供者在此受控,整链另行验证。 */
|
||||
class DispatchDeparturePersistenceTest {
|
||||
/** 同版本平台和PDA竞争仅一个命令成功,原键重放不再次发布来源或发运。 */
|
||||
@Test void platformAndPdaDepartureRaceAppliesOnlyOneCommand() throws Exception {
|
||||
var fixture=new Departure();var platformRequest=new DispatchCommands.Depart(fixture.version,"platform-depart","实际发车");
|
||||
var pdaRequest=new DispatchCommands.Depart(fixture.version,"pda-depart","实际发车");
|
||||
var start=new CountDownLatch(1);var executor=Executors.newFixedThreadPool(2);
|
||||
try {
|
||||
var a=executor.submit(()->fixture.tryDepart(start,fixture.f.actor,platformRequest));
|
||||
var b=executor.submit(()->fixture.tryDepart(start,new Actor("picker",ActorMode.PDA,"9"),pdaRequest));
|
||||
start.countDown();assertThat(List.of(a.get(20,TimeUnit.SECONDS),b.get(20,TimeUnit.SECONDS))).containsExactlyInAnyOrder(true,false);
|
||||
} finally {executor.shutdownNow();}
|
||||
assertThat(fixture.f.db.queryForObject("SELECT stage FROM ll_dispatch_order",String.class)).isEqualTo("DELIVERING");
|
||||
assertThat(fixture.f.db.queryForObject("SELECT status FROM ll_fleet_trip",String.class)).isEqualTo("IN_TRANSIT");
|
||||
assertThat(fixture.f.db.queryForObject("SELECT shipped_package_count FROM ll_dispatch_order",Integer.class)).isEqualTo(8);
|
||||
assertThat(fixture.f.db.queryForObject("SELECT COUNT(*) FROM ll_dispatch_command WHERE action='DEPART'",Long.class)).isOne();
|
||||
String winningKey=fixture.f.db.queryForObject("SELECT idempotency_key FROM ll_dispatch_command WHERE action='DEPART'",String.class);
|
||||
var winningActor="platform-depart".equals(winningKey)?fixture.f.actor:new Actor("picker",ActorMode.PDA,"9");
|
||||
var recovered=fixture.f.tx.execute(s->fixture.service.depart(winningActor,fixture.number,new DispatchCommands.Depart(fixture.version,winningKey,"实际发车")));
|
||||
assertThat(recovered.replayed()).isTrue();
|
||||
verify(fixture.deliveries,times(1)).markDeparted(any(),any(),any(),anyString());verify(fixture.sourceWorkflow,times(1)).departWholeSources(any(),any(),any(),any());
|
||||
var order=inOrder(fixture.deliveries,fixture.sourceMapper,fixture.sourceWorkflow);order.verify(fixture.deliveries).departureFacts(any(),eq(true));
|
||||
order.verify(fixture.sourceMapper).lockOrder("101",9001L);order.verify(fixture.sourceMapper).lockOrder("202",9002L);order.verify(fixture.deliveries).markDeparted(any(),any(),any(),anyString());order.verify(fixture.sourceWorkflow).departWholeSources(any(),any(),any(),any());
|
||||
}
|
||||
/** 后续来源同步失败时单据、资源、主单及账本一起回滚。 */
|
||||
@Test void sourceFailureRollsBackAllCustomerDocumentsAndTruckDeparture() throws Exception {
|
||||
var fixture=new Departure();doThrow(new IllegalStateException("模拟来源更新失败")).when(fixture.sourceWorkflow).departWholeSources(any(),any(),any(),any());
|
||||
assertThatThrownBy(()->fixture.f.tx.execute(s->fixture.service.depart(fixture.f.actor,fixture.number,new DispatchCommands.Depart(fixture.version,"failed-depart",null)))).isInstanceOf(IllegalStateException.class);
|
||||
assertThat(fixture.f.db.queryForObject("SELECT stage FROM ll_dispatch_order",String.class)).isEqualTo("AFTER_LOADING");
|
||||
assertThat(fixture.f.db.queryForList("SELECT status FROM ll_outbound_order",String.class)).containsOnly("WAIT_OUTBOUND");
|
||||
assertThat(fixture.f.db.queryForList("SELECT status FROM ll_delivery_order",String.class)).containsOnly("WAIT_DELIVERY");
|
||||
assertThat(fixture.f.db.queryForObject("SELECT status FROM ll_fleet_trip",String.class)).isEqualTo("PLANNED");
|
||||
assertThat(fixture.f.db.queryForObject("SELECT COUNT(*) FROM ll_dispatch_pallet_lock WHERE status='ACTIVE'",Long.class)).isEqualTo(2);
|
||||
assertThat(fixture.f.db.queryForObject("SELECT COUNT(*) FROM ll_dispatch_command WHERE action='DEPART'",Long.class)).isZero();
|
||||
}
|
||||
|
||||
/** 真建单、两级审核、真实归属读取、显式装车及主流程安排记录,隔离两个跨域应用提供者。 */
|
||||
private static final class Departure {
|
||||
final DispatchAggregatePersistenceTest.Fixture f=new DispatchAggregatePersistenceTest.Fixture();
|
||||
final DispatchDeliveryPort deliveries=mock(DispatchDeliveryPort.class);final DispatchPdaWorkflowService sourceWorkflow=mock(DispatchPdaWorkflowService.class);
|
||||
final DispatchSourceMapper sourceMapper;final DispatchDepartureService service;final String number;final long version;
|
||||
final List<OutboundReference> outbounds;final List<DeliveryReference> deliveryRefs;
|
||||
/** 所有状态均经过本域真实命令,跨域提供者只写本测试实际表以验证共同事务。 */
|
||||
Departure() throws Exception {
|
||||
var a=f.source(1,"101",501,"ONT8",3);var b=f.source(2,"202",502,"LAX9",5);
|
||||
var created=f.tx.execute(s->f.service.create(f.actor,f.configured("depart-create",List.of(a,b))));number=created.dispatchNo();var approved=f.approve(created);
|
||||
f.picked(1,"101",501,3);f.picked(2,"202",502,5);f.db.update("UPDATE ll_dispatch_order SET picked_package_count=8");
|
||||
var loading=f.tx.execute(s->f.service.loading(f.actor,number));var measurements=loading.pallets().stream().map(p->new LoadingMeasurement(p.warehousePalletId(),1,BigDecimal.ONE,BigDecimal.ONE,p.deliveryIndex())).toList();
|
||||
f.tx.executeWithoutResult(s->f.service.loadingComplete(f.actor,number,new DispatchCommands.LoadingComplete(approved.recordVersion(),"depart-loaded",loading.manifestHash(),measurements,"实际实载")));
|
||||
var context=f.tx.execute(s->f.service.preview(f.actor,number));
|
||||
outbounds=List.of(new OutboundReference("101","测试客户101","OUT-A","WAIT_OUTBOUND",3,1),new OutboundReference("202","测试客户202","OUT-B","WAIT_OUTBOUND",5,1));
|
||||
deliveryRefs=List.of(new DeliveryReference("GROUP-A","101","测试客户101","OUT-A","DL-A",context.cargo().get(0).destination(),"WAIT_DELIVERY",3,1),new DeliveryReference("GROUP-B","202","测试客户202","OUT-B","DL-B",context.cargo().get(1).destination(),"WAIT_DELIVERY",5,1));
|
||||
var arrangement=new ArrangementResult(number,context.recordVersion()+1,"ARR-TEST",context.cargoHash(),Instant.now(),outbounds,deliveryRefs);
|
||||
var identity=new CommandIdentity(Action.ARRANGE_DELIVERY,"depart-arrangement",context.recordVersion(),"a".repeat(64));
|
||||
version=f.tx.execute(s->f.service.recordArrangement(context,f.actor,identity,new ArrangementFact("ARR-TEST","b".repeat(64),context.cargoHash(),context.loadingCompletionHash(),arrangement),false)).recordVersion();
|
||||
for(int n=0;n<outbounds.size();n++){
|
||||
var o=outbounds.get(n);var d=deliveryRefs.get(n);f.db.update("INSERT INTO ll_outbound_order(id,customer_id,dispatch_order_id,outbound_no,status,package_count)VALUES(?,?,?,?,?,?)",6001+n,o.customerId(),context.dispatchId(),o.outboundNo(),o.status(),o.packageCount());
|
||||
f.db.update("INSERT INTO ll_delivery_order(id,customer_id,dispatch_order_id,outbound_order_id,outbound_no,delivery_no,status,package_count)VALUES(?,?,?,?,?,?,?,?)",7001+n,o.customerId(),context.dispatchId(),6001+n,o.outboundNo(),d.deliveryNo(),d.status(),d.packageCount());
|
||||
}
|
||||
f.db.execute("ALTER TABLE ll_order ADD detained INT DEFAULT 0");sourceMapper=spy(f.session.getMapper(DispatchSourceMapper.class));
|
||||
when(deliveries.departureFacts(any(),eq(true))).thenAnswer(call->{f.db.queryForList("SELECT id FROM ll_outbound_order ORDER BY id FOR UPDATE");f.db.queryForList("SELECT id FROM ll_delivery_order ORDER BY id FOR UPDATE");return new DepartureFacts(number,"ARR-TEST",context.cargoHash(),context.loadingCompletionHash(),"b".repeat(64),outbounds,deliveryRefs,context.cargo(),new Gate(true,null,null));});
|
||||
when(deliveries.markDeparted(any(),any(),any(),anyString())).thenAnswer(call->{
|
||||
f.db.update("UPDATE ll_outbound_order SET status='OUTBOUNDED'");f.db.update("UPDATE ll_delivery_order SET status='DELIVERING'");
|
||||
return new DepartureResult(outbounds.stream().map(o->new OutboundReference(o.customerId(),o.customerName(),o.outboundNo(),"OUTBOUNDED",o.packageCount(),o.palletCount())).toList(),
|
||||
deliveryRefs.stream().map(d->new DeliveryReference(d.groupKey(),d.customerId(),d.customerName(),d.outboundNo(),d.deliveryNo(),d.destination(),"DELIVERING",d.packageCount(),d.palletCount())).toList(),8);
|
||||
});
|
||||
var sources=new DispatchSourceRepository(mock(DispatchCandidateMapper.class),mock(ReturnDispatchCandidateMapper.class),sourceMapper);
|
||||
var commands=new DispatchCommandLedger(new DispatchCommandRepository(f.session.getMapper(DispatchCommandMapper.class)),new DispatchCommandCodec(f.json));
|
||||
service=new DispatchDepartureService(f.service,f.repository,sources,deliveries,sourceWorkflow,f.fleet,commands,new DispatchCommandHasher(f.json),f.json);clearInvocations(sourceMapper);
|
||||
}
|
||||
/** 两端各自持真实数据库事务等待同一资源根锁,版本冲突必须完整失败。 */
|
||||
boolean tryDepart(CountDownLatch start,Actor actor,DispatchCommands.Depart request) throws Exception {start.await(5,TimeUnit.SECONDS);try{f.tx.execute(s->service.depart(actor,number,request));return true;}catch(com.baiyue.common.core.exception.BizException expected){return false;}}
|
||||
}
|
||||
}
|
||||
+305
@@ -0,0 +1,305 @@
|
||||
package com.baiyue.lastleg.dispatch.service;
|
||||
|
||||
import com.baomidou.mybatisplus.core.MybatisConfiguration;
|
||||
import com.baomidou.mybatisplus.extension.spring.MybatisSqlSessionFactoryBean;
|
||||
import com.baiyue.common.core.exception.BizException;
|
||||
import com.baiyue.lastleg.dispatch.entity.DispatchDraft;
|
||||
import com.baiyue.lastleg.dispatch.entity.DispatchDraftContainer;
|
||||
import com.baiyue.lastleg.dispatch.mapper.*;
|
||||
import com.baiyue.lastleg.dispatch.model.DispatchPalletCandidate;
|
||||
import com.baiyue.lastleg.dispatch.port.*;
|
||||
import com.baiyue.lastleg.dispatch.repository.*;
|
||||
import com.baiyue.lastleg.appointmentwork.AppointmentWorkDispatchPort;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.fasterxml.jackson.databind.SerializationFeature;
|
||||
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
|
||||
import org.junit.jupiter.api.*;
|
||||
import org.mybatis.spring.SqlSessionTemplate;
|
||||
import org.springframework.aop.framework.ProxyFactory;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.jdbc.datasource.DataSourceTransactionManager;
|
||||
import org.springframework.jdbc.datasource.DriverManagerDataSource;
|
||||
import org.springframework.transaction.annotation.AnnotationTransactionAttributeSource;
|
||||
import org.springframework.transaction.interceptor.TransactionInterceptor;
|
||||
|
||||
import java.time.*;
|
||||
import java.util.*;
|
||||
import java.util.concurrent.*;
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
import java.util.concurrent.atomic.AtomicReference;
|
||||
|
||||
import static com.baiyue.lastleg.dispatch.port.DispatchContracts.*;
|
||||
import static com.baiyue.lastleg.dispatch.port.DispatchPlanningModels.*;
|
||||
import static com.baiyue.lastleg.dispatch.port.DispatchTypes.*;
|
||||
import static org.assertj.core.api.Assertions.*;
|
||||
import static org.mockito.ArgumentMatchers.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
/** 真实MyBatis/内存数据库/Spring事务代理;来源与整车提供者在边界隔离,不连接运行环境。 */
|
||||
class DispatchDraftPersistenceTest {
|
||||
private final Actor owner=new Actor("7",ActorMode.PLATFORM,null);
|
||||
private final Actor other=new Actor("8",ActorMode.PLATFORM,null);
|
||||
private final DispatchService sources=mock(DispatchService.class);
|
||||
private final DispatchAggregateService aggregate=mock(DispatchAggregateService.class);
|
||||
private final DispatchSourceRepository sourceClaims=mock(DispatchSourceRepository.class);
|
||||
private final AppointmentWorkDispatchPort appointmentProvider=mock(AppointmentWorkDispatchPort.class);
|
||||
private final Map<Long,String> versions=new ConcurrentHashMap<>();
|
||||
private final Map<Long,Long> physical=new ConcurrentHashMap<>();
|
||||
private final List<String> calls=Collections.synchronizedList(new ArrayList<>());
|
||||
private DriverManagerDataSource dataSource;
|
||||
private JdbcTemplate db;
|
||||
private DispatchDraftRepository repository;
|
||||
private DispatchDraftService service;
|
||||
private Clock clock=Clock.systemUTC();
|
||||
private Runnable beforeSourceLocks=()->{ };
|
||||
private final AtomicInteger commands=new AtomicInteger();
|
||||
|
||||
/** 仅建立本包真实表和主单边界写入探针,模拟同数据库提供者共同加入外层事务。 */
|
||||
@BeforeEach void setup()throws Exception {
|
||||
dataSource=new DriverManagerDataSource("jdbc:h2:mem:draft_"+UUID.randomUUID()+";MODE=MySQL;DB_CLOSE_DELAY=-1;LOCK_TIMEOUT=10000","sa","");
|
||||
db=new JdbcTemplate(dataSource);
|
||||
db.execute("CREATE TABLE ll_dispatch_draft(id BIGINT PRIMARY KEY,draft_token VARCHAR(128) UNIQUE,operator_id VARCHAR(128),warehouse_id VARCHAR(64),record_version BIGINT,sources_json CLOB,plan_ids_json CLOB,containers_json CLOB,status VARCHAR(32),expires_at TIMESTAMP,released_at TIMESTAMP,created_at TIMESTAMP,updated_at TIMESTAMP)");
|
||||
db.execute("CREATE TABLE ll_dispatch_draft_container(id BIGINT PRIMARY KEY,draft_id BIGINT,draft_token VARCHAR(128),sequence_no INT,capacity INT,source_keys_json CLOB,created_at TIMESTAMP,updated_at TIMESTAMP,UNIQUE(draft_id,sequence_no))");
|
||||
db.execute("CREATE TABLE ll_dispatch_draft_command(id BIGINT PRIMARY KEY,idempotency_key VARCHAR(128) UNIQUE,operator_id VARCHAR(128),action VARCHAR(32),target_token VARCHAR(128),request_hash CHAR(64),result_json CLOB,created_at TIMESTAMP)");
|
||||
db.execute("CREATE TABLE ll_dispatch_pallet_lock(id BIGINT PRIMARY KEY,customer_id VARCHAR(64),source_pallet_key VARCHAR(128),active_key VARCHAR(128),plan_pallet_id BIGINT,warehouse_pallet_id BIGINT,active_warehouse_pallet_id BIGINT UNIQUE,return_receipt_id BIGINT,dispatch_order_id BIGINT,draft_container_id BIGINT,draft_token VARCHAR(128),operator_id VARCHAR(128),status VARCHAR(32),locked_at TIMESTAMP,expires_at TIMESTAMP,released_at TIMESTAMP,created_at TIMESTAMP,updated_at TIMESTAMP,UNIQUE(customer_id,active_key))");
|
||||
db.execute("CREATE TABLE draft_created_dispatch(dispatch_no VARCHAR(128) PRIMARY KEY,cargo_count INT)");
|
||||
db.execute("CREATE TABLE draft_source_gate(id BIGINT PRIMARY KEY)");
|
||||
var config=new MybatisConfiguration();config.setMapUnderscoreToCamelCase(true);
|
||||
for(var mapper:List.of(DispatchDraftMapper.class,DispatchDraftContainerMapper.class,DispatchDraftCommandMapper.class,DispatchDraftClaimMapper.class))config.addMapper(mapper);
|
||||
var factory=new MybatisSqlSessionFactoryBean();factory.setDataSource(dataSource);factory.setConfiguration(config);
|
||||
var session=new SqlSessionTemplate(factory.getObject());
|
||||
repository=new DispatchDraftRepository(session.getMapper(DispatchDraftMapper.class),session.getMapper(DispatchDraftContainerMapper.class),
|
||||
session.getMapper(DispatchDraftCommandMapper.class),session.getMapper(DispatchDraftClaimMapper.class));
|
||||
service=proxy();
|
||||
for(long id=1;id<=40;id++){versions.put(id,"version-"+id);physical.put(id,1000+id);db.update("INSERT INTO draft_source_gate VALUES(?)",id);}
|
||||
when(sources.lockDraftSources(anyString(),anyList())).thenAnswer(call->{
|
||||
calls.add("sources");String warehouse=call.getArgument(0);List<DispatchCommands.CargoSelection> selections=call.getArgument(1);
|
||||
beforeSourceLocks.run();
|
||||
var result=new ArrayList<DispatchService.WholeSource>();
|
||||
for(var s:selections){long sourceId=s.planPalletId()!=null?s.planPalletId():s.returnReceiptId()!=null?s.returnReceiptId():s.warehousePalletId()-1000;
|
||||
db.queryForObject("SELECT id FROM draft_source_gate WHERE id=? FOR UPDATE",Long.class,sourceId);
|
||||
if(!Objects.equals(s.sourceVersion(),versions.get(sourceId)))throw new IllegalStateException("真实来源版本已变化");
|
||||
if(!"92".equals(warehouse))throw new IllegalStateException("真实来源仓不一致");
|
||||
var row=new DispatchPalletCandidate().planPalletId(sourceId).warehousePalletId(physical.get(sourceId))
|
||||
.returnReceiptId(s.sourceType()==SourceType.RETURN_RECEIPT?sourceId:null)
|
||||
.customerId(sourceId%2==0?"102":"101").customerName("客户"+sourceId).overseasWarehouseId("92")
|
||||
.overseasWarehouseCode("WH").overseasWarehouseName("海外仓").packageCount(10).palletNo("P-"+sourceId)
|
||||
.sourceStage(s.sourceType()==SourceType.RELABEL?"RELABEL":"RECEIVED").destinationType("FBA").destinationWarehouseCode(sourceId%2==0?"LAX9":"ONT8")
|
||||
.fbaWarehouseCode(sourceId%2==0?"LAX9":"ONT8");
|
||||
result.add(new DispatchService.WholeSource(row,s,DispatchService.wholeSourceKey(s),s.sourceVersion()));}
|
||||
return result;
|
||||
});
|
||||
when(sourceClaims.claims(anyString(),anyString(),any(),any())).thenReturn(List.of());
|
||||
when(appointmentProvider.candidateBatches(anyList())).thenAnswer(call->{List<AppointmentWorkDispatchPort.CandidatePhysical> items=call.getArgument(0);var result=new LinkedHashMap<AppointmentWorkDispatchPort.CandidatePhysical,AppointmentWorkDispatchPort.CandidateBatches>();for(var item:items)result.put(item,new AppointmentWorkDispatchPort.CandidateBatches(List.of(),List.of(),false));return result;});
|
||||
doAnswer(call->{calls.add("resources");return null;}).when(aggregate).lockDraftCreationResources(any(),anyList());
|
||||
when(aggregate.create(any(),any())).thenAnswer(call->{calls.add("create");DispatchCommands.Create request=call.getArgument(1);String no="DS-"+request.idempotencyKey();
|
||||
db.update("INSERT INTO draft_created_dispatch(dispatch_no,cargo_count) VALUES(?,?)",no,request.cargo().size());
|
||||
return result(no,request.idempotencyKey(),request.cargo().size());});
|
||||
}
|
||||
|
||||
/** 新实例只依赖数据库命令结果,关闭内存对象也能恢复创建及原布局结果。 */
|
||||
@Test void restartRecoversOriginalResultsAndRejectsChangedActorVersionOrPayload(){
|
||||
var request=new CreateDraft("create","92");var draft=service.createDraft(owner,request);
|
||||
var group=new CreateContainer(0L,"group",draft.draftToken(),15);var changed=service.createContainer(owner,group);
|
||||
assertThat(proxy().createDraft(owner,request)).isEqualTo(draft);
|
||||
assertThat(proxy().createContainer(owner,group)).isEqualTo(changed);
|
||||
assertThat(db.queryForObject("SELECT COUNT(*) FROM ll_dispatch_draft",Integer.class)).isEqualTo(1);
|
||||
assertThatThrownBy(()->service.createDraft(other,request)).isInstanceOf(BizException.class);
|
||||
assertThatThrownBy(()->service.createContainer(owner,new CreateContainer(1L,"group",draft.draftToken(),15))).isInstanceOf(BizException.class);
|
||||
assertThatThrownBy(()->service.createContainer(owner,new CreateContainer(0L,"group",draft.draftToken(),30))).isInstanceOf(BizException.class);
|
||||
assertThatThrownBy(()->service.createContainer(other,new CreateContainer(1L,"foreign",draft.draftToken(),15))).isInstanceOf(BizException.class);
|
||||
assertThat(db.queryForObject("SELECT COUNT(*) FROM ll_dispatch_draft_command WHERE idempotency_key='foreign'",Integer.class)).isZero();
|
||||
assertThat(service.drafts(other)).isEmpty();
|
||||
}
|
||||
|
||||
/** 一容器可保存多个真实客户和目的地,布局序号互换不冲撞唯一键。 */
|
||||
@Test void snapshotSwapsUniqueSequencesAndMovesOnlyPreviouslyClaimedSources(){
|
||||
var state=empty(2);state=select(state,0,1);state=select(state,0,2);state=select(state,1,3);
|
||||
assertThat(state.sources()).extracting(Candidate::customerId).containsExactly("101","102","101");
|
||||
var a=state.containers().get(0);var b=state.containers().get(1);
|
||||
state=service.saveSnapshot(owner,new DraftSnapshot(state.recordVersion(),key(),state.draftToken(),List.of(),List.of(
|
||||
new DraftContainer(a.draftContainerId(),2,15,b.sourceKeys()),new DraftContainer(b.draftContainerId(),1,15,a.sourceKeys()))));
|
||||
assertThat(state.containers()).extracting(DraftContainer::draftContainerId).containsExactly(b.draftContainerId(),a.draftContainerId());
|
||||
assertThat(db.queryForObject("SELECT draft_container_id FROM ll_dispatch_pallet_lock WHERE active_key='PLAN:1'",Long.class)).isEqualTo(b.draftContainerId());
|
||||
var original=state;String badKey=key();
|
||||
assertThatThrownBy(()->service.saveSnapshot(owner,new DraftSnapshot(original.recordVersion(),badKey,original.draftToken(),List.of(),List.of(
|
||||
new DraftContainer(a.draftContainerId(),1,15,List.of("PLAN:999")),new DraftContainer(b.draftContainerId(),2,15,List.of()))))).isInstanceOf(BizException.class);
|
||||
assertThat(service.drafts(owner).get(0)).isEqualTo(original);
|
||||
assertThat(db.queryForObject("SELECT COUNT(*) FROM ll_dispatch_draft_command WHERE idempotency_key=?",Integer.class,badKey)).isZero();
|
||||
}
|
||||
|
||||
/** 退回及换标的原选择字段保持互斥,不把提供者内部计划引用误写成公开来源字段。 */
|
||||
@Test void returnAndPartialRelabelChoicesKeepExclusiveSourceIdentity(){
|
||||
var state=empty(1);long container=state.containers().get(0).draftContainerId();
|
||||
var returned=new DispatchCommands.CargoSelection(SourceType.RETURN_RECEIPT,null,1L,null,1001L,"version-1");
|
||||
state=service.select(owner,new DraftSelection(state.recordVersion(),key(),state.draftToken(),container,returned));
|
||||
var relabel=new DispatchCommands.CargoSelection(SourceType.RELABEL,null,null,"RL-2",1002L,"version-2");
|
||||
state=service.select(owner,new DraftSelection(state.recordVersion(),key(),state.draftToken(),container,relabel));
|
||||
assertThat(state.sources()).extracting(Candidate::selection).containsExactly(returned,relabel);
|
||||
assertThat(state.containers().get(0).sourceKeys()).containsExactly("RETURN:1","RELABEL:RL-2:1002");
|
||||
}
|
||||
|
||||
/** 第二辆车失败必须撤销首车、草稿状态、命令及所有占用释放;原键重试成功后只恢复结果。 */
|
||||
@Test void laterVehicleFailureRollsBackWholeBatchAndUnknownSuccessReplaysAfterRestart(){
|
||||
var state=empty(2);state=select(state,0,1);state=select(state,0,2);state=select(state,1,3);
|
||||
var request=conversion(state,"convert");var original=state;
|
||||
doAnswer(call->{DispatchCommands.Create r=call.getArgument(1);db.update("INSERT INTO draft_created_dispatch VALUES(?,?)","DS-"+r.idempotencyKey(),r.cargo().size());
|
||||
if(r.idempotencyKey().endsWith("-2"))throw new IllegalStateException("第二车创建失败");return result("DS-"+r.idempotencyKey(),r.idempotencyKey(),r.cargo().size());}).when(aggregate).create(any(),any());
|
||||
assertThatThrownBy(()->service.convert(owner,request)).isInstanceOf(IllegalStateException.class);
|
||||
assertThat(db.queryForObject("SELECT COUNT(*) FROM draft_created_dispatch",Integer.class)).isZero();
|
||||
assertThat(db.queryForObject("SELECT COUNT(*) FROM ll_dispatch_pallet_lock WHERE status='ACTIVE'",Integer.class)).isEqualTo(3);
|
||||
assertThat(service.drafts(owner).get(0)).isEqualTo(original);
|
||||
assertThat(db.queryForObject("SELECT COUNT(*) FROM ll_dispatch_draft_command WHERE idempotency_key='convert'",Integer.class)).isZero();
|
||||
calls.clear();doAnswer(call->{calls.add("create");DispatchCommands.Create r=call.getArgument(1);String no="DS-"+r.idempotencyKey();
|
||||
db.update("INSERT INTO draft_created_dispatch VALUES(?,?)",no,r.cargo().size());return result(no,r.idempotencyKey(),r.cargo().size());}).when(aggregate).create(any(),any());
|
||||
var converted=service.convert(owner,request);assertThat(converted.vehicles()).hasSize(2);assertThat(converted.replayed()).isFalse();
|
||||
assertThat(calls).containsExactly("resources","sources","create","create");
|
||||
assertThat(db.queryForObject("SELECT COUNT(*) FROM draft_created_dispatch",Integer.class)).isEqualTo(2);
|
||||
assertThat(db.queryForObject("SELECT COUNT(*) FROM draft_created_dispatch WHERE cargo_count=2",Integer.class)).isEqualTo(1);
|
||||
assertThat(service.drafts(owner)).isEmpty();clearInvocations(aggregate,sources);
|
||||
var replay=proxy().convert(owner,request);assertThat(replay.replayed()).isTrue();assertThat(replay.vehicles()).isEqualTo(converted.vehicles());
|
||||
verifyNoInteractions(aggregate,sources);
|
||||
}
|
||||
|
||||
/** 少组、跨仓、换版本或货物不完整不能开始创建;真实来源变旧也不能转换。 */
|
||||
@Test void conversionRejectsPartialGroupsForeignWarehouseAndStaleSource(){
|
||||
var state=select(empty(2),0,1);state=select(state,1,2);var original=conversion(state,"convert");
|
||||
assertThatThrownBy(()->service.convert(owner,new ConvertDraft(original.recordVersion(),"partial",original.draftToken(),original.groups().subList(0,1)))).isInstanceOf(BizException.class);
|
||||
var first=original.groups().get(0);var r=first.dispatch();
|
||||
var foreign=new DispatchCommands.Create(r.idempotencyKey(),"93",r.vehicle(),r.driver(),r.plannedDepartureAt(),r.estimatedArrivalAt(),r.remark(),r.cargo(),r.batchAllocations());
|
||||
assertThatThrownBy(()->service.convert(owner,new ConvertDraft(original.recordVersion(),"foreign-wh",original.draftToken(),List.of(new DraftVehicle(first.draftContainerId(),foreign),original.groups().get(1))))).isInstanceOf(BizException.class);
|
||||
clearInvocations(aggregate);versions.put(1L,"changed");
|
||||
assertThatThrownBy(()->service.convert(owner,original)).isInstanceOf(IllegalStateException.class);
|
||||
verify(aggregate,never()).create(any(),any());
|
||||
assertThat(db.queryForObject("SELECT COUNT(*) FROM ll_dispatch_pallet_lock WHERE status='ACTIVE'",Integer.class)).isEqualTo(2);
|
||||
}
|
||||
|
||||
/** 同实托不同来源别名不能放到第二容器;非空删除和错容器解锁不影响原占用。 */
|
||||
@Test void physicalUniquenessWrongContainerAndReleaseStayOwnerScoped(){
|
||||
var state=select(empty(2),0,1);var original=state;physical.put(2L,1001L);
|
||||
assertThatThrownBy(()->select(original,1,2)).isInstanceOf(BizException.class);
|
||||
var first=state.containers().get(0);var second=state.containers().get(1);var source=state.sources().get(0).selection();
|
||||
assertThatThrownBy(()->service.unselect(owner,new DraftSelection(original.recordVersion(),key(),original.draftToken(),second.draftContainerId(),source))).isInstanceOf(BizException.class);
|
||||
assertThatThrownBy(()->service.removeContainer(owner,original.draftToken(),first.draftContainerId(),new DispatchCommands.ReasonCommand(original.recordVersion(),key(),"not empty"))).isInstanceOf(BizException.class);
|
||||
var release=new DraftRelease(state.recordVersion(),"release",state.draftToken());var released=service.release(owner,release);
|
||||
assertThat(released.status()).isEqualTo("RELEASED");assertThat(released.sources()).isEmpty();
|
||||
assertThat(proxy().release(owner,release)).isEqualTo(released);
|
||||
assertThat(db.queryForObject("SELECT COUNT(*) FROM ll_dispatch_pallet_lock WHERE active_key IS NOT NULL OR active_warehouse_pallet_id IS NOT NULL",Integer.class)).isZero();
|
||||
}
|
||||
|
||||
/** 过期和旧版本不能变更;布局容量按完整物理来源集合核对。 */
|
||||
@Test void expiryVersionAndCapacityCannotBeBypassed(){
|
||||
var state=empty(2);for(long id=1;id<=15;id++)state=select(state,0,id);state=select(state,1,16);var original=state;
|
||||
assertThatThrownBy(()->service.createContainer(owner,new CreateContainer(0L,key(),original.draftToken(),15))).isInstanceOf(BizException.class);
|
||||
var all=state.sources().stream().map(Candidate::sourceKey).toList();var a=state.containers().get(0);var b=state.containers().get(1);
|
||||
assertThatThrownBy(()->service.saveSnapshot(owner,new DraftSnapshot(original.recordVersion(),key(),original.draftToken(),List.of(),List.of(
|
||||
new DraftContainer(a.draftContainerId(),1,15,all),new DraftContainer(b.draftContainerId(),2,15,List.of()))))).isInstanceOf(BizException.class);
|
||||
db.update("UPDATE ll_dispatch_draft SET expires_at=? WHERE draft_token=?",LocalDateTime.ofInstant(Instant.now().minusSeconds(1),ZoneOffset.UTC),state.draftToken());
|
||||
assertThat(service.drafts(owner)).isEmpty();
|
||||
assertThatThrownBy(()->service.createContainer(owner,new CreateContainer(original.recordVersion(),key(),original.draftToken(),15))).isInstanceOf(BizException.class);
|
||||
}
|
||||
|
||||
/** 两个拥有者同时选同一实托,数据库唯一约束只允许一个草稿提交。 */
|
||||
@Test void concurrentDraftsCannotClaimSamePhysicalPallet()throws Exception {
|
||||
var a=empty(1);var b=service.createDraft(other,new CreateDraft(key(),"92"));b=service.createContainer(other,new CreateContainer(0L,key(),b.draftToken(),15));
|
||||
var finalB=b;var start=new CountDownLatch(1);var pool=Executors.newFixedThreadPool(2);
|
||||
try {var one=pool.submit(()->attempt(owner,a,1,start));var two=pool.submit(()->attempt(other,finalB,1,start));start.countDown();
|
||||
assertThat(List.of(one.get(20,TimeUnit.SECONDS),two.get(20,TimeUnit.SECONDS))).containsExactlyInAnyOrder(true,false);
|
||||
assertThat(db.queryForObject("SELECT COUNT(*) FROM ll_dispatch_pallet_lock WHERE status='ACTIVE'",Integer.class)).isEqualTo(1);
|
||||
}finally{pool.shutdownNow();}
|
||||
}
|
||||
|
||||
/** 同一原版本的两个写命令竞争根锁,只能提交一个公共版本增量。 */
|
||||
@Test void concurrentWritesCannotOverwriteTheSameDraftVersion()throws Exception {
|
||||
var state=empty(1);var start=new CountDownLatch(1);var pool=Executors.newFixedThreadPool(2);
|
||||
try {var one=pool.submit(()->attempt(owner,state,1,start));var two=pool.submit(()->attempt(owner,state,2,start));start.countDown();
|
||||
assertThat(List.of(one.get(20,TimeUnit.SECONDS),two.get(20,TimeUnit.SECONDS))).containsExactlyInAnyOrder(true,false);
|
||||
var actual=service.drafts(owner).get(0);assertThat(actual.recordVersion()).isEqualTo(state.recordVersion()+1);assertThat(actual.sources()).hasSize(1);
|
||||
assertThat(db.queryForObject("SELECT COUNT(*) FROM ll_dispatch_pallet_lock WHERE status='ACTIVE'",Integer.class)).isEqualTo(1);
|
||||
}finally{pool.shutdownNow();}
|
||||
}
|
||||
|
||||
/** 真实来源行锁等待跨过原到期时刻,首次选托不能返回无有效占用的成功结果。 */
|
||||
@Test void firstSelectionWaitingAcrossExpiryRollsBackSelectionClaimAndCommand()throws Exception {
|
||||
var started=Instant.now().truncatedTo(java.time.temporal.ChronoUnit.MILLIS);var controlled=new MutableClock(started);
|
||||
clock=controlled;service=proxy();var draft=empty(1);String selectKey="expired-select";
|
||||
controlled.set(started.plus(Duration.ofMinutes(30)).minusMillis(1));
|
||||
var entered=new CountDownLatch(1);beforeSourceLocks=entered::countDown;var pool=Executors.newSingleThreadExecutor();
|
||||
try(var holder=dataSource.getConnection()) {
|
||||
holder.setAutoCommit(false);
|
||||
try(var statement=holder.prepareStatement("SELECT id FROM draft_source_gate WHERE id=1 FOR UPDATE");var row=statement.executeQuery()){assertThat(row.next()).isTrue();}
|
||||
var pending=pool.submit(()->service.select(owner,new DraftSelection(draft.recordVersion(),selectKey,draft.draftToken(),draft.containers().get(0).draftContainerId(),selection(1))));
|
||||
assertThat(entered.await(5,TimeUnit.SECONDS)).isTrue();assertThat(pending.isDone()).isFalse();
|
||||
controlled.set(started.plus(Duration.ofMinutes(30)).plusMillis(1));holder.commit();
|
||||
assertThatThrownBy(()->pending.get(15,TimeUnit.SECONDS)).isInstanceOf(ExecutionException.class).hasCauseInstanceOf(BizException.class);
|
||||
assertThat(db.queryForObject("SELECT sources_json FROM ll_dispatch_draft WHERE draft_token=?",String.class,draft.draftToken())).isEqualTo("[]");
|
||||
assertThat(db.queryForObject("SELECT source_keys_json FROM ll_dispatch_draft_container WHERE draft_token=?",String.class,draft.draftToken())).isEqualTo("[]");
|
||||
assertThat(db.queryForObject("SELECT record_version FROM ll_dispatch_draft WHERE draft_token=?",Long.class,draft.draftToken())).isEqualTo(draft.recordVersion());
|
||||
assertThat(db.queryForObject("SELECT COUNT(*) FROM ll_dispatch_pallet_lock",Integer.class)).isZero();
|
||||
assertThat(db.queryForObject("SELECT COUNT(*) FROM ll_dispatch_draft_command WHERE idempotency_key=?",Integer.class,selectKey)).isZero();
|
||||
var otherDraft=service.createDraft(other,new CreateDraft(key(),"92"));otherDraft=service.createContainer(other,new CreateContainer(0L,key(),otherDraft.draftToken(),15));
|
||||
var selected=service.select(other,new DraftSelection(otherDraft.recordVersion(),key(),otherDraft.draftToken(),otherDraft.containers().get(0).draftContainerId(),selection(1)));
|
||||
assertThat(selected.sources()).hasSize(1);
|
||||
assertThat(db.queryForObject("SELECT COUNT(*) FROM ll_dispatch_pallet_lock WHERE status='ACTIVE' AND expires_at>?",Integer.class,LocalDateTime.ofInstant(controlled.instant(),ZoneOffset.UTC))).isEqualTo(1);
|
||||
}finally{pool.shutdownNow();}
|
||||
}
|
||||
|
||||
/** 当前批次投影更新不推进草稿版本,原命令仍恢复其不可变结果。 */
|
||||
@Test void currentBatchProjectionRefreshesWithoutChangingDraftLifecycleOrCommandReplay() {
|
||||
var batchVersion=new AtomicReference<>(0L);
|
||||
when(appointmentProvider.candidateBatches(anyList())).thenAnswer(call->{
|
||||
List<AppointmentWorkDispatchPort.CandidatePhysical> items=call.getArgument(0);var result=new LinkedHashMap<AppointmentWorkDispatchPort.CandidatePhysical,AppointmentWorkDispatchPort.CandidateBatches>();
|
||||
for(var item:items)result.put(item,new AppointmentWorkDispatchPort.CandidateBatches(List.of(new AppointmentWorkDispatchPort.BatchReference("FBA_APPOINTMENT:9","AP-9",batchVersion.get(),item.customerId(),List.of(new AppointmentWorkDispatchPort.BatchSource("BOOKING:real","FBA:ONT8",10)))),List.of(),true));
|
||||
return result;
|
||||
});
|
||||
var empty=empty(1);var request=new DraftSelection(empty.recordVersion(),"select-with-appointment",empty.draftToken(),empty.containers().get(0).draftContainerId(),selection(1));
|
||||
var original=service.select(owner,request);assertThat(original.sources().get(0).appointmentBatches().get(0).recordVersion()).isZero();
|
||||
batchVersion.set(7L);clearInvocations(appointmentProvider);var current=service.drafts(owner).get(0);
|
||||
assertThat(current.recordVersion()).isEqualTo(original.recordVersion());assertThat(current.sources().get(0).appointmentBatches().get(0).recordVersion()).isEqualTo(7L);
|
||||
assertThat(current.sources().get(0).appointmentBatches().get(0).sources().get(0).sourceKey()).isEqualTo("BOOKING:real");assertThat(current.sources().get(0).blockReasons()).isEmpty();
|
||||
verify(appointmentProvider).candidateBatches(List.of(new AppointmentWorkDispatchPort.CandidatePhysical("101",1L)));
|
||||
assertThat(service.select(owner,request)).isEqualTo(original);
|
||||
assertThat(db.queryForObject("SELECT COUNT(*) FROM ll_dispatch_pallet_lock WHERE status='ACTIVE'",Integer.class)).isEqualTo(1);
|
||||
}
|
||||
|
||||
/** 线程通过实际事务代理提交,业务冲突只回滚自身草稿命令。 */
|
||||
private boolean attempt(Actor actor,Draft draft,long id,CountDownLatch start)throws InterruptedException {
|
||||
start.await();try{service.select(actor,new DraftSelection(draft.recordVersion(),UUID.randomUUID().toString(),draft.draftToken(),draft.containers().get(0).draftContainerId(),selection(id)));return true;}
|
||||
catch(BizException conflict){return false;}
|
||||
}
|
||||
/** 新服务实例和ObjectMapper模拟业务进程内缓存消失,事务代理沿真实注解。 */
|
||||
private DispatchDraftService proxy(){var json=new ObjectMapper().registerModule(new JavaTimeModule()).disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS);
|
||||
var target=new DispatchDraftService(repository,sourceClaims,sources,aggregate,new DispatchCommandHasher(json),json,new DispatchCommandCodec(json),appointmentProvider,clock);var factory=new ProxyFactory(target);factory.setProxyTargetClass(true);
|
||||
factory.addAdvice(new TransactionInterceptor(new DataSourceTransactionManager(dataSource),new AnnotationTransactionAttributeSource()));return (DispatchDraftService)factory.getProxy();}
|
||||
/** 创建指定数量空容器,每次使用服务端返回版本。 */
|
||||
private Draft empty(int containers){var state=service.createDraft(owner,new CreateDraft(key(),"92"));for(int i=0;i<containers;i++)state=service.createContainer(owner,new CreateContainer(state.recordVersion(),key(),state.draftToken(),15));return state;}
|
||||
/** 选择请求使用候选提供者真实版本和物理身份。 */
|
||||
private Draft select(Draft draft,int container,long id){return service.select(owner,new DraftSelection(draft.recordVersion(),key(),draft.draftToken(),draft.containers().get(container).draftContainerId(),selection(id)));}
|
||||
/** 只构造普通来源边界输入;生产资格仍由真实DispatchService提供。 */
|
||||
private DispatchCommands.CargoSelection selection(long id){return new DispatchCommands.CargoSelection(SourceType.INBOUND_PLAN,id,null,null,physical.get(id),versions.get(id));}
|
||||
/** 每个非空服务器容器生成一组完整创建请求。 */
|
||||
private ConvertDraft conversion(Draft draft,String key){var groups=new ArrayList<DraftVehicle>();int n=0;for(var c:draft.containers())if(!c.sourceKeys().isEmpty())groups.add(new DraftVehicle(c.draftContainerId(),new DispatchCommands.Create("vehicle-"+(++n),draft.warehouseId(),null,null,null,null,null,
|
||||
draft.sources().stream().filter(s->c.sourceKeys().contains(s.sourceKey())).map(Candidate::selection).toList(),List.of())));return new ConvertDraft(draft.recordVersion(),key,draft.draftToken(),groups);}
|
||||
/** 主单边界返回冻结的真实公开结果形状。 */
|
||||
private static CommandResult result(String no,String key,int cargo){return new CommandResult(no,Action.CREATE,key,0,Status.ACTIVE,Stage.BEFORE_LOADING,Instant.now(),Effect.APPLIED,new Counts(cargo,cargo,10L*cargo,0,0),new DispatchResults.Saved(no,List.of()),false,false);}
|
||||
/** 测试独立命令键不依赖执行顺序或系统时间。 */
|
||||
private String key(){return "command-"+commands.incrementAndGet();}
|
||||
|
||||
/** 测试时钟共享原子时刻,使等待线程与调用线程观察同一可控边界。 */
|
||||
private static final class MutableClock extends Clock {
|
||||
private final AtomicReference<Instant> current;
|
||||
private final ZoneId zone;
|
||||
/** 从确定UTC时刻开始,不依赖测试实际运行耗时。 */
|
||||
private MutableClock(Instant initial){this(new AtomicReference<>(initial),ZoneOffset.UTC);}
|
||||
/** 时区视图共享同一时刻,不创建独立时间线。 */
|
||||
private MutableClock(AtomicReference<Instant> current,ZoneId zone){this.current=current;this.zone=zone;}
|
||||
/** 锁持有者控制时间越过原草稿到期点。 */
|
||||
private void set(Instant value){current.set(value);}
|
||||
/** 返回当前视图时区。 */
|
||||
@Override public ZoneId getZone(){return zone;}
|
||||
/** 保留共享时刻,满足Clock的时区视图语义。 */
|
||||
@Override public Clock withZone(ZoneId next){return new MutableClock(current,next);}
|
||||
/** 所有线程读取同一个已发布时刻。 */
|
||||
@Override public Instant instant(){return current.get();}
|
||||
}
|
||||
}
|
||||
+51
@@ -0,0 +1,51 @@
|
||||
package com.baiyue.lastleg.dispatch.service;
|
||||
|
||||
import com.baiyue.lastleg.fleet.trip.*;
|
||||
import com.baiyue.lastleg.fleet.driver.*;
|
||||
import com.baiyue.lastleg.masterdata.vehicle.application.port.out.VehicleRepository;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import static org.mockito.Mockito.*;
|
||||
import static org.mockito.ArgumentMatchers.*;
|
||||
|
||||
/** 批量转换资源锁协议,不允许按组交错车辆与司机/占用。 */
|
||||
class DispatchDraftResourceLockTest {
|
||||
@Test void existingScopeLocksEveryTripBeforeAnyVehicleDriverOrAssignment() {
|
||||
var repository=mock(FleetTripRepository.class);var vehicles=mock(VehicleRepository.class);
|
||||
var a=new FleetTripDispatch();a.setDispatchId(70L);a.setDispatchNo("DS-A");a.setTripId(7L);a.setTripNo("TR-7");
|
||||
var b=new FleetTripDispatch();b.setDispatchId(20L);b.setDispatchNo("DS-B");b.setTripId(2L);b.setTripNo("TR-2");
|
||||
var first=new FleetTripEntity();first.setId(7L);first.setTripNo("TR-7");first.setVehicleId(9L);first.setDriverId(8L);
|
||||
var second=new FleetTripEntity();second.setId(2L);second.setTripNo("TR-2");second.setVehicleId(3L);second.setDriverId(4L);
|
||||
when(repository.wholeBinding("DS-A")).thenReturn(a);when(repository.wholeBinding("DS-B")).thenReturn(b);
|
||||
when(repository.lockTrip("TR-7")).thenReturn(first);when(repository.lockTrip("TR-2")).thenReturn(second);
|
||||
when(repository.listBindings(7L)).thenReturn(List.of(a));when(repository.listBindings(2L)).thenReturn(List.of(b));
|
||||
var service=new FleetTripService(repository,vehicles,mock(DriverService.class),mock(FleetWarehouseDirectory.class),new ObjectMapper(),mock(FleetDispatchGateway.class),mock(FleetBookingReservations.class));
|
||||
service.lockExistingDispatchShells(List.of("DS-A","DS-B"));
|
||||
var order=inOrder(repository,vehicles);
|
||||
order.verify(repository).lockTrip("TR-2");order.verify(repository).lockTrip("TR-7");
|
||||
order.verify(repository).listBindings(2L);order.verify(repository).listBindings(7L);
|
||||
order.verify(vehicles).lockById(3L);order.verify(vehicles).lockById(9L);
|
||||
order.verify(repository).lockDriverIdentity(4L);order.verify(repository).lockDriverIdentity(8L);
|
||||
order.verify(repository).lockResource("VEHICLE",3L);order.verify(repository).lockResource("VEHICLE",9L);
|
||||
order.verify(repository).lockResource("DRIVER",4L);order.verify(repository).lockResource("DRIVER",8L);
|
||||
}
|
||||
/** 跨组逆序输入及重复引用仍只按全批身份全局顺序锁一次。 */
|
||||
@Test void locksAllVehiclesThenDriversThenAssignmentsInNumericOrder() {
|
||||
var repository=mock(FleetTripRepository.class);var vehicles=mock(VehicleRepository.class);
|
||||
when(vehicles.lockById(anyLong())).thenReturn(Optional.of(mock(VehicleRepository.VehicleSnapshot.class)));
|
||||
when(repository.lockDriverIdentity(anyLong())).thenAnswer(call->call.getArgument(0));
|
||||
var service=new FleetTripService(repository,vehicles,mock(DriverService.class),mock(FleetWarehouseDirectory.class),new ObjectMapper(),mock(FleetDispatchGateway.class),mock(FleetBookingReservations.class));
|
||||
var v9=new FleetExecutionModels.DispatchVehicle("MASTER",9L,null,null,null,null,null,List.of());
|
||||
var v3=new FleetExecutionModels.DispatchVehicle("MASTER",3L,null,null,null,null,null,List.of());
|
||||
var d8=new FleetExecutionModels.DispatchDriver("MASTER",8L,null,null,null,null,List.of(),null);
|
||||
var d4=new FleetExecutionModels.DispatchDriver("MASTER",4L,null,null,null,null,List.of(),null);
|
||||
service.lockDispatchCreationResources(List.of(v9,v3,v9),List.of(d8,d4,d8));
|
||||
var order=inOrder(vehicles,repository);order.verify(vehicles).lockById(3L);order.verify(vehicles).lockById(9L);
|
||||
order.verify(repository).lockDriverIdentity(4L);order.verify(repository).lockDriverIdentity(8L);
|
||||
order.verify(repository).lockResource("VEHICLE",3L);order.verify(repository).lockResource("VEHICLE",9L);
|
||||
order.verify(repository).lockResource("DRIVER",4L);order.verify(repository).lockResource("DRIVER",8L);
|
||||
verifyNoMoreInteractions(vehicles,repository);
|
||||
}
|
||||
}
|
||||
+37
@@ -0,0 +1,37 @@
|
||||
package com.baiyue.lastleg.dispatch.service;
|
||||
|
||||
import com.baomidou.mybatisplus.core.MybatisConfiguration;
|
||||
import com.baomidou.mybatisplus.extension.spring.MybatisSqlSessionFactoryBean;
|
||||
import com.baiyue.lastleg.dispatch.pda.mapper.DispatchWholePdaReadMapper;
|
||||
import com.baiyue.lastleg.dispatch.port.*;
|
||||
import com.baiyue.lastleg.dispatch.repository.DispatchPdaReadRepository;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.mybatis.spring.SqlSessionTemplate;
|
||||
import java.util.List;
|
||||
import static com.baiyue.lastleg.dispatch.port.DispatchTypes.*;
|
||||
import static org.assertj.core.api.Assertions.*;
|
||||
|
||||
/** 统一PDA读取实际多客户主单和逐项扫码,仓限制在SQL完成。 */
|
||||
class DispatchPdaReadPersistenceTest {
|
||||
/** 同仓两个客户、两个目的地只返回一个整车作业,别仓不能借单号读取。 */
|
||||
@Test void readsOneTruckAcrossCustomersAndRejectsOtherWarehouseNumber() throws Exception {
|
||||
var f=new DispatchAggregatePersistenceTest.Fixture();var a=f.source(1,"101",501,"ONT8",3);var b=f.source(2,"202",502,"LAX9",5);
|
||||
var created=f.tx.execute(s->f.service.create(f.actor,f.configured("pda-read",List.of(a,b))));f.approve(created);
|
||||
f.picked(1,"101",501,3);f.picked(2,"202",502,5);f.db.update("UPDATE ll_dispatch_order SET picked_package_count=8");
|
||||
f.db.execute("CREATE TABLE ll_inventory_position(id BIGINT,customer_id VARCHAR(64),balance_id BIGINT,pallet_id BIGINT,overseas_warehouse_id VARCHAR(64),available_packages BIGINT,hold_packages BIGINT,allocated_packages BIGINT,picked_packages BIGINT)");
|
||||
f.db.update("INSERT INTO ll_inventory_position VALUES(11,'101',21,501,'9',0,0,0,0),(12,'202',22,502,'9',0,0,0,0),(13,'999',23,501,'99',100,0,0,0)");
|
||||
var config=new MybatisConfiguration();config.setMapUnderscoreToCamelCase(true);config.addMapper(DispatchWholePdaReadMapper.class);
|
||||
var factory=new MybatisSqlSessionFactoryBean();factory.setDataSource(f.db.getDataSource());factory.setConfiguration(config);factory.afterPropertiesSet();
|
||||
var mapper=new SqlSessionTemplate(factory.getObject()).getMapper(DispatchWholePdaReadMapper.class);
|
||||
var queries=new DispatchPdaQueryService(new DispatchPdaReadRepository(mapper),f.service);
|
||||
var actor=new DispatchContracts.Actor("picker",ActorMode.PDA,"9");
|
||||
var list=f.tx.execute(s->queries.list(actor,Stage.LOADING));assertThat(list).hasSize(1);
|
||||
var task=list.get(0);assertThat(task.dispatchNo()).isEqualTo(created.dispatchNo());assertThat(task.pickingReconciled()).isTrue();
|
||||
assertThat(task.items()).extracting(DispatchViews.PdaItem::customerId).containsExactly("101","202");
|
||||
assertThat(task.items()).allSatisfy(item->assertThat(item.positions()).hasSize(1));
|
||||
assertThatThrownBy(()->f.tx.execute(s->queries.task(new DispatchContracts.Actor("foreign",ActorMode.PDA,"99"),created.dispatchNo())))
|
||||
.isInstanceOf(com.baiyue.common.core.exception.BizException.class).hasMessageContaining("当前绑定仓无此排车作业");
|
||||
f.db.update("DELETE FROM ll_dispatch_pda_pick_order WHERE customer_id='202'");
|
||||
assertThat(f.tx.execute(s->queries.task(actor,created.dispatchNo())).pickingReconciled()).isFalse();
|
||||
}
|
||||
}
|
||||
+104
@@ -0,0 +1,104 @@
|
||||
package com.baiyue.lastleg.dispatch.service;
|
||||
|
||||
import com.baomidou.mybatisplus.core.MybatisConfiguration;
|
||||
import com.baomidou.mybatisplus.core.metadata.TableInfoHelper;
|
||||
import com.baomidou.mybatisplus.extension.spring.MybatisSqlSessionFactoryBean;
|
||||
import com.baiyue.lastleg.dispatch.entity.DispatchItem;
|
||||
import com.baiyue.lastleg.dispatch.mapper.*;
|
||||
import com.baiyue.lastleg.dispatch.pda.entity.*;
|
||||
import com.baiyue.lastleg.dispatch.pda.mapper.*;
|
||||
import com.baiyue.lastleg.dispatch.pda.service.DispatchPickOrderAttributionService;
|
||||
import com.baiyue.lastleg.dispatch.port.*;
|
||||
import com.baiyue.lastleg.dispatch.repository.*;
|
||||
import com.baiyue.lastleg.inventory.command.service.InventoryCommandService;
|
||||
import com.baiyue.lastleg.inventory.mapper.InventoryPositionMapper;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.mybatis.spring.SqlSessionTemplate;
|
||||
import java.time.*;
|
||||
import java.util.*;
|
||||
import static com.baiyue.lastleg.dispatch.port.DispatchTypes.*;
|
||||
import static org.assertj.core.api.Assertions.*;
|
||||
import static org.mockito.ArgumentMatchers.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
/** 根版本、幂等、真实客户作业与扫码持久化;库存/归属应用命令在此明确模拟,另由原域直接测试验证。 */
|
||||
class DispatchPickingPersistenceTest {
|
||||
/** 原键在后续版本仍恢复原结果,不能重复调用SHIP或创建第二条真实扫码。 */
|
||||
@Test void sameKeyShipsOnceAndLastPickDoesNotConfirmLoading() throws Exception {
|
||||
var fixture=new Picking();var request=new DispatchCommands.Pick(fixture.version,"pda-pick-original",11L,3,6,null,"实际扫码");
|
||||
var result=fixture.f.tx.execute(s->fixture.service.pick(fixture.actor,fixture.number,fixture.item,request));
|
||||
assertThat(result.stage()).isEqualTo(Stage.LOADING);assertThat(((DispatchResults.Picked)result.result()).pickingReconciled()).isTrue();
|
||||
var replay=fixture.f.tx.execute(s->fixture.service.pick(fixture.actor,fixture.number,fixture.item,request));
|
||||
assertThat(replay.replayed()).isTrue();assertThat(replay.recordVersion()).isEqualTo(result.recordVersion());
|
||||
verify(fixture.inventory,times(1)).ship(eq("101"),eq("picker"),any());
|
||||
assertThat(fixture.f.db.queryForObject("SELECT COUNT(*) FROM ll_dispatch_pda_pick",Long.class)).isOne();
|
||||
assertThat(fixture.f.db.queryForObject("SELECT task_no FROM ll_dispatch_pda_task",String.class)).isEqualTo(fixture.number);
|
||||
assertThat(fixture.f.db.queryForObject("SELECT customer_id FROM ll_dispatch_pda_pick",String.class)).isEqualTo("101");
|
||||
assertThat(fixture.f.db.queryForObject("SELECT stage FROM ll_dispatch_order",String.class)).isEqualTo("LOADING");
|
||||
assertThat(fixture.f.db.queryForObject("SELECT available_packages FROM ll_inventory_position WHERE id=11",Long.class)).isZero();
|
||||
assertThatThrownBy(()->fixture.f.tx.execute(s->fixture.service.pick(new DispatchContracts.Actor("someone-else",ActorMode.PDA,"9"),fixture.number,fixture.item,request))).isInstanceOf(com.baiyue.common.core.exception.BizException.class);
|
||||
verify(fixture.inventory,times(1)).ship(any(),any(),any());
|
||||
}
|
||||
/** 不同真实客户共用唯一整车任务,客户归属留在扫码明细和库存命令。 */
|
||||
@Test void twoCustomersUseOneTaskAndKeepEachRealPickOwner() throws Exception {
|
||||
var fixture=new Picking(true);
|
||||
var first=fixture.f.tx.execute(s->fixture.service.pick(fixture.actor,fixture.number,fixture.item,new DispatchCommands.Pick(fixture.version,"first-customer",11L,3,6,null,null)));
|
||||
assertThat(((DispatchResults.Picked)first.result()).pickingReconciled()).isFalse();
|
||||
var second=fixture.f.tx.execute(s->fixture.service.pick(fixture.actor,fixture.number,fixture.secondItem,new DispatchCommands.Pick(first.recordVersion(),"second-customer",12L,2,4,null,null)));
|
||||
assertThat(((DispatchResults.Picked)second.result()).pickingReconciled()).isTrue();
|
||||
assertThat(second.stage()).isEqualTo(Stage.LOADING);
|
||||
assertThat(fixture.f.db.queryForObject("SELECT COUNT(*) FROM ll_dispatch_pda_task",Long.class)).isOne();
|
||||
assertThat(fixture.f.db.queryForObject("SELECT COUNT(DISTINCT task_id) FROM ll_dispatch_pda_pick",Long.class)).isOne();
|
||||
assertThat(fixture.f.db.queryForList("SELECT customer_id FROM ll_dispatch_pda_pick ORDER BY customer_id",String.class)).containsExactly("101","102");
|
||||
assertThat(Arrays.stream(DispatchPdaTask.class.getDeclaredFields()).map(java.lang.reflect.Field::getName)).doesNotContain("customerId");
|
||||
verify(fixture.inventory).ship(eq("101"),eq("picker"),any());verify(fixture.inventory).ship(eq("102"),eq("picker"),any());
|
||||
}
|
||||
/** 归属失败时连模拟库存SQL也回滚,不能留下仅库存已扣而没有扫码/账本的部分结果。 */
|
||||
@Test void attributionFailureRollsBackInventoryPickTaskHeaderAndLedger() throws Exception {
|
||||
var fixture=new Picking();doThrow(new IllegalStateException("模拟归属失败")).when(fixture.attribution).attribute(eq("101"),any(DispatchPdaPick.class),isNull(),isNull());
|
||||
assertThatThrownBy(()->fixture.f.tx.execute(s->fixture.service.pick(fixture.actor,fixture.number,fixture.item,new DispatchCommands.Pick(fixture.version,"rollback-pick",11L,3,6,null,null)))).isInstanceOf(IllegalStateException.class);
|
||||
assertThat(fixture.f.db.queryForObject("SELECT available_packages FROM ll_inventory_position WHERE id=11",Long.class)).isEqualTo(3);
|
||||
assertThat(fixture.f.db.queryForObject("SELECT COUNT(*) FROM ll_dispatch_pda_pick",Long.class)).isZero();assertThat(fixture.f.db.queryForObject("SELECT COUNT(*) FROM ll_dispatch_pda_task",Long.class)).isZero();
|
||||
assertThat(fixture.f.db.queryForObject("SELECT record_version FROM ll_dispatch_order",Long.class)).isEqualTo(fixture.version);
|
||||
assertThat(fixture.f.db.queryForObject("SELECT COUNT(*) FROM ll_dispatch_command WHERE idempotency_key='rollback-pick'",Long.class)).isZero();
|
||||
}
|
||||
|
||||
/** 真主单和实际MyBatis写入;仅模拟两个独立应用命令提供者,未连接真实环境。 */
|
||||
private static final class Picking {
|
||||
final DispatchAggregatePersistenceTest.Fixture f=new DispatchAggregatePersistenceTest.Fixture();
|
||||
final InventoryCommandService inventory=mock(InventoryCommandService.class);final DispatchPickOrderAttributionService attribution=mock(DispatchPickOrderAttributionService.class);
|
||||
final DispatchPickingService service;final String number;final long item,version;final Long secondItem;
|
||||
final DispatchContracts.Actor actor=new DispatchContracts.Actor("picker",ActorMode.PDA,"9");
|
||||
/** 建立真实客户来源、作业表、扫码表与可以参与回滚的库存行。 */
|
||||
Picking() throws Exception {this(false);}
|
||||
Picking(boolean multipleCustomers) throws Exception {
|
||||
var selections=new ArrayList<DispatchCommands.CargoSelection>();selections.add(f.source(1,"101",501,"ONT8",3));
|
||||
if(multipleCustomers)selections.add(f.source(2,"102",502,"LAX9",2));
|
||||
var created=f.tx.execute(s->f.service.create(f.actor,f.configured("picking-create",selections)));number=created.dispatchNo();version=f.approve(created).recordVersion();
|
||||
item=f.db.queryForObject("SELECT id FROM ll_dispatch_item WHERE customer_id='101'",Long.class);
|
||||
secondItem=multipleCustomers?f.db.queryForObject("SELECT id FROM ll_dispatch_item WHERE customer_id='102'",Long.class):null;
|
||||
f.db.update("INSERT INTO ll_order(id,customer_id,order_no,source_region,source_inbound_id)VALUES(9001,'101','ORDER-1','OVERSEAS','SOURCE-1')");
|
||||
f.db.update("INSERT INTO ll_inbound_stock_receipt(id,customer_id,inbound_code)VALUES(1,'101','IN-1')");
|
||||
if(multipleCustomers){f.db.update("INSERT INTO ll_order(id,customer_id,order_no,source_region,source_inbound_id)VALUES(9002,'102','ORDER-2','OVERSEAS','SOURCE-2')");f.db.update("INSERT INTO ll_inbound_stock_receipt(id,customer_id,inbound_code)VALUES(2,'102','IN-2')");}
|
||||
var config=new MybatisConfiguration();config.setMapUnderscoreToCamelCase(true);
|
||||
for(var mapper:List.of(DispatchPdaTaskMapper.class,DispatchPdaPickMapper.class,InventoryPositionMapper.class,DispatchItemMapper.class))config.addMapper(mapper);
|
||||
var factory=new MybatisSqlSessionFactoryBean();factory.setDataSource(f.db.getDataSource());factory.setConfiguration(config);factory.afterPropertiesSet();var session=new SqlSessionTemplate(factory.getObject());
|
||||
f.db.execute("DROP TABLE ll_dispatch_pda_pick");
|
||||
for(var type:List.of(DispatchPdaPick.class,DispatchPdaTask.class,com.baiyue.lastleg.inventory.entity.InventoryPosition.class)) {
|
||||
var table=TableInfoHelper.getTableInfo(type);var columns=new ArrayList<String>();columns.add(table.getKeyColumn()+" BIGINT PRIMARY KEY");
|
||||
for(var field:table.getFieldList())columns.add(field.getColumn()+" "+sqlType(field.getPropertyType()));f.db.execute("CREATE TABLE "+table.getTableName()+"("+String.join(",",columns)+")");
|
||||
}
|
||||
f.db.execute("ALTER TABLE ll_dispatch_pda_task ADD CONSTRAINT unique_task_dispatch UNIQUE(dispatch_order_id)");
|
||||
f.db.update("INSERT INTO ll_inventory_position(id,customer_id,balance_id,pallet_id,pallet_no,overseas_warehouse_id,available_packages,hold_packages,allocated_packages,picked_packages,version)VALUES(11,'101',21,501,'PL-501','9',3,0,0,0,1)");
|
||||
if(multipleCustomers)f.db.update("INSERT INTO ll_inventory_position(id,customer_id,balance_id,pallet_id,pallet_no,overseas_warehouse_id,available_packages,hold_packages,allocated_packages,picked_packages,version)VALUES(12,'102',22,502,'PL-502','9',2,0,0,0,1)");
|
||||
doAnswer(call->{com.baiyue.lastleg.inventory.command.dto.InventoryCommandRequests.Move move=call.getArgument(2);f.db.update("UPDATE ll_inventory_position SET available_packages=available_packages-?,version=version+1 WHERE id=? AND customer_id=?",move.packages(),move.positionId(),call.getArgument(0));return null;}).when(inventory).ship(anyString(),eq("picker"),any());
|
||||
doAnswer(call->{String customer=call.getArgument(0);DispatchPdaPick pick=call.getArgument(1);long order="101".equals(customer)?9001L:9002L;long receipt="101".equals(customer)?1L:2L;f.db.update("INSERT INTO ll_dispatch_pda_pick_order(id,customer_id,pick_id,order_id,receipt_id,packages,product_quantity)VALUES(?,?,?,?,?,?,?)",pick.getId(),customer,pick.getId(),order,receipt,pick.getPackages(),pick.getProductQuantity());return List.of(new DispatchPickOrderAttributionService.OrderPackages(order,pick.getPackages()));})
|
||||
.when(attribution).attribute(anyString(),any(DispatchPdaPick.class),isNull(),isNull());
|
||||
var sources=new DispatchSourceRepository(mock(DispatchCandidateMapper.class),mock(ReturnDispatchCandidateMapper.class),f.session.getMapper(DispatchSourceMapper.class));
|
||||
var commands=new DispatchCommandLedger(new DispatchCommandRepository(f.session.getMapper(DispatchCommandMapper.class)),new DispatchCommandCodec(f.json));
|
||||
service=new DispatchPickingService(f.service,f.repository,sources,new DispatchPickingRepository(session.getMapper(DispatchPdaTaskMapper.class),session.getMapper(DispatchPdaPickMapper.class),session.getMapper(InventoryPositionMapper.class),session.getMapper(DispatchItemMapper.class)),attribution,inventory,mock(ReturnRelabelDispatchStock.class),commands,new DispatchCommandHasher(f.json),f.json);
|
||||
}
|
||||
/** 使用实际实体属性建最小测试表,不修改或声称验证正式DDL。 */
|
||||
private static String sqlType(Class<?> type){return type==Long.class?"BIGINT":type==Integer.class?"INT":type==LocalDateTime.class?"TIMESTAMP(3)":"VARCHAR(20000)";}
|
||||
}
|
||||
}
|
||||
+19
-1
@@ -131,6 +131,24 @@ class DispatchRepairWorkflowTest {
|
||||
var execution = new DeliveryExecutionRepository(mapper(OutboundOrderMapper.class), mapper(DeliveryOrderMapper.class));
|
||||
dependencies.put(DeliveryExecutionRepository.class, execution);
|
||||
dependencies.put(DeliveryExecutionGuard.class, new DeliveryExecutionGuard(execution));
|
||||
dependencies.put(com.baiyue.lastleg.delivery.repository.DeliveryCancellationRepository.class,
|
||||
new com.baiyue.lastleg.delivery.repository.DeliveryCancellationRepository(mapper(OutboundOrderMapper.class),
|
||||
mapper(OutboundPalletItemMapper.class),mapper(DeliveryOrderMapper.class),mapper(OutboundPdaTaskMapper.class),
|
||||
mapper(DeliveryOperationMapper.class),mapper(RelabelApplicationMapper.class),mapper(ReturnInventoryCartonMapper.class),
|
||||
mapper(WarehouseTransferOrderMapper.class),mapper(LastlegOrderMapper.class),mapper(LastlegOrderStatusHistoryMapper.class)));
|
||||
// 本旧流程夹具只隔离新整车端口;父身份及已拣数量仍从本事务实际锁定的记录读取。
|
||||
var wholeExecution=mock(com.baiyue.lastleg.dispatch.port.DispatchExecutionPort.class);
|
||||
when(wholeExecution.lockForArrangement(any(),anyString())).thenAnswer(call -> {
|
||||
var parent=mapper(DispatchOrderMapper.class).lockGlobalByDispatchNo(call.getArgument(1));
|
||||
var context=mock(com.baiyue.lastleg.dispatch.port.DispatchContracts.Context.class);
|
||||
when(context.dispatchId()).thenReturn(parent.getId());
|
||||
when(context.status()).thenReturn(com.baiyue.lastleg.dispatch.port.DispatchTypes.Status.ACTIVE);
|
||||
when(context.stage()).thenReturn(com.baiyue.lastleg.dispatch.port.DispatchTypes.Stage.BEFORE_LOADING);
|
||||
when(context.counts()).thenReturn(new com.baiyue.lastleg.dispatch.port.DispatchContracts.Counts(0,0,0,
|
||||
parent.getPickedPackageCount()==null?0:parent.getPickedPackageCount(),0));
|
||||
return context;
|
||||
});
|
||||
dependencies.put(com.baiyue.lastleg.dispatch.port.DispatchExecutionPort.class,wholeExecution);
|
||||
dependencies.put(DispatchCargoSourceQuery.class,
|
||||
new DispatchCargoSourceQuery(new DispatchCargoSourceRepository(mapper(DispatchFeeCargoMapper.class))));
|
||||
dependencies.put(com.baiyue.lastleg.appointmentwork.AppointmentWorkDispatchPort.class,
|
||||
@@ -350,7 +368,7 @@ class DispatchRepairWorkflowTest {
|
||||
/** 准备统一未拣货排车、出库及派送关系,避免用桩伪造取消资格。 */
|
||||
private void seedPendingOutbound() {
|
||||
db.update("INSERT INTO ll_dispatch_order(id,customer_id,dispatch_no,overseas_warehouse_id,status,package_count,picked_package_count,shipped_package_count) VALUES(1,'C','DP-1','WH-1','PENDING_DEPARTURE',2,0,0)");
|
||||
db.update("INSERT INTO ll_outbound_order(id,customer_id,outbound_no,dispatch_order_id,outbound_type,status,package_count) VALUES(2,'C','OUT-1',1,'DISPATCH_OUTBOUND','WAIT_OUTBOUND',2)");
|
||||
db.update("INSERT INTO ll_outbound_order(id,customer_id,outbound_no,dispatch_order_id,dispatch_no,outbound_type,status,package_count) VALUES(2,'C','OUT-1',1,'DP-1','DISPATCH_OUTBOUND','WAIT_OUTBOUND',2)");
|
||||
db.update("INSERT INTO ll_delivery_order(id,customer_id,delivery_no,outbound_no,outbound_order_id,dispatch_order_id,status,version,package_count) VALUES(3,'C','DL-1','OUT-1',2,1,'WAIT_DELIVERY',0,2)");
|
||||
db.update("INSERT INTO ll_dispatch_item(id,customer_id,dispatch_order_id,warehouse_pallet_id,pallet_no,overseas_warehouse_id,package_count,destination_type,destination_warehouse_code) VALUES(4,'C',1,501,'P-501','WH-1',2,'FBA','ONT8')");
|
||||
db.update("INSERT INTO ll_outbound_pallet_item(id,customer_id,outbound_order_id,warehouse_pallet_id,pallet_no,package_count,picked_package_count,status,destination_warehouse_code) VALUES(5,'C',2,501,'P-501',2,0,'WAIT_PICK','ONT8')");
|
||||
|
||||
+14
-259
@@ -125,7 +125,6 @@ class DispatchServiceTest {
|
||||
@Mock DispatchCandidateMapper candidates;
|
||||
@Mock com.baiyue.lastleg.dispatch.mapper.ReturnDispatchCandidateMapper returnCandidates;
|
||||
@Mock DispatchDraftMapper drafts;
|
||||
@Mock DispatchDraftContainerMapper draftContainers;
|
||||
@Mock DispatchPdaTaskMapper pdaTasks;
|
||||
@Mock LastlegOrderMapper tailOrders;
|
||||
@Mock OutboundOrderMapper outboundOrders;
|
||||
@@ -151,7 +150,6 @@ class DispatchServiceTest {
|
||||
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);
|
||||
ReflectionTestUtils.setField(service, "tailOrders", tailOrders);
|
||||
ReflectionTestUtils.setField(service, "outboundOrders", outboundOrders);
|
||||
@@ -217,35 +215,9 @@ class DispatchServiceTest {
|
||||
assertEquals("ONT8,ABE4", dispatch.getValue().getRoute());
|
||||
}
|
||||
|
||||
@Test
|
||||
void movingPalletIntoDraftImmediatelyCreatesExpiringExclusiveLock() {
|
||||
when(candidates.lockSelected("100", List.of(10L))).thenReturn(List.of(candidate(10L, null, "ONT8")));
|
||||
|
||||
service.lockDraftPallet("100", "7", new DispatchRequests.DraftPallet(10L, "draft-1"));
|
||||
|
||||
ArgumentCaptor<DispatchPalletLock> lock = ArgumentCaptor.forClass(DispatchPalletLock.class);
|
||||
verify(locks).insert(lock.capture());
|
||||
assertEquals("draft-1", lock.getValue().getDraftToken());
|
||||
assertEquals("7", lock.getValue().getOperatorId());
|
||||
ArgumentCaptor<DispatchDraftContainer> group = ArgumentCaptor.forClass(DispatchDraftContainer.class);
|
||||
verify(draftContainers).insert(group.capture());
|
||||
assertNotNull(group.getValue().getId());
|
||||
assertEquals(30, group.getValue().getCapacity());
|
||||
assertEquals(group.getValue().getId(), lock.getValue().getDraftContainerId());
|
||||
assertNotNull(lock.getValue().getExpiresAt());
|
||||
assertEquals(null, lock.getValue().getDispatchOrderId());
|
||||
verify(locks).clearInactiveActiveKey("100", "PLAN:10");
|
||||
}
|
||||
|
||||
@Test
|
||||
void movingVolumeOnlyPlanPalletIntoDraftCreatesLock() {
|
||||
when(candidates.lockSelected("100", List.of(10L)))
|
||||
.thenReturn(List.of(candidate(10L, null, "ONT8", 0)));
|
||||
|
||||
service.lockDraftPallet("100", "7", new DispatchRequests.DraftPallet(10L, "draft-1"));
|
||||
|
||||
verify(locks).insert(any(DispatchPalletLock.class));
|
||||
}
|
||||
|
||||
private DispatchDraftContainer existingDraftGroup() {
|
||||
DispatchDraft draft = new DispatchDraft();
|
||||
@@ -256,49 +228,13 @@ class DispatchServiceTest {
|
||||
return group;
|
||||
}
|
||||
|
||||
@Test void omittedGroupReusesTheOnlyGroupAndPersistsItsIdentity() {
|
||||
var group = existingDraftGroup();
|
||||
when(draftContainers.findByToken("draft-1")).thenReturn(List.of(group));
|
||||
when(candidates.lockSelected("100", List.of(10L))).thenReturn(List.of(candidate(10L, null, "ONT8")));
|
||||
service.lockDraftPallet("100", "7", new DispatchRequests.DraftPallet(10L, "draft-1"));
|
||||
var lock = ArgumentCaptor.forClass(DispatchPalletLock.class);
|
||||
verify(locks).insert(lock.capture());
|
||||
assertEquals(21L, lock.getValue().getDraftContainerId());
|
||||
verify(draftContainers, never()).insert(any(DispatchDraftContainer.class));
|
||||
}
|
||||
|
||||
@Test void omittedGroupReplayDoesNotCreateAnotherGroupOrLock() {
|
||||
var group = existingDraftGroup();
|
||||
when(draftContainers.findByToken("draft-1")).thenReturn(List.of(group));
|
||||
when(candidates.lockSelected("100", List.of(10L))).thenReturn(List.of(candidate(10L, null, "ONT8")));
|
||||
var lock = new DispatchPalletLock(); lock.setDraftToken("draft-1"); lock.setOperatorId("7"); lock.setDraftContainerId(21L);
|
||||
when(locks.lockActive("100", "PLAN:10")).thenReturn(lock);
|
||||
service.lockDraftPallet("100", "7", new DispatchRequests.DraftPallet(10L, "draft-1"));
|
||||
verify(draftContainers, never()).insert(any(DispatchDraftContainer.class));
|
||||
verify(locks, never()).insert(any(DispatchPalletLock.class));
|
||||
}
|
||||
|
||||
@Test void multipleGroupsRequireExplicitSelectionWithoutCreatingAnotherGroup() {
|
||||
var group = existingDraftGroup();
|
||||
var second = new DispatchDraftContainer(); second.setId(22L);
|
||||
when(draftContainers.findByToken("draft-1")).thenReturn(List.of(group, second));
|
||||
assertEquals(400, assertThrows(ResponseStatusException.class, () ->
|
||||
service.lockDraftPallet("100", "7", new DispatchRequests.DraftPallet(10L, "draft-1"))).getStatusCode().value());
|
||||
verify(draftContainers, never()).insert(any(DispatchDraftContainer.class));
|
||||
verify(locks, never()).insert(any(DispatchPalletLock.class));
|
||||
}
|
||||
|
||||
@Test void replayCannotSilentlyMoveAPalletToAnotherGroup() {
|
||||
var group = existingDraftGroup();
|
||||
when(draftContainers.selectById(21L)).thenReturn(group);
|
||||
when(candidates.lockSelected("100", List.of(10L))).thenReturn(List.of(candidate(10L, null, "ONT8")));
|
||||
var lock = new DispatchPalletLock(); lock.setDraftToken("draft-1"); lock.setOperatorId("7"); lock.setDraftContainerId(22L);
|
||||
when(locks.lockActive("100", "PLAN:10")).thenReturn(lock);
|
||||
assertEquals(409, assertThrows(ResponseStatusException.class, () ->
|
||||
service.lockDraftPallet("100", "7", new DispatchRequests.DraftPallet(10L, "draft-1", 21L))).getStatusCode().value());
|
||||
assertEquals(22L, lock.getDraftContainerId());
|
||||
verify(locks, never()).insert(any(DispatchPalletLock.class));
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@Test
|
||||
void preArrangeAcceptsVolumeOnlyPlanPallet() {
|
||||
@@ -345,19 +281,7 @@ class DispatchServiceTest {
|
||||
verify(orders, never()).insert(any(DispatchOrder.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
void removingPalletFromOwnedDraftImmediatelyReleasesLock() {
|
||||
when(candidates.lockSelected("100", List.of(10L))).thenReturn(List.of(candidate(10L, null, "ONT8")));
|
||||
DispatchPalletLock lock = new DispatchPalletLock();
|
||||
lock.setDraftToken("draft-1"); lock.setOperatorId("7"); lock.setStatus("ACTIVE"); lock.setActiveKey("PLAN:10");
|
||||
when(locks.lockActive("100", "PLAN:10")).thenReturn(lock);
|
||||
|
||||
service.unlockDraftPallet("100", "7", new DispatchRequests.DraftPallet(10L, "draft-1"));
|
||||
|
||||
assertEquals("RELEASED", lock.getStatus());
|
||||
assertEquals(null, lock.getActiveKey());
|
||||
verify(locks).updateById(lock);
|
||||
}
|
||||
|
||||
@Test
|
||||
void preArrangeKeepsVirtualVehicleAndItsLocksEditableUntilOutboundExists() {
|
||||
@@ -406,34 +330,9 @@ class DispatchServiceTest {
|
||||
verify(orders).updateById(dispatch);
|
||||
}
|
||||
|
||||
@Test
|
||||
void activeDraftOnlyRestoresLocksStillOwnedByThatDraft() {
|
||||
DispatchDraft draft = new DispatchDraft();
|
||||
draft.setDraftToken("draft-1"); draft.setOperatorId("7"); draft.setStatus("ACTIVE");
|
||||
DispatchDraftContainer container = new DispatchDraftContainer();
|
||||
container.setId(21L); container.setDraftToken("draft-1"); container.setCapacity(12);
|
||||
DispatchPalletLock owned = new DispatchPalletLock();
|
||||
owned.setPlanPalletId(10L); owned.setDraftToken("draft-1"); owned.setDraftContainerId(21L);
|
||||
when(drafts.findActiveByOperator("7")).thenReturn(List.of(draft));
|
||||
when(draftContainers.findByToken("draft-1")).thenReturn(List.of(container));
|
||||
when(locks.findActiveByDraftContainer("draft-1", 21L)).thenReturn(List.of(owned));
|
||||
|
||||
var result = service.activeDrafts("7");
|
||||
|
||||
assertEquals(List.of("10"), result.get(0).containers().get(0).palletIds());
|
||||
verify(locks).findActiveByDraftContainer("draft-1", 21L);
|
||||
}
|
||||
|
||||
@Test
|
||||
void draftContainerOnlyAcceptsFifteenOrThirtyPalletVehicles() {
|
||||
ResponseStatusException exception = assertThrows(ResponseStatusException.class,
|
||||
() -> service.createDraftContainer("7", new DispatchRequests.DraftContainer("draft-1", 14)));
|
||||
|
||||
assertEquals(409, exception.getStatusCode().value());
|
||||
assertEquals("车辆大小仅支持15托或30托", exception.getReason());
|
||||
verify(drafts, never()).lockActive(any(), any());
|
||||
verify(draftContainers, never()).insert(any(DispatchDraftContainer.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsPalletAlreadyLockedByAnotherDispatch() {
|
||||
@@ -684,169 +583,25 @@ class DispatchServiceTest {
|
||||
verify(locks, never()).insert(any(DispatchPalletLock.class)); verify(pdaTasks, never()).insert(any(DispatchPdaTask.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
void twoLoadingGroupsCreateTwoIndependentDispatchesAndExactReplayDoesNotWriteAgain() throws Exception {
|
||||
var fixture = twoGroupFixture();
|
||||
assertNotNull(DispatchService.class.getMethod("preArrangeDraft", String.class,
|
||||
DispatchRequests.PreArrangeDraft.class).getAnnotation(Transactional.class));
|
||||
var result = service.preArrangeDraft("7", fixture.request());
|
||||
assertEquals(2, result.size());
|
||||
assertTrue(!result.get(0).dispatch().id().equals(result.get(1).dispatch().id()));
|
||||
for (var group : result) {
|
||||
assertEquals(2, group.dispatch().palletCount()); assertEquals(2, group.dispatch().packageCount());
|
||||
assertEquals("ABE4,ABE8", group.dispatch().route());
|
||||
assertEquals(2, group.dispatch().items().size());
|
||||
}
|
||||
assertEquals(java.util.Set.of(10L, 11L), result.get(0).dispatch().items().stream()
|
||||
.map(DispatchResponse.Item::planPalletId).collect(java.util.stream.Collectors.toSet()));
|
||||
assertEquals(java.util.Set.of(12L, 13L), result.get(1).dispatch().items().stream()
|
||||
.map(DispatchResponse.Item::planPalletId).collect(java.util.stream.Collectors.toSet()));
|
||||
org.mockito.Mockito.clearInvocations(orders, items, locks, pdaTasks, drafts);
|
||||
assertEquals(result, service.preArrangeDraft("7", fixture.request()));
|
||||
verify(orders, never()).insert(any(DispatchOrder.class)); verify(orders, never()).updateById(any(DispatchOrder.class));
|
||||
verify(items, never()).insert(any(DispatchItem.class)); verify(locks, never()).updateById(any(DispatchPalletLock.class));
|
||||
verify(pdaTasks, never()).insert(any(DispatchPdaTask.class)); verify(drafts, never()).updateById(any(DispatchDraft.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
void flatRequestCannotCollapseTwoOwnedDraftContainers() {
|
||||
var fixture = twoGroupFixture();
|
||||
when(candidates.lockSelected("100", List.of(10L,11L,12L,13L))).thenReturn(fixture.candidates());
|
||||
assertThrows(ResponseStatusException.class, () -> service.preArrange("100", "7",
|
||||
new DispatchRequests.PreArrange(List.of(10L,11L,12L,13L),null,null,null,"draft-multi")));
|
||||
verify(orders, never()).insert(any(DispatchOrder.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
void groupedInitialResponseUsesPersistedTimestampPrecisionForExactReplay() {
|
||||
var fixture = twoGroupFixture(true);
|
||||
var first = service.preArrangeDraft("7", fixture.request());
|
||||
assertEquals(123_000_000, first.get(0).dispatch().createdAt().getNano());
|
||||
assertEquals(first, service.preArrangeDraft("7", fixture.request()));
|
||||
}
|
||||
|
||||
@Test
|
||||
void changedMembershipDuplicateSourceExpiredDraftAndCapacityFailBeforeCreatingAnyGroup() {
|
||||
var fixture = twoGroupFixture();
|
||||
var swapped = new DispatchRequests.PreArrangeDraft("draft-multi", List.of(
|
||||
new DispatchRequests.DraftGroup(21L,List.of(10L,12L),List.of()),
|
||||
new DispatchRequests.DraftGroup(22L,List.of(11L,13L),List.of())));
|
||||
assertThrows(ResponseStatusException.class, () -> service.preArrangeDraft("7", swapped));
|
||||
var duplicated = new DispatchRequests.PreArrangeDraft("draft-multi", List.of(
|
||||
new DispatchRequests.DraftGroup(21L,List.of(10L,11L),List.of()),
|
||||
new DispatchRequests.DraftGroup(22L,List.of(10L,13L),List.of())));
|
||||
assertThrows(ResponseStatusException.class, () -> service.preArrangeDraft("7", duplicated));
|
||||
fixture.containers().get(1).setCapacity(1);
|
||||
assertThrows(ResponseStatusException.class, () -> service.preArrangeDraft("7", fixture.request()));
|
||||
fixture.containers().get(1).setCapacity(30);
|
||||
fixture.draft().setExpiresAt(LocalDateTime.now().minusSeconds(1));
|
||||
assertThrows(ResponseStatusException.class, () -> service.preArrangeDraft("7", fixture.request()));
|
||||
verify(orders, never()).insert(any(DispatchOrder.class)); verify(items, never()).insert(any(DispatchItem.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
void groupedPreArrangeRejectsUnsupportedStoredCapacityBeforeCreatingDispatches() {
|
||||
var fixture = twoGroupFixture();
|
||||
for (Integer capacity : java.util.Arrays.asList(null, 0, 31, 500)) {
|
||||
fixture.containers().get(0).setCapacity(capacity);
|
||||
var error = assertThrows(ResponseStatusException.class,
|
||||
() -> service.preArrangeDraft("7", fixture.request()));
|
||||
assertEquals(409, error.getStatusCode().value());
|
||||
assertEquals("车辆大小仅支持15托或30托", error.getReason());
|
||||
}
|
||||
verify(orders, never()).insert(any(DispatchOrder.class));
|
||||
verify(items, never()).insert(any(DispatchItem.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
void groupedPreArrangeRejectsThirtyOnePalletsInThirtyPalletVehicle() {
|
||||
twoGroupFixture();
|
||||
var palletIds = java.util.stream.LongStream.rangeClosed(100, 130).boxed().toList();
|
||||
var bound = palletIds.stream().map(id -> {
|
||||
var lock = new DispatchPalletLock();
|
||||
lock.setPlanPalletId(id);
|
||||
return lock;
|
||||
}).toList();
|
||||
when(locks.lockActiveByDraftContainer("draft-multi", 21L)).thenReturn(bound);
|
||||
var request = new DispatchRequests.PreArrangeDraft("draft-multi",
|
||||
List.of(new DispatchRequests.DraftGroup(21L, palletIds, List.of())));
|
||||
var error = assertThrows(ResponseStatusException.class,
|
||||
() -> service.preArrangeDraft("7", request));
|
||||
assertEquals(409, error.getStatusCode().value());
|
||||
assertEquals("装车分组超过车型托位容量", error.getReason());
|
||||
verify(orders, never()).insert(any(DispatchOrder.class));
|
||||
verify(items, never()).insert(any(DispatchItem.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
void secondGroupFailureRollsBackFirstGroupInsertInTheSameTransaction() {
|
||||
var fixture = twoGroupFixture();
|
||||
when(candidates.lockSelected("100",List.of(12L,13L))).thenReturn(List.of(
|
||||
candidate(12L,912L,null,1),candidate(13L,913L,"ABE8",1)));
|
||||
var ds = new org.h2.jdbcx.JdbcDataSource();
|
||||
ds.setURL("jdbc:h2:mem:dispatch-group-"+java.util.UUID.randomUUID()+";DB_CLOSE_DELAY=-1");
|
||||
var jdbc = new org.springframework.jdbc.core.JdbcTemplate(ds);
|
||||
jdbc.execute("CREATE TABLE dispatch_probe(id BIGINT PRIMARY KEY)");
|
||||
org.mockito.Mockito.doAnswer(call -> { var row=(DispatchOrder)call.getArgument(0);
|
||||
jdbc.update("INSERT INTO dispatch_probe VALUES(?)",row.getId());
|
||||
fixture.savedOrders().put(row.getId(), row);return 1; }).when(orders).insert(any(DispatchOrder.class));
|
||||
var transaction = new org.springframework.transaction.support.TransactionTemplate(
|
||||
new org.springframework.jdbc.datasource.DataSourceTransactionManager(ds));
|
||||
assertThrows(ResponseStatusException.class, () -> transaction.execute(status -> service.preArrangeDraft("7",fixture.request())));
|
||||
verify(orders).insert(any(DispatchOrder.class));
|
||||
assertEquals(0,jdbc.queryForObject("SELECT COUNT(*) FROM dispatch_probe",Integer.class));
|
||||
}
|
||||
|
||||
private GroupFixture twoGroupFixture() {
|
||||
return twoGroupFixture(false);
|
||||
}
|
||||
|
||||
private GroupFixture twoGroupFixture(boolean databaseTimestampPrecision) {
|
||||
var draft = new DispatchDraft(); draft.setId(1L);draft.setDraftToken("draft-multi");draft.setOperatorId("7");
|
||||
draft.setStatus("ACTIVE");draft.setExpiresAt(LocalDateTime.now().plusMinutes(30));
|
||||
lenient().when(drafts.lockActive("draft-multi","7")).thenReturn(draft);
|
||||
var containerList = new java.util.ArrayList<DispatchDraftContainer>();
|
||||
var allCandidates = new java.util.ArrayList<DispatchPalletCandidate>();
|
||||
var savedOrders = new java.util.HashMap<Long,DispatchOrder>();
|
||||
var savedItems = new java.util.HashMap<Long,java.util.ArrayList<DispatchItem>>();
|
||||
lenient().doAnswer(call -> {
|
||||
var row=(DispatchOrder)call.getArgument(0);
|
||||
if (databaseTimestampPrecision) {
|
||||
row.setCreatedAt(row.getCreatedAt().withNano(123_456_000));
|
||||
var persisted = new DispatchOrder();
|
||||
org.springframework.beans.BeanUtils.copyProperties(row, persisted);
|
||||
persisted.setCreatedAt(row.getCreatedAt().withNano(123_000_000));
|
||||
savedOrders.put(row.getId(), persisted);
|
||||
} else savedOrders.put(row.getId(),row);
|
||||
return 1;
|
||||
})
|
||||
.when(orders).insert(any(DispatchOrder.class));
|
||||
lenient().when(orders.lockById(eq("100"),any())).thenAnswer(call -> savedOrders.get(call.getArgument(1)));
|
||||
lenient().doAnswer(call -> {var row=(DispatchItem)call.getArgument(0);savedItems.computeIfAbsent(row.getDispatchOrderId(),k->new java.util.ArrayList<>()).add(row);return 1;})
|
||||
.when(items).insert(any(DispatchItem.class));
|
||||
lenient().when(items.findByDispatchOrder(eq("100"),any())).thenAnswer(call -> savedItems.getOrDefault(call.getArgument(1),new java.util.ArrayList<>()));
|
||||
for(int i=0;i<2;i++) {
|
||||
long containerId=21L+i, first=10L+2*i;
|
||||
var c=new DispatchDraftContainer();c.setId(containerId);c.setDraftId(1L);c.setDraftToken("draft-multi");c.setCapacity(30);c.setSequenceNo(i+1);containerList.add(c);
|
||||
var rows=List.of(candidate(first,900L+first,"ABE4",1),candidate(first+1,901L+first,"ABE8",1));allCandidates.addAll(rows);
|
||||
var bound=new java.util.ArrayList<DispatchPalletLock>();
|
||||
for(var row:rows) {var lock=new DispatchPalletLock();lock.setId(row.planPalletId());lock.setCustomerId("100");
|
||||
lock.setDraftToken("draft-multi");lock.setDraftContainerId(containerId);lock.setOperatorId("7");
|
||||
lock.setStatus("ACTIVE");lock.setPlanPalletId(row.planPalletId());lock.setSourcePalletKey(row.sourcePalletKey());bound.add(lock);
|
||||
lenient().when(locks.lockActive("100",row.sourcePalletKey())).thenReturn(lock);}
|
||||
lenient().when(locks.lockActiveByDraftContainer("draft-multi",containerId)).thenReturn(bound);
|
||||
lenient().when(locks.findDispatchOrderIdByDraftContainer("draft-multi",containerId)).thenAnswer(call -> bound.get(0).getDispatchOrderId());
|
||||
lenient().when(candidates.findCustomerScopesByPlanPalletIds(List.of(first,first+1))).thenReturn(List.of("100"));
|
||||
lenient().when(candidates.lockSelected("100",List.of(first,first+1))).thenReturn(rows);
|
||||
}
|
||||
lenient().when(draftContainers.findByToken("draft-multi")).thenReturn(containerList);
|
||||
return new GroupFixture(draft,containerList,allCandidates,new DispatchRequests.PreArrangeDraft("draft-multi",List.of(
|
||||
new DispatchRequests.DraftGroup(21L,List.of(10L,11L),List.of()),new DispatchRequests.DraftGroup(22L,List.of(12L,13L),List.of()))),savedOrders);
|
||||
}
|
||||
|
||||
private record GroupFixture(DispatchDraft draft,List<DispatchDraftContainer> containers,
|
||||
List<DispatchPalletCandidate> candidates,DispatchRequests.PreArrangeDraft request,
|
||||
java.util.Map<Long, DispatchOrder> savedOrders) { }
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
private DispatchPalletCandidate candidate(long planPalletId, Long warehousePalletId, String destination) {
|
||||
return candidate(planPalletId, warehousePalletId, destination, 12);
|
||||
|
||||
+73
@@ -0,0 +1,73 @@
|
||||
package com.baiyue.lastleg.dispatch.service;
|
||||
|
||||
import com.baiyue.lastleg.dispatch.mapper.*;
|
||||
import com.baiyue.lastleg.dispatch.port.*;
|
||||
import com.baiyue.lastleg.dispatch.repository.DispatchSourceRepository;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.test.util.ReflectionTestUtils;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
import java.util.*;
|
||||
import static org.assertj.core.api.Assertions.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
import static org.mockito.ArgumentMatchers.*;
|
||||
|
||||
/** 候选投影在此受控,来源订单/仓/库存版本/实收组成与锁序均经实际MyBatis查询。 */
|
||||
class DispatchWholeSourcePersistenceTest {
|
||||
/** 同仓多客户先锁齐来源订单,随后稳定锁计划和库存,不以MIN展示订单替代组成。 */
|
||||
@Test void resolvesMultipleCustomersAndLocksAllSourceOrdersBeforeInventory() throws Exception {
|
||||
var fixture=new Sources();var selections=fixture.selections();clearInvocations(fixture.sourceMapper);
|
||||
var actual=fixture.f.tx.execute(s->fixture.rules.lockWholeSources("9",selections));
|
||||
assertThat(actual).extracting(s->s.candidate().customerId()).containsExactly("101","202");
|
||||
var order=inOrder(fixture.sourceMapper);order.verify(fixture.sourceMapper).lockOrder("101",9001L);order.verify(fixture.sourceMapper).lockOrder("202",9002L);
|
||||
order.verify(fixture.sourceMapper).lockPlanPallet("101",1L);order.verify(fixture.sourceMapper).stock("101",501L,true);
|
||||
order.verify(fixture.sourceMapper).lockPlanPallet("202",2L);order.verify(fixture.sourceMapper).stock("202",502L,true);
|
||||
}
|
||||
/** 版本、实收组成、暂扣、物理身份和跨仓变化均不能沿旧选择继续排车。 */
|
||||
@Test void rejectsChangedFactsHeldSourcesForeignPalletAndWarehouse() throws Exception {
|
||||
var fixture=new Sources();var selected=fixture.selections();
|
||||
fixture.f.db.update("UPDATE ll_inventory_position SET version=version+1,available_packages=available_packages+1 WHERE id=1");
|
||||
assertThatThrownBy(()->fixture.f.tx.execute(s->fixture.rules.lockWholeSources("9",selected))).isInstanceOf(ResponseStatusException.class).hasMessageContaining("版本已变化");
|
||||
fixture.f.db.update("UPDATE ll_inventory_position SET version=1,available_packages=3 WHERE id=1");
|
||||
fixture.f.db.update("UPDATE ll_order SET detained=1 WHERE id=9002");
|
||||
assertThatThrownBy(()->fixture.f.tx.execute(s->fixture.rules.lockWholeSources("9",selected))).isInstanceOf(ResponseStatusException.class).hasMessageContaining("暂扣");
|
||||
fixture.f.db.update("UPDATE ll_order SET detained=0 WHERE id=9002");
|
||||
var forged=new DispatchCommands.CargoSelection(DispatchTypes.SourceType.INBOUND_PLAN,1L,null,null,999L,selected.get(0).sourceVersion());
|
||||
assertThatThrownBy(()->fixture.f.tx.execute(s->fixture.rules.lockWholeSources("9",List.of(forged)))).isInstanceOf(ResponseStatusException.class).hasMessageContaining("实托身份");
|
||||
fixture.f.db.update("UPDATE ll_inbound_stock_receipt SET received_packages=4 WHERE id=1");
|
||||
assertThatThrownBy(()->fixture.f.tx.execute(s->fixture.rules.lockWholeSources("9",selected))).isInstanceOf(ResponseStatusException.class).hasMessageContaining("版本已变化");
|
||||
fixture.f.db.update("UPDATE ll_inbound_stock_receipt SET received_packages=3 WHERE id=1");
|
||||
fixture.f.db.update("UPDATE ll_inventory_position SET overseas_warehouse_id='99' WHERE id=1");
|
||||
assertThatThrownBy(()->fixture.rules.wholeSourceVersion(fixture.f.candidates.get(1L),"PLAN:1",false)).isInstanceOf(ResponseStatusException.class).hasMessageContaining("起运仓不一致");
|
||||
}
|
||||
|
||||
/** 实际来源表保持独立客户;只有复杂候选投影查询在本用例隔离。 */
|
||||
private static final class Sources {
|
||||
final DispatchAggregatePersistenceTest.Fixture f=new DispatchAggregatePersistenceTest.Fixture();
|
||||
final DispatchSourceMapper sourceMapper;final DispatchService rules=new DispatchService();
|
||||
/** 构造真实订单组成和库存版本,不能由候选响应替代这些数据库事实。 */
|
||||
Sources() throws Exception {
|
||||
f.source(1,"101",501,"ONT8",3);f.source(2,"202",502,"LAX9",5);
|
||||
f.db.execute("ALTER TABLE ll_order ADD detained INT DEFAULT 0");
|
||||
f.db.execute("CREATE TABLE ll_inbound_plan(id BIGINT PRIMARY KEY,customer_id VARCHAR(64))");
|
||||
f.db.execute("CREATE TABLE ll_inbound_plan_pallet(id BIGINT PRIMARY KEY,plan_id BIGINT)");
|
||||
f.db.execute("CREATE TABLE ll_inventory_position(id BIGINT PRIMARY KEY,customer_id VARCHAR(64),balance_id BIGINT,pallet_id BIGINT,overseas_warehouse_id VARCHAR(64),available_packages BIGINT,hold_packages BIGINT,allocated_packages BIGINT,picked_packages BIGINT,version BIGINT)");
|
||||
for(String column:List.of("order_id BIGINT","inventory_position_id BIGINT","received_packages BIGINT","warehouse_pallet_id BIGINT"))f.db.execute("ALTER TABLE ll_inbound_stock_receipt ADD "+column);
|
||||
for(long id:List.of(1L,2L)) {
|
||||
var row=f.candidates.get(id);f.db.update("INSERT INTO ll_order(id,customer_id,order_no)VALUES(?,?,?)",row.orderId(),row.customerId(),row.orderNo());
|
||||
f.db.update("INSERT INTO ll_inbound_plan VALUES(?,?)",id,row.customerId());f.db.update("INSERT INTO ll_inbound_plan_pallet VALUES(?,?)",id,id);
|
||||
f.db.update("INSERT INTO ll_inbound_plan_line VALUES(?,?,?)",id,row.orderId(),row.customerId());f.db.update("INSERT INTO ll_inbound_plan_pallet_allocation VALUES(?,?)",id,id);
|
||||
f.db.update("INSERT INTO ll_inventory_position VALUES(?,?,?,?,?,?,0,0,0,1)",id,row.customerId(),id,row.warehousePalletId(),"9",row.packageCount());
|
||||
f.db.update("INSERT INTO ll_inbound_stock_receipt VALUES(?,?,?,?,?,?,?)",id,row.customerId(),row.inboundCode(),row.orderId(),id,row.packageCount(),row.warehousePalletId());
|
||||
}
|
||||
var plans=mock(DispatchCandidateMapper.class);
|
||||
when(plans.findCustomerScopesByPlanPalletIds(anyList())).thenAnswer(call->{List<Long> ids=call.getArgument(0);return List.of(f.candidates.get(ids.get(0)).customerId());});
|
||||
when(plans.readSelected(anyString(),anyList())).thenAnswer(call->{List<Long> ids=call.getArgument(1);return List.of(f.candidates.get(ids.get(0)));});
|
||||
sourceMapper=spy(f.session.getMapper(DispatchSourceMapper.class));
|
||||
var sources=new DispatchSourceRepository(plans,mock(ReturnDispatchCandidateMapper.class),sourceMapper);
|
||||
ReflectionTestUtils.setField(rules,"wholeSources",sources);ReflectionTestUtils.setField(rules,"wholeHashes",new DispatchCommandHasher(f.json));
|
||||
}
|
||||
/** GET与写锁内复用同一真实事实摘要。 */
|
||||
List<DispatchCommands.CargoSelection> selections(){return f.candidates.entrySet().stream().sorted(Map.Entry.comparingByKey()).map(entry->new DispatchCommands.CargoSelection(
|
||||
DispatchTypes.SourceType.INBOUND_PLAN,entry.getKey(),null,null,entry.getValue().warehousePalletId(),rules.wholeSourceVersion(entry.getValue(),"PLAN:"+entry.getKey(),false))).toList();}
|
||||
}
|
||||
}
|
||||
+14
-7
@@ -63,8 +63,10 @@ class ExceptionOrderDispatchReturnSourceTest {
|
||||
ReflectionTestUtils.setField(service, "inboundOrders", inboundOrders);
|
||||
}
|
||||
|
||||
@Test
|
||||
void returnsTheAttributedInventorySourceForDispatchDeliveryPicks() {
|
||||
/** 普通入仓与没有普通入仓单的换标主单均按真实拣货归属返回仓位来源。 */
|
||||
@org.junit.jupiter.params.ParameterizedTest
|
||||
@org.junit.jupiter.params.provider.ValueSource(booleans = {false, true})
|
||||
void returnsTheAttributedInventorySourceForDispatchDeliveryPicks(boolean relabelWithoutInbound) {
|
||||
DeliveryOrder delivery = new DeliveryOrder();
|
||||
delivery.setId(400L); delivery.setCustomerId("1001"); delivery.setDeliveryNo("DL-1");
|
||||
delivery.setOutboundNo("OUT-1"); delivery.setStatus("DELIVERED");
|
||||
@@ -75,7 +77,7 @@ class ExceptionOrderDispatchReturnSourceTest {
|
||||
outboundItem.setId(301L); outboundItem.setCustomerId("1001"); outboundItem.setOutboundOrderId(300L);
|
||||
outboundItem.setWarehousePalletId(501L); outboundItem.setPalletNo("PLT-1"); outboundItem.setInboundCode("IN-1");
|
||||
DispatchPdaTask task = new DispatchPdaTask();
|
||||
task.setId(201L); task.setCustomerId("1001"); task.setDispatchOrderId(200L);
|
||||
task.setId(201L); task.setDispatchOrderId(200L);
|
||||
DispatchItem dispatchItem = new DispatchItem();
|
||||
dispatchItem.setId(202L); dispatchItem.setCustomerId("1001"); dispatchItem.setDispatchOrderId(200L);
|
||||
dispatchItem.setWarehousePalletId(501L);
|
||||
@@ -94,11 +96,14 @@ class ExceptionOrderDispatchReturnSourceTest {
|
||||
|
||||
when(deliveryOrders.findByNo("1001", "DL-1")).thenReturn(delivery);
|
||||
when(lastMileOrders.selectList(any())).thenReturn(List.of(tail));
|
||||
when(inboundOrders.selectList(any())).thenReturn(List.of(inbound));
|
||||
if (relabelWithoutInbound) {
|
||||
tail.setInboundCode(null);dispatchItem.setSourceStage("RELABEL");
|
||||
dispatchItem.setInboundCode("RL-1");outboundItem.setInboundCode("RL-1");
|
||||
} else when(inboundOrders.selectList(any())).thenReturn(List.of(inbound));
|
||||
when(outboundOrders.findByNo("1001", "OUT-1")).thenReturn(outbound);
|
||||
when(outboundPalletItems.findByOutbound("1001", 300L)).thenReturn(List.of(outboundItem));
|
||||
when(outboundPdaPicks.findByOutbound("1001", 300L)).thenReturn(List.of());
|
||||
when(dispatchPdaTasks.findByDispatch("1001", 200L)).thenReturn(task);
|
||||
when(dispatchPdaTasks.findByDispatch(200L)).thenReturn(task);
|
||||
when(dispatchItems.findByDispatchOrder("1001", 200L)).thenReturn(List.of(dispatchItem));
|
||||
when(dispatchPdaPicks.listTask("1001", 201L)).thenReturn(List.of(pick));
|
||||
when(dispatchPdaPickOrders.listPick("1001", 203L)).thenReturn(List.of(attribution));
|
||||
@@ -107,6 +112,8 @@ class ExceptionOrderDispatchReturnSourceTest {
|
||||
var candidates = service.returnCandidates("1001", "DL-1");
|
||||
|
||||
assertThat(candidates).hasSize(1);
|
||||
assertThat(candidates.get(0).lastMileOrderId()).isEqualTo(101L);
|
||||
if (relabelWithoutInbound) assertThat(candidates.get(0).inboundOrderId()).isNull();
|
||||
assertThat(candidates.get(0).returnSources()).singleElement().satisfies(source -> {
|
||||
assertThat(source.outboundPalletItemId()).isEqualTo(301L);
|
||||
assertThat(source.inventoryPositionId()).isEqualTo(601L);
|
||||
@@ -130,7 +137,7 @@ class ExceptionOrderDispatchReturnSourceTest {
|
||||
outboundItem.setId(301L); outboundItem.setCustomerId("1001"); outboundItem.setOutboundOrderId(300L);
|
||||
outboundItem.setWarehousePalletId(501L); outboundItem.setPalletNo("PLT-1"); outboundItem.setInboundCode("IN-1");
|
||||
DispatchPdaTask task = new DispatchPdaTask();
|
||||
task.setId(201L); task.setCustomerId("1001"); task.setDispatchOrderId(200L);
|
||||
task.setId(201L); task.setDispatchOrderId(200L);
|
||||
DispatchItem dispatchItem = new DispatchItem();
|
||||
dispatchItem.setId(202L); dispatchItem.setCustomerId("1001"); dispatchItem.setDispatchOrderId(200L);
|
||||
dispatchItem.setWarehousePalletId(501L);
|
||||
@@ -192,7 +199,7 @@ class ExceptionOrderDispatchReturnSourceTest {
|
||||
: "MULTI_REVERSED".equals(layout) ? List.of(otherItem, outboundItem) : List.of(outboundItem, otherItem);
|
||||
when(outboundPalletItems.findByOutbound("1001", 300L)).thenReturn(outboundSnapshots);
|
||||
when(outboundPdaPicks.findByOutbound("1001", 300L)).thenReturn(List.of());
|
||||
when(dispatchPdaTasks.findByDispatch("1001", 200L)).thenReturn(task);
|
||||
when(dispatchPdaTasks.findByDispatch(200L)).thenReturn(task);
|
||||
when(dispatchItems.findByDispatchOrder("1001", 200L)).thenReturn(List.of(dispatchItem));
|
||||
when(dispatchPdaPicks.listTask("1001", 201L)).thenReturn(List.of(pick, secondPick));
|
||||
when(dispatchPdaPickOrders.listPick("1001", 203L)).thenReturn(List.of(attribution));
|
||||
|
||||
+5
-5
@@ -22,7 +22,7 @@ class FleetHandoverPdaQueryTest {
|
||||
final FleetHandoverPdaQuery service = new FleetHandoverPdaQuery(bindings, trips, history, dispatches, handovers, json);
|
||||
|
||||
@BeforeEach void setup() {
|
||||
var binding = new FleetTripDispatch(); binding.setDispatchNo("PC-A"); binding.setCustomerId("CUSTOMER-A");
|
||||
var binding = new FleetTripDispatch(); binding.setDispatchNo("PC-A");
|
||||
binding.setTripId(1L); binding.setTripNo("TR-ONE"); binding.setPackageCount(30);
|
||||
var trip = new FleetTripEntity(); trip.setId(1L); trip.setTripNo("TR-ONE"); trip.setWarehouseId(92L);
|
||||
trip.setStatus("IN_TRANSIT"); trip.setRecordVersion(10L);
|
||||
@@ -31,7 +31,7 @@ class FleetHandoverPdaQueryTest {
|
||||
{"trip":{"tripNo":"TR-ONE","warehouseId":"92","vehiclePlate":"ORIGINAL","driverName":"原司机"}}
|
||||
""");
|
||||
when(history.selectList(any())).thenReturn(List.of(departed));
|
||||
when(dispatches.transitEvidence("PC-A", false)).thenReturn(new FleetTransitEvidence("PC-A", "CUSTOMER-A", "92", 30, List.of(
|
||||
when(dispatches.transitEvidence("PC-A", false)).thenReturn(new FleetTransitEvidence("PC-A", "92", 30, List.of(
|
||||
new FleetTransitEvidence.Delivery(new FleetHandoverPolicy.Delivery("CUSTOMER-A", "PC-A", "DL-DONE", "FBA:ONE", 10, Instant.parse("2026-10-03T01:00:00Z"), List.of()),
|
||||
"DELIVERED", 2, new FleetTransitEvidence.Transport("ORIGINAL", "原司机", null)),
|
||||
new FleetTransitEvidence.Delivery(new FleetHandoverPolicy.Delivery("CUSTOMER-A", "PC-A", "DL-PENDING", "FBA:TWO", 20, null, List.of()),
|
||||
@@ -49,11 +49,11 @@ class FleetHandoverPdaQueryTest {
|
||||
assertEquals("REPLACEMENT", result.deliveries().get(1).transport().vehiclePlate());
|
||||
String serialized = json.writeValueAsString(result);
|
||||
assertFalse(serialized.contains("customerId")); assertFalse(serialized.contains("warehouseId")); assertFalse(serialized.contains("vehicleId"));
|
||||
verify(handovers).latestForCargo(eq(1L), argThat(value -> value.contains("CUSTOMER-A") && value.contains("PC-A")));
|
||||
verify(handovers).latestForCargo(eq(1L), argThat(value -> value.contains("PC-A") && !value.contains("customerId")));
|
||||
verify(dispatches, never()).handover(any(), any());
|
||||
}
|
||||
@Test void staleBindingToAnotherCustomerCannotExposeDeliveryResources() {
|
||||
when(dispatches.transitEvidence("PC-A", false)).thenReturn(new FleetTransitEvidence("PC-A", "CUSTOMER-B", "92", 30, List.of()));
|
||||
@Test void incompleteTruckEvidenceCannotExposeDeliveryResources() {
|
||||
when(dispatches.transitEvidence("PC-A", false)).thenReturn(new FleetTransitEvidence("PC-A", "93", 30, List.of()));
|
||||
assertThrows(org.springframework.web.server.ResponseStatusException.class, () -> service.status(92, "PC-A"));
|
||||
verifyNoInteractions(history, handovers);
|
||||
}
|
||||
|
||||
+1
-1
@@ -71,7 +71,7 @@ class FleetHandoverTransactionTest {
|
||||
var inspection = mock(FleetHandoverInspectionService.class);
|
||||
var cargo = new FleetHandoverPolicy.Cargo("CUSTOMER-A", "PC-A", "item", "pallet", "FBA:ONT8", 10);
|
||||
var delivery = new FleetHandoverPolicy.Delivery("CUSTOMER-A", "PC-A", "DL-A", "FBA:ONT8", 10, null, List.of());
|
||||
var evidence = new FleetTransitEvidence("PC-A", "CUSTOMER-A", "92", 10,
|
||||
var evidence = new FleetTransitEvidence("PC-A", "92", 10,
|
||||
List.of(new FleetTransitEvidence.Delivery(delivery, "WAIT_DELIVERY", 1, new FleetTransitEvidence.Transport("PLATE-11", "司机", "phone"))));
|
||||
when(inspection.prepare(anyString(), any(), any(), eq(true))).thenAnswer(call -> new FleetHandoverInspectionService.Prepared(state().getRecordVersion(),
|
||||
new FleetHandoverPolicy.Inspection(List.of(cargo), List.of(), Set.of("pallet"), new Load(1, BigDecimal.valueOf(100), BigDecimal.valueOf(2))), List.of(evidence)));
|
||||
|
||||
+33
-38
@@ -3,7 +3,9 @@ package com.baiyue.lastleg.fleet.exception;
|
||||
import com.baiyue.lastleg.delivery.entity.DeliveryOrder;
|
||||
import com.baiyue.lastleg.delivery.entity.OutboundOrder;
|
||||
import com.baiyue.lastleg.delivery.mapper.*;
|
||||
import com.baiyue.lastleg.dispatch.entity.DispatchOrder;
|
||||
import com.baiyue.lastleg.dispatch.entity.DispatchAggregate;
|
||||
import com.baiyue.lastleg.dispatch.entity.DispatchItem;
|
||||
import com.baiyue.lastleg.dispatch.repository.DispatchAggregateRepository;
|
||||
import com.baiyue.lastleg.dispatch.fleet.DispatchFleetAdapter;
|
||||
import com.baiyue.lastleg.dispatch.mapper.*;
|
||||
import com.baiyue.lastleg.dispatch.pda.mapper.*;
|
||||
@@ -17,11 +19,15 @@ import static org.junit.jupiter.api.Assertions.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
class FleetTransitEvidenceTest {
|
||||
@Test void removingOriginalPhysicalItemDelegatesToDispatchAuthority() {
|
||||
var service=mock(com.baiyue.lastleg.dispatch.service.DispatchService.class);
|
||||
org.springframework.test.util.ReflectionTestUtils.setField(adapter,"dispatchService",service);
|
||||
adapter.removeItem("PC-1",41L);
|
||||
verify(service).removeItem("PC-1",41L);verifyNoMoreInteractions(service);
|
||||
@Test void twoCustomersAreReadAsOneTruckWithoutAHeaderOwner() {
|
||||
var other=new DispatchItem();other.setCustomerId("CUSTOMER-B");other.setPackageCount(3);
|
||||
when(aggregates.cargo(11)).thenReturn(List.of(item,other));dispatch.setShippedPackageCount(13);
|
||||
var outbound=new OutboundOrder();outbound.setId(32L);outbound.setCustomerId("CUSTOMER-B");outbound.setDispatchOrderId(11L);outbound.setOverseasWarehouseId("21");outbound.setStatus("OUTBOUNDED");outbound.setPackageCount(3);
|
||||
var second=new DeliveryOrder();second.setId(42L);second.setDeliveryNo("DL-2");second.setCustomerId("CUSTOMER-B");second.setDispatchOrderId(11L);second.setOutboundOrderId(32L);second.setOverseasWarehouseId("21");second.setPackageCount(3);second.setStatus("DELIVERING");second.setVersion(0L);second.setDestinationType("FBA");second.setDestinationWarehouseCode("LAX9");
|
||||
when(outbounds.findAllByDispatch(11)).thenReturn(List.of(this.outbound,outbound));when(deliveries.findAllByDispatch(11)).thenReturn(List.of(delivery,second));
|
||||
var fact=adapter.terminalEvidence("PC-1",false);
|
||||
assertEquals(List.of("CUSTOMER-A","CUSTOMER-B"),fact.deliveries().stream().map(com.baiyue.lastleg.fleet.trip.FleetDispatchGateway.TerminalDelivery::customerId).toList());
|
||||
assertEquals(13,fact.shippedPackages());assertFalse(java.util.Arrays.stream(fact.getClass().getRecordComponents()).anyMatch(c->c.getName().equals("customerId")));
|
||||
}
|
||||
@BeforeAll static void initializeDeliveryMetadata() {
|
||||
com.baomidou.mybatisplus.core.metadata.TableInfoHelper.initTableInfo(
|
||||
@@ -29,41 +35,30 @@ class FleetTransitEvidenceTest {
|
||||
DeliveryOrder.class);
|
||||
}
|
||||
|
||||
final DispatchOrderMapper orders = mock(DispatchOrderMapper.class);
|
||||
final OutboundOrderMapper outbounds = mock(OutboundOrderMapper.class);
|
||||
final DeliveryOrderMapper deliveries = mock(DeliveryOrderMapper.class);
|
||||
final DeliveryOperationMapper operations = mock(DeliveryOperationMapper.class);
|
||||
final DispatchPdaWorkflowService workflow = mock(DispatchPdaWorkflowService.class);
|
||||
final DispatchFleetAdapter adapter = new DispatchFleetAdapter(orders, mock(DispatchPdaTaskMapper.class),
|
||||
outbounds, deliveries, mock(DispatchItemMapper.class), mock(DispatchPdaPickMapper.class), workflow, operations);
|
||||
final DispatchOrder dispatch = new DispatchOrder();
|
||||
final DeliveryOrder delivery = new DeliveryOrder();
|
||||
|
||||
final DispatchAggregateRepository aggregates=mock(DispatchAggregateRepository.class);
|
||||
final OutboundOrderMapper outbounds=mock(OutboundOrderMapper.class);final DeliveryOrderMapper deliveries=mock(DeliveryOrderMapper.class);
|
||||
final DeliveryOperationMapper operations=mock(DeliveryOperationMapper.class);
|
||||
final com.baiyue.lastleg.dispatch.service.DispatchAggregateService workflow=mock(com.baiyue.lastleg.dispatch.service.DispatchAggregateService.class);
|
||||
final DispatchFleetAdapter adapter=new DispatchFleetAdapter(aggregates,outbounds,deliveries,operations,mock(com.baiyue.lastleg.delivery.repository.DispatchCargoSourceRepository.class),workflow,new com.fasterxml.jackson.databind.ObjectMapper().findAndRegisterModules());
|
||||
final DispatchAggregate dispatch=new DispatchAggregate();final DispatchItem item=new DispatchItem();final OutboundOrder outbound=new OutboundOrder();final DeliveryOrder delivery=new DeliveryOrder();
|
||||
FleetTransitEvidenceTest() {
|
||||
dispatch.setId(11L); dispatch.setDispatchNo("PC-1"); dispatch.setCustomerId("CUSTOMER-A");
|
||||
dispatch.setOverseasWarehouseId("21"); dispatch.setStatus("DEPARTED"); dispatch.setShippedPackageCount(10);
|
||||
var outbound = new OutboundOrder(); outbound.setId(31L); outbound.setStatus("OUTBOUNDED"); outbound.setPackageCount(10);
|
||||
delivery.setId(41L); delivery.setDeliveryNo("DL-1"); delivery.setCustomerId("CUSTOMER-A");
|
||||
delivery.setDispatchOrderId(11L); delivery.setOutboundOrderId(31L); delivery.setOverseasWarehouseId("21");
|
||||
delivery.setPackageCount(10); delivery.setStatus("WAIT_DELIVERY"); delivery.setVersion(0L);
|
||||
delivery.setDestinationType("FBA"); delivery.setDestinationWarehouseCode("ONT8");
|
||||
when(orders.findGlobalByDispatchNo("PC-1")).thenReturn(dispatch);
|
||||
when(orders.lockGlobalByDispatchNo("PC-1")).thenReturn(dispatch);
|
||||
when(outbounds.findByDispatch("CUSTOMER-A", 11L)).thenReturn(outbound);
|
||||
when(outbounds.lockByDispatch("CUSTOMER-A", 11L)).thenReturn(outbound);
|
||||
when(deliveries.findByOutbound("CUSTOMER-A", 31L)).thenReturn(List.of(delivery));
|
||||
when(deliveries.lockByOutbound("CUSTOMER-A", 31L)).thenReturn(List.of(delivery));
|
||||
dispatch.setId(11L);dispatch.setDispatchNo("PC-1");dispatch.setOverseasWarehouseId("21");dispatch.setStatus("ACTIVE");dispatch.setStage("DELIVERING");dispatch.setDepartedAt(LocalDateTime.now(java.time.ZoneOffset.UTC).minusDays(1));dispatch.setShippedPackageCount(10);
|
||||
item.setCustomerId("CUSTOMER-A");item.setPackageCount(10);when(aggregates.cargo(11)).thenReturn(List.of(item));
|
||||
outbound.setId(31L);outbound.setCustomerId("CUSTOMER-A");outbound.setDispatchOrderId(11L);outbound.setOverseasWarehouseId("21");outbound.setStatus("OUTBOUNDED");outbound.setPackageCount(10);
|
||||
delivery.setId(41L);delivery.setDeliveryNo("DL-1");delivery.setCustomerId("CUSTOMER-A");delivery.setDispatchOrderId(11L);delivery.setOutboundOrderId(31L);delivery.setOverseasWarehouseId("21");delivery.setPackageCount(10);delivery.setStatus("DELIVERING");delivery.setVersion(0L);delivery.setDestinationType("FBA");delivery.setDestinationWarehouseCode("ONT8");
|
||||
when(aggregates.find("PC-1")).thenReturn(dispatch);when(aggregates.lock("PC-1")).thenReturn(dispatch);
|
||||
when(outbounds.findAllByDispatch(11)).thenReturn(List.of(outbound));when(outbounds.lockAllByDispatch(11)).thenReturn(List.of(outbound));
|
||||
when(deliveries.findAllByDispatch(11)).thenReturn(List.of(delivery));when(deliveries.lockAllByDispatch(11)).thenReturn(List.of(delivery));
|
||||
}
|
||||
|
||||
@Test void unresolvedReturnKeepsCargoAndUsesCustomerScopedLocks() {
|
||||
delivery.setStatus("RETURN_PENDING");
|
||||
var fact = adapter.transitEvidence("PC-1", true);
|
||||
assertNull(fact.deliveries().get(0).cargo().terminalAt());
|
||||
assertEquals("FBA:ONT8", fact.deliveries().get(0).cargo().destinationKey());
|
||||
var order = inOrder(orders, outbounds, deliveries);
|
||||
order.verify(orders).lockGlobalByDispatchNo("PC-1");
|
||||
order.verify(outbounds).lockByDispatch("CUSTOMER-A", 11L);
|
||||
order.verify(deliveries).lockByOutbound("CUSTOMER-A", 31L);
|
||||
var order = inOrder(aggregates, outbounds, deliveries);
|
||||
order.verify(aggregates).lock("PC-1");
|
||||
order.verify(outbounds).lockAllByDispatch(11);
|
||||
order.verify(deliveries).lockAllByDispatch(11);
|
||||
verifyNoInteractions(workflow);
|
||||
}
|
||||
|
||||
@@ -85,7 +80,7 @@ class FleetTransitEvidenceTest {
|
||||
assertEquals(returned.atZone(ZoneId.systemDefault()).toInstant(),
|
||||
adapter.transitEvidence("PC-1", false).deliveries().get(0).cargo().terminalAt());
|
||||
var terminal=adapter.terminalEvidence("PC-1",false);
|
||||
assertEquals("CUSTOMER-A",terminal.customerId());assertEquals(10,terminal.shippedPackages());
|
||||
assertEquals("CUSTOMER-A",terminal.deliveries().get(0).customerId());assertEquals(10,terminal.shippedPackages());
|
||||
var returnedDelivery=terminal.deliveries().get(0);
|
||||
assertEquals("FBA:ONT8",returnedDelivery.destinationKey());assertEquals(10,returnedDelivery.packages());
|
||||
assertEquals(returned.atZone(ZoneId.systemDefault()).toInstant(),returnedDelivery.terminalAt());
|
||||
@@ -103,9 +98,9 @@ class FleetTransitEvidenceTest {
|
||||
}
|
||||
|
||||
@Test void preDepartureAndCancelledDeliveryCannotBecomeHandoverCargo() {
|
||||
dispatch.setStatus("READY_TO_DEPART");
|
||||
dispatch.setStage("AFTER_LOADING");
|
||||
assertThrows(org.springframework.web.server.ResponseStatusException.class, () -> adapter.transitEvidence("PC-1", false));
|
||||
dispatch.setStatus("DEPARTED"); delivery.setStatus("CANCELLED");
|
||||
dispatch.setStage("DELIVERING"); delivery.setStatus("CANCELLED");
|
||||
assertThrows(org.springframework.web.server.ResponseStatusException.class, () -> adapter.transitEvidence("PC-1", false));
|
||||
}
|
||||
|
||||
@@ -115,7 +110,7 @@ class FleetTransitEvidenceTest {
|
||||
when(deliveries.update(isNull(), any())).thenReturn(1);
|
||||
adapter.handover(evidence, new com.baiyue.lastleg.fleet.trip.FleetDispatchGateway.Assignment("NEW-PLATE", "接替司机", "phone"));
|
||||
verify(deliveries).update(isNull(), any());
|
||||
verify(orders, never()).update(any(), any());
|
||||
verify(aggregates, never()).saveState(any(), anyLong());
|
||||
verifyNoInteractions(workflow);
|
||||
}
|
||||
|
||||
|
||||
+17
-27
@@ -1,33 +1,23 @@
|
||||
package com.baiyue.lastleg.fleet.trip;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import java.util.List;
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import java.util.*;
|
||||
import static org.assertj.core.api.Assertions.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
|
||||
class FleetDispatchWorkflowQueryTest {
|
||||
@Test
|
||||
void batchesReviewsAndKeepsCustomerDispatchDecisionsIndependent() {
|
||||
var bindings = mock(FleetTripDispatchMapper.class);
|
||||
var trips = mock(FleetTripMapper.class);
|
||||
var first = new FleetTripDispatch(); first.setDispatchNo("PC-A"); first.setTripId(1L); first.setTripNo("TR-A");
|
||||
first.setReviewJson("{\"status\":\"REJECTED\",\"reason\":\"货物需重新核对\"}");
|
||||
var second = new FleetTripDispatch(); second.setDispatchNo("PC-B"); second.setTripId(1L); second.setTripNo("TR-A");
|
||||
var trip = new FleetTripEntity(); trip.setId(1L);
|
||||
trip.setLoadingReviewJson("{\"status\":\"APPROVED\",\"reason\":\"审核通过\"}");
|
||||
when(bindings.findByDispatchNos(List.of("PC-A", "PC-B", "PC-C"))).thenReturn(List.of(first, second));
|
||||
when(trips.selectBatchIds(any())).thenReturn(List.of(trip));
|
||||
var query = new FleetDispatchWorkflowQuery(bindings, trips, new ObjectMapper().findAndRegisterModules());
|
||||
var result = query.read(List.of("PC-A", "PC-B", "PC-C"));
|
||||
assertEquals("REJECTED", result.get("PC-A").dispatchReview().status());
|
||||
assertEquals("货物需重新核对", result.get("PC-A").dispatchReview().reason());
|
||||
assertEquals("PENDING", result.get("PC-B").dispatchReview().status());
|
||||
assertEquals("APPROVED", result.get("PC-B").loadingReview().status());
|
||||
assertFalse(result.containsKey("PC-C"));
|
||||
verify(trips).selectBatchIds(List.of(1L));
|
||||
verifyNoMoreInteractions(trips);
|
||||
assertTrue(query.read(List.of()).isEmpty());
|
||||
@Test void batchesCurrentMainReviewsWithoutCustomerOrBindingReviewCopies() {
|
||||
var bindings=mock(FleetTripDispatchMapper.class);var gateway=mock(FleetDispatchGateway.class);
|
||||
var first=new FleetTripDispatch();first.setDispatchNo("DS-A");first.setTripNo("TR-A");first.setTripId(1L);
|
||||
var second=new FleetTripDispatch();second.setDispatchNo("DS-B");second.setTripNo("TR-B");second.setTripId(2L);
|
||||
var rejected=new FleetTripModels.ReviewState("REJECTED","核对本车来源","reviewer",null,null);
|
||||
var waiting=new FleetTripModels.ReviewState("NOT_SUBMITTED",null,null,null,null);
|
||||
var approved=new FleetTripModels.ReviewState("APPROVED",null,"reviewer",null,null);
|
||||
when(bindings.findByDispatchNos(List.of("DS-A","DS-B","DS-C"))).thenReturn(List.of(first,second));
|
||||
when(gateway.workflows(List.of("DS-A","DS-B"))).thenReturn(Map.of("DS-A",new FleetDispatchGateway.Workflow("ACTIVE","BEFORE_LOADING",5,rejected,waiting),"DS-B",new FleetDispatchGateway.Workflow("ACTIVE","LOADING",9,approved,approved)));
|
||||
var query=new FleetDispatchWorkflowQuery(bindings,gateway);var result=query.read(List.of("DS-A","DS-B","DS-C"));
|
||||
assertThat(result).containsOnlyKeys("DS-A","DS-B");assertThat(result.get("DS-A").dispatchReview().status()).isEqualTo("REJECTED");
|
||||
assertThat(result.get("DS-A").loadingReview().status()).isEqualTo("NOT_SUBMITTED");assertThat(result.get("DS-B").loadingReview().status()).isEqualTo("APPROVED");
|
||||
assertThat(result.get("DS-B").tripNo()).isEqualTo("TR-B");verify(gateway).workflows(List.of("DS-A","DS-B"));verifyNoMoreInteractions(gateway);
|
||||
assertThat(query.read(List.of())).isEmpty();
|
||||
assertThat(Arrays.stream(FleetTripDispatch.class.getDeclaredFields()).map(java.lang.reflect.Field::getName)).doesNotContain("customerId","reviewJson");
|
||||
}
|
||||
}
|
||||
|
||||
+18
-8
@@ -49,16 +49,9 @@ class FleetTripAuthorizationTest {
|
||||
var controller = context.getBean(PlatformFleetTripController.class);
|
||||
var service = context.getBean(FleetTripService.class);
|
||||
controller.detail("TR-TEST"); verify(service).detail("TR-TEST");
|
||||
assertThatThrownBy(() -> controller.create(null, null)).isInstanceOf(AccessDeniedException.class);
|
||||
assertThatThrownBy(() -> controller.assign(null, "TR-TEST", null)).isInstanceOf(AccessDeniedException.class);
|
||||
assertThatThrownBy(() -> controller.cancel(null, "TR-TEST", null)).isInstanceOf(AccessDeniedException.class);
|
||||
assertThatThrownBy(() -> controller.plan(null, "TR-TEST", null)).isInstanceOf(AccessDeniedException.class);
|
||||
assertThatThrownBy(() -> controller.bind(null, "TR-TEST", null)).isInstanceOf(AccessDeniedException.class);
|
||||
assertThatThrownBy(() -> controller.prepare(null,null)).isInstanceOf(AccessDeniedException.class);
|
||||
assertThatThrownBy(() -> controller.unbind(null, "TR-TEST", null)).isInstanceOf(AccessDeniedException.class);
|
||||
assertThatThrownBy(() -> controller.confirmLoading(null, "TR-TEST", null)).isInstanceOf(AccessDeniedException.class);
|
||||
assertThatThrownBy(() -> controller.reviewDispatch(null, "TR-TEST", "DP-1", null)).isInstanceOf(AccessDeniedException.class);
|
||||
assertThatThrownBy(() -> controller.reviewLoading(null, "TR-TEST", null)).isInstanceOf(AccessDeniedException.class);
|
||||
assertThatThrownBy(() -> controller.complete(null, "TR-TEST", null)).isInstanceOf(AccessDeniedException.class);
|
||||
assertThatThrownBy(() -> controller.release(null, "TR-TEST", null)).isInstanceOf(AccessDeniedException.class);
|
||||
assertThatThrownBy(() -> controller.confirmCapacityPlan(null, "TR-TEST", null)).isInstanceOf(AccessDeniedException.class);
|
||||
@@ -85,6 +78,23 @@ class FleetTripAuthorizationTest {
|
||||
assertThatThrownBy(()->pool.saveAndConfirm("FB-1",null)).isInstanceOf(AccessDeniedException.class);
|
||||
} finally {SecurityContextHolder.clearContext();}
|
||||
}
|
||||
@Test void externalArrangeRequiresCurrentCreateAndAllIndependentPermissions() {
|
||||
var required=List.of("platform:fleet:capacity-offer:manage","platform:fleet:trip:booking-edit","platform:fleet:trip:edit","platform:overseas-warehouse:dispatch:create");
|
||||
try(var context=new AnnotationConfigApplicationContext(Config.class)) {
|
||||
var controller=context.getBean(PlatformFleetCapacityOfferController.class);
|
||||
var service=context.getBean(FleetCapacityOfferService.class);
|
||||
for(var missing:required) {
|
||||
var permissions=new java.util.ArrayList<>(required);permissions.remove(missing);permissions.add("platform:overseas-warehouse:inbound-order:smart-dispatch");
|
||||
SecurityContextHolder.getContext().setAuthentication(new UsernamePasswordAuthenticationToken("operator","unused",permissions.stream().map(SimpleGrantedAuthority::new).toList()));
|
||||
assertThatThrownBy(()->controller.arrange("FB-1",null)).isInstanceOf(AccessDeniedException.class);
|
||||
}
|
||||
verifyNoInteractions(service);
|
||||
SecurityContextHolder.getContext().setAuthentication(new UsernamePasswordAuthenticationToken("operator","unused",required.stream().map(SimpleGrantedAuthority::new).toList()));
|
||||
controller.arrange("FB-1",null);verify(service).arrangeDispatch("FB-1",null);
|
||||
assertThat(java.util.Arrays.stream(PlatformFleetTripController.class.getDeclaredMethods()).map(java.lang.reflect.Method::getName))
|
||||
.doesNotContain("create","prepare","dispatchCandidates","bind","unbind","reviewDispatch","reviewLoading","confirmLoading");
|
||||
}finally{SecurityContextHolder.clearContext();}
|
||||
}
|
||||
@Configuration @EnableMethodSecurity
|
||||
static class Config {
|
||||
/** 既有取货能力依赖原仓储引用;权限测试使用只读替身,不连接数据库。 */
|
||||
@@ -94,7 +104,7 @@ class FleetTripAuthorizationTest {
|
||||
@Bean FleetTripCalendarService calendar() { return mock(FleetTripCalendarService.class); }
|
||||
@Bean FleetCapacityOfferCommandMapper commands() { return mock(FleetCapacityOfferCommandMapper.class); }
|
||||
@Bean FleetDispatchPlanningGateway planning() { return mock(FleetDispatchPlanningGateway.class); }
|
||||
@Bean FleetTripPreparationService preparation() { return mock(FleetTripPreparationService.class); }
|
||||
@Bean FleetDispatchGateway dispatches() { return mock(FleetDispatchGateway.class); }
|
||||
@Bean com.baiyue.lastleg.appointmentplanning.AppointmentCoverageService appointmentCoverage() { return mock(com.baiyue.lastleg.appointmentplanning.AppointmentCoverageService.class); }
|
||||
@Bean FleetCapacityOfferService capacityOffers() { return mock(FleetCapacityOfferService.class); }
|
||||
@Bean PlatformFleetCapacityOfferController poolController(FleetCapacityOfferService service) { return new PlatformFleetCapacityOfferController(service); }
|
||||
|
||||
+73
-1250
File diff suppressed because it is too large
Load Diff
+11
-8
@@ -38,9 +38,10 @@ class AppointmentWorkPreArrangementPersistenceTest {
|
||||
doReturn(List.of(new AppointmentWorkDispatchMapper.Candidate(101L,"P-B",11L,"IP-B","B","RECEIVED",false),new AppointmentWorkDispatchMapper.Candidate(102L,"P-D",12L,"IP-D","D","RECEIVED",false))).when(mapper).candidates(batch.id());
|
||||
doReturn(List.of(new AppointmentWorkDispatchMapper.Physical(1L,"B","FBA:ONT8",60,60))).when(mapper).physical(101L,"B");
|
||||
doReturn(List.of(new AppointmentWorkDispatchMapper.Physical(2L,"D","FBA:ONT8",40,40))).when(mapper).physical(102L,"D");
|
||||
var jdbc=new JdbcTemplate(context.getBean(DataSource.class));jdbc.update("INSERT INTO ll_dispatch_order(id,customer_id,status) VALUES(20,'B','PENDING_DEPARTURE')");
|
||||
var jdbc=new JdbcTemplate(context.getBean(DataSource.class));jdbc.update("INSERT INTO ll_dispatch_order(id,dispatch_no,record_version,status) VALUES(20,'DP-B',0,'ACTIVE')");
|
||||
jdbc.update("INSERT INTO ll_dispatch_item(id,customer_id,dispatch_order_id,plan_pallet_id,source_stage,package_count) VALUES(201,'B',20,101,'RECEIVED',60)");
|
||||
var candidates=new AppointmentWorkDispatchCandidates(mapper,batches,sources);
|
||||
var binding=new AppointmentWorkDispatchBinding(mapper,batches,sources,mock(AppointmentWorkChanges.class),store);
|
||||
var binding=new AppointmentWorkDispatchBinding(mapper,batches,sources,mock(AppointmentWorkChanges.class),store,candidates);
|
||||
var tx=new TransactionTemplate(context.getBean(PlatformTransactionManager.class));
|
||||
var before=candidates.read(batch.batchKey()).candidates().stream().filter(c->c.customerId().equals("B")).findFirst().orElseThrow();
|
||||
assertTrue(before.blockReasons().isEmpty());assertEquals(60,before.packageCount());
|
||||
@@ -48,24 +49,26 @@ class AppointmentWorkPreArrangementPersistenceTest {
|
||||
batch=receiving.plan("operator",new Plan("pending-d",new Selection(batch.batchKey(),batch.version(),"D"),plan("PENDING"),"transport-v1")).batches().get(0);
|
||||
assertEquals("PENDING",batch.appointmentStatus());assertEquals(originalB,store.plan(batch.batchKey(),"B"));
|
||||
var after=candidates.read(batch.batchKey()).candidates().stream().filter(c->c.customerId().equals("B")).findFirst().orElseThrow();
|
||||
assertEquals(before,after);assertBindable(tx,binding,mapper,after);
|
||||
assertEquals(before.packageCount(),after.packageCount());assertEquals(before.blockReasons(),after.blockReasons());
|
||||
assertNotEquals(before.allocations().get(0).recordVersion(),after.allocations().get(0).recordVersion());assertBindable(tx,binding,mapper,after);
|
||||
var bound=new AppointmentWorkDispatchMapper.Bound(20L,201L,101L,batch.batchKey(),"B","ORDER:1","FBA:ONT8",60);
|
||||
doReturn(List.of(bound)).when(mapper).bindings("DP-B");doReturn(List.of()).when(mapper).dispatchPhysical("DP-B");
|
||||
doReturn(List.of(bound)).when(mapper).bindings("DP-B");
|
||||
assertTrue(assertThrows(ResponseStatusException.class,()->binding.requireDeparture("DP-B")).getReason().contains("尚无有效收货确认"));
|
||||
// Unchanged real-source guards still reject an unverified pallet and an allocation owned by another customer.
|
||||
doReturn(List.of(new AppointmentWorkDispatchMapper.Physical(1L,"B","FBA:ONT8",60,null))).when(mapper).physical(101L,"B");
|
||||
assertFalse(candidates.read(batch.batchKey()).candidates().get(0).blockReasons().isEmpty());
|
||||
assertThrows(ResponseStatusException.class,()->tx.executeWithoutResult(s->binding.bind("B",20,List.of(new AppointmentWorkDispatchPort.PhysicalItem(201L,101L,"RECEIVED",60)),after.allocations())));
|
||||
assertThrows(ResponseStatusException.class,()->tx.executeWithoutResult(s->binding.bind("D",20,List.of(new AppointmentWorkDispatchPort.PhysicalItem(201L,101L,"RECEIVED",60)),after.allocations())));
|
||||
assertThrows(ResponseStatusException.class,()->tx.executeWithoutResult(s->binding.bindItems(20,List.of(new AppointmentWorkDispatchPort.PhysicalItem(201L,"B",101L,"RECEIVED",60)),after.allocations())));
|
||||
assertThrows(ResponseStatusException.class,()->tx.executeWithoutResult(s->binding.bindItems(20,List.of(new AppointmentWorkDispatchPort.PhysicalItem(201L,"D",101L,"RECEIVED",60)),after.allocations())));
|
||||
doReturn(List.of(new AppointmentWorkDispatchMapper.Physical(1L,"B","FBA:ONT8",61,61))).when(mapper).physical(101L,"B");
|
||||
var excessive=candidates.read(batch.batchKey()).candidates().get(0);assertFalse(excessive.blockReasons().isEmpty());
|
||||
assertThrows(ResponseStatusException.class,()->tx.executeWithoutResult(s->binding.bind("B",20,List.of(new AppointmentWorkDispatchPort.PhysicalItem(201L,101L,"RECEIVED",61)),excessive.allocations())));
|
||||
assertThrows(ResponseStatusException.class,()->tx.executeWithoutResult(s->binding.bindItems(20,List.of(new AppointmentWorkDispatchPort.PhysicalItem(201L,"B",101L,"RECEIVED",61)),excessive.allocations())));
|
||||
assertTrue(mapper.byDispatch(20).isEmpty());
|
||||
}
|
||||
}
|
||||
private static void assertBindable(TransactionTemplate tx,AppointmentWorkDispatchBinding binding,AppointmentWorkDispatchMapper mapper,AppointmentWorkDispatchCandidates.Candidate candidate) {
|
||||
tx.executeWithoutResult(status->{
|
||||
binding.bind("B",20,List.of(new AppointmentWorkDispatchPort.PhysicalItem(201L,101L,"RECEIVED",60)),candidate.allocations());
|
||||
binding.lockBatches(Map.of(candidate.allocations().get(0).batchKey(),candidate.allocations().get(0).recordVersion()));
|
||||
binding.bindItems(20,List.of(new AppointmentWorkDispatchPort.PhysicalItem(201L,"B",101L,"RECEIVED",60)),candidate.allocations());
|
||||
assertEquals(60,mapper.byDispatch(20).stream().mapToInt(AppointmentWorkDispatchMapper.Bound::packages).sum());
|
||||
status.setRollbackOnly(); // Compare identical physical availability before and after the independent customer's plan command.
|
||||
});
|
||||
|
||||
+4
-4
@@ -212,8 +212,8 @@ class FbaAppointmentPersistenceTest {
|
||||
String sql=baseline.substring(start,baseline.indexOf(';',start)+1).replace(" STORED","").replaceAll(" ENGINE=InnoDB[^;]*;",";");statement.execute(sql);
|
||||
}
|
||||
statement.execute("CREATE TABLE ll_order(id BIGINT PRIMARY KEY,version INT DEFAULT 0,customer_id VARCHAR(128),customer_name VARCHAR(128))");statement.execute("INSERT INTO ll_order(id) VALUES(1),(2)");
|
||||
statement.execute("CREATE TABLE ll_dispatch_item(order_id BIGINT,customer_id VARCHAR(128),dispatch_order_id BIGINT)");
|
||||
statement.execute("CREATE TABLE ll_dispatch_order(id BIGINT,customer_id VARCHAR(128),status VARCHAR(32))");
|
||||
statement.execute("CREATE TABLE ll_dispatch_item(id BIGINT,order_id BIGINT,customer_id VARCHAR(128),dispatch_order_id BIGINT,plan_pallet_id BIGINT,source_stage VARCHAR(32),package_count INT)");
|
||||
statement.execute("CREATE TABLE ll_dispatch_order(id BIGINT,dispatch_no VARCHAR(64),record_version BIGINT,status VARCHAR(32))");
|
||||
String work;try(var input=FbaAppointmentPersistenceTest.class.getResourceAsStream("/db/appointment-work.sql")){work=new String(Objects.requireNonNull(input).readAllBytes(),StandardCharsets.UTF_8);}
|
||||
for(var sql:work.replaceAll("(?m)^--.*$","").replaceAll(" ENGINE=InnoDB[^;]*;",";").split(";")) if(!sql.isBlank()) statement.execute(sql);
|
||||
String receiving;try(var input=FbaAppointmentPersistenceTest.class.getResourceAsStream("/db/appointment-receiving.sql")){receiving=new String(Objects.requireNonNull(input).readAllBytes(),StandardCharsets.UTF_8);}
|
||||
@@ -239,7 +239,7 @@ class FbaAppointmentPersistenceTest {
|
||||
@Bean FbaReceivingStore receivingStore(FbaReceivingMapper mapper,ObjectMapper json){return new FbaReceivingStore(mapper,json);}
|
||||
@Bean FbaAppointmentTransportPort receivingTransport(){
|
||||
var port=mock(FbaAppointmentTransportPort.class);var scope=new FbaAppointmentTransportPort.Scope("transport-v1",List.of(),List.of());
|
||||
when(port.inspect(anyList())).thenReturn(scope);when(port.lockAndInspect(anyList(),anyString())).thenReturn(scope);
|
||||
when(port.inspect(anyList())).thenReturn(scope);when(port.lockAndInspect(anyList(),anyString(),anyString())).thenReturn(scope);
|
||||
when(port.apply(any(),anyList(),anyString(),anyString())).thenReturn(new FbaAppointmentTransportPort.Result(List.of(),List.of()));return port;
|
||||
}
|
||||
@Bean FbaAppointmentWorkPort receivingWork(){
|
||||
@@ -250,7 +250,7 @@ class FbaAppointmentPersistenceTest {
|
||||
@Bean FbaAppointmentExecutionPort receivingExecution(){
|
||||
var port=mock(FbaAppointmentExecutionPort.class);
|
||||
org.mockito.stubbing.Answer<FbaAppointmentExecutionPort.Scope> scope=c->{List<FbaAppointmentExecutionPort.Member> members=c.getArgument(0);return new FbaAppointmentExecutionPort.Scope("transport-v1",members.stream().map(m->new FbaAppointmentExecutionPort.Eligibility(m,false,true,null)).toList());};
|
||||
when(port.inspect(anyList())).thenAnswer(scope);when(port.lockAndInspect(anyList(),anyString())).thenAnswer(scope);return port;
|
||||
when(port.inspect(anyList())).thenAnswer(scope);when(port.lockAndInspect(anyList(),anyString(),anyString())).thenAnswer(scope);return port;
|
||||
}
|
||||
@Bean FbaReceivingService receivingService(FbaReceivingStore store,InboundFbaAppointmentService batches,FbaAppointmentTransportPort transport,FbaAppointmentCommandMapper commands,FbaAppointmentWorkPort work,FbaAppointmentExecutionPort execution){return new FbaReceivingService(store,batches,transport,commands,work,execution);}
|
||||
@Bean AppointmentCargoVerificationMapper verifications(SqlSessionTemplate session){return session.getMapper(AppointmentCargoVerificationMapper.class);}
|
||||
|
||||
+14
-11
@@ -111,7 +111,7 @@ class FbaReceivingPersistenceTest {
|
||||
try(var f=new Fixture()) {
|
||||
var original=f.create();var group=f.store.currentAgreement(original.batchKey(),"B");
|
||||
var pending=f.propose(original,100,"rollback-propose");
|
||||
when(f.transport.apply(any(),anyList(),anyString(),anyString())).thenThrow(new IllegalStateException("physical adjustment rejected"));
|
||||
when(f.transport.apply(any(),anyList(),anyString(),anyString(),anyString())).thenThrow(new IllegalStateException("physical adjustment rejected"));
|
||||
assertThrows(IllegalStateException.class,()->f.confirm(pending.changeKey(),"retry-resolve"));
|
||||
assertEquals(original,f.batches.detail(original.appointmentNo()));
|
||||
assertEquals(group,f.store.currentAgreement(original.batchKey(),"B"));
|
||||
@@ -205,7 +205,7 @@ class FbaReceivingPersistenceTest {
|
||||
@Test void transportScopeConflictCannotCreatePendingChangeOrConsumeCommandKey() {
|
||||
try(var f=new Fixture()) {
|
||||
var original=f.create();
|
||||
when(f.transport.lockAndInspect(anyList(),eq(TRANSPORT))).thenThrow(new ResponseStatusException(org.springframework.http.HttpStatus.CONFLICT,"原车次范围已更新"));
|
||||
when(f.transport.lockAndInspect(anyList(),eq(TRANSPORT),anyString())).thenThrow(new ResponseStatusException(org.springframework.http.HttpStatus.CONFLICT,"原车次范围已更新"));
|
||||
conflict(()->f.propose(original,100,"stale-transport"));
|
||||
assertFalse(f.store.hasPending(original.batchKey(),"B"));
|
||||
assertEquals(0,f.db.queryForObject("SELECT COUNT(*) FROM ll_fba_appointment_command WHERE command_key='stale-transport'",Integer.class));
|
||||
@@ -272,7 +272,7 @@ class FbaReceivingPersistenceTest {
|
||||
try(var f=new Fixture()) {
|
||||
var original=f.create();var group=f.store.currentAgreement(original.batchKey(),"B");
|
||||
var pending=f.propose(original,100,"before-picking");
|
||||
when(f.transport.lockAndInspect(anyList(),eq(TRANSPORT))).thenThrow(new ResponseStatusException(org.springframework.http.HttpStatus.CONFLICT,"已开始拣货,禁止调整运输货量"));
|
||||
when(f.transport.lockAndInspect(anyList(),eq(TRANSPORT),anyString())).thenThrow(new ResponseStatusException(org.springframework.http.HttpStatus.CONFLICT,"已开始拣货,禁止调整运输货量"));
|
||||
conflict(()->f.confirm(pending.changeKey(),"confirm-after-picking"));
|
||||
assertEquals("WAITING",f.store.change(pending.changeKey(),false).status());
|
||||
conflict(()->f.receiving.resolve(OPERATOR,pending.changeKey(),new Resolve("withdraw-wrong-version",1,"WITHDRAW",null,TRANSPORT)));
|
||||
@@ -285,7 +285,7 @@ class FbaReceivingPersistenceTest {
|
||||
assertEquals(group,f.store.currentAgreement(original.batchKey(),"B"));
|
||||
assertEquals(result,f.receiving.resolve(OPERATOR,pending.changeKey(),request));
|
||||
conflict(()->f.receiving.resolve(OPERATOR,pending.changeKey(),new Resolve("withdraw-after-picking",0,"CONFIRM","修改请求内容",TRANSPORT)));
|
||||
verify(f.transport,times(2)).lockAndInspect(anyList(),eq(TRANSPORT));
|
||||
verify(f.transport,times(2)).lockAndInspect(anyList(),eq(TRANSPORT),anyString());
|
||||
verify(f.transport,never()).apply(any(),anyList(),anyString(),anyString());
|
||||
f.assertAllocations(80,160,40);
|
||||
}
|
||||
@@ -300,7 +300,7 @@ class FbaReceivingPersistenceTest {
|
||||
return new FbaAppointmentExecutionPort.Scope("execution-v1",members.stream().map(m->new FbaAppointmentExecutionPort.Eligibility(m,m.customerId().equals("A"),!m.customerId().equals("A"),m.customerId().equals("A")?"本客户货物已经全部签收":null)).toList());
|
||||
};
|
||||
when(f.execution.inspect(anyList())).thenAnswer(scope);
|
||||
when(f.execution.lockAndInspect(anyList(),anyString())).thenAnswer(scope);
|
||||
when(f.execution.lockAndInspect(anyList(),anyString(),anyString())).thenAnswer(scope);
|
||||
clearInvocations(f.transport);
|
||||
var candidates=f.receiving.groupContext(group.agreementKey());
|
||||
assertEquals(List.of("B","D"),candidates.eligibleMembers().stream().map(Member::customerId).sorted().toList());
|
||||
@@ -313,8 +313,8 @@ class FbaReceivingPersistenceTest {
|
||||
assertNull(f.store.currentAgreement(batch.batchKey(),"A"));
|
||||
var rebooked=f.receiving.book(OPERATOR,new Book("remaining-bd",List.of(new Booking(selections(batch,"B","D"),confirmed("REMAINING-BD"),"仓库仅确认未完成客户")),group.agreementKey(),cancelledGroup.version(),candidates.executionScope().version()));
|
||||
assertEquals(List.of("B","D"),rebooked.agreements().get(0).members().stream().map(Member::customerId).sorted().toList());
|
||||
verify(f.execution).lockAndInspect(eq(List.of(new FbaAppointmentExecutionPort.Member(batch.batchKey(),"B"),new FbaAppointmentExecutionPort.Member(batch.batchKey(),"D"))),eq("execution-v1"));
|
||||
verify(f.transport,never()).lockAndInspect(anyList(),anyString());
|
||||
verify(f.execution).lockAndInspect(eq(List.of(new FbaAppointmentExecutionPort.Member(batch.batchKey(),"B"),new FbaAppointmentExecutionPort.Member(batch.batchKey(),"D"))),eq("execution-v1"),anyString());
|
||||
verify(f.transport,never()).lockAndInspect(anyList(),anyString(),anyString());
|
||||
verify(f.transport,never()).apply(any(),anyList(),anyString(),anyString());
|
||||
f.assertAllocations(80,160,40);
|
||||
}
|
||||
@@ -331,6 +331,9 @@ class FbaReceivingPersistenceTest {
|
||||
assertNull(later.plannedDate());assertNull(later.plannedAt());assertNull(later.confirmationNo());
|
||||
verify(f.work,times(2)).resolveFollowup("42",followup.nextFollowupDate());
|
||||
verify(f.work).seedFollowup(new FbaAppointmentWorkPort.Member(original.batchKey(),"B"),new FbaAppointmentWorkPort.Member(later.batchKey(),"B"),followup,OPERATOR);
|
||||
var businessOrder=inOrder(f.transport,f.work);
|
||||
businessOrder.verify(f.transport).apply(any(),anyList(),eq(OPERATOR),eq(base.reason()),eq("undated-confirm"));
|
||||
businessOrder.verify(f.work).seedFollowup(any(),any(),eq(followup),eq(OPERATOR));
|
||||
var split=f.batches.repartition(OPERATOR,new RepartitionFbaAppointmentsRequest(
|
||||
List.of(new RepartitionFbaAppointmentsRequest.Original(later.appointmentNo(),later.version())),List.of(laterBatch(40),laterBatch(20)),"后送再拆分并继承跟进","inherit-split"));
|
||||
verify(f.work).inheritFollowup(eq(List.of(new FbaAppointmentWorkPort.Member(later.batchKey(),"B"))),eq(split.appointments().stream().map(b->new FbaAppointmentWorkPort.Member(b.batchKey(),"B")).toList()),eq(OPERATOR));
|
||||
@@ -413,7 +416,7 @@ class FbaReceivingPersistenceTest {
|
||||
var cancelled=f.cancel(old);var batch=cancelled.batches().get(0);var cancelledGroup=cancelled.agreements().get(0);
|
||||
var member=new FbaAppointmentExecutionPort.Member(batch.batchKey(),"B");
|
||||
var eligibility=new FbaAppointmentExecutionPort.Eligibility(member,false,true,null);
|
||||
when(f.execution.lockAndInspect(eq(List.of(member)),eq("execution-v1"))).thenReturn(new FbaAppointmentExecutionPort.Scope("execution-v1",List.of(eligibility)));
|
||||
when(f.execution.lockAndInspect(eq(List.of(member)),eq("execution-v1"),anyString())).thenReturn(new FbaAppointmentExecutionPort.Scope("execution-v1",List.of(eligibility)));
|
||||
when(f.execution.inspect(eq(List.of(member)))).thenReturn(new FbaAppointmentExecutionPort.Scope("execution-v2",List.of(eligibility)));
|
||||
var before=f.db.queryForObject("SELECT COUNT(*) FROM ll_fba_receiving_agreement",Integer.class);
|
||||
conflict(()->f.receiving.book(OPERATOR,new Book("raced-execution",List.of(new Booking(selections(batch,"B"),confirmed("RACED-B"),"并发新关联")),old.agreementKey(),cancelledGroup.version(),"execution-v1")));
|
||||
@@ -428,7 +431,7 @@ class FbaReceivingPersistenceTest {
|
||||
@Test void transportAssociationChangingBeforeBatchLockRejectsProposalWithoutPendingOrCommand() {
|
||||
try(var f=new Fixture()) {
|
||||
var original=f.create();var agreement=f.store.currentAgreement(original.batchKey(),"B");
|
||||
when(f.transport.lockAndInspect(anyList(),eq(TRANSPORT))).thenReturn(new FbaAppointmentTransportPort.Scope(TRANSPORT,List.of(),List.of()));
|
||||
when(f.transport.lockAndInspect(anyList(),eq(TRANSPORT),anyString())).thenReturn(new FbaAppointmentTransportPort.Scope(TRANSPORT,List.of(),List.of()));
|
||||
when(f.transport.inspect(anyList())).thenReturn(new FbaAppointmentTransportPort.Scope("transport-v2",List.of(),List.of()));
|
||||
conflict(()->f.propose(original,100,"raced-proposal"));
|
||||
assertFalse(f.store.hasPending(original.batchKey(),"B"));
|
||||
@@ -445,7 +448,7 @@ class FbaReceivingPersistenceTest {
|
||||
try(var f=new Fixture()) {
|
||||
var original=f.create();var agreement=f.store.currentAgreement(original.batchKey(),"A");
|
||||
var revisionCount=f.db.queryForObject("SELECT COUNT(*) FROM ll_fba_receiving_revision",Integer.class);
|
||||
when(f.transport.lockAndInspect(anyList(),eq(TRANSPORT))).thenReturn(new FbaAppointmentTransportPort.Scope(TRANSPORT,List.of(),List.of()));
|
||||
when(f.transport.lockAndInspect(anyList(),eq(TRANSPORT),anyString())).thenReturn(new FbaAppointmentTransportPort.Scope(TRANSPORT,List.of(),List.of()));
|
||||
when(f.transport.inspect(anyList())).thenReturn(new FbaAppointmentTransportPort.Scope("transport-v2",List.of(),List.of()));
|
||||
for(var action:List.of("CANCEL","RESCHEDULE")) {
|
||||
var key="raced-group-"+action;
|
||||
@@ -458,7 +461,7 @@ class FbaReceivingPersistenceTest {
|
||||
assertEquals(revisionCount,f.db.queryForObject("SELECT COUNT(*) FROM ll_fba_receiving_revision",Integer.class));
|
||||
f.assertAllocations(80,160,40);
|
||||
}
|
||||
verify(f.transport,times(2)).lockAndInspect(eq(List.of(original.batchKey())),eq(TRANSPORT));
|
||||
verify(f.transport,times(2)).lockAndInspect(eq(List.of(original.batchKey())),eq(TRANSPORT),anyString());
|
||||
verify(f.transport,never()).apply(any(),anyList(),anyString(),anyString());
|
||||
}
|
||||
}
|
||||
|
||||
+27
-20
@@ -13,29 +13,31 @@ import static org.mockito.ArgumentMatchers.*;
|
||||
class AppointmentWorkDispatchBindingTest {
|
||||
@Test void referencesCombinePhysicalRowsWithoutLosingBatchCustomerOrSourceIdentity() throws Exception {
|
||||
var mapper=mock(AppointmentWorkDispatchMapper.class);
|
||||
var service=new AppointmentWorkDispatchBinding(mapper,mock(InboundFbaAppointmentService.class),mock(AppointmentSourceService.class),mock(AppointmentWorkChanges.class),mock(com.baiyue.lastleg.inboundplan.receiving.FbaReceivingStore.class));
|
||||
var service=new AppointmentWorkDispatchBinding(mapper,mock(InboundFbaAppointmentService.class),mock(AppointmentSourceService.class),mock(AppointmentWorkChanges.class),mock(com.baiyue.lastleg.inboundplan.receiving.FbaReceivingStore.class),mock(AppointmentWorkDispatchCandidates.class));
|
||||
when(mapper.references(List.of(11L,12L))).thenReturn(List.of(
|
||||
new AppointmentWorkDispatchMapper.ReferenceRow(11L,"FBA_APPOINTMENT:1","AP-1","A","ORDER:1","FBA:ONT8",40),
|
||||
new AppointmentWorkDispatchMapper.ReferenceRow(11L,"FBA_APPOINTMENT:1","AP-1","A","ORDER:1","FBA:ONT8",20),
|
||||
new AppointmentWorkDispatchMapper.ReferenceRow(11L,"FBA_APPOINTMENT:2",null,"A","ORDER:2","FBA:ONT8",10),
|
||||
new AppointmentWorkDispatchMapper.ReferenceRow(12L,"DISPATCH:12","DP-12","B","ORDER:3","CUSTOMER:3",30)));
|
||||
new AppointmentWorkDispatchMapper.ReferenceRow(11L,"FBA_APPOINTMENT:1","AP-1",0L,"A","ORDER:1","FBA:ONT8",40),
|
||||
new AppointmentWorkDispatchMapper.ReferenceRow(11L,"FBA_APPOINTMENT:1","AP-1",0L,"A","ORDER:1","FBA:ONT8",20),
|
||||
new AppointmentWorkDispatchMapper.ReferenceRow(11L,"FBA_APPOINTMENT:2",null,2L,"A","ORDER:2","FBA:ONT8",10),
|
||||
new AppointmentWorkDispatchMapper.ReferenceRow(12L,"DISPATCH:12","DP-12",3L,"B","ORDER:3","CUSTOMER:3",30)));
|
||||
var result=service.references(List.of(12L,11L));assertEquals(2,result.get(11L).size());
|
||||
assertEquals(60,result.get(11L).get(0).sources().get(0).packageCount());assertNull(result.get(11L).get(1).batchNo());
|
||||
assertEquals("DP-12",result.get(12L).get(0).batchNo());assertEquals("B",result.get(12L).get(0).customerId());
|
||||
assertEquals(0L,result.get(11L).get(0).recordVersion());
|
||||
verify(mapper).references(List.of(11L,12L));
|
||||
var transaction=AppointmentWorkDispatchBinding.class.getMethod("references",List.class).getAnnotation(org.springframework.transaction.annotation.Transactional.class);
|
||||
assertEquals(org.springframework.transaction.annotation.Propagation.SUPPORTS,transaction.propagation());assertTrue(transaction.readOnly());
|
||||
when(mapper.references(List.of(11L))).thenReturn(java.util.Collections.nCopies(20001,new AppointmentWorkDispatchMapper.ReferenceRow(11L,"FBA_APPOINTMENT:1","AP-1","A","ORDER:1","FBA:ONT8",1)));
|
||||
when(mapper.references(List.of(11L))).thenReturn(java.util.Collections.nCopies(20001,new AppointmentWorkDispatchMapper.ReferenceRow(11L,"FBA_APPOINTMENT:1","AP-1",0L,"A","ORDER:1","FBA:ONT8",1)));
|
||||
assertThrows(org.springframework.web.server.ResponseStatusException.class,()->service.references(List.of(11L)));
|
||||
}
|
||||
@Test void departureRechecksCurrentSourceAllocationAfterBatchQuantityOrMembershipChanges() {
|
||||
var mapper=mock(AppointmentWorkDispatchMapper.class);var appointments=mock(InboundFbaAppointmentService.class);
|
||||
var sources=mock(AppointmentSourceService.class);var changes=mock(AppointmentWorkChanges.class);
|
||||
var receiving=mock(com.baiyue.lastleg.inboundplan.receiving.FbaReceivingStore.class);
|
||||
var service=new AppointmentWorkDispatchBinding(mapper,appointments,sources,changes,receiving);
|
||||
var service=new AppointmentWorkDispatchBinding(mapper,appointments,sources,changes,receiving,mock(AppointmentWorkDispatchCandidates.class));
|
||||
when(receiving.currentAgreement("FBA_APPOINTMENT:1","C")).thenReturn(agreement("C",4));
|
||||
var row=new AppointmentWorkDispatchMapper.Bound(20,30,10,"FBA_APPOINTMENT:1","C","ORDER:1","FBA:ONT8",4);
|
||||
when(mapper.bindings("D-20")).thenReturn(List.of(row));
|
||||
when(mapper.dispatchPhysical("D-20")).thenReturn(List.of(new AppointmentWorkDispatchMapper.DispatchPhysical(30,10L,"C","RECEIVED",4)));
|
||||
when(mapper.lockAppointment(1)).thenReturn(new com.baiyue.lastleg.inboundplan.entity.InboundFbaAppointment());
|
||||
var batch=mock(FbaAppointmentResponse.class);when(batch.batchKey()).thenReturn("FBA_APPOINTMENT:1");
|
||||
when(batch.appointmentStatus()).thenReturn("SUCCESS");when(batch.appointmentAt()).thenReturn(java.time.LocalDateTime.of(2026,10,18,10,0));
|
||||
@@ -82,22 +84,26 @@ class AppointmentWorkDispatchBindingTest {
|
||||
var member=new com.baiyue.lastleg.inboundplan.receiving.FbaReceivingContracts.Member("FBA_APPOINTMENT:1","AP-1",1,customer,List.of(source),null);
|
||||
return new com.baiyue.lastleg.inboundplan.receiving.FbaReceivingContracts.Agreement("RA-1",1,"ACTIVE","FBA:ONT8",details,"America/Los_Angeles",List.of(member),"仓库已确认");
|
||||
}
|
||||
@Test void groupedReplayCannotRewriteBatchBindingsAfterTheOriginalDispatchHasMovedOn() {
|
||||
var mapper=mock(AppointmentWorkDispatchMapper.class);var changes=mock(AppointmentWorkChanges.class);
|
||||
var service=new AppointmentWorkDispatchBinding(mapper,mock(InboundFbaAppointmentService.class),mock(AppointmentSourceService.class),changes,mock(com.baiyue.lastleg.inboundplan.receiving.FbaReceivingStore.class));
|
||||
var row=new AppointmentWorkDispatchMapper.Bound(20,30,10,"FBA_APPOINTMENT:1","C","ORDER:1","FBA:ONT8",4);
|
||||
when(mapper.byDispatch(20)).thenReturn(List.of(row));
|
||||
var allocation=new AppointmentWorkDispatchPort.Allocation(10L,"FBA_APPOINTMENT:1","C","ORDER:1","FBA:ONT8",4);
|
||||
assertDoesNotThrow(()->service.requireReplay("C",20,List.of(allocation)));
|
||||
assertThrows(org.springframework.web.server.ResponseStatusException.class,()->service.requireReplay("C",20,List.of(new AppointmentWorkDispatchPort.Allocation(10L,"FBA_APPOINTMENT:2","C","ORDER:1","FBA:ONT8",4))));
|
||||
verify(mapper,never()).remove(anyLong());verify(mapper,never()).insert(any());verifyNoInteractions(changes);
|
||||
@Test void batchPrelockUsesCanonicalSortedKeysAcceptsZeroAndRejectsStaleVersion() {
|
||||
var mapper=mock(AppointmentWorkDispatchMapper.class);
|
||||
var service=new AppointmentWorkDispatchBinding(mapper,mock(InboundFbaAppointmentService.class),mock(AppointmentSourceService.class),mock(AppointmentWorkChanges.class),mock(com.baiyue.lastleg.inboundplan.receiving.FbaReceivingStore.class),mock(AppointmentWorkDispatchCandidates.class));
|
||||
var zero=new com.baiyue.lastleg.inboundplan.entity.InboundFbaAppointment();zero.setVersion(0);
|
||||
var two=new com.baiyue.lastleg.inboundplan.entity.InboundFbaAppointment();two.setVersion(2);
|
||||
when(mapper.lockAppointment(10)).thenReturn(zero);when(mapper.lockAppointment(2)).thenReturn(two);
|
||||
service.lockBatches(Map.of("FBA_APPOINTMENT:2",2L,"FBA_APPOINTMENT:10",0L));
|
||||
var order=inOrder(mapper);order.verify(mapper).lockAppointment(10);order.verify(mapper).lockAppointment(2);
|
||||
assertThrows(org.springframework.web.server.ResponseStatusException.class,()->service.lockBatches(Map.of("FBA_APPOINTMENT:10",1L)));
|
||||
assertThrows(org.springframework.web.server.ResponseStatusException.class,()->service.lockBatches(Map.of("FBA_APPOINTMENT:01",0L)));
|
||||
assertThrows(org.springframework.web.server.ResponseStatusException.class,()->service.lockBatches(Collections.singletonMap("FBA_APPOINTMENT:10",null)));
|
||||
}
|
||||
@Test void privateDispatchBindsOriginalPhysicalSourceAndDepartureRequiresActualPickedQuantity() {
|
||||
var mapper=mock(AppointmentWorkDispatchMapper.class);var appointments=mock(InboundFbaAppointmentService.class);var sources=mock(AppointmentSourceService.class);var changes=mock(AppointmentWorkChanges.class);
|
||||
var service=new AppointmentWorkDispatchBinding(mapper,appointments,sources,changes,mock(com.baiyue.lastleg.inboundplan.receiving.FbaReceivingStore.class));
|
||||
var service=new AppointmentWorkDispatchBinding(mapper,appointments,sources,changes,mock(com.baiyue.lastleg.inboundplan.receiving.FbaReceivingStore.class),mock(AppointmentWorkDispatchCandidates.class));
|
||||
when(mapper.physical(10,"C")).thenReturn(List.of(new AppointmentWorkDispatchMapper.Physical(1L,"C","CUSTOMER:1",4,4)));
|
||||
when(sources.canonicalOrders(anyList())).thenReturn(List.of(new AppointmentCoverageService.SourceRef("C","ORDER:1",null)));
|
||||
service.bind("C",20,List.of(new AppointmentWorkDispatchPort.PhysicalItem(30L,10L,"RECEIVED",4)),List.of());
|
||||
var physical=new AppointmentWorkDispatchMapper.DispatchPhysical(30,10L,"C","RECEIVED",4);
|
||||
when(mapper.dispatchPhysicalById(20)).thenReturn(List.of(physical));when(mapper.dispatchPhysical("D-20")).thenReturn(List.of(physical));
|
||||
service.bindItems(20,List.of(new AppointmentWorkDispatchPort.PhysicalItem(30L,"C",10L,"RECEIVED",4)),List.of());
|
||||
var expected=new AppointmentWorkDispatchMapper.Bound(20,30,10,"DISPATCH:20","C","ORDER:1","CUSTOMER:1",4);
|
||||
verify(mapper).insert(expected);verify(changes).requirementsChanged();
|
||||
when(mapper.bindings("D-20")).thenReturn(List.of(expected));
|
||||
@@ -119,7 +125,7 @@ class AppointmentWorkDispatchBindingTest {
|
||||
@Test void emptyAllocationCannotBypassAnExistingBatchAtArrangeOrDeparture() {
|
||||
var mapper=mock(AppointmentWorkDispatchMapper.class); var appointments=mock(InboundFbaAppointmentService.class);
|
||||
var sources=mock(AppointmentSourceService.class); var changes=mock(AppointmentWorkChanges.class);
|
||||
var service=new AppointmentWorkDispatchBinding(mapper,appointments,sources,changes,mock(com.baiyue.lastleg.inboundplan.receiving.FbaReceivingStore.class));
|
||||
var service=new AppointmentWorkDispatchBinding(mapper,appointments,sources,changes,mock(com.baiyue.lastleg.inboundplan.receiving.FbaReceivingStore.class),mock(AppointmentWorkDispatchCandidates.class));
|
||||
var part=new AppointmentWorkDispatchMapper.Physical(1L,"C","FBA:ONT8",4,4);
|
||||
when(mapper.physical(10,"C")).thenReturn(List.of(part));
|
||||
when(sources.canonicalOrders(anyList())).thenReturn(List.of(new AppointmentCoverageService.SourceRef("C","ORDER:1",null)));
|
||||
@@ -127,9 +133,10 @@ class AppointmentWorkDispatchBindingTest {
|
||||
when(appointments.relatedBatchSnapshots(anyList())).thenReturn(List.of(batch));
|
||||
when(mapper.bindings("D-1")).thenReturn(List.of());
|
||||
when(mapper.dispatchPhysical("D-1")).thenReturn(List.of(new AppointmentWorkDispatchMapper.DispatchPhysical(30,10L,"C","RECEIVED",4)));
|
||||
when(mapper.dispatchPhysicalById(20)).thenReturn(List.of(new AppointmentWorkDispatchMapper.DispatchPhysical(30,10L,"C","RECEIVED",4)));
|
||||
for(var status:List.of("SUCCESS","CANCELLED","FAILED")) {
|
||||
when(batch.appointmentStatus()).thenReturn(status);
|
||||
assertThrows(org.springframework.web.server.ResponseStatusException.class,()->service.bind("C",20,List.of(new AppointmentWorkDispatchPort.PhysicalItem(30L,10L,"RECEIVED",4)),List.of()));
|
||||
assertThrows(org.springframework.web.server.ResponseStatusException.class,()->service.bindItems(20,List.of(new AppointmentWorkDispatchPort.PhysicalItem(30L,"C",10L,"RECEIVED",4)),List.of()));
|
||||
assertThrows(org.springframework.web.server.ResponseStatusException.class,()->service.requireDeparture("D-1"));
|
||||
}
|
||||
verify(mapper,never()).insert(any()); verifyNoInteractions(changes);
|
||||
|
||||
+143
@@ -0,0 +1,143 @@
|
||||
package com.baiyue.lastleg.inboundplan.work;
|
||||
|
||||
import com.baiyue.lastleg.appointmentplanning.*;
|
||||
import com.baiyue.lastleg.appointmentwork.*;
|
||||
import com.baiyue.lastleg.appointmentwork.AppointmentWorkDispatchPort.*;
|
||||
import com.baiyue.lastleg.inboundplan.dto.*;
|
||||
import com.baiyue.lastleg.inboundplan.mapper.InboundFbaAppointmentQueryMapper;
|
||||
import com.baiyue.lastleg.inboundplan.receiving.FbaReceivingStore;
|
||||
import com.baiyue.lastleg.inboundplan.service.InboundFbaAppointmentService;
|
||||
import com.baomidou.mybatisplus.core.MybatisConfiguration;
|
||||
import com.baomidou.mybatisplus.extension.spring.MybatisSqlSessionFactoryBean;
|
||||
import org.mybatis.spring.SqlSessionTemplate;
|
||||
import org.junit.jupiter.api.*;
|
||||
import org.springframework.aop.framework.ProxyFactory;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.jdbc.datasource.*;
|
||||
import org.springframework.transaction.TransactionDefinition;
|
||||
import org.springframework.transaction.annotation.AnnotationTransactionAttributeSource;
|
||||
import org.springframework.transaction.interceptor.TransactionInterceptor;
|
||||
import org.springframework.transaction.support.TransactionTemplate;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
import java.util.*;
|
||||
import java.util.concurrent.*;
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
/** 真实MyBatis、批次行锁、逐客户分配与回滚;原批次和来源读取边界由当前DB事实供给。 */
|
||||
class AppointmentWorkDispatchPersistenceTest {
|
||||
private JdbcTemplate jdbc;
|
||||
private TransactionTemplate tx;
|
||||
private AppointmentWorkDispatchPort binding;
|
||||
private AppointmentWorkDispatchMapper mapper;
|
||||
private InboundFbaAppointmentQueryMapper query;
|
||||
private AppointmentWorkDispatchCandidates candidates;
|
||||
private InboundFbaAppointmentService appointments;
|
||||
private AppointmentSourceService sources;
|
||||
private final String batch="FBA_APPOINTMENT:1";
|
||||
|
||||
/** 无客户主单,多个客户可在同车同批;真实计划逐票数量来自各自明细。 */
|
||||
@BeforeEach void setup() throws Exception {
|
||||
var ds=new DriverManagerDataSource("jdbc:h2:mem:appointment-dispatch-"+UUID.randomUUID()+";MODE=MySQL;DB_CLOSE_DELAY=-1;LOCK_TIMEOUT=10000","sa","");
|
||||
jdbc=new JdbcTemplate(ds);var manager=new DataSourceTransactionManager(ds);tx=new TransactionTemplate(manager);tx.setIsolationLevel(TransactionDefinition.ISOLATION_READ_COMMITTED);
|
||||
jdbc.execute("CREATE TABLE ll_dispatch_order(id BIGINT PRIMARY KEY,dispatch_no VARCHAR(40),record_version BIGINT,status VARCHAR(30))");
|
||||
jdbc.execute("CREATE TABLE ll_dispatch_item(id BIGINT PRIMARY KEY,dispatch_order_id BIGINT,customer_id VARCHAR(40),plan_pallet_id BIGINT,source_stage VARCHAR(30),package_count INT)");
|
||||
jdbc.execute("CREATE TABLE ll_dispatch_appointment_allocation(dispatch_id BIGINT,dispatch_item_id BIGINT,plan_pallet_id BIGINT,batch_key VARCHAR(60),customer_id VARCHAR(40),source_key VARCHAR(40),destination_key VARCHAR(40),package_count INT)");
|
||||
jdbc.execute("CREATE TABLE ll_inbound_fba_appointment(id BIGINT PRIMARY KEY,appointment_no VARCHAR(40),version INT,replacement_keys_json VARCHAR(200))");
|
||||
jdbc.execute("CREATE TABLE ll_inbound_fba_appointment_item(appointment_id BIGINT,order_id BIGINT,customer_id VARCHAR(40),source_key VARCHAR(60),fba_warehouse_code VARCHAR(40),package_count INT,active INT)");
|
||||
jdbc.execute("CREATE TABLE ll_inbound_plan(id BIGINT PRIMARY KEY,plan_no VARCHAR(40))");
|
||||
jdbc.execute("CREATE TABLE ll_inbound_plan_pallet(id BIGINT PRIMARY KEY,pallet_no VARCHAR(40),plan_id BIGINT,customer_id VARCHAR(40),warehouse_pallet_id BIGINT)");
|
||||
jdbc.execute("CREATE TABLE ll_inbound_plan_line(id BIGINT PRIMARY KEY,order_id BIGINT,customer_id VARCHAR(40),destination_type VARCHAR(30),fba_warehouse_code VARCHAR(40))");
|
||||
jdbc.execute("CREATE TABLE ll_inbound_plan_pallet_allocation(pallet_id BIGINT,line_id BIGINT,customer_id VARCHAR(40),planned_package_count INT,counted_package_count INT)");
|
||||
jdbc.execute("CREATE TABLE ll_dispatch_pallet_lock(id BIGINT PRIMARY KEY,plan_pallet_id BIGINT,warehouse_pallet_id BIGINT,status VARCHAR(30),dispatch_order_id BIGINT,expires_at TIMESTAMP)");
|
||||
jdbc.execute("CREATE TABLE change_guard(id BIGINT PRIMARY KEY,version INT)");jdbc.update("INSERT INTO change_guard VALUES(1,0)");
|
||||
jdbc.update("INSERT INTO ll_dispatch_order VALUES(1,'D-1',0,'ACTIVE')");jdbc.update("INSERT INTO ll_inbound_fba_appointment VALUES(1,'AP-1',0,NULL)");jdbc.update("INSERT INTO ll_inbound_plan VALUES(1,'PLAN-1')");
|
||||
seed(11,"101",1,80,"FBA");seed(21,"202",2,100,"FBA");seed(22,"202",2,60,"FBA");seed(31,"303",3,40,"PRIVATE");
|
||||
jdbc.update("INSERT INTO ll_inbound_fba_appointment_item VALUES(1,1,'101','ORDER:1','ONT8',80,1)");jdbc.update("INSERT INTO ll_inbound_fba_appointment_item VALUES(1,2,'202','BOOKING:real-2','ONT8',160,1)");
|
||||
var config=new MybatisConfiguration();config.setMapUnderscoreToCamelCase(true);config.addMapper(AppointmentWorkDispatchMapper.class);config.addMapper(InboundFbaAppointmentQueryMapper.class);
|
||||
var factory=new MybatisSqlSessionFactoryBean();factory.setDataSource(ds);factory.setConfiguration(config);var session=new SqlSessionTemplate(factory.getObject());
|
||||
mapper=session.getMapper(AppointmentWorkDispatchMapper.class);query=session.getMapper(InboundFbaAppointmentQueryMapper.class);
|
||||
appointments=mock(InboundFbaAppointmentService.class);sources=mock(AppointmentSourceService.class);var changes=mock(AppointmentWorkChanges.class);
|
||||
when(appointments.batchContext(batch)).thenAnswer(c->new FbaAppointmentBatchContext(snapshot(),List.of(),List.of(),List.of(),List.of(),List.of()));
|
||||
when(sources.canonicalOrders(anyList())).thenAnswer(c->{List<AppointmentExternalSourceGateway.OrderRef> orders=c.getArgument(0);return orders.stream().map(o->new AppointmentCoverageService.SourceRef(o.customerId(),o.orderId()==2?"BOOKING:real-2":"ORDER:"+o.orderId(),null)).toList();});
|
||||
doAnswer(c->{jdbc.update("UPDATE change_guard SET version=version+1 WHERE id=1");return null;}).when(changes).requirementsChanged();
|
||||
candidates=new AppointmentWorkDispatchCandidates(mapper,appointments,sources);
|
||||
var proxy=new ProxyFactory(new AppointmentWorkDispatchBinding(mapper,appointments,sources,changes,mock(FbaReceivingStore.class),candidates));proxy.setProxyTargetClass(true);proxy.addAdvice(new TransactionInterceptor(manager,new AnnotationTransactionAttributeSource()));binding=(AppointmentWorkDispatchPort)proxy.getProxy();
|
||||
}
|
||||
/** 插入真实客户、托盘和逐票数量,不从请求猜测。 */
|
||||
private void seed(long item,String customer,long order,int count,String destination) {
|
||||
jdbc.update("INSERT INTO ll_dispatch_item VALUES(?,1,?,?,'RECEIVED',?)",item,customer,item+100,count);
|
||||
jdbc.update("INSERT INTO ll_inbound_plan_pallet VALUES(?,?,1,?,?)",item+100,"P-"+item,customer,item+1000);
|
||||
jdbc.update("INSERT INTO ll_inbound_plan_line VALUES(?,?,?,?, 'ONT8')",item,order,customer,destination);
|
||||
jdbc.update("INSERT INTO ll_inbound_plan_pallet_allocation VALUES(?,?,?,?,?)",item+100,item,customer,count,count);
|
||||
}
|
||||
/** 测试中的原批次边界每次读取当前事务真实行。 */
|
||||
private FbaAppointmentResponse snapshot() {
|
||||
var response=mock(FbaAppointmentResponse.class);when(response.batchKey()).thenReturn(batch);when(response.id()).thenReturn(1L);when(response.version()).thenReturn(jdbc.queryForObject("SELECT version FROM ll_inbound_fba_appointment WHERE id=1",Integer.class));when(response.replacementBatchKeys()).thenReturn(List.of());
|
||||
var items=jdbc.query("SELECT * FROM ll_inbound_fba_appointment_item WHERE appointment_id=1 AND active=1",(rs,n)->{
|
||||
var item=mock(FbaAppointmentResponse.Item.class);when(item.customerId()).thenReturn(rs.getString("customer_id"));when(item.sourceKey()).thenReturn(rs.getString("source_key"));when(item.destinationKey()).thenReturn("FBA:"+rs.getString("fba_warehouse_code"));when(item.packageCount()).thenReturn(rs.getInt("package_count"));return item;
|
||||
});when(response.items()).thenReturn(items);return response;
|
||||
}
|
||||
/** 从本车真实明细构造Provider输入。 */
|
||||
private PhysicalItem item(long id) {return mapper.dispatchPhysicalById(1).stream().filter(i->i.itemId()==id).map(i->new PhysicalItem(i.itemId(),i.customerId(),i.planPalletId(),i.sourceStage(),i.packages())).findFirst().orElseThrow();}
|
||||
/** 选择对应真实来源的完整数量,版本零必须显式传递。 */
|
||||
private Allocation allocation(long id) {var i=item(id);return new Allocation(i.planPalletId(),batch,0L,i.customerId(),"202".equals(i.customerId())?"BOOKING:real-2":"ORDER:1","FBA:ONT8",i.packages());}
|
||||
/** 在来源锁前预锁,再增量绑定指定实物。 */
|
||||
private void bind(long... ids) {binding.lockBatches(Map.of(batch,0L));binding.bindItems(1,Arrays.stream(ids).mapToObj(this::item).toList(),Arrays.stream(ids).mapToObj(this::allocation).toList());}
|
||||
|
||||
@Test void incrementallyAddingOtherCustomersAndPrivateCargoPreservesOriginalAllocations() {
|
||||
tx.executeWithoutResult(s->bind(11));var original=jdbc.queryForList("SELECT * FROM ll_dispatch_appointment_allocation ORDER BY dispatch_item_id");
|
||||
tx.executeWithoutResult(s->bind(21,22));tx.executeWithoutResult(s->{binding.lockBatches(Map.of(batch,0L));binding.bindItems(1,List.of(item(31)),List.of());});
|
||||
assertEquals(original,jdbc.queryForList("SELECT * FROM ll_dispatch_appointment_allocation WHERE customer_id='101' ORDER BY dispatch_item_id"));
|
||||
assertEquals(280,jdbc.queryForObject("SELECT SUM(package_count) FROM ll_dispatch_appointment_allocation",Integer.class));
|
||||
assertEquals(1L,query.countActiveDispatches(1L));var references=binding.references(List.of(1L));assertEquals(3,references.get(1L).size());assertTrue(references.get(1L).stream().allMatch(r->r.recordVersion()==0));
|
||||
assertEquals("BOOKING:real-2",references.get(1L).stream().filter(r->r.customerId().equals("202")).findFirst().orElseThrow().sources().get(0).sourceKey());
|
||||
}
|
||||
@Test void untouchedSameCustomerAllocationParticipatesInCurrentBatchQuota() {
|
||||
tx.executeWithoutResult(s->bind(21,22));var original=jdbc.queryForList("SELECT * FROM ll_dispatch_appointment_allocation ORDER BY dispatch_item_id");
|
||||
jdbc.update("UPDATE ll_inbound_fba_appointment_item SET package_count=150 WHERE customer_id='202'");
|
||||
assertThrows(ResponseStatusException.class,()->tx.executeWithoutResult(s->bind(21)));
|
||||
assertEquals(original,jdbc.queryForList("SELECT * FROM ll_dispatch_appointment_allocation ORDER BY dispatch_item_id"));
|
||||
}
|
||||
@Test void forgedCustomerAndStaleOrMissingVersionCannotWritePhysicalBindings() {
|
||||
assertThrows(ResponseStatusException.class,()->tx.executeWithoutResult(s->{binding.lockBatches(Map.of(batch,0L));binding.bindItems(1,List.of(new PhysicalItem(11L,"202",111L,"RECEIVED",80)),List.of());}));
|
||||
assertThrows(ResponseStatusException.class,()->tx.executeWithoutResult(s->{binding.lockBatches(Map.of(batch,0L));binding.bindItems(1,List.of(item(11)),List.of(new Allocation(111L,batch,null,"101","ORDER:1","FBA:ONT8",80)));}));
|
||||
jdbc.update("UPDATE ll_inbound_fba_appointment SET version=1 WHERE id=1");assertThrows(ResponseStatusException.class,()->tx.executeWithoutResult(s->bind(11)));
|
||||
assertEquals(0,jdbc.queryForObject("SELECT COUNT(*) FROM ll_dispatch_appointment_allocation",Integer.class));
|
||||
}
|
||||
@Test void bindingCannotAcquireUnannouncedBatchAfterSourcePhase() {
|
||||
assertThrows(ResponseStatusException.class,()->tx.executeWithoutResult(s->binding.bindItems(1,List.of(item(11)),List.of(allocation(11)))));
|
||||
assertThrows(ResponseStatusException.class,()->tx.executeWithoutResult(s->{binding.lockBatches(Map.of());binding.lockBatches(Map.of(batch,0L));}));
|
||||
}
|
||||
@Test void expiredDraftDoesNotBlockCandidateButPhysicalAliasAndFormalClaimDo() {
|
||||
jdbc.update("INSERT INTO ll_dispatch_pallet_lock VALUES(1,111,NULL,'ACTIVE',NULL,DATEADD('SECOND',-30,CURRENT_TIMESTAMP))");
|
||||
var result=candidates.read(batch);assertEquals(0,result.version());assertFalse(result.candidates().stream().filter(c->c.planPalletId()==111).findFirst().orElseThrow().blockReasons().contains("托盘已被其他排车或草稿占用"));
|
||||
jdbc.update("UPDATE ll_dispatch_pallet_lock SET plan_pallet_id=NULL,warehouse_pallet_id=1011,expires_at=DATEADD('SECOND',30,CURRENT_TIMESTAMP) WHERE id=1");
|
||||
assertTrue(mapper.candidates(1).stream().filter(c->c.planPalletId()==111).findFirst().orElseThrow().locked());
|
||||
jdbc.update("UPDATE ll_dispatch_pallet_lock SET dispatch_order_id=1,expires_at=NULL WHERE id=1");assertTrue(mapper.candidates(1).stream().filter(c->c.planPalletId()==111).findFirst().orElseThrow().locked());
|
||||
assertEquals(1,jdbc.queryForObject("SELECT COUNT(*) FROM ll_dispatch_pallet_lock",Integer.class));
|
||||
assertTrue(result.candidates().stream().flatMap(c->c.allocations().stream()).allMatch(a->a.recordVersion()==0));
|
||||
}
|
||||
@Test void laterFailureRollsBackEarlierCustomerAllocationAndGuardWithinSameTransaction() {
|
||||
assertThrows(IllegalStateException.class,()->tx.executeWithoutResult(s->{bind(11);bind(21,22);throw new IllegalStateException("后续业务失败");}));
|
||||
assertEquals(0,jdbc.queryForObject("SELECT COUNT(*) FROM ll_dispatch_appointment_allocation",Integer.class));assertEquals(0,jdbc.queryForObject("SELECT version FROM change_guard WHERE id=1",Integer.class));
|
||||
}
|
||||
@Test void pageBatchReadUsesRealCustomerCanonicalSourceVersionsAndEffectiveOccupancy() {
|
||||
jdbc.update("INSERT INTO ll_dispatch_pallet_lock VALUES(1,NULL,1021,'ACTIVE',NULL,DATEADD('SECOND',30,CURRENT_TIMESTAMP))");
|
||||
var inputs=List.of(new CandidatePhysical("101",111L),new CandidatePhysical("202",121L),new CandidatePhysical("wrong",111L));
|
||||
var result=binding.candidateBatches(inputs);var a=result.get(inputs.get(0));var b=result.get(inputs.get(1));
|
||||
assertEquals(0L,a.appointmentBatches().get(0).recordVersion());assertFalse(a.occupied());assertTrue(b.occupied());
|
||||
assertEquals("BOOKING:real-2",b.appointmentBatches().get(0).sources().get(0).sourceKey());assertEquals(100,b.appointmentBatches().get(0).sources().get(0).packageCount());
|
||||
assertFalse(result.get(inputs.get(2)).blockReasons().isEmpty());verify(appointments,never()).batchContext(anyString());
|
||||
jdbc.update("UPDATE ll_inbound_fba_appointment SET version=3 WHERE id=1");assertEquals(3L,binding.candidateBatches(inputs).get(inputs.get(0)).appointmentBatches().get(0).recordVersion());
|
||||
}
|
||||
@Test void twoDispatchesCannotConsumeTheSameBatchQuotaConcurrently() throws Exception {
|
||||
jdbc.update("INSERT INTO ll_dispatch_order VALUES(2,'D-2',0,'ACTIVE')");jdbc.update("INSERT INTO ll_dispatch_item VALUES(12,2,'101',111,'RECEIVED',80)");
|
||||
var start=new CyclicBarrier(2);var pool=Executors.newFixedThreadPool(2);
|
||||
try {
|
||||
var results=new ArrayList<Future<Boolean>>();
|
||||
for(long dispatch:List.of(1L,2L)) results.add(pool.submit(()->{start.await(5,TimeUnit.SECONDS);try {tx.executeWithoutResult(s->{binding.lockBatches(Map.of(batch,0L));binding.bindItems(dispatch,List.of(new PhysicalItem(dispatch==1?11L:12L,"101",111L,"RECEIVED",80)),List.of(new Allocation(111L,batch,0L,"101","ORDER:1","FBA:ONT8",80)));});return true;}catch(ResponseStatusException conflict){return false;}}));
|
||||
int applied=0;for(var result:results) if(result.get(15,TimeUnit.SECONDS)) applied++;assertEquals(1,applied);assertEquals(80,jdbc.queryForObject("SELECT SUM(package_count) FROM ll_dispatch_appointment_allocation",Integer.class));
|
||||
} finally {pool.shutdownNow();}
|
||||
}
|
||||
}
|
||||
+26
-15
@@ -1,6 +1,7 @@
|
||||
package com.baiyue.lastleg.inboundplan.work;
|
||||
|
||||
import com.baiyue.lastleg.fleet.trip.*;
|
||||
import com.baiyue.lastleg.dispatch.port.DispatchAppointmentAmendmentPort;
|
||||
|
||||
import com.baiyue.lastleg.inboundplan.receiving.FbaAppointmentExecutionPort.*;
|
||||
import org.junit.jupiter.api.Test;
|
||||
@@ -13,50 +14,60 @@ class FbaAppointmentExecutionAdapterTest {
|
||||
final String batch="FBA_APPOINTMENT:1";
|
||||
final Member a=new Member(batch,"A"),b=new Member(batch,"B");
|
||||
final AppointmentWorkDispatchMapper mapper=mock(AppointmentWorkDispatchMapper.class);
|
||||
final FleetTripMapper trips=mock(FleetTripMapper.class);
|
||||
final DispatchAppointmentAmendmentPort amendments=mock(DispatchAppointmentAmendmentPort.class);
|
||||
final FleetDispatchGateway dispatches=mock(FleetDispatchGateway.class);
|
||||
final FbaAppointmentExecutionAdapter adapter=new FbaAppointmentExecutionAdapter(mapper,trips,dispatches);
|
||||
final FbaAppointmentExecutionAdapter adapter=new FbaAppointmentExecutionAdapter(mapper,amendments,dispatches);
|
||||
final Instant at=Instant.parse("2026-10-01T12:00:00Z");
|
||||
FbaAppointmentExecutionAdapterTest() {
|
||||
when(mapper.receivingSources(List.of(batch))).thenReturn(List.of(new AppointmentWorkDispatchMapper.ReceivingSource(batch,"A","ORDER:1","FBA:ONT8",100),new AppointmentWorkDispatchMapper.ReceivingSource(batch,"B","ORDER:2","FBA:ONT8",100)));
|
||||
when(mapper.transportRows(List.of(batch),List.of())).thenReturn(List.of(row("A","DEPARTED","COMPLETED"),row("B","PENDING_DEPARTURE","PLANNED")));
|
||||
when(mapper.transportRows(List.of(batch),List.of())).thenReturn(List.of(row("A","DELIVERING","COMPLETED"),row("B","BEFORE_LOADING","PLANNED")));
|
||||
when(dispatches.completion("D-A",false)).thenReturn(new FleetCompletionModels.DispatchCompletion("D-A","100",List.of(new FleetCompletionModels.Outcome("D-A","DL-A","A","ONT8","DELIVERED",100,at,100,0,0)),null));
|
||||
when(dispatches.completion("D-B",false)).thenReturn(new FleetCompletionModels.DispatchCompletion("D-B","100",List.of(),"排车出库尚未完成"));
|
||||
when(dispatches.terminalEvidence("D-A",false)).thenReturn(proof(100,at,1,List.of()));
|
||||
when(trips.lockByNo("T-B")).thenReturn(new FleetTripEntity());
|
||||
}
|
||||
@Test void sameBatchSubsetKeepsTokenAndOnlySelectedCustomerExecutionRowsAreLocked() {
|
||||
var all=adapter.inspect(List.of(a,b));var selected=adapter.inspect(List.of(b));
|
||||
assertEquals(all.version(),selected.version());assertTrue(all.members().get(0).completed());assertFalse(all.members().get(0).canRebook());assertTrue(selected.members().get(0).canRebook());
|
||||
var locked=adapter.lockAndInspect(List.of(b),all.version());assertTrue(locked.members().get(0).canRebook());
|
||||
var order=inOrder(trips,dispatches);order.verify(trips).lockByNo("T-B");order.verify(dispatches).completion("D-B",true);order.verify(dispatches).lockForPlanning("D-B");
|
||||
verify(trips,never()).lockByNo("T-A");verify(dispatches,never()).completion("D-A",true);verify(dispatches,never()).lockForPlanning("D-A");
|
||||
var locked=adapter.lockAndInspect(List.of(b),all.version(),"operator");assertTrue(locked.members().get(0).canRebook());
|
||||
var order=inOrder(amendments,dispatches);order.verify(amendments).lockExistingAppointmentDispatches(any(),eq(List.of("D-B")));order.verify(dispatches).completion("D-B",true);order.verify(dispatches).lockForPlanning("D-B");
|
||||
verify(dispatches,never()).completion("D-A",true);verify(dispatches,never()).lockForPlanning("D-A");
|
||||
}
|
||||
@Test void statusCannotReplaceRealProofAndPartialActualCoverageCannotCompleteWholeAllocation() {
|
||||
when(dispatches.terminalEvidence("D-A",false)).thenReturn(proof(50,at,1,List.of()));
|
||||
var partial=adapter.inspect(List.of(a)).members().get(0);assertFalse(partial.completed());assertFalse(partial.canRebook());
|
||||
when(dispatches.terminalEvidence("D-A",false)).thenThrow(new org.springframework.web.server.ResponseStatusException(org.springframework.http.HttpStatus.CONFLICT,"缺少真实出库依据"));
|
||||
var absent=adapter.inspect(List.of(a)).members().get(0);assertFalse(absent.completed());assertFalse(absent.canRebook());
|
||||
when(mapper.transportRows(List.of(batch),List.of())).thenReturn(List.of(row("B","PENDING_DEPARTURE","COMPLETED")));
|
||||
when(mapper.transportRows(List.of(batch),List.of())).thenReturn(List.of(row("B","BEFORE_LOADING","COMPLETED")));
|
||||
var tripOnly=adapter.inspect(List.of(b)).members().get(0);assertFalse(tripOnly.completed());assertFalse(tripOnly.canRebook());
|
||||
}
|
||||
@Test void actualReturnCompletionExcludesMemberAndSiblingExecutionChangeInvalidatesToken() {
|
||||
var prior=adapter.inspect(List.of(b));
|
||||
when(dispatches.terminalEvidence("D-A",false)).thenReturn(proof(100,at.plusSeconds(60),2,List.of(new FleetDispatchGateway.ReturnedReceipt("11","101",100,at.plusSeconds(60)))));
|
||||
var returned=adapter.inspect(List.of(a)).members().get(0);assertTrue(returned.completed());assertFalse(returned.canRebook());
|
||||
assertThrows(org.springframework.web.server.ResponseStatusException.class,()->adapter.lockAndInspect(List.of(b),prior.version()));
|
||||
assertThrows(org.springframework.web.server.ResponseStatusException.class,()->adapter.lockAndInspect(List.of(b),prior.version(),"operator"));
|
||||
verify(dispatches,never()).completion("D-A",true);
|
||||
}
|
||||
@Test void noCandidatesDoesNotGrantAnOperationOrReadOtherBusinessData() {
|
||||
clearInvocations(mapper,trips,dispatches);assertTrue(adapter.inspect(List.of()).members().isEmpty());
|
||||
assertThrows(org.springframework.web.server.ResponseStatusException.class,()->adapter.lockAndInspect(List.of(),"empty"));
|
||||
verifyNoInteractions(mapper,trips,dispatches);
|
||||
clearInvocations(mapper,amendments,dispatches);assertTrue(adapter.inspect(List.of()).members().isEmpty());
|
||||
assertThrows(org.springframework.web.server.ResponseStatusException.class,()->adapter.lockAndInspect(List.of(),"empty","operator"));
|
||||
verifyNoInteractions(mapper,amendments,dispatches);
|
||||
}
|
||||
/** 同车总发运量不能替代本客户的真实签收数量。 */
|
||||
@Test void onePhysicalDispatchUsesCustomerEvidenceInsteadOfTheWholeCarShipmentTotal() {
|
||||
var rows=List.of(new AppointmentWorkDispatchMapper.TransportRow(1,"D-ONE","A","ACTIVE","DELIVERING",2L,200,200,LocalDateTime.of(2026,10,1,12,0),11,101,100,"RECEIVED",batch,"ORDER:1","FBA:ONT8",100,"T-ONE",2L,"IN_TRANSIT",201L,"OUTBOUNDED","COMPLETED"),
|
||||
new AppointmentWorkDispatchMapper.TransportRow(1,"D-ONE","B","ACTIVE","DELIVERING",2L,200,200,LocalDateTime.of(2026,10,1,12,0),12,102,100,"RECEIVED",batch,"ORDER:2","FBA:ONT8",100,"T-ONE",2L,"IN_TRANSIT",202L,"OUTBOUNDED","COMPLETED"));
|
||||
when(mapper.transportRows(List.of(batch),List.of())).thenReturn(rows);
|
||||
when(dispatches.completion("D-ONE",false)).thenReturn(new FleetCompletionModels.DispatchCompletion("D-ONE","100",List.of(new FleetCompletionModels.Outcome("D-ONE","DL-A","A","ONT8","DELIVERED",100,at,100,0,0),new FleetCompletionModels.Outcome("D-ONE","DL-B","B","ONT8","DELIVERING",100,null,0,0,0)),null));
|
||||
when(dispatches.terminalEvidence("D-ONE",false)).thenReturn(new FleetDispatchGateway.TerminalEvidence("D-ONE","100",200,List.of(new FleetDispatchGateway.TerminalDelivery("A","DL-A","FBA:ONT8",100,at,"DELIVERED",1,List.of()),new FleetDispatchGateway.TerminalDelivery("B","DL-B","FBA:ONT8",100,null,"DELIVERING",1,List.of()))));
|
||||
var scope=adapter.inspect(List.of(a,b));assertTrue(scope.members().get(0).completed());assertFalse(scope.members().get(1).completed());assertFalse(scope.members().get(1).canRebook());
|
||||
when(dispatches.terminalEvidence("D-ONE",false)).thenReturn(new FleetDispatchGateway.TerminalEvidence("D-ONE","100",200,List.of(new FleetDispatchGateway.TerminalDelivery("A","DL-A","FBA:ONT8",50,at,"DELIVERED",1,List.of()),new FleetDispatchGateway.TerminalDelivery("B","DL-B","FBA:ONT8",150,at,"DELIVERED",1,List.of()))));
|
||||
assertFalse(adapter.inspect(List.of(a)).members().get(0).completed());
|
||||
}
|
||||
private AppointmentWorkDispatchMapper.TransportRow row(String customer,String status,String tripStatus) {
|
||||
long id="A".equals(customer)?1:2;boolean executed="DEPARTED".equals(status);
|
||||
return new AppointmentWorkDispatchMapper.TransportRow(id,"D-"+customer,customer,status,executed?100:0,executed?100:0,LocalDateTime.of(2026,10,1,12,0),id+10,id+100,100,"RECEIVED",batch,"ORDER:"+id,"FBA:ONT8",100,"T-"+customer,1L,tripStatus,id+200,executed?"OUTBOUNDED":"WAIT_OUTBOUND",executed?"COMPLETED":"PDA_PENDING");
|
||||
long id="A".equals(customer)?1:2;boolean executed="DELIVERING".equals(status);
|
||||
return new AppointmentWorkDispatchMapper.TransportRow(id,"D-"+customer,customer,"ACTIVE",status,0L,executed?100:0,executed?100:0,LocalDateTime.of(2026,10,1,12,0),id+10,id+100,100,"RECEIVED",batch,"ORDER:"+id,"FBA:ONT8",100,"T-"+customer,1L,tripStatus,id+200,executed?"OUTBOUNDED":"WAIT_OUTBOUND",executed?"COMPLETED":"PDA_PENDING");
|
||||
}
|
||||
private FleetDispatchGateway.TerminalEvidence proof(int packages,Instant terminal,long version,List<FleetDispatchGateway.ReturnedReceipt> returns) {
|
||||
return new FleetDispatchGateway.TerminalEvidence("D-A","A","100",packages,List.of(new FleetDispatchGateway.TerminalDelivery("A","DL-A","FBA:ONT8",packages,terminal,returns.isEmpty()?"DELIVERED":"RETURNED",version,returns)));
|
||||
return new FleetDispatchGateway.TerminalEvidence("D-A","100",packages,List.of(new FleetDispatchGateway.TerminalDelivery("A","DL-A","FBA:ONT8",packages,terminal,returns.isEmpty()?"DELIVERED":"RETURNED",version,returns)));
|
||||
}
|
||||
}
|
||||
|
||||
+56
-63
@@ -1,7 +1,7 @@
|
||||
package com.baiyue.lastleg.inboundplan.work;
|
||||
|
||||
import com.baiyue.lastleg.appointmentwork.AppointmentWorkChanges;
|
||||
import com.baiyue.lastleg.fleet.trip.*;
|
||||
import com.baiyue.lastleg.dispatch.port.*;
|
||||
import com.baiyue.lastleg.dispatch.port.DispatchContracts.*;
|
||||
import com.baiyue.lastleg.inboundplan.receiving.FbaAppointmentTransportPort;
|
||||
import com.baomidou.mybatisplus.core.MybatisConfiguration;
|
||||
import com.baomidou.mybatisplus.extension.spring.MybatisSqlSessionFactoryBean;
|
||||
@@ -17,87 +17,80 @@ import java.util.*;
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
/** Real mapper/transaction checks; original Fleet/Dispatch commands are represented by transactional SQL test doubles. */
|
||||
/** 真实Mapper和事务;主流程窄端口使用同事务SQL替身,不代替主流程集成验收。 */
|
||||
class FbaAppointmentTransportAdapterTest {
|
||||
private JdbcTemplate jdbc;
|
||||
private TransactionTemplate tx;
|
||||
private FbaAppointmentTransportPort adapter;
|
||||
private FleetTripService fleet;
|
||||
private FleetDispatchGateway dispatch;
|
||||
private DispatchAppointmentAmendmentPort amendments;
|
||||
/** 一车280箱、三个真实客户、唯一PDA;主单与PDA均无customer_id。 */
|
||||
@BeforeEach void setup() throws Exception {
|
||||
var ds=new DriverManagerDataSource("jdbc:h2:mem:transport-"+UUID.randomUUID()+";MODE=MySQL;DB_CLOSE_DELAY=-1","sa","");
|
||||
jdbc=new JdbcTemplate(ds);var manager=new DataSourceTransactionManager(ds);tx=new TransactionTemplate(manager);
|
||||
jdbc.execute("CREATE TABLE ll_fleet_trip(id BIGINT PRIMARY KEY,trip_no VARCHAR(40),record_version BIGINT,status VARCHAR(30))");
|
||||
jdbc.execute("CREATE TABLE ll_dispatch_order(id BIGINT PRIMARY KEY,dispatch_no VARCHAR(40),customer_id VARCHAR(40),status VARCHAR(30),package_count INT,picked_package_count INT,shipped_package_count INT,updated_at TIMESTAMP)");
|
||||
jdbc.execute("CREATE TABLE ll_dispatch_order(id BIGINT PRIMARY KEY,dispatch_no VARCHAR(40),status VARCHAR(30),stage VARCHAR(30),record_version BIGINT,package_count INT,picked_package_count INT,shipped_package_count INT,updated_at TIMESTAMP)");
|
||||
jdbc.execute("CREATE TABLE ll_dispatch_item(id BIGINT PRIMARY KEY,dispatch_order_id BIGINT,customer_id VARCHAR(40),plan_pallet_id BIGINT,source_stage VARCHAR(30),package_count INT)");
|
||||
jdbc.execute("CREATE TABLE ll_dispatch_appointment_allocation(dispatch_id BIGINT,dispatch_item_id BIGINT,plan_pallet_id BIGINT,batch_key VARCHAR(60),customer_id VARCHAR(40),source_key VARCHAR(40),destination_key VARCHAR(40),package_count INT)");
|
||||
jdbc.execute("CREATE TABLE ll_fleet_trip_dispatch(dispatch_id BIGINT PRIMARY KEY,trip_id BIGINT)");
|
||||
jdbc.execute("CREATE TABLE ll_outbound_order(id BIGINT,dispatch_order_id BIGINT,customer_id VARCHAR(40),status VARCHAR(30))");
|
||||
jdbc.execute("CREATE TABLE ll_dispatch_pda_task(id BIGINT,dispatch_order_id BIGINT,customer_id VARCHAR(40),status VARCHAR(30))");
|
||||
jdbc.execute("CREATE TABLE ll_dispatch_pda_task(id BIGINT,dispatch_order_id BIGINT,status VARCHAR(30))");
|
||||
jdbc.execute("CREATE TABLE ll_inbound_fba_appointment(id BIGINT,appointment_no VARCHAR(40),version INT)");
|
||||
jdbc.execute("CREATE TABLE adjustment_history(parent_key VARCHAR(64),dispatch_no VARCHAR(40),item_id BIGINT)");
|
||||
jdbc.update("INSERT INTO ll_fleet_trip VALUES(1,'T-1',1,'PLANNED')");
|
||||
seed(1,"A",80,List.of(80));seed(2,"B",160,List.of(100,60));seed(3,"C",40,List.of(40));
|
||||
var configuration=new MybatisConfiguration();configuration.setMapUnderscoreToCamelCase(true);configuration.addMapper(AppointmentWorkDispatchMapper.class);configuration.addMapper(FleetTripMapper.class);
|
||||
var factory=new MybatisSqlSessionFactoryBean();factory.setDataSource(ds);factory.setConfiguration(configuration);
|
||||
var session=new SqlSessionTemplate(factory.getObject());
|
||||
fleet=mock(FleetTripService.class);dispatch=mock(FleetDispatchGateway.class);var gateway=dispatch;
|
||||
when(gateway.lockForPlanning(anyString())).thenAnswer(c->{String number=c.getArgument(0);jdbc.queryForList("SELECT id FROM ll_dispatch_order WHERE dispatch_no=? FOR UPDATE",number);return null;});
|
||||
when(fleet.unbindDispatch(anyString(),anyString(),any())).thenAnswer(c->{var command=c.getArgument(2,FleetTripModels.BindingCommand.class);jdbc.update("DELETE FROM ll_fleet_trip_dispatch WHERE dispatch_id=(SELECT id FROM ll_dispatch_order WHERE dispatch_no=?)",command.dispatchNo());bump();return null;});
|
||||
when(fleet.bindDispatch(anyString(),anyString(),any())).thenAnswer(c->{var command=c.getArgument(2,FleetTripModels.BindingCommand.class);jdbc.update("INSERT INTO ll_fleet_trip_dispatch SELECT id,1 FROM ll_dispatch_order WHERE dispatch_no=?",command.dispatchNo());bump();return null;});
|
||||
when(fleet.detail("T-1")).thenAnswer(c->{
|
||||
var response=mock(FleetTripModels.Response.class);when(response.recordVersion()).thenReturn(jdbc.queryForObject("SELECT record_version FROM ll_fleet_trip WHERE id=1",Long.class));
|
||||
var bindings=jdbc.query("SELECT d.* FROM ll_dispatch_order d JOIN ll_fleet_trip_dispatch b ON b.dispatch_id=d.id ORDER BY d.id",(rs,n)->new FleetTripModels.DispatchBinding(rs.getString("dispatch_no"),rs.getString("customer_id"),rs.getInt("package_count"),"operator",null,null,List.of()));
|
||||
return new FleetTripModels.Detail(response,null,bindings,null,List.of(),null);
|
||||
});
|
||||
when(fleet.cancel(anyString(),anyString(),any())).thenAnswer(c->{jdbc.update("UPDATE ll_fleet_trip SET status='CANCELLED',record_version=record_version+1 WHERE id=1");return null;});
|
||||
doAnswer(c->{
|
||||
long item=c.getArgument(1);String number=c.getArgument(0);
|
||||
jdbc.update("DELETE FROM ll_dispatch_appointment_allocation WHERE dispatch_item_id=?",item);jdbc.update("DELETE FROM ll_dispatch_item WHERE id=?",item);
|
||||
jdbc.update("UPDATE ll_dispatch_order SET package_count=(SELECT SUM(package_count) FROM ll_dispatch_item WHERE dispatch_order_id=ll_dispatch_order.id) WHERE dispatch_no=?",number);return null;
|
||||
}).when(dispatch).removeItem(anyString(),anyLong());
|
||||
var target=new FbaAppointmentTransportAdapter(session.getMapper(AppointmentWorkDispatchMapper.class),session.getMapper(FleetTripMapper.class),gateway,fleet,mock(FleetBookingReservations.class),mock(AppointmentWorkChanges.class));
|
||||
var proxy=new ProxyFactory(target);proxy.setProxyTargetClass(true);proxy.addAdvice(new TransactionInterceptor(manager,new AnnotationTransactionAttributeSource()));adapter=(FbaAppointmentTransportPort)proxy.getProxy();
|
||||
jdbc.update("INSERT INTO ll_dispatch_order VALUES(1,'D-1','ACTIVE','BEFORE_LOADING',0,280,0,0,CURRENT_TIMESTAMP)");
|
||||
jdbc.update("INSERT INTO ll_fleet_trip_dispatch VALUES(1,1)");jdbc.update("INSERT INTO ll_dispatch_pda_task VALUES(1,1,'PDA_PENDING')");
|
||||
seed(11,"101",1,80);seed(21,"202",2,100);seed(22,"202",2,60);seed(31,"303",3,40);
|
||||
for(int id=1;id<=3;id++) jdbc.update("INSERT INTO ll_inbound_fba_appointment VALUES(?,?,0)",id,"AP-"+id);
|
||||
var config=new MybatisConfiguration();config.setMapUnderscoreToCamelCase(true);config.addMapper(AppointmentWorkDispatchMapper.class);
|
||||
var factory=new MybatisSqlSessionFactoryBean();factory.setDataSource(ds);factory.setConfiguration(config);
|
||||
var session=new SqlSessionTemplate(factory.getObject());amendments=mock(DispatchAppointmentAmendmentPort.class);
|
||||
doAnswer(c->{assertEquals("operator",c.getArgument(0,Actor.class).operatorId());for(var number:c.<List<String>>getArgument(1)) jdbc.queryForList("SELECT id FROM ll_dispatch_order WHERE dispatch_no=? FOR UPDATE",number);return null;}).when(amendments).lockExistingAppointmentDispatches(any(),anyList());
|
||||
when(amendments.applyExistingAppointmentChange(any(),anyString(),any(),anyList(),anyList(),anyList(),eq(true))).thenAnswer(this::applyOriginal);
|
||||
var proxy=new ProxyFactory(new FbaAppointmentTransportAdapter(session.getMapper(AppointmentWorkDispatchMapper.class),amendments));proxy.setProxyTargetClass(true);
|
||||
proxy.addAdvice(new TransactionInterceptor(manager,new AnnotationTransactionAttributeSource()));adapter=(FbaAppointmentTransportPort)proxy.getProxy();
|
||||
}
|
||||
private void seed(long id,String customer,int total,List<Integer> amounts) {
|
||||
jdbc.update("INSERT INTO ll_dispatch_order VALUES(?,?,?,'PENDING_DEPARTURE',?,0,0,CURRENT_TIMESTAMP)",id,"D-"+id,customer,total);
|
||||
jdbc.update("INSERT INTO ll_fleet_trip_dispatch VALUES(?,1)",id);
|
||||
for(int i=0;i<amounts.size();i++) {
|
||||
long item=id*10+i;
|
||||
jdbc.update("INSERT INTO ll_dispatch_item VALUES(?,?,?,?, 'RECEIVED',?)",item,id,customer,item+100,amounts.get(i));
|
||||
jdbc.update("INSERT INTO ll_dispatch_appointment_allocation VALUES(?,?,?,?,?,?,?,?)",id,item,item+100,"FBA_APPOINTMENT:"+id,customer,"ORDER:"+id,"FBA:ONT8",amounts.get(i));
|
||||
/** 只替身主端口;复核真实item、原分配、父键和版本后在当前事务写入。 */
|
||||
private CommandResult applyOriginal(org.mockito.invocation.InvocationOnMock call) {
|
||||
var command=call.getArgument(2,DispatchCommands.ReasonCommand.class);assertEquals("parent-command",command.idempotencyKey());assertEquals(0L,command.recordVersion());
|
||||
List<DispatchAppointmentAmendmentPort.PhysicalItem> touched=call.getArgument(3);List<BatchAllocation> expected=call.getArgument(4);List<BatchAllocation> next=call.getArgument(5);
|
||||
assertTrue(next.isEmpty());assertEquals(touched.size(),expected.size());
|
||||
for(var item:touched) {
|
||||
assertEquals("202",item.customerId());assertTrue(expected.stream().anyMatch(a->a.planPalletId().equals(item.planPalletId()) && a.customerId().equals(item.customerId()) && a.packageCount()==item.packages() && a.recordVersion()==0));
|
||||
jdbc.update("DELETE FROM ll_dispatch_appointment_allocation WHERE dispatch_id=1 AND dispatch_item_id=?",item.itemId());jdbc.update("DELETE FROM ll_dispatch_item WHERE id=? AND dispatch_order_id=1",item.itemId());
|
||||
jdbc.update("INSERT INTO adjustment_history VALUES(?,?,?)",command.idempotencyKey(),"D-1",item.itemId());
|
||||
}
|
||||
jdbc.update("UPDATE ll_dispatch_order SET package_count=(SELECT SUM(package_count) FROM ll_dispatch_item WHERE dispatch_order_id=1),record_version=record_version+1 WHERE id=1");return null;
|
||||
}
|
||||
private void bump() { jdbc.update("UPDATE ll_fleet_trip SET record_version=record_version+1 WHERE id=1"); }
|
||||
/** 插入逐客户真实实物;批次与整车归属独立。 */
|
||||
private void seed(long item,String customer,long batch,int count) {
|
||||
jdbc.update("INSERT INTO ll_dispatch_item VALUES(?,1,?,?, 'RECEIVED',?)",item,customer,item+100,count);
|
||||
jdbc.update("INSERT INTO ll_dispatch_appointment_allocation VALUES(1,?,?,?,?,?,?,?)",item,item+100,"FBA_APPOINTMENT:"+batch,customer,"ORDER:"+batch,"FBA:ONT8",count);
|
||||
}
|
||||
/** 沿原父预约命令执行一次完整实物调整。 */
|
||||
private FbaAppointmentTransportPort.Result move(int retained) {
|
||||
var scope=adapter.inspect(List.of("FBA_APPOINTMENT:2"));
|
||||
return tx.execute(status->{var locked=adapter.lockAndInspect(List.of("FBA_APPOINTMENT:2"),scope.version());return adapter.apply(locked,List.of(new FbaAppointmentTransportPort.Move("FBA_APPOINTMENT:2","B","ORDER:2","FBA:ONT8",160,retained,"FBA_APPOINTMENT:4")),"operator","客户明确调整");});
|
||||
var scope=adapter.inspect(List.of("FBA_APPOINTMENT:2"));return tx.execute(status->{var locked=adapter.lockAndInspect(List.of("FBA_APPOINTMENT:2"),scope.version(),"operator");return adapter.apply(locked,List.of(new FbaAppointmentTransportPort.Move("FBA_APPOINTMENT:2","202","ORDER:2","FBA:ONT8",160,retained,"FBA_APPOINTMENT:4")),"operator","客户明确调整","parent-command");});
|
||||
}
|
||||
private int remaining(FbaAppointmentTransportPort.Result result) { return result.vehicles().stream().filter(v->"T-1".equals(v.tripNo())).mapToInt(FbaAppointmentTransportPort.Vehicle::packageCount).sum(); }
|
||||
@Test void wholeCustomerExitLeavesOtherCustomers120AndReusesOriginalPhysicalDispatch() {
|
||||
var other=jdbc.queryForList("SELECT * FROM ll_dispatch_appointment_allocation WHERE customer_id<>'B'");
|
||||
var result=move(0);assertEquals(120,remaining(result));
|
||||
assertEquals(other,jdbc.queryForList("SELECT * FROM ll_dispatch_appointment_allocation WHERE customer_id<>'B'"));
|
||||
assertEquals(160,jdbc.queryForObject("SELECT SUM(package_count) FROM ll_dispatch_appointment_allocation WHERE batch_key='FBA_APPOINTMENT:4'",Integer.class));
|
||||
assertEquals(2,jdbc.queryForObject("SELECT COUNT(*) FROM ll_dispatch_item WHERE dispatch_order_id=2",Integer.class));verify(dispatch,never()).removeItem(anyString(),anyLong());
|
||||
@Test void wholeCustomerExitLeavesOtherCustomers120OnTheSamePhysicalDispatch() {
|
||||
var other=jdbc.queryForList("SELECT * FROM ll_dispatch_appointment_allocation WHERE customer_id<>'202' ORDER BY dispatch_item_id");var result=move(0);
|
||||
assertEquals(120,result.vehicles().stream().mapToInt(FbaAppointmentTransportPort.Vehicle::packageCount).sum());assertEquals(other,jdbc.queryForList("SELECT * FROM ll_dispatch_appointment_allocation WHERE customer_id<>'202' ORDER BY dispatch_item_id"));
|
||||
assertEquals(120,jdbc.queryForObject("SELECT package_count FROM ll_dispatch_order WHERE id=1",Integer.class));assertEquals(1,jdbc.queryForObject("SELECT COUNT(*) FROM ll_fleet_trip_dispatch WHERE dispatch_id=1",Integer.class));
|
||||
assertEquals(1,jdbc.queryForObject("SELECT COUNT(*) FROM ll_dispatch_pda_task WHERE dispatch_order_id=1",Integer.class));assertEquals(0,jdbc.queryForObject("SELECT COUNT(*) FROM ll_dispatch_appointment_allocation WHERE batch_key='FBA_APPOINTMENT:4'",Integer.class));assertEquals(1L,jdbc.queryForObject("SELECT record_version FROM ll_fleet_trip WHERE id=1",Long.class));
|
||||
}
|
||||
@Test void wholePhysical60MovesOutLeavingOriginalVehicle220AndLaterScopeHasNoSyntheticVehicle() {
|
||||
var result=move(100);assertEquals(220,remaining(result));verify(dispatch).removeItem("D-2",21L);
|
||||
assertEquals(100,jdbc.queryForObject("SELECT package_count FROM ll_dispatch_order WHERE id=2",Integer.class));
|
||||
var later=adapter.inspect(List.of("FBA_APPOINTMENT:4"));assertTrue(later.vehicles().isEmpty());
|
||||
var next=tx.execute(status->{var locked=adapter.lockAndInspect(List.of("FBA_APPOINTMENT:4"),later.version());return adapter.apply(locked,List.of(new FbaAppointmentTransportPort.Move("FBA_APPOINTMENT:4","B","ORDER:2","FBA:ONT8",60,40,"FBA_APPOINTMENT:5")),"operator","后送60分40和20");});
|
||||
assertTrue(next.vehicles().isEmpty());assertEquals(3,jdbc.queryForObject("SELECT COUNT(*) FROM ll_dispatch_item",Integer.class));
|
||||
@Test void wholePhysical60MovesOutLeavingOriginalVehicle220AndNoSyntheticLaterVehicle() {
|
||||
var result=move(100);assertEquals(220,result.vehicles().stream().mapToInt(FbaAppointmentTransportPort.Vehicle::packageCount).sum());assertEquals(220,jdbc.queryForObject("SELECT package_count FROM ll_dispatch_order WHERE id=1",Integer.class));
|
||||
assertEquals(List.of(11L,21L,31L),jdbc.queryForList("SELECT id FROM ll_dispatch_item ORDER BY id",Long.class));assertTrue(adapter.inspect(List.of("FBA_APPOINTMENT:4")).vehicles().isEmpty());
|
||||
}
|
||||
@Test void unmatchedPartialQuantityAndStaleScopeNeverMutateOriginalPhysicalRows() {
|
||||
assertThrows(org.springframework.web.server.ResponseStatusException.class,()->move(110));
|
||||
assertEquals(4,jdbc.queryForObject("SELECT COUNT(*) FROM ll_dispatch_item",Integer.class));verify(dispatch,never()).removeItem(anyString(),anyLong());
|
||||
var old=adapter.inspect(List.of("FBA_APPOINTMENT:2"));bump();
|
||||
assertThrows(org.springframework.web.server.ResponseStatusException.class,()->tx.execute(status->adapter.lockAndInspect(List.of("FBA_APPOINTMENT:2"),old.version())));
|
||||
@Test void sameCarSameBatchHasIndependentCustomerVehiclesAndWholeCarVersionInvalidatesScope() {
|
||||
jdbc.update("UPDATE ll_dispatch_appointment_allocation SET batch_key='FBA_APPOINTMENT:1'");var scope=adapter.inspect(List.of("FBA_APPOINTMENT:1"));
|
||||
assertEquals(Set.of("101","202","303"),scope.vehicles().stream().map(FbaAppointmentTransportPort.Vehicle::customerId).collect(java.util.stream.Collectors.toSet()));assertEquals(280,scope.vehicles().stream().mapToInt(FbaAppointmentTransportPort.Vehicle::packageCount).sum());
|
||||
jdbc.update("UPDATE ll_dispatch_order SET record_version=record_version+1 WHERE id=1");assertThrows(org.springframework.web.server.ResponseStatusException.class,()->tx.execute(status->adapter.lockAndInspect(List.of("FBA_APPOINTMENT:1"),scope.version(),"operator")));
|
||||
}
|
||||
@Test void downstreamFailureRollsBackUnbindingPhysicalRemovalAndQuantityTogether() {
|
||||
doThrow(new IllegalStateException("原车绑定失败")).when(fleet).bindDispatch(anyString(),anyString(),any());
|
||||
assertThrows(IllegalStateException.class,()->move(100));
|
||||
assertEquals(4,jdbc.queryForObject("SELECT COUNT(*) FROM ll_dispatch_item",Integer.class));
|
||||
assertEquals(160,jdbc.queryForObject("SELECT package_count FROM ll_dispatch_order WHERE id=2",Integer.class));
|
||||
assertEquals(3,jdbc.queryForObject("SELECT COUNT(*) FROM ll_fleet_trip_dispatch",Integer.class));
|
||||
assertEquals(1L,jdbc.queryForObject("SELECT record_version FROM ll_fleet_trip WHERE id=1",Long.class));
|
||||
@Test void unmatchedPartialQuantityNeverMutatesOriginalPhysicalRows() {
|
||||
assertThrows(org.springframework.web.server.ResponseStatusException.class,()->move(110));assertEquals(4,jdbc.queryForObject("SELECT COUNT(*) FROM ll_dispatch_item",Integer.class));verify(amendments,never()).applyExistingAppointmentChange(any(),anyString(),any(),anyList(),anyList(),anyList(),anyBoolean());
|
||||
}
|
||||
@Test void downstreamFailureRollsBackCoreQuantityPhysicalAllocationAndHistoryTogether() {
|
||||
when(amendments.applyExistingAppointmentChange(any(),anyString(),any(),anyList(),anyList(),anyList(),eq(true))).thenAnswer(c->{applyOriginal(c);throw new IllegalStateException("原主流程末尾失败");});var scope=adapter.inspect(List.of("FBA_APPOINTMENT:2"));
|
||||
assertThrows(IllegalStateException.class,()->tx.execute(status->{var locked=adapter.lockAndInspect(List.of("FBA_APPOINTMENT:2"),scope.version(),"operator");jdbc.update("UPDATE ll_inbound_fba_appointment SET appointment_no='CHANGED' WHERE id=2");return adapter.apply(locked,List.of(new FbaAppointmentTransportPort.Move("FBA_APPOINTMENT:2","202","ORDER:2","FBA:ONT8",160,100,"FBA_APPOINTMENT:4")),"operator","客户明确调整","parent-command");}));
|
||||
assertEquals(4,jdbc.queryForObject("SELECT COUNT(*) FROM ll_dispatch_item",Integer.class));assertEquals(280,jdbc.queryForObject("SELECT package_count FROM ll_dispatch_order WHERE id=1",Integer.class));assertEquals(0,jdbc.queryForObject("SELECT COUNT(*) FROM adjustment_history",Integer.class));assertEquals("AP-2",jdbc.queryForObject("SELECT appointment_no FROM ll_inbound_fba_appointment WHERE id=2",String.class));
|
||||
}
|
||||
}
|
||||
|
||||
+25
-5
@@ -13,11 +13,28 @@ import static org.assertj.core.api.Assertions.*;
|
||||
class CustomerOrderProgressMapperTest {
|
||||
private JdbcTemplate jdbc;
|
||||
private CustomerOrderProgressMapper mapper;
|
||||
/** 两客户共用一单一任务,计划和在途均按各自真实明细归属统计。 */
|
||||
@Test void sharedDispatchAndTaskKeepBothCustomerProgressSeparate() {
|
||||
jdbc.update("INSERT INTO ll_dispatch_item(id,dispatch_order_id,customer_id,plan_pallet_id,overseas_warehouse_id,destination_warehouse_code) VALUES(101,10,'22',501,'W1','FBA2')");
|
||||
jdbc.update("INSERT INTO ll_inbound_plan_pallet_allocation VALUES(501,1002,30)");
|
||||
assertThat(mapper.sources("11",null)).extracting(s->s.scheduledPackages()).containsExactly(10L,20L);
|
||||
assertThat(mapper.sources("22",null)).singleElement().satisfies(s->assertThat(s.scheduledPackages()).isEqualTo(30));
|
||||
jdbc.update("INSERT INTO ll_dispatch_pda_task VALUES(700,10)");
|
||||
jdbc.update("INSERT INTO ll_dispatch_pda_pick VALUES(600,'11',100,400,4,700),(601,'22',101,401,7,700)");
|
||||
jdbc.update("INSERT INTO ll_dispatch_pda_pick_order VALUES(900,600,'11',1,4),(901,601,'22',3,7)");
|
||||
jdbc.update("INSERT INTO ll_outbound_order VALUES(10000,'11','OUTBOUNDED'),(10002,'22','OUTBOUNDED')");
|
||||
jdbc.update("INSERT INTO ll_delivery_order VALUES(10001,'11',10,10000,'DELIVERING','CODE:FBA1'),(10003,'22',10,10002,'DELIVERING','CODE:FBA2')");
|
||||
jdbc.update("UPDATE ll_dispatch_order SET stage='DELIVERING',departed_at=CURRENT_TIMESTAMP");
|
||||
assertThat(mapper.sources("11",null)).extracting(s->s.deliveringPackages()).containsExactly(4L,0L);
|
||||
assertThat(mapper.sources("22",null)).singleElement().satisfies(s->assertThat(s.deliveringPackages()).isEqualTo(7));
|
||||
assertThat(mapper.sources("UNRELATED",null)).isEmpty();
|
||||
}
|
||||
/** 使用无客户头的真实新模型,按明细验证查询归属。 */
|
||||
@BeforeEach void setup() throws Exception {
|
||||
var ds=new JdbcDataSource();ds.setURL("jdbc:h2:mem:tail-progress-"+UUID.randomUUID()+";MODE=MySQL;DB_CLOSE_DELAY=-1");
|
||||
jdbc=new JdbcTemplate(ds);
|
||||
jdbc.execute("CREATE TABLE ll_order(id BIGINT,customer_id VARCHAR,source_order_id VARCHAR,source_inbound_id VARCHAR,source_region VARCHAR,order_type VARCHAR,package_count INT)");
|
||||
jdbc.execute("CREATE TABLE ll_dispatch_order(id BIGINT,customer_id VARCHAR,status VARCHAR,depart_at TIMESTAMP)");
|
||||
jdbc.execute("CREATE TABLE ll_dispatch_order(id BIGINT,status VARCHAR,stage VARCHAR,departed_at TIMESTAMP)");
|
||||
jdbc.execute("CREATE TABLE ll_dispatch_item(id BIGINT,dispatch_order_id BIGINT,customer_id VARCHAR,plan_pallet_id BIGINT,warehouse_pallet_id BIGINT,overseas_warehouse_id VARCHAR,destination_type VARCHAR,delivery_address VARCHAR,postal_code VARCHAR,destination_warehouse_code VARCHAR,fba_warehouse_code VARCHAR,destination_warehouse_id VARCHAR)");
|
||||
jdbc.execute("CREATE TABLE ll_inbound_plan_pallet_allocation(pallet_id BIGINT,line_id BIGINT,planned_package_count INT)");
|
||||
jdbc.execute("CREATE TABLE ll_inbound_plan_line(id BIGINT,customer_id VARCHAR,order_id BIGINT)");
|
||||
@@ -25,11 +42,11 @@ class CustomerOrderProgressMapperTest {
|
||||
jdbc.execute("CREATE TABLE ll_inventory_balance(id BIGINT,customer_id VARCHAR,inbound_order_id BIGINT)");
|
||||
jdbc.execute("CREATE TABLE ll_dispatch_pda_pick(id BIGINT,customer_id VARCHAR,dispatch_item_id BIGINT,inventory_position_id BIGINT,packages INT,task_id BIGINT)");
|
||||
jdbc.execute("CREATE TABLE ll_dispatch_pda_pick_order(id BIGINT,pick_id BIGINT,customer_id VARCHAR,order_id BIGINT,packages INT)");
|
||||
jdbc.execute("CREATE TABLE ll_dispatch_pda_task(id BIGINT,customer_id VARCHAR,dispatch_order_id BIGINT)");
|
||||
jdbc.execute("CREATE TABLE ll_dispatch_pda_task(id BIGINT,dispatch_order_id BIGINT UNIQUE)");
|
||||
jdbc.execute("CREATE TABLE ll_delivery_order(id BIGINT,customer_id VARCHAR,dispatch_order_id BIGINT,outbound_order_id BIGINT,status VARCHAR,delivery_group_key VARCHAR)");
|
||||
jdbc.execute("CREATE TABLE ll_outbound_order(id BIGINT,customer_id VARCHAR,status VARCHAR)");
|
||||
jdbc.update("INSERT INTO ll_order VALUES(1,'11','101','201','FIRSTLEG','FBA',10),(2,'11','102','202','FIRSTLEG','FBA',20),(3,'22','103','203','FIRSTLEG','FBA',30)");
|
||||
jdbc.update("INSERT INTO ll_dispatch_order VALUES(10,'11','PENDING_DEPARTURE',NULL)");
|
||||
jdbc.update("INSERT INTO ll_dispatch_order VALUES(10,'ACTIVE','BEFORE_LOADING',NULL)");
|
||||
jdbc.update("INSERT INTO ll_dispatch_item(id,dispatch_order_id,customer_id,plan_pallet_id,overseas_warehouse_id,destination_warehouse_code) VALUES(100,10,'11',500,'W1','FBA1')");
|
||||
jdbc.update("INSERT INTO ll_inbound_plan_line VALUES(1000,'11',1),(1001,'11',2),(1002,'22',3)");
|
||||
jdbc.update("INSERT INTO ll_inbound_plan_pallet_allocation VALUES(500,1000,10),(500,1001,20),(500,1002,30)");
|
||||
@@ -37,6 +54,7 @@ class CustomerOrderProgressMapperTest {
|
||||
var factory=new MybatisSqlSessionFactoryBean();factory.setDataSource(ds);factory.setConfiguration(config);
|
||||
mapper=new SqlSessionTemplate(factory.getObject()).getMapper(CustomerOrderProgressMapper.class);
|
||||
}
|
||||
/** 计划托盘上的所有本客户来源均计入,取消后不再计入。 */
|
||||
@Test void plannedSharedPalletIncludesEveryOwnSourceAndExcludesCancellation() {
|
||||
var sources=mapper.sources("11", null);assertThat(sources).hasSize(2);
|
||||
assertThat(sources.get(0).scheduledPackages()).isEqualTo(10);
|
||||
@@ -47,6 +65,7 @@ class CustomerOrderProgressMapperTest {
|
||||
assertThat(mapper.sources("11", null)).allMatch(s->s.scheduledPackages()==0);
|
||||
assertThat(mapper.sources("22", null)).hasSize(1).allMatch(s->s.scheduledPackages()==0);
|
||||
}
|
||||
/** 已收货使用实际库存和扫码,不与计划量重复累计。 */
|
||||
@Test void receivedPalletUsesInventoryAndActualPicksWithoutCountingBothPlanAndStock() {
|
||||
jdbc.update("UPDATE ll_dispatch_item SET warehouse_pallet_id=800");
|
||||
jdbc.update("INSERT INTO ll_inventory_balance VALUES(300,'11',1),(301,'11',2)");
|
||||
@@ -56,14 +75,15 @@ class CustomerOrderProgressMapperTest {
|
||||
assertThat(sources.get(1).scheduledPackages()).isEqualTo(20);
|
||||
assertThat(mapper.sources("11",java.util.List.of("101"))).containsExactly(sources.get(0));
|
||||
}
|
||||
/** 实际发车、出库及目的地须同时成立才计入在途。 */
|
||||
@Test void deliveryRequiresActualDepartureOutboundAndMatchingDestinationAttribution() {
|
||||
jdbc.update("INSERT INTO ll_dispatch_pda_task VALUES(700,'11',10)");
|
||||
jdbc.update("INSERT INTO ll_dispatch_pda_task VALUES(700,10)");
|
||||
jdbc.update("INSERT INTO ll_dispatch_pda_pick VALUES(600,'11',100,400,4,700)");
|
||||
jdbc.update("INSERT INTO ll_dispatch_pda_pick_order VALUES(900,600,'11',1,4)");
|
||||
jdbc.update("INSERT INTO ll_outbound_order VALUES(10000,'11','WAIT_OUTBOUND')");
|
||||
jdbc.update("INSERT INTO ll_delivery_order VALUES(10001,'11',10,10000,'DELIVERING','CODE:FBA1')");
|
||||
assertThat(mapper.sources("11", null)).allMatch(s->s.deliveringPackages()==0);
|
||||
jdbc.update("UPDATE ll_dispatch_order SET status='DEPARTED',depart_at=CURRENT_TIMESTAMP");
|
||||
jdbc.update("UPDATE ll_dispatch_order SET stage='DELIVERING',departed_at=CURRENT_TIMESTAMP");
|
||||
assertThat(mapper.sources("11", null)).allMatch(s->s.deliveringPackages()==0);
|
||||
jdbc.update("UPDATE ll_outbound_order SET status='OUTBOUNDED'");
|
||||
assertThat(mapper.sources("11", null).get(0).deliveringPackages()).isEqualTo(4);
|
||||
|
||||
+87
@@ -0,0 +1,87 @@
|
||||
package com.baiyue.lastleg.mapper;
|
||||
|
||||
import com.baiyue.lastleg.exceptionorder.mapper.ReturnCartonStockReceiptMapper;
|
||||
import com.baiyue.lastleg.inventory.mapper.ReturnStockReceiptMapper;
|
||||
import java.util.Arrays;
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
import org.apache.ibatis.annotations.Select;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
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 static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
/** 无客户头的一单一任务执行生产查询,验证退回证明仍逐客户隔离。 */
|
||||
class SharedDispatchReturnQueryTest {
|
||||
private JdbcTemplate db;
|
||||
private NamedParameterJdbcTemplate named;
|
||||
|
||||
/** 两个客户共用唯一排车和PDA任务,实物、派送及逐票归属分别保存。 */
|
||||
@BeforeEach void setup() {
|
||||
var source=new DriverManagerDataSource("jdbc:h2:mem:"+UUID.randomUUID()+";MODE=MySQL;DB_CLOSE_DELAY=-1","sa","");
|
||||
db=new JdbcTemplate(source);named=new NamedParameterJdbcTemplate(source);
|
||||
db.execute("CREATE TABLE ll_dispatch_order(id BIGINT PRIMARY KEY,dispatch_no VARCHAR(30),status VARCHAR(20))");
|
||||
db.execute("CREATE TABLE ll_dispatch_pda_task(id BIGINT PRIMARY KEY,dispatch_order_id BIGINT UNIQUE)");
|
||||
db.execute("CREATE TABLE ll_dispatch_item(id BIGINT,customer_id VARCHAR(20),dispatch_order_id BIGINT,return_receipt_id BIGINT,package_count INT,warehouse_pallet_id BIGINT,inbound_code VARCHAR(30),source_stage VARCHAR(30),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_delivery_order(id BIGINT,customer_id VARCHAR(20),dispatch_order_id BIGINT,outbound_order_id BIGINT,delivery_group_key VARCHAR(100),delivery_no VARCHAR(30),status VARCHAR(30))");
|
||||
db.execute("CREATE TABLE ll_outbound_order(id BIGINT,customer_id VARCHAR(20),dispatch_order_id BIGINT,outbound_no VARCHAR(30),status VARCHAR(30))");
|
||||
db.execute("CREATE TABLE ll_outbound_pallet_item(id BIGINT,customer_id VARCHAR(20),outbound_order_id BIGINT,warehouse_pallet_id BIGINT,inbound_code VARCHAR(30),destination_warehouse_code VARCHAR(30))");
|
||||
db.execute("CREATE TABLE ll_exception_order(id BIGINT,customer_id VARCHAR(20),delivery_order_id BIGINT,outbound_no VARCHAR(30),last_mile_order_id BIGINT,status VARCHAR(30),return_direction VARCHAR(30))");
|
||||
db.execute("CREATE TABLE ll_exception_return_item(id BIGINT,customer_id VARCHAR(20),exception_order_id BIGINT,outbound_pallet_item_id BIGINT,return_carton_id BIGINT,inventory_position_id BIGINT,approved_return_packages BIGINT,received_packages BIGINT)");
|
||||
db.execute("CREATE TABLE ll_dispatch_pda_pick(id BIGINT,customer_id VARCHAR(20),task_id BIGINT,dispatch_item_id BIGINT,return_carton_id BIGINT,inventory_position_id BIGINT,packages BIGINT,product_quantity BIGINT)");
|
||||
db.execute("CREATE TABLE ll_dispatch_pda_pick_order(id BIGINT,customer_id VARCHAR(20),pick_id BIGINT,order_id BIGINT,packages BIGINT,return_receipt_id BIGINT)");
|
||||
db.execute("CREATE TABLE ll_return_stock_receipt(id BIGINT,customer_id VARCHAR(20),receipt_no VARCHAR(30),received_packages BIGINT,received_at TIMESTAMP,exception_order_id BIGINT)");
|
||||
db.update("INSERT INTO ll_dispatch_order VALUES(1,'DP-1','ACTIVE')");
|
||||
db.update("INSERT INTO ll_dispatch_pda_task VALUES(201,1)");
|
||||
db.update("INSERT INTO ll_dispatch_item VALUES(10,'C',1,91,1,101,'RL-1','RETURN_RELABEL','FBA',NULL,NULL,'ONT8',NULL,NULL),(11,'OTHER',1,92,1,102,'RL-2','RETURN_RELABEL','FBA',NULL,NULL,'ONT8',NULL,NULL)");
|
||||
db.update("INSERT INTO ll_outbound_order VALUES(31,'C',1,'OUT-1','OUTBOUNDED'),(32,'OTHER',1,'OUT-2','OUTBOUNDED')");
|
||||
db.update("INSERT INTO ll_outbound_pallet_item VALUES(51,'C',31,101,'RL-1','ONT8'),(52,'OTHER',32,102,'RL-2','ONT8')");
|
||||
db.update("INSERT INTO ll_delivery_order VALUES(21,'C',1,31,'CODE:ONT8','DL-1','DELIVERING'),(22,'OTHER',1,32,'CODE:ONT8','DL-2','DELIVERING')");
|
||||
db.update("INSERT INTO ll_exception_order VALUES(1,'C',21,'OUT-1',101,'PROCESSING','OVERSEAS_WAREHOUSE'),(2,'OTHER',22,'OUT-2',102,'PROCESSING','OVERSEAS_WAREHOUSE')");
|
||||
db.update("INSERT INTO ll_exception_return_item VALUES(41,'C',1,51,701,NULL,1,0),(42,'OTHER',2,52,702,NULL,1,0)");
|
||||
db.update("INSERT INTO ll_dispatch_pda_pick VALUES(501,'C',201,10,701,NULL,1,8),(502,'OTHER',201,11,702,NULL,1,9)");
|
||||
db.update("INSERT INTO ll_dispatch_pda_pick_order VALUES(601,'C',501,101,1,91),(602,'OTHER',502,102,1,92)");
|
||||
db.update("INSERT INTO ll_return_stock_receipt VALUES(91,'C','RSR-1',1,CURRENT_TIMESTAMP,1),(92,'OTHER','RSR-2',1,CURRENT_TIMESTAMP,2)");
|
||||
}
|
||||
|
||||
/** 同车同任务的本客户原箱证明成立,其他客户不能借用其异常或箱。 */
|
||||
@Test void cartonShipmentFollowsRealCustomerItemPickAndOrder() {
|
||||
var sql=sql(ReturnCartonStockReceiptMapper.class,"shipment");
|
||||
assertThat(named.queryForList(sql,Map.of("customerId","C","exceptionId",1L,"itemId",51L,"cartonId",701L)))
|
||||
.singleElement().satisfies(row->assertThat(((Number)row.get("dispatch_item_id")).longValue()).isEqualTo(10));
|
||||
assertThat(named.queryForList(sql,Map.of("customerId","OTHER","exceptionId",2L,"itemId",52L,"cartonId",702L)))
|
||||
.singleElement().satisfies(row->assertThat(((Number)row.get("dispatch_item_id")).longValue()).isEqualTo(11));
|
||||
assertThat(named.queryForList(sql,Map.of("customerId","OTHER","exceptionId",1L,"itemId",51L,"cartonId",701L))).isEmpty();
|
||||
assertThat(named.queryForList(sql,Map.of("customerId","C","exceptionId",1L,"itemId",52L,"cartonId",702L))).isEmpty();
|
||||
}
|
||||
|
||||
/** 相同卸货组也只能关联本客户派送,错误客户归属不能增加他人的扫码量。 */
|
||||
@Test void redispatchHistoryKeepsBothMembersAndTheirDeliverySeparate() {
|
||||
db.update("INSERT INTO ll_dispatch_pda_pick_order VALUES(603,'C',501,101,99,92)");
|
||||
var sql=sql(ReturnStockReceiptMapper.class,"history");
|
||||
var own=named.queryForList(sql,Map.of("customerId","C","exceptionId",1L));
|
||||
var sibling=named.queryForList(sql,Map.of("customerId","OTHER","exceptionId",2L));
|
||||
assertThat(own).singleElement().satisfies(row->{
|
||||
assertThat(row.get("dispatchNo")).isEqualTo("DP-1");assertThat(row.get("deliveryNo")).isEqualTo("DL-1");
|
||||
assertThat(((Number)row.get("pickedPackages")).longValue()).isEqualTo(1);
|
||||
});
|
||||
assertThat(sibling).singleElement().satisfies(row->{
|
||||
assertThat(row.get("dispatchNo")).isEqualTo("DP-1");assertThat(row.get("deliveryNo")).isEqualTo("DL-2");
|
||||
assertThat(((Number)row.get("pickedPackages")).longValue()).isEqualTo(1);
|
||||
});
|
||||
assertThat(named.queryForList(sql,Map.of("customerId","OTHER","exceptionId",1L))).isEmpty();
|
||||
assertThat(named.queryForList(sql,Map.of("customerId","c","exceptionId",1L))).isEmpty();
|
||||
assertThat(named.queryForList(sql,Map.of("customerId","C ","exceptionId",1L))).isEmpty();
|
||||
}
|
||||
|
||||
/** H2用等价可变长二进制转换执行MySQL的BINARY比较,保留大小写和尾空格隔离。 */
|
||||
private String sql(Class<?> type,String name) {
|
||||
var method=Arrays.stream(type.getMethods()).filter(value->value.getName().equals(name)).findFirst().orElseThrow();
|
||||
var query=String.join(" ",method.getAnnotation(Select.class).value()).replaceAll("#\\{(\\w+)\\}",":$1");
|
||||
// 仅替换方言表达式,客户谓词、关联及状态条件均直接来自生产SQL;MySQL执行仍待环境验收。
|
||||
return query.replaceAll("(?s)\\bBINARY\\s+(CASE\\b.*?\\bEND)","CAST($1 AS VARBINARY)")
|
||||
.replaceAll("\\bBINARY\\s+([A-Za-z_][A-Za-z0-9_.]*|:\\w+)","CAST($1 AS VARBINARY)");
|
||||
}
|
||||
}
|
||||
+9
-6
@@ -4,7 +4,7 @@ import com.baiyue.lastleg.palletops.dto.PalletOperationRequests;
|
||||
import com.baiyue.lastleg.pda.move.dto.PdaInventoryMoveRequest;
|
||||
import com.baiyue.lastleg.pda.stocktake.dto.PdaStocktakeRequests;
|
||||
import com.baiyue.lastleg.relabel.pda.dto.PdaRelabelRequests;
|
||||
import com.baiyue.lastleg.dispatch.pda.dto.DispatchPdaRequests;
|
||||
import com.baiyue.lastleg.dispatch.port.DispatchCommands;
|
||||
import com.baiyue.lastleg.delivery.dto.DeliveryRequests;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
@@ -34,9 +34,9 @@ class PdaOperationsOpenApiContractTest {
|
||||
}
|
||||
}
|
||||
assertNoServerOwnedFields(PdaInventoryMoveRequest.class);
|
||||
assertNoServerOwnedFields(DispatchPdaRequests.Pick.class);
|
||||
assertNoServerOwnedFields(DispatchPdaRequests.CompletePicking.class);
|
||||
assertNoServerOwnedFields(DispatchPdaRequests.Depart.class);
|
||||
assertNoServerOwnedFields(DispatchCommands.Pick.class);
|
||||
assertNoServerOwnedFields(DispatchCommands.LoadingComplete.class);
|
||||
assertNoServerOwnedFields(DispatchCommands.Depart.class);
|
||||
assertNoServerOwnedFields(DeliveryRequests.PdaPick.class);
|
||||
assertNoServerOwnedFields(DeliveryRequests.PdaPalletPick.class);
|
||||
assertNoServerOwnedFields(DeliveryRequests.PdaComplete.class);
|
||||
@@ -72,7 +72,9 @@ class PdaOperationsOpenApiContractTest {
|
||||
"/api/pda/v1/last-leg/dispatches:",
|
||||
"/api/pda/v1/last-leg/dispatches/{dispatchNo}:",
|
||||
"/api/pda/v1/last-leg/dispatches/{dispatchNo}/items/{itemId}/pick:",
|
||||
"/api/pda/v1/last-leg/dispatches/{dispatchNo}/complete:",
|
||||
"/api/pda/v1/last-leg/dispatches/{dispatchNo}/loading:",
|
||||
"/api/pda/v1/last-leg/dispatches/{dispatchNo}/loading-complete:",
|
||||
"/api/pda/v1/last-leg/dispatches/{dispatchNo}/commands/{idempotencyKey}:",
|
||||
"/api/pda/v1/last-leg/dispatches/{dispatchNo}/depart:",
|
||||
"/api/pda/v1/last-leg/outbound-tasks:",
|
||||
"/api/pda/v1/last-leg/outbound-tasks/resolve/{businessNo}:",
|
||||
@@ -81,7 +83,8 @@ class PdaOperationsOpenApiContractTest {
|
||||
"/api/pda/v1/last-leg/outbound-tasks/{taskNo}/pallets/{palletNo}/pick:",
|
||||
"/api/pda/v1/last-leg/outbound-tasks/{taskNo}/complete:");
|
||||
for (String path : paths) assertTrue(yaml.contains(path), path);
|
||||
assertEquals(34, paths.size());
|
||||
assertEquals(36, paths.size());
|
||||
assertFalse(yaml.contains("/api/pda/v1/last-leg/dispatches/{dispatchNo}/complete:"));
|
||||
assertTrue(yaml.contains("enum: [LOCATION, PALLET]"));
|
||||
assertTrue(yaml.contains("enum: [APPROVE, REWORK]"));
|
||||
assertTrue(yaml.contains("Warehouse 权威实物托盘码"));
|
||||
|
||||
+8
-7
@@ -6,8 +6,10 @@ import com.baiyue.lastleg.delivery.dto.DeliveryRequests;
|
||||
import com.baiyue.lastleg.delivery.dto.DeliveryResponses;
|
||||
import com.baiyue.lastleg.delivery.service.DeliveryTransferService;
|
||||
import com.baiyue.lastleg.dispatch.pda.controller.DispatchPdaController;
|
||||
import com.baiyue.lastleg.dispatch.pda.dto.DispatchPdaRequests;
|
||||
import com.baiyue.lastleg.dispatch.pda.service.DispatchPdaWorkflowService;
|
||||
import com.baiyue.lastleg.dispatch.port.DispatchCommands;
|
||||
import com.baiyue.lastleg.dispatch.port.DispatchContracts;
|
||||
import com.baiyue.lastleg.dispatch.port.DispatchTypes;
|
||||
import com.baiyue.lastleg.dispatch.service.DispatchPdaQueryService;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.security.access.prepost.PreAuthorize;
|
||||
import org.springframework.test.util.ReflectionTestUtils;
|
||||
@@ -45,7 +47,7 @@ class PdaOutboundDispatchContractTest {
|
||||
}));
|
||||
}
|
||||
}
|
||||
assertTrue(Arrays.stream(DispatchPdaRequests.Pick.class.getRecordComponents())
|
||||
assertTrue(Arrays.stream(DispatchCommands.Pick.class.getRecordComponents())
|
||||
.noneMatch(component -> "warehouseId".equals(component.getName()) || "customerId".equals(component.getName())));
|
||||
assertTrue(Arrays.stream(DeliveryRequests.PdaPick.class.getRecordComponents())
|
||||
.noneMatch(component -> "warehouseId".equals(component.getName()) || "customerId".equals(component.getName())));
|
||||
@@ -55,19 +57,18 @@ class PdaOutboundDispatchContractTest {
|
||||
|
||||
@Test
|
||||
void controllersPassOnlyTokenBoundWarehouseToServices() {
|
||||
DispatchPdaWorkflowService dispatchService = mock(DispatchPdaWorkflowService.class);
|
||||
DispatchPdaQueryService dispatchService = mock(DispatchPdaQueryService.class);
|
||||
DeliveryTransferService outboundService = mock(DeliveryTransferService.class);
|
||||
DispatchPdaController dispatch = new DispatchPdaController();
|
||||
OutboundPdaController outbound = new OutboundPdaController();
|
||||
ReflectionTestUtils.setField(dispatch, "service", dispatchService);
|
||||
ReflectionTestUtils.setField(dispatch, "trips", mock(com.baiyue.lastleg.fleet.trip.FleetTripExecutionService.class));
|
||||
ReflectionTestUtils.setField(dispatch, "queries", dispatchService);
|
||||
ReflectionTestUtils.setField(outbound, "service", outboundService);
|
||||
AuthenticatedUser user = pda(Set.of("lastleg:pda:operate", "ROLE_OVERSEAS_WAREHOUSE_PDA_OPERATOR"), 900L);
|
||||
|
||||
dispatch.list(user, null);
|
||||
outbound.list(user, null);
|
||||
|
||||
verify(dispatchService).list("900", null);
|
||||
verify(dispatchService).list(new DispatchContracts.Actor("7",DispatchTypes.ActorMode.PDA,"900"),null);
|
||||
verify(outboundService).pdaTasks("900", null);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user