feat: add manufacturer/model fields to Article and propagate through pipeline

- Article entity gets manufacturer and modelNumber columns (migration 20260517240000)
- Vision output (manufacturer/model) is written to the article in DraftArticleHandler
- manufacturer is forwarded through SpecsResearchMessage so the specs prompt can use it
- serialNumber is now threaded through JsonCodingMessage and ValidationMessage
- EbayTextHandler pulls specsText from the job's stored output data
- DraftArticleHandler supports re-run mode (existing article reuse) and sets Draft status
- ArticleType and AttributeValue get __toString() for form/display use
- ArticleService exposes reserveInventoryNumber() publicly

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Simon Kuehn 2026-05-18 07:18:24 +00:00
parent f55e96b094
commit 9e59123683
12 changed files with 130 additions and 11 deletions

View file

@ -0,0 +1,28 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
final class Version20260517240000 extends AbstractMigration
{
public function getDescription(): string
{
return 'Add manufacturer and model_number columns to articles';
}
public function up(Schema $schema): void
{
$this->addSql('ALTER TABLE app.articles ADD COLUMN manufacturer VARCHAR(255) NULL');
$this->addSql('ALTER TABLE app.articles ADD COLUMN model_number VARCHAR(255) NULL');
}
public function down(Schema $schema): void
{
$this->addSql('ALTER TABLE app.articles DROP COLUMN manufacturer');
$this->addSql('ALTER TABLE app.articles DROP COLUMN model_number');
}
}

View file

