This commit is contained in:
lubiana 2025-06-08 13:58:51 +02:00
parent 837cfb6d43
commit 939840a3ac
Signed by: lubiana
SSH key fingerprint: SHA256:vW1EA0fRR3Fw+dD/sM0K+x3Il2gSry6YRYHqOeQwrfk
76 changed files with 6636 additions and 83 deletions

10
.env
View file

@ -18,3 +18,13 @@
APP_ENV=dev
APP_SECRET=
###< symfony/framework-bundle ###
###> doctrine/doctrine-bundle ###
# Format described at https://www.doctrine-project.org/projects/doctrine-dbal/en/latest/reference/configuration.html#connecting-using-a-url
# IMPORTANT: You MUST configure your server version, either here or in config/packages/doctrine.yaml
#
DATABASE_URL="sqlite:///%kernel.project_dir%/var/data_%kernel.environment%.db"
# DATABASE_URL="mysql://app:!ChangeMe!@127.0.0.1:3306/app?serverVersion=8.0.32&charset=utf8mb4"
# DATABASE_URL="mysql://app:!ChangeMe!@127.0.0.1:3306/app?serverVersion=10.11.2-MariaDB&charset=utf8mb4"
# DATABASE_URL="postgresql://app:!ChangeMe!@127.0.0.1:5432/app?serverVersion=16&charset=utf8"
###< doctrine/doctrine-bundle ###

6
.gitignore vendored
View file

@ -8,3 +8,9 @@
/var/
/vendor/
###< symfony/framework-bundle ###
###> phpstan/phpstan ###
phpstan.neon
###< phpstan/phpstan ###
.idea

BIN
bin/mago Executable file

Binary file not shown.

View file

