This commit is contained in:
2026-10-21 14:30:41 +08:00
parent 0af977bdef
commit 40a03978cd
53 changed files with 1004 additions and 1291 deletions
@@ -37,6 +37,7 @@ import static org.mockito.Mockito.*;
/** 真实生产Mapper及事务验证任何客户或主单CAS失败时全车原子回滚。 */
class DispatchDeliveryArrangementPersistenceTest {
private JdbcTemplate db;
private org.springframework.transaction.support.TransactionTemplate tx;
private DispatchDeliveryService service;
private final DispatchExecutionPort execution=mock(DispatchExecutionPort.class);
private final DispatchDeliveryFees fees=mock(DispatchDeliveryFees.class);
@@ -49,6 +50,7 @@ class DispatchDeliveryArrangementPersistenceTest {
@BeforeEach void prepare() throws Exception {
var ds=new DriverManagerDataSource("jdbc:h2:mem:arrangement-"+UUID.randomUUID()+";MODE=MySQL;DB_CLOSE_DELAY=-1","sa","");
db=new JdbcTemplate(ds);
tx=new org.springframework.transaction.support.TransactionTemplate(new DataSourceTransactionManager(ds));
for (var type:List.of(OutboundOrder.class,OutboundPalletItem.class,DeliveryOrder.class,LastlegOrder.class,LastlegOrderStatusHistory.class)) {
String columns=Arrays.stream(type.getDeclaredFields()).filter(f->!Modifier.isStatic(f.getModifiers())).map(f->{
String name=f.getName().replaceAll("([A-Z])","_$1").toLowerCase();
@@ -173,6 +175,32 @@ class DispatchDeliveryArrangementPersistenceTest {
assertEquals(6L,frozen.stream().filter(c->c.attributionId()==4).findFirst().orElseThrow().sourceOrigin().productQuantity());
}
/** 自动装车加入父事务,最终主单失败时全部客户单据与来源绑定均回滚。 */
@Test void automaticPreparationSharesLoadingParentTransactionAndRollback() {
assertThrows(IllegalStateException.class,()->tx.execute(status->{
service.prepareForDeparture(context,actor,"LOADING-FAIL");
throw new IllegalStateException("装车最终保存失败");
}));
for(var table:List.of("ll_outbound_order","ll_outbound_pallet_item","ll_delivery_order","ll_dispatch_delivery_arrangement","ll_loading_order_status_history"))
assertEquals(0,db.queryForObject("SELECT COUNT(*) FROM "+table,Integer.class));
assertEquals(2,db.queryForObject("SELECT COUNT(*) FROM ll_order WHERE outbound_order_no IS NULL AND version=0",Integer.class));
var fact=tx.execute(status->service.prepareForDeparture(context,actor,"LOADING-OK"));
assertEquals(2,fact.result().deliveries().size());
assertEquals(2,db.queryForObject("SELECT COUNT(*) FROM ll_delivery_order WHERE planned_delivery_at IS NULL AND delivery_method IS NULL AND pickup_method IS NULL AND loading_type IS NULL",Integer.class));
assertEquals(2,db.queryForObject("SELECT COUNT(*) FROM ll_outbound_order WHERE fee_coverage_json IS NULL",Integer.class));
clearInvocations(fees);
assertEquals(fact,tx.execute(status->service.prepareForDeparture(context,actor,"LOADING-OK")));
verifyNoInteractions(fees);
}
/** 外接费用发布失败同样回滚自动生成的全部客户单据,不能留下半车。 */
@Test void automaticExternalFreightFailureRollsBackEveryCustomer() {
doThrow(new IllegalStateException("外接费用发布失败")).when(fees).publish(any(),argThat(g->g.customerId().equals("102")),any(),any(),any(),any(),anyString(),anyString());
assertThrows(IllegalStateException.class,()->tx.execute(status->service.prepareForDeparture(context,actor,"LOADING-FAIL")));
assertEquals(0,db.queryForObject("SELECT COUNT(*) FROM ll_outbound_order",Integer.class));
assertEquals(0,db.queryForObject("SELECT COUNT(*) FROM ll_delivery_order",Integer.class));
}
/** 按真实共享记录构造完整客户目的地请求。 */
private ArrangeDelivery request() { return new ArrangeDelivery(3L,"ARRANGE-1","cargo-hash","loading-hash",grouping.groups(context).stream().map(g->
new ArrangeGroup(g.key(),g.cargoHash(),DEPARTED,"TRUCK",null,null,"WAREHOUSE_PICKUP","PALLET",null,null,null,null,null,
@@ -72,6 +72,38 @@ class DispatchDeliveryServiceTest {
verify(fees,times(3)).publish(any(),any(),any(),any(),isNull(),any(),eq("OP-A"),anyString());
}
/** PDA装车自动生成完整客户目的地,不猜派送计划、不要求人工费用或原费覆盖。 */
@Test void loadingAutomaticallyCreatesDocumentsWithoutInventedInputsOrFees() {
var pda=new Actor("PDA-1",ActorMode.PDA,"1001");
var fact=service.prepareForDeparture(context,pda,"LOADING-1");
assertEquals(2,fact.result().outbounds().size());assertEquals(3,fact.result().deliveries().size());
assertEquals(4,fact.result().recordVersion());
assertTrue(deliveries.stream().allMatch(d->d.getPlannedDeliveryAt()==null && d.getDeliveryMethod()==null
&& d.getPickupMethod()==null && d.getLoadingType()==null));
assertTrue(outbounds.stream().allMatch(o->o.getFeeCoverageJson()==null));
assertTrue(calls.indexOf("SOURCE")>calls.lastIndexOf("DELIVERY"));
verify(fees,never()).validate(any(),any(),any(),any());verify(fees,never()).coverage(any(),any(),any(),any());
var inputs=org.mockito.ArgumentCaptor.forClass(ArrangeGroup.class);
verify(fees,times(3)).publish(eq(context),any(),inputs.capture(),any(),isNull(),any(),eq("PDA-1"),anyString());
assertTrue(inputs.getAllValues().stream().allMatch(i->i.fees().isEmpty() && i.feeCoverage().isEmpty()));
clearInvocations(fees,repository);
assertEquals(fact,service.prepareForDeparture(context,pda,"LOADING-1"));
verifyNoInteractions(fees);verify(repository,never()).create(any(OutboundOrder.class));
assertThrows(org.springframework.web.server.ResponseStatusException.class,
()->service.prepareForDeparture(context,pda,"DIFFERENT-KEY"));
}
/** 自动生成仍拒绝越仓与孤立子单,不为异常半车补造第二套事实。 */
@Test void automaticPreparationRejectsWrongWarehouseAndOrphanDocuments() {
assertThrows(org.springframework.web.server.ResponseStatusException.class,
()->service.prepareForDeparture(context,new Actor("PDA-1",ActorMode.PDA,"OTHER"),"LOADING-1"));
verifyNoInteractions(repository,fees);
outbounds.add(new OutboundOrder());
assertThrows(org.springframework.web.server.ResponseStatusException.class,
()->service.prepareForDeparture(context,actor,"LOADING-1"));
verifyNoInteractions(fees);
}
/** 同实托的两申请分别冻结原货源,出库引用逐次归属且真实客户目的地与履约订单不被替换。 */
@Test void relabelOriginsStayFrozenBehindOutboundAttributionReferences() throws Exception {
var first=new SourceOrigin(10001L,201L,301L,401L,701L,"IN-701","CODE-701",null);
@@ -97,8 +97,6 @@ class PlatformDispatchOutboundContractTest {
String.class, DispatchCommands.Update.class);
Method cancel = DispatchAggregateController.class.getMethod("cancel", AuthenticatedUser.class,
String.class, DispatchCommands.ReasonCommand.class);
Method transfer = DeliveryTransferController.class.getMethod("transfer", AuthenticatedUser.class,
String.class, com.baiyue.lastleg.dispatch.port.DispatchDeliveryModels.ArrangeDelivery.class);
Method externalFreight = DeliveryTransferController.class.getMethod("externalFreight", AuthenticatedUser.class, String.class);
assertPermission(externalFreight, OverseasPermissions.DISPATCH_LIST);
Method outboundDetail = DeliveryTransferController.class.getMethod("outboundDetail",
@@ -113,11 +111,12 @@ class PlatformDispatchOutboundContractTest {
assertEquals(Set.of("/{dispatchNo}/cancel"), Set.of(cancelRoute.value()));
assertFalse(cancel.isAnnotationPresent(org.springframework.web.bind.annotation.DeleteMapping.class));
assertTrue(cancel.getParameters()[2].isAnnotationPresent(org.springframework.web.bind.annotation.RequestBody.class));
assertPermission(transfer, OverseasPermissions.DISPATCH_TRANSFER_DELIVERY);
assertTrue(Arrays.stream(DeliveryTransferController.class.getDeclaredMethods())
.noneMatch(method -> Set.of("transfer", "arrangement").contains(method.getName())));
assertPermission(outboundDetail, OverseasPermissions.OUTBOUND_LIST);
assertEquals(2, outboundDetail.getParameterCount());
assertPermission(outboundCancel, OverseasPermissions.OUTBOUND_CANCEL);
for (Method method : Set.of(update, cancel, transfer, externalFreight, outboundDetail, outboundCancel)) {
for (Method method : Set.of(update, cancel, externalFreight, outboundDetail, outboundCancel)) {
assertTrue(Arrays.stream(method.getParameters())
.noneMatch(parameter -> parameter.isAnnotationPresent(RequestParam.class)));
}
@@ -202,16 +201,13 @@ class PlatformDispatchOutboundContractTest {
assertFalse(schema.contains("uk_ll_outbound_no (customer_id,outbound_no)"));
}
/** 当前整车请求按公共版本一次提交全部组,响应保留多客户出库及多目的地派送数组。 */
/** 公开协议撤销人工安排入口,内部完整派送事实仍保留多客户和多目的地数组。 */
@Test
void openApiPublishesOnlyTheFrozenPlatformProtocol() throws Exception {
String yaml = Files.readString(Path.of("src/main/resources/openapi/overseas-business-v1.yaml"),
StandardCharsets.UTF_8);
String dispatchAction = slice(yaml, " /api/platform/v1/last-leg/dispatches/{dispatchNo}:",
" /api/platform/v1/last-leg/dispatches/candidates:");
String transfer = slice(yaml,
" /api/platform/v1/last-leg/dispatches/{dispatchNo}/transfer-delivery:",
" /api/platform/v1/last-leg/outbound-orders:");
String outboundDetail = slice(yaml,
" /api/platform/v1/last-leg/outbound-orders/{outboundNo}:",
" /api/platform/v1/last-leg/outbound-orders/{outboundNo}/cancel:");
@@ -219,7 +215,7 @@ class PlatformDispatchOutboundContractTest {
" /api/platform/v1/last-leg/outbound-orders/{outboundNo}/cancel:",
" /api/platform/v1/last-leg/overseas-procurements:");
for (String action : Set.of(dispatchAction, transfer, outboundDetail, outboundCancel)) {
for (String action : Set.of(dispatchAction, outboundDetail, outboundCancel)) {
assertFalse(action.contains("name: customerId"));
}
assertTrue(outboundDetail.contains("pallets[].palletNo"));
@@ -228,24 +224,13 @@ class PlatformDispatchOutboundContractTest {
assertTrue(yaml.contains("单次 SQL 返回,不逐行补查"));
assertTrue(yaml.contains("platform:overseas-warehouse:dispatch:edit"));
assertTrue(yaml.contains("platform:overseas-warehouse:dispatch:cancel"));
assertTrue(yaml.contains("platform:overseas-warehouse:dispatch:transfer-delivery"));
assertFalse(yaml.contains(" /api/platform/v1/last-leg/dispatches/{dispatchNo}/transfer-delivery:"));
assertFalse(yaml.contains(" /api/platform/v1/last-leg/dispatches/{dispatchNo}/delivery-arrangement:"));
assertTrue(yaml.contains("platform:overseas-warehouse:outbound:list"));
assertTrue(yaml.contains("platform:overseas-warehouse:outbound:create"));
assertTrue(yaml.contains("platform:overseas-warehouse:outbound:cancel"));
java.util.Map<?,?> document = new org.yaml.snakeyaml.Yaml().load(yaml);
java.util.Map<?,?> schemas = (java.util.Map<?,?>)((java.util.Map<?,?>)document.get("components")).get("schemas");
java.util.Map<?,?> arrange = (java.util.Map<?,?>)schemas.get("DispatchArrangeDelivery");
assertNotNull(arrange);
assertTrue(((java.util.List<?>)arrange.get("required")).containsAll(java.util.List.of("recordVersion", "idempotencyKey")));
java.util.Map<?,?> fields = (java.util.Map<?,?>)arrange.get("properties");
assertTrue(fields.keySet().containsAll(java.util.Set.of("recordVersion", "idempotencyKey", "cargoHash", "loadingCompletionHash", "groups")));
assertFalse(fields.containsKey("customerId"));
assertFalse(fields.containsKey("expectedVersion"));
java.util.Map<?,?> groups = (java.util.Map<?,?>)fields.get("groups");
assertEquals("array", groups.get("type"));
assertEquals("#/components/schemas/DispatchArrangeGroup", ((java.util.Map<?,?>)groups.get("items")).get("$ref"));
java.util.Map<?,?> groupFields = (java.util.Map<?,?>)((java.util.Map<?,?>)schemas.get("DispatchArrangeGroup")).get("properties");
assertTrue(groupFields.keySet().containsAll(java.util.Set.of("groupKey", "groupCargoHash", "fees", "feeCoverage")));
java.util.Map<?,?> resultFields = (java.util.Map<?,?>)((java.util.Map<?,?>)schemas.get("DispatchArrangementResult")).get("properties");
assertEquals("array", ((java.util.Map<?,?>)resultFields.get("outbounds")).get("type"));
assertEquals("array", ((java.util.Map<?,?>)resultFields.get("deliveries")).get("type"));