Implement a customer approval process based on a generic state machine
Edit on GitHubIntroduction
To implement any business processes based on the StateMachine
module, follow these steps:
- In the database, add a table to connect
Entity
andStateMachine
. In this case, it’s theCustomer
entity. - Create CRUD operations for our new table.
- Implement the
StateMachineHandlerInterface
plugin and add it to theStateMachine
module dependencies. - Implement some command and condition plugins if needed.
- Create astate machine XML file with the customer approval flow.
- Provide a Zed UI presentation.
Schema creation
Create the corresponding approval process schema:
Customer approval process database schema
<table name="pyz_customer_approve_process_item">
<column name="id_customer_approve_process_item" required="true" type="INTEGER" autoIncrement="true" primaryKey="true"/>
<column name="fk_customer" type="INTEGER" required="true"/>
<column name="fk_state_machine_item_state" type="INTEGER" required="false"/>
<column name="fk_state_machine_process" type="INTEGER" required="false"/>
<foreign-key name="pyz_customer_approve_process_item-fk_customer" foreignTable="spy_customer">
<reference local="fk_customer" foreign="id_customer"/>
</foreign-key>
<foreign-key name="pyz_customer_approve_process_item-fk_state_machine_item_state" foreignTable="spy_state_machine_item_state">
<reference local="fk_state_machine_item_state" foreign="id_state_machine_item_state"/>
</foreign-key>
<foreign-key name="pyz_customer_approve_process_item-fk_state_machine_process" foreignTable="spy_state_machine_process">
<reference local="fk_state_machine_process" foreign="id_state_machine_process"/>
</foreign-key>
<id-method-parameter value="pyz_customer_approve_process_item_pk_seq"/>
</table>
Several foreign keys have been added, including foreign key to the spy_customer
table.
CRUD implementation
Set up a few operations for managing customer approval process:
CustomerApproveProcessFacadeInterface
<?php
namespace Pyz\Zed\CustomerApproveProcess\Business;
use Generated\Shared\Transfer\CustomerApproveProcessItemTransfer;
use Generated\Shared\Transfer\CustomerTransfer;
use Generated\Shared\Transfer\StateMachineItemTransfer;
interface CustomerApproveProcessFacadeInterface
{
/**
* Retrieves all our approve process items. Used for Zed UI presentation.
*
* @return \Generated\Shared\Transfer\CustomerApproveProcessItemTransfer[]
*/
public function getAllCustomerApproveProcessItems(): array;
/**
* Retrieves approve process items by state ids. Used in `StateMachineHandlerPlugin`.
*
* @param int[] $stateIds
*
* @return \Generated\Shared\Transfer\CustomerApproveProcessItemTransfer[]
*/
public function getCustomerApproveProcessItemsByStateIds(array $stateIds = []): array;
/**
* Creates `SpyCustomerApproveProcessItem` entity based on `CustomerTransfer` (only customer ID is used) and saves it into DB.
*
* @param \Generated\Shared\Transfer\CustomerTransfer $customerTransfer
*
* @return \Generated\Shared\Transfer\CustomerApproveProcessItemTransfer
*/
public function createCustomerApproveProcessStateMachineItem(CustomerTransfer $customerTransfer): CustomerApproveProcessItemTransfer;
/**
* Updates `SpyCustomerApproveProcessItem` entity and saves it into DB. Used when we moving item through state machine.
*
* @param \Generated\Shared\Transfer\StateMachineItemTransfer $stateMachineItemTransfer
*
* @return \Generated\Shared\Transfer\CustomerApproveProcessItemTransfer
*/
public function updateCustomerApproveProcessStateMachineItem(StateMachineItemTransfer $stateMachineItemTransfer): CustomerApproveProcessItemTransfer;
/**
* Deletes `SpyCustomerApproveProcessItem` entity from DB.
*
* @param int $customerApproveProcessItemId
*
* @return void
*/
public function deleteCustomerApproveProcessStateMachineItem(int $customerApproveProcessItemId): void;
}
This is all you need to implement in the business layer.
CustomerApproveProcessItemTransfer
has been implemented to keep all the data in one place for further usage.
transfer.xml
<transfer name="CustomerApproveProcessItem">
<property name="idCustomerApproveProcessItem" type="int"/>
<property name="idCustomer" type="int"/>
<property name="customerName" type="string"/>
<property name="customerEmail" type="string"/>
<property name="stateMachineItem" type="StateMachineItem"/>
<property name="stateHistory" type="StateMachineItem[]"/>
</transfer>
StateMachineHandlerPlugin
Configure the StateMachineHandlerPlugin
:
CustomerApproveProcessStateMachineHandlerPlugin
<?php
namespace Pyz\Zed\CustomerApproveProcess\Communication\Plugin;
use Generated\Shared\Transfer\StateMachineItemTransfer;
use Pyz\Zed\CustomerApproveProcess\Communication\Plugin\Command\CustomerApproveProcessCommandPlugin;
use Pyz\Zed\CustomerApproveProcess\Communication\Plugin\Condition\CustomerApproveProcessConditionPlugin;
use Spryker\Zed\Kernel\Communication\AbstractPlugin;
use Spryker\Zed\StateMachine\Dependency\Plugin\StateMachineHandlerInterface;
/**
* @method \Pyz\Zed\CustomerApproveProcess\Business\CustomerApproveProcessFacadeInterface getFacade()
*/
class CustomerApproveProcessStateMachineHandlerPlugin extends AbstractPlugin implements StateMachineHandlerInterface
{
/**
* List of command plugins for this state machine for all processes.
*
* @return \Spryker\Zed\StateMachine\Dependency\Plugin\CommandPluginInterface[]
*/
public function getCommandPlugins(): array
{
return [
'CustomerApproveProcess/CustomerApproveProcessCommand' => new CustomerApproveProcessCommandPlugin(),
];
}
/**
* List of condition plugins for this state machine for all processes.
*
* @return \Spryker\Zed\StateMachine\Dependency\Plugin\ConditionPluginInterface[]
*/
public function getConditionPlugins(): array
{
return [
'CustomerApproveProcess/CustomerApproveProcessCondition' => new CustomerApproveProcessConditionPlugin(),
];
}
/**
* Name of state machine used by this handler.
*
* @return string
*/
public function getStateMachineName(): string
{
return 'CustomerApproveProcess';
}
/**
* List of active processes used for this state machine
*
* @return string[]
*/
public function getActiveProcesses(): array
{
return [
'Process01',
];
}
/**
* Provide initial state name for item when state machine initialized. Using process name.
*
* @param string $processName
*
* @throws \InvalidArgumentException
*
* @return string
*/
public function getInitialStateForProcess($processName): string
{
return 'new';
}
/**
* This method is called when state of item was changed, client can create custom logic for example update it's related table with new state id/name.
* StateMachineItemTransfer:identifier is ID of entity from implementor.
*
* @param \Generated\Shared\Transfer\StateMachineItemTransfer $stateMachineItemTransfer
*
* @return bool
*/
public function itemStateUpdated(StateMachineItemTransfer $stateMachineItemTransfer): bool
{
$customerApproveProcessItemTransfer = $this->getFacade()
->updateCustomerApproveProcessStateMachineItem($stateMachineItemTransfer);
if ($customerApproveProcessItemTransfer->getIdCustomerApproveProcessItem()) {
return true;
}
return false;
}
/**
* This method returns all list of StateMachineItemTransfer, with (identifier, IdStateMachineProcess, IdItemState)
*
* @param array $stateIds
*
* @return \Generated\Shared\Transfer\StateMachineItemTransfer[]
*/
public function getStateMachineItemsByStateIds(array $stateIds = []): array
{
$customerApproveProcessItems = $this->getFacade()->getCustomerApproveProcessItemsByStateIds($stateIds);
$stateMachineItems = [];
foreach ($customerApproveProcessItems as $customerApproveProcessItem) {
$stateMachineItems[] = $customerApproveProcessItem->getStateMachineItem();
}
return $stateMachineItems;
}
}
Commands and conditions
Configure the plugins:
CustomerApproveProcessCommandPlugin
<?php
namespace Pyz\Zed\CustomerApproveProcess\Communication\Plugin\Command;
use Generated\Shared\Transfer\StateMachineItemTransfer;
use Spryker\Zed\Kernel\Communication\AbstractPlugin;
use Spryker\Zed\StateMachine\Dependency\Plugin\CommandPluginInterface;
/**
* @method \Pyz\Zed\ExampleStateMachine\Business\ExampleStateMachineFacade getFacade()
* @method \Pyz\Zed\ExampleStateMachine\Communication\ExampleStateMachineCommunicationFactory getFactory()
* @method \Pyz\Zed\ExampleStateMachine\Persistence\ExampleStateMachineQueryContainerInterface getQueryContainer()
*/
class CustomerApproveProcessCommandPlugin extends AbstractPlugin implements CommandPluginInterface
{
/**
* @param \Generated\Shared\Transfer\StateMachineItemTransfer $stateMachineItemTransfer
*
* @return void
*/
public function run(StateMachineItemTransfer $stateMachineItemTransfer): void
{
//Do your business logic here through Facade.
}
}
CustomerApproveProcessConditionPlugin
<?php
namespace Pyz\Zed\CustomerApproveProcess\Communication\Plugin\Condition;
use Generated\Shared\Transfer\StateMachineItemTransfer;
use Spryker\Zed\Kernel\Communication\AbstractPlugin;
use Spryker\Zed\StateMachine\Dependency\Plugin\ConditionPluginInterface;
/**
* @method \Pyz\Zed\ExampleStateMachine\Business\ExampleStateMachineFacade getFacade()
* @method \Pyz\Zed\ExampleStateMachine\Communication\ExampleStateMachineCommunicationFactory getFactory()
* @method \Pyz\Zed\ExampleStateMachine\Persistence\ExampleStateMachineQueryContainerInterface getQueryContainer()
*/
class CustomerApproveProcessConditionPlugin extends AbstractPlugin implements ConditionPluginInterface
{
/**
* @param \Generated\Shared\Transfer\StateMachineItemTransfer $stateMachineItemTransfer
*
* @return bool
*/
public function check(StateMachineItemTransfer $stateMachineItemTransfer): bool
{
//Check your condition here.
return true;
}
}
Customer approve process flow XML file
There is a simple example of the state machine process, you need to put it into config/Zed/StateMachine/CustomerApproveProcess/Process01.xml
.
Process01.xml
<?xml version="1.0"?>
<statemachine
xmlns="spryker:state-machine-01"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="spryker:state-machine-01 http://static.spryker.com/state-machine-01.xsd">
<process name="Process01" main="true">
<states>
<state name="new" />
<state name="waiting for approve" />
<state name="approved" />
<state name="declined" />
<state name="closed" />
</states>
<events>
<event name="ask for approve" onEnter="true" command="CustomerApproveProcess/CustomerApproveProcessCommand" />
<event name="approve" manual="true" command="CustomerApproveProcess/CustomerApproveProcessCommand" />
<event name="decline" manual="true" command="CustomerApproveProcess/CustomerApproveProcessCommand" />
<event name="close" manual="true" command="CustomerApproveProcess/CustomerApproveProcessCommand" />
</events>
<transitions>
<transition happy="true">
<source>new</source>
<target>waiting for approve</target>
<event>ask for approve</event>
</transition>
<transition happy="true" condition="CustomerApproveProcess/CustomerApproveProcessCondition">
<source>waiting for approve</source>
<target>approved</target>
<event>approve</event>
</transition>
<transition>
<source>waiting for approve</source>
<target>declined</target>
<event>decline</event>
</transition>
<transition happy="true">
<source>approved</source>
<target>closed</target>
<event>close</event>
</transition>
<transition>
<source>declined</source>
<target>closed</target>
<event>close</event>
</transition>
</transitions>
</process>
</statemachine>
Zed UI presentation
For representing our process items in Zed UI we need only two things: controller and template. Controller includes the list of all items, add new item, delete item actions:
StateMachineItemsController
<?php
namespace Pyz\Zed\CustomerApproveProcess\Communication\Controller;
use Generated\Shared\Transfer\CustomerTransfer;
use Spryker\Zed\Kernel\Communication\Controller\AbstractController;
use Spryker\Zed\StateMachine\Business\StateMachineFacadeInterface;
use Symfony\Component\HttpFoundation\RedirectResponse;
use Symfony\Component\HttpFoundation\Request;
/**
* @method \Pyz\Zed\CustomerApproveProcess\Communication\CustomerApproveProcessCommunicationFactory getFactory()
* @method \Pyz\Zed\CustomerApproveProcess\Business\CustomerApproveProcessFacadeInterface getFacade()
*/
class StateMachineItemsController extends AbstractController
{
/**
* @return array
*/
public function listAction(): array
{
$customerApproveProcessStateItems = $this->getFacade()
->getAllCustomerApproveProcessItems();
$processedStateMachineItems = $this->getStateMachineFacade()
->getProcessedStateMachineItems(
$this->getStateMachineItems($customerApproveProcessStateItems)
);
$manualEvents = $this->getStateMachineFacade()
->getManualEventsForStateMachineItems($processedStateMachineItems);
return [
'customerApproveProcessItems' => $customerApproveProcessStateItems,
'manualEvents' => $manualEvents,
'stateMachineItems' => $this->createCustomerApproveProcessItemsLookupTable($processedStateMachineItems),
];
}
/**
* @return \Symfony\Component\HttpFoundation\RedirectResponse
*/
public function addItemAction(): RedirectResponse
{
$customerTransfer = (new CustomerTransfer())
->setIdCustomer(mt_rand(1, 26));
$this->getFacade()->createCustomerApproveProcessStateMachineItem($customerTransfer);
return $this->redirectResponse('/customer-approve-process/state-machine-items/list');
}
/**
* @param \Symfony\Component\HttpFoundation\Request $request
*
* @return \Symfony\Component\HttpFoundation\RedirectResponse
*/
public function deleteItemAction(Request $request): RedirectResponse
{
$idCustomerApproveProcessItem = $this->castId($request->query->get('id'));
$this->getFacade()->deleteCustomerApproveProcessStateMachineItem($idCustomerApproveProcessItem);
return $this->redirectResponse('/customer-approve-process/state-machine-items/list');
}
/**
* @param \Generated\Shared\Transfer\CustomerApproveProcessItemTransfer[] $customerApproveProcessStateItems
*
* @return \Generated\Shared\Transfer\StateMachineItemTransfer[]
*/
protected function getStateMachineItems(array $customerApproveProcessStateItems): array
{
$stateMachineItems = [];
foreach ($customerApproveProcessStateItems as $customerApproveProcessStateItem) {
$stateMachineItems[] = $customerApproveProcessStateItem->getStateMachineItem();
}
return $stateMachineItems;
}
/**
* @param \Generated\Shared\Transfer\StateMachineItemTransfer[] $stateMachineItems
*
* @return \Generated\Shared\Transfer\StateMachineItemTransfer[]
*/
protected function createCustomerApproveProcessItemsLookupTable(array $stateMachineItems): array
{
$lookupIndex = [];
foreach ($stateMachineItems as $stateMachineItemTransfer) {
$lookupIndex[$stateMachineItemTransfer->getIdentifier()] = $stateMachineItemTransfer;
}
return $lookupIndex;
}
/**
* @return \Spryker\Zed\StateMachine\Business\StateMachineFacadeInterface
*/
protected function getStateMachineFacade(): StateMachineFacadeInterface
{
return $this->getFactory()->getStateMachineFacade();
}
}
You need the template only for the list action; the following is an example:
StateMachineItemsControllertransfer.xml
{% extends '@Cms/Layout/layout.twig' %}
{% set widget_title = 'Customer Approve Process State Machine' %}
{% block head_title widget_title %}
{% block section_title widget_title %}
{% block content %}
<a href="/customer-approve-process/state-machine-items/add-item">Add item</a> <br />
<table class="table table-striped table-bordered table-hover gui-table-data dataTable">
<tr>
<th>Id</th>
<th>Customer Name</th>
<th>Customer Email</th>
<th>State Machine Name</th>
<th>Process Name</th>
<th>State</th>
<th>Trigger</th>
<td>Action</td>
</tr>
{% for customerApproveProcessItem in customerApproveProcessItems %}
<tr>
<td>{{ customerApproveProcessItem.idCustomerApproveProcessItem }}</td>
<td>
<a href="{{ url('/customer/view', { 'id-customer' : customerApproveProcessItem.idCustomer }) }}">
{{ customerApproveProcessItem.customerName }}
</a>
</td>
<td>{{ customerApproveProcessItem.customerEmail }}</td>
<td>{{ customerApproveProcessItem.stateMachineItem.stateMachineName }}</td>
<td>{{ customerApproveProcessItem.stateMachineItem.processName }}</td>
<td>
{% if stateMachineItems | length > 0 and stateMachineItems[customerApproveProcessItem.idCustomerApproveProcessItem] is defined %}
<a href="{{ url(
'/state-machine/graph/drawItem',
{
process: stateMachineItems[customerApproveProcessItem.idCustomerApproveProcessItem].getProcessName,
'state-machine': stateMachineItems[customerApproveProcessItem.idCustomerApproveProcessItem].getStateMachineName,
'highlight-state' : stateMachineItems[customerApproveProcessItem.idCustomerApproveProcessItem].getStateName
}) }}"
>
{{ stateMachineItems[customerApproveProcessItem.idCustomerApproveProcessItem].getStateName }}
</a>
{% endif %}
{% if customerApproveProcessItem.stateHistory | length > 1 %}
<div id="history_details_{{ customerApproveProcessItem.idCustomerApproveProcessItem }}">
{% for stateHistory in customerApproveProcessItem.stateHistory | slice(1) %}
<div>{{ stateHistory.stateName }} ({{ stateHistory.createdAt | formatDateTime }})</div>
{% endfor %}
</div>
{% endif %}
</td>
<td>
{% if manualEvents | length > 0 and manualEvents[customerApproveProcessItem.idCustomerApproveProcessItem] is defined %}
{% for event in manualEvents[customerApproveProcessItem.idCustomerApproveProcessItem] %}
<a class="btn btn-primary btn-sm trigger-order-single-event"
href="{{ url('/state-machine/trigger/trigger-event',
{
'event' : event,
'identifier': customerApproveProcessItem.idCustomerApproveProcessItem,
'id-state' : customerApproveProcessItem.stateMachineItem.idItemState,
redirect : '/customer-approve-process/state-machine-items/list'
}) }}"
>
{{ event }}
</a>
{% endfor %}
{% else -%}
No manual events
{%- endif %}
</td>
<td>
<a href="{{ url('/customer-approve-process/state-machine-items/delete-item', { ID : customerApproveProcessItem.idCustomerApproveProcessItem }) }}">
Delete
</a>
</td>
</tr>
{% endfor %}
</table>
{% endblock %}
Thank you!
For submitting the form