vibe
This commit is contained in:
parent
837cfb6d43
commit
939840a3ac
76 changed files with 6636 additions and 83 deletions
10
.env
10
.env
|
@ -18,3 +18,13 @@
|
||||||
APP_ENV=dev
|
APP_ENV=dev
|
||||||
APP_SECRET=
|
APP_SECRET=
|
||||||
###< symfony/framework-bundle ###
|
###< 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
6
.gitignore
vendored
|
@ -8,3 +8,9 @@
|
||||||
/var/
|
/var/
|
||||||
/vendor/
|
/vendor/
|
||||||
###< symfony/framework-bundle ###
|
###< symfony/framework-bundle ###
|
||||||
|
|
||||||
|
###> phpstan/phpstan ###
|
||||||
|
phpstan.neon
|
||||||
|
###< phpstan/phpstan ###
|
||||||
|
|
||||||
|
.idea
|
||||||
|
|
BIN
bin/mago
Executable file
BIN
bin/mago
Executable file
Binary file not shown.
|
@ -4,17 +4,31 @@
|
||||||
"minimum-stability": "stable",
|
"minimum-stability": "stable",
|
||||||
"prefer-stable": true,
|
"prefer-stable": true,
|
||||||
"require": {
|
"require": {
|
||||||
"php": ">=8.2",
|
"php": ">=8.4",
|
||||||
"ext-ctype": "*",
|
"ext-ctype": "*",
|
||||||
"ext-iconv": "*",
|
"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/console": "7.3.*",
|
||||||
"symfony/dotenv": "7.3.*",
|
"symfony/dotenv": "7.3.*",
|
||||||
"symfony/flex": "^2",
|
"symfony/flex": "^2",
|
||||||
|
"symfony/form": "7.3.*",
|
||||||
"symfony/framework-bundle": "7.3.*",
|
"symfony/framework-bundle": "7.3.*",
|
||||||
"symfony/runtime": "7.3.*",
|
"symfony/runtime": "7.3.*",
|
||||||
|
"symfony/security-csrf": "7.3.*",
|
||||||
|
"symfony/twig-bundle": "7.3.*",
|
||||||
|
"symfony/validator": "7.3.*",
|
||||||
"symfony/yaml": "7.3.*"
|
"symfony/yaml": "7.3.*"
|
||||||
},
|
},
|
||||||
"require-dev": {
|
"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": {
|
"config": {
|
||||||
"allow-plugins": {
|
"allow-plugins": {
|
||||||
|
@ -55,6 +69,10 @@
|
||||||
],
|
],
|
||||||
"post-update-cmd": [
|
"post-update-cmd": [
|
||||||
"@auto-scripts"
|
"@auto-scripts"
|
||||||
|
],
|
||||||
|
"lint": [
|
||||||
|
"rector",
|
||||||
|
"ecs --fix || ecs --fix"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"conflict": {
|
"conflict": {
|
||||||
|
|
3155
composer.lock
generated
3155
composer.lock
generated
File diff suppressed because it is too large
Load diff
|
@ -2,4 +2,9 @@
|
||||||
|
|
||||||
return [
|
return [
|
||||||
Symfony\Bundle\FrameworkBundle\FrameworkBundle::class => ['all' => true],
|
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
11
config/packages/cache.php
Normal 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,
|
||||||
|
]);
|
||||||
|
};
|
|
@ -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
22
config/packages/csrf.php
Normal 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',
|
||||||
|
],
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
};
|
74
config/packages/doctrine.php
Normal file
74
config/packages/doctrine.php
Normal 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',
|
||||||
|
],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
};
|
14
config/packages/doctrine_migrations.php
Normal file
14
config/packages/doctrine_migrations.php
Normal 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,
|
||||||
|
]);
|
||||||
|
};
|
20
config/packages/framework.php
Normal file
20
config/packages/framework.php
Normal 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',
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
};
|
|
@ -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
|
|
13
config/packages/property_info.php
Normal file
13
config/packages/property_info.php
Normal 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,
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
};
|
18
config/packages/routing.php
Normal file
18
config/packages/routing.php
Normal 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,
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
};
|
|
@ -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
16
config/packages/twig.php
Normal 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);
|
||||||
|
}
|
||||||
|
};
|
18
config/packages/validator.php
Normal file
18
config/packages/validator.php
Normal 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,
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
};
|
25
config/packages/web_profiler.php
Normal file
25
config/packages/web_profiler.php
Normal 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,
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
};
|
|
@ -1,5 +1,7 @@
|
||||||
<?php
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
if (file_exists(dirname(__DIR__) . '/var/cache/prod/App_KernelProdContainer.preload.php')) {
|
if (file_exists(dirname(__DIR__) . '/var/cache/prod/App_KernelProdContainer.preload.php')) {
|
||||||
require 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
12
config/routes.php
Normal 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');
|
||||||
|
};
|
|
@ -1,5 +0,0 @@
|
||||||
controllers:
|
|
||||||
resource:
|
|
||||||
path: ../src/Controller/
|
|
||||||
namespace: App\Controller
|
|
||||||
type: attribute
|
|
12
config/routes/framework.php
Normal file
12
config/routes/framework.php
Normal 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');
|
||||||
|
}
|
||||||
|
};
|
|
@ -1,4 +0,0 @@
|
||||||
when@dev:
|
|
||||||
_errors:
|
|
||||||
resource: '@FrameworkBundle/Resources/config/routing/errors.php'
|
|
||||||
prefix: /_error
|
|
14
config/routes/web_profiler.php
Normal file
14
config/routes/web_profiler.php
Normal 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
15
config/services.php
Normal 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/');
|
||||||
|
};
|
|
@ -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
41
ecs.php
Normal 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
0
migrations/.gitignore
vendored
Normal file
8
phpstan.dist.neon
Normal file
8
phpstan.dist.neon
Normal file
|
@ -0,0 +1,8 @@
|
||||||
|
parameters:
|
||||||
|
level: 6
|
||||||
|
paths:
|
||||||
|
- bin/
|
||||||
|
- config/
|
||||||
|
- public/
|
||||||
|
- src/
|
||||||
|
- tests/
|
13
podman/Containerfile
Normal file
13
podman/Containerfile
Normal 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
BIN
public/assets/.DS_Store
vendored
Normal file
Binary file not shown.
BIN
public/assets/css/.DS_Store
vendored
Normal file
BIN
public/assets/css/.DS_Store
vendored
Normal file
Binary file not shown.
6
public/assets/css/bootstrap.min.css
vendored
Normal file
6
public/assets/css/bootstrap.min.css
vendored
Normal file
File diff suppressed because one or more lines are too long
88
public/assets/css/styles.css
Normal file
88
public/assets/css/styles.css
Normal 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
797
public/assets/js/app.js
Normal 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;
|
||||||
|
}
|
7
public/assets/js/bootstrap.bundle.min.js
vendored
Normal file
7
public/assets/js/bootstrap.bundle.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
|
@ -1,9 +1,9 @@
|
||||||
<?php
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
use App\Kernel;
|
use App\Kernel;
|
||||||
|
|
||||||
require_once dirname(__DIR__) . '/vendor/autoload_runtime.php';
|
require_once dirname(__DIR__) . '/vendor/autoload_runtime.php';
|
||||||
|
|
||||||
return function (array $context) {
|
return fn(array $context): Kernel => new Kernel($context['APP_ENV'], (bool) $context['APP_DEBUG']);
|
||||||
return new Kernel($context['APP_ENV'], (bool) $context['APP_DEBUG']);
|
|
||||||
};
|
|
||||||
|
|
28
rector.php
Normal file
28
rector.php
Normal 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,
|
||||||
|
])
|
||||||
|
;
|
84
src/Controller/DrinkTypeController.php
Normal file
84
src/Controller/DrinkTypeController.php
Normal file
|
@ -0,0 +1,84 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Controller;
|
||||||
|
|
||||||
|
use App\Entity\DrinkType;
|
||||||
|
use App\Form\DrinkTypeForm;
|
||||||
|
use App\Repository\DrinkTypeRepository;
|
||||||
|
use App\Service\InventoryService;
|
||||||
|
use Doctrine\ORM\EntityManagerInterface;
|
||||||
|
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||||
|
use Symfony\Component\HttpFoundation\Request;
|
||||||
|
use Symfony\Component\HttpFoundation\Response;
|
||||||
|
use Symfony\Component\Routing\Attribute\Route;
|
||||||
|
|
||||||
|
#[Route('/drink-type')]
|
||||||
|
final class DrinkTypeController extends AbstractController
|
||||||
|
{
|
||||||
|
#[Route(name: 'app_drink_type_index', methods: ['GET'])]
|
||||||
|
public function index(InventoryService $inventoryService): Response
|
||||||
|
{
|
||||||
|
return $this->render('drink_type/index.html.twig', [
|
||||||
|
'drink_stocks' => $inventoryService->getAllDrinkTypesWithStockLevels(true)
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Route(path: '/new', name: 'app_drink_type_new', methods: ['GET', 'POST'])]
|
||||||
|
public function new(Request $request, EntityManagerInterface $entityManager): Response
|
||||||
|
{
|
||||||
|
$drinkType = new DrinkType('', '');
|
||||||
|
$form = $this->createForm(DrinkTypeForm::class, $drinkType);
|
||||||
|
$form->handleRequest($request);
|
||||||
|
|
||||||
|
if ($form->isSubmitted() && $form->isValid()) {
|
||||||
|
$entityManager->persist($drinkType);
|
||||||
|
$entityManager->flush();
|
||||||
|
|
||||||
|
return $this->redirectToRoute('app_drink_type_index', [], Response::HTTP_SEE_OTHER);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->render('drink_type/new.html.twig', [
|
||||||
|
'drink_type' => $drinkType,
|
||||||
|
'form' => $form,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Route(path: '/{id}', name: 'app_drink_type_show', methods: ['GET'])]
|
||||||
|
public function show(DrinkType $drinkType): Response
|
||||||
|
{
|
||||||
|
return $this->render('drink_type/show.html.twig', [
|
||||||
|
'drink_type' => $drinkType,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Route(path: '/{id}/edit', name: 'app_drink_type_edit', methods: ['GET', 'POST'])]
|
||||||
|
public function edit(Request $request, DrinkType $drinkType, EntityManagerInterface $entityManager): Response
|
||||||
|
{
|
||||||
|
$form = $this->createForm(DrinkTypeForm::class, $drinkType);
|
||||||
|
$form->handleRequest($request);
|
||||||
|
|
||||||
|
if ($form->isSubmitted() && $form->isValid()) {
|
||||||
|
$entityManager->flush();
|
||||||
|
|
||||||
|
return $this->redirectToRoute('app_drink_type_index', [], Response::HTTP_SEE_OTHER);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->render('drink_type/edit.html.twig', [
|
||||||
|
'drink_type' => $drinkType,
|
||||||
|
'form' => $form,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Route(path: '/{id}', name: 'app_drink_type_delete', methods: ['POST'])]
|
||||||
|
public function delete(Request $request, DrinkType $drinkType, EntityManagerInterface $entityManager): Response
|
||||||
|
{
|
||||||
|
if ($this->isCsrfTokenValid('delete' . $drinkType->getId(), $request->getPayload()->getString('_token'))) {
|
||||||
|
$entityManager->remove($drinkType);
|
||||||
|
$entityManager->flush();
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->redirectToRoute('app_drink_type_index', [], Response::HTTP_SEE_OTHER);
|
||||||
|
}
|
||||||
|
}
|
27
src/Controller/Index.php
Normal file
27
src/Controller/Index.php
Normal file
|
@ -0,0 +1,27 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Controller;
|
||||||
|
|
||||||
|
use App\Service\InventoryService;
|
||||||
|
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||||
|
use Symfony\Component\HttpFoundation\Response;
|
||||||
|
use Symfony\Component\Routing\Attribute\Route;
|
||||||
|
|
||||||
|
#[Route(path: '/', name: 'app_index')]
|
||||||
|
final class Index extends AbstractController
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly InventoryService $inventoryService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function __invoke(): Response
|
||||||
|
{
|
||||||
|
$drinkStocks = $this->inventoryService->getAllDrinkTypesWithStockLevels();
|
||||||
|
|
||||||
|
return $this->render('index.html.twig', [
|
||||||
|
'drinkStocks' => $drinkStocks,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
0
src/Entity/.gitignore
vendored
Normal file
0
src/Entity/.gitignore
vendored
Normal file
115
src/Entity/DrinkType.php
Normal file
115
src/Entity/DrinkType.php
Normal file
|
@ -0,0 +1,115 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Entity;
|
||||||
|
|
||||||
|
use App\Repository\DrinkTypeRepository;
|
||||||
|
use DateTimeImmutable;
|
||||||
|
use Doctrine\Common\Collections\ArrayCollection;
|
||||||
|
use Doctrine\Common\Collections\Collection;
|
||||||
|
use Doctrine\ORM\Mapping as ORM;
|
||||||
|
|
||||||
|
#[ORM\Entity(repositoryClass: DrinkTypeRepository::class)]
|
||||||
|
#[ORM\Table(name: 'drink_type')]
|
||||||
|
class DrinkType
|
||||||
|
{
|
||||||
|
#[ORM\Id]
|
||||||
|
#[ORM\GeneratedValue]
|
||||||
|
#[ORM\Column(type: 'integer')]
|
||||||
|
private null|int $id = null;
|
||||||
|
|
||||||
|
#[ORM\Column(type: 'datetime_immutable')]
|
||||||
|
private readonly DateTimeImmutable $createdAt;
|
||||||
|
|
||||||
|
#[ORM\Column(type: 'datetime_immutable')]
|
||||||
|
private DateTimeImmutable $updatedAt;
|
||||||
|
|
||||||
|
#[ORM\OneToMany(mappedBy: 'drinkType', targetEntity: InventoryRecord::class)]
|
||||||
|
private Collection $inventoryRecords;
|
||||||
|
|
||||||
|
#[ORM\OneToMany(mappedBy: 'drinkType', targetEntity: OrderItem::class)]
|
||||||
|
private Collection $orderItems;
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
#[ORM\Column(type: 'string', length: 255, unique: true)]
|
||||||
|
private string $name,
|
||||||
|
#[ORM\Column(type: 'text', nullable: true)]
|
||||||
|
private null|string $description = null,
|
||||||
|
#[ORM\Column(type: 'integer')]
|
||||||
|
private int $desiredStock = 10,
|
||||||
|
null|DateTimeImmutable $createdAt = null,
|
||||||
|
null|DateTimeImmutable $updatedAt = null,
|
||||||
|
) {
|
||||||
|
$this->createdAt = $createdAt ?? new DateTimeImmutable();
|
||||||
|
$this->updatedAt = $updatedAt ?? new DateTimeImmutable();
|
||||||
|
$this->inventoryRecords = new ArrayCollection();
|
||||||
|
$this->orderItems = new ArrayCollection();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getId(): null|int
|
||||||
|
{
|
||||||
|
return $this->id;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getName(): string
|
||||||
|
{
|
||||||
|
return $this->name;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setName(string $name): self
|
||||||
|
{
|
||||||
|
$this->name = $name;
|
||||||
|
$this->updateTimestamp();
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getDescription(): null|string
|
||||||
|
{
|
||||||
|
return $this->description;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setDescription(null|string $description): self
|
||||||
|
{
|
||||||
|
$this->description = $description;
|
||||||
|
$this->updateTimestamp();
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getDesiredStock(): int
|
||||||
|
{
|
||||||
|
return $this->desiredStock;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setDesiredStock(int $desiredStock): self
|
||||||
|
{
|
||||||
|
$this->desiredStock = $desiredStock;
|
||||||
|
$this->updateTimestamp();
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getCreatedAt(): DateTimeImmutable
|
||||||
|
{
|
||||||
|
return $this->createdAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getUpdatedAt(): DateTimeImmutable
|
||||||
|
{
|
||||||
|
return $this->updatedAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getInventoryRecords(): Collection
|
||||||
|
{
|
||||||
|
return $this->inventoryRecords;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getOrderItems(): Collection
|
||||||
|
{
|
||||||
|
return $this->orderItems;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function updateTimestamp(): void
|
||||||
|
{
|
||||||
|
$this->updatedAt = new DateTimeImmutable();
|
||||||
|
}
|
||||||
|
}
|
89
src/Entity/InventoryRecord.php
Normal file
89
src/Entity/InventoryRecord.php
Normal file
|
@ -0,0 +1,89 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Entity;
|
||||||
|
|
||||||
|
use App\Repository\InventoryRecordRepository;
|
||||||
|
use DateTimeImmutable;
|
||||||
|
use Doctrine\ORM\Mapping as ORM;
|
||||||
|
|
||||||
|
#[ORM\Entity(repositoryClass: InventoryRecordRepository::class)]
|
||||||
|
#[ORM\Table(name: 'inventory_record')]
|
||||||
|
class InventoryRecord
|
||||||
|
{
|
||||||
|
#[ORM\Id]
|
||||||
|
#[ORM\GeneratedValue]
|
||||||
|
#[ORM\Column(type: 'integer')]
|
||||||
|
private null|int $id = null;
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
#[ORM\ManyToOne(targetEntity: DrinkType::class, inversedBy: 'inventoryRecords')]
|
||||||
|
#[ORM\JoinColumn(name: 'drink_type_id', referencedColumnName: 'id', nullable: false)]
|
||||||
|
private DrinkType $drinkType,
|
||||||
|
#[ORM\Column(type: 'integer')]
|
||||||
|
private int $quantity,
|
||||||
|
#[ORM\Column(type: 'datetime_immutable')]
|
||||||
|
private DateTimeImmutable $timestamp = new DateTimeImmutable(),
|
||||||
|
#[ORM\Column(type: 'datetime_immutable')]
|
||||||
|
private DateTimeImmutable $createdAt = new DateTimeImmutable(),
|
||||||
|
#[ORM\Column(type: 'datetime_immutable')]
|
||||||
|
private DateTimeImmutable $updatedAt = new DateTimeImmutable(),
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function getId(): null|int
|
||||||
|
{
|
||||||
|
return $this->id;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getDrinkType(): DrinkType
|
||||||
|
{
|
||||||
|
return $this->drinkType;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setDrinkType(DrinkType $drinkType): self
|
||||||
|
{
|
||||||
|
$this->drinkType = $drinkType;
|
||||||
|
$this->updateTimestamp();
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getQuantity(): int
|
||||||
|
{
|
||||||
|
return $this->quantity;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setQuantity(int $quantity): self
|
||||||
|
{
|
||||||
|
$this->quantity = $quantity;
|
||||||
|
$this->updateTimestamp();
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getTimestamp(): DateTimeImmutable
|
||||||
|
{
|
||||||
|
return $this->timestamp;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setTimestamp(DateTimeImmutable $timestamp): self
|
||||||
|
{
|
||||||
|
$this->timestamp = $timestamp;
|
||||||
|
$this->updateTimestamp();
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getCreatedAt(): DateTimeImmutable
|
||||||
|
{
|
||||||
|
return $this->createdAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getUpdatedAt(): DateTimeImmutable
|
||||||
|
{
|
||||||
|
return $this->updatedAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function updateTimestamp(): void
|
||||||
|
{
|
||||||
|
$this->updatedAt = new DateTimeImmutable();
|
||||||
|
}
|
||||||
|
}
|
104
src/Entity/Order.php
Normal file
104
src/Entity/Order.php
Normal file
|
@ -0,0 +1,104 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Entity;
|
||||||
|
|
||||||
|
use App\Enum\OrderStatus;
|
||||||
|
use App\Repository\OrderRepository;
|
||||||
|
use DateTimeImmutable;
|
||||||
|
use Doctrine\Common\Collections\ArrayCollection;
|
||||||
|
use Doctrine\Common\Collections\Collection;
|
||||||
|
use Doctrine\ORM\Mapping as ORM;
|
||||||
|
|
||||||
|
#[ORM\Entity(repositoryClass: OrderRepository::class)]
|
||||||
|
#[ORM\Table(name: '`order`')] // 'order' is a reserved keyword in SQL, so we escape it
|
||||||
|
class Order
|
||||||
|
{
|
||||||
|
#[ORM\Id]
|
||||||
|
#[ORM\GeneratedValue]
|
||||||
|
#[ORM\Column(type: 'integer')]
|
||||||
|
private null|int $id = null;
|
||||||
|
|
||||||
|
#[ORM\Column(type: 'datetime_immutable')]
|
||||||
|
private readonly DateTimeImmutable $createdAt;
|
||||||
|
|
||||||
|
#[ORM\Column(type: 'datetime_immutable')]
|
||||||
|
private DateTimeImmutable $updatedAt;
|
||||||
|
|
||||||
|
#[ORM\OneToMany(mappedBy: 'order', targetEntity: OrderItem::class, cascade: ['persist', 'remove'])]
|
||||||
|
private Collection $orderItems;
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
#[ORM\Column(nullable: false, enumType: OrderStatus::class, options: [
|
||||||
|
'default' => OrderStatus::NEW,
|
||||||
|
])]
|
||||||
|
private OrderStatus $status = OrderStatus::NEW,
|
||||||
|
null|DateTimeImmutable $createdAt = null,
|
||||||
|
null|DateTimeImmutable $updatedAt = null,
|
||||||
|
) {
|
||||||
|
$this->createdAt = $createdAt ?? new DateTimeImmutable();
|
||||||
|
$this->updatedAt = $updatedAt ?? new DateTimeImmutable();
|
||||||
|
$this->orderItems = new ArrayCollection();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getId(): null|int
|
||||||
|
{
|
||||||
|
return $this->id;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getStatus(): OrderStatus
|
||||||
|
{
|
||||||
|
return $this->status;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setStatus(OrderStatus $status): self
|
||||||
|
{
|
||||||
|
$this->status = $status;
|
||||||
|
$this->updateTimestamp();
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getCreatedAt(): DateTimeImmutable
|
||||||
|
{
|
||||||
|
return $this->createdAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getUpdatedAt(): DateTimeImmutable
|
||||||
|
{
|
||||||
|
return $this->updatedAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return Collection<int, OrderItem>
|
||||||
|
*/
|
||||||
|
public function getOrderItems(): Collection
|
||||||
|
{
|
||||||
|
return $this->orderItems;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function addOrderItem(OrderItem $orderItem): self
|
||||||
|
{
|
||||||
|
if (!$this->orderItems->contains($orderItem)) {
|
||||||
|
$this->orderItems[] = $orderItem;
|
||||||
|
$orderItem->setOrder($this);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function removeOrderItem(OrderItem $orderItem): self
|
||||||
|
{
|
||||||
|
// set the owning side to null (unless already changed)
|
||||||
|
if ($this->orderItems->removeElement($orderItem) && $orderItem->getOrder() === $this) {
|
||||||
|
$orderItem->setOrder(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function updateTimestamp(): void
|
||||||
|
{
|
||||||
|
$this->updatedAt = new DateTimeImmutable();
|
||||||
|
}
|
||||||
|
}
|
114
src/Entity/OrderItem.php
Normal file
114
src/Entity/OrderItem.php
Normal file
|
@ -0,0 +1,114 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Entity;
|
||||||
|
|
||||||
|
use App\Repository\OrderItemRepository;
|
||||||
|
use DateTimeImmutable;
|
||||||
|
use Doctrine\ORM\Mapping as ORM;
|
||||||
|
|
||||||
|
#[ORM\Entity(repositoryClass: OrderItemRepository::class)]
|
||||||
|
#[ORM\Table(name: 'order_item')]
|
||||||
|
class OrderItem
|
||||||
|
{
|
||||||
|
#[ORM\Id]
|
||||||
|
#[ORM\GeneratedValue]
|
||||||
|
#[ORM\Column(type: 'integer')]
|
||||||
|
private null|int $id = null;
|
||||||
|
|
||||||
|
#[ORM\Column(type: 'datetime_immutable')]
|
||||||
|
private readonly DateTimeImmutable $createdAt;
|
||||||
|
|
||||||
|
#[ORM\Column(type: 'datetime_immutable')]
|
||||||
|
private DateTimeImmutable $updatedAt;
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
#[ORM\ManyToOne(targetEntity: DrinkType::class, inversedBy: 'orderItems')]
|
||||||
|
#[ORM\JoinColumn(name: 'drink_type_id', referencedColumnName: 'id', nullable: false)]
|
||||||
|
private DrinkType $drinkType,
|
||||||
|
#[ORM\Column(type: 'integer')]
|
||||||
|
private int $quantity,
|
||||||
|
#[ORM\ManyToOne(targetEntity: Order::class, inversedBy: 'orderItems')]
|
||||||
|
#[ORM\JoinColumn(name: 'order_id', referencedColumnName: 'id', nullable: false)]
|
||||||
|
private null|Order $order = null,
|
||||||
|
null|DateTimeImmutable $createdAt = null,
|
||||||
|
null|DateTimeImmutable $updatedAt = null,
|
||||||
|
) {
|
||||||
|
$this->createdAt = $createdAt ?? new DateTimeImmutable();
|
||||||
|
$this->updatedAt = $updatedAt ?? new DateTimeImmutable();
|
||||||
|
|
||||||
|
// Establish bidirectional relationship
|
||||||
|
if ($this->order instanceof Order) {
|
||||||
|
$this->order->addOrderItem($this);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getId(): null|int
|
||||||
|
{
|
||||||
|
return $this->id;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getOrder(): null|Order
|
||||||
|
{
|
||||||
|
return $this->order;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setOrder(null|Order $order): self
|
||||||
|
{
|
||||||
|
// Remove from old order if exists
|
||||||
|
if ($this->order instanceof Order && $this->order !== $order) {
|
||||||
|
$this->order->removeOrderItem($this);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set new order
|
||||||
|
$this->order = $order;
|
||||||
|
|
||||||
|
// Add to new order if not null
|
||||||
|
if ($order instanceof Order && !$order->getOrderItems()->contains($this)) {
|
||||||
|
$order->addOrderItem($this);
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->updateTimestamp();
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getDrinkType(): DrinkType
|
||||||
|
{
|
||||||
|
return $this->drinkType;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setDrinkType(DrinkType $drinkType): self
|
||||||
|
{
|
||||||
|
$this->drinkType = $drinkType;
|
||||||
|
$this->updateTimestamp();
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getQuantity(): int
|
||||||
|
{
|
||||||
|
return $this->quantity;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setQuantity(int $quantity): self
|
||||||
|
{
|
||||||
|
$this->quantity = $quantity;
|
||||||
|
$this->updateTimestamp();
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getCreatedAt(): DateTimeImmutable
|
||||||
|
{
|
||||||
|
return $this->createdAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getUpdatedAt(): DateTimeImmutable
|
||||||
|
{
|
||||||
|
return $this->updatedAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function updateTimestamp(): void
|
||||||
|
{
|
||||||
|
$this->updatedAt = new DateTimeImmutable();
|
||||||
|
}
|
||||||
|
}
|
89
src/Entity/SystemConfig.php
Normal file
89
src/Entity/SystemConfig.php
Normal file
|
@ -0,0 +1,89 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Entity;
|
||||||
|
|
||||||
|
use App\Enum\SystemSettingKey;
|
||||||
|
use App\Repository\SystemConfigRepository;
|
||||||
|
use DateTimeImmutable;
|
||||||
|
use Doctrine\ORM\Mapping as ORM;
|
||||||
|
|
||||||
|
#[ORM\Entity(repositoryClass: SystemConfigRepository::class)]
|
||||||
|
#[ORM\Table(name: 'system_config')]
|
||||||
|
class SystemConfig
|
||||||
|
{
|
||||||
|
public const DEFAULT_STOCK_ADJUSTMENT_LOOKBACK_ORDERS = '3';
|
||||||
|
public const DEFAULT_DEFAULT_DESIRED_STOCK = '2';
|
||||||
|
public const DEFAULT_SYSTEM_NAME = 'Zaufen';
|
||||||
|
public const DEFAULT_STOCK_INCREASE_AMOUNT = '1';
|
||||||
|
public const DEFAULT_STOCK_DECREASE_AMOUNT = '1';
|
||||||
|
public const DEFAULT_STOCK_LOW_MULTIPLIER = '0.3';
|
||||||
|
|
||||||
|
#[ORM\Id]
|
||||||
|
#[ORM\GeneratedValue]
|
||||||
|
#[ORM\Column(type: 'integer')]
|
||||||
|
private null|int $id = null;
|
||||||
|
|
||||||
|
#[ORM\Column(type: 'datetime_immutable')]
|
||||||
|
private readonly DateTimeImmutable $createdAt;
|
||||||
|
|
||||||
|
#[ORM\Column(type: 'datetime_immutable')]
|
||||||
|
private DateTimeImmutable $updatedAt;
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
#[ORM\Column(unique: true, enumType: SystemSettingKey::class)]
|
||||||
|
private SystemSettingKey $key,
|
||||||
|
#[ORM\Column(type: 'text')]
|
||||||
|
private string $value,
|
||||||
|
null|DateTimeImmutable $createdAt = null,
|
||||||
|
null|DateTimeImmutable $updatedAt = null,
|
||||||
|
) {
|
||||||
|
$this->createdAt = $createdAt ?? new DateTimeImmutable();
|
||||||
|
$this->updatedAt = $updatedAt ?? new DateTimeImmutable();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getId(): null|int
|
||||||
|
{
|
||||||
|
return $this->id;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getKey(): SystemSettingKey
|
||||||
|
{
|
||||||
|
return $this->key;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setKey(SystemSettingKey $key): self
|
||||||
|
{
|
||||||
|
$this->key = $key;
|
||||||
|
$this->updateTimestamp();
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getValue(): string
|
||||||
|
{
|
||||||
|
return $this->value;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setValue(string $value): self
|
||||||
|
{
|
||||||
|
$this->value = $value;
|
||||||
|
$this->updateTimestamp();
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getCreatedAt(): DateTimeImmutable
|
||||||
|
{
|
||||||
|
return $this->createdAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getUpdatedAt(): DateTimeImmutable
|
||||||
|
{
|
||||||
|
return $this->updatedAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function updateTimestamp(): void
|
||||||
|
{
|
||||||
|
$this->updatedAt = new DateTimeImmutable();
|
||||||
|
}
|
||||||
|
}
|
14
src/Enum/OrderStatus.php
Normal file
14
src/Enum/OrderStatus.php
Normal file
|
@ -0,0 +1,14 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Enum;
|
||||||
|
|
||||||
|
enum OrderStatus: string
|
||||||
|
{
|
||||||
|
case NEW = 'new';
|
||||||
|
case ORDERED = 'ordered';
|
||||||
|
case FULFILLED = 'fulfilled';
|
||||||
|
case IN_WORK = 'in_work';
|
||||||
|
case CANCELLED = 'cancelled';
|
||||||
|
}
|
13
src/Enum/StockState.php
Normal file
13
src/Enum/StockState.php
Normal file
|
@ -0,0 +1,13 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Enum;
|
||||||
|
|
||||||
|
enum StockState: string
|
||||||
|
{
|
||||||
|
case CRITICAL = 'critical';
|
||||||
|
case LOW = 'low';
|
||||||
|
case NORMAL = 'normal';
|
||||||
|
case HIGH = 'high';
|
||||||
|
}
|
32
src/Enum/SystemSettingKey.php
Normal file
32
src/Enum/SystemSettingKey.php
Normal file
|
@ -0,0 +1,32 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Enum;
|
||||||
|
|
||||||
|
use App\Entity\SystemConfig;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Enum for system setting keys
|
||||||
|
*/
|
||||||
|
enum SystemSettingKey: string
|
||||||
|
{
|
||||||
|
case STOCK_ADJUSTMENT_LOOKBACK_ORDERS = 'stock_adjustment_lookback_orders';
|
||||||
|
case DEFAULT_DESIRED_STOCK = 'default_desired_stock';
|
||||||
|
case SYSTEM_NAME = 'system_name';
|
||||||
|
case STOCK_INCREASE_AMOUNT = 'stock_increase_amount';
|
||||||
|
case STOCK_DECREASE_AMOUNT = 'stock_decrease_amount';
|
||||||
|
case STOCK_LOW_MULTIPLIER = 'stock_low_multiplier';
|
||||||
|
|
||||||
|
public static function getDefaultValue(self $key): string
|
||||||
|
{
|
||||||
|
return match ($key) {
|
||||||
|
self::STOCK_ADJUSTMENT_LOOKBACK_ORDERS => SystemConfig::DEFAULT_STOCK_ADJUSTMENT_LOOKBACK_ORDERS,
|
||||||
|
self::DEFAULT_DESIRED_STOCK => SystemConfig::DEFAULT_DEFAULT_DESIRED_STOCK,
|
||||||
|
self::SYSTEM_NAME => SystemConfig::DEFAULT_SYSTEM_NAME,
|
||||||
|
self::STOCK_INCREASE_AMOUNT => SystemConfig::DEFAULT_STOCK_INCREASE_AMOUNT,
|
||||||
|
self::STOCK_DECREASE_AMOUNT => SystemConfig::DEFAULT_STOCK_DECREASE_AMOUNT,
|
||||||
|
self::STOCK_LOW_MULTIPLIER => SystemConfig::DEFAULT_STOCK_LOW_MULTIPLIER,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
25
src/Form/DrinkTypeForm.php
Normal file
25
src/Form/DrinkTypeForm.php
Normal file
|
@ -0,0 +1,25 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Form;
|
||||||
|
|
||||||
|
use App\Entity\DrinkType;
|
||||||
|
use Symfony\Component\Form\AbstractType;
|
||||||
|
use Symfony\Component\Form\FormBuilderInterface;
|
||||||
|
use Symfony\Component\OptionsResolver\OptionsResolver;
|
||||||
|
|
||||||
|
class DrinkTypeForm extends AbstractType
|
||||||
|
{
|
||||||
|
public function buildForm(FormBuilderInterface $builder, array $options): void
|
||||||
|
{
|
||||||
|
$builder->add('name')->add('description')->add('desiredStock');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function configureOptions(OptionsResolver $resolver): void
|
||||||
|
{
|
||||||
|
$resolver->setDefaults([
|
||||||
|
'data_class' => DrinkType::class,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
|
@ -1,5 +1,7 @@
|
||||||
<?php
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
namespace App;
|
namespace App;
|
||||||
|
|
||||||
use Symfony\Bundle\FrameworkBundle\Kernel\MicroKernelTrait;
|
use Symfony\Bundle\FrameworkBundle\Kernel\MicroKernelTrait;
|
||||||
|
|
0
src/Repository/.gitignore
vendored
Normal file
0
src/Repository/.gitignore
vendored
Normal file
33
src/Repository/AbstractRepository.php
Normal file
33
src/Repository/AbstractRepository.php
Normal file
|
@ -0,0 +1,33 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Repository;
|
||||||
|
|
||||||
|
use Doctrine\ORM\EntityRepository;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @template E of object
|
||||||
|
* @extends EntityRepository<E>
|
||||||
|
*/
|
||||||
|
abstract class AbstractRepository extends EntityRepository
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @param E $entity
|
||||||
|
* @return void
|
||||||
|
*/
|
||||||
|
public function save(object $entity): void
|
||||||
|
{
|
||||||
|
$this->getEntityManager()->persist($entity);
|
||||||
|
$this->getEntityManager()->flush();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param E $entity
|
||||||
|
*/
|
||||||
|
public function remove(object $entity): void
|
||||||
|
{
|
||||||
|
$this->getEntityManager()->remove($entity);
|
||||||
|
$this->getEntityManager()->flush();
|
||||||
|
}
|
||||||
|
}
|
48
src/Repository/DrinkTypeRepository.php
Normal file
48
src/Repository/DrinkTypeRepository.php
Normal file
|
@ -0,0 +1,48 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Repository;
|
||||||
|
|
||||||
|
use App\Entity\DrinkType;
|
||||||
|
use Doctrine\ORM\EntityManagerInterface;
|
||||||
|
use Override;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @extends AbstractRepository<DrinkType>
|
||||||
|
*/
|
||||||
|
class DrinkTypeRepository extends AbstractRepository
|
||||||
|
{
|
||||||
|
public function __construct(EntityManagerInterface $entityManager)
|
||||||
|
{
|
||||||
|
parent::__construct($entityManager, $entityManager->getClassMetadata(DrinkType::class));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @inheritDoc */
|
||||||
|
#[Override]
|
||||||
|
public function findAll(): array
|
||||||
|
{
|
||||||
|
return parent::findBy(
|
||||||
|
criteria: [],
|
||||||
|
orderBy: [
|
||||||
|
'desiredStock' => 'DESC',
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return DrinkType[] */
|
||||||
|
public function findDesired(): array
|
||||||
|
{
|
||||||
|
$qb = $this->getEntityManager()->createQueryBuilder();
|
||||||
|
$qb
|
||||||
|
->select('d')
|
||||||
|
->from(DrinkType::class, 'd')
|
||||||
|
->where('d.desiredStock > 0')
|
||||||
|
->orderBy('d.desiredStock', 'DESC')
|
||||||
|
->addOrderBy('d.name', 'ASC');
|
||||||
|
|
||||||
|
/** @var array<int, DrinkType> $result */
|
||||||
|
$result = $qb->getQuery()->getResult();
|
||||||
|
return $result;
|
||||||
|
}
|
||||||
|
}
|
71
src/Repository/InventoryRecordRepository.php
Normal file
71
src/Repository/InventoryRecordRepository.php
Normal file
|
@ -0,0 +1,71 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Repository;
|
||||||
|
|
||||||
|
use App\Entity\DrinkType;
|
||||||
|
use App\Entity\InventoryRecord;
|
||||||
|
use DateTimeImmutable;
|
||||||
|
use Doctrine\ORM\EntityManagerInterface;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @extends AbstractRepository<InventoryRecord>
|
||||||
|
*/
|
||||||
|
class InventoryRecordRepository extends AbstractRepository
|
||||||
|
{
|
||||||
|
public function __construct(EntityManagerInterface $entityManager)
|
||||||
|
{
|
||||||
|
parent::__construct($entityManager, $entityManager->getClassMetadata(InventoryRecord::class));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<int, InventoryRecord>
|
||||||
|
*/
|
||||||
|
public function findByDrinkType(DrinkType $drinkType): array
|
||||||
|
{
|
||||||
|
return $this->findBy(
|
||||||
|
[
|
||||||
|
'drinkType' => $drinkType,
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'timestamp' => 'DESC',
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function findLatestByDrinkType(DrinkType $drinkType): null|InventoryRecord
|
||||||
|
{
|
||||||
|
$records = $this->findBy(
|
||||||
|
[
|
||||||
|
'drinkType' => $drinkType,
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'timestamp' => 'DESC',
|
||||||
|
],
|
||||||
|
1,
|
||||||
|
);
|
||||||
|
|
||||||
|
return $records[0] ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<int, InventoryRecord>
|
||||||
|
*/
|
||||||
|
public function findByTimestampRange(DateTimeImmutable $start, DateTimeImmutable $end): array
|
||||||
|
{
|
||||||
|
$qb = $this->getEntityManager()->createQueryBuilder();
|
||||||
|
$qb
|
||||||
|
->select('ir')
|
||||||
|
->from(InventoryRecord::class, 'ir')
|
||||||
|
->where('ir.timestamp >= :start')
|
||||||
|
->andWhere('ir.timestamp <= :end')
|
||||||
|
->setParameter('start', $start)
|
||||||
|
->setParameter('end', $end)
|
||||||
|
->orderBy('ir.timestamp', 'DESC');
|
||||||
|
|
||||||
|
/** @var array<int, InventoryRecord> $result */
|
||||||
|
$result = $qb->getQuery()->getResult();
|
||||||
|
return $result;
|
||||||
|
}
|
||||||
|
}
|
49
src/Repository/OrderItemRepository.php
Normal file
49
src/Repository/OrderItemRepository.php
Normal file
|
@ -0,0 +1,49 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Repository;
|
||||||
|
|
||||||
|
use App\Entity\DrinkType;
|
||||||
|
use App\Entity\Order;
|
||||||
|
use App\Entity\OrderItem;
|
||||||
|
use Doctrine\ORM\EntityManagerInterface;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @extends AbstractRepository<OrderItem>
|
||||||
|
*/
|
||||||
|
class OrderItemRepository extends AbstractRepository
|
||||||
|
{
|
||||||
|
public function __construct(EntityManagerInterface $entityManager)
|
||||||
|
{
|
||||||
|
parent::__construct($entityManager, $entityManager->getClassMetadata(OrderItem::class));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<int, OrderItem>
|
||||||
|
*/
|
||||||
|
public function findByOrder(Order $order): array
|
||||||
|
{
|
||||||
|
return $this->findBy([
|
||||||
|
'order' => $order,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<int, OrderItem>
|
||||||
|
*/
|
||||||
|
public function findByDrinkType(DrinkType $drinkType): array
|
||||||
|
{
|
||||||
|
return $this->findBy([
|
||||||
|
'drinkType' => $drinkType,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function findByOrderAndDrinkType(Order $order, DrinkType $drinkType): null|OrderItem
|
||||||
|
{
|
||||||
|
return $this->findOneBy([
|
||||||
|
'order' => $order,
|
||||||
|
'drinkType' => $drinkType,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
102
src/Repository/OrderRepository.php
Normal file
102
src/Repository/OrderRepository.php
Normal file
|
@ -0,0 +1,102 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Repository;
|
||||||
|
|
||||||
|
use App\Entity\DrinkType;
|
||||||
|
use App\Entity\Order;
|
||||||
|
use App\Enum\OrderStatus;
|
||||||
|
use DateTimeImmutable;
|
||||||
|
use Doctrine\ORM\EntityManagerInterface;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @extends AbstractRepository<Order>
|
||||||
|
*/
|
||||||
|
class OrderRepository extends AbstractRepository
|
||||||
|
{
|
||||||
|
public function __construct(EntityManagerInterface $entityManager)
|
||||||
|
{
|
||||||
|
parent::__construct($entityManager, $entityManager->getClassMetadata(Order::class));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<int, Order>
|
||||||
|
*/
|
||||||
|
public function findByStatus(OrderStatus $status): array
|
||||||
|
{
|
||||||
|
return $this->findBy(
|
||||||
|
[
|
||||||
|
'status' => $status,
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'createdAt' => 'DESC',
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<OrderStatus> $stati
|
||||||
|
* @return array<int, Order>
|
||||||
|
*/
|
||||||
|
public function findByMultipleStatus(array $stati): array
|
||||||
|
{
|
||||||
|
$qb = $this->getEntityManager()->createQueryBuilder();
|
||||||
|
$qb
|
||||||
|
->select('o')
|
||||||
|
->from(Order::class, 'o')
|
||||||
|
->where('o.status IN (:stati)')
|
||||||
|
->setParameter('stati', $stati)
|
||||||
|
->orderBy('o.createdAt', 'DESC');
|
||||||
|
|
||||||
|
/** @var array<int, Order> $result */
|
||||||
|
$result = $qb->getQuery()->getResult();
|
||||||
|
return $result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<int, Order>
|
||||||
|
*/
|
||||||
|
public function findByDateRange(DateTimeImmutable $start, DateTimeImmutable $end): array
|
||||||
|
{
|
||||||
|
$qb = $this->getEntityManager()->createQueryBuilder();
|
||||||
|
$qb
|
||||||
|
->select('o')
|
||||||
|
->from(Order::class, 'o')
|
||||||
|
->where('o.createdAt >= :start')
|
||||||
|
->andWhere('o.createdAt <= :end')
|
||||||
|
->setParameter('start', $start)
|
||||||
|
->setParameter('end', $end)
|
||||||
|
->orderBy('o.createdAt', 'DESC');
|
||||||
|
|
||||||
|
/** @var array<int, Order> $result */
|
||||||
|
$result = $qb->getQuery()->getResult();
|
||||||
|
return $result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find the last N orders that contain a specific drink type
|
||||||
|
*
|
||||||
|
* @param DrinkType $drinkType The drink type to search for
|
||||||
|
* @param int $limit The maximum number of orders to return
|
||||||
|
* @return array<int, Order> The last N orders containing the drink type, ordered by creation date (newest first)
|
||||||
|
*/
|
||||||
|
public function findLastOrdersForDrinkType(DrinkType $drinkType, int $limit = 5): array
|
||||||
|
{
|
||||||
|
$qb = $this->getEntityManager()->createQueryBuilder();
|
||||||
|
$qb
|
||||||
|
->select('o')
|
||||||
|
->from(Order::class, 'o')
|
||||||
|
->join('o.orderItems', 'oi')
|
||||||
|
->where('oi.drinkType = :drinkType')
|
||||||
|
->andWhere('o.status = :status') // Only consider fulfilled orders
|
||||||
|
->setParameter('drinkType', $drinkType)
|
||||||
|
->setParameter('status', OrderStatus::FULFILLED->value)
|
||||||
|
->orderBy('o.createdAt', 'DESC')
|
||||||
|
->setMaxResults($limit);
|
||||||
|
|
||||||
|
/** @var array<int, Order> $result */
|
||||||
|
$result = $qb->getQuery()->getResult();
|
||||||
|
return $result;
|
||||||
|
}
|
||||||
|
}
|
50
src/Repository/SystemConfigRepository.php
Normal file
50
src/Repository/SystemConfigRepository.php
Normal file
|
@ -0,0 +1,50 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Repository;
|
||||||
|
|
||||||
|
use App\Entity\SystemConfig;
|
||||||
|
use App\Enum\SystemSettingKey;
|
||||||
|
use Doctrine\ORM\EntityManagerInterface;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @extends AbstractRepository<SystemConfig>
|
||||||
|
*/
|
||||||
|
class SystemConfigRepository extends AbstractRepository
|
||||||
|
{
|
||||||
|
public function __construct(EntityManagerInterface $entityManager)
|
||||||
|
{
|
||||||
|
parent::__construct($entityManager, $entityManager->getClassMetadata(SystemConfig::class));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function findByKey(SystemSettingKey $key): SystemConfig
|
||||||
|
{
|
||||||
|
$config = $this->findOneBy([
|
||||||
|
'key' => $key->value,
|
||||||
|
]);
|
||||||
|
if (!($config instanceof SystemConfig)) {
|
||||||
|
$config = new SystemConfig($key, SystemSettingKey::getDefaultValue($key));
|
||||||
|
$this->save($config);
|
||||||
|
}
|
||||||
|
return $config;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getValue(SystemSettingKey $key, string $default = ''): string
|
||||||
|
{
|
||||||
|
$config = $this->findByKey($key);
|
||||||
|
return ($config instanceof SystemConfig) ? $config->getValue() : $default;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setValue(SystemSettingKey $key, string $value): void
|
||||||
|
{
|
||||||
|
$config = $this->findByKey($key);
|
||||||
|
|
||||||
|
if ($config instanceof SystemConfig) {
|
||||||
|
$config->setValue($value);
|
||||||
|
} else {
|
||||||
|
$config = new SystemConfig($key, $value);
|
||||||
|
}
|
||||||
|
$this->save($config);
|
||||||
|
}
|
||||||
|
}
|
22
src/Service/Config/AppName.php
Normal file
22
src/Service/Config/AppName.php
Normal file
|
@ -0,0 +1,22 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Service\Config;
|
||||||
|
|
||||||
|
use App\Entity\SystemConfig;
|
||||||
|
use App\Enum\SystemSettingKey;
|
||||||
|
use App\Service\ConfigurationService;
|
||||||
|
use Stringable;
|
||||||
|
|
||||||
|
final readonly class AppName implements Stringable
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private ConfigurationService $configService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function __toString(): string
|
||||||
|
{
|
||||||
|
return $this->configService->getConfigValue(SystemSettingKey::SYSTEM_NAME, SystemConfig::DEFAULT_SYSTEM_NAME);
|
||||||
|
}
|
||||||
|
}
|
26
src/Service/Config/LowStockMultiplier.php
Normal file
26
src/Service/Config/LowStockMultiplier.php
Normal file
|
@ -0,0 +1,26 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Service\Config;
|
||||||
|
|
||||||
|
use App\Entity\SystemConfig;
|
||||||
|
use App\Enum\SystemSettingKey;
|
||||||
|
use App\Service\ConfigurationService;
|
||||||
|
|
||||||
|
final readonly class LowStockMultiplier
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private ConfigurationService $configService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function getValue(): float
|
||||||
|
{
|
||||||
|
$value = $this->configService->getConfigValue(
|
||||||
|
SystemSettingKey::STOCK_LOW_MULTIPLIER,
|
||||||
|
SystemConfig::DEFAULT_STOCK_LOW_MULTIPLIER,
|
||||||
|
);
|
||||||
|
|
||||||
|
return (float) $value;
|
||||||
|
}
|
||||||
|
}
|
76
src/Service/ConfigurationService.php
Normal file
76
src/Service/ConfigurationService.php
Normal file
|
@ -0,0 +1,76 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Service;
|
||||||
|
|
||||||
|
use App\Entity\SystemConfig;
|
||||||
|
use App\Enum\SystemSettingKey;
|
||||||
|
use App\Repository\SystemConfigRepository;
|
||||||
|
use InvalidArgumentException;
|
||||||
|
|
||||||
|
readonly class ConfigurationService
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private SystemConfigRepository $systemConfigRepository,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all configuration entries
|
||||||
|
*
|
||||||
|
* @return SystemConfig[]
|
||||||
|
*/
|
||||||
|
public function getAllConfigs(): array
|
||||||
|
{
|
||||||
|
return $this->systemConfigRepository->findAll();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getConfigValue(SystemSettingKey $key, string $default = ''): string
|
||||||
|
{
|
||||||
|
return $this->systemConfigRepository->getValue($key, $default);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setConfigValue(SystemSettingKey $key, string $value): void
|
||||||
|
{
|
||||||
|
$this->systemConfigRepository->setValue($key, $value);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getConfigByKey(SystemSettingKey $key): SystemConfig
|
||||||
|
{
|
||||||
|
return $this->systemConfigRepository->findByKey($key);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function createConfig(SystemSettingKey $key, string $value): SystemConfig
|
||||||
|
{
|
||||||
|
if ($this->systemConfigRepository->findByKey($key) instanceof SystemConfig) {
|
||||||
|
throw new InvalidArgumentException("A configuration with the key '{$key->value}' already exists");
|
||||||
|
}
|
||||||
|
|
||||||
|
$config = new SystemConfig($key, $value);
|
||||||
|
$this->systemConfigRepository->save($config);
|
||||||
|
|
||||||
|
return $config;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function updateConfig(SystemConfig $config, string $value): SystemConfig
|
||||||
|
{
|
||||||
|
if ($value !== '') {
|
||||||
|
$config->setValue($value);
|
||||||
|
}
|
||||||
|
$this->systemConfigRepository->save($config);
|
||||||
|
|
||||||
|
return $config;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function resetAllConfigs(): void
|
||||||
|
{
|
||||||
|
foreach (SystemSettingKey::cases() as $key) {
|
||||||
|
$this->setDefaultValue($key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setDefaultValue(SystemSettingKey $key): void
|
||||||
|
{
|
||||||
|
$this->setConfigValue($key, SystemSettingKey::getDefaultValue($key));
|
||||||
|
}
|
||||||
|
}
|
141
src/Service/DrinkTypeService.php
Normal file
141
src/Service/DrinkTypeService.php
Normal file
|
@ -0,0 +1,141 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Service;
|
||||||
|
|
||||||
|
use App\Entity\DrinkType;
|
||||||
|
use App\Enum\SystemSettingKey;
|
||||||
|
use App\Repository\DrinkTypeRepository;
|
||||||
|
use InvalidArgumentException;
|
||||||
|
|
||||||
|
readonly class DrinkTypeService
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private DrinkTypeRepository $drinkTypeRepository,
|
||||||
|
private ConfigurationService $configService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all drink types
|
||||||
|
*
|
||||||
|
* @return DrinkType[]
|
||||||
|
*/
|
||||||
|
public function getAllDrinkTypes(): array
|
||||||
|
{
|
||||||
|
return $this->drinkTypeRepository->findAll();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get a drink type by ID
|
||||||
|
*
|
||||||
|
* @param int $id
|
||||||
|
* @return DrinkType|null
|
||||||
|
*/
|
||||||
|
public function getDrinkTypeById(int $id): null|DrinkType
|
||||||
|
{
|
||||||
|
return $this->drinkTypeRepository->find($id);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get a drink type by name
|
||||||
|
*
|
||||||
|
* @param string $name
|
||||||
|
* @return DrinkType|null
|
||||||
|
*/
|
||||||
|
public function getDrinkTypeByName(string $name): null|DrinkType
|
||||||
|
{
|
||||||
|
return $this->drinkTypeRepository->findOneBy([
|
||||||
|
'name' => $name,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a new drink type
|
||||||
|
*
|
||||||
|
* @param string $name
|
||||||
|
* @param string|null $description
|
||||||
|
* @param int|null $desiredStock
|
||||||
|
* @return DrinkType
|
||||||
|
* @throws InvalidArgumentException If a drink type with the same name already exists
|
||||||
|
*/
|
||||||
|
public function createDrinkType(
|
||||||
|
string $name,
|
||||||
|
null|string $description = null,
|
||||||
|
null|int $desiredStock = null,
|
||||||
|
): DrinkType {
|
||||||
|
// Check if a drink type with the same name already exists
|
||||||
|
if (
|
||||||
|
$this->drinkTypeRepository->findOneBy([
|
||||||
|
'name' => $name,
|
||||||
|
]) !== null
|
||||||
|
) {
|
||||||
|
throw new InvalidArgumentException("A drink type with the name '{$name}' already exists");
|
||||||
|
}
|
||||||
|
|
||||||
|
// If no desired stock is provided, use the default from configuration
|
||||||
|
if ($desiredStock === null) {
|
||||||
|
$desiredStock = (int) $this->configService->getConfigByKey(SystemSettingKey::DEFAULT_DESIRED_STOCK);
|
||||||
|
}
|
||||||
|
|
||||||
|
$drinkType = new DrinkType($name, $description, $desiredStock);
|
||||||
|
$this->drinkTypeRepository->save($drinkType);
|
||||||
|
|
||||||
|
return $drinkType;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update an existing drink type
|
||||||
|
*
|
||||||
|
* @param DrinkType $drinkType
|
||||||
|
* @param string|null $name
|
||||||
|
* @param string|null $description
|
||||||
|
* @param int|null $desiredStock
|
||||||
|
* @return DrinkType
|
||||||
|
* @throws InvalidArgumentException If a drink type with the same name already exists
|
||||||
|
*/
|
||||||
|
public function updateDrinkType(
|
||||||
|
DrinkType $drinkType,
|
||||||
|
null|string $name = null,
|
||||||
|
null|string $description = null,
|
||||||
|
null|int $desiredStock = null,
|
||||||
|
): DrinkType {
|
||||||
|
// Update name if provided
|
||||||
|
if ($name !== null && $name !== $drinkType->getName()) {
|
||||||
|
// Check if a drink type with the same name already exists
|
||||||
|
if (
|
||||||
|
$this->drinkTypeRepository->findOneBy([
|
||||||
|
'name' => $name,
|
||||||
|
]) !== null
|
||||||
|
) {
|
||||||
|
throw new InvalidArgumentException("A drink type with the name '{$name}' already exists");
|
||||||
|
}
|
||||||
|
$drinkType->setName($name);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update description if provided
|
||||||
|
if ($description !== null) {
|
||||||
|
$drinkType->setDescription($description);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update desired stock if provided
|
||||||
|
if ($desiredStock !== null) {
|
||||||
|
$drinkType->setDesiredStock($desiredStock);
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->drinkTypeRepository->save($drinkType);
|
||||||
|
|
||||||
|
return $drinkType;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete a drink type
|
||||||
|
*
|
||||||
|
* @param DrinkType $drinkType
|
||||||
|
* @return void
|
||||||
|
*/
|
||||||
|
public function deleteDrinkType(DrinkType $drinkType): void
|
||||||
|
{
|
||||||
|
$this->drinkTypeRepository->remove($drinkType);
|
||||||
|
}
|
||||||
|
}
|
119
src/Service/InventoryService.php
Normal file
119
src/Service/InventoryService.php
Normal file
|
@ -0,0 +1,119 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Service;
|
||||||
|
|
||||||
|
use App\Entity\DrinkType;
|
||||||
|
use App\Entity\InventoryRecord;
|
||||||
|
use App\Repository\DrinkTypeRepository;
|
||||||
|
use App\Repository\InventoryRecordRepository;
|
||||||
|
use App\Service\Config\LowStockMultiplier;
|
||||||
|
use App\ValueObject\DrinkStock;
|
||||||
|
use DateTimeImmutable;
|
||||||
|
|
||||||
|
readonly class InventoryService
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private InventoryRecordRepository $inventoryRecordRepository,
|
||||||
|
private DrinkTypeRepository $drinkTypeRepository,
|
||||||
|
private LowStockMultiplier $lowStockMultiplier,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all inventory records
|
||||||
|
*
|
||||||
|
* @return InventoryRecord[]
|
||||||
|
*/
|
||||||
|
public function getAllInventoryRecords(): array
|
||||||
|
{
|
||||||
|
return $this->inventoryRecordRepository->findAll();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get inventory records for a specific drink type
|
||||||
|
*
|
||||||
|
* @param DrinkType $drinkType
|
||||||
|
* @return InventoryRecord[]
|
||||||
|
*/
|
||||||
|
public function getInventoryRecordsByDrinkType(DrinkType $drinkType): array
|
||||||
|
{
|
||||||
|
return $this->inventoryRecordRepository->findByDrinkType($drinkType);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the latest inventory record for a specific drink type
|
||||||
|
*
|
||||||
|
* @param DrinkType $drinkType
|
||||||
|
* @return InventoryRecord
|
||||||
|
*/
|
||||||
|
public function getLatestInventoryRecord(DrinkType $drinkType): InventoryRecord
|
||||||
|
{
|
||||||
|
$record = $this->inventoryRecordRepository->findLatestByDrinkType($drinkType);
|
||||||
|
if (!($record instanceof InventoryRecord)) {
|
||||||
|
return new InventoryRecord($drinkType, 0);
|
||||||
|
}
|
||||||
|
return $record;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the current stock level for a specific drink type
|
||||||
|
*
|
||||||
|
* @param DrinkType $drinkType
|
||||||
|
* @return int
|
||||||
|
*/
|
||||||
|
public function getCurrentStockLevel(DrinkType $drinkType): int
|
||||||
|
{
|
||||||
|
$latestRecord = $this->getLatestInventoryRecord($drinkType);
|
||||||
|
return ($latestRecord instanceof InventoryRecord) ? $latestRecord->getQuantity() : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update the stock level for a specific drink type
|
||||||
|
*
|
||||||
|
* @param DrinkType $drinkType
|
||||||
|
* @param int $quantity
|
||||||
|
* @param DateTimeImmutable|null $timestamp
|
||||||
|
* @return InventoryRecord
|
||||||
|
*/
|
||||||
|
public function updateStockLevel(
|
||||||
|
DrinkType $drinkType,
|
||||||
|
int $quantity,
|
||||||
|
DateTimeImmutable $timestamp = new DateTimeImmutable(),
|
||||||
|
): InventoryRecord {
|
||||||
|
$inventoryRecord = new InventoryRecord($drinkType, $quantity, $timestamp);
|
||||||
|
|
||||||
|
$this->inventoryRecordRepository->save($inventoryRecord);
|
||||||
|
|
||||||
|
return $inventoryRecord;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return InventoryRecord[]
|
||||||
|
*/
|
||||||
|
public function getAllDrinkTypesWithStockLevels(bool $includeZeroDesiredStock = false): array
|
||||||
|
{
|
||||||
|
if ($includeZeroDesiredStock) {
|
||||||
|
$drinkTypes = $this->drinkTypeRepository->findAll();
|
||||||
|
} else {
|
||||||
|
$drinkTypes = $this->drinkTypeRepository->findDesired();
|
||||||
|
}
|
||||||
|
$result = [];
|
||||||
|
|
||||||
|
foreach ($drinkTypes as $drinkType) {
|
||||||
|
$result[] = $this->getDrinkStock($drinkType);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $result;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getDrinkStock(DrinkType $drinkType): DrinkStock
|
||||||
|
{
|
||||||
|
return DrinkStock::fromInventoryRecord(
|
||||||
|
$this->getLatestInventoryRecord($drinkType),
|
||||||
|
$this->lowStockMultiplier->getValue(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
237
src/Service/OrderService.php
Normal file
237
src/Service/OrderService.php
Normal file
|
@ -0,0 +1,237 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Service;
|
||||||
|
|
||||||
|
use App\Entity\DrinkType;
|
||||||
|
use App\Entity\Order;
|
||||||
|
use App\Entity\OrderItem;
|
||||||
|
use App\Enum\OrderStatus;
|
||||||
|
use App\Repository\DrinkTypeRepository;
|
||||||
|
use App\Repository\OrderItemRepository;
|
||||||
|
use App\Repository\OrderRepository;
|
||||||
|
use DateTimeImmutable;
|
||||||
|
use InvalidArgumentException;
|
||||||
|
|
||||||
|
readonly class OrderService
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private OrderRepository $orderRepository,
|
||||||
|
private OrderItemRepository $orderItemRepository,
|
||||||
|
private DrinkTypeRepository $drinkTypeRepository,
|
||||||
|
private InventoryService $inventoryService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all orders
|
||||||
|
*
|
||||||
|
* @return Order[]
|
||||||
|
*/
|
||||||
|
public function getAllOrders(): array
|
||||||
|
{
|
||||||
|
return $this->orderRepository->findAll();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get an order by ID
|
||||||
|
*
|
||||||
|
* @param int $id
|
||||||
|
* @return Order|null
|
||||||
|
*/
|
||||||
|
public function getOrderById(int $id): null|Order
|
||||||
|
{
|
||||||
|
return $this->orderRepository->find($id);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get orders by status
|
||||||
|
*
|
||||||
|
* @return Order[]
|
||||||
|
*/
|
||||||
|
public function getOrdersByStatus(OrderStatus $status): array
|
||||||
|
{
|
||||||
|
return $this->orderRepository->findByStatus($status);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getActiveOrders(): array
|
||||||
|
{
|
||||||
|
return $this->orderRepository->findByMultipleStatus([
|
||||||
|
OrderStatus::NEW,
|
||||||
|
OrderStatus::ORDERED,
|
||||||
|
OrderStatus::IN_WORK,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the most recent order in "new" or "in work" status
|
||||||
|
*
|
||||||
|
* @return Order|null
|
||||||
|
*/
|
||||||
|
public function getMostRecentActiveOrder(): null|Order
|
||||||
|
{
|
||||||
|
$newOrders = $this->orderRepository->findByStatus(OrderStatus::NEW);
|
||||||
|
$inWorkOrders = $this->orderRepository->findByStatus(OrderStatus::IN_WORK);
|
||||||
|
|
||||||
|
$activeOrders = array_merge($newOrders, $inWorkOrders);
|
||||||
|
|
||||||
|
// Sort by creation date (newest first)
|
||||||
|
usort($activeOrders, fn(Order $a, Order $b): int => $b->getCreatedAt() <=> $a->getCreatedAt());
|
||||||
|
|
||||||
|
return $activeOrders[0] ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if there are any orders in "new" or "in work" status
|
||||||
|
*
|
||||||
|
* @return bool
|
||||||
|
*/
|
||||||
|
public function hasActiveOrders(): bool
|
||||||
|
{
|
||||||
|
$newOrders = $this->orderRepository->findByStatus(OrderStatus::NEW);
|
||||||
|
$inWorkOrders = $this->orderRepository->findByStatus(OrderStatus::IN_WORK);
|
||||||
|
|
||||||
|
return $newOrders !== [] || $inWorkOrders !== [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get orders within a specific date range
|
||||||
|
*
|
||||||
|
* @param DateTimeImmutable $start
|
||||||
|
* @param DateTimeImmutable $end
|
||||||
|
* @return Order[]
|
||||||
|
*/
|
||||||
|
public function getOrdersByDateRange(DateTimeImmutable $start, DateTimeImmutable $end): array
|
||||||
|
{
|
||||||
|
return $this->orderRepository->findByDateRange($start, $end);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a new order
|
||||||
|
*
|
||||||
|
* @param array<int, array{drinkTypeId: int, quantity: int}> $items
|
||||||
|
* @return Order
|
||||||
|
* @throws InvalidArgumentException If a drink type ID is invalid
|
||||||
|
*/
|
||||||
|
public function createOrder(array $items): Order
|
||||||
|
{
|
||||||
|
$order = new Order();
|
||||||
|
$this->orderRepository->save($order);
|
||||||
|
|
||||||
|
foreach ($items as $item) {
|
||||||
|
$drinkType = $this->drinkTypeRepository->find($item['drinkTypeId']);
|
||||||
|
if ($drinkType === null) {
|
||||||
|
throw new InvalidArgumentException("Invalid drink type ID: {$item['drinkTypeId']}");
|
||||||
|
}
|
||||||
|
|
||||||
|
$orderItem = new OrderItem($drinkType, $item['quantity'], $order);
|
||||||
|
$this->orderItemRepository->save($orderItem);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $order;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create an order based on current stock levels
|
||||||
|
*
|
||||||
|
* @return Order
|
||||||
|
*/
|
||||||
|
public function createOrderFromStockLevels(): Order
|
||||||
|
{
|
||||||
|
$lowStockItems = $this->inventoryService->getAllDrinkTypesWithStockLevels();
|
||||||
|
$orderItems = [];
|
||||||
|
|
||||||
|
foreach ($lowStockItems as $item) {
|
||||||
|
if ($item['currentStock'] < $item['desiredStock']) {
|
||||||
|
$orderItems[] = [
|
||||||
|
'drinkTypeId' => $item['drinkType']->getId(),
|
||||||
|
'quantity' => $item['desiredStock'] - $item['currentStock'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->createOrder($orderItems);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update an order's status
|
||||||
|
*
|
||||||
|
* @return Order
|
||||||
|
* @throws InvalidArgumentException If the status is invalid
|
||||||
|
*/
|
||||||
|
public function updateOrderStatus(Order $order, OrderStatus $status): Order
|
||||||
|
{
|
||||||
|
$order->setStatus($status);
|
||||||
|
$this->orderRepository->save($order);
|
||||||
|
|
||||||
|
return $order;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add an item to an order
|
||||||
|
*
|
||||||
|
* @param Order $order
|
||||||
|
* @param DrinkType $drinkType
|
||||||
|
* @param int $quantity
|
||||||
|
* @return OrderItem
|
||||||
|
* @throws InvalidArgumentException If the order is not in 'new' or 'in_work' status
|
||||||
|
*/
|
||||||
|
public function addOrderItem(Order $order, DrinkType $drinkType, int $quantity): OrderItem
|
||||||
|
{
|
||||||
|
if (!in_array($order->getStatus(), [OrderStatus::STATUS_NEW, OrderStatus::STATUS_IN_WORK], true)) {
|
||||||
|
throw new InvalidArgumentException(
|
||||||
|
"Cannot add items to an order with status '{$order->getStatus()->value}'",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if the order already has an item for this drink type
|
||||||
|
$existingItem = $this->orderItemRepository->findByOrderAndDrinkType($order, $drinkType);
|
||||||
|
|
||||||
|
if ($existingItem instanceof OrderItem) {
|
||||||
|
// Update the existing item
|
||||||
|
$existingItem->setQuantity($existingItem->getQuantity() + $quantity);
|
||||||
|
$this->orderItemRepository->save($existingItem);
|
||||||
|
return $existingItem;
|
||||||
|
}
|
||||||
|
// Create a new item
|
||||||
|
$orderItem = new OrderItem($drinkType, $quantity, $order);
|
||||||
|
$this->orderItemRepository->save($orderItem);
|
||||||
|
return $orderItem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove an item from an order
|
||||||
|
*
|
||||||
|
* @param Order $order
|
||||||
|
* @param OrderItem $orderItem
|
||||||
|
* @return void
|
||||||
|
* @throws InvalidArgumentException If the order is not in 'new' or 'in_work' status
|
||||||
|
*/
|
||||||
|
public function removeOrderItem(Order $order, OrderItem $orderItem): void
|
||||||
|
{
|
||||||
|
if (!in_array($order->getStatus(), [OrderStatus::STATUS_NEW, OrderStatus::STATUS_IN_WORK], true)) {
|
||||||
|
throw new InvalidArgumentException(
|
||||||
|
"Cannot remove items from an order with status '{$order->getStatus()->value}'",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
$order->removeOrderItem($orderItem);
|
||||||
|
$this->orderItemRepository->remove($orderItem);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete an order
|
||||||
|
*
|
||||||
|
* @param Order $order
|
||||||
|
* @return void
|
||||||
|
* @throws InvalidArgumentException If the order is not in 'new' status
|
||||||
|
*/
|
||||||
|
public function deleteOrder(Order $order): void
|
||||||
|
{
|
||||||
|
if ($order->getStatus() !== OrderStatus::NEW && $order->getStatus() !== OrderStatus::IN_WORK) {
|
||||||
|
throw new InvalidArgumentException("Cannot delete an order with status '{$order->getStatus()->value}'");
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->orderRepository->remove($order);
|
||||||
|
}
|
||||||
|
}
|
124
src/Service/StockAdjustmentService.php
Normal file
124
src/Service/StockAdjustmentService.php
Normal file
|
@ -0,0 +1,124 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Service;
|
||||||
|
|
||||||
|
use App\Entity\DrinkType;
|
||||||
|
use App\Enum\SystemSettingKey;
|
||||||
|
use App\Repository\DrinkTypeRepository;
|
||||||
|
use App\Repository\InventoryRecordRepository;
|
||||||
|
use App\Repository\OrderRepository;
|
||||||
|
use App\Repository\SystemConfigRepository;
|
||||||
|
use App\ValueObject\StockAdjustmentProposal;
|
||||||
|
|
||||||
|
readonly class StockAdjustmentService
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private DrinkTypeRepository $drinkTypeRepository,
|
||||||
|
private InventoryRecordRepository $inventoryRecordRepository,
|
||||||
|
private OrderRepository $orderRepository,
|
||||||
|
private SystemConfigRepository $systemConfigRepository,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Proposes adjusted stock levels for all drink types
|
||||||
|
*
|
||||||
|
* @return array<int, StockAdjustmentProposal> Array of stock adjustment proposals
|
||||||
|
*/
|
||||||
|
public function proposeStockAdjustments(): array
|
||||||
|
{
|
||||||
|
$drinkTypes = $this->drinkTypeRepository->findAll();
|
||||||
|
$proposals = [];
|
||||||
|
|
||||||
|
foreach ($drinkTypes as $drinkType) {
|
||||||
|
$proposals[] = $this->proposeStockAdjustmentForDrinkType($drinkType);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $proposals;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Proposes an adjusted stock level for a specific drink type
|
||||||
|
*/
|
||||||
|
public function proposeStockAdjustmentForDrinkType(DrinkType $drinkType): StockAdjustmentProposal
|
||||||
|
{
|
||||||
|
$currentDesiredStock = $drinkType->getDesiredStock();
|
||||||
|
$lookbackOrders =
|
||||||
|
(int) $this->systemConfigRepository->getValue(SystemSettingKey::STOCK_ADJUSTMENT_LOOKBACK_ORDERS);
|
||||||
|
$increaseAmount = (int) $this->systemConfigRepository->getValue(SystemSettingKey::STOCK_INCREASE_AMOUNT);
|
||||||
|
$decreaseAmount = (int) $this->systemConfigRepository->getValue(SystemSettingKey::STOCK_DECREASE_AMOUNT);
|
||||||
|
|
||||||
|
// Get the last N orders for this drink type
|
||||||
|
$lastOrders = $this->orderRepository->findLastOrdersForDrinkType($drinkType, $lookbackOrders);
|
||||||
|
|
||||||
|
// If there are no orders, return the current desired stock
|
||||||
|
if ($lastOrders === []) {
|
||||||
|
return new StockAdjustmentProposal($drinkType, $currentDesiredStock);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if stock was 0 in the last order
|
||||||
|
$lastOrder = $lastOrders[0];
|
||||||
|
$lastOrderItems = $lastOrder->getOrderItems();
|
||||||
|
$stockWasZeroInLastOrder = false;
|
||||||
|
|
||||||
|
foreach ($lastOrderItems as $orderItem) {
|
||||||
|
if ($orderItem->getDrinkType()->getId() === $drinkType->getId()) {
|
||||||
|
// Find the inventory record closest to the order creation date
|
||||||
|
$inventoryRecords = $this->inventoryRecordRepository->findByDrinkType($drinkType);
|
||||||
|
foreach ($inventoryRecords as $record) {
|
||||||
|
if ($record->getTimestamp() <= $lastOrder->getCreatedAt()) {
|
||||||
|
if ($record->getQuantity() === 0) {
|
||||||
|
$stockWasZeroInLastOrder = true;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// If stock was 0 in the last order, increase desired stock
|
||||||
|
if ($stockWasZeroInLastOrder) {
|
||||||
|
return new StockAdjustmentProposal($drinkType, $currentDesiredStock + $increaseAmount);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if stock was above zero in all lookback orders
|
||||||
|
$stockWasAboveZeroInAllOrders = true;
|
||||||
|
$ordersToCheck = min(count($lastOrders), $lookbackOrders);
|
||||||
|
|
||||||
|
for ($i = 0; $i < $ordersToCheck; $i++) {
|
||||||
|
$order = $lastOrders[$i];
|
||||||
|
$orderItems = $order->getOrderItems();
|
||||||
|
|
||||||
|
foreach ($orderItems as $orderItem) {
|
||||||
|
if ($orderItem->getDrinkType()->getId() === $drinkType->getId()) {
|
||||||
|
// Find the inventory record closest to the order creation date
|
||||||
|
$inventoryRecords = $this->inventoryRecordRepository->findByDrinkType($drinkType);
|
||||||
|
foreach ($inventoryRecords as $record) {
|
||||||
|
if ($record->getTimestamp() <= $order->getCreatedAt()) {
|
||||||
|
if ($record->getQuantity() === 0) {
|
||||||
|
$stockWasAboveZeroInAllOrders = false;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!$stockWasAboveZeroInAllOrders) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// If stock was above zero in all lookback orders, decrease desired stock
|
||||||
|
if ($stockWasAboveZeroInAllOrders && $ordersToCheck === $lookbackOrders) {
|
||||||
|
$proposedStock = max(0, $currentDesiredStock - $decreaseAmount);
|
||||||
|
return new StockAdjustmentProposal($drinkType, $proposedStock);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Otherwise, keep the current desired stock
|
||||||
|
return new StockAdjustmentProposal($drinkType, $currentDesiredStock);
|
||||||
|
}
|
||||||
|
}
|
31
src/ValueObject/DrinkStock.php
Normal file
31
src/ValueObject/DrinkStock.php
Normal file
|
@ -0,0 +1,31 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\ValueObject;
|
||||||
|
|
||||||
|
use App\Entity\InventoryRecord;
|
||||||
|
use App\Enum\StockState;
|
||||||
|
|
||||||
|
final readonly class DrinkStock
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
public InventoryRecord $record,
|
||||||
|
public StockState $stock,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public static function fromInventoryRecord(InventoryRecord $record, float $lowStockMultiplier): self
|
||||||
|
{
|
||||||
|
if ($record->getQuantity() === 0 && $record->getDrinkType()->getDesiredStock() > 0) {
|
||||||
|
return new self($record, StockState::CRITICAL);
|
||||||
|
}
|
||||||
|
if ($record->getQuantity() < ($record->getDrinkType()->getDesiredStock() * $lowStockMultiplier)) {
|
||||||
|
return new self($record, StockState::LOW);
|
||||||
|
}
|
||||||
|
if ($record->getQuantity() > $record->getDrinkType()->getDesiredStock()) {
|
||||||
|
return new self($record, StockState::HIGH);
|
||||||
|
}
|
||||||
|
|
||||||
|
return new self($record, StockState::NORMAL);
|
||||||
|
}
|
||||||
|
}
|
119
symfony.lock
119
symfony.lock
|
@ -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": {
|
"symfony/console": {
|
||||||
"version": "7.3",
|
"version": "7.3",
|
||||||
"recipe": {
|
"recipe": {
|
||||||
|
@ -24,6 +72,18 @@
|
||||||
".env.dev"
|
".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": {
|
"symfony/framework-bundle": {
|
||||||
"version": "7.3",
|
"version": "7.3",
|
||||||
"recipe": {
|
"recipe": {
|
||||||
|
@ -44,6 +104,27 @@
|
||||||
".editorconfig"
|
".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": {
|
"symfony/routing": {
|
||||||
"version": "7.3",
|
"version": "7.3",
|
||||||
"recipe": {
|
"recipe": {
|
||||||
|
@ -56,5 +137,43 @@
|
||||||
"config/packages/routing.yaml",
|
"config/packages/routing.yaml",
|
||||||
"config/routes.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
38
templates/base.html.twig
Normal 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>
|
4
templates/drink_type/_delete_form.html.twig
Normal file
4
templates/drink_type/_delete_form.html.twig
Normal 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>
|
4
templates/drink_type/_form.html.twig
Normal file
4
templates/drink_type/_form.html.twig
Normal file
|
@ -0,0 +1,4 @@
|
||||||
|
{{ form_start(form) }}
|
||||||
|
{{ form_widget(form) }}
|
||||||
|
<button class="btn">{{ button_label|default('Save') }}</button>
|
||||||
|
{{ form_end(form) }}
|
13
templates/drink_type/edit.html.twig
Normal file
13
templates/drink_type/edit.html.twig
Normal 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 %}
|
46
templates/drink_type/index.html.twig
Normal file
46
templates/drink_type/index.html.twig
Normal 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 %}
|
11
templates/drink_type/new.html.twig
Normal file
11
templates/drink_type/new.html.twig
Normal 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 %}
|
42
templates/drink_type/show.html.twig
Normal file
42
templates/drink_type/show.html.twig
Normal 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
50
templates/index.html.twig
Normal 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 %}
|
Loading…
Add table
Add a link
Reference in a new issue