This commit is contained in:
lubiana 2025-05-31 21:43:13 +02:00
parent e958163a4a
commit b8a5a1ff58
Signed by: lubiana
SSH key fingerprint: SHA256:vW1EA0fRR3Fw+dD/sM0K+x3Il2gSry6YRYHqOeQwrfk
79 changed files with 15113 additions and 0 deletions

View file

@ -0,0 +1,197 @@
<?php
declare(strict_types=1);
namespace App\Service;
use InvalidArgumentException;
use App\Entity\SystemConfig;
use App\Repository\SystemConfigRepository;
use App\Enum\SystemSettingKey;
class ConfigurationService
{
// For backward compatibility
/**
* @deprecated Use SystemSettingKey::STOCK_ADJUSTMENT_LOOKBACK instead
*/
public const KEY_STOCK_ADJUSTMENT_LOOKBACK = 'stock_adjustment_lookback';
/**
* @deprecated Use SystemSettingKey::STOCK_ADJUSTMENT_LOOKBACK_ORDERS instead
*/
public const KEY_STOCK_ADJUSTMENT_LOOKBACK_ORDERS = 'stock_adjustment_lookback_orders';
/**
* @deprecated Use SystemSettingKey::STOCK_ADJUSTMENT_MAGNITUDE instead
*/
public const KEY_STOCK_ADJUSTMENT_MAGNITUDE = 'stock_adjustment_magnitude';
/**
* @deprecated Use SystemSettingKey::STOCK_ADJUSTMENT_THRESHOLD instead
*/
public const KEY_STOCK_ADJUSTMENT_THRESHOLD = 'stock_adjustment_threshold';
/**
* @deprecated Use SystemSettingKey::LOW_STOCK_THRESHOLD instead
*/
public const KEY_LOW_STOCK_THRESHOLD = 'low_stock_threshold';
/**
* @deprecated Use SystemSettingKey::CRITICAL_STOCK_THRESHOLD instead
*/
public const KEY_CRITICAL_STOCK_THRESHOLD = 'critical_stock_threshold';
/**
* @deprecated Use SystemSettingKey::DEFAULT_DESIRED_STOCK instead
*/
public const KEY_DEFAULT_DESIRED_STOCK = 'default_desired_stock';
/**
* @deprecated Use SystemSettingKey::SYSTEM_NAME instead
*/
public const KEY_SYSTEM_NAME = 'system_name';
/**
* @deprecated Use SystemSettingKey::ENABLE_AUTO_ADJUSTMENT instead
*/
public const KEY_ENABLE_AUTO_ADJUSTMENT = 'enable_auto_adjustment';
/**
* @deprecated Use SystemSettingKey::SHOW_LOW_STOCK_ALERTS instead
*/
public const KEY_SHOW_LOW_STOCK_ALERTS = 'show_low_stock_alerts';
/**
* @deprecated Use SystemSettingKey::SHOW_QUICK_UPDATE_FORM instead
*/
public const KEY_SHOW_QUICK_UPDATE_FORM = 'show_quick_update_form';
/**
* @deprecated Use SystemSettingKey::ITEMS_PER_PAGE instead
*/
public const KEY_ITEMS_PER_PAGE = 'items_per_page';
public function __construct(
private readonly SystemConfigRepository $systemConfigRepository
) {}
/**
* Get all configuration entries
*
* @return SystemConfig[]
*/
public function getAllConfigs(): array
{
return $this->systemConfigRepository->findAll();
}
/**
* Get a configuration value by key
*
* @param string $key
* @param string $default Default value if the key doesn't exist
* @return string
*/
public function getConfigValue(string $key, string $default = ''): string
{
return $this->systemConfigRepository->getValue($key, $default);
}
/**
* Set a configuration value
*
* @param string $key
* @param string $value
* @return void
*/
public function setConfigValue(string $key, string $value): void
{
$this->systemConfigRepository->setValue($key, $value);
}
/**
* Get a configuration entity by key
*
* @param string $key
* @return SystemConfig|null
*/
public function getConfigByKey(string $key): ?SystemConfig
{
return $this->systemConfigRepository->findByKey($key);
}
/**
* Create a new configuration entry
*
* @param string $key
* @param string $value
* @return SystemConfig
* @throws InvalidArgumentException If a configuration with the same key already exists
*/
public function createConfig(string $key, string $value): SystemConfig
{
if ($this->systemConfigRepository->findByKey($key) instanceof SystemConfig) {
throw new InvalidArgumentException("A configuration with the key '$key' already exists");
}
$config = new SystemConfig($key, $value);
$this->systemConfigRepository->save($config);
return $config;
}
/**
* Update an existing configuration entry
*
* @param SystemConfig $config
* @param string|null $key
* @param string|null $value
* @return SystemConfig
* @throws InvalidArgumentException If a configuration with the same key already exists
*/
public function updateConfig(
SystemConfig $config,
?string $key = null,
?string $value = null
): SystemConfig {
// Update key if provided
if ($key !== null && $key !== $config->getKey()) {
// Check if a configuration with the same key already exists
if ($this->systemConfigRepository->findByKey($key) instanceof SystemConfig) {
throw new InvalidArgumentException("A configuration with the key '$key' already exists");
}
$config->setKey($key);
}
// Update value if provided
if ($value !== null) {
$config->setValue($value);
}
$this->systemConfigRepository->save($config);
return $config;
}
/**
* Delete a configuration entry
*
* @param SystemConfig $config
* @return void
*/
public function deleteConfig(SystemConfig $config): void
{
$this->systemConfigRepository->remove($config);
}
/**
* Initialize default configuration values if they don't exist
*
* @return void
*/
public function initializeDefaultConfigs(): void
{
$defaults = [
SystemSettingKey::STOCK_ADJUSTMENT_LOOKBACK->value => '30', // Deprecated
SystemSettingKey::STOCK_ADJUSTMENT_LOOKBACK_ORDERS->value => '5',
SystemSettingKey::STOCK_ADJUSTMENT_MAGNITUDE->value => '0.2',
SystemSettingKey::STOCK_ADJUSTMENT_THRESHOLD->value => '0.1',
];
foreach ($defaults as $key => $value) {
if (!$this->getConfigByKey($key) instanceof SystemConfig) {
$this->createConfig($key, $value);
}
}
}
}