@ -4,17 +4,31 @@
"minimum-stability": "stable",
"prefer-stable": true,
"require": {
"php": ">=8.2",
"php": ">=8.4",
"ext-ctype": "*",
"ext-iconv": "*",
"doctrine/dbal": "^3",
"doctrine/doctrine-bundle": "^2.14",
"doctrine/doctrine-migrations-bundle": "^3.4",
"doctrine/orm": "^3.3",
"symfony/console": "7.3.*",
"symfony/dotenv": "7.3.*",
"symfony/flex": "^2",
"symfony/form": "7.3.*",
"symfony/framework-bundle": "7.3.*",
"symfony/runtime": "7.3.*",
"symfony/security-csrf": "7.3.*",
"symfony/twig-bundle": "7.3.*",
"symfony/validator": "7.3.*",
"symfony/yaml": "7.3.*"
},
"require-dev": {
"rector/rector": "^2.0",
"symfony/maker-bundle": "^1.63",
"symfony/stopwatch": "7.3.*",
"symfony/web-profiler-bundle": "7.3.*",
"symplify/config-transformer": "^12.4",
"symplify/easy-coding-standard": "^12.5"
},
"config": {
"allow-plugins": {
@ -55,6 +69,10 @@
],
"post-update-cmd": [
"@auto-scripts"
],
"lint": [
"rector",
"ecs --fix || ecs --fix"
]
},
"conflict": {

3155
composer.lock generated

File diff suppressed because it is too large Load diff

View file

@ -2,4 +2,9 @@
return [
Symfony\Bundle\FrameworkBundle\FrameworkBundle::class => ['all' => true],
Symfony\Bundle\TwigBundle\TwigBundle::class => ['all' => true],
Doctrine\Bundle\DoctrineBundle\DoctrineBundle::class => ['all' => true],
Doctrine\Bundle\MigrationsBundle\DoctrineMigrationsBundle::class => ['all' => true],
Symfony\Bundle\MakerBundle\MakerBundle::class => ['dev' => true],
Symfony\Bundle\WebProfilerBundle\WebProfilerBundle::class => ['dev' => true, 'test' => true],
];

11
config/packages/cache.php Normal file
View file

@ -0,0 +1,11 @@
<?php
declare(strict_types=1);
use Symfony\Component\DependencyInjection\Loader\Configurator\ContainerConfigurator;
return static function (ContainerConfigurator $containerConfigurator): void {
$containerConfigurator->extension('framework', [
'cache' => null,
]);
};

View file

@ -1,19 +0,0 @@
framework:
cache:
# Unique name of your app: used to compute stable namespaces for cache keys.
#prefix_seed: your_vendor_name/app_name
# The "app" cache stores to the filesystem by default.
# The data in this cache should persist between deploys.
# Other options include:
# Redis
#app: cache.adapter.redis
#default_redis_provider: redis://localhost
# APCu (not recommended with heavy random-write workloads as memory fragmentation can cause perf issues)
#app: cache.adapter.apcu
# Namespaced pools use the above "app" backend by default
#pools:
#my.dedicated.cache: null

22
config/packages/csrf.php Normal file
View file

@ -0,0 +1,22 @@
<?php
declare(strict_types=1);
use Symfony\Component\DependencyInjection\Loader\Configurator\ContainerConfigurator;
return static function (ContainerConfigurator $containerConfigurator): void {
$containerConfigurator->extension('framework', [
'form' => [
'csrf_protection' => [
'token_id' => 'submit',
],
],
'csrf_protection' => [
'stateless_token_ids' => [
'submit',
'authenticate',
'logout',
],
],
]);
};

View file

@ -0,0 +1,74 @@
<?php
declare(strict_types=1);
use Doctrine\DBAL\Platforms\PostgreSQLPlatform;
use Symfony\Component\DependencyInjection\Loader\Configurator\ContainerConfigurator;
return static function (ContainerConfigurator $containerConfigurator): void {
$containerConfigurator->extension('doctrine', [
'dbal' => [
'url' => '%env(resolve:DATABASE_URL)%',
'profiling_collect_backtrace' => '%kernel.debug%',
'use_savepoints' => true,
],
'orm' => [
'auto_generate_proxy_classes' => true,
'enable_lazy_ghost_objects' => true,
'report_fields_where_declared' => true,
'validate_xml_mapping' => true,
'naming_strategy' => 'doctrine.orm.naming_strategy.underscore_number_aware',
'identity_generation_preferences' => [
PostgreSQLPlatform::class => 'identity',
],
'auto_mapping' => true,
'mappings' => [
'App' => [
'type' => 'attribute',
'is_bundle' => false,
'dir' => '%kernel.project_dir%/src/Entity',
'prefix' => 'App\Entity',
'alias' => 'App',
],
],
'controller_resolver' => [
'auto_mapping' => false,
],
],
]);
if ($containerConfigurator->env() === 'test') {
$containerConfigurator->extension('doctrine', [
'dbal' => [
'dbname_suffix' => '_test%env(default::TEST_TOKEN)%',
],
]);
}
if ($containerConfigurator->env() === 'prod') {
$containerConfigurator->extension('doctrine', [
'orm' => [
'auto_generate_proxy_classes' => false,
'proxy_dir' => '%kernel.build_dir%/doctrine/orm/Proxies',
'query_cache_driver' => [
'type' => 'pool',
'pool' => 'doctrine.system_cache_pool',
],
'result_cache_driver' => [
'type' => 'pool',
'pool' => 'doctrine.result_cache_pool',
],
],
]);
$containerConfigurator->extension('framework', [
'cache' => [
'pools' => [
'doctrine.result_cache_pool' => [
'adapter' => 'cache.app',
],
'doctrine.system_cache_pool' => [
'adapter' => 'cache.system',
],
],
],
]);
}
};

View file

@ -0,0 +1,14 @@
<?php
declare(strict_types=1);
use Symfony\Component\DependencyInjection\Loader\Configurator\ContainerConfigurator;
return static function (ContainerConfigurator $containerConfigurator): void {
$containerConfigurator->extension('doctrine_migrations', [
'migrations_paths' => [
'DoctrineMigrations' => '%kernel.project_dir%/migrations',
],
'enable_profiler' => false,
]);
};

View file

@ -0,0 +1,20 @@
<?php
declare(strict_types=1);
use Symfony\Component\DependencyInjection\Loader\Configurator\ContainerConfigurator;
return static function (ContainerConfigurator $containerConfigurator): void {
$containerConfigurator->extension('framework', [
'secret' => '%env(APP_SECRET)%',
'session' => true,
]);
if ($containerConfigurator->env() === 'test') {
$containerConfigurator->extension('framework', [
'test' => true,
'session' => [
'storage_factory_id' => 'session.storage.factory.mock_file',
],
]);
}
};

View file

@ -1,15 +0,0 @@
# see https://symfony.com/doc/current/reference/configuration/framework.html
framework:
secret: '%env(APP_SECRET)%'
# Note that the session will be started ONLY if you read or write from it.
session: true
#esi: true
#fragments: true
when@test:
framework:
test: true
session:
storage_factory_id: session.storage.factory.mock_file

View file

@ -0,0 +1,13 @@
<?php
declare(strict_types=1);
use Symfony\Component\DependencyInjection\Loader\Configurator\ContainerConfigurator;
return static function (ContainerConfigurator $containerConfigurator): void {
$containerConfigurator->extension('framework', [
'property_info' => [
'with_constructor_extractor' => true,
],
]);
};

View file

@ -0,0 +1,18 @@
<?php
declare(strict_types=1);
use Symfony\Component\DependencyInjection\Loader\Configurator\ContainerConfigurator;
return static function (ContainerConfigurator $containerConfigurator): void {
$containerConfigurator->extension('framework', [
'router' => null,
]);
if ($containerConfigurator->env() === 'prod') {
$containerConfigurator->extension('framework', [
'router' => [
'strict_requirements' => null,
],
]);
}
};

View file

@ -1,10 +0,0 @@
framework:
router:
# Configure how to generate URLs in non-HTTP contexts, such as CLI commands.
# See https://symfony.com/doc/current/routing.html#generating-urls-in-commands
#default_uri: http://localhost
when@prod:
framework:
router:
strict_requirements: null

16
config/packages/twig.php Normal file
View file

@ -0,0 +1,16 @@
<?php
declare(strict_types=1);
use App\Service\Config\AppName;
use Symfony\Config\TwigConfig;
use Symfony\Component\DependencyInjection\Loader\Configurator\ContainerConfigurator;
return static function (ContainerConfigurator $containerConfigurator, TwigConfig $twig): void {
$twig->fileNamePattern('*.twig');
$twig->formThemes(['bootstrap_5_layout.html.twig']);
$twig->global('appName', \Symfony\Component\DependencyInjection\Loader\Configurator\service(AppName::class));
if ($containerConfigurator->env() === 'test') {
$twig->strictVariables(true);
}
};

View file

@ -0,0 +1,18 @@
<?php
declare(strict_types=1);
use Symfony\Component\DependencyInjection\Loader\Configurator\ContainerConfigurator;
return static function (ContainerConfigurator $containerConfigurator): void {
$containerConfigurator->extension('framework', [
'validation' => null,
]);
if ($containerConfigurator->env() === 'test') {
$containerConfigurator->extension('framework', [
'validation' => [
'not_compromised_password' => false,
],
]);
}
};

View file

@ -0,0 +1,25 @@
<?php
declare(strict_types=1);
use Symfony\Component\DependencyInjection\Loader\Configurator\ContainerConfigurator;
return static function (ContainerConfigurator $containerConfigurator): void {
if ($containerConfigurator->env() === 'dev') {
$containerConfigurator->extension('web_profiler', [
'toolbar' => true,
]);
$containerConfigurator->extension('framework', [
'profiler' => [
'collect_serializer_data' => true,
],
]);
}
if ($containerConfigurator->env() === 'test') {
$containerConfigurator->extension('framework', [
'profiler' => [
'collect' => false,
],
]);
}
};

View file

@ -1,5 +1,7 @@
<?php
if (file_exists(dirname(__DIR__).'/var/cache/prod/App_KernelProdContainer.preload.php')) {
require dirname(__DIR__).'/var/cache/prod/App_KernelProdContainer.preload.php';
declare(strict_types=1);
if (file_exists(dirname(__DIR__) . '/var/cache/prod/App_KernelProdContainer.preload.php')) {
require dirname(__DIR__) . '/var/cache/prod/App_KernelProdContainer.preload.php';
}

12
config/routes.php Normal file
View file

@ -0,0 +1,12 @@
<?php
declare(strict_types=1);
use Symfony\Component\Routing\Loader\Configurator\RoutingConfigurator;
return static function (RoutingConfigurator $routingConfigurator): void {
$routingConfigurator->import([
'path' => '../src/Controller/',
'namespace' => 'App\Controller',
], 'attribute');
};

View file

@ -1,5 +0,0 @@
controllers:
resource:
path: ../src/Controller/
namespace: App\Controller
type: attribute

View file

@ -0,0 +1,12 @@
<?php
declare(strict_types=1);
use Symfony\Component\Routing\Loader\Configurator\RoutingConfigurator;
return static function (RoutingConfigurator $routingConfigurator): void {
if ($routingConfigurator->env() === 'dev') {
$routingConfigurator->import('@FrameworkBundle/Resources/config/routing/errors.php')
->prefix('/_error');
}
};

View file

@ -1,4 +0,0 @@
when@dev:
_errors:
resource: '@FrameworkBundle/Resources/config/routing/errors.php'
prefix: /_error

View file

@ -0,0 +1,14 @@
<?php
declare(strict_types=1);
use Symfony\Component\Routing\Loader\Configurator\RoutingConfigurator;
return static function (RoutingConfigurator $routingConfigurator): void {
if ($routingConfigurator->env() === 'dev') {
$routingConfigurator->import('@WebProfilerBundle/Resources/config/routing/wdt.php')
->prefix('/_wdt');
$routingConfigurator->import('@WebProfilerBundle/Resources/config/routing/profiler.php')
->prefix('/_profiler');
}
};

15
config/services.php Normal file
View file

@ -0,0 +1,15 @@
<?php
declare(strict_types=1);
use Symfony\Component\DependencyInjection\Loader\Configurator\ContainerConfigurator;
return static function (ContainerConfigurator $containerConfigurator): void {
$services = $containerConfigurator->services();
$services->defaults()
->autowire()
->autoconfigure();
$services->load('App\\', __DIR__ . '/../src/');
};

View file

@ -1,20 +0,0 @@
# This file is the entry point to configure your own services.
# Files in the packages/ subdirectory configure your dependencies.
# Put parameters here that don't need to change on each machine where the app is deployed
# https://symfony.com/doc/current/best_practices.html#use-parameters-for-application-configuration
parameters:
services:
# default configuration for services in *this* file
_defaults:
autowire: true # Automatically injects dependencies in your services.
autoconfigure: true # Automatically registers your services as commands, event subscribers, etc.
# makes classes in src/ available to be used as services
# this creates a service per class whose id is the fully-qualified class name
App\:
resource: '../src/'
# add more service definitions when explicit configuration is needed
# please note that last definitions always *replace* previous ones

41
ecs.php Normal file
View file

@ -0,0 +1,41 @@
<?php
declare(strict_types=1);
use PhpCsFixer\Fixer\Import\NoUnusedImportsFixer;
use Symplify\EasyCodingStandard\Config\ECSConfig;
return ECSConfig::configure()
->withPaths([
__DIR__ . '/public',
__DIR__ . '/src',
__DIR__ . '/config',
__DIR__ . '/tests',
__DIR__ . '/bin',
])
->withRootFiles()
// add a single rule
->withRules([
NoUnusedImportsFixer::class,
])
->withPhpCsFixerSets(
per: true,
php84Migration: true,
)
// add sets - group of rules, from easiest to more complex ones
// uncomment one, apply one, commit, PR, merge and repeat
->withPreparedSets(
arrays: true,
comments: true,
controlStructures: true,
strict: true,
cleanCode: true,
)
;

0
migrations/.gitignore vendored Normal file
View file

8
phpstan.dist.neon Normal file
View file

@ -0,0 +1,8 @@
parameters:
level: 6
paths:
- bin/
- config/
- public/
- src/
- tests/

13
podman/Containerfile Normal file
View file

@ -0,0 +1,13 @@
FROM dunglas/frankenphp
ARG USER=appuser
RUN \
# Use "adduser -D ${USER}" for alpine based distros
useradd ${USER}; \
# Remove default capability
setcap -r /usr/local/bin/frankenphp; \
# Give write access to /data/caddy and /config/caddy
chown -R ${USER}:${USER} /data/caddy && chown -R ${USER}:${USER} /config/caddy
USER ${USER}

BIN
public/assets/.DS_Store vendored Normal file

Binary file not shown.

BIN
public/assets/css/.DS_Store vendored Normal file

Binary file not shown.

6
public/assets/css/bootstrap.min.css vendored Normal file

File diff suppressed because one or more lines are too long

View file

@ -0,0 +1,88 @@
/* Emoji Footprint Animation */
.emoji-footprint {
position: absolute;
font-size: 1.6rem;
pointer-events: none;
animation: emojiFade 1s ease-out forwards;
transform: translate(-50%, -50%) scale(1);
opacity: 1;
z-index: 9999;
text-shadow:
0 0 4px #ff00bf,
0 0 8px #ff80df,
0 0 12px #ffccff;
}
@keyframes emojiFade {
0% {
transform: translate(-50%, -50%) scale(1);
opacity: 1;
}
50% {
transform: translate(-50%, -50%) scale(1.5);
opacity: 0.7;
}
100% {
transform: translate(-50%, -50%) scale(2);
opacity: 0;
}
}
/* Light Mode (Default) */
:root {
--bs-primary: #ff47a3; /* Hot Pink */
--bs-secondary: #7367f0; /* Lavender */
--bs-success: #53e69d; /* Minty Green */
--bs-info: #51caff; /* Sky Blue */
--bs-warning: #ffd53e; /* Sunshine Yellow */
--bs-danger: #ff5666; /* Coral Red */
--bs-light: #fff7fa; /* Almost-white Pink */
--bs-dark: #3b233d; /* Deep Purple */
/* Quirk it up further */
--bs-body-bg: #fff7fa;
--bs-body-color: #3b233d;
--bs-link-color: #ff47a3;
--bs-link-hover-color: #7367f0;
--bs-border-color: #ffb8e1;
}
/* Dark Mode (Bootstrap's preferred way) */
@media (prefers-color-scheme: dark) {
:root {
--bs-primary: #ff9cd7; /* Pastel Pink */
--bs-secondary: #b39afd; /* Light Lavender */
--bs-success: #8fffd9; /* Lighter Mint */
--bs-info: #90e7ff; /* Pastel Sky Blue */
--bs-warning: #fff4b1; /* Pale Yellow */
--bs-danger: #ffb1b8; /* Soft Coral */
--bs-light: #2a102d; /* Deep Purple-Black */
--bs-dark: #fff7fa; /* Reverse for dark backgrounds */
--bs-body-bg: #2a102d;
--bs-body-color: #fff7fa;
--bs-link-color: #ff9cd7;
--bs-link-hover-color: #b39afd;
--bs-border-color: #6e3a6e;
}
}
/* Top Navigation Bar Styles */
main {
margin-top: 20px;
}
.navbar-brand {
font-weight: bold;
}
.navbar-text {
font-size: 0.9rem;
}
@media (max-width: 992px) {
.navbar-text {
display: none;
}
}

797
public/assets/js/app.js Normal file
View file

@ -0,0 +1,797 @@
// Sparkle effect on mouse move
document.addEventListener('mousemove', function (e) {
const emojis = ['✨', '💖', '🌟', '💅', '🦄', '🎉', '🌈'];
const sparkle = document.createElement('div');
sparkle.className = 'emoji-footprint';
sparkle.textContent = emojis[Math.floor(Math.random() * emojis.length)];
sparkle.style.left = e.pageX + 'px';
sparkle.style.top = e.pageY + 'px';
document.body.appendChild(sparkle);
setTimeout(() => {
sparkle.remove();
}, 1000);
});
// Dashboard, Order Management, Inventory Management, Settings, and Drink Type functionality
document.addEventListener('DOMContentLoaded', function() {
// Stock update form handling
initStockUpdateForms();
// Edit button handling
initEditButtons();
// Order form handling
initOrderForms();
// Order status form handling
initOrderStatusForms();
// Inventory form handling
initInventoryForm();
// Settings form handling
initSettingsForm();
// Drink Type form handling
initDrinkTypeForm();
});
/**
* Initialize stock update forms with AJAX submission and validation
*/
function initStockUpdateForms() {
// Get all stock update forms
const stockForms = document.querySelectorAll('.stock-update-form');
// Add event listener to each form
stockForms.forEach(form => {
// Add validation to quantity input
const quantityInput = form.querySelector('input[name="quantity"]');
if (quantityInput) {
quantityInput.addEventListener('input', function() {
validateQuantityInput(this);
});
}
// Add submit handler
form.addEventListener('submit', function(e) {
e.preventDefault();
// Validate form before submission
if (!validateStockForm(form)) {
return;
}
// Get form data
const formData = new FormData(form);
// Show loading state
const submitButton = form.querySelector('button[type="submit"]');
const originalText = submitButton.textContent;
submitButton.textContent = 'Updating...';
submitButton.disabled = true;
// Send AJAX request
fetch(form.action, {
method: 'POST',
body: formData
})
.then(response => {
if (!response.ok) {
throw new Error('Network response was not ok');
}
return response.json();
})
.then(data => {
// Reset button state
submitButton.textContent = originalText;
submitButton.disabled = false;
if (data.success) {
// Show success message
showAlert(form.parentNode, 'success', 'Stock updated successfully.');
} else {
// Show error message
showAlert(form.parentNode, 'danger', `Error updating stock: ${data.message}`);
}
})
.catch(error => {
// Reset button state
submitButton.textContent = originalText;
submitButton.disabled = false;
// Show error message
showAlert(form.parentNode, 'danger', `Error: ${error.message}`);
console.error('Error:', error);
});
});
});
}
/**
* Initialize edit buttons
*/
function initEditButtons() {
const editButtons = document.querySelectorAll('.btn-edit-drink-type');
editButtons.forEach(button => {
button.addEventListener('click', function() {
const drinkTypeId = this.dataset.drinkTypeId;
window.location.href = `/drink-types/edit/${drinkTypeId}`;
});
});
}
/**
* Validate a stock update form
* @param {HTMLFormElement} form - The form to validate
* @returns {boolean} - Whether the form is valid
*/
function validateStockForm(form) {
const quantityInput = form.querySelector('input[name="quantity"]');
return validateQuantityInput(quantityInput);
}
/**
* Validate a quantity input
* @param {HTMLInputElement} input - The input to validate
* @returns {boolean} - Whether the input is valid
*/
function validateQuantityInput(input) {
const value = parseInt(input.value);
// Check if value is a number
if (isNaN(value)) {
input.classList.add('is-invalid');
showInputError(input, 'Please enter a valid number');
return false;
}
// Check if value is non-negative
if (value < 0) {
input.classList.add('is-invalid');
showInputError(input, 'Quantity cannot be negative');
return false;
}
// Input is valid
input.classList.remove('is-invalid');
input.classList.add('is-valid');
return true;
}
/**
* Show an error message for an input
* @param {HTMLInputElement} input - The input with the error
* @param {string} message - The error message
*/
function showInputError(input, message) {
// Remove any existing error message
const existingError = input.parentNode.querySelector('.invalid-feedback');
if (existingError) {
existingError.remove();
}
// Create and add new error message
const errorDiv = document.createElement('div');
errorDiv.className = 'invalid-feedback';
errorDiv.textContent = message;
input.parentNode.appendChild(errorDiv);
}
/**
* Show an alert message
* @param {HTMLElement} container - The container to add the alert to
* @param {string} type - The type of alert (success, danger, warning, info)
* @param {string} message - The alert message
*/
function showAlert(container, type, message) {
// Remove any existing alerts
const existingAlerts = container.querySelectorAll('.alert');
existingAlerts.forEach(alert => alert.remove());
// Create and add new alert
const alert = document.createElement('div');
alert.className = `alert alert-${type} alert-dismissible fade show mt-3`;
alert.innerHTML = `
${message}
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
`;
container.appendChild(alert);
// Remove alert after 3 seconds
setTimeout(() => {
alert.remove();
}, 3000);
}
/**
* Initialize order forms with validation and dynamic item handling
*/
function initOrderForms() {
const orderForm = document.getElementById('orderForm');
if (!orderForm) return;
// Validate all quantity inputs
const quantityInputs = orderForm.querySelectorAll('input[type="number"]');
quantityInputs.forEach(input => {
input.addEventListener('input', function() {
validateQuantityInput(this);
});
});
// Add form submission handler
orderForm.addEventListener('submit', function(e) {
// Validate all inputs before submission
let isValid = true;
quantityInputs.forEach(input => {
if (!validateQuantityInput(input)) {
isValid = false;
}
});
if (!isValid) {
e.preventDefault();
showAlert(orderForm, 'danger', 'Please correct the errors in the form before submitting.');
}
});
// Add "Add Item" button functionality if it exists
const addItemButton = document.getElementById('addOrderItem');
if (addItemButton) {
addItemButton.addEventListener('click', function() {
addOrderItem();
});
}
// Add "Remove Item" button functionality
const removeButtons = orderForm.querySelectorAll('.remove-order-item');
removeButtons.forEach(button => {
button.addEventListener('click', function() {
removeOrderItem(this);
});
});
}
/**
* Add a new order item row to the order form
*/
function addOrderItem() {
const orderItemsTable = document.querySelector('#orderForm table tbody');
const drinkTypeSelect = document.getElementById('drinkTypeSelect');
if (!orderItemsTable || !drinkTypeSelect) return;
// Get the selected drink type
const selectedOption = drinkTypeSelect.options[drinkTypeSelect.selectedIndex];
const drinkTypeId = selectedOption.value;
const drinkTypeName = selectedOption.text;
// Check if this drink type is already in the table
const existingRow = orderItemsTable.querySelector(`input[value="${drinkTypeId}"]`);
if (existingRow) {
showAlert(orderItemsTable.parentNode, 'warning', `${drinkTypeName} is already in the order.`);
return;
}
// Create a new row
const newRow = document.createElement('tr');
const rowCount = orderItemsTable.querySelectorAll('tr').length;
newRow.innerHTML = `
<td>${drinkTypeName}</td>
<td>
<input type="hidden" name="items[${rowCount}][drinkTypeId]" value="${drinkTypeId}">
<input type="number" name="items[${rowCount}][quantity]"
class="form-control form-control-sm order-quantity"
value="1" min="0">
</td>
<td>
<button type="button" class="btn btn-sm btn-danger remove-order-item">Remove</button>
</td>
`;
// Add event listeners to the new row
const quantityInput = newRow.querySelector('input[type="number"]');
quantityInput.addEventListener('input', function() {
validateQuantityInput(this);
});
const removeButton = newRow.querySelector('.remove-order-item');
removeButton.addEventListener('click', function() {
removeOrderItem(this);
});
// Add the row to the table
orderItemsTable.appendChild(newRow);
}
/**
* Remove an order item row from the order form
* @param {HTMLElement} button - The remove button that was clicked
*/
function removeOrderItem(button) {
const row = button.closest('tr');
if (row) {
row.remove();
}
}
/**
* Initialize order status forms with AJAX submission
*/
function initOrderStatusForms() {
const statusForm = document.querySelector('form[action^="/orders/update-status/"]');
if (!statusForm) return;
statusForm.addEventListener('submit', function(e) {
e.preventDefault();
// Get form data
const formData = new FormData(statusForm);
// Show loading state
const submitButton = statusForm.querySelector('button[type="submit"]');
const originalText = submitButton.textContent;
submitButton.textContent = 'Updating...';
submitButton.disabled = true;
// Send AJAX request
fetch(statusForm.action, {
method: 'POST',
body: formData
})
.then(response => {
if (!response.ok) {
throw new Error('Network response was not ok');
}
return response.json();
})
.then(data => {
// Reset button state
submitButton.textContent = originalText;
submitButton.disabled = false;
if (data.success) {
// Show success message
showAlert(statusForm.parentNode, 'success', 'Order status updated successfully.');
// Update the status badge
const statusBadge = document.querySelector('.badge');
if (statusBadge) {
// Remove old status classes
statusBadge.classList.remove('bg-primary', 'bg-warning', 'bg-info', 'bg-success');
// Add new status class
const newStatus = formData.get('status');
if (newStatus === 'new') {
statusBadge.classList.add('bg-primary');
} else if (newStatus === 'in_work') {
statusBadge.classList.add('bg-warning');
} else if (newStatus === 'ordered') {
statusBadge.classList.add('bg-info');
} else if (newStatus === 'fulfilled') {
statusBadge.classList.add('bg-success');
}
// Update the text
statusBadge.textContent = newStatus.charAt(0).toUpperCase() + newStatus.slice(1);
}
} else {
// Show error message
showAlert(statusForm.parentNode, 'danger', `Error updating status: ${data.message}`);
}
})
.catch(error => {
// Reset button state
submitButton.textContent = originalText;
submitButton.disabled = false;
// Show error message
showAlert(statusForm.parentNode, 'danger', `Error: ${error.message}`);
console.error('Error:', error);
});
});
}
/**
* Initialize inventory form with validation, highlighting for changed values, and AJAX submission
*/
function initInventoryForm() {
const inventoryForm = document.getElementById('inventoryForm');
if (!inventoryForm) return;
// Get all quantity inputs
const quantityInputs = inventoryForm.querySelectorAll('.inventory-quantity');
// Add event listener to each input for validation and highlighting
quantityInputs.forEach(input => {
// Store original value for comparison
const originalValue = parseInt(input.dataset.originalValue);
// Add input event listener for validation
input.addEventListener('input', function() {
// Validate input
validateQuantityInput(this);
// Highlight changed values
const currentValue = parseInt(this.value);
if (currentValue !== originalValue) {
this.classList.add('bg-warning', 'text-dark');
} else {
this.classList.remove('bg-warning', 'text-dark');
}
});
});
// Add submit handler
inventoryForm.addEventListener('submit', function(e) {
e.preventDefault();
// Validate all inputs before submission
let isValid = true;
quantityInputs.forEach(input => {
if (!validateQuantityInput(input)) {
isValid = false;
}
});
if (!isValid) {
showAlert(inventoryForm, 'danger', 'Please correct the errors in the form before submitting.');
return;
}
// Check if any values have changed
let hasChanges = false;
quantityInputs.forEach(input => {
const originalValue = parseInt(input.dataset.originalValue);
const currentValue = parseInt(input.value);
if (currentValue !== originalValue) {
hasChanges = true;
}
});
if (!hasChanges) {
showAlert(inventoryForm, 'warning', 'No changes detected. Nothing to update.');
return;
}
// Get form data
const formData = new FormData(inventoryForm);
// Add X-Requested-With header to indicate AJAX request
const headers = new Headers({
'X-Requested-With': 'XMLHttpRequest'
});
// Show loading state
const submitButton = inventoryForm.querySelector('button[type="submit"]');
const originalText = submitButton.textContent;
submitButton.textContent = 'Updating...';
submitButton.disabled = true;
// Send AJAX request
fetch(inventoryForm.action, {
method: 'POST',
headers: headers,
body: formData
})
.then(response => {
if (!response.ok) {
throw new Error('Network response was not ok');
}
return response.json();
})
.then(data => {
// Reset button state
submitButton.textContent = originalText;
submitButton.disabled = false;
if (data.success) {
// Show success message
showAlert(inventoryForm, 'success', 'Inventory updated successfully.');
// Update original values and remove highlighting
quantityInputs.forEach(input => {
const drinkTypeId = input.previousElementSibling.value;
const updatedItem = data.updatedItems.find(item => item.drinkTypeId == drinkTypeId);
if (updatedItem) {
input.dataset.originalValue = updatedItem.newQuantity;
input.classList.remove('bg-warning', 'text-dark');
}
});
// Show update results
const updateResults = document.getElementById('updateResults');
const updateResultsContent = document.getElementById('updateResultsContent');
if (updateResults && updateResultsContent) {
updateResults.classList.remove('d-none');
let resultsHtml = '<h6>The following items were updated:</h6><ul>';
data.updatedItems.forEach(item => {
resultsHtml += `<li>${item.name}: ${item.oldQuantity}${item.newQuantity}</li>`;
});
resultsHtml += '</ul>';
updateResultsContent.innerHTML = resultsHtml;
}
} else {
// Show error message
let errorMessage = 'Error updating inventory.';
if (data.errors && data.errors.length > 0) {
errorMessage += ' ' + data.errors.join(' ');
}
showAlert(inventoryForm, 'danger', errorMessage);
}
})
.catch(error => {
// Reset button state
submitButton.textContent = originalText;
submitButton.disabled = false;
// Show error message
showAlert(inventoryForm, 'danger', `Error: ${error.message}`);
console.error('Error:', error);
});
});
}
/**
* Initialize settings form with validation and AJAX submission
*/
function initSettingsForm() {
const settingsForm = document.getElementById('settings-form');
if (!settingsForm) return;
// Get all inputs
const textInputs = settingsForm.querySelectorAll('input[type="text"]');
const numberInputs = settingsForm.querySelectorAll('input[type="number"]');
// Add event listeners for validation
textInputs.forEach(input => {
input.addEventListener('input', function() {
validateSettingTextInput(this);
});
});
numberInputs.forEach(input => {
input.addEventListener('input', function() {
validateSettingNumberInput(this);
});
});
// Add submit handler
settingsForm.addEventListener('submit', function(e) {
// Validate all inputs before submission
let isValid = true;
textInputs.forEach(input => {
if (!validateSettingTextInput(input)) {
isValid = false;
}
});
numberInputs.forEach(input => {
if (!validateSettingNumberInput(input)) {
isValid = false;
}
});
if (!isValid) {
e.preventDefault();
showAlert(settingsForm, 'danger', 'Please correct the errors in the form before submitting.');
}
});
// Reset to defaults button
const resetButton = document.getElementById('reset-defaults');
if (resetButton) {
resetButton.addEventListener('click', function() {
if (confirm('Are you sure you want to reset all settings to their default values?')) {
window.location.href = '/settings/reset';
}
});
}
}
/**
* Validate a text input for settings
* @param {HTMLInputElement} input - The input to validate
* @returns {boolean} - Whether the input is valid
*/
function validateSettingTextInput(input) {
// Check if value is empty
if (input.value.trim() === '') {
input.classList.add('is-invalid');
showInputError(input, 'This field cannot be empty');
return false;
}
// Input is valid
input.classList.remove('is-invalid');
input.classList.add('is-valid');
return true;
}
/**
* Validate a number input for settings
* @param {HTMLInputElement} input - The input to validate
* @returns {boolean} - Whether the input is valid
*/
function validateSettingNumberInput(input) {
const value = parseInt(input.value);
// Check if value is a number
if (isNaN(value)) {
input.classList.add('is-invalid');
showInputError(input, 'Please enter a valid number');
return false;
}
// Check if value is positive
if (value < 1) {
input.classList.add('is-invalid');
showInputError(input, 'Please enter a positive number');
return false;
}
// Input is valid
input.classList.remove('is-invalid');
input.classList.add('is-valid');
return true;
}
/**
* Initialize drink type form with validation and AJAX name uniqueness check
*/
function initDrinkTypeForm() {
const drinkTypeForm = document.getElementById('drink-type-form');
if (!drinkTypeForm) return;
// Get form inputs
const nameInput = drinkTypeForm.querySelector('input[name="name"]');
const descriptionInput = drinkTypeForm.querySelector('textarea[name="description"]');
const desiredStockInput = drinkTypeForm.querySelector('input[name="desired_stock"]');
// Store original name for edit mode
const originalName = nameInput ? nameInput.value : '';
// Add validation for name input
if (nameInput) {
nameInput.addEventListener('input', function() {
validateDrinkTypeName(this, originalName);
});
// Check name uniqueness on blur
nameInput.addEventListener('blur', function() {
checkDrinkTypeNameUniqueness(this, originalName);
});
}
// Add validation for desired stock input
if (desiredStockInput) {
desiredStockInput.addEventListener('input', function() {
validateDesiredStock(this);
});
}
// Add form submission handler
drinkTypeForm.addEventListener('submit', function(e) {
let isValid = true;
// Validate name
if (nameInput && !validateDrinkTypeName(nameInput, originalName)) {
isValid = false;
}
// Validate desired stock
if (desiredStockInput && !validateDesiredStock(desiredStockInput)) {
isValid = false;
}
if (!isValid) {
e.preventDefault();
showAlert(drinkTypeForm, 'danger', 'Please correct the errors in the form before submitting.');
}
});
}
/**
* Validate a drink type name
* @param {HTMLInputElement} input - The input to validate
* @param {string} originalName - The original name (for edit mode)
* @returns {boolean} - Whether the input is valid
*/
function validateDrinkTypeName(input, originalName) {
const value = input.value.trim();
// Check if value is empty
if (value === '') {
input.classList.add('is-invalid');
showInputError(input, 'Name is required');
return false;
}
// Check if value is too long
if (value.length > 255) {
input.classList.add('is-invalid');
showInputError(input, 'Name cannot be longer than 255 characters');
return false;
}
// Input is valid
input.classList.remove('is-invalid');
input.classList.add('is-valid');
return true;
}
/**
* Check if a drink type name is unique
* @param {HTMLInputElement} input - The input to check
* @param {string} originalName - The original name (for edit mode)
*/
function checkDrinkTypeNameUniqueness(input, originalName) {
const value = input.value.trim();
// Skip check if name hasn't changed or is empty
if (value === '' || value === originalName) {
return;
}
// Show loading state
input.classList.add('is-loading');
// Check if name already exists
fetch(`/api/drink-types/check-name?name=${encodeURIComponent(value)}`)
.then(response => {
if (!response.ok) {
throw new Error('Network response was not ok');
}
return response.json();
})
.then(data => {
// Remove loading state
input.classList.remove('is-loading');
if (data.exists) {
input.classList.add('is-invalid');
showInputError(input, 'A drink type with this name already exists');
}
})
.catch(error => {
// Remove loading state
input.classList.remove('is-loading');
console.error('Error checking name uniqueness:', error);
});
}
/**
* Validate a desired stock input
* @param {HTMLInputElement} input - The input to validate
* @returns {boolean} - Whether the input is valid
*/
function validateDesiredStock(input) {
const value = parseInt(input.value);
// Check if value is a number
if (isNaN(value)) {
input.classList.add('is-invalid');
showInputError(input, 'Desired stock must be a number');
return false;
}
// Check if value is non-negative
if (value < 0) {
input.classList.add('is-invalid');
showInputError(input, 'Desired stock cannot be negative');
return false;
}
// Input is valid
input.classList.remove('is-invalid');
input.classList.add('is-valid');
return true;
}

File diff suppressed because one or more lines are too long

View file

@ -1,9 +1,9 @@
<?php
declare(strict_types=1);
use App\Kernel;
require_once dirname(__DIR__).'/vendor/autoload_runtime.php';
require_once dirname(__DIR__) . '/vendor/autoload_runtime.php';
return function (array $context) {
return new Kernel($context['APP_ENV'], (bool) $context['APP_DEBUG']);
};
return fn(array $context): Kernel => new Kernel($context['APP_ENV'], (bool) $context['APP_DEBUG']);

28
rector.php Normal file
View file

@ -0,0 +1,28 @@
<?php
declare(strict_types=1);
use Rector\Config\RectorConfig;
use Rector\Php81\Rector\Array_\FirstClassCallableRector;
return RectorConfig::configure()
->withPaths([
__DIR__ . '/public',
__DIR__ . '/src',
__DIR__ . '/config',
__DIR__ . '/tests',
__DIR__ . '/bin',
])
->withRootFiles()
->withImportNames(removeUnusedImports: true)
->withPhpSets()
->withPreparedSets(
codeQuality: true,
typeDeclarations: true,
earlyReturn: true,
strictBooleans: true,
)
->withSkip([
FirstClassCallableRector::class,
])
;

View 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
View 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
View file

115
src/Entity/DrinkType.php Normal file
View 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();
}
}

