diff --git a/bin/test-integration b/bin/test-integration index 93d28c9..cf1d038 100755 --- a/bin/test-integration +++ b/bin/test-integration @@ -13,4 +13,5 @@ docker compose exec \ -e FRAPPE_ERP_BASE_URL="${FRAPPE_ERP_BASE_URL:-}" \ -e FRAPPE_ERP_API_KEY="${FRAPPE_ERP_API_KEY:-}" \ -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/}" diff --git a/config/packages/doctrine.yaml b/config/packages/doctrine.yaml index 693900e..d3ac6af 100644 --- a/config/packages/doctrine.yaml +++ b/config/packages/doctrine.yaml @@ -1,7 +1,7 @@ doctrine: dbal: url: '%env(resolve:DATABASE_URL)%' - schema_filter: ~^(?!logs_archive\.)~ + schema_filter: ~^(?!logs_archive\.|app\.inventory_seq$)~ orm: naming_strategy: doctrine.orm.naming_strategy.underscore_number_aware auto_mapping: true diff --git a/migrations/Version20260518150000.php b/migrations/Version20260518150000.php new file mode 100644 index 0000000..f337bb7 --- /dev/null +++ b/migrations/Version20260518150000.php @@ -0,0 +1,26 @@ +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'); + } +} diff --git a/migrations/Version20260518160000.php b/migrations/Version20260518160000.php new file mode 100644 index 0000000..6071c83 --- /dev/null +++ b/migrations/Version20260518160000.php @@ -0,0 +1,68 @@ + + +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: + +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', + ]); + } +} diff --git a/public/css/admin/custom.css b/public/css/admin/custom.css new file mode 100644 index 0000000..42e2b14 --- /dev/null +++ b/public/css/admin/custom.css @@ -0,0 +1,5 @@ +/* Required field asterisk */ +label.required::after { + content: " *"; + color: #dc3545; +} diff --git a/src/Application/Order/ErpAdapterInterface.php b/src/Application/Order/ErpAdapterInterface.php index 2d69686..0edff5a 100644 --- a/src/Application/Order/ErpAdapterInterface.php +++ b/src/Application/Order/ErpAdapterInterface.php @@ -26,4 +26,10 @@ interface ErpAdapterInterface * Returns raw binary PDF content. */ 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; } diff --git a/src/Domain/Article/Article.php b/src/Domain/Article/Article.php index cf94947..d8d48b9 100644 --- a/src/Domain/Article/Article.php +++ b/src/Domain/Article/Article.php @@ -63,6 +63,9 @@ class Article #[ORM\Column(type: 'text', nullable: true)] private ?string $ebayDescription = null; + #[ORM\Column(type: 'text', nullable: true)] + private ?string $specsText = null; + /** @var Collection */ #[ORM\OneToMany(mappedBy: 'article', targetEntity: AttributeValue::class, cascade: ['persist', 'remove'])] private Collection $attributeValues; @@ -198,6 +201,16 @@ class Article $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 */ public function getAttributeValues(): Collection { @@ -269,4 +282,14 @@ class Article { $this->ebayDescription = $desc; } + + public function getSpecsText(): ?string + { + return $this->specsText; + } + + public function setSpecsText(?string $specsText): void + { + $this->specsText = $specsText; + } } diff --git a/src/Domain/Article/AttributeValue.php b/src/Domain/Article/AttributeValue.php index e9c3eb8..2db9f2e 100644 --- a/src/Domain/Article/AttributeValue.php +++ b/src/Domain/Article/AttributeValue.php @@ -48,6 +48,11 @@ class AttributeValue return $this->id; } + public function getArticle(): Article + { + return $this->article; + } + public function getAttributeDefinition(): AttributeDefinition { return $this->attributeDefinition; diff --git a/src/Domain/Article/Repository/ArticleRepositoryInterface.php b/src/Domain/Article/Repository/ArticleRepositoryInterface.php index 4c09bc8..ee54aac 100644 --- a/src/Domain/Article/Repository/ArticleRepositoryInterface.php +++ b/src/Domain/Article/Repository/ArticleRepositoryInterface.php @@ -21,6 +21,13 @@ interface ArticleRepositoryInterface /** @return list
*/ 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 save(Article $article): void; diff --git a/src/Domain/Auth/User.php b/src/Domain/Auth/User.php index 543905a..e54287c 100644 --- a/src/Domain/Auth/User.php +++ b/src/Domain/Auth/User.php @@ -142,4 +142,19 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface, TotpTwo { unset($this->permissions[$permission]); } + + /** @return list */ + public function getGrantedPermissions(): array + { + return array_keys(array_filter($this->permissions)); + } + + /** @param list $granted */ + public function setGrantedPermissions(array $granted): void + { + $this->permissions = []; + foreach ($granted as $permission) { + $this->permissions[$permission] = true; + } + } } diff --git a/src/Infrastructure/AI/Agent/SpecsResearchAgent.php b/src/Infrastructure/AI/Agent/SpecsResearchAgent.php index 618b0da..3e05080 100644 --- a/src/Infrastructure/AI/Agent/SpecsResearchAgent.php +++ b/src/Infrastructure/AI/Agent/SpecsResearchAgent.php @@ -19,18 +19,25 @@ final class SpecsResearchAgent } /** + * @param list $attributeFields Attribute names defined for this article type + * * @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); $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', [ 'articleType' => $articleTypeName, 'subject' => $subject, 'searchResults' => $searchResults !== '' ? $searchResults : 'No web results available.', + 'fields' => $fieldsList, ]); $result = $this->client->generate($this->model, $prompt); diff --git a/src/Infrastructure/AI/PromptTemplateService.php b/src/Infrastructure/AI/PromptTemplateService.php index 743017e..c23f610 100644 --- a/src/Infrastructure/AI/PromptTemplateService.php +++ b/src/Infrastructure/AI/PromptTemplateService.php @@ -39,7 +39,7 @@ final class PromptTemplateService public static function knownKeys(): array { return [ - 'specs_research' => ['articleType', 'subject', 'searchResults'], + 'specs_research' => ['articleType', 'subject', 'searchResults', 'fields'], 'ebay_title' => ['typeName', 'deviceLabel', 'condition', 'specsSection'], 'ebay_description' => ['typeName', 'deviceLabel', 'condition', 'conditionNotes', 'specsSection'], 'vision_analyze' => [], diff --git a/src/Infrastructure/Channel/Frappe/FrappeErpAdapter.php b/src/Infrastructure/Channel/Frappe/FrappeErpAdapter.php index f65b86a..52c2dba 100644 --- a/src/Infrastructure/Channel/Frappe/FrappeErpAdapter.php +++ b/src/Infrastructure/Channel/Frappe/FrappeErpAdapter.php @@ -5,6 +5,7 @@ declare(strict_types=1); namespace App\Infrastructure\Channel\Frappe; use App\Application\Order\ErpAdapterInterface; +use App\Domain\Article\Article; use App\Domain\Order\Customer; use App\Domain\Order\Order; @@ -50,7 +51,7 @@ final class FrappeErpAdapter implements ErpAdapterInterface [ 'item_code' => $this->genericItemCode, 'item_name' => $article->getEbayTitle() ?? $article->getSku(), - 'description' => \sprintf('%s — Inventar: %s', $article->getEbayTitle() ?? $article->getSku(), $article->getInventoryNumber()), + 'description' => $this->buildItemDescription($article), 'qty' => 1, 'rate' => $order->getSalePrice(), ], @@ -62,11 +63,77 @@ final class FrappeErpAdapter implements ErpAdapterInterface /** @var array{data: array{name: string}} $draft */ $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; } + 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} $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} $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 { $path = http_build_query([ diff --git a/src/Infrastructure/Channel/Frappe/FrappeHttpClient.php b/src/Infrastructure/Channel/Frappe/FrappeHttpClient.php index 1ba2f1d..1baa9f4 100644 --- a/src/Infrastructure/Channel/Frappe/FrappeHttpClient.php +++ b/src/Infrastructure/Channel/Frappe/FrappeHttpClient.php @@ -59,6 +59,29 @@ class FrappeHttpClient return $result; } + /** + * PUT (update) a Frappe resource. + * + * @param array $data + * + * @return array + */ + 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 $result */ + $result = $response->toArray(); + + return $result; + } + /** * DELETE a Frappe resource. * diff --git a/src/Infrastructure/Http/Controller/Admin/ArticleCrudController.php b/src/Infrastructure/Http/Controller/Admin/ArticleCrudController.php index ed5fe2e..552c1aa 100644 --- a/src/Infrastructure/Http/Controller/Admin/ArticleCrudController.php +++ b/src/Infrastructure/Http/Controller/Admin/ArticleCrudController.php @@ -25,6 +25,7 @@ use EasyCorp\Bundle\EasyAdminBundle\Field\ChoiceField; use EasyCorp\Bundle\EasyAdminBundle\Field\CollectionField; use EasyCorp\Bundle\EasyAdminBundle\Field\Field; use EasyCorp\Bundle\EasyAdminBundle\Field\IdField; +use EasyCorp\Bundle\EasyAdminBundle\Field\IntegerField; use EasyCorp\Bundle\EasyAdminBundle\Field\MoneyField; use EasyCorp\Bundle\EasyAdminBundle\Field\TextareaField; use EasyCorp\Bundle\EasyAdminBundle\Field\TextField; @@ -110,6 +111,8 @@ final class ArticleCrudController extends AbstractCrudController ArticleStatus::cases(), ) )->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 ChoiceField::new('condition', new TranslatableMessage('field.condition', [], 'admin'))->setChoices( array_combine( diff --git a/src/Infrastructure/Http/Controller/Admin/DashboardController.php b/src/Infrastructure/Http/Controller/Admin/DashboardController.php index 35dc649..ab2772a 100644 --- a/src/Infrastructure/Http/Controller/Admin/DashboardController.php +++ b/src/Infrastructure/Http/Controller/Admin/DashboardController.php @@ -43,7 +43,9 @@ final class DashboardController extends AbstractDashboardController 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 diff --git a/src/Infrastructure/Http/Controller/Admin/ManualIngestController.php b/src/Infrastructure/Http/Controller/Admin/ManualIngestController.php index 6bbd468..cc0ff1c 100644 --- a/src/Infrastructure/Http/Controller/Admin/ManualIngestController.php +++ b/src/Infrastructure/Http/Controller/Admin/ManualIngestController.php @@ -84,6 +84,7 @@ final class ManualIngestController extends AbstractController 'articleTypeId' => $articleType->getId()->toRfc4122(), 'condition' => $form->get('condition')->getData()->value, 'conditionNotes' => $form->get('conditionNotes')->getData(), + 'stock' => (int) $form->get('stock')->getData(), 'originalFilename' => $image->getClientOriginalName(), 'storedPhotoPath' => $storedPath, 'extraPhotos' => $extraPhotos, diff --git a/src/Infrastructure/Http/Controller/Admin/UserCrudController.php b/src/Infrastructure/Http/Controller/Admin/UserCrudController.php index 161ea7e..5ec8d4b 100644 --- a/src/Infrastructure/Http/Controller/Admin/UserCrudController.php +++ b/src/Infrastructure/Http/Controller/Admin/UserCrudController.php @@ -5,11 +5,13 @@ declare(strict_types=1); namespace App\Infrastructure\Http\Controller\Admin; use App\Domain\Auth\User; +use App\Infrastructure\Security\PermissionVoter; use EasyCorp\Bundle\EasyAdminBundle\Config\Action; use EasyCorp\Bundle\EasyAdminBundle\Config\Actions; use EasyCorp\Bundle\EasyAdminBundle\Config\Crud; use EasyCorp\Bundle\EasyAdminBundle\Controller\AbstractCrudController; use EasyCorp\Bundle\EasyAdminBundle\Field\BooleanField; +use EasyCorp\Bundle\EasyAdminBundle\Field\ChoiceField; use EasyCorp\Bundle\EasyAdminBundle\Field\IdField; use EasyCorp\Bundle\EasyAdminBundle\Field\TextField; use Symfony\Component\Translation\TranslatableMessage; @@ -37,5 +39,14 @@ final class UserCrudController extends AbstractCrudController yield IdField::new('id')->hideOnForm()->hideOnIndex(); yield TextField::new('email')->setFormTypeOption('disabled', true); yield BooleanField::new('isActive'); + + $allPerms = PermissionVoter::allPermissions(); + $choices = array_combine($allPerms, $allPerms); + + yield ChoiceField::new('grantedPermissions', 'Permissions') + ->setChoices($choices) + ->allowMultipleChoices() + ->renderExpanded() + ->hideOnIndex(); } } diff --git a/src/Infrastructure/Http/Form/AttributeValueFormType.php b/src/Infrastructure/Http/Form/AttributeValueFormType.php index 118242d..4b3144e 100644 --- a/src/Infrastructure/Http/Form/AttributeValueFormType.php +++ b/src/Infrastructure/Http/Form/AttributeValueFormType.php @@ -23,11 +23,21 @@ final class AttributeValueFormType extends AbstractType } $def = $av->getAttributeDefinition(); + $defId = $def->getId()->toRfc4122(); $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, [ 'label' => $label, - 'required' => false, + 'required' => $isRequired, + 'empty_data' => '', ]); }); } diff --git a/src/Infrastructure/Http/Form/ManualIngestType.php b/src/Infrastructure/Http/Form/ManualIngestType.php index 55b61e6..6ab3273 100644 --- a/src/Infrastructure/Http/Form/ManualIngestType.php +++ b/src/Infrastructure/Http/Form/ManualIngestType.php @@ -10,9 +10,11 @@ use Symfony\Bridge\Doctrine\Form\Type\EntityType; use Symfony\Component\Form\AbstractType; use Symfony\Component\Form\Extension\Core\Type\EnumType; 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\FormBuilderInterface; use Symfony\Component\OptionsResolver\OptionsResolver; +use Symfony\Component\Validator\Constraints\GreaterThanOrEqual; use Symfony\Component\Validator\Constraints\Image; use Symfony\Component\Validator\Constraints\NotNull; @@ -42,6 +44,12 @@ final class ManualIngestType extends AbstractType ], 'attr' => ['accept' => 'image/*', 'capture' => 'environment'], ]) + ->add('stock', IntegerType::class, [ + 'label' => 'Quantity', + 'data' => 1, + 'attr' => ['min' => 1], + 'constraints' => [new GreaterThanOrEqual(1)], + ]) ->add('conditionNotes', TextareaType::class, [ 'label' => 'Condition Notes', 'required' => false, diff --git a/src/Infrastructure/Messenger/Handler/DraftArticleHandler.php b/src/Infrastructure/Messenger/Handler/DraftArticleHandler.php index d1aaaa2..d3f7a08 100644 --- a/src/Infrastructure/Messenger/Handler/DraftArticleHandler.php +++ b/src/Infrastructure/Messenger/Handler/DraftArticleHandler.php @@ -50,11 +50,12 @@ final class DraftArticleHandler } else { $condition = ArticleCondition::tryFrom($message->condition) ?? ArticleCondition::Good; $inventoryNumber = $message->inventoryNumber ?? ($job->getInputData()['inventoryNumber'] ?? null); + $stock = max(1, (int) ($job->getInputData()['stock'] ?? 1)); $article = $this->articleService->create( articleTypeId: Uuid::fromString($message->articleTypeId), condition: $condition, - stock: 1, + stock: $stock, inventoryNumber: $inventoryNumber, ); } @@ -76,6 +77,26 @@ final class DraftArticleHandler $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) { $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( jobId: $message->jobId, articleId: $article->getId()->toRfc4122(), diff --git a/src/Infrastructure/Messenger/Handler/PhotoUploadHandler.php b/src/Infrastructure/Messenger/Handler/PhotoUploadHandler.php index cf2d997..52bf856 100644 --- a/src/Infrastructure/Messenger/Handler/PhotoUploadHandler.php +++ b/src/Infrastructure/Messenger/Handler/PhotoUploadHandler.php @@ -4,8 +4,10 @@ declare(strict_types=1); namespace App\Infrastructure\Messenger\Handler; +use App\Domain\Article\Repository\ArticleRepositoryInterface; use App\Domain\Pipeline\Repository\AIPipelineJobRepositoryInterface; use App\Infrastructure\AI\Agent\OllamaVisionAgent; +use App\Infrastructure\Messenger\Message\DraftArticleMessage; use App\Infrastructure\Messenger\Message\PhotoUploadMessage; use App\Infrastructure\Messenger\Message\SpecsResearchMessage; use Symfony\Component\Messenger\Attribute\AsMessageHandler; @@ -18,6 +20,7 @@ final class PhotoUploadHandler public function __construct( private readonly OllamaVisionAgent $visionAgent, private readonly AIPipelineJobRepositoryInterface $jobRepository, + private readonly ArticleRepositoryInterface $articleRepository, private readonly MessageBusInterface $bus, ) { } @@ -50,6 +53,39 @@ final class PhotoUploadHandler 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( jobId: $message->jobId, articleTypeId: $message->articleTypeId, diff --git a/src/Infrastructure/Messenger/Handler/SpecsResearchHandler.php b/src/Infrastructure/Messenger/Handler/SpecsResearchHandler.php index ec43d90..d774005 100644 --- a/src/Infrastructure/Messenger/Handler/SpecsResearchHandler.php +++ b/src/Infrastructure/Messenger/Handler/SpecsResearchHandler.php @@ -44,11 +44,20 @@ final class SpecsResearchHandler $parts = array_filter([$message->modelName, $message->modelNumber]); $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 { $result = $this->specsAgent->research( $searchSubject, $articleType->getName(), $message->manufacturer, + $attributeFields, ); } catch (\RuntimeException $e) { $job->markNeedsReview('SpecsResearchAgent: '.$e->getMessage()); diff --git a/src/Infrastructure/Messenger/Message/SpecsResearchMessage.php b/src/Infrastructure/Messenger/Message/SpecsResearchMessage.php index e452856..17346af 100644 --- a/src/Infrastructure/Messenger/Message/SpecsResearchMessage.php +++ b/src/Infrastructure/Messenger/Message/SpecsResearchMessage.php @@ -6,6 +6,9 @@ namespace App\Infrastructure\Messenger\Message; final readonly class SpecsResearchMessage { + /** + * @param list $attributeFields Attribute definition names for this article type + */ public function __construct( public string $jobId, public string $articleTypeId, @@ -13,6 +16,7 @@ final readonly class SpecsResearchMessage public string $modelName, public string $serialNumber, public string $manufacturer = '', + public array $attributeFields = [], ) { } } diff --git a/src/Infrastructure/Messenger/PipelineJobFailureListener.php b/src/Infrastructure/Messenger/PipelineJobFailureListener.php new file mode 100644 index 0000000..004d5fd --- /dev/null +++ b/src/Infrastructure/Messenger/PipelineJobFailureListener.php @@ -0,0 +1,58 @@ +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); + } +} diff --git a/src/Infrastructure/Persistence/Repository/DoctrineArticleRepository.php b/src/Infrastructure/Persistence/Repository/DoctrineArticleRepository.php index 87ffc58..d3760aa 100644 --- a/src/Infrastructure/Persistence/Repository/DoctrineArticleRepository.php +++ b/src/Infrastructure/Persistence/Repository/DoctrineArticleRepository.php @@ -43,6 +43,26 @@ final class DoctrineArticleRepository implements ArticleRepositoryInterface 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 { $affected = $this->em->getConnection()->executeStatement( diff --git a/src/Infrastructure/Security/PermissionVoter.php b/src/Infrastructure/Security/PermissionVoter.php index 4f75a96..6ae97ea 100644 --- a/src/Infrastructure/Security/PermissionVoter.php +++ b/src/Infrastructure/Security/PermissionVoter.php @@ -15,6 +15,26 @@ final class PermissionVoter extends Voter { 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 */ + 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 { return str_starts_with($attribute, self::PREFIX); diff --git a/tests/Integration/Infrastructure/Channel/Frappe/FrappeErpAdapterIntegrationTest.php b/tests/Integration/Infrastructure/Channel/Frappe/FrappeErpAdapterIntegrationTest.php new file mode 100644 index 0000000..281f092 --- /dev/null +++ b/tests/Integration/Infrastructure/Channel/Frappe/FrappeErpAdapterIntegrationTest.php @@ -0,0 +1,138 @@ +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']); + } +} diff --git a/tests/Unit/Infrastructure/Channel/Frappe/FrappeErpAdapterTest.php b/tests/Unit/Infrastructure/Channel/Frappe/FrappeErpAdapterTest.php index bef8d86..cb0bc51 100644 --- a/tests/Unit/Infrastructure/Channel/Frappe/FrappeErpAdapterTest.php +++ b/tests/Unit/Infrastructure/Channel/Frappe/FrappeErpAdapterTest.php @@ -44,12 +44,15 @@ final class FrappeErpAdapterTest extends TestCase public function test_create_sales_invoice_submits_and_returns_id(): void { $this->frappe - ->expects($this->exactly(2)) + ->expects($this->once()) ->method('post') - ->willReturnOnConsecutiveCalls( - ['data' => ['name' => 'SINV-00001']], - ['data' => ['name' => 'SINV-00001', 'docstatus' => 1]], - ); + ->willReturn(['data' => ['name' => 'SINV-00001']]); + + $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->setFrappeCustomerId('CUST-00001'); @@ -79,4 +82,101 @@ final class FrappeErpAdapterTest extends TestCase $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); + } } diff --git a/translations/admin.de.yaml b/translations/admin.de.yaml index 1edba2f..0e1d236 100644 --- a/translations/admin.de.yaml +++ b/translations/admin.de.yaml @@ -57,6 +57,7 @@ field.attempts: Versuche field.started: Gestartet field.error: Fehler field.ai_results: 'KI-Ergebnisse' +field.stock: Anzahl field.price: Preis field.condition: Zustand field.manufacturer: Hersteller diff --git a/translations/admin.en.yaml b/translations/admin.en.yaml index 7d75703..ba588a1 100644 --- a/translations/admin.en.yaml +++ b/translations/admin.en.yaml @@ -57,6 +57,7 @@ field.attempts: Attempts field.started: Started field.error: Error field.ai_results: 'AI Results' +field.stock: Stock field.price: Price field.condition: Condition field.manufacturer: Manufacturer