В последнее время, мы в onest.by влюбились в SonataAdmin. А еще мы очень любим писать разные внутренние сервисы, упрощающие жизнь в организации. Например, сервис для метрологического учета оборудования 🙂
На этот раз мы напишем сервис для сбора прайслиста для интернет-магазина из прайслистов поставщиков в формате Excel на базе SonataAdmin.
Задача состоит в анализе пачки (10-20) прайслистов поставщиков интернет-магазина, выбора из них цен товаров и сбора одного общего прайслиста с самыми выгодными ценами. Идентификация товаров происходит по их уникальным артикулам.
Для каждого поставщика отдельно указываются:
Полученный в результате использования сервиса прайслист в формате csv предназначен для импорта в интернет-магазин. Причем сам интернет-магазин клиенту как таковой не принадлежит, а арендуется. То есть встроить этот инструмент в админпанель сайта не представляется возможным.
Таким образом, нам нужно разработать сервис для администрирования списка поставщиков и сборки прайслистов.
Плюсы решения на SonataAdmin:
К стандартному 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, $baseControllerName, ParameterBagInterface $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 $em, PricelistCollector $collector)
{
parent::__construct();
$this->em = $em;
$this->collector = $collector;
}
protected function configure()
{
$this
->setDescription('Собирает прайслисты')
;
}
protected function execute(InputInterface $input, OutputInterface $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;
}
}