*/ final class ArticleCrudController extends AbstractCrudController { public function __construct( private readonly ArticleService $articleService, private readonly EntityManagerInterface $em, private readonly AIPipelineJobRepositoryInterface $jobRepository, private readonly MessageBusInterface $bus, private readonly TranslatorInterface $translator, ) { } public static function getEntityFqcn(): string { return Article::class; } public function configureCrud(Crud $crud): Crud { return $crud ->setEntityLabelInSingular(new TranslatableMessage('crud.article.singular', [], 'admin')) ->setEntityLabelInPlural(new TranslatableMessage('crud.article.plural', [], 'admin')) ->showEntityActionsInlined(); } public function configureActions(Actions $actions): Actions { $activate = Action::new('activate', new TranslatableMessage('action.activate', [], 'admin'), 'fa fa-check') ->linkToRoute('admin_article_activate', static fn (Article $a) => ['id' => $a->getId()->toRfc4122()]) ->setCssClass('btn btn-sm btn-success') ->displayIf(static fn (Article $a) => ArticleStatus::Draft === $a->getStatus()); $markDraft = Action::new('markDraft', new TranslatableMessage('action.mark_as_draft', [], 'admin'), 'fa fa-pen-to-square') ->linkToCrudAction('markAsDraft') ->setCssClass('btn btn-sm btn-secondary') ->displayIf(static fn (Article $a) => ArticleStatus::Ingesting === $a->getStatus()); $rerunAi = Action::new('rerunAi', new TranslatableMessage('action.rerun_ai', [], 'admin'), 'fa fa-rotate') ->linkToCrudAction('rerunAiPipeline') ->setCssClass('btn btn-sm btn-info') ->askConfirmation(new TranslatableMessage('action.rerun_ai_confirm', [], 'admin')) ->displayIf(fn (Article $a) => \in_array( $a->getStatus(), [ArticleStatus::Draft, ArticleStatus::Ingesting, ArticleStatus::NeedsReview], true, ) && !$this->jobRepository->hasActiveJobForArticle($a->getId())); return $actions ->add(Crud::PAGE_INDEX, $activate) ->add(Crud::PAGE_INDEX, $markDraft) ->add(Crud::PAGE_INDEX, $rerunAi) ->add(Crud::PAGE_DETAIL, $activate) ->add(Crud::PAGE_DETAIL, $markDraft) ->add(Crud::PAGE_DETAIL, $rerunAi) ->disable(Action::NEW, Action::DELETE); } public function configureFields(string $pageName): iterable { // Detail-only: photos first yield Field::new('photos', new TranslatableMessage('field.photos', [], 'admin')) ->setTemplatePath('admin/field/photos.html.twig') ->onlyOnDetail(); yield TextField::new('inventoryNumber', new TranslatableMessage('field.inventory_number', [], 'admin'))->hideOnForm(); yield AssociationField::new('articleType')->hideOnForm(); yield TextField::new('ebayTitle', new TranslatableMessage('field.description', [], 'admin'))->setRequired(false); yield ChoiceField::new('status', new TranslatableMessage('field.status', [], 'admin'))->setChoices( array_combine( array_map(static fn ($s) => $s->value, ArticleStatus::cases()), ArticleStatus::cases(), ) )->hideOnForm(); yield MoneyField::new('listingPrice', new TranslatableMessage('field.price', [], 'admin'))->setCurrency('EUR')->setRequired(false); yield ChoiceField::new('condition', new TranslatableMessage('field.condition', [], 'admin'))->setChoices( array_combine( array_map(static fn ($c) => $c->value, ArticleCondition::cases()), ArticleCondition::cases(), ) )->hideOnIndex(); yield TextField::new('manufacturer', new TranslatableMessage('field.manufacturer', [], 'admin'))->setRequired(false)->hideOnIndex(); yield TextField::new('modelNumber', new TranslatableMessage('field.model_number', [], 'admin'))->setRequired(false)->hideOnIndex(); yield TextField::new('modelName', new TranslatableMessage('field.model_name', [], 'admin'))->setRequired(false)->hideOnIndex(); yield TextField::new('sku')->hideOnForm()->hideOnIndex(); yield TextField::new('serialNumber', new TranslatableMessage('field.serial_number', [], 'admin'))->setRequired(false)->hideOnIndex(); yield TextField::new('conditionNotes', new TranslatableMessage('field.condition_notes', [], 'admin'))->setRequired(false)->hideOnIndex(); yield Field::new('attributeValues', new TranslatableMessage('field.attributes', [], 'admin')) ->onlyOnDetail() ->setTemplatePath('admin/field/attribute_values.html.twig'); yield CollectionField::new('attributeValues', new TranslatableMessage('field.attributes', [], 'admin')) ->onlyOnForms() ->setEntryType(AttributeValueFormType::class) ->setEntryIsComplex(false) ->allowAdd(false) ->allowDelete(false); if (Crud::PAGE_DETAIL === $pageName) { yield Field::new('ebayDescription', new TranslatableMessage('field.ebay_description', [], 'admin')) ->setTemplatePath('admin/field/ebay_description.html.twig') ->onlyOnDetail(); } else { yield TextareaField::new('ebayDescription', new TranslatableMessage('field.ebay_description', [], 'admin')) ->setRequired(false) ->hideOnIndex() ->setNumOfRows(20); } } #[AdminRoute('/rerun-ai', name: 'rerunAi')] public function rerunAiPipeline(AdminContext $context): Response { /** @var Article $article */ $article = $context->getEntity()->getInstance(); $originalJob = $this->jobRepository->findByArticleId($article->getId()); $referrer = $context->getRequest()->headers->get('referer', $this->generateUrl('easyadmin')); if (null === $originalJob) { $this->addFlash('danger', $this->translator->trans('flash.pipeline_job_not_found', [], 'admin')); return $this->redirect($referrer); } $storedPhotoPath = (string) ($originalJob->getInputData()['storedPhotoPath'] ?? ''); if ('' === $storedPhotoPath || !file_exists($storedPhotoPath)) { $this->addFlash('danger', $this->translator->trans('flash.photo_not_found', ['%path%' => $storedPhotoPath], 'admin')); return $this->redirect($referrer); } $newJob = new AIPipelineJob(AIPipelineJobType::Photo, [ 'inventoryNumber' => $article->getInventoryNumber(), 'articleTypeId' => $article->getArticleType()->getId()->toRfc4122(), 'condition' => $article->getCondition()->value, 'conditionNotes' => $article->getConditionNotes(), 'originalFilename' => (string) ($originalJob->getInputData()['originalFilename'] ?? ''), 'storedPhotoPath' => $storedPhotoPath, ]); $newJob->setArticleId($article->getId()); $this->jobRepository->save($newJob); $this->bus->dispatch(new PhotoUploadMessage( jobId: $newJob->getId()->toRfc4122(), articleTypeId: $article->getArticleType()->getId()->toRfc4122(), storedPhotoPath: $storedPhotoPath, originalFilename: (string) ($originalJob->getInputData()['originalFilename'] ?? ''), )); $this->addFlash('success', $this->translator->trans( 'flash.pipeline_requeued', ['%label%' => $article->getInventoryNumber()], 'admin', )); return $this->redirect($referrer); } #[AdminRoute('/mark-as-draft', name: 'markAsDraft')] public function markAsDraft(AdminContext $context): Response { /** @var Article $article */ $article = $context->getEntity()->getInstance(); $referrer = $context->getRequest()->headers->get('referer', $this->generateUrl('easyadmin')); if (ArticleStatus::Ingesting === $article->getStatus()) { $article->transitionTo(ArticleStatus::Draft); $this->em->flush(); $this->addFlash('success', $this->translator->trans('flash.article_marked_draft', [], 'admin')); } return $this->redirect($referrer); } #[Route('/admin/articles/{id}/activate', name: 'admin_article_activate')] public function activateArticle(string $id, Request $request): RedirectResponse { $result = $this->articleService->activate(Uuid::fromString($id)); if ([] !== $result['missing']) { $this->addFlash('warning', $this->translator->trans('flash.article_missing_attributes', ['%attrs%' => implode(', ', $result['missing'])], 'admin')); } else { $this->addFlash('success', $this->translator->trans('flash.article_activated', [], 'admin')); } return $this->redirectToRoute('easyadmin', [ 'crudAction' => 'index', 'crudControllerFqcn' => self::class, ]); } }