# SuperSeller3000 — Plan 6: Order Processing > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** Vollständige Order-Verarbeitung: eBay-Webhooks in Frappe-Rechnungen umwandeln, Kunden-Matching-Kaskade, PDF-Versand an Lieferanten, Bestandssynchronisation nach Verkauf und Tracking-Übermittlung an eBay. **Architecture:** `OrderReceivedMessage` (aus Plan 5) triggert einen sequenziellen Handler: InventoryLock → CustomerResolver → Frappe-Invoice → PDF-Speicherung → E-Mail → Channel-Sync. Alle Ports (ErpAdapterInterface, CustomerResolverInterface, InvoiceMailerInterface) im Application-Layer; Implementierungen in Infrastructure. `EbayFulfillmentApiClient` holt vollständige Bestelldaten (Käuferadresse, Preis, Listing-ID) von der eBay Sell Fulfillment API v1, damit Customer-Matching und Artikel-Lookup funktionieren. `TrackingPushMessage` + Handler übermitteln Sendungsverfolgung asynchron über den `channel_sync`-Transport. **Tech Stack:** PHP 8.4, Symfony 7, Symfony Messenger, Symfony Mailer (SMTP), eBay Sell Fulfillment API v1, Frappe ERP REST API, PHPStan Level 9 --- ## Dateistruktur (gesamter Plan) ``` src/ Application/ Order/ ErpAdapterInterface.php # Port: createCustomer, createSalesInvoice, fetchInvoicePdf CustomerResolverInterface.php # Port: resolve() → Customer (find-or-create) InvoiceMailerInterface.php # Port: sendInvoice(Invoice) → void Domain/ Order/ Repository/ InvoiceRepositoryInterface.php # NEU Infrastructure/ Channel/ Ebay/ EbayFulfillmentApiClient.php # GET /sell/fulfillment/v1/order/{id} Frappe/ FrappeHttpClient.php # Low-level HTTP wrapper (nicht final → mockbar) FrappeErpAdapter.php # implements ErpAdapterInterface Order/ CustomerResolver.php # implements CustomerResolverInterface Mail/ SymfonyInvoiceMailer.php # implements InvoiceMailerInterface Messenger/ Message/ TrackingPushMessage.php Handler/ OrderReceivedHandler.php # Haupt-Orchestrator TrackingPushHandler.php Persistence/ Repository/ DoctrineInvoiceRepository.php Http/ Controller/ OrderController.php # PATCH /api/orders/{id}/tracking Admin/ OrderCrudController.php CustomerCrudController.php InvoiceCrudController.php config/ packages/ mailer.yaml tests/ Unit/ Infrastructure/ Order/ CustomerResolverTest.php Channel/ Frappe/ FrappeErpAdapterTest.php ``` **Modifizierte Dateien:** - `src/Domain/Article/Repository/ArticleRepositoryInterface.php` — `findByEbayListingId` ergänzen - `src/Infrastructure/Persistence/Repository/DoctrineArticleRepository.php` — implementieren - `config/services.yaml` — InvoiceRepository-Alias + neue Wirings - `config/packages/messenger.yaml` — TrackingPushMessage routing - `src/Infrastructure/Http/Admin/DashboardController.php` — neue CRUD-Controller registrieren --- ## Task 1: findByEbayListingId + InvoiceRepository **Files:** - Modify: `src/Domain/Article/Repository/ArticleRepositoryInterface.php` - Modify: `src/Infrastructure/Persistence/Repository/DoctrineArticleRepository.php` - Create: `src/Domain/Order/Repository/InvoiceRepositoryInterface.php` - Create: `src/Infrastructure/Persistence/Repository/DoctrineInvoiceRepository.php` - Modify: `config/services.yaml` - [ ] **Step 1: Failing test für findByEbayListingId schreiben** ```php assertTrue( method_exists(ArticleRepositoryInterface::class, 'findByEbayListingId'), ); } } ``` - [ ] **Step 2: Test ausführen — muss fehlschlagen** ```bash docker compose run --rm app ./vendor/bin/pest tests/Unit/Infrastructure/Persistence/DoctrineArticleRepositoryEbayTest.php # Expected: FAIL — method findByEbayListingId not found ``` - [ ] **Step 3: findByEbayListingId zum Interface ergänzen** Füge folgende Methode in `src/Domain/Article/Repository/ArticleRepositoryInterface.php` nach `findByInventoryNumber` ein: ```php public function findByEbayListingId(string $ebayListingId): ?Article; ``` - [ ] **Step 4: DoctrineArticleRepository implementieren** Füge folgende Methode in `src/Infrastructure/Persistence/Repository/DoctrineArticleRepository.php` nach `findByInventoryNumber` ein: ```php public function findByEbayListingId(string $ebayListingId): ?Article { return $this->em->getRepository(Article::class)->findOneBy(['ebayListingId' => $ebayListingId]); } ``` - [ ] **Step 5: Test ausführen — muss bestehen** ```bash docker compose run --rm app ./vendor/bin/pest tests/Unit/Infrastructure/Persistence/DoctrineArticleRepositoryEbayTest.php # Expected: PASS ``` - [ ] **Step 6: InvoiceRepositoryInterface schreiben** ```php em->find(Invoice::class, $id); } public function findByFrappeInvoiceId(string $frappeInvoiceId): ?Invoice { return $this->em->getRepository(Invoice::class)->findOneBy(['frappeInvoiceId' => $frappeInvoiceId]); } public function save(Invoice $invoice): void { $this->em->persist($invoice); $this->em->flush(); } } ``` - [ ] **Step 8: services.yaml ergänzen** ```yaml # config/services.yaml — nach den bestehenden Repository-Aliases anfügen: App\Domain\Order\Repository\InvoiceRepositoryInterface: alias: App\Infrastructure\Persistence\Repository\DoctrineInvoiceRepository ``` - [ ] **Step 9: PHPStan prüfen** ```bash docker compose run --rm app ./vendor/bin/phpstan analyse src/ --level=9 # Expected: 0 errors ``` - [ ] **Step 10: Commit** ```bash git add src/Domain/Article/Repository/ArticleRepositoryInterface.php \ src/Infrastructure/Persistence/Repository/DoctrineArticleRepository.php \ src/Domain/Order/Repository/InvoiceRepositoryInterface.php \ src/Infrastructure/Persistence/Repository/DoctrineInvoiceRepository.php \ config/services.yaml \ tests/Unit/Infrastructure/Persistence/DoctrineArticleRepositoryEbayTest.php git commit -m "feat: add findByEbayListingId to ArticleRepository, add InvoiceRepository" ``` --- ## Task 2: ErpAdapterInterface + FrappeHttpClient + FrappeErpAdapter **Files:** - Create: `src/Application/Order/ErpAdapterInterface.php` - Create: `src/Infrastructure/Channel/Frappe/FrappeHttpClient.php` - Create: `src/Infrastructure/Channel/Frappe/FrappeErpAdapter.php` - Test: `tests/Unit/Infrastructure/Channel/Frappe/FrappeErpAdapterTest.php` - Modify: `config/services.yaml` - Modify: `.env` - [ ] **Step 1: Failing tests schreiben** ```php frappe = $this->createMock(FrappeHttpClient::class); $this->adapter = new FrappeErpAdapter($this->frappe, 'REFURB-HW'); } public function test_create_customer_returns_frappe_id(): void { $this->frappe ->method('post') ->with('/api/resource/Customer', $this->isType('array')) ->willReturn(['data' => ['name' => 'CUST-00001']]); $customer = new Customer('Max Mustermann', 'max@test.de', ['street' => 'Str 1', 'city' => 'Berlin', 'zip' => '10115']); $customer->addPlatformId('ebay', 'buyer123'); $result = $this->adapter->createCustomer($customer); $this->assertSame('CUST-00001', $result); } public function test_create_sales_invoice_submits_and_returns_id(): void { $this->frappe ->expects($this->exactly(2)) ->method('post') ->willReturnOnConsecutiveCalls( ['data' => ['name' => 'SINV-00001']], ['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'); $article = $this->createArticleStub(); $platform = $this->createPlatformStub(); $order = new Order($article, $customer, $platform, 'ORDER-001', '299.99', new \DateTimeImmutable('2026-05-13')); $result = $this->adapter->createSalesInvoice($order); $this->assertSame('SINV-00001', $result); } public function test_fetch_invoice_pdf_returns_binary(): void { $this->frappe ->method('getContent') ->with($this->stringContains('SINV-00001')) ->willReturn('%PDF-binary-content'); $result = $this->adapter->fetchInvoicePdf('SINV-00001'); $this->assertStringStartsWith('%PDF', $result); } private function createArticleStub(): \App\Domain\Article\Article { $articleType = new \App\Domain\Article\ArticleType('Laptop'); return new \App\Domain\Article\Article($articleType, 'LAP-001', 1, \App\Domain\Article\ArticleCondition::Good); } private function createPlatformStub(): \App\Domain\Channel\Platform { return new \App\Domain\Channel\Platform('ebay', 'eBay DE', []); } } ``` - [ ] **Step 2: Tests ausführen — müssen fehlschlagen** ```bash docker compose run --rm app ./vendor/bin/pest tests/Unit/Infrastructure/Channel/Frappe/FrappeErpAdapterTest.php # Expected: FAIL — class FrappeErpAdapter not found ``` - [ ] **Step 3: ErpAdapterInterface schreiben** ```php authHeader = "token {$apiKey}:{$apiSecret}"; } /** * POST to a Frappe resource endpoint. * * @param array $data * @return array */ public function post(string $path, array $data): array { $response = $this->httpClient->request('POST', $this->baseUrl.$path, [ 'headers' => [ 'Authorization' => $this->authHeader, 'Content-Type' => 'application/json', ], 'json' => $data, ]); return $response->toArray(); } /** * GET raw binary content (for PDF downloads). */ public function getContent(string $path): string { $response = $this->httpClient->request('GET', $this->baseUrl.$path, [ 'headers' => ['Authorization' => $this->authHeader], ]); return $response->getContent(); } } ``` - [ ] **Step 5: FrappeErpAdapter schreiben** ```php $customer->getName(), 'customer_type' => 'Individual', 'customer_group' => 'Individual', 'territory' => 'Germany', 'custom_superseller_customer_id' => $customer->getId()->toRfc4122(), ]; $ebayUserId = $customer->getPlatformId('ebay'); if ($ebayUserId !== null) { $data['custom_ebay_user_id'] = $ebayUserId; } $response = $this->frappe->post('/api/resource/Customer', $data); return $response['data']['name']; } public function createSalesInvoice(Order $order): string { $article = $order->getArticle(); $customer = $order->getCustomer(); // Create draft invoice $draft = $this->frappe->post('/api/resource/Sales Invoice', [ 'customer' => $customer->getFrappeCustomerId(), 'posting_date' => $order->getSaleDate()->format('Y-m-d'), 'due_date' => $order->getSaleDate()->format('Y-m-d'), 'items' => [ [ 'item_code' => $this->genericItemCode, 'item_name' => $article->getEbayTitle() ?? $article->getSku(), 'description' => \sprintf('%s — Inventar: %s', $article->getEbayTitle() ?? $article->getSku(), $article->getInventoryNumber()), 'qty' => 1, 'rate' => $order->getSalePrice(), ], ], 'custom_platform_order_id' => $order->getPlatformOrderId(), 'custom_article_inventory_number' => $article->getInventoryNumber(), ]); $invoiceName = $draft['data']['name']; // Submit invoice (docstatus 1 = submitted) $this->frappe->post('/api/resource/Sales Invoice/'.$invoiceName.'/submit', []); return $invoiceName; } public function fetchInvoicePdf(string $frappeInvoiceId): string { $path = \http_build_query([ 'doctype' => 'Sales Invoice', 'name' => $frappeInvoiceId, 'format' => 'Standard', 'no_letterhead' => '0', '_lang' => 'de', ]); return $this->frappe->getContent('/api/method/frappe.utils.print_format.download_pdf?'.$path); } } ``` - [ ] **Step 6: Tests ausführen — müssen bestehen** ```bash docker compose run --rm app ./vendor/bin/pest tests/Unit/Infrastructure/Channel/Frappe/FrappeErpAdapterTest.php # Expected: PASS (3 tests) ``` - [ ] **Step 7: .env ergänzen** ```dotenv # .env — Frappe ERP FRAPPE_ERP_BASE_URL=https://erp.example.com FRAPPE_ERP_API_KEY=changeme FRAPPE_ERP_API_SECRET=changeme FRAPPE_GENERIC_ITEM_CODE=REFURB-HW ``` - [ ] **Step 8: services.yaml ergänzen** ```yaml # config/services.yaml App\Infrastructure\Channel\Frappe\FrappeHttpClient: arguments: $baseUrl: '%env(FRAPPE_ERP_BASE_URL)%' $apiKey: '%env(FRAPPE_ERP_API_KEY)%' $apiSecret: '%env(FRAPPE_ERP_API_SECRET)%' App\Infrastructure\Channel\Frappe\FrappeErpAdapter: arguments: $genericItemCode: '%env(FRAPPE_GENERIC_ITEM_CODE)%' App\Application\Order\ErpAdapterInterface: alias: App\Infrastructure\Channel\Frappe\FrappeErpAdapter ``` - [ ] **Step 9: PHPStan prüfen** ```bash docker compose run --rm app ./vendor/bin/phpstan analyse src/ --level=9 # Expected: 0 errors ``` - [ ] **Step 10: Commit** ```bash git add src/Application/Order/ErpAdapterInterface.php \ src/Infrastructure/Channel/Frappe/ \ tests/Unit/Infrastructure/Channel/Frappe/ \ config/services.yaml .env git commit -m "feat: add ErpAdapterInterface and FrappeErpAdapter (createCustomer, createSalesInvoice, fetchInvoicePdf)" ``` --- ## Task 3: EbayFulfillmentApiClient **Files:** - Create: `src/Infrastructure/Channel/Ebay/EbayFulfillmentApiClient.php` - Modify: `config/services.yaml` Der Client ruft die eBay Sell Fulfillment API v1 ab und gibt ein normiertes Array zurück, damit `OrderReceivedHandler` die Käuferadresse, den Artikel-Listing-ID und den Verkaufspreis kennt. - [ ] **Step 1: EbayFulfillmentApiClient schreiben** ```php */ public function getOrder(string $orderId): array { $token = $this->oauthClient->getAccessToken(); $response = $this->httpClient->request( 'GET', $this->apiBaseUrl.'/sell/fulfillment/v1/order/'.urlencode($orderId), ['headers' => ['Authorization' => 'Bearer '.$token]], ); /** @var array $data */ $data = $response->toArray(); $ship = $data['fulfillmentStartInstructions'][0]['shippingStep']['shipTo'] ?? []; $addr = $ship['contactAddress'] ?? []; $line = $data['lineItems'][0] ?? []; $buyer = $data['buyer'] ?? []; $buyerAddr = $buyer['buyerRegistrationAddress'] ?? []; return [ 'orderId' => (string) ($data['orderId'] ?? $orderId), 'buyerUsername' => (string) ($buyer['username'] ?? ''), 'buyerName' => (string) ($ship['fullName'] ?? $buyerAddr['fullName'] ?? ''), 'buyerEmail' => (string) ($ship['email'] ?? $buyerAddr['email'] ?? $buyer['username'].'@members.ebay.com'), 'shippingStreet' => (string) ($addr['addressLine1'] ?? ''), 'shippingCity' => (string) ($addr['city'] ?? ''), 'shippingZip' => (string) ($addr['postalCode'] ?? ''), 'shippingCountry' => (string) ($addr['countryCode'] ?? 'DE'), 'ebayListingId' => (string) ($line['legacyItemId'] ?? ''), 'salePrice' => (string) ($data['pricingSummary']['total']['value'] ?? '0.00'), 'saleDate' => (string) ($data['creationDate'] ?? (new \DateTimeImmutable())->format(\DateTimeInterface::ATOM)), ]; } } ``` - [ ] **Step 2: services.yaml ergänzen** ```yaml # config/services.yaml App\Infrastructure\Channel\Ebay\EbayFulfillmentApiClient: arguments: $apiBaseUrl: '%env(EBAY_API_BASE_URL)%' ``` (`EbayOAuthClient` und `HttpClientInterface` werden von Symfony per Autowiring injiziert.) - [ ] **Step 3: PHPStan prüfen** ```bash docker compose run --rm app ./vendor/bin/phpstan analyse src/Infrastructure/Channel/Ebay/EbayFulfillmentApiClient.php --level=9 # Expected: 0 errors ``` - [ ] **Step 4: Commit** ```bash git add src/Infrastructure/Channel/Ebay/EbayFulfillmentApiClient.php config/services.yaml git commit -m "feat: add EbayFulfillmentApiClient to fetch full order data from eBay Sell Fulfillment API v1" ``` --- ## Task 4: CustomerResolverInterface + CustomerResolver + InvoiceMailerInterface + SymfonyInvoiceMailer **Files:** - Create: `src/Application/Order/CustomerResolverInterface.php` - Create: `src/Application/Order/InvoiceMailerInterface.php` - Create: `src/Infrastructure/Order/CustomerResolver.php` - Create: `src/Infrastructure/Mail/SymfonyInvoiceMailer.php` - Test: `tests/Unit/Infrastructure/Order/CustomerResolverTest.php` - Create: `config/packages/mailer.yaml` - Modify: `config/services.yaml` - Modify: `.env` - [ ] **Step 1: Failing tests für CustomerResolver schreiben** ```php customerRepo = $this->createMock(CustomerRepositoryInterface::class); $this->erp = $this->createMock(ErpAdapterInterface::class); $this->resolver = new CustomerResolver($this->customerRepo, $this->erp); } public function test_stage_1_platform_id_match_returns_existing_customer_without_erp_call(): void { $existing = new Customer('Max Mustermann', 'max@test.de', ['street' => 'Musterstr 1', 'city' => 'Berlin', 'zip' => '10115']); $existing->addPlatformId('ebay', 'buyer123'); $this->customerRepo ->method('findByPlatformId') ->with('ebay', 'buyer123') ->willReturn($existing); $this->erp->expects($this->never())->method('createCustomer'); $this->customerRepo->expects($this->never())->method('save'); $result = $this->resolver->resolve( 'ebay', 'buyer123', 'Max Mustermann', 'max@test.de', ['street' => 'Musterstr 1', 'city' => 'Berlin', 'zip' => '10115'], ); $this->assertSame($existing, $result); } public function test_stage_2_address_match_adds_platform_id_and_saves(): void { $existing = new Customer('Max Mustermann', 'max@test.de', ['street' => 'Musterstr 1', 'city' => 'Berlin', 'zip' => '10115']); $this->customerRepo->method('findByPlatformId')->willReturn(null); $this->customerRepo ->method('findByMatchingKey') ->with($existing->getMatchingKey()) ->willReturn($existing); $this->customerRepo->expects($this->once())->method('save')->with($existing); $this->erp->expects($this->never())->method('createCustomer'); $result = $this->resolver->resolve( 'ebay', 'buyer456', 'Max Mustermann', 'max@test.de', ['street' => 'Musterstr 1', 'city' => 'Berlin', 'zip' => '10115'], ); $this->assertSame($existing, $result); $this->assertSame('buyer456', $result->getPlatformId('ebay')); } public function test_no_match_creates_new_customer_via_erp(): void { $this->customerRepo->method('findByPlatformId')->willReturn(null); $this->customerRepo->method('findByMatchingKey')->willReturn(null); $this->customerRepo->expects($this->once())->method('save'); $this->erp->method('createCustomer')->willReturn('CUST-99999'); $result = $this->resolver->resolve( 'ebay', 'newbuyer', 'Neue Käuferin', 'neu@test.de', ['street' => 'Neustr 5', 'city' => 'München', 'zip' => '80333'], ); $this->assertSame('CUST-99999', $result->getFrappeCustomerId()); $this->assertSame('newbuyer', $result->getPlatformId('ebay')); $this->assertSame('Neue Käuferin', $result->getName()); } } ``` - [ ] **Step 2: Tests ausführen — müssen fehlschlagen** ```bash docker compose run --rm app ./vendor/bin/pest tests/Unit/Infrastructure/Order/CustomerResolverTest.php # Expected: FAIL — class CustomerResolver not found ``` - [ ] **Step 3: CustomerResolverInterface schreiben** ```php $address Keys: street, city, zip */ public function resolve( string $platform, string $platformUserId, string $name, string $email, array $address, ): Customer; } ``` - [ ] **Step 4: CustomerResolver implementieren** ```php customers->findByPlatformId($platform, $platformUserId); if ($customer !== null) { return $customer; } // Stage 2: exact lowercase address match (cross-platform dedup) $probe = new Customer($name, $email, $address); $customer = $this->customers->findByMatchingKey($probe->getMatchingKey()); if ($customer !== null) { $customer->addPlatformId($platform, $platformUserId); $this->customers->save($customer); return $customer; } // No match: create new customer in DB + Frappe ERP $customer = new Customer($name, $email, $address); $customer->addPlatformId($platform, $platformUserId); $frappeId = $this->erp->createCustomer($customer); $customer->setFrappeCustomerId($frappeId); $this->customers->save($customer); return $customer; } } ``` - [ ] **Step 5: Tests ausführen — müssen bestehen** ```bash docker compose run --rm app ./vendor/bin/pest tests/Unit/Infrastructure/Order/CustomerResolverTest.php # Expected: PASS (3 tests) ``` - [ ] **Step 6: InvoiceMailerInterface schreiben** ```php getOrder(); $article = $order->getArticle(); $customer = $order->getCustomer(); $body = \sprintf( "Neue Bestellung eingegangen — bitte sofort versenden.\n\n". "Bestellnummer : %s\n". "Artikel : %s\n". "Inventarnummer: %s\n". "Käufer : %s\n". "Verkaufspreis : €%s\n\n". "Die Rechnung liegt diesem E-Mail als PDF bei.", $order->getPlatformOrderId(), $article->getEbayTitle() ?? $article->getSku(), $article->getInventoryNumber(), $customer->getName(), $order->getSalePrice(), ); $email = (new Email()) ->from($this->senderEmail) ->to($this->supplierEmail) ->subject('Neue Bestellung: '.$order->getPlatformOrderId()) ->text($body) ->attachFromPath( $invoice->getFullPath(), 'Rechnung-'.$invoice->getFrappeInvoiceId().'.pdf', 'application/pdf', ); $this->mailer->send($email); } } ``` - [ ] **Step 8: mailer.yaml schreiben** ```yaml # config/packages/mailer.yaml framework: mailer: dsn: '%env(MAILER_DSN)%' ``` - [ ] **Step 9: .env ergänzen** ```dotenv # .env — Mailer + Lieferanten-E-Mail MAILER_DSN=smtp://localhost:1025 SUPPLIER_EMAIL=lieferant@example.com SENDER_EMAIL=noreply@superseller3000.de ``` - [ ] **Step 10: services.yaml ergänzen** ```yaml # config/services.yaml App\Infrastructure\Mail\SymfonyInvoiceMailer: arguments: $supplierEmail: '%env(SUPPLIER_EMAIL)%' $senderEmail: '%env(SENDER_EMAIL)%' App\Application\Order\CustomerResolverInterface: alias: App\Infrastructure\Order\CustomerResolver App\Application\Order\InvoiceMailerInterface: alias: App\Infrastructure\Mail\SymfonyInvoiceMailer ``` - [ ] **Step 11: PHPStan prüfen** ```bash docker compose run --rm app ./vendor/bin/phpstan analyse src/ --level=9 # Expected: 0 errors ``` - [ ] **Step 12: Commit** ```bash git add src/Application/Order/ \ src/Infrastructure/Order/ \ src/Infrastructure/Mail/ \ tests/Unit/Infrastructure/Order/ \ config/packages/mailer.yaml \ config/services.yaml .env git commit -m "feat: add CustomerResolver (2-stage cascade), InvoiceMailer, ErpAdapter ports" ``` --- ## Task 5: OrderReceivedHandler **Files:** - Create: `src/Infrastructure/Messenger/Handler/OrderReceivedHandler.php` - Modify: `config/packages/messenger.yaml` Der Handler ist der zentrale Orchestrator des Order-Flows. Er verarbeitet `OrderReceivedMessage` und durchläuft alle 13 Schritte sequenziell. Symfony Messenger wiederholt den Handler bei Ausnahmen automatisch mit Exponential-Backoff (konfiguriert in messenger.yaml). Überverkauf wird mit `UnrecoverableMessageHandlingException` abgefangen — kein Retry. - [ ] **Step 1: Messenger-Retry-Policy konfigurieren** Stelle sicher, dass `config/packages/messenger.yaml` einen Failure-Transport und Retry-Policy für den `orders`-Transport hat: ```yaml # config/packages/messenger.yaml — zum bestehenden Inhalt ergänzen: framework: messenger: failure_transport: failed transports: # ... bestehende Transports ... failed: dsn: 'doctrine://default?queue_name=failed' buses: messenger.bus.default: middleware: - doctrine_transaction ``` (Der `doctrine_transaction` Middleware wickelt jeden Handler in eine DB-Transaktion — schlägt der Handler fehl, wird alles zurückgerollt.) - [ ] **Step 2: OrderReceivedHandler schreiben** ```php orders->findByPlatformOrderId($message->platformOrderId)) { $this->logger->info('OrderReceivedHandler: duplicate message, skipping', [ 'platformOrderId' => $message->platformOrderId, ]); return; } // 1. Fetch full order data from eBay Fulfillment API $ebayOrder = $this->fulfillmentClient->getOrder($message->platformOrderId); // 2. Find article by eBay listing ID $article = $this->articles->findByEbayListingId($ebayOrder['ebayListingId']); if (null === $article) { throw new UnrecoverableMessageHandlingException( "Article not found for eBay listing ID: {$ebayOrder['ebayListingId']}" ); } // 3. Find platform entity $platform = $this->platforms->findByType($message->platformType); if (null === $platform) { throw new UnrecoverableMessageHandlingException( "Platform '{$message->platformType}' not configured in database" ); } // 4. Atomic inventory lock — prevents overselling $locked = $this->articles->decrementStockAtomic($article->getId()); if (!$locked) { $this->logger->critical('OVERSTOCK: stock was already 0, sale cannot be fulfilled', [ 'articleId' => $article->getId()->toRfc4122(), 'platformOrderId' => $message->platformOrderId, ]); throw new UnrecoverableMessageHandlingException( "Overstock for article {$article->getId()->toRfc4122()} — manual intervention required" ); } // 5. Resolve customer (find-or-create via 2-stage cascade) $customer = $this->customerResolver->resolve( platform: $message->platformType, platformUserId: $ebayOrder['buyerUsername'], name: $ebayOrder['buyerName'], email: $ebayOrder['buyerEmail'], address: [ 'street' => $ebayOrder['shippingStreet'], 'city' => $ebayOrder['shippingCity'], 'zip' => $ebayOrder['shippingZip'], ], ); // 6. Create order record $order = new Order( $article, $customer, $platform, $message->platformOrderId, $ebayOrder['salePrice'], new \DateTimeImmutable($ebayOrder['saleDate']), ); $order->setStatus(OrderStatus::Processing); $this->orders->save($order); // 7. Create Sales Invoice in Frappe ERP $frappeInvoiceId = $this->erp->createSalesInvoice($order); // 8. Fetch invoice PDF from Frappe $pdfContent = $this->erp->fetchInvoicePdf($frappeInvoiceId); // 9. Store PDF via StorageManager $tmpFile = \tempnam(\sys_get_temp_dir(), 'invoice_'); \file_put_contents($tmpFile, $pdfContent); $stored = $this->storage->store($tmpFile, 'invoice-'.$frappeInvoiceId.'.pdf'); \unlink($tmpFile); // 10. Create Invoice record $invoice = new Invoice($order, $frappeInvoiceId, $stored->storagePath, $stored->filename); $order->setInvoice($invoice); $this->invoices->save($invoice); // 11. Send invoice email to supplier $this->mailer->sendInvoice($invoice); $invoice->markAsEmailed(); $this->invoices->save($invoice); // 12. Dispatch channel sync (UpdateStockOnChannelsHandler handles stock=0 → DeactivateListingMessage) $this->bus->dispatch(new UpdateStockOnChannelsMessage( articleId: $article->getId()->toRfc4122(), newStock: $article->getStock(), )); // 13. Complete order $order->setStatus(OrderStatus::Completed); $this->orders->save($order); $this->logger->info('Order processed successfully', [ 'orderId' => $order->getId()->toRfc4122(), 'platformOrderId' => $message->platformOrderId, 'frappeInvoiceId' => $frappeInvoiceId, ]); } } ``` - [ ] **Step 3: PHPStan prüfen** ```bash docker compose run --rm app ./vendor/bin/phpstan analyse src/Infrastructure/Messenger/Handler/OrderReceivedHandler.php --level=9 # Expected: 0 errors ``` - [ ] **Step 4: Commit** ```bash git add src/Infrastructure/Messenger/Handler/OrderReceivedHandler.php config/packages/messenger.yaml git commit -m "feat: add OrderReceivedHandler — full order flow orchestration (lock, customer, invoice, PDF, email, channel sync)" ``` --- ## Task 6: TrackingPushMessage + TrackingPushHandler + OrderController **Files:** - Create: `src/Infrastructure/Messenger/Message/TrackingPushMessage.php` - Create: `src/Infrastructure/Messenger/Handler/TrackingPushHandler.php` - Create: `src/Infrastructure/Http/Controller/OrderController.php` - Modify: `config/packages/messenger.yaml` - Modify: `config/routes/api.yaml` - [ ] **Step 1: TrackingPushMessage schreiben** ```php orders->findById(Uuid::fromString($message->orderId)); if (null === $order) { throw new UnrecoverableMessageHandlingException("Order {$message->orderId} not found"); } $platformType = $order->getPlatform()->getType(); $adapter = $this->channelAdapters->get($platformType); $adapter->pushTracking($order); $order->markTrackingPushedToEbay(); $this->orders->save($order); $this->logger->info('Tracking pushed to channel', [ 'orderId' => $message->orderId, 'platform' => $platformType, 'trackingNumber' => $message->trackingNumber, ]); } } ``` - [ ] **Step 4: OrderController schreiben** ```php orders->findById(Uuid::fromString($id)); if (null === $order) { return $this->json(['error' => 'Order not found'], Response::HTTP_NOT_FOUND); } if ($order->getStatus() === OrderStatus::Completed || $order->getStatus() === OrderStatus::Shipped) { // Already has tracking } /** @var array $body */ $body = \json_decode($request->getContent(), true) ?? []; $trackingNumber = \trim($body['tracking_number'] ?? ''); $carrier = \trim($body['carrier'] ?? ''); if ($trackingNumber === '' || $carrier === '') { return $this->json(['error' => 'tracking_number and carrier are required'], Response::HTTP_BAD_REQUEST); } $order->setTracking($trackingNumber, $carrier); $this->orders->save($order); $this->bus->dispatch(new TrackingPushMessage( orderId: $order->getId()->toRfc4122(), trackingNumber: $trackingNumber, carrier: $carrier, )); return $this->json([ 'id' => $order->getId()->toRfc4122(), 'trackingNumber' => $trackingNumber, 'carrier' => $carrier, 'status' => $order->getStatus()->value, ]); } /** * GET /api/orders/{id} */ #[Route('/{id}', name: 'get', methods: ['GET'])] public function get(string $id): JsonResponse { $order = $this->orders->findById(Uuid::fromString($id)); if (null === $order) { return $this->json(['error' => 'Order not found'], Response::HTTP_NOT_FOUND); } return $this->json([ 'id' => $order->getId()->toRfc4122(), 'platformOrderId' => $order->getPlatformOrderId(), 'status' => $order->getStatus()->value, 'salePrice' => $order->getSalePrice(), 'trackingNumber' => $order->getTrackingNumber(), 'carrier' => $order->getCarrier(), 'shippedAt' => $order->getShippedAt()?->format(\DateTimeInterface::ATOM), ]); } } ``` - [ ] **Step 5: Route registrieren** ```yaml # config/routes/api.yaml — anfügen: api_orders: resource: App\Infrastructure\Http\Controller\OrderController type: attribute ``` - [ ] **Step 6: PHPStan prüfen** ```bash docker compose run --rm app ./vendor/bin/phpstan analyse src/Infrastructure/Messenger/Message/TrackingPushMessage.php \ src/Infrastructure/Messenger/Handler/TrackingPushHandler.php \ src/Infrastructure/Http/Controller/OrderController.php --level=9 # Expected: 0 errors ``` - [ ] **Step 7: Commit** ```bash git add src/Infrastructure/Messenger/Message/TrackingPushMessage.php \ src/Infrastructure/Messenger/Handler/TrackingPushHandler.php \ src/Infrastructure/Http/Controller/OrderController.php \ config/packages/messenger.yaml \ config/routes/api.yaml git commit -m "feat: add TrackingPushMessage/Handler and OrderController PATCH /api/orders/{id}/tracking" ``` --- ## Task 7: EasyAdmin — Order, Customer, Invoice **Files:** - Create: `src/Infrastructure/Http/Admin/OrderCrudController.php` - Create: `src/Infrastructure/Http/Admin/CustomerCrudController.php` - Create: `src/Infrastructure/Http/Admin/InvoiceCrudController.php` - Modify: `src/Infrastructure/Http/Admin/DashboardController.php` Die CRUD-Controller in Plan 3 haben `DashboardController` und `ArticleCrudController`, `ArticleTypeCrudController`, `UserCrudController`, `AIPipelineJobCrudController` angelegt. Dieser Task ergänzt Order, Customer und Invoice. - [ ] **Step 1: OrderCrudController schreiben** ```php setEntityLabelInSingular('Bestellung') ->setEntityLabelInPlural('Bestellungen') ->setDefaultSort(['saleDate' => 'DESC']) ->showEntityActionsInlined(); } public function configureActions(Actions $actions): Actions { return $actions ->disable(Action::NEW, Action::DELETE) ->add(Crud::PAGE_INDEX, Action::DETAIL); } public function configureFields(string $pageName): iterable { yield IdField::new('id')->hideOnForm(); yield TextField::new('platformOrderId', 'Plattform-Bestellnr.'); yield AssociationField::new('article', 'Artikel'); yield AssociationField::new('customer', 'Käufer'); yield ChoiceField::new('status', 'Status') ->setChoices(\array_combine( \array_map(fn (OrderStatus $s) => \ucfirst($s->value), OrderStatus::cases()), \array_map(fn (OrderStatus $s) => $s->value, OrderStatus::cases()), )); yield MoneyField::new('salePrice', 'Verkaufspreis')->setCurrency('EUR'); yield DateTimeField::new('saleDate', 'Verkaufsdatum'); yield TextField::new('trackingNumber', 'Sendungsnummer')->onlyOnDetail(); yield TextField::new('carrier', 'Versanddienstleister')->onlyOnDetail(); yield DateTimeField::new('shippedAt', 'Versanddatum')->onlyOnDetail(); } public function configureFilters(Filters $filters): Filters { return $filters->add(ChoiceFilter::new('status')->setChoices([ 'Ausstehend' => 'pending', 'In Bearbeitung' => 'processing', 'Versandt' => 'shipped', 'Abgeschlossen' => 'completed', 'Fehlgeschlagen' => 'failed', ])); } } ``` - [ ] **Step 2: CustomerCrudController schreiben** ```php setEntityLabelInSingular('Kunde') ->setEntityLabelInPlural('Kunden') ->setDefaultSort(['name' => 'ASC']) ->showEntityActionsInlined(); } public function configureActions(Actions $actions): Actions { return $actions ->disable(Action::NEW, Action::DELETE) ->add(Crud::PAGE_INDEX, Action::DETAIL); } public function configureFields(string $pageName): iterable { yield IdField::new('id')->hideOnForm(); yield TextField::new('name', 'Name'); yield TextField::new('email', 'E-Mail'); yield TextField::new('frappeCustomerId', 'Frappe-ID')->onlyOnDetail(); } } ``` - [ ] **Step 3: InvoiceCrudController schreiben** ```php setEntityLabelInSingular('Rechnung') ->setEntityLabelInPlural('Rechnungen') ->setDefaultSort(['createdAt' => 'DESC']) ->showEntityActionsInlined(); } public function configureActions(Actions $actions): Actions { return $actions ->disable(Action::NEW, Action::EDIT, Action::DELETE) ->add(Crud::PAGE_INDEX, Action::DETAIL); } public function configureFields(string $pageName): iterable { yield IdField::new('id')->hideOnForm(); yield TextField::new('frappeInvoiceId', 'Frappe-Rechnungsnr.'); yield AssociationField::new('order', 'Bestellung'); yield DateTimeField::new('createdAt', 'Erstellt am'); yield DateTimeField::new('emailedAt', 'Per E-Mail versendet'); yield TextField::new('filename', 'PDF-Datei')->onlyOnDetail(); } } ``` - [ ] **Step 4: DashboardController erweitern** Füge in `src/Infrastructure/Http/Admin/DashboardController.php` die neuen Menüpunkte in der `configureMenuItems()`-Methode nach den bestehenden Einträgen an: ```php // Bestehende Einträge bleiben, folgende anfügen: yield MenuItem::section('Verkauf'); yield MenuItem::linkToCrud('Bestellungen', 'fa fa-shopping-cart', \App\Domain\Order\Order::class); yield MenuItem::linkToCrud('Kunden', 'fa fa-users', \App\Domain\Order\Customer::class); yield MenuItem::linkToCrud('Rechnungen', 'fa fa-file-invoice', \App\Domain\Order\Invoice::class); ``` Füge außerdem in der `configureDashboard()`-Methode keine Änderungen vor — nur die Menüpunkte ergänzen. - [ ] **Step 5: Container-Cache leeren und prüfen** ```bash docker compose run --rm app php bin/console cache:clear docker compose run --rm app php bin/console debug:router | grep -E 'admin|api' # Expected: EasyAdmin-Routen für Order, Customer, Invoice sichtbar ``` - [ ] **Step 6: PHPStan prüfen** ```bash docker compose run --rm app ./vendor/bin/phpstan analyse src/Infrastructure/Http/Admin/ --level=9 # Expected: 0 errors ``` - [ ] **Step 7: Commit** ```bash git add src/Infrastructure/Http/Admin/OrderCrudController.php \ src/Infrastructure/Http/Admin/CustomerCrudController.php \ src/Infrastructure/Http/Admin/InvoiceCrudController.php \ src/Infrastructure/Http/Admin/DashboardController.php git commit -m "feat: add EasyAdmin CRUD controllers for Order, Customer, Invoice" ``` --- ## Selbstreview **Spec-Abdeckung:** - eBay-Webhook → HMAC-Signatur → `OrderReceivedMessage` (Plan 5) ✓ - Atomic InventoryLock (`decrementStockAtomic`, 0 Zeilen = Überverkauf + CRITICAL Alert) ✓ (Task 5) - Customer-Matching-Kaskade: Stage 1 (platform_id) → Stage 2 (lowercase address) → Neu-Anlage ✓ (Task 4) - Frappe ERP: createCustomer mit Custom Fields (`superseller_customer_id`, `ebay_user_id`) ✓ (Task 2) - Frappe ERP: createSalesInvoice (Draft + Submit) ✓ (Task 2) - InvoicePdfFetcher → StorageManager → Invoice-Record ✓ (Task 5) - InvoiceMailer → SMTP → feste Lieferanten-Adresse → `emailed_at` setzen ✓ (Tasks 4 + 5) - Channel-Sync nach Verkauf: `UpdateStockOnChannelsMessage` dispatched (Plan 5's Handler übernimmt Deaktivierung bei stock=0) ✓ (Task 5) - TrackingPushMessage + Handler → `pushTracking()` + `markTrackingPushedToEbay()` ✓ (Task 6) - `PATCH /api/orders/{id}/tracking` Endpoint ✓ (Task 6) - EasyAdmin für Order, Customer, Invoice ✓ (Task 7) - Idempotenz des Handlers (doppelter Webhook → skip) ✓ (Task 5) - Unrecoverable Exception bei Überverkauf (kein sinnloser Retry) ✓ (Task 5) - Frappe TLS + API-Token im Secret Store (`.env.local`) ✓ (Konfiguration) **Placeholder-Scan:** Keine TBDs oder offenen Punkte gefunden. **Typ-Konsistenz:** - `UpdateStockOnChannelsMessage(articleId: string, newStock: int)` — Fieldname `newStock` aus Plan 5 ✓ - `TrackingPushMessage` routing → `channel_sync` (Plan 5 erwähnt dieses routing explizit) ✓ - `StoredFile.storagePath` + `StoredFile.filename` — aus Plan 2 ✓ - `Invoice.getFullPath()` verwendet `StoragePath.resolveFilePath()` — aus Plan 1 ✓ - `Order.setTracking()` setzt `status → Shipped` intern — aus Plan 1 ✓ - `ChannelAdapterRegistry.get(string $type)` — aus Plan 5 ✓ **Frappe-Deployment-Voraussetzungen (einmalig manuell vor der ersten Bestellung):** 1. Frappe Custom Fields anlegen: `Customer.custom_superseller_customer_id`, `Customer.custom_ebay_user_id`, `Sales Invoice.custom_platform_order_id`, `Sales Invoice.custom_article_inventory_number` 2. Frappe Item `REFURB-HW` (oder `FRAPPE_GENERIC_ITEM_CODE`) in der Frappe-Instanz anlegen 3. Frappe API Key + Secret erzeugen und in `.env.local` eintragen