fix(dispatch): enforce confirmed FBA appointment before scheduling

This commit is contained in:
2026-10-22 14:55:56 +08:00
parent 760bf8daa1
commit fc25507915
8 changed files with 200 additions and 11 deletions
@@ -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<ReceivingWindow> reviewEvidence(String dispatchNo);
void removeItem(long dispatchId,long itemId);
@@ -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;}
}
}
@@ -5974,7 +5974,7 @@ components:
required:
- idempotencyKey
- warehouseId
description: 同一起运仓,允许空单或多客户、多目的仓。客户和来源归属由服务端解析,临时资源不生成假档案。
description: 同一起运仓,允许空单或多客户、多目的仓。FBA货物须先取得覆盖本客户、来源和箱数的正式预约时间才能选入,不能等提交审核才检查。客户和来源归属由服务端解析,临时资源不生成假档案。
DispatchAddCargo:
type: object
x-java-record: DispatchCommands.AddCargo
@@ -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按字符串输出约定共同验证账本恢复。 */
@@ -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<AppointmentWorkDispatchPort.CandidatePhysical> identities=call.getArgument(1);var result=new LinkedHashMap<AppointmentWorkDispatchPort.CandidatePhysical,List<AppointmentWorkDispatchPort.AllocationSource>>();
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<DispatchContracts.BatchAllocation> 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);}
}
}
@@ -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());
}
/** 受控事实按原来源类型及服务器版本产生完整组,不把组装责任交给前端。 */
@@ -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<String> 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());
}
}
@@ -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));
}
}