View file

@ -0,0 +1,128 @@
<?php
declare(strict_types=1);
namespace App\Service;
use InvalidArgumentException;
use App\Entity\DrinkType;
use App\Repository\DrinkTypeRepository;
use App\Service\ConfigurationService;
class DrinkTypeService
{
public function __construct(
private readonly DrinkTypeRepository $drinkTypeRepository,
private readonly 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): ?DrinkType
{
return $this->drinkTypeRepository->find($id);
}
/**
* Get a drink type by name
*
* @param string $name
* @return DrinkType|null
*/
public function getDrinkTypeByName(string $name): ?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, ?string $description = 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->getConfigValue('default_desired_stock', '10');
}
$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,
?string $name = null,
?string $description = 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);
}
}

View file

@ -0,0 +1,204 @@
<?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\ConfigurationService;
use App\Enum\SystemSettingKey;
use DateTimeImmutable;
class InventoryService
{
public function __construct(
private readonly InventoryRecordRepository $inventoryRecordRepository,
private readonly DrinkTypeRepository $drinkTypeRepository,
private readonly ConfigurationService $configService
) {}
/**
* 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|null
*/
public function getLatestInventoryRecord(DrinkType $drinkType): ?InventoryRecord
{
return $this->inventoryRecordRepository->findLatestByDrinkType($drinkType);
}
/**
* Get inventory records within a specific time range
*
* @param DateTimeImmutable $start
* @param DateTimeImmutable $end
* @return InventoryRecord[]
*/
public function getInventoryRecordsByTimeRange(DateTimeImmutable $start, DateTimeImmutable $end): array
{
return $this->inventoryRecordRepository->findByTimestampRange($start, $end);
}
/**
* 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;
}
/**
* Get the difference between current and desired stock for a specific drink type
*
* @param DrinkType $drinkType
* @return int Positive value means excess stock, negative value means shortage
*/
public function getStockDifference(DrinkType $drinkType): int
{
$currentStock = $this->getCurrentStockLevel($drinkType);
$desiredStock = $drinkType->getDesiredStock();
return $currentStock - $desiredStock;
}
/**
* 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 = null
): InventoryRecord {
$inventoryRecord = new InventoryRecord(
$drinkType,
$quantity,
$timestamp
);
$this->inventoryRecordRepository->save($inventoryRecord);
return $inventoryRecord;
}
/**
* Get all drink types with their current stock levels
*
* @return array<int, array{drinkType: DrinkType, currentStock: int, desiredStock: int, difference: int}>
*/
public function getAllDrinkTypesWithStockLevels(): array
{
$drinkTypes = $this->drinkTypeRepository->findAll();
$result = [];
foreach ($drinkTypes as $drinkType) {
$currentStock = $this->getCurrentStockLevel($drinkType);
$desiredStock = $drinkType->getDesiredStock();
$difference = $currentStock - $desiredStock;
$result[] = [
'drinkType' => $drinkType,
'currentStock' => $currentStock,
'desiredStock' => $desiredStock,
'difference' => $difference,
];
}
return $result;
}
/**
* Get all drink types with low stock (current stock below low stock threshold)
*
* @return array<int, array{drinkType: DrinkType, currentStock: int, desiredStock: int, shortage: int}>
*/
public function getLowStockDrinkTypes(): array
{
$lowStockThreshold = (float) $this->configService->getConfigValue(SystemSettingKey::LOW_STOCK_THRESHOLD->value, '50') / 100;
$drinkTypes = $this->drinkTypeRepository->findAll();
$result = [];
foreach ($drinkTypes as $drinkType) {
$currentStock = $this->getCurrentStockLevel($drinkType);
$desiredStock = $drinkType->getDesiredStock();
// Check if current stock is below the threshold percentage of desired stock
if ($desiredStock > 0 && $currentStock < ($desiredStock * $lowStockThreshold)) {
$shortage = $desiredStock - $currentStock;
$result[] = [
'drinkType' => $drinkType,
'currentStock' => $currentStock,
'desiredStock' => $desiredStock,
'shortage' => $shortage,
];
}
}
return $result;
}
/**
* Get all drink types with critical stock (current stock below critical stock threshold)
*
* @return array<int, array{drinkType: DrinkType, currentStock: int, desiredStock: int, shortage: int}>
*/
public function getCriticalStockDrinkTypes(): array
{
$criticalStockThreshold = (float) $this->configService->getConfigValue(SystemSettingKey::CRITICAL_STOCK_THRESHOLD->value, '25') / 100;
$drinkTypes = $this->drinkTypeRepository->findAll();
$result = [];
foreach ($drinkTypes as $drinkType) {
$currentStock = $this->getCurrentStockLevel($drinkType);
$desiredStock = $drinkType->getDesiredStock();
// Check if current stock is below the threshold percentage of desired stock
if ($desiredStock > 0 && $currentStock < ($desiredStock * $criticalStockThreshold)) {
$shortage = $desiredStock - $currentStock;
$result[] = [
'drinkType' => $drinkType,
'currentStock' => $currentStock,
'desiredStock' => $desiredStock,
'shortage' => $shortage,
];
}
}
return $result;
}
}

