mep
This commit is contained in:
parent
e958163a4a
commit
b8a5a1ff58
79 changed files with 15113 additions and 0 deletions
4
.gitignore
vendored
Normal file
4
.gitignore
vendored
Normal file
|
@ -0,0 +1,4 @@
|
|||
vendor/
|
||||
var/
|
||||
.idea/
|
||||
.phpunit.result.cache
|
12
bin/doctrine.php
Executable file
12
bin/doctrine.php
Executable file
|
@ -0,0 +1,12 @@
|
|||
#!/usr/bin/env php
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
require_once __DIR__ . '/../vendor/autoload.php';
|
||||
|
||||
$container = (require __DIR__ . '/../config/container.php')();
|
||||
|
||||
\Doctrine\ORM\Tools\Console\ConsoleRunner::run(
|
||||
new \Doctrine\ORM\Tools\Console\EntityManagerProvider\SingleManagerProvider($container->get(\Doctrine\ORM\EntityManagerInterface::class))
|
||||
);
|
50
composer.json
Normal file
50
composer.json
Normal file
|
@ -0,0 +1,50 @@
|
|||
{
|
||||
"name": "lubiana/saufen",
|
||||
"type": "project",
|
||||
"require": {
|
||||
"php": "^8.4",
|
||||
"php-di/slim-bridge": "^3.4",
|
||||
"doctrine/orm": "^3.3",
|
||||
"monolog/monolog": "^3.5",
|
||||
"slim/twig-view": "^3.4",
|
||||
"slim/http": "^1.4",
|
||||
"slim/psr7": "^1.7",
|
||||
"ext-pdo": "*",
|
||||
"symfony/cache": "^7.3"
|
||||
},
|
||||
"require-dev": {
|
||||
"phpstan/phpstan-strict-rules": "^2.0",
|
||||
"phpstan/phpstan": "^2.1",
|
||||
"rector/rector": "^2.0",
|
||||
"symplify/easy-coding-standard": "^12.5",
|
||||
"pestphp/pest": "^3.8",
|
||||
"phpstan/extension-installer": "^1.4",
|
||||
"phpstan/phpstan-doctrine": "^2.0"
|
||||
},
|
||||
"scripts": {
|
||||
"lint": [
|
||||
"rector",
|
||||
"ecs --fix || ecs --fix"
|
||||
],
|
||||
"test": "pest --parallel"
|
||||
},
|
||||
"license": "MIT",
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"App\\": "src/",
|
||||
"Tests\\": "tests/"
|
||||
}
|
||||
},
|
||||
"authors": [
|
||||
{
|
||||
"name": "lubiana",
|
||||
"email": "lubiana@hannover.ccc.de"
|
||||
}
|
||||
],
|
||||
"config": {
|
||||
"allow-plugins": {
|
||||
"pestphp/pest-plugin": true,
|
||||
"phpstan/extension-installer": true
|
||||
}
|
||||
}
|
||||
}
|
6543
composer.lock
generated
Normal file
6543
composer.lock
generated
Normal file
File diff suppressed because it is too large
Load diff
24
config/container.php
Normal file
24
config/container.php
Normal file
|
@ -0,0 +1,24 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
|
||||
use App\Settings;
|
||||
use DI\ContainerBuilder;
|
||||
use Psr\Container\ContainerInterface;
|
||||
|
||||
return function (Settings $settings = new Settings()): ContainerInterface {
|
||||
$containerBuilder = new ContainerBuilder();
|
||||
|
||||
$containerBuilder->addDefinitions([
|
||||
Settings::class => $settings,
|
||||
]);
|
||||
// Define container entries
|
||||
$containerBuilder->addDefinitions(
|
||||
require __DIR__ . '/definitions.php',
|
||||
);
|
||||
|
||||
|
||||
|
||||
return $containerBuilder->build();
|
||||
};
|
93
config/definitions.php
Normal file
93
config/definitions.php
Normal file
|
@ -0,0 +1,93 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Enum\SystemSettingKey;
|
||||
use Doctrine\ORM\Configuration;
|
||||
use App\Settings;
|
||||
use Doctrine\DBAL\Connection;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Doctrine\DBAL\DriverManager;
|
||||
use Doctrine\ORM\EntityManager;
|
||||
use Doctrine\ORM\ORMSetup;
|
||||
use Slim\Views\Twig;
|
||||
use Monolog\Logger;
|
||||
use Monolog\Handler\StreamHandler;
|
||||
use Monolog\Processor\UidProcessor;
|
||||
use App\Repository\DrinkTypeRepository;
|
||||
use App\Repository\InventoryRecordRepository;
|
||||
use App\Repository\OrderRepository;
|
||||
use App\Repository\OrderItemRepository;
|
||||
use App\Repository\SystemConfigRepository;
|
||||
|
||||
return [
|
||||
Configuration::class => fn(Settings $s): Configuration => ORMSetup::createAttributeMetadataConfiguration(
|
||||
paths: [__DIR__ . '/../src/Entity'],
|
||||
isDevMode: $s->isTestMode,
|
||||
proxyDir: __DIR__ . '/../var/cache/doctrine/proxy',
|
||||
),
|
||||
Connection::class => fn(
|
||||
Configuration $configuration,
|
||||
Settings $s,
|
||||
): Connection => DriverManager::getConnection(
|
||||
params: $s->isTestMode ? [
|
||||
'driver' => 'pdo_sqlite',
|
||||
'memory' => true,
|
||||
] : [
|
||||
'driver' => 'pdo_sqlite',
|
||||
'path' => __DIR__ . '/../var/database.sqlite',
|
||||
],
|
||||
config: $configuration
|
||||
),
|
||||
EntityManagerInterface::class => fn(
|
||||
Connection $connection,
|
||||
Configuration $configuration
|
||||
): EntityManagerInterface => new EntityManager($connection, $configuration),
|
||||
|
||||
Twig::class => function (\App\Service\ConfigurationService $config): Twig {
|
||||
$paths = [__DIR__ . '/../templates'];
|
||||
$cache = __DIR__ . '/../var/cache/twig';
|
||||
|
||||
// Ensure cache directory exists
|
||||
if (!is_dir($cache)) {
|
||||
mkdir($cache, 0o755, true);
|
||||
}
|
||||
|
||||
$twig = Twig::create($paths, [
|
||||
'cache' => $cache,
|
||||
'auto_reload' => true,
|
||||
]);
|
||||
$twig['appName'] = $config->getConfigByKey(SystemSettingKey::SYSTEM_NAME->value)->getValue();
|
||||
return $twig;
|
||||
},
|
||||
|
||||
// Logger
|
||||
Logger::class => function (): Logger {
|
||||
$logDir = __DIR__ . '/../var/logs';
|
||||
|
||||
// Ensure log directory exists
|
||||
if (!is_dir($logDir)) {
|
||||
mkdir($logDir, 0o755, true);
|
||||
}
|
||||
|
||||
$logger = new Logger('app');
|
||||
|
||||
$processor = new UidProcessor();
|
||||
$logger->pushProcessor($processor);
|
||||
|
||||
$handler = new StreamHandler(
|
||||
$logDir . '/app.log',
|
||||
Logger::DEBUG
|
||||
);
|
||||
$logger->pushHandler($handler);
|
||||
|
||||
return $logger;
|
||||
},
|
||||
|
||||
// Repositories
|
||||
DrinkTypeRepository::class => fn(EntityManagerInterface $entityManager): DrinkTypeRepository => new DrinkTypeRepository($entityManager),
|
||||
InventoryRecordRepository::class => fn(EntityManagerInterface $entityManager): InventoryRecordRepository => new InventoryRecordRepository($entityManager),
|
||||
OrderRepository::class => fn(EntityManagerInterface $entityManager): OrderRepository => new OrderRepository($entityManager),
|
||||
OrderItemRepository::class => fn(EntityManagerInterface $entityManager): OrderItemRepository => new OrderItemRepository($entityManager),
|
||||
SystemConfigRepository::class => fn(EntityManagerInterface $entityManager): SystemConfigRepository => new SystemConfigRepository($entityManager),
|
||||
];
|
134
docs/database-schema.md
Normal file
134
docs/database-schema.md
Normal file
|
@ -0,0 +1,134 @@
|
|||
# Database Schema Design
|
||||
|
||||
This document outlines the database schema for the Drinks Inventory System.
|
||||
|
||||
## Entity Relationship Diagram
|
||||
|
||||
```
|
||||
+----------------+ +-------------------+ +----------------+
|
||||
| DrinkType | | InventoryRecord | | Order |
|
||||
+----------------+ +-------------------+ +----------------+
|
||||
| id | | id | | id |
|
||||
| name | | drink_type_id |<----->| created_at |
|
||||
| description | | quantity | | updated_at |
|
||||
| desired_stock |<----->| timestamp | | status |
|
||||
| created_at | | created_at | +----------------+
|
||||
| updated_at | | updated_at | ^
|
||||
+----------------+ +-------------------+ |
|
||||
|
|
||||
|
|
||||
+----------------+
|
||||
| OrderItem |
|
||||
+----------------+
|
||||
| id |
|
||||
| order_id |
|
||||
| drink_type_id |
|
||||
| quantity |
|
||||
| created_at |
|
||||
| updated_at |
|
||||
+----------------+
|
||||
|
||||
+-------------------+
|
||||
| SystemConfig |
|
||||
+-------------------+
|
||||
| id |
|
||||
| key |
|
||||
| value |
|
||||
| created_at |
|
||||
| updated_at |
|
||||
+-------------------+
|
||||
```
|
||||
|
||||
## Tables
|
||||
|
||||
### DrinkType
|
||||
|
||||
Stores information about different types of drinks.
|
||||
|
||||
| Column | Type | Constraints | Description |
|
||||
|----------------|--------------|-------------------|---------------------------------------|
|
||||
| id | INT | PK, AUTO_INCREMENT| Unique identifier |
|
||||
| name | VARCHAR(255) | UNIQUE, NOT NULL | Name of the drink type |
|
||||
| description | TEXT | NULL | Optional description |
|
||||
| desired_stock | INT | NOT NULL | Desired stock level in crates |
|
||||
| created_at | DATETIME | NOT NULL | Record creation timestamp |
|
||||
| updated_at | DATETIME | NOT NULL | Record last update timestamp |
|
||||
|
||||
### InventoryRecord
|
||||
|
||||
Stores the history of inventory changes.
|
||||
|
||||
| Column | Type | Constraints | Description |
|
||||
|----------------|--------------|-------------------|---------------------------------------|
|
||||
| id | INT | PK, AUTO_INCREMENT| Unique identifier |
|
||||
| drink_type_id | INT | FK, NOT NULL | Reference to DrinkType |
|
||||
| quantity | INT | NOT NULL | Current quantity in crates |
|
||||
| timestamp | DATETIME | NOT NULL | When the inventory was recorded |
|
||||
| created_at | DATETIME | NOT NULL | Record creation timestamp |
|
||||
| updated_at | DATETIME | NOT NULL | Record last update timestamp |
|
||||
|
||||
### Order
|
||||
|
||||
Stores information about orders.
|
||||
|
||||
| Column | Type | Constraints | Description |
|
||||
|----------------|--------------|-------------------|---------------------------------------|
|
||||
| id | INT | PK, AUTO_INCREMENT| Unique identifier |
|
||||
| status | ENUM | NOT NULL | Order status (new, in work, ordered, fulfilled) |
|
||||
| created_at | DATETIME | NOT NULL | Record creation timestamp |
|
||||
| updated_at | DATETIME | NOT NULL | Record last update timestamp |
|
||||
|
||||
### OrderItem
|
||||
|
||||
Stores items within an order.
|
||||
|
||||
| Column | Type | Constraints | Description |
|
||||
|----------------|--------------|-------------------|---------------------------------------|
|
||||
| id | INT | PK, AUTO_INCREMENT| Unique identifier |
|
||||
| order_id | INT | FK, NOT NULL | Reference to Order |
|
||||
| drink_type_id | INT | FK, NOT NULL | Reference to DrinkType |
|
||||
| quantity | INT | NOT NULL | Quantity to order in crates |
|
||||
| created_at | DATETIME | NOT NULL | Record creation timestamp |
|
||||
| updated_at | DATETIME | NOT NULL | Record last update timestamp |
|
||||
|
||||
### SystemConfig
|
||||
|
||||
Stores system configuration parameters.
|
||||
|
||||
| Column | Type | Constraints | Description |
|
||||
|----------------|--------------|-------------------|---------------------------------------|
|
||||
| id | INT | PK, AUTO_INCREMENT| Unique identifier |
|
||||
| key | VARCHAR(255) | UNIQUE, NOT NULL | Configuration key |
|
||||
| value | TEXT | NOT NULL | Configuration value |
|
||||
| created_at | DATETIME | NOT NULL | Record creation timestamp |
|
||||
| updated_at | DATETIME | NOT NULL | Record last update timestamp |
|
||||
|
||||
## Relationships
|
||||
|
||||
1. **DrinkType to InventoryRecord**: One-to-Many
|
||||
- A drink type can have multiple inventory records
|
||||
- Each inventory record belongs to one drink type
|
||||
|
||||
2. **DrinkType to OrderItem**: One-to-Many
|
||||
- A drink type can be included in multiple order items
|
||||
- Each order item refers to one drink type
|
||||
|
||||
3. **Order to OrderItem**: One-to-Many
|
||||
- An order can contain multiple order items
|
||||
- Each order item belongs to one order
|
||||
|
||||
## Indexes
|
||||
|
||||
- `drink_type_id` in `InventoryRecord` table
|
||||
- `order_id` in `OrderItem` table
|
||||
- `drink_type_id` in `OrderItem` table
|
||||
- `key` in `SystemConfig` table
|
||||
|
||||
## Configuration Parameters
|
||||
|
||||
The `SystemConfig` table will store the following configuration parameters:
|
||||
|
||||
- `stock_adjustment_lookback`: How many past orders and the stock at their time should be considered
|
||||
- `stock_adjustment_magnitude`: How much to adjust desired stock levels by
|
||||
- `stock_adjustment_threshold`: Threshold for triggering adjustments
|
||||
- Other system-wide configuration parameters
|
103
docs/development-guidelines.md
Normal file
103
docs/development-guidelines.md
Normal file
|
@ -0,0 +1,103 @@
|
|||
# Development Guidelines
|
||||
|
||||
This document outlines the development workflow and guidelines for the Drinks Inventory System.
|
||||
|
||||
## Development Environment Setup
|
||||
|
||||
### Requirements
|
||||
- PHP 8.4 or higher
|
||||
- Composer
|
||||
- SQLite
|
||||
- Git
|
||||
|
||||
### General
|
||||
|
||||
- Use classes for entities, repositories, services and controllers
|
||||
- Use Doctrine ORM for database interactions
|
||||
- Use twig for templates
|
||||
- Register services in the container and use dependency injection
|
||||
- Write unit as well as feature tests
|
||||
- Always use DateTimeImmutable unless there is a good reason not to
|
||||
- Leverage modern php features such as enums, property promotion, and property hooks
|
||||
|
||||
### PHP Constructor Property Promotion and Readonly Properties
|
||||
|
||||
- Use constructor property promotion for all properties that are initialized in the constructor
|
||||
- Use readonly properties for properties that should not be modified after initialization
|
||||
- Only use readonly for properties that don't have setter methods
|
||||
- Example:
|
||||
```php
|
||||
class Example
|
||||
{
|
||||
private ?int $id = null;
|
||||
|
||||
public function __construct(
|
||||
private string $name,
|
||||
private readonly DateTimeImmutable $createdAt = new DateTimeImmutable(),
|
||||
private DateTimeImmutable $updatedAt = new DateTimeImmutable()
|
||||
) {
|
||||
}
|
||||
|
||||
// Getter for readonly property
|
||||
public function getCreatedAt(): DateTimeImmutable
|
||||
{
|
||||
return $this->createdAt;
|
||||
}
|
||||
|
||||
// Getter and setter for non-readonly property
|
||||
public function getName(): string
|
||||
{
|
||||
return $this->name;
|
||||
}
|
||||
|
||||
public function setName(string $name): self
|
||||
{
|
||||
$this->name = $name;
|
||||
$this->updateTimestamp();
|
||||
return $this;
|
||||
}
|
||||
|
||||
private function updateTimestamp(): void
|
||||
{
|
||||
$this->updatedAt = new DateTimeImmutable();
|
||||
}
|
||||
}
|
||||
|
||||
### Doctrine ORM Guidelines
|
||||
|
||||
- Use PHP 8 attributes for entity mapping (not annotations or XML)
|
||||
- Place all entity classes in the `src/Entity` directory
|
||||
- Place all repository classes in the `src/Repository` directory
|
||||
- Use the naming convention `EntityNameRepository` for repository classes
|
||||
- Define relationships between entities using appropriate Doctrine annotations (`OneToMany`, `ManyToOne`, etc.)
|
||||
- Use `readonly` for properties that should not change after entity creation (like `createdAt`)
|
||||
- Always include `created_at` and `updated_at` fields in all entities
|
||||
- Implement an `updateTimestamp()` method to update the `updated_at` field when an entity is modified
|
||||
- Use the repository pattern for database operations, not direct entity manager operations in services
|
||||
- Define custom finder methods in repository classes for specific query needs
|
||||
- Use Doctrine's query builder for complex queries
|
||||
- Always validate entity data before persisting to the database
|
||||
|
||||
Example entity with Doctrine attributes:
|
||||
```php
|
||||
#[ORM\Entity(repositoryClass: ExampleRepository::class)]
|
||||
#[ORM\Table(name: 'example')]
|
||||
class Example
|
||||
{
|
||||
#[ORM\Id]
|
||||
#[ORM\GeneratedValue]
|
||||
#[ORM\Column(type: 'integer')]
|
||||
private ?int $id = null;
|
||||
|
||||
#[ORM\Column(type: 'string', length: 255)]
|
||||
private string $name;
|
||||
|
||||
#[ORM\Column(type: 'datetime_immutable')]
|
||||
private readonly DateTimeImmutable $createdAt;
|
||||
|
||||
#[ORM\Column(type: 'datetime_immutable')]
|
||||
private DateTimeImmutable $updatedAt;
|
||||
|
||||
// Constructor, getters, setters, etc.
|
||||
}
|
||||
```
|
127
docs/directory-structure.md
Normal file
127
docs/directory-structure.md
Normal file
|
@ -0,0 +1,127 @@
|
|||
# Directory Structure Proposal
|
||||
|
||||
This document outlines the proposed directory structure for the Drinks Inventory System.
|
||||
|
||||
```
|
||||
saufen/
|
||||
├── docs/ # Documentation
|
||||
│ ├── database-schema.md
|
||||
│ ├── development-guidelines.md
|
||||
│ ├── feature-specifications.md
|
||||
│ ├── implementation-tasks.md
|
||||
│ └── directory-structure.md
|
||||
├── public/ # Public web root
|
||||
│ ├── index.php # Entry point
|
||||
│ └── assets/ # Static assets
|
||||
│ ├── css/ # CSS files
|
||||
│ ├── js/ # JavaScript files
|
||||
│ └── images/ # Image files
|
||||
├── config/ # Configuration files
|
||||
│ ├── container.php # DI container factory
|
||||
│ ├── definitions.php # Container Definitions
|
||||
│ └── database.php # Database configuration
|
||||
├── src/ # Application source code
|
||||
│ ├── bootstrap.php # Application bootstrap
|
||||
│ ├── routes.php # Route definitions
|
||||
│ ├── Entity/ # Entity classes
|
||||
│ │ ├── DrinkType.php
|
||||
│ │ ├── InventoryRecord.php
|
||||
│ │ ├── Order.php
|
||||
│ │ ├── OrderItem.php
|
||||
│ │ └── SystemConfig.php
|
||||
│ ├── Repository/ # Repository classes
|
||||
│ │ ├── DrinkTypeRepository.php
|
||||
│ │ ├── InventoryRecordRepository.php
|
||||
│ │ ├── OrderRepository.php
|
||||
│ │ ├── OrderItemRepository.php
|
||||
│ │ └── SystemConfigRepository.php
|
||||
│ ├── Service/ # Service classes
|
||||
│ │ ├── DrinkTypeService.php
|
||||
│ │ ├── InventoryService.php
|
||||
│ │ ├── OrderService.php
|
||||
│ │ ├── StockAdjustmentService.php
|
||||
│ │ └── ConfigurationService.php
|
||||
│ ├── Controller/ # Controller classes
|
||||
│ │ ├── DashboardController.php
|
||||
│ │ ├── DrinkTypeController.php
|
||||
│ │ ├── InventoryController.php
|
||||
│ │ ├── OrderController.php
|
||||
│ │ └── SettingsController.php
|
||||
│ ├── Middleware/ # Middleware classes
|
||||
│ │ └── ErrorHandlerMiddleware.php
|
||||
├── templates/ # Twig templates
|
||||
│ ├── layout.twig # Base layout
|
||||
│ ├── components/ # Reusable components
|
||||
│ │ ├── navigation.twig
|
||||
│ │ ├── flash-messages.twig
|
||||
│ │ └── form-elements.twig
|
||||
│ ├── dashboard/
|
||||
│ │ └── index.twig
|
||||
│ ├── drink-types/
|
||||
│ │ ├── index.twig
|
||||
│ │ ├── form.twig
|
||||
│ │ └── detail.twig
|
||||
│ ├── inventory/
|
||||
│ │ ├── index.twig
|
||||
│ │ ├── update-form.twig
|
||||
│ │ └── history.twig
|
||||
│ ├── orders/
|
||||
│ │ ├── index.twig
|
||||
│ │ ├── form.twig
|
||||
│ │ ├── detail.twig
|
||||
│ │ └── status-form.twig
|
||||
│ └── settings/
|
||||
│ └── index.twig
|
||||
├── tests/ # Test files
|
||||
│ └── Feature/ # Feature tests
|
||||
│ ├── Controller/
|
||||
│ └── Integration/
|
||||
│ └── Service/
|
||||
├── var/ # Variable data
|
||||
│ ├── cache/ # Cache files
|
||||
│ ├── logs/ # Log files
|
||||
│ └── database.sqlite # SQLite database file
|
||||
├── vendor/ # Composer dependencies
|
||||
├── composer.json # Composer configuration
|
||||
├── composer.lock # Composer lock file
|
||||
├── ecs.php # Easy Coding Standard config
|
||||
└── rector.php # Rector config
|
||||
```
|
||||
|
||||
## Key Directories and Files
|
||||
|
||||
### src/
|
||||
Contains all the application source code, organized by component type.
|
||||
|
||||
- **Entity/**: Contains the entity classes that represent the database tables.
|
||||
- **Repository/**: Contains the repository classes that handle database operations.
|
||||
- **Service/**: Contains the service classes that implement business logic.
|
||||
- **Controller/**: Contains the controller classes that handle HTTP requests.
|
||||
- **Middleware/**: Contains middleware classes for request/response processing.
|
||||
- **Util/**: Contains utility classes and helper functions.
|
||||
- **config/**: Contains configuration files.
|
||||
|
||||
### templates/
|
||||
Contains all the Twig templates, organized by feature.
|
||||
|
||||
- **layout.twig**: The base layout template.
|
||||
- **components/**: Reusable template components.
|
||||
- **dashboard/**, **drink-types/**, **inventory/**, **orders/**, **settings/**: Feature-specific templates.
|
||||
|
||||
### public/
|
||||
Contains the web root and static assets.
|
||||
|
||||
- **index.php**: The application entry point.
|
||||
- **assets/**: Static assets like CSS, JavaScript, and images.
|
||||
|
||||
### tests/
|
||||
Contains all test files, organized by test type.
|
||||
|
||||
- **Unit/**: Unit tests for individual classes.
|
||||
- **Feature/**: Feature tests for controllers and integration tests.
|
||||
|
||||
### var/
|
||||
Contains variable data like cache files, logs, and the SQLite database file.
|
||||
|
||||
### docs/
|
||||
Contains project documentation.
|
135
docs/feature-specifications.md
Normal file
135
docs/feature-specifications.md
Normal file
|
@ -0,0 +1,135 @@
|
|||
# Feature Specifications
|
||||
|
||||
This document outlines the features and requirements for the Drinks Inventory System.
|
||||
|
||||
## Core Features
|
||||
|
||||
### 1. Drink Type Management
|
||||
|
||||
#### Description
|
||||
Allow users to manage different types of drinks in the inventory system.
|
||||
|
||||
#### User Stories
|
||||
- As a user, I want to add a new type of drink to the system
|
||||
- As a user, I want to edit existing drink types
|
||||
- As a user, I want to view a list of all drink types
|
||||
- As a user, I want to specify the desired amount of crates for each drink type
|
||||
|
||||
#### Acceptance Criteria
|
||||
- Users can create new drink types with the following information:
|
||||
- Name
|
||||
- Description (optional)
|
||||
- Desired stock level (in crates)
|
||||
- Users can edit existing drink types
|
||||
- Users can view a list of all drink types with their current and desired stock levels
|
||||
- The system validates that drink names are unique
|
||||
|
||||
### 2. Inventory Management
|
||||
|
||||
#### Description
|
||||
Allow users to manage the current inventory of drinks.
|
||||
|
||||
#### User Stories
|
||||
- As a user, I want to update the current stock level of a drink
|
||||
- As a user, I want to view the current stock levels of all drinks
|
||||
- As a user, I want to see the history of stock changes for each drink
|
||||
|
||||
#### Acceptance Criteria
|
||||
- Users can update the current stock level of a drink
|
||||
- The system records each stock update with a timestamp
|
||||
- Users can view a history of stock changes for each drink
|
||||
- The system displays the difference between current and desired stock levels
|
||||
|
||||
### 3. Order Management
|
||||
|
||||
#### Description
|
||||
Allow users to create and manage orders for drinks.
|
||||
|
||||
#### User Stories
|
||||
- As a user, I want the system to automatically calculate how much of each drink to order
|
||||
- As a user, I want to create a new order
|
||||
- As a user, I want to update the status of an order
|
||||
- As a user, I want to view all orders and their statuses
|
||||
|
||||
#### Acceptance Criteria
|
||||
- The system automatically calculates order quantities based on:
|
||||
- Current stock levels
|
||||
- Desired stock levels
|
||||
- Users can create new orders with the following states:
|
||||
- New
|
||||
- In Work
|
||||
- Ordered
|
||||
- Fulfilled
|
||||
- Users can update the status of an order
|
||||
- Users can view all orders with their details and current status
|
||||
|
||||
### 4. Adaptive Stock Level Adjustment
|
||||
|
||||
#### Description
|
||||
The system should automatically adjust desired stock levels based on consumption patterns.
|
||||
|
||||
#### User Stories
|
||||
- As a user, I want the system to automatically adjust desired stock levels based on consumption patterns
|
||||
- As a user, I want to configure the parameters for automatic stock level adjustments
|
||||
|
||||
#### Acceptance Criteria
|
||||
- The system analyzes consumption patterns when a new order is created
|
||||
- If a drink is consistently overstocked at order time, the system decreases its desired stock level
|
||||
- If a drink is consistently understocked at order time, the system increases its desired stock level
|
||||
- Users can configure the parameters for these adjustments, including:
|
||||
- Adjustment frequency
|
||||
- Adjustment magnitude
|
||||
- Threshold for triggering adjustments
|
||||
|
||||
## User Interface Requirements
|
||||
|
||||
### General UI Requirements
|
||||
- Simple, intuitive interface
|
||||
- Mobile-friendly design
|
||||
- Clear visual indicators for stock levels (e.g., color coding for low stock)
|
||||
- Responsive design for use on different devices
|
||||
|
||||
### Main Views
|
||||
1. **Dashboard**
|
||||
- Overview of current stock levels
|
||||
- Alerts for low stock items
|
||||
- Quick update form to update current stock levels
|
||||
- Quick access to create new orders
|
||||
|
||||
2. **Drink Types Management**
|
||||
- List of all drink types
|
||||
- Form for adding/editing drink types
|
||||
- Display of current and desired stock levels
|
||||
|
||||
3. **Inventory Management**
|
||||
- Interface for updating current stock levels
|
||||
- History of stock changes
|
||||
- Stock level trends over time
|
||||
|
||||
4. **Order Management**
|
||||
- List of all orders with their statuses
|
||||
- Interface for creating new orders
|
||||
- Interface for updating order statuses
|
||||
|
||||
5. **Settings**
|
||||
- Configuration for automatic stock level adjustments
|
||||
- System preferences
|
||||
|
||||
## Non-Functional Requirements
|
||||
|
||||
### Performance
|
||||
- Page load times should be under 2 seconds
|
||||
- Database queries should be optimized for performance
|
||||
|
||||
### Usability
|
||||
- The system should be usable without training
|
||||
- Forms should include validation and helpful error messages
|
||||
- The interface should be accessible
|
||||
|
||||
### Security
|
||||
- The system will be used internally without login
|
||||
- Input validation to prevent SQL injection and other attacks
|
||||
|
||||
### Reliability
|
||||
- The system should handle concurrent users
|
||||
- Data should be backed up regularly
|
128
docs/implementation-plan.md
Normal file
128
docs/implementation-plan.md
Normal file
|
@ -0,0 +1,128 @@
|
|||
# Implementation Plan with Timeline
|
||||
|
||||
This document outlines a proposed implementation plan with timeline for the Drinks Inventory System.
|
||||
|
||||
## Phase 1: Project Setup and Foundation (Week 1)
|
||||
|
||||
### Week 1: Setup and Basic Structure
|
||||
- **Days 1-2: Project Setup**
|
||||
- Set up database connection and configuration
|
||||
- Create database schema migration scripts
|
||||
- Configure dependency injection container
|
||||
- Set up Twig template engine
|
||||
- Create base layout template
|
||||
- Set up error handling and logging
|
||||
|
||||
- **Days 3-5: Entity and Repository Classes**
|
||||
- Create all entity classes (DrinkType, InventoryRecord, Order, OrderItem, SystemConfig)
|
||||
- Create all repository classes with basic CRUD operations
|
||||
- Set up database schema and initial migrations
|
||||
- Write unit tests for entities and repositories
|
||||
|
||||
## Phase 2: Core Features Implementation (Weeks 2-3)
|
||||
|
||||
### Week 2: Drink Type and Inventory Management
|
||||
- **Days 1-2: Service Layer**
|
||||
- Implement DrinkTypeService
|
||||
- Implement InventoryService
|
||||
- Write unit tests for services
|
||||
|
||||
- **Days 3-5: Controllers and Templates**
|
||||
- Implement DrinkTypeController with routes
|
||||
- Implement InventoryController with routes
|
||||
- Create templates for drink type management
|
||||
- Create templates for inventory management
|
||||
- Write feature tests for controllers
|
||||
|
||||
### Week 3: Order Management
|
||||
- **Days 1-3: Order Management Implementation**
|
||||
- Implement OrderService with automatic order calculation
|
||||
- Implement OrderController with routes
|
||||
- Create templates for order management
|
||||
- Write unit and feature tests
|
||||
|
||||
- **Days 4-5: Dashboard Implementation**
|
||||
- Implement DashboardController
|
||||
- Create dashboard template with overview of stock levels
|
||||
- Implement low stock alerts
|
||||
- Create quick update form for inventory
|
||||
|
||||
## Phase 3: Advanced Features and Refinement (Weeks 4-5)
|
||||
|
||||
### Week 4: Adaptive Stock Level and Settings
|
||||
- **Days 1-3: Adaptive Stock Level Adjustment**
|
||||
- Implement StockAdjustmentService
|
||||
- Implement consumption pattern analysis
|
||||
- Implement automatic stock level adjustment
|
||||
- Write unit tests for stock adjustment
|
||||
|
||||
- **Days 4-5: Settings Implementation**
|
||||
- Implement ConfigurationService
|
||||
- Implement SettingsController
|
||||
- Create settings templates
|
||||
- Write tests for configuration
|
||||
|
||||
### Week 5: UI Enhancements and Refinement
|
||||
- **Days 1-3: UI Enhancements**
|
||||
- Implement responsive design
|
||||
- Add color coding for stock levels
|
||||
- Implement mobile-friendly interface
|
||||
- Add form validation and error messages
|
||||
- Implement loading indicators
|
||||
|
||||
- **Days 4-5: Testing and Bug Fixes**
|
||||
- Conduct comprehensive testing
|
||||
- Fix identified bugs
|
||||
- Optimize performance
|
||||
- Refine user experience
|
||||
|
||||
## Phase 4: Documentation and Deployment (Week 6)
|
||||
|
||||
### Week 6: Documentation and Deployment
|
||||
- **Days 1-3: Documentation**
|
||||
- Create user documentation
|
||||
- Document API endpoints
|
||||
- Document configuration options
|
||||
- Update technical documentation
|
||||
|
||||
- **Days 4-5: Deployment**
|
||||
- Set up production environment
|
||||
- Configure database for production
|
||||
- Set up backup system
|
||||
- Configure error logging for production
|
||||
- Perform final testing in production environment
|
||||
|
||||
## Development Approach
|
||||
|
||||
### Iterative Development
|
||||
- Each feature will be developed following these steps:
|
||||
1. Implement entity and repository classes
|
||||
2. Implement service layer with business logic
|
||||
3. Implement controller with routes
|
||||
4. Create templates and UI components
|
||||
5. Write tests
|
||||
6. Review and refine
|
||||
|
||||
### Testing Strategy
|
||||
- Unit tests for all entity, repository, and service classes
|
||||
- Feature tests for controllers and UI flows
|
||||
- Integration tests for database operations
|
||||
- Manual testing for UI and user experience
|
||||
|
||||
### Code Quality Assurance
|
||||
- Follow PSR coding standards
|
||||
- Use static analysis tools (PHPStan, Rector)
|
||||
- Conduct code reviews
|
||||
- Maintain high test coverage
|
||||
|
||||
## Risk Management
|
||||
|
||||
### Potential Risks and Mitigation Strategies
|
||||
- **Scope Creep**: Strictly adhere to the defined requirements and manage change requests
|
||||
- **Technical Challenges**: Allocate additional time for complex features
|
||||
- **Integration Issues**: Implement comprehensive integration testing
|
||||
- **Performance Issues**: Conduct performance testing early and optimize as needed
|
||||
|
||||
## Conclusion
|
||||
|
||||
This implementation plan provides a structured approach to developing the Drinks Inventory System over a 6-week period. The phased approach allows for incremental development and testing, ensuring that each component is properly implemented and integrated before moving on to the next phase.
|
166
docs/implementation-tasks.md
Normal file
166
docs/implementation-tasks.md
Normal file
|
@ -0,0 +1,166 @@
|
|||
# Implementation Tasks for Drinks Inventory System
|
||||
|
||||
This document outlines the tasks required to implement the Drinks Inventory System based on the feature specifications, database schema, and development guidelines.
|
||||
Always run our qualitytools after implementing a step:
|
||||
run:
|
||||
- composer lint
|
||||
- composer test
|
||||
|
||||
## 1. Project Setup and Configuration
|
||||
|
||||
- [x] Set up database connection and configuration
|
||||
- [x] Configure dependency injection container
|
||||
- [x] Set up Twig template engine
|
||||
- [x] Create base layout template
|
||||
- [x] Set up error handling and logging
|
||||
|
||||
## 2. Entity Classes
|
||||
|
||||
- [x] Create DrinkType entity class
|
||||
- [x] Create InventoryRecord entity class
|
||||
- [x] Create Order entity class
|
||||
- [x] Create OrderItem entity class
|
||||
- [x] Create SystemConfig entity class
|
||||
|
||||
## 3. Repository Classes
|
||||
|
||||
- [x] Create DrinkTypeRepository
|
||||
- [x] Create InventoryRecordRepository
|
||||
- [x] Create OrderRepository
|
||||
- [x] Create OrderItemRepository
|
||||
- [x] Create SystemConfigRepository
|
||||
|
||||
## 4. Service Classes
|
||||
|
||||
- [x] Create DrinkTypeService
|
||||
- [x] Create InventoryService
|
||||
- [x] Create OrderService
|
||||
- [x] Create StockAdjustmentService
|
||||
- [x] Create ConfigurationService
|
||||
|
||||
## 5. Controller Classes
|
||||
|
||||
- [x] Create DashboardController
|
||||
- [x] Create DrinkTypeController
|
||||
- [x] Create InventoryController
|
||||
- [x] Create OrderController
|
||||
- [x] Create SettingsController
|
||||
|
||||
## 6. Templates
|
||||
|
||||
### 6.1 Layout and Common Components
|
||||
- [x] Create base layout template
|
||||
- [x] Create navigation component
|
||||
- [x] Create flash message component
|
||||
- [x] Create form components (inputs, buttons, etc.)
|
||||
|
||||
### 6.2 Dashboard
|
||||
- [x] Create dashboard template with overview of stock levels
|
||||
- [x] Create low stock alerts component
|
||||
- [x] Create quick update form
|
||||
|
||||
### 6.3 Drink Types
|
||||
- [x] Create drink type list template
|
||||
- [x] Create drink type form template (add/edit)
|
||||
- [x] Create drink type detail template
|
||||
|
||||
### 6.4 Inventory
|
||||
- [x] Create inventory overview template
|
||||
- [x] Create stock update form
|
||||
- [x] Create inventory history template
|
||||
|
||||
### 6.5 Orders
|
||||
- [x] Create order list template
|
||||
- [x] Create order creation form
|
||||
- [x] Create order detail template
|
||||
- [x] Create order status update form
|
||||
|
||||
### 6.6 Settings
|
||||
- [x] Create settings form template
|
||||
|
||||
## 7. Routes
|
||||
|
||||
- [x] Configure dashboard routes
|
||||
- [x] Configure drink type routes
|
||||
- [x] Configure inventory routes
|
||||
- [x] Configure order routes
|
||||
- [x] Configure settings routes
|
||||
|
||||
## 8. Feature Implementation
|
||||
|
||||
### 8.1 Drink Type Management
|
||||
- [ ] Implement adding new drink types
|
||||
- [ ] Implement editing existing drink types
|
||||
- [ ] Implement listing all drink types
|
||||
- [ ] Implement validation for drink type uniqueness
|
||||
|
||||
### 8.2 Inventory Management
|
||||
- [ ] Implement updating current stock levels
|
||||
- [ ] Implement viewing current stock levels
|
||||
- [ ] Implement viewing stock change history
|
||||
- [ ] Implement displaying difference between current and desired stock
|
||||
|
||||
### 8.3 Order Management
|
||||
- [ ] Implement automatic order quantity calculation
|
||||
- [ ] Implement order creation
|
||||
- [ ] Implement order status updates
|
||||
- [ ] Implement order listing and details
|
||||
|
||||
### 8.4 Adaptive Stock Level Adjustment
|
||||
- [x] Implement consumption pattern analysis
|
||||
- [x] Analyze the last 5 orders for each drink type
|
||||
- [x] Apply weighted importance (most recent orders count more)
|
||||
- [x] Calculate suggested stock levels based on consumption patterns
|
||||
- [x] Implement automatic stock level adjustment
|
||||
- [x] Adjust desired stock levels based on weighted consumption
|
||||
- [x] Apply gradual adjustments to avoid drastic changes
|
||||
- [x] Ensure minimum stock levels are maintained
|
||||
- [x] Implement configuration for adjustment parameters
|
||||
- [x] Configure number of orders to analyze (default: 5)
|
||||
- [x] Configure adjustment magnitude (default: 20%)
|
||||
- [x] Configure adjustment threshold (default: 10%)
|
||||
|
||||
## 9. UI Enhancements
|
||||
|
||||
- [x] Implement responsive design
|
||||
- [ ] Add color coding for stock levels
|
||||
- [x] Implement mobile-friendly interface
|
||||
- [ ] Add form validation and error messages
|
||||
- [ ] Implement loading indicators
|
||||
|
||||
## 10. Testing
|
||||
|
||||
- [ ] Write unit tests for entity classes
|
||||
- [ ] Write unit tests for repository classes
|
||||
- [ ] Write unit tests for service classes
|
||||
- [ ] Write feature tests for controllers
|
||||
- [ ] Write integration tests for database operations
|
||||
|
||||
## 11. Documentation
|
||||
|
||||
- [ ] Create user documentation
|
||||
- [ ] Document API endpoints
|
||||
- [ ] Document database schema
|
||||
- [ ] Document configuration options
|
||||
|
||||
## 12. Deployment
|
||||
|
||||
- [ ] Set up production environment
|
||||
- [ ] Configure database for production
|
||||
- [ ] Set up backup system
|
||||
- [ ] Configure error logging for production
|
||||
|
||||
## Implementation Order
|
||||
|
||||
For efficient development, the following implementation order is recommended:
|
||||
|
||||
1. Project setup and configuration
|
||||
2. Entity and repository classes
|
||||
3. Basic service classes
|
||||
4. Basic templates and controllers
|
||||
5. Core features (Drink Type and Inventory Management)
|
||||
6. Order Management
|
||||
7. Adaptive Stock Level Adjustment
|
||||
8. UI enhancements
|
||||
9. Testing and bug fixes
|
||||
10. Documentation and deployment
|
77
docs/project-summary.md
Normal file
77
docs/project-summary.md
Normal file
|
@ -0,0 +1,77 @@
|
|||
# Drinks Inventory System - Project Summary
|
||||
|
||||
## Project Overview
|
||||
|
||||
The Drinks Inventory System is a web application designed to manage the inventory of drinks, track stock levels, create orders, and automatically adjust desired stock levels based on consumption patterns. The system will be built using PHP 8.4+, Slim framework, Twig templates, and SQLite database.
|
||||
|
||||
## Key Documents
|
||||
|
||||
This project is documented through several key files:
|
||||
|
||||
1. **[Feature Specifications](feature-specifications.md)**: Outlines the core features and requirements of the system.
|
||||
2. **[Database Schema](database-schema.md)**: Defines the database structure and relationships.
|
||||
3. **[Development Guidelines](development-guidelines.md)**: Provides guidelines for development workflow and standards.
|
||||
4. **[Implementation Tasks](implementation-tasks.md)**: Lists all tasks required to implement the system.
|
||||
5. **[Directory Structure](directory-structure.md)**: Proposes an organized directory structure for the project.
|
||||
6. **[Implementation Plan](implementation-plan.md)**: Outlines a timeline and approach for implementing the system.
|
||||
|
||||
## Core Features
|
||||
|
||||
The system includes four core features:
|
||||
|
||||
1. **Drink Type Management**: Adding, editing, and viewing drink types with desired stock levels.
|
||||
2. **Inventory Management**: Updating and viewing current stock levels and history.
|
||||
3. **Order Management**: Creating orders based on stock levels and tracking their status.
|
||||
4. **Adaptive Stock Level Adjustment**: Automatically adjusting desired stock levels based on consumption patterns.
|
||||
|
||||
## Technical Architecture
|
||||
|
||||
The application follows a layered architecture:
|
||||
|
||||
1. **Entity Layer**: Represents the database tables (DrinkType, InventoryRecord, Order, OrderItem, SystemConfig).
|
||||
2. **Repository Layer**: Handles database operations for each entity.
|
||||
3. **Service Layer**: Implements business logic and coordinates between repositories.
|
||||
4. **Controller Layer**: Handles HTTP requests and responses.
|
||||
5. **View Layer**: Twig templates for rendering the UI.
|
||||
|
||||
## Database Design
|
||||
|
||||
The database consists of five tables:
|
||||
|
||||
1. **DrinkType**: Stores information about different types of drinks.
|
||||
2. **InventoryRecord**: Stores the history of inventory changes.
|
||||
3. **Order**: Stores information about orders.
|
||||
4. **OrderItem**: Stores items within an order.
|
||||
5. **SystemConfig**: Stores system configuration parameters.
|
||||
|
||||
## Implementation Approach
|
||||
|
||||
The implementation will follow an iterative approach over a 6-week period:
|
||||
|
||||
1. **Phase 1 (Week 1)**: Project setup and foundation
|
||||
2. **Phase 2 (Weeks 2-3)**: Core features implementation
|
||||
3. **Phase 3 (Weeks 4-5)**: Advanced features and refinement
|
||||
4. **Phase 4 (Week 6)**: Documentation and deployment
|
||||
|
||||
## Development Practices
|
||||
|
||||
The project will adhere to the following development practices:
|
||||
|
||||
1. **Object-Oriented Design**: Using classes for entities, repositories, services, and controllers.
|
||||
2. **Dependency Injection**: Registering services in the container and using dependency injection.
|
||||
3. **Testing**: Writing unit and feature tests for all components.
|
||||
4. **Code Quality**: Following PSR coding standards and using static analysis tools.
|
||||
5. **Version Control**: Using Git for version control.
|
||||
|
||||
## Next Steps
|
||||
|
||||
To begin implementation, follow these steps:
|
||||
|
||||
1. Set up the project structure according to the [Directory Structure](directory-structure.md) document.
|
||||
2. Implement the database connection and schema migrations.
|
||||
3. Create the entity and repository classes.
|
||||
4. Follow the [Implementation Plan](implementation-plan.md) for subsequent steps.
|
||||
|
||||
## Conclusion
|
||||
|
||||
This project summary provides an overview of the Drinks Inventory System, its features, architecture, and implementation approach. The detailed documents referenced in this summary provide comprehensive information for implementing the system.
|
33
ecs.php
Normal file
33
ecs.php
Normal file
|
@ -0,0 +1,33 @@
|
|||
<?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',
|
||||
])
|
||||
|
||||
// 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(
|
||||
strict: true,
|
||||
)
|
||||
;
|
6
phpstan.neon
Normal file
6
phpstan.neon
Normal file
|
@ -0,0 +1,6 @@
|
|||
parameters:
|
||||
level: 9
|
||||
paths:
|
||||
- src
|
||||
- config
|
||||
- tests
|
18
phpunit.xml
Normal file
18
phpunit.xml
Normal file
|
@ -0,0 +1,18 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:noNamespaceSchemaLocation="vendor/phpunit/phpunit/phpunit.xsd"
|
||||
bootstrap="vendor/autoload.php"
|
||||
colors="true"
|
||||
>
|
||||
<testsuites>
|
||||
<testsuite name="Test Suite">
|
||||
<directory suffix="Test.php">./tests</directory>
|
||||
</testsuite>
|
||||
</testsuites>
|
||||
<source>
|
||||
<include>
|
||||
<directory>app</directory>
|
||||
<directory>src</directory>
|
||||
</include>
|
||||
</source>
|
||||
</phpunit>
|
488
public/assets/css/styles.css
Normal file
488
public/assets/css/styles.css
Normal file
|
@ -0,0 +1,488 @@
|
|||
/* Pride-themed Bootstrap color overrides */
|
||||
:root {
|
||||
/* Pride flag colors with RGB values */
|
||||
--pride-red: #e40303;
|
||||
--pride-red-rgb: 228, 3, 3;
|
||||
|
||||
--pride-orange: #ff8c00;
|
||||
--pride-orange-rgb: 255, 140, 0;
|
||||
|
||||
--pride-yellow: #ffed00;
|
||||
--pride-yellow-rgb: 255, 237, 0;
|
||||
|
||||
--pride-green: #008026;
|
||||
--pride-green-rgb: 0, 128, 38;
|
||||
|
||||
--pride-blue: #004dff;
|
||||
--pride-blue-rgb: 0, 77, 255;
|
||||
|
||||
--pride-purple: #750787;
|
||||
--pride-purple-rgb: 117, 7, 135;
|
||||
|
||||
/* Additional pride colors */
|
||||
--pride-pink: #ff69b4;
|
||||
--pride-pink-rgb: 255, 105, 180;
|
||||
|
||||
--pride-cyan: #00ffff;
|
||||
--pride-cyan-rgb: 0, 255, 255;
|
||||
|
||||
--pride-lavender: #b57edc;
|
||||
--pride-lavender-rgb: 181, 126, 220;
|
||||
|
||||
/* Override Bootstrap's default colors with pride colors */
|
||||
--bs-primary: var(--pride-blue);
|
||||
--bs-primary-rgb: 0, 77, 255;
|
||||
|
||||
--bs-secondary: var(--pride-purple);
|
||||
--bs-secondary-rgb: 117, 7, 135;
|
||||
|
||||
--bs-success: var(--pride-green);
|
||||
--bs-success-rgb: 0, 128, 38;
|
||||
|
||||
--bs-info: #3ECDF3; /* Light blue from trans flag */
|
||||
--bs-info-rgb: 62, 205, 243;
|
||||
|
||||
--bs-warning: var(--pride-orange);
|
||||
--bs-warning-rgb: 255, 140, 0;
|
||||
|
||||
--bs-danger: var(--pride-red);
|
||||
--bs-danger-rgb: 228, 3, 3;
|
||||
|
||||
/* Replace light and dark with more colorful alternatives */
|
||||
--bs-light: #f0e6ff; /* Light lavender */
|
||||
--bs-light-rgb: 240, 230, 255;
|
||||
|
||||
--bs-dark: #330066; /* Deep purple */
|
||||
--bs-dark-rgb: 51, 0, 102;
|
||||
|
||||
/* Link colors */
|
||||
--bs-link-color: var(--pride-blue);
|
||||
--bs-link-hover-color: var(--pride-purple);
|
||||
|
||||
/* Button hover effects */
|
||||
--bs-btn-hover-bg-shade-amount: 15%;
|
||||
--bs-btn-hover-bg-tint-amount: 15%;
|
||||
|
||||
/* Card and border colors */
|
||||
--bs-border-color: rgba(var(--bs-primary-rgb), 0.2);
|
||||
--bs-card-cap-bg: rgba(var(--bs-primary-rgb), 0.1);
|
||||
|
||||
/* Navbar colors */
|
||||
--bs-navbar-active-color: var(--pride-yellow);
|
||||
--bs-navbar-brand-color: var(--pride-yellow);
|
||||
--bs-navbar-brand-hover-color: var(--pride-orange);
|
||||
--bs-navbar-hover-color: var(--pride-orange);
|
||||
}
|
||||
|
||||
/* Custom background classes for pride colors */
|
||||
.bg-pride-red { background-color: var(--pride-red) !important; color: white !important; }
|
||||
.bg-pride-orange { background-color: var(--pride-orange) !important; color: black !important; }
|
||||
.bg-pride-yellow { background-color: var(--pride-yellow) !important; color: black !important; }
|
||||
.bg-pride-green { background-color: var(--pride-green) !important; color: white !important; }
|
||||
.bg-pride-blue { background-color: var(--pride-blue) !important; color: white !important; }
|
||||
.bg-pride-purple { background-color: var(--pride-purple) !important; color: white !important; }
|
||||
.bg-pride-pink { background-color: var(--pride-pink) !important; color: white !important; }
|
||||
.bg-pride-cyan { background-color: var(--pride-cyan) !important; color: black !important; }
|
||||
.bg-pride-lavender { background-color: var(--pride-lavender) !important; color: black !important; }
|
||||
|
||||
/* Body styling with subtle gradient background */
|
||||
body {
|
||||
background: linear-gradient(135deg, var(--bs-light) 0%, white 50%, var(--bs-light) 100%);
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
/* Rainbow gradient backgrounds */
|
||||
.bg-rainbow {
|
||||
background: linear-gradient(
|
||||
to right,
|
||||
var(--pride-red),
|
||||
var(--pride-orange),
|
||||
var(--pride-yellow),
|
||||
var(--pride-green),
|
||||
var(--pride-blue),
|
||||
var(--pride-purple)
|
||||
) !important;
|
||||
color: white !important;
|
||||
text-shadow: 1px 1px 2px rgba(0, 0, 0, 0.7);
|
||||
}
|
||||
|
||||
.bg-rainbow-vertical {
|
||||
background: linear-gradient(
|
||||
to bottom,
|
||||
var(--pride-red),
|
||||
var(--pride-orange),
|
||||
var(--pride-yellow),
|
||||
var(--pride-green),
|
||||
var(--pride-blue),
|
||||
var(--pride-purple)
|
||||
) !important;
|
||||
color: white !important;
|
||||
text-shadow: 1px 1px 2px rgba(0, 0, 0, 0.7);
|
||||
}
|
||||
|
||||
/* Override specific Bootstrap components */
|
||||
.navbar-dark {
|
||||
background: linear-gradient(
|
||||
to right,
|
||||
var(--pride-red),
|
||||
var(--pride-orange),
|
||||
var(--pride-yellow),
|
||||
var(--pride-green),
|
||||
var(--pride-blue),
|
||||
var(--pride-purple)
|
||||
) !important;
|
||||
}
|
||||
|
||||
.navbar-dark .navbar-brand,
|
||||
.navbar-dark .nav-link {
|
||||
color: white !important;
|
||||
text-shadow: 1px 1px 2px rgba(0, 0, 0, 0.7);
|
||||
}
|
||||
|
||||
.navbar-dark .nav-link:hover {
|
||||
color: var(--bs-light) !important;
|
||||
text-shadow: 0 0 5px white;
|
||||
}
|
||||
|
||||
/* Card styling */
|
||||
.card {
|
||||
border-radius: 10px;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 4px 15px rgba(var(--pride-lavender-rgb), 0.3);
|
||||
transition: transform 0.3s ease, box-shadow 0.3s ease;
|
||||
border: none;
|
||||
background-color: rgba(255, 255, 255, 0.9);
|
||||
backdrop-filter: blur(5px);
|
||||
}
|
||||
|
||||
.card:hover {
|
||||
transform: translateY(-20px);
|
||||
box-shadow: 0 15px 30px rgba(var(--pride-pink-rgb), 0.4);
|
||||
}
|
||||
|
||||
.card-header {
|
||||
background: linear-gradient(
|
||||
to right,
|
||||
rgba(var(--pride-blue-rgb), 0.7),
|
||||
rgba(var(--pride-purple-rgb), 0.7)
|
||||
);
|
||||
color: white;
|
||||
text-shadow: 1px 1px 1px rgba(0, 0, 0, 0.5);
|
||||
border-bottom: none;
|
||||
padding: 1rem 1.25rem;
|
||||
}
|
||||
|
||||
.card-body {
|
||||
border-top: 3px solid rgba(255, 255, 255, 0.8);
|
||||
}
|
||||
|
||||
/* Add rainbow border to cards */
|
||||
.card {
|
||||
border: 3px solid transparent;
|
||||
background-clip: padding-box;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.card::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: -3px;
|
||||
left: -3px;
|
||||
right: -3px;
|
||||
bottom: -3px;
|
||||
z-index: -1;
|
||||
border-radius: 12px;
|
||||
background: linear-gradient(
|
||||
45deg,
|
||||
var(--pride-red),
|
||||
var(--pride-orange),
|
||||
var(--pride-yellow),
|
||||
var(--pride-green),
|
||||
var(--pride-blue),
|
||||
var(--pride-purple),
|
||||
var(--pride-pink)
|
||||
);
|
||||
}
|
||||
|
||||
/* Button styling */
|
||||
.btn {
|
||||
border-radius: 25px;
|
||||
font-weight: bold;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.btn:hover {
|
||||
transform: scale(1.2);
|
||||
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
/* Footer styling */
|
||||
footer {
|
||||
background: linear-gradient(
|
||||
to right,
|
||||
var(--pride-purple),
|
||||
var(--pride-blue),
|
||||
var(--pride-green),
|
||||
var(--pride-yellow),
|
||||
var(--pride-orange),
|
||||
var(--pride-red)
|
||||
) !important;
|
||||
color: white !important;
|
||||
text-shadow: 1px 1px 2px rgba(0, 0, 0, 0.7);
|
||||
padding: 1.5rem 0;
|
||||
margin-top: 2rem;
|
||||
}
|
||||
|
||||
/* Table styling */
|
||||
.table {
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 2px 10px rgba(var(--bs-primary-rgb), 0.1);
|
||||
}
|
||||
|
||||
.table thead {
|
||||
background: linear-gradient(
|
||||
to right,
|
||||
rgba(var(--pride-pink-rgb), 0.7),
|
||||
rgba(var(--bs-primary-rgb), 0.7)
|
||||
);
|
||||
color: white;
|
||||
text-shadow: 1px 1px 1px rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
|
||||
.table-striped > tbody > tr:nth-of-type(odd) {
|
||||
background-color: rgba(var(--pride-lavender-rgb), 0.15);
|
||||
}
|
||||
|
||||
.table-striped > tbody > tr:nth-of-type(even) {
|
||||
background-color: rgba(var(--pride-cyan-rgb), 0.1);
|
||||
}
|
||||
|
||||
.table-hover > tbody > tr:hover {
|
||||
background-color: rgba(var(--bs-warning-rgb), 0.2);
|
||||
transform: scale(1.01);
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
/* Badge styling */
|
||||
.badge {
|
||||
border-radius: 12px;
|
||||
padding: 0.5em 0.8em;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* Progress bar styling */
|
||||
.progress {
|
||||
height: 1.2rem;
|
||||
border-radius: 0.6rem;
|
||||
background-color: rgba(var(--bs-primary-rgb), 0.1);
|
||||
}
|
||||
|
||||
.progress-bar {
|
||||
border-radius: 0.6rem;
|
||||
}
|
||||
|
||||
/* Alert styling */
|
||||
.alert {
|
||||
border-radius: 10px;
|
||||
border: none;
|
||||
padding: 1rem 1.5rem;
|
||||
margin-bottom: 1.5rem;
|
||||
box-shadow: 0 4px 10px rgba(0, 0, 0, 0.1);
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
color: #000;
|
||||
font-weight: 500;
|
||||
text-shadow: 0 0 1px rgba(255, 255, 255, 0.5);
|
||||
}
|
||||
|
||||
.alert::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 5px;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.alert-primary {
|
||||
background-color: rgba(var(--pride-blue-rgb), 0.2);
|
||||
}
|
||||
|
||||
.alert-primary::before {
|
||||
background-color: var(--pride-blue);
|
||||
}
|
||||
|
||||
.alert-secondary {
|
||||
background-color: rgba(var(--pride-purple-rgb), 0.2);
|
||||
}
|
||||
|
||||
.alert-secondary::before {
|
||||
background-color: var(--pride-purple);
|
||||
}
|
||||
|
||||
.alert-success {
|
||||
background-color: rgba(var(--pride-green-rgb), 0.2);
|
||||
}
|
||||
|
||||
.alert-success::before {
|
||||
background-color: var(--pride-green);
|
||||
}
|
||||
|
||||
.alert-danger {
|
||||
background-color: rgba(var(--pride-red-rgb), 0.2);
|
||||
}
|
||||
|
||||
.alert-danger::before {
|
||||
background-color: var(--pride-red);
|
||||
}
|
||||
|
||||
.alert-warning {
|
||||
background-color: rgba(var(--pride-orange-rgb), 0.2);
|
||||
}
|
||||
|
||||
.alert-warning::before {
|
||||
background-color: var(--pride-orange);
|
||||
}
|
||||
|
||||
.alert-info {
|
||||
background-color: rgba(var(--bs-info-rgb), 0.2);
|
||||
}
|
||||
|
||||
.alert-info::before {
|
||||
background-color: var(--bs-info);
|
||||
}
|
||||
|
||||
/* Form control styling */
|
||||
.form-control {
|
||||
border-radius: 10px;
|
||||
border: 2px solid rgba(var(--pride-lavender-rgb), 0.3);
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.form-control:focus {
|
||||
border-color: var(--pride-blue);
|
||||
box-shadow: 0 0 0 0.25rem rgba(var(--pride-blue-rgb), 0.25);
|
||||
transform: translateY(-3px);
|
||||
}
|
||||
|
||||
.form-label {
|
||||
font-weight: bold;
|
||||
color: var(--pride-purple);
|
||||
}
|
||||
|
||||
.form-select {
|
||||
border-radius: 10px;
|
||||
border: 2px solid rgba(var(--pride-lavender-rgb), 0.3);
|
||||
background-image: linear-gradient(45deg, var(--pride-purple) 0%, var(--pride-blue) 100%);
|
||||
background-size: 20px 20px;
|
||||
color: var(--bs-dark);
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.form-select:focus {
|
||||
border-color: var(--pride-blue);
|
||||
box-shadow: 0 0 0 0.25rem rgba(var(--pride-blue-rgb), 0.25);
|
||||
transform: translateY(-3px);
|
||||
}
|
||||
|
||||
/* Rainbow text for special elements */
|
||||
.rainbow-text {
|
||||
background-image: linear-gradient(
|
||||
to right,
|
||||
var(--pride-red),
|
||||
var(--pride-orange),
|
||||
var(--pride-yellow),
|
||||
var(--pride-green),
|
||||
var(--pride-blue),
|
||||
var(--pride-purple),
|
||||
var(--pride-pink)
|
||||
);
|
||||
-webkit-background-clip: text;
|
||||
background-clip: text;
|
||||
color: transparent;
|
||||
font-weight: bold;
|
||||
animation: rainbow-shift 5s linear infinite;
|
||||
}
|
||||
|
||||
/* Apply rainbow text to headings */
|
||||
h1, h2, h3 {
|
||||
background-image: linear-gradient(
|
||||
to right,
|
||||
var(--pride-red),
|
||||
var(--pride-orange),
|
||||
var(--pride-yellow),
|
||||
var(--pride-green),
|
||||
var(--pride-blue),
|
||||
var(--pride-purple),
|
||||
var(--pride-pink)
|
||||
);
|
||||
-webkit-background-clip: text;
|
||||
background-clip: text;
|
||||
color: transparent;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
/* Flash messages styling */
|
||||
.flash-messages {
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.flash-message {
|
||||
padding: 1rem;
|
||||
margin-bottom: 1rem;
|
||||
border-radius: 10px;
|
||||
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1);
|
||||
animation: flash-pulse 2s infinite;
|
||||
font-weight: bold;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.flash-success {
|
||||
background: linear-gradient(to right, var(--pride-green), var(--pride-cyan));
|
||||
color: white;
|
||||
text-shadow: 1px 1px 1px rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
|
||||
.flash-error {
|
||||
background: linear-gradient(to right, var(--pride-red), var(--pride-pink));
|
||||
color: white;
|
||||
text-shadow: 1px 1px 1px rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
|
||||
.flash-warning {
|
||||
background: linear-gradient(to right, var(--pride-yellow), var(--pride-orange));
|
||||
color: black;
|
||||
text-shadow: 1px 1px 1px rgba(255, 255, 255, 0.5);
|
||||
}
|
||||
|
||||
.flash-info {
|
||||
background: linear-gradient(to right, var(--pride-blue), var(--pride-lavender));
|
||||
color: white;
|
||||
text-shadow: 1px 1px 1px rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
|
||||
/* Animations */
|
||||
@keyframes rainbow-shift {
|
||||
0% {
|
||||
background-position: 0% 50%;
|
||||
}
|
||||
50% {
|
||||
background-position: 100% 50%;
|
||||
}
|
||||
100% {
|
||||
background-position: 0% 50%;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes flash-pulse {
|
||||
0% {
|
||||
transform: scale(1);
|
||||
}
|
||||
50% {
|
||||
transform: scale(1.02);
|
||||
}
|
||||
100% {
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
21
public/assets/js/app.js
Normal file
21
public/assets/js/app.js
Normal file
|
@ -0,0 +1,21 @@
|
|||
/**
|
||||
* Main application JavaScript file
|
||||
*/
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
// Auto-hide flash messages after 5 seconds
|
||||
const flashMessages = document.querySelectorAll('.flash-message');
|
||||
if (flashMessages.length > 0) {
|
||||
setTimeout(function() {
|
||||
flashMessages.forEach(function(message) {
|
||||
message.style.opacity = '0';
|
||||
message.style.transition = 'opacity 0.5s ease';
|
||||
setTimeout(function() {
|
||||
message.style.display = 'none';
|
||||
}, 500);
|
||||
});
|
||||
}, 5000);
|
||||
}
|
||||
|
||||
// Add any other initialization code here
|
||||
console.log('Drinks Inventory System initialized');
|
||||
});
|
12
public/index.php
Normal file
12
public/index.php
Normal file
|
@ -0,0 +1,12 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Slim\App;
|
||||
|
||||
require __DIR__ . '/../vendor/autoload.php';
|
||||
|
||||
/** @var App $app */
|
||||
$app = require __DIR__ . '/../src/bootstrap.php';
|
||||
|
||||
$app->run();
|
27
rector.php
Normal file
27
rector.php
Normal file
|
@ -0,0 +1,27 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Rector\Config\RectorConfig;
|
||||
use Rector\Php81\Rector\Property\ReadOnlyPropertyRector;
|
||||
|
||||
return RectorConfig::configure()
|
||||
->withPaths([
|
||||
__DIR__ . '/public',
|
||||
__DIR__ . '/src',
|
||||
__DIR__ . '/config',
|
||||
__DIR__ . '/tests',
|
||||
__DIR__ . '/bin',
|
||||
])
|
||||
->withImportNames(removeUnusedImports: true)
|
||||
->withPhpSets()
|
||||
->withPreparedSets(
|
||||
codeQuality: true,
|
||||
typeDeclarations: true,
|
||||
earlyReturn: true,
|
||||
strictBooleans: true,
|
||||
)
|
||||
->withSkip([
|
||||
ReadonlyPropertyRector::class,
|
||||
])
|
||||
;
|
41
src/Controller/DashboardController.php
Normal file
41
src/Controller/DashboardController.php
Normal file
|
@ -0,0 +1,41 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Controller;
|
||||
|
||||
use App\Service\ConfigurationService;
|
||||
use App\Service\DrinkTypeService;
|
||||
use App\Service\InventoryService;
|
||||
use Psr\Http\Message\ResponseInterface as Response;
|
||||
use Slim\Views\Twig;
|
||||
|
||||
class DashboardController
|
||||
{
|
||||
public function __construct(
|
||||
private readonly Twig $view,
|
||||
private readonly InventoryService $inventoryService,
|
||||
private readonly DrinkTypeService $drinkTypeService,
|
||||
private readonly ConfigurationService $configService
|
||||
) {}
|
||||
|
||||
public function index(Response $response): Response
|
||||
{
|
||||
// Get low stock items for the low-stock-alerts component
|
||||
$lowStockItems = $this->inventoryService->getLowStockDrinkTypes();
|
||||
|
||||
// Get all drink types for the quick-update-form component
|
||||
$drinkTypes = $this->drinkTypeService->getAllDrinkTypes();
|
||||
|
||||
// Get configuration settings
|
||||
$showLowStockAlerts = (bool) $this->configService->getConfigValue('show_low_stock_alerts', '1');
|
||||
$showQuickUpdateForm = (bool) $this->configService->getConfigValue('show_quick_update_form', '1');
|
||||
|
||||
return $this->view->render($response, 'dashboard/index.twig', [
|
||||
'lowStockItems' => $lowStockItems,
|
||||
'drinkTypes' => $drinkTypes,
|
||||
'showLowStockAlerts' => $showLowStockAlerts,
|
||||
'showQuickUpdateForm' => $showQuickUpdateForm
|
||||
]);
|
||||
}
|
||||
}
|
151
src/Controller/DrinkTypeController.php
Normal file
151
src/Controller/DrinkTypeController.php
Normal file
|
@ -0,0 +1,151 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Controller;
|
||||
|
||||
use App\Entity\DrinkType;
|
||||
use App\Service\DrinkTypeService;
|
||||
use App\Service\InventoryService;
|
||||
use InvalidArgumentException;
|
||||
use Psr\Http\Message\ResponseInterface as Response;
|
||||
use Psr\Http\Message\ServerRequestInterface as Request;
|
||||
use Slim\Views\Twig;
|
||||
|
||||
class DrinkTypeController
|
||||
{
|
||||
public function __construct(
|
||||
private readonly DrinkTypeService $drinkTypeService,
|
||||
private readonly InventoryService $inventoryService,
|
||||
private readonly Twig $view
|
||||
) {}
|
||||
|
||||
public function index(Response $response): Response
|
||||
{
|
||||
$drinkTypes = $this->drinkTypeService->getAllDrinkTypes();
|
||||
|
||||
// Get current stock levels for all drink types
|
||||
$drinkTypesWithStock = [];
|
||||
foreach ($drinkTypes as $drinkType) {
|
||||
$currentStock = $this->inventoryService->getCurrentStockLevel($drinkType);
|
||||
$drinkTypesWithStock[] = [
|
||||
'id' => $drinkType->getId(),
|
||||
'name' => $drinkType->getName(),
|
||||
'description' => $drinkType->getDescription(),
|
||||
'desiredStock' => $drinkType->getDesiredStock(),
|
||||
'currentStock' => $currentStock,
|
||||
'createdAt' => $drinkType->getCreatedAt(),
|
||||
'updatedAt' => $drinkType->getUpdatedAt(),
|
||||
];
|
||||
}
|
||||
|
||||
return $this->view->render($response, 'drink-types/index.twig', [
|
||||
'drinkTypes' => $drinkTypesWithStock,
|
||||
]);
|
||||
}
|
||||
|
||||
public function show(Response $response, int $id): Response
|
||||
{
|
||||
$drinkType = $this->drinkTypeService->getDrinkTypeById($id);
|
||||
|
||||
if (!$drinkType instanceof DrinkType) {
|
||||
// Redirect to drink types list with error message
|
||||
// In a real application, you might want to use flash messages
|
||||
return $response->withHeader('Location', '/drink-types')->withStatus(302);
|
||||
}
|
||||
|
||||
// Get current stock information for the drink type
|
||||
$currentStock = $this->inventoryService->getCurrentStockLevel($drinkType);
|
||||
|
||||
// Get inventory records for the drink type
|
||||
$inventoryRecords = $this->inventoryService->getInventoryRecordsByDrinkType($drinkType);
|
||||
|
||||
return $this->view->render($response, 'drink-types/show.twig', [
|
||||
'drinkType' => $drinkType,
|
||||
'currentStock' => $currentStock,
|
||||
'inventoryRecords' => $inventoryRecords,
|
||||
]);
|
||||
}
|
||||
|
||||
public function create(Response $response): Response
|
||||
{
|
||||
return $this->view->render($response, 'drink-types/create.twig');
|
||||
}
|
||||
|
||||
public function store(Request $request, Response $response): Response
|
||||
{
|
||||
$data = $request->getParsedBody();
|
||||
|
||||
$name = $data['name'] ?? '';
|
||||
$description = $data['description'] ?? null;
|
||||
$desiredStock = isset($data['desired_stock']) ? (int) $data['desired_stock'] : 0;
|
||||
|
||||
try {
|
||||
$this->drinkTypeService->createDrinkType($name, $description, $desiredStock);
|
||||
|
||||
// Redirect to drink types list
|
||||
return $response->withHeader('Location', '/drink-types')->withStatus(302);
|
||||
} catch (InvalidArgumentException $e) {
|
||||
// Render the form again with error message
|
||||
return $this->view->render($response, 'drink-types/create.twig', [
|
||||
'error' => $e->getMessage(),
|
||||
'data' => $data,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
public function edit(Request $request, Response $response, int $id): Response
|
||||
{
|
||||
$drinkType = $this->drinkTypeService->getDrinkTypeById($id);
|
||||
|
||||
if (!$drinkType instanceof DrinkType) {
|
||||
// Redirect to drink types list
|
||||
return $response->withHeader('Location', '/drink-types')->withStatus(302);
|
||||
}
|
||||
|
||||
return $this->view->render($response, 'drink-types/edit.twig', [
|
||||
'drinkType' => $drinkType,
|
||||
]);
|
||||
}
|
||||
|
||||
public function update(Request $request, Response $response, int $id): Response
|
||||
{
|
||||
$drinkType = $this->drinkTypeService->getDrinkTypeById($id);
|
||||
|
||||
if (!$drinkType instanceof DrinkType) {
|
||||
// Redirect to drink types list
|
||||
return $response->withHeader('Location', '/drink-types')->withStatus(302);
|
||||
}
|
||||
|
||||
$data = $request->getParsedBody();
|
||||
|
||||
$name = $data['name'] ?? null;
|
||||
$description = $data['description'] ?? null;
|
||||
$desiredStock = isset($data['desired_stock']) ? (int) $data['desired_stock'] : null;
|
||||
|
||||
try {
|
||||
$this->drinkTypeService->updateDrinkType($drinkType, $name, $description, $desiredStock);
|
||||
|
||||
// Redirect to drink type details
|
||||
return $response->withHeader('Location', '/drink-types/' . $id)->withStatus(302);
|
||||
} catch (InvalidArgumentException $e) {
|
||||
// Render the form again with error message
|
||||
return $this->view->render($response, 'drink-types/edit.twig', [
|
||||
'drinkType' => $drinkType,
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
public function delete(Request $request, Response $response, int $id): Response
|
||||
{
|
||||
$drinkType = $this->drinkTypeService->getDrinkTypeById($id);
|
||||
|
||||
if ($drinkType instanceof DrinkType) {
|
||||
$this->drinkTypeService->deleteDrinkType($drinkType);
|
||||
}
|
||||
|
||||
// Redirect to drink types list
|
||||
return $response->withHeader('Location', '/drink-types')->withStatus(302);
|
||||
}
|
||||
}
|
96
src/Controller/InventoryController.php
Normal file
96
src/Controller/InventoryController.php
Normal file
|
@ -0,0 +1,96 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Controller;
|
||||
|
||||
use App\Entity\DrinkType;
|
||||
use App\Service\DrinkTypeService;
|
||||
use App\Service\InventoryService;
|
||||
use Psr\Http\Message\ResponseInterface as Response;
|
||||
use Psr\Http\Message\ServerRequestInterface as Request;
|
||||
use Slim\Views\Twig;
|
||||
|
||||
class InventoryController
|
||||
{
|
||||
public function __construct(
|
||||
private readonly InventoryService $inventoryService,
|
||||
private readonly DrinkTypeService $drinkTypeService,
|
||||
private readonly Twig $view
|
||||
) {}
|
||||
|
||||
public function index(Response $response): Response
|
||||
{
|
||||
$stockLevels = $this->inventoryService->getAllDrinkTypesWithStockLevels();
|
||||
|
||||
return $this->view->render($response, 'inventory/index.twig', [
|
||||
'stockLevels' => $stockLevels,
|
||||
]);
|
||||
}
|
||||
|
||||
public function showUpdateForm(Response $response): Response
|
||||
{
|
||||
$drinkTypes = $this->drinkTypeService->getAllDrinkTypes();
|
||||
|
||||
return $this->view->render($response, 'inventory/update.twig', [
|
||||
'drinkTypes' => $drinkTypes,
|
||||
]);
|
||||
}
|
||||
|
||||
public function update(Request $request, Response $response): Response
|
||||
{
|
||||
$data = $request->getParsedBody();
|
||||
|
||||
$drinkTypeId = isset($data['drink_type_id']) ? (int) $data['drink_type_id'] : 0;
|
||||
$quantity = isset($data['quantity']) ? (int) $data['quantity'] : 0;
|
||||
|
||||
$drinkType = $this->drinkTypeService->getDrinkTypeById($drinkTypeId);
|
||||
|
||||
if (!$drinkType instanceof DrinkType) {
|
||||
// Redirect to inventory page with error message
|
||||
return $response->withHeader('Location', '/inventory')->withStatus(302);
|
||||
}
|
||||
|
||||
$this->inventoryService->updateStockLevel($drinkType, $quantity);
|
||||
|
||||
// Redirect to inventory page
|
||||
return $response->withHeader('Location', '/inventory')->withStatus(302);
|
||||
}
|
||||
|
||||
public function history(Response $response, ?int $id = null): Response
|
||||
{
|
||||
$drinkTypeId = $id;
|
||||
|
||||
if ($drinkTypeId !== null && $drinkTypeId !== 0) {
|
||||
$drinkType = $this->drinkTypeService->getDrinkTypeById($drinkTypeId);
|
||||
|
||||
if (!$drinkType instanceof DrinkType) {
|
||||
// Redirect to inventory page
|
||||
return $response->withHeader('Location', '/inventory')->withStatus(302);
|
||||
}
|
||||
|
||||
$inventoryRecords = $this->inventoryService->getInventoryRecordsByDrinkType($drinkType);
|
||||
|
||||
return $this->view->render($response, 'inventory/history.twig', [
|
||||
'drinkType' => $drinkType,
|
||||
'inventoryRecords' => $inventoryRecords,
|
||||
]);
|
||||
}
|
||||
|
||||
// If no drink type ID is provided, show history for all drink types
|
||||
$inventoryRecords = $this->inventoryService->getAllInventoryRecords();
|
||||
|
||||
return $this->view->render($response, 'inventory/history.twig', [
|
||||
'inventoryRecords' => $inventoryRecords,
|
||||
]);
|
||||
}
|
||||
|
||||
public function lowStock(Response $response): Response
|
||||
{
|
||||
$lowStockItems = $this->inventoryService->getLowStockDrinkTypes();
|
||||
|
||||
return $this->view->render($response, 'inventory/low-stock.twig', [
|
||||
'lowStockItems' => $lowStockItems,
|
||||
]);
|
||||
}
|
||||
}
|
223
src/Controller/OrderController.php
Normal file
223
src/Controller/OrderController.php
Normal file
|
@ -0,0 +1,223 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Controller;
|
||||
|
||||
use App\Entity\DrinkType;
|
||||
use App\Entity\Order;
|
||||
use App\Service\DrinkTypeService;
|
||||
use App\Service\OrderService;
|
||||
use InvalidArgumentException;
|
||||
use Psr\Http\Message\ResponseInterface as Response;
|
||||
use Psr\Http\Message\ServerRequestInterface as Request;
|
||||
use Slim\Views\Twig;
|
||||
|
||||
class OrderController
|
||||
{
|
||||
public function __construct(
|
||||
private readonly OrderService $orderService,
|
||||
private readonly DrinkTypeService $drinkTypeService,
|
||||
private readonly Twig $view
|
||||
) {}
|
||||
|
||||
public function index(Response $response): Response
|
||||
{
|
||||
$orders = $this->orderService->getAllOrders();
|
||||
|
||||
return $this->view->render($response, 'orders/index.twig', [
|
||||
'orders' => $orders,
|
||||
]);
|
||||
}
|
||||
|
||||
public function show(Response $response, int $id): Response
|
||||
{
|
||||
$order = $this->orderService->getOrderById($id);
|
||||
|
||||
if (!$order instanceof Order) {
|
||||
// Redirect to orders list
|
||||
return $response->withHeader('Location', '/orders')->withStatus(302);
|
||||
}
|
||||
|
||||
return $this->view->render($response, 'orders/show.twig', [
|
||||
'order' => $order,
|
||||
]);
|
||||
}
|
||||
|
||||
public function create(Response $response): Response
|
||||
{
|
||||
$drinkTypes = $this->drinkTypeService->getAllDrinkTypes();
|
||||
|
||||
return $this->view->render($response, 'orders/create.twig', [
|
||||
'drinkTypes' => $drinkTypes,
|
||||
]);
|
||||
}
|
||||
|
||||
public function store(Request $request, Response $response): Response
|
||||
{
|
||||
$data = $request->getParsedBody();
|
||||
|
||||
$items = [];
|
||||
|
||||
// Process form data to create order items
|
||||
foreach ($data['items'] ?? [] as $item) {
|
||||
if (!empty($item['drink_type_id']) && !empty($item['quantity'])) {
|
||||
$items[] = [
|
||||
'drinkTypeId' => (int) $item['drink_type_id'],
|
||||
'quantity' => (int) $item['quantity'],
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
$order = $this->orderService->createOrder($items);
|
||||
|
||||
// Redirect to order details
|
||||
return $response->withHeader('Location', '/orders/' . $order->getId())->withStatus(302);
|
||||
} catch (InvalidArgumentException $e) {
|
||||
// Render the form again with error message
|
||||
return $this->view->render($response, 'orders/create.twig', [
|
||||
'error' => $e->getMessage(),
|
||||
'drinkTypes' => $this->drinkTypeService->getAllDrinkTypes(),
|
||||
'data' => $data,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
public function createFromStock(Response $response): Response
|
||||
{
|
||||
try {
|
||||
$order = $this->orderService->createOrderFromStockLevels();
|
||||
|
||||
// Redirect to order details
|
||||
return $response->withHeader('Location', '/orders/' . $order->getId())->withStatus(302);
|
||||
} catch (InvalidArgumentException) {
|
||||
// Redirect to orders list with error message
|
||||
return $response->withHeader('Location', '/orders')->withStatus(302);
|
||||
}
|
||||
}
|
||||
|
||||
public function updateStatus(Request $request, Response $response, int $id): Response
|
||||
{
|
||||
$order = $this->orderService->getOrderById($id);
|
||||
|
||||
if (!$order instanceof Order) {
|
||||
// Redirect to orders list
|
||||
return $response->withHeader('Location', '/orders')->withStatus(302);
|
||||
}
|
||||
|
||||
$data = $request->getParsedBody();
|
||||
$status = $data['status'] ?? '';
|
||||
|
||||
try {
|
||||
$this->orderService->updateOrderStatus($order, $status);
|
||||
|
||||
// Redirect to order details
|
||||
return $response->withHeader('Location', '/orders/' . $id)->withStatus(302);
|
||||
} catch (InvalidArgumentException $e) {
|
||||
// Render the order details with error message
|
||||
return $this->view->render($response, 'orders/show.twig', [
|
||||
'order' => $order,
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
public function addItem(Request $request, Response $response, int $id): Response
|
||||
{
|
||||
$order = $this->orderService->getOrderById($id);
|
||||
|
||||
if (!$order instanceof Order) {
|
||||
// Redirect to orders list
|
||||
return $response->withHeader('Location', '/orders')->withStatus(302);
|
||||
}
|
||||
|
||||
$data = $request->getParsedBody();
|
||||
$drinkTypeId = isset($data['drink_type_id']) ? (int) $data['drink_type_id'] : 0;
|
||||
$quantity = isset($data['quantity']) ? (int) $data['quantity'] : 0;
|
||||
|
||||
$drinkType = $this->drinkTypeService->getDrinkTypeById($drinkTypeId);
|
||||
|
||||
if (!$drinkType instanceof DrinkType) {
|
||||
// Render the order details with error message
|
||||
return $this->view->render($response, 'orders/show.twig', [
|
||||
'order' => $order,
|
||||
'error' => 'Invalid drink type',
|
||||
]);
|
||||
}
|
||||
|
||||
try {
|
||||
$this->orderService->addOrderItem($order, $drinkType, $quantity);
|
||||
|
||||
// Redirect to order details
|
||||
return $response->withHeader('Location', '/orders/' . $id)->withStatus(302);
|
||||
} catch (InvalidArgumentException $e) {
|
||||
// Render the order details with error message
|
||||
return $this->view->render($response, 'orders/show.twig', [
|
||||
'order' => $order,
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
public function removeItem(Response $response, int $id, int $itemId): Response
|
||||
{
|
||||
$orderId = $id;
|
||||
|
||||
$order = $this->orderService->getOrderById($orderId);
|
||||
|
||||
if (!$order instanceof Order) {
|
||||
// Redirect to orders list
|
||||
return $response->withHeader('Location', '/orders')->withStatus(302);
|
||||
}
|
||||
|
||||
$orderItem = null;
|
||||
foreach ($order->getOrderItems() as $item) {
|
||||
if ($item->getId() === $itemId) {
|
||||
$orderItem = $item;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!$orderItem) {
|
||||
// Redirect to order details
|
||||
return $response->withHeader('Location', '/orders/' . $orderId)->withStatus(302);
|
||||
}
|
||||
|
||||
try {
|
||||
$this->orderService->removeOrderItem($order, $orderItem);
|
||||
|
||||
// Redirect to order details
|
||||
return $response->withHeader('Location', '/orders/' . $orderId)->withStatus(302);
|
||||
} catch (InvalidArgumentException $e) {
|
||||
// Render the order details with error message
|
||||
return $this->view->render($response, 'orders/show.twig', [
|
||||
'order' => $order,
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
public function delete(Response $response, int $id): Response
|
||||
{
|
||||
$order = $this->orderService->getOrderById($id);
|
||||
|
||||
if (!$order instanceof Order) {
|
||||
// Redirect to orders list
|
||||
return $response->withHeader('Location', '/orders')->withStatus(302);
|
||||
}
|
||||
|
||||
try {
|
||||
$this->orderService->deleteOrder($order);
|
||||
|
||||
// Redirect to orders list
|
||||
return $response->withHeader('Location', '/orders')->withStatus(302);
|
||||
} catch (InvalidArgumentException $e) {
|
||||
// Render the order details with error message
|
||||
return $this->view->render($response, 'orders/show.twig', [
|
||||
'order' => $order,
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
74
src/Controller/SettingsController.php
Normal file
74
src/Controller/SettingsController.php
Normal file
|
@ -0,0 +1,74 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Controller;
|
||||
|
||||
use App\Entity\SystemConfig;
|
||||
use App\Service\ConfigurationService;
|
||||
use InvalidArgumentException;
|
||||
use Psr\Http\Message\ResponseInterface as Response;
|
||||
use Psr\Http\Message\ServerRequestInterface as Request;
|
||||
use Slim\Views\Twig;
|
||||
|
||||
class SettingsController
|
||||
{
|
||||
public function __construct(
|
||||
private readonly ConfigurationService $configurationService,
|
||||
private readonly Twig $view
|
||||
) {}
|
||||
|
||||
public function index(Response $response): Response
|
||||
{
|
||||
|
||||
$settings = [];
|
||||
foreach($this->configurationService->getAllConfigs() as $setting) {
|
||||
$settings[$setting->getKey()] = $setting->getValue();
|
||||
}
|
||||
return $this->view->render($response, 'settings/index.twig', [
|
||||
'configs' => $settings,
|
||||
]);
|
||||
}
|
||||
|
||||
public function update(Request $request, Response $response): Response
|
||||
{
|
||||
$data = $request->getParsedBody();
|
||||
|
||||
// Process each configuration value from the form
|
||||
foreach ($data['configs'] ?? [] as $key => $value) {
|
||||
try {
|
||||
// Get existing config or create a new one
|
||||
$config = $this->configurationService->getConfigByKey($key);
|
||||
|
||||
if ($config instanceof SystemConfig) {
|
||||
$this->configurationService->updateConfig($config, null, $value);
|
||||
} else {
|
||||
$this->configurationService->createConfig($key, $value);
|
||||
}
|
||||
} catch (InvalidArgumentException $e) {
|
||||
// If there's an error, render the form again with error message
|
||||
return $this->view->render($response, 'settings/index.twig', [
|
||||
'configs' => $this->configurationService->getAllConfigs(),
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
// Redirect back to settings page with success message
|
||||
return $response->withHeader('Location', '/settings')->withStatus(302);
|
||||
}
|
||||
|
||||
public function resetToDefaults(Response $response): Response
|
||||
{
|
||||
// Delete all existing configurations
|
||||
foreach ($this->configurationService->getAllConfigs() as $config) {
|
||||
$this->configurationService->deleteConfig($config);
|
||||
}
|
||||
|
||||
// Initialize default configurations
|
||||
$this->configurationService->initializeDefaultConfigs();
|
||||
|
||||
// Redirect back to settings page
|
||||
return $response->withHeader('Location', '/settings')->withStatus(302);
|
||||
}
|
||||
}
|
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 Doctrine\ORM\Mapping as ORM;
|
||||
use Doctrine\Common\Collections\Collection;
|
||||
use Doctrine\Common\Collections\ArrayCollection;
|
||||
use DateTimeImmutable;
|
||||
use App\Repository\DrinkTypeRepository;
|
||||
|
||||
#[ORM\Entity(repositoryClass: DrinkTypeRepository::class)]
|
||||
#[ORM\Table(name: 'drink_type')]
|
||||
class DrinkType
|
||||
{
|
||||
#[ORM\Id]
|
||||
#[ORM\GeneratedValue]
|
||||
#[ORM\Column(type: 'integer')]
|
||||
private ?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 ?string $description = null,
|
||||
#[ORM\Column(type: 'integer')]
|
||||
private int $desiredStock = 0,
|
||||
?DateTimeImmutable $createdAt = 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(): ?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(): ?string
|
||||
{
|
||||
return $this->description;
|
||||
}
|
||||
|
||||
public function setDescription(?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();
|
||||
}
|
||||
}
|
99
src/Entity/InventoryRecord.php
Normal file
99
src/Entity/InventoryRecord.php
Normal file
|
@ -0,0 +1,99 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Entity;
|
||||
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
use DateTimeImmutable;
|
||||
use App\Repository\InventoryRecordRepository;
|
||||
|
||||
#[ORM\Entity(repositoryClass: InventoryRecordRepository::class)]
|
||||
#[ORM\Table(name: 'inventory_record')]
|
||||
class InventoryRecord
|
||||
{
|
||||
#[ORM\Id]
|
||||
#[ORM\GeneratedValue]
|
||||
#[ORM\Column(type: 'integer')]
|
||||
private ?int $id = null;
|
||||
|
||||
#[ORM\Column(type: 'datetime_immutable')]
|
||||
private DateTimeImmutable $timestamp;
|
||||
|
||||
#[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: 'inventoryRecords')]
|
||||
#[ORM\JoinColumn(name: 'drink_type_id', referencedColumnName: 'id', nullable: false)]
|
||||
private DrinkType $drinkType,
|
||||
#[ORM\Column(type: 'integer')]
|
||||
private int $quantity,
|
||||
?DateTimeImmutable $timestamp = null,
|
||||
?DateTimeImmutable $createdAt = null,
|
||||
?DateTimeImmutable $updatedAt = null
|
||||
) {
|
||||
$this->timestamp = $timestamp ?? new DateTimeImmutable();
|
||||
$this->createdAt = $createdAt ?? new DateTimeImmutable();
|
||||
$this->updatedAt = $updatedAt ?? new DateTimeImmutable();
|
||||
}
|
||||
|
||||
public function getId(): ?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();
|
||||
}
|
||||
}
|
111
src/Entity/Order.php
Normal file
111
src/Entity/Order.php
Normal file
|
@ -0,0 +1,111 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Entity;
|
||||
|
||||
use InvalidArgumentException;
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
use Doctrine\Common\Collections\Collection;
|
||||
use Doctrine\Common\Collections\ArrayCollection;
|
||||
use DateTimeImmutable;
|
||||
use App\Repository\OrderRepository;
|
||||
|
||||
#[ORM\Entity(repositoryClass: OrderRepository::class)]
|
||||
#[ORM\Table(name: '`order`')] // 'order' is a reserved keyword in SQL, so we escape it
|
||||
class Order
|
||||
{
|
||||
public const STATUS_NEW = 'new';
|
||||
public const STATUS_IN_WORK = 'in_work';
|
||||
public const STATUS_ORDERED = 'ordered';
|
||||
public const STATUS_FULFILLED = 'fulfilled';
|
||||
|
||||
#[ORM\Id]
|
||||
#[ORM\GeneratedValue]
|
||||
#[ORM\Column(type: 'integer')]
|
||||
private ?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(type: 'string')]
|
||||
private string $status = self::STATUS_NEW,
|
||||
?DateTimeImmutable $createdAt = null,
|
||||
?DateTimeImmutable $updatedAt = null
|
||||
) {
|
||||
$this->createdAt = $createdAt ?? new DateTimeImmutable();
|
||||
$this->updatedAt = $updatedAt ?? new DateTimeImmutable();
|
||||
$this->orderItems = new ArrayCollection();
|
||||
}
|
||||
|
||||
public function getId(): ?int
|
||||
{
|
||||
return $this->id;
|
||||
}
|
||||
|
||||
public function getStatus(): string
|
||||
{
|
||||
return $this->status;
|
||||
}
|
||||
|
||||
public function setStatus(string $status): self
|
||||
{
|
||||
if (!in_array($status, [self::STATUS_NEW, self::STATUS_IN_WORK, self::STATUS_ORDERED, self::STATUS_FULFILLED], true)) {
|
||||
throw new InvalidArgumentException('Invalid status');
|
||||
}
|
||||
|
||||
$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 Doctrine\ORM\Mapping as ORM;
|
||||
use DateTimeImmutable;
|
||||
use App\Repository\OrderItemRepository;
|
||||
|
||||
#[ORM\Entity(repositoryClass: OrderItemRepository::class)]
|
||||
#[ORM\Table(name: 'order_item')]
|
||||
class OrderItem
|
||||
{
|
||||
#[ORM\Id]
|
||||
#[ORM\GeneratedValue]
|
||||
#[ORM\Column(type: 'integer')]
|
||||
private ?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 ?Order $order = null,
|
||||
?DateTimeImmutable $createdAt = 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(): ?int
|
||||
{
|
||||
return $this->id;
|
||||
}
|
||||
|
||||
public function getOrder(): ?Order
|
||||
{
|
||||
return $this->order;
|
||||
}
|
||||
|
||||
public function setOrder(?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();
|
||||
}
|
||||
}
|
81
src/Entity/SystemConfig.php
Normal file
81
src/Entity/SystemConfig.php
Normal file
|
@ -0,0 +1,81 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Entity;
|
||||
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
use DateTimeImmutable;
|
||||
use App\Repository\SystemConfigRepository;
|
||||
|
||||
#[ORM\Entity(repositoryClass: SystemConfigRepository::class)]
|
||||
#[ORM\Table(name: 'system_config')]
|
||||
class SystemConfig
|
||||
{
|
||||
#[ORM\Id]
|
||||
#[ORM\GeneratedValue]
|
||||
#[ORM\Column(type: 'integer')]
|
||||
private ?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(type: 'string', length: 255, unique: true)]
|
||||
private string $key,
|
||||
#[ORM\Column(type: 'text')]
|
||||
private string $value,
|
||||
?DateTimeImmutable $createdAt = null,
|
||||
?DateTimeImmutable $updatedAt = null
|
||||
) {
|
||||
$this->createdAt = $createdAt ?? new DateTimeImmutable();
|
||||
$this->updatedAt = $updatedAt ?? new DateTimeImmutable();
|
||||
}
|
||||
|
||||
public function getId(): ?int
|
||||
{
|
||||
return $this->id;
|
||||
}
|
||||
|
||||
public function getKey(): string
|
||||
{
|
||||
return $this->key;
|
||||
}
|
||||
|
||||
public function setKey(string $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();
|
||||
}
|
||||
}
|
27
src/Enum/SystemSettingKey.php
Normal file
27
src/Enum/SystemSettingKey.php
Normal file
|
@ -0,0 +1,27 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Enum;
|
||||
|
||||
/**
|
||||
* Enum for system setting keys
|
||||
*/
|
||||
enum SystemSettingKey: string
|
||||
{
|
||||
/**
|
||||
* @deprecated Use STOCK_ADJUSTMENT_LOOKBACK_ORDERS instead
|
||||
*/
|
||||
case STOCK_ADJUSTMENT_LOOKBACK = 'stock_adjustment_lookback';
|
||||
case STOCK_ADJUSTMENT_LOOKBACK_ORDERS = 'stock_adjustment_lookback_orders';
|
||||
case STOCK_ADJUSTMENT_MAGNITUDE = 'stock_adjustment_magnitude';
|
||||
case STOCK_ADJUSTMENT_THRESHOLD = 'stock_adjustment_threshold';
|
||||
case LOW_STOCK_THRESHOLD = 'low_stock_threshold';
|
||||
case CRITICAL_STOCK_THRESHOLD = 'critical_stock_threshold';
|
||||
case DEFAULT_DESIRED_STOCK = 'default_desired_stock';
|
||||
case SYSTEM_NAME = 'system_name';
|
||||
case ENABLE_AUTO_ADJUSTMENT = 'enable_auto_adjustment';
|
||||
case SHOW_LOW_STOCK_ALERTS = 'show_low_stock_alerts';
|
||||
case SHOW_QUICK_UPDATE_FORM = 'show_quick_update_form';
|
||||
case ITEMS_PER_PAGE = 'items_per_page';
|
||||
}
|
70
src/Middleware/ErrorHandlerMiddleware.php
Normal file
70
src/Middleware/ErrorHandlerMiddleware.php
Normal file
|
@ -0,0 +1,70 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Middleware;
|
||||
|
||||
use Monolog\Logger;
|
||||
use Psr\Http\Message\ResponseInterface;
|
||||
use Psr\Http\Message\ServerRequestInterface;
|
||||
use Psr\Http\Server\MiddlewareInterface;
|
||||
use Psr\Http\Server\RequestHandlerInterface;
|
||||
use Slim\Exception\HttpException;
|
||||
use Slim\Psr7\Response;
|
||||
use Slim\Views\Twig;
|
||||
use Throwable;
|
||||
|
||||
class ErrorHandlerMiddleware implements MiddlewareInterface
|
||||
{
|
||||
public function __construct(private readonly Logger $logger, private readonly Twig $twig, private readonly bool $displayErrorDetails = false) {}
|
||||
|
||||
public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface
|
||||
{
|
||||
try {
|
||||
return $handler->handle($request);
|
||||
} catch (HttpException $e) {
|
||||
return $this->handleException($e, $request, $e->getCode());
|
||||
} catch (Throwable $e) {
|
||||
return $this->handleException($e, $request);
|
||||
}
|
||||
}
|
||||
|
||||
private function handleException(Throwable $e, ServerRequestInterface $request, int $statusCode = 500): ResponseInterface
|
||||
{
|
||||
$this->logger->error($e->getMessage(), [
|
||||
'exception' => $e,
|
||||
'request' => [
|
||||
'method' => $request->getMethod(),
|
||||
'uri' => (string) $request->getUri(),
|
||||
],
|
||||
]);
|
||||
|
||||
$response = new Response($statusCode);
|
||||
|
||||
// Only show error details in development
|
||||
$error = [
|
||||
'message' => $this->displayErrorDetails ? $e->getMessage() : 'An error occurred',
|
||||
];
|
||||
|
||||
if ($this->displayErrorDetails) {
|
||||
$error['trace'] = $e->getTraceAsString();
|
||||
$error['file'] = $e->getFile();
|
||||
$error['line'] = $e->getLine();
|
||||
}
|
||||
|
||||
// Try to render an error page, fall back to JSON if Twig fails
|
||||
try {
|
||||
return $this->twig->render($response, 'error.twig', [
|
||||
'error' => $error,
|
||||
'status_code' => $statusCode,
|
||||
]);
|
||||
} catch (Throwable $e) {
|
||||
$this->logger->error('Failed to render error template', ['exception' => $e]);
|
||||
|
||||
$response = $response->withHeader('Content-Type', 'application/json');
|
||||
$response->getBody()->write(json_encode(['error' => $error]));
|
||||
|
||||
return $response;
|
||||
}
|
||||
}
|
||||
}
|
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();
|
||||
}
|
||||
}
|
20
src/Repository/DrinkTypeRepository.php
Normal file
20
src/Repository/DrinkTypeRepository.php
Normal file
|
@ -0,0 +1,20 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Repository;
|
||||
|
||||
use App\Entity\DrinkType;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
|
||||
/**
|
||||
* @extends AbstractRepository<DrinkType>
|
||||
*/
|
||||
class DrinkTypeRepository extends AbstractRepository
|
||||
{
|
||||
public function __construct(
|
||||
EntityManagerInterface $entityManager
|
||||
) {
|
||||
parent::__construct($entityManager, $entityManager->getClassMetadata(DrinkType::class));
|
||||
}
|
||||
}
|
61
src/Repository/InventoryRecordRepository.php
Normal file
61
src/Repository/InventoryRecordRepository.php
Normal file
|
@ -0,0 +1,61 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Repository;
|
||||
|
||||
use App\Entity\DrinkType;
|
||||
use App\Entity\InventoryRecord;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use DateTimeImmutable;
|
||||
|
||||
/**
|
||||
* @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): ?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;
|
||||
}
|
||||
|
||||
}
|
46
src/Repository/OrderItemRepository.php
Normal file
46
src/Repository/OrderItemRepository.php
Normal file
|
@ -0,0 +1,46 @@
|
|||
<?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): ?OrderItem
|
||||
{
|
||||
return $this->findOneBy([
|
||||
'order' => $order,
|
||||
'drinkType' => $drinkType,
|
||||
]);
|
||||
}
|
||||
}
|
74
src/Repository/OrderRepository.php
Normal file
74
src/Repository/OrderRepository.php
Normal file
|
@ -0,0 +1,74 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Repository;
|
||||
|
||||
use App\Entity\Order;
|
||||
use App\Entity\DrinkType;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use DateTimeImmutable;
|
||||
|
||||
/**
|
||||
* @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(string $status): array
|
||||
{
|
||||
return $this->findBy(['status' => $status], ['createdAt' => 'DESC']);
|
||||
}
|
||||
|
||||
/**
|
||||
* @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', Order::STATUS_FULFILLED)
|
||||
->orderBy('o.createdAt', 'DESC')
|
||||
->setMaxResults($limit);
|
||||
|
||||
/** @var array<int, Order> $result */
|
||||
$result = $qb->getQuery()->getResult();
|
||||
return $result;
|
||||
}
|
||||
}
|
43
src/Repository/SystemConfigRepository.php
Normal file
43
src/Repository/SystemConfigRepository.php
Normal file
|
@ -0,0 +1,43 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Repository;
|
||||
|
||||
use App\Entity\SystemConfig;
|
||||
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(string $key): ?SystemConfig
|
||||
{
|
||||
return $this->findOneBy(['key' => $key]);
|
||||
}
|
||||
|
||||
public function getValue(string $key, string $default = ''): string
|
||||
{
|
||||
$config = $this->findByKey($key);
|
||||
return $config instanceof SystemConfig ? $config->getValue() : $default;
|
||||
}
|
||||
|
||||
public function setValue(string $key, string $value): void
|
||||
{
|
||||
$config = $this->findByKey($key);
|
||||
|
||||
if ($config instanceof SystemConfig) {
|
||||
$config->setValue($value);
|
||||
} else {
|
||||
$config = new SystemConfig($key, $value);
|
||||
}
|
||||
$this->save($config);
|
||||
}
|
||||
}
|
197
src/Service/ConfigurationService.php
Normal file
197
src/Service/ConfigurationService.php
Normal file
|
@ -0,0 +1,197 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Service;
|
||||
|
||||
use InvalidArgumentException;
|
||||
use App\Entity\SystemConfig;
|
||||
use App\Repository\SystemConfigRepository;
|
||||
use App\Enum\SystemSettingKey;
|
||||
|
||||
class ConfigurationService
|
||||
{
|
||||
// For backward compatibility
|
||||
/**
|
||||
* @deprecated Use SystemSettingKey::STOCK_ADJUSTMENT_LOOKBACK instead
|
||||
*/
|
||||
public const KEY_STOCK_ADJUSTMENT_LOOKBACK = 'stock_adjustment_lookback';
|
||||
/**
|
||||
* @deprecated Use SystemSettingKey::STOCK_ADJUSTMENT_LOOKBACK_ORDERS instead
|
||||
*/
|
||||
public const KEY_STOCK_ADJUSTMENT_LOOKBACK_ORDERS = 'stock_adjustment_lookback_orders';
|
||||
/**
|
||||
* @deprecated Use SystemSettingKey::STOCK_ADJUSTMENT_MAGNITUDE instead
|
||||
*/
|
||||
public const KEY_STOCK_ADJUSTMENT_MAGNITUDE = 'stock_adjustment_magnitude';
|
||||
/**
|
||||
* @deprecated Use SystemSettingKey::STOCK_ADJUSTMENT_THRESHOLD instead
|
||||
*/
|
||||
public const KEY_STOCK_ADJUSTMENT_THRESHOLD = 'stock_adjustment_threshold';
|
||||
/**
|
||||
* @deprecated Use SystemSettingKey::LOW_STOCK_THRESHOLD instead
|
||||
*/
|
||||
public const KEY_LOW_STOCK_THRESHOLD = 'low_stock_threshold';
|
||||
/**
|
||||
* @deprecated Use SystemSettingKey::CRITICAL_STOCK_THRESHOLD instead
|
||||
*/
|
||||
public const KEY_CRITICAL_STOCK_THRESHOLD = 'critical_stock_threshold';
|
||||
/**
|
||||
* @deprecated Use SystemSettingKey::DEFAULT_DESIRED_STOCK instead
|
||||
*/
|
||||
public const KEY_DEFAULT_DESIRED_STOCK = 'default_desired_stock';
|
||||
/**
|
||||
* @deprecated Use SystemSettingKey::SYSTEM_NAME instead
|
||||
*/
|
||||
public const KEY_SYSTEM_NAME = 'system_name';
|
||||
/**
|
||||
* @deprecated Use SystemSettingKey::ENABLE_AUTO_ADJUSTMENT instead
|
||||
*/
|
||||
public const KEY_ENABLE_AUTO_ADJUSTMENT = 'enable_auto_adjustment';
|
||||
/**
|
||||
* @deprecated Use SystemSettingKey::SHOW_LOW_STOCK_ALERTS instead
|
||||
*/
|
||||
public const KEY_SHOW_LOW_STOCK_ALERTS = 'show_low_stock_alerts';
|
||||
/**
|
||||
* @deprecated Use SystemSettingKey::SHOW_QUICK_UPDATE_FORM instead
|
||||
*/
|
||||
public const KEY_SHOW_QUICK_UPDATE_FORM = 'show_quick_update_form';
|
||||
/**
|
||||
* @deprecated Use SystemSettingKey::ITEMS_PER_PAGE instead
|
||||
*/
|
||||
public const KEY_ITEMS_PER_PAGE = 'items_per_page';
|
||||
|
||||
public function __construct(
|
||||
private readonly SystemConfigRepository $systemConfigRepository
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Get all configuration entries
|
||||
*
|
||||
* @return SystemConfig[]
|
||||
*/
|
||||
public function getAllConfigs(): array
|
||||
{
|
||||
return $this->systemConfigRepository->findAll();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a configuration value by key
|
||||
*
|
||||
* @param string $key
|
||||
* @param string $default Default value if the key doesn't exist
|
||||
* @return string
|
||||
*/
|
||||
public function getConfigValue(string $key, string $default = ''): string
|
||||
{
|
||||
return $this->systemConfigRepository->getValue($key, $default);
|
||||
}
|
||||
|
||||
/**
|
||||
* Set a configuration value
|
||||
*
|
||||
* @param string $key
|
||||
* @param string $value
|
||||
* @return void
|
||||
*/
|
||||
public function setConfigValue(string $key, string $value): void
|
||||
{
|
||||
$this->systemConfigRepository->setValue($key, $value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a configuration entity by key
|
||||
*
|
||||
* @param string $key
|
||||
* @return SystemConfig|null
|
||||
*/
|
||||
public function getConfigByKey(string $key): ?SystemConfig
|
||||
{
|
||||
return $this->systemConfigRepository->findByKey($key);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new configuration entry
|
||||
*
|
||||
* @param string $key
|
||||
* @param string $value
|
||||
* @return SystemConfig
|
||||
* @throws InvalidArgumentException If a configuration with the same key already exists
|
||||
*/
|
||||
public function createConfig(string $key, string $value): SystemConfig
|
||||
{
|
||||
if ($this->systemConfigRepository->findByKey($key) instanceof SystemConfig) {
|
||||
throw new InvalidArgumentException("A configuration with the key '$key' already exists");
|
||||
}
|
||||
|
||||
$config = new SystemConfig($key, $value);
|
||||
$this->systemConfigRepository->save($config);
|
||||
|
||||
return $config;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update an existing configuration entry
|
||||
*
|
||||
* @param SystemConfig $config
|
||||
* @param string|null $key
|
||||
* @param string|null $value
|
||||
* @return SystemConfig
|
||||
* @throws InvalidArgumentException If a configuration with the same key already exists
|
||||
*/
|
||||
public function updateConfig(
|
||||
SystemConfig $config,
|
||||
?string $key = null,
|
||||
?string $value = null
|
||||
): SystemConfig {
|
||||
// Update key if provided
|
||||
if ($key !== null && $key !== $config->getKey()) {
|
||||
// Check if a configuration with the same key already exists
|
||||
if ($this->systemConfigRepository->findByKey($key) instanceof SystemConfig) {
|
||||
throw new InvalidArgumentException("A configuration with the key '$key' already exists");
|
||||
}
|
||||
$config->setKey($key);
|
||||
}
|
||||
|
||||
// Update value if provided
|
||||
if ($value !== null) {
|
||||
$config->setValue($value);
|
||||
}
|
||||
|
||||
$this->systemConfigRepository->save($config);
|
||||
|
||||
return $config;
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a configuration entry
|
||||
*
|
||||
* @param SystemConfig $config
|
||||
* @return void
|
||||
*/
|
||||
public function deleteConfig(SystemConfig $config): void
|
||||
{
|
||||
$this->systemConfigRepository->remove($config);
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize default configuration values if they don't exist
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function initializeDefaultConfigs(): void
|
||||
{
|
||||
$defaults = [
|
||||
SystemSettingKey::STOCK_ADJUSTMENT_LOOKBACK->value => '30', // Deprecated
|
||||
SystemSettingKey::STOCK_ADJUSTMENT_LOOKBACK_ORDERS->value => '5',
|
||||
SystemSettingKey::STOCK_ADJUSTMENT_MAGNITUDE->value => '0.2',
|
||||
SystemSettingKey::STOCK_ADJUSTMENT_THRESHOLD->value => '0.1',
|
||||
];
|
||||
|
||||
foreach ($defaults as $key => $value) {
|
||||
if (!$this->getConfigByKey($key) instanceof SystemConfig) {
|
||||
$this->createConfig($key, $value);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
128
src/Service/DrinkTypeService.php
Normal file
128
src/Service/DrinkTypeService.php
Normal file
|
@ -0,0 +1,128 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Service;
|
||||
|
||||
use InvalidArgumentException;
|
||||
use App\Entity\DrinkType;
|
||||
use App\Repository\DrinkTypeRepository;
|
||||
use App\Service\ConfigurationService;
|
||||
|
||||
class DrinkTypeService
|
||||
{
|
||||
public function __construct(
|
||||
private readonly DrinkTypeRepository $drinkTypeRepository,
|
||||
private readonly ConfigurationService $configService
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Get all drink types
|
||||
*
|
||||
* @return DrinkType[]
|
||||
*/
|
||||
public function getAllDrinkTypes(): array
|
||||
{
|
||||
return $this->drinkTypeRepository->findAll();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a drink type by ID
|
||||
*
|
||||
* @param int $id
|
||||
* @return DrinkType|null
|
||||
*/
|
||||
public function getDrinkTypeById(int $id): ?DrinkType
|
||||
{
|
||||
return $this->drinkTypeRepository->find($id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a drink type by name
|
||||
*
|
||||
* @param string $name
|
||||
* @return DrinkType|null
|
||||
*/
|
||||
public function getDrinkTypeByName(string $name): ?DrinkType
|
||||
{
|
||||
return $this->drinkTypeRepository->findOneBy(['name' => $name]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new drink type
|
||||
*
|
||||
* @param string $name
|
||||
* @param string|null $description
|
||||
* @param int|null $desiredStock
|
||||
* @return DrinkType
|
||||
* @throws InvalidArgumentException If a drink type with the same name already exists
|
||||
*/
|
||||
public function createDrinkType(string $name, ?string $description = null, ?int $desiredStock = null): DrinkType
|
||||
{
|
||||
// Check if a drink type with the same name already exists
|
||||
if ($this->drinkTypeRepository->findOneBy(['name' => $name]) !== null) {
|
||||
throw new InvalidArgumentException("A drink type with the name '$name' already exists");
|
||||
}
|
||||
|
||||
// If no desired stock is provided, use the default from configuration
|
||||
if ($desiredStock === null) {
|
||||
$desiredStock = (int) $this->configService->getConfigValue('default_desired_stock', '10');
|
||||
}
|
||||
|
||||
$drinkType = new DrinkType($name, $description, $desiredStock);
|
||||
$this->drinkTypeRepository->save($drinkType);
|
||||
|
||||
return $drinkType;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update an existing drink type
|
||||
*
|
||||
* @param DrinkType $drinkType
|
||||
* @param string|null $name
|
||||
* @param string|null $description
|
||||
* @param int|null $desiredStock
|
||||
* @return DrinkType
|
||||
* @throws InvalidArgumentException If a drink type with the same name already exists
|
||||
*/
|
||||
public function updateDrinkType(
|
||||
DrinkType $drinkType,
|
||||
?string $name = null,
|
||||
?string $description = null,
|
||||
?int $desiredStock = null
|
||||
): DrinkType {
|
||||
// Update name if provided
|
||||
if ($name !== null && $name !== $drinkType->getName()) {
|
||||
// Check if a drink type with the same name already exists
|
||||
if ($this->drinkTypeRepository->findOneBy(['name' => $name]) !== null) {
|
||||
throw new InvalidArgumentException("A drink type with the name '$name' already exists");
|
||||
}
|
||||
$drinkType->setName($name);
|
||||
}
|
||||
|
||||
// Update description if provided
|
||||
if ($description !== null) {
|
||||
$drinkType->setDescription($description);
|
||||
}
|
||||
|
||||
// Update desired stock if provided
|
||||
if ($desiredStock !== null) {
|
||||
$drinkType->setDesiredStock($desiredStock);
|
||||
}
|
||||
|
||||
$this->drinkTypeRepository->save($drinkType);
|
||||
|
||||
return $drinkType;
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a drink type
|
||||
*
|
||||
* @param DrinkType $drinkType
|
||||
* @return void
|
||||
*/
|
||||
public function deleteDrinkType(DrinkType $drinkType): void
|
||||
{
|
||||
$this->drinkTypeRepository->remove($drinkType);
|
||||
}
|
||||
}
|
204
src/Service/InventoryService.php
Normal file
204
src/Service/InventoryService.php
Normal file
|
@ -0,0 +1,204 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Service;
|
||||
|
||||
use App\Entity\DrinkType;
|
||||
use App\Entity\InventoryRecord;
|
||||
use App\Repository\DrinkTypeRepository;
|
||||
use App\Repository\InventoryRecordRepository;
|
||||
use App\Service\ConfigurationService;
|
||||
use App\Enum\SystemSettingKey;
|
||||
use DateTimeImmutable;
|
||||
|
||||
class InventoryService
|
||||
{
|
||||
public function __construct(
|
||||
private readonly InventoryRecordRepository $inventoryRecordRepository,
|
||||
private readonly DrinkTypeRepository $drinkTypeRepository,
|
||||
private readonly ConfigurationService $configService
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Get all inventory records
|
||||
*
|
||||
* @return InventoryRecord[]
|
||||
*/
|
||||
public function getAllInventoryRecords(): array
|
||||
{
|
||||
return $this->inventoryRecordRepository->findAll();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get inventory records for a specific drink type
|
||||
*
|
||||
* @param DrinkType $drinkType
|
||||
* @return InventoryRecord[]
|
||||
*/
|
||||
public function getInventoryRecordsByDrinkType(DrinkType $drinkType): array
|
||||
{
|
||||
return $this->inventoryRecordRepository->findByDrinkType($drinkType);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the latest inventory record for a specific drink type
|
||||
*
|
||||
* @param DrinkType $drinkType
|
||||
* @return InventoryRecord|null
|
||||
*/
|
||||
public function getLatestInventoryRecord(DrinkType $drinkType): ?InventoryRecord
|
||||
{
|
||||
return $this->inventoryRecordRepository->findLatestByDrinkType($drinkType);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get inventory records within a specific time range
|
||||
*
|
||||
* @param DateTimeImmutable $start
|
||||
* @param DateTimeImmutable $end
|
||||
* @return InventoryRecord[]
|
||||
*/
|
||||
public function getInventoryRecordsByTimeRange(DateTimeImmutable $start, DateTimeImmutable $end): array
|
||||
{
|
||||
return $this->inventoryRecordRepository->findByTimestampRange($start, $end);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current stock level for a specific drink type
|
||||
*
|
||||
* @param DrinkType $drinkType
|
||||
* @return int
|
||||
*/
|
||||
public function getCurrentStockLevel(DrinkType $drinkType): int
|
||||
{
|
||||
$latestRecord = $this->getLatestInventoryRecord($drinkType);
|
||||
return $latestRecord instanceof InventoryRecord ? $latestRecord->getQuantity() : 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the difference between current and desired stock for a specific drink type
|
||||
*
|
||||
* @param DrinkType $drinkType
|
||||
* @return int Positive value means excess stock, negative value means shortage
|
||||
*/
|
||||
public function getStockDifference(DrinkType $drinkType): int
|
||||
{
|
||||
$currentStock = $this->getCurrentStockLevel($drinkType);
|
||||
$desiredStock = $drinkType->getDesiredStock();
|
||||
|
||||
return $currentStock - $desiredStock;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the stock level for a specific drink type
|
||||
*
|
||||
* @param DrinkType $drinkType
|
||||
* @param int $quantity
|
||||
* @param DateTimeImmutable|null $timestamp
|
||||
* @return InventoryRecord
|
||||
*/
|
||||
public function updateStockLevel(
|
||||
DrinkType $drinkType,
|
||||
int $quantity,
|
||||
?DateTimeImmutable $timestamp = null
|
||||
): InventoryRecord {
|
||||
$inventoryRecord = new InventoryRecord(
|
||||
$drinkType,
|
||||
$quantity,
|
||||
$timestamp
|
||||
);
|
||||
|
||||
$this->inventoryRecordRepository->save($inventoryRecord);
|
||||
|
||||
return $inventoryRecord;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all drink types with their current stock levels
|
||||
*
|
||||
* @return array<int, array{drinkType: DrinkType, currentStock: int, desiredStock: int, difference: int}>
|
||||
*/
|
||||
public function getAllDrinkTypesWithStockLevels(): array
|
||||
{
|
||||
$drinkTypes = $this->drinkTypeRepository->findAll();
|
||||
$result = [];
|
||||
|
||||
foreach ($drinkTypes as $drinkType) {
|
||||
$currentStock = $this->getCurrentStockLevel($drinkType);
|
||||
$desiredStock = $drinkType->getDesiredStock();
|
||||
$difference = $currentStock - $desiredStock;
|
||||
|
||||
$result[] = [
|
||||
'drinkType' => $drinkType,
|
||||
'currentStock' => $currentStock,
|
||||
'desiredStock' => $desiredStock,
|
||||
'difference' => $difference,
|
||||
];
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all drink types with low stock (current stock below low stock threshold)
|
||||
*
|
||||
* @return array<int, array{drinkType: DrinkType, currentStock: int, desiredStock: int, shortage: int}>
|
||||
*/
|
||||
public function getLowStockDrinkTypes(): array
|
||||
{
|
||||
$lowStockThreshold = (float) $this->configService->getConfigValue(SystemSettingKey::LOW_STOCK_THRESHOLD->value, '50') / 100;
|
||||
$drinkTypes = $this->drinkTypeRepository->findAll();
|
||||
$result = [];
|
||||
|
||||
foreach ($drinkTypes as $drinkType) {
|
||||
$currentStock = $this->getCurrentStockLevel($drinkType);
|
||||
$desiredStock = $drinkType->getDesiredStock();
|
||||
|
||||
// Check if current stock is below the threshold percentage of desired stock
|
||||
if ($desiredStock > 0 && $currentStock < ($desiredStock * $lowStockThreshold)) {
|
||||
$shortage = $desiredStock - $currentStock;
|
||||
|
||||
$result[] = [
|
||||
'drinkType' => $drinkType,
|
||||
'currentStock' => $currentStock,
|
||||
'desiredStock' => $desiredStock,
|
||||
'shortage' => $shortage,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all drink types with critical stock (current stock below critical stock threshold)
|
||||
*
|
||||
* @return array<int, array{drinkType: DrinkType, currentStock: int, desiredStock: int, shortage: int}>
|
||||
*/
|
||||
public function getCriticalStockDrinkTypes(): array
|
||||
{
|
||||
$criticalStockThreshold = (float) $this->configService->getConfigValue(SystemSettingKey::CRITICAL_STOCK_THRESHOLD->value, '25') / 100;
|
||||
$drinkTypes = $this->drinkTypeRepository->findAll();
|
||||
$result = [];
|
||||
|
||||
foreach ($drinkTypes as $drinkType) {
|
||||
$currentStock = $this->getCurrentStockLevel($drinkType);
|
||||
$desiredStock = $drinkType->getDesiredStock();
|
||||
|
||||
// Check if current stock is below the threshold percentage of desired stock
|
||||
if ($desiredStock > 0 && $currentStock < ($desiredStock * $criticalStockThreshold)) {
|
||||
$shortage = $desiredStock - $currentStock;
|
||||
|
||||
$result[] = [
|
||||
'drinkType' => $drinkType,
|
||||
'currentStock' => $currentStock,
|
||||
'desiredStock' => $desiredStock,
|
||||
'shortage' => $shortage,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
}
|
217
src/Service/OrderService.php
Normal file
217
src/Service/OrderService.php
Normal file
|
@ -0,0 +1,217 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Service;
|
||||
|
||||
use InvalidArgumentException;
|
||||
use App\Entity\DrinkType;
|
||||
use App\Entity\Order;
|
||||
use App\Entity\OrderItem;
|
||||
use App\Repository\DrinkTypeRepository;
|
||||
use App\Repository\OrderItemRepository;
|
||||
use App\Repository\OrderRepository;
|
||||
use DateTimeImmutable;
|
||||
|
||||
class OrderService
|
||||
{
|
||||
public function __construct(
|
||||
private readonly OrderRepository $orderRepository,
|
||||
private readonly OrderItemRepository $orderItemRepository,
|
||||
private readonly DrinkTypeRepository $drinkTypeRepository,
|
||||
private readonly InventoryService $inventoryService
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Get all orders
|
||||
*
|
||||
* @return Order[]
|
||||
*/
|
||||
public function getAllOrders(): array
|
||||
{
|
||||
return $this->orderRepository->findAll();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get an order by ID
|
||||
*
|
||||
* @param int $id
|
||||
* @return Order|null
|
||||
*/
|
||||
public function getOrderById(int $id): ?Order
|
||||
{
|
||||
return $this->orderRepository->find($id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get orders by status
|
||||
*
|
||||
* @param string $status
|
||||
* @return Order[]
|
||||
*/
|
||||
public function getOrdersByStatus(string $status): array
|
||||
{
|
||||
return $this->orderRepository->findByStatus($status);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get orders within a specific date range
|
||||
*
|
||||
* @param DateTimeImmutable $start
|
||||
* @param DateTimeImmutable $end
|
||||
* @return Order[]
|
||||
*/
|
||||
public function getOrdersByDateRange(DateTimeImmutable $start, DateTimeImmutable $end): array
|
||||
{
|
||||
return $this->orderRepository->findByDateRange($start, $end);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new order
|
||||
*
|
||||
* @param array<int, array{drinkTypeId: int, quantity: int}> $items
|
||||
* @return Order
|
||||
* @throws InvalidArgumentException If a drink type ID is invalid
|
||||
*/
|
||||
public function createOrder(array $items): Order
|
||||
{
|
||||
$order = new Order();
|
||||
$this->orderRepository->save($order);
|
||||
|
||||
foreach ($items as $item) {
|
||||
$drinkType = $this->drinkTypeRepository->find($item['drinkTypeId']);
|
||||
if ($drinkType === null) {
|
||||
throw new InvalidArgumentException("Invalid drink type ID: {$item['drinkTypeId']}");
|
||||
}
|
||||
|
||||
$orderItem = new OrderItem($drinkType, $item['quantity'], $order);
|
||||
$this->orderItemRepository->save($orderItem);
|
||||
}
|
||||
|
||||
return $order;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an order based on current stock levels
|
||||
*
|
||||
* @return Order
|
||||
*/
|
||||
public function createOrderFromStockLevels(): Order
|
||||
{
|
||||
$lowStockItems = $this->inventoryService->getAllDrinkTypesWithStockLevels();
|
||||
$orderItems = [];
|
||||
|
||||
foreach ($lowStockItems as $item) {
|
||||
if ($item['currentStock'] < $item['desiredStock']) {
|
||||
$orderItems[] = [
|
||||
'drinkTypeId' => $item['drinkType']->getId(),
|
||||
'quantity' => $item['desiredStock'] - $item['currentStock'],
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
return $this->createOrder($orderItems);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update an order's status
|
||||
*
|
||||
* @param Order $order
|
||||
* @param string $status
|
||||
* @return Order
|
||||
* @throws InvalidArgumentException If the status is invalid
|
||||
*/
|
||||
public function updateOrderStatus(Order $order, string $status): Order
|
||||
{
|
||||
$order->setStatus($status);
|
||||
$this->orderRepository->save($order);
|
||||
|
||||
// If the order is fulfilled, update the inventory
|
||||
if ($status === Order::STATUS_FULFILLED) {
|
||||
$this->updateInventoryFromOrder($order);
|
||||
}
|
||||
|
||||
return $order;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update inventory based on a fulfilled order
|
||||
*
|
||||
* @param Order $order
|
||||
* @return void
|
||||
*/
|
||||
private function updateInventoryFromOrder(Order $order): void
|
||||
{
|
||||
foreach ($order->getOrderItems() as $orderItem) {
|
||||
$drinkType = $orderItem->getDrinkType();
|
||||
$currentStock = $this->inventoryService->getCurrentStockLevel($drinkType);
|
||||
$newStock = $currentStock + $orderItem->getQuantity();
|
||||
|
||||
$this->inventoryService->updateStockLevel($drinkType, $newStock);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Add an item to an order
|
||||
*
|
||||
* @param Order $order
|
||||
* @param DrinkType $drinkType
|
||||
* @param int $quantity
|
||||
* @return OrderItem
|
||||
* @throws InvalidArgumentException If the order is not in 'new' or 'in_work' status
|
||||
*/
|
||||
public function addOrderItem(Order $order, DrinkType $drinkType, int $quantity): OrderItem
|
||||
{
|
||||
if (!in_array($order->getStatus(), [Order::STATUS_NEW, Order::STATUS_IN_WORK], true)) {
|
||||
throw new InvalidArgumentException("Cannot add items to an order with status '{$order->getStatus()}'");
|
||||
}
|
||||
|
||||
// Check if the order already has an item for this drink type
|
||||
$existingItem = $this->orderItemRepository->findByOrderAndDrinkType($order, $drinkType);
|
||||
|
||||
if ($existingItem instanceof OrderItem) {
|
||||
// Update the existing item
|
||||
$existingItem->setQuantity($existingItem->getQuantity() + $quantity);
|
||||
$this->orderItemRepository->save($existingItem);
|
||||
return $existingItem;
|
||||
}
|
||||
// Create a new item
|
||||
$orderItem = new OrderItem($drinkType, $quantity, $order);
|
||||
$this->orderItemRepository->save($orderItem);
|
||||
return $orderItem;
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove an item from an order
|
||||
*
|
||||
* @param Order $order
|
||||
* @param OrderItem $orderItem
|
||||
* @return void
|
||||
* @throws InvalidArgumentException If the order is not in 'new' or 'in_work' status
|
||||
*/
|
||||
public function removeOrderItem(Order $order, OrderItem $orderItem): void
|
||||
{
|
||||
if (!in_array($order->getStatus(), [Order::STATUS_NEW, Order::STATUS_IN_WORK], true)) {
|
||||
throw new InvalidArgumentException("Cannot remove items from an order with status '{$order->getStatus()}'");
|
||||
}
|
||||
|
||||
$order->removeOrderItem($orderItem);
|
||||
$this->orderItemRepository->remove($orderItem);
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete an order
|
||||
*
|
||||
* @param Order $order
|
||||
* @return void
|
||||
* @throws InvalidArgumentException If the order is not in 'new' status
|
||||
*/
|
||||
public function deleteOrder(Order $order): void
|
||||
{
|
||||
if ($order->getStatus() !== Order::STATUS_NEW) {
|
||||
throw new InvalidArgumentException("Cannot delete an order with status '{$order->getStatus()}'");
|
||||
}
|
||||
|
||||
$this->orderRepository->remove($order);
|
||||
}
|
||||
}
|
262
src/Service/StockAdjustmentService.php
Normal file
262
src/Service/StockAdjustmentService.php
Normal file
|
@ -0,0 +1,262 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Service;
|
||||
|
||||
use App\Entity\InventoryRecord;
|
||||
use App\Entity\DrinkType;
|
||||
use App\Repository\DrinkTypeRepository;
|
||||
use App\Repository\InventoryRecordRepository;
|
||||
use App\Repository\OrderRepository;
|
||||
use DateTimeImmutable;
|
||||
|
||||
class StockAdjustmentService
|
||||
{
|
||||
private const int DEFAULT_LOOKBACK_ORDERS = 5;
|
||||
private const float DEFAULT_ADJUSTMENT_MAGNITUDE = 0.2; // 20%
|
||||
private const float DEFAULT_ADJUSTMENT_THRESHOLD = 0.1; // 10%
|
||||
|
||||
public function __construct(
|
||||
private readonly DrinkTypeRepository $drinkTypeRepository,
|
||||
private readonly InventoryRecordRepository $inventoryRecordRepository,
|
||||
private readonly OrderRepository $orderRepository,
|
||||
private readonly ConfigurationService $configService,
|
||||
private readonly InventoryService $inventoryService
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Analyze consumption patterns and adjust desired stock levels
|
||||
*
|
||||
* @return array<int, array{drinkType: DrinkType, oldDesiredStock: int, newDesiredStock: int, adjustmentReason: string}>
|
||||
*/
|
||||
public function adjustStockLevels(): array
|
||||
{
|
||||
$lookbackOrders = (int) $this->configService->getConfigValue(
|
||||
'stock_adjustment_lookback_orders',
|
||||
(string) self::DEFAULT_LOOKBACK_ORDERS
|
||||
);
|
||||
|
||||
$adjustmentMagnitude = (float) $this->configService->getConfigValue(
|
||||
'stock_adjustment_magnitude',
|
||||
(string) self::DEFAULT_ADJUSTMENT_MAGNITUDE
|
||||
);
|
||||
|
||||
$adjustmentThreshold = (float) $this->configService->getConfigValue(
|
||||
'stock_adjustment_threshold',
|
||||
(string) self::DEFAULT_ADJUSTMENT_THRESHOLD
|
||||
);
|
||||
|
||||
$drinkTypes = $this->drinkTypeRepository->findAll();
|
||||
$result = [];
|
||||
|
||||
foreach ($drinkTypes as $drinkType) {
|
||||
$consumptionRate = $this->calculateConsumptionRateFromOrders($drinkType, $lookbackOrders);
|
||||
|
||||
if ($consumptionRate === null) {
|
||||
continue; // Skip if we don't have enough data
|
||||
}
|
||||
|
||||
$currentDesiredStock = $drinkType->getDesiredStock();
|
||||
// For the suggested stock, we use the consumption rate directly as it's already weighted
|
||||
$suggestedStock = (int) ceil($consumptionRate);
|
||||
|
||||
// Only adjust if the difference is significant
|
||||
$difference = abs($suggestedStock - $currentDesiredStock) / max(1, $currentDesiredStock);
|
||||
|
||||
if ($difference >= $adjustmentThreshold) {
|
||||
// Calculate the new desired stock with a gradual adjustment
|
||||
$adjustment = (int) ceil(($suggestedStock - $currentDesiredStock) * $adjustmentMagnitude);
|
||||
$newDesiredStock = $currentDesiredStock + $adjustment;
|
||||
|
||||
// Ensure we don't go below 1
|
||||
$newDesiredStock = max(1, $newDesiredStock);
|
||||
|
||||
// Update the drink type
|
||||
$drinkType->setDesiredStock($newDesiredStock);
|
||||
$this->drinkTypeRepository->save($drinkType);
|
||||
|
||||
$result[] = [
|
||||
'drinkType' => $drinkType,
|
||||
'oldDesiredStock' => $currentDesiredStock,
|
||||
'newDesiredStock' => $newDesiredStock,
|
||||
'adjustmentReason' => $suggestedStock > $currentDesiredStock
|
||||
? 'Increased consumption rate'
|
||||
: 'Decreased consumption rate',
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate the weighted consumption rate based on the last N orders for a drink type
|
||||
*
|
||||
* @param DrinkType $drinkType
|
||||
* @param int $lookbackOrders Number of past orders to consider
|
||||
* @return float|null Weighted consumption rate, or null if not enough data
|
||||
*/
|
||||
private function calculateConsumptionRateFromOrders(
|
||||
DrinkType $drinkType,
|
||||
int $lookbackOrders
|
||||
): ?float {
|
||||
// Get the last N orders for this drink type
|
||||
$orders = $this->orderRepository->findLastOrdersForDrinkType($drinkType, $lookbackOrders);
|
||||
|
||||
// Need at least one order to calculate consumption
|
||||
if (count($orders) === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Calculate weighted consumption
|
||||
$totalWeightedConsumption = 0;
|
||||
$totalWeight = 0;
|
||||
|
||||
// Define weights - most recent order has highest weight
|
||||
// For example, with 5 orders: weights are 5, 4, 3, 2, 1
|
||||
$weights = range($lookbackOrders, 1);
|
||||
|
||||
foreach ($orders as $index => $order) {
|
||||
// Get the order items for this drink type
|
||||
$orderItems = $order->getOrderItems();
|
||||
$quantity = 0;
|
||||
|
||||
// Sum up quantities for this drink type
|
||||
foreach ($orderItems as $item) {
|
||||
if ($item->getDrinkType()->getId() === $drinkType->getId()) {
|
||||
$quantity += $item->getQuantity();
|
||||
}
|
||||
}
|
||||
|
||||
// Apply weight to this order's consumption
|
||||
$weight = $weights[$index] ?? 1; // Fallback to weight 1 if index is out of bounds
|
||||
$totalWeightedConsumption += $quantity * $weight;
|
||||
$totalWeight += $weight;
|
||||
}
|
||||
|
||||
// If total weight is 0, return null (shouldn't happen, but just in case)
|
||||
if ($totalWeight === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Calculate weighted average consumption
|
||||
return $totalWeightedConsumption / $totalWeight;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate the average daily consumption rate for a drink type
|
||||
*
|
||||
* @deprecated Use calculateConsumptionRateFromOrders instead
|
||||
* @param DrinkType $drinkType
|
||||
* @param DateTimeImmutable $startDate
|
||||
* @param DateTimeImmutable $endDate
|
||||
* @return float|null Average daily consumption rate, or null if not enough data
|
||||
*/
|
||||
private function calculateConsumptionRate(
|
||||
DrinkType $drinkType,
|
||||
DateTimeImmutable $startDate,
|
||||
DateTimeImmutable $endDate
|
||||
): ?float {
|
||||
// Get inventory records for the period
|
||||
$inventoryRecords = $this->inventoryRecordRepository->findByDrinkType($drinkType);
|
||||
|
||||
// Filter records within the date range
|
||||
$inventoryRecords = array_filter($inventoryRecords, function ($record) use ($startDate, $endDate): bool {
|
||||
$timestamp = $record->getTimestamp();
|
||||
return $timestamp >= $startDate && $timestamp <= $endDate;
|
||||
});
|
||||
|
||||
// Sort by timestamp
|
||||
usort($inventoryRecords, fn($a, $b): int => $a->getTimestamp() <=> $b->getTimestamp());
|
||||
|
||||
// Need at least two records to calculate consumption
|
||||
if (count($inventoryRecords) < 2) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Calculate total consumption
|
||||
$totalConsumption = 0;
|
||||
$previousRecord = null;
|
||||
|
||||
foreach ($inventoryRecords as $record) {
|
||||
if ($previousRecord instanceof InventoryRecord) {
|
||||
$previousQuantity = $previousRecord->getQuantity();
|
||||
$currentQuantity = $record->getQuantity();
|
||||
$daysDifference = $record->getTimestamp()->diff($previousRecord->getTimestamp())->days;
|
||||
|
||||
// If days difference is 0, use 1 to avoid division by zero
|
||||
$daysDifference = max(1, $daysDifference);
|
||||
|
||||
// If current quantity is less than previous, consumption occurred
|
||||
if ($currentQuantity < $previousQuantity) {
|
||||
$consumption = $previousQuantity - $currentQuantity;
|
||||
$totalConsumption += $consumption;
|
||||
}
|
||||
}
|
||||
|
||||
$previousRecord = $record;
|
||||
}
|
||||
|
||||
// Calculate days between first and last record
|
||||
$firstRecord = reset($inventoryRecords);
|
||||
$lastRecord = end($inventoryRecords);
|
||||
$totalDays = $lastRecord->getTimestamp()->diff($firstRecord->getTimestamp())->days;
|
||||
|
||||
// If total days is 0, use 1 to avoid division by zero
|
||||
$totalDays = max(1, $totalDays);
|
||||
|
||||
// Calculate average daily consumption
|
||||
return $totalConsumption / $totalDays;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the consumption history for a drink type
|
||||
*
|
||||
* @param DrinkType $drinkType
|
||||
* @param DateTimeImmutable $startDate
|
||||
* @param DateTimeImmutable $endDate
|
||||
* @return array<int, array{date: DateTimeImmutable, consumption: int}>
|
||||
*/
|
||||
public function getConsumptionHistory(
|
||||
DrinkType $drinkType,
|
||||
DateTimeImmutable $startDate,
|
||||
DateTimeImmutable $endDate
|
||||
): array {
|
||||
// Get inventory records for the period
|
||||
$inventoryRecords = $this->inventoryRecordRepository->findByDrinkType($drinkType);
|
||||
|
||||
// Filter records within the date range
|
||||
$inventoryRecords = array_filter($inventoryRecords, function ($record) use ($startDate, $endDate): bool {
|
||||
$timestamp = $record->getTimestamp();
|
||||
return $timestamp >= $startDate && $timestamp <= $endDate;
|
||||
});
|
||||
|
||||
// Sort by timestamp
|
||||
usort($inventoryRecords, fn($a, $b): int => $a->getTimestamp() <=> $b->getTimestamp());
|
||||
|
||||
// Calculate consumption between each record
|
||||
$consumptionHistory = [];
|
||||
$previousRecord = null;
|
||||
|
||||
foreach ($inventoryRecords as $record) {
|
||||
if ($previousRecord !== null) {
|
||||
$previousQuantity = $previousRecord->getQuantity();
|
||||
$currentQuantity = $record->getQuantity();
|
||||
|
||||
// If current quantity is less than previous, consumption occurred
|
||||
if ($currentQuantity < $previousQuantity) {
|
||||
$consumption = $previousQuantity - $currentQuantity;
|
||||
$consumptionHistory[] = [
|
||||
'date' => $record->getTimestamp(),
|
||||
'consumption' => $consumption,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
$previousRecord = $record;
|
||||
}
|
||||
|
||||
return $consumptionHistory;
|
||||
}
|
||||
}
|
14
src/Settings.php
Normal file
14
src/Settings.php
Normal file
|
@ -0,0 +1,14 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App;
|
||||
|
||||
final readonly class Settings
|
||||
{
|
||||
public function __construct(
|
||||
public bool $isTestMode = false,
|
||||
public bool $isDevMode = false,
|
||||
public string $cacheDir = __DIR__ . '/../var/cache'
|
||||
) {}
|
||||
}
|
38
src/bootstrap.php
Normal file
38
src/bootstrap.php
Normal file
|
@ -0,0 +1,38 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Middleware\ErrorHandlerMiddleware;
|
||||
use Monolog\Logger;
|
||||
use Slim\App;
|
||||
use Slim\Views\Twig;
|
||||
use DI\Bridge\Slim\Bridge;
|
||||
|
||||
return (function (): App {
|
||||
// Create container
|
||||
$container = (require __DIR__ . '/../config/container.php')();
|
||||
/** @var \App\Settings $settings */
|
||||
$settings = $container->get(\App\Settings::class);
|
||||
|
||||
// create cache dirs
|
||||
foreach (['twig', 'doctrine/proxy'] as $dir) {
|
||||
if (!is_dir($settings->cacheDir . '/' . $dir)) {
|
||||
mkdir($settings->cacheDir . '/' . $dir, 0777, true);
|
||||
}
|
||||
}
|
||||
|
||||
// Create app with container
|
||||
$app = Bridge::create($container);
|
||||
|
||||
// Add middleware
|
||||
$app->add(new ErrorHandlerMiddleware(
|
||||
$container->get(Logger::class),
|
||||
$container->get(Twig::class),
|
||||
true // Set to false in production
|
||||
));
|
||||
|
||||
// Add routes
|
||||
(require __DIR__ . '/routes.php')($app);
|
||||
|
||||
return $app;
|
||||
})();
|
48
src/routes.php
Normal file
48
src/routes.php
Normal file
|
@ -0,0 +1,48 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Controller\DashboardController;
|
||||
use App\Controller\DrinkTypeController;
|
||||
use App\Controller\InventoryController;
|
||||
use App\Controller\OrderController;
|
||||
use App\Controller\SettingsController;
|
||||
use Slim\App;
|
||||
|
||||
return function (App $app): void {
|
||||
// Dashboard routes
|
||||
$app->get('/', [DashboardController::class, 'index']);
|
||||
|
||||
// Drink Type routes
|
||||
$app->get('/drink-types', [DrinkTypeController::class, 'index']);
|
||||
$app->get('/drink-types/create', [DrinkTypeController::class, 'create']);
|
||||
$app->post('/drink-types', [DrinkTypeController::class, 'store']);
|
||||
$app->get('/drink-types/{id:[0-9]+}', [DrinkTypeController::class, 'show']);
|
||||
$app->get('/drink-types/{id:[0-9]+}/edit', [DrinkTypeController::class, 'edit']);
|
||||
$app->post('/drink-types/{id:[0-9]+}', [DrinkTypeController::class, 'update']);
|
||||
$app->post('/drink-types/{id:[0-9]+}/delete', [DrinkTypeController::class, 'delete']);
|
||||
|
||||
// Inventory routes
|
||||
$app->get('/inventory', [InventoryController::class, 'index']);
|
||||
$app->get('/inventory/update', [InventoryController::class, 'showUpdateForm']);
|
||||
$app->post('/inventory/update', [InventoryController::class, 'update']);
|
||||
$app->get('/inventory/history', [InventoryController::class, 'history']);
|
||||
$app->get('/inventory/history/{id:[0-9]+}', [InventoryController::class, 'history']);
|
||||
$app->get('/inventory/low-stock', [InventoryController::class, 'lowStock']);
|
||||
|
||||
// Order routes
|
||||
$app->get('/orders', [OrderController::class, 'index']);
|
||||
$app->get('/orders/create', [OrderController::class, 'create']);
|
||||
$app->post('/orders', [OrderController::class, 'store']);
|
||||
$app->get('/orders/create-from-stock', [OrderController::class, 'createFromStock']);
|
||||
$app->get('/orders/{id:[0-9]+}', [OrderController::class, 'show']);
|
||||
$app->post('/orders/{id:[0-9]+}/status', [OrderController::class, 'updateStatus']);
|
||||
$app->post('/orders/{id:[0-9]+}/items', [OrderController::class, 'addItem']);
|
||||
$app->post('/orders/{id:[0-9]+}/items/{itemId:[0-9]+}/remove', [OrderController::class, 'removeItem']);
|
||||
$app->post('/orders/{id:[0-9]+}/delete', [OrderController::class, 'delete']);
|
||||
|
||||
// Settings routes
|
||||
$app->get('/settings', [SettingsController::class, 'index']);
|
||||
$app->post('/settings', [SettingsController::class, 'update']);
|
||||
$app->post('/settings/reset', [SettingsController::class, 'resetToDefaults']);
|
||||
};
|
21
templates/components/flash-messages.twig
Normal file
21
templates/components/flash-messages.twig
Normal file
|
@ -0,0 +1,21 @@
|
|||
{% if flash %}
|
||||
<div class="flash-messages">
|
||||
{% for type, messages in flash %}
|
||||
{% for message in messages %}
|
||||
<div class="flash-message flash-{{ type }}">
|
||||
{% if type == 'success' %}
|
||||
🎉🎊 SUCCESS! 🎊🎉 {{ message }} 🥳🙌 WOOHOO! 🌟✨
|
||||
{% elseif type == 'error' %}
|
||||
❌😱 ERROR! 😱❌ {{ message }} 💥😭 OH NO! 💔🔥
|
||||
{% elseif type == 'warning' %}
|
||||
⚠️😬 WARNING! 😬⚠️ {{ message }} 🚨🚧 BE CAREFUL! 🚧🚨
|
||||
{% elseif type == 'info' %}
|
||||
ℹ️🧐 INFO! 🧐ℹ️ {{ message }} 📝💡 GOOD TO KNOW! 💭📊
|
||||
{% else %}
|
||||
💌✨ {{ message }} ✨💌
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
73
templates/components/form.twig
Normal file
73
templates/components/form.twig
Normal file
|
@ -0,0 +1,73 @@
|
|||
{# 🌈✨ Super Amazing Form Components 🎨🎉 #}
|
||||
|
||||
{# 📝 Input Field 📝 #}
|
||||
{% macro input(name, label, value, type = 'text', required = false, placeholder = '', class = '') %}
|
||||
<div class="form-group">
|
||||
<label for="{{ name }}">✨ {{ label }} ✨{% if required %} <span class="required">⭐ REQUIRED ⭐</span>{% endif %}</label>
|
||||
<input type="{{ type }}" id="{{ name }}" name="{{ name }}" value="{{ value|default('') }}"
|
||||
{% if required %}required{% endif %}
|
||||
{% if placeholder %}placeholder="{{ placeholder }} 💫"{% endif %}
|
||||
class="form-control {{ class }}">
|
||||
</div>
|
||||
{% endmacro %}
|
||||
|
||||
{# 📄 Textarea Field 📄 #}
|
||||
{% macro textarea(name, label, value, required = false, rows = 3, placeholder = '', class = '') %}
|
||||
<div class="form-group">
|
||||
<label for="{{ name }}">📝 {{ label }} 📝{% if required %} <span class="required">⭐ MUST FILL ⭐</span>{% endif %}</label>
|
||||
<textarea id="{{ name }}" name="{{ name }}" rows="{{ rows }}"
|
||||
{% if required %}required{% endif %}
|
||||
{% if placeholder %}placeholder="{{ placeholder }} ✏️"{% endif %}
|
||||
class="form-control {{ class }}">{{ value|default('') }}</textarea>
|
||||
</div>
|
||||
{% endmacro %}
|
||||
|
||||
{# 📋 Select Field 📋 #}
|
||||
{% macro select(name, label, options, selected, required = false, class = '') %}
|
||||
<div class="form-group">
|
||||
<label for="{{ name }}">🔽 {{ label }} 🔽{% if required %} <span class="required">⭐ PICK ONE ⭐</span>{% endif %}</label>
|
||||
<select id="{{ name }}" name="{{ name }}" class="form-control {{ class }}" {% if required %}required{% endif %}>
|
||||
<option value="">-- 🔍 Select {{ label }} 🔎 --</option>
|
||||
{% for value, text in options %}
|
||||
<option value="{{ value }}" {% if selected == value %}selected{% endif %}>✅ {{ text }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
{% endmacro %}
|
||||
|
||||
{# ✅ Checkbox Field ✅ #}
|
||||
{% macro checkbox(name, label, checked = false, value = '1', class = '') %}
|
||||
<div class="form-check">
|
||||
<input type="checkbox" id="{{ name }}" name="{{ name }}" value="{{ value }}"
|
||||
{% if checked %}checked{% endif %} class="form-check-input {{ class }}">
|
||||
<label class="form-check-label" for="{{ name }}">✅ {{ label }} ✅</label>
|
||||
</div>
|
||||
{% endmacro %}
|
||||
|
||||
{# 🔘 Radio Button 🔘 #}
|
||||
{% macro radio(name, label, value, checked = false, class = '') %}
|
||||
<div class="form-check">
|
||||
<input type="radio" id="{{ name }}_{{ value }}" name="{{ name }}" value="{{ value }}"
|
||||
{% if checked %}checked{% endif %} class="form-check-input {{ class }}">
|
||||
<label class="form-check-label" for="{{ name }}_{{ value }}">🔘 {{ label }} 🔘</label>
|
||||
</div>
|
||||
{% endmacro %}
|
||||
|
||||
{# 🚀 Submit Button 🚀 #}
|
||||
{% macro submit(label = 'Submit', class = 'btn-primary') %}
|
||||
<button type="submit" class="btn {{ class }}">🚀 {{ label }} 🚀</button>
|
||||
{% endmacro %}
|
||||
|
||||
{# 🔳 Button 🔳 #}
|
||||
{% macro button(label, type = 'button', class = 'btn-secondary', attributes = '') %}
|
||||
<button type="{{ type }}" class="btn {{ class }}" {{ attributes|raw }}>🔳 {{ label }} 🔳</button>
|
||||
{% endmacro %}
|
||||
|
||||
{# ⚠️ Form Error Display ⚠️ #}
|
||||
{% macro errors(error) %}
|
||||
{% if error %}
|
||||
<div class="alert alert-danger">
|
||||
⚠️😱 ERROR ALERT! 😱⚠️ {{ error }} 💥💔
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endmacro %}
|
9
templates/components/navigation.twig
Normal file
9
templates/components/navigation.twig
Normal file
|
@ -0,0 +1,9 @@
|
|||
<nav>
|
||||
<ul>
|
||||
<li><a href="/">🏠 Dashboard 📊</a></li>
|
||||
<li><a href="/drink-types">🍹 Drink Types 🍸🥤</a></li>
|
||||
<li><a href="/inventory">📦 Inventory 🗃️🧮</a></li>
|
||||
<li><a href="/orders">🛒 Orders 📝🚚</a></li>
|
||||
<li><a href="/settings">⚙️ Settings 🔧🛠️</a></li>
|
||||
</ul>
|
||||
</nav>
|
145
templates/dashboard/index.twig
Normal file
145
templates/dashboard/index.twig
Normal file
|
@ -0,0 +1,145 @@
|
|||
{% extends "layout.twig" %}
|
||||
|
||||
{% block title %}🏠 Dashboard 📊 - {{ parent() }}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="dashboard">
|
||||
<div class="row mb-4">
|
||||
<div class="col">
|
||||
<h2>🎉✨ WELCOME TO THE SUPER AMAZING DRINKS INVENTORY SYSTEM!!! 🍹🥂</h2>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row mb-4">
|
||||
<!-- Main Dashboard Content -->
|
||||
<div class="col-lg-8">
|
||||
<!-- Low Stock Alerts -->
|
||||
{% if showLowStockAlerts %}
|
||||
{% include 'dashboard/low-stock-alerts.twig' %}
|
||||
{% endif %}
|
||||
|
||||
<!-- Dashboard Summary Card -->
|
||||
<div class="card mb-4">
|
||||
<div class="card-header bg-rainbow text-white">
|
||||
<h5 class="card-title mb-0">🌟 DASHBOARD SUMMARY 🌟</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<p>🚀 This AWESOME system helps you manage your drinks inventory 🍺, track stock levels 📊, and create orders 📝!!! 🌟</p>
|
||||
|
||||
<div class="row mt-4">
|
||||
<div class="col-md-4 mb-3">
|
||||
<div class="d-grid">
|
||||
<a href="/drink-types" class="btn btn-primary btn-lg">
|
||||
<i class="fas fa-cocktail me-2"></i>🍸 Manage Drink Types 🍹
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-4 mb-3">
|
||||
<div class="d-grid">
|
||||
<a href="/inventory" class="btn btn-success btn-lg">
|
||||
<i class="fas fa-boxes me-2"></i>📦 Update Inventory 🔄
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-4 mb-3">
|
||||
<div class="d-grid">
|
||||
<a href="/orders" class="btn btn-warning btn-lg">
|
||||
<i class="fas fa-shopping-cart me-2"></i>🛒 View Orders 📋
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Getting Started Card -->
|
||||
<div class="card mb-4">
|
||||
<div class="card-header bg-info text-white">
|
||||
<h5 class="card-title mb-0">🚀 GETTING STARTED 🚀</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<ol class="list-group list-group-numbered mb-0">
|
||||
<li class="list-group-item d-flex justify-content-between align-items-start">
|
||||
<div class="ms-2 me-auto">
|
||||
<div class="fw-bold">🍹 Add drink types</div>
|
||||
Define your products and set desired stock levels 🎯
|
||||
</div>
|
||||
<a href="/drink-types/create" class="btn btn-sm btn-primary">Add Now</a>
|
||||
</li>
|
||||
<li class="list-group-item d-flex justify-content-between align-items-start">
|
||||
<div class="ms-2 me-auto">
|
||||
<div class="fw-bold">📦 Update inventory</div>
|
||||
Keep your stock levels accurate and up-to-date 🔄
|
||||
</div>
|
||||
<a href="/inventory/update" class="btn btn-sm btn-primary">Update</a>
|
||||
</li>
|
||||
<li class="list-group-item d-flex justify-content-between align-items-start">
|
||||
<div class="ms-2 me-auto">
|
||||
<div class="fw-bold">🛒 Create orders</div>
|
||||
Order more drinks based on inventory needs 📝
|
||||
</div>
|
||||
<a href="/orders/create" class="btn btn-sm btn-primary">Order</a>
|
||||
</li>
|
||||
<li class="list-group-item d-flex justify-content-between align-items-start">
|
||||
<div class="ms-2 me-auto">
|
||||
<div class="fw-bold">📋 Track orders</div>
|
||||
Update inventory when orders are fulfilled ✅
|
||||
</div>
|
||||
<a href="/orders" class="btn btn-sm btn-primary">View</a>
|
||||
</li>
|
||||
</ol>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Sidebar Content -->
|
||||
<div class="col-lg-4">
|
||||
<!-- Quick Update Form -->
|
||||
{% if showQuickUpdateForm %}
|
||||
{% include 'dashboard/quick-update-form.twig' %}
|
||||
{% endif %}
|
||||
|
||||
<!-- Fun Facts Card -->
|
||||
<div class="card mb-4">
|
||||
<div class="card-header bg-success text-white">
|
||||
<h5 class="card-title mb-0">💫 FUN FACTS ABOUT DRINKS!!! 💫</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="list-group">
|
||||
<div class="list-group-item">
|
||||
<i class="fas fa-beer text-warning me-2"></i>🍺 Beer is one of the oldest and most widely consumed alcoholic drinks in the world! 🌍
|
||||
</div>
|
||||
<div class="list-group-item">
|
||||
<i class="fas fa-wine-glass-alt text-danger me-2"></i>🍷 Wine has been produced for thousands of years! The earliest evidence of wine is from Georgia (6000 BC)! 🕰️
|
||||
</div>
|
||||
<div class="list-group-item">
|
||||
<i class="fas fa-glass-martini-alt text-info me-2"></i>🍹 The world's most expensive cocktail is the "Diamonds Are Forever" martini, costing $22,600! 💎
|
||||
</div>
|
||||
<div class="list-group-item">
|
||||
<i class="fas fa-glass-whiskey text-success me-2"></i>🥤 The average person will drink about 20,000 beverages in their lifetime! 😮
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Motivation Card -->
|
||||
<div class="card">
|
||||
<div class="card-header bg-pride-purple text-white">
|
||||
<h5 class="card-title mb-0">💪 MOTIVATION FOR TODAY!!! 💪</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="alert alert-success">
|
||||
<i class="fas fa-star me-2"></i>🌟 Keep your inventory STOCKED and your customers HAPPY! 😄 You're doing AMAZING work! 🎉
|
||||
</div>
|
||||
<div class="alert alert-info">
|
||||
<i class="fas fa-rocket me-2"></i>🚀 Remember: Every bottle tracked is a problem solved! 🧠 Stay AWESOME and keep CRUSHING it! 💯
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-footer bg-light">
|
||||
<p class="mb-0 text-center">✨ You're doing GREAT! Keep it up! ✨</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
49
templates/dashboard/low-stock-alerts.twig
Normal file
49
templates/dashboard/low-stock-alerts.twig
Normal file
|
@ -0,0 +1,49 @@
|
|||
{# 🚨 Super Important Low Stock Alerts Component 🚨 #}
|
||||
|
||||
<div class="card mb-4">
|
||||
<div class="card-header bg-warning text-white">
|
||||
<h5 class="card-title mb-0">🚨 OMG!!! LOW STOCK ALERTS!!! 🚨</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
{% if lowStockItems is empty %}
|
||||
<p class="text-success">✅✅✅ WOOHOO!!! All stock levels are SUPER ADEQUATE!!! 🎉🎊 PARTY TIME!!! 🥳🍾</p>
|
||||
{% else %}
|
||||
<div class="alert alert-danger">
|
||||
<h6>😱 OH NO!!! WE'RE RUNNING OUT OF DRINKS!!! 😱</h6>
|
||||
<p>🆘 EMERGENCY SITUATION!!! 🆘 We need to restock ASAP!!! ⏰⏰⏰</p>
|
||||
</div>
|
||||
<div class="table-responsive">
|
||||
<table class="table table-striped">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>🍹 Drink Type 🍸</th>
|
||||
<th>📉 Current Stock 📊</th>
|
||||
<th>🎯 Desired Stock 🏆</th>
|
||||
<th>⚠️ Difference ⚠️</th>
|
||||
<th>🛠️ Actions 🛠️</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for item in lowStockItems %}
|
||||
<tr>
|
||||
<td>🥤 {{ item.drinkType.name }} 🥤</td>
|
||||
<td>📉 {{ item.currentStock }} 📉</td>
|
||||
<td>🎯 {{ item.desiredStock }} 🎯</td>
|
||||
<td class="text-danger">⚠️ {{ item.currentStock - item.desiredStock }} ⚠️</td>
|
||||
<td>
|
||||
<a href="/inventory/update" class="btn btn-sm btn-primary">🔄 Update Stock NOW!!! 🔄</a>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="mt-3">
|
||||
<a href="/orders/create-from-stock" class="btn btn-success btn-lg">🚚 CREATE EMERGENCY ORDER NOW!!! 🚨</a>
|
||||
</div>
|
||||
<div class="mt-2">
|
||||
<p class="text-danger">⏰ Don't delay! Your customers are THIRSTY!!! 🥵 Act NOW before it's TOO LATE!!! ⏰</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
48
templates/dashboard/quick-update-form.twig
Normal file
48
templates/dashboard/quick-update-form.twig
Normal file
|
@ -0,0 +1,48 @@
|
|||
{# ⚡⚡⚡ SUPER FAST Quick Update Form Component ⚡⚡⚡ #}
|
||||
{% import 'components/form.twig' as form %}
|
||||
|
||||
<div class="card mb-4">
|
||||
<div class="card-header bg-primary text-white">
|
||||
<h5 class="card-title mb-0">⚡ LIGHTNING FAST STOCK UPDATE!!! ⚡</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<form action="/inventory/update" method="post">
|
||||
{{ form.errors(error) }}
|
||||
|
||||
<div class="alert alert-info">
|
||||
<h6>🏎️ ZOOM ZOOM!!! 🏎️</h6>
|
||||
<p>⏱️ Update your stock in SECONDS!!! ⏱️ SO FAST!!! SO EASY!!! 🤩</p>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-md-6">
|
||||
<div class="form-group">
|
||||
<label for="drink_type_id">🍹 CHOOSE YOUR DRINK!!! 🍸🥤</label>
|
||||
<select id="drink_type_id" name="drink_type_id" class="form-control" required>
|
||||
<option value="">-- 🔍 SELECT A SUPER AWESOME DRINK 🔎 --</option>
|
||||
{% for drinkType in drinkTypes %}
|
||||
<option value="{{ drinkType.id }}">🥂 {{ drinkType.name }} 🍾</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<div class="form-group">
|
||||
<label for="quantity">🔢 HOW MANY DO YOU HAVE??? 📊📈</label>
|
||||
<input type="number" id="quantity" name="quantity" class="form-control" min="0" required>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group mt-3">
|
||||
<button type="submit" class="btn btn-primary btn-lg">🚀 UPDATE STOCK NOW!!! 🚀</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<div class="card-footer">
|
||||
<small class="text-muted">⚡ This AMAZING form updates the current stock level for the selected drink type IN SECONDS!!! ⚡ WOW!!! 😲</small>
|
||||
</div>
|
||||
<div class="card-footer bg-light">
|
||||
<p class="mb-0 text-center">💯 PRODUCTIVITY TIP: Update your stock DAILY for MAXIMUM EFFICIENCY!!! 💯</p>
|
||||
</div>
|
||||
</div>
|
36
templates/drink-types/create.twig
Normal file
36
templates/drink-types/create.twig
Normal file
|
@ -0,0 +1,36 @@
|
|||
{% extends 'layout.twig' %}
|
||||
{% import 'components/form.twig' as form %}
|
||||
|
||||
{% block title %}Add New Drink Type - {{ parent() }}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="row mb-4">
|
||||
<div class="col">
|
||||
<h2>Add New Drink Type</h2>
|
||||
</div>
|
||||
<div class="col-auto">
|
||||
<a href="/drink-types" class="btn btn-secondary">
|
||||
<i class="fas fa-arrow-left"></i> Back to List
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
{{ form.errors(error) }}
|
||||
|
||||
<form action="/drink-types" method="post">
|
||||
{{ form.input('name', 'Name', data.name|default(''), 'text', true) }}
|
||||
|
||||
{{ form.textarea('description', 'Description', data.description|default(''), false, 3, 'Enter a description of the drink type') }}
|
||||
|
||||
{{ form.input('desired_stock', 'Desired Stock', data.desired_stock|default('10'), 'number', true, '', 'form-control-sm') }}
|
||||
|
||||
<div class="form-group mt-4">
|
||||
{{ form.submit('Create Drink Type') }}
|
||||
<a href="/drink-types" class="btn btn-link">Cancel</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
36
templates/drink-types/edit.twig
Normal file
36
templates/drink-types/edit.twig
Normal file
|
@ -0,0 +1,36 @@
|
|||
{% extends 'layout.twig' %}
|
||||
{% import 'components/form.twig' as form %}
|
||||
|
||||
{% block title %}Edit Drink Type - {{ parent() }}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="row mb-4">
|
||||
<div class="col">
|
||||
<h2>Edit Drink Type</h2>
|
||||
</div>
|
||||
<div class="col-auto">
|
||||
<a href="/drink-types" class="btn btn-secondary">
|
||||
<i class="fas fa-arrow-left"></i> Back to List
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
{{ form.errors(error) }}
|
||||
|
||||
<form action="/drink-types/{{ drinkType.id }}" method="post">
|
||||
{{ form.input('name', 'Name', drinkType.name, 'text', true) }}
|
||||
|
||||
{{ form.textarea('description', 'Description', drinkType.description, false, 3, 'Enter a description of the drink type') }}
|
||||
|
||||
{{ form.input('desired_stock', 'Desired Stock', drinkType.desiredStock, 'number', true, '', 'form-control-sm') }}
|
||||
|
||||
<div class="form-group mt-4">
|
||||
{{ form.submit('Update Drink Type') }}
|
||||
<a href="/drink-types/{{ drinkType.id }}" class="btn btn-link">Cancel</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
73
templates/drink-types/index.twig
Normal file
73
templates/drink-types/index.twig
Normal file
|
@ -0,0 +1,73 @@
|
|||
{% extends 'layout.twig' %}
|
||||
|
||||
{% block title %}Drink Types - {{ parent() }}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="row mb-4">
|
||||
<div class="col">
|
||||
<h2>Drink Types</h2>
|
||||
</div>
|
||||
<div class="col-auto">
|
||||
<a href="/drink-types/create" class="btn btn-success">
|
||||
<i class="fas fa-plus"></i> Add New Drink Type
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if drinkTypes is empty %}
|
||||
<div class="alert alert-info">
|
||||
No drink types found. Click the button above to add your first drink type.
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="table-responsive">
|
||||
<table class="table table-striped table-hover">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th>Name</th>
|
||||
<th>Description</th>
|
||||
<th>Desired Stock</th>
|
||||
<th>Current Stock</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for drinkType in drinkTypes %}
|
||||
<tr>
|
||||
<td>{{ drinkType.id }}</td>
|
||||
<td>{{ drinkType.name }}</td>
|
||||
<td>{{ drinkType.description|default('-') }}</td>
|
||||
<td>{{ drinkType.desiredStock }}</td>
|
||||
<td>
|
||||
{% if drinkType.currentStock is defined %}
|
||||
{% if drinkType.currentStock < drinkType.desiredStock %}
|
||||
<span class="text-danger">{{ drinkType.currentStock }}</span>
|
||||
{% else %}
|
||||
<span class="text-success">{{ drinkType.currentStock }}</span>
|
||||
{% endif %}
|
||||
{% else %}
|
||||
-
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
<div class="btn-group" role="group">
|
||||
<a href="/drink-types/{{ drinkType.id }}" class="btn btn-sm btn-info">
|
||||
<i class="fas fa-eye"></i> View
|
||||
</a>
|
||||
<a href="/drink-types/{{ drinkType.id }}/edit" class="btn btn-sm btn-primary">
|
||||
<i class="fas fa-edit"></i> Edit
|
||||
</a>
|
||||
<form action="/drink-types/{{ drinkType.id }}/delete" method="post" class="d-inline" onsubmit="return confirm('Are you sure you want to delete this drink type?');">
|
||||
<button type="submit" class="btn btn-sm btn-danger">
|
||||
<i class="fas fa-trash"></i> Delete
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endblock %}
|
141
templates/drink-types/show.twig
Normal file
141
templates/drink-types/show.twig
Normal file
|
@ -0,0 +1,141 @@
|
|||
{% extends 'layout.twig' %}
|
||||
|
||||
{% block title %}{{ drinkType.name }} - {{ parent() }}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="row mb-4">
|
||||
<div class="col">
|
||||
<h2>Drink Type Details</h2>
|
||||
</div>
|
||||
<div class="col-auto">
|
||||
<div class="btn-group" role="group">
|
||||
<a href="/drink-types" class="btn btn-secondary">
|
||||
<i class="fas fa-arrow-left"></i> Back to List
|
||||
</a>
|
||||
<a href="/drink-types/{{ drinkType.id }}/edit" class="btn btn-primary">
|
||||
<i class="fas fa-edit"></i> Edit
|
||||
</a>
|
||||
<form action="/drink-types/{{ drinkType.id }}/delete" method="post" class="d-inline" onsubmit="return confirm('Are you sure you want to delete this drink type?');">
|
||||
<button type="submit" class="btn btn-danger">
|
||||
<i class="fas fa-trash"></i> Delete
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-md-6">
|
||||
<div class="card mb-4">
|
||||
<div class="card-header">
|
||||
<h5 class="card-title mb-0">Basic Information</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<table class="table table-bordered">
|
||||
<tr>
|
||||
<th style="width: 30%">ID</th>
|
||||
<td>{{ drinkType.id }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<td>{{ drinkType.name }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>Description</th>
|
||||
<td>{{ drinkType.description|default('No description provided') }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>Desired Stock</th>
|
||||
<td>{{ drinkType.desiredStock }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>Current Stock</th>
|
||||
<td>
|
||||
{% if currentStock is defined %}
|
||||
{% if currentStock < drinkType.desiredStock %}
|
||||
<span class="text-danger">{{ currentStock }}</span>
|
||||
{% else %}
|
||||
<span class="text-success">{{ currentStock }}</span>
|
||||
{% endif %}
|
||||
{% else %}
|
||||
Not available
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>Created At</th>
|
||||
<td>{{ drinkType.createdAt|date('Y-m-d H:i:s') }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>Updated At</th>
|
||||
<td>{{ drinkType.updatedAt|date('Y-m-d H:i:s') }}</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-md-6">
|
||||
<div class="card mb-4">
|
||||
<div class="card-header">
|
||||
<h5 class="card-title mb-0">Stock History</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
{% if inventoryRecords is defined and inventoryRecords is not empty %}
|
||||
<div class="table-responsive">
|
||||
<table class="table table-striped table-sm">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Date</th>
|
||||
<th>Quantity</th>
|
||||
<th>Change</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for record in inventoryRecords %}
|
||||
<tr>
|
||||
<td>{{ record.createdAt|date('Y-m-d H:i:s') }}</td>
|
||||
<td>{{ record.quantity }}</td>
|
||||
<td>
|
||||
{% if record.change > 0 %}
|
||||
<span class="text-success">+{{ record.change }}</span>
|
||||
{% elseif record.change < 0 %}
|
||||
<span class="text-danger">{{ record.change }}</span>
|
||||
{% else %}
|
||||
<span class="text-muted">0</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="mt-3">
|
||||
<a href="/inventory/history/{{ drinkType.id }}" class="btn btn-sm btn-info">
|
||||
View Full History
|
||||
</a>
|
||||
</div>
|
||||
{% else %}
|
||||
<p class="text-muted">No inventory records found for this drink type.</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h5 class="card-title mb-0">Quick Actions</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="d-grid gap-2">
|
||||
<a href="/inventory/update" class="btn btn-primary">
|
||||
<i class="fas fa-sync"></i> Update Stock
|
||||
</a>
|
||||
<a href="/orders/create" class="btn btn-success">
|
||||
<i class="fas fa-shopping-cart"></i> Create Order
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
25
templates/error.twig
Normal file
25
templates/error.twig
Normal file
|
@ -0,0 +1,25 @@
|
|||
{% extends "layout.twig" %}
|
||||
|
||||
{% block title %}Error {{ status_code }} - {{ parent() }}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="error-container">
|
||||
<h2>Error {{ status_code }}</h2>
|
||||
|
||||
<div class="error-message">
|
||||
<p>{{ error.message }}</p>
|
||||
</div>
|
||||
|
||||
{% if error.trace is defined %}
|
||||
<div class="error-details">
|
||||
<h3>Error Details</h3>
|
||||
<p>File: {{ error.file }} (line {{ error.line }})</p>
|
||||
<pre>{{ error.trace }}</pre>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="error-actions">
|
||||
<a href="/" class="btn">Return to Dashboard</a>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
172
templates/inventory/history.twig
Normal file
172
templates/inventory/history.twig
Normal file
|
@ -0,0 +1,172 @@
|
|||
{% extends 'layout.twig' %}
|
||||
|
||||
{% block title %}Inventory History - {{ parent() }}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="row mb-4">
|
||||
<div class="col">
|
||||
<h2>
|
||||
{% if drinkType is defined %}
|
||||
Inventory History for {{ drinkType.name }}
|
||||
{% else %}
|
||||
Inventory History
|
||||
{% endif %}
|
||||
</h2>
|
||||
</div>
|
||||
<div class="col-auto">
|
||||
<div class="btn-group" role="group">
|
||||
<a href="/inventory" class="btn btn-secondary">
|
||||
<i class="fas fa-arrow-left"></i> Back to Inventory
|
||||
</a>
|
||||
{% if drinkType is defined %}
|
||||
<a href="/inventory/history" class="btn btn-info">
|
||||
<i class="fas fa-list"></i> All History
|
||||
</a>
|
||||
{% endif %}
|
||||
<a href="/inventory/update" class="btn btn-primary">
|
||||
<i class="fas fa-sync"></i> Update Stock
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if inventoryRecords is empty %}
|
||||
<div class="alert alert-info">
|
||||
No inventory records found.
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<div class="row">
|
||||
<div class="col">
|
||||
<h5 class="card-title mb-0">Stock Change History</h5>
|
||||
</div>
|
||||
<div class="col-auto">
|
||||
<div class="dropdown">
|
||||
<button class="btn btn-sm btn-outline-secondary dropdown-toggle" type="button" id="filterDropdown" data-bs-toggle="dropdown" aria-expanded="false">
|
||||
Filter
|
||||
</button>
|
||||
<ul class="dropdown-menu" aria-labelledby="filterDropdown">
|
||||
<li><a class="dropdown-item" href="/inventory/history">All Records</a></li>
|
||||
<li><hr class="dropdown-divider"></li>
|
||||
<li><h6 class="dropdown-header">By Drink Type</h6></li>
|
||||
{% for type in drinkTypes|default([]) %}
|
||||
<li><a class="dropdown-item" href="/inventory/history/{{ type.id }}">{{ type.name }}</a></li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="table-responsive">
|
||||
<table class="table table-striped table-hover">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Date</th>
|
||||
{% if drinkType is not defined %}
|
||||
<th>Drink Type</th>
|
||||
{% endif %}
|
||||
<th>Previous Quantity</th>
|
||||
<th>New Quantity</th>
|
||||
<th>Change</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for record in inventoryRecords %}
|
||||
<tr>
|
||||
<td>{{ record.createdAt|date('Y-m-d H:i:s') }}</td>
|
||||
{% if drinkType is not defined %}
|
||||
<td>
|
||||
<a href="/drink-types/{{ record.drinkType.id }}">
|
||||
{{ record.drinkType.name }}
|
||||
</a>
|
||||
</td>
|
||||
{% endif %}
|
||||
<td>{{ record.previousQuantity|default('-') }}</td>
|
||||
<td>{{ record.quantity }}</td>
|
||||
<td>
|
||||
{% if record.change is defined %}
|
||||
{% if record.change > 0 %}
|
||||
<span class="text-success">+{{ record.change }}</span>
|
||||
{% elseif record.change < 0 %}
|
||||
<span class="text-danger">{{ record.change }}</span>
|
||||
{% else %}
|
||||
<span class="text-muted">0</span>
|
||||
{% endif %}
|
||||
{% else %}
|
||||
<span class="text-muted">Initial</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-footer">
|
||||
<small class="text-muted">Showing {{ inventoryRecords|length }} records</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if drinkType is defined %}
|
||||
<div class="row mt-4">
|
||||
<div class="col-md-6">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h5 class="card-title mb-0">Stock Level Trend</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<p class="text-muted">Stock level trend visualization would be displayed here.</p>
|
||||
<div class="alert alert-info">
|
||||
<i class="fas fa-info-circle"></i> This is a placeholder for a chart showing the stock level trend over time.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h5 class="card-title mb-0">Drink Type Information</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<table class="table table-bordered">
|
||||
<tr>
|
||||
<th style="width: 30%">Name</th>
|
||||
<td>{{ drinkType.name }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>Description</th>
|
||||
<td>{{ drinkType.description|default('No description provided') }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>Desired Stock</th>
|
||||
<td>{{ drinkType.desiredStock }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>Current Stock</th>
|
||||
<td>
|
||||
{% if drinkType.currentStock is defined %}
|
||||
{% if drinkType.currentStock < drinkType.desiredStock %}
|
||||
<span class="text-danger">{{ drinkType.currentStock }}</span>
|
||||
{% else %}
|
||||
<span class="text-success">{{ drinkType.currentStock }}</span>
|
||||
{% endif %}
|
||||
{% else %}
|
||||
Not available
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
<div class="mt-3">
|
||||
<a href="/drink-types/{{ drinkType.id }}" class="btn btn-sm btn-info">
|
||||
<i class="fas fa-eye"></i> View Full Details
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
{% endblock %}
|
175
templates/inventory/index.twig
Normal file
175
templates/inventory/index.twig
Normal file
|
@ -0,0 +1,175 @@
|
|||
{% extends 'layout.twig' %}
|
||||
|
||||
{% block title %}Inventory Overview - {{ parent() }}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="row mb-4">
|
||||
<div class="col">
|
||||
<h2>Inventory Overview</h2>
|
||||
</div>
|
||||
<div class="col-auto">
|
||||
<div class="btn-group" role="group">
|
||||
<a href="/inventory/update" class="btn btn-primary">
|
||||
<i class="fas fa-sync"></i> Update Stock
|
||||
</a>
|
||||
<a href="/inventory/history" class="btn btn-info">
|
||||
<i class="fas fa-history"></i> View History
|
||||
</a>
|
||||
<a href="/orders/create-from-stock" class="btn btn-success">
|
||||
<i class="fas fa-shopping-cart"></i> Create Order
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if stockLevels is empty %}
|
||||
<div class="alert alert-info">
|
||||
No drink types found in inventory. <a href="/drink-types/create">Add a drink type</a> to get started.
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="row">
|
||||
<div class="col-md-8">
|
||||
<div class="card mb-4">
|
||||
<div class="card-header">
|
||||
<h5 class="card-title mb-0">Current Stock Levels</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="table-responsive">
|
||||
<table class="table table-striped table-hover">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Drink Type</th>
|
||||
<th>Current Stock</th>
|
||||
<th>Desired Stock</th>
|
||||
<th>Difference</th>
|
||||
<th>Status</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for item in stockLevels %}
|
||||
<tr>
|
||||
<td>
|
||||
<a href="/drink-types/{{ item.drinkType.id }}">
|
||||
{{ item.drinkType.name }}
|
||||
</a>
|
||||
</td>
|
||||
<td>{{ item.currentStock }}</td>
|
||||
<td>{{ item.drinkType.desiredStock }}</td>
|
||||
<td>
|
||||
{% set difference = item.currentStock - item.drinkType.desiredStock %}
|
||||
{% if difference < 0 %}
|
||||
<span class="text-danger">{{ difference }}</span>
|
||||
{% elseif difference > 0 %}
|
||||
<span class="text-success">+{{ difference }}</span>
|
||||
{% else %}
|
||||
<span class="text-muted">0</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
{% if item.currentStock < item.drinkType.desiredStock %}
|
||||
<span class="badge bg-danger">Low Stock</span>
|
||||
{% elseif item.currentStock == item.drinkType.desiredStock %}
|
||||
<span class="badge bg-success">Optimal</span>
|
||||
{% else %}
|
||||
<span class="badge bg-info">Excess</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
<div class="btn-group btn-group-sm" role="group">
|
||||
<a href="/inventory/history/{{ item.drinkType.id }}" class="btn btn-outline-secondary">
|
||||
<i class="fas fa-history"></i>
|
||||
</a>
|
||||
<a href="/inventory/update" class="btn btn-outline-primary">
|
||||
<i class="fas fa-edit"></i>
|
||||
</a>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-md-4">
|
||||
<div class="card mb-4">
|
||||
<div class="card-header bg-primary text-white">
|
||||
<h5 class="card-title mb-0">Inventory Summary</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
{% set totalItems = stockLevels|length %}
|
||||
{% set lowStockCount = 0 %}
|
||||
{% set optimalCount = 0 %}
|
||||
{% set excessCount = 0 %}
|
||||
|
||||
{% for item in stockLevels %}
|
||||
{% if item.currentStock < item.drinkType.desiredStock %}
|
||||
{% set lowStockCount = lowStockCount + 1 %}
|
||||
{% elseif item.currentStock == item.drinkType.desiredStock %}
|
||||
{% set optimalCount = optimalCount + 1 %}
|
||||
{% else %}
|
||||
{% set excessCount = excessCount + 1 %}
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
|
||||
<div class="mb-3">
|
||||
<h6>Total Drink Types: {{ totalItems }}</h6>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<div class="d-flex justify-content-between">
|
||||
<span>Low Stock:</span>
|
||||
<span class="text-danger">{{ lowStockCount }}</span>
|
||||
</div>
|
||||
<div class="progress">
|
||||
<div class="progress-bar bg-danger" role="progressbar" style="width: {{ (lowStockCount / totalItems * 100)|round }}%"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<div class="d-flex justify-content-between">
|
||||
<span>Optimal Stock:</span>
|
||||
<span class="text-success">{{ optimalCount }}</span>
|
||||
</div>
|
||||
<div class="progress">
|
||||
<div class="progress-bar bg-success" role="progressbar" style="width: {{ (optimalCount / totalItems * 100)|round }}%"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<div class="d-flex justify-content-between">
|
||||
<span>Excess Stock:</span>
|
||||
<span class="text-info">{{ excessCount }}</span>
|
||||
</div>
|
||||
<div class="progress">
|
||||
<div class="progress-bar bg-info" role="progressbar" style="width: {{ (excessCount / totalItems * 100)|round }}%"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-header bg-success text-white">
|
||||
<h5 class="card-title mb-0">Quick Actions</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="d-grid gap-2">
|
||||
<a href="/inventory/update" class="btn btn-primary">
|
||||
<i class="fas fa-sync"></i> Update Stock
|
||||
</a>
|
||||
<a href="/orders/create-from-stock" class="btn btn-success">
|
||||
<i class="fas fa-shopping-cart"></i> Create Order from Stock Levels
|
||||
</a>
|
||||
<a href="/inventory/low-stock" class="btn btn-warning">
|
||||
<i class="fas fa-exclamation-triangle"></i> View Low Stock Items
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endblock %}
|
159
templates/inventory/low-stock.twig
Normal file
159
templates/inventory/low-stock.twig
Normal file
|
@ -0,0 +1,159 @@
|
|||
{% extends 'layout.twig' %}
|
||||
|
||||
{% block title %}Low Stock Items - {{ parent() }}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="row mb-4">
|
||||
<div class="col">
|
||||
<h2>Low Stock Items</h2>
|
||||
</div>
|
||||
<div class="col-auto">
|
||||
<div class="btn-group" role="group">
|
||||
<a href="/inventory" class="btn btn-secondary">
|
||||
<i class="fas fa-arrow-left"></i> Back to Inventory
|
||||
</a>
|
||||
<a href="/inventory/update" class="btn btn-primary">
|
||||
<i class="fas fa-sync"></i> Update Stock
|
||||
</a>
|
||||
<a href="/orders/create-from-stock" class="btn btn-success">
|
||||
<i class="fas fa-shopping-cart"></i> Create Order
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if lowStockItems is empty %}
|
||||
<div class="alert alert-success">
|
||||
<i class="fas fa-check-circle"></i> All items are at or above their desired stock levels.
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="card">
|
||||
<div class="card-header bg-warning text-white">
|
||||
<h5 class="card-title mb-0">Items Below Desired Stock Levels</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="table-responsive">
|
||||
<table class="table table-striped table-hover">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Drink Type</th>
|
||||
<th>Current Stock</th>
|
||||
<th>Desired Stock</th>
|
||||
<th>Difference</th>
|
||||
<th>Status</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for item in lowStockItems %}
|
||||
<tr>
|
||||
<td>
|
||||
<a href="/drink-types/{{ item.drinkType.id }}">
|
||||
{{ item.drinkType.name }}
|
||||
</a>
|
||||
</td>
|
||||
<td>{{ item.currentStock }}</td>
|
||||
<td>{{ item.drinkType.desiredStock }}</td>
|
||||
<td class="text-danger">{{ item.currentStock - item.drinkType.desiredStock }}</td>
|
||||
<td>
|
||||
{% set percentage = (item.currentStock / item.drinkType.desiredStock * 100)|round %}
|
||||
{% if percentage < 25 %}
|
||||
<span class="badge bg-danger">Critical ({{ percentage }}%)</span>
|
||||
{% elseif percentage < 50 %}
|
||||
<span class="badge bg-warning">Low ({{ percentage }}%)</span>
|
||||
{% else %}
|
||||
<span class="badge bg-info">Below Target ({{ percentage }}%)</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
<div class="btn-group btn-group-sm" role="group">
|
||||
<a href="/inventory/history/{{ item.drinkType.id }}" class="btn btn-outline-secondary">
|
||||
<i class="fas fa-history"></i>
|
||||
</a>
|
||||
<a href="/inventory/update" class="btn btn-outline-primary">
|
||||
<i class="fas fa-edit"></i>
|
||||
</a>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-footer">
|
||||
<div class="d-flex justify-content-between align-items-center">
|
||||
<small class="text-muted">Showing {{ lowStockItems|length }} items below desired stock levels</small>
|
||||
<a href="/orders/create-from-stock" class="btn btn-success">
|
||||
<i class="fas fa-shopping-cart"></i> Create Order from Stock Levels
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row mt-4">
|
||||
<div class="col-md-6">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h5 class="card-title mb-0">Recommended Actions</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<ul class="list-group">
|
||||
<li class="list-group-item">
|
||||
<i class="fas fa-shopping-cart text-success"></i> Create an order to replenish stock
|
||||
</li>
|
||||
<li class="list-group-item">
|
||||
<i class="fas fa-chart-line text-info"></i> Review consumption patterns
|
||||
</li>
|
||||
<li class="list-group-item">
|
||||
<i class="fas fa-cog text-primary"></i> Adjust desired stock levels if needed
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h5 class="card-title mb-0">Stock Level Summary</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="alert alert-warning">
|
||||
<i class="fas fa-exclamation-triangle"></i> {{ lowStockItems|length }} items are below their desired stock levels.
|
||||
</div>
|
||||
|
||||
{% set criticalCount = 0 %}
|
||||
{% set lowCount = 0 %}
|
||||
{% set belowTargetCount = 0 %}
|
||||
|
||||
{% for item in lowStockItems %}
|
||||
{% set percentage = (item.currentStock / item.drinkType.desiredStock * 100)|round %}
|
||||
{% if percentage < 25 %}
|
||||
{% set criticalCount = criticalCount + 1 %}
|
||||
{% elseif percentage < 50 %}
|
||||
{% set lowCount = lowCount + 1 %}
|
||||
{% else %}
|
||||
{% set belowTargetCount = belowTargetCount + 1 %}
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
|
||||
<div class="mt-3">
|
||||
<div class="d-flex justify-content-between mb-1">
|
||||
<span>Critical (< 25%):</span>
|
||||
<span class="text-danger">{{ criticalCount }}</span>
|
||||
</div>
|
||||
<div class="d-flex justify-content-between mb-1">
|
||||
<span>Low (25-50%):</span>
|
||||
<span class="text-warning">{{ lowCount }}</span>
|
||||
</div>
|
||||
<div class="d-flex justify-content-between mb-1">
|
||||
<span>Below Target (> 50%):</span>
|
||||
<span class="text-info">{{ belowTargetCount }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endblock %}
|
88
templates/inventory/update.twig
Normal file
88
templates/inventory/update.twig
Normal file
|
@ -0,0 +1,88 @@
|
|||
{% extends 'layout.twig' %}
|
||||
{% import 'components/form.twig' as form %}
|
||||
|
||||
{% block title %}Update Stock - {{ parent() }}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="row mb-4">
|
||||
<div class="col">
|
||||
<h2>Update Stock Levels</h2>
|
||||
</div>
|
||||
<div class="col-auto">
|
||||
<a href="/inventory" class="btn btn-secondary">
|
||||
<i class="fas fa-arrow-left"></i> Back to Inventory
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-md-6">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h5 class="card-title mb-0">Update Single Item</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
{{ form.errors(error) }}
|
||||
|
||||
<form action="/inventory/update" method="post">
|
||||
<div class="form-group mb-3">
|
||||
<label for="drink_type_id">Drink Type</label>
|
||||
<select id="drink_type_id" name="drink_type_id" class="form-control" required>
|
||||
<option value="">-- Select Drink Type --</option>
|
||||
{% for drinkType in drinkTypes %}
|
||||
<option value="{{ drinkType.id }}">{{ drinkType.name }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="form-group mb-3">
|
||||
<label for="quantity">New Quantity</label>
|
||||
<input type="number" id="quantity" name="quantity" class="form-control" min="0" required>
|
||||
<small class="form-text text-muted">Enter the new total quantity, not the amount to add/remove.</small>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
{{ form.submit('Update Stock') }}
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-md-6">
|
||||
<div class="card">
|
||||
<div class="card-header bg-info text-white">
|
||||
<h5 class="card-title mb-0">Stock Update Tips</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="alert alert-info">
|
||||
<h6 class="alert-heading">How to update stock:</h6>
|
||||
<ol>
|
||||
<li>Select the drink type you want to update</li>
|
||||
<li>Enter the <strong>total</strong> current quantity (not the difference)</li>
|
||||
<li>Click "Update Stock" to save the changes</li>
|
||||
</ol>
|
||||
</div>
|
||||
|
||||
<div class="alert alert-warning">
|
||||
<h6 class="alert-heading">Important Notes:</h6>
|
||||
<ul>
|
||||
<li>Stock updates are logged in the inventory history</li>
|
||||
<li>The system will calculate the difference from the previous stock level</li>
|
||||
<li>If stock falls below desired levels, it will appear in low stock alerts</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="mt-4">
|
||||
<h6>Quick Links:</h6>
|
||||
<ul>
|
||||
<li><a href="/inventory/history">View inventory history</a></li>
|
||||
<li><a href="/inventory/low-stock">View low stock items</a></li>
|
||||
<li><a href="/orders/create-from-stock">Create order from stock levels</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
70
templates/layout.twig
Normal file
70
templates/layout.twig
Normal file
|
@ -0,0 +1,70 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>🍻🥂 {% block title %}Saufen{% endblock %} 🍹🥤</title>
|
||||
<!-- Bootstrap CSS -->
|
||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/css/bootstrap.min.css" rel="stylesheet" integrity="sha384-T3c6CoIi6uLrA9TneNEoa7RxnatzjcDSCmG1MXxSR1GAsXEV/Dwwykc2MPK8M2HN" crossorigin="anonymous">
|
||||
<!-- Font Awesome -->
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.2/css/all.min.css" integrity="sha512-z3gLpd7yknf1YoNbCzqRKc4qyor8gaKU1qmn+CShxbuBusANI9QpRohGBreCFkKxLhei6S9CQXFEbbKuqLg0DA==" crossorigin="anonymous" referrerpolicy="no-referrer" />
|
||||
<!-- Custom styles -->
|
||||
<link rel="stylesheet" href="/assets/css/styles.css">
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<nav class="navbar navbar-expand-lg navbar-dark bg-dark">
|
||||
<div class="container">
|
||||
<a class="navbar-brand" href="/">🍺🍷 {{ appName }} 🍸🥃</a>
|
||||
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarMain" aria-controls="navbarMain" aria-expanded="false" aria-label="Toggle navigation">
|
||||
<span class="navbar-toggler-icon"></span>
|
||||
</button>
|
||||
<div class="collapse navbar-collapse" id="navbarMain">
|
||||
<ul class="navbar-nav ms-auto mb-2 mb-lg-0">
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="/">🏠 Dashboard 📊</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="/drink-types">🍹 Drink Types 🍸🥤</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="/inventory">📦 Inventory 🗃️🧮</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="/orders">🛒 Orders 📝🚚</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="/settings">⚙️ Settings 🔧🛠️</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
</header>
|
||||
|
||||
<main class="container py-4">
|
||||
{% include 'components/flash-messages.twig' %}
|
||||
|
||||
{% block content %}{% endblock %}
|
||||
</main>
|
||||
|
||||
<footer class="bg-dark text-white py-4 mt-5">
|
||||
<div class="container">
|
||||
<div class="row">
|
||||
<div class="col-md-6">
|
||||
<p class="mb-0">🌟✨ © {{ "now"|date("Y") }} 🎉 {{ appName }} 🎊</p>
|
||||
</div>
|
||||
<div class="col-md-6 text-md-end">
|
||||
<p class="mb-0">Made with 💖 and 🍺 ✨🌟</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
<!-- Bootstrap Bundle with Popper -->
|
||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/js/bootstrap.bundle.min.js" integrity="sha384-C6RzsynM9kWDrMNeT87bh95OGNyZPhcTNXj1NW7RuBCsyN/o0jlpcV8Qyq46cDfL" crossorigin="anonymous"></script>
|
||||
<!-- Custom JavaScript -->
|
||||
<script src="/assets/js/app.js"></script>
|
||||
{% block scripts %}{% endblock %}
|
||||
</body>
|
||||
</html>
|
204
templates/orders/create.twig
Normal file
204
templates/orders/create.twig
Normal file
|
@ -0,0 +1,204 @@
|
|||
{% extends 'layout.twig' %}
|
||||
{% import 'components/form.twig' as form %}
|
||||
|
||||
{% block title %}Create Order - {{ parent() }}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="row mb-4">
|
||||
<div class="col">
|
||||
<h2>Create New Order</h2>
|
||||
</div>
|
||||
<div class="col-auto">
|
||||
<div class="btn-group" role="group">
|
||||
<a href="/orders" class="btn btn-secondary">
|
||||
<i class="fas fa-arrow-left"></i> Back to Orders
|
||||
</a>
|
||||
<a href="/orders/create-from-stock" class="btn btn-primary">
|
||||
<i class="fas fa-magic"></i> Create from Stock Levels
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
{{ form.errors(error) }}
|
||||
|
||||
<form action="/orders" method="post" id="orderForm">
|
||||
<div class="order-items">
|
||||
<div class="row mb-3">
|
||||
<div class="col">
|
||||
<h5>Order Items</h5>
|
||||
</div>
|
||||
<div class="col-auto">
|
||||
<button type="button" class="btn btn-sm btn-success add-item-btn">
|
||||
<i class="fas fa-plus"></i> Add Item
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="order-item-template d-none">
|
||||
<div class="card mb-3 order-item">
|
||||
<div class="card-body">
|
||||
<div class="row">
|
||||
<div class="col-md-6">
|
||||
<div class="form-group">
|
||||
<label>Drink Type</label>
|
||||
<select name="items[{index}][drink_type_id]" class="form-control drink-type-select" required>
|
||||
<option value="">-- Select Drink Type --</option>
|
||||
{% for drinkType in drinkTypes %}
|
||||
<option value="{{ drinkType.id }}" data-desired-stock="{{ drinkType.desiredStock }}" data-current-stock="{{ drinkType.currentStock|default(0) }}">
|
||||
{{ drinkType.name }}
|
||||
</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-5">
|
||||
<div class="form-group">
|
||||
<label>Quantity</label>
|
||||
<input type="number" name="items[{index}][quantity]" class="form-control quantity-input" min="1" required>
|
||||
<small class="form-text text-muted stock-info"></small>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-1 d-flex align-items-end">
|
||||
<button type="button" class="btn btn-sm btn-danger remove-item-btn mb-2">
|
||||
<i class="fas fa-trash"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="order-items-container">
|
||||
<!-- Order items will be added here -->
|
||||
<div class="card mb-3 order-item">
|
||||
<div class="card-body">
|
||||
<div class="row">
|
||||
<div class="col-md-6">
|
||||
<div class="form-group">
|
||||
<label>Drink Type</label>
|
||||
<select name="items[0][drink_type_id]" class="form-control drink-type-select" required>
|
||||
<option value="">-- Select Drink Type --</option>
|
||||
{% for drinkType in drinkTypes %}
|
||||
<option value="{{ drinkType.id }}" data-desired-stock="{{ drinkType.desiredStock }}" data-current-stock="{{ drinkType.currentStock|default(0) }}">
|
||||
{{ drinkType.name }}
|
||||
</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-5">
|
||||
<div class="form-group">
|
||||
<label>Quantity</label>
|
||||
<input type="number" name="items[0][quantity]" class="form-control quantity-input" min="1" required>
|
||||
<small class="form-text text-muted stock-info"></small>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-1 d-flex align-items-end">
|
||||
<button type="button" class="btn btn-sm btn-danger remove-item-btn mb-2" disabled>
|
||||
<i class="fas fa-trash"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="alert alert-info d-none no-items-alert">
|
||||
<i class="fas fa-info-circle"></i> Please add at least one item to the order.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group mt-4">
|
||||
{{ form.submit('Create Order') }}
|
||||
<a href="/orders" class="btn btn-link">Cancel</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
const orderItemsContainer = document.querySelector('.order-items-container');
|
||||
const orderItemTemplate = document.querySelector('.order-item-template');
|
||||
const addItemBtn = document.querySelector('.add-item-btn');
|
||||
const noItemsAlert = document.querySelector('.no-items-alert');
|
||||
|
||||
// Add item button click handler
|
||||
addItemBtn.addEventListener('click', function() {
|
||||
const newIndex = document.querySelectorAll('.order-item').length;
|
||||
const newItem = orderItemTemplate.querySelector('.order-item').cloneNode(true);
|
||||
|
||||
// Update name attributes with the correct index
|
||||
newItem.querySelectorAll('[name]').forEach(input => {
|
||||
input.name = input.name.replace('{index}', newIndex);
|
||||
});
|
||||
|
||||
// Enable the remove button
|
||||
newItem.querySelector('.remove-item-btn').disabled = false;
|
||||
|
||||
// Add the new item to the container
|
||||
orderItemsContainer.appendChild(newItem);
|
||||
|
||||
// Hide the no items alert
|
||||
noItemsAlert.classList.add('d-none');
|
||||
|
||||
// Add event listeners to the new item
|
||||
addItemEventListeners(newItem);
|
||||
});
|
||||
|
||||
// Add event listeners to initial item
|
||||
addItemEventListeners(orderItemsContainer.querySelector('.order-item'));
|
||||
|
||||
// Form submission handler
|
||||
document.getElementById('orderForm').addEventListener('submit', function(e) {
|
||||
const items = document.querySelectorAll('.order-item');
|
||||
|
||||
if (items.length === 0) {
|
||||
e.preventDefault();
|
||||
noItemsAlert.classList.remove('d-none');
|
||||
}
|
||||
});
|
||||
|
||||
// Function to add event listeners to an item
|
||||
function addItemEventListeners(item) {
|
||||
// Remove item button click handler
|
||||
item.querySelector('.remove-item-btn').addEventListener('click', function() {
|
||||
if (document.querySelectorAll('.order-item').length > 1) {
|
||||
item.remove();
|
||||
} else {
|
||||
noItemsAlert.classList.remove('d-none');
|
||||
}
|
||||
});
|
||||
|
||||
// Drink type select change handler
|
||||
item.querySelector('.drink-type-select').addEventListener('change', function() {
|
||||
const option = this.options[this.selectedIndex];
|
||||
const stockInfo = item.querySelector('.stock-info');
|
||||
const quantityInput = item.querySelector('.quantity-input');
|
||||
|
||||
if (option.value) {
|
||||
const desiredStock = parseInt(option.dataset.desiredStock);
|
||||
const currentStock = parseInt(option.dataset.currentStock);
|
||||
const difference = desiredStock - currentStock;
|
||||
|
||||
if (difference > 0) {
|
||||
stockInfo.textContent = `Current: ${currentStock}, Desired: ${desiredStock}, Suggested order: ${difference}`;
|
||||
quantityInput.value = difference;
|
||||
} else {
|
||||
stockInfo.textContent = `Current: ${currentStock}, Desired: ${desiredStock}, Stock is adequate`;
|
||||
quantityInput.value = '';
|
||||
}
|
||||
} else {
|
||||
stockInfo.textContent = '';
|
||||
quantityInput.value = '';
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
179
templates/orders/index.twig
Normal file
179
templates/orders/index.twig
Normal file
|
@ -0,0 +1,179 @@
|
|||
{% extends 'layout.twig' %}
|
||||
|
||||
{% block title %}Orders - {{ parent() }}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="row mb-4">
|
||||
<div class="col">
|
||||
<h2>Orders</h2>
|
||||
</div>
|
||||
<div class="col-auto">
|
||||
<div class="btn-group" role="group">
|
||||
<a href="/orders/create" class="btn btn-success">
|
||||
<i class="fas fa-plus"></i> Create Order
|
||||
</a>
|
||||
<a href="/orders/create-from-stock" class="btn btn-primary">
|
||||
<i class="fas fa-magic"></i> Create from Stock Levels
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if orders is empty %}
|
||||
<div class="alert alert-info">
|
||||
No orders found. Click one of the buttons above to create your first order.
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<div class="row">
|
||||
<div class="col">
|
||||
<h5 class="card-title mb-0">Order List</h5>
|
||||
</div>
|
||||
<div class="col-auto">
|
||||
<div class="dropdown">
|
||||
<button class="btn btn-sm btn-outline-secondary dropdown-toggle" type="button" id="filterDropdown" data-bs-toggle="dropdown" aria-expanded="false">
|
||||
Filter
|
||||
</button>
|
||||
<ul class="dropdown-menu" aria-labelledby="filterDropdown">
|
||||
<li><a class="dropdown-item" href="/orders">All Orders</a></li>
|
||||
<li><hr class="dropdown-divider"></li>
|
||||
<li><h6 class="dropdown-header">By Status</h6></li>
|
||||
<li><a class="dropdown-item" href="/orders?status=pending">Pending</a></li>
|
||||
<li><a class="dropdown-item" href="/orders?status=completed">Completed</a></li>
|
||||
<li><a class="dropdown-item" href="/orders?status=cancelled">Cancelled</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="table-responsive">
|
||||
<table class="table table-striped table-hover">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th>Date</th>
|
||||
<th>Items</th>
|
||||
<th>Total Quantity</th>
|
||||
<th>Status</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for order in orders %}
|
||||
<tr>
|
||||
<td>{{ order.id }}</td>
|
||||
<td>{{ order.createdAt|date('Y-m-d H:i:s') }}</td>
|
||||
<td>{{ order.orderItems|length }}</td>
|
||||
<td>
|
||||
{% set totalQuantity = 0 %}
|
||||
{% for item in order.orderItems %}
|
||||
{% set totalQuantity = totalQuantity + item.quantity %}
|
||||
{% endfor %}
|
||||
{{ totalQuantity }}
|
||||
</td>
|
||||
<td>
|
||||
{% if order.status == 'pending' %}
|
||||
<span class="badge bg-warning">Pending</span>
|
||||
{% elseif order.status == 'completed' %}
|
||||
<span class="badge bg-success">Completed</span>
|
||||
{% elseif order.status == 'cancelled' %}
|
||||
<span class="badge bg-danger">Cancelled</span>
|
||||
{% else %}
|
||||
<span class="badge bg-secondary">{{ order.status }}</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
<div class="btn-group btn-group-sm" role="group">
|
||||
<a href="/orders/{{ order.id }}" class="btn btn-outline-info">
|
||||
<i class="fas fa-eye"></i>
|
||||
</a>
|
||||
{% if order.status == 'pending' %}
|
||||
<form action="/orders/{{ order.id }}/status" method="post" class="d-inline">
|
||||
<input type="hidden" name="status" value="completed">
|
||||
<button type="submit" class="btn btn-outline-success" title="Mark as Completed">
|
||||
<i class="fas fa-check"></i>
|
||||
</button>
|
||||
</form>
|
||||
<form action="/orders/{{ order.id }}/status" method="post" class="d-inline">
|
||||
<input type="hidden" name="status" value="cancelled">
|
||||
<button type="submit" class="btn btn-outline-danger" title="Cancel Order">
|
||||
<i class="fas fa-times"></i>
|
||||
</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-footer">
|
||||
<small class="text-muted">Showing {{ orders|length }} orders</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row mt-4">
|
||||
<div class="col-md-6">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h5 class="card-title mb-0">Order Statistics</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
{% set pendingCount = 0 %}
|
||||
{% set completedCount = 0 %}
|
||||
{% set cancelledCount = 0 %}
|
||||
|
||||
{% for order in orders %}
|
||||
{% if order.status == 'pending' %}
|
||||
{% set pendingCount = pendingCount + 1 %}
|
||||
{% elseif order.status == 'completed' %}
|
||||
{% set completedCount = completedCount + 1 %}
|
||||
{% elseif order.status == 'cancelled' %}
|
||||
{% set cancelledCount = cancelledCount + 1 %}
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
|
||||
<div class="mb-3">
|
||||
<div class="d-flex justify-content-between mb-1">
|
||||
<span>Pending Orders:</span>
|
||||
<span class="text-warning">{{ pendingCount }}</span>
|
||||
</div>
|
||||
<div class="d-flex justify-content-between mb-1">
|
||||
<span>Completed Orders:</span>
|
||||
<span class="text-success">{{ completedCount }}</span>
|
||||
</div>
|
||||
<div class="d-flex justify-content-between mb-1">
|
||||
<span>Cancelled Orders:</span>
|
||||
<span class="text-danger">{{ cancelledCount }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<div class="card">
|
||||
<div class="card-header bg-primary text-white">
|
||||
<h5 class="card-title mb-0">Quick Actions</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="d-grid gap-2">
|
||||
<a href="/orders/create" class="btn btn-success">
|
||||
<i class="fas fa-plus"></i> Create New Order
|
||||
</a>
|
||||
<a href="/orders/create-from-stock" class="btn btn-primary">
|
||||
<i class="fas fa-magic"></i> Create Order from Stock Levels
|
||||
</a>
|
||||
<a href="/inventory" class="btn btn-info">
|
||||
<i class="fas fa-boxes"></i> View Inventory
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endblock %}
|
252
templates/orders/show.twig
Normal file
252
templates/orders/show.twig
Normal file
|
@ -0,0 +1,252 @@
|
|||
{% extends 'layout.twig' %}
|
||||
{% import 'components/form.twig' as form %}
|
||||
|
||||
{% block title %}Order #{{ order.id }} - {{ parent() }}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="row mb-4">
|
||||
<div class="col">
|
||||
<h2>Order #{{ order.id }}</h2>
|
||||
</div>
|
||||
<div class="col-auto">
|
||||
<div class="btn-group" role="group">
|
||||
<a href="/orders" class="btn btn-secondary">
|
||||
<i class="fas fa-arrow-left"></i> Back to Orders
|
||||
</a>
|
||||
{% if order.status == 'pending' %}
|
||||
<button type="button" class="btn btn-success" data-bs-toggle="modal" data-bs-target="#completeOrderModal">
|
||||
<i class="fas fa-check"></i> Complete Order
|
||||
</button>
|
||||
<button type="button" class="btn btn-danger" data-bs-toggle="modal" data-bs-target="#cancelOrderModal">
|
||||
<i class="fas fa-times"></i> Cancel Order
|
||||
</button>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-md-8">
|
||||
<div class="card mb-4">
|
||||
<div class="card-header">
|
||||
<h5 class="card-title mb-0">Order Items</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
{% if order.orderItems is empty %}
|
||||
<div class="alert alert-warning">
|
||||
<i class="fas fa-exclamation-triangle"></i> This order has no items.
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="table-responsive">
|
||||
<table class="table table-striped">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Drink Type</th>
|
||||
<th>Quantity</th>
|
||||
{% if order.status == 'pending' %}
|
||||
<th>Actions</th>
|
||||
{% endif %}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for item in order.orderItems %}
|
||||
<tr>
|
||||
<td>
|
||||
<a href="/drink-types/{{ item.drinkType.id }}">
|
||||
{{ item.drinkType.name }}
|
||||
</a>
|
||||
</td>
|
||||
<td>{{ item.quantity }}</td>
|
||||
{% if order.status == 'pending' %}
|
||||
<td>
|
||||
<form action="/orders/{{ order.id }}/items/{{ item.id }}/remove" method="post" class="d-inline" onsubmit="return confirm('Are you sure you want to remove this item?');">
|
||||
<button type="submit" class="btn btn-sm btn-danger">
|
||||
<i class="fas fa-trash"></i> Remove
|
||||
</button>
|
||||
</form>
|
||||
</td>
|
||||
{% endif %}
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
<tfoot>
|
||||
<tr class="table-active">
|
||||
<th>Total</th>
|
||||
<th>
|
||||
{% set totalQuantity = 0 %}
|
||||
{% for item in order.orderItems %}
|
||||
{% set totalQuantity = totalQuantity + item.quantity %}
|
||||
{% endfor %}
|
||||
{{ totalQuantity }}
|
||||
</th>
|
||||
{% if order.status == 'pending' %}
|
||||
<th></th>
|
||||
{% endif %}
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if order.status == 'pending' %}
|
||||
<div class="mt-4">
|
||||
<button type="button" class="btn btn-primary" data-bs-toggle="modal" data-bs-target="#addItemModal">
|
||||
<i class="fas fa-plus"></i> Add Item
|
||||
</button>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-md-4">
|
||||
<div class="card mb-4">
|
||||
<div class="card-header">
|
||||
<h5 class="card-title mb-0">Order Details</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<table class="table table-bordered">
|
||||
<tr>
|
||||
<th style="width: 40%">Order ID</th>
|
||||
<td>{{ order.id }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>Status</th>
|
||||
<td>
|
||||
{% if order.status == 'pending' %}
|
||||
<span class="badge bg-warning">Pending</span>
|
||||
{% elseif order.status == 'completed' %}
|
||||
<span class="badge bg-success">Completed</span>
|
||||
{% elseif order.status == 'cancelled' %}
|
||||
<span class="badge bg-danger">Cancelled</span>
|
||||
{% else %}
|
||||
<span class="badge bg-secondary">{{ order.status }}</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>Created At</th>
|
||||
<td>{{ order.createdAt|date('Y-m-d H:i:s') }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>Updated At</th>
|
||||
<td>{{ order.updatedAt|date('Y-m-d H:i:s') }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>Total Items</th>
|
||||
<td>{{ order.orderItems|length }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>Total Quantity</th>
|
||||
<td>
|
||||
{% set totalQuantity = 0 %}
|
||||
{% for item in order.orderItems %}
|
||||
{% set totalQuantity = totalQuantity + item.quantity %}
|
||||
{% endfor %}
|
||||
{{ totalQuantity }}
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if order.status == 'pending' %}
|
||||
<div class="card">
|
||||
<div class="card-header bg-danger text-white">
|
||||
<h5 class="card-title mb-0">Danger Zone</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<form action="/orders/{{ order.id }}/delete" method="post" onsubmit="return confirm('Are you sure you want to delete this order? This action cannot be undone.');">
|
||||
<div class="d-grid">
|
||||
<button type="submit" class="btn btn-danger">
|
||||
<i class="fas fa-trash"></i> Delete Order
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if order.status == 'pending' %}
|
||||
<!-- Add Item Modal -->
|
||||
<div class="modal fade" id="addItemModal" tabindex="-1" aria-labelledby="addItemModalLabel" aria-hidden="true">
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title" id="addItemModalLabel">Add Item to Order</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||
</div>
|
||||
<form action="/orders/{{ order.id }}/items" method="post">
|
||||
<div class="modal-body">
|
||||
<div class="form-group mb-3">
|
||||
<label for="drink_type_id">Drink Type</label>
|
||||
<select id="drink_type_id" name="drink_type_id" class="form-control" required>
|
||||
<option value="">-- Select Drink Type --</option>
|
||||
{% for drinkType in drinkTypes|default([]) %}
|
||||
<option value="{{ drinkType.id }}">{{ drinkType.name }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="quantity">Quantity</label>
|
||||
<input type="number" id="quantity" name="quantity" class="form-control" min="1" required>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
|
||||
<button type="submit" class="btn btn-primary">Add Item</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Complete Order Modal -->
|
||||
<div class="modal fade" id="completeOrderModal" tabindex="-1" aria-labelledby="completeOrderModalLabel" aria-hidden="true">
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title" id="completeOrderModalLabel">Complete Order</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<p>Are you sure you want to mark this order as completed?</p>
|
||||
<p class="text-muted">This will update the inventory levels accordingly.</p>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
|
||||
<form action="/orders/{{ order.id }}/status" method="post">
|
||||
<input type="hidden" name="status" value="completed">
|
||||
<button type="submit" class="btn btn-success">Complete Order</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Cancel Order Modal -->
|
||||
<div class="modal fade" id="cancelOrderModal" tabindex="-1" aria-labelledby="cancelOrderModalLabel" aria-hidden="true">
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title" id="cancelOrderModalLabel">Cancel Order</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<p>Are you sure you want to cancel this order?</p>
|
||||
<p class="text-muted">This action cannot be undone.</p>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">No, Keep Order</button>
|
||||
<form action="/orders/{{ order.id }}/status" method="post">
|
||||
<input type="hidden" name="status" value="cancelled">
|
||||
<button type="submit" class="btn btn-danger">Yes, Cancel Order</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endblock %}
|
169
templates/settings/index.twig
Normal file
169
templates/settings/index.twig
Normal file
|
@ -0,0 +1,169 @@
|
|||
{% extends 'layout.twig' %}
|
||||
{% import 'components/form.twig' as form %}
|
||||
|
||||
{% block title %}⚙️🔧 System Settings 🛠️🔩 - {{ parent() }}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="row mb-4">
|
||||
<div class="col">
|
||||
<h2>⚙️✨ SUPER AWESOME SYSTEM SETTINGS 🔧🌟</h2>
|
||||
</div>
|
||||
<div class="col-auto">
|
||||
<button type="button" class="btn btn-danger" data-bs-toggle="modal" data-bs-target="#resetSettingsModal">
|
||||
<i class="fas fa-undo"></i> 💣 RESET TO DEFAULTS 💥
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
{{ form.errors(error) }}
|
||||
|
||||
<form action="/settings" method="post">
|
||||
<div class="row">
|
||||
<div class="col-md-6">
|
||||
<h5 class="mb-3">📊 Stock Adjustment Settings 📈🔄</h5>
|
||||
|
||||
<div class="form-group mb-3">
|
||||
<label for="stock_adjustment_lookback_orders">🔍 Orders to Analyze 🧐🔎</label>
|
||||
<input type="number" id="stock_adjustment_lookback_orders" name="configs[stock_adjustment_lookback_orders]"
|
||||
class="form-control" min="1" max="20"
|
||||
value="{{ configs['stock_adjustment_lookback_orders']|default(5) }}">
|
||||
<small class="form-text text-muted">🧮 Number of recent orders to analyze for consumption patterns (default: 5) 📝</small>
|
||||
</div>
|
||||
|
||||
<div class="form-group mb-3">
|
||||
<label for="stock_adjustment_magnitude">📏 Adjustment Magnitude (%) 📐💯</label>
|
||||
<input type="number" id="stock_adjustment_magnitude" name="configs[stock_adjustment_magnitude]"
|
||||
class="form-control" min="1" max="100"
|
||||
value="{{ configs['stock_adjustment_magnitude']|default(20) }}">
|
||||
<small class="form-text text-muted">🔼 Maximum percentage to adjust stock levels by (default: 20%) 🔽</small>
|
||||
</div>
|
||||
|
||||
<div class="form-group mb-3">
|
||||
<label for="stock_adjustment_threshold">🚧 Adjustment Threshold (%) 🚨🔔</label>
|
||||
<input type="number" id="stock_adjustment_threshold" name="configs[stock_adjustment_threshold]"
|
||||
class="form-control" min="1" max="100"
|
||||
value="{{ configs['stock_adjustment_threshold']|default(10) }}">
|
||||
<small class="form-text text-muted">⚠️ Minimum difference required to trigger an adjustment (default: 10%) 🚦</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-md-6">
|
||||
<h5 class="mb-3">🏪 Inventory Settings 📦🗃️</h5>
|
||||
|
||||
<div class="form-group mb-3">
|
||||
<label for="low_stock_threshold">📉 Low Stock Threshold (%) 😰🆘</label>
|
||||
<input type="number" id="low_stock_threshold" name="configs[low_stock_threshold]"
|
||||
class="form-control" min="1" max="100"
|
||||
value="{{ configs['low_stock_threshold']|default(50) }}">
|
||||
<small class="form-text text-muted">⚠️ Percentage of desired stock below which items are considered low stock (default: 50%) 😱</small>
|
||||
</div>
|
||||
|
||||
<div class="form-group mb-3">
|
||||
<label for="critical_stock_threshold">🔥 Critical Stock Threshold (%) 🚨💀</label>
|
||||
<input type="number" id="critical_stock_threshold" name="configs[critical_stock_threshold]"
|
||||
class="form-control" min="1" max="100"
|
||||
value="{{ configs['critical_stock_threshold']|default(25) }}">
|
||||
<small class="form-text text-muted">🆘 Percentage of desired stock below which items are considered critical (default: 25%) 😭</small>
|
||||
</div>
|
||||
|
||||
<div class="form-group mb-3">
|
||||
<label for="default_desired_stock">🎯 Default Desired Stock 🥅🏆</label>
|
||||
<input type="number" id="default_desired_stock" name="configs[default_desired_stock]"
|
||||
class="form-control" min="0"
|
||||
value="{{ configs['default_desired_stock']|default(10) }}">
|
||||
<small class="form-text text-muted">🌟 Default desired stock level for new drink types (default: 10) 🍹</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row mt-4">
|
||||
<div class="col-md-6">
|
||||
<h5 class="mb-3">🖥️ System Settings 💻🌐</h5>
|
||||
|
||||
<div class="form-group mb-3">
|
||||
<label for="system_name">✨ System Name 🏷️🔤</label>
|
||||
<input type="text" id="system_name" name="configs[system_name]"
|
||||
class="form-control"
|
||||
value="{{ configs['system_name']|default('Drinks Inventory System') }}">
|
||||
<small class="form-text text-muted">👑 Name of the system displayed in the header 📛</small>
|
||||
</div>
|
||||
|
||||
<div class="form-group mb-3">
|
||||
<div class="form-check form-switch">
|
||||
<input class="form-check-input" type="checkbox" id="enable_auto_adjustment"
|
||||
name="configs[enable_auto_adjustment]" value="1"
|
||||
{% if configs['enable_auto_adjustment']|default(1) == 1 %}checked{% endif %}>
|
||||
<label class="form-check-label" for="enable_auto_adjustment">
|
||||
🤖 Enable Automatic Stock Adjustment 🔄✨
|
||||
</label>
|
||||
</div>
|
||||
<small class="form-text text-muted">🧠 Automatically adjust desired stock levels based on consumption patterns 📊</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-md-6">
|
||||
<h5 class="mb-3">🎨 Display Settings 📱💅</h5>
|
||||
|
||||
<div class="form-group mb-3">
|
||||
<div class="form-check form-switch">
|
||||
<input class="form-check-input" type="checkbox" id="show_low_stock_alerts"
|
||||
name="configs[show_low_stock_alerts]" value="1"
|
||||
{% if configs['show_low_stock_alerts']|default(1) == 1 %}checked{% endif %}>
|
||||
<label class="form-check-label" for="show_low_stock_alerts">
|
||||
🚨 Show Low Stock Alerts on Dashboard 📉😱
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group mb-3">
|
||||
<div class="form-check form-switch">
|
||||
<input class="form-check-input" type="checkbox" id="show_quick_update_form"
|
||||
name="configs[show_quick_update_form]" value="1"
|
||||
{% if configs['show_quick_update_form']|default(1) == 1 %}checked{% endif %}>
|
||||
<label class="form-check-label" for="show_quick_update_form">
|
||||
⚡ Show Quick Update Form on Dashboard 🔄💨
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group mb-3">
|
||||
<label for="items_per_page">📋 Items Per Page 📑📄</label>
|
||||
<input type="number" id="items_per_page" name="configs[items_per_page]"
|
||||
class="form-control" min="5" max="100"
|
||||
value="{{ configs['items_per_page']|default(20) }}">
|
||||
<small class="form-text text-muted">🔢 Number of items to display per page in lists 📊</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group mt-4">
|
||||
{{ form.submit('💾 SAVE AWESOME SETTINGS 🚀') }}
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Reset Settings Modal -->
|
||||
<div class="modal fade" id="resetSettingsModal" tabindex="-1" aria-labelledby="resetSettingsModalLabel" aria-hidden="true">
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title" id="resetSettingsModalLabel">⚠️ RESET SETTINGS ⚠️</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<p>🤔 Are you SUPER DUPER sure you want to reset ALL settings to their default values? 🧐</p>
|
||||
<p class="text-danger">💣 This action CANNOT be undone! 💥 Say bye-bye to your settings! 👋</p>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">❌ Cancel ❌</button>
|
||||
<form action="/settings/reset" method="post">
|
||||
<button type="submit" class="btn btn-danger">💥 RESET TO DEFAULTS 💣</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
26
tests/Feature/DatabaseTest.php
Normal file
26
tests/Feature/DatabaseTest.php
Normal file
|
@ -0,0 +1,26 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Feature;
|
||||
|
||||
use Override;
|
||||
use Tests\TestCase;
|
||||
use App\Repository\DrinkTypeRepository;
|
||||
|
||||
class DatabaseTest extends TestCase
|
||||
{
|
||||
#[Override]
|
||||
public function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
$this->setUpDB();
|
||||
}
|
||||
public function testDatabase(): void
|
||||
{
|
||||
/** @var DrinkTypeRepository $drinkRepository */
|
||||
$drinkRepository = $this->container->get(DrinkTypeRepository::class);
|
||||
|
||||
$this->assertCount(0, $drinkRepository->findAll());
|
||||
}
|
||||
}
|
122
tests/Feature/Repository/DrinkTypeRepositoryTest.php
Normal file
122
tests/Feature/Repository/DrinkTypeRepositoryTest.php
Normal file
|
@ -0,0 +1,122 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Feature\Repository;
|
||||
|
||||
use Override;
|
||||
use Tests\TestCase;
|
||||
use App\Entity\DrinkType;
|
||||
use App\Repository\DrinkTypeRepository;
|
||||
|
||||
class DrinkTypeRepositoryTest extends TestCase
|
||||
{
|
||||
private DrinkTypeRepository $repository;
|
||||
|
||||
#[Override]
|
||||
public function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
$this->setUpDB();
|
||||
$this->repository = $this->container->get(DrinkTypeRepository::class);
|
||||
}
|
||||
|
||||
public function testFindAll(): void
|
||||
{
|
||||
// Initially the repository should be empty
|
||||
$this->assertCount(0, $this->repository->findAll());
|
||||
|
||||
// Create some drink types
|
||||
$drinkType1 = new DrinkType('Cola', 'Refreshing cola drink', 10);
|
||||
$drinkType2 = new DrinkType('Fanta', 'Orange soda', 5);
|
||||
|
||||
// Save them to the repository
|
||||
$this->repository->save($drinkType1);
|
||||
$this->repository->save($drinkType2);
|
||||
|
||||
// Now findAll should return both drink types
|
||||
$drinkTypes = $this->repository->findAll();
|
||||
$this->assertCount(2, $drinkTypes);
|
||||
$this->assertContainsOnlyInstancesOf(DrinkType::class, $drinkTypes);
|
||||
}
|
||||
|
||||
public function testFind(): void
|
||||
{
|
||||
// Create a drink type
|
||||
$drinkType = new DrinkType('Cola', 'Refreshing cola drink', 10);
|
||||
$this->repository->save($drinkType);
|
||||
|
||||
// Get the ID
|
||||
$id = $drinkType->getId();
|
||||
$this->assertNotNull($id);
|
||||
|
||||
// Find by ID
|
||||
$foundDrinkType = $this->repository->find($id);
|
||||
$this->assertInstanceOf(DrinkType::class, $foundDrinkType);
|
||||
$this->assertEquals('Cola', $foundDrinkType->getName());
|
||||
$this->assertEquals('Refreshing cola drink', $foundDrinkType->getDescription());
|
||||
$this->assertEquals(10, $foundDrinkType->getDesiredStock());
|
||||
|
||||
// Try to find a non-existent ID
|
||||
$nonExistentId = 9999;
|
||||
$this->assertNull($this->repository->find($nonExistentId));
|
||||
}
|
||||
|
||||
public function testFindOneBy(): void
|
||||
{
|
||||
// Create a drink type
|
||||
$drinkType = new DrinkType('Cola', 'Refreshing cola drink', 10);
|
||||
$this->repository->save($drinkType);
|
||||
|
||||
// Find by name
|
||||
$foundDrinkType = $this->repository->findOneBy(['name' => 'Cola']);
|
||||
$this->assertInstanceOf(DrinkType::class, $foundDrinkType);
|
||||
$this->assertEquals('Cola', $foundDrinkType->getName());
|
||||
|
||||
// Try to find a non-existent name
|
||||
$this->assertNull($this->repository->findOneBy(['name' => 'NonExistent']));
|
||||
}
|
||||
|
||||
public function testSave(): void
|
||||
{
|
||||
// Create a drink type
|
||||
$drinkType = new DrinkType('Cola', 'Refreshing cola drink', 10);
|
||||
|
||||
// Save it
|
||||
$this->repository->save($drinkType);
|
||||
|
||||
// Check that it was saved
|
||||
$id = $drinkType->getId();
|
||||
$this->assertNotNull($id);
|
||||
|
||||
// Find it by ID to confirm it's in the database
|
||||
$foundDrinkType = $this->repository->find($id);
|
||||
$this->assertInstanceOf(DrinkType::class, $foundDrinkType);
|
||||
$this->assertEquals('Cola', $foundDrinkType->getName());
|
||||
|
||||
// Update it
|
||||
$drinkType->setName('Pepsi');
|
||||
$this->repository->save($drinkType);
|
||||
|
||||
// Find it again to confirm the update
|
||||
$foundDrinkType = $this->repository->find($id);
|
||||
$this->assertEquals('Pepsi', $foundDrinkType->getName());
|
||||
}
|
||||
|
||||
public function testRemove(): void
|
||||
{
|
||||
// Create a drink type
|
||||
$drinkType = new DrinkType('Cola', 'Refreshing cola drink', 10);
|
||||
$this->repository->save($drinkType);
|
||||
|
||||
// Get the ID
|
||||
$id = $drinkType->getId();
|
||||
$this->assertNotNull($id);
|
||||
|
||||
// Remove it
|
||||
$this->repository->remove($drinkType);
|
||||
|
||||
// Try to find it by ID to confirm it's gone
|
||||
$this->assertNull($this->repository->find($id));
|
||||
}
|
||||
}
|
197
tests/Feature/Repository/InventoryRecordRepositoryTest.php
Normal file
197
tests/Feature/Repository/InventoryRecordRepositoryTest.php
Normal file
|
@ -0,0 +1,197 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Feature\Repository;
|
||||
|
||||
use Override;
|
||||
use Tests\TestCase;
|
||||
use App\Entity\DrinkType;
|
||||
use App\Entity\InventoryRecord;
|
||||
use App\Repository\DrinkTypeRepository;
|
||||
use App\Repository\InventoryRecordRepository;
|
||||
use DateTimeImmutable;
|
||||
|
||||
class InventoryRecordRepositoryTest extends TestCase
|
||||
{
|
||||
private InventoryRecordRepository $repository;
|
||||
private DrinkTypeRepository $drinkTypeRepository;
|
||||
private DrinkType $drinkType;
|
||||
|
||||
#[Override]
|
||||
public function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
$this->setUpDB();
|
||||
$this->repository = $this->container->get(InventoryRecordRepository::class);
|
||||
$this->drinkTypeRepository = $this->container->get(DrinkTypeRepository::class);
|
||||
|
||||
// Create a drink type for testing
|
||||
$this->drinkType = new DrinkType('Cola', 'Refreshing cola drink', 10);
|
||||
$this->drinkTypeRepository->save($this->drinkType);
|
||||
}
|
||||
|
||||
public function testFindAll(): void
|
||||
{
|
||||
// Initially the repository should be empty
|
||||
$this->assertCount(0, $this->repository->findAll());
|
||||
|
||||
// Create some inventory records
|
||||
$record1 = new InventoryRecord($this->drinkType, 5, new DateTimeImmutable('2023-01-01'));
|
||||
$record2 = new InventoryRecord($this->drinkType, 10, new DateTimeImmutable('2023-01-02'));
|
||||
|
||||
// Save them to the repository
|
||||
$this->repository->save($record1);
|
||||
$this->repository->save($record2);
|
||||
|
||||
// Now findAll should return both records
|
||||
$records = $this->repository->findAll();
|
||||
$this->assertCount(2, $records);
|
||||
$this->assertContainsOnlyInstancesOf(InventoryRecord::class, $records);
|
||||
}
|
||||
|
||||
public function testFind(): void
|
||||
{
|
||||
// Create an inventory record
|
||||
$record = new InventoryRecord($this->drinkType, 5, new DateTimeImmutable('2023-01-01'));
|
||||
$this->repository->save($record);
|
||||
|
||||
// Get the ID
|
||||
$id = $record->getId();
|
||||
$this->assertNotNull($id);
|
||||
|
||||
// Find by ID
|
||||
$foundRecord = $this->repository->find($id);
|
||||
$this->assertInstanceOf(InventoryRecord::class, $foundRecord);
|
||||
$this->assertEquals(5, $foundRecord->getQuantity());
|
||||
$this->assertEquals('2023-01-01', $foundRecord->getTimestamp()->format('Y-m-d'));
|
||||
|
||||
// Try to find a non-existent ID
|
||||
$nonExistentId = 9999;
|
||||
$this->assertNull($this->repository->find($nonExistentId));
|
||||
}
|
||||
|
||||
public function testFindByDrinkType(): void
|
||||
{
|
||||
// Create another drink type
|
||||
$anotherDrinkType = new DrinkType('Fanta', 'Orange soda', 5);
|
||||
$this->drinkTypeRepository->save($anotherDrinkType);
|
||||
|
||||
// Create inventory records for both drink types
|
||||
$record1 = new InventoryRecord($this->drinkType, 5, new DateTimeImmutable('2023-01-01'));
|
||||
$record2 = new InventoryRecord($this->drinkType, 10, new DateTimeImmutable('2023-01-02'));
|
||||
$record3 = new InventoryRecord($anotherDrinkType, 15, new DateTimeImmutable('2023-01-03'));
|
||||
|
||||
// Save them to the repository
|
||||
$this->repository->save($record1);
|
||||
$this->repository->save($record2);
|
||||
$this->repository->save($record3);
|
||||
|
||||
// Find by drink type
|
||||
$records = $this->repository->findByDrinkType($this->drinkType);
|
||||
$this->assertCount(2, $records);
|
||||
$this->assertContainsOnlyInstancesOf(InventoryRecord::class, $records);
|
||||
|
||||
// Check that the records are for the correct drink type
|
||||
foreach ($records as $record) {
|
||||
$this->assertEquals($this->drinkType->getId(), $record->getDrinkType()->getId());
|
||||
}
|
||||
|
||||
// Find by the other drink type
|
||||
$otherRecords = $this->repository->findByDrinkType($anotherDrinkType);
|
||||
$this->assertCount(1, $otherRecords);
|
||||
$this->assertEquals($anotherDrinkType->getId(), $otherRecords[0]->getDrinkType()->getId());
|
||||
}
|
||||
|
||||
public function testFindLatestByDrinkType(): void
|
||||
{
|
||||
// Create inventory records with different timestamps
|
||||
$record1 = new InventoryRecord($this->drinkType, 5, new DateTimeImmutable('2023-01-01'));
|
||||
$record2 = new InventoryRecord($this->drinkType, 10, new DateTimeImmutable('2023-01-02'));
|
||||
$record3 = new InventoryRecord($this->drinkType, 15, new DateTimeImmutable('2023-01-03'));
|
||||
|
||||
// Save them to the repository
|
||||
$this->repository->save($record1);
|
||||
$this->repository->save($record2);
|
||||
$this->repository->save($record3);
|
||||
|
||||
// Find the latest record
|
||||
$latestRecord = $this->repository->findLatestByDrinkType($this->drinkType);
|
||||
$this->assertInstanceOf(InventoryRecord::class, $latestRecord);
|
||||
$this->assertEquals(15, $latestRecord->getQuantity());
|
||||
$this->assertEquals('2023-01-03', $latestRecord->getTimestamp()->format('Y-m-d'));
|
||||
}
|
||||
|
||||
public function testFindByTimestampRange(): void
|
||||
{
|
||||
// Create inventory records with different timestamps
|
||||
$record1 = new InventoryRecord($this->drinkType, 5, new DateTimeImmutable('2023-01-01'));
|
||||
$record2 = new InventoryRecord($this->drinkType, 10, new DateTimeImmutable('2023-01-02'));
|
||||
$record3 = new InventoryRecord($this->drinkType, 15, new DateTimeImmutable('2023-01-03'));
|
||||
$record4 = new InventoryRecord($this->drinkType, 20, new DateTimeImmutable('2023-01-04'));
|
||||
$record5 = new InventoryRecord($this->drinkType, 25, new DateTimeImmutable('2023-01-05'));
|
||||
|
||||
// Save them to the repository
|
||||
$this->repository->save($record1);
|
||||
$this->repository->save($record2);
|
||||
$this->repository->save($record3);
|
||||
$this->repository->save($record4);
|
||||
$this->repository->save($record5);
|
||||
|
||||
// Find records in a specific range
|
||||
$start = new DateTimeImmutable('2023-01-02');
|
||||
$end = new DateTimeImmutable('2023-01-04');
|
||||
$records = $this->repository->findByTimestampRange($start, $end);
|
||||
|
||||
$this->assertCount(3, $records);
|
||||
|
||||
// Check that the records are within the range
|
||||
foreach ($records as $record) {
|
||||
$timestamp = $record->getTimestamp();
|
||||
$this->assertTrue($timestamp >= $start && $timestamp <= $end);
|
||||
}
|
||||
}
|
||||
|
||||
public function testSave(): void
|
||||
{
|
||||
// Create an inventory record
|
||||
$record = new InventoryRecord($this->drinkType, 5, new DateTimeImmutable('2023-01-01'));
|
||||
|
||||
// Save it
|
||||
$this->repository->save($record);
|
||||
|
||||
// Check that it was saved
|
||||
$id = $record->getId();
|
||||
$this->assertNotNull($id);
|
||||
|
||||
// Find it by ID to confirm it's in the database
|
||||
$foundRecord = $this->repository->find($id);
|
||||
$this->assertInstanceOf(InventoryRecord::class, $foundRecord);
|
||||
$this->assertEquals(5, $foundRecord->getQuantity());
|
||||
|
||||
// Update it
|
||||
$record->setQuantity(10);
|
||||
$this->repository->save($record);
|
||||
|
||||
// Find it again to confirm the update
|
||||
$foundRecord = $this->repository->find($id);
|
||||
$this->assertEquals(10, $foundRecord->getQuantity());
|
||||
}
|
||||
|
||||
public function testRemove(): void
|
||||
{
|
||||
// Create an inventory record
|
||||
$record = new InventoryRecord($this->drinkType, 5, new DateTimeImmutable('2023-01-01'));
|
||||
$this->repository->save($record);
|
||||
|
||||
// Get the ID
|
||||
$id = $record->getId();
|
||||
$this->assertNotNull($id);
|
||||
|
||||
// Remove it
|
||||
$this->repository->remove($record);
|
||||
|
||||
// Try to find it by ID to confirm it's gone
|
||||
$this->assertNull($this->repository->find($id));
|
||||
}
|
||||
}
|
226
tests/Feature/Repository/OrderItemRepositoryTest.php
Normal file
226
tests/Feature/Repository/OrderItemRepositoryTest.php
Normal file
|
@ -0,0 +1,226 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Feature\Repository;
|
||||
|
||||
use Override;
|
||||
use Tests\TestCase;
|
||||
use App\Entity\DrinkType;
|
||||
use App\Entity\Order;
|
||||
use App\Entity\OrderItem;
|
||||
use App\Repository\DrinkTypeRepository;
|
||||
use App\Repository\OrderRepository;
|
||||
use App\Repository\OrderItemRepository;
|
||||
use DateTimeImmutable;
|
||||
|
||||
class OrderItemRepositoryTest extends TestCase
|
||||
{
|
||||
private OrderItemRepository $repository;
|
||||
private DrinkTypeRepository $drinkTypeRepository;
|
||||
private OrderRepository $orderRepository;
|
||||
private DrinkType $drinkType;
|
||||
private Order $order;
|
||||
|
||||
#[Override]
|
||||
public function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
$this->setUpDB();
|
||||
$this->repository = $this->container->get(OrderItemRepository::class);
|
||||
$this->drinkTypeRepository = $this->container->get(DrinkTypeRepository::class);
|
||||
$this->orderRepository = $this->container->get(OrderRepository::class);
|
||||
|
||||
// Create a drink type for testing
|
||||
$this->drinkType = new DrinkType('Cola', 'Refreshing cola drink', 10);
|
||||
$this->drinkTypeRepository->save($this->drinkType);
|
||||
|
||||
// Create an order for testing
|
||||
$this->order = new Order(Order::STATUS_NEW, new DateTimeImmutable('2023-01-01'));
|
||||
$this->orderRepository->save($this->order);
|
||||
}
|
||||
|
||||
public function testFindAll(): void
|
||||
{
|
||||
// Initially the repository should be empty
|
||||
$this->assertCount(0, $this->repository->findAll());
|
||||
|
||||
// Create some order items
|
||||
$orderItem1 = new OrderItem($this->drinkType, 5, $this->order);
|
||||
$orderItem2 = new OrderItem($this->drinkType, 10, $this->order);
|
||||
|
||||
// Save them to the repository
|
||||
$this->repository->save($orderItem1);
|
||||
$this->repository->save($orderItem2);
|
||||
|
||||
// Now findAll should return both order items
|
||||
$orderItems = $this->repository->findAll();
|
||||
$this->assertCount(2, $orderItems);
|
||||
$this->assertContainsOnlyInstancesOf(OrderItem::class, $orderItems);
|
||||
}
|
||||
|
||||
public function testFind(): void
|
||||
{
|
||||
// Create an order item
|
||||
$orderItem = new OrderItem($this->drinkType, 5, $this->order);
|
||||
$this->repository->save($orderItem);
|
||||
|
||||
// Get the ID
|
||||
$id = $orderItem->getId();
|
||||
$this->assertNotNull($id);
|
||||
|
||||
// Find by ID
|
||||
$foundOrderItem = $this->repository->find($id);
|
||||
$this->assertInstanceOf(OrderItem::class, $foundOrderItem);
|
||||
$this->assertEquals(5, $foundOrderItem->getQuantity());
|
||||
$this->assertEquals($this->drinkType->getId(), $foundOrderItem->getDrinkType()->getId());
|
||||
$this->assertEquals($this->order->getId(), $foundOrderItem->getOrder()->getId());
|
||||
|
||||
// Try to find a non-existent ID
|
||||
$nonExistentId = 9999;
|
||||
$this->assertNull($this->repository->find($nonExistentId));
|
||||
}
|
||||
|
||||
public function testFindByOrder(): void
|
||||
{
|
||||
// Create another order
|
||||
$anotherOrder = new Order(Order::STATUS_NEW, new DateTimeImmutable('2023-01-02'));
|
||||
$this->orderRepository->save($anotherOrder);
|
||||
|
||||
// Create order items for both orders
|
||||
$orderItem1 = new OrderItem($this->drinkType, 5, $this->order);
|
||||
$orderItem2 = new OrderItem($this->drinkType, 10, $this->order);
|
||||
$orderItem3 = new OrderItem($this->drinkType, 15, $anotherOrder);
|
||||
|
||||
// Save them to the repository
|
||||
$this->repository->save($orderItem1);
|
||||
$this->repository->save($orderItem2);
|
||||
$this->repository->save($orderItem3);
|
||||
|
||||
// Find by order
|
||||
$orderItems = $this->repository->findByOrder($this->order);
|
||||
$this->assertCount(2, $orderItems);
|
||||
$this->assertContainsOnlyInstancesOf(OrderItem::class, $orderItems);
|
||||
|
||||
// Check that the order items are for the correct order
|
||||
foreach ($orderItems as $orderItem) {
|
||||
$this->assertEquals($this->order->getId(), $orderItem->getOrder()->getId());
|
||||
}
|
||||
|
||||
// Find by the other order
|
||||
$otherOrderItems = $this->repository->findByOrder($anotherOrder);
|
||||
$this->assertCount(1, $otherOrderItems);
|
||||
$this->assertEquals($anotherOrder->getId(), $otherOrderItems[0]->getOrder()->getId());
|
||||
}
|
||||
|
||||
public function testFindByDrinkType(): void
|
||||
{
|
||||
// Create another drink type
|
||||
$anotherDrinkType = new DrinkType('Fanta', 'Orange soda', 5);
|
||||
$this->drinkTypeRepository->save($anotherDrinkType);
|
||||
|
||||
// Create order items for both drink types
|
||||
$orderItem1 = new OrderItem($this->drinkType, 5, $this->order);
|
||||
$orderItem2 = new OrderItem($this->drinkType, 10, $this->order);
|
||||
$orderItem3 = new OrderItem($anotherDrinkType, 15, $this->order);
|
||||
|
||||
// Save them to the repository
|
||||
$this->repository->save($orderItem1);
|
||||
$this->repository->save($orderItem2);
|
||||
$this->repository->save($orderItem3);
|
||||
|
||||
// Find by drink type
|
||||
$orderItems = $this->repository->findByDrinkType($this->drinkType);
|
||||
$this->assertCount(2, $orderItems);
|
||||
$this->assertContainsOnlyInstancesOf(OrderItem::class, $orderItems);
|
||||
|
||||
// Check that the order items are for the correct drink type
|
||||
foreach ($orderItems as $orderItem) {
|
||||
$this->assertEquals($this->drinkType->getId(), $orderItem->getDrinkType()->getId());
|
||||
}
|
||||
|
||||
// Find by the other drink type
|
||||
$otherOrderItems = $this->repository->findByDrinkType($anotherDrinkType);
|
||||
$this->assertCount(1, $otherOrderItems);
|
||||
$this->assertEquals($anotherDrinkType->getId(), $otherOrderItems[0]->getDrinkType()->getId());
|
||||
}
|
||||
|
||||
public function testFindByOrderAndDrinkType(): void
|
||||
{
|
||||
// Create another drink type and order
|
||||
$anotherDrinkType = new DrinkType('Fanta', 'Orange soda', 5);
|
||||
$this->drinkTypeRepository->save($anotherDrinkType);
|
||||
|
||||
$anotherOrder = new Order(Order::STATUS_NEW, new DateTimeImmutable('2023-01-02'));
|
||||
$this->orderRepository->save($anotherOrder);
|
||||
|
||||
// Create order items with different combinations
|
||||
$orderItem1 = new OrderItem($this->drinkType, 5, $this->order);
|
||||
$orderItem2 = new OrderItem($anotherDrinkType, 10, $this->order);
|
||||
$orderItem3 = new OrderItem($this->drinkType, 15, $anotherOrder);
|
||||
$orderItem4 = new OrderItem($anotherDrinkType, 20, $anotherOrder);
|
||||
|
||||
// Save them to the repository
|
||||
$this->repository->save($orderItem1);
|
||||
$this->repository->save($orderItem2);
|
||||
$this->repository->save($orderItem3);
|
||||
$this->repository->save($orderItem4);
|
||||
|
||||
// Find by order and drink type
|
||||
$orderItem = $this->repository->findByOrderAndDrinkType($this->order, $this->drinkType);
|
||||
$this->assertInstanceOf(OrderItem::class, $orderItem);
|
||||
$this->assertEquals(5, $orderItem->getQuantity());
|
||||
$this->assertEquals($this->order->getId(), $orderItem->getOrder()->getId());
|
||||
$this->assertEquals($this->drinkType->getId(), $orderItem->getDrinkType()->getId());
|
||||
|
||||
// Find by another combination
|
||||
$anotherOrderItem = $this->repository->findByOrderAndDrinkType($anotherOrder, $anotherDrinkType);
|
||||
$this->assertInstanceOf(OrderItem::class, $anotherOrderItem);
|
||||
$this->assertEquals(20, $anotherOrderItem->getQuantity());
|
||||
$this->assertEquals($anotherOrder->getId(), $anotherOrderItem->getOrder()->getId());
|
||||
$this->assertEquals($anotherDrinkType->getId(), $anotherOrderItem->getDrinkType()->getId());
|
||||
}
|
||||
|
||||
public function testSave(): void
|
||||
{
|
||||
// Create an order item
|
||||
$orderItem = new OrderItem($this->drinkType, 5, $this->order);
|
||||
|
||||
// Save it
|
||||
$this->repository->save($orderItem);
|
||||
|
||||
// Check that it was saved
|
||||
$id = $orderItem->getId();
|
||||
$this->assertNotNull($id);
|
||||
|
||||
// Find it by ID to confirm it's in the database
|
||||
$foundOrderItem = $this->repository->find($id);
|
||||
$this->assertInstanceOf(OrderItem::class, $foundOrderItem);
|
||||
$this->assertEquals(5, $foundOrderItem->getQuantity());
|
||||
|
||||
// Update it
|
||||
$orderItem->setQuantity(10);
|
||||
$this->repository->save($orderItem);
|
||||
|
||||
// Find it again to confirm the update
|
||||
$foundOrderItem = $this->repository->find($id);
|
||||
$this->assertEquals(10, $foundOrderItem->getQuantity());
|
||||
}
|
||||
|
||||
public function testRemove(): void
|
||||
{
|
||||
// Create an order item
|
||||
$orderItem = new OrderItem($this->drinkType, 5, $this->order);
|
||||
$this->repository->save($orderItem);
|
||||
|
||||
// Get the ID
|
||||
$id = $orderItem->getId();
|
||||
$this->assertNotNull($id);
|
||||
|
||||
// Remove it
|
||||
$this->repository->remove($orderItem);
|
||||
|
||||
// Try to find it by ID to confirm it's gone
|
||||
$this->assertNull($this->repository->find($id));
|
||||
}
|
||||
}
|
223
tests/Feature/Repository/OrderRepositoryTest.php
Normal file
223
tests/Feature/Repository/OrderRepositoryTest.php
Normal file
|
@ -0,0 +1,223 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Feature\Repository;
|
||||
|
||||
use Override;
|
||||
use Tests\TestCase;
|
||||
use App\Entity\DrinkType;
|
||||
use App\Entity\Order;
|
||||
use App\Entity\OrderItem;
|
||||
use App\Repository\DrinkTypeRepository;
|
||||
use App\Repository\OrderRepository;
|
||||
use App\Repository\OrderItemRepository;
|
||||
use DateTimeImmutable;
|
||||
|
||||
class OrderRepositoryTest extends TestCase
|
||||
{
|
||||
private OrderRepository $repository;
|
||||
private DrinkTypeRepository $drinkTypeRepository;
|
||||
private OrderItemRepository $orderItemRepository;
|
||||
private DrinkType $drinkType;
|
||||
|
||||
#[Override]
|
||||
public function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
$this->setUpDB();
|
||||
$this->repository = $this->container->get(OrderRepository::class);
|
||||
$this->drinkTypeRepository = $this->container->get(DrinkTypeRepository::class);
|
||||
$this->orderItemRepository = $this->container->get(OrderItemRepository::class);
|
||||
|
||||
// Create a drink type for testing
|
||||
$this->drinkType = new DrinkType('Cola', 'Refreshing cola drink', 10);
|
||||
$this->drinkTypeRepository->save($this->drinkType);
|
||||
}
|
||||
|
||||
public function testFindAll(): void
|
||||
{
|
||||
// Initially the repository should be empty
|
||||
$this->assertCount(0, $this->repository->findAll());
|
||||
|
||||
// Create some orders
|
||||
$order1 = new Order(Order::STATUS_NEW, new DateTimeImmutable('2023-01-01'));
|
||||
$order2 = new Order(Order::STATUS_FULFILLED, new DateTimeImmutable('2023-01-02'));
|
||||
|
||||
// Save them to the repository
|
||||
$this->repository->save($order1);
|
||||
$this->repository->save($order2);
|
||||
|
||||
// Now findAll should return both orders
|
||||
$orders = $this->repository->findAll();
|
||||
$this->assertCount(2, $orders);
|
||||
$this->assertContainsOnlyInstancesOf(Order::class, $orders);
|
||||
}
|
||||
|
||||
public function testFind(): void
|
||||
{
|
||||
// Create an order
|
||||
$order = new Order(Order::STATUS_NEW, new DateTimeImmutable('2023-01-01'));
|
||||
$this->repository->save($order);
|
||||
|
||||
// Get the ID
|
||||
$id = $order->getId();
|
||||
$this->assertNotNull($id);
|
||||
|
||||
// Find by ID
|
||||
$foundOrder = $this->repository->find($id);
|
||||
$this->assertInstanceOf(Order::class, $foundOrder);
|
||||
$this->assertEquals(Order::STATUS_NEW, $foundOrder->getStatus());
|
||||
$this->assertEquals('2023-01-01', $foundOrder->getCreatedAt()->format('Y-m-d'));
|
||||
|
||||
// Try to find a non-existent ID
|
||||
$nonExistentId = 9999;
|
||||
$this->assertNull($this->repository->find($nonExistentId));
|
||||
}
|
||||
|
||||
public function testFindByStatus(): void
|
||||
{
|
||||
// Create orders with different statuses
|
||||
$order1 = new Order(Order::STATUS_NEW, new DateTimeImmutable('2023-01-01'));
|
||||
$order2 = new Order(Order::STATUS_IN_WORK, new DateTimeImmutable('2023-01-02'));
|
||||
$order3 = new Order(Order::STATUS_ORDERED, new DateTimeImmutable('2023-01-03'));
|
||||
$order4 = new Order(Order::STATUS_FULFILLED, new DateTimeImmutable('2023-01-04'));
|
||||
$order5 = new Order(Order::STATUS_NEW, new DateTimeImmutable('2023-01-05'));
|
||||
|
||||
// Save them to the repository
|
||||
$this->repository->save($order1);
|
||||
$this->repository->save($order2);
|
||||
$this->repository->save($order3);
|
||||
$this->repository->save($order4);
|
||||
$this->repository->save($order5);
|
||||
|
||||
// Find by status
|
||||
$newOrders = $this->repository->findByStatus(Order::STATUS_NEW);
|
||||
$this->assertCount(2, $newOrders);
|
||||
$this->assertContainsOnlyInstancesOf(Order::class, $newOrders);
|
||||
|
||||
// Check that the orders have the correct status
|
||||
foreach ($newOrders as $order) {
|
||||
$this->assertEquals(Order::STATUS_NEW, $order->getStatus());
|
||||
}
|
||||
|
||||
// Find by another status
|
||||
$fulfilledOrders = $this->repository->findByStatus(Order::STATUS_FULFILLED);
|
||||
$this->assertCount(1, $fulfilledOrders);
|
||||
$this->assertEquals(Order::STATUS_FULFILLED, $fulfilledOrders[0]->getStatus());
|
||||
}
|
||||
|
||||
public function testFindByDateRange(): void
|
||||
{
|
||||
// Create orders with different dates
|
||||
$order1 = new Order(Order::STATUS_NEW, new DateTimeImmutable('2023-01-01'));
|
||||
$order2 = new Order(Order::STATUS_NEW, new DateTimeImmutable('2023-01-02'));
|
||||
$order3 = new Order(Order::STATUS_NEW, new DateTimeImmutable('2023-01-03'));
|
||||
$order4 = new Order(Order::STATUS_NEW, new DateTimeImmutable('2023-01-04'));
|
||||
$order5 = new Order(Order::STATUS_NEW, new DateTimeImmutable('2023-01-05'));
|
||||
|
||||
// Save them to the repository
|
||||
$this->repository->save($order1);
|
||||
$this->repository->save($order2);
|
||||
$this->repository->save($order3);
|
||||
$this->repository->save($order4);
|
||||
$this->repository->save($order5);
|
||||
|
||||
// Find orders in a specific date range
|
||||
$start = new DateTimeImmutable('2023-01-02');
|
||||
$end = new DateTimeImmutable('2023-01-04');
|
||||
$orders = $this->repository->findByDateRange($start, $end);
|
||||
|
||||
$this->assertCount(3, $orders);
|
||||
|
||||
// Check that the orders are within the date range
|
||||
foreach ($orders as $order) {
|
||||
$createdAt = $order->getCreatedAt();
|
||||
$this->assertTrue($createdAt >= $start && $createdAt <= $end);
|
||||
}
|
||||
}
|
||||
|
||||
public function testFindLastOrdersForDrinkType(): void
|
||||
{
|
||||
// Create orders with different dates
|
||||
$order1 = new Order(Order::STATUS_FULFILLED, new DateTimeImmutable('2023-01-01'));
|
||||
$order2 = new Order(Order::STATUS_FULFILLED, new DateTimeImmutable('2023-01-02'));
|
||||
$order3 = new Order(Order::STATUS_FULFILLED, new DateTimeImmutable('2023-01-03'));
|
||||
$order4 = new Order(Order::STATUS_FULFILLED, new DateTimeImmutable('2023-01-04'));
|
||||
$order5 = new Order(Order::STATUS_FULFILLED, new DateTimeImmutable('2023-01-05'));
|
||||
|
||||
// Save them to the repository
|
||||
$this->repository->save($order1);
|
||||
$this->repository->save($order2);
|
||||
$this->repository->save($order3);
|
||||
$this->repository->save($order4);
|
||||
$this->repository->save($order5);
|
||||
|
||||
// Create order items for the drink type
|
||||
$orderItem1 = new OrderItem($this->drinkType, 5, $order1);
|
||||
$orderItem2 = new OrderItem($this->drinkType, 10, $order2);
|
||||
$orderItem3 = new OrderItem($this->drinkType, 15, $order3);
|
||||
$orderItem4 = new OrderItem($this->drinkType, 20, $order4);
|
||||
$orderItem5 = new OrderItem($this->drinkType, 25, $order5);
|
||||
|
||||
// Save them to the repository
|
||||
$this->orderItemRepository->save($orderItem1);
|
||||
$this->orderItemRepository->save($orderItem2);
|
||||
$this->orderItemRepository->save($orderItem3);
|
||||
$this->orderItemRepository->save($orderItem4);
|
||||
$this->orderItemRepository->save($orderItem5);
|
||||
|
||||
// Find the last 3 orders for the drink type
|
||||
$orders = $this->repository->findLastOrdersForDrinkType($this->drinkType, 3);
|
||||
|
||||
$this->assertCount(3, $orders);
|
||||
|
||||
// Check that the orders are the most recent ones
|
||||
$this->assertEquals('2023-01-05', $orders[0]->getCreatedAt()->format('Y-m-d'));
|
||||
$this->assertEquals('2023-01-04', $orders[1]->getCreatedAt()->format('Y-m-d'));
|
||||
$this->assertEquals('2023-01-03', $orders[2]->getCreatedAt()->format('Y-m-d'));
|
||||
}
|
||||
|
||||
public function testSave(): void
|
||||
{
|
||||
// Create an order
|
||||
$order = new Order(Order::STATUS_NEW, new DateTimeImmutable('2023-01-01'));
|
||||
|
||||
// Save it
|
||||
$this->repository->save($order);
|
||||
|
||||
// Check that it was saved
|
||||
$id = $order->getId();
|
||||
$this->assertNotNull($id);
|
||||
|
||||
// Find it by ID to confirm it's in the database
|
||||
$foundOrder = $this->repository->find($id);
|
||||
$this->assertInstanceOf(Order::class, $foundOrder);
|
||||
$this->assertEquals(Order::STATUS_NEW, $foundOrder->getStatus());
|
||||
|
||||
// Update it
|
||||
$order->setStatus(Order::STATUS_IN_WORK);
|
||||
$this->repository->save($order);
|
||||
|
||||
// Find it again to confirm the update
|
||||
$foundOrder = $this->repository->find($id);
|
||||
$this->assertEquals(Order::STATUS_IN_WORK, $foundOrder->getStatus());
|
||||
}
|
||||
|
||||
public function testRemove(): void
|
||||
{
|
||||
// Create an order
|
||||
$order = new Order(Order::STATUS_NEW, new DateTimeImmutable('2023-01-01'));
|
||||
$this->repository->save($order);
|
||||
|
||||
// Get the ID
|
||||
$id = $order->getId();
|
||||
$this->assertNotNull($id);
|
||||
|
||||
// Remove it
|
||||
$this->repository->remove($order);
|
||||
|
||||
// Try to find it by ID to confirm it's gone
|
||||
$this->assertNull($this->repository->find($id));
|
||||
}
|
||||
}
|
167
tests/Feature/Repository/SystemConfigRepositoryTest.php
Normal file
167
tests/Feature/Repository/SystemConfigRepositoryTest.php
Normal file
|
@ -0,0 +1,167 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Feature\Repository;
|
||||
|
||||
use Override;
|
||||
use Tests\TestCase;
|
||||
use App\Entity\SystemConfig;
|
||||
use App\Repository\SystemConfigRepository;
|
||||
|
||||
class SystemConfigRepositoryTest extends TestCase
|
||||
{
|
||||
private SystemConfigRepository $repository;
|
||||
|
||||
#[Override]
|
||||
public function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
$this->setUpDB();
|
||||
$this->repository = $this->container->get(SystemConfigRepository::class);
|
||||
}
|
||||
|
||||
public function testFindAll(): void
|
||||
{
|
||||
// Initially the repository should be empty
|
||||
$this->assertCount(0, $this->repository->findAll());
|
||||
|
||||
// Create some config entries
|
||||
$config1 = new SystemConfig('key1', 'value1');
|
||||
$config2 = new SystemConfig('key2', 'value2');
|
||||
|
||||
// Save them to the repository
|
||||
$this->repository->save($config1);
|
||||
$this->repository->save($config2);
|
||||
|
||||
// Now findAll should return both configs
|
||||
$configs = $this->repository->findAll();
|
||||
$this->assertCount(2, $configs);
|
||||
$this->assertContainsOnlyInstancesOf(SystemConfig::class, $configs);
|
||||
}
|
||||
|
||||
public function testFind(): void
|
||||
{
|
||||
// Create a config
|
||||
$config = new SystemConfig('key1', 'value1');
|
||||
$this->repository->save($config);
|
||||
|
||||
// Get the ID
|
||||
$id = $config->getId();
|
||||
$this->assertNotNull($id);
|
||||
|
||||
// Find by ID
|
||||
$foundConfig = $this->repository->find($id);
|
||||
$this->assertInstanceOf(SystemConfig::class, $foundConfig);
|
||||
$this->assertEquals('key1', $foundConfig->getKey());
|
||||
$this->assertEquals('value1', $foundConfig->getValue());
|
||||
|
||||
// Try to find a non-existent ID
|
||||
$nonExistentId = 9999;
|
||||
$this->assertNull($this->repository->find($nonExistentId));
|
||||
}
|
||||
|
||||
public function testFindByKey(): void
|
||||
{
|
||||
// Create some configs
|
||||
$config1 = new SystemConfig('key1', 'value1');
|
||||
$config2 = new SystemConfig('key2', 'value2');
|
||||
|
||||
// Save them to the repository
|
||||
$this->repository->save($config1);
|
||||
$this->repository->save($config2);
|
||||
|
||||
// Find by key
|
||||
$foundConfig = $this->repository->findByKey('key1');
|
||||
$this->assertInstanceOf(SystemConfig::class, $foundConfig);
|
||||
$this->assertEquals('key1', $foundConfig->getKey());
|
||||
$this->assertEquals('value1', $foundConfig->getValue());
|
||||
|
||||
// Try to find a non-existent key
|
||||
$this->assertNull($this->repository->findByKey('nonexistent'));
|
||||
}
|
||||
|
||||
public function testGetValue(): void
|
||||
{
|
||||
// Create a config
|
||||
$config = new SystemConfig('key1', 'value1');
|
||||
$this->repository->save($config);
|
||||
|
||||
// Get value by key
|
||||
$value = $this->repository->getValue('key1');
|
||||
$this->assertEquals('value1', $value);
|
||||
|
||||
// Get value for a non-existent key (should return default)
|
||||
$value = $this->repository->getValue('nonexistent', 'default');
|
||||
$this->assertEquals('default', $value);
|
||||
}
|
||||
|
||||
public function testSetValue(): void
|
||||
{
|
||||
// Initially the repository should be empty
|
||||
$this->assertCount(0, $this->repository->findAll());
|
||||
|
||||
// Set a value for a new key
|
||||
$this->repository->setValue('key1', 'value1');
|
||||
|
||||
// Check that a new config was created
|
||||
$configs = $this->repository->findAll();
|
||||
$this->assertCount(1, $configs);
|
||||
$config = $configs[0];
|
||||
$this->assertEquals('key1', $config->getKey());
|
||||
$this->assertEquals('value1', $config->getValue());
|
||||
|
||||
// Set a value for an existing key
|
||||
$this->repository->setValue('key1', 'updated');
|
||||
|
||||
// Check that the value was updated
|
||||
$config = $this->repository->findByKey('key1');
|
||||
$this->assertInstanceOf(SystemConfig::class, $config);
|
||||
$this->assertEquals('key1', $config->getKey());
|
||||
$this->assertEquals('updated', $config->getValue());
|
||||
}
|
||||
|
||||
public function testSave(): void
|
||||
{
|
||||
// Create a config
|
||||
$config = new SystemConfig('key1', 'value1');
|
||||
|
||||
// Save it
|
||||
$this->repository->save($config);
|
||||
|
||||
// Check that it was saved
|
||||
$id = $config->getId();
|
||||
$this->assertNotNull($id);
|
||||
|
||||
// Find it by ID to confirm it's in the database
|
||||
$foundConfig = $this->repository->find($id);
|
||||
$this->assertInstanceOf(SystemConfig::class, $foundConfig);
|
||||
$this->assertEquals('key1', $foundConfig->getKey());
|
||||
$this->assertEquals('value1', $foundConfig->getValue());
|
||||
|
||||
// Update it
|
||||
$config->setValue('updated');
|
||||
$this->repository->save($config);
|
||||
|
||||
// Find it again to confirm the update
|
||||
$foundConfig = $this->repository->find($id);
|
||||
$this->assertEquals('updated', $foundConfig->getValue());
|
||||
}
|
||||
|
||||
public function testRemove(): void
|
||||
{
|
||||
// Create a config
|
||||
$config = new SystemConfig('key1', 'value1');
|
||||
$this->repository->save($config);
|
||||
|
||||
// Get the ID
|
||||
$id = $config->getId();
|
||||
$this->assertNotNull($id);
|
||||
|
||||
// Remove it
|
||||
$this->repository->remove($config);
|
||||
|
||||
// Try to find it by ID to confirm it's gone
|
||||
$this->assertNull($this->repository->find($id));
|
||||
}
|
||||
}
|
201
tests/Feature/Service/ConfigurationServiceTest.php
Normal file
201
tests/Feature/Service/ConfigurationServiceTest.php
Normal file
|
@ -0,0 +1,201 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Feature\Service;
|
||||
|
||||
use Override;
|
||||
use Tests\TestCase;
|
||||
use App\Entity\SystemConfig;
|
||||
use App\Service\ConfigurationService;
|
||||
use App\Repository\SystemConfigRepository;
|
||||
use App\Enum\SystemSettingKey;
|
||||
use InvalidArgumentException;
|
||||
|
||||
class ConfigurationServiceTest extends TestCase
|
||||
{
|
||||
private ConfigurationService $service;
|
||||
private SystemConfigRepository $repository;
|
||||
|
||||
#[Override]
|
||||
public function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
$this->setUpDB();
|
||||
$this->repository = $this->container->get(SystemConfigRepository::class);
|
||||
$this->service = $this->container->get(ConfigurationService::class);
|
||||
}
|
||||
|
||||
public function testGetAllConfigs(): void
|
||||
{
|
||||
// Initially the repository should be empty
|
||||
$this->assertCount(0, $this->service->getAllConfigs());
|
||||
|
||||
// Create some config entries
|
||||
$config1 = new SystemConfig('key1', 'value1');
|
||||
$config2 = new SystemConfig('key2', 'value2');
|
||||
$this->repository->save($config1);
|
||||
$this->repository->save($config2);
|
||||
|
||||
// Now getAllConfigs should return both configs
|
||||
$configs = $this->service->getAllConfigs();
|
||||
$this->assertCount(2, $configs);
|
||||
$this->assertContainsOnlyInstancesOf(SystemConfig::class, $configs);
|
||||
}
|
||||
|
||||
public function testGetConfigValue(): void
|
||||
{
|
||||
// Create a config
|
||||
$config = new SystemConfig('key1', 'value1');
|
||||
$this->repository->save($config);
|
||||
|
||||
// Get value by key
|
||||
$value = $this->service->getConfigValue('key1');
|
||||
$this->assertEquals('value1', $value);
|
||||
|
||||
// Get value for a non-existent key (should return default)
|
||||
$value = $this->service->getConfigValue('nonexistent', 'default');
|
||||
$this->assertEquals('default', $value);
|
||||
}
|
||||
|
||||
public function testSetConfigValue(): void
|
||||
{
|
||||
// Initially the repository should be empty
|
||||
$this->assertCount(0, $this->service->getAllConfigs());
|
||||
|
||||
// Set a value for a new key
|
||||
$this->service->setConfigValue('key1', 'value1');
|
||||
|
||||
// Check that a new config was created
|
||||
$configs = $this->service->getAllConfigs();
|
||||
$this->assertCount(1, $configs);
|
||||
$config = $configs[0];
|
||||
$this->assertEquals('key1', $config->getKey());
|
||||
$this->assertEquals('value1', $config->getValue());
|
||||
|
||||
// Set a value for an existing key
|
||||
$this->service->setConfigValue('key1', 'updated');
|
||||
|
||||
// Check that the value was updated
|
||||
$config = $this->service->getConfigByKey('key1');
|
||||
$this->assertInstanceOf(SystemConfig::class, $config);
|
||||
$this->assertEquals('key1', $config->getKey());
|
||||
$this->assertEquals('updated', $config->getValue());
|
||||
}
|
||||
|
||||
public function testGetConfigByKey(): void
|
||||
{
|
||||
// Create some configs
|
||||
$config1 = new SystemConfig('key1', 'value1');
|
||||
$config2 = new SystemConfig('key2', 'value2');
|
||||
$this->repository->save($config1);
|
||||
$this->repository->save($config2);
|
||||
|
||||
// Find by key
|
||||
$foundConfig = $this->service->getConfigByKey('key1');
|
||||
$this->assertInstanceOf(SystemConfig::class, $foundConfig);
|
||||
$this->assertEquals('key1', $foundConfig->getKey());
|
||||
$this->assertEquals('value1', $foundConfig->getValue());
|
||||
|
||||
// Try to find a non-existent key
|
||||
$this->assertNull($this->service->getConfigByKey('nonexistent'));
|
||||
}
|
||||
|
||||
public function testCreateConfig(): void
|
||||
{
|
||||
// Create a new config
|
||||
$config = $this->service->createConfig('key1', 'value1');
|
||||
|
||||
// Check that it was created correctly
|
||||
$this->assertInstanceOf(SystemConfig::class, $config);
|
||||
$this->assertEquals('key1', $config->getKey());
|
||||
$this->assertEquals('value1', $config->getValue());
|
||||
|
||||
// Check that it's in the database
|
||||
$foundConfig = $this->service->getConfigByKey('key1');
|
||||
$this->assertInstanceOf(SystemConfig::class, $foundConfig);
|
||||
$this->assertEquals('key1', $foundConfig->getKey());
|
||||
$this->assertEquals('value1', $foundConfig->getValue());
|
||||
|
||||
// Try to create a config with the same key (should throw an exception)
|
||||
$this->expectException(InvalidArgumentException::class);
|
||||
$this->service->createConfig('key1', 'value2');
|
||||
}
|
||||
|
||||
public function testUpdateConfig(): void
|
||||
{
|
||||
// Create a config
|
||||
$config = $this->service->createConfig('key1', 'value1');
|
||||
|
||||
// Update the value
|
||||
$updatedConfig = $this->service->updateConfig($config, null, 'updated');
|
||||
$this->assertEquals('key1', $updatedConfig->getKey());
|
||||
$this->assertEquals('updated', $updatedConfig->getValue());
|
||||
|
||||
// Check that it was updated in the database
|
||||
$foundConfig = $this->service->getConfigByKey('key1');
|
||||
$this->assertEquals('updated', $foundConfig->getValue());
|
||||
|
||||
// Update the key
|
||||
$updatedConfig = $this->service->updateConfig($config, 'newKey', null);
|
||||
$this->assertEquals('newKey', $updatedConfig->getKey());
|
||||
$this->assertEquals('updated', $updatedConfig->getValue());
|
||||
|
||||
// Check that it was updated in the database
|
||||
$foundConfig = $this->service->getConfigByKey('newKey');
|
||||
$this->assertInstanceOf(SystemConfig::class, $foundConfig);
|
||||
$this->assertEquals('newKey', $foundConfig->getKey());
|
||||
$this->assertEquals('updated', $foundConfig->getValue());
|
||||
|
||||
// Create another config
|
||||
$config2 = $this->service->createConfig('key2', 'value2');
|
||||
|
||||
// Try to update the key to an existing key (should throw an exception)
|
||||
$this->expectException(InvalidArgumentException::class);
|
||||
$this->service->updateConfig($config2, 'newKey', null);
|
||||
}
|
||||
|
||||
public function testDeleteConfig(): void
|
||||
{
|
||||
// Create a config
|
||||
$config = $this->service->createConfig('key1', 'value1');
|
||||
|
||||
// Check that it exists
|
||||
$this->assertInstanceOf(SystemConfig::class, $this->service->getConfigByKey('key1'));
|
||||
|
||||
// Delete it
|
||||
$this->service->deleteConfig($config);
|
||||
|
||||
// Check that it's gone
|
||||
$this->assertNull($this->service->getConfigByKey('key1'));
|
||||
}
|
||||
|
||||
public function testInitializeDefaultConfigs(): void
|
||||
{
|
||||
// Initially the repository should be empty
|
||||
$this->assertCount(0, $this->service->getAllConfigs());
|
||||
|
||||
// Initialize default configs
|
||||
$this->service->initializeDefaultConfigs();
|
||||
|
||||
// Check that the default configs were created
|
||||
$configs = $this->service->getAllConfigs();
|
||||
$this->assertGreaterThanOrEqual(3, count($configs)); // At least 3 default configs
|
||||
|
||||
// Check specific default configs
|
||||
$lookbackOrders = $this->service->getConfigValue(SystemSettingKey::STOCK_ADJUSTMENT_LOOKBACK_ORDERS->value);
|
||||
$this->assertEquals('5', $lookbackOrders);
|
||||
|
||||
$magnitude = $this->service->getConfigValue(SystemSettingKey::STOCK_ADJUSTMENT_MAGNITUDE->value);
|
||||
$this->assertEquals('0.2', $magnitude);
|
||||
|
||||
$threshold = $this->service->getConfigValue(SystemSettingKey::STOCK_ADJUSTMENT_THRESHOLD->value);
|
||||
$this->assertEquals('0.1', $threshold);
|
||||
|
||||
// Initialize again (should not create duplicates)
|
||||
$this->service->initializeDefaultConfigs();
|
||||
|
||||
// Count should be the same
|
||||
$this->assertCount(count($configs), $this->service->getAllConfigs());
|
||||
}
|
||||
}
|
169
tests/Feature/Service/DrinkTypeServiceTest.php
Normal file
169
tests/Feature/Service/DrinkTypeServiceTest.php
Normal file
|
@ -0,0 +1,169 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Feature\Service;
|
||||
|
||||
use Override;
|
||||
use Tests\TestCase;
|
||||
use App\Entity\DrinkType;
|
||||
use App\Service\DrinkTypeService;
|
||||
use App\Repository\DrinkTypeRepository;
|
||||
use InvalidArgumentException;
|
||||
|
||||
class DrinkTypeServiceTest extends TestCase
|
||||
{
|
||||
private DrinkTypeService $service;
|
||||
private DrinkTypeRepository $repository;
|
||||
|
||||
#[Override]
|
||||
public function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
$this->setUpDB();
|
||||
$this->repository = $this->container->get(DrinkTypeRepository::class);
|
||||
$this->service = $this->container->get(DrinkTypeService::class);
|
||||
}
|
||||
|
||||
public function testGetAllDrinkTypes(): void
|
||||
{
|
||||
// Initially the repository should be empty
|
||||
$this->assertCount(0, $this->service->getAllDrinkTypes());
|
||||
|
||||
// Create some drink types
|
||||
$drinkType1 = new DrinkType('Cola', 'Refreshing cola drink', 10);
|
||||
$drinkType2 = new DrinkType('Fanta', 'Orange soda', 5);
|
||||
$this->repository->save($drinkType1);
|
||||
$this->repository->save($drinkType2);
|
||||
|
||||
// Now getAllDrinkTypes should return both drink types
|
||||
$drinkTypes = $this->service->getAllDrinkTypes();
|
||||
$this->assertCount(2, $drinkTypes);
|
||||
$this->assertContainsOnlyInstancesOf(DrinkType::class, $drinkTypes);
|
||||
}
|
||||
|
||||
public function testGetDrinkTypeById(): void
|
||||
{
|
||||
// Create a drink type
|
||||
$drinkType = new DrinkType('Cola', 'Refreshing cola drink', 10);
|
||||
$this->repository->save($drinkType);
|
||||
|
||||
// Get the ID
|
||||
$id = $drinkType->getId();
|
||||
$this->assertNotNull($id);
|
||||
|
||||
// Find by ID
|
||||
$foundDrinkType = $this->service->getDrinkTypeById($id);
|
||||
$this->assertInstanceOf(DrinkType::class, $foundDrinkType);
|
||||
$this->assertEquals('Cola', $foundDrinkType->getName());
|
||||
$this->assertEquals('Refreshing cola drink', $foundDrinkType->getDescription());
|
||||
$this->assertEquals(10, $foundDrinkType->getDesiredStock());
|
||||
|
||||
// Try to find a non-existent ID
|
||||
$nonExistentId = 9999;
|
||||
$this->assertNull($this->service->getDrinkTypeById($nonExistentId));
|
||||
}
|
||||
|
||||
public function testGetDrinkTypeByName(): void
|
||||
{
|
||||
// Create some drink types
|
||||
$drinkType1 = new DrinkType('Cola', 'Refreshing cola drink', 10);
|
||||
$drinkType2 = new DrinkType('Fanta', 'Orange soda', 5);
|
||||
$this->repository->save($drinkType1);
|
||||
$this->repository->save($drinkType2);
|
||||
|
||||
// Find by name
|
||||
$foundDrinkType = $this->service->getDrinkTypeByName('Cola');
|
||||
$this->assertInstanceOf(DrinkType::class, $foundDrinkType);
|
||||
$this->assertEquals('Cola', $foundDrinkType->getName());
|
||||
$this->assertEquals('Refreshing cola drink', $foundDrinkType->getDescription());
|
||||
$this->assertEquals(10, $foundDrinkType->getDesiredStock());
|
||||
|
||||
// Try to find a non-existent name
|
||||
$this->assertNull($this->service->getDrinkTypeByName('nonexistent'));
|
||||
}
|
||||
|
||||
public function testCreateDrinkType(): void
|
||||
{
|
||||
// Create a new drink type
|
||||
$drinkType = $this->service->createDrinkType('Cola', 'Refreshing cola drink', 10);
|
||||
|
||||
// Check that it was created correctly
|
||||
$this->assertInstanceOf(DrinkType::class, $drinkType);
|
||||
$this->assertEquals('Cola', $drinkType->getName());
|
||||
$this->assertEquals('Refreshing cola drink', $drinkType->getDescription());
|
||||
$this->assertEquals(10, $drinkType->getDesiredStock());
|
||||
|
||||
// Check that it's in the database
|
||||
$foundDrinkType = $this->service->getDrinkTypeByName('Cola');
|
||||
$this->assertInstanceOf(DrinkType::class, $foundDrinkType);
|
||||
$this->assertEquals('Cola', $foundDrinkType->getName());
|
||||
$this->assertEquals('Refreshing cola drink', $foundDrinkType->getDescription());
|
||||
$this->assertEquals(10, $foundDrinkType->getDesiredStock());
|
||||
|
||||
// Try to create a drink type with the same name (should throw an exception)
|
||||
$this->expectException(InvalidArgumentException::class);
|
||||
$this->service->createDrinkType('Cola', 'Another cola drink', 5);
|
||||
}
|
||||
|
||||
public function testUpdateDrinkType(): void
|
||||
{
|
||||
// Create a drink type
|
||||
$drinkType = $this->service->createDrinkType('Cola', 'Refreshing cola drink', 10);
|
||||
|
||||
// Update the description
|
||||
$updatedDrinkType = $this->service->updateDrinkType($drinkType, null, 'Updated description', null);
|
||||
$this->assertEquals('Cola', $updatedDrinkType->getName());
|
||||
$this->assertEquals('Updated description', $updatedDrinkType->getDescription());
|
||||
$this->assertEquals(10, $updatedDrinkType->getDesiredStock());
|
||||
|
||||
// Check that it was updated in the database
|
||||
$foundDrinkType = $this->service->getDrinkTypeByName('Cola');
|
||||
$this->assertEquals('Updated description', $foundDrinkType->getDescription());
|
||||
|
||||
// Update the desired stock
|
||||
$updatedDrinkType = $this->service->updateDrinkType($drinkType, null, null, 20);
|
||||
$this->assertEquals('Cola', $updatedDrinkType->getName());
|
||||
$this->assertEquals('Updated description', $updatedDrinkType->getDescription());
|
||||
$this->assertEquals(20, $updatedDrinkType->getDesiredStock());
|
||||
|
||||
// Check that it was updated in the database
|
||||
$foundDrinkType = $this->service->getDrinkTypeByName('Cola');
|
||||
$this->assertEquals(20, $foundDrinkType->getDesiredStock());
|
||||
|
||||
// Update the name
|
||||
$updatedDrinkType = $this->service->updateDrinkType($drinkType, 'Pepsi', null, null);
|
||||
$this->assertEquals('Pepsi', $updatedDrinkType->getName());
|
||||
$this->assertEquals('Updated description', $updatedDrinkType->getDescription());
|
||||
$this->assertEquals(20, $updatedDrinkType->getDesiredStock());
|
||||
|
||||
// Check that it was updated in the database
|
||||
$foundDrinkType = $this->service->getDrinkTypeByName('Pepsi');
|
||||
$this->assertInstanceOf(DrinkType::class, $foundDrinkType);
|
||||
$this->assertEquals('Pepsi', $foundDrinkType->getName());
|
||||
$this->assertEquals('Updated description', $foundDrinkType->getDescription());
|
||||
$this->assertEquals(20, $foundDrinkType->getDesiredStock());
|
||||
|
||||
// Create another drink type
|
||||
$drinkType2 = $this->service->createDrinkType('Fanta', 'Orange soda', 5);
|
||||
|
||||
// Try to update the name to an existing name (should throw an exception)
|
||||
$this->expectException(InvalidArgumentException::class);
|
||||
$this->service->updateDrinkType($drinkType2, 'Pepsi', null, null);
|
||||
}
|
||||
|
||||
public function testDeleteDrinkType(): void
|
||||
{
|
||||
// Create a drink type
|
||||
$drinkType = $this->service->createDrinkType('Cola', 'Refreshing cola drink', 10);
|
||||
|
||||
// Check that it exists
|
||||
$this->assertInstanceOf(DrinkType::class, $this->service->getDrinkTypeByName('Cola'));
|
||||
|
||||
// Delete it
|
||||
$this->service->deleteDrinkType($drinkType);
|
||||
|
||||
// Check that it's gone
|
||||
$this->assertNull($this->service->getDrinkTypeByName('Cola'));
|
||||
}
|
||||
}
|
251
tests/Feature/Service/InventoryServiceTest.php
Normal file
251
tests/Feature/Service/InventoryServiceTest.php
Normal file
|
@ -0,0 +1,251 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Feature\Service;
|
||||
|
||||
use Override;
|
||||
use Tests\TestCase;
|
||||
use App\Entity\DrinkType;
|
||||
use App\Entity\InventoryRecord;
|
||||
use App\Service\InventoryService;
|
||||
use App\Service\ConfigurationService;
|
||||
use App\Enum\SystemSettingKey;
|
||||
use App\Repository\DrinkTypeRepository;
|
||||
use App\Repository\InventoryRecordRepository;
|
||||
use DateTimeImmutable;
|
||||
|
||||
class InventoryServiceTest extends TestCase
|
||||
{
|
||||
private InventoryService $service;
|
||||
private DrinkTypeRepository $drinkTypeRepository;
|
||||
private InventoryRecordRepository $inventoryRecordRepository;
|
||||
private ConfigurationService $configService;
|
||||
private DrinkType $drinkType;
|
||||
|
||||
#[Override]
|
||||
public function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
$this->setUpDB();
|
||||
$this->drinkTypeRepository = $this->container->get(DrinkTypeRepository::class);
|
||||
$this->inventoryRecordRepository = $this->container->get(InventoryRecordRepository::class);
|
||||
$this->configService = $this->container->get(ConfigurationService::class);
|
||||
$this->service = $this->container->get(InventoryService::class);
|
||||
|
||||
// Create a drink type for testing
|
||||
$this->drinkType = new DrinkType('Cola', 'Refreshing cola drink', 10);
|
||||
$this->drinkTypeRepository->save($this->drinkType);
|
||||
}
|
||||
|
||||
public function testGetAllInventoryRecords(): void
|
||||
{
|
||||
// Initially the repository should be empty
|
||||
$this->assertCount(0, $this->service->getAllInventoryRecords());
|
||||
|
||||
// Create some inventory records
|
||||
$record1 = new InventoryRecord($this->drinkType, 5, new DateTimeImmutable('2023-01-01'));
|
||||
$record2 = new InventoryRecord($this->drinkType, 10, new DateTimeImmutable('2023-01-02'));
|
||||
$this->inventoryRecordRepository->save($record1);
|
||||
$this->inventoryRecordRepository->save($record2);
|
||||
|
||||
// Now getAllInventoryRecords should return both records
|
||||
$records = $this->service->getAllInventoryRecords();
|
||||
$this->assertCount(2, $records);
|
||||
$this->assertContainsOnlyInstancesOf(InventoryRecord::class, $records);
|
||||
}
|
||||
|
||||
public function testGetInventoryRecordsByDrinkType(): void
|
||||
{
|
||||
// Create another drink type
|
||||
$anotherDrinkType = new DrinkType('Fanta', 'Orange soda', 5);
|
||||
$this->drinkTypeRepository->save($anotherDrinkType);
|
||||
|
||||
// Create inventory records for both drink types
|
||||
$record1 = new InventoryRecord($this->drinkType, 5, new DateTimeImmutable('2023-01-01'));
|
||||
$record2 = new InventoryRecord($this->drinkType, 10, new DateTimeImmutable('2023-01-02'));
|
||||
$record3 = new InventoryRecord($anotherDrinkType, 15, new DateTimeImmutable('2023-01-03'));
|
||||
$this->inventoryRecordRepository->save($record1);
|
||||
$this->inventoryRecordRepository->save($record2);
|
||||
$this->inventoryRecordRepository->save($record3);
|
||||
|
||||
// Get records for the first drink type
|
||||
$records = $this->service->getInventoryRecordsByDrinkType($this->drinkType);
|
||||
$this->assertCount(2, $records);
|
||||
$this->assertContainsOnlyInstancesOf(InventoryRecord::class, $records);
|
||||
|
||||
// Check that the records are for the correct drink type
|
||||
foreach ($records as $record) {
|
||||
$this->assertEquals($this->drinkType->getId(), $record->getDrinkType()->getId());
|
||||
}
|
||||
|
||||
// Get records for the second drink type
|
||||
$records = $this->service->getInventoryRecordsByDrinkType($anotherDrinkType);
|
||||
$this->assertCount(1, $records);
|
||||
$this->assertEquals($anotherDrinkType->getId(), $records[0]->getDrinkType()->getId());
|
||||
}
|
||||
|
||||
public function testGetLatestInventoryRecord(): void
|
||||
{
|
||||
// Create inventory records with different timestamps
|
||||
$record1 = new InventoryRecord($this->drinkType, 5, new DateTimeImmutable('2023-01-01'));
|
||||
$record2 = new InventoryRecord($this->drinkType, 10, new DateTimeImmutable('2023-01-02'));
|
||||
$record3 = new InventoryRecord($this->drinkType, 15, new DateTimeImmutable('2023-01-03'));
|
||||
$this->inventoryRecordRepository->save($record1);
|
||||
$this->inventoryRecordRepository->save($record2);
|
||||
$this->inventoryRecordRepository->save($record3);
|
||||
|
||||
// Get the latest record
|
||||
$latestRecord = $this->service->getLatestInventoryRecord($this->drinkType);
|
||||
$this->assertInstanceOf(InventoryRecord::class, $latestRecord);
|
||||
$this->assertEquals(15, $latestRecord->getQuantity());
|
||||
$this->assertEquals('2023-01-03', $latestRecord->getTimestamp()->format('Y-m-d'));
|
||||
}
|
||||
|
||||
public function testGetInventoryRecordsByTimeRange(): void
|
||||
{
|
||||
// Create inventory records with different timestamps
|
||||
$record1 = new InventoryRecord($this->drinkType, 5, new DateTimeImmutable('2023-01-01'));
|
||||
$record2 = new InventoryRecord($this->drinkType, 10, new DateTimeImmutable('2023-01-02'));
|
||||
$record3 = new InventoryRecord($this->drinkType, 15, new DateTimeImmutable('2023-01-03'));
|
||||
$record4 = new InventoryRecord($this->drinkType, 20, new DateTimeImmutable('2023-01-04'));
|
||||
$record5 = new InventoryRecord($this->drinkType, 25, new DateTimeImmutable('2023-01-05'));
|
||||
$this->inventoryRecordRepository->save($record1);
|
||||
$this->inventoryRecordRepository->save($record2);
|
||||
$this->inventoryRecordRepository->save($record3);
|
||||
$this->inventoryRecordRepository->save($record4);
|
||||
$this->inventoryRecordRepository->save($record5);
|
||||
|
||||
// Get records in a specific range
|
||||
$start = new DateTimeImmutable('2023-01-02');
|
||||
$end = new DateTimeImmutable('2023-01-04');
|
||||
$records = $this->service->getInventoryRecordsByTimeRange($start, $end);
|
||||
|
||||
$this->assertCount(3, $records);
|
||||
|
||||
// Check that the records are within the range
|
||||
foreach ($records as $record) {
|
||||
$timestamp = $record->getTimestamp();
|
||||
$this->assertTrue($timestamp >= $start && $timestamp <= $end);
|
||||
}
|
||||
}
|
||||
|
||||
public function testGetCurrentStockLevel(): void
|
||||
{
|
||||
// Initially there should be no stock
|
||||
$this->assertEquals(0, $this->service->getCurrentStockLevel($this->drinkType));
|
||||
|
||||
// Create inventory records with different timestamps
|
||||
$record1 = new InventoryRecord($this->drinkType, 5, new DateTimeImmutable('2023-01-01'));
|
||||
$record2 = new InventoryRecord($this->drinkType, 10, new DateTimeImmutable('2023-01-02'));
|
||||
$record3 = new InventoryRecord($this->drinkType, 15, new DateTimeImmutable('2023-01-03'));
|
||||
$this->inventoryRecordRepository->save($record1);
|
||||
$this->inventoryRecordRepository->save($record2);
|
||||
$this->inventoryRecordRepository->save($record3);
|
||||
|
||||
// The current stock level should be from the latest record
|
||||
$this->assertEquals(15, $this->service->getCurrentStockLevel($this->drinkType));
|
||||
}
|
||||
|
||||
public function testGetStockDifference(): void
|
||||
{
|
||||
// Create inventory records
|
||||
$record = new InventoryRecord($this->drinkType, 15, new DateTimeImmutable('2023-01-01'));
|
||||
$this->inventoryRecordRepository->save($record);
|
||||
|
||||
// The drink type has a desired stock of 10, and a current stock of 15
|
||||
// So the difference should be 5 (excess)
|
||||
$this->assertEquals(5, $this->service->getStockDifference($this->drinkType));
|
||||
|
||||
// Create a new record with less stock
|
||||
$record = new InventoryRecord($this->drinkType, 5, new DateTimeImmutable('2023-01-02'));
|
||||
$this->inventoryRecordRepository->save($record);
|
||||
|
||||
// Now the current stock is 5, and the desired stock is 10
|
||||
// So the difference should be -5 (shortage)
|
||||
$this->assertEquals(-5, $this->service->getStockDifference($this->drinkType));
|
||||
}
|
||||
|
||||
public function testUpdateStockLevel(): void
|
||||
{
|
||||
// Update the stock level with a specific timestamp
|
||||
$timestamp1 = new DateTimeImmutable('2023-01-01 10:00:00');
|
||||
$record = $this->service->updateStockLevel($this->drinkType, 15, $timestamp1);
|
||||
|
||||
// Check that the record was created correctly
|
||||
$this->assertInstanceOf(InventoryRecord::class, $record);
|
||||
$this->assertEquals($this->drinkType->getId(), $record->getDrinkType()->getId());
|
||||
$this->assertEquals(15, $record->getQuantity());
|
||||
$this->assertEquals($timestamp1, $record->getTimestamp());
|
||||
|
||||
// Check that the current stock level is updated
|
||||
$this->assertEquals(15, $this->service->getCurrentStockLevel($this->drinkType));
|
||||
|
||||
// Update the stock level again with a later timestamp
|
||||
$timestamp2 = new DateTimeImmutable('2023-01-02 10:00:00');
|
||||
$record = $this->service->updateStockLevel($this->drinkType, 20, $timestamp2);
|
||||
|
||||
// Check that the record was created correctly
|
||||
$this->assertEquals(20, $record->getQuantity());
|
||||
$this->assertEquals($timestamp2, $record->getTimestamp());
|
||||
|
||||
// Check that the current stock level is updated to the latest record's quantity
|
||||
$this->assertEquals(20, $this->service->getCurrentStockLevel($this->drinkType));
|
||||
}
|
||||
|
||||
public function testGetAllDrinkTypesWithStockLevels(): void
|
||||
{
|
||||
// Create another drink type
|
||||
$anotherDrinkType = new DrinkType('Fanta', 'Orange soda', 5);
|
||||
$this->drinkTypeRepository->save($anotherDrinkType);
|
||||
|
||||
// Create inventory records
|
||||
$record1 = new InventoryRecord($this->drinkType, 15, new DateTimeImmutable('2023-01-01'));
|
||||
$record2 = new InventoryRecord($anotherDrinkType, 3, new DateTimeImmutable('2023-01-01'));
|
||||
$this->inventoryRecordRepository->save($record1);
|
||||
$this->inventoryRecordRepository->save($record2);
|
||||
|
||||
// Get all drink types with stock levels
|
||||
$result = $this->service->getAllDrinkTypesWithStockLevels();
|
||||
|
||||
$this->assertCount(2, $result);
|
||||
|
||||
// Check the first drink type
|
||||
$this->assertEquals($this->drinkType->getId(), $result[0]['drinkType']->getId());
|
||||
$this->assertEquals(15, $result[0]['currentStock']);
|
||||
$this->assertEquals(10, $result[0]['desiredStock']);
|
||||
$this->assertEquals(5, $result[0]['difference']);
|
||||
|
||||
// Check the second drink type
|
||||
$this->assertEquals($anotherDrinkType->getId(), $result[1]['drinkType']->getId());
|
||||
$this->assertEquals(3, $result[1]['currentStock']);
|
||||
$this->assertEquals(5, $result[1]['desiredStock']);
|
||||
$this->assertEquals(-2, $result[1]['difference']);
|
||||
}
|
||||
|
||||
public function testGetLowStockDrinkTypes(): void
|
||||
{
|
||||
// Set the low stock threshold to 70% so that 3/5 (60%) is considered low stock
|
||||
$this->configService->setConfigValue(SystemSettingKey::LOW_STOCK_THRESHOLD->value, '70');
|
||||
|
||||
// Create another drink type
|
||||
$anotherDrinkType = new DrinkType('Fanta', 'Orange soda', 5);
|
||||
$this->drinkTypeRepository->save($anotherDrinkType);
|
||||
|
||||
// Create inventory records
|
||||
$record1 = new InventoryRecord($this->drinkType, 15, new DateTimeImmutable('2023-01-01'));
|
||||
$record2 = new InventoryRecord($anotherDrinkType, 3, new DateTimeImmutable('2023-01-01'));
|
||||
$this->inventoryRecordRepository->save($record1);
|
||||
$this->inventoryRecordRepository->save($record2);
|
||||
|
||||
// Get low stock drink types
|
||||
$result = $this->service->getLowStockDrinkTypes();
|
||||
|
||||
// Only the second drink type has low stock
|
||||
$this->assertCount(1, $result);
|
||||
$this->assertEquals($anotherDrinkType->getId(), $result[0]['drinkType']->getId());
|
||||
$this->assertEquals(3, $result[0]['currentStock']);
|
||||
$this->assertEquals(5, $result[0]['desiredStock']);
|
||||
$this->assertEquals(2, $result[0]['shortage']);
|
||||
}
|
||||
}
|
374
tests/Feature/Service/OrderServiceTest.php
Normal file
374
tests/Feature/Service/OrderServiceTest.php
Normal file
|
@ -0,0 +1,374 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Feature\Service;
|
||||
|
||||
use Override;
|
||||
use Tests\TestCase;
|
||||
use App\Entity\DrinkType;
|
||||
use App\Entity\Order;
|
||||
use App\Entity\OrderItem;
|
||||
use App\Service\OrderService;
|
||||
use App\Repository\DrinkTypeRepository;
|
||||
use App\Repository\OrderRepository;
|
||||
use App\Repository\OrderItemRepository;
|
||||
use App\Service\InventoryService;
|
||||
use App\Service\ConfigurationService;
|
||||
use App\Enum\SystemSettingKey;
|
||||
use DateTimeImmutable;
|
||||
use InvalidArgumentException;
|
||||
|
||||
class OrderServiceTest extends TestCase
|
||||
{
|
||||
private OrderService $service;
|
||||
private OrderRepository $orderRepository;
|
||||
private OrderItemRepository $orderItemRepository;
|
||||
private DrinkTypeRepository $drinkTypeRepository;
|
||||
private InventoryService $inventoryService;
|
||||
private ConfigurationService $configService;
|
||||
private DrinkType $drinkType;
|
||||
|
||||
#[Override]
|
||||
public function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
$this->setUpDB();
|
||||
$this->orderRepository = $this->container->get(OrderRepository::class);
|
||||
$this->orderItemRepository = $this->container->get(OrderItemRepository::class);
|
||||
$this->drinkTypeRepository = $this->container->get(DrinkTypeRepository::class);
|
||||
$this->inventoryService = $this->container->get(InventoryService::class);
|
||||
$this->configService = $this->container->get(ConfigurationService::class);
|
||||
$this->service = $this->container->get(OrderService::class);
|
||||
|
||||
// Create a drink type for testing
|
||||
$this->drinkType = new DrinkType('Cola', 'Refreshing cola drink', 10);
|
||||
$this->drinkTypeRepository->save($this->drinkType);
|
||||
}
|
||||
|
||||
public function testGetAllOrders(): void
|
||||
{
|
||||
// Initially the repository should be empty
|
||||
$this->assertCount(0, $this->service->getAllOrders());
|
||||
|
||||
// Create some orders
|
||||
$order1 = new Order(Order::STATUS_NEW, new DateTimeImmutable('2023-01-01'));
|
||||
$order2 = new Order(Order::STATUS_FULFILLED, new DateTimeImmutable('2023-01-02'));
|
||||
$this->orderRepository->save($order1);
|
||||
$this->orderRepository->save($order2);
|
||||
|
||||
// Now getAllOrders should return both orders
|
||||
$orders = $this->service->getAllOrders();
|
||||
$this->assertCount(2, $orders);
|
||||
$this->assertContainsOnlyInstancesOf(Order::class, $orders);
|
||||
}
|
||||
|
||||
public function testGetOrderById(): void
|
||||
{
|
||||
// Create an order
|
||||
$order = new Order(Order::STATUS_NEW, new DateTimeImmutable('2023-01-01'));
|
||||
$this->orderRepository->save($order);
|
||||
|
||||
// Get the ID
|
||||
$id = $order->getId();
|
||||
$this->assertNotNull($id);
|
||||
|
||||
// Find by ID
|
||||
$foundOrder = $this->service->getOrderById($id);
|
||||
$this->assertInstanceOf(Order::class, $foundOrder);
|
||||
$this->assertEquals(Order::STATUS_NEW, $foundOrder->getStatus());
|
||||
$this->assertEquals('2023-01-01', $foundOrder->getCreatedAt()->format('Y-m-d'));
|
||||
|
||||
// Try to find a non-existent ID
|
||||
$nonExistentId = 9999;
|
||||
$this->assertNull($this->service->getOrderById($nonExistentId));
|
||||
}
|
||||
|
||||
public function testGetOrdersByStatus(): void
|
||||
{
|
||||
// Create orders with different statuses
|
||||
$order1 = new Order(Order::STATUS_NEW, new DateTimeImmutable('2023-01-01'));
|
||||
$order2 = new Order(Order::STATUS_IN_WORK, new DateTimeImmutable('2023-01-02'));
|
||||
$order3 = new Order(Order::STATUS_ORDERED, new DateTimeImmutable('2023-01-03'));
|
||||
$order4 = new Order(Order::STATUS_FULFILLED, new DateTimeImmutable('2023-01-04'));
|
||||
$order5 = new Order(Order::STATUS_NEW, new DateTimeImmutable('2023-01-05'));
|
||||
$this->orderRepository->save($order1);
|
||||
$this->orderRepository->save($order2);
|
||||
$this->orderRepository->save($order3);
|
||||
$this->orderRepository->save($order4);
|
||||
$this->orderRepository->save($order5);
|
||||
|
||||
// Get orders by status
|
||||
$newOrders = $this->service->getOrdersByStatus(Order::STATUS_NEW);
|
||||
$this->assertCount(2, $newOrders);
|
||||
$this->assertContainsOnlyInstancesOf(Order::class, $newOrders);
|
||||
|
||||
// Check that the orders have the correct status
|
||||
foreach ($newOrders as $order) {
|
||||
$this->assertEquals(Order::STATUS_NEW, $order->getStatus());
|
||||
}
|
||||
|
||||
// Get orders by another status
|
||||
$fulfilledOrders = $this->service->getOrdersByStatus(Order::STATUS_FULFILLED);
|
||||
$this->assertCount(1, $fulfilledOrders);
|
||||
$this->assertEquals(Order::STATUS_FULFILLED, $fulfilledOrders[0]->getStatus());
|
||||
}
|
||||
|
||||
public function testGetOrdersByDateRange(): void
|
||||
{
|
||||
// Create orders with different dates
|
||||
$order1 = new Order(Order::STATUS_NEW, new DateTimeImmutable('2023-01-01'));
|
||||
$order2 = new Order(Order::STATUS_NEW, new DateTimeImmutable('2023-01-02'));
|
||||
$order3 = new Order(Order::STATUS_NEW, new DateTimeImmutable('2023-01-03'));
|
||||
$order4 = new Order(Order::STATUS_NEW, new DateTimeImmutable('2023-01-04'));
|
||||
$order5 = new Order(Order::STATUS_NEW, new DateTimeImmutable('2023-01-05'));
|
||||
$this->orderRepository->save($order1);
|
||||
$this->orderRepository->save($order2);
|
||||
$this->orderRepository->save($order3);
|
||||
$this->orderRepository->save($order4);
|
||||
$this->orderRepository->save($order5);
|
||||
|
||||
// Get orders in a specific date range
|
||||
$start = new DateTimeImmutable('2023-01-02');
|
||||
$end = new DateTimeImmutable('2023-01-04');
|
||||
$orders = $this->service->getOrdersByDateRange($start, $end);
|
||||
|
||||
$this->assertCount(3, $orders);
|
||||
|
||||
// Check that the orders are within the date range
|
||||
foreach ($orders as $order) {
|
||||
$createdAt = $order->getCreatedAt();
|
||||
$this->assertTrue($createdAt >= $start && $createdAt <= $end);
|
||||
}
|
||||
}
|
||||
|
||||
public function testCreateOrder(): void
|
||||
{
|
||||
// Create an order with items
|
||||
$items = [
|
||||
['drinkTypeId' => $this->drinkType->getId(), 'quantity' => 5],
|
||||
];
|
||||
$order = $this->service->createOrder($items);
|
||||
|
||||
// Check that the order was created correctly
|
||||
$this->assertInstanceOf(Order::class, $order);
|
||||
$this->assertEquals(Order::STATUS_NEW, $order->getStatus());
|
||||
|
||||
// Refresh the order from the database
|
||||
$refreshedOrder = $this->service->getOrderById($order->getId());
|
||||
$this->assertInstanceOf(Order::class, $refreshedOrder);
|
||||
|
||||
// Check that the order items were created correctly
|
||||
$orderItems = $refreshedOrder->getOrderItems();
|
||||
$this->assertCount(1, $orderItems);
|
||||
$orderItem = $orderItems->first();
|
||||
$this->assertEquals($this->drinkType->getId(), $orderItem->getDrinkType()->getId());
|
||||
$this->assertEquals(5, $orderItem->getQuantity());
|
||||
|
||||
// Try to create an order with an invalid drink type ID
|
||||
$this->expectException(InvalidArgumentException::class);
|
||||
$this->service->createOrder([['drinkTypeId' => 9999, 'quantity' => 5]]);
|
||||
}
|
||||
|
||||
public function testCreateOrderFromStockLevels(): void
|
||||
{
|
||||
// Set the low stock threshold to 70% so that 3/5 (60%) is considered low stock
|
||||
$this->configService->setConfigValue(SystemSettingKey::LOW_STOCK_THRESHOLD->value, '70');
|
||||
|
||||
// Create a drink type with low stock
|
||||
$drinkType = new DrinkType('Fanta', 'Orange soda', 5);
|
||||
$this->drinkTypeRepository->save($drinkType);
|
||||
|
||||
// Create inventory records
|
||||
$this->inventoryService->updateStockLevel($this->drinkType, 15); // Excess stock
|
||||
$this->inventoryService->updateStockLevel($drinkType, 3); // Low stock
|
||||
|
||||
// Create an order from stock levels
|
||||
$order = $this->service->createOrderFromStockLevels();
|
||||
|
||||
// Check that the order was created correctly
|
||||
$this->assertInstanceOf(Order::class, $order);
|
||||
$this->assertEquals(Order::STATUS_NEW, $order->getStatus());
|
||||
|
||||
// Refresh the order from the database
|
||||
$refreshedOrder = $this->service->getOrderById($order->getId());
|
||||
$this->assertInstanceOf(Order::class, $refreshedOrder);
|
||||
|
||||
// Check that the order items were created correctly
|
||||
$orderItems = $refreshedOrder->getOrderItems();
|
||||
$this->assertCount(1, $orderItems); // Only one drink type has low stock
|
||||
$orderItem = $orderItems->first();
|
||||
$this->assertEquals($drinkType->getId(), $orderItem->getDrinkType()->getId());
|
||||
$this->assertEquals(2, $orderItem->getQuantity()); // Shortage is 2
|
||||
}
|
||||
|
||||
public function testUpdateOrderStatus(): void
|
||||
{
|
||||
// Create an order
|
||||
$order = new Order(Order::STATUS_NEW);
|
||||
$this->orderRepository->save($order);
|
||||
|
||||
// Update the status
|
||||
$updatedOrder = $this->service->updateOrderStatus($order, Order::STATUS_IN_WORK);
|
||||
$this->assertEquals(Order::STATUS_IN_WORK, $updatedOrder->getStatus());
|
||||
|
||||
// Check that it was updated in the database
|
||||
$foundOrder = $this->service->getOrderById($order->getId());
|
||||
$this->assertEquals(Order::STATUS_IN_WORK, $foundOrder->getStatus());
|
||||
|
||||
// Update to fulfilled status
|
||||
$orderItem = new OrderItem($this->drinkType, 5, $foundOrder);
|
||||
$this->orderItemRepository->save($orderItem);
|
||||
|
||||
// Refresh the order from the database
|
||||
$refreshedOrder = $this->service->getOrderById($foundOrder->getId());
|
||||
$this->assertInstanceOf(Order::class, $refreshedOrder);
|
||||
$this->assertCount(1, $refreshedOrder->getOrderItems());
|
||||
|
||||
// Create an initial inventory record with a specific timestamp
|
||||
$timestamp1 = new DateTimeImmutable('2023-01-01 10:00:00');
|
||||
$this->inventoryService->updateStockLevel($this->drinkType, 10, $timestamp1);
|
||||
|
||||
// Update the status to fulfilled
|
||||
$updatedOrder = $this->service->updateOrderStatus($refreshedOrder, Order::STATUS_FULFILLED);
|
||||
$this->assertEquals(Order::STATUS_FULFILLED, $updatedOrder->getStatus());
|
||||
|
||||
// Check that the inventory was updated with a new record that has a later timestamp
|
||||
$currentStock = $this->inventoryService->getCurrentStockLevel($this->drinkType);
|
||||
$this->assertEquals(15, $currentStock); // 10 + 5 = 15
|
||||
|
||||
// Try to update to an invalid status
|
||||
$this->expectException(InvalidArgumentException::class);
|
||||
$this->service->updateOrderStatus($updatedOrder, 'invalid_status');
|
||||
}
|
||||
|
||||
public function testAddOrderItem(): void
|
||||
{
|
||||
// Create an order
|
||||
$order = new Order(Order::STATUS_NEW);
|
||||
$this->orderRepository->save($order);
|
||||
|
||||
// Add an item
|
||||
$orderItem = $this->service->addOrderItem($order, $this->drinkType, 5);
|
||||
|
||||
// Check that the item was added correctly
|
||||
$this->assertInstanceOf(OrderItem::class, $orderItem);
|
||||
$this->assertEquals($this->drinkType->getId(), $orderItem->getDrinkType()->getId());
|
||||
$this->assertEquals(5, $orderItem->getQuantity());
|
||||
$this->assertEquals($order->getId(), $orderItem->getOrder()->getId());
|
||||
|
||||
// Refresh the order from the database
|
||||
$refreshedOrder = $this->service->getOrderById($order->getId());
|
||||
$this->assertInstanceOf(Order::class, $refreshedOrder);
|
||||
|
||||
// Check that the item is in the order
|
||||
$orderItems = $refreshedOrder->getOrderItems();
|
||||
$this->assertCount(1, $orderItems);
|
||||
$this->assertEquals($orderItem->getId(), $orderItems->first()->getId());
|
||||
|
||||
// Add another item for the same drink type
|
||||
$orderItem2 = $this->service->addOrderItem($refreshedOrder, $this->drinkType, 3);
|
||||
|
||||
// Check that the quantity was updated
|
||||
$this->assertEquals(8, $orderItem2->getQuantity()); // 5 + 3 = 8
|
||||
|
||||
// Refresh the order again
|
||||
$refreshedOrder = $this->service->getOrderById($refreshedOrder->getId());
|
||||
|
||||
// Check that there's still only one item in the order
|
||||
$orderItems = $refreshedOrder->getOrderItems();
|
||||
$this->assertCount(1, $orderItems);
|
||||
|
||||
// Create another drink type
|
||||
$anotherDrinkType = new DrinkType('Fanta', 'Orange soda', 5);
|
||||
$this->drinkTypeRepository->save($anotherDrinkType);
|
||||
|
||||
// Add an item for the new drink type
|
||||
$orderItem3 = $this->service->addOrderItem($refreshedOrder, $anotherDrinkType, 2);
|
||||
|
||||
// Check that the item was added correctly
|
||||
$this->assertInstanceOf(OrderItem::class, $orderItem3);
|
||||
$this->assertEquals($anotherDrinkType->getId(), $orderItem3->getDrinkType()->getId());
|
||||
$this->assertEquals(2, $orderItem3->getQuantity());
|
||||
|
||||
// Refresh the order again
|
||||
$refreshedOrder = $this->service->getOrderById($refreshedOrder->getId());
|
||||
|
||||
// Check that there are now two items in the order
|
||||
$orderItems = $refreshedOrder->getOrderItems();
|
||||
$this->assertCount(2, $orderItems);
|
||||
|
||||
// Try to add an item to an order that's not in 'new' or 'in_work' status
|
||||
$refreshedOrder->setStatus(Order::STATUS_ORDERED);
|
||||
$this->orderRepository->save($refreshedOrder);
|
||||
|
||||
$this->expectException(InvalidArgumentException::class);
|
||||
$this->service->addOrderItem($refreshedOrder, $this->drinkType, 1);
|
||||
}
|
||||
|
||||
public function testRemoveOrderItem(): void
|
||||
{
|
||||
// Create an order
|
||||
$order = new Order(Order::STATUS_NEW);
|
||||
$this->orderRepository->save($order);
|
||||
|
||||
// Add an item
|
||||
$orderItem = $this->service->addOrderItem($order, $this->drinkType, 5);
|
||||
|
||||
// Refresh the order from the database
|
||||
$refreshedOrder = $this->service->getOrderById($order->getId());
|
||||
$this->assertInstanceOf(Order::class, $refreshedOrder);
|
||||
|
||||
// Check that the item is in the order
|
||||
$orderItems = $refreshedOrder->getOrderItems();
|
||||
$this->assertCount(1, $orderItems);
|
||||
$this->assertEquals($orderItem->getId(), $orderItems->first()->getId());
|
||||
|
||||
// Remove the item
|
||||
$this->service->removeOrderItem($refreshedOrder, $orderItem);
|
||||
|
||||
// Refresh the order again
|
||||
$refreshedOrder = $this->service->getOrderById($refreshedOrder->getId());
|
||||
|
||||
// Check that the item is gone
|
||||
$orderItems = $refreshedOrder->getOrderItems();
|
||||
$this->assertCount(0, $orderItems);
|
||||
|
||||
// Try to remove an item from an order that's not in 'new' or 'in_work' status
|
||||
$refreshedOrder->setStatus(Order::STATUS_ORDERED);
|
||||
$this->orderRepository->save($refreshedOrder);
|
||||
|
||||
$orderItem = new OrderItem($this->drinkType, 5, $refreshedOrder);
|
||||
$this->orderItemRepository->save($orderItem);
|
||||
|
||||
$this->expectException(InvalidArgumentException::class);
|
||||
$this->service->removeOrderItem($refreshedOrder, $orderItem);
|
||||
}
|
||||
|
||||
public function testDeleteOrder(): void
|
||||
{
|
||||
// Create an order
|
||||
$order = new Order(Order::STATUS_NEW);
|
||||
$this->orderRepository->save($order);
|
||||
|
||||
// Get the ID
|
||||
$id = $order->getId();
|
||||
$this->assertNotNull($id);
|
||||
|
||||
// Check that it exists
|
||||
$this->assertInstanceOf(Order::class, $this->service->getOrderById($id));
|
||||
|
||||
// Delete it
|
||||
$this->service->deleteOrder($order);
|
||||
|
||||
// Check that it's gone
|
||||
$this->assertNull($this->service->getOrderById($id));
|
||||
|
||||
// Try to delete an order that's not in 'new' status
|
||||
$order = new Order(Order::STATUS_IN_WORK);
|
||||
$this->orderRepository->save($order);
|
||||
|
||||
$this->expectException(InvalidArgumentException::class);
|
||||
$this->service->deleteOrder($order);
|
||||
}
|
||||
}
|
203
tests/Feature/Service/StockAdjustmentServiceTest.php
Normal file
203
tests/Feature/Service/StockAdjustmentServiceTest.php
Normal file
|
@ -0,0 +1,203 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Feature\Service;
|
||||
|
||||
use Override;
|
||||
use Tests\TestCase;
|
||||
use App\Entity\DrinkType;
|
||||
use App\Entity\InventoryRecord;
|
||||
use App\Entity\Order;
|
||||
use App\Entity\OrderItem;
|
||||
use App\Service\StockAdjustmentService;
|
||||
use App\Service\ConfigurationService;
|
||||
use App\Service\InventoryService;
|
||||
use App\Enum\SystemSettingKey;
|
||||
use App\Repository\DrinkTypeRepository;
|
||||
use App\Repository\InventoryRecordRepository;
|
||||
use App\Repository\OrderRepository;
|
||||
use App\Repository\OrderItemRepository;
|
||||
use DateTimeImmutable;
|
||||
|
||||
class StockAdjustmentServiceTest extends TestCase
|
||||
{
|
||||
private StockAdjustmentService $service;
|
||||
private DrinkTypeRepository $drinkTypeRepository;
|
||||
private InventoryRecordRepository $inventoryRecordRepository;
|
||||
private OrderRepository $orderRepository;
|
||||
private OrderItemRepository $orderItemRepository;
|
||||
private ConfigurationService $configService;
|
||||
private InventoryService $inventoryService;
|
||||
private DrinkType $drinkType;
|
||||
|
||||
#[Override]
|
||||
public function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
$this->setUpDB();
|
||||
$this->drinkTypeRepository = $this->container->get(DrinkTypeRepository::class);
|
||||
$this->inventoryRecordRepository = $this->container->get(InventoryRecordRepository::class);
|
||||
$this->orderRepository = $this->container->get(OrderRepository::class);
|
||||
$this->orderItemRepository = $this->container->get(OrderItemRepository::class);
|
||||
$this->configService = $this->container->get(ConfigurationService::class);
|
||||
$this->inventoryService = $this->container->get(InventoryService::class);
|
||||
$this->service = $this->container->get(StockAdjustmentService::class);
|
||||
|
||||
// Initialize default configs
|
||||
$this->configService->initializeDefaultConfigs();
|
||||
|
||||
// Create a drink type for testing
|
||||
$this->drinkType = new DrinkType('Cola', 'Refreshing cola drink', 10);
|
||||
$this->drinkTypeRepository->save($this->drinkType);
|
||||
}
|
||||
|
||||
public function testAdjustStockLevels(): void
|
||||
{
|
||||
// Create orders with different dates
|
||||
$order1 = new Order(Order::STATUS_FULFILLED, new DateTimeImmutable('2023-01-01'));
|
||||
$order2 = new Order(Order::STATUS_FULFILLED, new DateTimeImmutable('2023-01-02'));
|
||||
$order3 = new Order(Order::STATUS_FULFILLED, new DateTimeImmutable('2023-01-03'));
|
||||
$order4 = new Order(Order::STATUS_FULFILLED, new DateTimeImmutable('2023-01-04'));
|
||||
$order5 = new Order(Order::STATUS_FULFILLED, new DateTimeImmutable('2023-01-05'));
|
||||
$this->orderRepository->save($order1);
|
||||
$this->orderRepository->save($order2);
|
||||
$this->orderRepository->save($order3);
|
||||
$this->orderRepository->save($order4);
|
||||
$this->orderRepository->save($order5);
|
||||
|
||||
// Create order items for the drink type with different quantities
|
||||
$orderItem1 = new OrderItem($this->drinkType, 5, $order1);
|
||||
$orderItem2 = new OrderItem($this->drinkType, 10, $order2);
|
||||
$orderItem3 = new OrderItem($this->drinkType, 15, $order3);
|
||||
$orderItem4 = new OrderItem($this->drinkType, 20, $order4);
|
||||
$orderItem5 = new OrderItem($this->drinkType, 25, $order5);
|
||||
$this->orderItemRepository->save($orderItem1);
|
||||
$this->orderItemRepository->save($orderItem2);
|
||||
$this->orderItemRepository->save($orderItem3);
|
||||
$this->orderItemRepository->save($orderItem4);
|
||||
$this->orderItemRepository->save($orderItem5);
|
||||
|
||||
// Set the desired stock to a value that will trigger an adjustment
|
||||
$this->drinkType->setDesiredStock(5);
|
||||
$this->drinkTypeRepository->save($this->drinkType);
|
||||
|
||||
// Adjust stock levels
|
||||
$result = $this->service->adjustStockLevels();
|
||||
|
||||
// Check that the adjustment was made
|
||||
$this->assertCount(1, $result);
|
||||
$this->assertEquals($this->drinkType->getId(), $result[0]['drinkType']->getId());
|
||||
$this->assertEquals(5, $result[0]['oldDesiredStock']);
|
||||
|
||||
// The new desired stock should be higher than the old one
|
||||
$this->assertGreaterThan(5, $result[0]['newDesiredStock']);
|
||||
|
||||
// Check that the drink type was updated in the database
|
||||
$updatedDrinkType = $this->drinkTypeRepository->find($this->drinkType->getId());
|
||||
$this->assertEquals($result[0]['newDesiredStock'], $updatedDrinkType->getDesiredStock());
|
||||
}
|
||||
|
||||
public function testAdjustStockLevelsWithNoOrders(): void
|
||||
{
|
||||
// No orders, so no adjustment should be made
|
||||
$result = $this->service->adjustStockLevels();
|
||||
|
||||
// Check that no adjustment was made
|
||||
$this->assertCount(0, $result);
|
||||
|
||||
// Check that the drink type was not updated
|
||||
$updatedDrinkType = $this->drinkTypeRepository->find($this->drinkType->getId());
|
||||
$this->assertEquals(10, $updatedDrinkType->getDesiredStock());
|
||||
}
|
||||
|
||||
public function testAdjustStockLevelsWithSmallDifference(): void
|
||||
{
|
||||
// Create orders with different dates
|
||||
$order1 = new Order(Order::STATUS_FULFILLED, new DateTimeImmutable('2023-01-01'));
|
||||
$this->orderRepository->save($order1);
|
||||
|
||||
// Create order items for the drink type with a quantity close to the desired stock
|
||||
$orderItem1 = new OrderItem($this->drinkType, 11, $order1);
|
||||
$this->orderItemRepository->save($orderItem1);
|
||||
|
||||
// Set the threshold to a high value so that small differences don't trigger an adjustment
|
||||
$this->configService->setConfigValue(SystemSettingKey::STOCK_ADJUSTMENT_THRESHOLD->value, '0.5');
|
||||
|
||||
// Adjust stock levels
|
||||
$result = $this->service->adjustStockLevels();
|
||||
|
||||
// Check that no adjustment was made because the difference is too small
|
||||
$this->assertCount(0, $result);
|
||||
|
||||
// Check that the drink type was not updated
|
||||
$updatedDrinkType = $this->drinkTypeRepository->find($this->drinkType->getId());
|
||||
$this->assertEquals(10, $updatedDrinkType->getDesiredStock());
|
||||
}
|
||||
|
||||
public function testGetConsumptionHistory(): void
|
||||
{
|
||||
// Create inventory records with different timestamps
|
||||
$record1 = new InventoryRecord($this->drinkType, 20, new DateTimeImmutable('2023-01-01'));
|
||||
$record2 = new InventoryRecord($this->drinkType, 15, new DateTimeImmutable('2023-01-02')); // Consumption: 5
|
||||
$record3 = new InventoryRecord($this->drinkType, 10, new DateTimeImmutable('2023-01-03')); // Consumption: 5
|
||||
$record4 = new InventoryRecord($this->drinkType, 5, new DateTimeImmutable('2023-01-04')); // Consumption: 5
|
||||
$record5 = new InventoryRecord($this->drinkType, 15, new DateTimeImmutable('2023-01-05')); // No consumption (increase)
|
||||
$this->inventoryRecordRepository->save($record1);
|
||||
$this->inventoryRecordRepository->save($record2);
|
||||
$this->inventoryRecordRepository->save($record3);
|
||||
$this->inventoryRecordRepository->save($record4);
|
||||
$this->inventoryRecordRepository->save($record5);
|
||||
|
||||
// Get consumption history
|
||||
$start = new DateTimeImmutable('2023-01-01');
|
||||
$end = new DateTimeImmutable('2023-01-05');
|
||||
$history = $this->service->getConsumptionHistory($this->drinkType, $start, $end);
|
||||
|
||||
// Check that the history is correct
|
||||
$this->assertCount(3, $history); // 3 consumption events
|
||||
|
||||
// Check the first consumption event
|
||||
$this->assertEquals('2023-01-02', $history[0]['date']->format('Y-m-d'));
|
||||
$this->assertEquals(5, $history[0]['consumption']);
|
||||
|
||||
// Check the second consumption event
|
||||
$this->assertEquals('2023-01-03', $history[1]['date']->format('Y-m-d'));
|
||||
$this->assertEquals(5, $history[1]['consumption']);
|
||||
|
||||
// Check the third consumption event
|
||||
$this->assertEquals('2023-01-04', $history[2]['date']->format('Y-m-d'));
|
||||
$this->assertEquals(5, $history[2]['consumption']);
|
||||
}
|
||||
|
||||
public function testGetConsumptionHistoryWithDateRange(): void
|
||||
{
|
||||
// Create inventory records with different timestamps
|
||||
$record1 = new InventoryRecord($this->drinkType, 20, new DateTimeImmutable('2023-01-01'));
|
||||
$record2 = new InventoryRecord($this->drinkType, 15, new DateTimeImmutable('2023-01-02')); // Consumption: 5
|
||||
$record3 = new InventoryRecord($this->drinkType, 10, new DateTimeImmutable('2023-01-03')); // Consumption: 5
|
||||
$record4 = new InventoryRecord($this->drinkType, 5, new DateTimeImmutable('2023-01-04')); // Consumption: 5
|
||||
$record5 = new InventoryRecord($this->drinkType, 0, new DateTimeImmutable('2023-01-05')); // Consumption: 5
|
||||
$this->inventoryRecordRepository->save($record1);
|
||||
$this->inventoryRecordRepository->save($record2);
|
||||
$this->inventoryRecordRepository->save($record3);
|
||||
$this->inventoryRecordRepository->save($record4);
|
||||
$this->inventoryRecordRepository->save($record5);
|
||||
|
||||
// Get consumption history for a specific date range
|
||||
$start = new DateTimeImmutable('2023-01-02');
|
||||
$end = new DateTimeImmutable('2023-01-04');
|
||||
$history = $this->service->getConsumptionHistory($this->drinkType, $start, $end);
|
||||
|
||||
// Check that the history is correct
|
||||
$this->assertCount(2, $history); // 2 consumption events within the range
|
||||
|
||||
// Check the first consumption event
|
||||
$this->assertEquals('2023-01-03', $history[0]['date']->format('Y-m-d'));
|
||||
$this->assertEquals(5, $history[0]['consumption']);
|
||||
|
||||
// Check the second consumption event
|
||||
$this->assertEquals('2023-01-04', $history[1]['date']->format('Y-m-d'));
|
||||
$this->assertEquals(5, $history[1]['consumption']);
|
||||
}
|
||||
}
|
7
tests/Pest.php
Normal file
7
tests/Pest.php
Normal file
|
@ -0,0 +1,7 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Tests\TestCase;
|
||||
|
||||
pest()->extend(TestCase::class)->in('Feature');
|
35
tests/TestCase.php
Normal file
35
tests/TestCase.php
Normal file
|
@ -0,0 +1,35 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests;
|
||||
|
||||
use App\Settings;
|
||||
use DI\Bridge\Slim\Bridge;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Doctrine\ORM\Tools\SchemaTool;
|
||||
use PHPUnit\Framework\TestCase as BaseTestCase;
|
||||
use Psr\Container\ContainerInterface;
|
||||
use Slim\App;
|
||||
|
||||
abstract class TestCase extends BaseTestCase
|
||||
{
|
||||
public ContainerInterface $container;
|
||||
public App $app;
|
||||
public function setUp(): void
|
||||
{
|
||||
$this->container = (require __DIR__ . '/../config/container.php')(new Settings(true));
|
||||
$this->app = Bridge::create($this->container);
|
||||
}
|
||||
|
||||
public function setUpDB(): void
|
||||
{
|
||||
$entityManager = $this->container->get(EntityManagerInterface::class);
|
||||
|
||||
// Create schema from entities
|
||||
$metadata = $entityManager->getMetadataFactory()->getAllMetadata();
|
||||
$tool = new SchemaTool($entityManager);
|
||||
$tool->dropSchema($metadata);
|
||||
$tool->createSchema($metadata);
|
||||
}
|
||||
}
|
Loading…
Add table
Add a link
Reference in a new issue