2026-05-17 22:44:03 +00:00
|
|
|
<?php
|
|
|
|
|
|
|
|
|
|
declare(strict_types=1);
|
|
|
|
|
|
|
|
|
|
namespace App\Infrastructure\Http\Controller\Admin;
|
|
|
|
|
|
|
|
|
|
use App\Application\Article\ArticleService;
|
|
|
|
|
use App\Domain\Article\Article;
|
|
|
|
|
use App\Domain\Article\ArticleCondition;
|
|
|
|
|
use App\Domain\Article\ArticleStatus;
|
2026-05-18 07:18:44 +00:00
|
|
|
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;
|
2026-05-17 22:44:03 +00:00
|
|
|
use EasyCorp\Bundle\EasyAdminBundle\Config\Action;
|
|
|
|
|
use EasyCorp\Bundle\EasyAdminBundle\Config\Actions;
|
|
|
|
|
use EasyCorp\Bundle\EasyAdminBundle\Config\Crud;
|
2026-05-18 07:18:44 +00:00
|
|
|
use EasyCorp\Bundle\EasyAdminBundle\Context\AdminContext;
|
2026-05-17 22:44:03 +00:00
|
|
|
use EasyCorp\Bundle\EasyAdminBundle\Controller\AbstractCrudController;
|
|
|
|
|
use EasyCorp\Bundle\EasyAdminBundle\Field\AssociationField;
|
|
|
|
|
use EasyCorp\Bundle\EasyAdminBundle\Field\ChoiceField;
|
2026-05-18 07:18:44 +00:00
|
|
|
use EasyCorp\Bundle\EasyAdminBundle\Field\CollectionField;
|
2026-05-17 22:44:03 +00:00
|
|
|
use EasyCorp\Bundle\EasyAdminBundle\Field\IdField;
|
|
|
|
|
use EasyCorp\Bundle\EasyAdminBundle\Field\MoneyField;
|
2026-05-18 07:18:44 +00:00
|
|
|
use EasyCorp\Bundle\EasyAdminBundle\Field\TextareaField;
|
2026-05-17 22:44:03 +00:00
|
|
|
use EasyCorp\Bundle\EasyAdminBundle\Field\TextField;
|
|
|
|
|
use Symfony\Component\HttpFoundation\RedirectResponse;
|
|
|
|
|
use Symfony\Component\HttpFoundation\Request;
|
2026-05-18 07:18:44 +00:00
|
|
|
use Symfony\Component\HttpFoundation\Response;
|
|
|
|
|
use Symfony\Component\Messenger\MessageBusInterface;
|
2026-05-17 22:44:03 +00:00
|
|
|
use Symfony\Component\Routing\Attribute\Route;
|
|
|
|
|
use Symfony\Component\Uid\Uuid;
|
|
|
|
|
|
|
|
|
|
/** @extends AbstractCrudController<Article> */
|
|
|
|
|
final class ArticleCrudController extends AbstractCrudController
|
|
|
|
|
{
|
2026-05-18 07:18:44 +00:00
|
|
|
public function __construct(
|
|
|
|
|
private readonly ArticleService $articleService,
|
|
|
|
|
private readonly EntityManagerInterface $em,
|
|
|
|
|
private readonly AIPipelineJobRepositoryInterface $jobRepository,
|
|
|
|
|
private readonly MessageBusInterface $bus,
|
|
|
|
|
) {
|
2026-05-17 22:44:03 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public static function getEntityFqcn(): string
|
|
|
|
|
{
|
|
|
|
|
return Article::class;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public function configureCrud(Crud $crud): Crud
|
|
|
|
|
{
|
2026-05-18 07:18:44 +00:00
|
|
|
return $crud
|
|
|
|
|
->setEntityLabelInSingular('Article')
|
|
|
|
|
->setEntityLabelInPlural('Articles')
|
|
|
|
|
->showEntityActionsInlined();
|
2026-05-17 22:44:03 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public function configureActions(Actions $actions): Actions
|
|
|
|
|
{
|
2026-05-18 07:18:44 +00:00
|
|
|
$activate = Action::new('activate', 'Activate', 'fa fa-check')
|
2026-05-17 22:44:03 +00:00
|
|
|
->linkToRoute('admin_article_activate', static fn (Article $a) => ['id' => $a->getId()->toRfc4122()])
|
2026-05-18 07:18:44 +00:00
|
|
|
->setCssClass('btn btn-sm btn-success')
|
2026-05-17 22:44:03 +00:00
|
|
|
->displayIf(static fn (Article $a) => ArticleStatus::Draft === $a->getStatus());
|
|
|
|
|
|
2026-05-18 07:18:44 +00:00
|
|
|
$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,
|
|
|
|
|
));
|
|
|
|
|
|
2026-05-17 22:44:03 +00:00
|
|
|
return $actions
|
|
|
|
|
->add(Crud::PAGE_INDEX, $activate)
|
2026-05-18 07:18:44 +00:00
|
|
|
->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);
|
2026-05-17 22:44:03 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public function configureFields(string $pageName): iterable
|
|
|
|
|
{
|
2026-05-18 07:18:44 +00:00
|
|
|
yield IdField::new('id')->hideOnForm()->hideOnIndex();
|
|
|
|
|
yield TextField::new('inventoryNumber', 'Inventory #')->hideOnForm();
|
|
|
|
|
yield AssociationField::new('articleType')->hideOnForm();
|
|
|
|
|
yield TextField::new('ebayTitle', 'Description')->setRequired(false);
|
2026-05-17 22:44:03 +00:00
|
|
|
yield ChoiceField::new('status')->setChoices(
|
|
|
|
|
array_combine(
|
|
|
|
|
array_map(static fn ($s) => $s->value, ArticleStatus::cases()),
|
|
|
|
|
ArticleStatus::cases(),
|
|
|
|
|
)
|
2026-05-18 07:18:44 +00:00
|
|
|
)->hideOnForm();
|
|
|
|
|
yield MoneyField::new('listingPrice', 'Price')->setCurrency('EUR')->setRequired(false);
|
2026-05-17 22:44:03 +00:00
|
|
|
yield ChoiceField::new('condition')->setChoices(
|
|
|
|
|
array_combine(
|
|
|
|
|
array_map(static fn ($c) => $c->value, ArticleCondition::cases()),
|
|
|
|
|
ArticleCondition::cases(),
|
|
|
|
|
)
|
2026-05-18 07:18:44 +00:00
|
|
|
)->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()
|
|
|
|
|
? '<strong>'.$av->getAttributeDefinition()->getName().'</strong>: '.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);
|
2026-05-17 22:44:03 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[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 {
|
2026-05-18 07:18:44 +00:00
|
|
|
$this->addFlash('success', 'Article activated and queued for channel publishing.');
|
2026-05-17 22:44:03 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return $this->redirectToRoute('easyadmin', [
|
|
|
|
|
'crudAction' => 'index',
|
|
|
|
|
'crudControllerFqcn' => self::class,
|
|
|
|
|
]);
|
|
|
|
|
}
|
|
|
|
|
}
|