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>
This commit is contained in:
Simon Kuehn 2026-05-18 16:42:15 +00:00
parent cba8ebcf5e
commit c19637465b
31 changed files with 721 additions and 14 deletions

View file

@ -13,4 +13,5 @@ docker compose exec \
-e FRAPPE_ERP_BASE_URL="${FRAPPE_ERP_BASE_URL:-}" \ -e FRAPPE_ERP_BASE_URL="${FRAPPE_ERP_BASE_URL:-}" \
-e FRAPPE_ERP_API_KEY="${FRAPPE_ERP_API_KEY:-}" \ -e FRAPPE_ERP_API_KEY="${FRAPPE_ERP_API_KEY:-}" \
-e FRAPPE_ERP_API_SECRET="${FRAPPE_ERP_API_SECRET:-}" \ -e FRAPPE_ERP_API_SECRET="${FRAPPE_ERP_API_SECRET:-}" \
app php vendor/bin/phpunit tests/Integration/ --testdox "$@" -e FRAPPE_GENERIC_ITEM_CODE="${FRAPPE_GENERIC_ITEM_CODE:-}" \
app php vendor/bin/phpunit --testdox "${@:-tests/Integration/}"

View file

@ -1,7 +1,7 @@
doctrine: doctrine:
dbal: dbal:
url: '%env(resolve:DATABASE_URL)%' url: '%env(resolve:DATABASE_URL)%'
schema_filter: ~^(?!logs_archive\.)~ schema_filter: ~^(?!logs_archive\.|app\.inventory_seq$)~
orm: orm:
naming_strategy: doctrine.orm.naming_strategy.underscore_number_aware naming_strategy: doctrine.orm.naming_strategy.underscore_number_aware
auto_mapping: true auto_mapping: true

View file

@ -0,0 +1,26 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
final class Version20260518150000 extends AbstractMigration
{
public function getDescription(): string
{
return 'Add specs_text column to articles for ERP invoice description';
}
public function up(Schema $schema): void
{
$this->addSql('ALTER TABLE app.articles ADD COLUMN specs_text TEXT DEFAULT NULL');
}
public function down(Schema $schema): void
{
$this->addSql('ALTER TABLE app.articles DROP COLUMN specs_text');
}
}

View file

@ -0,0 +1,68 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
final class Version20260518160000 extends AbstractMigration
{
public function getDescription(): string
{
return 'Update specs_research prompt to use article-type-specific attribute fields instead of hardcoded laptop specs';
}
public function up(Schema $schema): void
{
$body = 'You are a hardware specifications expert. Extract the technical specifications for the {{articleType}}: "{{subject}}".
Web search results:
{{searchResults}}
Based on the search results above, extract the following specifications:
{{fields}}
Be specific and accurate. Use the exact values from the search results.
If a specification is not found in the search results, omit it rather than guessing.
Output only the specifications as a plain list no introduction, no commentary.
If the search results reveal that the model number in "{{subject}}" contains an OCR error
(e.g. a letter misread as a digit), output the corrected model number on the very first line
in exactly this format, then leave a blank line before the specs:
CORRECTED_MODEL_NUMBER: <corrected number>
If the model number is correct or no model number was provided, omit the CORRECTED_MODEL_NUMBER line entirely.';
$this->addSql('UPDATE app.prompt_templates SET body = :body WHERE key = :key', [
'body' => $body,
'key' => 'specs_research',
]);
}
public function down(Schema $schema): void
{
$body = 'You are a hardware specifications expert. Extract the technical specifications for the {{articleType}}: "{{subject}}".
Web search results:
{{searchResults}}
Based on the search results above, list all technical specifications including:
processor, RAM, storage variants, display size and resolution, GPU, battery capacity,
ports, connectivity, weight, dimensions, OS, and any other relevant specs.
Be specific and accurate. If a spec is not found in the search results, omit it rather than guessing.
If the search results reveal that the model number in "{{subject}}" contains an OCR error
(e.g. a letter misread as a digit), output the corrected model number on the very first line
in exactly this format, then leave a blank line before the specs:
CORRECTED_MODEL_NUMBER: <corrected number>
If the model number is correct or no model number was provided, omit the CORRECTED_MODEL_NUMBER line entirely.';
$this->addSql('UPDATE app.prompt_templates SET body = :body WHERE key = :key', [
'body' => $body,
'key' => 'specs_research',
]);
}
}

View file

@ -0,0 +1,5 @@
/* Required field asterisk */
label.required::after {
content: " *";
color: #dc3545;
}

View file

@ -26,4 +26,10 @@ interface ErpAdapterInterface
* Returns raw binary PDF content. * Returns raw binary PDF content.
*/ */
public function fetchInvoicePdf(string $frappeInvoiceId): string; public function fetchInvoicePdf(string $frappeInvoiceId): string;
/**
* Searches Frappe for a customer matching name + address.
* Returns the Frappe document name (e.g. "CUST-00001") or null if not found.
*/
public function findExistingCustomer(string $name, string $street, string $city, string $zip): ?string;
} }

View file

