WorkflowServiceImpl.java

  1. /*
  2.  * Copyright 2024 Global Crop Diversity Trust
  3.  *
  4.  * Licensed under the Apache License, Version 2.0 (the "License");
  5.  * you may not use this file except in compliance with the License.
  6.  * You may obtain a copy of the License at
  7.  *
  8.  *   http://www.apache.org/licenses/LICENSE-2.0
  9.  *
  10.  * Unless required by applicable law or agreed to in writing, software
  11.  * distributed under the License is distributed on an "AS IS" BASIS,
  12.  * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
  13.  * See the License for the specific language governing permissions and
  14.  * limitations under the License.
  15.  */

  16. package org.gringlobal.service.impl;

  17. import com.google.common.cache.Cache;
  18. import com.google.common.cache.CacheBuilder;
  19. import com.querydsl.jpa.impl.JPAQueryFactory;
  20. import lombok.extern.slf4j.Slf4j;

  21. import java.util.ArrayList;
  22. import java.util.HashMap;
  23. import java.util.List;
  24. import java.util.Map;
  25. import java.util.Objects;
  26. import java.util.Set;
  27. import java.util.concurrent.TimeUnit;
  28. import java.util.stream.Collectors;

  29. import org.apache.commons.collections4.CollectionUtils;
  30. import org.apache.commons.collections4.MapUtils;
  31. import org.apache.commons.lang3.StringUtils;
  32. import org.genesys.blocks.util.CurrentApplicationContext;
  33. import org.gringlobal.api.exception.InvalidApiUsageException;
  34. import org.gringlobal.component.elastic.AppContextHelper;
  35. import org.gringlobal.model.AbstractAction;
  36. import org.gringlobal.model.AccessionAction;
  37. import org.gringlobal.model.InventoryAction;
  38. import org.gringlobal.model.InventoryViabilityAction;
  39. import org.gringlobal.model.OrderRequestAction;
  40. import org.gringlobal.model.OrderRequestItemAction;
  41. import org.gringlobal.model.workflow.QWorkflowTransition;
  42. import org.gringlobal.model.workflow.Workflow;
  43. import org.gringlobal.model.workflow.WorkflowActionStep;
  44. import org.gringlobal.model.workflow.WorkflowEndStep;
  45. import org.gringlobal.model.workflow.WorkflowHelperMethods;
  46. import org.gringlobal.model.workflow.WorkflowStartStep;
  47. import org.gringlobal.model.workflow.WorkflowStep;
  48. import org.gringlobal.model.workflow.WorkflowTransition;
  49. import org.gringlobal.persistence.WorkflowRepository;
  50. import org.gringlobal.persistence.WorkflowStepRepository;
  51. import org.gringlobal.service.AccessionActionService;
  52. import org.gringlobal.service.ActionService;
  53. import org.gringlobal.service.InventoryActionService;
  54. import org.gringlobal.service.InventoryViabilityActionService;
  55. import org.gringlobal.service.OrderRequestActionService;
  56. import org.gringlobal.service.OrderRequestItemActionService;
  57. import org.gringlobal.service.WorkflowService;
  58. import org.gringlobal.service.filter.WorkflowFilter;
  59. import org.gringlobal.spring.CustomStandardEvaluationContext;
  60. import org.hibernate.Hibernate;
  61. import org.springframework.beans.factory.annotation.Autowired;
  62. import org.springframework.expression.Expression;
  63. import org.springframework.expression.ExpressionParser;
  64. import org.springframework.expression.spel.standard.SpelExpressionParser;
  65. import org.springframework.security.access.prepost.PreAuthorize;
  66. import org.springframework.stereotype.Service;
  67. import org.springframework.transaction.annotation.Transactional;
  68. import org.springframework.validation.annotation.Validated;

  69. import javax.validation.ValidationException;

  70. /**
  71.  * The Class WorkflowServiceImpl.
  72.  */
  73. @Service
  74. @Transactional(readOnly = true)
  75. @Validated
  76. @PreAuthorize("hasAuthority('GROUP_ADMINS')")
  77. @Slf4j
  78. public class WorkflowServiceImpl extends FilteredCRUDService2Impl<Workflow, WorkflowFilter, WorkflowRepository> implements WorkflowService {

  79.     private final Cache<String, Expression> parsedExpressions = CacheBuilder.newBuilder().maximumSize(30).expireAfterAccess(10, TimeUnit.HOURS).build();

  80.     @Autowired
  81.     private WorkflowStepRepository workflowStepRepository;

  82.     @Autowired
  83.     private JPAQueryFactory jpaQueryFactory;

  84.     private Map<String, ActionService> actionServices;

  85.     private final ExpressionParser expressionParser = new SpelExpressionParser();

  86.     private CustomStandardEvaluationContext context;

  87.     @Override
  88.     public void afterPropertiesSet() throws Exception {
  89.         super.afterPropertiesSet();

  90.         actionServices = new HashMap<>();
  91.         actionServices.put(AccessionAction.class.getName(), CurrentApplicationContext.getContext().getBean(AccessionActionService.class));
  92.         actionServices.put(InventoryAction.class.getName(), CurrentApplicationContext.getContext().getBean(InventoryActionService.class));
  93.         actionServices.put(InventoryViabilityAction.class.getName(), CurrentApplicationContext.getContext().getBean(InventoryViabilityActionService.class));
  94.         actionServices.put(OrderRequestAction.class.getName(), CurrentApplicationContext.getContext().getBean(OrderRequestActionService.class));
  95.         actionServices.put(OrderRequestItemAction.class.getName(), CurrentApplicationContext.getContext().getBean(OrderRequestItemActionService.class));

  96.         WorkflowHelperMethods.setJpaQueryFactory(jpaQueryFactory);
  97.         context = new CustomStandardEvaluationContext();
  98.         if (MapUtils.isNotEmpty(WorkflowHelperMethods.methods)) {
  99.             WorkflowHelperMethods.methods.forEach(context::registerFunction);
  100.         }
  101.     }

  102.     @Override
  103.     public Workflow create(Workflow source) {
  104.         return createFast(source);
  105.     }

  106.     @Override
  107.     public Workflow createFast(Workflow source) {
  108.         assert (CollectionUtils.isEmpty(source.getTransitions()));
  109.         Workflow workflow = new Workflow();
  110.         workflow.apply(source);
  111.         if (!actionServices.containsKey(workflow.getActionType())) {
  112.             throw new InvalidApiUsageException("Invalid workflow action type");
  113.         }
  114.         workflow = super.createFast(source);
  115.         return createStartEndWorkflowSteps(workflow);
  116.     }

  117.     private Workflow createStartEndWorkflowSteps(Workflow workflow) {
  118.         WorkflowStartStep startStep = new WorkflowStartStep();
  119.         startStep.setWorkflow(workflow);
  120.         WorkflowEndStep endStep = new WorkflowEndStep();
  121.         endStep.setWorkflow(workflow);
  122.         workflowStepRepository.saveAll(List.of(startStep, endStep));
  123.         return repository.getReferenceById(workflow.getId());
  124.     }

  125.     @Override
  126.     public Workflow update(Workflow updated, Workflow target) {
  127.         return updateFast(updated, target);
  128.     }

  129.     @Override
  130.     @Transactional
  131.     public Workflow updateTransitions(Workflow target, List<WorkflowTransition> transitions) {
  132.         var update = get(target.getId());

  133.         if (CollectionUtils.isEmpty(transitions)) {
  134.             update.getTransitions().clear();
  135.         } else {
  136.             validateWorkflowTransitions(transitions, update);

  137.             if (update.getTransitions() == null) {
  138.                 update.setTransitions(transitions);
  139.             } else {
  140.                 update.getTransitions().removeIf(t -> {
  141.                     boolean shouldRemove = /* By origin+target */
  142.                         null == transitions.stream()
  143.                             .filter(tt -> Objects.equals(tt.getOrigin().getId(), t.getOrigin().getId()) && Objects.equals(tt.getTarget().getId(), t.getTarget().getId()))
  144.                             // Find match in existing transitions
  145.                             .findFirst().orElse(null);
  146.                     // if (shouldRemove) {
  147.                     //  log.warn("Removing {}", t);
  148.                     // } else {
  149.                     //  log.warn("Keeping {}", t);
  150.                     // }
  151.                     return shouldRemove;
  152.                 });

  153.                 update.getTransitions().forEach(t -> {
  154.                     transitions.stream()
  155.                         .filter(tt ->
  156.                             Objects.equals(tt.getOrigin().getId(), t.getOrigin().getId())
  157.                                 && Objects.equals(tt.getTarget().getId(), t.getTarget().getId())
  158.                                 && !StringUtils.equals(tt.getCondition(), t.getCondition())
  159.                         )
  160.                         // Find match in existing transitions
  161.                         .findFirst()
  162.                         .ifPresent(updated -> t.setCondition(updated.getCondition()));
  163.                 });

  164.                 var toAdd = transitions.stream()
  165.                     // Add missing transitions
  166.                     .filter(t -> {
  167.                         boolean shouldAdd = /* By origin + target */
  168.                             null == update.getTransitions().stream()
  169.                                 .filter(tt -> Objects.equals(tt.getOrigin().getId(), t.getOrigin().getId()) && Objects.equals(tt.getTarget().getId(), t.getTarget().getId()))
  170.                                 // Fiund match in existing transitions
  171.                                 .findFirst().orElse(null);
  172.                         // if (shouldAdd) {
  173.                         //  log.warn("Adding {}", t);
  174.                         // } else {
  175.                         //  log.warn("Ignoring {}", t);
  176.                         // }
  177.                         return shouldAdd;
  178.                     })
  179.                     // Collect
  180.                     .collect(Collectors.toList());

  181.                 update.getTransitions().addAll(toAdd);
  182.             }
  183.         }

  184.         return repository.save(update);
  185.     }

  186.     @Override
  187.     public void validateWorkflowTransitions(List<WorkflowTransition> transitions, Workflow workflow) {
  188.         transitions.forEach(transition -> {
  189.             assert (Objects.equals(transition.getOrigin().getWorkflow().getId(), workflow.getId()));
  190.             assert (Objects.equals(transition.getTarget().getWorkflow().getId(), workflow.getId()));
  191.             assert (!Objects.equals(transition.getOrigin().getId(), transition.getTarget().getId()));
  192.         });

  193.         var conditions = transitions.stream()
  194.             .map(WorkflowTransition::getCondition)
  195.             .filter(Objects::nonNull)
  196.             .collect(Collectors.toSet());

  197.         if (!conditions.isEmpty()) {
  198.             for (String condition : conditions) {
  199.                 parseExpression(condition);
  200.             }
  201.         }

  202.         // Check unlinked transitions
  203.         var originSteps = transitions.stream()
  204.             .map(WorkflowTransition::getOrigin)
  205.             .filter(step -> Hibernate.unproxy(step) instanceof WorkflowActionStep)
  206.             .collect(Collectors.toSet());
  207.         var targetSteps = transitions.stream()
  208.             .map(WorkflowTransition::getTarget)
  209.             .filter(step -> Hibernate.unproxy(step) instanceof WorkflowActionStep)
  210.             .collect(Collectors.toSet());
  211.         for (WorkflowStep originStep : originSteps) {
  212.             if (!targetSteps.contains(originStep)) {
  213.                 throw new InvalidApiUsageException("All steps must be present as origins and as targets in transitions");
  214.             }
  215.         }
  216.         for (WorkflowStep targetStep : targetSteps) {
  217.             if (!originSteps.contains(targetStep)) {
  218.                 throw new InvalidApiUsageException("All steps must be present as origins and as targets in transitions");
  219.             }
  220.         }

  221.         var startTransitions = transitions.stream()
  222.             .filter(transition -> Hibernate.unproxy(transition.getOrigin()) instanceof WorkflowStartStep)
  223.             .collect(Collectors.toList());
  224.         if (startTransitions.isEmpty() || startTransitions.stream().map(WorkflowTransition::getOrigin).distinct().count() > 1) {
  225.             throw new InvalidApiUsageException("Transitions must have one workflow start step.");
  226.         }

  227.         checkStepsIsNotEquals(startTransitions);

  228.         startTransitions.forEach(startTransition -> validateTransitions(transitions, startTransition.getTarget(), 0));
  229.     }

  230.     private Expression parseExpression(String condition) {
  231.         try {
  232.             return parsedExpressions.get("parsed_spel_expr:" + condition, () -> expressionParser.parseExpression(condition));
  233.         } catch (Exception e) {
  234.             throw new ValidationException("Invalid SpEL expression", e);
  235.         }
  236.     }

  237.     private void validateTransitions(List<WorkflowTransition> transitions, WorkflowStep targetStep, int transitionNumber) {
  238.         // Check if target is end step
  239.         if ((Hibernate.unproxy(targetStep) instanceof WorkflowEndStep)) {
  240.             return;
  241.         }
  242.         // The longest linked list of steps must be smaller than the transitions size.
  243.         if (transitionNumber + 1 == transitions.size()) {
  244.             if (!(Hibernate.unproxy(targetStep) instanceof WorkflowEndStep)) {
  245.                 // Cyclic links ?
  246.                 throw new InvalidApiUsageException("Cyclic transition links");
  247.             }
  248.         } else {
  249.             // Find all transitions with origin = targetStep
  250.             var targetTransitions = transitions.stream()
  251.                 .filter(transition -> transition.getOrigin().equals(targetStep))
  252.                 .collect(Collectors.toList());

  253.             if (targetTransitions.size() < 1) {
  254.                 throw new InvalidApiUsageException("The flow chain is broken");
  255.             }

  256.             checkStepsIsNotEquals(targetTransitions);

  257.             targetTransitions.forEach(transition -> validateTransitions(transitions, transition.getTarget(), transitionNumber + 1));
  258.         }
  259.     }

  260.     private void checkStepsIsNotEquals(List<WorkflowTransition> transitions) {
  261.         var targetSteps = transitions.stream()
  262.             .filter(transition -> Hibernate.unproxy(transition.getTarget()) instanceof WorkflowActionStep)
  263.             .map(transition -> (WorkflowActionStep) Hibernate.unproxy(transition.getTarget()))
  264.             .collect(Collectors.toList());
  265.         var distinctSteps = targetSteps.stream()
  266.             .map(step -> String.valueOf(step.getActionAssignee().getId()).concat(step.getActionNameCode()))
  267.             .distinct()
  268.             .count();
  269.         if (distinctSteps != targetSteps.size()) {
  270.             throw new InvalidApiUsageException("Equal target steps are not allowed");
  271.         }
  272.     }

  273.     @Override
  274.     public Workflow updateFast(Workflow updated, Workflow target) {
  275.         target.apply(updated); // Doesn't update List<WorkflowTransitions>
  276.         return repository.save(target);
  277.     }

  278.     @Override
  279.     public Workflow remove(Workflow entity) {
  280.         return super.remove(entity);
  281.     }

  282.     @Transactional
  283.     @Override
  284.     public void createNextStepAction(AbstractAction<?> completedAction) {
  285.         if (completedAction.getWorkflowStep() == null) {
  286.             return;
  287.         }
  288.         var completedStep = workflowStepRepository.getReferenceById(completedAction.getWorkflowStep().getId());
  289.         ActionService service = actionServices.get(completedStep.getWorkflow().getActionType()); // This will pull workflow from the database
  290.         if (service != null) {
  291.             var targetTransitions = jpaQueryFactory.select(QWorkflowTransition.workflowTransition).from(QWorkflowTransition.workflowTransition).where(QWorkflowTransition.workflowTransition.origin().eq(completedStep)).fetch();
  292.             var targetSteps = targetTransitions.stream()
  293.                 .filter(transition -> {
  294.                     var condition = transition.getCondition();
  295.                     if (condition != null) {
  296.                         var action = service.get(completedAction.getId());
  297.                         return parseExpression(condition).getValue(new CustomStandardEvaluationContext(context, action), Boolean.class);
  298.                     }
  299.                     return true;
  300.                 })
  301.                 .map(WorkflowTransition::getTarget)
  302.                 .collect(Collectors.toList());
  303.             targetSteps.stream()
  304.                 .map(nextStep -> Hibernate.unproxy(nextStep))
  305.                 .filter(nextStep -> nextStep instanceof WorkflowActionStep)
  306.                 .filter(nextStep -> !Objects.equals(nextStep, completedStep)) // Just in case!
  307.                 .forEach(nextStep -> {
  308.                     log.info("Completed step {} starting {}", completedStep, nextStep);
  309.                     service.createNextWorkflowStepAction((WorkflowActionStep) nextStep, completedAction);
  310.                 });
  311.         }
  312.     }

  313.     @Override
  314.     public <T extends AbstractAction<T>> List<T> startWorkflow(long workflowId, Set<Long> owningEntityIds, ActionCreator<T> actionCreator) {
  315.         var loadedWorkflow = load(workflowId);

  316.         var transitions = loadedWorkflow.getTransitions();
  317.         var stepWithTransition = transitions.stream()
  318.             .filter(transition -> Hibernate.unproxy(transition.getOrigin()) instanceof WorkflowStartStep && Hibernate.unproxy(transition.getTarget()) instanceof WorkflowActionStep)
  319.             .collect(Collectors.toMap((t) -> (WorkflowActionStep)Hibernate.unproxy(t.getTarget()), (t) -> t));
  320.    
  321.         if (stepWithTransition.isEmpty()) {
  322.             throw new IllegalArgumentException("The workflow does not contain steps to complete");
  323.         }
  324.        
  325.         // Build actions by ActionCreator.buildActions
  326.         var actions = actionCreator.buildActions(new ArrayList<>(stepWithTransition.keySet()));
  327.        
  328.         // Filter by condition and return to persist
  329.         return actions.stream().filter(action -> {
  330.             var transition = stepWithTransition.get(action.getWorkflowStep());
  331.             var condition = transition.getCondition();
  332.             if (condition != null) {
  333.                 return parseExpression(condition).getValue(new CustomStandardEvaluationContext(context, action), Boolean.class);
  334.             }
  335.             return true;
  336.         }).collect(Collectors.toList());
  337.     }
  338.    
  339.     public interface ActionCreator<T extends AbstractAction<T>> {

  340.         List<T> buildActions(List<WorkflowActionStep> steps);
  341.     }

  342.     @Service
  343.     @Transactional(readOnly = true)
  344.     @Validated
  345.     @PreAuthorize("hasAuthority('GROUP_ADMINS')")
  346.     @Slf4j
  347.     public static class WorkflowStepServiceImpl extends CRUDService2Impl<WorkflowStep, WorkflowStepRepository> implements WorkflowStepService {

  348.         @Autowired
  349.         private WorkflowRepository workflowRepository;

  350.         @Autowired
  351.         private WorkflowService workflowService;

  352.         @Override
  353.         @Transactional
  354.         public WorkflowStep createFast(WorkflowStep source) {
  355.             if (source instanceof WorkflowStartStep || source instanceof WorkflowEndStep) {
  356.                 throw new InvalidApiUsageException("WorkflowStartStep and WorkflowEndStep are automatically created along with the workflow.");
  357.             }

  358.             var workflow = workflowRepository.getReferenceById(source.getWorkflow().getId());
  359.             String actionCodeGroup;
  360.             try {
  361.                 Class<AbstractAction<?>> actionTypeClass = (Class<AbstractAction<?>>) Class.forName(workflow.getActionType());
  362.                 actionCodeGroup = actionTypeClass.getDeclaredConstructor().newInstance().getActionGroupName();
  363.             } catch (Exception e) {
  364.                 throw new InvalidApiUsageException("Invalid workflow action type");
  365.             }

  366.             if (!AppContextHelper.validateCodeValue(actionCodeGroup, ((WorkflowActionStep) source).getActionNameCode())) {
  367.                 throw new InvalidApiUsageException("Invalid action code value");
  368.             }

  369.             return repository.save(source);
  370.         }

  371.         @Override
  372.         @Transactional
  373.         public WorkflowStep create(WorkflowStep source) {
  374.             return createFast(source);
  375.         }


  376.         @Override
  377.         @Transactional
  378.         public WorkflowStep updateFast(WorkflowStep updated, WorkflowStep target) {
  379.             target.apply(updated);
  380.             var saved = repository.saveAndFlush(target);
  381.             var workflow = workflowRepository.getReferenceById(saved.getWorkflow().getId());
  382.             var transitions = workflow.getTransitions();
  383.             if (transitions.stream().anyMatch(t -> t.getOrigin().equals(saved) || t.getTarget().equals(saved))) {
  384.                 workflowService.validateWorkflowTransitions(transitions, workflow);
  385.             }
  386.             return saved;
  387.         }

  388.         @Override
  389.         @Transactional
  390.         public WorkflowStep update(WorkflowStep updated, WorkflowStep target) {
  391.             return updateFast(updated, target);
  392.         }
  393.     }
  394. }