View 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
View 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
View 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();
}
}

View 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
View 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
View 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';
}

View 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,
};
}
}

View 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,
]);
}
}

View file

@ -1,5 +1,7 @@
<?php
declare(strict_types=1);
namespace App;
use Symfony\Bundle\FrameworkBundle\Kernel\MicroKernelTrait;

0
src/Repository/.gitignore vendored Normal file
View file

View 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();
}
}

View 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;
}
}

View 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;
}
}

View 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,
]);
}
}

View 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;
}
}

View 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);
}
}

View 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);
}
}

View 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;
}
}

View 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));
}
}

View 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);
}
}

View 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(),
);
}
}

View 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);
}
}

View 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);
}
}

View 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);
}
}

View file

@ -1,4 +1,52 @@
{
"doctrine/deprecations": {
"version": "1.1",
"recipe": {
"repo": "github.com/symfony/recipes",
"branch": "main",
"version": "1.0",
"ref": "87424683adc81d7dc305eefec1fced883084aab9"
}
},
"doctrine/doctrine-bundle": {
"version": "2.14",
"recipe": {
"repo": "github.com/symfony/recipes",
"branch": "main",
"version": "2.13",
"ref": "620b57f496f2e599a6015a9fa222c2ee0a32adcb"
},
"files": [
"config/packages/doctrine.yaml",
"src/Entity/.gitignore",
"src/Repository/.gitignore"
]
},
"doctrine/doctrine-migrations-bundle": {
"version": "3.4",
"recipe": {
"repo": "github.com/symfony/recipes",
"branch": "main",
"version": "3.1",
"ref": "1d01ec03c6ecbd67c3375c5478c9a423ae5d6a33"
},
"files": [
"config/packages/doctrine_migrations.yaml",
"migrations/.gitignore"
]
},
"phpstan/phpstan": {
"version": "2.1",
"recipe": {
"repo": "github.com/symfony/recipes-contrib",
"branch": "main",
"version": "1.0",
"ref": "5e490cc197fb6bb1ae22e5abbc531ddc633b6767"
},
"files": [
"phpstan.dist.neon"
]
},
"symfony/console": {
"version": "7.3",
"recipe": {
@ -24,6 +72,18 @@
".env.dev"
]
},
"symfony/form": {
"version": "7.3",
"recipe": {
"repo": "github.com/symfony/recipes",
"branch": "main",
"version": "7.2",
"ref": "7d86a6723f4a623f59e2bf966b6aad2fc461d36b"
},
"files": [
"config/packages/csrf.yaml"
]
},
"symfony/framework-bundle": {
"version": "7.3",
"recipe": {
@ -44,6 +104,27 @@
".editorconfig"
]
},
"symfony/maker-bundle": {
"version": "1.63",
"recipe": {
"repo": "github.com/symfony/recipes",
"branch": "main",
"version": "1.0",
"ref": "fadbfe33303a76e25cb63401050439aa9b1a9c7f"
}
},
"symfony/property-info": {
"version": "7.3",
"recipe": {
"repo": "github.com/symfony/recipes",
"branch": "main",
"version": "7.3",
"ref": "dae70df71978ae9226ae915ffd5fad817f5ca1f7"
},
"files": [
"config/packages/property_info.yaml"
]
},
"symfony/routing": {
"version": "7.3",
"recipe": {
@ -56,5 +137,43 @@
"config/packages/routing.yaml",
"config/routes.yaml"
]
},
"symfony/twig-bundle": {
"version": "7.3",
"recipe": {
"repo": "github.com/symfony/recipes",
"branch": "main",
"version": "6.4",
"ref": "cab5fd2a13a45c266d45a7d9337e28dee6272877"
},
"files": [
"config/packages/twig.yaml",
"templates/base.html.twig"
]
},
"symfony/validator": {
"version": "7.3",
"recipe": {
"repo": "github.com/symfony/recipes",
"branch": "main",
"version": "7.0",
"ref": "8c1c4e28d26a124b0bb273f537ca8ce443472bfd"
},
"files": [
"config/packages/validator.yaml"
]
},
"symfony/web-profiler-bundle": {
"version": "7.3",
"recipe": {
"repo": "github.com/symfony/recipes",
"branch": "main",
"version": "7.3",
"ref": "5b2b543e13942495c0003f67780cb4448af9e606"
},
"files": [
"config/packages/web_profiler.yaml",
"config/routes/web_profiler.yaml"
]
}
}

