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>
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.php—findByEbayListingIdergänzensrc/Infrastructure/Persistence/Repository/DoctrineArticleRepository.php— implementierenconfig/services.yaml— InvoiceRepository-Alias + neue Wiringsconfig/packages/messenger.yaml— TrackingPushMessage routingsrc/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_atsetzen ✓ (Tasks 4 + 5) - Channel-Sync nach Verkauf:
UpdateStockOnChannelsMessagedispatched (Plan 5's Handler übernimmt Deaktivierung bei stock=0) ✓ (Task 5) - TrackingPushMessage + Handler →
pushTracking()+markTrackingPushedToEbay()✓ (Task 6) PATCH /api/orders/{id}/trackingEndpoint ✓ (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)— FieldnamenewStockaus Plan 5 ✓TrackingPushMessagerouting →channel_sync(Plan 5 erwähnt dieses routing explizit) ✓StoredFile.storagePath+StoredFile.filename— aus Plan 2 ✓Invoice.getFullPath()verwendetStoragePath.resolveFilePath()— aus Plan 1 ✓Order.setTracking()setztstatus → Shippedintern — aus Plan 1 ✓ChannelAdapterRegistry.get(string $type)— aus Plan 5 ✓
Frappe-Deployment-Voraussetzungen (einmalig manuell vor der ersten Bestellung):
- 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 - Frappe Item
REFURB-HW(oderFRAPPE_GENERIC_ITEM_CODE) in der Frappe-Instanz anlegen - Frappe API Key + Secret erzeugen und in
.env.localeintragen