diff --git a/src/Infrastructure/Http/Controller/Admin/ArticleCrudController.php b/src/Infrastructure/Http/Controller/Admin/ArticleCrudController.php index d49c033..cc9f9f9 100644 --- a/src/Infrastructure/Http/Controller/Admin/ArticleCrudController.php +++ b/src/Infrastructure/Http/Controller/Admin/ArticleCrudController.php @@ -8,25 +8,41 @@ use App\Application\Article\ArticleService; use App\Domain\Article\Article; use App\Domain\Article\ArticleCondition; use App\Domain\Article\ArticleStatus; +use App\Domain\Pipeline\AIPipelineJob; +use App\Domain\Pipeline\AIPipelineJobType; +use App\Domain\Pipeline\Repository\AIPipelineJobRepositoryInterface; +use App\Infrastructure\Http\Form\AttributeValueFormType; +use App\Infrastructure\Messenger\Message\PhotoUploadMessage; +use Doctrine\ORM\EntityManagerInterface; +use EasyCorp\Bundle\EasyAdminBundle\Attribute\AdminRoute; use EasyCorp\Bundle\EasyAdminBundle\Config\Action; use EasyCorp\Bundle\EasyAdminBundle\Config\Actions; use EasyCorp\Bundle\EasyAdminBundle\Config\Crud; +use EasyCorp\Bundle\EasyAdminBundle\Context\AdminContext; use EasyCorp\Bundle\EasyAdminBundle\Controller\AbstractCrudController; use EasyCorp\Bundle\EasyAdminBundle\Field\AssociationField; use EasyCorp\Bundle\EasyAdminBundle\Field\ChoiceField; +use EasyCorp\Bundle\EasyAdminBundle\Field\CollectionField; use EasyCorp\Bundle\EasyAdminBundle\Field\IdField; use EasyCorp\Bundle\EasyAdminBundle\Field\MoneyField; +use EasyCorp\Bundle\EasyAdminBundle\Field\TextareaField; use EasyCorp\Bundle\EasyAdminBundle\Field\TextField; use Symfony\Component\HttpFoundation\RedirectResponse; use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\HttpFoundation\Response; +use Symfony\Component\Messenger\MessageBusInterface; use Symfony\Component\Routing\Attribute\Route; use Symfony\Component\Uid\Uuid; /** @extends AbstractCrudController
*/ final class ArticleCrudController extends AbstractCrudController { - public function __construct(private readonly ArticleService $articleService) - { + public function __construct( + private readonly ArticleService $articleService, + private readonly EntityManagerInterface $em, + private readonly AIPipelineJobRepositoryInterface $jobRepository, + private readonly MessageBusInterface $bus, + ) { } public static function getEntityFqcn(): string @@ -36,39 +52,155 @@ final class ArticleCrudController extends AbstractCrudController public function configureCrud(Crud $crud): Crud { - return $crud->setEntityLabelInSingular('Article')->setEntityLabelInPlural('Articles'); + return $crud + ->setEntityLabelInSingular('Article') + ->setEntityLabelInPlural('Articles') + ->showEntityActionsInlined(); } public function configureActions(Actions $actions): Actions { - $activate = Action::new('activate', 'Activate') + $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) - ->disable(Action::NEW, Action::EDIT, Action::DELETE); + ->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(); - yield TextField::new('sku'); - yield TextField::new('inventoryNumber', 'Inventory #'); - yield AssociationField::new('articleType'); + 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(), ) - ); - yield MoneyField::new('listingPrice')->setCurrency('EUR')->setRequired(false); + )->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')] @@ -79,7 +211,7 @@ final class ArticleCrudController extends AbstractCrudController if ([] !== $result['missing']) { $this->addFlash('warning', 'Cannot activate: missing attributes: '.implode(', ', $result['missing'])); } else { - $this->addFlash('success', 'Article activated.'); + $this->addFlash('success', 'Article activated and queued for channel publishing.'); } return $this->redirectToRoute('easyadmin', [ diff --git a/src/Infrastructure/Http/Controller/Admin/ArticleTypeCrudController.php b/src/Infrastructure/Http/Controller/Admin/ArticleTypeCrudController.php index a88802b..88d31fc 100644 --- a/src/Infrastructure/Http/Controller/Admin/ArticleTypeCrudController.php +++ b/src/Infrastructure/Http/Controller/Admin/ArticleTypeCrudController.php @@ -58,7 +58,10 @@ final class ArticleTypeCrudController extends AbstractCrudController 'by_reference' => false, 'attr' => ['data-ea-widget' => 'ea-autocomplete'], ]) - ->hideOnIndex(); + ->hideOnIndex() + ->formatValue(static fn (mixed $v, ArticleType $at): string => + implode(', ', $at->getRequiredAttributeDefs()->map(fn (AttributeDefinition $d) => $d->getName())->toArray()) ?: '—' + ); yield Field::new('optionalAttributeDefs', 'Optional Attributes') ->setFormType(EntityType::class) @@ -70,7 +73,10 @@ final class ArticleTypeCrudController extends AbstractCrudController 'by_reference' => false, 'attr' => ['data-ea-widget' => 'ea-autocomplete'], ]) - ->hideOnIndex(); + ->hideOnIndex() + ->formatValue(static fn (mixed $v, ArticleType $at): string => + implode(', ', $at->getOptionalAttributeDefs()->map(fn (AttributeDefinition $d) => $d->getName())->toArray()) ?: '—' + ); } public function persistEntity(EntityManagerInterface $entityManager, mixed $entityInstance): void