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_SECRET=
|
||||
###< symfony/framework-bundle ###
|
||||
|
||||
###> doctrine/doctrine-bundle ###
|
||||
# Format described at https://www.doctrine-project.org/projects/doctrine-dbal/en/latest/reference/configuration.html#connecting-using-a-url
|
||||
# IMPORTANT: You MUST configure your server version, either here or in config/packages/doctrine.yaml
|
||||
#
|
||||
DATABASE_URL="sqlite:///%kernel.project_dir%/var/data_%kernel.environment%.db"
|
||||
# DATABASE_URL="mysql://app:!ChangeMe!@127.0.0.1:3306/app?serverVersion=8.0.32&charset=utf8mb4"
|
||||
# DATABASE_URL="mysql://app:!ChangeMe!@127.0.0.1:3306/app?serverVersion=10.11.2-MariaDB&charset=utf8mb4"
|
||||
# DATABASE_URL="postgresql://app:!ChangeMe!@127.0.0.1:5432/app?serverVersion=16&charset=utf8"
|
||||
###< doctrine/doctrine-bundle ###
|
||||
|
|
6
.gitignore
vendored
6
.gitignore
vendored
|
@ -8,3 +8,9 @@
|
|||
/var/
|
||||
/vendor/
|
||||
###< 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",
|
||||
"prefer-stable": true,
|
||||
"require": {
|
||||
"php": ">=8.2",
|
||||
"php": ">=8.4",
|
||||
"ext-ctype": "*",
|
||||
"ext-iconv": "*",
|
||||
"doctrine/dbal": "^3",
|
||||
"doctrine/doctrine-bundle": "^2.14",
|
||||
"doctrine/doctrine-migrations-bundle": "^3.4",
|
||||
"doctrine/orm": "^3.3",
|
||||
"symfony/console": "7.3.*",
|
||||
"symfony/dotenv": "7.3.*",
|
||||
"symfony/flex": "^2",
|
||||
"symfony/form": "7.3.*",
|
||||
"symfony/framework-bundle": "7.3.*",
|
||||
"symfony/runtime": "7.3.*",
|
||||
"symfony/security-csrf": "7.3.*",
|
||||
"symfony/twig-bundle": "7.3.*",
|
||||
"symfony/validator": "7.3.*",
|
||||
"symfony/yaml": "7.3.*"
|
||||
},
|
||||
"require-dev": {
|
||||
"rector/rector": "^2.0",
|
||||
"symfony/maker-bundle": "^1.63",
|
||||
"symfony/stopwatch": "7.3.*",
|
||||
"symfony/web-profiler-bundle": "7.3.*",
|
||||
"symplify/config-transformer": "^12.4",
|
||||
"symplify/easy-coding-standard": "^12.5"
|
||||
},
|
||||
"config": {
|
||||
"allow-plugins": {
|
||||
|
@ -55,6 +69,10 @@
|
|||
],
|
||||
"post-update-cmd": [
|
||||
"@auto-scripts"
|
||||
],
|
||||
"lint": [
|
||||
"rector",
|
||||
"ecs --fix || ecs --fix"
|
||||
]
|
||||
},
|
||||
"conflict": {
|
||||
|
|
3155
composer.lock
generated
3155
composer.lock
generated
File diff suppressed because it is too large
Load diff
|
@ -2,4 +2,9 @@
|
|||
|
||||
return [
|
||||
Symfony\Bundle\FrameworkBundle\FrameworkBundle::class => ['all' => true],
|
||||
Symfony\Bundle\TwigBundle\TwigBundle::class => ['all' => true],
|
||||
Doctrine\Bundle\DoctrineBundle\DoctrineBundle::class => ['all' => true],
|
||||
Doctrine\Bundle\MigrationsBundle\DoctrineMigrationsBundle::class => ['all' => true],
|
||||
Symfony\Bundle\MakerBundle\MakerBundle::class => ['dev' => true],
|
||||
Symfony\Bundle\WebProfilerBundle\WebProfilerBundle::class => ['dev' => true, 'test' => true],
|
||||
];
|
||||
|
|
11
config/packages/cache.php
Normal file
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
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
if (file_exists(dirname(__DIR__) . '/var/cache/prod/App_KernelProdContainer.preload.php')) {
|
||||
require dirname(__DIR__) . '/var/cache/prod/App_KernelProdContainer.preload.php';
|
||||
}
|
||||
|
|
12
config/routes.php
Normal file
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
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Kernel;
|
||||
|
||||
require_once dirname(__DIR__) . '/vendor/autoload_runtime.php';
|
||||
|
||||
return function (array $context) {
|
||||
return new Kernel($context['APP_ENV'], (bool) $context['APP_DEBUG']);
|
||||
};
|
||||
return fn(array $context): Kernel => new Kernel($context['APP_ENV'], (bool) $context['APP_DEBUG']);
|
||||
|
|
28
rector.php
Normal file
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
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App;
|
||||
|
||||
use Symfony\Bundle\FrameworkBundle\Kernel\MicroKernelTrait;
|
||||
|
|
0
src/Repository/.gitignore
vendored
Normal file
0
src/Repository/.gitignore
vendored
Normal file
33
src/Repository/AbstractRepository.php
Normal file
33
src/Repository/AbstractRepository.php
Normal file
|
@ -0,0 +1,33 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Repository;
|
||||
|
||||
use Doctrine\ORM\EntityRepository;
|
||||
|
||||
/**
|
||||
* @template E of object
|
||||
* @extends EntityRepository<E>
|
||||
*/
|
||||
abstract class AbstractRepository extends EntityRepository
|
||||
{
|
||||
/**
|
||||
* @param E $entity
|
||||
* @return void
|
||||
*/
|
||||
public function save(object $entity): void
|
||||
{
|
||||
$this->getEntityManager()->persist($entity);
|
||||
$this->getEntityManager()->flush();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param E $entity
|
||||
*/
|
||||
public function remove(object $entity): void
|
||||
{
|
||||
$this->getEntityManager()->remove($entity);
|
||||
$this->getEntityManager()->flush();
|
||||
}
|
||||
}
|
48
src/Repository/DrinkTypeRepository.php
Normal file
48
src/Repository/DrinkTypeRepository.php
Normal file
|
@ -0,0 +1,48 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Repository;
|
||||
|
||||
use App\Entity\DrinkType;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Override;
|
||||
|
||||
/**
|
||||
* @extends AbstractRepository<DrinkType>
|
||||
*/
|
||||
class DrinkTypeRepository extends AbstractRepository
|
||||
{
|
||||
public function __construct(EntityManagerInterface $entityManager)
|
||||
{
|
||||
parent::__construct($entityManager, $entityManager->getClassMetadata(DrinkType::class));
|
||||
}
|
||||
|
||||
/** @inheritDoc */
|
||||
#[Override]
|
||||
public function findAll(): array
|
||||
{
|
||||
return parent::findBy(
|
||||
criteria: [],
|
||||
orderBy: [
|
||||
'desiredStock' => 'DESC',
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
/** @return DrinkType[] */
|
||||
public function findDesired(): array
|
||||
{
|
||||
$qb = $this->getEntityManager()->createQueryBuilder();
|
||||
$qb
|
||||
->select('d')
|
||||
->from(DrinkType::class, 'd')
|
||||
->where('d.desiredStock > 0')
|
||||
->orderBy('d.desiredStock', 'DESC')
|
||||
->addOrderBy('d.name', 'ASC');
|
||||
|
||||
/** @var array<int, DrinkType> $result */
|
||||
$result = $qb->getQuery()->getResult();
|
||||
return $result;
|
||||
}
|
||||
}
|
71
src/Repository/InventoryRecordRepository.php
Normal file
71
src/Repository/InventoryRecordRepository.php
Normal file
|
@ -0,0 +1,71 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Repository;
|
||||
|
||||
use App\Entity\DrinkType;
|
||||
use App\Entity\InventoryRecord;
|
||||
use DateTimeImmutable;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
|
||||
/**
|
||||
* @extends AbstractRepository<InventoryRecord>
|
||||
*/
|
||||
class InventoryRecordRepository extends AbstractRepository
|
||||
{
|
||||
public function __construct(EntityManagerInterface $entityManager)
|
||||
{
|
||||
parent::__construct($entityManager, $entityManager->getClassMetadata(InventoryRecord::class));
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, InventoryRecord>
|
||||
*/
|
||||
public function findByDrinkType(DrinkType $drinkType): array
|
||||
{
|
||||
return $this->findBy(
|
||||
[
|
||||
'drinkType' => $drinkType,
|
||||
],
|
||||
[
|
||||
'timestamp' => 'DESC',
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
public function findLatestByDrinkType(DrinkType $drinkType): null|InventoryRecord
|
||||
{
|
||||
$records = $this->findBy(
|
||||
[
|
||||
'drinkType' => $drinkType,
|
||||
],
|
||||
[
|
||||
'timestamp' => 'DESC',
|
||||
],
|
||||
1,
|
||||
);
|
||||
|
||||
return $records[0] ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, InventoryRecord>
|
||||
*/
|
||||
public function findByTimestampRange(DateTimeImmutable $start, DateTimeImmutable $end): array
|
||||
{
|
||||
$qb = $this->getEntityManager()->createQueryBuilder();
|
||||
$qb
|
||||
->select('ir')
|
||||
->from(InventoryRecord::class, 'ir')
|
||||
->where('ir.timestamp >= :start')
|
||||
->andWhere('ir.timestamp <= :end')
|
||||
->setParameter('start', $start)
|
||||
->setParameter('end', $end)
|
||||
->orderBy('ir.timestamp', 'DESC');
|
||||
|
||||
/** @var array<int, InventoryRecord> $result */
|
||||
$result = $qb->getQuery()->getResult();
|
||||
return $result;
|
||||
}
|
||||
}
|
49
src/Repository/OrderItemRepository.php
Normal file
49
src/Repository/OrderItemRepository.php
Normal file
|
@ -0,0 +1,49 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Repository;
|
||||
|
||||
use App\Entity\DrinkType;
|
||||
use App\Entity\Order;
|
||||
use App\Entity\OrderItem;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
|
||||
/**
|
||||
* @extends AbstractRepository<OrderItem>
|
||||
*/
|
||||
class OrderItemRepository extends AbstractRepository
|
||||
{
|
||||
public function __construct(EntityManagerInterface $entityManager)
|
||||
{
|
||||
parent::__construct($entityManager, $entityManager->getClassMetadata(OrderItem::class));
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, OrderItem>
|
||||
*/
|
||||
public function findByOrder(Order $order): array
|
||||
{
|
||||
return $this->findBy([
|
||||
'order' => $order,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, OrderItem>
|
||||
*/
|
||||
public function findByDrinkType(DrinkType $drinkType): array
|
||||
{
|
||||
return $this->findBy([
|
||||
'drinkType' => $drinkType,
|
||||
]);
|
||||
}
|
||||
|
||||
public function findByOrderAndDrinkType(Order $order, DrinkType $drinkType): null|OrderItem
|
||||
{
|
||||
return $this->findOneBy([
|
||||
'order' => $order,
|
||||
'drinkType' => $drinkType,
|
||||
]);
|
||||
}
|
||||
}
|
102
src/Repository/OrderRepository.php
Normal file
102
src/Repository/OrderRepository.php
Normal file
|
@ -0,0 +1,102 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Repository;
|
||||
|
||||
use App\Entity\DrinkType;
|
||||
use App\Entity\Order;
|
||||
use App\Enum\OrderStatus;
|
||||
use DateTimeImmutable;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
|
||||
/**
|
||||
* @extends AbstractRepository<Order>
|
||||
*/
|
||||
class OrderRepository extends AbstractRepository
|
||||
{
|
||||
public function __construct(EntityManagerInterface $entityManager)
|
||||
{
|
||||
parent::__construct($entityManager, $entityManager->getClassMetadata(Order::class));
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, Order>
|
||||
*/
|
||||
public function findByStatus(OrderStatus $status): array
|
||||
{
|
||||
return $this->findBy(
|
||||
[
|
||||
'status' => $status,
|
||||
],
|
||||
[
|
||||
'createdAt' => 'DESC',
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<OrderStatus> $stati
|
||||
* @return array<int, Order>
|
||||
*/
|
||||
public function findByMultipleStatus(array $stati): array
|
||||
{
|
||||
$qb = $this->getEntityManager()->createQueryBuilder();
|
||||
$qb
|
||||
->select('o')
|
||||
->from(Order::class, 'o')
|
||||
->where('o.status IN (:stati)')
|
||||
->setParameter('stati', $stati)
|
||||
->orderBy('o.createdAt', 'DESC');
|
||||
|
||||
/** @var array<int, Order> $result */
|
||||
$result = $qb->getQuery()->getResult();
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, Order>
|
||||
*/
|
||||
public function findByDateRange(DateTimeImmutable $start, DateTimeImmutable $end): array
|
||||
{
|
||||
$qb = $this->getEntityManager()->createQueryBuilder();
|
||||
$qb
|
||||
->select('o')
|
||||
->from(Order::class, 'o')
|
||||
->where('o.createdAt >= :start')
|
||||
->andWhere('o.createdAt <= :end')
|
||||
->setParameter('start', $start)
|
||||
->setParameter('end', $end)
|
||||
->orderBy('o.createdAt', 'DESC');
|
||||
|
||||
/** @var array<int, Order> $result */
|
||||
$result = $qb->getQuery()->getResult();
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the last N orders that contain a specific drink type
|
||||
*
|
||||
* @param DrinkType $drinkType The drink type to search for
|
||||
* @param int $limit The maximum number of orders to return
|
||||
* @return array<int, Order> The last N orders containing the drink type, ordered by creation date (newest first)
|
||||
*/
|
||||
public function findLastOrdersForDrinkType(DrinkType $drinkType, int $limit = 5): array
|
||||
{
|
||||
$qb = $this->getEntityManager()->createQueryBuilder();
|
||||
$qb
|
||||
->select('o')
|
||||
->from(Order::class, 'o')
|
||||
->join('o.orderItems', 'oi')
|
||||
->where('oi.drinkType = :drinkType')
|
||||
->andWhere('o.status = :status') // Only consider fulfilled orders
|
||||
->setParameter('drinkType', $drinkType)
|
||||
->setParameter('status', OrderStatus::FULFILLED->value)
|
||||
->orderBy('o.createdAt', 'DESC')
|
||||
->setMaxResults($limit);
|
||||
|
||||
/** @var array<int, Order> $result */
|
||||
$result = $qb->getQuery()->getResult();
|
||||
return $result;
|
||||
}
|
||||
}
|
50
src/Repository/SystemConfigRepository.php
Normal file
50
src/Repository/SystemConfigRepository.php
Normal file
|
@ -0,0 +1,50 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Repository;
|
||||
|
||||
use App\Entity\SystemConfig;
|
||||
use App\Enum\SystemSettingKey;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
|
||||
/**
|
||||
* @extends AbstractRepository<SystemConfig>
|
||||
*/
|
||||
class SystemConfigRepository extends AbstractRepository
|
||||
{
|
||||
public function __construct(EntityManagerInterface $entityManager)
|
||||
{
|
||||
parent::__construct($entityManager, $entityManager->getClassMetadata(SystemConfig::class));
|
||||
}
|
||||
|
||||
public function findByKey(SystemSettingKey $key): SystemConfig
|
||||
{
|
||||
$config = $this->findOneBy([
|
||||
'key' => $key->value,
|
||||
]);
|
||||
if (!($config instanceof SystemConfig)) {
|
||||
$config = new SystemConfig($key, SystemSettingKey::getDefaultValue($key));
|
||||
$this->save($config);
|
||||
}
|
||||
return $config;
|
||||
}
|
||||
|
||||
public function getValue(SystemSettingKey $key, string $default = ''): string
|
||||
{
|
||||
$config = $this->findByKey($key);
|
||||
return ($config instanceof SystemConfig) ? $config->getValue() : $default;
|
||||
}
|
||||
|
||||
public function setValue(SystemSettingKey $key, string $value): void
|
||||
{
|
||||
$config = $this->findByKey($key);
|
||||
|
||||
if ($config instanceof SystemConfig) {
|
||||
$config->setValue($value);
|
||||
} else {
|
||||
$config = new SystemConfig($key, $value);
|
||||
}
|
||||
$this->save($config);
|
||||
}
|
||||
}
|
22
src/Service/Config/AppName.php
Normal file
22
src/Service/Config/AppName.php
Normal file
|
@ -0,0 +1,22 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Service\Config;
|
||||
|
||||
use App\Entity\SystemConfig;
|
||||
use App\Enum\SystemSettingKey;
|
||||
use App\Service\ConfigurationService;
|
||||
use Stringable;
|
||||
|
||||
final readonly class AppName implements Stringable
|
||||
{
|
||||
public function __construct(
|
||||
private ConfigurationService $configService,
|
||||
) {}
|
||||
|
||||
public function __toString(): string
|
||||
{
|
||||
return $this->configService->getConfigValue(SystemSettingKey::SYSTEM_NAME, SystemConfig::DEFAULT_SYSTEM_NAME);
|
||||
}
|
||||
}
|
26
src/Service/Config/LowStockMultiplier.php
Normal file
26
src/Service/Config/LowStockMultiplier.php
Normal file
|
@ -0,0 +1,26 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Service\Config;
|
||||
|
||||
use App\Entity\SystemConfig;
|
||||
use App\Enum\SystemSettingKey;
|
||||
use App\Service\ConfigurationService;
|
||||
|
||||
final readonly class LowStockMultiplier
|
||||
{
|
||||
public function __construct(
|
||||
private ConfigurationService $configService,
|
||||
) {}
|
||||
|
||||
public function getValue(): float
|
||||
{
|
||||
$value = $this->configService->getConfigValue(
|
||||
SystemSettingKey::STOCK_LOW_MULTIPLIER,
|
||||
SystemConfig::DEFAULT_STOCK_LOW_MULTIPLIER,
|
||||
);
|
||||
|
||||
return (float) $value;
|
||||
}
|
||||
}
|
76
src/Service/ConfigurationService.php
Normal file
76
src/Service/ConfigurationService.php
Normal file
|
@ -0,0 +1,76 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Service;
|
||||
|
||||
use App\Entity\SystemConfig;
|
||||
use App\Enum\SystemSettingKey;
|
||||
use App\Repository\SystemConfigRepository;
|
||||
use InvalidArgumentException;
|
||||
|
||||
readonly class ConfigurationService
|
||||
{
|
||||
public function __construct(
|
||||
private SystemConfigRepository $systemConfigRepository,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Get all configuration entries
|
||||
*
|
||||
* @return SystemConfig[]
|
||||
*/
|
||||
public function getAllConfigs(): array
|
||||
{
|
||||
return $this->systemConfigRepository->findAll();
|
||||
}
|
||||
|
||||
public function getConfigValue(SystemSettingKey $key, string $default = ''): string
|
||||
{
|
||||
return $this->systemConfigRepository->getValue($key, $default);
|
||||
}
|
||||
|
||||
public function setConfigValue(SystemSettingKey $key, string $value): void
|
||||
{
|
||||
$this->systemConfigRepository->setValue($key, $value);
|
||||
}
|
||||
|
||||
public function getConfigByKey(SystemSettingKey $key): SystemConfig
|
||||
{
|
||||
return $this->systemConfigRepository->findByKey($key);
|
||||
}
|
||||
|
||||
public function createConfig(SystemSettingKey $key, string $value): SystemConfig
|
||||
{
|
||||
if ($this->systemConfigRepository->findByKey($key) instanceof SystemConfig) {
|
||||
throw new InvalidArgumentException("A configuration with the key '{$key->value}' already exists");
|
||||
}
|
||||
|
||||
$config = new SystemConfig($key, $value);
|
||||
$this->systemConfigRepository->save($config);
|
||||
|
||||
return $config;
|
||||
}
|
||||
|
||||
public function updateConfig(SystemConfig $config, string $value): SystemConfig
|
||||
{
|
||||
if ($value !== '') {
|
||||
$config->setValue($value);
|
||||
}
|
||||
$this->systemConfigRepository->save($config);
|
||||
|
||||
return $config;
|
||||
}
|
||||
|
||||
public function resetAllConfigs(): void
|
||||
{
|
||||
foreach (SystemSettingKey::cases() as $key) {
|
||||
$this->setDefaultValue($key);
|
||||
}
|
||||
}
|
||||
|
||||
public function setDefaultValue(SystemSettingKey $key): void
|
||||
{
|
||||
$this->setConfigValue($key, SystemSettingKey::getDefaultValue($key));
|
||||
}
|
||||
}
|
141
src/Service/DrinkTypeService.php
Normal file
141
src/Service/DrinkTypeService.php
Normal file
|
@ -0,0 +1,141 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Service;
|
||||
|
||||
use App\Entity\DrinkType;
|
||||
use App\Enum\SystemSettingKey;
|
||||
use App\Repository\DrinkTypeRepository;
|
||||
use InvalidArgumentException;
|
||||
|
||||
readonly class DrinkTypeService
|
||||
{
|
||||
public function __construct(
|
||||
private DrinkTypeRepository $drinkTypeRepository,
|
||||
private ConfigurationService $configService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Get all drink types
|
||||
*
|
||||
* @return DrinkType[]
|
||||
*/
|
||||
public function getAllDrinkTypes(): array
|
||||
{
|
||||
return $this->drinkTypeRepository->findAll();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a drink type by ID
|
||||
*
|
||||
* @param int $id
|
||||
* @return DrinkType|null
|
||||
*/
|
||||
public function getDrinkTypeById(int $id): null|DrinkType
|
||||
{
|
||||
return $this->drinkTypeRepository->find($id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a drink type by name
|
||||
*
|
||||
* @param string $name
|
||||
* @return DrinkType|null
|
||||
*/
|
||||
public function getDrinkTypeByName(string $name): null|DrinkType
|
||||
{
|
||||
return $this->drinkTypeRepository->findOneBy([
|
||||
'name' => $name,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new drink type
|
||||
*
|
||||
* @param string $name
|
||||
* @param string|null $description
|
||||
* @param int|null $desiredStock
|
||||
* @return DrinkType
|
||||
* @throws InvalidArgumentException If a drink type with the same name already exists
|
||||
*/
|
||||
public function createDrinkType(
|
||||
string $name,
|
||||
null|string $description = null,
|
||||
null|int $desiredStock = null,
|
||||
): DrinkType {
|
||||
// Check if a drink type with the same name already exists
|
||||
if (
|
||||
$this->drinkTypeRepository->findOneBy([
|
||||
'name' => $name,
|
||||
]) !== null
|
||||
) {
|
||||
throw new InvalidArgumentException("A drink type with the name '{$name}' already exists");
|
||||
}
|
||||
|
||||
// If no desired stock is provided, use the default from configuration
|
||||
if ($desiredStock === null) {
|
||||
$desiredStock = (int) $this->configService->getConfigByKey(SystemSettingKey::DEFAULT_DESIRED_STOCK);
|
||||
}
|
||||
|
||||
$drinkType = new DrinkType($name, $description, $desiredStock);
|
||||
$this->drinkTypeRepository->save($drinkType);
|
||||
|
||||
return $drinkType;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update an existing drink type
|
||||
*
|
||||
* @param DrinkType $drinkType
|
||||
* @param string|null $name
|
||||
* @param string|null $description
|
||||
* @param int|null $desiredStock
|
||||
* @return DrinkType
|
||||
* @throws InvalidArgumentException If a drink type with the same name already exists
|
||||
*/
|
||||
public function updateDrinkType(
|
||||
DrinkType $drinkType,
|
||||
null|string $name = null,
|
||||
null|string $description = null,
|
||||
null|int $desiredStock = null,
|
||||
): DrinkType {
|
||||
// Update name if provided
|
||||
if ($name !== null && $name !== $drinkType->getName()) {
|
||||
// Check if a drink type with the same name already exists
|
||||
if (
|
||||
$this->drinkTypeRepository->findOneBy([
|
||||
'name' => $name,
|
||||
]) !== null
|
||||
) {
|
||||
throw new InvalidArgumentException("A drink type with the name '{$name}' already exists");
|
||||
}
|
||||
$drinkType->setName($name);
|
||||
}
|
||||
|
||||
// Update description if provided
|
||||
if ($description !== null) {
|
||||
$drinkType->setDescription($description);
|
||||
}
|
||||
|
||||
// Update desired stock if provided
|
||||
if ($desiredStock !== null) {
|
||||
$drinkType->setDesiredStock($desiredStock);
|
||||
}
|
||||
|
||||
$this->drinkTypeRepository->save($drinkType);
|
||||
|
||||
return $drinkType;
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a drink type
|
||||
*
|
||||
* @param DrinkType $drinkType
|
||||
* @return void
|
||||
*/
|
||||
public function deleteDrinkType(DrinkType $drinkType): void
|
||||
{
|
||||
$this->drinkTypeRepository->remove($drinkType);
|
||||
}
|
||||
}
|
119
src/Service/InventoryService.php
Normal file
119
src/Service/InventoryService.php
Normal file
|
@ -0,0 +1,119 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Service;
|
||||
|
||||
use App\Entity\DrinkType;
|
||||
use App\Entity\InventoryRecord;
|
||||
use App\Repository\DrinkTypeRepository;
|
||||
use App\Repository\InventoryRecordRepository;
|
||||
use App\Service\Config\LowStockMultiplier;
|
||||
use App\ValueObject\DrinkStock;
|
||||
use DateTimeImmutable;
|
||||
|
||||
readonly class InventoryService
|
||||
{
|
||||
public function __construct(
|
||||
private InventoryRecordRepository $inventoryRecordRepository,
|
||||
private DrinkTypeRepository $drinkTypeRepository,
|
||||
private LowStockMultiplier $lowStockMultiplier,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Get all inventory records
|
||||
*
|
||||
* @return InventoryRecord[]
|
||||
*/
|
||||
public function getAllInventoryRecords(): array
|
||||
{
|
||||
return $this->inventoryRecordRepository->findAll();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get inventory records for a specific drink type
|
||||
*
|
||||
* @param DrinkType $drinkType
|
||||
* @return InventoryRecord[]
|
||||
*/
|
||||
public function getInventoryRecordsByDrinkType(DrinkType $drinkType): array
|
||||
{
|
||||
return $this->inventoryRecordRepository->findByDrinkType($drinkType);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the latest inventory record for a specific drink type
|
||||
*
|
||||
* @param DrinkType $drinkType
|
||||
* @return InventoryRecord
|
||||
*/
|
||||
public function getLatestInventoryRecord(DrinkType $drinkType): InventoryRecord
|
||||
{
|
||||
$record = $this->inventoryRecordRepository->findLatestByDrinkType($drinkType);
|
||||
if (!($record instanceof InventoryRecord)) {
|
||||
return new InventoryRecord($drinkType, 0);
|
||||
}
|
||||
return $record;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/**
|
||||
* Get the current stock level for a specific drink type
|
||||
*
|
||||
* @param DrinkType $drinkType
|
||||
* @return int
|
||||
*/
|
||||
public function getCurrentStockLevel(DrinkType $drinkType): int
|
||||
{
|
||||
$latestRecord = $this->getLatestInventoryRecord($drinkType);
|
||||
return ($latestRecord instanceof InventoryRecord) ? $latestRecord->getQuantity() : 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the stock level for a specific drink type
|
||||
*
|
||||
* @param DrinkType $drinkType
|
||||
* @param int $quantity
|
||||
* @param DateTimeImmutable|null $timestamp
|
||||
* @return InventoryRecord
|
||||
*/
|
||||
public function updateStockLevel(
|
||||
DrinkType $drinkType,
|
||||
int $quantity,
|
||||
DateTimeImmutable $timestamp = new DateTimeImmutable(),
|
||||
): InventoryRecord {
|
||||
$inventoryRecord = new InventoryRecord($drinkType, $quantity, $timestamp);
|
||||
|
||||
$this->inventoryRecordRepository->save($inventoryRecord);
|
||||
|
||||
return $inventoryRecord;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return InventoryRecord[]
|
||||
*/
|
||||
public function getAllDrinkTypesWithStockLevels(bool $includeZeroDesiredStock = false): array
|
||||
{
|
||||
if ($includeZeroDesiredStock) {
|
||||
$drinkTypes = $this->drinkTypeRepository->findAll();
|
||||
} else {
|
||||
$drinkTypes = $this->drinkTypeRepository->findDesired();
|
||||
}
|
||||
$result = [];
|
||||
|
||||
foreach ($drinkTypes as $drinkType) {
|
||||
$result[] = $this->getDrinkStock($drinkType);
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
public function getDrinkStock(DrinkType $drinkType): DrinkStock
|
||||
{
|
||||
return DrinkStock::fromInventoryRecord(
|
||||
$this->getLatestInventoryRecord($drinkType),
|
||||
$this->lowStockMultiplier->getValue(),
|
||||
);
|
||||
}
|
||||
}
|
237
src/Service/OrderService.php
Normal file
237
src/Service/OrderService.php
Normal file
|
@ -0,0 +1,237 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Service;
|
||||
|
||||
use App\Entity\DrinkType;
|
||||
use App\Entity\Order;
|
||||
use App\Entity\OrderItem;
|
||||
use App\Enum\OrderStatus;
|
||||
use App\Repository\DrinkTypeRepository;
|
||||
use App\Repository\OrderItemRepository;
|
||||
use App\Repository\OrderRepository;
|
||||
use DateTimeImmutable;
|
||||
use InvalidArgumentException;
|
||||
|
||||
readonly class OrderService
|
||||
{
|
||||
public function __construct(
|
||||
private OrderRepository $orderRepository,
|
||||
private OrderItemRepository $orderItemRepository,
|
||||
private DrinkTypeRepository $drinkTypeRepository,
|
||||
private InventoryService $inventoryService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Get all orders
|
||||
*
|
||||
* @return Order[]
|
||||
*/
|
||||
public function getAllOrders(): array
|
||||
{
|
||||
return $this->orderRepository->findAll();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get an order by ID
|
||||
*
|
||||
* @param int $id
|
||||
* @return Order|null
|
||||
*/
|
||||
public function getOrderById(int $id): null|Order
|
||||
{
|
||||
return $this->orderRepository->find($id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get orders by status
|
||||
*
|
||||
* @return Order[]
|
||||
*/
|
||||
public function getOrdersByStatus(OrderStatus $status): array
|
||||
{
|
||||
return $this->orderRepository->findByStatus($status);
|
||||
}
|
||||
|
||||
public function getActiveOrders(): array
|
||||
{
|
||||
return $this->orderRepository->findByMultipleStatus([
|
||||
OrderStatus::NEW,
|
||||
OrderStatus::ORDERED,
|
||||
OrderStatus::IN_WORK,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the most recent order in "new" or "in work" status
|
||||
*
|
||||
* @return Order|null
|
||||
*/
|
||||
public function getMostRecentActiveOrder(): null|Order
|
||||
{
|
||||
$newOrders = $this->orderRepository->findByStatus(OrderStatus::NEW);
|
||||
$inWorkOrders = $this->orderRepository->findByStatus(OrderStatus::IN_WORK);
|
||||
|
||||
$activeOrders = array_merge($newOrders, $inWorkOrders);
|
||||
|
||||
// Sort by creation date (newest first)
|
||||
usort($activeOrders, fn(Order $a, Order $b): int => $b->getCreatedAt() <=> $a->getCreatedAt());
|
||||
|
||||
return $activeOrders[0] ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if there are any orders in "new" or "in work" status
|
||||
*
|
||||
* @return bool
|
||||
*/
|
||||
public function hasActiveOrders(): bool
|
||||
{
|
||||
$newOrders = $this->orderRepository->findByStatus(OrderStatus::NEW);
|
||||
$inWorkOrders = $this->orderRepository->findByStatus(OrderStatus::IN_WORK);
|
||||
|
||||
return $newOrders !== [] || $inWorkOrders !== [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get orders within a specific date range
|
||||
*
|
||||
* @param DateTimeImmutable $start
|
||||
* @param DateTimeImmutable $end
|
||||
* @return Order[]
|
||||
*/
|
||||
public function getOrdersByDateRange(DateTimeImmutable $start, DateTimeImmutable $end): array
|
||||
{
|
||||
return $this->orderRepository->findByDateRange($start, $end);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new order
|
||||
*
|
||||
* @param array<int, array{drinkTypeId: int, quantity: int}> $items
|
||||
* @return Order
|
||||
* @throws InvalidArgumentException If a drink type ID is invalid
|
||||
*/
|
||||
public function createOrder(array $items): Order
|
||||
{
|
||||
$order = new Order();
|
||||
$this->orderRepository->save($order);
|
||||
|
||||
foreach ($items as $item) {
|
||||
$drinkType = $this->drinkTypeRepository->find($item['drinkTypeId']);
|
||||
if ($drinkType === null) {
|
||||
throw new InvalidArgumentException("Invalid drink type ID: {$item['drinkTypeId']}");
|
||||
}
|
||||
|
||||
$orderItem = new OrderItem($drinkType, $item['quantity'], $order);
|
||||
$this->orderItemRepository->save($orderItem);
|
||||
}
|
||||
|
||||
return $order;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an order based on current stock levels
|
||||
*
|
||||
* @return Order
|
||||
*/
|
||||
public function createOrderFromStockLevels(): Order
|
||||
{
|
||||
$lowStockItems = $this->inventoryService->getAllDrinkTypesWithStockLevels();
|
||||
$orderItems = [];
|
||||
|
||||
foreach ($lowStockItems as $item) {
|
||||
if ($item['currentStock'] < $item['desiredStock']) {
|
||||
$orderItems[] = [
|
||||
'drinkTypeId' => $item['drinkType']->getId(),
|
||||
'quantity' => $item['desiredStock'] - $item['currentStock'],
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
return $this->createOrder($orderItems);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update an order's status
|
||||
*
|
||||
* @return Order
|
||||
* @throws InvalidArgumentException If the status is invalid
|
||||
*/
|
||||
public function updateOrderStatus(Order $order, OrderStatus $status): Order
|
||||
{
|
||||
$order->setStatus($status);
|
||||
$this->orderRepository->save($order);
|
||||
|
||||
return $order;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add an item to an order
|
||||
*
|
||||
* @param Order $order
|
||||
* @param DrinkType $drinkType
|
||||
* @param int $quantity
|
||||
* @return OrderItem
|
||||
* @throws InvalidArgumentException If the order is not in 'new' or 'in_work' status
|
||||
*/
|
||||
public function addOrderItem(Order $order, DrinkType $drinkType, int $quantity): OrderItem
|
||||
{
|
||||
if (!in_array($order->getStatus(), [OrderStatus::STATUS_NEW, OrderStatus::STATUS_IN_WORK], true)) {
|
||||
throw new InvalidArgumentException(
|
||||
"Cannot add items to an order with status '{$order->getStatus()->value}'",
|
||||
);
|
||||
}
|
||||
|
||||
// Check if the order already has an item for this drink type
|
||||
$existingItem = $this->orderItemRepository->findByOrderAndDrinkType($order, $drinkType);
|
||||
|
||||
if ($existingItem instanceof OrderItem) {
|
||||
// Update the existing item
|
||||
$existingItem->setQuantity($existingItem->getQuantity() + $quantity);
|
||||
$this->orderItemRepository->save($existingItem);
|
||||
return $existingItem;
|
||||
}
|
||||
// Create a new item
|
||||
$orderItem = new OrderItem($drinkType, $quantity, $order);
|
||||
$this->orderItemRepository->save($orderItem);
|
||||
return $orderItem;
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove an item from an order
|
||||
*
|
||||
* @param Order $order
|
||||
* @param OrderItem $orderItem
|
||||
* @return void
|
||||
* @throws InvalidArgumentException If the order is not in 'new' or 'in_work' status
|
||||
*/
|
||||
public function removeOrderItem(Order $order, OrderItem $orderItem): void
|
||||
{
|
||||
if (!in_array($order->getStatus(), [OrderStatus::STATUS_NEW, OrderStatus::STATUS_IN_WORK], true)) {
|
||||
throw new InvalidArgumentException(
|
||||
"Cannot remove items from an order with status '{$order->getStatus()->value}'",
|
||||
);
|
||||
}
|
||||
|
||||
$order->removeOrderItem($orderItem);
|
||||
$this->orderItemRepository->remove($orderItem);
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete an order
|
||||
*
|
||||
* @param Order $order
|
||||
* @return void
|
||||
* @throws InvalidArgumentException If the order is not in 'new' status
|
||||
*/
|
||||
public function deleteOrder(Order $order): void
|
||||
{
|
||||
if ($order->getStatus() !== OrderStatus::NEW && $order->getStatus() !== OrderStatus::IN_WORK) {
|
||||
throw new InvalidArgumentException("Cannot delete an order with status '{$order->getStatus()->value}'");
|
||||
}
|
||||
|
||||
$this->orderRepository->remove($order);
|
||||
}
|
||||
}
|
124
src/Service/StockAdjustmentService.php
Normal file
124
src/Service/StockAdjustmentService.php
Normal file
|
@ -0,0 +1,124 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Service;
|
||||
|
||||
use App\Entity\DrinkType;
|
||||
use App\Enum\SystemSettingKey;
|
||||
use App\Repository\DrinkTypeRepository;
|
||||
use App\Repository\InventoryRecordRepository;
|
||||
use App\Repository\OrderRepository;
|
||||
use App\Repository\SystemConfigRepository;
|
||||
use App\ValueObject\StockAdjustmentProposal;
|
||||
|
||||
readonly class StockAdjustmentService
|
||||
{
|
||||
public function __construct(
|
||||
private DrinkTypeRepository $drinkTypeRepository,
|
||||
private InventoryRecordRepository $inventoryRecordRepository,
|
||||
private OrderRepository $orderRepository,
|
||||
private SystemConfigRepository $systemConfigRepository,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Proposes adjusted stock levels for all drink types
|
||||
*
|
||||
* @return array<int, StockAdjustmentProposal> Array of stock adjustment proposals
|
||||
*/
|
||||
public function proposeStockAdjustments(): array
|
||||
{
|
||||
$drinkTypes = $this->drinkTypeRepository->findAll();
|
||||
$proposals = [];
|
||||
|
||||
foreach ($drinkTypes as $drinkType) {
|
||||
$proposals[] = $this->proposeStockAdjustmentForDrinkType($drinkType);
|
||||
}
|
||||
|
||||
return $proposals;
|
||||
}
|
||||
|
||||
/**
|
||||
* Proposes an adjusted stock level for a specific drink type
|
||||
*/
|
||||
public function proposeStockAdjustmentForDrinkType(DrinkType $drinkType): StockAdjustmentProposal
|
||||
{
|
||||
$currentDesiredStock = $drinkType->getDesiredStock();
|
||||
$lookbackOrders =
|
||||
(int) $this->systemConfigRepository->getValue(SystemSettingKey::STOCK_ADJUSTMENT_LOOKBACK_ORDERS);
|
||||
$increaseAmount = (int) $this->systemConfigRepository->getValue(SystemSettingKey::STOCK_INCREASE_AMOUNT);
|
||||
$decreaseAmount = (int) $this->systemConfigRepository->getValue(SystemSettingKey::STOCK_DECREASE_AMOUNT);
|
||||
|
||||
// Get the last N orders for this drink type
|
||||
$lastOrders = $this->orderRepository->findLastOrdersForDrinkType($drinkType, $lookbackOrders);
|
||||
|
||||
// If there are no orders, return the current desired stock
|
||||
if ($lastOrders === []) {
|
||||
return new StockAdjustmentProposal($drinkType, $currentDesiredStock);
|
||||
}
|
||||
|
||||
// Check if stock was 0 in the last order
|
||||
$lastOrder = $lastOrders[0];
|
||||
$lastOrderItems = $lastOrder->getOrderItems();
|
||||
$stockWasZeroInLastOrder = false;
|
||||
|
||||
foreach ($lastOrderItems as $orderItem) {
|
||||
if ($orderItem->getDrinkType()->getId() === $drinkType->getId()) {
|
||||
// Find the inventory record closest to the order creation date
|
||||
$inventoryRecords = $this->inventoryRecordRepository->findByDrinkType($drinkType);
|
||||
foreach ($inventoryRecords as $record) {
|
||||
if ($record->getTimestamp() <= $lastOrder->getCreatedAt()) {
|
||||
if ($record->getQuantity() === 0) {
|
||||
$stockWasZeroInLastOrder = true;
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// If stock was 0 in the last order, increase desired stock
|
||||
if ($stockWasZeroInLastOrder) {
|
||||
return new StockAdjustmentProposal($drinkType, $currentDesiredStock + $increaseAmount);
|
||||
}
|
||||
|
||||
// Check if stock was above zero in all lookback orders
|
||||
$stockWasAboveZeroInAllOrders = true;
|
||||
$ordersToCheck = min(count($lastOrders), $lookbackOrders);
|
||||
|
||||
for ($i = 0; $i < $ordersToCheck; $i++) {
|
||||
$order = $lastOrders[$i];
|
||||
$orderItems = $order->getOrderItems();
|
||||
|
||||
foreach ($orderItems as $orderItem) {
|
||||
if ($orderItem->getDrinkType()->getId() === $drinkType->getId()) {
|
||||
// Find the inventory record closest to the order creation date
|
||||
$inventoryRecords = $this->inventoryRecordRepository->findByDrinkType($drinkType);
|
||||
foreach ($inventoryRecords as $record) {
|
||||
if ($record->getTimestamp() <= $order->getCreatedAt()) {
|
||||
if ($record->getQuantity() === 0) {
|
||||
$stockWasAboveZeroInAllOrders = false;
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!$stockWasAboveZeroInAllOrders) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// If stock was above zero in all lookback orders, decrease desired stock
|
||||
if ($stockWasAboveZeroInAllOrders && $ordersToCheck === $lookbackOrders) {
|
||||
$proposedStock = max(0, $currentDesiredStock - $decreaseAmount);
|
||||
return new StockAdjustmentProposal($drinkType, $proposedStock);
|
||||
}
|
||||
|
||||
// Otherwise, keep the current desired stock
|
||||
return new StockAdjustmentProposal($drinkType, $currentDesiredStock);
|
||||
}
|
||||
}
|
31
src/ValueObject/DrinkStock.php
Normal file
31
src/ValueObject/DrinkStock.php
Normal file
|
@ -0,0 +1,31 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\ValueObject;
|
||||
|
||||
use App\Entity\InventoryRecord;
|
||||
use App\Enum\StockState;
|
||||
|
||||
final readonly class DrinkStock
|
||||
{
|
||||
public function __construct(
|
||||
public InventoryRecord $record,
|
||||
public StockState $stock,
|
||||
) {}
|
||||
|
||||
public static function fromInventoryRecord(InventoryRecord $record, float $lowStockMultiplier): self
|
||||
{
|
||||
if ($record->getQuantity() === 0 && $record->getDrinkType()->getDesiredStock() > 0) {
|
||||
return new self($record, StockState::CRITICAL);
|
||||
}
|
||||
if ($record->getQuantity() < ($record->getDrinkType()->getDesiredStock() * $lowStockMultiplier)) {
|
||||
return new self($record, StockState::LOW);
|
||||
}
|
||||
if ($record->getQuantity() > $record->getDrinkType()->getDesiredStock()) {
|
||||
return new self($record, StockState::HIGH);
|
||||
}
|
||||
|
||||
return new self($record, StockState::NORMAL);
|
||||
}
|
||||
}
|
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": {
|
||||
"version": "7.3",
|
||||
"recipe": {
|
||||
|
@ -24,6 +72,18 @@
|
|||
".env.dev"
|
||||
]
|
||||
},
|
||||
"symfony/form": {
|
||||
"version": "7.3",
|
||||
"recipe": {
|
||||
"repo": "github.com/symfony/recipes",
|
||||
"branch": "main",
|
||||
"version": "7.2",
|
||||
"ref": "7d86a6723f4a623f59e2bf966b6aad2fc461d36b"
|
||||
},
|
||||
"files": [
|
||||
"config/packages/csrf.yaml"
|
||||
]
|
||||
},
|
||||
"symfony/framework-bundle": {
|
||||
"version": "7.3",
|
||||
"recipe": {
|
||||
|
@ -44,6 +104,27 @@
|
|||
".editorconfig"
|
||||
]
|
||||
},
|
||||
"symfony/maker-bundle": {
|
||||
"version": "1.63",
|
||||
"recipe": {
|
||||
"repo": "github.com/symfony/recipes",
|
||||
"branch": "main",
|
||||
"version": "1.0",
|
||||
"ref": "fadbfe33303a76e25cb63401050439aa9b1a9c7f"
|
||||
}
|
||||
},
|
||||
"symfony/property-info": {
|
||||
"version": "7.3",
|
||||
"recipe": {
|
||||
"repo": "github.com/symfony/recipes",
|
||||
"branch": "main",
|
||||
"version": "7.3",
|
||||
"ref": "dae70df71978ae9226ae915ffd5fad817f5ca1f7"
|
||||
},
|
||||
"files": [
|
||||
"config/packages/property_info.yaml"
|
||||
]
|
||||
},
|
||||
"symfony/routing": {
|
||||
"version": "7.3",
|
||||
"recipe": {
|
||||
|
@ -56,5 +137,43 @@
|
|||
"config/packages/routing.yaml",
|
||||
"config/routes.yaml"
|
||||
]
|
||||
},
|
||||
"symfony/twig-bundle": {
|
||||
"version": "7.3",
|
||||
"recipe": {
|
||||
"repo": "github.com/symfony/recipes",
|
||||
"branch": "main",
|
||||
"version": "6.4",
|
||||
"ref": "cab5fd2a13a45c266d45a7d9337e28dee6272877"
|
||||
},
|
||||
"files": [
|
||||
"config/packages/twig.yaml",
|
||||
"templates/base.html.twig"
|
||||
]
|
||||
},
|
||||
"symfony/validator": {
|
||||
"version": "7.3",
|
||||
"recipe": {
|
||||
"repo": "github.com/symfony/recipes",
|
||||
"branch": "main",
|
||||
"version": "7.0",
|
||||
"ref": "8c1c4e28d26a124b0bb273f537ca8ce443472bfd"
|
||||
},
|
||||
"files": [
|
||||
"config/packages/validator.yaml"
|
||||
]
|
||||
},
|
||||
"symfony/web-profiler-bundle": {
|
||||
"version": "7.3",
|
||||
"recipe": {
|
||||
"repo": "github.com/symfony/recipes",
|
||||
"branch": "main",
|
||||
"version": "7.3",
|
||||
"ref": "5b2b543e13942495c0003f67780cb4448af9e606"
|
||||
},
|
||||
"files": [
|
||||
"config/packages/web_profiler.yaml",
|
||||
"config/routes/web_profiler.yaml"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
|
38
templates/base.html.twig
Normal file
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