Our path in creating an information system for checking counterparties Laravel having no problems

Our path in creating an information system for checking counterparties Laravel having no problems

Checking the trustworthiness of potential counterparties is an integral part of doing business. It is necessary to effectively manage risks, to observe due diligence, to exclude reputational risks and financial losses. This is done by the Department of Financial and Economic Security (FEB).

The verification process includes many steps.

We worked out functionality that allowed us to optimize the work of checking counterparties from open sources. This reduced the labor costs of the FEB department, and shortened the time of checking the counterparty from three days to one. In the future implementation, the check will take place within 10 minutes.

What stack, you might be asking? The answer was quite simple for us, because it is what we know best, and it fit perfectly into the project: Laravel+Inertia.js/VueJS/PostgreSQL.

After rolling out the infrastructure, deploying the project, setting up CI/CD, we were ready for future horizons.

The development of this system was somewhat of a challenge for our team: it was necessary to prepare a universal tool that can be easily supplemented and improved without affecting the existing implementation. We also had a very important task: we did not want to change the front-end component of our project when adding new blocks to it.

Taking into account the requirements, approximately the following format of interaction between the front and the back was agreed upon. When opening the corresponding section in the administrative panel, the frontend sends a request for the appearance of the editing form to the backend. GET /catalog/{type}/fields.

In response to such a request, the service sends a formatted structure of fields corresponding to the set of fields of the object of this directory:

