This commit is contained in:
2026-10-21 17:26:43 +08:00
parent 0976b6ef0d
commit c442c9eaa7
32 changed files with 587 additions and 43 deletions
@@ -148,7 +148,7 @@ public class DispatchAggregatePersistenceTest {
String dispatch(ResourceMode mode,Long driverId,List<Long> ids) throws Exception {
var created=f.tx.execute(t->f.service.create(f.actor,f.create("evidence-"+UUID.randomUUID(),List.of())));
var resource=new DispatchContracts.Resources(null,new DispatchContracts.DriverSnapshot(mode,driverId,"测试司机","123456789",
new DispatchContracts.DriverEvidence("TEST-LICENSE",LocalDate.of(2099,1,1),ids,Instant.parse("2098-01-01T00:00:00Z"))));
new DispatchContracts.DriverEvidence("TEST-LICENSE",LocalDate.of(2099,1,1),ids,Instant.parse("2098-01-01T00:00:00Z"),List.of())));
f.db.update("UPDATE ll_dispatch_order SET resources_json=? WHERE dispatch_no=?",f.json.writeValueAsString(resource),created.dispatchNo());return created.dispatchNo();
}
/** File内容固定为离线测试字节;真实复合归属由既有两个HTTP网关合同分别覆盖。 */
@@ -591,7 +591,7 @@ public class DispatchAggregatePersistenceTest {
return new DispatchCommands.Create(key,"9",new DispatchCommands.Vehicle(ResourceMode.EXTERNAL,null,"TEST-TRUCK","测试承运方",
new DispatchContracts.Capacity(15,new BigDecimal("10000"),new BigDecimal("50")),List.of(101L)),
new DispatchCommands.Driver(ResourceMode.EXTERNAL,null,"测试司机","123456789",
new DispatchContracts.DriverEvidence("TEST-LICENSE",java.time.LocalDate.of(2099,1,1),List.of(202L),departure.plusSeconds(86400))),
new DispatchContracts.DriverEvidence("TEST-LICENSE",java.time.LocalDate.of(2099,1,1),List.of(202L),departure.plusSeconds(86400),List.of())),
departure,null,null,sources,List.of());
}
@@ -157,7 +157,7 @@ class DispatchRepairWorkflowTest {
assertThat(created.dispatchNo()).isNotEqualTo("DP-1");
var current=dispatch.detail(aggregateFixture.actor,created.dispatchNo());
assertThat(current.actions().get(DispatchTypes.Action.CANCEL).allowed()).isTrue();
assertThat(current.actions().get(DispatchTypes.Action.ARRANGE_DELIVERY).allowed()).isFalse();
assertThat(current.actions()).doesNotContainKey(DispatchTypes.Action.ARRANGE_DELIVERY);
assertThat(created.stage()).isEqualTo(DispatchTypes.Stage.BEFORE_LOADING);
assertThat(db.queryForObject("SELECT COUNT(*) FROM ll_dispatch_pallet_lock WHERE status='ACTIVE' AND dispatch_order_id<>1", Long.class)).isEqualTo(1);
assertThat(db.queryForObject("SELECT dispatch_order_id FROM ll_dispatch_physical_pallet_lock WHERE active_warehouse_pallet_id=501",Long.class)).isEqualTo(aggregateFixture.repository.find(created.dispatchNo()).getId());
@@ -0,0 +1,93 @@
package com.baiyue.lastleg.dispatch.service;
import com.baiyue.lastleg.dispatch.gateway.DispatchEvidenceGateway;
import com.baiyue.lastleg.dispatch.port.DispatchCommands;
import com.baiyue.lastleg.dispatch.port.DispatchContracts;
import org.junit.jupiter.api.Test;
import org.springframework.test.util.ReflectionTestUtils;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.Mockito.atLeastOnce;
import static org.mockito.Mockito.doThrow;
import static org.mockito.Mockito.verify;
/** 车辆维护保存司机照片后,真实资源快照、审核和原键恢复均保留相同引用。 */
class DispatchResourcePhotosPersistenceTest {
/** 新字段缺省不改变升级前命令及审核依据的摘要,实际照片变化仍必须识别。 */
@Test void optionalPhotosPreserveLegacyHashesButBindActualReferences() throws Exception {
var f = new DispatchAggregatePersistenceTest.Fixture();
var input = f.configured("legacy-photos", List.of());
var request = update(input, 0L, "legacy-edit", List.of());
var oldJson = f.json.valueToTree(request);
((com.fasterxml.jackson.databind.node.ObjectNode) oldJson.path("driver").path("evidence")).remove("photoFileIds");
var hashes = new DispatchCommandHasher(f.json);
var action = com.baiyue.lastleg.dispatch.port.DispatchTypes.Action.EDIT;
var oldHash = hashes.command(f.actor, action, "PC-OLD", 0L, oldJson);
assertThat(hashes.command(f.actor, action, "PC-OLD", 0L, request)).isEqualTo(oldHash);
assertThat(hashes.source(request)).isEqualTo(hashes.source(oldJson));
var withPhotos = update(input, 0L, "legacy-edit", List.of(401L, 402L));
assertThat(hashes.command(f.actor, action, "PC-OLD", 0L, withPhotos)).isNotEqualTo(oldHash)
.isEqualTo(hashes.command(f.actor, action, "PC-OLD", 0L, update(input, 0L, "legacy-edit", List.of(402L, 401L))));
}
/** 使用真实资源持久化与两级审核,防止车队资源规范化吞掉独立司机照片。 */
@Test void savedPhotosSurviveReplayAndBothReviews() throws Exception {
var f = new DispatchAggregatePersistenceTest.Fixture();
var input = f.configured("photos-create", List.of(f.source(1, "101", 501, "ONT8", 3)));
var created = f.tx.execute(t -> f.service.create(f.actor, input));
var request = update(input, created.recordVersion(), "photos-save", List.of(401L));
var saved = f.tx.execute(t -> f.service.update(f.actor, created.dispatchNo(), request));
assertThat(f.tx.execute(t -> f.service.update(f.actor, created.dispatchNo(), request)).replayed()).isTrue();
var approved = f.approve(saved);
var detail = f.tx.execute(t -> f.service.detail(f.actor, created.dispatchNo()));
assertThat(detail.vehicleInfo().driver().evidence().photoFileIds()).containsExactly(401L);
assertThat(detail.vehicleInfo().driver().evidence().fileIds()).containsExactly(202L);
verify(gateway(f), atLeastOnce()).metadata(f.repository.find(created.dispatchNo()).getId(), DispatchEvidenceGateway.Kind.DRIVER_PHOTO, 401L);
assertThat(approved.recordVersion()).isGreaterThan(saved.recordVersion());
}
/** 错车或错类附件失败时,事务不得覆盖原资源、推进版本或留下成功命令。 */
@Test void foreignPhotoCannotChangeSavedResources() throws Exception {
var f = new DispatchAggregatePersistenceTest.Fixture();
var input = f.configured("foreign-photo-create", List.of(f.source(1, "101", 501, "ONT8", 3)));
var created = f.tx.execute(t -> f.service.create(f.actor, input));
var before = f.db.queryForMap("SELECT resources_json,record_version FROM ll_dispatch_order WHERE dispatch_no=?", created.dispatchNo());
doThrow(new IllegalArgumentException("本车分类归属不匹配")).when(gateway(f))
.metadata(f.repository.find(created.dispatchNo()).getId(), DispatchEvidenceGateway.Kind.DRIVER_PHOTO, 401L);
var request = update(input, created.recordVersion(), "foreign-photo-save", List.of(401L));
assertThatThrownBy(() -> f.tx.execute(t -> f.service.update(f.actor, created.dispatchNo(), request)))
.hasMessageContaining("归属不匹配");
assertThat(f.db.queryForMap("SELECT resources_json,record_version FROM ll_dispatch_order WHERE dispatch_no=?", created.dispatchNo())).isEqualTo(before);
assertThat(f.db.queryForObject("SELECT COUNT(*) FROM ll_dispatch_command WHERE idempotency_key='foreign-photo-save'", Long.class)).isZero();
}
/** 编辑清空照片必须真实持久化;历史资源缺该字段时仍按空集合读取。 */
@Test void photosCanBeRemovedAndOldSnapshotsRemainReadable() throws Exception {
var f = new DispatchAggregatePersistenceTest.Fixture();
var input = f.configured("remove-photo-create", List.of());
var created = f.tx.execute(t -> f.service.create(f.actor, input));
var saved = f.tx.execute(t -> f.service.update(f.actor, created.dispatchNo(), update(input, created.recordVersion(), "add-photo", List.of(401L))));
f.tx.execute(t -> f.service.update(f.actor, created.dispatchNo(), update(input, saved.recordVersion(), "remove-photo", List.of())));
assertThat(f.tx.execute(t -> f.service.detail(f.actor, created.dispatchNo())).vehicleInfo().driver().evidence().photoFileIds()).isEmpty();
var legacy = f.json.readValue("{\"licenseNo\":\"TEST\",\"fileIds\":[202]}", DispatchContracts.DriverEvidence.class);
assertThat(legacy.photoFileIds()).isEmpty();
assertThat(legacy.fileIds()).containsExactly(202L);
}
/** 仅替换司机照片,保留车辆、驾驶证和计划字段。 */
private static DispatchCommands.Update update(DispatchCommands.Create input, long version, String key, List<Long> photos) {
var d = input.driver();
var e = d.evidence();
var driver = new DispatchCommands.Driver(d.mode(), d.driverId(), d.name(), d.phone(),
new DispatchContracts.DriverEvidence(e.licenseNo(), e.licenseExpiresOn(), e.fileIds(), e.expectedTaskEndAt(), photos));
return new DispatchCommands.Update(version, key, input.vehicle(), driver, input.plannedDepartureAt(), input.estimatedArrivalAt(), "维护照片");
}
/** 只替代外部附件提供者,资源、命令和事务仍走真实服务。 */
private static DispatchEvidenceGateway gateway(DispatchAggregatePersistenceTest.Fixture f) {
return (DispatchEvidenceGateway) ReflectionTestUtils.getField(f.service, "evidence");
}
}
@@ -11,6 +11,32 @@ import static org.assertj.core.api.Assertions.*;
/** 审核两端共用真实持久主单,驳回回客服且不能复用上一轮批准。 */
class DispatchReviewWorkflowPersistenceTest {
/** 取货复用同轮两级审核与驳回重提,不创建仓内拣货任务。 */
@Test void pickupUsesBothReviewsAndRejectsSkippingOrStaleApproval() throws Exception {
var f=new DispatchAggregatePersistenceTest.Fixture();
var created=f.tx.execute(s->f.service.create(f.actor,f.configured("pickup-review",List.of(f.source(1,"101",501,"ONT8",3)))));
f.db.update("UPDATE ll_dispatch_order SET task_type='PICKUP' WHERE dispatch_no=?",created.dispatchNo());
var transport=org.mockito.Mockito.mock(com.baiyue.lastleg.dispatch.transport.TransportTaskService.class);
var trip=new com.baiyue.lastleg.fleet.trip.FleetTripEntity();trip.setStopsJson("[]");
org.mockito.Mockito.when(transport.transport(org.mockito.ArgumentMatchers.eq(created.dispatchNo()),org.mockito.ArgumentMatchers.anyBoolean())).thenReturn(trip);
org.springframework.test.util.ReflectionTestUtils.setField(f.service,"transportTasks",transport);
var submitted=submit(f,created,"pickup-submit");assertQueue(f,created.dispatchNo(),BeforeLoadingSection.DISPATCH_REVIEW);
assertThatThrownBy(()->review(f,f.actor,submitted,true,"skip",Decision.APPROVED,null)).hasMessageContaining("状态已变化");
var approved=review(f,f.actor,submitted,false,"pickup-dispatch",Decision.APPROVED,null);
assertQueue(f,created.dispatchNo(),BeforeLoadingSection.LOADING_REVIEW);
var rejected=review(f,f.actor,approved,true,"pickup-reject",Decision.REJECTED,"车辆不符");
assertQueue(f,created.dispatchNo(),BeforeLoadingSection.VEHICLE_INFO);
var next=submit(f,rejected,"pickup-resubmit");
var nextApproved=review(f,f.actor,next,false,"pickup-dispatch-2",Decision.APPROVED,null);
var loaded=review(f,f.actor,nextApproved,true,"pickup-loading-2",Decision.APPROVED,null);
assertThat(loaded.stage()).isEqualTo(Stage.LOADING);
var row=f.repository.find(created.dispatchNo());
assertThat(row.getReviewState()).isEqualTo("APPROVED");
assertThat(f.service.actions(f.actor,row).get(Action.PICK).allowed()).isFalse();
assertThat(f.service.actions(f.actor,row).get(Action.LOADING_COMPLETE).allowed()).isFalse();
assertThat(f.repository.outbounds(row.getId())).isEmpty();
org.mockito.Mockito.verify(transport,org.mockito.Mockito.times(5)).verifyPickupReview(created.dispatchNo());
}
/** 客服、调度、理货队列互斥,任一级驳回都只回客服队列。 */
@Test void eachReviewRejectionReturnsOnlyToCustomerServiceQueue() throws Exception {
var f=new DispatchAggregatePersistenceTest.Fixture();
@@ -15,6 +15,35 @@ import static org.mockito.ArgumentMatchers.*;
import static org.assertj.core.api.Assertions.*;
class TransportTaskProjectionTest {
/** 已出发也不能跳过照片阶段,全部取货照片确认后才进入派送中。 */
@Test void oldDepartureDoesNotSkipPickupPhotosStage() {
var roots=mock(DispatchAggregateRepository.class);var links=mock(TransportTaskRepository.class);var pickups=mock(com.baiyue.lastleg.fleet.pickup.FleetPickupRepository.class);
var projection=new TransportTaskProjection(roots,links,mock(UsTruckOrderRepository.class),mock(DispatchNumberGenerator.class),new ObjectMapper().findAndRegisterModules());
org.springframework.test.util.ReflectionTestUtils.setField(projection,"pickupFacts",pickups);
var root=new DispatchAggregate();root.setId(2L);root.setDispatchNo("PC1");root.setTaskType("PICKUP");root.setRecordVersion(10L);root.setStage("LOADING");
var task=new com.baiyue.lastleg.fleet.pickup.FleetPickupTask();task.setStatus("ASSIGNED");
when(links.byTrip(1L)).thenReturn(root);when(roots.lock("PC1")).thenReturn(root);when(roots.saveState(eq(root),anyLong())).thenReturn(1);when(pickups.tasks("TR1")).thenReturn(java.util.List.of(task));
var trip=new FleetTripEntity();trip.setId(1L);trip.setTripNo("TR1");trip.setTripPurpose("PICKUP");trip.setStatus("IN_TRANSIT");
projection.changed(new TransportTripChanged(trip,"PICKUP_DEPART","出发","OP"));assertThat(root.getStage()).isEqualTo("LOADING");
task.setStatus("PICKED");projection.changed(new TransportTripChanged(trip,"PICKUP_PICKED","照片核齐","OP"));assertThat(root.getStage()).isEqualTo("DELIVERING");
trip.setStatus("COMPLETED");projection.changed(new TransportTripChanged(trip,"PICKUP_ARRIVED","到仓照片核齐","OP"));assertThat(root.getStage()).isEqualTo("DELIVERED");
}
/** 审核推进后车次版本可能较小,换车仍须递增主单版本并作废两级审核。 */
@Test void pickupResourceChangeInvalidatesApprovalWithoutRegressingVersion() throws Exception {
var roots=mock(DispatchAggregateRepository.class);var links=mock(TransportTaskRepository.class);
var json=new ObjectMapper().findAndRegisterModules();
var projection=new TransportTaskProjection(roots,links,mock(UsTruckOrderRepository.class),mock(DispatchNumberGenerator.class),json);
var root=new DispatchAggregate();root.setId(2L);root.setDispatchNo("PC1");root.setTaskType("PICKUP");root.setStatus("ACTIVE");root.setStage("LOADING");root.setRecordVersion(10L);root.setReviewRound(1L);root.setReviewState("APPROVED");root.setReviewBasisHash("basis");root.setResourcesJson("{}");
var review=new com.baiyue.lastleg.dispatch.port.DispatchContracts.Review(com.baiyue.lastleg.dispatch.port.DispatchTypes.ReviewStatus.APPROVED,null,"OP",java.time.Instant.now(),1,"basis",null,null);
root.setDispatchReviewJson(json.writeValueAsString(review));root.setLoadingReviewJson(json.writeValueAsString(review));
when(links.byTrip(1L)).thenReturn(root);when(roots.lock("PC1")).thenReturn(root);when(roots.saveState(root,10L)).thenReturn(1);
assertThatCode(()->projection.requireApproved(1L)).doesNotThrowAnyException();
var trip=new FleetTripEntity();trip.setId(1L);trip.setTripPurpose("PICKUP");trip.setStatus("PLANNED");trip.setRecordVersion(4L);trip.setUpdatedAt(LocalDateTime.now());
projection.changed(new TransportTripChanged(trip,"ASSIGN","换车","OP"));
assertThat(root.getRecordVersion()).isEqualTo(11L);assertThat(root.getReviewState()).isEqualTo("DRAFT");assertThat(root.getStage()).isEqualTo("BEFORE_LOADING");
assertThatThrownBy(()->projection.requireApproved(1L)).hasMessageContaining("调度审核和理货员审核");
verify(roots).saveState(root,10L);
}
/** 旧取消入口产生的运输事实也必须撤销原单安排和资源投影。 */
@Test void legacyCancellationSynchronizesDispatchAndUnlocksSource(){
var roots=mock(DispatchAggregateRepository.class);var links=mock(TransportTaskRepository.class);var trucks=mock(UsTruckOrderRepository.class);
@@ -58,6 +58,30 @@ class TransportTaskServiceTest {
@Test void evidenceDownloadRequiresPersistedConfirmation(){
assertThatThrownBy(()->service.evidence("PC1",77L)).hasMessageContaining("没有该完成凭证");verifyNoInteractions(files);
}
/** 只对审核通过的上门取货生成排车,已有及取消任务都不得重建。 */
@Test void approvalCreatesOnlyEligibleUnassignedPickup() {
var facts=mock(FleetPickupRepository.class);
var subject=spy(new TransportTaskService(roots,links,mock(TransportTaskProjection.class),tripRepo,trips,
mock(FleetPickupService.class),facts,trucks,mock(FleetPoolOrderQueries.class),mock(DriverService.class),
mock(FleetTransportVehicleGuard.class),files,new ObjectMapper().findAndRegisterModules()));
var source=new FleetPickupSource();source.setId(42L);source.setTransferNo("ZY1");source.setInboundCode("ZY1");
source.setDeliveryMethod("WAREHOUSE_PICKUP");source.setStatus("WAIT_PDA_RECEIVING");
when(facts.source("C1","ZY1",true)).thenReturn(source);
doReturn(null).when(subject).createPickup(anyString(),any());
subject.ensureApprovedPickup("C1","ZY1","OP");
var request=org.mockito.ArgumentCaptor.forClass(FleetPickupModels.Create.class);
verify(subject).createPickup(eq("OP"),request.capture());
assertThat(request.getValue().sources()).containsExactly(new FleetPickupModels.Source("C1","ZY1"));
assertThat(request.getValue().idempotencyKey()).isEqualTo("transfer-approval:42");
assertThat(request.getValue().expectedDriverEndAt()).isAfter(request.getValue().plannedDepartureAt());
clearInvocations(subject);
when(facts.lockTask(42L)).thenReturn(new FleetPickupTask());
subject.ensureApprovedPickup("C1","ZY1","OP2");
verify(subject,never()).createPickup(anyString(),any());
source.setDeliveryMethod("CUSTOMER_DELIVERY");clearInvocations(facts);
subject.ensureApprovedPickup("C1","ZY1","OP");
verify(facts,never()).lockTask(anyLong());
}
/** 构造固定版本的测试执行命令。 */
private TransportTaskModels.Command command(String key,List<Long> files){return new TransportTaskModels.Command(3L,key,"结束运输",files);}
}
@@ -60,4 +60,12 @@ class FleetPickupMapperTest {
db.update("INSERT INTO ll_fleet_pickup_task VALUES(1,'C1','WT-1','TR-1','PICKED',CURRENT_TIMESTAMP)");
assertThat(mapper.unassign("TR-1")).isZero();assertThat(mapper.task(1L).getTripNo()).isEqualTo("TR-1");
}
/** 联动取消保留原车次身份,已取货成员不能被取消SQL覆盖。 */
@Test void cancellationKeepsSourceLinkAndDoesNotRewritePickedFacts() {
db.update("INSERT INTO ll_fleet_pickup_task VALUES(1,'C1','WT-1','TR-1','ASSIGNED',CURRENT_TIMESTAMP)");
db.update("INSERT INTO ll_fleet_pickup_task VALUES(6,'C6','WT-6','TR-1','PICKED',CURRENT_TIMESTAMP)");
assertThat(mapper.cancelTasks("TR-1")).isEqualTo(1);
assertThat(mapper.task(1L).getTripNo()).isEqualTo("TR-1");assertThat(mapper.task(1L).getStatus()).isEqualTo("CANCELLED");
assertThat(mapper.task(6L).getStatus()).isEqualTo("PICKED");assertThat(mapper.cancelTasks("TR-1")).isZero();
}
}
@@ -15,11 +15,15 @@ import static org.mockito.ArgumentMatchers.*;
class FleetPickupServiceTest {
FleetPickupRepository repository;FleetTripService trips;FleetPickupService service;
PickupDepartureReview departureReview;
DriverService drivers;VehicleRepository vehicles;
TripOperationsEvidenceGateway files;FleetTripEntity trip;FleetPickupSource source;FleetPickupTask task;
final Map<String,String> commands=new HashMap<>();
@BeforeEach void setup() {
repository=mock(FleetPickupRepository.class);trips=mock(FleetTripService.class);files=mock(TripOperationsEvidenceGateway.class);
service=new FleetPickupService(repository,trips,mock(FleetWarehouseDirectory.class),mock(DriverService.class),mock(VehicleRepository.class),files,new ObjectMapper().findAndRegisterModules());
departureReview=mock(PickupDepartureReview.class);
drivers=mock(DriverService.class);vehicles=mock(VehicleRepository.class);
service=new FleetPickupService(repository,trips,mock(FleetWarehouseDirectory.class),drivers,vehicles,files,new ObjectMapper().findAndRegisterModules(),departureReview);
trip=new FleetTripEntity();trip.setId(9L);trip.setTripNo("TR-1");trip.setTripPurpose("PICKUP");trip.setStatus("IN_TRANSIT");trip.setWarehouseId(100L);trip.setRecordVersion(2L);
source=source(1L,"C1","WT-1","100");task=new FleetPickupTask();task.setTransferOrderId(1L);task.setCustomerId("C1");task.setTransferNo("WT-1");task.setTripNo("TR-1");task.setStatus("ASSIGNED");
when(repository.trip("TR-1",true)).thenReturn(trip);when(repository.trip("TR-1",false)).thenReturn(trip);
@@ -29,6 +33,29 @@ class FleetPickupServiceTest {
doAnswer(c->{commands.put(c.getArgument(1),c.getArgument(2));return null;}).when(repository).command(anyString(),anyString(),anyString());
doAnswer(c->{trip.setRecordVersion(trip.getRecordVersion()+1);return null;}).when(trips).recordExecution(any(),anyLong(),anyString(),anyString(),anyString());
}
/** 审核完成后直接在装货阶段上传取货照片,全车核齐自动进入派送。 */
@Test void pickupPhotosStartDeliveryWithoutSeparateDeparture() {
trip.setStatus("PLANNED");trip.setVehicleId(20L);trip.setDriverId(30L);trip.setExpectedDriverEndAt(LocalDateTime.now(ZoneOffset.UTC).plusDays(1));
when(repository.ownsResources(9L,20L,30L)).thenReturn(true);
var vehicle=mock(VehicleRepository.VehicleSnapshot.class);when(vehicle.region()).thenReturn("OVERSEAS");when(vehicle.status()).thenReturn("IN_SERVICE");when(vehicles.lockById(20L)).thenReturn(Optional.of(vehicle));
var driver=mock(DriverModels.Response.class);var details=mock(DriverModels.Draft.class);when(details.region()).thenReturn("OVERSEAS");when(driver.details()).thenReturn(details);when(drivers.requireDispatchable(eq(30L),any())).thenReturn(driver);
when(repository.ownsUpload(1L,"TR-1",88L)).thenReturn(true);
when(files.metadata(9L,88L)).thenReturn(new TripOperationsRecordModels.EvidenceFile("88","取货照片.jpg","image/jpeg",500));
var request=new FleetPickupModels.Pickup(2L,"pickup-photo-key",5,List.of(88L),null);
service.picked("TR-1","C1","WT-1","OP",request);
assertThat(task.getStatus()).isEqualTo("PICKED");assertThat(trip.getStatus()).isEqualTo("IN_TRANSIT");assertThat(trip.getDepartedAt()).isNotNull();
verify(departureReview).requireApproved(9L);verify(trips,times(1)).recordExecution(any(),eq(2L),eq("PICKUP_PICKED"),anyString(),eq("OP"));
service.picked("TR-1","C1","WT-1","OP",request);verify(repository,times(1)).update(task);
}
/** 原车队发车接口不能在两级审核未通过时写入实际发车事实。 */
@Test void legacyDepartureCannotBypassReview() {
trip.setStatus("PLANNED");
doThrow(new IllegalStateException("请先完成两级审核")).when(departureReview).requireApproved(9L);
assertThatThrownBy(()->service.depart("TR-1","OP",new FleetPickupModels.Command(2L,"depart-review-key","发车"))).hasMessageContaining("两级审核");
assertThat(trip.getStatus()).isEqualTo("PLANNED");assertThat(trip.getDepartedAt()).isNull();
verify(trips,never()).recordExecution(any(),anyLong(),anyString(),anyString(),anyString());
assertThat(commands).isEmpty();
}
/** 原单所属域锁定来源后,车队仅以该来源核对真实送仓事实。 */
@Test void pickupMustArriveBeforePda() {
assertThatThrownBy(()->service.requireArrived(1L,"WAREHOUSE_PICKUP","WT-1","WT-1")).hasMessageContaining("先完成车辆取货送仓");
@@ -79,10 +106,23 @@ class FleetPickupServiceTest {
}
@Test void arrivalOpensPdaAndCompletionKeepsWarehouseOrderInMeasurement() {
task.setStatus("PICKED");task.setActualPackages(5);task.setPickedAt(LocalDateTime.now(ZoneOffset.UTC));
service.arrive("TR-1","C1","WT-1","OP",new FleetPickupModels.Command(2L,"arrive-key-1","已送海外仓月台"));
when(repository.ownsUpload(1L,"TR-1",89L)).thenReturn(true);
when(files.metadata(9L,89L)).thenReturn(new TripOperationsRecordModels.EvidenceFile("89","到仓照片.jpg","image/jpeg",500));
var request=new FleetPickupModels.Command(2L,"arrive-key-1","已送海外仓月台",List.of(89L));
service.arrive("TR-1","C1","WT-1","OP",request);
assertThat(task.getStatus()).isEqualTo("ARRIVED");assertThat(source.getStatus()).isEqualTo("WAIT_PDA_RECEIVING");
service.complete("TR-1","OP",new FleetPickupModels.Command(3L,"complete-key-1","全部送仓"));
assertThat(trip.getStatus()).isEqualTo("COMPLETED");assertThat(trip.getCompletionJson()).contains("complete-key-1");assertThat(source.getStatus()).isEqualTo("WAIT_PDA_RECEIVING");
assertThat(trip.getStatus()).isEqualTo("COMPLETED");assertThat(trip.getCompletionJson()).contains("arrive-key-1");assertThat(source.getStatus()).isEqualTo("WAIT_PDA_RECEIVING");
assertThat(task.getEvidenceJson()).contains("ARRIVAL","到仓照片");
service.arrive("TR-1","C1","WT-1","OP",request);verify(repository,times(1)).update(task);
}
/** 到仓必须上传独立的新照片,不能用取货照片冒充或提前开放清点。 */
@Test void arrivalRequiresFreshPhotoEvidence() {
task.setStatus("PICKED");
task.setEvidenceJson("[{\"fileId\":\"88\",\"fileName\":\"取货.jpg\",\"contentType\":\"image/jpeg\",\"sizeBytes\":1,\"phase\":\"PICKUP\"}]");
assertThatThrownBy(()->service.arrive("TR-1","C1","WT-1","OP",new FleetPickupModels.Command(2L,"no-photo","送仓"))).hasMessageContaining("上传到仓照片");
when(repository.ownsUpload(1L,"TR-1",88L)).thenReturn(true);
assertThatThrownBy(()->service.arrive("TR-1","C1","WT-1","OP",new FleetPickupModels.Command(2L,"same-photo","送仓",List.of(88L)))).hasMessageContaining("不能复用取货照片");
assertThat(task.getStatus()).isEqualTo("PICKED");verify(repository,never()).update(any());
}
@Test void staleVersionAndWrongTripPurposeAreRejected() {
assertThatThrownBy(()->service.arrive("TR-1","C1","WT-1","OP",new FleetPickupModels.Command(1L,"arrive-key-1","送仓"))).hasMessageContaining("车次已变化");
@@ -122,6 +122,8 @@ class FleetTripAuthorizationTest {
@Bean com.baiyue.lastleg.fleet.business.FleetLooseboardDemandRepository looseboardDemandRepository() { return mock(com.baiyue.lastleg.fleet.business.FleetLooseboardDemandRepository.class); }
/** 既有取货能力依赖原仓储引用;权限测试使用只读替身,不连接数据库。 */
@Bean com.baiyue.lastleg.fleet.pickup.FleetPickupRepository pickups() { return mock(com.baiyue.lastleg.fleet.pickup.FleetPickupRepository.class); }
/** 取消原单使用所属域替身,权限测试不写业务数据。 */
@Bean com.baiyue.lastleg.fleet.pickup.PickupOrderCancellation pickupCancellation() { return mock(com.baiyue.lastleg.fleet.pickup.PickupOrderCancellation.class); }
@Bean com.baiyue.lastleg.fleet.trip.work.AppointmentWorkTripChanges workTripChanges() { return mock(com.baiyue.lastleg.fleet.trip.work.AppointmentWorkTripChanges.class); }
@Bean com.baiyue.lastleg.appointmentwork.AppointmentWorkDispatchPort workDispatches() { return mock(com.baiyue.lastleg.appointmentwork.AppointmentWorkDispatchPort.class); }
@Bean FleetTripCalendarService calendar() { return mock(FleetTripCalendarService.class); }
@@ -75,7 +75,6 @@ class PdaOperationsOpenApiContractTest {
"/api/pda/v1/last-leg/dispatches/{dispatchNo}/loading:",
"/api/pda/v1/last-leg/dispatches/{dispatchNo}/loading-complete:",
"/api/pda/v1/last-leg/dispatches/{dispatchNo}/commands/{idempotencyKey}:",
"/api/pda/v1/last-leg/dispatches/{dispatchNo}/depart:",
"/api/pda/v1/last-leg/outbound-tasks:",
"/api/pda/v1/last-leg/outbound-tasks/resolve/{businessNo}:",
"/api/pda/v1/last-leg/outbound-tasks/{taskNo}:",
@@ -83,7 +82,8 @@ class PdaOperationsOpenApiContractTest {
"/api/pda/v1/last-leg/outbound-tasks/{taskNo}/pallets/{palletNo}/pick:",
"/api/pda/v1/last-leg/outbound-tasks/{taskNo}/complete:");
for (String path : paths) assertTrue(yaml.contains(path), path);
assertEquals(36, paths.size());
assertEquals(35, paths.size());
assertFalse(yaml.contains("/api/pda/v1/last-leg/dispatches/{dispatchNo}/depart:"));
assertFalse(yaml.contains("/api/pda/v1/last-leg/dispatches/{dispatchNo}/complete:"));
assertTrue(yaml.contains("enum: [LOCATION, PALLET]"));
assertTrue(yaml.contains("enum: [APPROVE, REWORK]"));
@@ -0,0 +1,62 @@
package com.baiyue.lastleg.transfer.service;
import com.baomidou.mybatisplus.core.MybatisConfiguration;
import com.baomidou.mybatisplus.extension.spring.MybatisSqlSessionFactoryBean;
import com.baiyue.lastleg.fleet.pickup.FleetPickupModels;
import com.baiyue.lastleg.transfer.mapper.TransferPickupCancellationMapper;
import com.baiyue.lastleg.transfer.repository.TransferPickupCancellationRepository;
import org.h2.jdbcx.JdbcDataSource;
import org.mybatis.spring.SqlSessionTemplate;
import org.junit.jupiter.api.*;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.jdbc.datasource.DataSourceTransactionManager;
import org.springframework.transaction.support.TransactionTemplate;
import java.util.List;
import static org.assertj.core.api.Assertions.*;
/** 通过真实SQL验证联动取消、客户隔离以及多票失败回滚。 */
class TransferPickupCancellationPersistenceTest {
JdbcTemplate db;TransactionTemplate tx;TransferPickupCancellationService service;
/** 每例建立隔离库,保留取消所需真实字段和状态约束。 */
@BeforeEach void setup() throws Exception {
var ds=new JdbcDataSource();ds.setURL("jdbc:h2:mem:pickup_cancel_"+System.nanoTime()+";MODE=MySQL;DB_CLOSE_DELAY=-1");db=new JdbcTemplate(ds);tx=new TransactionTemplate(new DataSourceTransactionManager(ds));
db.execute("CREATE TABLE ll_warehouse_transfer_order(id BIGINT PRIMARY KEY,customer_id VARCHAR(64),transfer_no VARCHAR(64),inbound_code VARCHAR(64),lastleg_order_id BIGINT,inbound_order_id BIGINT,delivery_method VARCHAR(40),status VARCHAR(32),updated_at TIMESTAMP)");
db.execute("CREATE TABLE ll_order(id BIGINT PRIMARY KEY,customer_id VARCHAR(64),current_status VARCHAR(40),sub_order_status VARCHAR(255),version INT,updated_at TIMESTAMP)");
db.execute("CREATE TABLE ll_inbound_order(id BIGINT PRIMARY KEY,customer_id VARCHAR(64),status VARCHAR(40),updated_at TIMESTAMP)");
db.execute("CREATE TABLE ll_inbound_plan(id BIGINT PRIMARY KEY,customer_id VARCHAR(64),source_order_id BIGINT,creation_mode VARCHAR(40),status VARCHAR(40),version INT,updated_at TIMESTAMP)");
db.execute("CREATE TABLE ll_loading_order_status_history(customer_id VARCHAR(64),order_id BIGINT,from_status VARCHAR(40),to_status VARCHAR(40),event_id VARCHAR(64),remark VARCHAR(1000),created_at TIMESTAMP)");
db.execute("CREATE TABLE ll_warehouse_transfer_receipt(customer_id VARCHAR(64),transfer_order_id BIGINT)");
db.execute("CREATE TABLE ll_warehouse_transfer_carton(customer_id VARCHAR(64),transfer_order_id BIGINT,counted_at TIMESTAMP)");
db.execute("CREATE TABLE ll_warehouse_transfer_pallet(customer_id VARCHAR(64),transfer_order_id BIGINT)");
for(int i=1;i<=2;i++) {
db.update("INSERT INTO ll_warehouse_transfer_order VALUES(?,? ,? ,? ,?,?,'WAREHOUSE_PICKUP','WAIT_PDA_RECEIVING',CURRENT_TIMESTAMP)",i,"C"+i,"WT"+i,"WT"+i,i,i);
db.update("INSERT INTO ll_order VALUES(?,?,'INBOUND_MEASURING','WAREHOUSE_TRANSFER:APPROVED',0,CURRENT_TIMESTAMP)",i,"C"+i);
db.update("INSERT INTO ll_inbound_order VALUES(?,?,'PENDING_INBOUND',CURRENT_TIMESTAMP)",i,"C"+i);
db.update("INSERT INTO ll_inbound_plan VALUES(?, ?,?,'WAREHOUSE_TRANSFER','PDA_PENDING',0,CURRENT_TIMESTAMP)",i,"C"+i,i);
}
var config=new MybatisConfiguration();config.addMapper(TransferPickupCancellationMapper.class);
var factory=new MybatisSqlSessionFactoryBean();factory.setDataSource(ds);factory.setConfiguration(config);
service=new TransferPickupCancellationService(new TransferPickupCancellationRepository(new SqlSessionTemplate(factory.getObject()).getMapper(TransferPickupCancellationMapper.class)));
}
/** 释放本例数据库资源。 */
@AfterEach void close(){db.execute("SHUTDOWN");}
/** 全车跨客户成员分别取消自己的原单与计划,不影响其他客户。 */
@Test void cancelsOnlyTheLinkedBusinessAndKeepsAudit() {
tx.executeWithoutResult(s->service.cancel(List.of(new FleetPickupModels.Source("C1","WT1")),"TR1","OP","客户取消"));
for(String table:List.of("ll_warehouse_transfer_order","ll_inbound_order","ll_inbound_plan")) {
assertThat(db.queryForObject("SELECT status FROM "+table+" WHERE id=1",String.class)).isEqualTo("CANCELLED");
assertThat(db.queryForObject("SELECT status FROM "+table+" WHERE id=2",String.class)).isNotEqualTo("CANCELLED");
}
assertThat(db.queryForObject("SELECT current_status FROM ll_order WHERE id=1",String.class)).isEqualTo("CANCELLED");
assertThat(db.queryForObject("SELECT version FROM ll_order WHERE id=1",Integer.class)).isEqualTo(1);
assertThat(db.queryForObject("SELECT remark FROM ll_loading_order_status_history",String.class)).contains("OP","客户取消");
}
/** 后一票已经清点时,前一票取消及历史写入全部回滚。 */
@Test void oneStartedMemberRollsBackAllOtherCancellations() {
db.update("INSERT INTO ll_warehouse_transfer_carton VALUES('C2',2,CURRENT_TIMESTAMP)");
assertThatThrownBy(()->tx.executeWithoutResult(s->service.cancel(List.of(new FleetPickupModels.Source("C1","WT1"),new FleetPickupModels.Source("C2","WT2")),"TR1","OP","取消"))).hasMessageContaining("不能取消");
assertThat(db.queryForObject("SELECT COUNT(*) FROM ll_warehouse_transfer_order WHERE status='CANCELLED'",Integer.class)).isZero();
assertThat(db.queryForObject("SELECT COUNT(*) FROM ll_loading_order_status_history",Integer.class)).isZero();
assertThatThrownBy(()->tx.executeWithoutResult(s->service.cancel(List.of(new FleetPickupModels.Source("C2","WT1")),"TR1","OP","取消"))).hasMessageContaining("不能取消");
}
}
@@ -27,7 +27,7 @@ class WarehouseTransferCartonCountTest {
service = spy(new WarehouseTransferService());
ReflectionTestUtils.setField(service, "transfers", orders);
var pickupRepository = org.mockito.Mockito.mock(com.baiyue.lastleg.fleet.pickup.FleetPickupRepository.class);
ReflectionTestUtils.setField(service, "pickupService", new com.baiyue.lastleg.fleet.pickup.FleetPickupService(pickupRepository,null,null,null,null,null,new ObjectMapper()));
ReflectionTestUtils.setField(service, "pickupService", new com.baiyue.lastleg.fleet.pickup.FleetPickupService(pickupRepository,null,null,null,null,null,new ObjectMapper(),mock(com.baiyue.lastleg.fleet.pickup.PickupDepartureReview.class)));
ReflectionTestUtils.setField(service, "transferCartons", cartons);
ReflectionTestUtils.setField(service, "transferItems", items);
ReflectionTestUtils.setField(service, "transferPallets", palletMapper);
@@ -202,6 +202,7 @@ class WarehouseTransferServiceTest {
@Mock com.baiyue.lastleg.relabel.service.RelabelApplicationService relabelService;
private WarehouseTransferService service;
@Mock WarehouseTransferReceiptCoordinator receiptCoordinator;
@Mock com.baiyue.lastleg.dispatch.transport.TransportTaskService transportTasks;
@Mock com.baiyue.lastleg.fleet.customer.FleetCustomerDirectory customerDirectory;
private com.baiyue.lastleg.service.LastlegDocumentNumberGenerator compactNumbers;
@@ -209,6 +210,7 @@ class WarehouseTransferServiceTest {
void setUp() {
compactNumbers = org.mockito.Mockito.mock(com.baiyue.lastleg.service.LastlegDocumentNumberGenerator.class);
service = new WarehouseTransferService();
ReflectionTestUtils.setField(service, "transportTaskService", transportTasks);
var measurementRepository = new com.baiyue.lastleg.transfer.repository.TransferMeasurementRepository();
ReflectionTestUtils.setField(measurementRepository, "cartons", transferCartons);
ReflectionTestUtils.setField(service, "transferMeasurementRepository", measurementRepository);
@@ -233,7 +235,7 @@ class WarehouseTransferServiceTest {
});
ReflectionTestUtils.setField(service, "transfers", transfers);
var pickupRepository = org.mockito.Mockito.mock(com.baiyue.lastleg.fleet.pickup.FleetPickupRepository.class);
ReflectionTestUtils.setField(service, "pickupService", new com.baiyue.lastleg.fleet.pickup.FleetPickupService(pickupRepository,null,null,null,null,null,new ObjectMapper()));
ReflectionTestUtils.setField(service, "pickupService", new com.baiyue.lastleg.fleet.pickup.FleetPickupService(pickupRepository,null,null,null,null,null,new ObjectMapper(),mock(com.baiyue.lastleg.fleet.pickup.PickupDepartureReview.class)));
ReflectionTestUtils.setField(service, "transferItems", transferItems);
ReflectionTestUtils.setField(service, "transferCartons", transferCartons);
ReflectionTestUtils.setField(service, "sourceOrders", sourceOrders);
@@ -724,6 +726,7 @@ class WarehouseTransferServiceTest {
assertEquals(5, line.getValue().getRequiredPackageCount());
assertEquals("PENDING_INBOUND", inbound.getStatus());
assertEquals(WarehouseTransferService.WAIT_PDA_RECEIVING, transfer.getStatus());
verify(transportTasks).ensureApprovedPickup("TENANT-A", "WTR-1", "USER-1");
}
@Test