优化
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) {
|
||||
|
||||
Reference in New Issue
Block a user