feat: add Order, Pipeline, Auth domain entities (Customer with matching-key, Order, Invoice, AIPipelineJob, User, ApiKey)

This commit is contained in:
Simon Kuehn 2026-05-14 04:30:12 +00:00
parent 11c894b8a4
commit e8fb01f707
7 changed files with 559 additions and 0 deletions

View file

@ -0,0 +1,81 @@
<?php
declare(strict_types=1);
namespace App\Domain\Auth;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Uid\Uuid;
#[ORM\Entity]
#[ORM\Table(name: 'api_keys', schema: 'app')]
class ApiKey
{
#[ORM\Id]
#[ORM\Column(type: 'uuid')]
private Uuid $id;
#[ORM\ManyToOne(targetEntity: User::class)]
#[ORM\JoinColumn(nullable: false)]
private User $user;
#[ORM\Column(type: 'string', length: 255)]
private string $label;
#[ORM\Column(type: 'string', length: 255, unique: true)]
private string $keyHash;
/** @var array<string, bool> */
#[ORM\Column(type: 'json')]
private array $permissions = [];
#[ORM\Column(type: 'boolean')]
private bool $isActive = true;
#[ORM\Column(type: 'datetime_immutable', nullable: true)]
private ?\DateTimeImmutable $lastUsedAt = null;
#[ORM\Column(type: 'datetime_immutable', nullable: true)]
private ?\DateTimeImmutable $expiresAt = null;
public function __construct(User $user, string $label, string $keyHash)
{
$this->id = Uuid::v7();
$this->user = $user;
$this->label = $label;
$this->keyHash = $keyHash;
}
public function getId(): Uuid { return $this->id; }
public function getUser(): User { return $this->user; }
public function getLabel(): string { return $this->label; }
public function getKeyHash(): string { return $this->keyHash; }
public function isActive(): bool { return $this->isActive; }
public function setIsActive(bool $active): void { $this->isActive = $active; }
public function getLastUsedAt(): ?\DateTimeImmutable { return $this->lastUsedAt; }
public function getExpiresAt(): ?\DateTimeImmutable { return $this->expiresAt; }
public function setExpiresAt(?\DateTimeImmutable $expiresAt): void { $this->expiresAt = $expiresAt; }
/** @return array<string, bool> */
public function getPermissions(): array { return $this->permissions; }
public function hasPermission(string $permission): bool
{
return $this->permissions[$permission] ?? false;
}
public function grantPermission(string $permission): void
{
$this->permissions[$permission] = true;
}
public function markUsed(): void
{
$this->lastUsedAt = new \DateTimeImmutable();
}
public function isExpired(): bool
{
return null !== $this->expiresAt && $this->expiresAt < new \DateTimeImmutable();
}
}

75
src/Domain/Auth/User.php Normal file
View file

@ -0,0 +1,75 @@
<?php
declare(strict_types=1);
namespace App\Domain\Auth;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface;
use Symfony\Component\Security\Core\User\UserInterface;
use Symfony\Component\Uid\Uuid;
#[ORM\Entity]
#[ORM\Table(name: 'users', schema: 'app')]
class User implements UserInterface, PasswordAuthenticatedUserInterface
{
#[ORM\Id]
#[ORM\Column(type: 'uuid')]
private Uuid $id;
#[ORM\Column(type: 'string', length: 255, unique: true)]
private string $email;
#[ORM\Column(type: 'string')]
private string $passwordHash;
#[ORM\Column(type: 'string', nullable: true)]
private ?string $totpSecret = null;
/** @var array<string, bool> */
#[ORM\Column(type: 'json')]
private array $permissions = [];
#[ORM\Column(type: 'boolean')]
private bool $isActive = true;
public function __construct(string $email, string $passwordHash)
{
$this->id = Uuid::v7();
$this->email = $email;
$this->passwordHash = $passwordHash;
}
public function getId(): Uuid { return $this->id; }
public function getEmail(): string { return $this->email; }
public function getPassword(): string { return $this->passwordHash; }
public function getUserIdentifier(): string { return $this->email; }
/** @return list<string> */
public function getRoles(): array { return ['ROLE_USER']; }
public function eraseCredentials(): void {}
public function getTotpSecret(): ?string { return $this->totpSecret; }
public function setTotpSecret(?string $secret): void { $this->totpSecret = $secret; }
public function isActive(): bool { return $this->isActive; }
public function setIsActive(bool $active): void { $this->isActive = $active; }
/** @return array<string, bool> */
public function getPermissions(): array { return $this->permissions; }
public function hasPermission(string $permission): bool
{
return $this->permissions[$permission] ?? false;
}
public function grantPermission(string $permission): void
{
$this->permissions[$permission] = true;
}
public function revokePermission(string $permission): void
{
unset($this->permissions[$permission]);
}
}

