diff --git a/config/packages/oneup_uploader.yaml b/config/packages/oneup_uploader.yaml index 984c6e7..5535c8f 100644 --- a/config/packages/oneup_uploader.yaml +++ b/config/packages/oneup_uploader.yaml @@ -36,6 +36,15 @@ oneup_uploader: namer: app.upload_namer storage: directory: '%kernel.project_dir%/uploads/driver_license' + # Belege backing a Honorarnote. The directory has to be named exactly after + # Upload::TYPE_RECEIPT: UploadHandler::getUploadFilepath() interpolates the type into + # uploads/// rather than looking the mapping up. + receipt: + frontend: dropzone + use_orphanage: true + namer: app.upload_namer + storage: + directory: '%kernel.project_dir%/uploads/receipt' chunks: maxage: 86400 storage: diff --git a/src/Controller/Admin/System/Contact/CreateController.php b/src/Controller/Admin/System/Contact/CreateController.php index d4b1a30..921c09c 100644 --- a/src/Controller/Admin/System/Contact/CreateController.php +++ b/src/Controller/Admin/System/Contact/CreateController.php @@ -37,13 +37,13 @@ class CreateController extends AbstractController /** @var User $user */ $user = $this->getUser(); - $photo = Upload::fromUploadDto($uploadSession->getUploads()->first(), $user, Upload::TYPE_PHOTO); + $photo = Upload::fromUploadDto($uploadSession->getUploadsByType(Upload::TYPE_PHOTO)->first(), $user, Upload::TYPE_PHOTO); $contact->setPhoto($photo); $this->entityManager->flush(); $this->uploadHandler->moveUploadSessionFilesFromOrphanage(Upload::TYPE_PHOTO, $uploadSession); - $this->uploadHandler->destroyUploadSession(); + $this->uploadHandler->removeUploadSessionUploadsByType(Upload::TYPE_PHOTO); } $form = $this->createForm(ContactType::class, $contact, ['upload_session' => $uploadSession]); diff --git a/src/Controller/Admin/System/Contact/EditController.php b/src/Controller/Admin/System/Contact/EditController.php index 687f451..c1abe3b 100644 --- a/src/Controller/Admin/System/Contact/EditController.php +++ b/src/Controller/Admin/System/Contact/EditController.php @@ -34,14 +34,14 @@ class EditController extends AbstractController if (true === $request->isMethod('POST') && 0 < $uploadSession->getCount()) { /** @var User $user */ $user = $this->getUser(); - $photo = Upload::fromUploadDto($uploadSession->getUploads()->first(), $user, Upload::TYPE_PHOTO); + $photo = Upload::fromUploadDto($uploadSession->getUploadsByType(Upload::TYPE_PHOTO)->first(), $user, Upload::TYPE_PHOTO); if (null !== $existingPhoto = $contact->getPhoto()) { $this->entityManager->remove($existingPhoto); } $contact->setPhoto($photo); $this->entityManager->flush(); $this->uploadHandler->moveUploadSessionFilesFromOrphanage(Upload::TYPE_PHOTO, $uploadSession); - $this->uploadHandler->destroyUploadSession(); + $this->uploadHandler->removeUploadSessionUploadsByType(Upload::TYPE_PHOTO); } $form = $this->createForm(ContactType::class, $contact, ['upload_session' => $uploadSession]); diff --git a/src/Controller/Administrative/Disposition/DocumentUploadController.php b/src/Controller/Administrative/Disposition/DocumentUploadController.php index ca46724..a80e238 100644 --- a/src/Controller/Administrative/Disposition/DocumentUploadController.php +++ b/src/Controller/Administrative/Disposition/DocumentUploadController.php @@ -89,7 +89,7 @@ class DocumentUploadController extends AbstractController $uploadSession = $this->uploadHandler->getUploadSession(); $upload = Upload::fromUploadDto( - $uploadSession->getUploads()->first(), + $uploadSession->getUploadsByType($type)->first(), $teamer->getUser(), $type ); @@ -113,7 +113,7 @@ class DocumentUploadController extends AbstractController $this->entityManager->flush(); $this->uploadHandler->moveUploadSessionFilesFromOrphanage($type, $uploadSession); - $this->uploadHandler->destroyUploadSession(); + $this->uploadHandler->removeUploadSessionUploadsByType($type); if (true === $this->featureManager->isActive('sanitize_uploads')) { $this->uploadHandler->ensurePdf($upload); diff --git a/src/Controller/Administrative/Document/CheckController.php b/src/Controller/Administrative/Document/CheckController.php index efe0ce6..c846d4e 100644 --- a/src/Controller/Administrative/Document/CheckController.php +++ b/src/Controller/Administrative/Document/CheckController.php @@ -94,6 +94,9 @@ class CheckController extends AbstractController return $this->render('administrative/document/modal_check.html.twig', [ 'document' => $document, 'form' => $form->createView(), + 'receipts' => Upload::TYPE_INVOICE === $document->getType() + ? $document->getDisposition()?->getDocumentsByType(Upload::TYPE_RECEIPT) + : null, ]); } @@ -117,6 +120,25 @@ class CheckController extends AbstractController $this->entityManager->flush(); } + private function removeReceipts(Upload $document): void + { + $receipts = $document->getDisposition()?->getDocumentsByType(Upload::TYPE_RECEIPT); + + if (null === $receipts || 0 === $receipts->count()) { + return; + } + + foreach ($receipts as $receipt) { + $this->entityManager->remove($receipt); + } + + $this->logger->info('Delete receipts of accepted invoice', [ + 'document_id' => $document->getId(), + 'disposition_id' => $document->getDisposition()->getId(), + 'count' => $receipts->count(), + ]); + } + private function confirmDocument(Upload $document, string $transition, string $status, DocumentCheckDto $formData): void { /** @var User $user */ @@ -147,6 +169,14 @@ class CheckController extends AbstractController if (true === $this->workflow->can($document->getDisposition(), $transition)) { $this->workflow->apply($document->getDisposition(), $transition); + + // Belege only have to survive until the Honorarnote is accepted. Removing them here + // and not before the guard means a blocked confirmation - which still persists the + // new document status below - cannot destroy the evidence for an invoice that was in + // fact not approved. DeleteUploadListener removes the files from disk on preRemove. + if (Upload::TYPE_INVOICE === $document->getType() && Upload::STATUS_PAID === $status) { + $this->removeReceipts($document); + } } else { $blockers = $this->workflow->buildTransitionBlockerList($document->getDisposition(), $transition); foreach ($blockers as $blocker) { diff --git a/src/Controller/Administrative/System/Document/ReplaceController.php b/src/Controller/Administrative/System/Document/ReplaceController.php index af0d673..756b878 100644 --- a/src/Controller/Administrative/System/Document/ReplaceController.php +++ b/src/Controller/Administrative/System/Document/ReplaceController.php @@ -32,10 +32,10 @@ class ReplaceController extends AbstractController if (true === $request->isMethod('POST') && 0 < $uploadSession->getCount()) { /** @var User $user */ $user = $this->getUser(); - Upload::fromUploadDto($uploadSession->getUploads()->first(), $user, Upload::TYPE_DOCUMENT, $upload); + Upload::fromUploadDto($uploadSession->getUploadsByType(Upload::TYPE_DOCUMENT)->first(), $user, Upload::TYPE_DOCUMENT, $upload); $this->entityManager->flush(); $this->uploadHandler->moveUploadSessionFilesFromOrphanage(Upload::TYPE_DOCUMENT, $uploadSession); - $this->uploadHandler->destroyUploadSession(); + $this->uploadHandler->removeUploadSessionUploadsByType(Upload::TYPE_DOCUMENT); } $filenameBefore = $upload->getOriginalFilename(); diff --git a/src/Controller/Administrative/System/Document/UploadController.php b/src/Controller/Administrative/System/Document/UploadController.php index 6ee43d8..c037e04 100644 --- a/src/Controller/Administrative/System/Document/UploadController.php +++ b/src/Controller/Administrative/System/Document/UploadController.php @@ -39,14 +39,14 @@ class UploadController extends AbstractController if (true === $request->isMethod('POST') && 0 < $uploadSession->getCount()) { /** @var User $user */ $user = $this->getUser(); - foreach ($uploadSession->getUploads() as $upload) { + foreach ($uploadSession->getUploadsByType(Upload::TYPE_DOCUMENT) as $upload) { $document = Upload::fromUploadDto($upload, $user, Upload::TYPE_DOCUMENT); $uploadedDocuments[] = $document; $this->entityManager->persist($document); } $this->entityManager->flush(); $this->uploadHandler->moveUploadSessionFilesFromOrphanage(Upload::TYPE_DOCUMENT, $uploadSession); - $this->uploadHandler->destroyUploadSession(); + $this->uploadHandler->removeUploadSessionUploadsByType(Upload::TYPE_DOCUMENT); } if ($form->isSubmitted() && $form->isValid()) { diff --git a/src/Controller/Teamer/Check/DriverLicenseController.php b/src/Controller/Teamer/Check/DriverLicenseController.php index 5bc9202..286118a 100644 --- a/src/Controller/Teamer/Check/DriverLicenseController.php +++ b/src/Controller/Teamer/Check/DriverLicenseController.php @@ -133,13 +133,13 @@ class DriverLicenseController extends AbstractController $this->entityManager->remove($existingUpload); } - $upload = Upload::fromUploadDto($uploadSession->getUploads()->first(), $user, Upload::TYPE_DRIVER_LICENSE); + $upload = Upload::fromUploadDto($uploadSession->getUploadsByType(Upload::TYPE_DRIVER_LICENSE)->first(), $user, Upload::TYPE_DRIVER_LICENSE); $upload->setStatus(Upload::STATUS_NEW); $teamer->setDriverLicenseUpload($upload); $this->entityManager->flush(); $this->uploadHandler->moveUploadSessionFilesFromOrphanage(Upload::TYPE_DRIVER_LICENSE, $uploadSession); - $this->uploadHandler->destroyUploadSession(); + $this->uploadHandler->removeUploadSessionUploadsByType(Upload::TYPE_DRIVER_LICENSE); } } diff --git a/src/Controller/Teamer/Disposition/DetailController.php b/src/Controller/Teamer/Disposition/DetailController.php index 2ae0c28..348c447 100644 --- a/src/Controller/Teamer/Disposition/DetailController.php +++ b/src/Controller/Teamer/Disposition/DetailController.php @@ -86,14 +86,18 @@ class DetailController extends AbstractController /** @var User $user */ $user = $this->getUser(); - $upload = Upload::fromUploadDto($uploadSession->getUploads()->first(), $user, Upload::TYPE_CONTRACT); + $upload = Upload::fromUploadDto( + $uploadSession->getUploadsByType(Upload::TYPE_CONTRACT)->first(), + $user, + Upload::TYPE_CONTRACT + ); $upload->setStatus(Upload::STATUS_NEW); $disposition->addDocument($upload); $this->workflow->apply($disposition, 'upload_contract'); $this->uploadHandler->moveUploadSessionFilesFromOrphanage(Upload::TYPE_CONTRACT, $uploadSession); - $this->uploadHandler->destroyUploadSession(); + $this->uploadHandler->removeUploadSessionUploadsByType(Upload::TYPE_CONTRACT); if (true === $this->featureManager->isActive('sanitize_uploads')) { $this->uploadHandler->ensurePdf($upload); @@ -119,14 +123,18 @@ class DetailController extends AbstractController /** @var User $user */ $user = $this->getUser(); - $upload = Upload::fromUploadDto($uploadSession->getUploads()->first(), $user, Upload::TYPE_INVOICE); + $upload = Upload::fromUploadDto( + $uploadSession->getUploadsByType(Upload::TYPE_INVOICE)->first(), + $user, + Upload::TYPE_INVOICE + ); $upload->setStatus(Upload::STATUS_NEW); $disposition->addDocument($upload); $this->workflow->apply($disposition, 'upload_invoice'); $this->uploadHandler->moveUploadSessionFilesFromOrphanage(Upload::TYPE_INVOICE, $uploadSession); - $this->uploadHandler->destroyUploadSession(); + $this->uploadHandler->removeUploadSessionUploadsByType(Upload::TYPE_INVOICE); if (true === $this->featureManager->isActive('sanitize_uploads')) { $this->uploadHandler->ensurePdf($upload); diff --git a/src/Controller/Teamer/Disposition/ReceiptController.php b/src/Controller/Teamer/Disposition/ReceiptController.php new file mode 100644 index 0000000..7598630 --- /dev/null +++ b/src/Controller/Teamer/Disposition/ReceiptController.php @@ -0,0 +1,156 @@ +createFormBuilder()->getForm(); + $form->handleRequest($request); + + if ($form->isSubmitted() && $form->isValid()) { + $count = $this->processUpload($disposition); + + $this->addFlash( + 'success', + 1 === $count ? 'Der Beleg wurde hochgeladen' : 'Die Belege wurden hochgeladen' + ); + + return new HxRedirectResponse($this->generateUrl('app_teamer_disposition_detail', [ + 'uuid' => $disposition->getUuid(), + 'r' => $request->query->get('r'), + ])); + } + + return $this->render('teamer/disposition/modal_receipt_upload.html.twig', [ + 'disposition' => $disposition, + 'form' => $form->createView(), + ]); + } + + #[Route('/teamer/disposition/receipt/delete/{uuid}', name: 'app_teamer_disposition_receipt_delete')] + #[IsGranted('ROLE_TEAMER')] + #[IsGranted('DELETE', subject: 'receipt')] + public function delete(Upload $receipt, Request $request): Response + { + if (Upload::TYPE_RECEIPT !== $receipt->getType()) { + throw $this->createNotFoundException('Not a receipt'); + } + + // UploadVoter::DELETE only asserts ownership, which would still hold once the invoice has + // been accepted. The window itself is the disposition's to answer. + $this->denyAccessUnlessGranted('MANAGE_RECEIPTS', $receipt->getDisposition()); + + if (true === $request->isMethod('POST')) { + $disposition = $receipt->getDisposition(); + + // Doctrine's DeleteUploadListener removes the file from disk on preRemove. + $this->entityManager->remove($receipt); + $this->entityManager->flush(); + + $this->addFlash('success', 'Der Beleg wurde gelöscht'); + $this->logger->info('Delete receipt', [ + 'disposition_id' => $disposition->getId(), + 'receipt_filename' => $receipt->getOriginalFilename(), + 'owner' => $receipt->getOwner()->getFullName(), + ]); + + return new HxRedirectResponse($this->generateUrl('app_teamer_disposition_detail', [ + 'uuid' => $disposition->getUuid(), + 'r' => $request->query->get('r'), + ])); + } + + return $this->render('teamer/disposition/modal_receipt_delete.html.twig', [ + 'receipt' => $receipt, + ]); + } + + private function processUpload(Disposition $disposition): int + { + /** @var User $user */ + $user = $this->getUser(); + + $uploadSession = $this->uploadHandler->getUploadSession(); + + // Only this dropzone's files: on the detail page in place "ended" the invoice dropzone is + // live at the same time and shares the one session. + $uploadDtos = $uploadSession->getUploadsByType(Upload::TYPE_RECEIPT); + + $uploads = []; + + /** @var UploadDto $uploadDto */ + foreach ($uploadDtos as $uploadDto) { + $upload = Upload::fromUploadDto($uploadDto, $user, Upload::TYPE_RECEIPT); + + // No status on purpose. UploadVoter grants CHECK only for "new" and "pending", so a + // null status is what keeps a receipt out of the checking process by construction. + $upload->setStatus(null); + + $disposition->addDocument($upload); + $this->entityManager->persist($upload); + + $uploads[] = $upload; + } + + $this->uploadHandler->moveUploadSessionFilesFromOrphanage(Upload::TYPE_RECEIPT, $uploadSession); + $this->uploadHandler->removeUploadSessionUploadsByType(Upload::TYPE_RECEIPT); + + if (true === $this->featureManager->isActive('sanitize_uploads')) { + foreach ($uploads as $upload) { + $this->uploadHandler->ensurePdf($upload); + } + } + + $this->entityManager->flush(); + + $this->logger->info('Upload receipts', [ + 'user' => $user->getUserIdentifier(), + 'disposition_id' => $disposition->getId(), + 'count' => count($uploads), + ]); + + // No DocumentUploadedEvent: its listener mails the managers about a document waiting to be + // checked, which is exactly what a receipt is not. + + return count($uploads); + } +} diff --git a/src/Controller/Teamer/Profile/IndexController.php b/src/Controller/Teamer/Profile/IndexController.php index de1a57c..ddcef51 100644 --- a/src/Controller/Teamer/Profile/IndexController.php +++ b/src/Controller/Teamer/Profile/IndexController.php @@ -96,11 +96,11 @@ class IndexController extends AbstractController $this->entityManager->remove($existingUpload); } - $upload = Upload::fromUploadDto($uploadSession->getUploads()->first(), $user, Upload::TYPE_PHOTO); + $upload = Upload::fromUploadDto($uploadSession->getUploadsByType(Upload::TYPE_PHOTO)->first(), $user, Upload::TYPE_PHOTO); $teamer->setPhoto($upload); $this->entityManager->flush(); $this->uploadHandler->moveUploadSessionFilesFromOrphanage(Upload::TYPE_PHOTO, $uploadSession); - $this->uploadHandler->destroyUploadSession(); + $this->uploadHandler->removeUploadSessionUploadsByType(Upload::TYPE_PHOTO); $this->addFlash('success', 'Dein Profilbild wurde aktualisiert'); $this->logger->info('Update teamer photo', [ diff --git a/src/Controller/Teamer/Profile/License/AddController.php b/src/Controller/Teamer/Profile/License/AddController.php index 98b8541..56aabaf 100644 --- a/src/Controller/Teamer/Profile/License/AddController.php +++ b/src/Controller/Teamer/Profile/License/AddController.php @@ -73,11 +73,11 @@ class AddController extends AbstractController private function updateCertificate(User $user, License $license, UploadSessionDto $uploadSession): void { - $upload = Upload::fromUploadDto($uploadSession->getUploads()->first(), $user, Upload::TYPE_CERTIFICATE); + $upload = Upload::fromUploadDto($uploadSession->getUploadsByType(Upload::TYPE_CERTIFICATE)->first(), $user, Upload::TYPE_CERTIFICATE); $license->setCertificate($upload); $this->entityManager->flush(); $this->uploadHandler->moveUploadSessionFilesFromOrphanage(Upload::TYPE_CERTIFICATE, $uploadSession); - $this->uploadHandler->destroyUploadSession(); + $this->uploadHandler->removeUploadSessionUploadsByType(Upload::TYPE_CERTIFICATE); $this->addFlash('success', 'Der Nachweis wurde hochgeladen'); } diff --git a/src/Entity/Disposition.php b/src/Entity/Disposition.php index 8924154..43f94ac 100644 --- a/src/Entity/Disposition.php +++ b/src/Entity/Disposition.php @@ -188,6 +188,20 @@ class Disposition implements BlameableEntityInterface, TimestampableEntityInterf return null; } + /** + * Unlike getDocumentByType() this returns every match, for the types a disposition can hold + * more than one of. Belege are the only such type today: there is exactly one Honorarvertrag + * and one Honorarnote, but any number of receipts backing the latter. + * + * @return Collection + */ + public function getDocumentsByType(string $type): Collection + { + return $this->documents->filter(function (Upload $upload) use ($type) { + return $type === $upload->getType(); + }); + } + public function addDocument(Upload $document): static { if (!$this->documents->contains($document)) { diff --git a/src/Entity/Upload.php b/src/Entity/Upload.php index f98761b..4174d1d 100644 --- a/src/Entity/Upload.php +++ b/src/Entity/Upload.php @@ -22,6 +22,7 @@ class Upload implements BlameableEntityInterface, TimestampableEntityInterface public const TYPE_INVOICE = 'invoice'; public const TYPE_DOCUMENT = 'document'; public const TYPE_DRIVER_LICENSE = 'driver_license'; + public const TYPE_RECEIPT = 'receipt'; public const STATUS_NEW = 'new'; public const STATUS_PENDING = 'pending'; @@ -135,6 +136,7 @@ class Upload implements BlameableEntityInterface, TimestampableEntityInterface self::TYPE_INVOICE => 'Honorarnote', self::TYPE_DOCUMENT => 'Info', self::TYPE_DRIVER_LICENSE => 'Führerschein', + self::TYPE_RECEIPT => 'Beleg', default => 'Dokument', }; } diff --git a/src/EventListener/UploadSessionListener.php b/src/EventListener/UploadSessionListener.php index 2bd8af9..b6033b4 100644 --- a/src/EventListener/UploadSessionListener.php +++ b/src/EventListener/UploadSessionListener.php @@ -25,12 +25,15 @@ class UploadSessionListener // Get original filename from Dropzone request $originalFileName = $request->request->get('originalFilename'); + // The mapping name, which is 1:1 with the Upload::TYPE_* values. Without it the session + // is a flat list and a page with two dropzones cannot tell its own files apart. $upload = new UploadDto( $uuid, $uploadedFile->getFilename(), $originalFileName, $uploadedFile->getMimeType(), - $uploadedFile->getSize() + $uploadedFile->getSize(), + $event->getType() ); $uploadSession = $this->uploadHandler->addUploadToSession($upload); diff --git a/src/Model/UploadDto.php b/src/Model/UploadDto.php index cb836db..4512bf2 100644 --- a/src/Model/UploadDto.php +++ b/src/Model/UploadDto.php @@ -4,13 +4,25 @@ namespace App\Model; class UploadDto { + /** + * The oneup_uploader mapping the file was uploaded through, which is 1:1 with the + * Upload::TYPE_* values. Deliberately not a promoted constructor property: the enclosing + * UploadSessionDto is serialized into the PHP session, and a promoted property carries no + * class-level default, so entries written before this field existed would deserialize + * uninitialized and throw on access. Nullable with a real default lets them come back as + * "unknown type", which matches no type and is therefore ignored. + */ + private ?string $type = null; + public function __construct( private readonly string $uuid, private readonly string $filename, private readonly string $originalFilename, private readonly string $mimeType, private readonly int $size, + ?string $type = null, ) { + $this->type = $type; } public function getUuid(): string @@ -37,4 +49,9 @@ class UploadDto { return $this->size; } + + public function getType(): ?string + { + return $this->type; + } } diff --git a/src/Model/UploadSessionDto.php b/src/Model/UploadSessionDto.php index 9ffff55..2a0bd9f 100644 --- a/src/Model/UploadSessionDto.php +++ b/src/Model/UploadSessionDto.php @@ -52,6 +52,29 @@ class UploadSessionDto return $this->uploads; } + /** + * The uploads that came in through one oneup_uploader mapping. Two dropzones can be live on + * the same page (the Honorarnote and its Belege), and the session is one flat list, so a + * consumer must never take getUploads()->first() and hope. + * + * @return Collection + */ + public function getUploadsByType(string $type): Collection + { + return $this->uploads->filter(function (UploadDto $upload) use ($type) { + return $type === $upload->getType(); + }); + } + + public function removeUploadsByType(string $type): static + { + foreach ($this->getUploadsByType($type) as $upload) { + $this->uploads->removeElement($upload); + } + + return $this; + } + public function flushUploads(): static { $this->uploads->clear(); diff --git a/src/Repository/UploadRepository.php b/src/Repository/UploadRepository.php index a6a4b25..391cd13 100644 --- a/src/Repository/UploadRepository.php +++ b/src/Repository/UploadRepository.php @@ -143,6 +143,15 @@ class UploadRepository extends ServiceEntityRepository ->andWhere($qb->expr()->eq('upload.type', ':type')) ->setParameter('type', $type) ; + } else { + // Belege are evidence for a Honorarnote, never checked in their own right - they have + // no status and are reachable from the check modal of their invoice. This is the only + // upload query that is not already scoped to a type, so it is the only one that would + // otherwise pad the list with them. + $qb + ->andWhere($qb->expr()->neq('upload.type', ':excludedType')) + ->setParameter('excludedType', Upload::TYPE_RECEIPT) + ; } if (null !== $dateFrom = $filterDto->getDateFrom()) { diff --git a/src/Security/Voter/DispositionVoter.php b/src/Security/Voter/DispositionVoter.php index 8a1ec4f..bc9877c 100644 --- a/src/Security/Voter/DispositionVoter.php +++ b/src/Security/Voter/DispositionVoter.php @@ -21,6 +21,7 @@ class DispositionVoter extends Voter public const INVOICE = 'INVOICE'; public const FEEDBACK = 'FEEDBACK'; public const CALL_OFF = 'CALL_OFF'; + public const MANAGE_RECEIPTS = 'MANAGE_RECEIPTS'; public function __construct(private readonly Security $security) { @@ -42,6 +43,7 @@ class DispositionVoter extends Voter static::INVOICE, static::FEEDBACK, static::CALL_OFF, + static::MANAGE_RECEIPTS, ]); } @@ -66,10 +68,42 @@ class DispositionVoter extends Voter static::ADMIN_DOCUMENT_UPLOAD => $this->assertAdminDocumentUploadAllowed($disposition), static::CALL_OFF => $this->security->isGranted('ROLE_ADMINISTRATIVE') && Disposition::STATUS_CALLED_OFF !== $disposition->getStatus(), + static::MANAGE_RECEIPTS => $this->assertManageReceiptsAllowed($token, $disposition), default => false, }; } + /** + * Whether Belege backing the Honorarnote may be added, listed or deleted. + * + * This is deliberately a permission and not a workflow transition. The disposition state + * machine offers upload_invoice only from "ended", so a teamer whose invoice is being checked + * has no transition available at all - which is precisely the phase in which receipts must + * stay manageable. Receipts also have to survive the ended -> checking_invoice -> ended + * bouncing that upload_invoice and reject_invoice cause, so the window is expressed as the two + * places themselves rather than as anything workflow_can() could answer. Do not "tidy" this + * into a workflow check. + * + * Once the invoice is accepted the disposition leaves for "completed" and the receipts are + * deleted (see Administrative\Document\CheckController), so the window closes on its own. + */ + private function assertManageReceiptsAllowed(TokenInterface $token, Disposition $disposition): bool + { + if (true === $disposition->isSkipFormalities()) { + return false; + } + + if (false === $this->security->isGranted('ROLE_ADMINISTRATIVE') + && false === $this->assertTeamerAccess($token, $disposition)) { + return false; + } + + return in_array($disposition->getStatus(), [ + Disposition::STATUS_ENDED, + Disposition::STATUS_CHECKING_INVOICE, + ], true); + } + private function assertHouseManagerAccess(TokenInterface $token, Disposition $disposition): bool { if (false === $this->security->isGranted('ROLE_HOUSE_MANAGER')) { diff --git a/src/Service/Upload/UploadHandler.php b/src/Service/Upload/UploadHandler.php index eca0bb2..7a3fced 100644 --- a/src/Service/Upload/UploadHandler.php +++ b/src/Service/Upload/UploadHandler.php @@ -23,7 +23,10 @@ class UploadHandler private readonly LoggerInterface $logger, private readonly string $projectDir, private readonly string $environment, - private readonly string $uploadReplacementFile, + // Bound from %env(default::UPLOAD_REPLACEMENT_FILE)%, whose empty fallback parameter + // resolves to null when the variable is not set at all - so "unset", "set to empty" and + // "absent" all have to mean the same thing here. + private readonly ?string $uploadReplacementFile, ) { } @@ -65,6 +68,23 @@ class UploadHandler return $uploadSession; } + /** + * Drops the uploads of one mapping from the session and writes the trimmed DTO back, so a + * consumer clears only what it has just persisted. destroyUploadSession() below still exists + * for a full reset, but a page with two dropzones must not use it: it would throw away the + * files belonging to the other one. + */ + public function removeUploadSessionUploadsByType(string $type): UploadSessionDto + { + $uploadSession = $this->getUploadSession(); + $uploadSession->removeUploadsByType($type); + + $session = $this->requestStack->getSession(); + $session->set(self::SESSION_KEY, $uploadSession); + + return $uploadSession; + } + public function removeUploadFromSession(string $filename): UploadSessionDto { $uploadSession = $this->getUploadSession(); @@ -153,7 +173,9 @@ class UploadHandler { $originalPath = $this->getUploadFilepath($upload); - if ('' === $this->uploadReplacementFile || 'prod' === $this->environment) { + if (null === $this->uploadReplacementFile + || '' === $this->uploadReplacementFile + || 'prod' === $this->environment) { return $originalPath; } diff --git a/templates/_partials/_receipts.html.twig b/templates/_partials/_receipts.html.twig new file mode 100644 index 0000000..35f9f06 --- /dev/null +++ b/templates/_partials/_receipts.html.twig @@ -0,0 +1,47 @@ +{# Belege backing the Honorarnote. Rendered both while the invoice can still be uploaded and while + it is being checked - the window is MANAGE_RECEIPTS, not a workflow transition. Must not be + placed inside the invoice form: the triggers below are their own forms. #} +{% set receipts = disposition.documentsByType('receipt') %} +
+

