Разработка внутреннего сервиса организации на Sonata Admin

В последнее время, мы в onest.by влюбились в SonataAdmin. А еще мы очень любим писать разные внутренние сервисы, упрощающие жизнь в организации. Например, сервис для метрологического учета оборудования 🙂

На этот раз мы напишем сервис для сбора прайслиста для интернет-магазина из прайслистов поставщиков в формате Excel на базе SonataAdmin.

Задача состоит в анализе пачки (10-20) прайслистов поставщиков интернет-магазина, выбора из них цен товаров и сбора одного общего прайслиста с самыми выгодными ценами. Идентификация товаров происходит по их уникальным артикулам.

Для каждого поставщика отдельно указываются:

  • срок доставки строкой,
  • дополнительные условия (например, возможность рассрочки) и специальная цена для этих условий,
  • валюта цен в прайслистах поставщиков.

Полученный в результате использования сервиса прайслист в формате csv предназначен для импорта в интернет-магазин. Причем сам интернет-магазин клиенту как таковой не принадлежит, а арендуется. То есть встроить этот инструмент в админпанель сайта не представляется возможным.

Таким образом, нам нужно разработать сервис для администрирования списка поставщиков и сборки прайслистов.

Плюсы решения на SonataAdmin:

  • не нужно разрабатывать и верстать дизайн админпанели;
  • не нужно с нуля писать CRUD;
  • есть система администрирования пользователей.

К стандартному CRUD от SonataAdmin нужно будет дописать необходимую бизнес-логику для сбора прайслистов. Фронтэнд при этом отсутствует в принципе.

После установки и настройки SonataAdmin у нас получилась такая панель:

На скриншотах видно, что у нас есть CRUD для ввода поставщиков со всеми их условиями.

Теперь нужно сделать самое интересное: внедрить бизнес-логику, ради которой все и затевалось.

Поскольку анализ excel файлов может занять некоторое время, через админпанель будем только создавать задания на обработку прайслистов, а саму обработку вынесем в отдельную команду, которую будем запускать периодически.

Интерес представляет обработка связей таблиц базы данных в SonataAdmin. Например, поле для ввода массива прайслистов в сущности задания выглядит так:

<?php
namespace App\Entity;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\ORM\Mapping as ORM;
/**
 * @ORM\Entity(repositoryClass="App\Repository\TaskRepository")
 * @ORM\HasLifecycleCallbacks()
 */
class Task
{
...
    /**
     * @ORM\OneToMany(targetEntity="App\Entity\TaskItem", mappedBy="task", orphanRemoval=true, cascade={"persist"})
     */
    private $items;
...
}

Связь файла задания (TaskItem) с самим заданием (Task), а также с сущностью прайслиста поставщика:

<?php
namespace App\Entity;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\HttpFoundation\File\UploadedFile;
/**
 * @ORM\Entity(repositoryClass="App\Repository\TaskItemRepository")
 */
class TaskItem
{
...
    /**
     * @ORM\ManyToOne(targetEntity="App\Entity\Pricelist", cascade={"persist"})
     * @ORM\JoinColumn(nullable=false)
     * Прайслист поставщика
     */
    private $pricelist;
    /**
     * @ORM\ManyToOne(targetEntity="App\Entity\Task", inversedBy="items")
     * @ORM\JoinColumn(nullable=false)
     * Задание
     */
    private $task;
...
}

Список прайслистов поставщика представлен в классе TaskAdmin как CollectionType:

<?php
namespace App\Admin;
use Sonata\AdminBundle\Admin\AbstractAdmin;
use Sonata\AdminBundle\Route\RouteCollection;
use Sonata\AdminBundle\Datagrid\ListMapper;
use Sonata\AdminBundle\Datagrid\DatagridMapper;
use Sonata\AdminBundle\Form\FormMapper;
use Symfony\Component\Form\Extension\Core\Type\TextType;
use Symfony\Component\Form\Extension\Core\Type\ChoiceType;
use Symfony\Component\Form\Extension\Core\Type\NumberType;
use Symfony\Component\Form\Extension\Core\Type\CheckboxType;
use Sonata\Form\Type\CollectionType;
use Symfony\Component\DependencyInjection\ParameterBag\ParameterBagInterface;
final class TaskAdmin extends AbstractAdmin
{
...
    protected function configureFormFields(FormMapper $formMapper)
    {
        $formMapper
            ->with('Файлы прайслистов поставщиков', [ 'class' => 'col-md-6' ])
                ->add('items'CollectionType::class, [
                    'label' => 'Прайслисты (xls, xlsx)',
                    'required' => false,
                    'type_options' => [
                        'delete' => true,
                        'delete_options' => [
                            'type'         => CheckboxType::class,
                            'type_options' => [
                                'mapped'   => false,
                                'required' => false,
                            ]
                        ]
                    ]
                ], [
                    'edit' => 'inline',
                    'inline' => 'table',
                    'sortable' => 'position',
                ])
            ->end()
            ->with('Курсы валют для пересчета цен', [ 'class' => 'col-md-6' ])
                ->add('rate_usd'NumberType::class, [
                    'label' => 'Курс доллара',
                ])
                ->add('rate_eur'NumberType::class, [
                    'label' => 'Курс евро',
                ])
                ->add('rate_rub'NumberType::class, [
                    'label' => 'Курс российского рубля',
                ])
            ->end()
        ;
    }
...
}

