src/Admin/ServiceHistoryAdmin.php line 29

Open in your IDE?
  1. <?php
  2. declare(strict_types=1);
  3. namespace App\Admin;
  4. use App\Entity\Motorcycle;
  5. use App\Entity\ServiceHistory;
  6. use App\Entity\ServicePerformer;
  7. use App\Entity\ServiceWorkType;
  8. use App\Entity\SparePart;
  9. use App\Repository\SparePartRepository;
  10. use App\Service\TransactionService;
  11. use Doctrine\ORM\EntityManagerInterface;
  12. use Doctrine\ORM\QueryBuilder;
  13. use Sonata\AdminBundle\Datagrid\DatagridMapper;
  14. use Sonata\AdminBundle\Datagrid\ListMapper;
  15. use Sonata\AdminBundle\Form\FormMapper;
  16. use Sonata\AdminBundle\Form\Type\ModelType;
  17. use Sonata\AdminBundle\Show\ShowMapper;
  18. use Symfony\Bridge\Doctrine\Form\Type\EntityType;
  19. use Symfony\Component\Form\Extension\Core\Type\IntegerType;
  20. use Symfony\Component\Form\Extension\Core\Type\NumberType;
  21. use Symfony\Component\Form\Extension\Core\Type\TextareaType;
  22. use Symfony\Component\Form\FormEvent;
  23. use Symfony\Component\Form\FormEvents;
  24. use Symfony\Component\Form\FormInterface;
  25. final class ServiceHistoryAdmin extends AbstractAdmin
  26. {
  27.     private ?EntityManagerInterface $entityManager null;
  28.     private ?TransactionService $transactionService null;
  29.     protected $datagridValues = [
  30.         '_sort_order' => 'DESC',
  31.         '_sort_by' => 'id',
  32.     ];
  33.     /**
  34.      * Super admin sees all service records.
  35.      * Other admins see only records they created.
  36.      */
  37.     public function createQuery($context 'list')
  38.     {
  39.         /** @var QueryBuilder $query */
  40.         $query parent::createQuery($context);
  41.         if (!$this->isSuperAdmin()) {
  42.             $alias $query->getRootAliases()[0];
  43.             $query
  44.                 ->andWhere(sprintf('%s.admin = :currentAdmin'$alias))
  45.                 ->setParameter('currentAdmin'$this->getCurrentUser());
  46.         }
  47.         return $query;
  48.     }
  49.     /**
  50.      * Prevent direct URL access to someone else's service record.
  51.      *
  52.      * @param string               $action
  53.      * @param ServiceHistory|null  $object
  54.      */
  55.     public function hasAccess($action$object null): bool
  56.     {
  57.         if (!parent::hasAccess($action$object)) {
  58.             return false;
  59.         }
  60.         if (
  61.             $object instanceof ServiceHistory
  62.             && !$this->isSuperAdmin()
  63.             && in_array($action, ['edit''show''delete'], true)
  64.         ) {
  65.             return $this->isOwnedByCurrentUser($object);
  66.         }
  67.         return true;
  68.     }
  69.     protected function configureDatagridFilters(DatagridMapper $datagridMapper): void
  70.     {
  71.         $datagridMapper
  72.             ->add('motorcycle'null, ['label' => 'Мотоцикл'])
  73.             ->add('workType'null, ['label' => 'Вид работ'])
  74.             ->add('performer'null, ['label' => 'Кто обслуживает'])
  75.             ->add('spareParts'null, ['label' => 'Запчасти'])
  76.             ->add('mileage'null, ['label' => 'Пробег'])
  77.             ->add('cost'null, ['label' => 'Стоимость'])
  78.             ->add('comment'null, ['label' => 'Комментарий'])
  79.         ;
  80.         if ($this->isSuperAdmin()) {
  81.             $datagridMapper->add('admin'null, ['label' => 'Автор']);
  82.         }
  83.     }
  84.     protected function configureListFields(ListMapper $listMapper): void
  85.     {
  86.         $listMapper
  87.             ->add('motorcycle'null, ['label' => 'Мотоцикл'])
  88.             ->add('workType'null, [
  89.                 'label' => 'Вид работ',
  90.                 'route' => ['name' => ''], // plain text, not a link
  91.             ])
  92.             ->add('performer'null, [
  93.                 'label' => 'Кто обслуживает',
  94.                 'route' => ['name' => ''], // plain text, not a link
  95.             ])
  96.             ->add('spareParts'null, [
  97.                 'label' => 'Запчасти',
  98.                 'route' => ['name' => ''], // plain text, not a link
  99.             ])
  100.             ->add('mileage'null, ['label' => 'Пробег, км'])
  101.             ->add('cost'null, ['label' => 'Стоимость'])
  102.             ->add('comment'null, ['label' => 'Комментарий'])
  103.         ;
  104.         if ($this->isSuperAdmin()) {
  105.             $listMapper->add('admin'null, [
  106.                 'label' => 'Автор',
  107.                 'template' => 'admin/ServiceHistory/list_admin.html.twig',
  108.             ]);
  109.         }
  110.         $listMapper
  111.             ->add('createdAt'null, ['label' => 'Дата записи'])
  112.             ->add('_action'null, [
  113.                 'actions' => [
  114.                     'show' => [],
  115.                     'edit' => [],
  116.                     'delete' => [],
  117.                 ],
  118.             ])
  119.         ;
  120.     }
  121.     protected function configureFormFields(FormMapper $formMapper): void
  122.     {
  123.         $subject $this->getSubject();
  124.         $motorcycle $subject instanceof ServiceHistory $subject->getMotorcycle() : null;
  125.         $includeIds = [];
  126.         if ($subject instanceof ServiceHistory) {
  127.             foreach ($subject->getSpareParts() as $part) {
  128.                 if ($part->getId()) {
  129.                     $includeIds[] = $part->getId();
  130.                 }
  131.             }
  132.         }
  133.         $formMapper
  134.             ->with('Обслуживание', ['class' => 'col-md-12'])
  135.             ->add('motorcycle'ModelType::class, [
  136.                 'class' => Motorcycle::class,
  137.                 'required' => true,
  138.                 'btn_add' => false,
  139.                 'label' => 'Мотоцикл',
  140.             ])
  141.             ->add('workType'ModelType::class, [
  142.                 'class' => ServiceWorkType::class,
  143.                 'required' => false,
  144.                 'btn_add' => 'добавить',
  145.                 'label' => 'Вид работ',
  146.             ])
  147.             ->add('performer'ModelType::class, [
  148.                 'class' => ServicePerformer::class,
  149.                 'required' => false,
  150.                 'btn_add' => 'добавить',
  151.                 'label' => 'Кто обслуживает',
  152.             ])
  153.             ->add('mileage'IntegerType::class, [
  154.                 'required' => true,
  155.                 'label' => 'Пробег, км',
  156.                 'help' => 'Пробег, на котором выполнялись работы (целое число, км)',
  157.                 'attr' => [
  158.                     'min' => 0,
  159.                     'max' => 999999999,
  160.                 ],
  161.             ])
  162.             ->add('cost'NumberType::class, [
  163.                 'required' => true,
  164.                 'label' => 'Стоимость работ',
  165.                 'scale' => 2,
  166.             ])
  167.             ->add('spareParts'EntityType::class, $this->buildSparePartsFieldOptions($motorcycle$includeIds))
  168.             ->add('comment'TextareaType::class, [
  169.                 'required' => false,
  170.                 'label' => 'Комментарий',
  171.                 'attr' => ['rows' => 5],
  172.             ])
  173.             ->end()
  174.         ;
  175.         // Reorder spare part choices by selected motorcycle without leaving FormMapper layout.
  176.         $admin $this;
  177.         $formMapper->getFormBuilder()->addEventListener(
  178.             FormEvents::PRE_SET_DATA,
  179.             function (FormEvent $event) use ($admin): void {
  180.                 $data $event->getData();
  181.                 $motorcycle $data instanceof ServiceHistory $data->getMotorcycle() : null;
  182.                 $includeIds = [];
  183.                 if ($data instanceof ServiceHistory) {
  184.                     foreach ($data->getSpareParts() as $part) {
  185.                         if ($part->getId()) {
  186.                             $includeIds[] = $part->getId();
  187.                         }
  188.                     }
  189.                 }
  190.                 $admin->refreshSparePartsField($event->getForm(), $motorcycle$includeIds);
  191.             }
  192.         );
  193.         $formMapper->getFormBuilder()->addEventListener(
  194.             FormEvents::PRE_SUBMIT,
  195.             function (FormEvent $event) use ($admin): void {
  196.                 $data $event->getData();
  197.                 $motorcycle null;
  198.                 $includeIds = [];
  199.                 $em $admin->getEntityManager();
  200.                 if (is_array($data)) {
  201.                     if (!empty($data['motorcycle']) && $em) {
  202.                         $motorcycle $em
  203.                             ->getRepository(Motorcycle::class)
  204.                             ->find($data['motorcycle']);
  205.                     }
  206.                     if (!empty($data['spareParts']) && is_array($data['spareParts'])) {
  207.                         $includeIds array_map('intval'$data['spareParts']);
  208.                     }
  209.                 }
  210.                 $admin->refreshSparePartsField($event->getForm(), $motorcycle$includeIds);
  211.             }
  212.         );
  213.     }
  214.     public function getEntityManager(): ?EntityManagerInterface
  215.     {
  216.         return $this->entityManager;
  217.     }
  218.     /**
  219.      * @internal used by form event listeners
  220.      */
  221.     public function refreshSparePartsField(FormInterface $form, ?Motorcycle $motorcycle, array $includeIds = []): void
  222.     {
  223.         if (!$form->has('spareParts')) {
  224.             return;
  225.         }
  226.         $form->add('spareParts'EntityType::class, $this->buildSparePartsFieldOptions($motorcycle$includeIds));
  227.     }
  228.     /**
  229.      * @return array<string, mixed>
  230.      */
  231.     private function buildSparePartsFieldOptions(?Motorcycle $motorcycle, array $includeIds = []): array
  232.     {
  233.         $motorcycleId $motorcycle $motorcycle->getId() : null;
  234.         $choices = [];
  235.         $em $this->getEntityManager();
  236.         if ($em) {
  237.             /** @var SparePartRepository $repo */
  238.             $repo $em->getRepository(SparePart::class);
  239.             $choices $repo->findOrderedForService($motorcycleId$includeIds);
  240.         }
  241.         return [
  242.             'class' => SparePart::class,
  243.             'label' => 'Запчасти',
  244.             'required' => false,
  245.             'multiple' => true,
  246.             'by_reference' => false,
  247.             'choices' => $choices,
  248.             'choice_label' => static function (SparePart $part): string {
  249.                 return $part->getLabelWithStock();
  250.             },
  251.             'help' => 'Сортировка: для выбранного мотоцикла → без мотоцикла → остальные. При выборе количество уменьшается на 1.',
  252.             'attr' => ['data-sonata-select2' => 'true'],
  253.         ];
  254.     }
  255.     protected function configureShowFields(ShowMapper $showMapper): void
  256.     {
  257.         $showMapper
  258.             ->add('id')
  259.             ->add('motorcycle'null, ['label' => 'Мотоцикл'])
  260.             ->add('workType'null, ['label' => 'Вид работ'])
  261.             ->add('performer'null, ['label' => 'Кто обслуживает'])
  262.             ->add('spareParts'null, ['label' => 'Запчасти'])
  263.             ->add('mileage'null, ['label' => 'Пробег, км'])
  264.             ->add('cost'null, ['label' => 'Стоимость работ'])
  265.             ->add('comment'null, ['label' => 'Комментарий'])
  266.         ;
  267.         if ($this->isSuperAdmin()) {
  268.             $showMapper->add('admin'null, ['label' => 'Автор']);
  269.         }
  270.         $showMapper->add('createdAt'null, ['label' => 'Дата записи']);
  271.     }
  272.     /**
  273.      * @param ServiceHistory $object
  274.      */
  275.     public function prePersist($object): void
  276.     {
  277.         if ($object->getAdmin() === null) {
  278.             $object->setAdmin($this->getCurrentUser());
  279.         }
  280.         $this->applySparePartStockDelta([], $this->sparePartIds($object));
  281.     }
  282.     /**
  283.      * @param ServiceHistory $object
  284.      */
  285.     public function preUpdate($object): void
  286.     {
  287.         if (!$this->entityManager || !$object->getId()) {
  288.             return;
  289.         }
  290.         $oldIds $this->fetchPersistedSparePartIds((int) $object->getId());
  291.         $newIds $this->sparePartIds($object);
  292.         $this->applySparePartStockDelta($oldIds$newIds);
  293.     }
  294.     /**
  295.      * @param ServiceHistory $object
  296.      */
  297.     public function preRemove($object): void
  298.     {
  299.         if (!$object->getId()) {
  300.             return;
  301.         }
  302.         $oldIds $this->fetchPersistedSparePartIds((int) $object->getId());
  303.         $this->applySparePartStockDelta($oldIds, []);
  304.     }
  305.     /**
  306.      * @param ServiceHistory $object
  307.      */
  308.     public function postPersist($object): void
  309.     {
  310.         if (!$this->transactionService || !$this->entityManager) {
  311.             return;
  312.         }
  313.         $this->transactionService->createServiceHistoryOutgoingTransaction($object);
  314.         $this->entityManager->flush();
  315.     }
  316.     /**
  317.      * @param ServiceHistory $object
  318.      */
  319.     public function postUpdate($object): void
  320.     {
  321.         if (!$this->transactionService || !$this->entityManager) {
  322.             return;
  323.         }
  324.         $this->transactionService->syncServiceHistoryOutgoingTransaction($object);
  325.         $this->entityManager->flush();
  326.     }
  327.     /**
  328.      * @param int[] $oldIds
  329.      * @param int[] $newIds
  330.      */
  331.     private function applySparePartStockDelta(array $oldIds, array $newIds): void
  332.     {
  333.         if (!$this->entityManager) {
  334.             return;
  335.         }
  336.         $oldIds array_values(array_unique(array_map('intval'$oldIds)));
  337.         $newIds array_values(array_unique(array_map('intval'$newIds)));
  338.         $toRestore array_diff($oldIds$newIds);
  339.         $toConsume array_diff($newIds$oldIds);
  340.         $repo $this->entityManager->getRepository(SparePart::class);
  341.         foreach ($toRestore as $id) {
  342.             /** @var SparePart|null $part */
  343.             $part $repo->find($id);
  344.             if ($part) {
  345.                 $part->setQuantity($part->getQuantity() + 1);
  346.             }
  347.         }
  348.         foreach ($toConsume as $id) {
  349.             /** @var SparePart|null $part */
  350.             $part $repo->find($id);
  351.             if (!$part) {
  352.                 continue;
  353.             }
  354.             if ($part->getQuantity() < 1) {
  355.                 throw new \RuntimeException(sprintf(
  356.                     'Недостаточно запчасти «%s» (остаток: %d).',
  357.                     $part->getName(),
  358.                     $part->getQuantity()
  359.                 ));
  360.             }
  361.             $part->setQuantity($part->getQuantity() - 1);
  362.         }
  363.     }
  364.     /**
  365.      * @return int[]
  366.      */
  367.     private function sparePartIds(ServiceHistory $serviceHistory): array
  368.     {
  369.         $ids = [];
  370.         foreach ($serviceHistory->getSpareParts() as $part) {
  371.             if ($part->getId()) {
  372.                 $ids[] = (int) $part->getId();
  373.             }
  374.         }
  375.         return $ids;
  376.     }
  377.     /**
  378.      * Reads already persisted associations from DB (before flush of current change set).
  379.      *
  380.      * @return int[]
  381.      */
  382.     private function fetchPersistedSparePartIds(int $serviceHistoryId): array
  383.     {
  384.         if (!$this->entityManager) {
  385.             return [];
  386.         }
  387.         $rows $this->entityManager->getConnection()->fetchAllAssociative(
  388.             'SELECT spare_part_id FROM service_history_spare_part WHERE service_history_id = ?',
  389.             [$serviceHistoryId]
  390.         );
  391.         return array_map(static function (array $row): int {
  392.             return (int) $row['spare_part_id'];
  393.         }, $rows);
  394.     }
  395.     /**
  396.      * @required
  397.      */
  398.     public function setEntityManager(EntityManagerInterface $entityManager): void
  399.     {
  400.         $this->entityManager $entityManager;
  401.     }
  402.     /**
  403.      * @required
  404.      */
  405.     public function setTransactionService(TransactionService $transactionService): void
  406.     {
  407.         $this->transactionService $transactionService;
  408.     }
  409.     private function isSuperAdmin(): bool
  410.     {
  411.         $authorizationChecker $this->getContainer()->get('security.authorization_checker');
  412.         return $authorizationChecker->isGranted('ROLE_SUPER_ADMIN');
  413.     }
  414.     private function isOwnedByCurrentUser(ServiceHistory $serviceHistory): bool
  415.     {
  416.         $author $serviceHistory->getAdmin();
  417.         if ($author === null) {
  418.             return false;
  419.         }
  420.         return $author->getId() === $this->getCurrentUser()->getId();
  421.     }
  422. }