SuperSeller3000/src/Infrastructure/Http/Controller/Admin/ArticleCrudController.php

237 lines
11 KiB
PHP
Raw Normal View History

<?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;
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\Field;
use EasyCorp\Bundle\EasyAdminBundle\Field\IdField;
feat: Frappe ERP matching, pipeline model cache, ACL, stock field, specs by type Frappe ERP: - findExistingCustomer() on FrappeErpAdapter — two-step name+address lookup - FrappeHttpClient: add put() method; switch invoice submit to PUT docstatus=1 (Frappe v16) - buildItemDescription() uses specsText + inventory number + serial number - Integration tests: find Simon Kühn, create real 1337€ invoice, cancel+delete in tearDown - FRAPPE_GENERIC_ITEM_CODE=SKU002 added to .env.local and bin/test-integration Pipeline — model cache: - PhotoUploadHandler: after vision, check DB for existing article with same modelNumber - On match: copy ebayTitle/ebayDescription/specsText/attributes, skip specs+JSON+eBay steps - DraftArticleHandler: apply model_match data and mark job complete directly - ArticleRepository: findCompletedByModelNumber() query Pipeline — specs by article type: - SpecsResearchAgent: accept attributeFields list, format as bullet list in {{fields}} var - SpecsResearchHandler: derive attribute names from ArticleType, pass to agent - SpecsResearchMessage: add attributeFields param - Prompt migration: replace hardcoded laptop spec list with {{fields}} placeholder Article: - specsText field (nullable text column + migration) - stock field visible on index and editable in CRUD form - addAttributeValue()/removeAttributeValue() adder-remover pair for Symfony form binding - AttributeValue::getArticle() getter - AttributeValueFormType: detect required attributes from ArticleType assignments, set required=true - ManualIngestType: add stock/quantity field (default 1, min 1) Users / ACL: - PermissionVoter: define named permission constants + allPermissions() - User: getGrantedPermissions()/setGrantedPermissions() helpers - UserCrudController: permissions checkbox group on edit form UI / assets: - public/css/admin/custom.css: red asterisk for required fields - DashboardController: register custom CSS Infra: - PipelineJobFailureListener: mark job failed (with real error) when Messenger exhausts retries - doctrine.yaml: exclude app.inventory_seq from schema diff - ErpAdapterInterface: add findExistingCustomer() Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-18 16:42:15 +00:00
use EasyCorp\Bundle\EasyAdminBundle\Field\IntegerField;
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\Translation\TranslatableMessage;
use Symfony\Component\Uid\Uuid;
use Symfony\Contracts\Translation\TranslatorInterface;
/** @extends AbstractCrudController<Article> */
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, Action::DETAIL)
->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();
feat: Frappe ERP matching, pipeline model cache, ACL, stock field, specs by type Frappe ERP: - findExistingCustomer() on FrappeErpAdapter — two-step name+address lookup - FrappeHttpClient: add put() method; switch invoice submit to PUT docstatus=1 (Frappe v16) - buildItemDescription() uses specsText + inventory number + serial number - Integration tests: find Simon Kühn, create real 1337€ invoice, cancel+delete in tearDown - FRAPPE_GENERIC_ITEM_CODE=SKU002 added to .env.local and bin/test-integration Pipeline — model cache: - PhotoUploadHandler: after vision, check DB for existing article with same modelNumber - On match: copy ebayTitle/ebayDescription/specsText/attributes, skip specs+JSON+eBay steps - DraftArticleHandler: apply model_match data and mark job complete directly - ArticleRepository: findCompletedByModelNumber() query Pipeline — specs by article type: - SpecsResearchAgent: accept attributeFields list, format as bullet list in {{fields}} var - SpecsResearchHandler: derive attribute names from ArticleType, pass to agent - SpecsResearchMessage: add attributeFields param - Prompt migration: replace hardcoded laptop spec list with {{fields}} placeholder Article: - specsText field (nullable text column + migration) - stock field visible on index and editable in CRUD form - addAttributeValue()/removeAttributeValue() adder-remover pair for Symfony form binding - AttributeValue::getArticle() getter - AttributeValueFormType: detect required attributes from ArticleType assignments, set required=true - ManualIngestType: add stock/quantity field (default 1, min 1) Users / ACL: - PermissionVoter: define named permission constants + allPermissions() - User: getGrantedPermissions()/setGrantedPermissions() helpers - UserCrudController: permissions checkbox group on edit form UI / assets: - public/css/admin/custom.css: red asterisk for required fields - DashboardController: register custom CSS Infra: - PipelineJobFailureListener: mark job failed (with real error) when Messenger exhausts retries - doctrine.yaml: exclude app.inventory_seq from schema diff - ErpAdapterInterface: add findExistingCustomer() Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-18 16:42:15 +00:00
yield IntegerField::new('stock', new TranslatableMessage('field.stock', [], 'admin'))
->setFormTypeOption('attr', ['min' => 0]);
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,
]);
}
}