fix: support cross-flow state reachability

This commit is contained in:
王鹏
2026-07-15 17:53:14 +08:00
parent de98ea59b1
commit b37c68f2a3
3 changed files with 192 additions and 22 deletions

View File

@@ -41,6 +41,7 @@ public class FlowConfigSanitizer
repairMetricStatuses(flow); repairMetricStatuses(flow);
repairSelfUpdateRelationFields(flow); repairSelfUpdateRelationFields(flow);
repairEffectValues(flow); repairEffectValues(flow);
repairMirroredCrossTableRelationFields(flow);
} }
return config; return config;
} }
@@ -84,6 +85,36 @@ public class FlowConfigSanitizer
return StringUtils.trim(requestFields.get(0)); 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) private void repairEffectValues(SimpleFlowConfig config)
{ {
for (SimpleFlowConfig.Action action : config.getActions()) for (SimpleFlowConfig.Action action : config.getActions())

View File

@@ -1,6 +1,7 @@
package com.ruoyi.generator.factory.flow; package com.ruoyi.generator.factory.flow;
import java.util.HashSet; import java.util.HashSet;
import java.util.LinkedHashMap;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.Set; import java.util.Set;
@@ -141,37 +142,41 @@ public class FlowConfigValidator
private void validateReachability(SimpleFlowConfig config) private void validateReachability(SimpleFlowConfig config)
{ {
List<SimpleFlowConfig.Action> allActions = FlowConfigSupport.actions(config); List<SimpleFlowConfig> flows = FlowConfigSupport.leafFlows(config);
for (SimpleFlowConfig flow : FlowConfigSupport.leafFlows(config)) Map<SimpleFlowConfig, Set<String>> reachableByFlow = new LinkedHashMap<SimpleFlowConfig, Set<String>>();
for (SimpleFlowConfig flow : flows)
{ {
Set<String> reachable = new HashSet<String>(); reachableByFlow.put(flow, new HashSet<String>());
for (Action action : allActions)
{
for (Effect effect : action.getEffects())
{
if (effect == null || !"create_record".equals(effect.getType())
|| !StringUtils.equals(flow.getMainTable(), effect.getTable())) continue;
Map<String, String> values = effect.getValues();
String state = values == null ? null : values.get(flow.getStatusField());
if (StringUtils.isNotBlank(state)) reachable.add(unquote(state));
}
}
if (reachable.isEmpty())
{
fail("流程 " + flow.getCode() + " 缺少创建 " + flow.getMainTable()
+ " 主记录的发起动作,初始状态不可到达");
} }
boolean changed; boolean changed;
do do
{ {
changed = false; changed = false;
for (Action action : flow.getActions()) for (SimpleFlowConfig sourceFlow : flows)
{ {
if (StringUtils.isNotBlank(action.getFrom()) && reachable.contains(action.getFrom()) Set<String> sourceReachable = reachableByFlow.get(sourceFlow);
&& reachable.add(action.getTo())) changed = true; for (Action action : sourceFlow.getActions())
{
if (!isActionReachable(action, sourceReachable)) continue;
if (StringUtils.isNotBlank(action.getFrom()) && sourceReachable.add(action.getTo()))
{
changed = true;
}
if (applyStateEffects(action, flows, reachableByFlow)) changed = true;
}
} }
} }
while (changed); while (changed);
for (SimpleFlowConfig flow : flows)
{
if (!hasCreationEffect(config, flow))
{
fail("流程 " + flow.getCode() + " 缺少创建 " + flow.getMainTable()
+ " 主记录的发起动作,初始状态不可到达");
}
Set<String> reachable = reachableByFlow.get(flow);
for (State state : flow.getStates()) for (State state : flow.getStates())
{ {
if (!reachable.contains(state.getCode())) if (!reachable.contains(state.getCode()))
@@ -182,6 +187,60 @@ public class FlowConfigValidator
} }
} }
private boolean isActionReachable(Action action, Set<String> reachable)
{
return StringUtils.isBlank(action.getFrom()) || reachable.contains(action.getFrom());
}
private boolean applyStateEffects(Action action, List<SimpleFlowConfig> flows,
Map<SimpleFlowConfig, Set<String>> 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<String, String> 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<String, String> 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) private String unquote(String value)
{ {
String normalized = StringUtils.defaultString(value).trim(); String normalized = StringUtils.defaultString(value).trim();

View File

@@ -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 @Test
public void compilesReservationAndBorrowingAsTwoIndependentFlows() public void compilesReservationAndBorrowingAsTwoIndependentFlows()
{ {
@@ -306,6 +329,63 @@ public class FlowConfigCompilerTest
assertEquals("borrowing", compilation.getLoopPlan().getStateMachines().get(1).getTableName()); 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) private String compileButtonTarget(String scope)
{ {
SimpleFlowConfig config = buttonFlow("list.rowActions"); SimpleFlowConfig config = buttonFlow("list.rowActions");