SuperSeller3000/docs/superpowers/plans/2026-05-13-06-order-processing.md
Simon Kuehn f55e96b094 chore: add tooling config, test bootstrap, env templates and docs
PHPUnit config (phpunit.dist.xml, bin/phpunit, bootstrap.php), PHP CS
Fixer config, .editorconfig. Separate .env.dev/.env.test templates.
Ollama tunnel setup script. Architecture and plan docs. Updated
application-layer unit tests to match current service signatures.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-17 22:44:16 +00:00

57 KiB

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.phpfindByEbayListingId 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
// tests/Unit/Infrastructure/Persistence/DoctrineArticleRepositoryEbayTest.php
declare(strict_types=1);

namespace App\Tests\Unit\Infrastructure\Persistence;

use App\Domain\Article\Repository\ArticleRepositoryInterface;
use PHPUnit\Framework\TestCase;

final class DoctrineArticleRepositoryEbayTest extends TestCase
{
    public function test_interface_declares_find_by_ebay_listing_id(): void
    {
        $this->assertTrue(
            method_exists(ArticleRepositoryInterface::class, 'findByEbayListingId'),
        );
    }
}
  • Step 2: Test ausführen — muss fehlschlagen
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:

    public function findByEbayListingId(string $ebayListingId): ?Article;
  • Step 4: DoctrineArticleRepository implementieren

Füge folgende Methode in src/Infrastructure/Persistence/Repository/DoctrineArticleRepository.php nach findByInventoryNumber ein:

    public function findByEbayListingId(string $ebayListingId): ?Article
    {
        return $this->em->getRepository(Article::class)->findOneBy(['ebayListingId' => $ebayListingId]);
    }
  • Step 5: Test ausführen — muss bestehen
docker compose run --rm app ./vendor/bin/pest tests/Unit/Infrastructure/Persistence/DoctrineArticleRepositoryEbayTest.php
# Expected: PASS
  • Step 6: InvoiceRepositoryInterface schreiben
<?php
// src/Domain/Order/Repository/InvoiceRepositoryInterface.php
declare(strict_types=1);

namespace App\Domain\Order\Repository;

use App\Domain\Order\Invoice;
use Symfony\Component\Uid\Uuid;

interface InvoiceRepositoryInterface
{
    public function findById(Uuid $id): ?Invoice;

    public function findByFrappeInvoiceId(string $frappeInvoiceId): ?Invoice;

    public function save(Invoice $invoice): void;
}
  • Step 7: DoctrineInvoiceRepository schreiben
<?php
// src/Infrastructure/Persistence/Repository/DoctrineInvoiceRepository.php
declare(strict_types=1);

namespace App\Infrastructure\Persistence\Repository;

use App\Domain\Order\Invoice;
use App\Domain\Order\Repository\InvoiceRepositoryInterface;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\Uid\Uuid;

final class DoctrineInvoiceRepository implements InvoiceRepositoryInterface
{
    public function __construct(private readonly EntityManagerInterface $em) {}

