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>
1699 lines
57 KiB
Markdown
1699 lines
57 KiB
Markdown
# 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
|