View file

@ -0,0 +1,78 @@
<?php
declare(strict_types=1);
namespace App\Domain\Order;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Uid\Uuid;
#[ORM\Entity]
#[ORM\Table(name: 'customers', schema: 'app')]
class Customer
{
#[ORM\Id]
#[ORM\Column(type: 'uuid')]
private Uuid $id;
#[ORM\Column(type: 'string', length: 255)]
private string $name;
#[ORM\Column(type: 'string', length: 255)]
private string $email;
/** @var array<string, string> */
#[ORM\Column(type: 'json')]
private array $address;
#[ORM\Column(type: 'string', length: 255, nullable: true)]
private ?string $frappeCustomerId = null;
/** @var array<string, string> */
#[ORM\Column(type: 'json')]
private array $platformIds = [];
/**
* @param array<string, string> $address
*/
public function __construct(string $name, string $email, array $address)
{
$this->id = Uuid::v7();
$this->name = $name;
$this->email = $email;
$this->address = $address;
}
public function getId(): Uuid { return $this->id; }
public function getName(): string { return $this->name; }
public function getEmail(): string { return $this->email; }
/** @return array<string, string> */
public function getAddress(): array { return $this->address; }
public function getFrappeCustomerId(): ?string { return $this->frappeCustomerId; }
public function setFrappeCustomerId(?string $id): void { $this->frappeCustomerId = $id; }
/** @return array<string, string> */
public function getPlatformIds(): array { return $this->platformIds; }
public function getPlatformId(string $platform): ?string
{
return $this->platformIds[$platform] ?? null;
}
public function addPlatformId(string $platform, string $id): void
{
$this->platformIds[$platform] = $id;
}
public function getMatchingKey(): string
{
return \mb_strtolower(\implode('|', [
$this->name,
$this->address['street'] ?? '',
$this->address['city'] ?? '',
$this->address['zip'] ?? '',
]));
}
}

View file

@ -0,0 +1,70 @@
<?php
declare(strict_types=1);
namespace App\Domain\Order;
use App\Domain\Storage\StoragePath;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Uid\Uuid;
#[ORM\Entity]
#[ORM\Table(name: 'invoices', schema: 'app')]
class Invoice
{
#[ORM\Id]
#[ORM\Column(type: 'uuid')]
private Uuid $id;
#[ORM\OneToOne(targetEntity: Order::class, inversedBy: 'invoice')]
#[ORM\JoinColumn(nullable: false)]
private Order $order;
#[ORM\Column(type: 'string', length: 255)]
private string $frappeInvoiceId;
#[ORM\ManyToOne(targetEntity: StoragePath::class)]
#[ORM\JoinColumn(nullable: false)]
private StoragePath $storagePath;
#[ORM\Column(type: 'string', length: 500)]
private string $filename;
#[ORM\Column(type: 'datetime_immutable')]
private \DateTimeImmutable $createdAt;
#[ORM\Column(type: 'datetime_immutable', nullable: true)]
private ?\DateTimeImmutable $emailedAt = null;
public function __construct(
Order $order,
string $frappeInvoiceId,
StoragePath $storagePath,
string $filename,
) {
$this->id = Uuid::v7();
$this->order = $order;
$this->frappeInvoiceId = $frappeInvoiceId;
$this->storagePath = $storagePath;
$this->filename = $filename;
$this->createdAt = new \DateTimeImmutable();
}
public function getId(): Uuid { return $this->id; }
public function getOrder(): Order { return $this->order; }
public function getFrappeInvoiceId(): string { return $this->frappeInvoiceId; }
public function getStoragePath(): StoragePath { return $this->storagePath; }
public function getFilename(): string { return $this->filename; }
public function getCreatedAt(): \DateTimeImmutable { return $this->createdAt; }
public function getEmailedAt(): ?\DateTimeImmutable { return $this->emailedAt; }
public function markAsEmailed(): void
{
$this->emailedAt = new \DateTimeImmutable();
}
public function getFullPath(): string
{
return $this->storagePath->resolveFilePath($this->filename);
}
}

