Bestellungen: PrintOrder/OrderItem + OrderController (Backend)
- PrintOrder (company, status-Workflow new/in_production/shipped/completed/ cancelled, number, createdBy, items) + OrderItem (product, employee, quantity) - OrderController: GET Liste (scoped: Firma eigene / Reseller alle seiner Firmen / Plattform alle), GET Detail (inkl. PDF-Link je Position), POST anlegen (Firmen-Admin), PATCH /status (Reseller wickelt ab / Firma storniert solange neu). Produkt-/Mitarbeiter-Sichtbarkeit geprüft. - Migration + Demo-Bestellung (Muster: 100 Visitenkarten + 10 NFC). KONZEPT §13. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
6dd6d3a96e
commit
b09931997b
43
backend/migrations/Version20260603105617.php
Normal file
43
backend/migrations/Version20260603105617.php
Normal file
@ -0,0 +1,43 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace DoctrineMigrations;
|
||||||
|
|
||||||
|
use Doctrine\DBAL\Schema\Schema;
|
||||||
|
use Doctrine\Migrations\AbstractMigration;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Auto-generated Migration: Please modify to your needs!
|
||||||
|
*/
|
||||||
|
final class Version20260603105617 extends AbstractMigration
|
||||||
|
{
|
||||||
|
public function getDescription(): string
|
||||||
|
{
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function up(Schema $schema): void
|
||||||
|
{
|
||||||
|
// this up() migration is auto-generated, please modify it to your needs
|
||||||
|
$this->addSql('CREATE TABLE order_item (id BINARY(16) NOT NULL, quantity INT NOT NULL, order_id BINARY(16) NOT NULL, product_id BINARY(16) NOT NULL, employee_id BINARY(16) NOT NULL, INDEX IDX_52EA1F098D9F6D38 (order_id), INDEX IDX_52EA1F094584665A (product_id), INDEX IDX_52EA1F098C03F15C (employee_id), PRIMARY KEY (id)) DEFAULT CHARACTER SET utf8mb4');
|
||||||
|
$this->addSql('CREATE TABLE print_order (id BINARY(16) NOT NULL, number VARCHAR(20) NOT NULL, status VARCHAR(20) NOT NULL, note LONGTEXT DEFAULT NULL, created_at DATETIME NOT NULL, company_id BINARY(16) NOT NULL, created_by_id BINARY(16) DEFAULT NULL, INDEX IDX_844C1953979B1AD6 (company_id), INDEX IDX_844C1953B03A8386 (created_by_id), PRIMARY KEY (id)) DEFAULT CHARACTER SET utf8mb4');
|
||||||
|
$this->addSql('ALTER TABLE order_item ADD CONSTRAINT FK_52EA1F098D9F6D38 FOREIGN KEY (order_id) REFERENCES print_order (id)');
|
||||||
|
$this->addSql('ALTER TABLE order_item ADD CONSTRAINT FK_52EA1F094584665A FOREIGN KEY (product_id) REFERENCES product (id)');
|
||||||
|
$this->addSql('ALTER TABLE order_item ADD CONSTRAINT FK_52EA1F098C03F15C FOREIGN KEY (employee_id) REFERENCES employee (id)');
|
||||||
|
$this->addSql('ALTER TABLE print_order ADD CONSTRAINT FK_844C1953979B1AD6 FOREIGN KEY (company_id) REFERENCES company (id)');
|
||||||
|
$this->addSql('ALTER TABLE print_order ADD CONSTRAINT FK_844C1953B03A8386 FOREIGN KEY (created_by_id) REFERENCES employee (id) ON DELETE SET NULL');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(Schema $schema): void
|
||||||
|
{
|
||||||
|
// this down() migration is auto-generated, please modify it to your needs
|
||||||
|
$this->addSql('ALTER TABLE order_item DROP FOREIGN KEY FK_52EA1F098D9F6D38');
|
||||||
|
$this->addSql('ALTER TABLE order_item DROP FOREIGN KEY FK_52EA1F094584665A');
|
||||||
|
$this->addSql('ALTER TABLE order_item DROP FOREIGN KEY FK_52EA1F098C03F15C');
|
||||||
|
$this->addSql('ALTER TABLE print_order DROP FOREIGN KEY FK_844C1953979B1AD6');
|
||||||
|
$this->addSql('ALTER TABLE print_order DROP FOREIGN KEY FK_844C1953B03A8386');
|
||||||
|
$this->addSql('DROP TABLE order_item');
|
||||||
|
$this->addSql('DROP TABLE print_order');
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -6,7 +6,9 @@ use App\Entity\Company;
|
|||||||
use App\Entity\ContactLink;
|
use App\Entity\ContactLink;
|
||||||
use App\Entity\Employee;
|
use App\Entity\Employee;
|
||||||
use App\Entity\Location;
|
use App\Entity\Location;
|
||||||
|
use App\Entity\OrderItem;
|
||||||
use App\Entity\PlatformPlan;
|
use App\Entity\PlatformPlan;
|
||||||
|
use App\Entity\PrintOrder;
|
||||||
use App\Entity\Product;
|
use App\Entity\Product;
|
||||||
use App\Entity\Reseller;
|
use App\Entity\Reseller;
|
||||||
use Doctrine\ORM\EntityManagerInterface;
|
use Doctrine\ORM\EntityManagerInterface;
|
||||||
@ -42,9 +44,9 @@ final class SeedCommand extends Command
|
|||||||
$this->em->persist($plan);
|
$this->em->persist($plan);
|
||||||
|
|
||||||
// Globaler Produktkatalog (Plattform-Produkte, für alle Reseller sichtbar)
|
// Globaler Produktkatalog (Plattform-Produkte, für alle Reseller sichtbar)
|
||||||
$this->product(null, Product::KIND_BUSINESS_CARD, 'Visitenkarte', 85.0, 55.0, 2.0, 4.0, 2, false, 0);
|
$card = $this->product(null, Product::KIND_BUSINESS_CARD, 'Visitenkarte', 85.0, 55.0, 2.0, 4.0, 2, false, 0);
|
||||||
$this->product(null, Product::KIND_NAME_TAG, 'Namensschild', 90.0, 55.0, 0.0, 3.0, 1, false, 1);
|
$this->product(null, Product::KIND_NAME_TAG, 'Namensschild', 90.0, 55.0, 0.0, 3.0, 1, false, 1);
|
||||||
$this->product(null, Product::KIND_NFC_CARD, 'NFC-Karte', 85.6, 54.0, 2.0, 4.0, 2, true, 2);
|
$nfc = $this->product(null, Product::KIND_NFC_CARD, 'NFC-Karte', 85.6, 54.0, 2.0, 4.0, 2, true, 2);
|
||||||
|
|
||||||
// Plattform-Betreiber = Reseller mit isPlatform + Org-Firma + 2 Plattform-Admins
|
// Plattform-Betreiber = Reseller mit isPlatform + Org-Firma + 2 Plattform-Admins
|
||||||
[$platform, $pOrg] = $this->reseller('vcard4reseller', 'platform', $plan, true);
|
[$platform, $pOrg] = $this->reseller('vcard4reseller', 'platform', $plan, true);
|
||||||
@ -56,7 +58,7 @@ final class SeedCommand extends Command
|
|||||||
// Reseller-eigenes Produkt (nur im Mandanten der Demo Druckerei sichtbar)
|
// Reseller-eigenes Produkt (nur im Mandanten der Demo Druckerei sichtbar)
|
||||||
$this->product($demo, Product::KIND_BUSINESS_CARD, 'Premium-Visitenkarte 90×50', 90.0, 50.0, 3.0, 4.0, 2, true, 0);
|
$this->product($demo, Product::KIND_BUSINESS_CARD, 'Premium-Visitenkarte 90×50', 90.0, 50.0, 3.0, 4.0, 2, true, 0);
|
||||||
$this->staff($dOrg, 'Demo', 'Reseller', 'reseller@demo.de', 'reseller', Employee::ROLE_RESELLER_ADMIN);
|
$this->staff($dOrg, 'Demo', 'Reseller', 'reseller@demo.de', 'reseller', Employee::ROLE_RESELLER_ADMIN);
|
||||||
$this->customer($demo, 'Muster GmbH', 'muster', 'firma@muster.de', 'firma', 'Erika', 'Mustermann');
|
$this->customer($demo, 'Muster GmbH', 'muster', 'firma@muster.de', 'firma', 'Erika', 'Mustermann', $card, $nfc);
|
||||||
|
|
||||||
// Reseller „Print Studio"
|
// Reseller „Print Studio"
|
||||||
[$ps, $psOrg] = $this->reseller('Print Studio', 'printstudio', $plan, false);
|
[$ps, $psOrg] = $this->reseller('Print Studio', 'printstudio', $plan, false);
|
||||||
@ -108,7 +110,7 @@ final class SeedCommand extends Command
|
|||||||
return $e;
|
return $e;
|
||||||
}
|
}
|
||||||
|
|
||||||
private function customer(Reseller $reseller, string $companyName, string $companySlug, string $adminEmail, string $adminPw, string $first, string $last): void
|
private function customer(Reseller $reseller, string $companyName, string $companySlug, string $adminEmail, string $adminPw, string $first, string $last, ?Product $card = null, ?Product $nfc = null): void
|
||||||
{
|
{
|
||||||
$company = (new Company())->setName($companyName)->setSlug($companySlug)->setReseller($reseller)->setSelfEditEnabled(true);
|
$company = (new Company())->setName($companyName)->setSlug($companySlug)->setReseller($reseller)->setSelfEditEnabled(true);
|
||||||
$this->em->persist($company);
|
$this->em->persist($company);
|
||||||
@ -118,7 +120,7 @@ final class SeedCommand extends Command
|
|||||||
$this->em->persist($location);
|
$this->em->persist($location);
|
||||||
|
|
||||||
// Firmen-Admin (Mitarbeiter mit Login)
|
// Firmen-Admin (Mitarbeiter mit Login)
|
||||||
$this->staff($company, 'Firmen', 'Admin', $adminEmail, $adminPw, Employee::ROLE_COMPANY_ADMIN)
|
$admin = $this->staff($company, 'Firmen', 'Admin', $adminEmail, $adminPw, Employee::ROLE_COMPANY_ADMIN)
|
||||||
->setSlug('firmen-admin');
|
->setSlug('firmen-admin');
|
||||||
|
|
||||||
// Öffentliches Profil-Beispiel (ohne Login)
|
// Öffentliches Profil-Beispiel (ohne Login)
|
||||||
@ -131,5 +133,14 @@ final class SeedCommand extends Command
|
|||||||
$link = (new ContactLink())->setType('linkedin')->setUrl('https://linkedin.com/in/'.strtolower($first))->setPosition(0);
|
$link = (new ContactLink())->setType('linkedin')->setUrl('https://linkedin.com/in/'.strtolower($first))->setPosition(0);
|
||||||
$employee->addContactLink($link);
|
$employee->addContactLink($link);
|
||||||
$this->em->persist($link);
|
$this->em->persist($link);
|
||||||
|
|
||||||
|
// Demo-Bestellung: 100 Visitenkarten (Profil) + 10 NFC-Karten (Admin)
|
||||||
|
if (null !== $card && null !== $nfc) {
|
||||||
|
$order = (new PrintOrder())->setCompany($company)->setCreatedBy($admin)
|
||||||
|
->setNote('Erstausstattung neues Team');
|
||||||
|
$order->addItem((new OrderItem())->setProduct($card)->setEmployee($employee)->setQuantity(100));
|
||||||
|
$order->addItem((new OrderItem())->setProduct($nfc)->setEmployee($admin)->setQuantity(10));
|
||||||
|
$this->em->persist($order);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
246
backend/src/Controller/OrderController.php
Normal file
246
backend/src/Controller/OrderController.php
Normal file
@ -0,0 +1,246 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Controller;
|
||||||
|
|
||||||
|
use App\Entity\Company;
|
||||||
|
use App\Entity\Employee;
|
||||||
|
use App\Entity\OrderItem;
|
||||||
|
use App\Entity\PrintOrder;
|
||||||
|
use App\Entity\Product;
|
||||||
|
use App\Repository\EmployeeRepository;
|
||||||
|
use App\Repository\PrintOrderRepository;
|
||||||
|
use App\Repository\ProductRepository;
|
||||||
|
use App\Security\TenantContext;
|
||||||
|
use Doctrine\ORM\EntityManagerInterface;
|
||||||
|
use Symfony\Component\HttpFoundation\JsonResponse;
|
||||||
|
use Symfony\Component\HttpFoundation\Request;
|
||||||
|
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
|
||||||
|
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
|
||||||
|
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
||||||
|
use Symfony\Component\Routing\Attribute\Route;
|
||||||
|
use Symfony\Component\Security\Http\Attribute\IsGranted;
|
||||||
|
use Symfony\Component\Uid\Uuid;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bestellungen/Druckaufträge: Firma bestellt Produkte je Mitarbeiter,
|
||||||
|
* Reseller wickelt ab. Mandantengeprüft (KONZEPT §13 → Bestellungen).
|
||||||
|
*/
|
||||||
|
#[IsGranted('ROLE_COMPANY_ADMIN')]
|
||||||
|
final class OrderController
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly PrintOrderRepository $orders,
|
||||||
|
private readonly ProductRepository $products,
|
||||||
|
private readonly EmployeeRepository $employees,
|
||||||
|
private readonly EntityManagerInterface $em,
|
||||||
|
private readonly TenantContext $tenant,
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Route('/api/orders', name: 'orders_list', methods: ['GET'])]
|
||||||
|
public function list(): JsonResponse
|
||||||
|
{
|
||||||
|
$qb = $this->orders->createQueryBuilder('o')
|
||||||
|
->join('o.company', 'c')->addSelect('c')
|
||||||
|
->orderBy('o.createdAt', 'DESC');
|
||||||
|
|
||||||
|
if (!$this->tenant->isPlatformAdmin()) {
|
||||||
|
$reseller = $this->tenant->getReseller();
|
||||||
|
if (null === $reseller) {
|
||||||
|
return new JsonResponse(['member' => []]);
|
||||||
|
}
|
||||||
|
$qb->andWhere('c.reseller = :r')->setParameter('r', $reseller->getId(), 'uuid');
|
||||||
|
$company = $this->tenant->getCompany();
|
||||||
|
if (null !== $company) {
|
||||||
|
$qb->andWhere('c = :company')->setParameter('company', $company->getId(), 'uuid');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$member = array_map(fn (PrintOrder $o) => $this->serializeSummary($o), $qb->getQuery()->getResult());
|
||||||
|
|
||||||
|
return new JsonResponse(['member' => $member, 'totalItems' => count($member)]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Route('/api/orders/{id}', name: 'orders_get', methods: ['GET'])]
|
||||||
|
public function get(string $id): JsonResponse
|
||||||
|
{
|
||||||
|
return new JsonResponse($this->serializeDetail($this->order($id)));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Route('/api/orders', name: 'orders_create', methods: ['POST'])]
|
||||||
|
public function create(Request $request): JsonResponse
|
||||||
|
{
|
||||||
|
$data = json_decode($request->getContent(), true) ?? [];
|
||||||
|
$company = $this->resolveCompany($data['company'] ?? null);
|
||||||
|
|
||||||
|
$rawItems = is_array($data['items'] ?? null) ? $data['items'] : [];
|
||||||
|
if (0 === count($rawItems)) {
|
||||||
|
throw new BadRequestHttpException('Bestellung enthält keine Positionen.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$order = (new PrintOrder())->setCompany($company)
|
||||||
|
->setNote(isset($data['note']) ? trim((string) $data['note']) ?: null : null)
|
||||||
|
->setCreatedBy($this->tenant->getEmployee());
|
||||||
|
|
||||||
|
foreach ($rawItems as $row) {
|
||||||
|
$product = $this->product((string) ($row['product'] ?? ''));
|
||||||
|
$employee = $this->employeeInCompany((string) ($row['employee'] ?? ''), $company);
|
||||||
|
$qty = (int) ($row['quantity'] ?? 0);
|
||||||
|
if ($qty < 1) {
|
||||||
|
throw new BadRequestHttpException('Menge muss mindestens 1 sein.');
|
||||||
|
}
|
||||||
|
$item = (new OrderItem())->setProduct($product)->setEmployee($employee)->setQuantity($qty);
|
||||||
|
$order->addItem($item);
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->em->persist($order);
|
||||||
|
$this->em->flush();
|
||||||
|
|
||||||
|
return new JsonResponse($this->serializeDetail($order), 201);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Route('/api/orders/{id}/status', name: 'orders_status', methods: ['PATCH'])]
|
||||||
|
public function status(string $id, Request $request): JsonResponse
|
||||||
|
{
|
||||||
|
$order = $this->order($id);
|
||||||
|
$data = json_decode($request->getContent(), true) ?? [];
|
||||||
|
$target = (string) ($data['status'] ?? '');
|
||||||
|
if (!in_array($target, PrintOrder::STATUSES, true)) {
|
||||||
|
throw new BadRequestHttpException('Ungültiger Status.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$isFulfiller = null === $this->tenant->getCompany(); // Reseller-/Plattform-Admin
|
||||||
|
if (PrintOrder::STATUS_CANCELLED === $target) {
|
||||||
|
// Firma darf nur stornieren, solange „neu"; Reseller/Plattform jederzeit
|
||||||
|
if (!$isFulfiller && PrintOrder::STATUS_NEW !== $order->getStatus()) {
|
||||||
|
throw new AccessDeniedHttpException('Stornieren nur möglich, solange die Bestellung neu ist.');
|
||||||
|
}
|
||||||
|
} elseif (!$isFulfiller) {
|
||||||
|
throw new AccessDeniedHttpException('Status wird vom Reseller (Druckshop) gesetzt.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$order->setStatus($target);
|
||||||
|
$this->em->flush();
|
||||||
|
|
||||||
|
return new JsonResponse($this->serializeDetail($order));
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Hilfen ---
|
||||||
|
|
||||||
|
private function order(string $id): PrintOrder
|
||||||
|
{
|
||||||
|
$order = $this->orders->find(Uuid::fromString($id));
|
||||||
|
if (!$order instanceof PrintOrder) {
|
||||||
|
throw new NotFoundHttpException('Bestellung nicht gefunden.');
|
||||||
|
}
|
||||||
|
$this->assertScope($order->getCompany());
|
||||||
|
|
||||||
|
return $order;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Firma der Bestellung: Firmen-Admin = eigene; Reseller/Plattform = aus Body. */
|
||||||
|
private function resolveCompany(?string $companyId): Company
|
||||||
|
{
|
||||||
|
$own = $this->tenant->getCompany();
|
||||||
|
if (null !== $own) {
|
||||||
|
return $own;
|
||||||
|
}
|
||||||
|
if (null === $companyId || '' === $companyId) {
|
||||||
|
throw new BadRequestHttpException('Firma (company) erforderlich.');
|
||||||
|
}
|
||||||
|
$company = $this->em->getRepository(Company::class)->find(Uuid::fromString($companyId));
|
||||||
|
if (!$company instanceof Company) {
|
||||||
|
throw new NotFoundHttpException('Firma nicht gefunden.');
|
||||||
|
}
|
||||||
|
$this->assertScope($company);
|
||||||
|
|
||||||
|
return $company;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function assertScope(Company $company): void
|
||||||
|
{
|
||||||
|
if ($this->tenant->isPlatformAdmin()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
$reseller = $this->tenant->getReseller();
|
||||||
|
if (null === $reseller || $company->getReseller()?->getId()->equals($reseller->getId()) !== true) {
|
||||||
|
throw new AccessDeniedHttpException('Firma gehört nicht zum eigenen Mandanten.');
|
||||||
|
}
|
||||||
|
$own = $this->tenant->getCompany();
|
||||||
|
if (null !== $own && !$company->getId()->equals($own->getId())) {
|
||||||
|
throw new AccessDeniedHttpException('Nur die eigene Firma.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function product(string $id): Product
|
||||||
|
{
|
||||||
|
$product = '' !== $id ? $this->products->find(Uuid::fromString($id)) : null;
|
||||||
|
if (!$product instanceof Product) {
|
||||||
|
throw new NotFoundHttpException('Produkt nicht gefunden.');
|
||||||
|
}
|
||||||
|
if ($this->tenant->isPlatformAdmin() || $product->isGlobal()) {
|
||||||
|
return $product;
|
||||||
|
}
|
||||||
|
$reseller = $this->tenant->getReseller();
|
||||||
|
if (null === $reseller || $product->getReseller()?->getId()->equals($reseller->getId()) !== true) {
|
||||||
|
throw new AccessDeniedHttpException('Produkt im eigenen Mandanten nicht verfügbar.');
|
||||||
|
}
|
||||||
|
|
||||||
|
return $product;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function employeeInCompany(string $id, Company $company): Employee
|
||||||
|
{
|
||||||
|
$employee = '' !== $id ? $this->employees->find(Uuid::fromString($id)) : null;
|
||||||
|
if (!$employee instanceof Employee) {
|
||||||
|
throw new NotFoundHttpException('Mitarbeiter nicht gefunden.');
|
||||||
|
}
|
||||||
|
if (!$employee->getCompany()->getId()->equals($company->getId())) {
|
||||||
|
throw new AccessDeniedHttpException('Mitarbeiter gehört nicht zur bestellenden Firma.');
|
||||||
|
}
|
||||||
|
|
||||||
|
return $employee;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return array<string, mixed> */
|
||||||
|
private function serializeSummary(PrintOrder $o): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'id' => (string) $o->getId(),
|
||||||
|
'number' => $o->getNumber(),
|
||||||
|
'status' => $o->getStatus(),
|
||||||
|
'company' => ['id' => (string) $o->getCompany()->getId(), 'name' => $o->getCompany()->getName()],
|
||||||
|
'itemCount' => $o->getItems()->count(),
|
||||||
|
'totalQuantity' => $o->getTotalQuantity(),
|
||||||
|
'createdAt' => $o->getCreatedAt()->format(\DATE_ATOM),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return array<string, mixed> */
|
||||||
|
private function serializeDetail(PrintOrder $o): array
|
||||||
|
{
|
||||||
|
$items = [];
|
||||||
|
foreach ($o->getItems() as $item) {
|
||||||
|
$emp = $item->getEmployee();
|
||||||
|
$items[] = [
|
||||||
|
'id' => (string) $item->getId(),
|
||||||
|
'product' => ['id' => (string) $item->getProduct()->getId(), 'name' => $item->getProduct()->getName(), 'kind' => $item->getProduct()->getKind()],
|
||||||
|
'employee' => ['id' => (string) $emp->getId(), 'name' => trim($emp->getFirstName().' '.$emp->getLastName())],
|
||||||
|
'quantity' => $item->getQuantity(),
|
||||||
|
'pdfUrl' => sprintf('/api/employees/%s/card.pdf?product=%s', $emp->getId(), $item->getProduct()->getId()),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'id' => (string) $o->getId(),
|
||||||
|
'number' => $o->getNumber(),
|
||||||
|
'status' => $o->getStatus(),
|
||||||
|
'note' => $o->getNote(),
|
||||||
|
'company' => ['id' => (string) $o->getCompany()->getId(), 'name' => $o->getCompany()->getName()],
|
||||||
|
'createdBy' => $o->getCreatedBy() ? trim($o->getCreatedBy()->getFirstName().' '.$o->getCreatedBy()->getLastName()) : null,
|
||||||
|
'createdAt' => $o->getCreatedAt()->format(\DATE_ATOM),
|
||||||
|
'totalQuantity' => $o->getTotalQuantity(),
|
||||||
|
'items' => $items,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
92
backend/src/Entity/OrderItem.php
Normal file
92
backend/src/Entity/OrderItem.php
Normal file
@ -0,0 +1,92 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Entity;
|
||||||
|
|
||||||
|
use App\Repository\OrderItemRepository;
|
||||||
|
use Doctrine\ORM\Mapping as ORM;
|
||||||
|
use Symfony\Bridge\Doctrine\Types\UuidType;
|
||||||
|
use Symfony\Component\Uid\Uuid;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Eine Position einer Bestellung: ein Produkt für einen Mitarbeiter in bestimmter Auflage.
|
||||||
|
*/
|
||||||
|
#[ORM\Entity(repositoryClass: OrderItemRepository::class)]
|
||||||
|
class OrderItem
|
||||||
|
{
|
||||||
|
#[ORM\Id]
|
||||||
|
#[ORM\Column(type: UuidType::NAME, unique: true)]
|
||||||
|
private Uuid $id;
|
||||||
|
|
||||||
|
#[ORM\ManyToOne(targetEntity: PrintOrder::class, inversedBy: 'items')]
|
||||||
|
#[ORM\JoinColumn(nullable: false)]
|
||||||
|
private PrintOrder $order;
|
||||||
|
|
||||||
|
#[ORM\ManyToOne(targetEntity: Product::class)]
|
||||||
|
#[ORM\JoinColumn(nullable: false)]
|
||||||
|
private Product $product;
|
||||||
|
|
||||||
|
#[ORM\ManyToOne(targetEntity: Employee::class)]
|
||||||
|
#[ORM\JoinColumn(nullable: false)]
|
||||||
|
private Employee $employee;
|
||||||
|
|
||||||
|
#[ORM\Column(type: 'integer')]
|
||||||
|
private int $quantity = 1;
|
||||||
|
|
||||||
|
public function __construct()
|
||||||
|
{
|
||||||
|
$this->id = Uuid::v7();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getId(): Uuid
|
||||||
|
{
|
||||||
|
return $this->id;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getOrder(): PrintOrder
|
||||||
|
{
|
||||||
|
return $this->order;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setOrder(PrintOrder $order): self
|
||||||
|
{
|
||||||
|
$this->order = $order;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getProduct(): Product
|
||||||
|
{
|
||||||
|
return $this->product;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setProduct(Product $product): self
|
||||||
|
{
|
||||||
|
$this->product = $product;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getEmployee(): Employee
|
||||||
|
{
|
||||||
|
return $this->employee;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setEmployee(Employee $employee): self
|
||||||
|
{
|
||||||
|
$this->employee = $employee;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getQuantity(): int
|
||||||
|
{
|
||||||
|
return $this->quantity;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setQuantity(int $quantity): self
|
||||||
|
{
|
||||||
|
$this->quantity = max(1, $quantity);
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
}
|
||||||
164
backend/src/Entity/PrintOrder.php
Normal file
164
backend/src/Entity/PrintOrder.php
Normal file
@ -0,0 +1,164 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Entity;
|
||||||
|
|
||||||
|
use App\Repository\PrintOrderRepository;
|
||||||
|
use Doctrine\Common\Collections\ArrayCollection;
|
||||||
|
use Doctrine\Common\Collections\Collection;
|
||||||
|
use Doctrine\ORM\Mapping as ORM;
|
||||||
|
use Symfony\Bridge\Doctrine\Types\UuidType;
|
||||||
|
use Symfony\Component\Uid\Uuid;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bestellung/Druckauftrag einer Firma (KONZEPT §13 → Bestellungen).
|
||||||
|
* Die Firma bestellt Produkte je Mitarbeiter; der Reseller wickelt ab.
|
||||||
|
*/
|
||||||
|
#[ORM\Entity(repositoryClass: PrintOrderRepository::class)]
|
||||||
|
#[ORM\Table(name: 'print_order')]
|
||||||
|
class PrintOrder implements ResellerOwnedInterface
|
||||||
|
{
|
||||||
|
public const STATUS_NEW = 'new';
|
||||||
|
public const STATUS_IN_PRODUCTION = 'in_production';
|
||||||
|
public const STATUS_SHIPPED = 'shipped';
|
||||||
|
public const STATUS_COMPLETED = 'completed';
|
||||||
|
public const STATUS_CANCELLED = 'cancelled';
|
||||||
|
|
||||||
|
public const STATUSES = [
|
||||||
|
self::STATUS_NEW,
|
||||||
|
self::STATUS_IN_PRODUCTION,
|
||||||
|
self::STATUS_SHIPPED,
|
||||||
|
self::STATUS_COMPLETED,
|
||||||
|
self::STATUS_CANCELLED,
|
||||||
|
];
|
||||||
|
|
||||||
|
#[ORM\Id]
|
||||||
|
#[ORM\Column(type: UuidType::NAME, unique: true)]
|
||||||
|
private Uuid $id;
|
||||||
|
|
||||||
|
#[ORM\Column(length: 20)]
|
||||||
|
private string $number;
|
||||||
|
|
||||||
|
#[ORM\ManyToOne(targetEntity: Company::class)]
|
||||||
|
#[ORM\JoinColumn(nullable: false)]
|
||||||
|
private Company $company;
|
||||||
|
|
||||||
|
#[ORM\Column(length: 20)]
|
||||||
|
private string $status = self::STATUS_NEW;
|
||||||
|
|
||||||
|
#[ORM\Column(type: 'text', nullable: true)]
|
||||||
|
private ?string $note = null;
|
||||||
|
|
||||||
|
#[ORM\ManyToOne(targetEntity: Employee::class)]
|
||||||
|
#[ORM\JoinColumn(nullable: true, onDelete: 'SET NULL')]
|
||||||
|
private ?Employee $createdBy = null;
|
||||||
|
|
||||||
|
/** @var Collection<int, OrderItem> */
|
||||||
|
#[ORM\OneToMany(targetEntity: OrderItem::class, mappedBy: 'order', cascade: ['persist', 'remove'], orphanRemoval: true)]
|
||||||
|
private Collection $items;
|
||||||
|
|
||||||
|
#[ORM\Column(type: 'datetime_immutable')]
|
||||||
|
private \DateTimeImmutable $createdAt;
|
||||||
|
|
||||||
|
public function __construct()
|
||||||
|
{
|
||||||
|
$this->id = Uuid::v7();
|
||||||
|
$this->createdAt = new \DateTimeImmutable();
|
||||||
|
$this->items = new ArrayCollection();
|
||||||
|
$this->number = 'B-'.strtoupper(bin2hex(random_bytes(3)));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getId(): Uuid
|
||||||
|
{
|
||||||
|
return $this->id;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getNumber(): string
|
||||||
|
{
|
||||||
|
return $this->number;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getCompany(): Company
|
||||||
|
{
|
||||||
|
return $this->company;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setCompany(Company $company): self
|
||||||
|
{
|
||||||
|
$this->company = $company;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getStatus(): string
|
||||||
|
{
|
||||||
|
return $this->status;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setStatus(string $status): self
|
||||||
|
{
|
||||||
|
$this->status = $status;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getNote(): ?string
|
||||||
|
{
|
||||||
|
return $this->note;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setNote(?string $note): self
|
||||||
|
{
|
||||||
|
$this->note = $note;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getCreatedBy(): ?Employee
|
||||||
|
{
|
||||||
|
return $this->createdBy;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setCreatedBy(?Employee $createdBy): self
|
||||||
|
{
|
||||||
|
$this->createdBy = $createdBy;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return Collection<int, OrderItem> */
|
||||||
|
public function getItems(): Collection
|
||||||
|
{
|
||||||
|
return $this->items;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function addItem(OrderItem $item): self
|
||||||
|
{
|
||||||
|
if (!$this->items->contains($item)) {
|
||||||
|
$this->items->add($item);
|
||||||
|
$item->setOrder($this);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Gesamtauflage aller Positionen. */
|
||||||
|
public function getTotalQuantity(): int
|
||||||
|
{
|
||||||
|
$sum = 0;
|
||||||
|
foreach ($this->items as $item) {
|
||||||
|
$sum += $item->getQuantity();
|
||||||
|
}
|
||||||
|
|
||||||
|
return $sum;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getReseller(): ?Reseller
|
||||||
|
{
|
||||||
|
return $this->company->getReseller();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getCreatedAt(): \DateTimeImmutable
|
||||||
|
{
|
||||||
|
return $this->createdAt;
|
||||||
|
}
|
||||||
|
}
|
||||||
18
backend/src/Repository/OrderItemRepository.php
Normal file
18
backend/src/Repository/OrderItemRepository.php
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Repository;
|
||||||
|
|
||||||
|
use App\Entity\OrderItem;
|
||||||
|
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
|
||||||
|
use Doctrine\Persistence\ManagerRegistry;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @extends ServiceEntityRepository<OrderItem>
|
||||||
|
*/
|
||||||
|
class OrderItemRepository extends ServiceEntityRepository
|
||||||
|
{
|
||||||
|
public function __construct(ManagerRegistry $registry)
|
||||||
|
{
|
||||||
|
parent::__construct($registry, OrderItem::class);
|
||||||
|
}
|
||||||
|
}
|
||||||
18
backend/src/Repository/PrintOrderRepository.php
Normal file
18
backend/src/Repository/PrintOrderRepository.php
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Repository;
|
||||||
|
|
||||||
|
use App\Entity\PrintOrder;
|
||||||
|
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
|
||||||
|
use Doctrine\Persistence\ManagerRegistry;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @extends ServiceEntityRepository<PrintOrder>
|
||||||
|
*/
|
||||||
|
class PrintOrderRepository extends ServiceEntityRepository
|
||||||
|
{
|
||||||
|
public function __construct(ManagerRegistry $registry)
|
||||||
|
{
|
||||||
|
parent::__construct($registry, PrintOrder::class);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -393,6 +393,36 @@ einer Firma für ein Produkt** (eine Firma kann je Produkt ein Design haben). Fo
|
|||||||
- `nfc_card`: zusätzlich NFC-Programmierung über die bestehende `shortCode`/`/t/`-Infra
|
- `nfc_card`: zusätzlich NFC-Programmierung über die bestehende `shortCode`/`/t/`-Infra
|
||||||
(Tag schreibt die Kurz-URL des Profils) — Detail in §12/§14.
|
(Tag schreibt die Kurz-URL des Profils) — Detail in §12/§14.
|
||||||
|
|
||||||
|
### Bestellungen (PrintOrder)
|
||||||
|
|
||||||
|
Firmen-Admins **bestellen** Produkte für ihre Mitarbeiter; der Reseller (Druckshop)
|
||||||
|
**wickelt ab** (produziert, versendet). Beispiel: *10 Visitenkarten + 5 NFC-Karten für
|
||||||
|
verschiedene Mitarbeiter*.
|
||||||
|
|
||||||
|
**Datenmodell:**
|
||||||
|
|
||||||
|
- **`PrintOrder`** (`ResellerOwnedInterface` via `company.reseller`): `number`
|
||||||
|
(kurze Bestellnr.), `company`, `status`, `note`, `createdBy` (Employee), `createdAt`,
|
||||||
|
`items[]`.
|
||||||
|
- **`OrderItem`**: `product`, `employee` (für wen — personalisiert), `quantity` (Auflage).
|
||||||
|
|
||||||
|
**Status-Workflow:** `new` → `in_production` → `shipped` → `completed`; `cancelled` quer.
|
||||||
|
- Firmen-Admin: legt Bestellung an (`new`), kann sie **stornieren** solange `new`.
|
||||||
|
- Reseller-/Plattform-Admin: schiebt den Status vorwärts (Produktion/Versand/erledigt),
|
||||||
|
kann jederzeit stornieren.
|
||||||
|
|
||||||
|
**Druckdaten:** je Position liefert das bestehende `GET /api/employees/{id}/card.pdf?product={productId}`
|
||||||
|
das druckfertige PDF (Mitarbeiter × Produkt). Sammel-/Bogen-PDF später.
|
||||||
|
|
||||||
|
**Endpunkte (`OrderController`, mandantengeprüft):**
|
||||||
|
- `GET /api/orders` — Liste (Firma: eigene; Reseller: alle seiner Firmen; Plattform: alle).
|
||||||
|
- `GET /api/orders/{id}` — Detail inkl. Positionen + PDF-Links.
|
||||||
|
- `POST /api/orders` — anlegen (Firmen-Admin; `items[]` = Produkt+Mitarbeiter+Menge).
|
||||||
|
- `PATCH /api/orders/{id}/status` — Status setzen (Reseller wickelt ab / Firma storniert).
|
||||||
|
|
||||||
|
**Sichtbarkeit:** Produkt muss im Mandanten sichtbar sein (global oder eigener Reseller),
|
||||||
|
Mitarbeiter muss zur bestellenden Firma gehören. Preise/Abrechnung = spätere Ausbaustufe.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 14. Druckdaten: Visitenkarten-PDF (Kerngeschäft)
|
## 14. Druckdaten: Visitenkarten-PDF (Kerngeschäft)
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user