feat(dispatch): 从当前排车提供内部散板需求池

This commit is contained in:
2026-10-21 18:50:56 +08:00
parent 632d35faaf
commit e2ad7857b4
27 changed files with 302 additions and 24 deletions
@@ -123,7 +123,7 @@ class PlatformDispatchOutboundContractTest {
assertTrue(Arrays.stream(DispatchController.class.getDeclaredMethods())
.noneMatch(method -> Set.of("depart", "cancel").contains(method.getName())));
assertEquals(Set.of("recordVersion", "idempotencyKey", "vehicle", "driver",
"plannedDepartureAt", "estimatedArrivalAt", "remark"),
"plannedDepartureAt", "estimatedArrivalAt", "remark", "publishLooseboardDemand"),
Arrays.stream(DispatchCommands.Update.class.getRecordComponents())
.map(component -> component.getName()).collect(java.util.stream.Collectors.toSet()));
assertEquals(Set.of("recordVersion", "idempotencyKey", "reason"),
@@ -38,7 +38,7 @@ class DispatchSchemaContractTest {
"ll_dispatch_pda_task",com.baiyue.lastleg.dispatch.pda.entity.DispatchPdaTask.class);
for(var entry:tables.entrySet()) {
String body=table(sql,entry.getKey());var columns=new TreeSet<String>();
var matcher=Pattern.compile("(?:^|,)\\s*([a-z][a-z0-9_]*)\\s+(?:BIGINT|INT|CHAR|VARCHAR|DATETIME|TIMESTAMP|JSON|TEXT|LONGTEXT|DECIMAL)\\b",Pattern.MULTILINE).matcher(body);
var matcher=Pattern.compile("(?:^|,)\\s*([a-z][a-z0-9_]*)\\s+(?:BIGINT|TINYINT|INT|CHAR|VARCHAR|DATETIME|TIMESTAMP|JSON|TEXT|LONGTEXT|DECIMAL)\\b",Pattern.MULTILINE).matcher(body);
while(matcher.find())columns.add(matcher.group(1));
var fields=new TreeSet<String>();for(var field:entry.getValue().getDeclaredFields())if(!Modifier.isStatic(field.getModifiers()) && !field.isSynthetic())fields.add(field.getName().replaceAll("([a-z0-9])([A-Z])","$1_$2").toLowerCase(Locale.ROOT));
assertThat(columns).as(entry.getKey()).isEqualTo(fields).doesNotContain("customer_id");
@@ -29,6 +29,7 @@ import static org.mockito.Mockito.when;
class DispatchQueryControllerTest {
@Mock DispatchAggregateService service;
@Mock DispatchBolPrintService printService;
@Mock com.baiyue.lastleg.dispatch.service.DispatchCapacityDemandService capacityDemands;
private DispatchQueryController controller;
@BeforeEach
@@ -36,6 +37,7 @@ class DispatchQueryControllerTest {
controller = new DispatchQueryController();
ReflectionTestUtils.setField(controller, "aggregateService", service);
ReflectionTestUtils.setField(controller, "printService", printService);
ReflectionTestUtils.setField(controller, "capacityDemands", capacityDemands);
}
@Test
@@ -72,6 +74,19 @@ class DispatchQueryControllerTest {
assertThat(print.value()).isEqualTo("hasAuthority('" + OverseasPermissions.DISPATCH_LIST + "')");
}
/** 需求池使用独立列表权限且只允许平台身份,不向客户开放摘要来源。 */
@Test void capacityDemandPoolUsesItsOwnPermissionAndRejectsMerchantIdentity() throws Exception {
var query=new com.baiyue.lastleg.dispatch.port.DispatchCapacityDemands.Query(1,20,null,null);
var permission=DispatchQueryController.class.getMethod("capacityDemands",AuthenticatedUser.class,query.getClass()).getAnnotation(PreAuthorize.class);
assertThat(permission.value()).isEqualTo("hasAnyAuthority('platform:fleet:capacity-offer:list','platform:fleet:capacity-offer:manage')");
when(capacityDemands.page(query)).thenReturn(new PageResult<>(List.of(),1,20,0,0));
assertThat(controller.capacityDemands(platform(),query).data().records()).isEmpty();
var merchant=new AuthenticatedUser(7L,9L,9L,8L,"customer","MERCHANT","WEB",Set.of());
assertThatThrownBy(()->controller.capacityDemands(merchant,query)).isInstanceOfSatisfying(ResponseStatusException.class,error->assertThat(error.getStatusCode().value()).isEqualTo(403));
assertThatThrownBy(()->controller.capacityDemands(null,query)).isInstanceOfSatisfying(ResponseStatusException.class,error->assertThat(error.getStatusCode().value()).isEqualTo(401));
verify(capacityDemands).page(query);
}
private static AuthenticatedUser platform() {
return new AuthenticatedUser(7L, 0L, 0L, 8L, "operator", "PLATFORM", "WEB", Set.of());
}
@@ -39,7 +39,7 @@ public final class DispatchPublicJsonContract {
rejectsMissing(requests, request, "{}");
rejectsMissing(requests, request, "{\"recordVersion\":null}");
rejectsMissing(requests, request, "{\"recordVersion\":-1}");
Object zero = requests.readValue("{\"recordVersion\":0}", request);
Object zero = requests.readValue(request == DispatchCommands.Update.class ? "{\"recordVersion\":0,\"publishLooseboardDemand\":true}" : "{\"recordVersion\":0}", request);
require(Long.valueOf(0).equals(request.getMethod("recordVersion").invoke(zero)), "显式零版本必须合法");
}
rejectsMissing(requests, DispatchCommands.Update.class,
@@ -204,7 +204,7 @@ public class DispatchAggregatePersistenceTest {
/** 空单编辑保留零货量与版本命令,不借编辑隐式制造可装车来源。 */
@Test void emptyShellEditPreservesZeroCargoAndVersionedReplay() throws Exception {
var f=new Fixture();var created=f.tx.execute(t->f.service.create(f.actor,f.create("empty-edit",List.of())));
var request=new DispatchCommands.Update(0L,"empty-edit-original",null,null,null,null,"空单补充备注");
var request=new DispatchCommands.Update(0L,"empty-edit-original",null,null,null,null,"空单补充备注", true);
var saved=f.tx.execute(t->f.service.update(f.actor,created.dispatchNo(),request));
assertThat(saved.stage()).isEqualTo(Stage.BEFORE_LOADING);assertThat(saved.counts().packageCount()).isZero();
assertThat(saved.recordVersion()).isGreaterThan(created.recordVersion());
@@ -244,7 +244,7 @@ public class DispatchAggregatePersistenceTest {
}
@Test void updateSavesCurrentVehiclePlanAndReplayWithoutAnyVehicleAppointmentField() throws Exception {
var f=new Fixture();var input=f.configured("update-without-vehicle-appointment",List.of(f.source(1,"101",501,"ONT8",3)));var created=f.tx.execute(t->f.service.create(f.actor,input));
var arrival=input.plannedDepartureAt().plusSeconds(3600);var request=new DispatchCommands.Update(created.recordVersion(),"update-current-plan",input.vehicle(),input.driver(),input.plannedDepartureAt(),arrival,"调整本趟计划");
var arrival=input.plannedDepartureAt().plusSeconds(3600);var request=new DispatchCommands.Update(created.recordVersion(),"update-current-plan",input.vehicle(),input.driver(),input.plannedDepartureAt(),arrival,"调整本趟计划", true);
var saved=f.tx.execute(t->f.service.update(f.actor,created.dispatchNo(),request));assertThat(saved.recordVersion()).isGreaterThan(created.recordVersion());
var detail=f.tx.execute(t->f.service.detail(f.actor,created.dispatchNo()));assertThat(detail.dispatchInfo().estimatedArrivalAt()).isEqualTo(arrival);assertThat(detail.documents().remark()).isEqualTo("调整本趟计划");
var publicJson=f.json.valueToTree(detail);assertThat(publicJson.path("dispatchInfo").has("vehicleAppointment")).isFalse();assertThat(publicJson.path("dispatchInfo").has("fbaAppointments")).isTrue();
@@ -604,7 +604,7 @@ public class DispatchAggregatePersistenceTest {
}
/** 空计划和资源是合法待完善事实,不补假日期或主档。 */
public DispatchCommands.Create create(String key,List<DispatchCommands.CargoSelection> selections) { return new DispatchCommands.Create(key,"9",null,null,null,null,null,selections,List.of()); }
public DispatchCommands.Create create(String key,List<DispatchCommands.CargoSelection> selections) { return new DispatchCommands.Create(key,"9",null,null,null,null,null,selections,List.of(), true); }
/** 临时资源不创建主档;文件提供者在此夹具隔离,归属HTTP网关另行验证。 */
public DispatchCommands.Create configured(String key,List<DispatchCommands.CargoSelection> sources) {
@@ -613,7 +613,7 @@ public class DispatchAggregatePersistenceTest {
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),List.of())),
departure,null,null,sources,List.of());
departure,null,null,sources,List.of(), true);
}
/** 通过实际两级流程进入装车,测试不直接伪造通过状态。 */
@@ -632,6 +632,6 @@ public class DispatchAggregatePersistenceTest {
}
/** 测试建表按实际持久属性类型生成,正式DDL仍由独立主文档集成。 */
private static String type(Class<?> type) { return type==Long.class||type==long.class?"BIGINT":type==Integer.class||type==int.class?"INT":type==LocalDateTime.class?"TIMESTAMP(3)":type==BigDecimal.class?"DECIMAL(18,6)":"VARCHAR(20000)"; }
private static String type(Class<?> type) { return type==Long.class||type==long.class?"BIGINT":type==Boolean.class||type==boolean.class?"BOOLEAN":type==Integer.class||type==int.class?"INT":type==LocalDateTime.class?"TIMESTAMP(3)":type==BigDecimal.class?"DECIMAL(18,6)":"VARCHAR(20000)"; }
}
}
@@ -40,6 +40,6 @@ class DispatchAppointmentAllocationsTest {
when(provider.candidateBatches(identities)).thenReturn(Map.of());assertThatThrownBy(()->service.candidates(List.of(a,b))).hasMessageContaining("批读范围不完整");
}
private static com.baiyue.lastleg.dispatch.port.DispatchCommands.Create create(String key,List<BatchAllocation> allocations) {
return new com.baiyue.lastleg.dispatch.port.DispatchCommands.Create(key,"9",null,null,null,null,null,List.of(),allocations);
return new com.baiyue.lastleg.dispatch.port.DispatchCommands.Create(key,"9",null,null,null,null,null,List.of(),allocations, true);
}
}
@@ -0,0 +1,78 @@
package com.baiyue.lastleg.dispatch.service;
import com.baiyue.lastleg.dispatch.port.*;
import com.baiyue.lastleg.dispatch.port.DispatchContracts.*;
import org.junit.jupiter.api.Test;
import java.math.BigDecimal;
import java.time.Instant;
import java.util.List;
import static org.assertj.core.api.Assertions.*;
class DispatchCapacityDemandPersistenceTest {
/** 默认发布和取消发布随原排车命令同事务保存,原请求重放不重复修改。 */
@Test void defaultsAndUnpublishesThroughTheExistingVersionedCommand() throws Exception {
var f=new DispatchAggregatePersistenceTest.Fixture();
var request=f.json.readValue("{\"idempotencyKey\":\"pool-default\",\"warehouseId\":\"9\",\"cargo\":[],\"batchAllocations\":[]}",DispatchCommands.Create.class);
var created=f.tx.execute(s->f.service.create(f.actor,request));
var service=new DispatchCapacityDemandService(f.repository,f.json);
assertThat(service.page(query()).records()).extracting(DispatchCapacityDemands.Demand::dispatchNo).containsExactly(created.dispatchNo());
assertThat(f.service.detail(f.actor,created.dispatchNo()).publishLooseboardDemand()).isTrue();
var update=new DispatchCommands.Update(created.recordVersion(),"pool-disable",null,null,null,null,null,false);
var saved=f.tx.execute(s->f.service.update(f.actor,created.dispatchNo(),update));
assertThat(f.service.detail(f.actor,created.dispatchNo()).publishLooseboardDemand()).isFalse();
assertThat(service.page(query()).total()).isZero();
var replay=f.tx.execute(s->f.service.update(f.actor,created.dispatchNo(),update));
assertThat(replay.recordVersion()).isEqualTo(saved.recordVersion());
assertThat(service.page(query()).total()).isZero();
}
/** 余位缺测时保持未知;不能将实托数量当标准托位或使用默认三十托。 */
@Test void unknownCapacityIsVisibleAndNonDeliveryOrDepartedRowsAreExcluded() throws Exception {
var f=new DispatchAggregatePersistenceTest.Fixture();
var service=new DispatchCapacityDemandService(f.repository,f.json);
var created=f.tx.execute(s->f.service.create(f.actor,f.create("pool-unknown",List.of())));
var demand=service.page(query()).records().get(0);
assertThat(demand.capacityKnown()).isFalse();assertThat(demand.remainingPalletSpaces()).isNull();
assertThat(demand.remainingWeightKg()).isNull();assertThat(demand.capacityReason()).isNotBlank();
for(var type:List.of("PICKUP","LOCAL_TRUCK")) {
f.db.update("UPDATE ll_dispatch_order SET task_type=?",type);assertThat(service.page(query()).total()).isZero();
}
f.db.update("UPDATE ll_dispatch_order SET task_type='DELIVERY',stage='DELIVERING'");assertThat(service.page(query()).total()).isZero();
f.db.update("UPDATE ll_dispatch_order SET stage='BEFORE_LOADING',status='CANCELLED'");assertThat(service.page(query()).total()).isZero();
}
/** 真实装载去重后计算三维余位,已知满载在分页前排除。 */
@Test void computesConfirmedCapacityAndRemovesFullLoadsBeforePagination() throws Exception {
var f=new DispatchAggregatePersistenceTest.Fixture();
var service=new DispatchCapacityDemandService(f.repository,f.json);
var created=f.tx.execute(s->f.service.create(f.actor,f.configured("pool-known",List.of())));
var load=new ConfirmedLoad("test-manifest",List.of(new LoadingMeasurement(101L,3,new BigDecimal("400"),new BigDecimal("5"),1)),"operator-test",Instant.now());
f.db.update("UPDATE ll_dispatch_order SET loading_confirmation_json=?,stage='AFTER_LOADING',pallet_count=1",f.json.writeValueAsString(load));
var demand=service.page(query()).records().get(0);
assertThat(demand.capacityKnown()).isTrue();assertThat(demand.remainingPalletSpaces()).isEqualTo(12);
assertThat(demand.remainingWeightKg()).isEqualByComparingTo("9600");assertThat(demand.remainingVolumeCbm()).isEqualByComparingTo("45");
// 同一张实托占三个标准托位,不能按实托一件减一。
assertThat(demand.scheduledPalletCount()).isOne();
var full=new ConfirmedLoad("full",List.of(new LoadingMeasurement(101L,15,new BigDecimal("400"),new BigDecimal("5"),1)),"operator-test",Instant.now());
f.db.update("UPDATE ll_dispatch_order SET loading_confirmation_json=?",f.json.writeValueAsString(full));
assertThat(service.page(query()).total()).isZero();assertThat(service.page(query()).records()).isEmpty();
}
/** 重量或体积缺测不掩盖已明确的托位满载。 */
@Test void knownFullPalletSpacesHideEvenWithUnknownWeight() throws Exception {
var f=new DispatchAggregatePersistenceTest.Fixture();
f.tx.execute(s->f.service.create(f.actor,f.configured("pool-full-slots",List.of())));
var load=new ConfirmedLoad("partial",List.of(new LoadingMeasurement(101L,15,null,null,1)),"operator-test",Instant.now());
f.db.update("UPDATE ll_dispatch_order SET loading_confirmation_json=?",f.json.writeValueAsString(load));
assertThat(new DispatchCapacityDemandService(f.repository,f.json).page(query()).total()).isZero();
}
/** 新建时可以显式取消默认发布,后续不产生额外发布记录。 */
@Test void createCanOptOutAndFiltersUseTheActualWarehouseAndRoute() throws Exception {
var f=new DispatchAggregatePersistenceTest.Fixture();var initial=f.create("pool-opt-out",List.of());
var request=new DispatchCommands.Create(initial.idempotencyKey(),initial.warehouseId(),null,null,null,null,null,List.of(),List.of(),false);
f.tx.execute(s->f.service.create(f.actor,request));
var service=new DispatchCapacityDemandService(f.repository,f.json);assertThat(service.page(query()).total()).isZero();
f.db.update("UPDATE ll_dispatch_order SET publish_looseboard_demand=TRUE,overseas_warehouse_name='测试仓',route='测试仓 → ONT8'");
assertThat(service.page(new DispatchCapacityDemands.Query(1,20,"测试","ONT8")).total()).isOne();
assertThat(service.page(new DispatchCapacityDemands.Query(1,20,"其它仓",null)).total()).isZero();
}
/** 使用与正式列表相同的分页输入。 */
private DispatchCapacityDemands.Query query() { return new DispatchCapacityDemands.Query(1,20,null,null); }
}
@@ -326,7 +326,7 @@ class DispatchDraftPersistenceTest {
@Test void conversionKeepsWholeSharedGroupAndOriginalCommandIdentity(){
relabelPair();var state=select(empty(1),0,1);var full=conversion(state,"convert-whole");var vehicle=full.groups().get(0);var create=vehicle.dispatch();
var partial=new DispatchCommands.Create("partial-child",create.warehouseId(),create.vehicle(),create.driver(),create.plannedDepartureAt(),
create.estimatedArrivalAt(),create.remark(),create.cargo().subList(0,1),create.batchAllocations());
create.estimatedArrivalAt(),create.remark(),create.cargo().subList(0,1),create.batchAllocations(), true);
assertThatThrownBy(()->service.convert(owner,new ConvertDraft(state.recordVersion(),"partial-convert",state.draftToken(),List.of(new DraftVehicle(vehicle.draftContainerId(),partial)))))
.isInstanceOf(BizException.class);
assertThat(db.queryForObject("SELECT COUNT(*) FROM draft_created_dispatch",Integer.class)).isZero();
@@ -368,7 +368,7 @@ class DispatchDraftPersistenceTest {
var state=select(empty(2),0,1);state=select(state,1,2);var original=conversion(state,"convert");
assertThatThrownBy(()->service.convert(owner,new ConvertDraft(original.recordVersion(),"partial",original.draftToken(),original.groups().subList(0,1)))).isInstanceOf(BizException.class);
var first=original.groups().get(0);var r=first.dispatch();
var foreign=new DispatchCommands.Create(r.idempotencyKey(),"93",r.vehicle(),r.driver(),r.plannedDepartureAt(),r.estimatedArrivalAt(),r.remark(),r.cargo(),r.batchAllocations());
var foreign=new DispatchCommands.Create(r.idempotencyKey(),"93",r.vehicle(),r.driver(),r.plannedDepartureAt(),r.estimatedArrivalAt(),r.remark(),r.cargo(),r.batchAllocations(), true);
assertThatThrownBy(()->service.convert(owner,new ConvertDraft(original.recordVersion(),"foreign-wh",original.draftToken(),List.of(new DraftVehicle(first.draftContainerId(),foreign),original.groups().get(1))))).isInstanceOf(BizException.class);
clearInvocations(aggregate);versions.put(1L,"changed");
assertThatThrownBy(()->service.convert(owner,original)).isInstanceOf(IllegalStateException.class);
@@ -554,7 +554,7 @@ class DispatchDraftPersistenceTest {
}
/** 每个非空服务器容器生成一组完整创建请求。 */
private ConvertDraft conversion(Draft draft,String key){var groups=new ArrayList<DraftVehicle>();int n=0;for(var c:draft.containers())if(!c.sourceKeys().isEmpty())groups.add(new DraftVehicle(c.draftContainerId(),new DispatchCommands.Create("vehicle-"+(++n),draft.warehouseId(),null,null,null,null,null,
draft.sources().stream().filter(s->c.sourceKeys().contains(s.sourceKey())).map(Candidate::selection).toList(),List.of())));return new ConvertDraft(draft.recordVersion(),key,draft.draftToken(),groups);}
draft.sources().stream().filter(s->c.sourceKeys().contains(s.sourceKey())).map(Candidate::selection).toList(),List.of(), true)));return new ConvertDraft(draft.recordVersion(),key,draft.draftToken(),groups);}
/** 主单边界返回冻结的真实公开结果形状。 */
private static CommandResult result(String no,String key,int cargo){return new CommandResult(no,Action.CREATE,key,0,Status.ACTIVE,Stage.BEFORE_LOADING,Instant.now(),Effect.APPLIED,new Counts(cargo,cargo,10L*cargo,0,0),new DispatchResults.Saved(no,List.of()),false,false);}
/** 测试独立命令键不依赖执行顺序或系统时间。 */
@@ -397,7 +397,7 @@ class DispatchRepairWorkflowTest {
}
/** 使用当前公开整车创建DTO,不恢复旧客户预排接口。 */
private DispatchCommands.Create create(List<DispatchCommands.CargoSelection> cargo) {
return new DispatchCommands.Create(UUID.randomUUID().toString(),"9",null,null,null,null,null,cargo,List.of());
return new DispatchCommands.Create(UUID.randomUUID().toString(),"9",null,null,null,null,null,cargo,List.of(), true);
}
/** 重放固定原版本及原键,取消旧单不会取得或释放新单占用。 */
private void cancel(String number) {
@@ -83,7 +83,7 @@ class DispatchResourcePhotosPersistenceTest {
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(), "维护照片");
return new DispatchCommands.Update(version, key, input.vehicle(), driver, input.plannedDepartureAt(), input.estimatedArrivalAt(), "维护照片", true);
}
/** 只替代外部附件提供者,资源、命令和事务仍走真实服务。 */
@@ -88,7 +88,7 @@ class DispatchReviewWorkflowPersistenceTest {
long item=f.db.queryForObject("SELECT id FROM ll_dispatch_item WHERE plan_pallet_id=3",Long.class);
var removed=f.tx.execute(s->f.service.removeCargo(f.actor,created.dispatchNo(),item,new DispatchCommands.ReasonCommand(addedAgain.recordVersion(),"remove","调整目的仓")));
var resources=f.configured("updated-resources",List.of());
var updated=f.tx.execute(s->f.service.update(f.actor,created.dispatchNo(),new DispatchCommands.Update(removed.recordVersion(),"update-vehicle",resources.vehicle(),resources.driver(),resources.plannedDepartureAt().plusSeconds(60),null,"客服更正")));
var updated=f.tx.execute(s->f.service.update(f.actor,created.dispatchNo(),new DispatchCommands.Update(removed.recordVersion(),"update-vehicle",resources.vehicle(),resources.driver(),resources.plannedDepartureAt().plusSeconds(60),null,"客服更正", true)));
var detail=f.service.detail(f.actor,created.dispatchNo());
var retained=loading?detail.reviews().loadingReview():detail.reviews().dispatchReview();
assertThat(detail.reviewState()).isEqualTo(ReviewState.DRAFT);
@@ -40,7 +40,7 @@ class OwnedBusinessScopePersistenceTest {
when(reader.detail(any(),anyString())).thenAnswer(call->{
var s=baseline.repository.source(call.getArgument(1));var r=baseline.codec.read(s.getResourcesJson(),DispatchContracts.Resources.class);
return new DispatchViews.Detail(s.getDispatchNo(),"DELIVERY",DispatchTypes.Status.ACTIVE,DispatchTypes.Stage.valueOf(s.getStage()),s.getRecordVersion(),
DispatchTypes.ReviewState.APPROVED,null,null,true,Map.of(),new DispatchViews.VehicleInfo(r.vehicle(),r.driver()),null,null,List.of());
DispatchTypes.ReviewState.APPROVED,null,null,true,Map.of(),new DispatchViews.VehicleInfo(r.vehicle(),r.driver()),null,null,List.of(),true);
});
queries=tx(new OwnedBusinessQueryService(baseline.repository,baseline.projection,baseline.codec,reader));
var commands=tx(new OwnedBusinessCommandService(baseline.repository,baseline.codec));