А селект для выбора конкретного поставщика в классе TaskItemAdmin представлен как ModelType:

<?php
namespace App\Admin;
use Sonata\AdminBundle\Admin\AbstractAdmin;
use Sonata\AdminBundle\Datagrid\ListMapper;
use Sonata\AdminBundle\Form\FormMapper;
use Sonata\AdminBundle\Form\Type\ModelType;
use Symfony\Component\Form\Extension\Core\Type\FileType;
use Sonata\Form\Type\CollectionType;
final class TaskItemAdmin extends AbstractAdmin
{
    protected function configureFormFields(FormMapper $formMapper)
    {
        $formMapper
            ->with('Прайслист для анализа')
                ->add('pricelist'ModelType::class, [
                    'label' => 'Поставщик',
                    'btn_add' => false,
                ])
                ->add('uploadedFile'FileType::class, [
                    'label' => 'Файл прайслиста',
                ])
            ->end()
        ;
    }
...
}

Таким образом мы видим, что связи корректно обрабатываются необходимым минимумом кода.

Однако, не обошлось без ложки дегтя. При создании новых единиц CollectionType (TaskItem), в них автоматически не проставляется связь с классом Task. Поэтому, пришлось прибегнуть к такому хаку:

<?php
...
use Symfony\Component\DependencyInjection\ParameterBag\ParameterBagInterface;
final class TaskAdmin extends AbstractAdmin
{
    /**
     * @var ParameterBagInterface
     */
    protected $params;
    public function __construct($code$class$baseControllerNameParameterBagInterface $params)
    {
        parent::__construct($code$class$baseControllerName);
        $this->params = $params;
    }
...
    public function prePersist($task)
    {
        $this->preUpdate($task);
    }
    public function preUpdate($task)
    {
        foreach ($task->getItems() as $item) {
            // Привязка к заданию
            $item->setTask($task);
            // Загрузка файла пользователя на сервер
            if ($file = $item->getUploadedFile()) {
                $originalFilename = pathinfo($file->getClientOriginalName(), PATHINFO_FILENAME);
                $safeFilename = transliterator_transliterate('Any-Latin; Latin-ASCII; [^A-Za-z0-9_] remove; Lower()'$originalFilename);
                $newFilename = $safeFilename.'-'.uniqid().'.'.$file->guessExtension();
                $file->move$this->params->get('uploads_directory'), $newFilename );
                $item->setFile($newFilename);
            }
        }
    }
}

Разбор файлов прайслистов выполнен при помощи PhpSpreadsheet и не представляет интереса в контексте этой статьи.

Сокращенный код команды для обработки прайслистов:

<?php
namespace App\Command;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
use Doctrine\ORM\EntityManagerInterface;
use App\Entity\Task;
use App\Service\PricelistCollector;
class CollectCommand extends Command
{
    protected static $defaultName = 'app:collect';
    /**
     * @var EntityManagerInterface
     */
    protected $em;
    /**
     * @var PricelistCollector
     */
    protected $collector;
    public function __construct(EntityManagerInterface $emPricelistCollector $collector)
    {
        parent::__construct();
        $this->em = $em;
        $this->collector = $collector;
    }
    protected function configure()
    {
        $this
            ->setDescription('Собирает прайслисты')
        ;
    }
    protected function execute(InputInterface $inputOutputInterface $output): int
    {
        $io = new SymfonyStyle($input$output);
        $tasks = $this->em->getRepository(Task::class)->findByReady(false);
        foreach ($tasks as $task) {
        $fname = $this->collector->collect($task);
        $task
            ->setOutput($fname)
            ->setReady(true)
        ;
        $this->em->persist($task);
        $this->em->flush();
        }
        $io->success('Все задания обработаны');
        return 0;
    }
}