vibe
This commit is contained in:
parent
837cfb6d43
commit
939840a3ac
76 changed files with 6636 additions and 83 deletions
84
src/Controller/DrinkTypeController.php
Normal file
84
src/Controller/DrinkTypeController.php
Normal file
|
@ -0,0 +1,84 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Controller;
|
||||
|
||||
use App\Entity\DrinkType;
|
||||
use App\Form\DrinkTypeForm;
|
||||
use App\Repository\DrinkTypeRepository;
|
||||
use App\Service\InventoryService;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\Routing\Attribute\Route;
|
||||
|
||||
#[Route('/drink-type')]
|
||||
final class DrinkTypeController extends AbstractController
|
||||
{
|
||||
#[Route(name: 'app_drink_type_index', methods: ['GET'])]
|
||||
public function index(InventoryService $inventoryService): Response
|
||||
{
|
||||
return $this->render('drink_type/index.html.twig', [
|
||||
'drink_stocks' => $inventoryService->getAllDrinkTypesWithStockLevels(true)
|
||||
]);
|
||||
}
|
||||
|
||||
#[Route(path: '/new', name: 'app_drink_type_new', methods: ['GET', 'POST'])]
|
||||
public function new(Request $request, EntityManagerInterface $entityManager): Response
|
||||
{
|
||||
$drinkType = new DrinkType('', '');
|
||||
$form = $this->createForm(DrinkTypeForm::class, $drinkType);
|
||||
$form->handleRequest($request);
|
||||
|
||||
if ($form->isSubmitted() && $form->isValid()) {
|
||||
$entityManager->persist($drinkType);
|
||||
$entityManager->flush();
|
||||
|
||||
return $this->redirectToRoute('app_drink_type_index', [], Response::HTTP_SEE_OTHER);
|
||||
}
|
||||
|
||||
return $this->render('drink_type/new.html.twig', [
|
||||
'drink_type' => $drinkType,
|
||||
'form' => $form,
|
||||
]);
|
||||
}
|
||||
|
||||
#[Route(path: '/{id}', name: 'app_drink_type_show', methods: ['GET'])]
|
||||
public function show(DrinkType $drinkType): Response
|
||||
{
|
||||
return $this->render('drink_type/show.html.twig', [
|
||||
'drink_type' => $drinkType,
|
||||
]);
|
||||
}
|
||||
|
||||
#[Route(path: '/{id}/edit', name: 'app_drink_type_edit', methods: ['GET', 'POST'])]
|
||||
public function edit(Request $request, DrinkType $drinkType, EntityManagerInterface $entityManager): Response
|
||||
{
|
||||
$form = $this->createForm(DrinkTypeForm::class, $drinkType);
|
||||
$form->handleRequest($request);
|
||||
|
||||
if ($form->isSubmitted() && $form->isValid()) {
|
||||
$entityManager->flush();
|
||||
|
||||
return $this->redirectToRoute('app_drink_type_index', [], Response::HTTP_SEE_OTHER);
|
||||
}
|
||||
|
||||
return $this->render('drink_type/edit.html.twig', [
|
||||
'drink_type' => $drinkType,
|
||||
'form' => $form,
|
||||
]);
|
||||
}
|
||||
|
||||
#[Route(path: '/{id}', name: 'app_drink_type_delete', methods: ['POST'])]
|
||||
public function delete(Request $request, DrinkType $drinkType, EntityManagerInterface $entityManager): Response
|
||||
{
|
||||
if ($this->isCsrfTokenValid('delete' . $drinkType->getId(), $request->getPayload()->getString('_token'))) {
|
||||
$entityManager->remove($drinkType);
|
||||
$entityManager->flush();
|
||||
}
|
||||
|
||||
return $this->redirectToRoute('app_drink_type_index', [], Response::HTTP_SEE_OTHER);
|
||||
}
|
||||
}
|
27
src/Controller/Index.php
Normal file
27
src/Controller/Index.php
Normal file
|
@ -0,0 +1,27 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Controller;
|
||||
|
||||
use App\Service\InventoryService;
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\Routing\Attribute\Route;
|
||||
|
||||
#[Route(path: '/', name: 'app_index')]
|
||||
final class Index extends AbstractController
|
||||
{
|
||||
public function __construct(
|
||||
private readonly InventoryService $inventoryService,
|
||||
) {}
|
||||
|
||||
public function __invoke(): Response
|
||||
{
|
||||
$drinkStocks = $this->inventoryService->getAllDrinkTypesWithStockLevels();
|
||||
|
||||
return $this->render('index.html.twig', [
|
||||
'drinkStocks' => $drinkStocks,
|
||||
]);
|
||||
}
|
||||
}
|
0
src/Entity/.gitignore
vendored
Normal file
0
src/Entity/.gitignore
vendored
Normal file
115
src/Entity/DrinkType.php
Normal file
115
src/Entity/DrinkType.php
Normal file
|
@ -0,0 +1,115 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Entity;
|
||||
|
||||
use App\Repository\DrinkTypeRepository;
|
||||
use DateTimeImmutable;
|
||||
use Doctrine\Common\Collections\ArrayCollection;
|
||||
use Doctrine\Common\Collections\Collection;
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
|
||||
#[ORM\Entity(repositoryClass: DrinkTypeRepository::class)]
|
||||
#[ORM\Table(name: 'drink_type')]
|
||||
class DrinkType
|
||||
{
|
||||
#[ORM\Id]
|
||||
#[ORM\GeneratedValue]
|
||||
#[ORM\Column(type: 'integer')]
|
||||
private null|int $id = null;
|
||||
|
||||
#[ORM\Column(type: 'datetime_immutable')]
|
||||
private readonly DateTimeImmutable $createdAt;
|
||||
|
||||
#[ORM\Column(type: 'datetime_immutable')]
|
||||
private DateTimeImmutable $updatedAt;
|
||||
|
||||
#[ORM\OneToMany(mappedBy: 'drinkType', targetEntity: InventoryRecord::class)]
|
||||
private Collection $inventoryRecords;
|
||||
|
||||
#[ORM\OneToMany(mappedBy: 'drinkType', targetEntity: OrderItem::class)]
|
||||
private Collection $orderItems;
|
||||
|
||||
public function __construct(
|
||||
#[ORM\Column(type: 'string', length: 255, unique: true)]
|
||||
private string $name,
|
||||
#[ORM\Column(type: 'text', nullable: true)]
|
||||
private null|string $description = null,
|
||||
#[ORM\Column(type: 'integer')]
|
||||
private int $desiredStock = 10,
|
||||
null|DateTimeImmutable $createdAt = null,
|
||||
null|DateTimeImmutable $updatedAt = null,
|
||||
) {
|
||||
$this->createdAt = $createdAt ?? new DateTimeImmutable();
|
||||
$this->updatedAt = $updatedAt ?? new DateTimeImmutable();
|
||||
$this->inventoryRecords = new ArrayCollection();
|
||||
$this->orderItems = new ArrayCollection();
|
||||
}
|
||||
|
||||
public function getId(): null|int
|
||||
{
|
||||
return $this->id;
|
||||
}
|
||||
|
||||
public function getName(): string
|
||||
{
|
||||
return $this->name;
|
||||
}
|
||||
|
||||
public function setName(string $name): self
|
||||
{
|
||||
$this->name = $name;
|
||||
$this->updateTimestamp();
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getDescription(): null|string
|
||||
{
|
||||
return $this->description;
|
||||
}
|
||||
|
||||
public function setDescription(null|string $description): self
|
||||
{
|
||||
$this->description = $description;
|
||||
$this->updateTimestamp();
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getDesiredStock(): int
|
||||
{
|
||||
return $this->desiredStock;
|
||||
}
|
||||
|
||||
public function setDesiredStock(int $desiredStock): self
|
||||
{
|
||||
$this->desiredStock = $desiredStock;
|
||||
$this->updateTimestamp();
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getCreatedAt(): DateTimeImmutable
|
||||
{
|
||||
return $this->createdAt;
|
||||
}
|
||||
|
||||
public function getUpdatedAt(): DateTimeImmutable
|
||||
{
|
||||
return $this->updatedAt;
|
||||
}
|
||||
|
||||
public function getInventoryRecords(): Collection
|
||||
{
|
||||
return $this->inventoryRecords;
|
||||
}
|
||||
|
||||
public function getOrderItems(): Collection
|
||||
{
|
||||
return $this->orderItems;
|
||||
}
|
||||
|
||||
private function updateTimestamp(): void
|
||||
{
|
||||
$this->updatedAt = new DateTimeImmutable();
|
||||
}
|
||||
}
|
89
src/Entity/InventoryRecord.php
Normal file
89
src/Entity/InventoryRecord.php
Normal file
|
@ -0,0 +1,89 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Entity;
|
||||
|
||||
use App\Repository\InventoryRecordRepository;
|
||||
use DateTimeImmutable;
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
|
||||
#[ORM\Entity(repositoryClass: InventoryRecordRepository::class)]
|
||||
#[ORM\Table(name: 'inventory_record')]
|
||||
class InventoryRecord
|
||||
{
|
||||
#[ORM\Id]
|
||||
#[ORM\GeneratedValue]
|
||||
#[ORM\Column(type: 'integer')]
|
||||
private null|int $id = null;
|
||||
|
||||
public function __construct(
|
||||
#[ORM\ManyToOne(targetEntity: DrinkType::class, inversedBy: 'inventoryRecords')]
|
||||
#[ORM\JoinColumn(name: 'drink_type_id', referencedColumnName: 'id', nullable: false)]
|
||||
private DrinkType $drinkType,
|
||||
#[ORM\Column(type: 'integer')]
|
||||
private int $quantity,
|
||||
#[ORM\Column(type: 'datetime_immutable')]
|
||||
private DateTimeImmutable $timestamp = new DateTimeImmutable(),
|
||||
#[ORM\Column(type: 'datetime_immutable')]
|
||||
private DateTimeImmutable $createdAt = new DateTimeImmutable(),
|
||||
#[ORM\Column(type: 'datetime_immutable')]
|
||||
private DateTimeImmutable $updatedAt = new DateTimeImmutable(),
|
||||
) {}
|
||||
|
||||
public function getId(): null|int
|
||||
{
|
||||
return $this->id;
|
||||
}
|
||||
|
||||
public function getDrinkType(): DrinkType
|
||||
{
|
||||
return $this->drinkType;
|
||||
}
|
||||
|
||||
public function setDrinkType(DrinkType $drinkType): self
|
||||
{
|
||||
$this->drinkType = $drinkType;
|
||||
$this->updateTimestamp();
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getQuantity(): int
|
||||
{
|
||||
return $this->quantity;
|
||||
}
|
||||
|
||||
public function setQuantity(int $quantity): self
|
||||
{
|
||||
$this->quantity = $quantity;
|
||||
$this->updateTimestamp();
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getTimestamp(): DateTimeImmutable
|
||||
{
|
||||
return $this->timestamp;
|
||||
}
|
||||
|
||||
public function setTimestamp(DateTimeImmutable $timestamp): self
|
||||
{
|
||||
$this->timestamp = $timestamp;
|
||||
$this->updateTimestamp();
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getCreatedAt(): DateTimeImmutable
|
||||
{
|
||||
return $this->createdAt;
|
||||
}
|
||||
|
||||
public function getUpdatedAt(): DateTimeImmutable
|
||||
{
|
||||
return $this->updatedAt;
|
||||
}
|
||||
|
||||
private function updateTimestamp(): void
|
||||
{
|
||||
$this->updatedAt = new DateTimeImmutable();
|
||||
}
|
||||
}
|
104
src/Entity/Order.php
Normal file
104
src/Entity/Order.php
Normal file
|
@ -0,0 +1,104 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Entity;
|
||||
|
||||
use App\Enum\OrderStatus;
|
||||
use App\Repository\OrderRepository;
|
||||
use DateTimeImmutable;
|
||||
use Doctrine\Common\Collections\ArrayCollection;
|
||||
use Doctrine\Common\Collections\Collection;
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
|
||||
#[ORM\Entity(repositoryClass: OrderRepository::class)]
|
||||
#[ORM\Table(name: '`order`')] // 'order' is a reserved keyword in SQL, so we escape it
|
||||
class Order
|
||||
{
|
||||
#[ORM\Id]
|
||||
#[ORM\GeneratedValue]
|
||||
#[ORM\Column(type: 'integer')]
|
||||
private null|int $id = null;
|
||||
|
||||
#[ORM\Column(type: 'datetime_immutable')]
|
||||
private readonly DateTimeImmutable $createdAt;
|
||||
|
||||
#[ORM\Column(type: 'datetime_immutable')]
|
||||
private DateTimeImmutable $updatedAt;
|
||||
|
||||
#[ORM\OneToMany(mappedBy: 'order', targetEntity: OrderItem::class, cascade: ['persist', 'remove'])]
|
||||
private Collection $orderItems;
|
||||
|
||||
public function __construct(
|
||||
#[ORM\Column(nullable: false, enumType: OrderStatus::class, options: [
|
||||
'default' => OrderStatus::NEW,
|
||||
])]
|
||||
private OrderStatus $status = OrderStatus::NEW,
|
||||
null|DateTimeImmutable $createdAt = null,
|
||||
null|DateTimeImmutable $updatedAt = null,
|
||||
) {
|
||||
$this->createdAt = $createdAt ?? new DateTimeImmutable();
|
||||
$this->updatedAt = $updatedAt ?? new DateTimeImmutable();
|
||||
$this->orderItems = new ArrayCollection();
|
||||
}
|
||||
|
||||
public function getId(): null|int
|
||||
{
|
||||
return $this->id;
|
||||
}
|
||||
|
||||
public function getStatus(): OrderStatus
|
||||
{
|
||||
return $this->status;
|
||||
}
|
||||
|
||||
public function setStatus(OrderStatus $status): self
|
||||
{
|
||||
$this->status = $status;
|
||||
$this->updateTimestamp();
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getCreatedAt(): DateTimeImmutable
|
||||
{
|
||||
return $this->createdAt;
|
||||
}
|
||||
|
||||
public function getUpdatedAt(): DateTimeImmutable
|
||||
{
|
||||
return $this->updatedAt;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return Collection<int, OrderItem>
|
||||
*/
|
||||
public function getOrderItems(): Collection
|
||||
{
|
||||
return $this->orderItems;
|
||||
}
|
||||
|
||||
public function addOrderItem(OrderItem $orderItem): self
|
||||
{
|
||||
if (!$this->orderItems->contains($orderItem)) {
|
||||
$this->orderItems[] = $orderItem;
|
||||
$orderItem->setOrder($this);
|
||||
}
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function removeOrderItem(OrderItem $orderItem): self
|
||||
{
|
||||
// set the owning side to null (unless already changed)
|
||||
if ($this->orderItems->removeElement($orderItem) && $orderItem->getOrder() === $this) {
|
||||
$orderItem->setOrder(null);
|
||||
}
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
private function updateTimestamp(): void
|
||||
{
|
||||
$this->updatedAt = new DateTimeImmutable();
|
||||
}
|
||||
}
|
114
src/Entity/OrderItem.php
Normal file
114
src/Entity/OrderItem.php
Normal file
|
@ -0,0 +1,114 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Entity;
|
||||
|
||||
use App\Repository\OrderItemRepository;
|
||||
use DateTimeImmutable;
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
|
||||
#[ORM\Entity(repositoryClass: OrderItemRepository::class)]
|
||||
#[ORM\Table(name: 'order_item')]
|
||||
class OrderItem
|
||||
{
|
||||
#[ORM\Id]
|
||||
#[ORM\GeneratedValue]
|
||||
#[ORM\Column(type: 'integer')]
|
||||
private null|int $id = null;
|
||||
|
||||
#[ORM\Column(type: 'datetime_immutable')]
|
||||
private readonly DateTimeImmutable $createdAt;
|
||||
|
||||
#[ORM\Column(type: 'datetime_immutable')]
|
||||
private DateTimeImmutable $updatedAt;
|
||||
|
||||
public function __construct(
|
||||
#[ORM\ManyToOne(targetEntity: DrinkType::class, inversedBy: 'orderItems')]
|
||||
#[ORM\JoinColumn(name: 'drink_type_id', referencedColumnName: 'id', nullable: false)]
|
||||
private DrinkType $drinkType,
|
||||
#[ORM\Column(type: 'integer')]
|
||||
private int $quantity,
|
||||
#[ORM\ManyToOne(targetEntity: Order::class, inversedBy: 'orderItems')]
|
||||
#[ORM\JoinColumn(name: 'order_id', referencedColumnName: 'id', nullable: false)]
|
||||
private null|Order $order = null,
|
||||
null|DateTimeImmutable $createdAt = null,
|
||||
null|DateTimeImmutable $updatedAt = null,
|
||||
) {
|
||||
$this->createdAt = $createdAt ?? new DateTimeImmutable();
|
||||
$this->updatedAt = $updatedAt ?? new DateTimeImmutable();
|
||||
|
||||
// Establish bidirectional relationship
|
||||
if ($this->order instanceof Order) {
|
||||
$this->order->addOrderItem($this);
|
||||
}
|
||||
}
|
||||
|
||||
public function getId(): null|int
|
||||
{
|
||||
return $this->id;
|
||||
}
|
||||
|
||||
public function getOrder(): null|Order
|
||||
{
|
||||
return $this->order;
|
||||
}
|
||||
|
||||
public function setOrder(null|Order $order): self
|
||||
{
|
||||
// Remove from old order if exists
|
||||
if ($this->order instanceof Order && $this->order !== $order) {
|
||||
$this->order->removeOrderItem($this);
|
||||
}
|
||||
|
||||
// Set new order
|
||||
$this->order = $order;
|
||||
|
||||
// Add to new order if not null
|
||||
if ($order instanceof Order && !$order->getOrderItems()->contains($this)) {
|
||||
$order->addOrderItem($this);
|
||||
}
|
||||
|
||||
$this->updateTimestamp();
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getDrinkType(): DrinkType
|
||||
{
|
||||
return $this->drinkType;
|
||||
}
|
||||
|
||||
public function setDrinkType(DrinkType $drinkType): self
|
||||
{
|
||||
$this->drinkType = $drinkType;
|
||||
$this->updateTimestamp();
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getQuantity(): int
|
||||
{
|
||||
return $this->quantity;
|
||||
}
|
||||
|
||||
public function setQuantity(int $quantity): self
|
||||
{
|
||||
$this->quantity = $quantity;
|
||||
$this->updateTimestamp();
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getCreatedAt(): DateTimeImmutable
|
||||
{
|
||||
return $this->createdAt;
|
||||
}
|
||||
|
||||
public function getUpdatedAt(): DateTimeImmutable
|
||||
{
|
||||
return $this->updatedAt;
|
||||
}
|
||||
|
||||
private function updateTimestamp(): void
|
||||
{
|
||||
$this->updatedAt = new DateTimeImmutable();
|
||||
}
|
||||
}
|
89
src/Entity/SystemConfig.php
Normal file
89
src/Entity/SystemConfig.php
Normal file
|
@ -0,0 +1,89 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Entity;
|
||||
|
||||
use App\Enum\SystemSettingKey;
|
||||
use App\Repository\SystemConfigRepository;
|
||||
use DateTimeImmutable;
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
|
||||
#[ORM\Entity(repositoryClass: SystemConfigRepository::class)]
|
||||
#[ORM\Table(name: 'system_config')]
|
||||
class SystemConfig
|
||||
{
|
||||
public const DEFAULT_STOCK_ADJUSTMENT_LOOKBACK_ORDERS = '3';
|
||||
public const DEFAULT_DEFAULT_DESIRED_STOCK = '2';
|
||||
public const DEFAULT_SYSTEM_NAME = 'Zaufen';
|
||||
public const DEFAULT_STOCK_INCREASE_AMOUNT = '1';
|
||||
public const DEFAULT_STOCK_DECREASE_AMOUNT = '1';
|
||||
public const DEFAULT_STOCK_LOW_MULTIPLIER = '0.3';
|
||||
|
||||
#[ORM\Id]
|
||||
#[ORM\GeneratedValue]
|
||||
#[ORM\Column(type: 'integer')]
|
||||
private null|int $id = null;
|
||||
|
||||
#[ORM\Column(type: 'datetime_immutable')]
|
||||
private readonly DateTimeImmutable $createdAt;
|
||||
|
||||
#[ORM\Column(type: 'datetime_immutable')]
|
||||
private DateTimeImmutable $updatedAt;
|
||||
|
||||
public function __construct(
|
||||
#[ORM\Column(unique: true, enumType: SystemSettingKey::class)]
|
||||
private SystemSettingKey $key,
|
||||
#[ORM\Column(type: 'text')]
|
||||
private string $value,
|
||||
null|DateTimeImmutable $createdAt = null,
|
||||
null|DateTimeImmutable $updatedAt = null,
|
||||
) {
|
||||
$this->createdAt = $createdAt ?? new DateTimeImmutable();
|
||||
$this->updatedAt = $updatedAt ?? new DateTimeImmutable();
|
||||
}
|
||||
|
||||
public function getId(): null|int
|
||||
{
|
||||
return $this->id;
|
||||
}
|
||||
|
||||
public function getKey(): SystemSettingKey
|
||||
{
|
||||
return $this->key;
|
||||
}
|
||||
|
||||
public function setKey(SystemSettingKey $key): self
|
||||
{
|
||||
$this->key = $key;
|
||||
$this->updateTimestamp();
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getValue(): string
|
||||
{
|
||||
return $this->value;
|
||||
}
|
||||
|
||||
public function setValue(string $value): self
|
||||
{
|
||||
$this->value = $value;
|
||||
$this->updateTimestamp();
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getCreatedAt(): DateTimeImmutable
|
||||
{
|
||||
return $this->createdAt;
|
||||
}
|
||||
|
||||
public function getUpdatedAt(): DateTimeImmutable
|
||||
{
|
||||
return $this->updatedAt;
|
||||
}
|
||||
|
||||
private function updateTimestamp(): void
|
||||
{
|
||||
$this->updatedAt = new DateTimeImmutable();
|
||||
}
|
||||
}
|
14
src/Enum/OrderStatus.php
Normal file
14
src/Enum/OrderStatus.php
Normal file
|
@ -0,0 +1,14 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Enum;
|
||||
|
||||
enum OrderStatus: string
|
||||
{
|
||||
case NEW = 'new';
|
||||
case ORDERED = 'ordered';
|
||||
case FULFILLED = 'fulfilled';
|
||||
case IN_WORK = 'in_work';
|
||||
case CANCELLED = 'cancelled';
|
||||
}
|
13
src/Enum/StockState.php
Normal file
13
src/Enum/StockState.php
Normal file
|
@ -0,0 +1,13 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Enum;
|
||||
|
||||
enum StockState: string
|
||||
{
|
||||
case CRITICAL = 'critical';
|
||||
case LOW = 'low';
|
||||
case NORMAL = 'normal';
|
||||
case HIGH = 'high';
|
||||
}
|
32
src/Enum/SystemSettingKey.php
Normal file
32
src/Enum/SystemSettingKey.php
Normal file
|
@ -0,0 +1,32 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Enum;
|
||||
|
||||
use App\Entity\SystemConfig;
|
||||
|
||||
/**
|
||||
* Enum for system setting keys
|
||||
*/
|
||||
enum SystemSettingKey: string
|
||||
{
|
||||
case STOCK_ADJUSTMENT_LOOKBACK_ORDERS = 'stock_adjustment_lookback_orders';
|
||||
case DEFAULT_DESIRED_STOCK = 'default_desired_stock';
|
||||
case SYSTEM_NAME = 'system_name';
|
||||
case STOCK_INCREASE_AMOUNT = 'stock_increase_amount';
|
||||
case STOCK_DECREASE_AMOUNT = 'stock_decrease_amount';
|
||||
case STOCK_LOW_MULTIPLIER = 'stock_low_multiplier';
|
||||
|
||||
public static function getDefaultValue(self $key): string
|
||||
{
|
||||
return match ($key) {
|
||||
self::STOCK_ADJUSTMENT_LOOKBACK_ORDERS => SystemConfig::DEFAULT_STOCK_ADJUSTMENT_LOOKBACK_ORDERS,
|
||||
self::DEFAULT_DESIRED_STOCK => SystemConfig::DEFAULT_DEFAULT_DESIRED_STOCK,
|
||||
self::SYSTEM_NAME => SystemConfig::DEFAULT_SYSTEM_NAME,
|
||||
self::STOCK_INCREASE_AMOUNT => SystemConfig::DEFAULT_STOCK_INCREASE_AMOUNT,
|
||||
self::STOCK_DECREASE_AMOUNT => SystemConfig::DEFAULT_STOCK_DECREASE_AMOUNT,
|
||||
self::STOCK_LOW_MULTIPLIER => SystemConfig::DEFAULT_STOCK_LOW_MULTIPLIER,
|
||||
};
|
||||
}
|
||||
}
|
25
src/Form/DrinkTypeForm.php
Normal file
25
src/Form/DrinkTypeForm.php
Normal file
|
@ -0,0 +1,25 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Form;
|
||||
|
||||
use App\Entity\DrinkType;
|
||||
use Symfony\Component\Form\AbstractType;
|
||||
use Symfony\Component\Form\FormBuilderInterface;
|
||||
use Symfony\Component\OptionsResolver\OptionsResolver;
|
||||
|
||||
class DrinkTypeForm extends AbstractType
|
||||
{
|
||||
public function buildForm(FormBuilderInterface $builder, array $options): void
|
||||
{
|
||||
$builder->add('name')->add('description')->add('desiredStock');
|
||||
}
|
||||
|
||||
public function configureOptions(OptionsResolver $resolver): void
|
||||
{
|
||||
$resolver->setDefaults([
|
||||
'data_class' => DrinkType::class,
|
||||
]);
|
||||
}
|
||||
}
|
|
@ -1,5 +1,7 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App;
|
||||
|
||||
use Symfony\Bundle\FrameworkBundle\Kernel\MicroKernelTrait;
|
||||
|
|
0
src/Repository/.gitignore
vendored
Normal file
0
src/Repository/.gitignore
vendored
Normal file
33
src/Repository/AbstractRepository.php
Normal file
33
src/Repository/AbstractRepository.php
Normal file
|
@ -0,0 +1,33 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Repository;
|
||||
|
||||
use Doctrine\ORM\EntityRepository;
|
||||
|
||||
/**
|
||||
* @template E of object
|
||||
* @extends EntityRepository<E>
|
||||
*/
|
||||
abstract class AbstractRepository extends EntityRepository
|
||||
{
|
||||
/**
|
||||
* @param E $entity
|
||||
* @return void
|
||||
*/
|
||||
public function save(object $entity): void
|
||||
{
|
||||
$this->getEntityManager()->persist($entity);
|
||||
$this->getEntityManager()->flush();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param E $entity
|
||||
*/
|
||||
public function remove(object $entity): void
|
||||
{
|
||||
$this->getEntityManager()->remove($entity);
|
||||
$this->getEntityManager()->flush();
|
||||
}
|
||||
}
|
48
src/Repository/DrinkTypeRepository.php
Normal file
48
src/Repository/DrinkTypeRepository.php
Normal file
|
@ -0,0 +1,48 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Repository;
|
||||
|
||||
use App\Entity\DrinkType;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Override;
|
||||
|
||||
/**
|
||||
* @extends AbstractRepository<DrinkType>
|
||||
*/
|
||||
class DrinkTypeRepository extends AbstractRepository
|
||||
{
|
||||
public function __construct(EntityManagerInterface $entityManager)
|
||||
{
|
||||
parent::__construct($entityManager, $entityManager->getClassMetadata(DrinkType::class));
|
||||
}
|
||||
|
||||
/** @inheritDoc */
|
||||
#[Override]
|
||||
public function findAll(): array
|
||||
{
|
||||
return parent::findBy(
|
||||
criteria: [],
|
||||
orderBy: [
|
||||
'desiredStock' => 'DESC',
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
/** @return DrinkType[] */
|
||||
public function findDesired(): array
|
||||
{
|
||||
$qb = $this->getEntityManager()->createQueryBuilder();
|
||||
$qb
|
||||
->select('d')
|
||||
->from(DrinkType::class, 'd')
|
||||
->where('d.desiredStock > 0')
|
||||
->orderBy('d.desiredStock', 'DESC')
|
||||
->addOrderBy('d.name', 'ASC');
|
||||
|
||||
/** @var array<int, DrinkType> $result */
|
||||
$result = $qb->getQuery()->getResult();
|
||||
return $result;
|
||||
}
|
||||
}
|
71
src/Repository/InventoryRecordRepository.php
Normal file
71
src/Repository/InventoryRecordRepository.php
Normal file
|
@ -0,0 +1,71 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Repository;
|
||||
|
||||
use App\Entity\DrinkType;
|
||||
use App\Entity\InventoryRecord;
|
||||
use DateTimeImmutable;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
|
||||
/**
|
||||
* @extends AbstractRepository<InventoryRecord>
|
||||
*/
|
||||
class InventoryRecordRepository extends AbstractRepository
|
||||
{
|
||||
public function __construct(EntityManagerInterface $entityManager)
|
||||
{
|
||||
parent::__construct($entityManager, $entityManager->getClassMetadata(InventoryRecord::class));
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, InventoryRecord>
|
||||
*/
|
||||
public function findByDrinkType(DrinkType $drinkType): array
|
||||
{
|
||||
return $this->findBy(
|
||||
[
|
||||
'drinkType' => $drinkType,
|
||||
],
|
||||
[
|
||||
'timestamp' => 'DESC',
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
public function findLatestByDrinkType(DrinkType $drinkType): null|InventoryRecord
|
||||
{
|
||||
$records = $this->findBy(
|
||||
[
|
||||
'drinkType' => $drinkType,
|
||||
],
|
||||
[
|
||||
'timestamp' => 'DESC',
|
||||
],
|
||||
1,
|
||||
);
|
||||
|
||||
return $records[0] ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, InventoryRecord>
|
||||
*/
|
||||
public function findByTimestampRange(DateTimeImmutable $start, DateTimeImmutable $end): array
|
||||
{
|
||||
$qb = $this->getEntityManager()->createQueryBuilder();
|
||||
$qb
|
||||
->select('ir')
|
||||
->from(InventoryRecord::class, 'ir')
|
||||
->where('ir.timestamp >= :start')
|
||||
->andWhere('ir.timestamp <= :end')
|
||||
->setParameter('start', $start)
|
||||
->setParameter('end', $end)
|
||||
->orderBy('ir.timestamp', 'DESC');
|
||||
|
||||
/** @var array<int, InventoryRecord> $result */
|
||||
$result = $qb->getQuery()->getResult();
|
||||
return $result;
|
||||
}
|
||||
}
|
49
src/Repository/OrderItemRepository.php
Normal file
49
src/Repository/OrderItemRepository.php
Normal file
|
@ -0,0 +1,49 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Repository;
|
||||
|
||||
use App\Entity\DrinkType;
|
||||
use App\Entity\Order;
|
||||
use App\Entity\OrderItem;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
|
||||
/**
|
||||
* @extends AbstractRepository<OrderItem>
|
||||
*/
|
||||
class OrderItemRepository extends AbstractRepository
|
||||
{
|
||||
public function __construct(EntityManagerInterface $entityManager)
|
||||
{
|
||||
parent::__construct($entityManager, $entityManager->getClassMetadata(OrderItem::class));
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, OrderItem>
|
||||
*/
|
||||
public function findByOrder(Order $order): array
|
||||
{
|
||||
return $this->findBy([
|
||||
'order' => $order,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, OrderItem>
|
||||
*/
|
||||
public function findByDrinkType(DrinkType $drinkType): array
|
||||
{
|
||||
return $this->findBy([
|
||||
'drinkType' => $drinkType,
|
||||
]);
|
||||
}
|
||||
|
||||
public function findByOrderAndDrinkType(Order $order, DrinkType $drinkType): null|OrderItem
|
||||
{
|
||||
return $this->findOneBy([
|
||||
'order' => $order,
|
||||
'drinkType' => $drinkType,
|
||||
]);
|
||||
}
|
||||
}
|
102
src/Repository/OrderRepository.php
Normal file
102
src/Repository/OrderRepository.php
Normal file
|
@ -0,0 +1,102 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Repository;
|
||||
|
||||
use App\Entity\DrinkType;
|
||||
use App\Entity\Order;
|
||||
use App\Enum\OrderStatus;
|
||||
use DateTimeImmutable;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
|
||||
/**
|
||||
* @extends AbstractRepository<Order>
|
||||
*/
|
||||
class OrderRepository extends AbstractRepository
|
||||
{
|
||||
public function __construct(EntityManagerInterface $entityManager)
|
||||
{
|
||||
parent::__construct($entityManager, $entityManager->getClassMetadata(Order::class));
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, Order>
|
||||
*/
|
||||
public function findByStatus(OrderStatus $status): array
|
||||
{
|
||||
return $this->findBy(
|
||||
[
|
||||
'status' => $status,
|
||||
],
|
||||
[
|
||||
'createdAt' => 'DESC',
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<OrderStatus> $stati
|
||||
* @return array<int, Order>
|
||||
*/
|
||||
public function findByMultipleStatus(array $stati): array
|
||||
{
|
||||
$qb = $this->getEntityManager()->createQueryBuilder();
|
||||
$qb
|
||||
->select('o')
|
||||
->from(Order::class, 'o')
|
||||
->where('o.status IN (:stati)')
|
||||
->setParameter('stati', $stati)
|
||||
->orderBy('o.createdAt', 'DESC');
|
||||
|
||||
/** @var array<int, Order> $result */
|
||||
$result = $qb->getQuery()->getResult();
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, Order>
|
||||
*/
|
||||
public function findByDateRange(DateTimeImmutable $start, DateTimeImmutable $end): array
|
||||
{
|
||||
$qb = $this->getEntityManager()->createQueryBuilder();
|
||||
$qb
|
||||
->select('o')
|
||||
->from(Order::class, 'o')
|
||||
->where('o.createdAt >= :start')
|
||||
->andWhere('o.createdAt <= :end')
|
||||
->setParameter('start', $start)
|
||||
->setParameter('end', $end)
|
||||
->orderBy('o.createdAt', 'DESC');
|
||||
|
||||
/** @var array<int, Order> $result */
|
||||
$result = $qb->getQuery()->getResult();
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the last N orders that contain a specific drink type
|
||||
*
|
||||
* @param DrinkType $drinkType The drink type to search for
|
||||
* @param int $limit The maximum number of orders to return
|
||||
* @return array<int, Order> The last N orders containing the drink type, ordered by creation date (newest first)
|
||||
*/
|
||||
public function findLastOrdersForDrinkType(DrinkType $drinkType, int $limit = 5): array
|
||||
{
|
||||
$qb = $this->getEntityManager()->createQueryBuilder();
|
||||
$qb
|
||||
->select('o')
|
||||
->from(Order::class, 'o')
|
||||
->join('o.orderItems', 'oi')
|
||||
->where('oi.drinkType = :drinkType')
|
||||
->andWhere('o.status = :status') // Only consider fulfilled orders
|
||||
->setParameter('drinkType', $drinkType)
|
||||
->setParameter('status', OrderStatus::FULFILLED->value)
|
||||
->orderBy('o.createdAt', 'DESC')
|
||||
->setMaxResults($limit);
|
||||
|
||||
/** @var array<int, Order> $result */
|
||||
$result = $qb->getQuery()->getResult();
|
||||
return $result;
|
||||
}
|
||||
}
|
50
src/Repository/SystemConfigRepository.php
Normal file
50
src/Repository/SystemConfigRepository.php
Normal file
|
@ -0,0 +1,50 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Repository;
|
||||
|
||||
use App\Entity\SystemConfig;
|
||||
use App\Enum\SystemSettingKey;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
|
||||
/**
|
||||
* @extends AbstractRepository<SystemConfig>
|
||||
*/
|
||||
class SystemConfigRepository extends AbstractRepository
|
||||
{
|
||||
public function __construct(EntityManagerInterface $entityManager)
|
||||
{
|
||||
parent::__construct($entityManager, $entityManager->getClassMetadata(SystemConfig::class));
|
||||
}
|
||||
|
||||
public function findByKey(SystemSettingKey $key): SystemConfig
|
||||
{
|
||||
$config = $this->findOneBy([
|
||||
'key' => $key->value,
|
||||
]);
|
||||
if (!($config instanceof SystemConfig)) {
|
||||
$config = new SystemConfig($key, SystemSettingKey::getDefaultValue($key));
|
||||
$this->save($config);
|
||||
}
|
||||
return $config;
|
||||
}
|
||||
|
||||
public function getValue(SystemSettingKey $key, string $default = ''): string
|
||||
{
|
||||
$config = $this->findByKey($key);
|
||||
return ($config instanceof SystemConfig) ? $config->getValue() : $default;
|
||||
}
|
||||
|
||||
public function setValue(SystemSettingKey $key, string $value): void
|
||||
{
|
||||
$config = $this->findByKey($key);
|
||||
|
||||
if ($config instanceof SystemConfig) {
|
||||
$config->setValue($value);
|
||||
} else {
|
||||
$config = new SystemConfig($key, $value);
|
||||
}
|
||||
$this->save($config);
|
||||
}
|
||||
}
|
22
src/Service/Config/AppName.php
Normal file
22
src/Service/Config/AppName.php
Normal file
|
@ -0,0 +1,22 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Service\Config;
|
||||
|
||||
use App\Entity\SystemConfig;
|
||||
use App\Enum\SystemSettingKey;
|
||||
use App\Service\ConfigurationService;
|
||||
use Stringable;
|
||||
|
||||
final readonly class AppName implements Stringable
|
||||
{
|
||||
public function __construct(
|
||||
private ConfigurationService $configService,
|
||||
) {}
|
||||
|
||||
public function __toString(): string
|
||||
{
|
||||
return $this->configService->getConfigValue(SystemSettingKey::SYSTEM_NAME, SystemConfig::DEFAULT_SYSTEM_NAME);
|
||||
}
|
||||
}
|
26
src/Service/Config/LowStockMultiplier.php
Normal file
26
src/Service/Config/LowStockMultiplier.php
Normal file
|
@ -0,0 +1,26 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Service\Config;
|
||||
|
||||
use App\Entity\SystemConfig;
|
||||
use App\Enum\SystemSettingKey;
|
||||
use App\Service\ConfigurationService;
|
||||
|
||||
final readonly class LowStockMultiplier
|
||||
{
|
||||
public function __construct(
|
||||
private ConfigurationService $configService,
|
||||
) {}
|
||||
|
||||
public function getValue(): float
|
||||
{
|
||||
$value = $this->configService->getConfigValue(
|
||||
SystemSettingKey::STOCK_LOW_MULTIPLIER,
|
||||
SystemConfig::DEFAULT_STOCK_LOW_MULTIPLIER,
|
||||
);
|
||||
|
||||
return (float) $value;
|
||||
}
|
||||
}
|
76
src/Service/ConfigurationService.php
Normal file
76
src/Service/ConfigurationService.php
Normal file
|
@ -0,0 +1,76 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Service;
|
||||
|
||||
use App\Entity\SystemConfig;
|
||||
use App\Enum\SystemSettingKey;
|
||||
use App\Repository\SystemConfigRepository;
|
||||
use InvalidArgumentException;
|
||||
|
||||
readonly class ConfigurationService
|
||||
{
|
||||
public function __construct(
|
||||
private SystemConfigRepository $systemConfigRepository,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Get all configuration entries
|
||||
*
|
||||
* @return SystemConfig[]
|
||||
*/
|
||||
public function getAllConfigs(): array
|
||||
{
|
||||
return $this->systemConfigRepository->findAll();
|
||||
}
|
||||
|
||||
public function getConfigValue(SystemSettingKey $key, string $default = ''): string
|
||||
{
|
||||
return $this->systemConfigRepository->getValue($key, $default);
|
||||
}
|
||||
|
||||
public function setConfigValue(SystemSettingKey $key, string $value): void
|
||||
{
|
||||
$this->systemConfigRepository->setValue($key, $value);
|
||||
}
|
||||
|
||||
public function getConfigByKey(SystemSettingKey $key): SystemConfig
|
||||
{
|
||||
return $this->systemConfigRepository->findByKey($key);
|
||||
}
|
||||
|
||||
public function createConfig(SystemSettingKey $key, string $value): SystemConfig
|
||||
{
|
||||
if ($this->systemConfigRepository->findByKey($key) instanceof SystemConfig) {
|
||||
throw new InvalidArgumentException("A configuration with the key '{$key->value}' already exists");
|
||||
}
|
||||
|
||||
$config = new SystemConfig($key, $value);
|
||||
$this->systemConfigRepository->save($config);
|
||||
|
||||
return $config;
|
||||
}
|
||||
|
||||
public function updateConfig(SystemConfig $config, string $value): SystemConfig
|
||||
{
|
||||
if ($value !== '') {
|
||||
$config->setValue($value);
|
||||
}
|
||||
$this->systemConfigRepository->save($config);
|
||||
|
||||
return $config;
|
||||
}
|
||||
|
||||
public function resetAllConfigs(): void
|
||||
{
|
||||
foreach (SystemSettingKey::cases() as $key) {
|
||||
$this->setDefaultValue($key);
|
||||
}
|
||||
}
|
||||
|
||||
public function setDefaultValue(SystemSettingKey $key): void
|
||||
{
|
||||
$this->setConfigValue($key, SystemSettingKey::getDefaultValue($key));
|
||||
}
|
||||
}
|
141
src/Service/DrinkTypeService.php
Normal file
141
src/Service/DrinkTypeService.php
Normal file
|
@ -0,0 +1,141 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Service;
|
||||
|
||||
use App\Entity\DrinkType;
|
||||
use App\Enum\SystemSettingKey;
|
||||
use App\Repository\DrinkTypeRepository;
|
||||
use InvalidArgumentException;
|
||||
|
||||
readonly class DrinkTypeService
|
||||
{
|
||||
public function __construct(
|
||||
private DrinkTypeRepository $drinkTypeRepository,
|
||||
private ConfigurationService $configService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Get all drink types
|
||||
*
|
||||
* @return DrinkType[]
|
||||
*/
|
||||
public function getAllDrinkTypes(): array
|
||||
{
|
||||
return $this->drinkTypeRepository->findAll();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a drink type by ID
|
||||
*
|
||||
* @param int $id
|
||||
* @return DrinkType|null
|
||||
*/
|
||||
public function getDrinkTypeById(int $id): null|DrinkType
|
||||
{
|
||||
return $this->drinkTypeRepository->find($id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a drink type by name
|
||||
*
|
||||
* @param string $name
|
||||
* @return DrinkType|null
|
||||
*/
|
||||
public function getDrinkTypeByName(string $name): null|DrinkType
|
||||
{
|
||||
return $this->drinkTypeRepository->findOneBy([
|
||||
'name' => $name,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new drink type
|
||||
*
|
||||
* @param string $name
|
||||
* @param string|null $description
|
||||
* @param int|null $desiredStock
|
||||
* @return DrinkType
|
||||
* @throws InvalidArgumentException If a drink type with the same name already exists
|
||||
*/
|
||||
public function createDrinkType(
|
||||
string $name,
|
||||
null|string $description = null,
|
||||
null|int $desiredStock = null,
|
||||
): DrinkType {
|
||||
// Check if a drink type with the same name already exists
|
||||
if (
|
||||
$this->drinkTypeRepository->findOneBy([
|
||||
'name' => $name,
|
||||
]) !== null
|
||||
) {
|
||||
throw new InvalidArgumentException("A drink type with the name '{$name}' already exists");
|
||||
}
|
||||
|
||||
// If no desired stock is provided, use the default from configuration
|
||||
if ($desiredStock === null) {
|
||||
$desiredStock = (int) $this->configService->getConfigByKey(SystemSettingKey::DEFAULT_DESIRED_STOCK);
|
||||
}
|
||||
|
||||
$drinkType = new DrinkType($name, $description, $desiredStock);
|
||||
$this->drinkTypeRepository->save($drinkType);
|
||||
|
||||
return $drinkType;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update an existing drink type
|
||||
*
|
||||
* @param DrinkType $drinkType
|
||||
* @param string|null $name
|
||||
* @param string|null $description
|
||||
* @param int|null $desiredStock
|
||||
* @return DrinkType
|
||||
* @throws InvalidArgumentException If a drink type with the same name already exists
|
||||
*/
|
||||
public function updateDrinkType(
|
||||
DrinkType $drinkType,
|
||||
null|string $name = null,
|
||||
null|string $description = null,
|
||||
null|int $desiredStock = null,
|
||||
): DrinkType {
|
||||
// Update name if provided
|
||||
if ($name !== null && $name !== $drinkType->getName()) {
|
||||
// Check if a drink type with the same name already exists
|
||||
if (
|
||||
$this->drinkTypeRepository->findOneBy([
|
||||
'name' => $name,
|
||||
]) !== null
|
||||
) {
|
||||
throw new InvalidArgumentException("A drink type with the name '{$name}' already exists");
|
||||
}
|
||||
$drinkType->setName($name);
|
||||
}
|
||||
|
||||
// Update description if provided
|
||||
if ($description !== null) {
|
||||
$drinkType->setDescription($description);
|
||||
}
|
||||
|
||||
// Update desired stock if provided
|
||||
if ($desiredStock !== null) {
|
||||
$drinkType->setDesiredStock($desiredStock);
|
||||
}
|
||||
|
||||
$this->drinkTypeRepository->save($drinkType);
|
||||
|
||||
return $drinkType;
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a drink type
|
||||
*
|
||||
* @param DrinkType $drinkType
|
||||
* @return void
|
||||
*/
|
||||
public function deleteDrinkType(DrinkType $drinkType): void
|
||||
{
|
||||
$this->drinkTypeRepository->remove($drinkType);
|
||||
}
|
||||
}
|
119
src/Service/InventoryService.php
Normal file
119
src/Service/InventoryService.php
Normal file
|
@ -0,0 +1,119 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Service;
|
||||
|
||||
use App\Entity\DrinkType;
|
||||
use App\Entity\InventoryRecord;
|
||||
use App\Repository\DrinkTypeRepository;
|
||||
use App\Repository\InventoryRecordRepository;
|
||||
use App\Service\Config\LowStockMultiplier;
|
||||
use App\ValueObject\DrinkStock;
|
||||
use DateTimeImmutable;
|
||||
|
||||
readonly class InventoryService
|
||||
{
|
||||
public function __construct(
|
||||
private InventoryRecordRepository $inventoryRecordRepository,
|
||||
private DrinkTypeRepository $drinkTypeRepository,
|
||||
private LowStockMultiplier $lowStockMultiplier,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Get all inventory records
|
||||
*
|
||||
* @return InventoryRecord[]
|
||||
*/
|
||||
public function getAllInventoryRecords(): array
|
||||
{
|
||||
return $this->inventoryRecordRepository->findAll();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get inventory records for a specific drink type
|
||||
*
|
||||
* @param DrinkType $drinkType
|
||||
* @return InventoryRecord[]
|
||||
*/
|
||||
public function getInventoryRecordsByDrinkType(DrinkType $drinkType): array
|
||||
{
|
||||
return $this->inventoryRecordRepository->findByDrinkType($drinkType);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the latest inventory record for a specific drink type
|
||||
*
|
||||
* @param DrinkType $drinkType
|
||||
* @return InventoryRecord
|
||||
*/
|
||||
public function getLatestInventoryRecord(DrinkType $drinkType): InventoryRecord
|
||||
{
|
||||
$record = $this->inventoryRecordRepository->findLatestByDrinkType($drinkType);
|
||||
if (!($record instanceof InventoryRecord)) {
|
||||
return new InventoryRecord($drinkType, 0);
|
||||
}
|
||||
return $record;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/**
|
||||
* Get the current stock level for a specific drink type
|
||||
*
|
||||
* @param DrinkType $drinkType
|
||||
* @return int
|
||||
*/
|
||||
public function getCurrentStockLevel(DrinkType $drinkType): int
|
||||
{
|
||||
$latestRecord = $this->getLatestInventoryRecord($drinkType);
|
||||
return ($latestRecord instanceof InventoryRecord) ? $latestRecord->getQuantity() : 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the stock level for a specific drink type
|
||||
*
|
||||
* @param DrinkType $drinkType
|
||||
* @param int $quantity
|
||||
* @param DateTimeImmutable|null $timestamp
|
||||
* @return InventoryRecord
|
||||
*/
|
||||
public function updateStockLevel(
|
||||
DrinkType $drinkType,
|
||||
int $quantity,
|
||||
DateTimeImmutable $timestamp = new DateTimeImmutable(),
|
||||
): InventoryRecord {
|
||||
$inventoryRecord = new InventoryRecord($drinkType, $quantity, $timestamp);
|
||||
|
||||
$this->inventoryRecordRepository->save($inventoryRecord);
|
||||
|
||||
return $inventoryRecord;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return InventoryRecord[]
|
||||
*/
|
||||
public function getAllDrinkTypesWithStockLevels(bool $includeZeroDesiredStock = false): array
|
||||
{
|
||||
if ($includeZeroDesiredStock) {
|
||||
$drinkTypes = $this->drinkTypeRepository->findAll();
|
||||
} else {
|
||||
$drinkTypes = $this->drinkTypeRepository->findDesired();
|
||||
}
|
||||
$result = [];
|
||||
|
||||
foreach ($drinkTypes as $drinkType) {
|
||||
$result[] = $this->getDrinkStock($drinkType);
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
public function getDrinkStock(DrinkType $drinkType): DrinkStock
|
||||
{
|
||||
return DrinkStock::fromInventoryRecord(
|
||||
$this->getLatestInventoryRecord($drinkType),
|
||||
$this->lowStockMultiplier->getValue(),
|
||||
);
|
||||
}
|
||||
}
|
237
src/Service/OrderService.php
Normal file
237
src/Service/OrderService.php
Normal file
|
@ -0,0 +1,237 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Service;
|
||||
|
||||
use App\Entity\DrinkType;
|
||||
use App\Entity\Order;
|
||||
use App\Entity\OrderItem;
|
||||
use App\Enum\OrderStatus;
|
||||
use App\Repository\DrinkTypeRepository;
|
||||
use App\Repository\OrderItemRepository;
|
||||
use App\Repository\OrderRepository;
|
||||
use DateTimeImmutable;
|
||||
use InvalidArgumentException;
|
||||
|
||||
readonly class OrderService
|
||||
{
|
||||
public function __construct(
|
||||
private OrderRepository $orderRepository,
|
||||
private OrderItemRepository $orderItemRepository,
|
||||
private DrinkTypeRepository $drinkTypeRepository,
|
||||
private InventoryService $inventoryService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Get all orders
|
||||
*
|
||||
* @return Order[]
|
||||
*/
|
||||
public function getAllOrders(): array
|
||||
{
|
||||
return $this->orderRepository->findAll();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get an order by ID
|
||||
*
|
||||
* @param int $id
|
||||
* @return Order|null
|
||||
*/
|
||||
public function getOrderById(int $id): null|Order
|
||||
{
|
||||
return $this->orderRepository->find($id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get orders by status
|
||||
*
|
||||
* @return Order[]
|
||||
*/
|
||||
public function getOrdersByStatus(OrderStatus $status): array
|
||||
{
|
||||
return $this->orderRepository->findByStatus($status);
|
||||
}
|
||||
|
||||
public function getActiveOrders(): array
|
||||
{
|
||||
return $this->orderRepository->findByMultipleStatus([
|
||||
OrderStatus::NEW,
|
||||
OrderStatus::ORDERED,
|
||||
OrderStatus::IN_WORK,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the most recent order in "new" or "in work" status
|
||||
*
|
||||
* @return Order|null
|
||||
*/
|
||||
public function getMostRecentActiveOrder(): null|Order
|
||||
{
|
||||
$newOrders = $this->orderRepository->findByStatus(OrderStatus::NEW);
|
||||
$inWorkOrders = $this->orderRepository->findByStatus(OrderStatus::IN_WORK);
|
||||
|
||||
$activeOrders = array_merge($newOrders, $inWorkOrders);
|
||||
|
||||
// Sort by creation date (newest first)
|
||||
usort($activeOrders, fn(Order $a, Order $b): int => $b->getCreatedAt() <=> $a->getCreatedAt());
|
||||
|
||||
return $activeOrders[0] ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if there are any orders in "new" or "in work" status
|
||||
*
|
||||
* @return bool
|
||||
*/
|
||||
public function hasActiveOrders(): bool
|
||||
{
|
||||
$newOrders = $this->orderRepository->findByStatus(OrderStatus::NEW);
|
||||
$inWorkOrders = $this->orderRepository->findByStatus(OrderStatus::IN_WORK);
|
||||
|
||||
return $newOrders !== [] || $inWorkOrders !== [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get orders within a specific date range
|
||||
*
|
||||
* @param DateTimeImmutable $start
|
||||
* @param DateTimeImmutable $end
|
||||
* @return Order[]
|
||||
*/
|
||||
public function getOrdersByDateRange(DateTimeImmutable $start, DateTimeImmutable $end): array
|
||||
{
|
||||
return $this->orderRepository->findByDateRange($start, $end);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new order
|
||||
*
|
||||
* @param array<int, array{drinkTypeId: int, quantity: int}> $items
|
||||
* @return Order
|
||||
* @throws InvalidArgumentException If a drink type ID is invalid
|
||||
*/
|
||||
public function createOrder(array $items): Order
|
||||
{
|
||||
$order = new Order();
|
||||
$this->orderRepository->save($order);
|
||||
|
||||
foreach ($items as $item) {
|
||||
$drinkType = $this->drinkTypeRepository->find($item['drinkTypeId']);
|
||||
if ($drinkType === null) {
|
||||
throw new InvalidArgumentException("Invalid drink type ID: {$item['drinkTypeId']}");
|
||||
}
|
||||
|
||||
$orderItem = new OrderItem($drinkType, $item['quantity'], $order);
|
||||
$this->orderItemRepository->save($orderItem);
|
||||
}
|
||||
|
||||
return $order;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an order based on current stock levels
|
||||
*
|
||||
* @return Order
|
||||
*/
|
||||
public function createOrderFromStockLevels(): Order
|
||||
{
|
||||
$lowStockItems = $this->inventoryService->getAllDrinkTypesWithStockLevels();
|
||||
$orderItems = [];
|
||||
|
||||
foreach ($lowStockItems as $item) {
|
||||
if ($item['currentStock'] < $item['desiredStock']) {
|
||||
$orderItems[] = [
|
||||
'drinkTypeId' => $item['drinkType']->getId(),
|
||||
'quantity' => $item['desiredStock'] - $item['currentStock'],
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
return $this->createOrder($orderItems);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update an order's status
|
||||
*
|
||||
* @return Order
|
||||
* @throws InvalidArgumentException If the status is invalid
|
||||
*/
|
||||
public function updateOrderStatus(Order $order, OrderStatus $status): Order
|
||||
{
|
||||
$order->setStatus($status);
|
||||
$this->orderRepository->save($order);
|
||||
|
||||
return $order;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add an item to an order
|
||||
*
|
||||
* @param Order $order
|
||||
* @param DrinkType $drinkType
|
||||
* @param int $quantity
|
||||
* @return OrderItem
|
||||
* @throws InvalidArgumentException If the order is not in 'new' or 'in_work' status
|
||||
*/
|
||||
public function addOrderItem(Order $order, DrinkType $drinkType, int $quantity): OrderItem
|
||||
{
|
||||
if (!in_array($order->getStatus(), [OrderStatus::STATUS_NEW, OrderStatus::STATUS_IN_WORK], true)) {
|
||||
throw new InvalidArgumentException(
|
||||
"Cannot add items to an order with status '{$order->getStatus()->value}'",
|
||||
);
|
||||
}
|
||||
|
||||
// Check if the order already has an item for this drink type
|
||||
$existingItem = $this->orderItemRepository->findByOrderAndDrinkType($order, $drinkType);
|
||||
|
||||
if ($existingItem instanceof OrderItem) {
|
||||
// Update the existing item
|
||||
$existingItem->setQuantity($existingItem->getQuantity() + $quantity);
|
||||
$this->orderItemRepository->save($existingItem);
|
||||
return $existingItem;
|
||||
}
|
||||
// Create a new item
|
||||
$orderItem = new OrderItem($drinkType, $quantity, $order);
|
||||
$this->orderItemRepository->save($orderItem);
|
||||
return $orderItem;
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove an item from an order
|
||||
*
|
||||
* @param Order $order
|
||||
* @param OrderItem $orderItem
|
||||
* @return void
|
||||
* @throws InvalidArgumentException If the order is not in 'new' or 'in_work' status
|
||||
*/
|
||||
public function removeOrderItem(Order $order, OrderItem $orderItem): void
|
||||
{
|
||||
if (!in_array($order->getStatus(), [OrderStatus::STATUS_NEW, OrderStatus::STATUS_IN_WORK], true)) {
|
||||
throw new InvalidArgumentException(
|
||||
"Cannot remove items from an order with status '{$order->getStatus()->value}'",
|
||||
);
|
||||
}
|
||||
|
||||
$order->removeOrderItem($orderItem);
|
||||
$this->orderItemRepository->remove($orderItem);
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete an order
|
||||
*
|
||||
* @param Order $order
|
||||
* @return void
|
||||
* @throws InvalidArgumentException If the order is not in 'new' status
|
||||
*/
|
||||
public function deleteOrder(Order $order): void
|
||||
{
|
||||
if ($order->getStatus() !== OrderStatus::NEW && $order->getStatus() !== OrderStatus::IN_WORK) {
|
||||
throw new InvalidArgumentException("Cannot delete an order with status '{$order->getStatus()->value}'");
|
||||
}
|
||||
|
||||
$this->orderRepository->remove($order);
|
||||
}
|
||||
}
|
124
src/Service/StockAdjustmentService.php
Normal file
124
src/Service/StockAdjustmentService.php
Normal file
|
@ -0,0 +1,124 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Service;
|
||||
|
||||
use App\Entity\DrinkType;
|
||||
use App\Enum\SystemSettingKey;
|
||||
use App\Repository\DrinkTypeRepository;
|
||||
use App\Repository\InventoryRecordRepository;
|
||||
use App\Repository\OrderRepository;
|
||||
use App\Repository\SystemConfigRepository;
|
||||
use App\ValueObject\StockAdjustmentProposal;
|
||||
|
||||
readonly class StockAdjustmentService
|
||||
{
|
||||
public function __construct(
|
||||
private DrinkTypeRepository $drinkTypeRepository,
|
||||
private InventoryRecordRepository $inventoryRecordRepository,
|
||||
private OrderRepository $orderRepository,
|
||||
private SystemConfigRepository $systemConfigRepository,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Proposes adjusted stock levels for all drink types
|
||||
*
|
||||
* @return array<int, StockAdjustmentProposal> Array of stock adjustment proposals
|
||||
*/
|
||||
public function proposeStockAdjustments(): array
|
||||
{
|
||||
$drinkTypes = $this->drinkTypeRepository->findAll();
|
||||
$proposals = [];
|
||||
|
||||
foreach ($drinkTypes as $drinkType) {
|
||||
$proposals[] = $this->proposeStockAdjustmentForDrinkType($drinkType);
|
||||
}
|
||||
|
||||
return $proposals;
|
||||
}
|
||||
|
||||
/**
|
||||
* Proposes an adjusted stock level for a specific drink type
|
||||
*/
|
||||
public function proposeStockAdjustmentForDrinkType(DrinkType $drinkType): StockAdjustmentProposal
|
||||
{
|
||||
$currentDesiredStock = $drinkType->getDesiredStock();
|
||||
$lookbackOrders =
|
||||
(int) $this->systemConfigRepository->getValue(SystemSettingKey::STOCK_ADJUSTMENT_LOOKBACK_ORDERS);
|
||||
$increaseAmount = (int) $this->systemConfigRepository->getValue(SystemSettingKey::STOCK_INCREASE_AMOUNT);
|
||||
$decreaseAmount = (int) $this->systemConfigRepository->getValue(SystemSettingKey::STOCK_DECREASE_AMOUNT);
|
||||
|
||||
// Get the last N orders for this drink type
|
||||
$lastOrders = $this->orderRepository->findLastOrdersForDrinkType($drinkType, $lookbackOrders);
|
||||
|
||||
// If there are no orders, return the current desired stock
|
||||
if ($lastOrders === []) {
|
||||
return new StockAdjustmentProposal($drinkType, $currentDesiredStock);
|
||||
}
|
||||
|
||||
// Check if stock was 0 in the last order
|
||||
$lastOrder = $lastOrders[0];
|
||||
$lastOrderItems = $lastOrder->getOrderItems();
|
||||
$stockWasZeroInLastOrder = false;
|
||||
|
||||
foreach ($lastOrderItems as $orderItem) {
|
||||
if ($orderItem->getDrinkType()->getId() === $drinkType->getId()) {
|
||||
// Find the inventory record closest to the order creation date
|
||||
$inventoryRecords = $this->inventoryRecordRepository->findByDrinkType($drinkType);
|
||||
foreach ($inventoryRecords as $record) {
|
||||
if ($record->getTimestamp() <= $lastOrder->getCreatedAt()) {
|
||||
if ($record->getQuantity() === 0) {
|
||||
$stockWasZeroInLastOrder = true;
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// If stock was 0 in the last order, increase desired stock
|
||||
if ($stockWasZeroInLastOrder) {
|
||||
return new StockAdjustmentProposal($drinkType, $currentDesiredStock + $increaseAmount);
|
||||
}
|
||||
|
||||
// Check if stock was above zero in all lookback orders
|
||||
$stockWasAboveZeroInAllOrders = true;
|
||||
$ordersToCheck = min(count($lastOrders), $lookbackOrders);
|
||||
|
||||
for ($i = 0; $i < $ordersToCheck; $i++) {
|
||||
$order = $lastOrders[$i];
|
||||
$orderItems = $order->getOrderItems();
|
||||
|
||||
foreach ($orderItems as $orderItem) {
|
||||
if ($orderItem->getDrinkType()->getId() === $drinkType->getId()) {
|
||||
// Find the inventory record closest to the order creation date
|
||||
$inventoryRecords = $this->inventoryRecordRepository->findByDrinkType($drinkType);
|
||||
foreach ($inventoryRecords as $record) {
|
||||
if ($record->getTimestamp() <= $order->getCreatedAt()) {
|
||||
if ($record->getQuantity() === 0) {
|
||||
$stockWasAboveZeroInAllOrders = false;
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!$stockWasAboveZeroInAllOrders) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// If stock was above zero in all lookback orders, decrease desired stock
|
||||
if ($stockWasAboveZeroInAllOrders && $ordersToCheck === $lookbackOrders) {
|
||||
$proposedStock = max(0, $currentDesiredStock - $decreaseAmount);
|
||||
return new StockAdjustmentProposal($drinkType, $proposedStock);
|
||||
}
|
||||
|
||||
// Otherwise, keep the current desired stock
|
||||
return new StockAdjustmentProposal($drinkType, $currentDesiredStock);
|
||||
}
|
||||
}
|
31
src/ValueObject/DrinkStock.php
Normal file
31
src/ValueObject/DrinkStock.php
Normal file
|
@ -0,0 +1,31 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\ValueObject;
|
||||
|
||||
use App\Entity\InventoryRecord;
|
||||
use App\Enum\StockState;
|
||||
|
||||
final readonly class DrinkStock
|
||||
{
|
||||
public function __construct(
|
||||
public InventoryRecord $record,
|
||||
public StockState $stock,
|
||||
) {}
|
||||
|
||||
public static function fromInventoryRecord(InventoryRecord $record, float $lowStockMultiplier): self
|
||||
{
|
||||
if ($record->getQuantity() === 0 && $record->getDrinkType()->getDesiredStock() > 0) {
|
||||
return new self($record, StockState::CRITICAL);
|
||||
}
|
||||
if ($record->getQuantity() < ($record->getDrinkType()->getDesiredStock() * $lowStockMultiplier)) {
|
||||
return new self($record, StockState::LOW);
|
||||
}
|
||||
if ($record->getQuantity() > $record->getDrinkType()->getDesiredStock()) {
|
||||
return new self($record, StockState::HIGH);
|
||||
}
|
||||
|
||||
return new self($record, StockState::NORMAL);
|
||||
}
|
||||
}
|
Loading…
Add table
Add a link
Reference in a new issue