@ -135,6 +135,11 @@ final class ArticleService
return ['article' => $article, 'missing' => []];
}
public function reserveInventoryNumber(): string
{
return $this->nextInventoryNumber();
}
private function nextInventoryNumber(): string
{
/** @var string $seq */

View file

@ -45,6 +45,12 @@ class Article
#[ORM\Column(type: 'string', length: 255, nullable: true)]
private ?string $serialNumber = null;
#[ORM\Column(type: 'string', length: 255, nullable: true)]
private ?string $manufacturer = null;
#[ORM\Column(type: 'string', length: 255, nullable: true)]
private ?string $modelNumber = null;
#[ORM\Column(type: 'string', length: 255, nullable: true)]
private ?string $ebayListingId = null;
@ -182,8 +188,8 @@ class Article
{
foreach ($this->attributeValues as $existing) {
if ($existing->getAttributeDefinition()->getId()->equals($value->getAttributeDefinition()->getId())) {
$this->attributeValues->removeElement($existing);
break;
$existing->setValue($value->getValue());
return;
}
}
$this->attributeValues->add($value);
@ -216,6 +222,26 @@ class Article
$this->serialNumber = $sn;
}
public function getManufacturer(): ?string
{
return $this->manufacturer;
}
public function setManufacturer(?string $manufacturer): void
{
$this->manufacturer = $manufacturer;
}
public function getModelNumber(): ?string
{
return $this->modelNumber;
}
public function setModelNumber(?string $modelNumber): void
{
$this->modelNumber = $modelNumber;
}
public function setEbayListingId(?string $id): void
{
$this->ebayListingId = $id;

View file

@ -37,6 +37,11 @@ class ArticleType
$this->attributeAssignments = new ArrayCollection();
}
public function __toString(): string
{
return $this->name;
}
public function getId(): Uuid
{
return $this->id;

View file

@ -38,6 +38,11 @@ class AttributeValue
$this->value = $value;
}
public function __toString(): string
{
return $this->attributeDefinition->getName().': '.$this->value;
}
public function getId(): Uuid
{
return $this->id;

View file

@ -6,6 +6,8 @@ namespace App\Infrastructure\Messenger\Handler;
use App\Application\Article\ArticleService;
use App\Domain\Article\ArticleCondition;
use App\Domain\Article\ArticleStatus;
use App\Domain\Article\Repository\ArticleRepositoryInterface;
use App\Domain\Pipeline\Repository\AIPipelineJobRepositoryInterface;
use App\Infrastructure\Messenger\Message\DraftArticleMessage;
use App\Infrastructure\Messenger\Message\EbayTextMessage;
@ -18,6 +20,7 @@ final class DraftArticleHandler
{
public function __construct(
private readonly ArticleService $articleService,
private readonly ArticleRepositoryInterface $articleRepository,
private readonly AIPipelineJobRepositoryInterface $jobRepository,
private readonly MessageBusInterface $bus,
) {
@ -30,23 +33,49 @@ final class DraftArticleHandler
return;
}
$condition = ArticleCondition::tryFrom($message->condition) ?? ArticleCondition::Good;
// Re-run mode: job already has an articleId → update existing article
$existingArticleId = $job->getArticleId();
if (null !== $existingArticleId) {
$article = $this->articleRepository->findById($existingArticleId);
if (null === $article) {
$job->markFailed("Re-run target article {$existingArticleId->toRfc4122()} not found");
$this->jobRepository->save($job);
$article = $this->articleService->create(
articleTypeId: Uuid::fromString($message->articleTypeId),
condition: $condition,
stock: 1,
inventoryNumber: $message->inventoryNumber,
);
return;
}
} else {
$condition = ArticleCondition::tryFrom($message->condition) ?? ArticleCondition::Good;
$inventoryNumber = $message->inventoryNumber ?? ($job->getInputData()['inventoryNumber'] ?? null);
$article = $this->articleService->create(
articleTypeId: Uuid::fromString($message->articleTypeId),
condition: $condition,
stock: 1,
inventoryNumber: $inventoryNumber,
);
}
if (null !== $message->serialNumber) {
$article->setSerialNumber($message->serialNumber);
}
$vision = $job->getOutputData()['vision'] ?? [];
if (isset($vision['manufacturer']) && '' !== $vision['manufacturer']) {
$article->setManufacturer((string) $vision['manufacturer']);
}
if (isset($vision['model']) && '' !== $vision['model']) {
$article->setModelNumber((string) $vision['model']);
}
if ([] !== $message->attributes) {
$this->articleService->updateAttributes($article->getId(), $message->attributes);
}
if (ArticleStatus::Ingesting === $article->getStatus() || ArticleStatus::NeedsReview === $article->getStatus()) {
$article->transitionTo(ArticleStatus::Draft);
}
$this->articleRepository->save($article);
$job->setArticleId($article->getId());
$job->markCompleted(['articleId' => $article->getId()->toRfc4122()]);
$this->jobRepository->save($job);

View file

@ -6,6 +6,7 @@ namespace App\Infrastructure\Messenger\Handler;
use App\Application\Article\ArticleService;
use App\Domain\Article\Repository\ArticleRepositoryInterface;
use App\Domain\Pipeline\Repository\AIPipelineJobRepositoryInterface;
use App\Infrastructure\AI\Agent\EbayTextAgent;
use App\Infrastructure\Messenger\Message\EbayTextMessage;
use Symfony\Component\Messenger\Attribute\AsMessageHandler;
@ -18,6 +19,7 @@ final class EbayTextHandler
private readonly EbayTextAgent $ebayTextAgent,
private readonly ArticleRepositoryInterface $articleRepository,
private readonly ArticleService $articleService,
private readonly AIPipelineJobRepositoryInterface $jobRepository,
) {
}
@ -28,7 +30,13 @@ final class EbayTextHandler
return;
}
$texts = $this->ebayTextAgent->generate($article);
$specsText = '';
$job = $this->jobRepository->findById(Uuid::fromString($message->jobId));
if (null !== $job) {
$specsText = (string) ($job->getOutputData()['specs_research']['specsText'] ?? '');
}
$texts = $this->ebayTextAgent->generate($article, $specsText);
$this->articleService->setEbayTexts(
articleId: $article->getId(),

View file

@ -29,11 +29,19 @@ final class PhotoUploadHandler
return;
}
$job->incrementAttempt();
$job->markProcessing();
$this->jobRepository->save($job);
$result = $this->visionAgent->analyze($message->storedPhotoPath);
$job->recordStep('vision', [
'manufacturer' => $result['manufacturer'],
'model' => $result['model'],
'serial' => $result['serial'],
]);
$this->jobRepository->save($job);
if ('' === $result['model']) {
$job->markNeedsReview('OllamaVisionAgent: no model name detected on nameplate');
$this->jobRepository->save($job);
@ -46,6 +54,7 @@ final class PhotoUploadHandler
articleTypeId: $message->articleTypeId,
modelName: $result['model'],
serialNumber: $result['serial'],
manufacturer: $result['manufacturer'],
));
}
}

View file

@ -41,7 +41,7 @@ final class ValidationHandler
attributes: $message->attributes,
condition: 'good',
inventoryNumber: null,
serialNumber: null,
serialNumber: $message->serialNumber,
));
return;
@ -62,6 +62,7 @@ final class ValidationHandler
articleTypeId: $message->articleTypeId,
specsText: $message->specsText,
missingFields: $missing,
serialNumber: $message->serialNumber,
));
}

View file

@ -12,6 +12,7 @@ final readonly class JsonCodingMessage
public string $articleTypeId,
public string $specsText,
public array $missingFields = [],
public ?string $serialNumber = null,
) {
}
}

View file

@ -11,6 +11,7 @@ final readonly class SpecsResearchMessage
public string $articleTypeId,
public string $modelName,
public string $serialNumber,
public string $manufacturer = '',
) {
}
}

View file

@ -12,6 +12,7 @@ final readonly class ValidationMessage
public string $articleTypeId,
public string $specsText,
public array $attributes,
public ?string $serialNumber = null,
) {
}
}