105
src/Domain/Order/Order.php Normal file
View file

@ -0,0 +1,105 @@
<?php
declare(strict_types=1);
namespace App\Domain\Order;
use App\Domain\Article\Article;
use App\Domain\Channel\Platform;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Uid\Uuid;
#[ORM\Entity]
#[ORM\Table(name: 'orders', schema: 'app')]
class Order
{
#[ORM\Id]
#[ORM\Column(type: 'uuid')]
private Uuid $id;
#[ORM\ManyToOne(targetEntity: Article::class)]
#[ORM\JoinColumn(nullable: false)]
private Article $article;
#[ORM\ManyToOne(targetEntity: Customer::class)]
#[ORM\JoinColumn(nullable: false)]
private Customer $customer;
#[ORM\ManyToOne(targetEntity: Platform::class)]
#[ORM\JoinColumn(nullable: false)]
private Platform $platform;
#[ORM\Column(type: 'string', length: 255)]
private string $platformOrderId;
#[ORM\Column(type: 'string', enumType: OrderStatus::class)]
private OrderStatus $status;
#[ORM\Column(type: 'decimal', precision: 10, scale: 2)]
private string $salePrice;
#[ORM\Column(type: 'datetime_immutable')]
private \DateTimeImmutable $saleDate;
#[ORM\Column(type: 'string', length: 255, nullable: true)]
private ?string $trackingNumber = null;
#[ORM\Column(type: 'string', length: 100, nullable: true)]
private ?string $carrier = null;
#[ORM\Column(type: 'datetime_immutable', nullable: true)]
private ?\DateTimeImmutable $shippedAt = null;
#[ORM\Column(type: 'datetime_immutable', nullable: true)]
private ?\DateTimeImmutable $trackingPushedToEbayAt = null;
#[ORM\OneToOne(mappedBy: 'order', targetEntity: Invoice::class, cascade: ['persist'])]
private ?Invoice $invoice = null;
public function __construct(
Article $article,
Customer $customer,
Platform $platform,
string $platformOrderId,
string $salePrice,
\DateTimeImmutable $saleDate,
) {
$this->id = Uuid::v7();
$this->article = $article;
$this->customer = $customer;
$this->platform = $platform;
$this->platformOrderId = $platformOrderId;
$this->status = OrderStatus::Pending;
$this->salePrice = $salePrice;
$this->saleDate = $saleDate;
}
public function setTracking(string $trackingNumber, string $carrier): void
{
$this->trackingNumber = $trackingNumber;
$this->carrier = $carrier;
$this->shippedAt = new \DateTimeImmutable();
$this->status = OrderStatus::Shipped;
}
public function markTrackingPushedToEbay(): void
{
$this->trackingPushedToEbayAt = new \DateTimeImmutable();
}
public function getId(): Uuid { return $this->id; }
public function getArticle(): Article { return $this->article; }
public function getCustomer(): Customer { return $this->customer; }
public function getPlatform(): Platform { return $this->platform; }
public function getPlatformOrderId(): string { return $this->platformOrderId; }
public function getStatus(): OrderStatus { return $this->status; }
public function setStatus(OrderStatus $status): void { $this->status = $status; }
public function getSalePrice(): string { return $this->salePrice; }
public function getSaleDate(): \DateTimeImmutable { return $this->saleDate; }
public function getTrackingNumber(): ?string { return $this->trackingNumber; }
public function getCarrier(): ?string { return $this->carrier; }
public function getShippedAt(): ?\DateTimeImmutable { return $this->shippedAt; }
public function getTrackingPushedToEbayAt(): ?\DateTimeImmutable { return $this->trackingPushedToEbayAt; }
public function getInvoice(): ?Invoice { return $this->invoice; }
public function setInvoice(Invoice $invoice): void { $this->invoice = $invoice; }
}

View file