    public function findById(Uuid $id): ?Invoice
    {
        return $this->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
# 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
docker compose run --rm app ./vendor/bin/phpstan analyse src/ --level=9
# Expected: 0 errors
  • Step 10: Commit
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
// tests/Unit/Infrastructure/Channel/Frappe/FrappeErpAdapterTest.php
declare(strict_types=1);

namespace App\Tests\Unit\Infrastructure\Channel\Frappe;

use App\Domain\Order\Customer;
use App\Domain\Order\Invoice;
use App\Domain\Order\Order;
use App\Domain\Order\OrderStatus;
use App\Infrastructure\Channel\Frappe\FrappeErpAdapter;
use App\Infrastructure\Channel\Frappe\FrappeHttpClient;
use PHPUnit\Framework\MockObject\MockObject;
use PHPUnit\Framework\TestCase;

final class FrappeErpAdapterTest extends TestCase
{
    private FrappeHttpClient&MockObject $frappe;
    private FrappeErpAdapter $adapter;

    protected function setUp(): void
    {
        $this->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
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
// src/Application/Order/ErpAdapterInterface.php
declare(strict_types=1);

namespace App\Application\Order;

use App\Domain\Order\Customer;
use App\Domain\Order\Order;

interface ErpAdapterInterface
{
    /**
     * Creates a Customer document in Frappe ERP.
     * Returns the Frappe document name (e.g. "CUST-00001").
     */
    public function createCustomer(Customer $customer): string;

    /**
     * Creates and submits a Sales Invoice in Frappe ERP.
     * Returns the Frappe document name (e.g. "SINV-00001").
     */
    public function createSalesInvoice(Order $order): string;

    /**
     * Fetches the PDF of a submitted Sales Invoice.
     * Returns raw binary PDF content.
     */
    public function fetchInvoicePdf(string $frappeInvoiceId): string;
}
  • Step 4: FrappeHttpClient schreiben
<?php
// src/Infrastructure/Channel/Frappe/FrappeHttpClient.php
declare(strict_types=1);

namespace App\Infrastructure\Channel\Frappe;

use Symfony\Contracts\HttpClient\HttpClientInterface;

class FrappeHttpClient
{
    private string $authHeader;

    public function __construct(
        private readonly HttpClientInterface $httpClient,
        private readonly string $baseUrl,
        string $apiKey,
        string $apiSecret,
    ) {
        $this->authHeader = "token {$apiKey}:{$apiSecret}";
    }

    /**
     * POST to a Frappe resource endpoint.
     *
     * @param array<string, mixed> $data
     * @return array<string, mixed>
     */
    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
// src/Infrastructure/Channel/Frappe/FrappeErpAdapter.php
declare(strict_types=1);

namespace App\Infrastructure\Channel\Frappe;

use App\Application\Order\ErpAdapterInterface;
use App\Domain\Order\Customer;
use App\Domain\Order\Order;

final class FrappeErpAdapter implements ErpAdapterInterface
{
    public function __construct(
        private readonly FrappeHttpClient $frappe,
        private readonly string $genericItemCode,
    ) {}

    public function createCustomer(Customer $customer): string
    {
        $data = [
            'customer_name'                    => $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
docker compose run --rm app ./vendor/bin/pest tests/Unit/Infrastructure/Channel/Frappe/FrappeErpAdapterTest.php
# Expected: PASS (3 tests)
  • Step 7: .env ergänzen
# .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
# 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
docker compose run --rm app ./vendor/bin/phpstan analyse src/ --level=9
# Expected: 0 errors
  • Step 10: Commit
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
// src/Infrastructure/Channel/Ebay/EbayFulfillmentApiClient.php
declare(strict_types=1);

namespace App\Infrastructure\Channel\Ebay;

use Symfony\Contracts\HttpClient\HttpClientInterface;

final class EbayFulfillmentApiClient
{
    public function __construct(
        private readonly EbayOAuthClient $oauthClient,
        private readonly HttpClientInterface $httpClient,
        private readonly string $apiBaseUrl,
    ) {}

    /**
     * Fetches a full order from eBay Sell Fulfillment API v1.
     *
     * Returns a normalized array:
     *   orderId: string
     *   buyerUsername: string
     *   buyerName: string
     *   buyerEmail: string
     *   shippingStreet: string
     *   shippingCity: string
     *   shippingZip: string
     *   shippingCountry: string
     *   ebayListingId: string   (legacyItemId of first line item)
     *   salePrice: string       (decimal string, e.g. "299.99")
     *   saleDate: string        (ISO 8601)
     *
     * @return array<string, string>
     */
    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<string, mixed> $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
# 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
docker compose run --rm app ./vendor/bin/phpstan analyse src/Infrastructure/Channel/Ebay/EbayFulfillmentApiClient.php --level=9
# Expected: 0 errors
  • Step 4: Commit
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
// tests/Unit/Infrastructure/Order/CustomerResolverTest.php
declare(strict_types=1);

namespace App\Tests\Unit\Infrastructure\Order;

use App\Application\Order\ErpAdapterInterface;
use App\Domain\Order\Customer;
use App\Domain\Order\Repository\CustomerRepositoryInterface;
use App\Infrastructure\Order\CustomerResolver;
use PHPUnit\Framework\MockObject\MockObject;
use PHPUnit\Framework\TestCase;

final class CustomerResolverTest extends TestCase
{
    private CustomerRepositoryInterface&MockObject $customerRepo;
    private ErpAdapterInterface&MockObject $erp;
    private CustomerResolver $resolver;

    protected function setUp(): void
    {
        $this->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
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
// src/Application/Order/CustomerResolverInterface.php
declare(strict_types=1);

namespace App\Application\Order;

use App\Domain\Order\Customer;

interface CustomerResolverInterface
{
    /**
     * Finds or creates a Customer using a two-stage cascade:
     * Stage 1: exact platform_id match (fast path)
     * Stage 2: exact lowercase(name|street|city|zip) match (cross-platform dedup)
     * No match: creates new customer in DB + Frappe ERP.
     *
     * @param array<string, string> $address  Keys: street, city, zip
     */
    public function resolve(
        string $platform,
        string $platformUserId,
        string $name,
        string $email,
        array $address,
    ): Customer;
}
  • Step 4: CustomerResolver implementieren
<?php
// src/Infrastructure/Order/CustomerResolver.php
declare(strict_types=1);

namespace App\Infrastructure\Order;

use App\Application\Order\CustomerResolverInterface;
use App\Application\Order\ErpAdapterInterface;
use App\Domain\Order\Customer;
use App\Domain\Order\Repository\CustomerRepositoryInterface;

final class CustomerResolver implements CustomerResolverInterface
{
    public function __construct(
        private readonly CustomerRepositoryInterface $customers,
        private readonly ErpAdapterInterface $erp,
    ) {}

    public function resolve(
        string $platform,
        string $platformUserId,
        string $name,
        string $email,
        array $address,
    ): Customer {
        // Stage 1: exact platform_id match
        $customer = $this->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
docker compose run --rm app ./vendor/bin/pest tests/Unit/Infrastructure/Order/CustomerResolverTest.php
# Expected: PASS (3 tests)
  • Step 6: InvoiceMailerInterface schreiben
<?php
// src/Application/Order/InvoiceMailerInterface.php
declare(strict_types=1);

namespace App\Application\Order;

use App\Domain\Order\Invoice;

interface InvoiceMailerInterface
{
    /**
     * Sends the invoice PDF to the configured supplier email address.
     * The supplier then ships the item (dropshipping model).
     */
    public function sendInvoice(Invoice $invoice): void;
}
  • Step 7: SymfonyInvoiceMailer implementieren
<?php
// src/Infrastructure/Mail/SymfonyInvoiceMailer.php
declare(strict_types=1);

namespace App\Infrastructure\Mail;

use App\Application\Order\InvoiceMailerInterface;
use App\Domain\Order\Invoice;
use Symfony\Component\Mailer\MailerInterface;
use Symfony\Component\Mime\Email;

final class SymfonyInvoiceMailer implements InvoiceMailerInterface
{
    public function __construct(
        private readonly MailerInterface $mailer,
        private readonly string $supplierEmail,
        private readonly string $senderEmail,
    ) {}

    public function sendInvoice(Invoice $invoice): void
    {
        $order   = $invoice->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
# config/packages/mailer.yaml
framework:
    mailer:
        dsn: '%env(MAILER_DSN)%'
  • Step 9: .env ergänzen
# .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
# 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
docker compose run --rm app ./vendor/bin/phpstan analyse src/ --level=9
# Expected: 0 errors
  • Step 12: Commit
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:

# 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
// src/Infrastructure/Messenger/Handler/OrderReceivedHandler.php
declare(strict_types=1);

namespace App\Infrastructure\Messenger\Handler;

use App\Application\Order\CustomerResolverInterface;
use App\Application\Order\ErpAdapterInterface;
use App\Application\Order\InvoiceMailerInterface;
use App\Application\Storage\StorageManagerInterface;
use App\Domain\Article\ArticleStatus;
use App\Domain\Article\Repository\ArticleRepositoryInterface;
use App\Domain\Channel\Repository\PlatformRepositoryInterface;
use App\Domain\Order\Invoice;
use App\Domain\Order\Order;
use App\Domain\Order\OrderStatus;
use App\Domain\Order\Repository\InvoiceRepositoryInterface;
use App\Domain\Order\Repository\OrderRepositoryInterface;
use App\Infrastructure\Channel\Ebay\EbayFulfillmentApiClient;
use App\Infrastructure\Messenger\Message\OrderReceivedMessage;
use App\Infrastructure\Messenger\Message\UpdateStockOnChannelsMessage;
use Psr\Log\LoggerInterface;
use Symfony\Component\Messenger\Attribute\AsMessageHandler;
use Symfony\Component\Messenger\Exception\UnrecoverableMessageHandlingException;
use Symfony\Component\Messenger\MessageBusInterface;

#[AsMessageHandler]
final class OrderReceivedHandler
{
    public function __construct(
        private readonly EbayFulfillmentApiClient $fulfillmentClient,
        private readonly ArticleRepositoryInterface $articles,
        private readonly PlatformRepositoryInterface $platforms,
        private readonly OrderRepositoryInterface $orders,
        private readonly InvoiceRepositoryInterface $invoices,
        private readonly CustomerResolverInterface $customerResolver,
        private readonly ErpAdapterInterface $erp,
        private readonly StorageManagerInterface $storage,
        private readonly InvoiceMailerInterface $mailer,
        private readonly MessageBusInterface $bus,
        private readonly LoggerInterface $logger,
    ) {}

    public function __invoke(OrderReceivedMessage $message): void
    {
        // 0. Idempotency: skip if order already processed
        if (null !== $this->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
docker compose run --rm app ./vendor/bin/phpstan analyse src/Infrastructure/Messenger/Handler/OrderReceivedHandler.php --level=9
# Expected: 0 errors
  • Step 4: Commit
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
// src/Infrastructure/Messenger/Message/TrackingPushMessage.php
declare(strict_types=1);

namespace App\Infrastructure\Messenger\Message;

final readonly class TrackingPushMessage
{
    public function __construct(
        public string $orderId,
        public string $trackingNumber,
        public string $carrier,
    ) {}
}
  • Step 2: Messenger-Routing ergänzen
# config/packages/messenger.yaml — routing ergänzen:
        App\Infrastructure\Messenger\Message\TrackingPushMessage: channel_sync
  • Step 3: TrackingPushHandler schreiben
<?php
// src/Infrastructure/Messenger/Handler/TrackingPushHandler.php
declare(strict_types=1);

namespace App\Infrastructure\Messenger\Handler;

use App\Application\Channel\ChannelAdapterRegistry;
use App\Domain\Order\Repository\OrderRepositoryInterface;
use App\Infrastructure\Messenger\Message\TrackingPushMessage;
use Psr\Log\LoggerInterface;
use Symfony\Component\Messenger\Attribute\AsMessageHandler;
use Symfony\Component\Messenger\Exception\UnrecoverableMessageHandlingException;
use Symfony\Component\Uid\Uuid;

#[AsMessageHandler]
final class TrackingPushHandler
{
    public function __construct(
        private readonly OrderRepositoryInterface $orders,
        private readonly ChannelAdapterRegistry $channelAdapters,
        private readonly LoggerInterface $logger,
    ) {}

    public function __invoke(TrackingPushMessage $message): void
    {
        $order = $this->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
// src/Infrastructure/Http/Controller/OrderController.php
declare(strict_types=1);

namespace App\Infrastructure\Http\Controller;

use App\Domain\Order\OrderStatus;
use App\Domain\Order\Repository\OrderRepositoryInterface;
use App\Infrastructure\Messenger\Message\TrackingPushMessage;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Messenger\MessageBusInterface;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Uid\Uuid;

#[Route('/api/orders', name: 'api_order_')]
final class OrderController extends AbstractController
{
    public function __construct(
        private readonly OrderRepositoryInterface $orders,
        private readonly MessageBusInterface $bus,
    ) {}

    /**
     * PATCH /api/orders/{id}/tracking
     *
     * Body: {"tracking_number": "1Z999...", "carrier": "DHL"}
     *
     * Sets tracking info on the order and dispatches TrackingPushMessage
     * so the carrier info is pushed to all sales channels asynchronously.
     */
    #[Route('/{id}/tracking', name: 'patch_tracking', methods: ['PATCH'])]
    public function patchTracking(string $id, Request $request): JsonResponse
    {
        $order = $this->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<string, string> $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
# config/routes/api.yaml — anfügen:
api_orders:
    resource: App\Infrastructure\Http\Controller\OrderController
    type:     attribute
  • Step 6: PHPStan prüfen
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
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
// src/Infrastructure/Http/Admin/OrderCrudController.php
declare(strict_types=1);

namespace App\Infrastructure\Http\Admin;

use App\Domain\Order\Order;
use App\Domain\Order\OrderStatus;
use EasyCorp\Bundle\EasyAdminBundle\Config\Action;
use EasyCorp\Bundle\EasyAdminBundle\Config\Actions;
use EasyCorp\Bundle\EasyAdminBundle\Config\Crud;
use EasyCorp\Bundle\EasyAdminBundle\Config\Filters;
use EasyCorp\Bundle\EasyAdminBundle\Controller\AbstractCrudController;
use EasyCorp\Bundle\EasyAdminBundle\Field\AssociationField;
use EasyCorp\Bundle\EasyAdminBundle\Field\ChoiceField;
use EasyCorp\Bundle\EasyAdminBundle\Field\DateTimeField;
use EasyCorp\Bundle\EasyAdminBundle\Field\IdField;
use EasyCorp\Bundle\EasyAdminBundle\Field\MoneyField;
use EasyCorp\Bundle\EasyAdminBundle\Field\TextField;
use EasyCorp\Bundle\EasyAdminBundle\Filter\ChoiceFilter;

final class OrderCrudController extends AbstractCrudController
{
    public static function getEntityFqcn(): string
    {
        return Order::class;
    }

    public function configureCrud(Crud $crud): Crud
    {
        return $crud
            ->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
// src/Infrastructure/Http/Admin/CustomerCrudController.php
declare(strict_types=1);

namespace App\Infrastructure\Http\Admin;

use App\Domain\Order\Customer;
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\IdField;
use EasyCorp\Bundle\EasyAdminBundle\Field\TextField;

final class CustomerCrudController extends AbstractCrudController
{
    public static function getEntityFqcn(): string
    {
        return Customer::class;
    }

    public function configureCrud(Crud $crud): Crud
    {
        return $crud
            ->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
// src/Infrastructure/Http/Admin/InvoiceCrudController.php
declare(strict_types=1);

namespace App\Infrastructure\Http\Admin;

use App\Domain\Order\Invoice;
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\AssociationField;
use EasyCorp\Bundle\EasyAdminBundle\Field\DateTimeField;
use EasyCorp\Bundle\EasyAdminBundle\Field\IdField;
use EasyCorp\Bundle\EasyAdminBundle\Field\TextField;

final class InvoiceCrudController extends AbstractCrudController
{
    public static function getEntityFqcn(): string
    {
        return Invoice::class;
    }

    public function configureCrud(Crud $crud): Crud
    {
        return $crud
            ->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:

    // 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
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
docker compose run --rm app ./vendor/bin/phpstan analyse src/Infrastructure/Http/Admin/ --level=9
# Expected: 0 errors
  • Step 7: Commit
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