View file

@ -0,0 +1,217 @@
<?php
declare(strict_types=1);
namespace App\Service;
use InvalidArgumentException;
use App\Entity\DrinkType;
use App\Entity\Order;
use App\Entity\OrderItem;
use App\Repository\DrinkTypeRepository;
use App\Repository\OrderItemRepository;
use App\Repository\OrderRepository;
use DateTimeImmutable;
class OrderService
{
public function __construct(
private readonly OrderRepository $orderRepository,
private readonly OrderItemRepository $orderItemRepository,
private readonly DrinkTypeRepository $drinkTypeRepository,
private readonly 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): ?Order
{
return $this->orderRepository->find($id);
}
/**
* Get orders by status
*
* @param string $status
* @return Order[]
*/
public function getOrdersByStatus(string $status): array
{
return $this->orderRepository->findByStatus($status);
}
/**
* 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
*
* @param Order $order
* @param string $status
* @return Order
* @throws InvalidArgumentException If the status is invalid
*/
public function updateOrderStatus(Order $order, string $status): Order
{
$order->setStatus($status);
$this->orderRepository->save($order);
// If the order is fulfilled, update the inventory
if ($status === Order::STATUS_FULFILLED) {
$this->updateInventoryFromOrder($order);
}
return $order;
}
/**
* Update inventory based on a fulfilled order
*
* @param Order $order
* @return void
*/
private function updateInventoryFromOrder(Order $order): void
{
foreach ($order->getOrderItems() as $orderItem) {
$drinkType = $orderItem->getDrinkType();
$currentStock = $this->inventoryService->getCurrentStockLevel($drinkType);
$newStock = $currentStock + $orderItem->getQuantity();
$this->inventoryService->updateStockLevel($drinkType, $newStock);
}
}
/**
* 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(), [Order::STATUS_NEW, Order::STATUS_IN_WORK], true)) {
throw new InvalidArgumentException("Cannot add items to an order with status '{$order->getStatus()}'");
}
// 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(), [Order::STATUS_NEW, Order::STATUS_IN_WORK], true)) {
throw new InvalidArgumentException("Cannot remove items from an order with status '{$order->getStatus()}'");
}
$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() !== Order::STATUS_NEW) {
throw new InvalidArgumentException("Cannot delete an order with status '{$order->getStatus()}'");
}
$this->orderRepository->remove($order);
}
}

View file

@ -0,0 +1,262 @@
<?php
declare(strict_types=1);
namespace App\Service;
use App\Entity\InventoryRecord;
use App\Entity\DrinkType;
use App\Repository\DrinkTypeRepository;
use App\Repository\InventoryRecordRepository;
use App\Repository\OrderRepository;
use DateTimeImmutable;
class StockAdjustmentService
{
private const int DEFAULT_LOOKBACK_ORDERS = 5;
private const float DEFAULT_ADJUSTMENT_MAGNITUDE = 0.2; // 20%
private const float DEFAULT_ADJUSTMENT_THRESHOLD = 0.1; // 10%
public function __construct(
private readonly DrinkTypeRepository $drinkTypeRepository,
private readonly InventoryRecordRepository $inventoryRecordRepository,
private readonly OrderRepository $orderRepository,
private readonly ConfigurationService $configService,
private readonly InventoryService $inventoryService
) {}
/**
* Analyze consumption patterns and adjust desired stock levels
*
* @return array<int, array{drinkType: DrinkType, oldDesiredStock: int, newDesiredStock: int, adjustmentReason: string}>
*/
public function adjustStockLevels(): array
{
$lookbackOrders = (int) $this->configService->getConfigValue(
'stock_adjustment_lookback_orders',
(string) self::DEFAULT_LOOKBACK_ORDERS
);
$adjustmentMagnitude = (float) $this->configService->getConfigValue(
'stock_adjustment_magnitude',
(string) self::DEFAULT_ADJUSTMENT_MAGNITUDE
);
$adjustmentThreshold = (float) $this->configService->getConfigValue(
'stock_adjustment_threshold',
(string) self::DEFAULT_ADJUSTMENT_THRESHOLD
);
$drinkTypes = $this->drinkTypeRepository->findAll();
$result = [];
foreach ($drinkTypes as $drinkType) {
$consumptionRate = $this->calculateConsumptionRateFromOrders($drinkType, $lookbackOrders);
if ($consumptionRate === null) {
continue; // Skip if we don't have enough data
}
$currentDesiredStock = $drinkType->getDesiredStock();
// For the suggested stock, we use the consumption rate directly as it's already weighted
$suggestedStock = (int) ceil($consumptionRate);
// Only adjust if the difference is significant
$difference = abs($suggestedStock - $currentDesiredStock) / max(1, $currentDesiredStock);
if ($difference >= $adjustmentThreshold) {
// Calculate the new desired stock with a gradual adjustment
$adjustment = (int) ceil(($suggestedStock - $currentDesiredStock) * $adjustmentMagnitude);
$newDesiredStock = $currentDesiredStock + $adjustment;
// Ensure we don't go below 1
$newDesiredStock = max(1, $newDesiredStock);
// Update the drink type
$drinkType->setDesiredStock($newDesiredStock);
$this->drinkTypeRepository->save($drinkType);
$result[] = [
'drinkType' => $drinkType,
'oldDesiredStock' => $currentDesiredStock,
'newDesiredStock' => $newDesiredStock,
'adjustmentReason' => $suggestedStock > $currentDesiredStock
? 'Increased consumption rate'
: 'Decreased consumption rate',
];
}
}
return $result;
}
/**
* Calculate the weighted consumption rate based on the last N orders for a drink type
*
* @param DrinkType $drinkType
* @param int $lookbackOrders Number of past orders to consider
* @return float|null Weighted consumption rate, or null if not enough data
*/
private function calculateConsumptionRateFromOrders(
DrinkType $drinkType,
int $lookbackOrders
): ?float {
// Get the last N orders for this drink type
$orders = $this->orderRepository->findLastOrdersForDrinkType($drinkType, $lookbackOrders);
// Need at least one order to calculate consumption
if (count($orders) === 0) {
return null;
}
// Calculate weighted consumption
$totalWeightedConsumption = 0;
$totalWeight = 0;
// Define weights - most recent order has highest weight
// For example, with 5 orders: weights are 5, 4, 3, 2, 1
$weights = range($lookbackOrders, 1);
foreach ($orders as $index => $order) {
// Get the order items for this drink type
$orderItems = $order->getOrderItems();
$quantity = 0;
// Sum up quantities for this drink type
foreach ($orderItems as $item) {
if ($item->getDrinkType()->getId() === $drinkType->getId()) {
$quantity += $item->getQuantity();
}
}
// Apply weight to this order's consumption
$weight = $weights[$index] ?? 1; // Fallback to weight 1 if index is out of bounds
$totalWeightedConsumption += $quantity * $weight;
$totalWeight += $weight;
}
// If total weight is 0, return null (shouldn't happen, but just in case)
if ($totalWeight === 0) {
return null;
}
// Calculate weighted average consumption
return $totalWeightedConsumption / $totalWeight;
}
/**
* Calculate the average daily consumption rate for a drink type
*
* @deprecated Use calculateConsumptionRateFromOrders instead
* @param DrinkType $drinkType
* @param DateTimeImmutable $startDate
* @param DateTimeImmutable $endDate
* @return float|null Average daily consumption rate, or null if not enough data
*/
private function calculateConsumptionRate(
DrinkType $drinkType,
DateTimeImmutable $startDate,
DateTimeImmutable $endDate
): ?float {
// Get inventory records for the period
$inventoryRecords = $this->inventoryRecordRepository->findByDrinkType($drinkType);
// Filter records within the date range
$inventoryRecords = array_filter($inventoryRecords, function ($record) use ($startDate, $endDate): bool {
$timestamp = $record->getTimestamp();
return $timestamp >= $startDate && $timestamp <= $endDate;
});
// Sort by timestamp
usort($inventoryRecords, fn($a, $b): int => $a->getTimestamp() <=> $b->getTimestamp());
// Need at least two records to calculate consumption
if (count($inventoryRecords) < 2) {
return null;
}
// Calculate total consumption
$totalConsumption = 0;
$previousRecord = null;
foreach ($inventoryRecords as $record) {
if ($previousRecord instanceof InventoryRecord) {
$previousQuantity = $previousRecord->getQuantity();
$currentQuantity = $record->getQuantity();
$daysDifference = $record->getTimestamp()->diff($previousRecord->getTimestamp())->days;
// If days difference is 0, use 1 to avoid division by zero
$daysDifference = max(1, $daysDifference);
// If current quantity is less than previous, consumption occurred
if ($currentQuantity < $previousQuantity) {
$consumption = $previousQuantity - $currentQuantity;
$totalConsumption += $consumption;
}
}
$previousRecord = $record;
}
// Calculate days between first and last record
$firstRecord = reset($inventoryRecords);
$lastRecord = end($inventoryRecords);
$totalDays = $lastRecord->getTimestamp()->diff($firstRecord->getTimestamp())->days;
// If total days is 0, use 1 to avoid division by zero
$totalDays = max(1, $totalDays);
// Calculate average daily consumption
return $totalConsumption / $totalDays;
}
/**
* Get the consumption history for a drink type
*
* @param DrinkType $drinkType
* @param DateTimeImmutable $startDate
* @param DateTimeImmutable $endDate
* @return array<int, array{date: DateTimeImmutable, consumption: int}>
*/
public function getConsumptionHistory(
DrinkType $drinkType,
DateTimeImmutable $startDate,
DateTimeImmutable $endDate
): array {
// Get inventory records for the period
$inventoryRecords = $this->inventoryRecordRepository->findByDrinkType($drinkType);
// Filter records within the date range
$inventoryRecords = array_filter($inventoryRecords, function ($record) use ($startDate, $endDate): bool {
$timestamp = $record->getTimestamp();
return $timestamp >= $startDate && $timestamp <= $endDate;
});
// Sort by timestamp
usort($inventoryRecords, fn($a, $b): int => $a->getTimestamp() <=> $b->getTimestamp());
// Calculate consumption between each record
$consumptionHistory = [];
$previousRecord = null;
foreach ($inventoryRecords as $record) {
if ($previousRecord !== null) {
$previousQuantity = $previousRecord->getQuantity();
$currentQuantity = $record->getQuantity();
// If current quantity is less than previous, consumption occurred
if ($currentQuantity < $previousQuantity) {
$consumption = $previousQuantity - $currentQuantity;
$consumptionHistory[] = [
'date' => $record->getTimestamp(),
'consumption' => $consumption,
];
}
}
$previousRecord = $record;
}
return $consumptionHistory;
}
}