@ -0,0 +1,111 @@
<?php
declare(strict_types=1);
namespace App\Domain\Pipeline;
use App\Domain\Article\Article;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Uid\Uuid;
#[ORM\Entity]
#[ORM\Table(name: 'ai_pipeline_jobs', schema: 'app')]
class AIPipelineJob
{
#[ORM\Id]
#[ORM\Column(type: 'uuid')]
private Uuid $id;
#[ORM\Column(type: 'string', enumType: AIPipelineJobType::class)]
private AIPipelineJobType $type;
#[ORM\ManyToOne(targetEntity: Article::class)]
#[ORM\JoinColumn(nullable: true)]
private ?Article $article = null;
#[ORM\Column(type: 'string', enumType: AIPipelineJobStatus::class)]
private AIPipelineJobStatus $status;
#[ORM\Column(type: 'integer')]
private int $attemptCount = 0;
/** @var array<string, mixed> */
#[ORM\Column(type: 'json')]
private array $inputData;
/** @var array<string, mixed> */
#[ORM\Column(type: 'json')]
private array $outputData = [];
/** @var list<string> */
#[ORM\Column(type: 'simple_array', nullable: true)]
private array $missingFields = [];
#[ORM\Column(type: 'text', nullable: true)]
private ?string $errorMessage = null;
#[ORM\Column(type: 'datetime_immutable')]
private \DateTimeImmutable $createdAt;
#[ORM\Column(type: 'datetime_immutable', nullable: true)]
private ?\DateTimeImmutable $completedAt = null;
/**
* @param array<string, mixed> $inputData
*/
public function __construct(AIPipelineJobType $type, array $inputData)
{
$this->id = Uuid::v7();
$this->type = $type;
$this->inputData = $inputData;
$this->status = AIPipelineJobStatus::Queued;
$this->createdAt = new \DateTimeImmutable();
}
public function incrementAttempt(): void { ++$this->attemptCount; }
public function markCompleted(): void
{
$this->status = AIPipelineJobStatus::Completed;
$this->completedAt = new \DateTimeImmutable();
}
public function markFailed(string $errorMessage): void
{
$this->status = AIPipelineJobStatus::Failed;
$this->errorMessage = $errorMessage;
}
public function markNeedsReview(string $reason): void
{
$this->status = AIPipelineJobStatus::NeedsReview;
$this->errorMessage = $reason;
}
public function getId(): Uuid { return $this->id; }
public function getType(): AIPipelineJobType { return $this->type; }
public function getArticle(): ?Article { return $this->article; }
public function setArticle(Article $article): void { $this->article = $article; }
public function getStatus(): AIPipelineJobStatus { return $this->status; }
public function setStatus(AIPipelineJobStatus $status): void { $this->status = $status; }
public function getAttemptCount(): int { return $this->attemptCount; }
/** @return array<string, mixed> */
public function getInputData(): array { return $this->inputData; }
/** @return array<string, mixed> */
public function getOutputData(): array { return $this->outputData; }
/** @param array<string, mixed> $outputData */
public function setOutputData(array $outputData): void { $this->outputData = $outputData; }
/** @return list<string> */
public function getMissingFields(): array { return $this->missingFields; }
/** @param list<string> $fields */
public function setMissingFields(array $fields): void { $this->missingFields = $fields; }
public function getErrorMessage(): ?string { return $this->errorMessage; }
public function getCreatedAt(): \DateTimeImmutable { return $this->createdAt; }
public function getCompletedAt(): ?\DateTimeImmutable { return $this->completedAt; }
}

View file

@ -0,0 +1,39 @@
<?php
declare(strict_types=1);
namespace App\Tests\Unit\Domain\Order;
use App\Domain\Order\Customer;
use PHPUnit\Framework\TestCase;
final class CustomerTest extends TestCase
{
public function test_new_customer_has_empty_platform_ids(): void
{
$customer = new Customer('Max Mustermann', 'max@example.com', []);
$this->assertSame([], $customer->getPlatformIds());
$this->assertNull($customer->getPlatformId('ebay'));
}
public function test_add_platform_id(): void
{
$customer = new Customer('Max Mustermann', 'max@example.com', []);
$customer->addPlatformId('ebay', 'ebay-user-123');
$this->assertSame('ebay-user-123', $customer->getPlatformId('ebay'));
}
public function test_matching_key_is_lowercase_normalized(): void
{
$customer = new Customer('Max Mustermann', 'max@example.com', [
'street' => 'Musterstraße 1',
'city' => 'Berlin',
'zip' => '10115',
]);
$expected = 'max mustermann|musterstraße 1|berlin|10115';
$this->assertSame($expected, $customer->getMatchingKey());
}
}