38
templates/base.html.twig Normal file
View file

@ -0,0 +1,38 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{% block title %}Welcome!{% endblock %}</title>
{% block stylesheets %}
<link rel="stylesheet" href="/assets/css/bootstrap.min.css">
<link rel="stylesheet" href="/assets/css/styles.css">
{% endblock %}
{% block javascripts %}
<script src="/assets/js/app.js"></script>
<script src="/assets/js/bootstrap.bundle.min.js"></script>
{% endblock %}
</head>
<body>
<nav class="navbar navbar-expand-lg navbar-light bg-light">
<div class="container-fluid">
<a class="navbar-brand" href="/">{{ appName }}</a>
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarNav"
aria-controls="navbarNav" aria-expanded="false" aria-label="Toggle navigation">
<span class="navbar-toggler-icon"></span>
</button>
<div class="collapse navbar-collapse" id="navbarNav">
<ul class="navbar-nav">
<li class="nav-item">
<a class="nav-link active" aria-current="page" href="/">Home</a>
</li>
</ul>
</div>
</div>
</nav>
<div class="container mt-4">
{% block body %}{% endblock %}
</div>
</body>
</html>

View file

@ -0,0 +1,4 @@
<form method="post" action="{{ path('app_drink_type_delete', {'id': drink_type.id}) }}" onsubmit="return confirm('Are you sure you want to delete this item?');">
<input type="hidden" name="_token" value="{{ csrf_token('delete' ~ drink_type.id) }}">
<button class="btn">Delete</button>
</form>

