Проверка потенциальных контрагентов на благонадёжность — неотъемлемая часть ведения бизнеса. Она нужна, чтобы эффективно управлять рисками, соблюдать должную осмотрительность, исключить репутационные риски и финансовые потери. Этим занимается отдел по финансово-экономической безопасности (ФЭБ).
Процесс проверки включает много этапов.
Мы проработали функциональность, которая позволила оптимизировать работу по проверке контрагентов из открытых источников. Это снизило трудозатраты отдела ФЭБ, сократило время проверки контрагента с трёх дней до одного. В последующей реализации проверка будет проходить в течение 10 минут.
Вы, наверно, спросите, каков стек? Ответ для нас был достаточно простым, так как его мы знаем лучше всего, и он вписывался в проект отлично: Laravel+Inertia.js/VueJS/PostgreSQL.
После раскатки инфраструктуры, размещения проекта, настройки CI/CD мы были готовы к предстоящим горизонтам.
Разработка этой системы была в определенной степени вызовом для нашей команды: нужно было подготовить универсальный инструмент, который возможно легко дополнять и улучшать, не задевая существующую реализацию. У нас также была весьма важная задача: мы не хотели менять фронтенд-составляющую нашего проекта при добавлении в него новых блоков.
С учетом требований согласовали примерно такой формат взаимодействия между фронтом и бэком. При открытии соответствующей секции в административной панели фронтенд присылает на бэкенд запрос внешнего вида формы редактирования — GET /catalog/{type}/fields
.
В ответе на такой запрос сервис присылает отформатированную структуру полей, соотносящихся с набором полей объекта этого справочника:
{
{
"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
}
]
}
Тут следует заранее уточнить, что мы намеренно сделали так, чтобы фронтенд умел работать со всеми допустимыми вариантами полей, которые в принципе могут понадобиться нашему бэкенду. В частности, мы умеем отрисовывать:
текстовое поле,
числовое поле,
красивый селект с возможностью выбора по нескольким объектам и поиском,
чекбокс, причем как в формате свитча, так и в формате обычного квадратика с галочкой,
радиокнопки,
большое текстовое поле,
селектор даты.
В целом нам этого достаточно.
Для того, чтобы универсально использовать компонент формы, мы воспользовались интересным «хаком» с использованием функции h() при отрисовке компонента. Мы проксируем отрисовку формы через компонент, который нам нужен. Таким образом инкапсулируем логику «switch-case» и проброс всех компонентов через очень простую обертку.
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"
/>
Перед описанием работы бэкенда, хотим рассказать о важных принципах его устройства. Как мы уже упомянули, мы используем Laravel, однако архитектура проекта предусматривает одновременно использование тонких моделей и тонких контроллеров и команд. Контроллер и команда у нас являются своеобразным «грязным местом» в коде, который может заниматься и инициализацией необходимых зависимостей (как через конструктор, так и через DI контейнер), и, соответственно, пробросом значений в конкретный сервис.
<?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;
}
Однако, во всех местах вне контейнера у нас практически отсутствует код, не относящийся к бизнес-логике. Кроме того, мы отказались от использования ассоциативных массивов или потенциально изменяемых моделей, и для передачи сущностей между слоями приложения используем самописный класс сущности (entity), который вполне отвечает нашим потребностям и значительно упрощает описание swagger-спецификации, тестирование, а также читаемость, в том числе, за счет подсказок IDE.
<?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});
}
}
Теперь, когда вы знаете наши принципы работы с бэкендом, опишем реализацию наших блоков.
Получая запрос, контроллер извлекает параметр {type} из запроса и на его основе с помощью абстрактной фабрики формирует сервис, который в дальнейшем отвечает за работу с конкретным видом справочников.
public static function resolve(EnumType $type): DataReader
{
return match ($type) {
Type::Type1 => new Type1DataReader(),
default => new UniversalDataReader(),
};
}
Когда у нас есть уже необходимая фабрика, мы можем запросить все важные вещи, которые необходимы для обработки запросов. Например, можем извлечь правила валидации входных значений, если хотим добавить или отредактировать элемент справочника, можем получить поля для формы редактирования, создания или отображения сущностей, а также сделать ремап значений запроса в нашу внутреннюю сущность, чтобы работать в дальнейшем с ней.
Таким образом, не нужно делать отдельный метод контроллера для работы с каждым отдельным видом сущности: контроллер работает с высокоуровневой абстракцией и только вызывает соответствующие методы из интерфейса, которым он обладает. Всем остальным занимается уже конкретная реализация.
Что мы получили от такой реализации
Во-первых, у нас появилась возможность очень удобно и эффективно работать с любыми значениями блоков, в том числе, добавлять новые блоки с нужными нам полями, добавив всего несколько классов: фабрику, сущность, несколько реализаций для методов фабрики.
Во-вторых, мы значительно упростили работу фронтенд-специалистам, так как им больше не требуется включаться каждый раз, когда нам необходимо дополнить список справочников новой реализацией, которая в прошлом требовала создания новой страницы.
В-третьих, мы получили простую и поддерживаемую систему, которая не доставляет никаких проблем.
Значительно повысилось удобство работы в системе и распределения задач за счёт внедрения подхода интуитивно понятного интерфейса и расчета нагрузки на сотрудников. На верхнем уровне создан специализированный дэшборд, обеспечивающий поддержку принятия решений.
В условиях жесткой политики импортозамещения нам не пришлось практически ничего менять. Мы избежали простоев в работе системы, так как изначально не использовали в проекте «импортных коробок»: всё написано на open-source технологиях.
ddruganov
Зачем вам spa, если вы наглухо привязали фронт к бэку? Проще было на формах лары все это шлепать, раз вы так не хотите фронтов напрягать)