@ -63,6 +63,9 @@ class Article
#[ORM\Column(type: 'text', nullable: true)] #[ORM\Column(type: 'text', nullable: true)]
private ?string $ebayDescription = null; private ?string $ebayDescription = null;
#[ORM\Column(type: 'text', nullable: true)]
private ?string $specsText = null;
/** @var Collection<int, AttributeValue> */ /** @var Collection<int, AttributeValue> */
#[ORM\OneToMany(mappedBy: 'article', targetEntity: AttributeValue::class, cascade: ['persist', 'remove'])] #[ORM\OneToMany(mappedBy: 'article', targetEntity: AttributeValue::class, cascade: ['persist', 'remove'])]
private Collection $attributeValues; private Collection $attributeValues;
@ -198,6 +201,16 @@ class Article
$this->attributeValues->add($value); $this->attributeValues->add($value);
} }
public function addAttributeValue(AttributeValue $value): void
{
$this->setAttributeValue($value);
}
public function removeAttributeValue(AttributeValue $value): void
{
$this->attributeValues->removeElement($value);
}
/** @return Collection<int, AttributeValue> */ /** @return Collection<int, AttributeValue> */
public function getAttributeValues(): Collection public function getAttributeValues(): Collection
{ {
@ -269,4 +282,14 @@ class Article
{ {
$this->ebayDescription = $desc; $this->ebayDescription = $desc;
} }
public function getSpecsText(): ?string
{
return $this->specsText;
}
public function setSpecsText(?string $specsText): void
{
$this->specsText = $specsText;
}
} }

View file

@ -48,6 +48,11 @@ class AttributeValue
return $this->id; return $this->id;
} }
public function getArticle(): Article
{
return $this->article;
}
public function getAttributeDefinition(): AttributeDefinition public function getAttributeDefinition(): AttributeDefinition
{ {
return $this->attributeDefinition; return $this->attributeDefinition;

View file

@ -21,6 +21,13 @@ interface ArticleRepositoryInterface
/** @return list<Article> */ /** @return list<Article> */
public function findByStatus(ArticleStatus $status): array; public function findByStatus(ArticleStatus $status): array;
/**
* Find the most-recently created article with this model number that has
* completed the pipeline (Draft, Active, or Sold). Returns null when the
* model number is empty or no match exists.
*/
public function findCompletedByModelNumber(string $modelNumber): ?Article;
public function decrementStockAtomic(Uuid $articleId): bool; public function decrementStockAtomic(Uuid $articleId): bool;
public function save(Article $article): void; public function save(Article $article): void;

View file

@ -142,4 +142,19 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface, TotpTwo
{ {
unset($this->permissions[$permission]); unset($this->permissions[$permission]);
} }
/** @return list<string> */
public function getGrantedPermissions(): array
{
return array_keys(array_filter($this->permissions));
}
/** @param list<string> $granted */
public function setGrantedPermissions(array $granted): void
{
$this->permissions = [];
foreach ($granted as $permission) {
$this->permissions[$permission] = true;
}
}
} }

View file

