defmodule Mozart.BpmProcess do @moduledoc """ This module implements a BPM Domain Specific Language (DSL) framework. To use, insert **use Mozart.BpmProcess** into your modules. ```elixir defmodule Mozart.MyBpmApplication do use Mozart.BpmProcess # Your process model definitions end ``` """ alias Mozart.Task.Conditional alias Mozart.Task.Service alias Mozart.Task.User alias Mozart.Task.Subprocess alias Mozart.Task.Parallel alias Mozart.Task.Service alias Mozart.Task.Rule alias Mozart.Task.Case alias Mozart.Task.Receive alias Mozart.Task.Send alias Mozart.Task.Timer alias Mozart.Task.Prototype alias Mozart.Task.Repeat alias Mozart.Task.Reroute alias Mozart.Task.Conditional alias Mozart.Type.Choice alias Mozart.Type.MultiChoice alias Mozart.Type.Number alias Mozart.Type.Confirm alias Mozart.Event.TaskExit alias Mozart.Data.ProcessModel alias Mozart.Data.BpmApplication alias Mozart.ProcessService defmacro __using__(_opts) do quote do import Mozart.BpmProcess # Holds the tasks in a single process definition @tasks [] @processes [] @capture_subtasks false # A stack used to hold the subtasks of a single task @subtasks [] # Holds all task subtasks. When compilation is complete list is flattened @subtask_sets [] @events %{} @event_tasks [] @route_task_names [] @cases [] @event_task_process_map %{} @bpm_application nil @types [] @groups [] @before_compile Mozart.BpmProcess end end @doc """ Used to define a BPM application. An BPM application is used to identity a single business process implemented in a Elixir module. It's purpose is to facilitate external tool integration. A single Elixir module might have zero or more such definitions. It's parameters are as follows: * **process**: The name of the top level process defintion. * **data**: A comma separated list of input parameters that the busines process should be initialized with. Example: def_bpm_application("Home Loan", data: "Customer Name,Income,Debt") """ defmacro def_bpm_application(process, data: data, bk_prefix: prefix) do quote do if @bpm_application, do: raise "Only one BPM application allowed per module" data = parse_params(unquote(data)) prefix = parse_params(unquote(prefix)) bpm_application = %BpmApplication{ process: unquote(process), data: data, bk_prefix: prefix, module: __MODULE__ } @bpm_application bpm_application end end @doc """ Used to implement a business process model. Arguments are a name followed by one or more task functions. ``` defprocess "two timer task process" do timer_task("one second timer task", duration: 1000) timer_task("two second timer task", duration: 2000) end ``` """ defmacro defprocess(name, do: body) do quote do process = %ProcessModel{name: unquote(name)} unquote(body) tasks = set_next_tasks(@tasks) tasks = tasks ++ List.flatten(@subtask_sets) check_for_duplicates("task", tasks) process = Map.put(process, :tasks, tasks) initial_task_name = Map.get(hd(tasks), :name) process = Map.put(process, :initial_task, initial_task_name) @processes [process | @processes] check_for_duplicates("process", @processes) @tasks [] @subtasks [] @subtask_sets [] end end @doc """ Used to associate a single choice, enumerated type for a process data parameter. It's purpose is to facilitate interation with external tools, e.g. GUIs. Example: def_choice_type("Pre Approval", choices: "Approved, Declined") """ defmacro def_choice_type(param_name, choices: choices) do quote do choices = parse_params(unquote(choices)) choice = %Choice{param_name: unquote(param_name), choices: choices} @types [choice | @types] end end @doc """ Used to associate a multiple choice, enumerated type for a process data parameter. It's purpose is to facilitate interation with external tools, e.g. GUIs. Example: def_multi_choice_type("multi choice param", choices: "foo,bar,foobar") """ defmacro def_multi_choice_type(param_name, choices: choices) do quote do choices = parse_params(unquote(choices)) choice = %MultiChoice{param_name: unquote(param_name), choices: choices} @types [choice | @types] end end @doc """ Used to associate a numerical type for a process data parameter. It's purpose is to facilitate interation with external tools, e.g. GUIs. Example: def_number_type("number param", min: 0, max: 5) """ defmacro def_number_type(param_name, options) do quote do options = unquote(options) min = Enum.find_value(options, fn {k, v} -> if k == :min, do: v end) max = Enum.find_value(options, fn {k, v} -> if k == :max, do: v end) num_type = %Number{param_name: unquote(param_name), min: min, max: max} @types [num_type | @types] end end @doc """ Used to associate a confirmational type for a process data parameter. It's purpose is to facilitate interation with external tools, e.g. GUIs. Intended to be associated with a check box that the user must check to complete a task. Example: def_confirm_type("confirm param") """ defmacro def_confirm_type(param_name) do quote do confirm_type = %Confirm{param_name: unquote(param_name)} @types [confirm_type | @types] end end @doc """ Used to implement a business process event. Arguments are: * the name of the event * **process**: the name of the process that the event will act upon * **exit_task**: the name of the task to be exited * **selector**: a function that matches on the target event * **do**: one or more tasks to be executed when the target task is exited. ``` defevent "exit loan decision 1", process: "exit a user task 1", exit_task: "user task 1", selector: :event_selector do prototype_task("event 1 prototype task 1") prototype_task("event 1 prototype task 2") end ``` """ defmacro defevent(name, options, do: tasks) do quote do options = unquote(options) process = Keyword.get(options, :process) exit_task = Keyword.get(options, :exit_task) selector = Keyword.get(options, :selector) module = Keyword.get(options, :module) || __MODULE__ event = %TaskExit{ name: unquote(name), exit_task: exit_task, selector: selector, module: module } new_subtask_context() unquote(tasks) order_subtasks() first = hd(get_subtasks()) event = Map.put(event, :next, Map.get(hd(get_subtasks()), :name)) @event_task_process_map Map.put(@event_task_process_map, process, get_subtasks()) @events Map.put(@events, process, event) @tasks [] @subtasks [] @subtask_sets [] @capture_subtasks false end end @doc false def check_for_duplicates(type, tasks) do task_names = Enum.map(tasks, fn t -> t.name end) dup_task_names = Enum.uniq(task_names -- Enum.uniq(task_names)) if dup_task_names != [], do: raise("Duplicate #{type} names: #{inspect(dup_task_names)}") end @doc false def set_next_tasks([task]), do: [task] def set_next_tasks([task1, task2 | rest]) do task1 = Map.put(task1, :next, task2.name) [task1 | set_next_tasks([task2 | rest])] end @doc """ Used to define parallel execution paths. Arguments are the task name and a list of routes. ``` defprocess "two parallel routes process" do parallel_task "a parallel task" do route do user_task("1", group: "admin") user_task("2", group: "admin") end route do user_task("3", group: "admin") user_task("4", group: "admin") end end end ``` """ defmacro parallel_task(name, do: routes) do quote do task = %Parallel{name: unquote(name)} unquote(routes) task = Map.put(task, :multi_next, @route_task_names) insert_new_task(task) @route_task_names [] end end @doc false defmacro route(do: tasks) do quote do new_subtask_context() unquote(tasks) first = hd(get_subtasks()) order_subtasks() @route_task_names @route_task_names ++ [first.name] reset_subtasks() end end @doc """ Used to define a task that executes only when a condition (function) evaluates to true (a truthy value). When the condition evaluates to false, execution proceeds directly to the next task. Example: ``` defprocess "process with conditional task" do prototype_task("fast initial credit check") conditional_task "additional credit check", condition: :initial_check_inconclusive do prototype_task("extensive credit check") end prototype_task("report credit check results") end ``` """ defmacro conditional_task(name, options, do: tasks) do quote do options = unquote(options) condition = Keyword.get(options, :condition) module = Keyword.get(options, :module) || __MODULE__ name = unquote(name) c_task = %Conditional{name: name, condition: condition, module: module} insert_new_task(c_task) new_subtask_context() unquote(tasks) order_subtasks() first = List.first(get_subtasks()) last = List.last(get_subtasks()) c_task = Map.put(c_task, :first, first.name) |> Map.put(:last, last.name) reset_subtasks() update_tasks(c_task) end end @doc """ Used to a sequence of tasks that repeat as long as a condition evaluates to a truthy value. Example: ``` defprocess "account overdue process" do repeat_task "notify customer", condition: :third_notice_sent do service_task("send overdue notice", function: :record_notifiction_sent) end end ``` """ defmacro repeat_task(name, options, do: tasks) do quote do options = unquote(options) condition = Keyword.get(options, :condition) module = Keyword.get(options, :module) || __MODULE__ name = unquote(name) r_task = %Repeat{name: name, condition: condition, module: module} insert_new_task(r_task) new_subtask_context() unquote(tasks) order_subtasks() first = List.first(get_subtasks()) last = List.last(get_subtasks()) r_task = Map.put(r_task, :first, first.name) |> Map.put(:last, last.name) reset_subtasks() update_tasks(r_task) end end @doc false defmacro update_tasks(task) do quote do task = unquote(task) if @subtasks == [] do @tasks Enum.map(@tasks, fn t -> if t.name == task.name, do: task, else: t end) else new_subtasks = Enum.map(get_subtasks(), fn t -> if t.name == task.name, do: task, else: t end) set_subtasks(new_subtasks) end end end @doc false defmacro order_subtasks() do quote do ordered_subtasks = set_next_tasks(get_subtasks()) set_subtasks(ordered_subtasks) end end @doc false defmacro set_subtasks(subtasks) do quote do [_first | rest] = @subtasks @subtasks [unquote(subtasks) | rest] end end @doc false defmacro insert_new_task(task) do quote do if @subtasks == [] do @tasks @tasks ++ [unquote(task)] else new_subtasks = get_subtasks() ++ [unquote(task)] set_subtasks(new_subtasks) end end end @doc false defmacro insert_group(group) do quote do group = unquote(group) unless Enum.member?(@groups, group) do @groups [group | @groups] end end end @doc false defmacro get_subtasks() do quote do hd(@subtasks) end end @doc false defmacro new_subtask_context() do quote do subtasks_push() end end @doc false defmacro reset_subtasks() do quote do @subtask_sets [get_subtasks() | @subtask_sets] subtasks_pop() end end @doc false defmacro subtasks_push() do quote do @subtasks [[] | @subtasks] end end @doc false defmacro subtasks_pop() do quote do [_first | rest] = @subtasks @subtasks rest end end @doc false def parse_params(inputs_string) do String.split(inputs_string, ",") |> Enum.map(fn s -> String.trim(s) end) end @doc false def parse_user_groups(groups_string) do String.split(groups_string, ",") |> Enum.map(fn s -> String.trim(s) end) end @doc """ Used to reroute a process flow off the typical execution path (off the 'happy path'). Example: ``` defprocess "act on one of multiple events" do prototype_task("create order") receive_task("receive payment details", selector: :receive_payment_details) reroute_task "payment period expired", condition: :payment_period_expired do prototype_task("cancel order due to timeout") end reroute_task "order canceled", condition: :order_canceled do prototype_task("cancel order due to order cancelation") end prototype_task("process payment") end ``` """ defmacro reroute_task(name, options, do: tasks) do quote do options = unquote(options) condition = Keyword.get(options, :condition) module = Keyword.get(options, :module) || __MODULE__ name = unquote(name) reroute = %Reroute{name: name, condition: condition, module: module} insert_new_task(reroute) new_subtask_context() tasks = unquote(tasks) first = hd(get_subtasks()) reroute = Map.put(reroute, :reroute_first, first.name) order_subtasks() reset_subtasks() update_tasks(reroute) end end @doc """ Used to select one of many execution paths. Arguments are a task name and a list of cases. ``` defprocess "two case process" do case_task "yes or no" do case_i :x_less_than_y do user_task("1", group: "admin") user_task("2", group: "admin") end case_i :x_greater_or_equal_y do user_task("3", group: "admin") user_task("4", group: "admin") end end end ``` """ defmacro case_task(name, do: cases) do quote do task = %Case{name: unquote(name)} unquote(cases) task = Map.put(task, :cases, @cases) insert_new_task(task) @cases [] end end @doc """ Used to specify one of many alternate execution paths. The first argument is either a captured function or an atom corresponding to local function of arity 1. The second argument is a block of tasks. ``` defprocess "two case process" do case_task "yes or no" do case_i :x_less_than_y do user_task("1", group: "admin") user_task("2", group: "admin") end case_i :x_greater_or_equal_y do user_task("3", group: "admin") user_task("4", group: "admin") end end end ``` """ defmacro case_i(expr, do: tasks) do quote do new_subtask_context() unquote(tasks) expr = unquote(expr) expr = if is_atom(expr), do: Function.capture(__MODULE__, expr, 1), else: expr first = hd(get_subtasks()) order_subtasks() case = %{expression: expr, next: first.name} @cases @cases ++ [case] reset_subtasks() end end @doc """ Used to specify a timed delay in an execution path. Arguments are a task name and a duration in milliseconds. ``` defprocess "two timer task process" do timer_task("one second timer task", duration: 1000) timer_task("two second timer task", duration: 2000) end ``` """ defmacro timer_task(name, options) do quote do options = unquote(options) function = Keyword.get(options, :function) duration = Keyword.get(options, :duration) module = Keyword.get(options, :module) || __MODULE__ task = %Timer{name: unquote(name), timer_duration: duration, function: function, module: module} insert_new_task(task) end end @doc """ Used to specify a task that has no behavior. Used for prototyping. Has a single name argument ``` defprocess "two prototype task process" do prototype_task("prototype task 1") prototype_task("prototype task 2") end ``` """ defmacro prototype_task(name, data \\ nil) do quote do data = unquote(data) name = unquote(name) task = if data, do: %Prototype{name: name, data: data}, else: %Prototype{name: name} insert_new_task(task) end end @doc """ Used to set data properties based on evaluation of rules in a rule table. Arguments are a task name, a set of inputs and a rule table. ``` rule_table = \""" F income || status 1 > 50000 || approved 2 <= 49999 || declined \""" defprocess "single rule task process" do rule_task("loan decision", inputs: "income", rule_table: rule_table) end ``` """ defmacro rule_task(name, inputs: inputs, rule_table: rule_table) do quote do inputs = parse_params(unquote(inputs)) rule_table = Tablex.new(unquote(rule_table)) rule_task = %Rule{name: unquote(name), inputs: inputs, rule_table: rule_table} insert_new_task(rule_task) end end @doc """ Used to specify a task completed by spawning and completing a subprocess. Arguments are a task name and the name of the subprocess model. ``` defprocess "subprocess task process" do subprocess_task("subprocess task", process: "two service tasks") end ``` """ defmacro subprocess_task(name, process: subprocess_name) do quote do subprocess = %Subprocess{name: unquote(name), process: unquote(subprocess_name)} insert_new_task(subprocess) end end @doc """ A task that send a PubSub event to a receiving receive_task. Arguments are a task name and a PubSub message. ``` defprocess "send barrower income process" do send_task("send barrower income", message: {:barrower_income, 100_000}) end ``` """ defmacro send_task(name, message: message) do quote do task = %Send{name: unquote(name), message: unquote(message)} insert_new_task(task) end end @doc """ Used to specify a task completed by calling an Elixir function. Arguments are a task name, a set of comma delitmited inputs a captured Elixir function. ``` def square(data) do Map.put(data, :square, data.x * data.x) end defprocess "one service task process" do service_task("a service task", function: :square, inputs: "x") end ``` """ defmacro service_task(name, options) do quote do options = unquote(options) function = Keyword.get(options, :function) module = Keyword.get(options, :module) || __MODULE__ inputs = Keyword.get(options, :inputs) inputs = parse_params(inputs) service = %Service{name: unquote(name), function: function, module: module, inputs: inputs} insert_new_task(service) end end @doc """ Used to suspend an execution path until receiving a specified PubSub event. Arguemnts are a task name and captured function ``` def receive_loan_income(msg) do case msg do {:barrower_income, income} -> %{barrower_income: income} _ -> nil end end defprocess "receive barrower income process" do receive_task("receive barrower income", selector: :receive_loan_income) end ``` """ defmacro receive_task(name, options) do quote do options = unquote(options) selector = Keyword.get(options, :selector) module = Keyword.get(options, :module) || __MODULE__ task = %Receive{name: unquote(name), selector: selector, module: module} insert_new_task(task) end end @doc """ Used to specify a task performed by a user belonging to a specified workgroup. Arguments are a task name and a set of comma delimited workgroups. ``` defprocess "single user task process" do user_task("add one to x", group: "admin,customer_service") end ``` """ defmacro user_task(name, args) do quote do args = unquote(args) group = Keyword.get(args, :group) inputs = Keyword.get(args, :inputs) |> then(fn i -> if i, do: parse_params(i) end) outputs = Keyword.get(args, :outputs) |> then(fn o -> if o, do: parse_params(o) end) listener = Keyword.get(args, :listener) module = Keyword.get(args, :module) || __MODULE__ user_task = %User{ name: unquote(name), assigned_group: group, inputs: inputs, outputs: outputs, listener: listener, module: module } insert_new_task(user_task) if group, do: insert_group(group) end end def merge_event_tasks(event_task_map, processes) do Enum.map(processes, fn p -> tasks = Map.get(event_task_map, p.name) if tasks, do: Map.put(p, :tasks, p.tasks ++ tasks), else: p end) end def assign_events(event_map, processes) do Enum.map(processes, fn p -> events = Enum.reduce(event_map, [], fn {pname, event}, acc -> if pname == p.name, do: [event | acc], else: acc end) Map.put(p, :events, events) end) end defmacro __before_compile__(_env) do quote do if @event_task_process_map != %{} do @processes merge_event_tasks(@event_task_process_map, @processes) @processes assign_events(Map.to_list(@events), @processes) end if @bpm_application do @bpm_application Map.put(@bpm_application, :groups, @groups) end def get_processes, do: Enum.reverse(@processes) def get_process(name), do: Enum.find(@processes, fn p -> p.name == name end) def load() do ProcessService.load_process_models(get_processes()) if @bpm_application, do: ProcessService.load_bpm_application(@bpm_application) ProcessService.load_types(@types) end def get_events, do: Map.to_list(@events) end end end