SuperSeller3000/docs/superpowers/plans/2026-05-13-06-order-processing.md

1700 lines
57 KiB
Markdown
Raw Normal View History

# SuperSeller3000 — Plan 6: Order Processing
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Vollständige Order-Verarbeitung: eBay-Webhooks in Frappe-Rechnungen umwandeln, Kunden-Matching-Kaskade, PDF-Versand an Lieferanten, Bestandssynchronisation nach Verkauf und Tracking-Übermittlung an eBay.
**Architecture:** `OrderReceivedMessage` (aus Plan 5) triggert einen sequenziellen Handler: InventoryLock → CustomerResolver → Frappe-Invoice → PDF-Speicherung → E-Mail → Channel-Sync. Alle Ports (ErpAdapterInterface, CustomerResolverInterface, InvoiceMailerInterface) im Application-Layer; Implementierungen in Infrastructure. `EbayFulfillmentApiClient` holt vollständige Bestelldaten (Käuferadresse, Preis, Listing-ID) von der eBay Sell Fulfillment API v1, damit Customer-Matching und Artikel-Lookup funktionieren. `TrackingPushMessage` + Handler übermitteln Sendungsverfolgung asynchron über den `channel_sync`-Transport.
**Tech Stack:** PHP 8.4, Symfony 7, Symfony Messenger, Symfony Mailer (SMTP), eBay Sell Fulfillment API v1, Frappe ERP REST API, PHPStan Level 9
---
## Dateistruktur (gesamter Plan)
```
src/
Application/
Order/
ErpAdapterInterface.php # Port: createCustomer, createSalesInvoice, fetchInvoicePdf
CustomerResolverInterface.php # Port: resolve() → Customer (find-or-create)
InvoiceMailerInterface.php # Port: sendInvoice(Invoice) → void
Domain/
Order/
Repository/
InvoiceRepositoryInterface.php # NEU
Infrastructure/
Channel/
Ebay/
EbayFulfillmentApiClient.php # GET /sell/fulfillment/v1/order/{id}
Frappe/
FrappeHttpClient.php # Low-level HTTP wrapper (nicht final → mockbar)
FrappeErpAdapter.php # implements ErpAdapterInterface
Order/
CustomerResolver.php # implements CustomerResolverInterface
Mail/
SymfonyInvoiceMailer.php # implements InvoiceMailerInterface
Messenger/
Message/
TrackingPushMessage.php
Handler/
OrderReceivedHandler.php # Haupt-Orchestrator
TrackingPushHandler.php
Persistence/
Repository/
DoctrineInvoiceRepository.php
Http/
Controller/
OrderController.php # PATCH /api/orders/{id}/tracking
Admin/
OrderCrudController.php
CustomerCrudController.php
InvoiceCrudController.php
config/
packages/
mailer.yaml
tests/
Unit/
Infrastructure/
Order/
CustomerResolverTest.php
Channel/
Frappe/
FrappeErpAdapterTest.php
```
**Modifizierte Dateien:**
- `src/Domain/Article/Repository/ArticleRepositoryInterface.php``findByEbayListingId` ergänzen
- `src/Infrastructure/Persistence/Repository/DoctrineArticleRepository.php` — implementieren
- `config/services.yaml` — InvoiceRepository-Alias + neue Wirings
- `config/packages/messenger.yaml` — TrackingPushMessage routing
- `src/Infrastructure/Http/Admin/DashboardController.php` — neue CRUD-Controller registrieren
---
## Task 1: findByEbayListingId + InvoiceRepository
**Files:**
- Modify: `src/Domain/Article/Repository/ArticleRepositoryInterface.php`
- Modify: `src/Infrastructure/Persistence/Repository/DoctrineArticleRepository.php`
- Create: `src/Domain/Order/Repository/InvoiceRepositoryInterface.php`
- Create: `src/Infrastructure/Persistence/Repository/DoctrineInvoiceRepository.php`
- Modify: `config/services.yaml`
- [ ] **Step 1: Failing test für findByEbayListingId schreiben**
```php
<?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**
```bash
docker compose run --rm app ./vendor/bin/pest tests/Unit/Infrastructure/Persistence/DoctrineArticleRepositoryEbayTest.php
# Expected: FAIL — method findByEbayListingId not found
```
- [ ] **Step 3: findByEbayListingId zum Interface ergänzen**
Füge folgende Methode in `src/Domain/Article/Repository/ArticleRepositoryInterface.php` nach `findByInventoryNumber` ein:
```php
public function findByEbayListingId(string $ebayListingId): ?Article;
```
- [ ] **Step 4: DoctrineArticleRepository implementieren**
Füge folgende Methode in `src/Infrastructure/Persistence/Repository/DoctrineArticleRepository.php` nach `findByInventoryNumber` ein:
```php
public function findByEbayListingId(string $ebayListingId): ?Article
{
return $this->em->getRepository(Article::class)->findOneBy(['ebayListingId' => $ebayListingId]);
}
```
- [ ] **Step 5: Test ausführen — muss bestehen**
```bash
docker compose run --rm app ./vendor/bin/pest tests/Unit/Infrastructure/Persistence/DoctrineArticleRepositoryEbayTest.php
# Expected: PASS
```
- [ ] **Step 6: InvoiceRepositoryInterface schreiben**
```php
<?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
<?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**
```yaml
# config/services.yaml — nach den bestehenden Repository-Aliases anfügen:
App\Domain\Order\Repository\InvoiceRepositoryInterface:
alias: App\Infrastructure\Persistence\Repository\DoctrineInvoiceRepository
```
- [ ] **Step 9: PHPStan prüfen**
```bash
docker compose run --rm app ./vendor/bin/phpstan analyse src/ --level=9
# Expected: 0 errors
```
- [ ] **Step 10: Commit**
```bash
git add src/Domain/Article/Repository/ArticleRepositoryInterface.php \
src/Infrastructure/Persistence/Repository/DoctrineArticleRepository.php \
src/Domain/Order/Repository/InvoiceRepositoryInterface.php \
src/Infrastructure/Persistence/Repository/DoctrineInvoiceRepository.php \
config/services.yaml \
tests/Unit/Infrastructure/Persistence/DoctrineArticleRepositoryEbayTest.php
git commit -m "feat: add findByEbayListingId to ArticleRepository, add InvoiceRepository"
```
---
## Task 2: ErpAdapterInterface + FrappeHttpClient + FrappeErpAdapter
**Files:**
- Create: `src/Application/Order/ErpAdapterInterface.php`
- Create: `src/Infrastructure/Channel/Frappe/FrappeHttpClient.php`
- Create: `src/Infrastructure/Channel/Frappe/FrappeErpAdapter.php`
- Test: `tests/Unit/Infrastructure/Channel/Frappe/FrappeErpAdapterTest.php`
- Modify: `config/services.yaml`
- Modify: `.env`
- [ ] **Step 1: Failing tests schreiben**
```php
<?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**
```bash
docker compose run --rm app ./vendor/bin/pest tests/Unit/Infrastructure/Channel/Frappe/FrappeErpAdapterTest.php
# Expected: FAIL — class FrappeErpAdapter not found
```
- [ ] **Step 3: ErpAdapterInterface schreiben**
```php
<?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
<?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
<?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**
```bash
docker compose run --rm app ./vendor/bin/pest tests/Unit/Infrastructure/Channel/Frappe/FrappeErpAdapterTest.php
# Expected: PASS (3 tests)
```
- [ ] **Step 7: .env ergänzen**
```dotenv
# .env — Frappe ERP
FRAPPE_ERP_BASE_URL=https://erp.example.com
FRAPPE_ERP_API_KEY=changeme
FRAPPE_ERP_API_SECRET=changeme
FRAPPE_GENERIC_ITEM_CODE=REFURB-HW
```
- [ ] **Step 8: services.yaml ergänzen**
```yaml
# config/services.yaml
App\Infrastructure\Channel\Frappe\FrappeHttpClient:
arguments:
$baseUrl: '%env(FRAPPE_ERP_BASE_URL)%'
$apiKey: '%env(FRAPPE_ERP_API_KEY)%'
$apiSecret: '%env(FRAPPE_ERP_API_SECRET)%'
App\Infrastructure\Channel\Frappe\FrappeErpAdapter:
arguments:
$genericItemCode: '%env(FRAPPE_GENERIC_ITEM_CODE)%'
App\Application\Order\ErpAdapterInterface:
alias: App\Infrastructure\Channel\Frappe\FrappeErpAdapter
```
- [ ] **Step 9: PHPStan prüfen**
```bash
docker compose run --rm app ./vendor/bin/phpstan analyse src/ --level=9
# Expected: 0 errors
```
- [ ] **Step 10: Commit**
```bash
git add src/Application/Order/ErpAdapterInterface.php \
src/Infrastructure/Channel/Frappe/ \
tests/Unit/Infrastructure/Channel/Frappe/ \
config/services.yaml .env
git commit -m "feat: add ErpAdapterInterface and FrappeErpAdapter (createCustomer, createSalesInvoice, fetchInvoicePdf)"
```
---
## Task 3: EbayFulfillmentApiClient
**Files:**
- Create: `src/Infrastructure/Channel/Ebay/EbayFulfillmentApiClient.php`
- Modify: `config/services.yaml`
Der Client ruft die eBay Sell Fulfillment API v1 ab und gibt ein normiertes Array zurück, damit `OrderReceivedHandler` die Käuferadresse, den Artikel-Listing-ID und den Verkaufspreis kennt.
- [ ] **Step 1: EbayFulfillmentApiClient schreiben**
```php
<?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**
```yaml
# config/services.yaml
App\Infrastructure\Channel\Ebay\EbayFulfillmentApiClient:
arguments:
$apiBaseUrl: '%env(EBAY_API_BASE_URL)%'
```
(`EbayOAuthClient` und `HttpClientInterface` werden von Symfony per Autowiring injiziert.)
- [ ] **Step 3: PHPStan prüfen**
```bash
docker compose run --rm app ./vendor/bin/phpstan analyse src/Infrastructure/Channel/Ebay/EbayFulfillmentApiClient.php --level=9
# Expected: 0 errors
```
- [ ] **Step 4: Commit**
```bash
git add src/Infrastructure/Channel/Ebay/EbayFulfillmentApiClient.php config/services.yaml
git commit -m "feat: add EbayFulfillmentApiClient to fetch full order data from eBay Sell Fulfillment API v1"
```
---
## Task 4: CustomerResolverInterface + CustomerResolver + InvoiceMailerInterface + SymfonyInvoiceMailer
**Files:**
- Create: `src/Application/Order/CustomerResolverInterface.php`
- Create: `src/Application/Order/InvoiceMailerInterface.php`
- Create: `src/Infrastructure/Order/CustomerResolver.php`
- Create: `src/Infrastructure/Mail/SymfonyInvoiceMailer.php`
- Test: `tests/Unit/Infrastructure/Order/CustomerResolverTest.php`
- Create: `config/packages/mailer.yaml`
- Modify: `config/services.yaml`
- Modify: `.env`
- [ ] **Step 1: Failing tests für CustomerResolver schreiben**
```php
<?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**
```bash
docker compose run --rm app ./vendor/bin/pest tests/Unit/Infrastructure/Order/CustomerResolverTest.php
# Expected: FAIL — class CustomerResolver not found
```
- [ ] **Step 3: CustomerResolverInterface schreiben**
```php
<?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
<?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**
```bash
docker compose run --rm app ./vendor/bin/pest tests/Unit/Infrastructure/Order/CustomerResolverTest.php
# Expected: PASS (3 tests)
```
- [ ] **Step 6: InvoiceMailerInterface schreiben**
```php
<?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
<?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**
```yaml
# config/packages/mailer.yaml
framework:
mailer:
dsn: '%env(MAILER_DSN)%'
```
- [ ] **Step 9: .env ergänzen**
```dotenv
# .env — Mailer + Lieferanten-E-Mail
MAILER_DSN=smtp://localhost:1025
SUPPLIER_EMAIL=lieferant@example.com
SENDER_EMAIL=noreply@superseller3000.de
```
- [ ] **Step 10: services.yaml ergänzen**
```yaml
# config/services.yaml
App\Infrastructure\Mail\SymfonyInvoiceMailer:
arguments:
$supplierEmail: '%env(SUPPLIER_EMAIL)%'
$senderEmail: '%env(SENDER_EMAIL)%'
App\Application\Order\CustomerResolverInterface:
alias: App\Infrastructure\Order\CustomerResolver
App\Application\Order\InvoiceMailerInterface:
alias: App\Infrastructure\Mail\SymfonyInvoiceMailer
```
- [ ] **Step 11: PHPStan prüfen**
```bash
docker compose run --rm app ./vendor/bin/phpstan analyse src/ --level=9
# Expected: 0 errors
```
- [ ] **Step 12: Commit**
```bash
git add src/Application/Order/ \
src/Infrastructure/Order/ \
src/Infrastructure/Mail/ \
tests/Unit/Infrastructure/Order/ \
config/packages/mailer.yaml \
config/services.yaml .env
git commit -m "feat: add CustomerResolver (2-stage cascade), InvoiceMailer, ErpAdapter ports"
```
---
## Task 5: OrderReceivedHandler
**Files:**
- Create: `src/Infrastructure/Messenger/Handler/OrderReceivedHandler.php`
- Modify: `config/packages/messenger.yaml`
Der Handler ist der zentrale Orchestrator des Order-Flows. Er verarbeitet `OrderReceivedMessage` und durchläuft alle 13 Schritte sequenziell. Symfony Messenger wiederholt den Handler bei Ausnahmen automatisch mit Exponential-Backoff (konfiguriert in messenger.yaml). Überverkauf wird mit `UnrecoverableMessageHandlingException` abgefangen — kein Retry.
- [ ] **Step 1: Messenger-Retry-Policy konfigurieren**
Stelle sicher, dass `config/packages/messenger.yaml` einen Failure-Transport und Retry-Policy für den `orders`-Transport hat:
```yaml
# config/packages/messenger.yaml — zum bestehenden Inhalt ergänzen:
framework:
messenger:
failure_transport: failed
transports:
# ... bestehende Transports ...
failed:
dsn: 'doctrine://default?queue_name=failed'
buses:
messenger.bus.default:
middleware:
- doctrine_transaction
```
(Der `doctrine_transaction` Middleware wickelt jeden Handler in eine DB-Transaktion — schlägt der Handler fehl, wird alles zurückgerollt.)
- [ ] **Step 2: OrderReceivedHandler schreiben**
```php
<?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**
```bash
docker compose run --rm app ./vendor/bin/phpstan analyse src/Infrastructure/Messenger/Handler/OrderReceivedHandler.php --level=9
# Expected: 0 errors
```
- [ ] **Step 4: Commit**
```bash
git add src/Infrastructure/Messenger/Handler/OrderReceivedHandler.php config/packages/messenger.yaml
git commit -m "feat: add OrderReceivedHandler — full order flow orchestration (lock, customer, invoice, PDF, email, channel sync)"
```
---
## Task 6: TrackingPushMessage + TrackingPushHandler + OrderController
**Files:**
- Create: `src/Infrastructure/Messenger/Message/TrackingPushMessage.php`
- Create: `src/Infrastructure/Messenger/Handler/TrackingPushHandler.php`
- Create: `src/Infrastructure/Http/Controller/OrderController.php`
- Modify: `config/packages/messenger.yaml`
- Modify: `config/routes/api.yaml`
- [ ] **Step 1: TrackingPushMessage schreiben**
```php
<?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**
```yaml
# config/packages/messenger.yaml — routing ergänzen:
App\Infrastructure\Messenger\Message\TrackingPushMessage: channel_sync
```
- [ ] **Step 3: TrackingPushHandler schreiben**
```php
<?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
<?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**
```yaml
# config/routes/api.yaml — anfügen:
api_orders:
resource: App\Infrastructure\Http\Controller\OrderController
type: attribute
```
- [ ] **Step 6: PHPStan prüfen**
```bash
docker compose run --rm app ./vendor/bin/phpstan analyse src/Infrastructure/Messenger/Message/TrackingPushMessage.php \
src/Infrastructure/Messenger/Handler/TrackingPushHandler.php \
src/Infrastructure/Http/Controller/OrderController.php --level=9
# Expected: 0 errors
```
- [ ] **Step 7: Commit**
```bash
git add src/Infrastructure/Messenger/Message/TrackingPushMessage.php \
src/Infrastructure/Messenger/Handler/TrackingPushHandler.php \
src/Infrastructure/Http/Controller/OrderController.php \
config/packages/messenger.yaml \
config/routes/api.yaml
git commit -m "feat: add TrackingPushMessage/Handler and OrderController PATCH /api/orders/{id}/tracking"
```
---
## Task 7: EasyAdmin — Order, Customer, Invoice
**Files:**
- Create: `src/Infrastructure/Http/Admin/OrderCrudController.php`
- Create: `src/Infrastructure/Http/Admin/CustomerCrudController.php`
- Create: `src/Infrastructure/Http/Admin/InvoiceCrudController.php`
- Modify: `src/Infrastructure/Http/Admin/DashboardController.php`
Die CRUD-Controller in Plan 3 haben `DashboardController` und `ArticleCrudController`, `ArticleTypeCrudController`, `UserCrudController`, `AIPipelineJobCrudController` angelegt. Dieser Task ergänzt Order, Customer und Invoice.
- [ ] **Step 1: OrderCrudController schreiben**
```php
<?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
<?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
<?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:
```php
// Bestehende Einträge bleiben, folgende anfügen:
yield MenuItem::section('Verkauf');
yield MenuItem::linkToCrud('Bestellungen', 'fa fa-shopping-cart', \App\Domain\Order\Order::class);
yield MenuItem::linkToCrud('Kunden', 'fa fa-users', \App\Domain\Order\Customer::class);
yield MenuItem::linkToCrud('Rechnungen', 'fa fa-file-invoice', \App\Domain\Order\Invoice::class);
```
Füge außerdem in der `configureDashboard()`-Methode keine Änderungen vor — nur die Menüpunkte ergänzen.
- [ ] **Step 5: Container-Cache leeren und prüfen**
```bash
docker compose run --rm app php bin/console cache:clear
docker compose run --rm app php bin/console debug:router | grep -E 'admin|api'
# Expected: EasyAdmin-Routen für Order, Customer, Invoice sichtbar
```
- [ ] **Step 6: PHPStan prüfen**
```bash
docker compose run --rm app ./vendor/bin/phpstan analyse src/Infrastructure/Http/Admin/ --level=9
# Expected: 0 errors
```
- [ ] **Step 7: Commit**
```bash
git add src/Infrastructure/Http/Admin/OrderCrudController.php \
src/Infrastructure/Http/Admin/CustomerCrudController.php \
src/Infrastructure/Http/Admin/InvoiceCrudController.php \
src/Infrastructure/Http/Admin/DashboardController.php
git commit -m "feat: add EasyAdmin CRUD controllers for Order, Customer, Invoice"
```
---
## Selbstreview
**Spec-Abdeckung:**
- eBay-Webhook → HMAC-Signatur → `OrderReceivedMessage` (Plan 5) ✓
- Atomic InventoryLock (`decrementStockAtomic`, 0 Zeilen = Überverkauf + CRITICAL Alert) ✓ (Task 5)
- Customer-Matching-Kaskade: Stage 1 (platform_id) → Stage 2 (lowercase address) → Neu-Anlage ✓ (Task 4)
- Frappe ERP: createCustomer mit Custom Fields (`superseller_customer_id`, `ebay_user_id`) ✓ (Task 2)
- Frappe ERP: createSalesInvoice (Draft + Submit) ✓ (Task 2)
- InvoicePdfFetcher → StorageManager → Invoice-Record ✓ (Task 5)
- InvoiceMailer → SMTP → feste Lieferanten-Adresse → `emailed_at` setzen ✓ (Tasks 4 + 5)
- Channel-Sync nach Verkauf: `UpdateStockOnChannelsMessage` dispatched (Plan 5's Handler übernimmt Deaktivierung bei stock=0) ✓ (Task 5)
- TrackingPushMessage + Handler → `pushTracking()` + `markTrackingPushedToEbay()` ✓ (Task 6)
- `PATCH /api/orders/{id}/tracking` Endpoint ✓ (Task 6)
- EasyAdmin für Order, Customer, Invoice ✓ (Task 7)
- Idempotenz des Handlers (doppelter Webhook → skip) ✓ (Task 5)
- Unrecoverable Exception bei Überverkauf (kein sinnloser Retry) ✓ (Task 5)
- Frappe TLS + API-Token im Secret Store (`.env.local`) ✓ (Konfiguration)
**Placeholder-Scan:** Keine TBDs oder offenen Punkte gefunden.
**Typ-Konsistenz:**
- `UpdateStockOnChannelsMessage(articleId: string, newStock: int)` — Fieldname `newStock` aus Plan 5 ✓
- `TrackingPushMessage` routing → `channel_sync` (Plan 5 erwähnt dieses routing explizit) ✓
- `StoredFile.storagePath` + `StoredFile.filename` — aus Plan 2 ✓
- `Invoice.getFullPath()` verwendet `StoragePath.resolveFilePath()` — aus Plan 1 ✓
- `Order.setTracking()` setzt `status → Shipped` intern — aus Plan 1 ✓
- `ChannelAdapterRegistry.get(string $type)` — aus Plan 5 ✓
**Frappe-Deployment-Voraussetzungen (einmalig manuell vor der ersten Bestellung):**
1. Frappe Custom Fields anlegen: `Customer.custom_superseller_customer_id`, `Customer.custom_ebay_user_id`, `Sales Invoice.custom_platform_order_id`, `Sales Invoice.custom_article_inventory_number`
2. Frappe Item `REFURB-HW` (oder `FRAPPE_GENERIC_ITEM_CODE`) in der Frappe-Instanz anlegen
3. Frappe API Key + Secret erzeugen und in `.env.local` eintragen