fix: support cross-flow state reachability
This commit is contained in:
@@ -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())
|
||||
|
||||
@@ -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<SimpleFlowConfig.Action> allActions = FlowConfigSupport.actions(config);
|
||||
for (SimpleFlowConfig flow : FlowConfigSupport.leafFlows(config))
|
||||
List<SimpleFlowConfig> flows = FlowConfigSupport.leafFlows(config);
|
||||
Map<SimpleFlowConfig, Set<String>> reachableByFlow = new LinkedHashMap<SimpleFlowConfig, Set<String>>();
|
||||
for (SimpleFlowConfig flow : flows)
|
||||
{
|
||||
Set<String> reachable = new HashSet<String>();
|
||||
for (Action action : allActions)
|
||||
reachableByFlow.put(flow, new HashSet<String>());
|
||||
}
|
||||
|
||||
boolean changed;
|
||||
do
|
||||
{
|
||||
changed = false;
|
||||
for (SimpleFlowConfig sourceFlow : flows)
|
||||
{
|
||||
for (Effect effect : action.getEffects())
|
||||
Set<String> 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<String, String> 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<String> 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<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)
|
||||
{
|
||||
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
|
||||
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");
|
||||
|
||||
Reference in New Issue
Block a user