View file

@ -0,0 +1,4 @@
{{ form_start(form) }}
{{ form_widget(form) }}
<button class="btn">{{ button_label|default('Save') }}</button>
{{ form_end(form) }}

View file

@ -0,0 +1,13 @@
{% extends 'base.html.twig' %}
{% block title %}Edit DrinkType{% endblock %}
{% block body %}
<h1>Edit DrinkType</h1>
{{ include('drink_type/_form.html.twig', {'button_label': 'Update'}) }}
<a href="{{ path('app_drink_type_index') }}">back to list</a>
{{ include('drink_type/_delete_form.html.twig') }}
{% endblock %}

View file

@ -0,0 +1,46 @@
{% extends 'base.html.twig' %}
{% block title %}DrinkType index{% endblock %}
{% block body %}
<h1>DrinkType index</h1>
<table class="table">
<thead>
<tr>
<th>Id</th>
<th>CreatedAt</th>
<th>UpdatedAt</th>
<th>Name</th>
<th>Description</th>
<th>DesiredStock</th>
<th>DrinkStock</th>
<th>actions</th>
</tr>
</thead>
<tbody>
{% for drink_stock in drink_stocks %}
{% set drink_type = drink_stock.record.drinkType %}
<tr>
<td>{{ drink_type.id }}</td>
<td>{{ drink_type.createdAt ? drink_type.createdAt|date('Y-m-d H:i:s') : '' }}</td>
<td>{{ drink_type.updatedAt ? drink_type.updatedAt|date('Y-m-d H:i:s') : '' }}</td>
<td>{{ drink_type.name }}</td>
<td>{{ drink_type.description }}</td>
<td>{{ drink_type.desiredStock }}</td>
<td>{{ drink_stock.record.quantity }}</td>
<td>
<a href="{{ path('app_drink_type_show', {'id': drink_type.id}) }}">show</a>
<a href="{{ path('app_drink_type_edit', {'id': drink_type.id}) }}">edit</a>
</td>
</tr>
{% else %}
<tr>
<td colspan="7">no records found</td>
</tr>
{% endfor %}
</tbody>
</table>
<a href="{{ path('app_drink_type_new') }}">Create new</a>
{% endblock %}

