fix: support cross-flow state reachability
This commit is contained in:
@@ -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())
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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");
|
||||||
|
|||||||
Reference in New Issue
Block a user