*/ final class ArticleCrudController extends AbstractCrudController { public function __construct( private readonly ArticleService $articleService, private readonly EntityManagerInterface $em, private readonly AIPipelineJobRepositoryInterface $jobRepository, private readonly MessageBusInterface $bus, ) { } public static function getEntityFqcn(): string { return Article::class; } public function configureCrud(Crud $crud): Crud { return $crud ->setEntityLabelInSingular('Article') ->setEntityLabelInPlural('Articles') ->showEntityActionsInlined(); } public function configureActions(Actions $actions): Actions { $activate = Action::new('activate', 'Activate', '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', 'Mark as Draft', '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', 'Re-run AI', 'fa fa-rotate') ->linkToCrudAction('rerunAiPipeline') ->setCssClass('btn btn-sm btn-info') ->displayIf(static fn (Article $a) => \in_array( $a->getStatus(), [ArticleStatus::Draft, ArticleStatus::Ingesting, ArticleStatus::NeedsReview], true, )); 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) ->add(Crud::PAGE_INDEX, Action::DETAIL) ->disable(Action::NEW, Action::DELETE); } public function configureFields(string $pageName): iterable { yield IdField::new('id')->hideOnForm()->hideOnIndex(); yield TextField::new('inventoryNumber', 'Inventory #')->hideOnForm(); yield AssociationField::new('articleType')->hideOnForm(); yield TextField::new('ebayTitle', 'Description')->setRequired(false); yield ChoiceField::new('status')->setChoices( array_combine( array_map(static fn ($s) => $s->value, ArticleStatus::cases()), ArticleStatus::cases(), ) )->hideOnForm(); yield MoneyField::new('listingPrice', 'Price')->setCurrency('EUR')->setRequired(false); yield ChoiceField::new('condition')->setChoices( array_combine( array_map(static fn ($c) => $c->value, ArticleCondition::cases()), ArticleCondition::cases(), ) )->hideOnIndex(); yield TextField::new('manufacturer', 'Manufacturer')->setRequired(false)->hideOnIndex(); yield TextField::new('modelNumber', 'Model #')->setRequired(false)->hideOnIndex(); yield TextField::new('sku')->hideOnForm()->hideOnIndex(); yield TextField::new('serialNumber', 'Serial #')->setRequired(false)->hideOnIndex(); yield TextField::new('conditionNotes', 'Condition Notes')->setRequired(false)->hideOnIndex(); yield TextareaField::new('ebayDescription', 'eBay Description')->setRequired(false)->hideOnIndex(); // Flat key-value display for detail view yield TextField::new('attributeValues', 'Attributes') ->onlyOnDetail() ->formatValue(static fn ($v, Article $a): string => implode( ' | ', array_filter( $a->getAttributeValues()->map( static fn ($av) => '' !== $av->getValue() ? ''.$av->getAttributeDefinition()->getName().': '.htmlspecialchars($av->getValue()) : '' )->toArray() ) )); yield CollectionField::new('attributeValues', 'Attributes') ->onlyOnForms() ->setEntryType(AttributeValueFormType::class) ->setEntryIsComplex(false) ->allowAdd(false) ->allowDelete(false); } #[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', 'No original pipeline job found — cannot determine which photo to use.'); return $this->redirect($referrer); } $storedPhotoPath = (string) ($originalJob->getInputData()['storedPhotoPath'] ?? ''); if ('' === $storedPhotoPath || !file_exists($storedPhotoPath)) { $this->addFlash('danger', 'Stored photo not found at: '.$storedPhotoPath); 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', sprintf( 'AI pipeline re-queued for %s — attributes and eBay texts will be updated when complete.', $article->getInventoryNumber(), )); 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', 'Article marked as draft.'); } 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', 'Cannot activate: missing attributes: '.implode(', ', $result['missing'])); } else { $this->addFlash('success', 'Article activated and queued for channel publishing.'); } return $this->redirectToRoute('easyadmin', [ 'crudAction' => 'index', 'crudControllerFqcn' => self::class, ]); } }