This commit is contained in:
2026-10-21 14:30:41 +08:00
parent 0af977bdef
commit 40a03978cd
53 changed files with 1004 additions and 1291 deletions
@@ -37,6 +37,7 @@ import static org.mockito.Mockito.*;
/** 真实生产Mapper及事务验证任何客户或主单CAS失败时全车原子回滚。 */
class DispatchDeliveryArrangementPersistenceTest {
private JdbcTemplate db;
private org.springframework.transaction.support.TransactionTemplate tx;
private DispatchDeliveryService service;
private final DispatchExecutionPort execution=mock(DispatchExecutionPort.class);
private final DispatchDeliveryFees fees=mock(DispatchDeliveryFees.class);
@@ -49,6 +50,7 @@ class DispatchDeliveryArrangementPersistenceTest {
@BeforeEach void prepare() throws Exception {
var ds=new DriverManagerDataSource("jdbc:h2:mem:arrangement-"+UUID.randomUUID()+";MODE=MySQL;DB_CLOSE_DELAY=-1","sa","");
db=new JdbcTemplate(ds);
tx=new org.springframework.transaction.support.TransactionTemplate(new DataSourceTransactionManager(ds));
for (var type:List.of(OutboundOrder.class,OutboundPalletItem.class,DeliveryOrder.class,LastlegOrder.class,LastlegOrderStatusHistory.class)) {
String columns=Arrays.stream(type.getDeclaredFields()).filter(f->!Modifier.isStatic(f.getModifiers())).map(f->{
String name=f.getName().replaceAll("([A-Z])","_$1").toLowerCase();
@@ -173,6 +175,32 @@ class DispatchDeliveryArrangementPersistenceTest {
assertEquals(6L,frozen.stream().filter(c->c.attributionId()==4).findFirst().orElseThrow().sourceOrigin().productQuantity());
}
/** 自动装车加入父事务,最终主单失败时全部客户单据与来源绑定均回滚。 */
@Test void automaticPreparationSharesLoadingParentTransactionAndRollback() {
assertThrows(IllegalStateException.class,()->tx.execute(status->{
service.prepareForDeparture(context,actor,"LOADING-FAIL");
throw new IllegalStateException("装车最终保存失败");
}));
for(var table:List.of("ll_outbound_order","ll_outbound_pallet_item","ll_delivery_order","ll_dispatch_delivery_arrangement","ll_loading_order_status_history"))
assertEquals(0,db.queryForObject("SELECT COUNT(*) FROM "+table,Integer.class));
assertEquals(2,db.queryForObject("SELECT COUNT(*) FROM ll_order WHERE outbound_order_no IS NULL AND version=0",Integer.class));
var fact=tx.execute(status->service.prepareForDeparture(context,actor,"LOADING-OK"));
assertEquals(2,fact.result().deliveries().size());
assertEquals(2,db.queryForObject("SELECT COUNT(*) FROM ll_delivery_order WHERE planned_delivery_at IS NULL AND delivery_method IS NULL AND pickup_method IS NULL AND loading_type IS NULL",Integer.class));
assertEquals(2,db.queryForObject("SELECT COUNT(*) FROM ll_outbound_order WHERE fee_coverage_json IS NULL",Integer.class));
clearInvocations(fees);
assertEquals(fact,tx.execute(status->service.prepareForDeparture(context,actor,"LOADING-OK")));
verifyNoInteractions(fees);
}
/** 外接费用发布失败同样回滚自动生成的全部客户单据,不能留下半车。 */
@Test void automaticExternalFreightFailureRollsBackEveryCustomer() {
doThrow(new IllegalStateException("外接费用发布失败")).when(fees).publish(any(),argThat(g->g.customerId().equals("102")),any(),any(),any(),any(),anyString(),anyString());
assertThrows(IllegalStateException.class,()->tx.execute(status->service.prepareForDeparture(context,actor,"LOADING-FAIL")));
assertEquals(0,db.queryForObject("SELECT COUNT(*) FROM ll_outbound_order",Integer.class));
assertEquals(0,db.queryForObject("SELECT COUNT(*) FROM ll_delivery_order",Integer.class));
}
/** 按真实共享记录构造完整客户目的地请求。 */
private ArrangeDelivery request() { return new ArrangeDelivery(3L,"ARRANGE-1","cargo-hash","loading-hash",grouping.groups(context).stream().map(g->
new ArrangeGroup(g.key(),g.cargoHash(),DEPARTED,"TRUCK",null,null,"WAREHOUSE_PICKUP","PALLET",null,null,null,null,null,
@@ -72,6 +72,38 @@ class DispatchDeliveryServiceTest {
verify(fees,times(3)).publish(any(),any(),any(),any(),isNull(),any(),eq("OP-A"),anyString());
}
/** PDA装车自动生成完整客户目的地,不猜派送计划、不要求人工费用或原费覆盖。 */
@Test void loadingAutomaticallyCreatesDocumentsWithoutInventedInputsOrFees() {
var pda=new Actor("PDA-1",ActorMode.PDA,"1001");
var fact=service.prepareForDeparture(context,pda,"LOADING-1");
assertEquals(2,fact.result().outbounds().size());assertEquals(3,fact.result().deliveries().size());
assertEquals(4,fact.result().recordVersion());
assertTrue(deliveries.stream().allMatch(d->d.getPlannedDeliveryAt()==null && d.getDeliveryMethod()==null
&& d.getPickupMethod()==null && d.getLoadingType()==null));
assertTrue(outbounds.stream().allMatch(o->o.getFeeCoverageJson()==null));
assertTrue(calls.indexOf("SOURCE")>calls.lastIndexOf("DELIVERY"));
verify(fees,never()).validate(any(),any(),any(),any());verify(fees,never()).coverage(any(),any(),any(),any());
var inputs=org.mockito.ArgumentCaptor.forClass(ArrangeGroup.class);
verify(fees,times(3)).publish(eq(context),any(),inputs.capture(),any(),isNull(),any(),eq("PDA-1"),anyString());
assertTrue(inputs.getAllValues().stream().allMatch(i->i.fees().isEmpty() && i.feeCoverage().isEmpty()));
clearInvocations(fees,repository);
assertEquals(fact,service.prepareForDeparture(context,pda,"LOADING-1"));
verifyNoInteractions(fees);verify(repository,never()).create(any(OutboundOrder.class));
assertThrows(org.springframework.web.server.ResponseStatusException.class,
()->service.prepareForDeparture(context,pda,"DIFFERENT-KEY"));
}
/** 自动生成仍拒绝越仓与孤立子单,不为异常半车补造第二套事实。 */
@Test void automaticPreparationRejectsWrongWarehouseAndOrphanDocuments() {
assertThrows(org.springframework.web.server.ResponseStatusException.class,
()->service.prepareForDeparture(context,new Actor("PDA-1",ActorMode.PDA,"OTHER"),"LOADING-1"));
verifyNoInteractions(repository,fees);
outbounds.add(new OutboundOrder());
assertThrows(org.springframework.web.server.ResponseStatusException.class,
()->service.prepareForDeparture(context,actor,"LOADING-1"));
verifyNoInteractions(fees);
}
/** 同实托的两申请分别冻结原货源,出库引用逐次归属且真实客户目的地与履约订单不被替换。 */
@Test void relabelOriginsStayFrozenBehindOutboundAttributionReferences() throws Exception {
var first=new SourceOrigin(10001L,201L,301L,401L,701L,"IN-701","CODE-701",null);
@@ -97,8 +97,6 @@ class PlatformDispatchOutboundContractTest {
String.class, DispatchCommands.Update.class);
Method cancel = DispatchAggregateController.class.getMethod("cancel", AuthenticatedUser.class,
String.class, DispatchCommands.ReasonCommand.class);
Method transfer = DeliveryTransferController.class.getMethod("transfer", AuthenticatedUser.class,
String.class, com.baiyue.lastleg.dispatch.port.DispatchDeliveryModels.ArrangeDelivery.class);
Method externalFreight = DeliveryTransferController.class.getMethod("externalFreight", AuthenticatedUser.class, String.class);
assertPermission(externalFreight, OverseasPermissions.DISPATCH_LIST);
Method outboundDetail = DeliveryTransferController.class.getMethod("outboundDetail",
@@ -113,11 +111,12 @@ class PlatformDispatchOutboundContractTest {
assertEquals(Set.of("/{dispatchNo}/cancel"), Set.of(cancelRoute.value()));
assertFalse(cancel.isAnnotationPresent(org.springframework.web.bind.annotation.DeleteMapping.class));
assertTrue(cancel.getParameters()[2].isAnnotationPresent(org.springframework.web.bind.annotation.RequestBody.class));
assertPermission(transfer, OverseasPermissions.DISPATCH_TRANSFER_DELIVERY);
assertTrue(Arrays.stream(DeliveryTransferController.class.getDeclaredMethods())
.noneMatch(method -> Set.of("transfer", "arrangement").contains(method.getName())));
assertPermission(outboundDetail, OverseasPermissions.OUTBOUND_LIST);
assertEquals(2, outboundDetail.getParameterCount());
assertPermission(outboundCancel, OverseasPermissions.OUTBOUND_CANCEL);
for (Method method : Set.of(update, cancel, transfer, externalFreight, outboundDetail, outboundCancel)) {
for (Method method : Set.of(update, cancel, externalFreight, outboundDetail, outboundCancel)) {
assertTrue(Arrays.stream(method.getParameters())
.noneMatch(parameter -> parameter.isAnnotationPresent(RequestParam.class)));
}
@@ -202,16 +201,13 @@ class PlatformDispatchOutboundContractTest {
assertFalse(schema.contains("uk_ll_outbound_no (customer_id,outbound_no)"));
}
/** 当前整车请求按公共版本一次提交全部组,响应保留多客户出库及多目的地派送数组。 */
/** 公开协议撤销人工安排入口,内部完整派送事实仍保留多客户和多目的地数组。 */
@Test
void openApiPublishesOnlyTheFrozenPlatformProtocol() throws Exception {
String yaml = Files.readString(Path.of("src/main/resources/openapi/overseas-business-v1.yaml"),
StandardCharsets.UTF_8);
String dispatchAction = slice(yaml, " /api/platform/v1/last-leg/dispatches/{dispatchNo}:",
" /api/platform/v1/last-leg/dispatches/candidates:");
String transfer = slice(yaml,
" /api/platform/v1/last-leg/dispatches/{dispatchNo}/transfer-delivery:",
" /api/platform/v1/last-leg/outbound-orders:");
String outboundDetail = slice(yaml,
" /api/platform/v1/last-leg/outbound-orders/{outboundNo}:",
" /api/platform/v1/last-leg/outbound-orders/{outboundNo}/cancel:");
@@ -219,7 +215,7 @@ class PlatformDispatchOutboundContractTest {
" /api/platform/v1/last-leg/outbound-orders/{outboundNo}/cancel:",
" /api/platform/v1/last-leg/overseas-procurements:");
for (String action : Set.of(dispatchAction, transfer, outboundDetail, outboundCancel)) {
for (String action : Set.of(dispatchAction, outboundDetail, outboundCancel)) {
assertFalse(action.contains("name: customerId"));
}
assertTrue(outboundDetail.contains("pallets[].palletNo"));
@@ -228,24 +224,13 @@ class PlatformDispatchOutboundContractTest {
assertTrue(yaml.contains("单次 SQL 返回,不逐行补查"));
assertTrue(yaml.contains("platform:overseas-warehouse:dispatch:edit"));
assertTrue(yaml.contains("platform:overseas-warehouse:dispatch:cancel"));
assertTrue(yaml.contains("platform:overseas-warehouse:dispatch:transfer-delivery"));
assertFalse(yaml.contains(" /api/platform/v1/last-leg/dispatches/{dispatchNo}/transfer-delivery:"));
assertFalse(yaml.contains(" /api/platform/v1/last-leg/dispatches/{dispatchNo}/delivery-arrangement:"));
assertTrue(yaml.contains("platform:overseas-warehouse:outbound:list"));
assertTrue(yaml.contains("platform:overseas-warehouse:outbound:create"));
assertTrue(yaml.contains("platform:overseas-warehouse:outbound:cancel"));
java.util.Map<?,?> document = new org.yaml.snakeyaml.Yaml().load(yaml);
java.util.Map<?,?> schemas = (java.util.Map<?,?>)((java.util.Map<?,?>)document.get("components")).get("schemas");
java.util.Map<?,?> arrange = (java.util.Map<?,?>)schemas.get("DispatchArrangeDelivery");
assertNotNull(arrange);
assertTrue(((java.util.List<?>)arrange.get("required")).containsAll(java.util.List.of("recordVersion", "idempotencyKey")));
java.util.Map<?,?> fields = (java.util.Map<?,?>)arrange.get("properties");
assertTrue(fields.keySet().containsAll(java.util.Set.of("recordVersion", "idempotencyKey", "cargoHash", "loadingCompletionHash", "groups")));
assertFalse(fields.containsKey("customerId"));
assertFalse(fields.containsKey("expectedVersion"));
java.util.Map<?,?> groups = (java.util.Map<?,?>)fields.get("groups");
assertEquals("array", groups.get("type"));
assertEquals("#/components/schemas/DispatchArrangeGroup", ((java.util.Map<?,?>)groups.get("items")).get("$ref"));
java.util.Map<?,?> groupFields = (java.util.Map<?,?>)((java.util.Map<?,?>)schemas.get("DispatchArrangeGroup")).get("properties");
assertTrue(groupFields.keySet().containsAll(java.util.Set.of("groupKey", "groupCargoHash", "fees", "feeCoverage")));
java.util.Map<?,?> resultFields = (java.util.Map<?,?>)((java.util.Map<?,?>)schemas.get("DispatchArrangementResult")).get("properties");
assertEquals("array", ((java.util.Map<?,?>)resultFields.get("outbounds")).get("type"));
assertEquals("array", ((java.util.Map<?,?>)resultFields.get("deliveries")).get("type"));
@@ -419,12 +419,13 @@ public class DispatchAggregatePersistenceTest {
assertThat(f.db.queryForList("SELECT DISTINCT customer_id FROM ll_dispatch_item ORDER BY customer_id",String.class)).containsExactly("101","202");
assertThat(f.db.queryForObject("SELECT COUNT(DISTINCT dispatch_order_id) FROM ll_dispatch_item",Long.class)).isOne();
assertThat(f.db.queryForObject("SELECT package_count FROM ll_fleet_trip_dispatch",Integer.class)).isEqualTo(8);
var query=new DispatchViews.Query(1,20,null,null,null,"101",null,StatusFilter.ACTIVE,Stage.BEFORE_LOADING,BeforeLoadingSection.DISPATCH_REVIEW,ReviewStatus.NOT_SUBMITTED);
var query=new DispatchViews.Query(1,20,null,null,null,"101",null,StatusFilter.ACTIVE,Stage.BEFORE_LOADING,BeforeLoadingSection.VEHICLE_INFO,null);
var page=f.tx.execute(s->f.service.page(f.actor,query));
assertThat(page.total()).isOne(); assertThat(page.records()).hasSize(1);
assertThat(page.records().get(0).counts().packageCount()).isEqualTo(8);
assertThat(page.statistics().activeTotal()).isOne(); assertThat(page.statistics().stageCounts().get(Stage.BEFORE_LOADING)).isOne();
assertThat(page.statistics().beforeLoadingReviewCounts().dispatchReview().notSubmitted()).isOne();
assertThat(page.statistics().beforeLoadingReviewCounts().vehicleInfoTotal()).isOne();
assertThat(page.statistics().beforeLoadingReviewCounts().dispatchReview().notSubmitted()).isZero();
long dispatch=f.db.queryForObject("SELECT id FROM ll_dispatch_order",Long.class);
long item=f.db.queryForObject("SELECT id FROM ll_dispatch_item WHERE customer_id='101'",Long.class);
@@ -0,0 +1,144 @@
package com.baiyue.lastleg.dispatch.service;
import com.baiyue.lastleg.dispatch.mapper.*;
import com.baiyue.lastleg.dispatch.pda.service.DispatchPdaWorkflowService;
import com.baiyue.lastleg.dispatch.port.*;
import com.baiyue.lastleg.dispatch.repository.*;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.AfterEach;
import org.springframework.security.authentication.TestingAuthenticationToken;
import org.springframework.security.core.context.SecurityContextHolder;
import java.math.BigDecimal;
import java.time.Instant;
import java.util.*;
import java.util.concurrent.*;
import static com.baiyue.lastleg.dispatch.port.DispatchContracts.*;
import static com.baiyue.lastleg.dispatch.port.DispatchDeliveryModels.*;
import static com.baiyue.lastleg.dispatch.port.DispatchTypes.*;
import static org.assertj.core.api.Assertions.*;
import static org.mockito.Mockito.*;
import static org.mockito.ArgumentMatchers.*;
/** 装完即发车的共同事务、原键最终结果及跨端竞争使用真实根单据和版本。 */
class DispatchLoadingDeparturePersistenceTest {
/** 每例恢复隔离身份,不影响其他服务权限测试。 */
@AfterEach void clearSecurity(){SecurityContextHolder.clearContext();}
/** 一次确认完成全部客户发运;原键重放返回派送中,不再生成子单或执行来源。 */
@Test void loadingCompletesAllCustomersAndReplaysOnlyFinalResult() throws Exception {
authorize();var f=new Fixture();
var result=f.f.tx.execute(s->f.service.complete(f.f.actor,f.number,f.request("complete")));
assertThat(result.action()).isEqualTo(Action.LOADING_COMPLETE);assertThat(result.stage()).isEqualTo(Stage.DELIVERING);
assertThat(result.counts().shippedPackageCount()).isEqualTo(8);assertThat(result.result()).isInstanceOf(DispatchResults.Loaded.class);
assertThat(f.f.db.queryForList("SELECT status FROM ll_outbound_order",String.class)).containsExactly("OUTBOUNDED","OUTBOUNDED");
assertThat(f.f.db.queryForList("SELECT status FROM ll_delivery_order",String.class)).containsExactly("DELIVERING","DELIVERING");
assertThat(f.f.db.queryForObject("SELECT status FROM ll_fleet_trip",String.class)).isEqualTo("IN_TRANSIT");
assertThat(f.f.db.queryForObject("SELECT COUNT(*) FROM ll_dispatch_pda_pick",Long.class)).isEqualTo(2);
assertThat(f.f.db.queryForObject("SELECT result_json FROM ll_dispatch_command WHERE idempotency_key='complete'",String.class)).contains("DELIVERING");
var replay=f.f.tx.execute(s->f.service.complete(f.f.actor,f.number,f.request("complete")));
assertThat(replay.replayed()).isTrue();assertThat(replay.recordVersion()).isEqualTo(result.recordVersion());assertThat(replay.result()).isEqualTo(result.result());
assertThat(f.f.tx.execute(s->f.f.service.command(f.f.actor,f.number,"complete")).stage()).isEqualTo(Stage.DELIVERING);
verify(f.delivery,times(1)).prepareForDeparture(any(),any(),anyString());verify(f.sourceWorkflow,times(1)).departWholeSources(any(),any(),any(),any());
assertThat(f.f.service.actions(f.f.actor,f.f.repository.find(f.number))).doesNotContainKeys(Action.ARRANGE_DELIVERY,Action.DEPART);
assertThatThrownBy(()->f.f.tx.execute(s->f.service.complete(f.f.actor,f.number,f.request("stale-new")))).hasMessageContaining("版本或审核阶段");
}
/** 任一后续步骤失败都恢复装车中,已提交的真实拣货保持,不留下中间装车或子单。 */
@Test void preparationAndDepartureFailuresRollBackTheEntireLoadingCommand() throws Exception {
authorize();
for(boolean failPreparation:List.of(true,false)) {
var f=new Fixture();
if(failPreparation)doThrow(new IllegalStateException("模拟单据准备失败")).when(f.delivery).prepareForDeparture(any(),any(),anyString());
else doThrow(new IllegalStateException("模拟来源发运失败")).when(f.sourceWorkflow).departWholeSources(any(),any(),any(),any());
assertThatThrownBy(()->f.f.tx.execute(s->f.service.complete(f.f.actor,f.number,f.request("failed")))).isInstanceOf(IllegalStateException.class);
var row=f.f.repository.find(f.number);assertThat(row.getStage()).isEqualTo("LOADING");assertThat(row.getRecordVersion()).isEqualTo(f.version);
assertThat(row.getLoadingCompletionHash()).isNull();assertThat(row.getArrangementKey()).isNull();assertThat(row.getDepartedAt()).isNull();
assertThat(f.f.db.queryForObject("SELECT COUNT(*) FROM ll_outbound_order",Long.class)).isZero();
assertThat(f.f.db.queryForObject("SELECT COUNT(*) FROM ll_delivery_order",Long.class)).isZero();
assertThat(f.f.db.queryForObject("SELECT COUNT(*) FROM ll_dispatch_command WHERE action IN ('LOADING_COMPLETE','ARRANGE_DELIVERY','DEPART')",Long.class)).isZero();
assertThat(f.f.db.queryForObject("SELECT COUNT(*) FROM ll_dispatch_pda_pick",Long.class)).isEqualTo(2);
assertThat(f.f.db.queryForObject("SELECT status FROM ll_fleet_trip",String.class)).isEqualTo("PLANNED");
}
}
/** PDA保持本仓权限、原键权限复验;不借自动流程绕开真实装载清单。 */
@Test void pdaUsesOnePermissionButRejectsOtherWarehouseAndInvalidEvidence() throws Exception {
authorize();var f=new Fixture();var pda=new Actor("picker",ActorMode.PDA,"9");
assertThatThrownBy(()->f.f.tx.execute(s->f.service.complete(new Actor("picker",ActorMode.PDA,"99"),f.number,f.request("foreign")))).hasMessageContaining("当前绑定仓");
var bad=new DispatchCommands.LoadingComplete(f.version,"bad-manifest","changed",f.measurements,null);
assertThatThrownBy(()->f.f.tx.execute(s->f.service.complete(pda,f.number,bad))).hasMessageContaining("真实装载清单已变化");
verify(f.delivery,never()).prepareForDeparture(any(),any(),anyString());
var result=f.f.tx.execute(s->f.service.complete(pda,f.number,f.request("pda")));assertThat(result.stage()).isEqualTo(Stage.DELIVERING);
SecurityContextHolder.getContext().setAuthentication(new TestingAuthenticationToken("picker","","ROLE_OVERSEAS_WAREHOUSE_PDA_OPERATOR"));
assertThatThrownBy(()->f.f.tx.execute(s->f.service.complete(pda,f.number,f.request("pda")))).hasMessageContaining("装车完成权限");
}
/** 同一版本后台/PDA同时确认只有一条完整链提交,不会产生半车或重复派送。 */
@Test void concurrentAdminAndPdaCompleteOnlyOneDeparture() throws Exception {
authorize();var f=new Fixture();var start=new CountDownLatch(1);var workers=Executors.newFixedThreadPool(2);
try {
var a=workers.submit(()->f.tryComplete(start,f.f.actor,"admin"));
var b=workers.submit(()->f.tryComplete(start,new Actor("picker",ActorMode.PDA,"9"),"pda"));
start.countDown();assertThat(List.of(a.get(25,TimeUnit.SECONDS),b.get(25,TimeUnit.SECONDS))).containsExactlyInAnyOrder(true,false);
} finally {workers.shutdownNow();}
assertThat(f.f.repository.find(f.number).getStage()).isEqualTo("DELIVERING");
verify(f.delivery,times(1)).prepareForDeparture(any(),any(),anyString());verify(f.sourceWorkflow,times(1)).departWholeSources(any(),any(),any(),any());
}
/** 仅配置装车完成及PDA既有角色权限,不授予人工派送、发车权限。 */
private static void authorize(){SecurityContextHolder.getContext().setAuthentication(new TestingAuthenticationToken("operator","",
"platform:overseas-warehouse:dispatch:loading-complete","lastleg:pda:operate","ROLE_OVERSEAS_WAREHOUSE_PDA_OPERATOR"));}
/** 主流程与发车使用真实SQL,派送提供者受控写入真实子单来验证共同回滚。 */
private static final class Fixture {
final DispatchAggregatePersistenceTest.Fixture f=new DispatchAggregatePersistenceTest.Fixture();
final DispatchDeliveryPort delivery=mock(DispatchDeliveryPort.class);
final DispatchPdaWorkflowService sourceWorkflow=mock(DispatchPdaWorkflowService.class);
final DispatchLoadingDepartureService service;final String number;final long version;final String manifest;
final List<LoadingMeasurement> measurements;
/** 创建两客户货物并走真实两级审核、真实拣货归属,不伪造已装车状态。 */
Fixture() throws Exception {
var created=f.tx.execute(s->f.service.create(f.actor,f.configured("create",List.of(f.source(1,"101",501,"ONT8",3),f.source(2,"202",502,"LAX9",5)))));
number=created.dispatchNo();version=f.approve(created).recordVersion();
f.picked(1,"101",501,3);f.picked(2,"202",502,5);f.db.update("UPDATE ll_dispatch_order SET picked_package_count=8");
f.db.execute("ALTER TABLE ll_order ADD detained INT DEFAULT 0");
var preview=f.tx.execute(s->f.service.loading(f.actor,number));manifest=preview.manifestHash();
measurements=preview.pallets().stream().map(p->new LoadingMeasurement(p.warehousePalletId(),1,BigDecimal.ONE,BigDecimal.ONE,p.deliveryIndex())).toList();
when(delivery.prepareForDeparture(any(),any(),anyString())).thenAnswer(call->{
Context c=call.getArgument(0);assertThat(c.stage()).isEqualTo(Stage.AFTER_LOADING);
var os=outbounds("WAIT_OUTBOUND");var ds=deliveries(c,"WAIT_DELIVERY");
for(int n=0;n<os.size();n++) {
var o=os.get(n);var d=ds.get(n);
f.db.update("INSERT INTO ll_outbound_order(id,customer_id,dispatch_order_id,outbound_no,status,package_count)VALUES(?,?,?,?,?,?)",6001+n,o.customerId(),c.dispatchId(),o.outboundNo(),o.status(),o.packageCount());
f.db.update("INSERT INTO ll_delivery_order(id,customer_id,dispatch_order_id,outbound_order_id,outbound_no,delivery_no,status,package_count)VALUES(?,?,?,?,?,?,?,?)",7001+n,o.customerId(),c.dispatchId(),6001+n,o.outboundNo(),d.deliveryNo(),d.status(),d.packageCount());
}
return new ArrangementFact("AUTO-ARR","business-hash",c.cargoHash(),c.loadingCompletionHash(),new ArrangementResult(number,c.recordVersion()+1,"AUTO-ARR",c.cargoHash(),Instant.now(),os,ds));
});
when(delivery.departureFacts(any(),eq(true))).thenAnswer(call->{
Context c=call.getArgument(0);f.db.queryForList("SELECT id FROM ll_outbound_order ORDER BY id FOR UPDATE");f.db.queryForList("SELECT id FROM ll_delivery_order ORDER BY id FOR UPDATE");
return new DepartureFacts(number,"AUTO-ARR",c.cargoHash(),c.loadingCompletionHash(),"business-hash",outbounds("WAIT_OUTBOUND"),deliveries(c,"WAIT_DELIVERY"),c.cargo(),new Gate(true,null,null));
});
when(delivery.markDeparted(any(),any(),any(),anyString())).thenAnswer(call->{
Context c=call.getArgument(0);f.db.update("UPDATE ll_outbound_order SET status='OUTBOUNDED'");f.db.update("UPDATE ll_delivery_order SET status='DELIVERING'");
return new DepartureResult(outbounds("OUTBOUNDED"),deliveries(c,"DELIVERING"),8);
});
var ledger=new DispatchCommandLedger(new DispatchCommandRepository(f.session.getMapper(DispatchCommandMapper.class)),new DispatchCommandCodec(f.json));
var hashes=new DispatchCommandHasher(f.json);
var sources=new DispatchSourceRepository(mock(DispatchCandidateMapper.class),mock(ReturnDispatchCandidateMapper.class),f.session.getMapper(DispatchSourceMapper.class));
var departure=new DispatchDepartureService(f.service,f.repository,sources,delivery,sourceWorkflow,f.fleet,ledger,hashes,f.json);
service=new DispatchLoadingDepartureService(f.service,delivery,departure,ledger,hashes);
}
/** 每次请求沿用最初真实测量和版本,便于验证竞争及原键恢复。 */
DispatchCommands.LoadingComplete request(String key){return new DispatchCommands.LoadingComplete(version,key,manifest,measurements,"实装发车");}
/** 两客户子出库保持独立业务号和箱数。 */
List<OutboundReference> outbounds(String status){return List.of(new OutboundReference("101","客户101","OUT-A",status,3,1),new OutboundReference("202","客户202","OUT-B",status,5,1));}
/** 两目的地仍对应各自真实客户,不把整车合并为单客户。 */
List<DeliveryReference> deliveries(Context c,String status){return List.of(new DeliveryReference("GA","101","客户101","OUT-A","DL-A",c.cargo().get(0).destination(),status,3,1),new DeliveryReference("GB","202","客户202","OUT-B","DL-B",c.cargo().get(1).destination(),status,5,1));}
/** 各线程独立身份和数据库事务竞争同一整车父锁。 */
boolean tryComplete(CountDownLatch start,Actor actor,String key) throws Exception {
authorize();try {start.await(5,TimeUnit.SECONDS);f.tx.execute(s->service.complete(actor,number,request(key)));return true;}
catch(com.baiyue.common.core.exception.BizException expected){return false;}
finally {SecurityContextHolder.clearContext();}
}
}
}
@@ -13,6 +13,32 @@ import static org.assertj.core.api.Assertions.*;
/** 统一PDA读取实际多客户主单和逐项扫码,仓限制在SQL完成。 */
class DispatchPdaReadPersistenceTest {
/** 默认PDA待办包含已通过调度的待理货整车,资源取同一冻结快照。 */
@Test void pendingTallyReviewIsVisibleWithResourcesButDraftAndRejectedAreNot() throws Exception {
var security=org.springframework.security.core.context.SecurityContextHolder.getContext();var previous=security.getAuthentication();
security.setAuthentication(new org.springframework.security.authentication.TestingAuthenticationToken("picker","","lastleg:pda:operate","ROLE_OVERSEAS_WAREHOUSE_PDA_OPERATOR"));
try {
var f=new DispatchAggregatePersistenceTest.Fixture();
var created=f.tx.execute(s->f.service.create(f.actor,f.configured("pending-read",List.of(f.source(1,"101",501,"ONT8",3)))));
f.db.execute("CREATE TABLE ll_inventory_position(id BIGINT,customer_id VARCHAR(64),balance_id BIGINT,pallet_id BIGINT,overseas_warehouse_id VARCHAR(64),available_packages BIGINT,hold_packages BIGINT,allocated_packages BIGINT,picked_packages BIGINT)");
var config=new MybatisConfiguration();config.setMapUnderscoreToCamelCase(true);config.addMapper(DispatchWholePdaReadMapper.class);
var factory=new MybatisSqlSessionFactoryBean();factory.setDataSource(f.db.getDataSource());factory.setConfiguration(config);factory.afterPropertiesSet();
var queries=new DispatchPdaQueryService(new DispatchPdaReadRepository(new SqlSessionTemplate(factory.getObject()).getMapper(DispatchWholePdaReadMapper.class)),f.service);
var actor=new DispatchContracts.Actor("picker",ActorMode.PDA,"9");
assertThat(f.tx.<List<DispatchViews.PdaTask>>execute(s->queries.list(actor,null))).isEmpty();
var submitted=f.tx.execute(s->f.service.submitReview(f.actor,created.dispatchNo(),new DispatchCommands.SubmitReview(created.recordVersion(),"submit",null)));
assertThat(f.tx.<List<DispatchViews.PdaTask>>execute(s->queries.list(actor,null))).isEmpty();
var approved=f.tx.execute(s->f.service.review(f.actor,created.dispatchNo(),false,new DispatchCommands.ReviewCommand(submitted.recordVersion(),"dispatch",Decision.APPROVED,null)));
var list=f.tx.execute(s->queries.list(actor,null));assertThat(list).hasSize(1);
var task=list.get(0);assertThat(task.stage()).isEqualTo(Stage.BEFORE_LOADING);
assertThat(task.actions().get(Action.LOADING_REVIEW).allowed()).isTrue();assertThat(task.actions().get(Action.PICK).allowed()).isFalse();
assertThat(task.resources().vehicle().plate()).isEqualTo("TEST-TRUCK");assertThat(task.resources().driver().name()).isEqualTo("测试司机");
f.tx.execute(s->f.service.review(actor,created.dispatchNo(),true,new DispatchCommands.ReviewCommand(approved.recordVersion(),"tally-reject",Decision.REJECTED,"车辆不符")));
assertThat(f.tx.<List<DispatchViews.PdaTask>>execute(s->queries.list(actor,null))).isEmpty();
assertThat(f.tx.execute(s->queries.task(actor,created.dispatchNo())).reviews().loadingReview().reason()).isEqualTo("车辆不符");
} finally {security.setAuthentication(previous);}
}
/** 同仓两个客户、两个目的地只返回一个整车作业,别仓不能借单号读取。 */
@Test void readsOneTruckAcrossCustomersAndRejectsOtherWarehouseNumber() throws Exception {
var f=new DispatchAggregatePersistenceTest.Fixture();var a=f.source(1,"101",501,"ONT8",3);var b=f.source(2,"202",502,"LAX9",5);
@@ -0,0 +1,126 @@
package com.baiyue.lastleg.dispatch.service;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.lastleg.dispatch.port.*;
import org.junit.jupiter.api.Test;
import org.springframework.security.authentication.TestingAuthenticationToken;
import org.springframework.security.core.context.SecurityContextHolder;
import java.util.List;
import static com.baiyue.lastleg.dispatch.port.DispatchTypes.*;
import static org.assertj.core.api.Assertions.*;
/** 审核两端共用真实持久主单,驳回回客服且不能复用上一轮批准。 */
class DispatchReviewWorkflowPersistenceTest {
/** 客服、调度、理货队列互斥,任一级驳回都只回客服队列。 */
@Test void eachReviewRejectionReturnsOnlyToCustomerServiceQueue() throws Exception {
var f=new DispatchAggregatePersistenceTest.Fixture();
var created=f.tx.execute(s->f.service.create(f.actor,f.configured("queues",List.of(f.source(1,"101",501,"ONT8",3)))));
assertQueue(f,created.dispatchNo(),BeforeLoadingSection.VEHICLE_INFO);
var submitted=submit(f,created,"submit-1");assertQueue(f,created.dispatchNo(),BeforeLoadingSection.DISPATCH_REVIEW);
var rejected=review(f,f.actor,submitted,false,"dispatch-reject",Decision.REJECTED," 车辆图片不清楚 ");
assertQueue(f,created.dispatchNo(),BeforeLoadingSection.VEHICLE_INFO);
assertThat(f.service.detail(f.actor,created.dispatchNo()).reviews().dispatchReview().reason()).isEqualTo("车辆图片不清楚");
var resubmitted=submit(f,rejected,"submit-2");
var approved=review(f,f.actor,resubmitted,false,"dispatch-approve",Decision.APPROVED,null);
assertQueue(f,created.dispatchNo(),BeforeLoadingSection.LOADING_REVIEW);
var tallyRejected=review(f,f.actor,approved,true,"loading-reject",Decision.REJECTED,"司机不符");
assertQueue(f,created.dispatchNo(),BeforeLoadingSection.VEHICLE_INFO);
var detail=f.service.detail(f.actor,created.dispatchNo());
assertThat(detail.reviews().loadingReview().reason()).isEqualTo("司机不符");
assertThat(detail.reviews().dispatchReview().status()).isEqualTo(ReviewStatus.NOT_SUBMITTED);
assertThatThrownBy(()->review(f,f.actor,tallyRejected,true,"cannot-skip-dispatch",Decision.APPROVED,null)).hasMessageContaining("状态已变化");
var third=submit(f,tallyRejected,"submit-3");assertQueue(f,created.dispatchNo(),BeforeLoadingSection.DISPATCH_REVIEW);
detail=f.service.detail(f.actor,created.dispatchNo());
assertThat(detail.reviews().dispatchReview().reviewRound()).isEqualTo(3);
assertThat(detail.reviews().loadingReview().reason()).isNull();
assertThatThrownBy(()->review(f,f.actor,third,true,"cannot-skip-again",Decision.APPROVED,null)).hasMessageContaining("状态已变化");
}
/** 两级拒绝都须真实原因;失败不写版本、审核历史或命令结果。 */
@Test void blankRejectionReasonIsRejectedAtBothLevelsWithoutWrites() throws Exception {
var f=new DispatchAggregatePersistenceTest.Fixture();
var created=f.tx.execute(s->f.service.create(f.actor,f.configured("reason",List.of(f.source(1,"101",501,"ONT8",3)))));
var submitted=submit(f,created,"submit");
assertThatThrownBy(()->review(f,f.actor,submitted,false,"blank-dispatch",Decision.REJECTED," \n ")).hasMessageContaining("驳回原因不能为空");
assertThat(f.repository.find(created.dispatchNo()).getRecordVersion()).isEqualTo(submitted.recordVersion());
var approved=review(f,f.actor,submitted,false,"approve",Decision.APPROVED,null);
assertThatThrownBy(()->review(f,f.actor,approved,true,"blank-loading",Decision.REJECTED,null)).hasMessageContaining("驳回原因不能为空");
assertThat(f.repository.find(created.dispatchNo()).getRecordVersion()).isEqualTo(approved.recordVersion());
assertThat(f.db.queryForObject("SELECT COUNT(*) FROM ll_dispatch_command WHERE idempotency_key LIKE 'blank-%'",Long.class)).isZero();
}
/** 客服多次改车货保留本轮驳回原因,重新提交才开始全新审核轮次。 */
@Test void cargoChangesKeepRejectionReasonUntilNextSubmission() throws Exception {
for(boolean loading:List.of(false,true)) {
var f=new DispatchAggregatePersistenceTest.Fixture();
var created=f.tx.execute(s->f.service.create(f.actor,f.configured("retain",List.of(f.source(1,"101",501,"ONT8",3)))));
var current=submit(f,created,"submit");
if(loading)current=review(f,f.actor,current,false,"approve",Decision.APPROVED,null);
var rejected=review(f,f.actor,current,loading,"reject",Decision.REJECTED,"请重新核对货物");
var added=f.tx.execute(s->f.service.addCargo(f.actor,created.dispatchNo(),new DispatchCommands.AddCargo(rejected.recordVersion(),"add",List.of(f.source(2,"101",502,"ONT8",2)),List.of())));
var addedAgain=f.tx.execute(s->f.service.addCargo(f.actor,created.dispatchNo(),new DispatchCommands.AddCargo(added.recordVersion(),"add-again",List.of(f.source(3,"101",503,"ONT8",1)),List.of())));
long item=f.db.queryForObject("SELECT id FROM ll_dispatch_item WHERE plan_pallet_id=3",Long.class);
var removed=f.tx.execute(s->f.service.removeCargo(f.actor,created.dispatchNo(),item,new DispatchCommands.ReasonCommand(addedAgain.recordVersion(),"remove","调整目的仓")));
var resources=f.configured("updated-resources",List.of());
var updated=f.tx.execute(s->f.service.update(f.actor,created.dispatchNo(),new DispatchCommands.Update(removed.recordVersion(),"update-vehicle",resources.vehicle(),resources.driver(),resources.plannedDepartureAt().plusSeconds(60),null,"客服更正")));
var detail=f.service.detail(f.actor,created.dispatchNo());
var retained=loading?detail.reviews().loadingReview():detail.reviews().dispatchReview();
assertThat(detail.reviewState()).isEqualTo(ReviewState.DRAFT);
assertThat(retained.status()).isEqualTo(ReviewStatus.REJECTED);assertThat(retained.reason()).isEqualTo("请重新核对货物");
assertThat(retained.basisHash()).isNull();assertQueue(f,created.dispatchNo(),BeforeLoadingSection.VEHICLE_INFO);
submit(f,updated,"resubmit");detail=f.service.detail(f.actor,created.dispatchNo());
assertThat(detail.reviews().dispatchReview().status()).isEqualTo(ReviewStatus.PENDING);
assertThat(detail.reviews().dispatchReview().reason()).isNull();assertThat(detail.reviews().loadingReview().reason()).isNull();
}
}
/** PDA理货共享后台版本;成功恢复不重复写,越仓、失权和替换身份均拒绝。 */
@Test void pdaReviewSharesVersionAndOriginalRecoveryWithAdmin() throws Exception {
var security=SecurityContextHolder.getContext();var previous=security.getAuthentication();
try {
security.setAuthentication(new TestingAuthenticationToken("picker","","lastleg:pda:operate","ROLE_OVERSEAS_WAREHOUSE_PDA_OPERATOR"));
var f=new DispatchAggregatePersistenceTest.Fixture();
var pda=new DispatchContracts.Actor("picker",ActorMode.PDA,"9");
var created=f.tx.execute(s->f.service.create(f.actor,f.configured("pda-review",List.of(f.source(1,"101",501,"ONT8",3)))));
var submitted=submit(f,created,"submit");
assertThat(f.service.actions(pda,f.repository.find(created.dispatchNo())).get(Action.LOADING_REVIEW).allowed()).isFalse();
assertThatThrownBy(()->review(f,pda,submitted,false,"pda-dispatch",Decision.APPROVED,null)).hasMessageContaining("仅当前绑定仓");
var approved=review(f,f.actor,submitted,false,"dispatch",Decision.APPROVED,null);
assertThat(f.service.actions(pda,f.repository.find(created.dispatchNo())).get(Action.LOADING_REVIEW).allowed()).isTrue();
assertThatThrownBy(()->review(f,new DispatchContracts.Actor("picker",ActorMode.PDA,"99"),approved,true,"wrong-warehouse",Decision.APPROVED,null)).hasMessageContaining("当前绑定仓");
var loaded=review(f,pda,approved,true,"pda-approve",Decision.APPROVED,null);
assertThat(loaded.stage()).isEqualTo(Stage.LOADING);
assertThatThrownBy(()->review(f,f.actor,approved,true,"admin-stale",Decision.APPROVED,null)).hasMessageContaining("版本已变化");
assertThat(review(f,pda,approved,true,"pda-approve",Decision.APPROVED,null).replayed()).isTrue();
assertThat(f.tx.execute(s->f.service.command(pda,created.dispatchNo(),"pda-approve")).replayed()).isTrue();
assertThatThrownBy(()->f.tx.execute(s->f.service.command(new DispatchContracts.Actor("picker",ActorMode.PDA,"99"),created.dispatchNo(),"pda-approve"))).hasMessageContaining("当前绑定仓");
assertThatThrownBy(()->f.tx.execute(s->f.service.command(new DispatchContracts.Actor("other",ActorMode.PDA,"9"),created.dispatchNo(),"pda-approve"))).hasMessageContaining("无权");
security.setAuthentication(new TestingAuthenticationToken("picker","","lastleg:pda:operate"));
assertThatThrownBy(()->review(f,pda,approved,true,"pda-approve",Decision.APPROVED,null)).hasMessageContaining("仅当前绑定仓");
assertThatThrownBy(()->f.tx.execute(s->f.service.command(pda,created.dispatchNo(),"pda-approve"))).hasMessageContaining("原动作权限");
assertThat(f.repository.find(created.dispatchNo()).getRecordVersion()).isEqualTo(loaded.recordVersion());
} finally {security.setAuthentication(previous);}
}
/** 使用服务真实SQL分页和全量统计验证当前队列,不以返回动作猜测队列。 */
private static void assertQueue(DispatchAggregatePersistenceTest.Fixture f,String number,BeforeLoadingSection expected) {
for(var section:BeforeLoadingSection.values()) {
var page=f.tx.execute(s->f.service.page(f.actor,new DispatchViews.Query(1,20,number,null,null,null,null,StatusFilter.ACTIVE,Stage.BEFORE_LOADING,section,null)));
assertThat(page.total()).isEqualTo(section==expected?1:0);
var counts=page.statistics().beforeLoadingReviewCounts();
assertThat(counts.vehicleInfoTotal()).isEqualTo(expected==BeforeLoadingSection.VEHICLE_INFO?1:0);
assertThat(counts.dispatchReview().pending()).isEqualTo(expected==BeforeLoadingSection.DISPATCH_REVIEW?1:0);
assertThat(counts.loadingReview().pending()).isEqualTo(expected==BeforeLoadingSection.LOADING_REVIEW?1:0);
}
}
/** 每次提交以真实返回版本开始下一轮,不人工造审核状态。 */
private static DispatchContracts.CommandResult submit(DispatchAggregatePersistenceTest.Fixture f,DispatchContracts.CommandResult prior,String key) {
return f.tx.execute(s->f.service.submitReview(f.actor,prior.dispatchNo(),new DispatchCommands.SubmitReview(prior.recordVersion(),key,null)));
}
/** 通过同一审核服务分别调用两端,版本和幂等键始终由请求明确提供。 */
private static DispatchContracts.CommandResult review(DispatchAggregatePersistenceTest.Fixture f,DispatchContracts.Actor actor,DispatchContracts.CommandResult prior,boolean loading,String key,Decision decision,String reason) {
return f.tx.execute(s->f.service.review(actor,prior.dispatchNo(),loading,new DispatchCommands.ReviewCommand(prior.recordVersion(),key,decision,reason)));
}
}
@@ -72,6 +72,18 @@ class PdaOutboundDispatchContractTest {
verify(outboundService).pdaTasks("900", null);
}
/** PDA只暴露理货审核且身份固定取绑定仓,不提供调度审核入口。 */
@Test
void loadingReviewUsesTheSharedServiceWithBoundPdaIdentity() {
var workflow=mock(com.baiyue.lastleg.dispatch.service.DispatchAggregateService.class);
var controller=new DispatchPdaController();ReflectionTestUtils.setField(controller,"workflow",workflow);
var user=pda(Set.of("lastleg:pda:operate","ROLE_OVERSEAS_WAREHOUSE_PDA_OPERATOR"),900L);
var request=new DispatchCommands.ReviewCommand(2L,"pda-review",DispatchTypes.Decision.REJECTED,"司机资料待核对");
controller.loadingReview(user,"PC202610210001",request);
verify(workflow).review(new DispatchContracts.Actor("7",DispatchTypes.ActorMode.PDA,"900"),"PC202610210001",true,request);
assertFalse(Arrays.stream(DispatchPdaController.class.getDeclaredMethods()).anyMatch(method->method.getName().equals("dispatchReview")));
}
@Test
void missingRolePermissionOrWarehouseBindingIsRejected() {
DispatchPdaController controller = new DispatchPdaController();
@@ -88,7 +100,10 @@ class PdaOutboundDispatchContractTest {
String yaml = Files.readString(Path.of("src/main/resources/openapi/overseas-pda-operations-v1.yaml"),
StandardCharsets.UTF_8);
assertTrue(yaml.contains("/api/pda/v1/last-leg/dispatches:"));
assertTrue(yaml.contains("/api/pda/v1/last-leg/dispatches/{dispatchNo}/depart:"));
assertTrue(yaml.contains("/api/pda/v1/last-leg/dispatches/{dispatchNo}/loading-review:"));
assertTrue(yaml.contains("overseas-business-v1.yaml#/components/schemas/DispatchReviewCommand"));
assertFalse(yaml.contains("/api/pda/v1/last-leg/dispatches/{dispatchNo}/depart:"));
assertTrue(yaml.contains("/api/pda/v1/last-leg/dispatches/{dispatchNo}/loading-complete:"));
assertTrue(yaml.contains("/api/pda/v1/last-leg/outbound-tasks/{taskNo}/complete:"));
assertTrue(yaml.contains("/api/pda/v1/last-leg/outbound-tasks/{taskNo}/pallets/{palletNo}/pick:"));
assertFalse(yaml.contains("name: customerId"));