From b37c68f2a35db53e0b6d503bb5b94acfcd6d27cb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E9=B9=8F?= Date: Wed, 15 Jul 2026 17:53:14 +0800 Subject: [PATCH] fix: support cross-flow state reachability --- .../factory/flow/FlowConfigSanitizer.java | 31 ++++++ .../factory/flow/FlowConfigValidator.java | 103 ++++++++++++++---- .../factory/flow/FlowConfigCompilerTest.java | 80 ++++++++++++++ 3 files changed, 192 insertions(+), 22 deletions(-) diff --git a/RuoYi-Vue/ruoyi-generator/src/main/java/com/ruoyi/generator/factory/flow/FlowConfigSanitizer.java b/RuoYi-Vue/ruoyi-generator/src/main/java/com/ruoyi/generator/factory/flow/FlowConfigSanitizer.java index 16d1514..f33c511 100644 --- a/RuoYi-Vue/ruoyi-generator/src/main/java/com/ruoyi/generator/factory/flow/FlowConfigSanitizer.java +++ b/RuoYi-Vue/ruoyi-generator/src/main/java/com/ruoyi/generator/factory/flow/FlowConfigSanitizer.java @@ -41,6 +41,7 @@ public class FlowConfigSanitizer repairMetricStatuses(flow); repairSelfUpdateRelationFields(flow); repairEffectValues(flow); + repairMirroredCrossTableRelationFields(flow); } return config; } @@ -84,6 +85,36 @@ public class FlowConfigSanitizer return StringUtils.trim(requestFields.get(0)); } + /** + * AI occasionally places an owner's foreign key in values while using the + * owner's primary key as relationField. For the narrow target_table_id + * mirror shape, move that field to relationField instead of generating an + * update against the wrong related row and a bogus target-column write. + */ + private void repairMirroredCrossTableRelationFields(SimpleFlowConfig config) + { + for (SimpleFlowConfig.Action action : config.getActions()) + { + if (action == null) continue; + String ownerTable = StringUtils.defaultIfBlank(action.getOwnerTable(), config.getMainTable()); + for (SimpleFlowConfig.Effect effect : action.getEffects()) + { + if (effect == null || "create_record".equals(effect.getType()) + || StringUtils.equals(ownerTable, effect.getTable()) + || !StringUtils.equals(ownerRequestId(action), effect.getRelationField()) + || effect.getValues() == null) + { + continue; + } + String candidate = StringUtils.defaultString(effect.getTable()) + "_id"; + String expression = effect.getValues().get(candidate); + if (!StringUtils.equals("record." + candidate, StringUtils.trim(expression))) continue; + effect.setRelationField(candidate); + effect.getValues().remove(candidate); + } + } + } + private void repairEffectValues(SimpleFlowConfig config) { for (SimpleFlowConfig.Action action : config.getActions()) diff --git a/RuoYi-Vue/ruoyi-generator/src/main/java/com/ruoyi/generator/factory/flow/FlowConfigValidator.java b/RuoYi-Vue/ruoyi-generator/src/main/java/com/ruoyi/generator/factory/flow/FlowConfigValidator.java index f9d7920..af1e240 100644 --- a/RuoYi-Vue/ruoyi-generator/src/main/java/com/ruoyi/generator/factory/flow/FlowConfigValidator.java +++ b/RuoYi-Vue/ruoyi-generator/src/main/java/com/ruoyi/generator/factory/flow/FlowConfigValidator.java @@ -1,6 +1,7 @@ package com.ruoyi.generator.factory.flow; import java.util.HashSet; +import java.util.LinkedHashMap; import java.util.List; import java.util.Map; import java.util.Set; @@ -141,37 +142,41 @@ public class FlowConfigValidator private void validateReachability(SimpleFlowConfig config) { - List allActions = FlowConfigSupport.actions(config); - for (SimpleFlowConfig flow : FlowConfigSupport.leafFlows(config)) + List flows = FlowConfigSupport.leafFlows(config); + Map> reachableByFlow = new LinkedHashMap>(); + for (SimpleFlowConfig flow : flows) { - Set reachable = new HashSet(); - for (Action action : allActions) + reachableByFlow.put(flow, new HashSet()); + } + + boolean changed; + do + { + changed = false; + for (SimpleFlowConfig sourceFlow : flows) { - for (Effect effect : action.getEffects()) + Set sourceReachable = reachableByFlow.get(sourceFlow); + for (Action action : sourceFlow.getActions()) { - if (effect == null || !"create_record".equals(effect.getType()) - || !StringUtils.equals(flow.getMainTable(), effect.getTable())) continue; - Map values = effect.getValues(); - String state = values == null ? null : values.get(flow.getStatusField()); - if (StringUtils.isNotBlank(state)) reachable.add(unquote(state)); + if (!isActionReachable(action, sourceReachable)) continue; + if (StringUtils.isNotBlank(action.getFrom()) && sourceReachable.add(action.getTo())) + { + changed = true; + } + if (applyStateEffects(action, flows, reachableByFlow)) changed = true; } } - if (reachable.isEmpty()) + } + while (changed); + + for (SimpleFlowConfig flow : flows) + { + if (!hasCreationEffect(config, flow)) { fail("流程 " + flow.getCode() + " 缺少创建 " + flow.getMainTable() + " 主记录的发起动作,初始状态不可到达"); } - boolean changed; - do - { - changed = false; - for (Action action : flow.getActions()) - { - if (StringUtils.isNotBlank(action.getFrom()) && reachable.contains(action.getFrom()) - && reachable.add(action.getTo())) changed = true; - } - } - while (changed); + Set reachable = reachableByFlow.get(flow); for (State state : flow.getStates()) { if (!reachable.contains(state.getCode())) @@ -182,6 +187,60 @@ public class FlowConfigValidator } } + private boolean isActionReachable(Action action, Set reachable) + { + return StringUtils.isBlank(action.getFrom()) || reachable.contains(action.getFrom()); + } + + private boolean applyStateEffects(Action action, List flows, + Map> reachableByFlow) + { + boolean changed = false; + for (Effect effect : action.getEffects()) + { + if (effect == null || (!"create_record".equals(effect.getType()) + && !"update_related".equals(effect.getType()))) continue; + for (SimpleFlowConfig targetFlow : flows) + { + if (!StringUtils.equals(targetFlow.getMainTable(), effect.getTable())) continue; + Map values = effect.getValues(); + String state = values == null ? null : values.get(targetFlow.getStatusField()); + String normalized = unquote(state); + if (declaresState(targetFlow, normalized) && reachableByFlow.get(targetFlow).add(normalized)) + { + changed = true; + } + } + } + return changed; + } + + private boolean hasCreationEffect(SimpleFlowConfig config, SimpleFlowConfig targetFlow) + { + for (Action action : FlowConfigSupport.actions(config)) + { + for (Effect effect : action.getEffects()) + { + if (effect == null || !"create_record".equals(effect.getType()) + || !StringUtils.equals(targetFlow.getMainTable(), effect.getTable())) continue; + Map values = effect.getValues(); + String state = values == null ? null : values.get(targetFlow.getStatusField()); + if (declaresState(targetFlow, unquote(state))) return true; + } + } + return false; + } + + private boolean declaresState(SimpleFlowConfig flow, String code) + { + if (StringUtils.isBlank(code)) return false; + for (State state : flow.getStates()) + { + if (state != null && StringUtils.equals(state.getCode(), code)) return true; + } + return false; + } + private String unquote(String value) { String normalized = StringUtils.defaultString(value).trim(); diff --git a/RuoYi-Vue/ruoyi-generator/src/test/java/com/ruoyi/generator/factory/flow/FlowConfigCompilerTest.java b/RuoYi-Vue/ruoyi-generator/src/test/java/com/ruoyi/generator/factory/flow/FlowConfigCompilerTest.java index 389e727..cb2f71e 100644 --- a/RuoYi-Vue/ruoyi-generator/src/test/java/com/ruoyi/generator/factory/flow/FlowConfigCompilerTest.java +++ b/RuoYi-Vue/ruoyi-generator/src/test/java/com/ruoyi/generator/factory/flow/FlowConfigCompilerTest.java @@ -259,6 +259,29 @@ public class FlowConfigCompilerTest } } + @Test + public void sanitizerRepairsMirroredCrossTableRelationField() + { + SimpleFlowConfig config = JSON.parseObject("{" + + "\"version\":\"1.1\",\"code\":\"contract_flow\",\"name\":\"Contract approval\"," + + "\"mainTable\":\"contract\",\"statusField\":\"status\"," + + "\"states\":[{\"code\":\"pending\",\"label\":\"Pending\"}," + + "{\"code\":\"effective\",\"label\":\"Effective\"}]," + + "\"actions\":[{\"code\":\"approve\",\"name\":\"Approve\"," + + "\"from\":\"pending\",\"to\":\"effective\",\"ownerTable\":\"contract\"," + + "\"requestFields\":[\"id\"],\"effects\":[{\"type\":\"update_related\"," + + "\"table\":\"client\",\"relationField\":\"id\"," + + "\"values\":{\"status\":\"signed\",\"client_id\":\"record.client_id\"}}]}]," + + "\"metrics\":[]}", SimpleFlowConfig.class); + + new FlowConfigSanitizer().sanitize(config); + + SimpleFlowConfig.Effect effect = config.getActions().get(0).getEffects().get(0); + assertEquals("client_id", effect.getRelationField()); + assertEquals("signed", effect.getValues().get("status")); + assertNull(effect.getValues().get("client_id")); + } + @Test public void compilesReservationAndBorrowingAsTwoIndependentFlows() { @@ -306,6 +329,63 @@ public class FlowConfigCompilerTest assertEquals("borrowing", compilation.getLoopPlan().getStateMachines().get(1).getTableName()); } + @Test + public void validatorAcceptsStateReachedByReachableCrossFlowUpdate() + { + SimpleFlowConfig config = crossFlowClientSigningConfig(true); + + new FlowConfigValidator().validate(config); + } + + @Test + public void validatorRejectsCrossFlowUpdateFromUnreachableAction() + { + SimpleFlowConfig config = crossFlowClientSigningConfig(false); + + try + { + new FlowConfigValidator().validate(config); + fail("Expected signed client state to remain unreachable"); + } + catch (ServiceException e) + { + assertTrue(e.getMessage().contains("client_flow")); + assertTrue(e.getMessage().contains("signed")); + } + } + + private SimpleFlowConfig crossFlowClientSigningConfig(boolean contractCreationReachable) + { + String opportunityStates = contractCreationReachable + ? "[{\"code\":\"new\",\"label\":\"New\"},{\"code\":\"following\",\"label\":\"Following\"},{\"code\":\"confirmed\",\"label\":\"Confirmed\"}]" + : "[{\"code\":\"new\",\"label\":\"New\"},{\"code\":\"following\",\"label\":\"Following\"},{\"code\":\"blocked\",\"label\":\"Blocked\"},{\"code\":\"confirmed\",\"label\":\"Confirmed\"}]"; + String confirmFrom = contractCreationReachable ? "following" : "blocked"; + return JSON.parseObject("{\"version\":\"1.1\",\"code\":\"crm\",\"name\":\"CRM\",\"flows\":[{" + + "\"code\":\"client_flow\",\"name\":\"Clients\",\"mainTable\":\"client\",\"statusField\":\"status\"," + + "\"states\":[{\"code\":\"potential\",\"label\":\"Potential\"},{\"code\":\"following\",\"label\":\"Following\"}," + + "{\"code\":\"signed\",\"label\":\"Signed\"},{\"code\":\"lost\",\"label\":\"Lost\"}]," + + "\"actions\":[{\"code\":\"create_client\",\"name\":\"Create client\",\"ownerTable\":\"client\"," + + "\"requestFields\":[\"name\"],\"effects\":[{\"type\":\"create_record\",\"table\":\"client\",\"values\":{\"status\":\"potential\"}}]}," + + "{\"code\":\"assign_client\",\"name\":\"Assign client\",\"from\":\"potential\",\"to\":\"following\"," + + "\"ownerTable\":\"client\",\"requestFields\":[\"id\"],\"effects\":[]}," + + "{\"code\":\"mark_lost\",\"name\":\"Mark lost\",\"from\":\"following\",\"to\":\"lost\"," + + "\"ownerTable\":\"client\",\"requestFields\":[\"id\"],\"effects\":[]}],\"metrics\":[]},{" + + "\"code\":\"opportunity_flow\",\"name\":\"Opportunities\",\"mainTable\":\"opportunity\",\"statusField\":\"status\"," + + "\"states\":" + opportunityStates + ",\"actions\":[{\"code\":\"create_opportunity\",\"name\":\"Create opportunity\"," + + "\"ownerTable\":\"client\",\"requestFields\":[\"id\"],\"effects\":[{\"type\":\"create_record\",\"table\":\"opportunity\"," + + "\"values\":{\"status\":\"new\",\"client_id\":\"record.id\"}}]}," + + "{\"code\":\"follow_opportunity\",\"name\":\"Follow opportunity\",\"from\":\"new\",\"to\":\"following\"," + + "\"ownerTable\":\"opportunity\",\"requestFields\":[\"id\"],\"effects\":[]}," + + "{\"code\":\"confirm_opportunity\",\"name\":\"Confirm opportunity\",\"from\":\"" + confirmFrom + "\",\"to\":\"confirmed\"," + + "\"ownerTable\":\"opportunity\",\"requestFields\":[\"id\"],\"effects\":[{\"type\":\"create_record\",\"table\":\"contract\"," + + "\"values\":{\"status\":\"pending\",\"client_id\":\"record.client_id\"}}]}],\"metrics\":[]},{" + + "\"code\":\"contract_flow\",\"name\":\"Contracts\",\"mainTable\":\"contract\",\"statusField\":\"status\"," + + "\"states\":[{\"code\":\"pending\",\"label\":\"Pending\"},{\"code\":\"effective\",\"label\":\"Effective\"}]," + + "\"actions\":[{\"code\":\"approve_contract\",\"name\":\"Approve contract\",\"from\":\"pending\",\"to\":\"effective\"," + + "\"ownerTable\":\"contract\",\"requestFields\":[\"id\"],\"effects\":[{\"type\":\"update_related\",\"table\":\"client\"," + + "\"relationField\":\"client_id\",\"values\":{\"status\":\"signed\"}}]}],\"metrics\":[]}]}", SimpleFlowConfig.class); + } + private String compileButtonTarget(String scope) { SimpleFlowConfig config = buttonFlow("list.rowActions");