<?php
declare(strict_types=1);
namespace App\Admin;
use App\Entity\Motorcycle;
use App\Entity\ServiceHistory;
use App\Entity\ServicePerformer;
use App\Entity\ServiceWorkType;
use App\Entity\SparePart;
use App\Repository\SparePartRepository;
use App\Service\TransactionService;
use Doctrine\ORM\EntityManagerInterface;
use Doctrine\ORM\QueryBuilder;
use Sonata\AdminBundle\Datagrid\DatagridMapper;
use Sonata\AdminBundle\Datagrid\ListMapper;
use Sonata\AdminBundle\Form\FormMapper;
use Sonata\AdminBundle\Form\Type\ModelType;
use Sonata\AdminBundle\Show\ShowMapper;
use Symfony\Bridge\Doctrine\Form\Type\EntityType;
use Symfony\Component\Form\Extension\Core\Type\IntegerType;
use Symfony\Component\Form\Extension\Core\Type\NumberType;
use Symfony\Component\Form\Extension\Core\Type\TextareaType;
use Symfony\Component\Form\FormEvent;
use Symfony\Component\Form\FormEvents;
use Symfony\Component\Form\FormInterface;
final class ServiceHistoryAdmin extends AbstractAdmin
{
private ?EntityManagerInterface $entityManager = null;
private ?TransactionService $transactionService = null;
protected $datagridValues = [
'_sort_order' => 'DESC',
'_sort_by' => 'id',
];
/**
* Super admin sees all service records.
* Other admins see only records they created.
*/
public function createQuery($context = 'list')
{
/** @var QueryBuilder $query */
$query = parent::createQuery($context);
if (!$this->isSuperAdmin()) {
$alias = $query->getRootAliases()[0];
$query
->andWhere(sprintf('%s.admin = :currentAdmin', $alias))
->setParameter('currentAdmin', $this->getCurrentUser());
}
return $query;
}
/**
* Prevent direct URL access to someone else's service record.
*
* @param string $action
* @param ServiceHistory|null $object
*/
public function hasAccess($action, $object = null): bool
{
if (!parent::hasAccess($action, $object)) {
return false;
}
if (
$object instanceof ServiceHistory
&& !$this->isSuperAdmin()
&& in_array($action, ['edit', 'show', 'delete'], true)
) {
return $this->isOwnedByCurrentUser($object);
}
return true;
}
protected function configureDatagridFilters(DatagridMapper $datagridMapper): void
{
$datagridMapper
->add('motorcycle', null, ['label' => 'Мотоцикл'])
->add('workType', null, ['label' => 'Вид работ'])
->add('performer', null, ['label' => 'Кто обслуживает'])
->add('spareParts', null, ['label' => 'Запчасти'])
->add('mileage', null, ['label' => 'Пробег'])
->add('cost', null, ['label' => 'Стоимость'])
->add('comment', null, ['label' => 'Комментарий'])
;
if ($this->isSuperAdmin()) {
$datagridMapper->add('admin', null, ['label' => 'Автор']);
}
}
protected function configureListFields(ListMapper $listMapper): void
{
$listMapper
->add('motorcycle', null, ['label' => 'Мотоцикл'])
->add('workType', null, [
'label' => 'Вид работ',
'route' => ['name' => ''], // plain text, not a link
])
->add('performer', null, [
'label' => 'Кто обслуживает',
'route' => ['name' => ''], // plain text, not a link
])
->add('spareParts', null, [
'label' => 'Запчасти',
'route' => ['name' => ''], // plain text, not a link
])
->add('mileage', null, ['label' => 'Пробег, км'])
->add('cost', null, ['label' => 'Стоимость'])
->add('comment', null, ['label' => 'Комментарий'])
;
if ($this->isSuperAdmin()) {
$listMapper->add('admin', null, [
'label' => 'Автор',
'template' => 'admin/ServiceHistory/list_admin.html.twig',
]);
}
$listMapper
->add('createdAt', null, ['label' => 'Дата записи'])
->add('_action', null, [
'actions' => [
'show' => [],
'edit' => [],
'delete' => [],
],
])
;
}
protected function configureFormFields(FormMapper $formMapper): void
{
$subject = $this->getSubject();
$motorcycle = $subject instanceof ServiceHistory ? $subject->getMotorcycle() : null;
$includeIds = [];
if ($subject instanceof ServiceHistory) {
foreach ($subject->getSpareParts() as $part) {
if ($part->getId()) {
$includeIds[] = $part->getId();
}
}
}
$formMapper
->with('Обслуживание', ['class' => 'col-md-12'])
->add('motorcycle', ModelType::class, [
'class' => Motorcycle::class,
'required' => true,
'btn_add' => false,
'label' => 'Мотоцикл',
])
->add('workType', ModelType::class, [
'class' => ServiceWorkType::class,
'required' => false,
'btn_add' => 'добавить',
'label' => 'Вид работ',
])
->add('performer', ModelType::class, [
'class' => ServicePerformer::class,
'required' => false,
'btn_add' => 'добавить',
'label' => 'Кто обслуживает',
])
->add('mileage', IntegerType::class, [
'required' => true,
'label' => 'Пробег, км',
'help' => 'Пробег, на котором выполнялись работы (целое число, км)',
'attr' => [
'min' => 0,
'max' => 999999999,
],
])
->add('cost', NumberType::class, [
'required' => true,
'label' => 'Стоимость работ',
'scale' => 2,
])
->add('spareParts', EntityType::class, $this->buildSparePartsFieldOptions($motorcycle, $includeIds))
->add('comment', TextareaType::class, [
'required' => false,
'label' => 'Комментарий',
'attr' => ['rows' => 5],
])
->end()
;
// Reorder spare part choices by selected motorcycle without leaving FormMapper layout.
$admin = $this;
$formMapper->getFormBuilder()->addEventListener(
FormEvents::PRE_SET_DATA,
function (FormEvent $event) use ($admin): void {
$data = $event->getData();
$motorcycle = $data instanceof ServiceHistory ? $data->getMotorcycle() : null;
$includeIds = [];
if ($data instanceof ServiceHistory) {
foreach ($data->getSpareParts() as $part) {
if ($part->getId()) {
$includeIds[] = $part->getId();
}
}
}
$admin->refreshSparePartsField($event->getForm(), $motorcycle, $includeIds);
}
);
$formMapper->getFormBuilder()->addEventListener(
FormEvents::PRE_SUBMIT,
function (FormEvent $event) use ($admin): void {
$data = $event->getData();
$motorcycle = null;
$includeIds = [];
$em = $admin->getEntityManager();
if (is_array($data)) {
if (!empty($data['motorcycle']) && $em) {
$motorcycle = $em
->getRepository(Motorcycle::class)
->find($data['motorcycle']);
}
if (!empty($data['spareParts']) && is_array($data['spareParts'])) {
$includeIds = array_map('intval', $data['spareParts']);
}
}
$admin->refreshSparePartsField($event->getForm(), $motorcycle, $includeIds);
}
);
}
public function getEntityManager(): ?EntityManagerInterface
{
return $this->entityManager;
}
/**
* @internal used by form event listeners
*/
public function refreshSparePartsField(FormInterface $form, ?Motorcycle $motorcycle, array $includeIds = []): void
{
if (!$form->has('spareParts')) {
return;
}
$form->add('spareParts', EntityType::class, $this->buildSparePartsFieldOptions($motorcycle, $includeIds));
}
/**
* @return array<string, mixed>
*/
private function buildSparePartsFieldOptions(?Motorcycle $motorcycle, array $includeIds = []): array
{
$motorcycleId = $motorcycle ? $motorcycle->getId() : null;
$choices = [];
$em = $this->getEntityManager();
if ($em) {
/** @var SparePartRepository $repo */
$repo = $em->getRepository(SparePart::class);
$choices = $repo->findOrderedForService($motorcycleId, $includeIds);
}
return [
'class' => SparePart::class,
'label' => 'Запчасти',
'required' => false,
'multiple' => true,
'by_reference' => false,
'choices' => $choices,
'choice_label' => static function (SparePart $part): string {
return $part->getLabelWithStock();
},
'help' => 'Сортировка: для выбранного мотоцикла → без мотоцикла → остальные. При выборе количество уменьшается на 1.',
'attr' => ['data-sonata-select2' => 'true'],
];
}
protected function configureShowFields(ShowMapper $showMapper): void
{
$showMapper
->add('id')
->add('motorcycle', null, ['label' => 'Мотоцикл'])
->add('workType', null, ['label' => 'Вид работ'])
->add('performer', null, ['label' => 'Кто обслуживает'])
->add('spareParts', null, ['label' => 'Запчасти'])
->add('mileage', null, ['label' => 'Пробег, км'])
->add('cost', null, ['label' => 'Стоимость работ'])
->add('comment', null, ['label' => 'Комментарий'])
;
if ($this->isSuperAdmin()) {
$showMapper->add('admin', null, ['label' => 'Автор']);
}
$showMapper->add('createdAt', null, ['label' => 'Дата записи']);
}
/**
* @param ServiceHistory $object
*/
public function prePersist($object): void
{
if ($object->getAdmin() === null) {
$object->setAdmin($this->getCurrentUser());
}
$this->applySparePartStockDelta([], $this->sparePartIds($object));
}
/**
* @param ServiceHistory $object
*/
public function preUpdate($object): void
{
if (!$this->entityManager || !$object->getId()) {
return;
}
$oldIds = $this->fetchPersistedSparePartIds((int) $object->getId());
$newIds = $this->sparePartIds($object);
$this->applySparePartStockDelta($oldIds, $newIds);
}
/**
* @param ServiceHistory $object
*/
public function preRemove($object): void
{
if (!$object->getId()) {
return;
}
$oldIds = $this->fetchPersistedSparePartIds((int) $object->getId());
$this->applySparePartStockDelta($oldIds, []);
}
/**
* @param ServiceHistory $object
*/
public function postPersist($object): void
{
if (!$this->transactionService || !$this->entityManager) {
return;
}
$this->transactionService->createServiceHistoryOutgoingTransaction($object);
$this->entityManager->flush();
}
/**
* @param ServiceHistory $object
*/
public function postUpdate($object): void
{
if (!$this->transactionService || !$this->entityManager) {
return;
}
$this->transactionService->syncServiceHistoryOutgoingTransaction($object);
$this->entityManager->flush();
}
/**
* @param int[] $oldIds
* @param int[] $newIds
*/
private function applySparePartStockDelta(array $oldIds, array $newIds): void
{
if (!$this->entityManager) {
return;
}
$oldIds = array_values(array_unique(array_map('intval', $oldIds)));
$newIds = array_values(array_unique(array_map('intval', $newIds)));
$toRestore = array_diff($oldIds, $newIds);
$toConsume = array_diff($newIds, $oldIds);
$repo = $this->entityManager->getRepository(SparePart::class);
foreach ($toRestore as $id) {
/** @var SparePart|null $part */
$part = $repo->find($id);
if ($part) {
$part->setQuantity($part->getQuantity() + 1);
}
}
foreach ($toConsume as $id) {
/** @var SparePart|null $part */
$part = $repo->find($id);
if (!$part) {
continue;
}
if ($part->getQuantity() < 1) {
throw new \RuntimeException(sprintf(
'Недостаточно запчасти «%s» (остаток: %d).',
$part->getName(),
$part->getQuantity()
));
}
$part->setQuantity($part->getQuantity() - 1);
}
}
/**
* @return int[]
*/
private function sparePartIds(ServiceHistory $serviceHistory): array
{
$ids = [];
foreach ($serviceHistory->getSpareParts() as $part) {
if ($part->getId()) {
$ids[] = (int) $part->getId();
}
}
return $ids;
}
/**
* Reads already persisted associations from DB (before flush of current change set).
*
* @return int[]
*/
private function fetchPersistedSparePartIds(int $serviceHistoryId): array
{
if (!$this->entityManager) {
return [];
}
$rows = $this->entityManager->getConnection()->fetchAllAssociative(
'SELECT spare_part_id FROM service_history_spare_part WHERE service_history_id = ?',
[$serviceHistoryId]
);
return array_map(static function (array $row): int {
return (int) $row['spare_part_id'];
}, $rows);
}
/**
* @required
*/
public function setEntityManager(EntityManagerInterface $entityManager): void
{
$this->entityManager = $entityManager;
}
/**
* @required
*/
public function setTransactionService(TransactionService $transactionService): void
{
$this->transactionService = $transactionService;
}
private function isSuperAdmin(): bool
{
$authorizationChecker = $this->getContainer()->get('security.authorization_checker');
return $authorizationChecker->isGranted('ROLE_SUPER_ADMIN');
}
private function isOwnedByCurrentUser(ServiceHistory $serviceHistory): bool
{
$author = $serviceHistory->getAdmin();
if ($author === null) {
return false;
}
return $author->getId() === $this->getCurrentUser()->getId();
}
}