+ Belege +

+
+ Hast du Auslagen in deiner Honorarnote aufgeführt? Dann lade die zugehörigen Belege hier + hoch. Sie werden nach der Prüfung deiner Honorarnote automatisch gelöscht. +
+
    + {% for receipt in receipts %} +
  • +
    + {{ receipt }} + + {{ icon('download', 'w-4 h-4') }} + + +
    +
  • + {% else %} +
  • + Du hast bisher keine Belege hochgeladen +
  • + {% endfor %} +
+ +
diff --git a/templates/administrative/document/modal_check.html.twig b/templates/administrative/document/modal_check.html.twig index b19eefd..292491e 100644 --- a/templates/administrative/document/modal_check.html.twig +++ b/templates/administrative/document/modal_check.html.twig @@ -13,6 +13,26 @@ {{ icon('eye') }} + {# Belege backing this Honorarnote. Opened inline so the download route does not mark them + as downloaded - that marking is meant for the invoice itself. They are deleted as soon as + the invoice is accepted. #} + {% if receipts is not empty %} +
+
Belege
+ +
+ {% endif %}
{{ form_row(form.status) }} {% if form.specialAgreements is defined %} diff --git a/templates/teamer/disposition/detail.html.twig b/templates/teamer/disposition/detail.html.twig index 793071b..857acb6 100644 --- a/templates/teamer/disposition/detail.html.twig +++ b/templates/teamer/disposition/detail.html.twig @@ -122,6 +122,11 @@ {% endfor %} {% endif %} + + {# Outside the invoice form above - the receipt triggers are their own forms. #} + {% if is_granted('MANAGE_RECEIPTS', disposition) %} + {% include '_partials/_receipts.html.twig' %} + {% endif %} {% endif %} {# Contract uploaded #} @@ -189,6 +194,10 @@
{% endif %} {% endif %} + + {% if is_granted('MANAGE_RECEIPTS', disposition) %} + {% include '_partials/_receipts.html.twig' %} + {% endif %} {% endif %} {# Invoice paid #} diff --git a/templates/teamer/disposition/modal_receipt_delete.html.twig b/templates/teamer/disposition/modal_receipt_delete.html.twig new file mode 100644 index 0000000..5308691 --- /dev/null +++ b/templates/teamer/disposition/modal_receipt_delete.html.twig @@ -0,0 +1,7 @@ +{% extends 'htmx_confirmation_modal.html.twig' %} + +{% block content %} +
+ Möchtest du den Beleg {{ receipt }} wirklich löschen? +
+{% endblock %} diff --git a/templates/teamer/disposition/modal_receipt_upload.html.twig b/templates/teamer/disposition/modal_receipt_upload.html.twig new file mode 100644 index 0000000..44089ec --- /dev/null +++ b/templates/teamer/disposition/modal_receipt_upload.html.twig @@ -0,0 +1,24 @@ +{% extends 'htmx_modal.html.twig' %} + +{% block title %}Belege hochladen{% endblock %} + +{% block content %} + {% set formAttr = stimulus_controller('form-upload-guard')|stimulus_action('form-upload-guard', 'guard', 'submit') %} + {{ form_start(form, { 'attr': { 'hx-post': app.request.uri, 'hx-target': '#htmx-modal', 'hx-swap': 'outerHTML' }|merge(formAttr.toArray()) }) }} +
+ Lade hier die Belege zu den Auslagen hoch, die du in deiner Honorarnote aufführst. +
+
+ {% include '_partials/_upload_collection_form.html.twig' with { + 'endpoint_upload': path('_uploader_upload_receipt'), + 'max_filesize': 5, + 'max_files': 10, + 'accepted_files': 'image/jpg,image/jpeg,application/pdf', + } %} +
+ + {{ form_rest(form) }} + {{ form_end(form) }} +{% endblock %} diff --git a/tests/Entity/DispositionTest.php b/tests/Entity/DispositionTest.php index faa7d66..da02ed3 100644 --- a/tests/Entity/DispositionTest.php +++ b/tests/Entity/DispositionTest.php @@ -146,6 +146,45 @@ class DispositionTest extends TestCase $this->assertFalse($disposition->isContractRejected()); } + /** + * getDocumentByType() answers with the first match and is right for the Honorarvertrag and the + * Honorarnote, of which there is one each. Belege are the first type a disposition can hold + * several of, so they need the collection-returning sibling. + */ + public function testTheReceiptsOfADispositionAreReturnedAsACollection(): void + { + $disposition = $this->createDisposition(createdAt: 'today', assignmentEndsIn: '-1 day'); + + $disposition + ->addDocument((new Upload())->setType(Upload::TYPE_INVOICE)) + ->addDocument((new Upload())->setType(Upload::TYPE_RECEIPT)->setOriginalFilename('bahn.pdf')) + ->addDocument((new Upload())->setType(Upload::TYPE_CONTRACT)) + ->addDocument((new Upload())->setType(Upload::TYPE_RECEIPT)->setOriginalFilename('taxi.jpg')) + ; + + $receipts = $disposition->getDocumentsByType(Upload::TYPE_RECEIPT); + + $this->assertCount(2, $receipts); + $this->assertSame( + ['bahn.pdf', 'taxi.jpg'], + array_values(array_map( + fn (Upload $upload): string => $upload->getOriginalFilename(), + $receipts->toArray() + )) + ); + + // The single-result sibling keeps working for the types that only ever have one. + $this->assertSame(Upload::TYPE_INVOICE, $disposition->getDocumentByType(Upload::TYPE_INVOICE)?->getType()); + } + + public function testADispositionWithoutReceiptsReturnsAnEmptyCollection(): void + { + $disposition = $this->createDisposition(createdAt: 'today', assignmentEndsIn: '-1 day'); + $disposition->addDocument((new Upload())->setType(Upload::TYPE_INVOICE)); + + $this->assertCount(0, $disposition->getDocumentsByType(Upload::TYPE_RECEIPT)); + } + private function createDisposition( string $createdAt, string $assignmentEndsIn, diff --git a/tests/Model/UploadSessionDtoTest.php b/tests/Model/UploadSessionDtoTest.php new file mode 100644 index 0000000..8d74e39 --- /dev/null +++ b/tests/Model/UploadSessionDtoTest.php @@ -0,0 +1,92 @@ +first() - so submitting + * the invoice form could persist a receipt as the Honorarnote. That is what these pin down. + */ +class UploadSessionDtoTest extends TestCase +{ + public function testUploadsAreSelectedByTheMappingTheyCameFrom(): void + { + $session = $this->createSession(); + + $this->assertSame( + ['honorarnote.pdf'], + $this->filenames($session->getUploadsByType(Upload::TYPE_INVOICE)) + ); + $this->assertSame( + ['bahn.pdf', 'taxi.jpg'], + $this->filenames($session->getUploadsByType(Upload::TYPE_RECEIPT)) + ); + } + + public function testRemovingOneMappingLeavesTheOtherAlone(): void + { + $session = $this->createSession(); + + $session->removeUploadsByType(Upload::TYPE_RECEIPT); + + $this->assertCount(0, $session->getUploadsByType(Upload::TYPE_RECEIPT)); + $this->assertSame(['honorarnote.pdf'], $this->filenames($session->getUploadsByType(Upload::TYPE_INVOICE))); + } + + /** + * A session written before UploadDto carried a type deserializes with a null one. Such an + * entry must match no mapping at all rather than being claimed by the first consumer to ask - + * that would be the very ambiguity the type removes. + */ + public function testAnUploadWithoutAMappingMatchesNothing(): void + { + $session = new UploadSessionDto(); + $session->addUpload(new UploadDto('uuid-legacy', 'legacy.pdf', 'legacy.pdf', 'application/pdf', 1)); + + $this->assertCount(0, $session->getUploadsByType(Upload::TYPE_INVOICE)); + $this->assertCount(0, $session->getUploadsByType(Upload::TYPE_RECEIPT)); + $this->assertCount(1, $session->getUploads()); + } + + private function createSession(): UploadSessionDto + { + $session = new UploadSessionDto(); + + $session + ->addUpload($this->createUpload('uuid-1', 'honorarnote.pdf', Upload::TYPE_INVOICE)) + ->addUpload($this->createUpload('uuid-2', 'bahn.pdf', Upload::TYPE_RECEIPT)) + ->addUpload($this->createUpload('uuid-3', 'taxi.jpg', Upload::TYPE_RECEIPT)) + ; + + return $session; + } + + private function createUpload(string $uuid, string $filename, string $type): UploadDto + { + return new UploadDto($uuid, $filename, $filename, 'application/pdf', 1024, $type); + } + + /** + * @param \Doctrine\Common\Collections\Collection $uploads + * + * @return array + */ + private function filenames(iterable $uploads): array + { + $filenames = []; + + foreach ($uploads as $upload) { + $filenames[] = $upload->getFilename(); + } + + return $filenames; + } +} diff --git a/tests/Security/Voter/DispositionVoterTest.php b/tests/Security/Voter/DispositionVoterTest.php index 920787a..7c4ac02 100644 --- a/tests/Security/Voter/DispositionVoterTest.php +++ b/tests/Security/Voter/DispositionVoterTest.php @@ -8,6 +8,7 @@ use App\Entity\Application; use App\Entity\Assignment; use App\Entity\Disposition; use App\Entity\Teamer; +use App\Entity\User; use App\Security\Voter\DispositionVoter; use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; @@ -85,6 +86,98 @@ class DispositionVoterTest extends TestCase $this->assertSame(VoterInterface::ACCESS_GRANTED, $this->vote(DispositionVoter::CALL_OFF, $disposition)); } + /** + * The receipt window is the one permission in this voter that is not derived from a workflow + * transition, because there is none to derive it from: while the Honorarnote is being checked + * the state machine offers nothing at all, and that is exactly when receipts must stay + * manageable. + * + * @dataProvider receiptWindowStatuses + */ + public function testTheReceiptWindowIsTheTwoInvoiceStates(string $status, bool $expected): void + { + $this->security->method('isGranted')->willReturn(true); + + $disposition = $this->createDisposition(skipFormalities: false)->setStatus($status); + + $this->assertSame( + $expected ? VoterInterface::ACCESS_GRANTED : VoterInterface::ACCESS_DENIED, + $this->vote(DispositionVoter::MANAGE_RECEIPTS, $disposition), + ); + } + + /** + * @return array + */ + public static function receiptWindowStatuses(): array + { + return [ + 'invoice may still be uploaded' => [Disposition::STATUS_ENDED, true], + 'invoice is being checked' => [Disposition::STATUS_CHECKING_INVOICE, true], + 'nothing has happened yet' => [Disposition::STATUS_NEW, false], + 'contract is being checked' => [Disposition::STATUS_CHECKING_CONTRACT, false], + 'assignment is still ahead' => [Disposition::STATUS_CONFIRMED, false], + 'invoice was accepted' => [Disposition::STATUS_COMPLETED, false], + 'placement was cancelled' => [Disposition::STATUS_CALLED_OFF, false], + ]; + } + + public function testReceiptsAreDeniedOnASkipFormalitiesAssignment(): void + { + $this->security->method('isGranted')->willReturn(true); + + $disposition = $this + ->createDisposition(skipFormalities: true) + ->setStatus(Disposition::STATUS_CHECKING_INVOICE) + ; + + $this->assertSame( + VoterInterface::ACCESS_DENIED, + $this->vote(DispositionVoter::MANAGE_RECEIPTS, $disposition), + ); + } + + /** + * The owning teamer gets in without being administrative; anyone else's teamer does not. + * + * @dataProvider receiptOwnership + */ + public function testOnlyTheOwningTeamerMayManageTheirReceipts(bool $owning, bool $expected): void + { + $this->security + ->method('isGranted') + ->willReturnCallback(fn (string $role): bool => 'ROLE_TEAMER' === $role) + ; + + $teamer = new Teamer(); + $assignment = (new Assignment())->setSkipFormalities(false); + + $disposition = (new Disposition(new Application($assignment, $teamer))) + ->setStatus(Disposition::STATUS_CHECKING_INVOICE) + ; + + $user = (new User())->setTeamer($owning ? $teamer : new Teamer()); + + $token = $this->createMock(TokenInterface::class); + $token->method('getUser')->willReturn($user); + + $this->assertSame( + $expected ? VoterInterface::ACCESS_GRANTED : VoterInterface::ACCESS_DENIED, + (new DispositionVoter($this->security))->vote($token, $disposition, [DispositionVoter::MANAGE_RECEIPTS]), + ); + } + + /** + * @return array + */ + public static function receiptOwnership(): array + { + return [ + 'their own placement' => [true, true], + 'somebody else\'s placement' => [false, false], + ]; + } + private function vote(string $attribute, Disposition $disposition): int { return (new DispositionVoter($this->security))->vote( diff --git a/translations/messages.de.yaml b/translations/messages.de.yaml index 27ada81..f267a38 100644 --- a/translations/messages.de.yaml +++ b/translations/messages.de.yaml @@ -20,6 +20,7 @@ label: certificate: Lizenznachweis photo: Foto driver_license: Führerschein + receipt: Beleg status: new: neu pending: in Bearbeitung