get ready

This commit is contained in:
lubiana 2025-06-11 20:28:45 +02:00
parent ca9819a436
commit 6b0a478ca5
Signed by: lubiana
SSH key fingerprint: SHA256:vW1EA0fRR3Fw+dD/sM0K+x3Il2gSry6YRYHqOeQwrfk
16 changed files with 395 additions and 124 deletions

View file

@ -0,0 +1,51 @@
<?php
declare(strict_types=1);
namespace App\Controller;
use App\Form\BulkEditDrinkTypeStockForm;
use App\Repository\DrinkTypeRepository;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
#[Route('/drink-types')]
final class DrinkTypeBulkController extends AbstractController
{
#[Route('/bulk-edit-stock', name: 'app_drink_type_bulk_edit_stock')]
public function bulkEditStock(
Request $request,
DrinkTypeRepository $drinkTypeRepository,
EntityManagerInterface $entityManager
): Response {
$drinkTypes = $drinkTypeRepository->findAll();
$form = $this->createForm(BulkEditDrinkTypeStockForm::class, [
'drinkTypes' => $drinkTypes,
]);
$form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) {
$data = $form->getData();
foreach ($data['drinkTypes'] as $drinkType) {
$entityManager->persist($drinkType);
}
$entityManager->flush();
$this->addFlash('success', 'Wanted stock levels updated successfully!');
return $this->redirectToRoute('app_drink_type_bulk_edit_stock');
}
return $this->render('drink_type/bulk_edit_stock.html.twig', [
'form' => $form->createView(),
'drinkTypes' => $drinkTypes,
]);
}
}

View file

