fix(dispatch): 在审核及拣货前核对预约与运输时间

This commit is contained in:
2026-10-21 19:42:32 +08:00
parent e2ad7857b4
commit 234216b3eb
13 changed files with 216 additions and 15 deletions
@@ -454,8 +454,14 @@ public class DispatchAggregatePersistenceTest {
f.db.update("INSERT INTO ll_dispatch_appointment_allocation(dispatch_id,dispatch_item_id,plan_pallet_id,batch_key,customer_id,source_key,destination_key,package_count) VALUES(?,?,1,'FBA_APPOINTMENT:900','101','ORDER:9001','FBA:ONT8',3)",dispatch,item);
var detail=f.tx.execute(s->f.service.detail(f.actor,original.dispatchNo()));
assertThat(detail.orderDetails()).hasSize(2); assertThat(detail.dispatchInfo().appointmentBatches()).hasSize(1);
assertThat(detail.dispatchInfo().fbaAppointments().get(0).appointmentNo()).isEqualTo("CONFIRM-REAL");
assertThat(detail.dispatchInfo().fbaAppointments().get(0).confirmedAt()).isEqualTo(Instant.parse("2026-10-19T10:00:00Z"));
// 批次头写SUCCESS不代表存在当前有效收货确认,不再把旧头时间作为审批依据。
assertThat(detail.dispatchInfo().fbaAppointments().get(0).status()).isEqualTo("PENDING");
assertThat(detail.dispatchInfo().fbaAppointments().get(0).confirmedAt()).isNull();
var provider=mock(com.baiyue.lastleg.appointmentwork.AppointmentWorkDispatchPort.class);
when(provider.reviewEvidence(original.dispatchNo())).thenReturn(List.of(new com.baiyue.lastleg.appointmentwork.AppointmentWorkDispatchPort.ReceivingWindow("FBA_APPOINTMENT:900","AP-EXISTING","101","FBA:ONT8","CURRENT-CONFIRM",Instant.parse("2026-10-20T17:00:00Z"),null,"AGR-1",1)));
org.springframework.test.util.ReflectionTestUtils.setField(f.service,"appointments",new DispatchAppointmentAllocations(provider));
var current=f.tx.execute(s->f.service.detail(f.actor,original.dispatchNo())).dispatchInfo().fbaAppointments().get(0);
assertThat(current.appointmentNo()).isEqualTo("CURRENT-CONFIRM");assertThat(current.confirmedAt()).isEqualTo(Instant.parse("2026-10-20T17:00:00Z"));
var c=f.source(3,"101",503,"ONT8",2);
f.tx.executeWithoutResult(s->f.service.addCargo(f.actor,original.dispatchNo(),new DispatchCommands.AddCargo(original.recordVersion(),"add-third",List.of(c),List.of())));
var restored=f.tx.execute(s->f.service.create(f.actor,f.create("two-customers",List.of(a,b))));
@@ -19,6 +19,64 @@ import static org.mockito.Mockito.*;
/** 真实排车、车次、命令和来源入口回归;未物化PLAN不得进入仅接受实托的执行锁。 */
class DispatchLoadingEligibilityPersistenceTest {
@org.junit.jupiter.api.BeforeEach void authorize() {
org.springframework.security.core.context.SecurityContextHolder.getContext().setAuthentication(new org.springframework.security.authentication.TestingAuthenticationToken("operator","",
"platform:overseas-warehouse:dispatch:list","platform:overseas-warehouse:dispatch:edit","platform:overseas-warehouse:dispatch:loading-complete",
"platform:fleet:trip:dispatch-review","platform:fleet:trip:loading-review","lastleg:pda:operate","ROLE_OVERSEAS_WAREHOUSE_PDA_OPERATOR"));
}
@org.junit.jupiter.api.AfterEach void clearIdentity(){org.springframework.security.core.context.SecurityContextHolder.clearContext();}
/** 允许预排;客服提交及两级批准均复验,缺预约时仍能驳回客服而不能开始拣货。 */
@org.junit.jupiter.api.Test void preArrangementRemainsAllowedButReviewsCannotApproveUnconfirmedAppointments() throws Exception {
var f=new DispatchAggregatePersistenceTest.Fixture();
var created=f.tx.execute(t->f.service.create(f.actor,f.configured("review-appointment",List.of(f.source(1,"101",501,"ONT8",3)))));
var provider=mock(com.baiyue.lastleg.appointmentwork.AppointmentWorkDispatchPort.class);
ReflectionTestUtils.setField(f.service,"appointments",new DispatchAppointmentAllocations(provider));
when(provider.reviewBlockReason(anyString(),any(),any())).thenReturn("预约收货时间尚未确认");
assertThatThrownBy(()->f.tx.execute(t->f.service.submitReview(f.actor,created.dispatchNo(),new DispatchCommands.SubmitReview(created.recordVersion(),"blocked-submit",null)))).hasMessageContaining("尚未确认");
when(provider.reviewBlockReason(anyString(),any(),any())).thenReturn(null);
var submitted=f.tx.execute(t->f.service.submitReview(f.actor,created.dispatchNo(),new DispatchCommands.SubmitReview(created.recordVersion(),"submit",null)));
when(provider.reviewBlockReason(anyString(),any(),any())).thenReturn("预约收货时间尚未确认");
var gate=f.tx.execute(t->f.service.detail(f.actor,created.dispatchNo())).actions().get(com.baiyue.lastleg.dispatch.port.DispatchTypes.Action.DISPATCH_REVIEW);
assertThat(gate.allowed()).isTrue();assertThat(gate.blockCode()).isEqualTo("APPROVAL_BLOCKED");
assertThatThrownBy(()->f.tx.execute(t->f.service.review(f.actor,created.dispatchNo(),false,new DispatchCommands.ReviewCommand(submitted.recordVersion(),"bad-approve",com.baiyue.lastleg.dispatch.port.DispatchTypes.Decision.APPROVED,null,null)))).hasMessageContaining("尚未确认");
// 预约依据变化后仍可驳回,不让失效审核把客服修正入口锁死。
var changedWindow=new com.baiyue.lastleg.appointmentwork.AppointmentWorkDispatchPort.ReceivingWindow("FBA_APPOINTMENT:1","YY1","101","FBA:ONT8","CONF1",java.time.Instant.parse("2026-12-01T10:00:00Z"),null,"agreement-1",2);
when(provider.reviewEvidence(anyString())).thenReturn(List.of(changedWindow));
var rejected=f.tx.execute(t->f.service.review(f.actor,created.dispatchNo(),false,new DispatchCommands.ReviewCommand(submitted.recordVersion(),"reject",com.baiyue.lastleg.dispatch.port.DispatchTypes.Decision.REJECTED,"请客服核对预约",null)));
when(provider.reviewBlockReason(anyString(),any(),any())).thenReturn(null);
var submittedAgain=f.tx.execute(t->f.service.submitReview(f.actor,created.dispatchNo(),new DispatchCommands.SubmitReview(rejected.recordVersion(),"resubmit",null)));
var reviewed=f.tx.execute(t->f.service.review(f.actor,created.dispatchNo(),false,new DispatchCommands.ReviewCommand(submittedAgain.recordVersion(),"dispatch-pass",com.baiyue.lastleg.dispatch.port.DispatchTypes.Decision.APPROVED,null,null)));
when(provider.reviewBlockReason(anyString(),any(),any())).thenReturn("预约收货时间尚未确认");
assertThatThrownBy(()->f.tx.execute(t->f.service.review(f.actor,created.dispatchNo(),true,new DispatchCommands.ReviewCommand(reviewed.recordVersion(),"bad-loading-approve",com.baiyue.lastleg.dispatch.port.DispatchTypes.Decision.APPROVED,null,null)))).hasMessageContaining("尚未确认");
when(provider.reviewBlockReason(anyString(),any(),any())).thenReturn(null);
f.tx.execute(t->f.service.review(f.actor,created.dispatchNo(),true,new DispatchCommands.ReviewCommand(reviewed.recordVersion(),"loading-pass",com.baiyue.lastleg.dispatch.port.DispatchTypes.Decision.APPROVED,null,null)));
when(provider.reviewEvidence(anyString())).thenReturn(List.of());
assertThatThrownBy(()->f.tx.execute(t->{f.service.requireReviewAppointment(f.repository.find(created.dispatchNo()));return null;})).hasMessageContaining("原审核依据失效");
when(provider.reviewBlockReason(anyString(),any(),any())).thenReturn("预约收货时间尚未确认");
var pda=new DispatchContracts.Actor("picker",ActorMode.PDA,"9");
assertThat(f.tx.execute(t->f.service.detail(pda,created.dispatchNo())).actions().get(com.baiyue.lastleg.dispatch.port.DispatchTypes.Action.PICK).allowed()).isFalse();
assertThatThrownBy(()->f.tx.execute(t->{f.service.requireReviewAppointment(f.repository.find(created.dispatchNo()));return null;})).hasMessageContaining("尚未确认");
}
/** 照片或终端不改变预约门槛;显示与正式提交均拒绝未准备好的预约。 */
@org.junit.jupiter.api.Test void appointmentBlockIsVisibleBeforeLoadingSubmission() throws Exception {
var f=new DispatchAggregatePersistenceTest.Fixture();
var created=f.tx.execute(t->f.service.create(f.actor,f.configured("appointment-gate",List.of(f.source(1,"101",501,"ONT8",3)))));
var approved=f.approve(created);f.picked(1,"101",501,3);
f.db.update("UPDATE ll_dispatch_order SET picked_package_count=3");
var provider=mock(com.baiyue.lastleg.appointmentwork.AppointmentWorkDispatchPort.class);
when(provider.departureBlockReason(created.dispatchNo())).thenReturn("请先约仓成功再排车或发车");
ReflectionTestUtils.setField(f.service,"appointments",new DispatchAppointmentAllocations(provider));
var actor=new DispatchContracts.Actor("picker",ActorMode.PDA,"9");
var preview=f.tx.execute(t->f.service.loading(actor,created.dispatchNo()));
assertThat(preview.pickingReconciled()).isTrue();
assertThat(preview.loadingComplete().allowed()).isFalse();
assertThat(preview.loadingComplete().blockCode()).isEqualTo("APPOINTMENT_NOT_READY");
assertThat(f.tx.execute(t->f.service.detail(actor,created.dispatchNo())).actions().get(com.baiyue.lastleg.dispatch.port.DispatchTypes.Action.LOADING_COMPLETE).allowed()).isFalse();
assertThatThrownBy(()->f.tx.execute(t->f.service.loadingComplete(actor,created.dispatchNo(),new DispatchCommands.LoadingComplete(approved.recordVersion(),"blocked-loading",preview.manifestHash(),null))))
.hasMessageContaining("先约仓成功");
assertThat(f.repository.find(created.dispatchNo()).getStage()).isEqualTo("LOADING");
assertThat(f.repository.find(created.dispatchNo()).getRecordVersion()).isEqualTo(approved.recordVersion());
}
/** 全车在途和部分实托已拣的混合车均应业务拒绝,原单/命令/占用/扫码事实完全不变。 */
@ParameterizedTest
@ValueSource(booleans={false,true})
@@ -31,6 +31,10 @@ class DispatchPdaReadPersistenceTest {
var approved=f.tx.execute(s->f.service.review(f.actor,created.dispatchNo(),false,new DispatchCommands.ReviewCommand(submitted.recordVersion(),"dispatch",Decision.APPROVED,null,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);
var platform=f.tx.execute(s->f.service.detail(f.actor,created.dispatchNo()));
assertThat(task.plannedDepartureAt()).isEqualTo(platform.dispatchInfo().plannedDepartureAt());
assertThat(task.estimatedArrivalAt()).isEqualTo(platform.dispatchInfo().estimatedArrivalAt());
assertThat(task.fbaAppointments()).isEqualTo(platform.dispatchInfo().fbaAppointments());
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,"车辆不符",null)));
@@ -23,6 +23,17 @@ import static org.mockito.Mockito.*;
/** 根版本、幂等、真实客户作业与扫码持久化;库存/归属应用命令在此明确模拟,另由原域直接测试验证。 */
class DispatchPickingPersistenceTest {
/** 即使旧记录已经审核通过,真实扫码仍复验预约;拒绝时不物化、不扣库存。 */
@Test void unconfirmedAppointmentBlocksPickingBeforeInventoryWrites() throws Exception {
var fixture=new Picking(true);fixture.receivedPlans(9,11);
var provider=mock(com.baiyue.lastleg.appointmentwork.AppointmentWorkDispatchPort.class);
when(provider.reviewBlockReason(anyString(),any(),any())).thenReturn("预约收货时间尚未确认");
org.springframework.test.util.ReflectionTestUtils.setField(fixture.f.service,"appointments",new DispatchAppointmentAllocations(provider));
assertThatThrownBy(()->fixture.f.tx.execute(s->fixture.service.pick(fixture.actor,fixture.number,fixture.item,
new DispatchCommands.Pick(fixture.version,"appointment-blocked",11L,9,0,null,null)))).hasMessageContaining("尚未确认");
verify(fixture.inventory,never()).ship(any(),any(),any());
assertThat(fixture.f.db.queryForList("SELECT package_count FROM ll_dispatch_item ORDER BY customer_id",Integer.class)).containsExactly(10,10);
}
/** 数量可自然同步不代表能接管其他车的实际托盘。 */
@Test void receivedPalletClaimedByAnotherDispatchCannotBePicked() throws Exception {
var fixture=new Picking(true);fixture.receivedPlans(9,11);
@@ -58,6 +58,16 @@ class AppointmentWorkDispatchBindingTest {
when(mapper.pickedPhysical(30,"C")).thenReturn(List.of(new AppointmentWorkDispatchMapper.Physical(1L,"C","FBA:ONT8",null,4)));
when(sources.canonicalOrders(anyList())).thenReturn(List.of(new AppointmentCoverageService.SourceRef("C","ORDER:1",null)));
assertDoesNotThrow(()->service.requireDeparture("D-20"));
when(mapper.physical(10,"C")).thenReturn(List.of(new AppointmentWorkDispatchMapper.Physical(1L,"C","FBA:ONT8",4,4)));
var departure=java.time.Instant.parse("2026-10-18T12:00:00Z");
var arrival=java.time.Instant.parse("2026-10-18T17:00:00Z");
clearInvocations(mapper);
assertNull(service.reviewBlockReason("D-20",departure,arrival));
verify(mapper,never()).pickedPhysical(anyLong(),anyString());
assertEquals(arrival,service.reviewEvidence("D-20").get(0).startAt(),"10点洛杉矶预约应按当地时区解释");
assertNotNull(service.reviewBlockReason("D-20",departure,null));
assertNotNull(service.reviewBlockReason("D-20",arrival.plusSeconds(60),arrival.plusSeconds(120)));
assertNotNull(service.reviewBlockReason("D-20",departure,arrival.plusSeconds(60)));
when(item.packageCount()).thenReturn(5);when(mapper.allocated("FBA_APPOINTMENT:1","C","ORDER:1","FBA:ONT8",20)).thenReturn(1L);
assertTrue(service.departureBlockReason("D-20").contains("收货确认未完整覆盖")); // Batch has 5, but the current agreement covers only 4 across every dispatch.
when(item.packageCount()).thenReturn(4);when(mapper.allocated("FBA_APPOINTMENT:1","C","ORDER:1","FBA:ONT8",20)).thenReturn(0L);
@@ -76,6 +86,7 @@ class AppointmentWorkDispatchBindingTest {
assertThrows(org.springframework.web.server.ResponseStatusException.class,()->service.requireDeparture("D-20"));
when(receiving.currentAgreement("FBA_APPOINTMENT:1","C")).thenReturn(null);
assertThrows(org.springframework.web.server.ResponseStatusException.class,()->service.requireDeparture("D-20"));
assertTrue(service.reviewBlockReason("D-20",departure,arrival).contains("尚无有效收货确认"));
verifyNoInteractions(changes);
}
private com.baiyue.lastleg.inboundplan.receiving.FbaReceivingContracts.Agreement agreement(String customer,int packages) {