From 776763585467f934cdadcb26bc2b17611bfeab1b Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 1 Sep 2026 17:05:30 +0000 Subject: [PATCH 1/4] Fix DB error when creating a new part lot via withdraw/move form target_id=new (sentinel for "create a new lot") was passed straight to EntityManager::find(PartLot::class, ...), which fails on PostgreSQL with "invalid input syntax for type integer" since the id column is an integer. Skip the lookup for the 'new' sentinel so the existing new-lot-creation branch in the "move" case can handle it as before. --- src/Controller/PartController.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Controller/PartController.php b/src/Controller/PartController.php index 5dbbe6761..b4b450088 100644 --- a/src/Controller/PartController.php +++ b/src/Controller/PartController.php @@ -704,7 +704,7 @@ public function withdrawAddHandler(Part $part, Request $request, EntityManagerIn //Try to determine the target lot (used for move actions), if the parameter is existing $targetId = $request->request->get('target_id', null); - $targetLot = $targetId ? $em->find(PartLot::class, $targetId) : null; + $targetLot = ($targetId && $targetId !== 'new') ? $em->find(PartLot::class, $targetId) : null; if ($targetLot && $targetLot->getPart() !== $part) { throw new \RuntimeException("The target partlot does not belong to the part!"); } From 30562301f2f15979abbb34921362bd113d2bb672 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 1 Sep 2026 17:19:24 +0000 Subject: [PATCH 2/4] Add regression test for moving stock to a newly created part lot Covers the withdraw/add/move form's "move to new lot" action (target_id=new), which previously crashed with a DBAL DriverException because the sentinel string was passed straight to EntityManager::find(PartLot::class, ...). --- tests/Controller/PartControllerTest.php | 75 +++++++++++++++++++++++++ 1 file changed, 75 insertions(+) diff --git a/tests/Controller/PartControllerTest.php b/tests/Controller/PartControllerTest.php index c2cacb2c6..c9e9d0409 100644 --- a/tests/Controller/PartControllerTest.php +++ b/tests/Controller/PartControllerTest.php @@ -28,6 +28,7 @@ use App\Entity\Parts\Footprint; use App\Entity\Parts\Manufacturer; use App\Entity\Parts\Part; +use App\Entity\Parts\PartLot; use App\Entity\Parts\StorageLocation; use App\Entity\Parts\Supplier; use App\Entity\ProjectSystem\Project; @@ -37,6 +38,7 @@ use PHPUnit\Framework\Attributes\Group; use Symfony\Bundle\FrameworkBundle\Test\WebTestCase; use Symfony\Component\HttpFoundation\Response; +use Symfony\Component\Security\Csrf\CsrfTokenManagerInterface; #[Group("slow")] #[Group("DB")] @@ -451,6 +453,79 @@ public function testMergePartsUsedInSameProjectSubmitsWithoutError(): void $entityManager->flush(); } + public function testWithdrawAddMoveToNewLotDoesNotThrow(): void + { + // Regression test for a bug where moving stock to a newly created lot (target_id=new) + // caused a DBAL exception, because "new" was passed straight to EntityManager::find() + // instead of being recognized as the "create a new lot" sentinel. + $client = static::createClient(); + $this->loginAsUser($client, 'admin'); + + $entityManager = $client->getContainer()->get('doctrine')->getManager(); + + $category = $entityManager->getRepository(Category::class)->find(1); + $storageLocation = $entityManager->getRepository(StorageLocation::class)->find(1); + if (!$category || !$storageLocation) { + $this->markTestSkipped('Required test data not found in fixtures'); + } + + // Create a part with a single lot that we can move stock away from + $part = new Part(); + $part->setName('Move to new lot test part'); + $part->setCategory($category); + + $sourceLot = new PartLot(); + $sourceLot->setAmount(10); + $sourceLot->setStorageLocation($storageLocation); + $part->addPartLot($sourceLot); + + $entityManager->persist($part); + $entityManager->flush(); + + $partId = $part->getId(); + $sourceLotId = $sourceLot->getId(); + + $csrfTokenManager = $client->getContainer()->get(CsrfTokenManagerInterface::class); + $token = $csrfTokenManager->getToken('part_withraw' . $partId)->getValue(); + + $client->request('POST', "/en/part/{$partId}/add_withdraw", [ + 'lot_id' => $sourceLotId, + 'target_id' => 'new', + 'amount' => '4', + 'action' => 'move', + 'part_lot' => [ + 'storage_location' => $storageLocation->getId(), + ], + '_csfr' => $token, + ]); + + // Must not crash with a DB exception (this used to be a 500 error) and instead redirect back to the part page + $this->assertResponseRedirects(); + + $entityManager = $client->getContainer()->get('doctrine')->getManager(); + $entityManager->clear(); + + $refreshedPart = $entityManager->getRepository(Part::class)->find($partId); + self::assertNotNull($refreshedPart); + self::assertCount(2, $refreshedPart->getPartLots(), 'A new part lot should have been created'); + + $lots = $refreshedPart->getPartLots(); + $originLot = $lots->filter(static fn (PartLot $lot) => $lot->getId() === $sourceLotId)->first(); + $newLot = $lots->filter(static fn (PartLot $lot) => $lot->getId() !== $sourceLotId)->first(); + + self::assertNotFalse($originLot); + self::assertNotFalse($newLot); + self::assertSame(6.0, $originLot->getAmount()); + self::assertSame(4.0, $newLot->getAmount()); + + // Clean up + foreach ($refreshedPart->getPartLots() as $lot) { + $entityManager->remove($lot); + } + $entityManager->remove($refreshedPart); + $entityManager->flush(); + } + public function testAccessControlForUnauthorizedUser(): void { $client = static::createClient(); From 8cb030af95e032e3047cb2d699a14d081b2d5155 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 1 Sep 2026 17:30:29 +0000 Subject: [PATCH 3/4] Fix SessionNotFoundException in new part lot regression test CsrfTokenManagerInterface::getToken() needs an active session, which doesn't exist until a request has gone through the client. Grab the CSRF token from the rendered withdraw/move form on the part page instead of asking the token manager directly before any request. --- tests/Controller/PartControllerTest.php | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/tests/Controller/PartControllerTest.php b/tests/Controller/PartControllerTest.php index c9e9d0409..d50b62913 100644 --- a/tests/Controller/PartControllerTest.php +++ b/tests/Controller/PartControllerTest.php @@ -38,7 +38,6 @@ use PHPUnit\Framework\Attributes\Group; use Symfony\Bundle\FrameworkBundle\Test\WebTestCase; use Symfony\Component\HttpFoundation\Response; -use Symfony\Component\Security\Csrf\CsrfTokenManagerInterface; #[Group("slow")] #[Group("DB")] @@ -485,8 +484,11 @@ public function testWithdrawAddMoveToNewLotDoesNotThrow(): void $partId = $part->getId(); $sourceLotId = $sourceLot->getId(); - $csrfTokenManager = $client->getContainer()->get(CsrfTokenManagerInterface::class); - $token = $csrfTokenManager->getToken('part_withraw' . $partId)->getValue(); + // Load the part page first, both to start a session (the CSRF token storage needs one) + // and to grab the real CSRF token the withdraw/move form would submit. + $crawler = $client->request('GET', "/en/part/{$partId}"); + $this->assertResponseStatusCodeSame(Response::HTTP_OK); + $token = (string) $crawler->filter('input[name="_csfr"]')->first()->attr('value'); $client->request('POST', "/en/part/{$partId}/add_withdraw", [ 'lot_id' => $sourceLotId, From 6a51fe36778e7b593dd4f49ff39485fc4565950c Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 1 Sep 2026 18:07:23 +0000 Subject: [PATCH 4/4] Send comment field in new part lot regression test PartStockChangedLogEntry::move() requires a string comment. The real withdraw/move form always submits this field (empty string by default), so the test needs to as well instead of omitting it, which produced a TypeError since Request::get() returns null for a missing key. --- tests/Controller/PartControllerTest.php | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tests/Controller/PartControllerTest.php b/tests/Controller/PartControllerTest.php index d50b62913..8a6206021 100644 --- a/tests/Controller/PartControllerTest.php +++ b/tests/Controller/PartControllerTest.php @@ -495,6 +495,8 @@ public function testWithdrawAddMoveToNewLotDoesNotThrow(): void 'target_id' => 'new', 'amount' => '4', 'action' => 'move', + //The real form always submits this field (even when empty), so mirror that here + 'comment' => '', 'part_lot' => [ 'storage_location' => $storageLocation->getId(), ],