@ -8,10 +8,8 @@ use App\Entity\DrinkType;
use App\Form\DrinkTypeForm; use App\Form\DrinkTypeForm;
use App\Form\DrinkTypeFormCurrentStockForm; use App\Form\DrinkTypeFormCurrentStockForm;
use App\Repository\DrinkTypeRepository; use App\Repository\DrinkTypeRepository;
use App\Repository\PropertyChangeLogRepository;
use App\Service\DrinkType\GetStockHistory; use App\Service\DrinkType\GetStockHistory;
use App\Service\DrinkType\GetWantedHistory; use App\Service\DrinkType\GetWantedHistory;
use App\Service\InventoryService;
use Doctrine\ORM\EntityManagerInterface; use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Request;
@ -54,8 +52,7 @@ final class DrinkTypeController extends AbstractController
DrinkType $drinkType, DrinkType $drinkType,
GetStockHistory $getStockHistory, GetStockHistory $getStockHistory,
GetWantedHistory $getWantedHistory, GetWantedHistory $getWantedHistory,
): Response ): Response {
{
// Get orders that contain this drink type // Get orders that contain this drink type
$orderItems = $drinkType->getOrderItems(); $orderItems = $drinkType->getOrderItems();

View file

@ -9,6 +9,7 @@ use DateTimeImmutable;
use Doctrine\Common\Collections\ArrayCollection; use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection; use Doctrine\Common\Collections\Collection;
use Doctrine\ORM\Mapping as ORM; use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Validator\Constraints as Assert;
#[ORM\Entity(repositoryClass: DrinkTypeRepository::class)] #[ORM\Entity(repositoryClass: DrinkTypeRepository::class)]
#[ORM\Table(name: 'drink_type')] #[ORM\Table(name: 'drink_type')]
@ -33,6 +34,7 @@ class DrinkType
#[ORM\Column(type: 'text', nullable: true)] #[ORM\Column(type: 'text', nullable: true)]
private null|string $description = null; private null|string $description = null;
#[ORM\Column(type: 'integer')] #[ORM\Column(type: 'integer')]
#[Assert\GreaterThanOrEqual(0, message: 'Wanted stock must not be negative.')]
private int $wantedStock = 10; private int $wantedStock = 10;
#[ORM\Column(type: 'integer')] #[ORM\Column(type: 'integer')]

View file

@ -0,0 +1,41 @@
<?php
declare(strict_types=1);
namespace App\Form;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\CollectionType;
use Symfony\Component\Form\Extension\Core\Type\SubmitType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
class BulkEditDrinkTypeStockForm extends AbstractType
{
public function buildForm(FormBuilderInterface $builder, array $options): void
{
$builder
->add('drinkTypes', CollectionType::class, [
'entry_type' => DrinkTypeStockEditType::class,
'entry_options' => [
'label' => false,
],
'allow_add' => false,
'allow_delete' => false,
'by_reference' => false,
])
->add('save', SubmitType::class, [
'label' => 'Update Wanted Stock Levels',
'attr' => [
'class' => 'btn btn-primary',
],
]);
}
public function configureOptions(OptionsResolver $resolver): void
{
$resolver->setDefaults([
'data_class' => null,
]);
}
}

View file

@ -0,0 +1,45 @@
<?php
declare(strict_types=1);
namespace App\Form;
use App\Entity\DrinkType;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\HiddenType;
use Symfony\Component\Form\Extension\Core\Type\NumberType;
use Symfony\Component\Form\Extension\Core\Type\TextType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
class DrinkTypeStockEditType extends AbstractType
{
public function buildForm(FormBuilderInterface $builder, array $options): void
{
$builder
->add('id', HiddenType::class, [
'mapped' => false,
])
->add('name', TextType::class, [
'disabled' => true,
'attr' => [
'readonly' => true,
],
])
->add('wantedStock', NumberType::class, [
'label' => 'Wanted Stock',
'attr' => [
'min' => 0,
'step' => 1,
'class' => 'form-control',
],
]);
}
public function configureOptions(OptionsResolver $resolver): void
{
$resolver->setDefaults([
'data_class' => DrinkType::class,
]);
}
}

View file

@ -19,7 +19,7 @@ final readonly class CreateNewOrderItems
public function __invoke(Order $order): void public function __invoke(Order $order): void
{ {
new ArrayCollection($this->drinkTypeRepository->findWanted())->forAll( new ArrayCollection($this->drinkTypeRepository->findWanted())->forAll(
function (int $index, DrinkType $drinkType) use ($order) { function (int $index, DrinkType $drinkType) use ($order): true {
$quantity = $drinkType->getWantedStock() - $drinkType->getCurrentStock(); $quantity = $drinkType->getWantedStock() - $drinkType->getCurrentStock();
if ($quantity > 0) { if ($quantity > 0) {
$order->addOrderItem( $order->addOrderItem(

View file

@ -0,0 +1,49 @@
{% extends 'base.html.twig' %}
{% block title %}Bulk Edit Drink Type Wanted Stock{% endblock %}
{% block body %}
<div class="container mt-4">
<h1>Bulk Edit Drink Type Wanted Stock</h1>
{% for message in app.flashes('success') %}
<div class="alert alert-success alert-dismissible fade show" role="alert">
{{ message }}
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
</div>
{% endfor %}
{{ form_start(form) }}
<div class="table-responsive">
<table class="table table-striped">
<thead>
<tr>
<th>Drink Type</th>
<th>Wanted Stock</th>
</tr>
</thead>
<tbody>
{% for drinkTypeForm in form.drinkTypes %}
<tr>
<td>
{{ form_widget(drinkTypeForm.id) }}
{{ form_widget(drinkTypeForm.name) }}
</td>
<td>
{{ form_widget(drinkTypeForm.wantedStock) }}
{{ form_errors(drinkTypeForm.wantedStock) }}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
<div class="mt-3">
{{ form_widget(form.save) }}
</div>
{{ form_end(form) }}
</div>
{% endblock %}

View file

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

View file

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

View file

@ -1,7 +0,0 @@
{{ form_start(form, {'attr': {'hx-post': form.vars.action, 'hx-target': '#htmxModalBody'}}) }}
{{ form_widget(form) }}
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Close</button>
<button type="submit" class="btn btn-primary">{{ button_label|default('Save') }}</button>
</div>
{{ form_end(form) }}

View file

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

View file

@ -1,41 +0,0 @@
{% extends 'base.html.twig' %}
{% block title %}InventoryRecord index{% endblock %}
{% block body %}
<h1>InventoryRecord index</h1>
<table class="table">
<thead>
<tr>
<th>Id</th>
<th>Quantity</th>
<th>Timestamp</th>
<th>CreatedAt</th>
<th>UpdatedAt</th>
<th>actions</th>
</tr>
</thead>
<tbody>
{% for inventory_record in inventory_records %}
<tr>
<td>{{ inventory_record.id }}</td>
<td>{{ inventory_record.quantity }}</td>
<td>{{ inventory_record.timestamp ? inventory_record.timestamp|date('Y-m-d H:i:s') : '' }}</td>
<td>{{ inventory_record.createdAt ? inventory_record.createdAt|date('Y-m-d H:i:s') : '' }}</td>
<td>{{ inventory_record.updatedAt ? inventory_record.updatedAt|date('Y-m-d H:i:s') : '' }}</td>
<td>
<a href="{{ path('app_inventory_record_show', {'id': inventory_record.id}) }}">show</a>
<a href="{{ path('app_inventory_record_edit', {'id': inventory_record.id}) }}">edit</a>
</td>
</tr>
{% else %}
<tr>
<td colspan="6">no records found</td>
</tr>
{% endfor %}
</tbody>
</table>
<a href="{{ path('app_inventory_record_new') }}">Create new</a>
{% endblock %}

View file

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

View file

@ -1,38 +0,0 @@
{% extends 'base.html.twig' %}
{% block title %}InventoryRecord{% endblock %}
{% block body %}
<h1>InventoryRecord</h1>
<table class="table">
<tbody>
<tr>
<th>Id</th>
<td>{{ inventory_record.id }}</td>
</tr>
<tr>
<th>Quantity</th>
<td>{{ inventory_record.quantity }}</td>
</tr>
<tr>
<th>Timestamp</th>
<td>{{ inventory_record.timestamp ? inventory_record.timestamp|date('Y-m-d H:i:s') : '' }}</td>
</tr>
<tr>
<th>CreatedAt</th>
<td>{{ inventory_record.createdAt ? inventory_record.createdAt|date('Y-m-d H:i:s') : '' }}</td>
</tr>
<tr>
<th>UpdatedAt</th>
<td>{{ inventory_record.updatedAt ? inventory_record.updatedAt|date('Y-m-d H:i:s') : '' }}</td>
</tr>
</tbody>
</table>
<a href="{{ path('app_inventory_record_index') }}">back to list</a>
<a href="{{ path('app_inventory_record_edit', {'id': inventory_record.id}) }}">edit</a>
{{ include('inventory_record/_delete_form.html.twig') }}
{% endblock %}

View file

@ -84,7 +84,7 @@ test('CreateNewOrderItems adds order items for wanted drink types', function ():
expect($orderItem2->getQuantity())->toBe(6); // 8 wanted - 2 current = 6 to order expect($orderItem2->getQuantity())->toBe(6); // 8 wanted - 2 current = 6 to order
// Verify that drink types 3 and 4 don't have order items // Verify that drink types 3 and 4 don't have order items
$drinkType3Names = array_map(fn($item) => $item->getDrinkType()->getName(), $orderItems->toArray()); $drinkType3Names = array_map(fn($item): string => $item->getDrinkType()->getName(), $orderItems->toArray());
expect($drinkType3Names)->not->toContain('Drink Type 3'); expect($drinkType3Names)->not->toContain('Drink Type 3');
expect($drinkType3Names)->not->toContain('Drink Type 4'); expect($drinkType3Names)->not->toContain('Drink Type 4');
}); });

View file

@ -0,0 +1,204 @@
<?php
declare(strict_types=1);
use App\Entity\DrinkType;
use Doctrine\ORM\EntityManagerInterface;
test('Bulk Edit Form displays correctly with drink types', function (): void {
$this->ensureKernelShutdown();
$client = static::createClient();
// Create test drink types
$em = $this->getContainer()->get(EntityManagerInterface::class);
$drinkType1 = new DrinkType();
$drinkType1->setName('Cola');
$drinkType1->setWantedStock(10);
$drinkType1->setCurrentStock(5);
$drinkType2 = new DrinkType();
$drinkType2->setName('Beer');
$drinkType2->setWantedStock(20);
$drinkType2->setCurrentStock(15);
$em->persist($drinkType1);
$em->persist($drinkType2);
$em->flush();
// Request the bulk edit page
$crawler = $client->request('GET', '/drink-types/bulk-edit-stock');
// Validate successful response
$this->assertResponseIsSuccessful();
// Check page title
$this->assertSelectorTextContains('h1', 'Bulk Edit Drink Type Wanted Stock');
// Check that drink types are displayed in the table (they're in the value attribute of disabled inputs)
$this->assertSelectorExists('input[value="Cola"]');
$this->assertSelectorExists('input[value="Beer"]');
// Check that the form has the correct submit button
$this->assertSelectorTextContains('button[type="submit"]', 'Update Wanted Stock Levels');
// Check that input fields exist for each drink type
$this->assertCount(2, $crawler->filter('input[name*="[wantedStock]"]'));
});
test('Bulk Edit Form submission updates drink type wanted stock levels', function (): void {
$this->ensureKernelShutdown();
$client = static::createClient();
// Create test drink types
$em = $this->getContainer()->get(EntityManagerInterface::class);
$drinkType1 = new DrinkType();
$drinkType1->setName('Cola');
$drinkType1->setWantedStock(10);
$drinkType1->setCurrentStock(5);
$drinkType2 = new DrinkType();
$drinkType2->setName('Beer');
$drinkType2->setWantedStock(20);
$drinkType2->setCurrentStock(15);
$em->persist($drinkType1);
$em->persist($drinkType2);
$em->flush();
// Get the bulk edit page to get the form
$crawler = $client->request('GET', '/drink-types/bulk-edit-stock');
// Submit the form with updated values
$form = $crawler->selectButton('Update Wanted Stock Levels')->form();
// Update the wanted stock values - note: drink types are ordered by wantedStock DESC
// So Beer (wantedStock 20) comes first [0], Cola (wantedStock 10) comes second [1]
$form['bulk_edit_drink_type_stock_form[drinkTypes][0][wantedStock]'] = 25; // Beer
$form['bulk_edit_drink_type_stock_form[drinkTypes][1][wantedStock]'] = 15; // Cola
$client->submit($form);
// Check that we're redirected back to the same page
$this->assertResponseRedirects('/drink-types/bulk-edit-stock');
// Follow the redirect
$client->followRedirect();
// Check for success message
$this->assertSelectorTextContains('.alert-success', 'Wanted stock levels updated successfully!');
// Verify the database was updated
$em->clear(); // Clear entity manager to reload from database
$updatedDrinkType1 = $em->find(DrinkType::class, $drinkType1->getId());
$updatedDrinkType2 = $em->find(DrinkType::class, $drinkType2->getId());
expect($updatedDrinkType1->getWantedStock())->toBe(15); // Cola
expect($updatedDrinkType2->getWantedStock())->toBe(25); // Beer
});
test('Bulk Edit Form handles empty drink types list', function (): void {
$this->ensureKernelShutdown();
$client = static::createClient();
// Clear existing drink types
$em = $this->getContainer()->get(EntityManagerInterface::class);
$drinkTypeRepository = $em->getRepository(DrinkType::class);
$existingDrinkTypes = $drinkTypeRepository->findAll();
foreach ($existingDrinkTypes as $drinkType) {
$em->remove($drinkType);
}
$em->flush();
// Request the bulk edit page
$crawler = $client->request('GET', '/drink-types/bulk-edit-stock');
// Validate successful response
$this->assertResponseIsSuccessful();
// Check page title
$this->assertSelectorTextContains('h1', 'Bulk Edit Drink Type Wanted Stock');
// Check that the table exists but has no rows (no tbody content)
$this->assertSelectorExists('table');
$this->assertCount(0, $crawler->filter('table tbody tr'));
// Check that the form still has the submit button
$this->assertSelectorTextContains('button[type="submit"]', 'Update Wanted Stock Levels');
});
test('Bulk Edit Form rejects negative values (validation)', function (): void {
$this->ensureKernelShutdown();
$client = static::createClient();
// Create test drink type
$em = $this->getContainer()->get(EntityManagerInterface::class);
$drinkType = new DrinkType();
$drinkType->setName('Test Drink');
$drinkType->setWantedStock(10);
$drinkType->setCurrentStock(5);
$em->persist($drinkType);
$em->flush();
// Get the bulk edit page
$crawler = $client->request('GET', '/drink-types/bulk-edit-stock');
// Submit the form with negative value
$form = $crawler->selectButton('Update Wanted Stock Levels')->form();
$form['bulk_edit_drink_type_stock_form[drinkTypes][0][wantedStock]'] = -5;
$client->submit($form);
// The form should NOT redirect, but show the validation error
$this->assertResponseIsSuccessful();
$this->assertSelectorTextContains('h1', 'Bulk Edit Drink Type Wanted Stock');
$this->assertSelectorTextContains('.form-error-message, .invalid-feedback', 'Wanted stock must not be negative');
// Verify the database was not updated
$em->clear();
$updatedDrinkType = $em->find(DrinkType::class, $drinkType->getId());
expect($updatedDrinkType->getWantedStock())->toBe(10); // Should remain unchanged
});
test('Bulk Edit Form preserves drink type names as read-only', function (): void {
$this->ensureKernelShutdown();
$client = static::createClient();
// Create test drink type
$em = $this->getContainer()->get(EntityManagerInterface::class);
$drinkType = new DrinkType();
$drinkType->setName('Original Name');
$drinkType->setWantedStock(10);
$drinkType->setCurrentStock(5);
$em->persist($drinkType);
$em->flush();
// Get the bulk edit page
$crawler = $client->request('GET', '/drink-types/bulk-edit-stock');
// Check that the name field is read-only
$nameInput = $crawler->filter('input[name*="[name]"]')->first();
expect($nameInput->attr('readonly'))->toBe('readonly');
expect($nameInput->attr('disabled'))->toBe('disabled');
// Submit the form
$form = $crawler->selectButton('Update Wanted Stock Levels')->form();
$form['bulk_edit_drink_type_stock_form[drinkTypes][0][wantedStock]'] = 15;
$client->submit($form);
// Follow the redirect
$client->followRedirect();
// Verify the name was not changed
$em->clear();
$updatedDrinkType = $em->find(DrinkType::class, $drinkType->getId());
expect($updatedDrinkType->getName())->toBe('Original Name');
expect($updatedDrinkType->getWantedStock())->toBe(15);
});