This commit is contained in:
2026-11-02 17:14:02 +08:00
parent 035e7e06af
commit 21a9ce7d27
98 changed files with 2558 additions and 104 deletions
@@ -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)");
@@ -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));
@@ -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事务。 */
@@ -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'");
@@ -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;
}
}
@@ -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;}
}
}
@@ -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);
}
}
@@ -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);
@@ -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());
@@ -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");}
@@ -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();
}
}
}
@@ -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);
@@ -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);
@@ -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());
}
}
@@ -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);
@@ -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));
@@ -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);
@@ -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());
@@ -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");
}
@@ -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) {
@@ -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());
}
@@ -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),
@@ -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);
}
}
}
@@ -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();
@@ -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
@@ -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);