提交
This commit is contained in:
+20
-4
@@ -21,8 +21,10 @@ class DispatchCandidateMapperContractTest {
|
||||
void reservedPalletsCanBePlannedAfterDepartureWithoutInventingReceivedStock() throws Exception {
|
||||
try (var connection = DriverManager.getConnection("jdbc:h2:mem:dispatch-prearrival;MODE=MySQL")) {
|
||||
var db = connection.createStatement();
|
||||
db.execute("CREATE TABLE ll_inbound_plan(id BIGINT,customer_id VARCHAR,status VARCHAR DEFAULT 'PDA_PENDING')");
|
||||
db.execute("CREATE TABLE ll_inbound_plan_pallet(id BIGINT,plan_id BIGINT,warehouse_pallet_id BIGINT,assignment_status VARCHAR,actual_package_count INT)");
|
||||
db.execute("CREATE TABLE ll_inbound_plan(id BIGINT,customer_id VARCHAR,source_order_id BIGINT,overseas_warehouse_id BIGINT,status VARCHAR DEFAULT 'PDA_PENDING')");
|
||||
db.execute("CREATE TABLE ll_inbound_plan_pallet(id BIGINT,plan_id BIGINT,warehouse_pallet_id BIGINT,assignment_status VARCHAR,actual_package_count INT,source_pallet_no VARCHAR)");
|
||||
db.execute("CREATE TABLE ll_local_looseboard_order(customer_id VARCHAR,lastleg_order_id BIGINT,transfer_no VARCHAR,status VARCHAR)");
|
||||
db.execute("CREATE TABLE ll_warehouse_transfer_order(customer_id VARCHAR,lastleg_order_id BIGINT,transfer_no VARCHAR,operating_warehouse_id VARCHAR,status VARCHAR)");
|
||||
db.execute("CREATE TABLE ll_inbound_plan_line(id BIGINT,customer_id VARCHAR,order_id BIGINT)");
|
||||
db.execute("CREATE TABLE ll_inbound_plan_pallet_allocation(pallet_id BIGINT,line_id BIGINT)");
|
||||
db.execute("CREATE TABLE ll_order(id BIGINT,customer_id VARCHAR,order_type VARCHAR,manual_node_override BOOLEAN,source_container_booking_id BIGINT)");
|
||||
@@ -31,7 +33,7 @@ class DispatchCandidateMapperContractTest {
|
||||
db.execute("CREATE TABLE ll_inventory_position(customer_id VARCHAR,pallet_id BIGINT,available_packages INT)");
|
||||
for (int id = 1; id <= 14; id++) {
|
||||
db.execute("INSERT INTO ll_inbound_plan(id,customer_id) VALUES (" + id + ",'A')");
|
||||
db.execute("INSERT INTO ll_inbound_plan_pallet VALUES (" + id + "," + id + "," + (100 + id) + ",'RESERVED',0)");
|
||||
db.execute("INSERT INTO ll_inbound_plan_pallet VALUES (" + id + "," + id + "," + (100 + id) + ",'RESERVED',0,NULL)");
|
||||
db.execute("INSERT INTO ll_inbound_plan_line VALUES (" + id + ",'A'," + id + ")");
|
||||
db.execute("INSERT INTO ll_inbound_plan_pallet_allocation VALUES (" + id + "," + id + ")");
|
||||
db.execute("INSERT INTO ll_order VALUES (" + id + ",'A','FIRST_LEG',FALSE," + id + ")");
|
||||
@@ -51,6 +53,18 @@ class DispatchCandidateMapperContractTest {
|
||||
db.execute("UPDATE ll_inbound_plan SET status='PARTIAL_INBOUND' WHERE id=12");
|
||||
db.execute("UPDATE ll_inbound_plan_pallet SET warehouse_pallet_id=NULL WHERE id=13");
|
||||
db.execute("UPDATE ll_inbound_plan_pallet SET assignment_status='ASSIGNED' WHERE id=14");
|
||||
// 散板无需头程离港和实收库存;资格必须沿同客户、同仓审核履约核对。
|
||||
for (int id=15;id<=20;id++) {
|
||||
db.execute("INSERT INTO ll_inbound_plan(id,customer_id,source_order_id,overseas_warehouse_id) VALUES ("+id+",'A',"+id+",9)");
|
||||
db.execute("INSERT INTO ll_inbound_plan_pallet VALUES ("+id+","+id+",NULL,'UNASSIGNED',NULL,'LB-PLAN:"+id+"')");
|
||||
db.execute("INSERT INTO ll_local_looseboard_order VALUES ('A',"+id+",'ZY"+id+"','WAITING_INBOUND')");
|
||||
db.execute("INSERT INTO ll_warehouse_transfer_order VALUES ('A',"+id+",'ZY"+id+"','9','WAIT_PDA_RECEIVING')");
|
||||
}
|
||||
db.execute("UPDATE ll_local_looseboard_order SET status='CANCELLED' WHERE lastleg_order_id=16");
|
||||
db.execute("UPDATE ll_warehouse_transfer_order SET customer_id='B' WHERE lastleg_order_id=17");
|
||||
db.execute("UPDATE ll_warehouse_transfer_order SET operating_warehouse_id='10' WHERE lastleg_order_id=18");
|
||||
db.execute("DELETE FROM ll_local_looseboard_order WHERE lastleg_order_id=19");
|
||||
db.execute("UPDATE ll_warehouse_transfer_order SET status='PENDING_REVIEW' WHERE lastleg_order_id=20");
|
||||
String plan = DispatchCandidateMapper.PLAN_ONLY;
|
||||
// 直接执行两条生产SQL中的资格片段,避免仅验证测试自行拼出的规则。
|
||||
for (String sql : List.of(DispatchCandidateMapper.AVAILABLE_PLAN_BODY, DispatchCandidateMapper.SELECTED_PLAN_SQL)) {
|
||||
@@ -71,7 +85,7 @@ class DispatchCandidateMapperContractTest {
|
||||
}
|
||||
}
|
||||
// 完成入库或已确认短少后,空预留托盘不可排车,真实实收托盘继续按库存校验。
|
||||
assertEquals(List.of(1L, 3L, 7L, 11L), ids);
|
||||
assertEquals(List.of(1L, 3L, 7L, 11L, 15L), ids);
|
||||
assertTrue(sql.contains("CASE WHEN " + plan + " THEN NULL ELSE p.warehouse_pallet_id END AS warehousePalletId"));
|
||||
}
|
||||
}
|
||||
@@ -134,6 +148,8 @@ class DispatchCandidateMapperContractTest {
|
||||
String sql = String.join(" ", DispatchCandidateMapper.class
|
||||
.getMethod(method, String.class, String.class, String.class).getAnnotation(Select.class).value());
|
||||
String eligibility = sql.substring(sql.indexOf("WHERE a.delivery_method"), sql.indexOf("<if test='customerId"));
|
||||
// 本用例只执行运输方式门禁;箱级废弃过滤由独立来源测试验证。
|
||||
if (eligibility.contains("AND NOT EXISTS")) eligibility=eligibility.substring(0,eligibility.indexOf("AND NOT EXISTS"));
|
||||
try (var connection = DriverManager.getConnection("jdbc:h2:mem:relabel-mode-" + method + ";MODE=MySQL")) {
|
||||
var statement = connection.createStatement();
|
||||
statement.execute("CREATE TABLE ll_relabel_application(delivery_method VARCHAR,status VARCHAR,fulfillment_status VARCHAR)");
|
||||
|
||||
+16
@@ -518,6 +518,21 @@ public class DispatchAggregatePersistenceTest {
|
||||
assertThat(f.tx.execute(s->f.service.detail(f.actor,original.dispatchNo())).counts().packageCount()).isEqualTo(10);
|
||||
}
|
||||
|
||||
/** 同车逐票追加未到仓计划,不借用执行校验要求实收库存。 */
|
||||
@Test void addsTwoUnreceivedPlansWithoutRequiringPhysicalPallets() throws Exception {
|
||||
var f=new Fixture();var a=f.source(1,"101",501,"ONT8",3);var b=f.source(2,"202",502,"LAX9",5);
|
||||
f.candidates.values().forEach(c->c.warehousePalletId(null).sourceStage("PLAN"));
|
||||
var created=f.tx.execute(s->f.service.create(f.actor,f.create("planned-first",List.of(a))));
|
||||
var rules=(DispatchService)org.springframework.test.util.ReflectionTestUtils.getField(f.service,"sourceRules");
|
||||
clearInvocations(rules);
|
||||
f.tx.executeWithoutResult(s->f.service.addCargo(f.actor,created.dispatchNo(),new DispatchCommands.AddCargo(created.recordVersion(),"planned-second",List.of(b),List.of())));
|
||||
var detail=f.tx.execute(s->f.service.detail(f.actor,created.dispatchNo()));
|
||||
assertThat(detail.counts().packageCount()).isEqualTo(8);
|
||||
assertThat(f.repository.cargo(f.repository.find(created.dispatchNo()).getId())).hasSize(2).allMatch(i->i.getWarehousePalletId()==null);
|
||||
verify(rules).lockAppointmentSources(eq("9"),argThat(items->items.size()==1 && items.get(0).getPlanPalletId().equals(1L)));
|
||||
verify(rules,never()).lockExecutionSources(anyString(),anyList());
|
||||
}
|
||||
|
||||
/** 到仓取货、本土卡车不制造货物明细,但仍须按原客户出现在现有整车排车列表。 */
|
||||
@Test void customerFilterFindsPickupAndLocalTruckByTheirRealSourceLinks() throws Exception {
|
||||
var f=new Fixture();
|
||||
@@ -633,6 +648,7 @@ public class DispatchAggregatePersistenceTest {
|
||||
service=new DispatchAggregateService(repository,
|
||||
new DispatchSourceRepository(session.getMapper(DispatchCandidateMapper.class),session.getMapper(ReturnDispatchCandidateMapper.class),session.getMapper(DispatchSourceMapper.class)),rules,fleet,
|
||||
new DispatchCommandLedger(new DispatchCommandRepository(session.getMapper(DispatchCommandMapper.class)),new DispatchCommandCodec(json)),new DispatchCommandHasher(json),json);
|
||||
org.springframework.test.util.ReflectionTestUtils.setField(service,"looseboardDisplay",new DispatchLooseboardDisplayService(mock(com.baiyue.lastleg.dispatch.repository.LooseboardCargoRepository.class)));
|
||||
org.springframework.test.util.ReflectionTestUtils.setField(service,"exceptionWorkflow",mock(com.baiyue.lastleg.dispatch.port.DispatchExceptionPort.class));
|
||||
org.springframework.test.util.ReflectionTestUtils.setField(service,"outboundWorkflow",mock(DispatchOutboundPort.class));
|
||||
org.springframework.test.util.ReflectionTestUtils.setField(service, "storageRent", org.mockito.Mockito.mock(com.baiyue.lastleg.storagerent.service.StorageRentService.class));
|
||||
|
||||
+16
-2
@@ -15,6 +15,20 @@ import static org.mockito.Mockito.*;
|
||||
|
||||
/** 实际父锁、CAS、历史和信号事务验证,派送事实提供者在此隔离并由其本域测试覆盖。 */
|
||||
class DispatchCompletionPersistenceTest {
|
||||
/** 混排即使仓库子单全签收,缺少散板照片仍保留在途与资源;补齐后重新汇总才完成。 */
|
||||
@Test void mixedDispatchWaitsForLooseboardPickupAndArrivalPhotos() throws Exception {
|
||||
var fixture=new CompletionFixture();fixture.allSigned();fixture.acknowledges();
|
||||
var photos=mock(DispatchLooseboardFacts.class);
|
||||
when(photos.cargo(anyLong())).thenReturn(List.of(new DispatchLooseboardFacts.Cargo(new com.baiyue.lastleg.dispatch.entity.DispatchItem(),"LB1")));
|
||||
org.springframework.test.util.ReflectionTestUtils.setField(fixture.target,"looseboardFacts",photos);
|
||||
var blocked=fixture.port.reconcile(fixture.signal);
|
||||
assertThat(blocked.changed()).isFalse();assertThat(blocked.stage()).isEqualTo(Stage.DELIVERING);verifyNoInteractions(fixture.completion);
|
||||
when(photos.complete(any(),eq(DispatchLooseboardFacts.Phase.PICKUP))).thenReturn(true);
|
||||
assertThat(fixture.port.reconcile(fixture.signal).changed()).isFalse();
|
||||
when(photos.complete(any(),eq(DispatchLooseboardFacts.Phase.ARRIVAL))).thenReturn(true);
|
||||
assertThat(fixture.port.reconcile(fixture.signal).changed()).isTrue();
|
||||
verify(fixture.completion).completeDelivered(anyString(),eq("SYSTEM_DELIVERY_RECONCILE"));
|
||||
}
|
||||
/** CAS之后信号确认失败,两者及完成证据一起回滚;重试只推进一次。 */
|
||||
@Test void stageAndSignalAcknowledgeRollbackTogetherThenRetryExactlyOnce() throws Exception {
|
||||
var fixture=new CompletionFixture();fixture.allSigned();
|
||||
@@ -46,14 +60,14 @@ class DispatchCompletionPersistenceTest {
|
||||
final DispatchAggregatePersistenceTest.Fixture f=new DispatchAggregatePersistenceTest.Fixture();
|
||||
final DispatchDeliveryPort deliveries=mock(DispatchDeliveryPort.class);final DispatchSignalPort signals=mock(DispatchSignalPort.class);
|
||||
final com.baiyue.lastleg.fleet.trip.FleetTripCompletionService completion=mock(com.baiyue.lastleg.fleet.trip.FleetTripCompletionService.class);
|
||||
final DispatchCompletionPort port;final String number;final Signal signal;final Instant signedAt=Instant.now().minusSeconds(5);
|
||||
final DispatchCompletionPort port;final DispatchCompletionService target;final String number;final Signal signal;final Instant signedAt=Instant.now().minusSeconds(5);
|
||||
/** 使用真实Spring事务代理,REQUIRES_NEW与回滚均按生产注解执行。 */
|
||||
CompletionFixture() throws Exception {
|
||||
var selected=f.source(1,"101",501,"ONT8",3);var created=f.tx.execute(s->f.service.create(f.actor,f.configured("completion-create",List.of(selected))));number=created.dispatchNo();
|
||||
f.picked(1,"101",501,3);f.db.update("UPDATE ll_dispatch_order SET stage='DELIVERING',picked_package_count=3,shipped_package_count=3,departed_at=?",LocalDateTime.ofInstant(signedAt.minusSeconds(60),ZoneOffset.UTC));
|
||||
f.db.execute("CREATE TABLE completion_test_signal(id BIGINT PRIMARY KEY,state VARCHAR(32))");f.db.update("INSERT INTO completion_test_signal VALUES(1,'PENDING')");
|
||||
signal=new Signal(1L,number,"DELIVERY-A",1,0,Instant.now());
|
||||
var target=new DispatchCompletionService(f.repository,f.executionReads,f.fleet,f.service,deliveries,signals,f.json,completion);
|
||||
target=new DispatchCompletionService(f.repository,f.executionReads,f.fleet,f.service,deliveries,signals,f.json,completion);
|
||||
var proxy=new ProxyFactory(target);proxy.addAdvice(new TransactionInterceptor(f.transactions,new AnnotationTransactionAttributeSource()));port=(DispatchCompletionPort)proxy.getProxy();
|
||||
}
|
||||
/** 父事务中的信号SQL参与同一个DataSource事务。 */
|
||||
|
||||
+11
@@ -109,6 +109,10 @@ class DispatchInboundReceiptPersistenceTest {
|
||||
db.update("INSERT INTO ll_inbound_plan_pallet VALUES(?,1,'101',?,?,NULL)",n,500+n,"P-"+n);
|
||||
db.update("INSERT INTO ll_inbound_plan_pallet_allocation VALUES(?,1,'101',10,?,?)",n,n==1?9:11,new BigDecimal(n==1?"1.5876":"1.9404"));
|
||||
}
|
||||
db.execute("ALTER TABLE ll_inbound_plan ADD source_order_id BIGINT");
|
||||
db.execute("ALTER TABLE ll_inbound_plan_pallet ADD actual_package_count INT");
|
||||
db.execute("CREATE TABLE ll_warehouse_transfer_order(id BIGINT,customer_id VARCHAR,lastleg_order_id BIGINT)");
|
||||
db.execute("CREATE TABLE ll_warehouse_transfer_pallet(transfer_order_id BIGINT,customer_id VARCHAR,warehouse_pallet_id BIGINT,gross_weight_kg DECIMAL(18,3),carton_count INT,status VARCHAR,completed_at TIMESTAMP)");
|
||||
var config=new MybatisConfiguration();config.setMapUnderscoreToCamelCase(true);config.addMapper(DispatchSourceMapper.class);
|
||||
var factory=new MybatisSqlSessionFactoryBean();factory.setDataSource(ds);factory.setConfiguration(config);factory.afterPropertiesSet();
|
||||
var mapper=new SqlSessionTemplate(factory.getObject()).getMapper(DispatchSourceMapper.class);
|
||||
@@ -120,6 +124,12 @@ class DispatchInboundReceiptPersistenceTest {
|
||||
assertThat(mapper.confirmedPlan("101",2).get(0).volumeCbm()).isEqualByComparingTo("1.9404");
|
||||
db.update("DELETE FROM ll_order_detail WHERE customer_id='101'");
|
||||
assertThat(mapper.confirmedPlan("101",1).get(0).grossWeightKg()).isNull();
|
||||
// 仓储转运取已完成测量的该托实重,不要求头程货物明细或借计划总重。
|
||||
db.update("UPDATE ll_inbound_plan SET source_order_id=9001");
|
||||
db.update("UPDATE ll_inbound_plan_pallet SET actual_package_count=9 WHERE id=1");
|
||||
db.update("INSERT INTO ll_warehouse_transfer_order VALUES(88,'101',9001)");
|
||||
db.update("INSERT INTO ll_warehouse_transfer_pallet VALUES(88,'101',501,42.5,9,'COMPLETED',CURRENT_TIMESTAMP)");
|
||||
assertThat(mapper.confirmedPlan("101",1).get(0).grossWeightKg()).isEqualByComparingTo("42.5");
|
||||
assertThat(mapper.confirmedPlan("other",1)).isEmpty();
|
||||
db.update("UPDATE ll_inbound_plan SET status='PARTIAL_INBOUND'");
|
||||
assertThat(mapper.confirmedPlan("101",1).get(0).packageCount()).isEqualTo(9);
|
||||
@@ -254,6 +264,7 @@ class DispatchInboundReceiptPersistenceTest {
|
||||
/** 预排估算为10+10,收货输入固定9+11,来源及预约均保持同一票。 */
|
||||
Fixture() throws Exception {
|
||||
p.receivedPlans(9,11);
|
||||
p.f.db.execute("ALTER TABLE ll_warehouse_transfer_order ADD (destination_type VARCHAR(32),destination_warehouse_code VARCHAR(128))");
|
||||
p.f.db.update("UPDATE ll_dispatch_item SET customer_id='101',order_id=9001,destination_type='FBA',fba_warehouse_code='ONT8'");
|
||||
p.f.db.update("UPDATE ll_inventory_position SET customer_id='101',available_packages=0");
|
||||
p.f.db.update("UPDATE ll_inbound_stock_receipt SET customer_id='101'");
|
||||
|
||||
+69
@@ -0,0 +1,69 @@
|
||||
package com.baiyue.lastleg.dispatch.service;
|
||||
|
||||
import com.baiyue.lastleg.dispatch.entity.DispatchItem;
|
||||
import com.baiyue.lastleg.dispatch.mapper.LooseboardCargoMapper;
|
||||
import com.baiyue.lastleg.dispatch.mapper.LooseboardCargoMapper.*;
|
||||
import com.baiyue.lastleg.dispatch.repository.LooseboardCargoRepository;
|
||||
import com.baiyue.lastleg.dispatch.port.*;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.test.util.ReflectionTestUtils;
|
||||
import java.util.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
import static org.mockito.ArgumentMatchers.*;
|
||||
import static org.assertj.core.api.Assertions.*;
|
||||
|
||||
class DispatchLooseboardDisplayServiceTest {
|
||||
/** 历史排车仍按真实关联显示LB原单;不把内部ZY号代填为入仓码或集装箱号。 */
|
||||
@Test void historicalDetailsUseBusinessOrderAndNoInventedContainer() {
|
||||
var repository=mock(LooseboardCargoRepository.class);
|
||||
when(repository.businessSources(any())).thenReturn(List.of(new BusinessSource("A","ZY1","LB1",11L)));
|
||||
var display=new DispatchLooseboardDisplayService(repository);
|
||||
var aggregate=new DispatchAggregateService(null,null,null,null,null,null,new ObjectMapper());
|
||||
ReflectionTestUtils.setField(aggregate,"looseboardDisplay",display);
|
||||
var looseboard=item("A","ZY1",11L,"ZY1");var other=item("B","ZY1",12L,"REAL-CONTAINER");
|
||||
List<DispatchViews.OrderDetail> result=ReflectionTestUtils.invokeMethod(aggregate,"orderDetails",List.of(looseboard,other));
|
||||
assertThat(result.get(0).orderNo()).isEqualTo("LB1");assertThat(result.get(0).sourceBusinessNo()).isEqualTo("LB1");
|
||||
assertThat(result.get(0).sourceBusinessType()).isEqualTo("LOCAL_LOOSEBOARD");assertThat(result.get(0).containerNo()).isNull();
|
||||
assertThat(result.get(0).inboundCode()).isNull();assertThat(result.get(1).inboundCode()).isEqualTo("ZY1");
|
||||
assertThat(result.get(1).orderNo()).isEqualTo("ZY1");assertThat(result.get(1).containerNo()).isEqualTo("REAL-CONTAINER");
|
||||
assertThat(looseboard.getOrderNo()).isEqualTo("ZY1");assertThat(looseboard.getSourceSnapshotJson()).contains("ZY1");
|
||||
}
|
||||
/** 展示替换不得更改加货的来源键、版本或原计划身份。 */
|
||||
@Test void candidateRetainsCommandIdentityWithoutInventedInboundCode() {
|
||||
var repository=mock(LooseboardCargoRepository.class);
|
||||
when(repository.businessSources(any())).thenReturn(List.of(new BusinessSource("A","ZY1","LB1",11L)));
|
||||
var display=new DispatchLooseboardDisplayService(repository);
|
||||
var selection=new DispatchCommands.CargoSelection(DispatchTypes.SourceType.INBOUND_PLAN,11L,null,null,null,"version");
|
||||
var group=new DispatchPlanningModels.SelectionGroup("group","group-version",List.of(new DispatchPlanningModels.SelectionGroupSource(selection,"PLAN:11","A","customer","INBOUND_PLAN","WT-ZY1",2)),List.of(),true,List.of());
|
||||
var original=new DispatchPlanningModels.Candidate(selection,"PLAN:11","version","A","customer","9",null,null,"ZY1","ZY1","WT-ZY1",null,"ZY1",null,2,null,null,null,List.of(),List.of(),group);
|
||||
var result=display.candidates(List.of(original)).get(0);
|
||||
assertThat(result.orderNo()).isEqualTo("LB1");assertThat(result.containerNo()).isNull();assertThat(result.inboundCode()).isNull();
|
||||
assertThat(result.selection()).isSameAs(selection);assertThat(result.sourceVersion()).isEqualTo("version");assertThat(result.sourceKey()).isEqualTo("PLAN:11");
|
||||
assertThat(result.selectionGroup().sources().get(0).sourceBusinessType()).isEqualTo("LOCAL_LOOSEBOARD");
|
||||
assertThat(result.selectionGroup().sources().get(0).sourceBusinessNo()).isEqualTo("LB1");
|
||||
}
|
||||
/** 商业关联查询必须同客户、同履约单且同尾程身份,避免同号跨客户串单。 */
|
||||
@Test void mapperRequiresTheAuthoritativeLinkAndCustomer() throws Exception {
|
||||
var ds=new org.springframework.jdbc.datasource.DriverManagerDataSource("jdbc:h2:mem:"+UUID.randomUUID()+";MODE=MySQL;DB_CLOSE_DELAY=-1","sa","");
|
||||
var db=new org.springframework.jdbc.core.JdbcTemplate(ds);
|
||||
db.execute("CREATE TABLE ll_local_looseboard_order(customer_id VARCHAR,order_no VARCHAR,lastleg_order_id BIGINT,transfer_no VARCHAR,delivery_mode VARCHAR DEFAULT 'WAREHOUSE_PICKUP')");
|
||||
db.execute("CREATE TABLE ll_warehouse_transfer_order(customer_id VARCHAR,transfer_no VARCHAR,lastleg_order_id BIGINT)");
|
||||
db.execute("CREATE TABLE ll_inbound_plan(id BIGINT,source_order_id BIGINT,customer_id VARCHAR)");
|
||||
db.execute("CREATE TABLE ll_inbound_plan_pallet(id BIGINT,plan_id BIGINT,customer_id VARCHAR)");
|
||||
db.update("INSERT INTO ll_local_looseboard_order(customer_id,order_no,lastleg_order_id,transfer_no) VALUES('A','LB1',1,'ZY1'),('B','LB2',2,'ZY1'),('A','LB-BROKEN',3,'ZY3')");
|
||||
db.update("INSERT INTO ll_warehouse_transfer_order VALUES('A','ZY1',1),('B','ZY1',2),('B','ZY3',3)");
|
||||
db.update("INSERT INTO ll_inbound_plan VALUES(10,1,'A')");db.update("INSERT INTO ll_inbound_plan_pallet VALUES(11,10,'A')");
|
||||
var config=new org.apache.ibatis.session.Configuration();config.setMapUnderscoreToCamelCase(true);config.addMapper(LooseboardCargoMapper.class);
|
||||
var factory=new org.mybatis.spring.SqlSessionFactoryBean();factory.setDataSource(ds);factory.setConfiguration(config);factory.afterPropertiesSet();
|
||||
var mapper=new org.mybatis.spring.SqlSessionTemplate(factory.getObject()).getMapper(LooseboardCargoMapper.class);
|
||||
assertThat(mapper.businessSources(List.of(new Reference("A","ZY1",null)))).extracting(BusinessSource::orderNo).containsExactly("LB1");
|
||||
assertThat(mapper.businessSources(List.of(new Reference("B",null,11L)))).isEmpty();
|
||||
assertThat(mapper.businessSources(List.of(new Reference("A","ZY3",null)))).isEmpty();
|
||||
}
|
||||
/** 构造旧排车保存的履约快照,验证读取时纠正且不改原来源。 */
|
||||
private DispatchItem item(String customer,String number,long pallet,String container) {
|
||||
var item=new DispatchItem();item.setCustomerId(customer);item.setOrderNo(number);item.setOrderRefs(number);item.setPlanPalletId(pallet);item.setInboundCode(number);
|
||||
item.setSourceSnapshotJson("{\"containerNo\":\""+container+"\",\"planNo\":\"plan\"}");return item;
|
||||
}
|
||||
}
|
||||
+178
@@ -0,0 +1,178 @@
|
||||
package com.baiyue.lastleg.dispatch.service;
|
||||
|
||||
import com.baiyue.lastleg.dispatch.entity.*;
|
||||
import com.baiyue.lastleg.dispatch.gateway.DispatchEvidenceGateway;
|
||||
import com.baiyue.lastleg.dispatch.mapper.LooseboardCargoMapper.BusinessSource;
|
||||
import com.baiyue.lastleg.dispatch.repository.*;
|
||||
import com.baiyue.lastleg.dispatch.port.*;
|
||||
import com.baiyue.lastleg.fleet.trip.*;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import java.time.*;
|
||||
import java.util.*;
|
||||
import static com.baiyue.lastleg.dispatch.service.DispatchLooseboardFacts.*;
|
||||
import static com.baiyue.lastleg.dispatch.port.DispatchContracts.*;
|
||||
import static com.baiyue.lastleg.dispatch.port.DispatchDeliveryModels.*;
|
||||
import static org.assertj.core.api.Assertions.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
import static org.mockito.ArgumentMatchers.*;
|
||||
|
||||
class DispatchLooseboardExecutionServiceTest {
|
||||
/** 追加、逐张删除及清空后重传不重复计入取货箱数;删除历史保留但不再授权下载。 */
|
||||
@Test void editsPhotoListsWithoutDuplicatingRemotePickup() {
|
||||
var f=new Fixture(true);f.row.setStage("AFTER_LOADING");f.row.setPickedPackageCount(2);
|
||||
f.save(101,Phase.PICKUP,1);
|
||||
var appended=f.replace(101,Phase.PICKUP,1L,2L);
|
||||
assertThat(appended.items().get(0).pickupPhotos()).extracting(DispatchEvidenceGateway.File::fileId).containsExactly(1L,2L);
|
||||
f.replace(101,Phase.PICKUP,2L);
|
||||
assertThatThrownBy(()->f.service.content(f.actor,"PC1",101,Phase.PICKUP,1)).hasMessageContaining("未绑定");
|
||||
f.replace(101,Phase.PICKUP);
|
||||
assertThat(f.facts.current(f.row).get(0).files()).isEmpty();
|
||||
assertThat(f.facts.complete(f.row,Phase.PICKUP)).isFalse();
|
||||
f.replace(101,Phase.PICKUP,3L);
|
||||
assertThat(f.row.getShippedPackageCount()).isEqualTo(2);
|
||||
assertThat(f.facts.pickedRemotePackages(f.row)).isEqualTo(2);
|
||||
assertThat(f.facts.history(1)).hasSize(5);
|
||||
verify(f.workflow,times(1)).requireRemotePickupCapacity(f.row);
|
||||
verify(f.orders,times(1)).progress("A","LB1","operator",0);
|
||||
}
|
||||
/** 送达后可替换及追加照片,但最后一张完成凭证不可删,修改不重复触发结案。 */
|
||||
@Test void completedPhotosCanBeAppendedAndDeletedWhileRetainingEvidence() {
|
||||
var f=new Fixture(false);f.save(101,Phase.PICKUP,1);f.save(102,Phase.PICKUP,2);
|
||||
f.save(101,Phase.ARRIVAL,3);
|
||||
assertThatThrownBy(()->f.replace(101,Phase.PICKUP)).hasMessageContaining("至少保留一张");
|
||||
f.save(102,Phase.ARRIVAL,4);
|
||||
f.replace(101,Phase.ARRIVAL,3L,5L);f.replace(101,Phase.ARRIVAL,5L);
|
||||
assertThat(f.service.get(f.actor,"PC1").items().get(0).arrivalPhotos()).extracting(DispatchEvidenceGateway.File::fileId).containsExactly(5L);
|
||||
assertThatThrownBy(()->f.replace(101,Phase.ARRIVAL)).hasMessageContaining("至少保留一张");
|
||||
assertThat(f.row.getStage()).isEqualTo("DELIVERED");
|
||||
verify(f.completion,times(1)).completeDelivered("TRIP1","operator");
|
||||
verify(f.fleet,times(1)).departPhotoDispatchShell(eq("PC1"),eq("operator"),any(),anyString());
|
||||
verify(f.orders,times(1)).progress("A","LB1","operator",2);
|
||||
}
|
||||
/** 两票散板无库存出库,取货缺一票不发运,送达缺一票不结案;重复命令不重复发车。 */
|
||||
@Test void pureLooseboardNeedsEveryPickupAndArrivalButNeverPicks() {
|
||||
var f=new Fixture(false);var first=f.save(101,Phase.PICKUP,1);
|
||||
assertThat(first.direct()).isTrue();assertThat(first.stage()).isEqualTo("LOADING");verifyNoInteractions(f.fleet);
|
||||
var request=new DispatchLooseboardExecutionService.Save(f.row.getRecordVersion(),"pickup-original",List.of(2L));
|
||||
var departed=f.service.save(f.actor,"PC1",102,Phase.PICKUP,request);
|
||||
assertThat(departed.stage()).isEqualTo("DELIVERING");assertThat(f.row.getPickedPackageCount()).isZero();assertThat(f.row.getShippedPackageCount()).isEqualTo(4);
|
||||
f.service.save(f.actor,"PC1",102,Phase.PICKUP,request);
|
||||
verify(f.fleet,times(1)).departPhotoDispatchShell(eq("PC1"),eq("operator"),any(),anyString());
|
||||
verify(f.outbounds,times(1)).revoke(1);verifyNoMoreInteractions(f.outbounds);
|
||||
f.save(101,Phase.ARRIVAL,3);assertThat(f.row.getStage()).isEqualTo("DELIVERING");verifyNoInteractions(f.completion);
|
||||
f.save(102,Phase.ARRIVAL,4);assertThat(f.row.getStage()).isEqualTo("DELIVERED");
|
||||
verify(f.completion).completeDelivered("TRIP1","operator");
|
||||
assertThat(f.facts.outcomes(f.row)).hasSize(2).allMatch(o->o.remainingPackages()==0 && o.signedPackages()==2);
|
||||
verify(f.workflow).releaseSources(eq(f.row),eq(f.actor),anyList(),any());
|
||||
}
|
||||
/** 混排仍保留原出库链,散板照片不能单独结束其他客户的派送。 */
|
||||
@Test void mixedCargoOnlyAddsEvidenceAndWakesNormalCompletion() {
|
||||
var f=new Fixture(true);f.row.setStage("DELIVERING");f.row.setPickedPackageCount(2);f.row.setShippedPackageCount(2);
|
||||
f.save(101,Phase.PICKUP,1);f.save(102,Phase.PICKUP,2);f.save(101,Phase.ARRIVAL,3);f.save(102,Phase.ARRIVAL,4);
|
||||
assertThat(f.row.getStage()).isEqualTo("DELIVERING");assertThat(f.facts.direct(f.row)).isFalse();
|
||||
assertThat(f.facts.complete(f.row,Phase.PICKUP)).isTrue();assertThat(f.facts.complete(f.row,Phase.ARRIVAL)).isTrue();
|
||||
verifyNoInteractions(f.fleet,f.outbounds,f.completion);
|
||||
verify(f.deliveries).normalDeliveryFacts(any());
|
||||
assertThat(f.row.getShippedPackageCount()).isEqualTo(6);assertThat(f.row.getPickedPackageCount()).isEqualTo(2);
|
||||
}
|
||||
/** 仓内货先送达不阻塞后续异地取货;各票散板可交错取送,最后一票送达才整车完结。 */
|
||||
@Test void mixedWarehouseDeliveryMayFinishBeforeRemotePickupsAndInterleavedArrivals() {
|
||||
var f=new Fixture(true);f.row.setStage("DELIVERING");f.row.setPickedPackageCount(2);f.row.setShippedPackageCount(2);
|
||||
when(f.deliveries.normalDeliveryFacts(any())).thenReturn(new NormalDeliveryFacts("PC1","signed",true,Instant.now(),List.of("DL1"),List.of(),List.of()));
|
||||
var initial=f.service.get(f.actor,"PC1");
|
||||
assertThat(initial.stage()).isEqualTo("DELIVERING");
|
||||
assertThat(initial.items()).allMatch(i->i.canPickup() && !i.canArrive());
|
||||
f.save(101,Phase.PICKUP,1);
|
||||
var partial=f.save(101,Phase.ARRIVAL,2);
|
||||
assertThat(partial.stage()).isEqualTo("DELIVERING");
|
||||
assertThat(partial.items().stream().filter(i->i.itemId().equals("102")).findFirst().orElseThrow().canPickup()).isTrue();
|
||||
verifyNoInteractions(f.completion);
|
||||
f.save(102,Phase.PICKUP,3);
|
||||
assertThat(f.row.getStage()).isEqualTo("DELIVERING");
|
||||
f.save(102,Phase.ARRIVAL,4);
|
||||
assertThat(f.row.getStage()).isEqualTo("DELIVERED");
|
||||
assertThat(f.row.getPickedPackageCount()).isEqualTo(2);assertThat(f.row.getShippedPackageCount()).isEqualTo(6);
|
||||
verify(f.completion).completeDelivered("TRIP1","operator");
|
||||
verify(f.fleet,never()).departPhotoDispatchShell(anyString(),anyString(),any(),anyString());
|
||||
verifyNoInteractions(f.outbounds);
|
||||
}
|
||||
/** 装车后即可保存散板取送凭证,但不能代替仓内正式发车或提前结案。 */
|
||||
@Test void mixedAfterLoadingAcceptsPhotosWithoutPrematureDepartureOrCompletion() {
|
||||
var f=new Fixture(true);f.row.setStage("AFTER_LOADING");f.row.setPickedPackageCount(2);
|
||||
var initial=f.service.get(f.actor,"PC1");
|
||||
assertThat(initial.items()).allMatch(i->i.canPickup() && !i.canArrive());
|
||||
var picked=f.save(101,Phase.PICKUP,1);
|
||||
assertThat(picked.stage()).isEqualTo("AFTER_LOADING");
|
||||
assertThat(picked.items().stream().filter(i->i.itemId().equals("101")).findFirst().orElseThrow().canArrive()).isTrue();
|
||||
f.save(101,Phase.ARRIVAL,2);f.save(102,Phase.PICKUP,3);f.save(102,Phase.ARRIVAL,4);
|
||||
assertThat(f.row.getStage()).isEqualTo("AFTER_LOADING");
|
||||
assertThat(f.row.getDepartedAt()).isNull();assertThat(f.row.getDeliveryCompletedAt()).isNull();
|
||||
assertThat(f.row.getPickedPackageCount()).isEqualTo(2);assertThat(f.row.getShippedPackageCount()).isEqualTo(4);
|
||||
assertThat(f.facts.pickedRemotePackages(f.row)).isEqualTo(4);
|
||||
verify(f.workflow,times(2)).requireRemotePickupCapacity(f.row);
|
||||
verifyNoInteractions(f.fleet,f.outbounds,f.deliveries,f.completion);
|
||||
}
|
||||
/** 未确认装车的混排不允许提前上传,取消后同样不能上传。 */
|
||||
@Test void mixedLoadingAndCancelledDispatchRejectPhotos() {
|
||||
var f=new Fixture(true);
|
||||
assertThat(f.service.get(f.actor,"PC1").items()).allMatch(i->!i.canPickup() && !i.canArrive());
|
||||
assertThatThrownBy(()->f.save(101,Phase.PICKUP,1)).hasMessageContaining("阶段");
|
||||
f.row.setStage("AFTER_LOADING");f.row.setStatus("CANCELLED");
|
||||
assertThat(f.service.get(f.actor,"PC1").items()).allMatch(i->!i.canPickup() && !i.canArrive());
|
||||
}
|
||||
/** 入仓实托必须参与拣货,只有未入仓的上门取货明细排除。 */
|
||||
@Test void receivedLooseboardRemainsInWarehousePicking(){
|
||||
var f=new Fixture(true);f.repository.cargo(1).get(0).setWarehousePalletId(501L);
|
||||
assertThat(f.facts.remoteItemIds(1)).containsExactly(102L);
|
||||
assertThat(f.facts.warehouseItems(1)).extracting(DispatchItem::getId).containsExactly(101L,103L);
|
||||
assertThat(f.facts.direct(f.row)).isFalse();
|
||||
}
|
||||
/** 送达必须晚于取货和发运;公共版本、其他票身份及照片复用均拒绝。 */
|
||||
@Test void rejectsWrongNodeStaleVersionForeignItemAndFileReuse() {
|
||||
var f=new Fixture(false);
|
||||
assertThatThrownBy(()->f.save(101,Phase.ARRIVAL,1)).hasMessageContaining("阶段");
|
||||
assertThatThrownBy(()->f.service.save(f.actor,"PC1",101,Phase.PICKUP,new DispatchLooseboardExecutionService.Save(99L,"stale-version",List.of(1L)))).hasMessageContaining("版本");
|
||||
assertThatThrownBy(()->f.save(999,Phase.PICKUP,1)).hasMessageContaining("不属于本车");
|
||||
f.save(101,Phase.PICKUP,1);
|
||||
assertThatThrownBy(()->f.save(102,Phase.PICKUP,1)).hasMessageContaining("重复使用");
|
||||
assertThatThrownBy(()->f.service.content(f.actor,"PC1",102,Phase.PICKUP,1)).hasMessageContaining("未绑定");
|
||||
verifyNoInteractions(f.fleet,f.outbounds,f.completion);
|
||||
}
|
||||
/** 退回重审后旧证据只留历史;有实际扫码的旧车不得跳过库存链。 */
|
||||
@Test void oldReviewPhotosDoNotCountAndActualPicksDisableDirectMode() {
|
||||
var f=new Fixture(false);f.save(101,Phase.PICKUP,1);f.row.setReviewRound(2L);
|
||||
assertThat(f.facts.current(f.row)).isEmpty();assertThat(f.facts.history(1)).hasSize(1);
|
||||
when(f.repository.realPicked(1)).thenReturn(1L);assertThat(f.facts.direct(f.row)).isFalse();
|
||||
}
|
||||
static class Fixture {
|
||||
final ObjectMapper json=new ObjectMapper().findAndRegisterModules();final Actor actor=new Actor("operator",DispatchTypes.ActorMode.PLATFORM,null);
|
||||
final DispatchAggregate row=new DispatchAggregate();final List<String> history=new ArrayList<>();
|
||||
final DispatchAggregateRepository repository=mock(DispatchAggregateRepository.class);
|
||||
final DispatchLooseboardEvidenceRepository evidence=mock(DispatchLooseboardEvidenceRepository.class);
|
||||
final DispatchAggregateService workflow=mock(DispatchAggregateService.class);final DispatchEvidenceGateway files=mock(DispatchEvidenceGateway.class);
|
||||
final DispatchOutboundPort outbounds=mock(DispatchOutboundPort.class);final FleetTripExecutionService fleet=mock(FleetTripExecutionService.class);
|
||||
final FleetTripCompletionService completion=mock(FleetTripCompletionService.class);final DispatchDeliveryPort deliveries=mock(DispatchDeliveryPort.class);
|
||||
final LooseboardTransportPort orders=mock(LooseboardTransportPort.class);
|
||||
final DispatchLooseboardFacts facts;final DispatchLooseboardExecutionService service;
|
||||
Fixture(boolean mixed){
|
||||
row.setId(1L);row.setDispatchNo("PC1");row.setStage("LOADING");row.setStatus("ACTIVE");row.setReviewState("APPROVED");row.setRecordVersion(0L);
|
||||
row.setReviewRound(1L);row.setCargoHash("cargo");row.setPackageCount(mixed?6:4);row.setPickedPackageCount(0);row.setPalletCount(mixed?3:2);row.setShippedPackageCount(0);
|
||||
var cargo=new ArrayList<>(List.of(item(101,"A","ZY1"),item(102,"B","ZY2")));if(mixed)cargo.add(item(103,"C","OTHER"));
|
||||
when(repository.find("PC1")).thenReturn(row);when(repository.cargo(1)).thenReturn(cargo);when(repository.saveState(eq(row),anyLong())).thenReturn(1);
|
||||
when(repository.history(anyLong(),eq(row),anyString(),anyString(),any(),anyString(),any())).thenAnswer(call->{if("LOOSEBOARD_PHOTO".equals(call.getArgument(2)))history.add(call.getArgument(5));return 1;});
|
||||
when(evidence.snapshots(1)).thenAnswer(call->List.copyOf(history));when(evidence.deliveredPackages(anyString(),anyString())).thenReturn(2L);
|
||||
var sourceRepo=mock(LooseboardCargoRepository.class);when(sourceRepo.businessSources(anyList())).thenReturn(List.of(new BusinessSource("A","ZY1","LB1",101L,"WAREHOUSE_PICKUP"),new BusinessSource("B","ZY2","LB2",102L,"WAREHOUSE_PICKUP")));
|
||||
facts=new DispatchLooseboardFacts(repository,new DispatchLooseboardDisplayService(sourceRepo),evidence,json);
|
||||
var context=mock(Context.class);var resources=new Resources(new VehicleSnapshot(DispatchTypes.ResourceMode.MASTER,1L,"TEST",null,"IN_SERVICE",new Capacity(10,new java.math.BigDecimal("1000"),new java.math.BigDecimal("50")),List.of()),null,null);
|
||||
when(context.resources()).thenReturn(resources);when(workflow.preview(actor,"PC1")).thenReturn(context);when(workflow.lockForArrangement(actor,"PC1")).thenReturn(context);
|
||||
when(fleet.dispatchIdentity("PC1")).thenReturn(new FleetExecutionModels.DispatchShellLock(1L,"TRIP1","9"));
|
||||
when(files.metadata(eq(1L),any(),anyLong())).thenAnswer(call->new DispatchEvidenceGateway.File(call.getArgument(2),"test.png","image/png",10));
|
||||
when(deliveries.normalDeliveryFacts(context)).thenReturn(new NormalDeliveryFacts("PC1","hash",false,null,List.of("DL1"),List.of(new NormalDeliveryFact("A","group","OUT1","DL1",5,6,0,0,0,"DELIVERING",true,null)),List.of()));
|
||||
service=new DispatchLooseboardExecutionService(workflow,repository,facts,files,outbounds,fleet,completion,deliveries,orders,new DispatchCommandHasher(json),json);
|
||||
}
|
||||
DispatchLooseboardExecutionService.Detail save(long item,Phase phase,long file){return service.save(actor,"PC1",item,phase,new DispatchLooseboardExecutionService.Save(row.getRecordVersion(),UUID.randomUUID().toString(),List.of(file)));}
|
||||
DispatchLooseboardExecutionService.Detail replace(long item,Phase phase,Long... files){return service.save(actor,"PC1",item,phase,new DispatchLooseboardExecutionService.Save(row.getRecordVersion(),UUID.randomUUID().toString(),List.of(files)));}
|
||||
DispatchItem item(long id,String customer,String number){var item=new DispatchItem();item.setId(id);item.setCustomerId(customer);item.setOrderNo(number);item.setPlanPalletId(id);item.setPackageCount(2);item.setDeliveryAddress("TEST DESTINATION");return item;}
|
||||
}
|
||||
}
|
||||
+64
@@ -0,0 +1,64 @@
|
||||
package com.baiyue.lastleg.dispatch.service;
|
||||
|
||||
import com.baiyue.lastleg.dispatch.mapper.*;
|
||||
import com.baiyue.lastleg.dispatch.repository.*;
|
||||
import com.baiyue.lastleg.dispatch.gateway.DispatchEvidenceGateway;
|
||||
import com.baiyue.lastleg.dispatch.port.*;
|
||||
import com.baiyue.lastleg.fleet.trip.*;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.aop.framework.ProxyFactory;
|
||||
import org.springframework.transaction.annotation.AnnotationTransactionAttributeSource;
|
||||
import org.springframework.transaction.interceptor.TransactionInterceptor;
|
||||
import java.util.*;
|
||||
import static com.baiyue.lastleg.dispatch.port.DispatchContracts.*;
|
||||
import static org.assertj.core.api.Assertions.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
import static org.mockito.ArgumentMatchers.*;
|
||||
|
||||
class DispatchLooseboardPhotoPersistenceTest {
|
||||
/** 最后一票取货照片、公共版本及发车同事务;车队失败时不能留下已保存照片或假发运。 */
|
||||
@Test void pickupPhotoAndDispatchProgressRollbackTogetherThenRetry() throws Exception {
|
||||
var f=new DispatchAggregatePersistenceTest.Fixture();var selection=f.source(1,"101",501,"ONT8",3);
|
||||
var created=f.tx.execute(s->f.service.create(f.actor,f.configured("photo-rollback-create",List.of(selection))));var number=created.dispatchNo();
|
||||
f.db.update("UPDATE ll_dispatch_order SET stage='LOADING',review_state='APPROVED',review_round=1");
|
||||
f.db.update("UPDATE ll_dispatch_item SET warehouse_pallet_id=NULL");
|
||||
var root=f.repository.find(number);var item=f.repository.cargo(root.getId()).get(0);
|
||||
f.session.getConfiguration().addMapper(DispatchLooseboardEvidenceMapper.class);
|
||||
var evidence=new DispatchLooseboardEvidenceRepository(f.session.getMapper(DispatchLooseboardEvidenceMapper.class));
|
||||
var linked=mock(LooseboardCargoRepository.class);when(linked.businessSources(anyList())).thenReturn(List.of(new LooseboardCargoMapper.BusinessSource(item.getCustomerId(),item.getOrderNo(),"LB1",item.getPlanPalletId())));
|
||||
var facts=new DispatchLooseboardFacts(f.repository,new DispatchLooseboardDisplayService(linked),evidence,f.json);
|
||||
var workflow=mock(DispatchAggregateService.class);var context=mock(Context.class);
|
||||
when(workflow.preview(eq(f.actor),eq(number))).thenReturn(context);when(workflow.lockForArrangement(eq(f.actor),eq(number))).thenReturn(context);
|
||||
when(context.resources()).thenReturn(new Resources(new VehicleSnapshot(DispatchTypes.ResourceMode.MASTER,1L,"TEST",null,"IN_SERVICE",new Capacity(10,new java.math.BigDecimal("999999"),new java.math.BigDecimal("999999")),List.of()),null,null));
|
||||
var files=mock(DispatchEvidenceGateway.class);when(files.metadata(anyLong(),any(),eq(77L))).thenReturn(new DispatchEvidenceGateway.File(77L,"pickup.png","image/png",10));
|
||||
var fleet=mock(FleetTripExecutionService.class);var outbounds=mock(DispatchOutboundPort.class);
|
||||
var target=new DispatchLooseboardExecutionService(workflow,f.repository,facts,files,outbounds,fleet,mock(FleetTripCompletionService.class),mock(DispatchDeliveryPort.class),mock(LooseboardTransportPort.class),new DispatchCommandHasher(f.json),f.json);
|
||||
var proxy=new ProxyFactory(target);proxy.setProxyTargetClass(true);proxy.addAdvice(new TransactionInterceptor(f.transactions,new AnnotationTransactionAttributeSource()));
|
||||
var service=(DispatchLooseboardExecutionService)proxy.getProxy();
|
||||
var request=new DispatchLooseboardExecutionService.Save(root.getRecordVersion(),"pickup-rollback-original",List.of(77L));
|
||||
doThrow(new IllegalStateException("模拟发车故障")).when(fleet).departPhotoDispatchShell(eq(number),anyString(),any(),anyString());
|
||||
assertThatThrownBy(()->service.save(f.actor,number,item.getId(),DispatchLooseboardFacts.Phase.PICKUP,request)).hasMessageContaining("模拟发车故障");
|
||||
assertThat(evidence.snapshots(root.getId())).isEmpty();assertThat(f.repository.find(number).getRecordVersion()).isEqualTo(root.getRecordVersion());
|
||||
assertThat(f.repository.find(number).getStage()).isEqualTo("LOADING");assertThat(f.repository.find(number).getShippedPackageCount()).isZero();
|
||||
doNothing().when(fleet).departPhotoDispatchShell(eq(number),anyString(),any(),anyString());
|
||||
assertThat(service.save(f.actor,number,item.getId(),DispatchLooseboardFacts.Phase.PICKUP,request).stage()).isEqualTo("DELIVERING");
|
||||
assertThat(evidence.snapshots(root.getId())).hasSize(1);assertThat(f.repository.find(number).getPickedPackageCount()).isZero();
|
||||
service.save(f.actor,number,item.getId(),DispatchLooseboardFacts.Phase.PICKUP,request);
|
||||
assertThat(evidence.snapshots(root.getId())).hasSize(1);
|
||||
// 追加与删除采用新的不可变列表快照;删除失败回滚时,原两张照片仍可读取。
|
||||
when(files.metadata(anyLong(),any(),eq(78L))).thenReturn(new DispatchEvidenceGateway.File(78L,"extra.png","image/png",10));
|
||||
long version=f.repository.find(number).getRecordVersion();
|
||||
service.save(f.actor,number,item.getId(),DispatchLooseboardFacts.Phase.PICKUP,new DispatchLooseboardExecutionService.Save(version,"pickup-append-photos",List.of(77L,78L)));
|
||||
long appended=f.repository.find(number).getRecordVersion();
|
||||
var deletion=new DispatchLooseboardExecutionService.Save(appended,"pickup-delete-photo",List.of(78L));
|
||||
assertThatThrownBy(()->f.tx.execute(s->{service.save(f.actor,number,item.getId(),DispatchLooseboardFacts.Phase.PICKUP,deletion);throw new IllegalStateException("模拟删除后故障");})).hasMessageContaining("模拟删除后故障");
|
||||
assertThat(service.get(f.actor,number).items().get(0).pickupPhotos()).extracting(DispatchEvidenceGateway.File::fileId).containsExactly(77L,78L);
|
||||
assertThat(f.repository.find(number).getRecordVersion()).isEqualTo(appended);
|
||||
service.save(f.actor,number,item.getId(),DispatchLooseboardFacts.Phase.PICKUP,deletion);
|
||||
assertThat(service.get(f.actor,number).items().get(0).pickupPhotos()).extracting(DispatchEvidenceGateway.File::fileId).containsExactly(78L);
|
||||
assertThat(evidence.snapshots(root.getId())).hasSize(3);
|
||||
assertThatThrownBy(()->service.content(f.actor,number,item.getId(),DispatchLooseboardFacts.Phase.PICKUP,77)).hasMessageContaining("未绑定");
|
||||
assertThat(f.repository.find(number).getStage()).isEqualTo("DELIVERING");
|
||||
assertThat(f.repository.find(number).getShippedPackageCount()).isEqualTo(3);
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -229,7 +229,7 @@ class DispatchMergedRelabelPersistenceTest {
|
||||
var schema=Files.readString(Path.of("../deploy/sql/海外区域统一增量-MySQL8.sql"));
|
||||
for(var table:List.of("ll_order","ll_inventory_position","ll_inventory_balance","ll_inventory_ledger","ll_inventory_operation","ll_physical_pallet_guard",
|
||||
"ll_relabel_application","ll_relabel_item","ll_relabel_pda_task","ll_relabel_pda_history","ll_relabel_pda_unit_fact","ll_inbound_plan","ll_inbound_plan_pallet",
|
||||
"ll_inbound_stock_receipt","ll_dispatch_item_source","ll_dispatch_physical_pallet_lock","ll_dispatch_pallet_lock","ll_dispatch_pda_task","ll_dispatch_pda_pick","ll_dispatch_pda_pick_order","ll_cargo_discard_carton","ll_warehouse_transfer_inventory_allocation","ll_warehouse_transfer_order","ll_warehouse_transfer_pallet",
|
||||
"ll_inbound_stock_receipt","ll_dispatch_item_source","ll_dispatch_physical_pallet_lock","ll_dispatch_pallet_lock","ll_dispatch_pda_task","ll_dispatch_pda_pick","ll_dispatch_pda_pick_order","ll_cargo_discard_carton","ll_warehouse_transfer_inventory_allocation","ll_warehouse_transfer_order","ll_local_looseboard_order","ll_warehouse_transfer_pallet",
|
||||
"ll_inbound_plan_line","ll_inbound_plan_pallet_allocation","ll_inbound_plan_carton","ll_order_detail","ll_inbound_order","ll_inbound_fba_appointment","ll_inbound_fba_appointment_item","ll_order_manual_node","ll_container_shipping_gate",
|
||||
"ll_return_inventory_carton","ll_return_receipt","ll_return_stock_receipt","ll_exception_order","ll_delivery_order")) {
|
||||
f.db.execute("DROP TABLE IF EXISTS "+table);int start=schema.indexOf("CREATE TABLE "+table+" (");int end=schema.indexOf(") ENGINE=",start);
|
||||
|
||||
+1
@@ -19,6 +19,7 @@ class DispatchRegionalCandidatesTest {
|
||||
var service=new DispatchAggregateService(null,null,sources,null,null,null,new com.fasterxml.jackson.databind.ObjectMapper());
|
||||
org.springframework.test.util.ReflectionTestUtils.setField(service, "storageRent", org.mockito.Mockito.mock(com.baiyue.lastleg.storagerent.service.StorageRentService.class));
|
||||
ReflectionTestUtils.setField(service,"appointments",appointments);
|
||||
ReflectionTestUtils.setField(service,"looseboardDisplay",new DispatchLooseboardDisplayService(mock(com.baiyue.lastleg.dispatch.repository.LooseboardCargoRepository.class)));
|
||||
var actor=new Actor("7",ActorMode.PLATFORM,null);
|
||||
when(sources.candidatePage(eq("7"),any())).thenReturn(new DispatchService.CandidatePage(List.of(),41));
|
||||
when(sources.selectionGroups(List.of())).thenReturn(Map.of());when(appointments.candidates(List.of())).thenReturn(Map.of());
|
||||
|
||||
+12
@@ -117,6 +117,18 @@ class DispatchServiceTest {
|
||||
@Test void sourceIdentityCannotCombinePlanAndReturnReceipt(){
|
||||
assertThatThrownBy(()->DispatchService.wholeSourceKey(new DispatchCommands.CargoSelection(DispatchTypes.SourceType.INBOUND_PLAN,1L,2L,null,501L,"v"))).hasMessageContaining("来源字段");
|
||||
}
|
||||
/** 未到仓计划只锁真实订单和客户计划;空实托集合不进入库存串行器。 */
|
||||
@Test void plannedSourceLockRequiresOwnershipWithoutPhysicalStock(){
|
||||
var item=new com.baiyue.lastleg.dispatch.entity.DispatchItem();
|
||||
item.setCustomerId("101");item.setOrderId(901L);item.setPlanPalletId(11L);
|
||||
when(sources.lockOrder("101",901L)).thenReturn(901L);
|
||||
when(sources.lockPlan("101",11L)).thenReturn(11L);
|
||||
service.lockAppointmentSources("9",List.of(item));
|
||||
verify(sources).lockOrder("101",901L);verify(sources).lockPlan("101",11L);
|
||||
verifyNoInteractions(palletSerial,facts);
|
||||
when(sources.lockPlan("101",11L)).thenReturn(null);
|
||||
assertThatThrownBy(()->service.lockAppointmentSources("9",List.of(item))).hasMessageContaining("归属变化");
|
||||
}
|
||||
private static DispatchPalletCandidate row(long plan,long pallet,String customer){return new DispatchPalletCandidate().planPalletId(plan).warehousePalletId(pallet).customerId(customer).overseasWarehouseId("9").sourceStage("RECEIVED").packageCount(3).orderNo("ORDER-"+plan).palletNo("PALLET-"+pallet);}
|
||||
/** 换标生产者没有计划托盘别名,始终提供真实申请业务号与实托。 */
|
||||
private static DispatchPalletCandidate relabelRow(long pallet){return row(0,pallet,"101").planPalletId(null).sourceStage("RELABEL").orderRefs("RL-1");}
|
||||
|
||||
+46
@@ -0,0 +1,46 @@
|
||||
package com.baiyue.lastleg.exceptionorder.service;
|
||||
|
||||
import com.baiyue.lastleg.mapper.DeliveryReturnCargoMapper;
|
||||
import org.apache.ibatis.mapping.Environment;
|
||||
import org.apache.ibatis.session.*;
|
||||
import org.apache.ibatis.transaction.jdbc.JdbcTransactionFactory;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.jdbc.datasource.DriverManagerDataSource;
|
||||
import java.util.UUID;
|
||||
import static org.assertj.core.api.Assertions.*;
|
||||
|
||||
/** 使用真实Mapper及隔离数据库核对来源关联、部分箱数与客户隔离。 */
|
||||
class DeliveryReturnCargoMapperTest {
|
||||
/** 单一来源带出SKU品名及精确数量;多个不明SKU不得复制整票数据。 */
|
||||
@Test void realMapperReadsUniqueSourceAndDoesNotGuessMixedSku(){
|
||||
var ds=new DriverManagerDataSource("jdbc:h2:mem:"+UUID.randomUUID()+";MODE=MySQL;DB_CLOSE_DELAY=-1","sa","");var db=new JdbcTemplate(ds);
|
||||
db.execute("CREATE TABLE ll_order(id BIGINT,customer_id VARCHAR(20),order_type VARCHAR(32),source_region VARCHAR(32),source_inbound_id VARCHAR(32),inbound_code VARCHAR(50))");
|
||||
db.execute("CREATE TABLE ll_exception_order(id BIGINT,customer_id VARCHAR(20),last_mile_order_id BIGINT)");
|
||||
db.execute("CREATE TABLE ll_exception_return_item(id BIGINT,customer_id VARCHAR(20),exception_order_id BIGINT,inventory_position_id BIGINT,sku_code VARCHAR(50),approved_return_packages BIGINT,received_packages BIGINT)");
|
||||
db.execute("CREATE TABLE ll_inventory_position(id BIGINT,customer_id VARCHAR(20),balance_id BIGINT)");
|
||||
db.execute("CREATE TABLE ll_inventory_balance(id BIGINT,customer_id VARCHAR(20),inbound_code VARCHAR(50),sku_code VARCHAR(50),product_name_zh VARCHAR(50),product_name_en VARCHAR(50))");
|
||||
db.execute("CREATE TABLE ll_order_detail(id BIGINT,customer_id VARCHAR(20),order_id BIGINT,sku_code VARCHAR(50),product_name_zh VARCHAR(50),product_name_en VARCHAR(50),package_count INT,product_quantity INT,gross_weight_kg DECIMAL(18,3),net_weight_kg DECIMAL(18,3),volume_cbm DECIMAL(18,6),shipping_mark VARCHAR(50),shipment_no VARCHAR(50),fba_tracking_no VARCHAR(50),source_inbound_detail_id VARCHAR(50))");
|
||||
db.update("INSERT INTO ll_order VALUES(10,'C1','DELIVERY_RETURN','EXCEPTION_RETURN','1',NULL),(9,'C1','WAREHOUSE_TRANSFER','CUSTOMER',NULL,'IN9')");
|
||||
db.update("INSERT INTO ll_exception_order VALUES(1,'C1',9)");db.update("INSERT INTO ll_exception_return_item VALUES(2,'C1',1,3,NULL,1,0)");
|
||||
db.update("INSERT INTO ll_inventory_position VALUES(3,'C1',4)");db.update("INSERT INTO ll_inventory_balance VALUES(4,'C1','IN9',NULL,NULL,NULL)");
|
||||
db.update("INSERT INTO ll_order_detail VALUES(5,'C1',9,'SKU1','测试货物',NULL,2,4,4,3,0.02,NULL,NULL,NULL,NULL)");
|
||||
var config=new Configuration(new Environment("test",new JdbcTransactionFactory(),ds));config.setMapUnderscoreToCamelCase(true);config.addMapper(DeliveryReturnCargoMapper.class);
|
||||
try(var session=new SqlSessionFactoryBuilder().build(config).openSession()){
|
||||
var mapper=session.getMapper(DeliveryReturnCargoMapper.class);var rows=mapper.cargo("C1",10);
|
||||
assertThat(rows).hasSize(1);assertThat(rows.get(0).getSkuCode()).isEqualTo("SKU1");assertThat(rows.get(0).getProductNameZh()).isEqualTo("测试货物");
|
||||
assertThat(rows.get(0).getPackageCount()).isEqualTo(1);assertThat(rows.get(0).getProductQuantity()).isEqualTo(2);
|
||||
assertThat(rows.get(0).getGrossWeightKg()).isEqualByComparingTo("2");assertThat(rows.get(0).getVolumeCbm()).isEqualByComparingTo("0.01");
|
||||
assertThat(mapper.cargo("C2",10)).isEmpty();
|
||||
// 生成时保存待退箱数,后续实收不能使详情数量随剩余量缩水。
|
||||
db.update("INSERT INTO ll_order_detail VALUES(7,'C1',10,NULL,NULL,NULL,1,NULL,NULL,NULL,NULL,NULL,NULL,NULL,'2')");
|
||||
db.update("UPDATE ll_exception_return_item SET received_packages=1 WHERE id=2");session.clearCache();
|
||||
assertThat(mapper.cargo("C1",10).get(0).getPackageCount()).isEqualTo(1);
|
||||
// 多张来源订单共用旧入仓码时,仍只能返回一条冻结退回货物。
|
||||
db.update("INSERT INTO ll_order VALUES(8,'C1','FIRST_LEG','CN',NULL,'IN9')");session.clearCache();
|
||||
assertThat(mapper.cargo("C1",10)).hasSize(1);
|
||||
db.update("INSERT INTO ll_order_detail VALUES(6,'C1',9,'SKU2','另一货物',NULL,2,8,6,5,0.03,NULL,NULL,NULL,NULL)");session.clearCache();
|
||||
var mixed=mapper.cargo("C1",10).get(0);assertThat(mixed.getSkuCode()).isNull();assertThat(mixed.getProductNameZh()).isNull();assertThat(mixed.getProductQuantity()).isNull();
|
||||
}
|
||||
}
|
||||
}
|
||||
+15
@@ -164,6 +164,21 @@ class ExceptionOrderLifecycleTest {
|
||||
assertThat(item.getValue().getReceivedPackages()).isEqualTo(1);
|
||||
}
|
||||
|
||||
/** 已建计划的退回单按真实实收推进,分批不能退回未计划状态。 */
|
||||
@org.junit.jupiter.params.ParameterizedTest
|
||||
@org.junit.jupiter.params.provider.ValueSource(ints = {1, 4})
|
||||
void linkedPlanTracksActualPartialOrCompleteReceipt(int amount) {
|
||||
partialReturn(4, 10, amount);
|
||||
var workflow=(com.baiyue.lastleg.exceptionorder.repository.ExceptionWorkflowRepository)ReflectionTestUtils.getField(service,"workflow");
|
||||
var tail=new com.baiyue.lastleg.entity.LastlegOrder();tail.setId(99L);tail.setVersion(2);tail.setInboundOrderNo("IP1");
|
||||
when(workflow.returnOrder(any())).thenReturn(tail);
|
||||
service.complete("1001", "9001", "EX-1", receipt(amount));
|
||||
assertThat(tail.getCurrentStatus()).isEqualTo(amount==4?"INBOUND_COMPLETED":"PARTIAL_INBOUND");
|
||||
assertThat(tail.getReceivedPackageCount()).isEqualTo(amount);
|
||||
assertThat(tail.getInboundOrderNo()).isEqualTo("IP1");
|
||||
verify(workflow).update(tail);
|
||||
}
|
||||
|
||||
@Test
|
||||
void fullyReceivingAPartialReturnCaseDoesNotCloseTheRemainingDeliveryCargo() {
|
||||
var delivery = partialReturn(2, 10, 2);
|
||||
|
||||
+1
-1
@@ -38,7 +38,7 @@ class ExceptionReturnInboundServiceTest {
|
||||
var generated=service.generate("C1","7","EX1",new ExceptionOrderRequests.StartProcessing(0,"key",null));
|
||||
assertThat(generated.getSourceOrderNo()).isEqualTo("WC9");assertThat(generated.getSourceInboundId()).isEqualTo("1");
|
||||
assertThat(generated.getPackageCount()).isEqualTo(2);assertThat(generated.getReceivedPackageCount()).isZero();
|
||||
assertThat(generated.getCurrentStatus()).isEqualTo("PENDING_INBOUND");assertThat(generated.getInboundCode()).isNull();
|
||||
assertThat(generated.getCurrentStatus()).isEqualTo("PENDING_INBOUND");assertThat(generated.getInboundCode()).isEqualTo("WC10");
|
||||
when(repository.returnOrder(exception)).thenReturn(generated);
|
||||
assertThat(service.generate("C1","7","EX1",new ExceptionOrderRequests.StartProcessing(0,"retry",null))).isSameAs(generated);
|
||||
verify(repository,times(1)).insert(any()); verify(repository).copyDetails(generated,exception);
|
||||
|
||||
+73
@@ -0,0 +1,73 @@
|
||||
package com.baiyue.lastleg.exceptionorder.service;
|
||||
|
||||
import com.baiyue.lastleg.entity.LastlegOrder;
|
||||
import com.baiyue.lastleg.entity.LastlegOrderCommandRecord;
|
||||
import com.baiyue.lastleg.exceptionorder.dto.ExceptionReturnPlanRequest;
|
||||
import com.baiyue.lastleg.exceptionorder.entity.ExceptionOrder;
|
||||
import com.baiyue.lastleg.exceptionorder.repository.*;
|
||||
import com.baiyue.lastleg.inboundplan.dto.ManualInboundPlanRequest;
|
||||
import com.baiyue.lastleg.inboundplan.entity.InboundPlan;
|
||||
import com.baiyue.lastleg.inboundplan.pda.gateway.WarehouseResourceGateway;
|
||||
import com.baiyue.lastleg.inboundplan.service.InboundPlanService;
|
||||
import com.baiyue.lastleg.service.LastlegOrderNoGenerator;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.mockito.ArgumentCaptor;
|
||||
import java.time.LocalDateTime;
|
||||
import static org.mockito.Mockito.*;
|
||||
import static org.assertj.core.api.Assertions.*;
|
||||
|
||||
/** 异常退回计划不依赖来源柜,并保持版本、客户及原键边界。 */
|
||||
class ExceptionReturnPlanServiceTest {
|
||||
final ExceptionReturnPlanRepository repository=mock(ExceptionReturnPlanRepository.class);
|
||||
final ExceptionWorkflowRepository exceptions=mock(ExceptionWorkflowRepository.class);
|
||||
final WarehouseResourceGateway warehouse=mock(WarehouseResourceGateway.class);
|
||||
final InboundPlanService plans=mock(InboundPlanService.class);
|
||||
final LastlegOrderNoGenerator numbers=mock(LastlegOrderNoGenerator.class);
|
||||
final ExceptionReturnPlanService service=new ExceptionReturnPlanService(repository,exceptions,warehouse,plans,numbers,new ObjectMapper().findAndRegisterModules());
|
||||
final LocalDateTime arrival=LocalDateTime.of(2026,11,5,14,30);
|
||||
/** 请求只带本单、版本和到仓时间,不构造不存在的柜号。 */
|
||||
ExceptionReturnPlanRequest request(){return new ExceptionReturnPlanRequest(new ManualInboundPlanRequest("C1",10L,0,1,"WOOD",false,false,false,"退回计划"),arrival);}
|
||||
/** 准备合法且尚未收货的异常及退回单。 */
|
||||
LastlegOrder ready(){
|
||||
var order=new LastlegOrder();order.setId(10L);order.setCustomerId("C1");order.setOrderNo("WC10");
|
||||
order.setOrderType("DELIVERY_RETURN");order.setSourceRegion("EXCEPTION_RETURN");order.setSourceEventId("EX1");order.setSourceInboundId("1");
|
||||
order.setCurrentStatus("PENDING_INBOUND");order.setOverseasWarehouseId("11");order.setVersion(0);order.setPackageCount(1);
|
||||
when(repository.find("C1",10)).thenReturn(order);when(repository.lock("C1",10)).thenReturn(order);
|
||||
var exception=new ExceptionOrder();exception.setId(1L);exception.setStatus("PROCESSING");exception.setReturnDirection("OVERSEAS_WAREHOUSE");exception.setOverseasWarehouseId("11");
|
||||
when(exceptions.lock("C1","EX1")).thenReturn(exception);
|
||||
when(warehouse.requireActiveSharedWarehouse(11)).thenReturn(new WarehouseResourceGateway.WarehouseReference(11,"LAX","洛杉矶仓","ACTIVE"));
|
||||
when(numbers.next("INBOUND_PLAN")).thenReturn("IP1");return order;
|
||||
}
|
||||
/** 无来源柜仍生成计划,保留新旧订单关系且不调用任何库存接口。 */
|
||||
@Test void standaloneReturnCreatesLinkedPlanWithExplicitArrival(){
|
||||
var order=ready();service.create(request(),"key","7");
|
||||
var plan=ArgumentCaptor.forClass(InboundPlan.class);verify(repository).insert(plan.capture(),any());
|
||||
assertThat(plan.getValue().getSourceOrderId()).isEqualTo(10L);assertThat(plan.getValue().getContainerNo()).isEmpty();
|
||||
assertThat(plan.getValue().getPlannedArrivalDate()).isEqualTo(arrival.toLocalDate());assertThat(plan.getValue().getCreationMode()).isEqualTo("EXCEPTION_RETURN");
|
||||
assertThat(plan.getValue().getStatus()).isEqualTo("PENDING_RECEIVING");
|
||||
assertThat(order.getEstimatedWarehouseAt()).isEqualTo(arrival);assertThat(order.getInboundCode()).isEqualTo("WC10");
|
||||
assertThat(order.getInboundOrderNo()).isEqualTo("IP1");assertThat(order.getCurrentStatus()).isEqualTo("INBOUND_PLANNED");
|
||||
assertThat(order.getReceivedPackageCount()).isNull();verify(warehouse).requireActiveSharedWarehouse(11);verifyNoMoreInteractions(warehouse);
|
||||
}
|
||||
/** 预计到仓为空不能建单,跨客户目标在查不到订单时失败关闭。 */
|
||||
@Test void missingArrivalAndForeignOrderNeverWrite(){
|
||||
assertThatThrownBy(()->service.create(new ExceptionReturnPlanRequest(request().plan(),null),"key","7")).hasMessageContaining("预计到仓");
|
||||
assertThatThrownBy(()->service.create(request(),"key","7")).hasMessageContaining("不存在");verify(repository,never()).insert(any(InboundPlan.class),any());
|
||||
}
|
||||
/** 本单版本或异常目标仓变化均不能写计划。 */
|
||||
@Test void staleVersionAndChangedWarehouseNeverWrite(){
|
||||
var order=ready();order.setVersion(1);
|
||||
assertThatThrownBy(()->service.create(request(),"key","7")).hasMessageContaining("版本");
|
||||
order.setVersion(0);order.setOverseasWarehouseId("12");
|
||||
assertThatThrownBy(()->service.create(request(),"key","7")).hasMessageContaining("目标仓");verify(repository,never()).insert(any(InboundPlan.class),any());
|
||||
}
|
||||
/** 原键重放只恢复计划;变更到仓时间必须拒绝,不能多建单。 */
|
||||
@Test void replayRestoresOnlyOriginalRequest(){
|
||||
ready();service.create(request(),"key","7");var command=ArgumentCaptor.forClass(LastlegOrderCommandRecord.class);verify(repository).insert(command.capture());
|
||||
when(repository.command("C1",10,"7","key")).thenReturn(command.getValue());
|
||||
service.create(request(),"key","7");
|
||||
assertThatThrownBy(()->service.create(new ExceptionReturnPlanRequest(request().plan(),arrival.plusDays(1)),"key","7")).hasMessageContaining("幂等键");
|
||||
verify(repository,times(1)).insert(any(InboundPlan.class),any());
|
||||
}
|
||||
}
|
||||
+11
@@ -98,6 +98,17 @@ class InboundPdaWorkflowServiceTest {
|
||||
lenient().when(orders.selectById(1001L)).thenReturn(sourceOrder);
|
||||
}
|
||||
|
||||
/** 异常退回计划只允许原异常验收,不能产生第二笔普通入库库存。 */
|
||||
@Test
|
||||
void exceptionReturnCannotPushOrdinaryInboundTask() {
|
||||
plan.setCreationMode("EXCEPTION_RETURN");
|
||||
var error=assertThrows(ResponseStatusException.class,
|
||||
() -> service.push("100", "LIP1", new PdaRequests.Push("return-plan", "7", "worker")));
|
||||
assertTrue(error.getReason().contains("异常"));
|
||||
verify(repository, never()).insert(any(InboundPdaTask.class));
|
||||
org.mockito.Mockito.verifyNoInteractions(stockReceipts, warehouse);
|
||||
}
|
||||
|
||||
@Test
|
||||
void pushRequiresEveryPalletToBeAssignedAndLocked() {
|
||||
targetPallet.setWarehouseResourceLockId(null);
|
||||
|
||||
+16
@@ -125,6 +125,22 @@ class AppointmentWorkDispatchBindingTest {
|
||||
when(mapper.pickedPhysical(30,"C")).thenReturn(List.of(new AppointmentWorkDispatchMapper.Physical(1L,"C","CUSTOMER:1",null,4)));
|
||||
assertDoesNotThrow(()->service.requireDeparture("D-20"));verifyNoInteractions(appointments);
|
||||
}
|
||||
/** 照片直送核对计划逐票数量,普通发车仍要求实拣且不能借其他客户补足。 */
|
||||
@Test void photoDepartureUsesOriginalPlannedSourcesWithoutInventingPicking() {
|
||||
var mapper=mock(AppointmentWorkDispatchMapper.class);var sources=mock(AppointmentSourceService.class);
|
||||
var service=new AppointmentWorkDispatchBinding(mapper,mock(InboundFbaAppointmentService.class),sources,mock(AppointmentWorkChanges.class),mock(com.baiyue.lastleg.inboundplan.receiving.FbaReceivingStore.class),mock(AppointmentWorkDispatchCandidates.class));
|
||||
when(mapper.dispatchPhysical("D")).thenReturn(List.of(new AppointmentWorkDispatchMapper.DispatchPhysical(30,10L,"C","PLAN",4)));
|
||||
when(mapper.bindings("D")).thenReturn(List.of(new AppointmentWorkDispatchMapper.Bound(20,30,10,"DISPATCH:20","C","ORDER:1","CUSTOMER:1",4)));
|
||||
when(mapper.physical(10,"C")).thenReturn(List.of(new AppointmentWorkDispatchMapper.Physical(1L,"C","CUSTOMER:1",4,null)));
|
||||
when(sources.canonicalOrders(anyList())).thenReturn(List.of(new AppointmentCoverageService.SourceRef("C","ORDER:1",null)));
|
||||
assertDoesNotThrow(()->service.requirePhotoDeparture("D"));
|
||||
verify(mapper,never()).pickedPhysical(anyLong(),anyString());
|
||||
assertThrows(org.springframework.web.server.ResponseStatusException.class,()->service.requireDeparture("D"));
|
||||
when(mapper.physical(10,"C")).thenReturn(List.of(new AppointmentWorkDispatchMapper.Physical(1L,"C","CUSTOMER:1",3,null)));
|
||||
assertThrows(org.springframework.web.server.ResponseStatusException.class,()->service.requirePhotoDeparture("D"));
|
||||
when(mapper.bindings("D")).thenReturn(List.of(new AppointmentWorkDispatchMapper.Bound(20,30,10,"DISPATCH:20","OTHER","ORDER:1","CUSTOMER:1",4)));
|
||||
assertThrows(org.springframework.web.server.ResponseStatusException.class,()->service.requirePhotoDeparture("D"));
|
||||
}
|
||||
@Test void partialPickingKeepsReviewEligibleButDepartureStillRequiresAllOriginalSources() {
|
||||
var mapper=mock(AppointmentWorkDispatchMapper.class);var sources=mock(AppointmentSourceService.class);
|
||||
var service=new AppointmentWorkDispatchBinding(mapper,mock(InboundFbaAppointmentService.class),sources,mock(AppointmentWorkChanges.class),mock(com.baiyue.lastleg.inboundplan.receiving.FbaReceivingStore.class),mock(AppointmentWorkDispatchCandidates.class));
|
||||
|
||||
+3
-1
@@ -64,6 +64,8 @@ class AppointmentWorkDispatchPersistenceTest {
|
||||
jdbc.execute("CREATE TABLE ll_inventory_position(id BIGINT,customer_id VARCHAR(40),pallet_id BIGINT,balance_id BIGINT,available_packages INT,allocated_packages INT,picked_packages INT)");
|
||||
jdbc.execute("CREATE TABLE ll_inventory_balance(id BIGINT,customer_id VARCHAR(40),inbound_code VARCHAR(128))");
|
||||
jdbc.execute("CREATE TABLE ll_order(id BIGINT PRIMARY KEY,customer_id VARCHAR(40),inbound_code VARCHAR(128),destination_type VARCHAR(30),fba_warehouse_code VARCHAR(40),order_no VARCHAR(40))");
|
||||
jdbc.execute("ALTER TABLE ll_order ADD order_type VARCHAR(40)");
|
||||
jdbc.execute("CREATE TABLE ll_warehouse_transfer_order(lastleg_order_id BIGINT,customer_id VARCHAR(40),destination_type VARCHAR(30),destination_warehouse_code VARCHAR(40))");
|
||||
jdbc.execute("CREATE TABLE ll_dispatch_pda_pick(id BIGINT,customer_id VARCHAR(40),dispatch_item_id BIGINT)");
|
||||
jdbc.execute("CREATE TABLE ll_dispatch_pda_pick_order(pick_id BIGINT,customer_id VARCHAR(40),order_id BIGINT,packages INT)");
|
||||
jdbc.execute("CREATE TABLE ll_dispatch_appointment_allocation(dispatch_id BIGINT,dispatch_item_id BIGINT,plan_pallet_id BIGINT,batch_key VARCHAR(60),customer_id VARCHAR(40),source_key VARCHAR(40),destination_key VARCHAR(40),package_count INT)");
|
||||
@@ -107,7 +109,7 @@ class AppointmentWorkDispatchPersistenceTest {
|
||||
jdbc.update("INSERT INTO ll_inventory_position VALUES(?,?,?,?,?,0,0)",item,customer,item+1000,item,count);
|
||||
jdbc.update("INSERT INTO ll_inventory_balance VALUES(?,?,?)",item,customer,"IN-"+order);
|
||||
if(jdbc.queryForObject("SELECT COUNT(*) FROM ll_order WHERE id=?",Integer.class,order)==0)
|
||||
jdbc.update("INSERT INTO ll_order VALUES(?,?,?,?,'ONT8',?)",order,customer,"IN-"+order,destination,"WC-"+order);
|
||||
jdbc.update("INSERT INTO ll_order(id,customer_id,inbound_code,destination_type,fba_warehouse_code,order_no) VALUES(?,?,?,?,'ONT8',?)",order,customer,"IN-"+order,destination,"WC-"+order);
|
||||
jdbc.update("INSERT INTO ll_inbound_plan_pallet(id,pallet_no,plan_id,customer_id,warehouse_pallet_id) VALUES(?,?,1,?,?)",item+100,"P-"+item,customer,item+1000);
|
||||
jdbc.update("INSERT INTO ll_inbound_plan_line VALUES(?,?,?,?, 'ONT8')",item,order,customer,destination);
|
||||
jdbc.update("INSERT INTO ll_inbound_plan_pallet_allocation VALUES(?,?,?,?,?)",item+100,item,customer,count,count);
|
||||
|
||||
+11
-1
@@ -18,9 +18,11 @@ class AppointmentWorkReceivedPhysicalTest {
|
||||
db.execute("CREATE TABLE ll_inventory_position(id BIGINT,customer_id VARCHAR,pallet_id BIGINT,balance_id BIGINT,available_packages INT,allocated_packages INT,picked_packages INT,hold_packages INT)");
|
||||
db.execute("CREATE TABLE ll_inventory_balance(id BIGINT,customer_id VARCHAR,inbound_code VARCHAR)");
|
||||
db.execute("CREATE TABLE ll_order(id BIGINT,customer_id VARCHAR,inbound_code VARCHAR,destination_type VARCHAR,fba_warehouse_code VARCHAR)");
|
||||
db.execute("ALTER TABLE ll_order ADD order_type VARCHAR");
|
||||
db.execute("CREATE TABLE ll_warehouse_transfer_order(lastleg_order_id BIGINT,customer_id VARCHAR,destination_type VARCHAR,destination_warehouse_code VARCHAR)");
|
||||
db.update("INSERT INTO ll_inbound_plan_pallet VALUES(10,'C',99)");
|
||||
db.update("INSERT INTO ll_inventory_balance VALUES(1,'C','IN1'),(2,'C','IN2'),(3,'OTHER','IN1')");
|
||||
db.update("INSERT INTO ll_order VALUES(101,'C','IN1','FBA','ONT8'),(102,'C','IN2','PRIVATE',NULL),(201,'OTHER','IN1','FBA','ONT8')");
|
||||
db.update("INSERT INTO ll_order(id,customer_id,inbound_code,destination_type,fba_warehouse_code) VALUES(101,'C','IN1','FBA','ONT8'),(102,'C','IN2','PRIVATE',NULL),(201,'OTHER','IN1','FBA','ONT8')");
|
||||
db.update("INSERT INTO ll_inventory_position VALUES(1,'C',99,1,0,1,0,5),(2,'C',99,2,1,0,1,0),(3,'OTHER',99,3,99,0,0,0),(4,'C',100,1,99,0,0,0)");
|
||||
var config = new Configuration(new Environment("test", new JdbcTransactionFactory(), ds));
|
||||
config.setMapUnderscoreToCamelCase(true); config.addMapper(AppointmentWorkDispatchMapper.class);
|
||||
@@ -33,6 +35,14 @@ class AppointmentWorkReceivedPhysicalTest {
|
||||
assertEquals("CUSTOMER:102",rows.stream().filter(r->r.orderId()==102).findFirst().orElseThrow().destinationKey());
|
||||
assertTrue(rows.stream().allMatch(r->r.plannedPackages()==null && r.customerId().equals("C")));
|
||||
assertTrue(mapper.receivedPhysical(10,"OTHER").isEmpty());
|
||||
// 转运来源以已审核转运目的地为准;缺少权威目的地继续拒绝,不能猜测。
|
||||
db.update("UPDATE ll_order SET order_type='WAREHOUSE_TRANSFER',destination_type=NULL WHERE id=102");
|
||||
db.update("INSERT INTO ll_warehouse_transfer_order VALUES(102,'OTHER','FBA','WRONG'),(102,'C','PRIVATE',NULL)");
|
||||
session.clearCache();
|
||||
assertEquals("CUSTOMER:102",mapper.receivedPhysical(10,"C").stream().filter(r->r.orderId()==102).findFirst().orElseThrow().destinationKey());
|
||||
db.update("UPDATE ll_warehouse_transfer_order SET destination_type=NULL WHERE customer_id='C'");
|
||||
session.clearCache();
|
||||
assertNull(mapper.receivedPhysical(10,"C").stream().filter(r->r.orderId()==102).findFirst().orElseThrow().destinationKey());
|
||||
db.update("UPDATE ll_inventory_position SET available_packages=0,picked_packages=0 WHERE id=2");
|
||||
session.clearCache(); rows=mapper.receivedPhysical(10,"C");
|
||||
assertEquals(1,rows.size()); assertEquals(1,rows.get(0).countedPackages());
|
||||
|
||||
+3
-3
@@ -11,14 +11,14 @@ import java.util.List;
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
class LocalLooseboardRequestsTest {
|
||||
/** 验证真实 Bean Validation:新增和编辑均要求四项联系人及两个时间。 */
|
||||
/** 验证真实 Bean Validation:新增和编辑均要求取货模式要求起运资料、目的地联系人及两个时间。 */
|
||||
@Test void requiresContactsAndBothLocalTimes() throws Exception {
|
||||
try (var factory = Validation.buildDefaultValidatorFactory()) {
|
||||
var validator = factory.getValidator();
|
||||
var json = new ObjectMapper().findAndRegisterModules();
|
||||
ObjectNode valid = json.valueToTree(request());
|
||||
assertTrue(validator.validate(request()).isEmpty());
|
||||
for (String field : List.of("originContactName", "originContactPhone", "destinationContactName", "destinationContactPhone", "pickupAt", "deliveryAt")) {
|
||||
for (String field : List.of("origin", "originAddress", "originContactName", "originContactPhone", "destinationContactName", "destinationContactPhone", "pickupAt", "deliveryAt")) {
|
||||
ObjectNode missing = valid.deepCopy(); missing.remove(field);
|
||||
assertFalse(validator.validate(json.treeToValue(missing, LocalLooseboardRequests.Create.class)).isEmpty(), field);
|
||||
missing.remove("idempotencyKey"); missing.put("version", 0);
|
||||
@@ -44,7 +44,7 @@ class LocalLooseboardRequestsTest {
|
||||
|
||||
/** 创建具有独立预计托数和件数的最小完整需求。 */
|
||||
private LocalLooseboardRequests.Create request() {
|
||||
return new LocalLooseboardRequests.Create("LA", null, "ONT", null, "货物", 3, 8,
|
||||
return new LocalLooseboardRequests.Create("9", "FBA", null, "WAREHOUSE_PICKUP", null, null, null, null, "LA", "123 Test Road", "ONT", null, "货物", 3, 8,
|
||||
BigDecimal.ZERO, BigDecimal.ZERO, null, "USD", "发货人", "1", "收货人", "2",
|
||||
LocalDateTime.of(2030, 1, 2, 10, 30), LocalDateTime.of(2030, 1, 2, 15, 45), null, "create-1");
|
||||
}
|
||||
|
||||
+2
-2
@@ -41,14 +41,14 @@ class PlatformLocalLooseboardControllerTest {
|
||||
@Test
|
||||
void updateRequiresIndependentEditPermission() {
|
||||
var v = create();
|
||||
var request = new LocalLooseboardRequests.Update(0L, v.origin(), v.originAddress(), v.destination(), v.destinationAddress(), v.productName(), v.estimatedPalletCount(), v.packageCount(), v.totalWeightKg(), v.totalVolumeCbm(), v.budgetAmount(), v.currency(), v.originContactName(), v.originContactPhone(), v.destinationContactName(), v.destinationContactPhone(), v.pickupAt(), v.deliveryAt(), v.remark());
|
||||
var request = new LocalLooseboardRequests.Update("9", "FBA", null, "WAREHOUSE_PICKUP", null, null, null, null, 0L, v.origin(), v.originAddress(), v.destination(), v.destinationAddress(), v.productName(), v.estimatedPalletCount(), v.packageCount(), v.totalWeightKg(), v.totalVolumeCbm(), v.budgetAmount(), v.currency(), v.originContactName(), v.originContactPhone(), v.destinationContactName(), v.destinationContactPhone(), v.pickupAt(), v.deliveryAt(), v.remark());
|
||||
ResponseStatusException error = assertThrows(ResponseStatusException.class,
|
||||
() -> controller.update(user(Set.of(OverseasPermissions.LOOSEBOARD_CREATE)), "100", "LB1", request));
|
||||
assertEquals(403, error.getStatusCode().value());
|
||||
}
|
||||
|
||||
private LocalLooseboardRequests.Create create() {
|
||||
return new LocalLooseboardRequests.Create("LA", null, "Ontario", null, "货物", 1, 1, BigDecimal.ZERO, BigDecimal.ZERO, null, "USD", "A", "1", "B", "2", java.time.LocalDateTime.of(2030, 1, 2, 9, 30), java.time.LocalDateTime.of(2030, 1, 2, 15, 30), null, "key-1");
|
||||
return new LocalLooseboardRequests.Create("9", "FBA", null, "WAREHOUSE_PICKUP", null, null, null, null, "LA", null, "Ontario", null, "货物", 1, 1, BigDecimal.ZERO, BigDecimal.ZERO, null, "USD", "A", "1", "B", "2", java.time.LocalDateTime.of(2030, 1, 2, 9, 30), java.time.LocalDateTime.of(2030, 1, 2, 15, 30), null, "key-1");
|
||||
}
|
||||
|
||||
private AuthenticatedUser user(Set<String> permissions) {
|
||||
|
||||
+2
-2
@@ -192,14 +192,14 @@ class LocalLooseboardServiceTest {
|
||||
}
|
||||
|
||||
private LocalLooseboardRequests.Create create(String key) {
|
||||
return new LocalLooseboardRequests.Create("Los Angeles", "Address A", "Ontario", "Address B",
|
||||
return new LocalLooseboardRequests.Create("9", "FBA", null, "WAREHOUSE_PICKUP", null, null, null, null, "Los Angeles", "Address A", "Ontario", "Address B",
|
||||
"家居用品", 3, 12, new BigDecimal("100.000"), new BigDecimal("2.500000"), new BigDecimal("500.00"),
|
||||
"USD", "Alice", "+1-555-0100", "Bob", "+1-555-0101", LocalDateTime.of(2030, 1, 2, 9, 30), LocalDateTime.of(2030, 1, 2, 10, 30), null, key);
|
||||
}
|
||||
|
||||
private LocalLooseboardRequests.Update update(long version) {
|
||||
var v = create("unused");
|
||||
return new LocalLooseboardRequests.Update(version, v.origin(), v.originAddress(), v.destination(), v.destinationAddress(),
|
||||
return new LocalLooseboardRequests.Update("9", "FBA", null, "WAREHOUSE_PICKUP", null, null, null, null, version, v.origin(), v.originAddress(), v.destination(), v.destinationAddress(),
|
||||
v.productName(), v.estimatedPalletCount(), v.packageCount(), v.totalWeightKg(), v.totalVolumeCbm(), v.budgetAmount(), v.currency(),
|
||||
v.originContactName(), v.originContactPhone(), v.destinationContactName(), v.destinationContactPhone(), v.pickupAt(), v.deliveryAt(), v.remark());
|
||||
}
|
||||
|
||||
+1
-1
@@ -116,7 +116,7 @@ class TaillegRealConsumerAcceptanceTest {
|
||||
createTransfer(sourceOrderId, inboundCode, true, "RETURN-RELABEL");
|
||||
|
||||
if (!exists("ll_local_looseboard_order", MARKER + "-LOOSEBOARD-KEY")) {
|
||||
looseboard.create(CUSTOMER, "real-acceptance", new LocalLooseboardRequests.Create(
|
||||
looseboard.create(CUSTOMER, "real-acceptance", new LocalLooseboardRequests.Create("9", "FBA", null, "WAREHOUSE_PICKUP", null, null, null, null,
|
||||
"洛杉矶海外仓", "Los Angeles",
|
||||
"ONT8", "FBA ONT8", "验收货物", 2, 3, new BigDecimal("30.00"), new BigDecimal("0.40"),
|
||||
new BigDecimal("120.00"), "USD", "验收联系人", "13800000000", "验收收货人", "13800000001", REQUESTED_DATE.atTime(9, 30), REQUESTED_DATE.atTime(15, 30),
|
||||
|
||||
+57
@@ -0,0 +1,57 @@
|
||||
package com.baiyue.lastleg.transfer.service;
|
||||
|
||||
import com.baiyue.lastleg.transfer.looseboard.*;
|
||||
import com.baiyue.lastleg.transfer.entity.*;
|
||||
import com.baiyue.lastleg.looseboard.entity.LocalLooseboardOrder;
|
||||
import com.baiyue.lastleg.inboundplan.entity.*;
|
||||
import com.baiyue.lastleg.inboundplan.pda.gateway.InboundDispatchReceiptGateway;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
import static org.mockito.ArgumentMatchers.*;
|
||||
import static org.assertj.core.api.Assertions.*;
|
||||
|
||||
class LooseboardPlannedCargoServiceTest {
|
||||
/** 预排不绑定实托、不填写实收数量;重试不重复创建。 */
|
||||
@Test void approvalCreatesOnlyLogicalCargo() {
|
||||
var f=new Fixture(); var order=new LocalLooseboardOrder();order.setCustomerId("A");order.setEstimatedPalletCount(2);order.setPackageCount(5);order.setTotalVolumeCbm(new BigDecimal("1.0001"));
|
||||
f.service.prepare(order,"ZY1");
|
||||
assertThat(f.pallets).hasSize(2).allSatisfy(p->{assertThat(p.getWarehousePalletId()).isNull();assertThat(p.getActualPackageCount()).isNull();});
|
||||
assertThat(f.allocations.values()).extracting(InboundPlanPalletAllocation::getPlannedPackageCount).containsExactlyInAnyOrder(3,2);
|
||||
assertThat(f.allocations.values()).allSatisfy(a->{assertThat(a.getCountedPackageCount()).isNull();assertThat(a.getPickedPackageCount()).isNull();});
|
||||
assertThat(f.pallets.stream().map(InboundPlanPallet::getAllocatedVolumeCbm).reduce(BigDecimal.ZERO,BigDecimal::add)).isEqualByComparingTo("1.0001");
|
||||
f.service.prepare(order,"ZY1"); assertThat(f.pallets).hasSize(2);verifyNoInteractions(f.dispatch);
|
||||
}
|
||||
/** 实收少一托时原ID保留,明确清零的空来源由正式交接释放。 */
|
||||
@Test void receiptKeepsOriginalSourceIdsAndReconcilesShortage() {
|
||||
var f=new Fixture();var order=new LocalLooseboardOrder();order.setCustomerId("A");order.setEstimatedPalletCount(2);order.setPackageCount(5);order.setTotalVolumeCbm(BigDecimal.ONE);
|
||||
f.service.prepare(order,"ZY1");var ids=f.pallets.stream().map(InboundPlanPallet::getId).toList();
|
||||
var actual=new WarehouseTransferPallet();actual.setId(77L);actual.setSequenceNo(1);actual.setPalletNo("ACTUAL");actual.setWarehousePalletId(900L);actual.setCartonCount(4);actual.setVolumeCbm(new BigDecimal("0.8"));
|
||||
var now=LocalDateTime.now();f.service.lockReceipt("ZY1","9");assertThat(f.service.complete(f.transfer,List.of(actual),"operator",now)).isTrue();
|
||||
assertThat(f.pallets.stream().map(InboundPlanPallet::getId).toList()).isEqualTo(ids);
|
||||
assertThat(f.pallets.get(0).getWarehousePalletId()).isEqualTo(900L);assertThat(f.pallets.get(0).getActualPackageCount()).isEqualTo(4);
|
||||
assertThat(f.pallets.get(1).getWarehousePalletId()).isNull();assertThat(f.pallets.get(1).getActualPackageCount()).isZero();
|
||||
assertThat(f.allocations.get(ids.get(1)).getCountedPackageCount()).isZero();assertThat(f.plan.getStatus()).isEqualTo("COMPLETED");
|
||||
var sequence=inOrder(f.dispatch);sequence.verify(f.dispatch).lock(List.of(1L));sequence.verify(f.dispatch).complete(List.of(1L),"operator",now);
|
||||
}
|
||||
/** 纯普通转运没有散板预排时继续走原收货链。 */
|
||||
@Test void ordinaryTransferDoesNotInvokePlannedHandoff() {
|
||||
var f=new Fixture();assertThat(f.service.complete(f.transfer,List.of(),"operator",LocalDateTime.now())).isFalse();verifyNoInteractions(f.dispatch);
|
||||
}
|
||||
/** 测试仅隔离持久化,验证真实服务的计划及实收行为。 */
|
||||
static class Fixture {
|
||||
final LooseboardPlannedCargoRepository repository=mock(LooseboardPlannedCargoRepository.class);
|
||||
final InboundDispatchReceiptGateway dispatch=mock(InboundDispatchReceiptGateway.class);
|
||||
final LooseboardPlannedCargoService service=new LooseboardPlannedCargoService(repository,dispatch);
|
||||
final WarehouseTransferOrder transfer=new WarehouseTransferOrder();final InboundPlan plan=new InboundPlan();final InboundPlanLine line=new InboundPlanLine();
|
||||
final List<InboundPlanPallet> pallets=new ArrayList<>(); final Map<Long,InboundPlanPalletAllocation> allocations=new LinkedHashMap<>();
|
||||
Fixture() {
|
||||
transfer.setCustomerId("A");transfer.setLastlegOrderId(3L);plan.setId(1L);plan.setPlanNo("WT-ZY1");plan.setCustomerId("A");line.setId(2L);line.setDestinationType("PRIVATE");line.setDeliveryAddress("Test");
|
||||
when(repository.transfer("A","ZY1")).thenReturn(transfer);when(repository.transferAt("ZY1","9")).thenReturn(transfer);when(repository.plan(transfer)).thenReturn(plan);when(repository.line(plan)).thenReturn(line);
|
||||
when(repository.pallets(plan)).thenAnswer(a->List.copyOf(pallets));when(repository.allocation(any())).thenAnswer(a->allocations.get(((InboundPlanPallet)a.getArgument(0)).getId()));
|
||||
doAnswer(a->{var p=(InboundPlanPallet)a.getArgument(0);pallets.add(p);allocations.put(p.getId(),a.getArgument(1));return null;}).when(repository).insert(any(),any());when(repository.update(any(),any())).thenReturn(true);
|
||||
}
|
||||
}
|
||||
}
|
||||
+17
@@ -288,6 +288,7 @@ class WarehouseTransferServiceTest {
|
||||
ReflectionTestUtils.setField(service, "inventory", inventory);
|
||||
ReflectionTestUtils.setField(service, "inventoryCommands", inventoryCommands);
|
||||
ReflectionTestUtils.setField(service, "relabelService", relabelService);
|
||||
ReflectionTestUtils.setField(service, "looseboardPlannedCargo", mock(com.baiyue.lastleg.transfer.looseboard.LooseboardPlannedCargoService.class));
|
||||
ReflectionTestUtils.setField(service, "objectMapper", new ObjectMapper().findAndRegisterModules());
|
||||
lenient().doReturn("ZY202610200001").when(orderNoGenerator).next(anyString());
|
||||
lenient().when(inventoryAllocations.selectList(any())).thenReturn(List.of());
|
||||
@@ -603,6 +604,7 @@ class WarehouseTransferServiceTest {
|
||||
assertEquals("ONT8", transfer.getValue().getFbaWarehouseCode());
|
||||
assertNull(transfer.getValue().getFbaShipmentNo());
|
||||
assertEquals("ONT8", tailOrder.getValue().getFbaWarehouseCode());
|
||||
assertEquals("FBA", tailOrder.getValue().getDestinationType());
|
||||
assertEquals("INVENTORY_TRANSFER", transfer.getValue().getDeliveryMethod());
|
||||
assertNull(transfer.getValue().getEstimatedArrivalAt());
|
||||
}
|
||||
@@ -639,6 +641,7 @@ class WarehouseTransferServiceTest {
|
||||
"FBA", null, "ONT8", "ONT8", null, null, null, null, "BIND-1"));
|
||||
|
||||
assertEquals(WarehouseTransferService.READY_FOR_DISPATCH, transfer.getStatus());
|
||||
assertEquals("FBA", order.getDestinationType());
|
||||
assertEquals("WAIT_PRE_DISPATCH", relabel.getFulfillmentStatus());
|
||||
verify(relabelApplications).updateById(relabel);
|
||||
}
|
||||
@@ -747,6 +750,20 @@ class WarehouseTransferServiceTest {
|
||||
verify(transportTasks).ensureApprovedPickup("TENANT-A", "WTR-1", "USER-1");
|
||||
}
|
||||
|
||||
/** 散板货源审核不另建到仓取货车,实际承运排车以取货照片执行。 */
|
||||
@Test void approvedLooseboardSourceDoesNotCreateSecondPickupDispatch() {
|
||||
var transfer=persistedTransfer();transfer.setInboundCode("WTR-1");transfer.setOperatingWarehouseId("77");transfer.setDeliveryMethod("WAREHOUSE_PICKUP");
|
||||
transfer.setEstimatedArrivalAt(LocalDateTime.of(2026,9,17,10,0));
|
||||
var tail=tailOrder();tail.setCurrentStatus(WarehouseTransferService.PENDING_REVIEW);
|
||||
var inbound=new LastlegInboundOrder();inbound.setId(101L);inbound.setCustomerId("TENANT-A");inbound.setStatus(WarehouseTransferService.PENDING_REVIEW);
|
||||
when(transfers.lockNo("TENANT-A","WTR-1")).thenReturn(transfer);when(transfers.findNo("TENANT-A","WTR-1")).thenReturn(transfer);
|
||||
when(orders.selectById(31L)).thenReturn(tail);when(inboundOrders.selectById(101L)).thenReturn(inbound);
|
||||
when(transferItems.selectList(any())).thenReturn(List.of());when(sourceOrders.selectList(any())).thenReturn(List.of());
|
||||
service.reviewLooseboardSource("TENANT-A","WTR-1","USER-1",new WarehouseTransferRequests.Review("APPROVE",null));
|
||||
assertEquals(WarehouseTransferService.WAIT_PDA_RECEIVING,transfer.getStatus());
|
||||
verifyNoInteractions(transportTasks,inventory);
|
||||
}
|
||||
|
||||
@Test
|
||||
void merchantSubmitsDraftForReviewBeforePrintingLabels() {
|
||||
WarehouseTransferOrder transfer = persistedTransfer();
|
||||
|
||||
+7
@@ -36,6 +36,7 @@ import static org.mockito.ArgumentMatchers.anyLong;
|
||||
import static org.mockito.ArgumentMatchers.anyString;
|
||||
import static org.mockito.Mockito.when;
|
||||
import static org.mockito.Mockito.lenient;
|
||||
import static org.mockito.Mockito.verify;
|
||||
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class UsTruckOrderServiceTest {
|
||||
@@ -44,6 +45,7 @@ class UsTruckOrderServiceTest {
|
||||
@Mock UsTruckOrderStatusHistoryMapper histories;
|
||||
@Mock UsTruckOrderOperationMapper operations;
|
||||
@Mock LastlegOrderNoGenerator orderNoGenerator;
|
||||
@Mock com.baiyue.lastleg.wallet.budget.OrderBudgetService orderBudget;
|
||||
private final List<UsTruckOrderPallet> palletRows = new ArrayList<>();
|
||||
private final List<UsTruckOrderStatusHistory> historyRows = new ArrayList<>();
|
||||
private final Map<String, UsTruckOrderOperation> operationRows = new HashMap<>();
|
||||
@@ -57,6 +59,7 @@ class UsTruckOrderServiceTest {
|
||||
ReflectionTestUtils.setField(service, "repository", new com.baiyue.lastleg.trucking.repository.UsTruckOrderRepository(orders, pallets, histories, operations));
|
||||
ReflectionTestUtils.setField(service, "objectMapper", new ObjectMapper().findAndRegisterModules());
|
||||
ReflectionTestUtils.setField(service, "orderNoGenerator", orderNoGenerator);
|
||||
ReflectionTestUtils.setField(service, "orderBudget", orderBudget);
|
||||
order = order(UsTruckOrderService.PENDING_REVIEW, 0L, 2);
|
||||
lenient().when(orders.lockNo("100", "TK1")).thenAnswer(call -> order);
|
||||
lenient().when(orders.findNo("100", "TK1")).thenAnswer(call -> order);
|
||||
@@ -106,6 +109,7 @@ class UsTruckOrderServiceTest {
|
||||
}
|
||||
}
|
||||
|
||||
/** 审核通过进入排车的同时必须释放下单预算,不能继续占用客户资金。 */
|
||||
@Test
|
||||
void reviewEntersDispatchWithoutInventingPallets() {
|
||||
order.setPalletCount(0);
|
||||
@@ -113,6 +117,7 @@ class UsTruckOrderServiceTest {
|
||||
assertEquals(UsTruckOrderService.PENDING_DISPATCH, reviewed.summary().status());
|
||||
assertTrue(palletRows.isEmpty());
|
||||
assertEquals(0, reviewed.summary().receivedPalletCount());
|
||||
verify(orderBudget).releaseAfterReview("100", "trucking", "TK1");
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -156,6 +161,7 @@ class UsTruckOrderServiceTest {
|
||||
&& row.getIdempotencyKey() != null));
|
||||
}
|
||||
|
||||
/** 同一审核重放只返回原结果,不能再次提交资金释放。 */
|
||||
@Test
|
||||
void reviewIsIdempotentAtDispatchBoundary() {
|
||||
var reviewed = service.review("100", "TK1", "operator", new UsTruckOrderRequests.Review(
|
||||
@@ -163,6 +169,7 @@ class UsTruckOrderServiceTest {
|
||||
assertEquals(UsTruckOrderService.PENDING_DISPATCH, reviewed.summary().status());
|
||||
assertEquals(1L, service.review("100", "TK1", "operator", new UsTruckOrderRequests.Review(
|
||||
0L, "APPROVE", "review-partial", null, 1L)).summary().version());
|
||||
verify(orderBudget).releaseAfterReview("100", "trucking", "TK1");
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
+66
-3
@@ -20,6 +20,8 @@ class OrderBudgetPersistenceTest {
|
||||
JdbcTemplate jdbc;
|
||||
OrderBudgetService service;
|
||||
OrderFundingPort funding;
|
||||
SqlSessionTemplate sessions;
|
||||
/** 隔离数据库使用真实预算、卡车和事件表,服务事务与生产 Mapper 保持一致。 */
|
||||
@BeforeEach void setup() throws Exception {
|
||||
var ds = new DriverManagerDataSource("jdbc:h2:mem:"+UUID.randomUUID()+";MODE=MySQL;DB_CLOSE_DELAY=-1;INIT=SET TIME ZONE '+08:00'", "sa", "");
|
||||
jdbc = new JdbcTemplate(ds);
|
||||
@@ -29,11 +31,18 @@ class OrderBudgetPersistenceTest {
|
||||
var outbox = Pattern.compile("(?s)CREATE TABLE IF NOT EXISTS ll_event_outbox \\(.*?;").matcher(sql);
|
||||
assertThat(outbox.find()).isTrue();jdbc.execute(outbox.group());
|
||||
jdbc.execute("CREATE TABLE ll_overseas_procurement_order(customer_id VARCHAR(64),order_no VARCHAR(64),status VARCHAR(32))");
|
||||
jdbc.execute("CREATE TABLE ll_us_truck_order(customer_id VARCHAR(64),order_no VARCHAR(64),status VARCHAR(32))");
|
||||
for (String table : new String[]{"ll_us_truck_order", "ll_us_truck_order_pallet", "ll_us_truck_order_operation", "ll_us_truck_order_status_history"}) {
|
||||
var ddl = Pattern.compile("(?s)CREATE TABLE IF NOT EXISTS " + table + " \\(.*?;").matcher(sql);
|
||||
assertThat(ddl.find()).isTrue(); jdbc.execute(ddl.group());
|
||||
}
|
||||
var config = new MybatisConfiguration();config.addMapper(OrderBudgetMapper.class);
|
||||
config.addMapper(com.baiyue.lastleg.mapper.LastlegEventOutboxMapper.class);
|
||||
config.addMapper(com.baiyue.lastleg.trucking.mapper.UsTruckOrderMapper.class);
|
||||
config.addMapper(com.baiyue.lastleg.trucking.mapper.UsTruckOrderPalletMapper.class);
|
||||
config.addMapper(com.baiyue.lastleg.trucking.mapper.UsTruckOrderOperationMapper.class);
|
||||
config.addMapper(com.baiyue.lastleg.trucking.mapper.UsTruckOrderStatusHistoryMapper.class);
|
||||
var factory = new MybatisSqlSessionFactoryBean();factory.setDataSource(ds);factory.setConfiguration(config);
|
||||
var sessions = new SqlSessionTemplate(factory.getObject());
|
||||
sessions = new SqlSessionTemplate(factory.getObject());
|
||||
repository = new OrderBudgetRepository(sessions.getMapper(OrderBudgetMapper.class));
|
||||
var wallet = new com.baiyue.lastleg.wallet.WalletCommandOutboxService(
|
||||
sessions.getMapper(com.baiyue.lastleg.mapper.LastlegEventOutboxMapper.class),
|
||||
@@ -54,7 +63,8 @@ class OrderBudgetPersistenceTest {
|
||||
for (String kind : new String[]{"procurement","trucking"}) {
|
||||
String table = "procurement".equals(kind) ? "ll_overseas_procurement_order" : "ll_us_truck_order";
|
||||
assertThatThrownBy(() -> service.submit("9",kind,"rollback",BigDecimal.TEN,"CNY",() -> {
|
||||
jdbc.update("INSERT INTO " + table + " VALUES('9','FAIL','FUNDING_PENDING')");
|
||||
if ("trucking".equals(kind)) jdbc.update("INSERT INTO ll_us_truck_order(id,customer_id,order_no,origin,destination,status,idempotency_key,request_hash) VALUES(1,'9','FAIL','A','B','FUNDING_PENDING','rollback',?)", "a".repeat(64));
|
||||
else jdbc.update("INSERT INTO " + table + " VALUES('9','FAIL','FUNDING_PENDING')");
|
||||
throw new IllegalStateException("订单明细写入失败");
|
||||
}, value -> String.valueOf(value))).hasMessageContaining("订单明细写入失败");
|
||||
assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM " + table,Integer.class)).isZero();
|
||||
@@ -71,6 +81,59 @@ class OrderBudgetPersistenceTest {
|
||||
}
|
||||
assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM ll_event_outbox",Integer.class)).isEqualTo(2);
|
||||
}
|
||||
|
||||
/** 本地真实建卡车单,核对冻结、审核释放、回执和重复审核的持久化结果。 */
|
||||
@Test void truckCreationAndApprovalReleaseTheOriginalBudget() {
|
||||
jdbc.execute("CREATE TABLE ll_dispatch_truck_task(customer_id VARCHAR(128),order_no VARCHAR(64),active_order VARCHAR(64))");
|
||||
var orders = sessions.getMapper(com.baiyue.lastleg.trucking.mapper.UsTruckOrderMapper.class);
|
||||
var histories = sessions.getMapper(com.baiyue.lastleg.trucking.mapper.UsTruckOrderStatusHistoryMapper.class);
|
||||
var trucks = new com.baiyue.lastleg.trucking.service.UsTruckOrderService();
|
||||
org.springframework.test.util.ReflectionTestUtils.setField(trucks, "repository",
|
||||
new com.baiyue.lastleg.trucking.repository.UsTruckOrderRepository(orders,
|
||||
sessions.getMapper(com.baiyue.lastleg.trucking.mapper.UsTruckOrderPalletMapper.class), histories,
|
||||
sessions.getMapper(com.baiyue.lastleg.trucking.mapper.UsTruckOrderOperationMapper.class)));
|
||||
org.springframework.test.util.ReflectionTestUtils.setField(trucks, "objectMapper", new com.fasterxml.jackson.databind.ObjectMapper().findAndRegisterModules());
|
||||
org.springframework.test.util.ReflectionTestUtils.setField(trucks, "fleetPool", org.mockito.Mockito.mock(com.baiyue.lastleg.fleet.business.FleetPoolOrderQueries.class));
|
||||
org.springframework.test.util.ReflectionTestUtils.setField(trucks, "orderBudget", service);
|
||||
var numbers = org.mockito.Mockito.mock(com.baiyue.lastleg.service.LastlegOrderNoGenerator.class);
|
||||
org.mockito.Mockito.when(numbers.next("TRUCK")).thenReturn("TRUCK-LOCAL-BUDGET-1");
|
||||
org.springframework.test.util.ReflectionTestUtils.setField(trucks, "orderNoGenerator", numbers);
|
||||
var actualFunding = new com.baiyue.lastleg.procurement.funding.OrderFundingService(
|
||||
new com.baiyue.lastleg.procurement.funding.OrderFundingRepository(null, null, orders, histories));
|
||||
org.mockito.Mockito.doAnswer(call -> {
|
||||
actualFunding.apply(call.getArgument(0), call.getArgument(1), call.getArgument(2)); return null;
|
||||
}).when(funding).apply(org.mockito.ArgumentMatchers.any(), org.mockito.ArgumentMatchers.anyString(), org.mockito.ArgumentMatchers.any());
|
||||
var request = new com.baiyue.lastleg.trucking.dto.UsTruckOrderRequests.Create("PALLET_15", "A", null, "B", null,
|
||||
3, BigDecimal.ONE, BigDecimal.ONE, new BigDecimal("100.00"), "CNY", "发货人", "10086", "收货人", "10010",
|
||||
LocalDateTime.of(2026,11,2,9,0), LocalDateTime.of(2026,11,3,9,0), null, "local-budget-order");
|
||||
var created = service.submit("9", "trucking", request.idempotencyKey(), request.budgetAmount(), request.currency(),
|
||||
() -> trucks.createFundingPending("9", "tester", request), value -> value.summary().orderNo());
|
||||
String number = created.summary().orderNo();
|
||||
assertThat(created.summary().status()).isEqualTo("FUNDING_PENDING");
|
||||
var budget = repository.lockOrder("9", "trucking", number);
|
||||
service.handle(truckReceipt(budget, "FREEZE", "FROZEN"));
|
||||
var pending = trucks.detail("9", number);
|
||||
assertThat(pending.summary().status()).isEqualTo("PENDING_REVIEW");
|
||||
assertThat(repository.lockOrder("9", "trucking", number).getStatus()).isEqualTo("FROZEN");
|
||||
var review = new com.baiyue.lastleg.trucking.dto.UsTruckOrderRequests.Review(pending.summary().version(), "APPROVE", "local-budget-review", null, 1L);
|
||||
assertThat(trucks.review("9", number, "reviewer", review).summary().status()).isEqualTo("PENDING_DISPATCH");
|
||||
budget = repository.lockOrder("9", "trucking", number);
|
||||
assertThat(budget.getStatus()).isEqualTo("RELEASE_PENDING");
|
||||
assertThat(budget.getReleaseRequested()).isTrue();
|
||||
assertThat(budget.getAmountCny()).isEqualByComparingTo("100.00");
|
||||
trucks.review("9", number, "reviewer", review);
|
||||
assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM ll_event_outbox", Integer.class)).isEqualTo(2);
|
||||
service.handle(truckReceipt(budget, "RELEASE", "RELEASED"));
|
||||
assertThat(repository.lockOrder("9", "trucking", number).getStatus()).isEqualTo("RELEASED");
|
||||
assertThat(trucks.detail("9", number).summary().status()).isEqualTo("PENDING_DISPATCH");
|
||||
}
|
||||
|
||||
/** 模拟 Finance 按原资金身份返回成功事实,不用伪造本地已释放状态。 */
|
||||
private com.baiyue.common.event.WalletCommandCompletedV1 truckReceipt(OrderBudget budget, String operation, String status) {
|
||||
return new com.baiyue.common.event.WalletCommandCompletedV1(budget.getCommandEventId(), operation, 9L,
|
||||
OrderBudgetService.BUSINESS_TYPE, budget.getId().toString(), "SUCCEEDED", status,
|
||||
budget.getAmountCny(), null, null, java.time.Instant.now(), "local-hold", null);
|
||||
}
|
||||
/** 免费样品无需发出冻结指令,直接开放审核门禁。 */
|
||||
@Test void freeSampleSkipsFinanceAndEntersReview() {
|
||||
service.submit("9","procurement","zero",BigDecimal.ZERO,"CNY",()->"FREE1",v->v);
|
||||
|
||||
Reference in New Issue
Block a user