diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentwork/AppointmentWorkDispatchPort.java b/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentwork/AppointmentWorkDispatchPort.java index 6469b9ab6..9a5ff8842 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentwork/AppointmentWorkDispatchPort.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentwork/AppointmentWorkDispatchPort.java @@ -33,7 +33,7 @@ public interface AppointmentWorkDispatchPort { String departureBlockReason(String dispatchNo); record ReceivingWindow(String batchKey,String batchNo,String customerId,String destinationKey,String confirmationNo, java.time.Instant startAt,java.time.Instant endAt,String agreementKey,int version) { } - /** 预排不受限;提交审核、审核通过和开始拣货须有真实收货确认及可执行时间。 */ + /** FBA选入前须已有正式时间确认;提交审核、审核通过和开始拣货继续复核运输安排。 */ String reviewBlockReason(String dispatchNo,java.time.Instant plannedDepartureAt,java.time.Instant estimatedArrivalAt); List reviewEvidence(String dispatchNo); void removeItem(long dispatchId,long itemId); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/work/AppointmentDispatchAvailability.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/work/AppointmentDispatchAvailability.java index 820e36542..c5fc8090e 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/work/AppointmentDispatchAvailability.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/work/AppointmentDispatchAvailability.java @@ -25,16 +25,25 @@ public class AppointmentDispatchAvailability { && a.members().stream().anyMatch(m->snapshot.batchKey().equals(m.batchKey()) && customer.equals(m.customerId()))).toList(); if(agreements.size()!=1)return 0; var agreement=agreements.get(0);var details=agreement.details(); - if(details==null || !"SUCCESS".equals(details.appointmentStatus()) || details.appointmentAt()==null || !validTime(details.appointmentAt(),agreement.timezone(),details.startOffset()))return 0; - if(details.appointmentEndAt()!=null && (!validTime(details.appointmentEndAt(),agreement.timezone(),details.endOffset()) || details.appointmentEndAt().isBefore(details.appointmentAt())))return 0; + if(details==null || !"SUCCESS".equals(details.appointmentStatus()) || details.appointmentAt()==null)return 0; + var start=confirmedInstant(details.appointmentAt(),agreement.timezone(),details.startOffset()); + if(start==null)return 0; + if(details.appointmentEndAt()!=null) { + var end=confirmedInstant(details.appointmentEndAt(),agreement.timezone(),details.endOffset()); + if(end==null || end.isBefore(start))return 0; + } var members=agreement.members().stream().filter(m->snapshot.batchKey().equals(m.batchKey()) && customer.equals(m.customerId())).toList(); if(members.size()!=1)return 0; var sources=members.get(0).sources().stream().filter(s->source.equals(s.sourceKey()) && destination.equals(s.destinationKey())).toList(); return sources.size()==1?Math.max(0,sources.get(0).packageCount()):0; } /** 夏令时歧义须有明确偏移;不能把当地时间按服务器时区猜测。 */ - private boolean validTime(LocalDateTime at,String timezone,String offset) { - try {var offsets=ZoneId.of(timezone).getRules().getValidOffsets(at);return offset==null?offsets.size()==1:offsets.contains(ZoneOffset.of(offset));} - catch(DateTimeException|NullPointerException error){return false;} + private Instant confirmedInstant(LocalDateTime at,String timezone,String offset) { + try { + var offsets=ZoneId.of(timezone).getRules().getValidOffsets(at); + if(offsets.isEmpty() || offset==null && offsets.size()!=1)return null; + var selected=offset==null?offsets.get(0):ZoneOffset.of(offset); + return offsets.contains(selected)?at.toInstant(selected):null; + } catch(DateTimeException|NullPointerException error){return null;} } } diff --git a/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml b/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml index 996c5c0b3..52df15bb7 100644 --- a/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml +++ b/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml @@ -5974,7 +5974,7 @@ components: required: - idempotencyKey - warehouseId - description: 同一起运仓,允许空单或多客户、多目的仓。客户和来源归属由服务端解析,临时资源不生成假档案。 + description: 同一起运仓,允许空单或多客户、多目的仓。FBA货物须先取得覆盖本客户、来源和箱数的正式预约时间才能选入,不能等提交审核才检查。客户和来源归属由服务端解析,临时资源不生成假档案。 DispatchAddCargo: type: object x-java-record: DispatchCommands.AddCargo diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/port/DispatchPublicJsonContract.java b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/port/DispatchPublicJsonContract.java index af9f501d9..017833f86 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/port/DispatchPublicJsonContract.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/port/DispatchPublicJsonContract.java @@ -33,7 +33,7 @@ public final class DispatchPublicJsonContract { DispatchCommands.Depart.class, ArrangeDelivery.class, DispatchPlanningModels.CreateContainer.class, DispatchPlanningModels.DraftSelection.class, DispatchPlanningModels.DraftSnapshot.class, DispatchPlanningModels.DraftRelease.class, - DispatchPlanningModels.ConvertDraft.class,DispatchWorkModels.RouteCommand.class, + DispatchPlanningModels.ConvertDraft.class,DispatchWorkModels.RouteCommand.class,DispatchWorkModels.AppointmentAllocationCommand.class, BatchAllocation.class,BatchReference.class }) { rejectsMissing(requests, request, "{}"); @@ -77,7 +77,7 @@ public final class DispatchPublicJsonContract { codec.decode("{\"action\":\"PICK\",\"recordVersion\":0,\"result\":{\"dispatchNo\":\"DP-CONTRACT\"}}"); throw new AssertionError("损坏或错误动作结果不得恢复为成功"); } catch (IllegalStateException expected) { require(expected.getCause() != null, "保留损坏原因"); } - System.out.println("DispatchPublicJsonContract: PASS (16 requests and 2 batch records missing/null/negative/zero; vehicle appointment removed; FBA batch references retained; all action JSON restart/replay)"); + System.out.println("DispatchPublicJsonContract: PASS (17 requests and 2 batch records missing/null/negative/zero; vehicle appointment removed; FBA batch references retained; all action JSON restart/replay)"); } /** 与正式Long按字符串输出约定共同验证账本恢复。 */ diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchAppointmentLinkPersistenceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchAppointmentLinkPersistenceTest.java new file mode 100644 index 000000000..e7199572b --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchAppointmentLinkPersistenceTest.java @@ -0,0 +1,82 @@ +package com.baiyue.lastleg.dispatch.service; + +import com.baiyue.lastleg.appointmentwork.AppointmentWorkDispatchPort; +import com.baiyue.lastleg.dispatch.port.*; +import org.junit.jupiter.api.*; +import org.springframework.security.authentication.TestingAuthenticationToken; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.test.util.ReflectionTestUtils; +import java.util.*; +import static org.assertj.core.api.Assertions.*; +import static org.mockito.Mockito.*; + +/** 原车关联命令使用真实主单、版本CAS、历史与命令账本事务。 */ +class DispatchAppointmentLinkPersistenceTest { + /** 每项测试分别授权列表与维护,避免缺权限造成伪业务拦截。 */ + @BeforeEach void authorize() {SecurityContextHolder.getContext().setAuthentication(new TestingAuthenticationToken("operator-test","","platform:overseas-warehouse:dispatch:list","platform:overseas-warehouse:dispatch:edit"));} + /** 不把测试身份传给后续其他用例。 */ + @AfterEach void clear() {SecurityContextHolder.clearContext();} + + /** 补分配保留原车、同车其他客户与来源占用,作废旧审批且支持原键恢复。 */ + @Test void linksExistingCargoAndInvalidatesReviewWithoutRecreatingTruckOrClaims() throws Exception { + var s=new Scenario();s.f.db.update("UPDATE ll_dispatch_order SET review_state='DISPATCH_PENDING',review_basis_hash='old-basis'"); + long claims=s.count("ll_dispatch_pallet_lock"),history=s.count("ll_dispatch_history"); + var cargo=s.f.db.queryForList("SELECT * FROM ll_dispatch_item ORDER BY id"); + var saved=s.f.tx.execute(t->s.f.service.saveAppointmentAllocations(s.f.actor,s.no,s.request(s.version,"link-original"))); + assertThat(saved.dispatchNo()).isEqualTo(s.no);assertThat(saved.recordVersion()).isEqualTo(s.version+1); + assertThat(s.f.repository.find(s.no).getReviewState()).isEqualTo("DRAFT");assertThat(s.f.repository.find(s.no).getReviewBasisHash()).isNull(); + assertThat(s.f.db.queryForList("SELECT * FROM ll_dispatch_item ORDER BY id")).isEqualTo(cargo); + assertThat(s.count("ll_dispatch_pallet_lock")).isEqualTo(claims);assertThat(s.count("ll_dispatch_order")).isOne();assertThat(s.count("ll_dispatch_history")).isEqualTo(history+1); + verify(s.provider).bindItems(eq(s.root),eq(List.of(new AppointmentWorkDispatchPort.PhysicalItem(s.item,"101",1L,"RECEIVED",10))),eq(List.of(new AppointmentWorkDispatchPort.Allocation(1L,"FBA_APPOINTMENT:9",0L,"101","ORDER:9001","FBA:ACY2",10)))); + assertThat(s.f.tx.execute(t->s.f.service.saveAppointmentAllocations(s.f.actor,s.no,s.request(s.version,"link-original"))).replayed()).isTrue(); + verify(s.provider,times(1)).bindItems(anyLong(),anyList(),anyList());assertThat(s.count("ll_dispatch_history")).isEqualTo(history+1); + assertThatThrownBy(()->s.f.tx.execute(t->s.f.service.saveAppointmentAllocations(s.f.actor,s.no,s.request(s.version,"stale-new-key")))).hasMessageContaining("版本"); + } + /** 真实扫码优先于头部零计数,外车明细与只读权限都不能进入写入。 */ + @Test void rejectsActualPicksForeignItemsAndReadOnlyActorBeforeProviderMutation() throws Exception { + var s=new Scenario(); + assertThatThrownBy(()->s.f.tx.execute(t->s.f.service.saveAppointmentAllocations(s.f.actor,s.no,new DispatchWorkModels.AppointmentAllocationCommand(s.version,"foreign",List.of(s.item+999),s.allocations())))).hasMessageContaining("本车"); + s.f.picked(1,"101",501,1); + assertThatThrownBy(()->s.f.tx.execute(t->s.f.service.saveAppointmentAllocations(s.f.actor,s.no,s.request(s.version,"picked")))).hasMessageContaining("实际扫码"); + SecurityContextHolder.getContext().setAuthentication(new TestingAuthenticationToken("operator-test","","platform:overseas-warehouse:dispatch:list")); + assertThatThrownBy(()->s.f.tx.execute(t->s.f.service.saveAppointmentAllocations(s.f.actor,s.no,s.request(s.version,"read-only")))).hasMessageContaining("权限"); + verifyNoInteractions(s.provider);assertThat(s.f.repository.find(s.no).getRecordVersion()).isEqualTo(s.version); + } + /** 原批次拒绝时,主单版本、审核和账本都保留修改前事实。 */ + @Test void providerFailureRollsBackVersionReviewAndCommandLedger() throws Exception { + var s=new Scenario();var before=s.f.json.writeValueAsString(s.f.repository.find(s.no));long history=s.count("ll_dispatch_history"),commands=s.count("ll_dispatch_command"); + doThrow(new IllegalStateException("预约版本已变化")).when(s.provider).bindItems(anyLong(),anyList(),anyList()); + assertThatThrownBy(()->s.f.tx.execute(t->s.f.service.saveAppointmentAllocations(s.f.actor,s.no,s.request(s.version,"reject-batch")))).hasMessageContaining("预约版本"); + assertThat(s.f.json.writeValueAsString(s.f.repository.find(s.no))).isEqualTo(before);assertThat(s.count("ll_dispatch_history")).isEqualTo(history);assertThat(s.count("ll_dispatch_command")).isEqualTo(commands); + } + /** 候选必须属于本车真实实物,原版本零与本车分配对称返回。 */ + @Test void readsOnlyOwnedPhysicalOptionsAndDisablesWriteAfterActualPick() throws Exception { + var s=new Scenario(); + when(s.provider.allocationOptions(eq(s.root),anyList())).thenAnswer(call->{ + List identities=call.getArgument(1);var result=new LinkedHashMap>(); + for(var identity:identities)result.put(identity,List.of(new AppointmentWorkDispatchPort.AllocationSource("ORDER:9001","WC-1","IN-1","FBA:ACY2",10,List.of(),List.of())));return result; + }); + var result=s.f.tx.execute(t->s.f.service.appointmentAllocations(s.f.actor,s.no)); + assertThat(result.dispatchNo()).isEqualTo(s.no);assertThat(result.items()).hasSize(2);assertThat(result.edit().allowed()).isTrue(); + s.f.picked(1,"101",501,1); + assertThat(s.f.tx.execute(t->s.f.service.appointmentAllocations(s.f.actor,s.no)).edit().allowed()).isFalse(); + } + private static class Scenario { + final DispatchAggregatePersistenceTest.Fixture f=new DispatchAggregatePersistenceTest.Fixture(); + final AppointmentWorkDispatchPort provider=mock(AppointmentWorkDispatchPort.class); + final String no;final long root,item,version; + /** 先建已有车和两客户货物,模拟后续新增正式预约,不能靠重建车完成补关联。 */ + Scenario() throws Exception { + var created=f.tx.execute(t->f.service.create(f.actor,f.configured("original-before-appointment",List.of(f.source(1,"101",501,"ACY2",10),f.source(2,"202",502,"ONT8",5))))); + no=created.dispatchNo();version=created.recordVersion();root=f.repository.find(no).getId(); + item=f.db.queryForObject("SELECT id FROM ll_dispatch_item WHERE plan_pallet_id=1",Long.class); + ReflectionTestUtils.setField(f.service,"appointments",new DispatchAppointmentAllocations(provider)); + } + /** 本次只触及第一客户原有托盘,不重发选货请求。 */ + DispatchWorkModels.AppointmentAllocationCommand request(long at,String key) {return new DispatchWorkModels.AppointmentAllocationCommand(at,key,List.of(item),allocations());} + /** 正式预约显式提交当前权威版本零。 */ + List allocations() {return List.of(new DispatchContracts.BatchAllocation(1L,"FBA_APPOINTMENT:9","101","ORDER:9001","FBA:ACY2",10,0L));} + /** 统计真实持久化记录,验证失败和重放均不产生额外副作用。 */ + long count(String table) {return f.db.queryForObject("SELECT COUNT(*) FROM "+table,Long.class);} + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchDraftPersistenceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchDraftPersistenceTest.java index 12f6d1774..69745c6aa 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchDraftPersistenceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchDraftPersistenceTest.java @@ -120,6 +120,22 @@ class DispatchDraftPersistenceTest { return result(no,request.idempotencyKey(),request.cargo().size());}); } + /** 新实例只依赖数据库命令结果,关闭内存对象也能恢复创建及原布局结果。 */ + @Test void unconfirmedFbaCannotBeSelectedIntoDraftOrLeaveClaims() { + var state=empty(1); + when(appointmentProvider.candidateBatches(anyList())).thenReturn(Map.of(new AppointmentWorkDispatchPort.CandidatePhysical("101",1L), + new AppointmentWorkDispatchPort.CandidateBatches(List.of(),List.of("FBA货物须先预约成功并明确送仓时间"),false))); + assertThatThrownBy(()->select(state,0,1)).hasMessageContaining("先预约成功"); + assertThat(db.queryForObject("SELECT COUNT(*) FROM ll_dispatch_pallet_lock",Integer.class)).isZero(); + assertThat(service.drafts(owner).get(0).sources()).isEmpty(); + } + /** FBA来源尚不能关联正式预约时不得借其他来源类型绕过确认门禁。 */ + @Test void fbaWithoutAuthoritativeAppointmentSourceCannotEnterDraft() { + var state=empty(1);var choice=new DispatchCommands.CargoSelection(SourceType.RELABEL,null,null,"RL-1",1001L,"version-1");choices.put(1L,choice); + doAnswer(call->{var resolved=whole(choice);resolved.candidate().destinationType("FBA");return List.of(resolved);}).when(sources).lockDraftSources(anyString(),anyList()); + assertThatThrownBy(()->select(state,0,1)).hasMessageContaining("缺少可核对的正式预约来源"); + assertThat(db.queryForObject("SELECT COUNT(*) FROM ll_dispatch_pallet_lock",Integer.class)).isZero(); + } /** 新实例只依赖数据库命令结果,关闭内存对象也能恢复创建及原布局结果。 */ @Test void restartRecoversOriginalResultsAndRejectsChangedActorVersionOrPayload(){ var request=new CreateDraft("create","92");var draft=service.createDraft(owner,request); @@ -524,8 +540,8 @@ class DispatchDraftPersistenceTest { .returnReceiptId(selection.sourceType()==SourceType.RETURN_RECEIPT?sourceId:null) .customerId(sourceId%2==0?"102":"101").customerName("客户"+sourceId).overseasWarehouseId("92") .overseasWarehouseCode("WH").overseasWarehouseName("海外仓").packageCount(10).palletNo("P-"+pallet) - .sourceStage(selection.sourceType()==SourceType.RELABEL?"RELABEL":"RECEIVED").destinationType("FBA") - .destinationWarehouseCode(destination).fbaWarehouseCode(destination); + .sourceStage(selection.sourceType()==SourceType.RELABEL?"RELABEL":"RECEIVED").destinationType(selection.sourceType()==SourceType.INBOUND_PLAN?"FBA":"PRIVATE") + .destinationWarehouseCode(destination).fbaWarehouseCode(selection.sourceType()==SourceType.INBOUND_PLAN?destination:null); return new DispatchService.WholeSource(row,selection,DispatchService.wholeSourceKey(selection),selection.sourceVersion()); } /** 受控事实按原来源类型及服务器版本产生完整组,不把组装责任交给前端。 */ diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/work/AppointmentDispatchAvailabilityTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/work/AppointmentDispatchAvailabilityTest.java new file mode 100644 index 000000000..175457499 --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/work/AppointmentDispatchAvailabilityTest.java @@ -0,0 +1,50 @@ +package com.baiyue.lastleg.inboundplan.work; + +import com.baiyue.lastleg.inboundplan.dto.FbaAppointmentDetails; +import com.baiyue.lastleg.inboundplan.receiving.FbaReceivingContracts.*; +import com.baiyue.lastleg.inboundplan.receiving.FbaReceivingStore; +import org.junit.jupiter.api.Test; +import java.time.*; +import java.util.*; +import static org.assertj.core.api.Assertions.*; +import static org.mockito.Mockito.*; + +/** 选入排车前的确认资格按真实客户和逐票覆盖核对。 */ +class AppointmentDispatchAvailabilityTest { + private final AppointmentDispatchAvailability policy=new AppointmentDispatchAvailability(mock(FbaReceivingStore.class)); + /** 预约头成功不能替代当前客户的有效确认,取消及无时间均不放行。 */ + @Test void onlyCurrentConfirmedCustomerSourceAndDestinationCanEnterDispatch() { + assertThat(count(snapshot("ACTIVE","SUCCESS","America/New_York",LocalDateTime.of(2026,10,26,10,0),"A"),"A")).isEqualTo(20); + assertThat(count(snapshot("ACTIVE","SUCCESS","America/New_York",LocalDateTime.of(2026,10,26,10,0),"B"),"A")).isZero(); + assertThat(count(snapshot("CANCELLED","SUCCESS","America/New_York",LocalDateTime.of(2026,10,26,10,0),"A"),"A")).isZero(); + assertThat(count(snapshot("ACTIVE","PENDING","America/New_York",LocalDateTime.of(2026,10,26,10,0),"A"),"A")).isZero(); + assertThat(count(snapshot("ACTIVE","SUCCESS","America/New_York",null,"A"),"A")).isZero(); + var confirmed=snapshot("ACTIVE","SUCCESS","America/New_York",LocalDateTime.of(2026,10,26,10,0),"A"); + assertThat(policy.confirmedPackages(confirmed,"A","ORDER:2","FBA:ACY2")).isZero(); + assertThat(policy.confirmedPackages(confirmed,"A","ORDER:1","FBA:ONT8")).isZero(); + } + /** 夏令时歧义及未维护时区不能猜成一个可排时间。 */ + @Test void invalidOrAmbiguousLocalTimeCannotAuthorizeDispatch() { + assertThat(count(snapshot("ACTIVE","SUCCESS",null,LocalDateTime.of(2026,10,26,10,0),"A"),"A")).isZero(); + assertThat(count(snapshot("ACTIVE","SUCCESS","America/New_York",LocalDateTime.of(2026,11,1,1,30),"A"),"A")).isZero(); + var before=snapshot("ACTIVE","SUCCESS","America/New_York",LocalDateTime.of(2026,11,1,1,30),"A");var old=before.agreements().get(0); + var details=new FbaAppointmentDetails(null,null,null,"CONF-1",LocalDateTime.of(2026,11,1,1,30),LocalDateTime.of(2026,11,1,1,15),"SUCCESS",null,"-04:00","-05:00"); + var explicit=new Agreement(old.agreementKey(),old.version(),old.status(),old.destinationKey(),details,old.timezone(),old.members(),old.warehouseEvidence()); + assertThat(count(new BatchReceiving(before.batchKey(),List.of(explicit),List.of(),List.of()),"A")).isEqualTo(20); + } + /** 批读分块不丢客户确认,不逐托重复查询。 */ + @Test void batchSnapshotReadHonorsExistingProviderChunkLimit() { + var store=mock(FbaReceivingStore.class);var reader=new AppointmentDispatchAvailability(store); + when(store.snapshots(anyList())).thenAnswer(call->{List keys=call.getArgument(0);return keys.stream().map(key->new BatchReceiving(key,List.of(),List.of(),List.of())).toList();}); + var keys=java.util.stream.IntStream.rangeClosed(1,401).mapToObj(i->"FBA_APPOINTMENT:"+i).toList(); + assertThat(reader.snapshots(keys)).hasSize(401);verify(store,times(3)).snapshots(anyList()); + } + /** 使用与页面相同的稳定身份检查来源覆盖。 */ + private int count(BatchReceiving snapshot,String customer) {return policy.confirmedPackages(snapshot,customer,"ORDER:1","FBA:ACY2");} + /** 构造独立客户的正式收货确认,避免靠预约头状态模拟成功。 */ + private BatchReceiving snapshot(String status,String result,String timezone,LocalDateTime at,String customer) { + var details=new FbaAppointmentDetails(null,null,null,"CONF-1",at,null,result,null,null,null); + var member=new Member("FBA_APPOINTMENT:1","YY-1",0,customer,List.of(new Source("ORDER:1","FBA:ACY2",20,"v1",1,"IN-1")),"客户甲"); + return new BatchReceiving("FBA_APPOINTMENT:1",List.of(new Agreement("RA-1",0,status,"FBA:ACY2",details,timezone,List.of(member),"确认回复")),List.of(),List.of()); + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/work/AppointmentWorkDispatchPersistenceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/work/AppointmentWorkDispatchPersistenceTest.java index 201137ada..e2efe169a 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/work/AppointmentWorkDispatchPersistenceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/work/AppointmentWorkDispatchPersistenceTest.java @@ -171,4 +171,36 @@ class AppointmentWorkDispatchPersistenceTest { int applied=0;for(var result:results) if(result.get(15,TimeUnit.SECONDS)) applied++;assertEquals(1,applied);assertEquals(80,jdbc.queryForObject("SELECT SUM(package_count) FROM ll_dispatch_appointment_allocation",Integer.class)); } finally {pool.shutdownNow();} } + /** 同一票两托在原车补齐预约分配,数量仍按各托真实来源核验。 */ + @Test void lateConfirmedAppointmentCanBindBothExistingPalletsWithoutReplacingCargo() { + jdbc.update("UPDATE ll_dispatch_item SET package_count=40 WHERE id=11"); + jdbc.update("UPDATE ll_inbound_plan_pallet_allocation SET planned_package_count=40,counted_package_count=40 WHERE pallet_id=111"); + seed(12,"101",1,40,"FBA"); + var original=jdbc.queryForList("SELECT * FROM ll_dispatch_item ORDER BY id"); + tx.executeWithoutResult(s->bind(11,12)); + assertEquals(2,jdbc.queryForObject("SELECT COUNT(*) FROM ll_dispatch_appointment_allocation",Integer.class)); + assertEquals(80,jdbc.queryForObject("SELECT SUM(package_count) FROM ll_dispatch_appointment_allocation",Integer.class)); + assertEquals(original,jdbc.queryForList("SELECT * FROM ll_dispatch_item ORDER BY id")); + } + /** 正式确认未完成时,候选和写入都阻止提前排车,不留下部分分配。 */ + @Test void unconfirmedAppointmentBlocksBothCandidateAndBindingBeforeReview() { + when(receiving.currentAgreement(batch,"101")).thenReturn(null); + when(receiving.snapshots(anyList())).thenReturn(List.of()); + assertFalse(candidates.batchReferences(List.of(new CandidatePhysical("101",111L))).get(new CandidatePhysical("101",111L)).blockReasons().isEmpty()); + assertThrows(ResponseStatusException.class,()->tx.executeWithoutResult(s->bind(11))); + assertEquals(0,jdbc.queryForObject("SELECT COUNT(*) FROM ll_dispatch_appointment_allocation",Integer.class)); + } + /** 查询本车重分配量排除本车原占用,保留其他车占用及真实业务标识。 */ + @Test void ownedAllocationOptionsExcludeOnlyThisDispatchAndKeepOtherCarsQuota() { + jdbc.execute("CREATE TABLE ll_order(id BIGINT PRIMARY KEY,customer_id VARCHAR(40),order_no VARCHAR(40),inbound_code VARCHAR(40))"); + jdbc.update("INSERT INTO ll_order VALUES(1,'101','WC-1','IN-1'),(2,'202','WC-2','IN-2'),(3,'303','WC-3','IN-3')"); + tx.executeWithoutResult(s->bind(11)); + var identity=new CandidatePhysical("101",111L); + var option=binding.allocationOptions(1,List.of(identity)).get(identity).get(0); + assertEquals("WC-1",option.orderNo());assertEquals("IN-1",option.inboundCode());assertEquals(80,option.options().get(0).availablePackages()); + jdbc.update("INSERT INTO ll_dispatch_order VALUES(2,'D-2',0,'ACTIVE')"); + jdbc.update("INSERT INTO ll_dispatch_appointment_allocation VALUES(2,99,999,'FBA_APPOINTMENT:1','101','ORDER:1','FBA:ONT8',5)"); + assertEquals(75,binding.allocationOptions(1,List.of(identity)).get(identity).get(0).options().get(0).availablePackages()); + assertEquals(2,jdbc.queryForObject("SELECT COUNT(*) FROM ll_dispatch_appointment_allocation",Integer.class)); + } }