@ -19,18 +19,25 @@ final class SpecsResearchAgent
} }
/** /**
* @param list<string> $attributeFields Attribute names defined for this article type
*
* @return array{specsText: string, correctedModelNumber: string} * @return array{specsText: string, correctedModelNumber: string}
*/ */
public function research(string $modelName, string $articleTypeName, string $manufacturer = ''): array public function research(string $modelName, string $articleTypeName, string $manufacturer = '', array $attributeFields = []): array
{ {
$subject = trim(($manufacturer !== '' ? $manufacturer.' ' : '').$modelName); $subject = trim(($manufacturer !== '' ? $manufacturer.' ' : '').$modelName);
$searchResults = $this->search->search("{$subject} {$articleTypeName} specifications"); $searchResults = $this->search->search("{$subject} {$articleTypeName} specifications");
$fieldsList = [] !== $attributeFields
? implode("\n", array_map(static fn (string $f) => "- {$f}", $attributeFields))
: '- all relevant technical specifications';
$prompt = $this->prompts->render('specs_research', [ $prompt = $this->prompts->render('specs_research', [
'articleType' => $articleTypeName, 'articleType' => $articleTypeName,
'subject' => $subject, 'subject' => $subject,
'searchResults' => $searchResults !== '' ? $searchResults : 'No web results available.', 'searchResults' => $searchResults !== '' ? $searchResults : 'No web results available.',
'fields' => $fieldsList,
]); ]);
$result = $this->client->generate($this->model, $prompt); $result = $this->client->generate($this->model, $prompt);

View file

@ -39,7 +39,7 @@ final class PromptTemplateService
public static function knownKeys(): array public static function knownKeys(): array
{ {
return [ return [
'specs_research' => ['articleType', 'subject', 'searchResults'], 'specs_research' => ['articleType', 'subject', 'searchResults', 'fields'],
'ebay_title' => ['typeName', 'deviceLabel', 'condition', 'specsSection'], 'ebay_title' => ['typeName', 'deviceLabel', 'condition', 'specsSection'],
'ebay_description' => ['typeName', 'deviceLabel', 'condition', 'conditionNotes', 'specsSection'], 'ebay_description' => ['typeName', 'deviceLabel', 'condition', 'conditionNotes', 'specsSection'],
'vision_analyze' => [], 'vision_analyze' => [],

View file

@ -5,6 +5,7 @@ declare(strict_types=1);
namespace App\Infrastructure\Channel\Frappe; namespace App\Infrastructure\Channel\Frappe;
use App\Application\Order\ErpAdapterInterface; use App\Application\Order\ErpAdapterInterface;
use App\Domain\Article\Article;
use App\Domain\Order\Customer; use App\Domain\Order\Customer;
use App\Domain\Order\Order; use App\Domain\Order\Order;
@ -50,7 +51,7 @@ final class FrappeErpAdapter implements ErpAdapterInterface
[ [
'item_code' => $this->genericItemCode, 'item_code' => $this->genericItemCode,
'item_name' => $article->getEbayTitle() ?? $article->getSku(), 'item_name' => $article->getEbayTitle() ?? $article->getSku(),
'description' => \sprintf('%s — Inventar: %s', $article->getEbayTitle() ?? $article->getSku(), $article->getInventoryNumber()), 'description' => $this->buildItemDescription($article),
'qty' => 1, 'qty' => 1,
'rate' => $order->getSalePrice(), 'rate' => $order->getSalePrice(),
], ],
@ -62,11 +63,77 @@ final class FrappeErpAdapter implements ErpAdapterInterface
/** @var array{data: array{name: string}} $draft */ /** @var array{data: array{name: string}} $draft */
$invoiceName = $draft['data']['name']; $invoiceName = $draft['data']['name'];
$this->frappe->post('/api/resource/Sales Invoice/'.$invoiceName.'/submit', []); $this->frappe->put('/api/resource/Sales Invoice/'.$invoiceName, ['docstatus' => 1]);
return $invoiceName; return $invoiceName;
} }
public function findExistingCustomer(string $name, string $street, string $city, string $zip): ?string
{
$query = http_build_query([
'filters' => json_encode([['customer_name', '=', $name]]),
'fields' => json_encode(['name']),
]);
$response = $this->frappe->get('/api/resource/Customer?'.$query);
/** @var array{data: list<array{name: string}>} $response */
foreach ($response['data'] as $candidate) {
$customerId = $candidate['name'];
$addrQuery = http_build_query([
'filters' => json_encode([
['Dynamic Link', 'link_doctype', '=', 'Customer'],
['Dynamic Link', 'link_name', '=', $customerId],
]),
'fields' => json_encode(['address_line1', 'city', 'pincode']),
]);
$addrResponse = $this->frappe->get('/api/resource/Address?'.$addrQuery);
/** @var array{data: list<array{address_line1: string, city: string, pincode: string}>} $addrResponse */
foreach ($addrResponse['data'] as $addr) {
if ($this->addressMatches($addr, $street, $city, $zip)) {
return $customerId;
}
}
}
return null;
}
private function buildItemDescription(Article $article): string
{
$meta = 'Inventar: '.$article->getInventoryNumber();
if (null !== $article->getSerialNumber()) {
$meta .= ' — S/N: '.$article->getSerialNumber();
}
if (null !== $article->getSpecsText()) {
return $article->getSpecsText()."\n\n".$meta;
}
$fallback = $article->getEbayTitle() ?? trim(implode(' ', array_filter([
$article->getManufacturer(),
$article->getModelName(),
$article->getModelNumber(),
]))) ?: $article->getSku();
return $fallback.' — '.$meta;
}
/**
* @param array{address_line1?: string, city?: string, pincode?: string} $addr
*/
private function addressMatches(array $addr, string $street, string $city, string $zip): bool
{
$n = static fn(string $s): string => mb_strtolower(trim($s));
return $n($addr['address_line1'] ?? '') === $n($street)
&& $n($addr['city'] ?? '') === $n($city)
&& trim($addr['pincode'] ?? '') === trim($zip);
}
public function fetchInvoicePdf(string $frappeInvoiceId): string public function fetchInvoicePdf(string $frappeInvoiceId): string
{ {
$path = http_build_query([ $path = http_build_query([

View file

@ -59,6 +59,29 @@ class FrappeHttpClient
return $result; return $result;
} }
/**
* PUT (update) a Frappe resource.
*
* @param array<string, mixed> $data
*
* @return array<string, mixed>
*/
public function put(string $path, array $data): array
{
$response = $this->httpClient->request('PUT', $this->baseUrl.$path, [
'headers' => [
'Authorization' => $this->authHeader,
'Content-Type' => 'application/json',
],
'json' => $data,
]);
/** @var array<string, mixed> $result */
$result = $response->toArray();
return $result;
}
/** /**
* DELETE a Frappe resource. * DELETE a Frappe resource.
* *

View file

@ -25,6 +25,7 @@ use EasyCorp\Bundle\EasyAdminBundle\Field\ChoiceField;
use EasyCorp\Bundle\EasyAdminBundle\Field\CollectionField; use EasyCorp\Bundle\EasyAdminBundle\Field\CollectionField;
use EasyCorp\Bundle\EasyAdminBundle\Field\Field; use EasyCorp\Bundle\EasyAdminBundle\Field\Field;
use EasyCorp\Bundle\EasyAdminBundle\Field\IdField; use EasyCorp\Bundle\EasyAdminBundle\Field\IdField;
use EasyCorp\Bundle\EasyAdminBundle\Field\IntegerField;
use EasyCorp\Bundle\EasyAdminBundle\Field\MoneyField; use EasyCorp\Bundle\EasyAdminBundle\Field\MoneyField;
use EasyCorp\Bundle\EasyAdminBundle\Field\TextareaField; use EasyCorp\Bundle\EasyAdminBundle\Field\TextareaField;
use EasyCorp\Bundle\EasyAdminBundle\Field\TextField; use EasyCorp\Bundle\EasyAdminBundle\Field\TextField;
@ -110,6 +111,8 @@ final class ArticleCrudController extends AbstractCrudController
ArticleStatus::cases(), ArticleStatus::cases(),
) )
)->hideOnForm(); )->hideOnForm();
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 MoneyField::new('listingPrice', new TranslatableMessage('field.price', [], 'admin'))->setCurrency('EUR')->setRequired(false);
yield ChoiceField::new('condition', new TranslatableMessage('field.condition', [], 'admin'))->setChoices( yield ChoiceField::new('condition', new TranslatableMessage('field.condition', [], 'admin'))->setChoices(
array_combine( array_combine(

View file

@ -43,7 +43,9 @@ final class DashboardController extends AbstractDashboardController
public function configureAssets(): Assets public function configureAssets(): Assets
{ {
return Assets::new()->addJsFile('js/admin/pipeline-notifications.js'); return Assets::new()
->addCssFile('css/admin/custom.css')
->addJsFile('js/admin/pipeline-notifications.js');
} }
public function configureDashboard(): Dashboard public function configureDashboard(): Dashboard

View file

@ -84,6 +84,7 @@ final class ManualIngestController extends AbstractController
'articleTypeId' => $articleType->getId()->toRfc4122(), 'articleTypeId' => $articleType->getId()->toRfc4122(),
'condition' => $form->get('condition')->getData()->value, 'condition' => $form->get('condition')->getData()->value,
'conditionNotes' => $form->get('conditionNotes')->getData(), 'conditionNotes' => $form->get('conditionNotes')->getData(),
'stock' => (int) $form->get('stock')->getData(),
'originalFilename' => $image->getClientOriginalName(), 'originalFilename' => $image->getClientOriginalName(),
'storedPhotoPath' => $storedPath, 'storedPhotoPath' => $storedPath,
'extraPhotos' => $extraPhotos, 'extraPhotos' => $extraPhotos,

View file

@ -5,11 +5,13 @@ declare(strict_types=1);
namespace App\Infrastructure\Http\Controller\Admin; namespace App\Infrastructure\Http\Controller\Admin;
use App\Domain\Auth\User; use App\Domain\Auth\User;
use App\Infrastructure\Security\PermissionVoter;
use EasyCorp\Bundle\EasyAdminBundle\Config\Action; use EasyCorp\Bundle\EasyAdminBundle\Config\Action;
use EasyCorp\Bundle\EasyAdminBundle\Config\Actions; use EasyCorp\Bundle\EasyAdminBundle\Config\Actions;
use EasyCorp\Bundle\EasyAdminBundle\Config\Crud; use EasyCorp\Bundle\EasyAdminBundle\Config\Crud;
use EasyCorp\Bundle\EasyAdminBundle\Controller\AbstractCrudController; use EasyCorp\Bundle\EasyAdminBundle\Controller\AbstractCrudController;
use EasyCorp\Bundle\EasyAdminBundle\Field\BooleanField; use EasyCorp\Bundle\EasyAdminBundle\Field\BooleanField;
use EasyCorp\Bundle\EasyAdminBundle\Field\ChoiceField;
use EasyCorp\Bundle\EasyAdminBundle\Field\IdField; use EasyCorp\Bundle\EasyAdminBundle\Field\IdField;
use EasyCorp\Bundle\EasyAdminBundle\Field\TextField; use EasyCorp\Bundle\EasyAdminBundle\Field\TextField;
use Symfony\Component\Translation\TranslatableMessage; use Symfony\Component\Translation\TranslatableMessage;
@ -37,5 +39,14 @@ final class UserCrudController extends AbstractCrudController
yield IdField::new('id')->hideOnForm()->hideOnIndex(); yield IdField::new('id')->hideOnForm()->hideOnIndex();
yield TextField::new('email')->setFormTypeOption('disabled', true); yield TextField::new('email')->setFormTypeOption('disabled', true);
yield BooleanField::new('isActive'); yield BooleanField::new('isActive');
$allPerms = PermissionVoter::allPermissions();
$choices = array_combine($allPerms, $allPerms);
yield ChoiceField::new('grantedPermissions', 'Permissions')
->setChoices($choices)
->allowMultipleChoices()
->renderExpanded()
->hideOnIndex();
} }
} }

View file

@ -23,11 +23,21 @@ final class AttributeValueFormType extends AbstractType
} }
$def = $av->getAttributeDefinition(); $def = $av->getAttributeDefinition();
$defId = $def->getId()->toRfc4122();
$label = $def->getName().($def->getUnit() ? ' ('.$def->getUnit().')' : ''); $label = $def->getName().($def->getUnit() ? ' ('.$def->getUnit().')' : '');
$isRequired = false;
foreach ($av->getArticle()->getArticleType()->getAttributeAssignments() as $assignment) {
if ($assignment->getAttributeDefinition()->getId()->toRfc4122() === $defId) {
$isRequired = $assignment->isRequired();
break;
}
}
$event->getForm()->add('value', TextType::class, [ $event->getForm()->add('value', TextType::class, [
'label' => $label, 'label' => $label,
'required' => false, 'required' => $isRequired,
'empty_data' => '',
]); ]);
}); });
} }

View file

@ -10,9 +10,11 @@ use Symfony\Bridge\Doctrine\Form\Type\EntityType;
use Symfony\Component\Form\AbstractType; use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\EnumType; use Symfony\Component\Form\Extension\Core\Type\EnumType;
use Symfony\Component\Form\Extension\Core\Type\FileType; use Symfony\Component\Form\Extension\Core\Type\FileType;
use Symfony\Component\Form\Extension\Core\Type\IntegerType;
use Symfony\Component\Form\Extension\Core\Type\TextareaType; use Symfony\Component\Form\Extension\Core\Type\TextareaType;
use Symfony\Component\Form\FormBuilderInterface; use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver; use Symfony\Component\OptionsResolver\OptionsResolver;
use Symfony\Component\Validator\Constraints\GreaterThanOrEqual;
use Symfony\Component\Validator\Constraints\Image; use Symfony\Component\Validator\Constraints\Image;
use Symfony\Component\Validator\Constraints\NotNull; use Symfony\Component\Validator\Constraints\NotNull;
@ -42,6 +44,12 @@ final class ManualIngestType extends AbstractType
], ],
'attr' => ['accept' => 'image/*', 'capture' => 'environment'], 'attr' => ['accept' => 'image/*', 'capture' => 'environment'],
]) ])
->add('stock', IntegerType::class, [
'label' => 'Quantity',
'data' => 1,
'attr' => ['min' => 1],
'constraints' => [new GreaterThanOrEqual(1)],
])
->add('conditionNotes', TextareaType::class, [ ->add('conditionNotes', TextareaType::class, [
'label' => 'Condition Notes', 'label' => 'Condition Notes',
'required' => false, 'required' => false,

View file

@ -50,11 +50,12 @@ final class DraftArticleHandler
} else { } else {
$condition = ArticleCondition::tryFrom($message->condition) ?? ArticleCondition::Good; $condition = ArticleCondition::tryFrom($message->condition) ?? ArticleCondition::Good;
$inventoryNumber = $message->inventoryNumber ?? ($job->getInputData()['inventoryNumber'] ?? null); $inventoryNumber = $message->inventoryNumber ?? ($job->getInputData()['inventoryNumber'] ?? null);
$stock = max(1, (int) ($job->getInputData()['stock'] ?? 1));
$article = $this->articleService->create( $article = $this->articleService->create(
articleTypeId: Uuid::fromString($message->articleTypeId), articleTypeId: Uuid::fromString($message->articleTypeId),
condition: $condition, condition: $condition,
stock: 1, stock: $stock,
inventoryNumber: $inventoryNumber, inventoryNumber: $inventoryNumber,
); );
} }
@ -76,6 +77,26 @@ final class DraftArticleHandler
$article->setModelName((string) $vision['modelName']); $article->setModelName((string) $vision['modelName']);
} }
$modelMatch = $job->getOutputData()['model_match'] ?? null;
if (null !== $modelMatch) {
// Cache hit: copy texts directly from the matched article
if (isset($modelMatch['ebayTitle'])) {
$article->setEbayTitle((string) $modelMatch['ebayTitle']);
}
if (isset($modelMatch['ebayDescription'])) {
$article->setEbayDescription((string) $modelMatch['ebayDescription']);
}
if (isset($modelMatch['specsText'])) {
$article->setSpecsText((string) $modelMatch['specsText']);
}
} else {
$specsText = (string) ($job->getOutputData()['specs_research']['specsText'] ?? '');
if ('' !== $specsText) {
$article->setSpecsText($specsText);
}
}
if ([] !== $message->attributes) { if ([] !== $message->attributes) {
$this->articleService->updateAttributes($article->getId(), $message->attributes); $this->articleService->updateAttributes($article->getId(), $message->attributes);
} }
@ -104,6 +125,17 @@ final class DraftArticleHandler
} }
} }
if (null !== $modelMatch) {
// Skip eBay text generation — texts already copied from cache
$job->markCompleted([
'articleId' => $article->getId()->toRfc4122(),
'model_match' => ['sourceArticleId' => $modelMatch['sourceArticleId'] ?? null],
]);
$this->jobRepository->save($job);
return;
}
$this->bus->dispatch(new EbayTextMessage( $this->bus->dispatch(new EbayTextMessage(
jobId: $message->jobId, jobId: $message->jobId,
articleId: $article->getId()->toRfc4122(), articleId: $article->getId()->toRfc4122(),

View file

@ -4,8 +4,10 @@ declare(strict_types=1);
namespace App\Infrastructure\Messenger\Handler; namespace App\Infrastructure\Messenger\Handler;
use App\Domain\Article\Repository\ArticleRepositoryInterface;
use App\Domain\Pipeline\Repository\AIPipelineJobRepositoryInterface; use App\Domain\Pipeline\Repository\AIPipelineJobRepositoryInterface;
use App\Infrastructure\AI\Agent\OllamaVisionAgent; use App\Infrastructure\AI\Agent\OllamaVisionAgent;
use App\Infrastructure\Messenger\Message\DraftArticleMessage;
use App\Infrastructure\Messenger\Message\PhotoUploadMessage; use App\Infrastructure\Messenger\Message\PhotoUploadMessage;
use App\Infrastructure\Messenger\Message\SpecsResearchMessage; use App\Infrastructure\Messenger\Message\SpecsResearchMessage;
use Symfony\Component\Messenger\Attribute\AsMessageHandler; use Symfony\Component\Messenger\Attribute\AsMessageHandler;
@ -18,6 +20,7 @@ final class PhotoUploadHandler
public function __construct( public function __construct(
private readonly OllamaVisionAgent $visionAgent, private readonly OllamaVisionAgent $visionAgent,
private readonly AIPipelineJobRepositoryInterface $jobRepository, private readonly AIPipelineJobRepositoryInterface $jobRepository,
private readonly ArticleRepositoryInterface $articleRepository,
private readonly MessageBusInterface $bus, private readonly MessageBusInterface $bus,
) { ) {
} }
@ -50,6 +53,39 @@ final class PhotoUploadHandler
return; return;
} }
$modelNumber = $result['modelNumber'];
$existing = $this->articleRepository->findCompletedByModelNumber($modelNumber);
if (null !== $existing) {
$attributes = [];
foreach ($existing->getAttributeValues() as $av) {
if ('' !== $av->getValue()) {
$attributes[$av->getAttributeDefinition()->getName()] = $av->getValue();
}
}
$job->recordStep('model_match', [
'sourceArticleId' => $existing->getId()->toRfc4122(),
'ebayTitle' => $existing->getEbayTitle(),
'ebayDescription' => $existing->getEbayDescription(),
'specsText' => $existing->getSpecsText(),
'attributes' => $attributes,
]);
$this->jobRepository->save($job);
$inputData = $job->getInputData();
$this->bus->dispatch(new DraftArticleMessage(
jobId: $message->jobId,
articleTypeId: $message->articleTypeId,
attributes: $attributes,
condition: $inputData['condition'] ?? 'good',
inventoryNumber: $inputData['inventoryNumber'] ?? null,
serialNumber: $result['serial'] !== '' ? $result['serial'] : null,
));
return;
}
$this->bus->dispatch(new SpecsResearchMessage( $this->bus->dispatch(new SpecsResearchMessage(
jobId: $message->jobId, jobId: $message->jobId,
articleTypeId: $message->articleTypeId, articleTypeId: $message->articleTypeId,

View file

@ -44,11 +44,20 @@ final class SpecsResearchHandler
$parts = array_filter([$message->modelName, $message->modelNumber]); $parts = array_filter([$message->modelName, $message->modelNumber]);
$searchSubject = implode(' ', $parts); $searchSubject = implode(' ', $parts);
$attributeFields = $message->attributeFields;
if ([] === $attributeFields) {
// Derive from article type when the message predates this field
foreach ($articleType->getAttributeDefinitions() as $def) {
$attributeFields[] = $def->getName();
}
}
try { try {
$result = $this->specsAgent->research( $result = $this->specsAgent->research(
$searchSubject, $searchSubject,
$articleType->getName(), $articleType->getName(),
$message->manufacturer, $message->manufacturer,
$attributeFields,
); );
} catch (\RuntimeException $e) { } catch (\RuntimeException $e) {
$job->markNeedsReview('SpecsResearchAgent: '.$e->getMessage()); $job->markNeedsReview('SpecsResearchAgent: '.$e->getMessage());

View file

@ -6,6 +6,9 @@ namespace App\Infrastructure\Messenger\Message;
final readonly class SpecsResearchMessage final readonly class SpecsResearchMessage
{ {
/**
* @param list<string> $attributeFields Attribute definition names for this article type
*/
public function __construct( public function __construct(
public string $jobId, public string $jobId,
public string $articleTypeId, public string $articleTypeId,
@ -13,6 +16,7 @@ final readonly class SpecsResearchMessage
public string $modelName, public string $modelName,
public string $serialNumber, public string $serialNumber,
public string $manufacturer = '', public string $manufacturer = '',
public array $attributeFields = [],
) { ) {
} }
} }

View file

@ -0,0 +1,58 @@
<?php
declare(strict_types=1);
namespace App\Infrastructure\Messenger;
use App\Domain\Pipeline\Repository\AIPipelineJobRepositoryInterface;
use Symfony\Component\EventDispatcher\Attribute\AsEventListener;
use Symfony\Component\Messenger\Event\WorkerMessageFailedEvent;
use Symfony\Component\Uid\Uuid;
/**
* Marks a pipeline job as failed when Messenger exhausts all retries.
* Without this the job stays stuck in "processing" forever.
*/
#[AsEventListener]
final class PipelineJobFailureListener
{
public function __construct(
private readonly AIPipelineJobRepositoryInterface $jobRepository,
) {
}
public function __invoke(WorkerMessageFailedEvent $event): void
{
if ($event->willRetry()) {
return;
}
$message = $event->getEnvelope()->getMessage();
if (!property_exists($message, 'jobId')) {
return;
}
$jobId = $message->jobId; // @phpstan-ignore-line
if (!\is_string($jobId) || '' === $jobId) {
return;
}
$job = $this->jobRepository->findById(Uuid::fromString($jobId));
if (null === $job) {
return;
}
$throwable = $event->getThrowable();
$error = $throwable->getMessage();
// Unwrap HandlerFailedException to get the real cause
$previous = $throwable->getPrevious();
if (null !== $previous) {
$error = $previous->getMessage();
}
$job->markFailed($error);
$this->jobRepository->save($job);
}
}

View file

@ -43,6 +43,26 @@ final class DoctrineArticleRepository implements ArticleRepositoryInterface
return $this->em->getRepository(Article::class)->findBy(['status' => $status]); return $this->em->getRepository(Article::class)->findBy(['status' => $status]);
} }
public function findCompletedByModelNumber(string $modelNumber): ?Article
{
if ('' === $modelNumber) {
return null;
}
/** @var ?Article */
return $this->em->createQueryBuilder()
->select('a')
->from(Article::class, 'a')
->where('a.modelNumber = :modelNumber')
->andWhere('a.status IN (:statuses)')
->setParameter('modelNumber', $modelNumber)
->setParameter('statuses', [ArticleStatus::Draft, ArticleStatus::Active, ArticleStatus::Sold])
->orderBy('a.id', 'DESC')
->setMaxResults(1)
->getQuery()
->getOneOrNullResult();
}
public function decrementStockAtomic(Uuid $articleId): bool public function decrementStockAtomic(Uuid $articleId): bool
{ {
$affected = $this->em->getConnection()->executeStatement( $affected = $this->em->getConnection()->executeStatement(

View file

@ -15,6 +15,26 @@ final class PermissionVoter extends Voter
{ {
public const PREFIX = 'PERM_'; public const PREFIX = 'PERM_';
public const ARTICLES_MANAGE = 'articles.manage';
public const PIPELINE_RUN = 'pipeline.run';
public const ORDERS_MANAGE = 'orders.manage';
public const USERS_MANAGE = 'users.manage';
public const PROMPTS_MANAGE = 'prompts.manage';
public const SETTINGS_MANAGE = 'settings.manage';
/** @return list<string> */
public static function allPermissions(): array
{
return [
self::ARTICLES_MANAGE,
self::PIPELINE_RUN,
self::ORDERS_MANAGE,
self::USERS_MANAGE,
self::PROMPTS_MANAGE,
self::SETTINGS_MANAGE,
];
}
protected function supports(string $attribute, mixed $subject): bool protected function supports(string $attribute, mixed $subject): bool
{ {
return str_starts_with($attribute, self::PREFIX); return str_starts_with($attribute, self::PREFIX);

View file

@ -0,0 +1,138 @@
<?php
declare(strict_types=1);
namespace App\Tests\Integration\Infrastructure\Channel\Frappe;
use App\Domain\Article\Article;
use App\Domain\Article\ArticleCondition;
use App\Domain\Article\ArticleType;
use App\Domain\Channel\Platform;
use App\Domain\Order\Customer;
use App\Domain\Order\Order;
use App\Infrastructure\Channel\Frappe\FrappeErpAdapter;
use App\Infrastructure\Channel\Frappe\FrappeHttpClient;
use PHPUnit\Framework\TestCase;
use Symfony\Component\HttpClient\HttpClient;
/**
* Integration tests against the live ERPNext staging instance.
* Requires FRAPPE_ERP_BASE_URL, FRAPPE_ERP_API_KEY, FRAPPE_ERP_API_SECRET,
* FRAPPE_GENERIC_ITEM_CODE in .env.local.
*
* Run with: bin/test-integration
*/
final class FrappeErpAdapterIntegrationTest extends TestCase
{
private FrappeErpAdapter $adapter;
private FrappeHttpClient $client;
private string $createdInvoiceName = '';
protected function setUp(): void
{
$baseUrl = $_SERVER['FRAPPE_ERP_BASE_URL'] ?? getenv('FRAPPE_ERP_BASE_URL');
$apiKey = $_SERVER['FRAPPE_ERP_API_KEY'] ?? getenv('FRAPPE_ERP_API_KEY');
$apiSecret = $_SERVER['FRAPPE_ERP_API_SECRET'] ?? getenv('FRAPPE_ERP_API_SECRET');
$itemCode = $_SERVER['FRAPPE_GENERIC_ITEM_CODE'] ?? getenv('FRAPPE_GENERIC_ITEM_CODE');
if (!$baseUrl || !$apiKey || !$apiSecret || !$itemCode) {
$this->markTestSkipped('FRAPPE_ERP_* env vars not set');
}
$this->client = new FrappeHttpClient(
HttpClient::create(),
(string) $baseUrl,
(string) $apiKey,
(string) $apiSecret,
);
$this->adapter = new FrappeErpAdapter($this->client, (string) $itemCode);
}
protected function tearDown(): void
{
if ('' !== $this->createdInvoiceName) {
try {
$this->client->put('/api/resource/Sales Invoice/'.$this->createdInvoiceName, ['docstatus' => 2]);
$this->client->delete('/api/resource/Sales Invoice/'.$this->createdInvoiceName);
} catch (\Throwable) {
// best-effort cleanup
}
}
}
public function test_find_simon_kuehn_by_name_and_address(): void
{
$customerId = $this->adapter->findExistingCustomer(
'Simon Kühn',
'Kirchstr. 1',
'Karbach',
'56281',
);
$this->assertNotNull($customerId, 'Simon Kühn should exist in Frappe staging');
$this->assertStringStartsWith('Simon', $customerId);
}
public function test_unknown_person_is_not_found(): void
{
$customerId = $this->adapter->findExistingCustomer(
'Voldemort',
'Dunkle Gasse 1',
'Nirgendwo',
'00000',
);
$this->assertNull($customerId);
}
public function test_find_simon_and_create_invoice_for_1337(): void
{
$frappeId = $this->adapter->findExistingCustomer(
'Simon Kühn',
'Kirchstr. 1',
'Karbach',
'56281',
);
$this->assertNotNull($frappeId, 'Simon Kühn should exist in Frappe staging');
$simon = new Customer('Simon Kühn', 'simon.kuehn83@gmail.com', [
'street' => 'Kirchstr. 1',
'city' => 'Karbach',
'zip' => '56281',
]);
$simon->setFrappeCustomerId($frappeId);
$article = new Article(
new ArticleType('Laptop'),
'LAP-THINK-SIMON',
'INV-THINK-SIMON',
1,
ArticleCondition::Good,
);
$article->setManufacturer('Lenovo');
$article->setModelName('ThinkBook 14 G6 IRL');
$article->setModelNumber('21KG00NQGE');
$article->setSerialNumber('PNV09SJZ');
$article->setEbayTitle('Lenovo ThinkBook 14 G6 IRL — generalüberholt, läuft wie Butter');
$order = new Order(
$article,
$simon,
new Platform('direct', 'Direktverkauf'),
'ORDER-SIMON-1337',
'1337.00',
new \DateTimeImmutable('2026-05-18'),
);
$invoiceId = $this->adapter->createSalesInvoice($order);
$this->assertNotEmpty($invoiceId);
$this->createdInvoiceName = $invoiceId;
// Verify the invoice actually exists in Frappe
$response = $this->client->get('/api/resource/Sales Invoice/'.$invoiceId);
$this->assertSame($invoiceId, $response['data']['name']);
$this->assertEquals(1337.0, $response['data']['grand_total']);
}
}

View file

@ -44,12 +44,15 @@ final class FrappeErpAdapterTest extends TestCase
public function test_create_sales_invoice_submits_and_returns_id(): void public function test_create_sales_invoice_submits_and_returns_id(): void
{ {
$this->frappe $this->frappe
->expects($this->exactly(2)) ->expects($this->once())
->method('post') ->method('post')
->willReturnOnConsecutiveCalls( ->willReturn(['data' => ['name' => 'SINV-00001']]);
['data' => ['name' => 'SINV-00001']],
['data' => ['name' => 'SINV-00001', 'docstatus' => 1]], $this->frappe
); ->expects($this->once())
->method('put')
->with($this->stringContains('SINV-00001'), ['docstatus' => 1])
->willReturn(['data' => ['name' => 'SINV-00001', 'docstatus' => 1]]);
$customer = new Customer('Max Mustermann', 'max@test.de', ['street' => 'Str 1', 'city' => 'Berlin', 'zip' => '10115']); $customer = new Customer('Max Mustermann', 'max@test.de', ['street' => 'Str 1', 'city' => 'Berlin', 'zip' => '10115']);
$customer->setFrappeCustomerId('CUST-00001'); $customer->setFrappeCustomerId('CUST-00001');
@ -79,4 +82,101 @@ final class FrappeErpAdapterTest extends TestCase
$this->assertStringStartsWith('%PDF', $result); $this->assertStringStartsWith('%PDF', $result);
} }
public function test_find_existing_customer_returns_id_when_address_matches(): void
{
$this->frappe
->expects($this->exactly(2))
->method('get')
->willReturnOnConsecutiveCalls(
['data' => [['name' => 'CUST-99999']]],
['data' => [['address_line1' => 'Kirchstr. 1', 'city' => 'Karbach', 'pincode' => '56281']]],
);
$result = $this->adapter->findExistingCustomer('Simon Kühn', 'Kirchstr. 1', 'Karbach', '56281');
$this->assertSame('CUST-99999', $result);
}
public function test_find_existing_customer_returns_null_when_address_mismatch(): void
{
$this->frappe
->expects($this->exactly(2))
->method('get')
->willReturnOnConsecutiveCalls(
['data' => [['name' => 'CUST-99999']]],
['data' => [['address_line1' => 'Musterstr. 5', 'city' => 'Berlin', 'pincode' => '10115']]],
);
$result = $this->adapter->findExistingCustomer('Simon Kühn', 'Kirchstr. 1', 'Karbach', '56281');
$this->assertNull($result);
}
public function test_find_existing_customer_returns_null_when_not_in_erp(): void
{
$this->frappe
->expects($this->once())
->method('get')
->willReturn(['data' => []]);
$result = $this->adapter->findExistingCustomer('Nobody', 'Unknown St. 1', 'Nowhere', '00000');
$this->assertNull($result);
}
public function test_find_simon_and_create_invoice_for_1337(): void
{
$this->frappe
->expects($this->exactly(2))
->method('get')
->willReturnOnConsecutiveCalls(
['data' => [['name' => 'CUST-99999']]],
['data' => [['address_line1' => 'Kirchstr. 1', 'city' => 'Karbach', 'pincode' => '56281']]],
);
$this->frappe
->expects($this->once())
->method('post')
->willReturn(['data' => ['name' => 'SINV-13370']]);
$this->frappe
->expects($this->once())
->method('put')
->willReturn(['data' => ['name' => 'SINV-13370', 'docstatus' => 1]]);
$frappeId = $this->adapter->findExistingCustomer('Simon Kühn', 'Kirchstr. 1', 'Karbach', '56281');
$this->assertSame('CUST-99999', $frappeId);
$simon = new Customer('Simon Kühn', 'simon.kuehn83@gmail.com', [
'street' => 'Kirchstr. 1',
'city' => 'Karbach',
'zip' => '56281',
]);
$simon->setFrappeCustomerId($frappeId);
$article = new Article(
new ArticleType('Laptop'),
'LAP-THINK-001',
'INV-THINK-001',
1,
ArticleCondition::Good,
);
$article->setManufacturer('Lenovo');
$article->setModelName('ThinkBook 14 G6 IRL');
$article->setModelNumber('21KG00NQGE');
$article->setEbayTitle('Lenovo ThinkBook 14 G6 IRL — generalüberholt, top Zustand');
$order = new Order(
$article,
$simon,
new Platform('direct', 'Direktverkauf'),
'ORDER-SIMON-1337',
'1337.00',
new \DateTimeImmutable('2026-05-18'),
);
$invoiceId = $this->adapter->createSalesInvoice($order);
$this->assertSame('SINV-13370', $invoiceId);
}
} }

View file

@ -57,6 +57,7 @@ field.attempts: Versuche
field.started: Gestartet field.started: Gestartet
field.error: Fehler field.error: Fehler
field.ai_results: 'KI-Ergebnisse' field.ai_results: 'KI-Ergebnisse'
field.stock: Anzahl
field.price: Preis field.price: Preis
field.condition: Zustand field.condition: Zustand
field.manufacturer: Hersteller field.manufacturer: Hersteller

View file

@ -57,6 +57,7 @@ field.attempts: Attempts
field.started: Started field.started: Started
field.error: Error field.error: Error
field.ai_results: 'AI Results' field.ai_results: 'AI Results'
field.stock: Stock
field.price: Price field.price: Price
field.condition: Condition field.condition: Condition
field.manufacturer: Manufacturer field.manufacturer: Manufacturer