{ 
{ 
	"fields": [ 
    	{ 
        	"type": "textarea", 
        	"name": "description", 
        	"default": "", 
        	"label": "Описание" 
    	}, 
    	{ 
        	"type": "select", 
        	"name": "variant", 
        	"default": "9588f61b-c0b2-4bb1-bd5f-64ac83086513", 
        	"label": "Вариант", 
        	"options": [ 
            	{"label": "Вариант 1", "value": "9588f61b-c0b2-4bb1-bd5f-64ac83086513"}, 
            	{"label": "Вариант 2", "value": "f94b4e13-373a-4719-bbe1-60dff7fa7df7"} 
        	] 
    	}, 
    	{ 
        	"type": "select", 
        	"name": "clauses", 
        	"default": "9588f61b-c0b2-4bb1-bd5f-64ac83086513", 
        	"label": "Условия", 
        	"options": [ 
            	{"label": "Условие 1", "value": "9588f61b-c0b2-4bb1-bd5f-64ac83086513"}, 
            	{"label": "Условие 2", "value": "f94b4e13-373a-4719-bbe1-60dff7fa7df7"} 
        	], 
        	"multiple": true 
    	} 
	] 
} 

Here, it should be clarified in advance that we deliberately made the frontend able to work with all possible variants of the fields that our backend may need. In particular, we can draw:

  • text box,

  • numeric field,

  • a good selection with the possibility of selecting several objects and searching,

  • checkbox, both in the format of a switch and in the format of a regular square with a checkmark,

  • radio buttons,

  • large text box,

  • Date selector.

In general, this is enough for us.

In order to use the form component universally, we used an interesting “hack” using the h() function to render the component. We proxy the drawing of the form through the component we need. In this way, we encapsulate the “switch-case” logic and the awakening of all components through a very simple wrapper.

import { h } from 'vue'; 
import Input from "./Input.vue"; 
import Checkbox from "@/Components/Form/Checkbox.vue"; 
import Select from "@/Components/Form/Select.vue"; 
  
export default { 
	name: "FormInput", 
	props: { 
    	modelValue: {default: null}, 
    	name: {type: String, required: true}, 
    	handler: {type: String, default: 'text'}, 
    	visible: {type: Boolean, default: true}, 
    	errors: {type: Array, default: []}, 
    	loading: {type: Boolean, default: false}, 
	}, 
	inheritAttrs: false, 
	emits: ['update:modelValue'], 
	render () { 
    	return h(this.getComponent(this.handler), Object.assign({}, this.$attrs, { 
        	name: this.name, 
        	errors: this.errorMessages, 
        	loading: this.loading, 
        	modelValue: this.modelValue, 
        	visible: this.visible, 
        	'onUpdate:modelValue': (value) => {this.update(value)} 
    	}), this.$slots.default); 
	}, 
	methods: { 
    	update (value) { 
        	this.$emit('update:modelValue', value); 
    	}, 
    	getComponent(name) { 
        	switch(name) { 
            	case 'checkbox': 
                	return Checkbox; 
            	case 'select': 
                	return Select; 
            	default: 
                	return Input; 
        	} 
    	} 
	}, 
	computed: { 
    	errorMessages() { 
        	let errors = this.errors[this.name]; 
        	return errors ? errors : []; 
    	} 
	} 
} 
  
// Эта обертка используется в двух вариантах, первый, когда мы фиксированно выводим список полей 
<Handler :modelValue="model.property" :name="FieldName" :errors="errors" /> 
  
// Второй, когда мы выводим поля из ответа backend. 
<Handler :name="field.name" 
    	:title="field.label" 
    	:handler="field.type" 
    	v-model="props.item[field.name]" 
    	:loading="props.loading" 
    	:errors="props.errors" 
    	:values="field?.values" 
    	:multiple="field?.multiple" 
    	/> 

Before describing the operation of the backend, we would like to talk about the important principles of its arrangement. As we mentioned, we use Laravel, but the architecture of the project involves the simultaneous use of thin models and thin controllers and commands. The controller and the command are a kind of “dirty place” in the code, which can deal with the initialization of the necessary dependencies (both through the constructor and through the DI container) and, accordingly, the awakening of values ​​in a specific service.

<?php 
  
public function handle(ValidatedRequest $request, StoreRepositoryFactory $factory) { 
	try { 
    	$type = AffilateType::from($request->input('filters.type')); 
    	$shop = ShopEntity::create([ 
        	'name' => $request->input('name'), 
        	'workingHours' => $request->input('working_hours') 
    	]); 
    	$reader = $factory->resolve($type); 
    	return response()->json($reader->handle($shop)); 
	} catch (ValueError $e) { 
    	return response()->json([ 
        	'data' => [] 
        	'status' => false, 
        	'errors' => [ 
            	'type' => 'Filter type not found' 
        	] 
    	]); 
	} 
} 
  
/** 
 * @OA\Schema() 
 */ 
class ShopEntity extends Entity 
{ 
	/** 
 	* 
 	* @OA\Property() 
 	* @var string 
 	*/ 
	public string $name; 
  
	/** 
 	* 
 	* @OA\Property() 
 	* @var string|null 
 	*/ 
	public ?string $workingHours = null; 
} 

However, in all places outside the container, we have almost no code that does not relate to business logic. In addition, we abandoned the use of associative arrays or potentially mutable models, and to transfer entities between application layers we use a self-written entity class, which fully meets our needs and greatly simplifies the description of the swagger specification, testing, as well as reading, including numbers, due to IDE prompts.

<?php 
declare(strict_types=1); 
 
namespace Package\ValueObjects; 
 
use ArrayAccess; 
use Carbon\Carbon; 
use Illuminate\Http\Request; 
use JsonException; 
use JsonSerializable; 
use LogicException; 
use ReflectionClass; 
use ReflectionProperty; 
 
/** 
 * Объект, применяемый на замену ассоциативным массивам. 
 * Дочерний класс обязательно должен содержать зафиксированный список полей, причем если значение не подразумевает обязательного заполнения - 
 * должно быть заполненно значение по умолчанию 
 * 
 * Объект создается путем new MyObject(['property' => 'value']) 
 * Специально для вызова через array_map([MyObject::class, 'create'], $rows) сделана возможность вызова без new через статический метод create 
 * 
 * Объект умеет автоматически конвертироваться в массив или в json-строку вызовом toArray и toJson соответственно, либо через json_encode($object); 
 * Можно скрыть поля, для этого они должны быть описаны в массиве $hiddenFields 
 * Чтобы автоматически преобразовывать строки к Carbon - объектам запишите их в $datetimeFields 
 */ 
abstract class Entity implements JsonSerializable, ArrayAccess 
{ 
	/** 
 	* Список всех полей, доступных в классе 
 	* 
 	* @var string[] 
 	*/ 
	protected array $propertyNames = []; 
 
	/** 
     * Список полей, доступных для экспорта через json_encode 
 	* 
 	* @var string[] 
 	*/ 
	protected array $exportableFields = []; 
 
	/** 
     * Список полей, недоступных для экспорта. Заполняется статически в детях 
 	* 
 	* @var string[] 
 	*/ 
	protected array $hiddenFields = []; 
 
	/** 
 	* Список полей, которые преобразуются из строки в Carbon-объект 
 	* Обратно-преобразуются в дату-время 
 	* 
 	* @var string[] 
 	*/ 
	protected array $datetimeFields = []; 
 
	/** 
     * Список полей, которые преобразуются из строки в Carbon-объект 
 	* Обратно преобразуются в дату 
 	* 
 	* @var string[] 
 	*/ 
	protected array $dateFields = []; 
 
	/** 
 	* Конструктор. Создает объект подкласса valueObject 
 	* 
 	* @param array<mixed> $values 
 	*/ 
	public function __construct(array $values = []) 
	{ 
    	$className = static::class; 
    	$this->setupFieldNames(); 
 
    	foreach ($values as $key => $value) { 
        	if (in_array($key, $this->datetimeFields, true) && !empty($value)) { 
            	$value = Carbon::parse($value); 
        	} 
 
        	if (in_array($key, $this->dateFields, true) && !empty($value)) { 
            	$value = Carbon::parse($value); 
        	} 
 
        	if (!property_exists($this, $key)) { 
            	throw new LogicException("Свойство {$key} не существует в объекте {$className}"); 
        	} 
 
        	$this->{$key} = $value; 
    	} 
	} 
 
	/** 
 	* Метод для вызова __construct в ситуации array_map([Object::class, 'create'], $objects); 
 	* 
 	* @param array $values 
 	* @return static 
 	*/ 
	public static function create(array $values) 
	{ 
    	return new static($values); 
	} 
 
	/** 
 	* Формирует список публичных полей дочернего класса 
 	* 
 	* @return void 
 	*/ 
	private function setupFieldNames(): void 
	{ 
    	$childClass = new ReflectionClass(static::class); 
    	$properties = $childClass->getProperties(ReflectionProperty::IS_PUBLIC); 
    	foreach ($properties as $property) { 
        	$propertyName = $property->getName(); 
        	$this->propertyNames[] = $propertyName; 
        	if (!in_array($propertyName, $this->hiddenFields, true)) { 
            	$this->exportableFields[] = $propertyName; 
        	} 
    	} 
	} 
 
	/** 
 	* Экспортируем объект для функции json_encode 
 	* 
 	* @return array 
 	*/ 
	public function jsonSerialize(): array 
	{ 
    	$result = []; 
    	foreach ($this->exportableFields as $key) { 
        	if (in_array($key, $this->datetimeFields, true)) { 
            	$value = !empty($this->{$key}) ? Carbon::parse($this->{$key})->format('d.m.Y H:i') : null; 
            	$result[$key] = $value; 
            	continue; 
        	} 
 
        	if (in_array($key, $this->dateFields, true)) { 
            	$value = !empty($this->{$key}) ? Carbon::parse($this->{$key})->format('d.m.Y') : null; 
            	$result[$key] = $value; 
            	continue; 
        	} 
 
        	$result[$key] = $this->{$key}; 
    	} 
 
    	return $result; 
	} 
 
	/** 
 	* Преобразуем объект в JSON-строку. 
 	* 
 	* @param bool $pretty 
 	* @return string 
 	* @throws JsonException 
 	*/ 
	public function toJson(bool $pretty = false): string 
	{ 
    	$options = JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR; 
    	if ($pretty) { 
        	$options |= JSON_PRETTY_PRINT; 
    	} 
 
    	return json_encode($this, $options); 
	} 
 
	/** 
 	* Декодируем в массив. 
 	* Так делать не стоит - желательно только для тестов. 
 	* 
 	* @throws JsonException 
 	*/ 
	public function toArray() 
	{ 
    	return json_decode($this->toJson(), true, 512, JSON_THROW_ON_ERROR); 
	} 
 
	/** 
 	* Проверяем есть ли свойство по ключу 
 	* 
 	* @param mixed $offset 
 	* @return bool 
 	*/ 
	public function offsetExists(mixed $offset): bool 
	{ 
    	return property_exists($this, $offset); 
	} 
 
	/** 
 	* Получаем значение свойства по ключу 
 	* 
 	* @param mixed $offset 
 	* @return mixed 
 	*/ 
	public function offsetGet(mixed $offset): mixed 
	{ 
    	return $this->{$offset} ?? null; 
	} 
 
	/** 
 	* Устанавливаем значение свойства по ключу 
 	* 
 	* @param mixed $offset 
 	* @param mixed $value 
 	* @return void 
 	*/ 
	public function offsetSet(mixed $offset, mixed $value): void 
	{ 
    	$this->{$offset} = $value; 
	} 
 
	/** 
 	* Удаляем значение свойства по ключу 
 	* 
 	* @param mixed $offset 
 	* @return void 
 	*/ 
	public function offsetUnset(mixed $offset): void 
	{ 
    	unset($this->{$offset}); 
	} 
} 

Now that you know our principles of working with the backend, let’s describe the implementation of our blocks.

When receiving a request, the controller extracts the {type} parameter from the request and, based on it, forms a service using an abstract factory, which is further responsible for working with a specific type of directories.

public static function resolve(EnumType $type): DataReader 
{ 
	return match ($type) { 
    	Type::Type1 => new Type1DataReader(), 
    	default => new UniversalDataReader(), 
    }; 
} 

When we already have the required factory, we can request all the important things that are needed to process requests. For example, we can get the rules for validating input values ​​if we want to add or edit a reference item, we can get the fields for the edit form, create or display entities, and remap the query values ​​to our internal entity to work with it further.

Thus, there is no need to make a separate controller method to work with each separate type of entity: the controller works with a high-level abstraction and only calls the appropriate methods from the interface it owns. Everything else is handled by a specific implementation.

What we got from this realization

First, we have the opportunity to work very conveniently and efficiently with any block values, including adding new blocks with the fields we need, by adding just a few classes: factory, entity, several implementations for factory methods.

Second, we’ve made it a lot easier for front-end developers because they no longer need to jump in every time we need to populate the directory list with a new implementation, which in the past required creating a new page.

Third, we got a simple and maintainable system that does not cause any problems.

The convenience of working in the system and the distribution of tasks has significantly increased due to the introduction of an intuitive interface approach and the calculation of the workload on employees. At the top level, a specialized dashboard has been created to support decision-making.

In the conditions of a strict policy of import substitution, we did not have to change practically anything. We avoided downtime in the system because we did not initially use “import boxes” in the project: everything was written on open-source technologies.

Related posts