View file

@ -0,0 +1,11 @@
{% extends 'base.html.twig' %}
{% block title %}New DrinkType{% endblock %}
{% block body %}
<h1>Create new DrinkType</h1>
{{ include('drink_type/_form.html.twig') }}
<a href="{{ path('app_drink_type_index') }}">back to list</a>
{% endblock %}

View file

@ -0,0 +1,42 @@
{% extends 'base.html.twig' %}
{% block title %}DrinkType{% endblock %}
{% block body %}
<h1>DrinkType</h1>
<table class="table">
<tbody>
<tr>
<th>Id</th>
<td>{{ drink_type.id }}</td>
</tr>
<tr>
<th>CreatedAt</th>
<td>{{ drink_type.createdAt ? drink_type.createdAt|date('Y-m-d H:i:s') : '' }}</td>
</tr>
<tr>
<th>UpdatedAt</th>
<td>{{ drink_type.updatedAt ? drink_type.updatedAt|date('Y-m-d H:i:s') : '' }}</td>
</tr>
<tr>
<th>Name</th>
<td>{{ drink_type.name }}</td>
</tr>
<tr>
<th>Description</th>
<td>{{ drink_type.description }}</td>
</tr>
<tr>
<th>DesiredStock</th>
<td>{{ drink_type.desiredStock }}</td>
</tr>
</tbody>
</table>
<a href="{{ path('app_drink_type_index') }}">back to list</a>
<a href="{{ path('app_drink_type_edit', {'id': drink_type.id}) }}">edit</a>
{{ include('drink_type/_delete_form.html.twig') }}
{% endblock %}

50
templates/index.html.twig Normal file
View file

@ -0,0 +1,50 @@
{% extends 'base.html.twig' %}
{% block title %}Inventory Overview{% endblock %}
{% block body %}
<div class="container">
<h1>Drink Inventory</h1>
<div class="card">
<div class="card-header">
<h5 class="card-title">Drink Inventory Overview</h5>
</div>
<div class="card-body">
<table class="table table-striped">
<thead>
<tr>
<th>Drink Name</th>
<th>Current Stock</th>
<th>Desired Stock</th>
<th>Status</th>
<th>Description</th>
</tr>
</thead>
<tbody>
{% for drinkStock in drinkStocks %}
{% set rowClass = '' %}
{% if drinkStock.stock.value == 'critical' %}
{% set rowClass = 'table-danger' %}
{% elseif drinkStock.stock.value == 'low' %}
{% set rowClass = 'table-warning' %}
{% elseif drinkStock.stock.value == 'high' %}
{% set rowClass = 'table-success' %}
{% endif %}
<tr class="{{ rowClass }}">
<td>{{ drinkStock.record.drinkType.name }}</td>
<td>{{ drinkStock.record.quantity }}</td>
<td>{{ drinkStock.record.drinkType.desiredStock }}</td>
<td>{{ drinkStock.stock.value|capitalize }}</td>
<td>{{ drinkStock.record.drinkType.description }}</td>
</tr>
{% endfor %}
</tbody>
</table>
<div class="mt-3">
<a href="{{ path('app_drink_type_index') }}" class="btn btn-primary">View All Drinks</a>
</div>
</div>
</div>
</div>
{% endblock %}