Compare commits

...
5 Commits
Author SHA1 Message Date
fromme 3cdaee1df3 chore: code cleanup 2026-09-09 11:15:35 +02:00
fromme 4b20aad98e chore: fix deprecations 2026-09-09 11:14:00 +02:00
fromme 52926ec6cc feat: additional receipts as attachments to teamer invoices
addresses #869dv97u9
2026-09-09 11:10:57 +02:00
fromme 16d847137d feat: project and subdomain rename 2026-09-08 14:13:46 +02:00
fromme 66527515ef feat: record application deleted event for statistics 2026-09-08 14:11:30 +02:00
65 changed files with 984 additions and 98 deletions
+3 -3
View File
@@ -25,7 +25,7 @@ texts:
Zusätzliche Absprachen: {specialAgreements}
Schau einmal in das My E&P-Team Portal, um die Einsatzdetails einzusehen und deinen Honorarvertrag zu unterschreiben.
Schau einmal in das E&P-Team Portal, um die Einsatzdetails einzusehen und deinen Honorarvertrag zu unterschreiben.
Mit dem Erhalt dieser E-Mail hast du {contractUploadDeadlineDays} Tage Zeit deinen Einsatz zu bestätigen, indem du den unterschriebenen Vertrag hochlädst. Ist diese Frist vergangen, wird der Einsatz für deine Teamkolleg:innen freigeschaltet.
@@ -45,7 +45,7 @@ texts:
Damit ist für dich alles erledigt - für diesen Einsatz brauchen wir weder einen Honorarvertrag noch eine Honorarnote von dir.
Schau einmal in das My E&P-Team Portal, um die Einsatzdetails einzusehen.
Schau einmal in das E&P-Team Portal, um die Einsatzdetails einzusehen.
Schön, dass du dabei bist und ganz viel Spaß in den Bergen! 😊
@@ -139,7 +139,7 @@ texts:
Wir hoffen, dass du ich schon freust und wünschen dir viel Spaß und Erfolg! Finale Infos erhältst du, falls noch nicht geschehen, ein paar Tage vor deinem Einsatz von deinen zuständigen Haus- oder Reisemanager:innen.
Bitte denk daran, deine **ausgefüllte Honorarnote bis zu {invoiceUploadDeadlineDays} Tage nach deinem Einsatz** in My E&P Team hochzuladen.
Bitte denk daran, deine **ausgefüllte Honorarnote bis zu {invoiceUploadDeadlineDays} Tage nach deinem Einsatz** in E&P Team hochzuladen.
Liebe Grüße,
dein Team Personalabteilung
+9
View File
@@ -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/<type>/<shard>/<filename> 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:
+1 -1
View File
@@ -5,7 +5,7 @@ zenstruck_schedule:
service: mailer
default_to: [email protected]
default_from: [email protected]
subject_prefix: "[MyE&P-Team]"
subject_prefix: "[E&P-Team]"
schedule_extensions:
email_on_failure:
@@ -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]);
@@ -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]);
@@ -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);
@@ -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) {
@@ -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();
@@ -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()) {
@@ -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);
}
}
@@ -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);
@@ -0,0 +1,156 @@
<?php
namespace App\Controller\Teamer\Disposition;
use App\Entity\Disposition;
use App\Entity\Upload;
use App\Entity\User;
use App\Htmx\HxRedirectResponse;
use App\Model\UploadDto;
use App\Service\Upload\UploadHandler;
use Doctrine\ORM\EntityManagerInterface;
use Flagception\Manager\FeatureManagerInterface;
use Psr\Log\LoggerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;
/**
* Belege backing a Honorarnote.
*
* Deliberately separate from DetailController and from the disposition workflow: a receipt is
* evidence for the invoice, never a document in its own right. It is not approved or rejected, it
* carries no status, and it applies no transition - which is what lets a teamer keep managing
* receipts while the invoice is being checked, a phase in which the state machine offers no
* transition at all. The window is guarded by the MANAGE_RECEIPTS attribute on the disposition.
*/
class ReceiptController extends AbstractController
{
public function __construct(
private readonly UploadHandler $uploadHandler,
private readonly EntityManagerInterface $entityManager,
private readonly LoggerInterface $logger,
private readonly FeatureManagerInterface $featureManager,
) {
}
#[Route('/teamer/disposition/receipt/{uuid}', name: 'app_teamer_disposition_receipt_upload')]
#[IsGranted('ROLE_TEAMER')]
#[IsGranted('MANAGE_RECEIPTS', subject: 'disposition')]
public function upload(Disposition $disposition, Request $request): Response
{
// Dummy form without fields, as elsewhere: the files travel in the upload session, not in
// the form itself.
$form = $this->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);
}
}
@@ -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', [
@@ -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');
}
+14
View File
@@ -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<int, Upload>
*/
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)) {
+2
View File
@@ -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',
};
}
+20
View File
@@ -0,0 +1,20 @@
<?php
namespace App\Enum;
/**
* What became of an application that was hard deleted.
*
* Applications are removed on the way to a placement just as they are removed when they come
* to nothing, and the row is gone either way. Without this, staffing somebody would read as
* an application being thrown away, and "how many applications did we lose" would count every
* success along with every loss.
*/
enum ApplicationDeletionOutcome: string
{
/** It became a placement: a disposition was created from it in the same flush. */
case DISPOSED = 'disposed';
/** Nobody was staffed from it - withdrawn by the teamer, deleted by the office, or purged. */
case REMOVED = 'removed';
}
+2
View File
@@ -19,6 +19,7 @@ enum StatisticsEventName: string
case DISPOSITION_CALLED_OFF = 'disposition.called_off';
case DISPOSITION_DELETED = 'disposition.deleted';
case APPLICATION_CREATED = 'application.created';
case APPLICATION_DELETED = 'application.deleted';
public function label(): string
{
@@ -28,6 +29,7 @@ enum StatisticsEventName: string
self::DISPOSITION_CALLED_OFF => 'Einsatz abgesagt',
self::DISPOSITION_DELETED => 'Einteilung gelöscht',
self::APPLICATION_CREATED => 'Bewerbung eingegangen',
self::APPLICATION_DELETED => 'Bewerbung gelöscht',
};
}
}
+4 -1
View File
@@ -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);
+1 -1
View File
@@ -118,7 +118,7 @@ class AssignmentType extends AbstractType
return $qb
->where($qb->expr()->like('user.roles', ':role'))
->setParameter('role', '%ROLE_MANAGER%')
->orderBy('user.firstName', 'ASC')
->orderBy('user.firstName', \SortDirection::Ascending)
;
},
])
+1 -1
View File
@@ -37,7 +37,7 @@ class JobProfileType extends AbstractType
'expanded' => true,
'query_builder' => function (EntityRepository $repository) {
return $repository->createQueryBuilder('job_profile')
->orderBy('job_profile.name', 'ASC')
->orderBy('job_profile.name', \SortDirection::Ascending)
;
},
])
+1 -1
View File
@@ -48,7 +48,7 @@ class TeamerFilterType extends AbstractType
'empty_label' => 'nicht filtern',
'query_builder' => function (EntityRepository $repository) {
return $repository->createQueryBuilder('job_profile')
->orderBy('job_profile.name', 'ASC');
->orderBy('job_profile.name', \SortDirection::Ascending);
},
])
->add('noTrainings', CheckboxType::class, [
@@ -52,5 +52,4 @@ class DriverLicenseDeclarationDto
return $this;
}
}
+17
View File
@@ -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;
}
}
+23
View File
@@ -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<int, UploadDto>
*/
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();
+3 -3
View File
@@ -231,7 +231,7 @@ class ApplicationRepository extends ServiceEntityRepository
->innerJoin('application.teamer', 'teamer')
->innerJoin('teamer.user', 'user')
->where($qb->expr()->neq('application.status', ':status'))
->orderBy('application.createdAt', 'DESC')
->orderBy('application.createdAt', \SortDirection::Descending)
->setParameter('status', Application::STATUS_REJECTED)
->setMaxResults($limit)
->getQuery()
@@ -280,8 +280,8 @@ class ApplicationRepository extends ServiceEntityRepository
->innerJoin('application.assignment', 'assignment')
->innerJoin('assignment.destination', 'destination')
->groupBy('hotelCode')
->orderBy('totalCount', 'DESC')
->addOrderBy('hotelCode', 'ASC')
->orderBy('totalCount', \SortDirection::Descending)
->addOrderBy('hotelCode', \SortDirection::Ascending)
;
SeasonPeriodFilter::apply($qb, $dateFrom, $dateTo);
+4 -4
View File
@@ -152,7 +152,7 @@ class AssignmentRepository extends ServiceEntityRepository
;
}
if (0 < count((array)$filterDto->getHotels())) {
if (0 < count((array) $filterDto->getHotels())) {
$constraints = [];
foreach ($filterDto->getHotels() as $index => $hotel) {
$constraints[] = $qb->expr()->like('destination.hotel', ':hotel'.$index);
@@ -293,7 +293,7 @@ class AssignmentRepository extends ServiceEntityRepository
$result = $qb
->select('assignment', 'job_profile')
->innerJoin('assignment.jobProfile', 'job_profile')
->orderBy('job_profile.name', 'ASC')
->orderBy('job_profile.name', \SortDirection::Ascending)
->where($qb->expr()->isNull('assignment.deletedAt'))
->getQuery()
->getResult()
@@ -314,7 +314,7 @@ class AssignmentRepository extends ServiceEntityRepository
->innerJoin('assignment.destination', 'destination')
->innerJoin('assignment.jobProfile', 'job_profile')
->innerJoin('assignment.owner', 'owner')
->orderBy('assignment.createdAt', 'DESC')
->orderBy('assignment.createdAt', \SortDirection::Descending)
->setMaxResults($limit)
->getQuery()
->getResult()
@@ -358,7 +358,7 @@ class AssignmentRepository extends ServiceEntityRepository
}
return $qb
->orderBy('destination.dateFrom', 'ASC')
->orderBy('destination.dateFrom', \SortDirection::Ascending)
->setMaxResults($limit)
->getQuery()
->getResult()
+3 -3
View File
@@ -35,7 +35,7 @@ class AvailabilityRepository extends ServiceEntityRepository
$qb->expr()->gt('availability.dateFrom', ':today'),
$qb->expr()->isNull('availability.deletedAt')
))
->orderBy('availability.dateFrom', 'ASC')
->orderBy('availability.dateFrom', \SortDirection::Ascending)
->setParameter('today', new \DateTimeImmutable())
;
@@ -116,7 +116,7 @@ class AvailabilityRepository extends ServiceEntityRepository
),
$qb->expr()->isNull('availability.deletedAt')
))
->orderBy('availability.dateFrom', 'ASC')
->orderBy('availability.dateFrom', \SortDirection::Ascending)
->setParameter('teamer', $teamer)
->setParameter('availabilities', $teamer->getAvailabilities())
->setParameter('today', new \DateTimeImmutable())
@@ -131,7 +131,7 @@ class AvailabilityRepository extends ServiceEntityRepository
return $qb
->where($qb->expr()->isNotNull('availability.owner'))
->orderBy('availability.createdAt', 'DESC')
->orderBy('availability.createdAt', \SortDirection::Descending)
->setMaxResults($limit)
->getQuery()
->getResult()
+3 -3
View File
@@ -28,7 +28,7 @@ class ContactRepository extends ServiceEntityRepository
return $qb
->leftJoin('contact.photo', 'photo')
->where($qb->expr()->isNull('contact.destination'))
->orderBy('contact.name', 'ASC')
->orderBy('contact.name', \SortDirection::Ascending)
->getQuery()
->getResult()
;
@@ -41,8 +41,8 @@ class ContactRepository extends ServiceEntityRepository
return $qb
->leftJoin('contact.photo', 'photo')
->where($qb->expr()->isNotNull('contact.destination'))
->orderBy('contact.name', 'ASC')
->addOrderBy('contact.destination', 'ASC')
->orderBy('contact.name', \SortDirection::Ascending)
->addOrderBy('contact.destination', \SortDirection::Ascending)
->getQuery()
->getResult()
;
+3 -3
View File
@@ -89,8 +89,8 @@ class DestinationRepository extends ServiceEntityRepository
}
$dates = $qb
->orderBy('destination.dateFrom', 'ASC')
->addOrderBy('destination.product', 'ASC')
->orderBy('destination.dateFrom', \SortDirection::Ascending)
->addOrderBy('destination.product', \SortDirection::Ascending)
->getQuery()
->getResult()
;
@@ -119,7 +119,7 @@ class DestinationRepository extends ServiceEntityRepository
return $qb
->select('destination.hotel', 'destination.hotelCode', 'destination.hotelBusProId')
->groupBy('destination.hotelBusProId', 'destination.hotel', 'destination.hotelCode')
->orderBy('destination.hotel', 'ASC')
->orderBy('destination.hotel', \SortDirection::Ascending)
->getQuery()
->getArrayResult()
;
+7 -7
View File
@@ -118,7 +118,7 @@ class DispositionRepository extends ServiceEntityRepository
->setParameter('teamer', $teamer)
->setParameter('assignmentStatus', Assignment::STATUS_CALLED_OFF)
->setParameter('dispositionStatus', Disposition::STATUS_CALLED_OFF)
->orderBy('destination.dateFrom', 'DESC')
->orderBy('destination.dateFrom', \SortDirection::Descending)
->setMaxResults($limit)
->getQuery()
->getResult()
@@ -173,7 +173,7 @@ class DispositionRepository extends ServiceEntityRepository
Disposition::STATUS_NEW,
Disposition::STATUS_CONFIRMED,
])
->orderBy('destination.dateFrom', 'ASC')
->orderBy('destination.dateFrom', \SortDirection::Ascending)
->getQuery()
->getResult()
;
@@ -198,7 +198,7 @@ class DispositionRepository extends ServiceEntityRepository
$qb->expr()->gte('destination.dateTo', ':dateTo'),
$qb->expr()->eq('disposition.status', ':status')
))
->orderBy('destination.dateFrom', 'ASC')
->orderBy('destination.dateFrom', \SortDirection::Ascending)
->setParameter('hotelCodes', $hotelCodes)
->setParameter('dateTo', new \DateTimeImmutable())
->setParameter('status', Disposition::STATUS_CONFIRMED)
@@ -300,7 +300,7 @@ class DispositionRepository extends ServiceEntityRepository
->innerJoin('assignment.destination', 'destination')
->innerJoin('assignment.jobProfile', 'job_profile')
->where($qb->expr()->gt('destination.dateFrom', ':dateFrom'))
->orderBy('disposition.createdAt', 'DESC')
->orderBy('disposition.createdAt', \SortDirection::Descending)
->setParameter('dateFrom', new \DateTimeImmutable())
->setMaxResults($limit)
->getQuery()
@@ -341,7 +341,7 @@ class DispositionRepository extends ServiceEntityRepository
Disposition::STATUS_COMPLETED,
])
->groupBy('destination.hotelCode', 'destination.hotel')
->orderBy('destination.hotel', 'ASC')
->orderBy('destination.hotel', \SortDirection::Ascending)
;
SeasonPeriodFilter::apply($qb, $dateFrom, $dateTo);
@@ -423,7 +423,7 @@ class DispositionRepository extends ServiceEntityRepository
Disposition::STATUS_COMPLETED,
])
->groupBy('hotelCode')
->orderBy('hotelCode', 'ASC')
->orderBy('hotelCode', \SortDirection::Ascending)
;
SeasonPeriodFilter::apply($qb, $dateFrom, $dateTo);
@@ -643,7 +643,7 @@ class DispositionRepository extends ServiceEntityRepository
->setParameter('dueDate', $dueDate)
->setParameter('status', Disposition::STATUS_NEW)
->setParameter('assignmentStatus', [Assignment::STATUS_CALLED_OFF, Assignment::STATUS_DELETED])
->orderBy('disposition.createdAt', 'ASC')
->orderBy('disposition.createdAt', \SortDirection::Ascending)
->getQuery()
;
}
+1 -1
View File
@@ -27,7 +27,7 @@ class FeeRepository extends ServiceEntityRepository
return $qb
->where($qb->expr()->isNull('fee.deletedAt'))
->orderBy('fee.name', 'ASC')
->orderBy('fee.name', \SortDirection::Ascending)
->getQuery()
->getResult()
;
+5 -5
View File
@@ -59,9 +59,9 @@ class FeedbackRepository extends ServiceEntityRepository
->innerJoin('feedback.feedbackSet', 'feedbackSet')
->andWhere($qb->expr()->eq('feedback.status', ':status'))
->setParameter('status', Feedback::STATUS_PUBLISHED)
->orderBy('teamer.lastName', 'ASC')
->addOrderBy('teamer.firstName', 'ASC')
->addOrderBy('feedback.assignmentDateFrom', 'ASC')
->orderBy('teamer.lastName', \SortDirection::Ascending)
->addOrderBy('teamer.firstName', \SortDirection::Ascending)
->addOrderBy('feedback.assignmentDateFrom', \SortDirection::Ascending)
;
$this->applyFilterCriteria($qb, $filterDto);
@@ -118,7 +118,7 @@ class FeedbackRepository extends ServiceEntityRepository
->innerJoin('teamer.user', 'user')
->where($qb->expr()->eq('feedback.status', ':status'))
->setParameter('status', Feedback::STATUS_NEW)
->orderBy('feedback.createdAt', 'DESC')
->orderBy('feedback.createdAt', \SortDirection::Descending)
->setMaxResults($limit)
->getQuery()
->getResult()
@@ -149,7 +149,7 @@ class FeedbackRepository extends ServiceEntityRepository
))
->setParameter('teamer', $teamer)
->setParameter('status', Feedback::STATUS_PUBLISHED)
->orderBy('feedback.createdAt', 'DESC')
->orderBy('feedback.createdAt', \SortDirection::Descending)
->setMaxResults($limit)
->getQuery()
->getResult()
+2 -2
View File
@@ -29,7 +29,7 @@ class JobProfileRepository extends ServiceEntityRepository
->select('job_profile', 'required_training')
->leftJoin('job_profile.requiredTrainings', 'required_training')
->where($qb->expr()->isNull('job_profile.deletedAt'))
->orderBy('job_profile.name', 'ASC')
->orderBy('job_profile.name', \SortDirection::Ascending)
->getQuery()
->getResult()
;
@@ -41,7 +41,7 @@ class JobProfileRepository extends ServiceEntityRepository
return $qb
->where($qb->expr()->like('job_profile.requiredLicenses', ':licenseType'))
->orderBy('job_profile.name', 'ASC')
->orderBy('job_profile.name', \SortDirection::Ascending)
->setParameter('licenseType', '%'.$licenseType.'%')
->getQuery()
->getResult()
+2 -2
View File
@@ -22,8 +22,8 @@ class NewsRepository extends ServiceEntityRepository
return $qb
->where($qb->expr()->neq('news.status', ':status'))
->orderBy('news.status', 'DESC')
->addOrderBy('news.createdAt', 'DESC')
->orderBy('news.status', \SortDirection::Descending)
->addOrderBy('news.createdAt', \SortDirection::Descending)
->setParameter('status', News::STATUS_DEACTIVATED)
->setMaxResults(1)
->getQuery()
+1 -1
View File
@@ -91,7 +91,7 @@ class StatisticsEventRepository extends ServiceEntityRepository
->where($qb->expr()->eq('event.name', ':name'))
->setParameter('name', $name->value)
->groupBy('value')
->orderBy('total', 'DESC')
->orderBy('total', \SortDirection::Descending)
;
$filtered = null !== $dateFrom || null !== $dateTo;
+5 -5
View File
@@ -35,8 +35,8 @@ class TeamerRepository extends ServiceEntityRepository
{
return $this
->createListQueryBuilder($filterDto)
->addOrderBy('teamer.viewed', 'asc')
->addOrderBy('teamer.lastName', 'asc')
->addOrderBy('teamer.viewed', \SortDirection::Ascending)
->addOrderBy('teamer.lastName', \SortDirection::Ascending)
->getQuery()
;
}
@@ -67,7 +67,7 @@ class TeamerRepository extends ServiceEntityRepository
)
// teamer.viewed says nothing about a mailing and a DISTINCT select cannot order
// by a field it does not carry, so this list sorts by name alone
->addOrderBy('teamer.lastName', 'asc')
->addOrderBy('teamer.lastName', \SortDirection::Ascending)
->getQuery()
->getArrayResult()
;
@@ -177,8 +177,8 @@ class TeamerRepository extends ServiceEntityRepository
$qb->expr()->like('teamer.firstName', ':search'),
))
->andWhere($qb->expr()->isNull('teamer.deletedAt'))
->orderBy('teamer.lastName', 'ASC')
->addOrderBy('teamer.firstName', 'ASC')
->orderBy('teamer.lastName', \SortDirection::Ascending)
->addOrderBy('teamer.firstName', \SortDirection::Ascending)
->setParameter('search', '%'.$this->escapeLikeWildcards($search).'%')
->getQuery()
->getResult()
+1 -1
View File
@@ -29,7 +29,7 @@ class TrainingRepository extends ServiceEntityRepository
->select('training', 'training_attendance')
->leftJoin('training.trainingAttendances', 'training_attendance')
->where($qb->expr()->isNull('training.deletedAt'))
->orderBy('training.name', 'ASC')
->orderBy('training.name', \SortDirection::Ascending)
->getQuery()
->getResult()
;
+13 -4
View File
@@ -55,7 +55,7 @@ class UploadRepository extends ServiceEntityRepository
$qb->expr()->like('upload.displayName', ':filename')
))
->setParameter('filename', '%'.$this->escapeLikeWildcards($search).'%')
->orderBy('upload.originalFilename', 'ASC')
->orderBy('upload.originalFilename', \SortDirection::Ascending)
->getQuery()
->getResult()
;
@@ -97,7 +97,7 @@ class UploadRepository extends ServiceEntityRepository
$qb->expr()->eq('upload.status', ':status'),
$qb->expr()->isNull('upload.downloadedAt'),
))
->orderBy('upload.createdAt', 'DESC')
->orderBy('upload.createdAt', \SortDirection::Descending)
->setParameter('type', $type)
->setParameter('status', $status)
->getQuery()
@@ -126,7 +126,7 @@ class UploadRepository extends ServiceEntityRepository
->innerJoin('assignment.destination', 'destination')
->innerJoin('disposition.teamer', 'teamer')
->innerJoin('teamer.user', 'user')
->addOrderBy('FIELD(upload.status, :checked, :new)', 'DESC')
->addOrderBy('FIELD(upload.status, :checked, :new)', \SortDirection::Descending)
->setParameter('new', Upload::STATUS_NEW)
->setParameter('checked', Upload::STATUS_CHECKED)
;
@@ -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()) {
@@ -198,7 +207,7 @@ class UploadRepository extends ServiceEntityRepository
$qb->expr()->eq('upload.type', ':type'),
$qb->expr()->in('upload.status', ':status')
))
->orderBy('upload.createdAt', 'DESC')
->orderBy('upload.createdAt', \SortDirection::Descending)
->setParameter('type', $type)
->setParameter('status', [Upload::STATUS_NEW, Upload::STATUS_PENDING])
->getQuery()
+5 -5
View File
@@ -55,7 +55,7 @@ class UserRepository extends ServiceEntityRepository
// andWhere after a chain of orWhere yields "(... OR ...) AND ...", so this
// narrows the whole list rather than widening it by one more alternative
->andWhere($qb->expr()->isNull('u.deletedAt'))
->orderBy('u.lastName', 'ASC')
->orderBy('u.lastName', \SortDirection::Ascending)
->getQuery()
->getResult()
;
@@ -83,8 +83,8 @@ class UserRepository extends ServiceEntityRepository
->andWhere($roleMatches)
->andWhere($qb->expr()->isNull('user.deletedAt'))
->andWhere($qb->expr()->isNull('user.disabledAt'))
->orderBy('user.lastName', 'ASC')
->addOrderBy('user.firstName', 'ASC')
->orderBy('user.lastName', \SortDirection::Ascending)
->addOrderBy('user.firstName', \SortDirection::Ascending)
->getQuery()
->getResult()
;
@@ -132,8 +132,8 @@ class UserRepository extends ServiceEntityRepository
))
->andWhere('JSON_CONTAINS(user.roles, :role) = 1')
->andWhere($qb->expr()->isNull('user.deletedAt'))
->orderBy('user.lastName', 'ASC')
->addOrderBy('user.firstName', 'ASC')
->orderBy('user.lastName', \SortDirection::Ascending)
->addOrderBy('user.firstName', \SortDirection::Ascending)
->setParameter('search', '%'.$this->escapeLikeWildcards($search).'%')
->setParameter('role', json_encode($role))
->getQuery()
@@ -3,7 +3,6 @@
namespace App\RequiredTeamerCheck;
use App\Entity\User;
use App\RequiredTeamerCheck\RequiredTeamerCheckInterface;
class RequiredTeamerCheckRegistry
{
@@ -19,4 +19,4 @@ class AuthorizationRequestException extends \Exception
{
return $this->request;
}
}
}
+34
View File
@@ -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')) {
+1 -1
View File
@@ -16,7 +16,7 @@ class IcsGenerator
{
$assignment = $this->disposition->getAssignment();
$calendar = Calendar::create('MyE&P Team');
$calendar = Calendar::create('E&P Team');
$label = sprintf('E&P: Einteilung als %s', $assignment->getJobProfile()->getName());
$address = sprintf("%s\n%s", $assignment->getDestination()->getProduct(), $assignment->getDestination()->getHotel());
@@ -5,7 +5,6 @@ namespace App\Service\Cron;
use App\BusProNet\DataProvider\HotelDataProvider;
use App\Email\Mailer;
use App\Entity\Disposition;
use App\Entity\User;
use App\Repository\DispositionRepository;
use App\Repository\UserRepository;
use Psr\Log\LoggerInterface;
@@ -0,0 +1,100 @@
<?php
namespace App\Service\Statistics\Collector;
use App\Entity\Application;
use App\Entity\Assignment;
use App\Entity\Disposition;
use App\Entity\Teamer;
use App\Enum\ApplicationDeletionOutcome;
use App\Enum\StatisticsEventName;
use App\Service\Statistics\CollectedStatisticsEvent;
use App\Service\Statistics\StatisticsDimensions;
use App\Service\Statistics\StatisticsFlush;
/**
* An application leaving the table, and whether that was a loss.
*
* Applications are hard deleted with no cascade and no soft delete behind them, so until this
* existed an application.created row had no counterpart and nothing but the audit log said
* where the application went. Read from the flush rather than from the four controllers and
* listeners that remove one, so every path is counted.
*
* The outcome is the point. Staffing somebody deletes their application too - the dispose
* controller persists the placement and removes the application in the same flush - so
* counting deletions alone would read every success as a lost application.
*
* Dimensions are resolved here, during onFlush: by postFlush the entity is detached and its
* id nulled, so StatisticsRecorder could not resolve them from the subject.
*/
class ApplicationDeletedCollector implements StatisticsCollectorInterface
{
public function collect(StatisticsFlush $flush): iterable
{
$placements = $this->placementsCreatedIn($flush);
foreach ($flush->deletionsOf(Application::class) as $application) {
$key = $this->key($application->getAssignment(), $application->getTeamer());
$dispositionUuid = null !== $key ? ($placements[$key] ?? null) : null;
yield new CollectedStatisticsEvent(
StatisticsEventName::APPLICATION_DELETED,
$application,
[
'outcome' => (null !== $dispositionUuid
? ApplicationDeletionOutcome::DISPOSED
: ApplicationDeletionOutcome::REMOVED)->value,
'previous_status' => $application->getStatus(),
// The only handle that outlives the row - application_id points at a
// deleted record - and the same value LoggingSubscriber writes to the
// audit log, which is what makes the two joinable.
'application_uuid' => $application->getUuid(),
'disposition_uuid' => $dispositionUuid,
],
StatisticsDimensions::forApplication($application),
);
}
}
/**
* The placements created in this flush, by the teamer and assignment they are for.
*
* Keyed on uuids rather than ids or object identity: a disposition being inserted has no
* id yet at this point, and the application it came from loses its own the moment the
* delete commits. Disposition::__construct() copies the assignment and the teamer off the
* application without keeping a reference back to it, so that pair is all there is to
* match on - and a teamer holds at most one application per overlapping period, which is
* what ApplicationValidator enforces.
*
* @return array<string, string>
*/
private function placementsCreatedIn(StatisticsFlush $flush): array
{
$placements = [];
foreach ($flush->insertionsOf(Disposition::class) as $disposition) {
$key = $this->key($disposition->getAssignment(), $disposition->getTeamer());
if (null === $key) {
continue;
}
$placements[$key] = $disposition->getUuid();
}
return $placements;
}
/**
* Null when either side is missing: two half-anonymous records must not match each other
* on the strength of what they both lack.
*/
private function key(?Assignment $assignment, ?Teamer $teamer): ?string
{
if (null === $assignment || null === $teamer) {
return null;
}
return sprintf('%s:%s', $assignment->getUuid(), $teamer->getUuid());
}
}
+24 -2
View File
@@ -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;
}
-1
View File
@@ -7,7 +7,6 @@ use App\BusProNet\DataProvider\PickupDataProvider;
use App\Entity\Teamer;
use App\Entity\Upload;
use App\Service\Teamer\PickupResolver;
use App\Service\Upload\UploadHandler;
use Carbon\CarbonImmutable;
use Symfony\Component\HttpFoundation\RequestStack;
use Symfony\Contracts\Translation\TranslatorInterface;
+47
View File
@@ -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') %}
<div class="pt-8">
<h3 class="text-lg font-bold pb-2">
Belege
</h3>
<div class="text-sm pb-4">
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.
</div>
<ul class="list-disc pl-4 pb-4">
{% for receipt in receipts %}
<li>
<div class="flex items-center space-x-4">
<span>{{ receipt }}</span>
<a href="{{ path('app_common_download', { 'uuid': receipt.uuid, 'inline': true }) }}"
target="_blank"
title="Beleg öffnen">
{{ icon('download', 'w-4 h-4') }}
</a>
<button type="button"
class="text-red-500"
title="Beleg löschen"
hx-get="{{ path('app_teamer_disposition_receipt_delete', { 'uuid': receipt.uuid }) }}"
hx-target="body"
hx-swap="beforeend">
{{ icon('delete', 'w-4 h-4') }}
</button>
</div>
</li>
{% else %}
<li class="text-sm">
Du hast bisher keine Belege hochgeladen
</li>
{% endfor %}
</ul>
<button type="button"
class="btn"
title="Beleg hinzufügen"
hx-get="{{ path('app_teamer_disposition_receipt_upload', { 'uuid': disposition.uuid }) }}"
hx-target="body"
hx-swap="beforeend">
Belege hinzufügen
</button>
</div>
@@ -13,6 +13,26 @@
</h2>
{{ icon('eye') }}
</a>
{# 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 %}
<div class="pb-4">
<div class="font-bold pb-1">Belege</div>
<ul class="list-disc pl-4">
{% for receipt in receipts %}
<li>
<a href="{{ path('app_common_download', { 'uuid': receipt.uuid, 'inline': true }) }}"
target="_blank"
class="underline"
title="Beleg öffnen">
{{ receipt }}
</a>
</li>
{% endfor %}
</ul>
</div>
{% endif %}
<div class="flex flex-col space-y-4 pb-4">
{{ form_row(form.status) }}
{% if form.specialAgreements is defined %}
+1 -1
View File
@@ -3,7 +3,7 @@
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>{% block title %}MyE&amp;P Team{% endblock %}</title>
<title>{% block title %}E&amp;P Team{% endblock %}</title>
<link rel="icon" href="{{ asset('favicon.ico') }}" sizes="any">
<link rel="icon" href="{{ asset('icon.svg') }}" type="image/svg+xml">
<link rel="apple-touch-icon" href="{{ asset('apple-touch-icon.png') }}">
+3 -3
View File
@@ -2,7 +2,7 @@
<html lang="und" dir="auto" xmlns="http://www.w3.org/1999/xhtml" xmlns:v="urn:schemas-microsoft-com:vml" xmlns:o="urn:schemas-microsoft-com:office:office">
<head>
<title>MyE&amp;P Team</title>
<title>E&amp;P Team</title>
<!--[if !mso]><!-->
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<!--<![endif]-->
@@ -181,8 +181,8 @@
</head>
<body style="word-spacing:normal;background-color:#666666;">
<div style="display:none;font-size:1px;color:#ffffff;line-height:1px;max-height:0px;max-width:0px;opacity:0;overflow:hidden;">MyE&amp;P Team</div>
<div aria-label="MyE&amp;P Team" aria-roledescription="email" style="background-color:#666666;" role="article" lang="und" dir="auto">
<div style="display:none;font-size:1px;color:#ffffff;line-height:1px;max-height:0px;max-width:0px;opacity:0;overflow:hidden;">E&amp;P Team</div>
<div aria-label="E&amp;P Team" aria-roledescription="email" style="background-color:#666666;" role="article" lang="und" dir="auto">
<!--[if mso | IE]><table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width:600px;" width="600" ><tr><td style="line-height:0;font-size:0;mso-line-height-rule:exactly;"><v:image style="border:0;mso-position-horizontal:center;position:absolute;top:0;width:600px;z-index:-3;" xmlns:v="urn:schemas-microsoft-com:vml" /><![endif]-->
<div style="margin:0 auto;max-width:600px;">
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="width:100%;">
+1 -1
View File
@@ -4,7 +4,7 @@
<div class="max-w-screen-2xl mx-auto px-4 lg:px-8 lg:grid lg:grid-cols-4 gap-8 py-8">
<div class="lg:col-span-4 flex items-center justify-between pb-4 lg:pb-0">
<a href="{{ path(app.user ? app.user.defaultRoute : 'app_security_login') }}">
<img class="h-8 w-auto" src="{{ asset('build/images/logo.svg') }}" alt="My E&amp;P Team">
<img class="h-8 w-auto" src="{{ asset('build/images/logo.svg') }}" alt="E&amp;P Team">
</a>
{% if app.user %}
<div class="flex items-center space-x-4">
+1 -1
View File
@@ -5,7 +5,7 @@
<div class="w-full max-w-md p-8 bg-gray-100 border border-gray-200 rounded-md">
<h1 class="flex items-center justify-between mb-6">
<img class="h-10 w-auto" src="{{ asset('build/images/logo.svg') }}" alt="My E&amp;P Team">
<span class="text-3xl md:text-4xl leading-none font-bold text-gray-900">MyE&amp;P Team</span>
<span class="text-3xl md:text-4xl leading-none font-bold text-gray-900">E&amp;P Team</span>
</h1>
<h2 class="text-2xl font-bold mb-8">
...ist vorübergehend außer Betrieb
+2 -2
View File
@@ -4,8 +4,8 @@
<div class="w-full h-screen flex flex-col items-center justify-center px-4">
<div class="w-full max-w-md p-8 bg-gray-100 border border-gray-200 rounded-md">
<h1 class="flex items-center justify-between mb-6">
<img class="h-10 w-auto" src="{{ asset('build/images/logo.svg') }}" alt="My E&amp;P Team">
<span class="text-3xl md:text-4xl leading-none font-bold text-gray-900">MyE&amp;P Team</span>
<img class="h-10 w-auto" src="{{ asset('build/images/logo.svg') }}" alt="E&amp;P Team">
<span class="text-3xl md:text-4xl leading-none font-bold text-gray-900">E&amp;P Team</span>
</h1>
{% if error %}
<div class="flex items-center space-x-1 bg-red-500 text-white p-2 rounded-md mb-6">
+3 -3
View File
@@ -4,8 +4,8 @@
<div class="w-full h-screen flex flex-col items-center justify-center px-4">
<div class="w-full max-w-md p-8 bg-gray-100 border border-gray-200 rounded-md">
<h1 class="flex items-center justify-between mb-6">
<span class="text-3xl md:text-4xl leading-none font-bold text-gray-900">MyE&amp;P Team</span>
<img class="h-10 w-auto" src="{{ asset('build/images/logo.svg') }}" alt="My E&amp;P Team">
<span class="text-3xl md:text-4xl leading-none font-bold text-gray-900">E&amp;P Team</span>
<img class="h-10 w-auto" src="{{ asset('build/images/logo.svg') }}" alt="E&amp;P Team">
</h1>
{{ form_start(form) }}
<div class="mb-6">
@@ -28,4 +28,4 @@
</div>
</div>
{% endblock %}
{% endblock %}
+2 -2
View File
@@ -37,7 +37,7 @@
Kontakt
</h1>
<p class="pb-4">
Hast du allgemeine Fragen zum Team-Sein bei E&amp;P Reisen, zur Benutzung von MyE&amp;P Team, zur Abrechnung
Hast du allgemeine Fragen zum Team-Sein bei E&amp;P Reisen, zur Benutzung von E&amp;P Team, zur Abrechnung
oder Ähnlichem? Dann schau mal in den FAQs nach, ob deine Frage dort beantwortet wird.
</p>
<p class="pb-4">
@@ -70,4 +70,4 @@
</div>
</div>
</div>
{% endblock %}
{% endblock %}
@@ -122,6 +122,11 @@
<twig:MessageBox message="{{ blocker.message }}"/>
{% 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 @@
</div>
{% endif %}
{% endif %}
{% if is_granted('MANAGE_RECEIPTS', disposition) %}
{% include '_partials/_receipts.html.twig' %}
{% endif %}
{% endif %}
{# Invoice paid #}
@@ -0,0 +1,7 @@
{% extends 'htmx_confirmation_modal.html.twig' %}
{% block content %}
<div>
Möchtest du den Beleg <em>{{ receipt }}</em> wirklich löschen?
</div>
{% endblock %}
@@ -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()) }) }}
<div class="pb-4">
Lade hier die Belege zu den Auslagen hoch, die du in deiner Honorarnote aufführst.
</div>
<div class="pb-4">
{% 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',
} %}
</div>
<button type="submit" class="btn" {{ stimulus_target('form-upload-guard', 'submit') }}>
Belege speichern
</button>
{{ form_rest(form) }}
{{ form_end(form) }}
{% endblock %}
+39
View File
@@ -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,
@@ -6,13 +6,16 @@ namespace App\Tests\EventListener;
use App\Entity\Application;
use App\Entity\Assignment;
use App\Entity\Destination;
use App\Entity\Disposition;
use App\Entity\JobProfile;
use App\Entity\Teamer;
use App\Enum\ApplicationDeletionOutcome;
use App\Enum\CallOffScope;
use App\Enum\StatisticsEventName;
use App\EventListener\StatisticsChangeSetListener;
use App\Service\Statistics\Collector\ApplicationCreatedCollector;
use App\Service\Statistics\Collector\ApplicationDeletedCollector;
use App\Service\Statistics\Collector\CallOffCollector;
use App\Service\Statistics\Collector\DispositionDeletedCollector;
use App\Service\Statistics\Collector\JobProfileChangeCollector;
@@ -345,6 +348,111 @@ class StatisticsChangeSetListenerTest extends TestCase
$this->flush([[$this->disposition(), ['remarks' => ['alt', 'neu']]]]);
}
/**
* A teamer withdrawing leaves nothing behind - the application row is hard deleted - so
* this is the only trace that they applied and then thought better of it.
*/
public function testRecordsAWithdrawnApplicationAsALoss(): void
{
$application = new Application(new Assignment(), new Teamer());
$application->setStatus(Application::STATUS_PENDING);
$this->recorder
->expects($this->once())
->method('record')
->with(
StatisticsEventName::APPLICATION_DELETED,
$this->anything(),
$this->callback(static function (array $payload) use ($application): bool {
return ApplicationDeletionOutcome::REMOVED->value === $payload['outcome']
&& Application::STATUS_PENDING === $payload['previous_status']
&& $application->getUuid() === $payload['application_uuid']
&& null === $payload['disposition_uuid'];
})
)
;
$this->flush([], [], [$application]);
}
/**
* Staffing somebody deletes their application too: the dispose controller persists the
* placement and removes the application in one flush. Counting that as a lost application
* would turn every success into a failure.
*/
public function testAnApplicationTurnedIntoAPlacementIsNotALoss(): void
{
$assignment = new Assignment();
$teamer = new Teamer();
$application = new Application($assignment, $teamer);
$disposition = new Disposition($application);
$this->recorder
->expects($this->once())
->method('record')
->with(
StatisticsEventName::APPLICATION_DELETED,
$this->anything(),
$this->callback(static function (array $payload) use ($disposition): bool {
return ApplicationDeletionOutcome::DISPOSED->value === $payload['outcome']
&& $disposition->getUuid() === $payload['disposition_uuid'];
})
)
;
$this->flush([], [$disposition], [$application]);
}
/**
* Two unrelated things happening in one flush do not make one the cause of the other. The
* application still counts as lost.
*/
public function testAPlacementForSomebodyElseDoesNotExcuseTheDeletion(): void
{
$assignment = new Assignment();
$application = new Application($assignment, new Teamer());
$disposition = new Disposition(new Application($assignment, new Teamer()));
$this->recorder
->expects($this->once())
->method('record')
->with(
StatisticsEventName::APPLICATION_DELETED,
$this->anything(),
$this->callback(static function (array $payload): bool {
return ApplicationDeletionOutcome::REMOVED->value === $payload['outcome']
&& null === $payload['disposition_uuid'];
})
)
;
$this->flush([], [$disposition], [$application]);
}
/**
* The dimensions have to be frozen while the entity is still whole. Going through
* recordForApplication() instead would resolve them in postFlush, by which time Doctrine
* has detached the application and nulled its id, and the row would name nothing.
*/
public function testADeletedApplicationCarriesDimensionsFrozenDuringTheFlush(): void
{
$assignment = (new Assignment())->setDestination((new Destination())->setHotelCode('SERZIL'));
$this->recorder->expects($this->never())->method('recordForApplication');
$this->recorder
->expects($this->once())
->method('record')
->with(
StatisticsEventName::APPLICATION_DELETED,
$this->callback(static fn (array $dimensions): bool => 'ZIL' === $dimensions['hotel_code']),
$this->anything()
)
;
$this->flush([], [], [new Application($assignment, new Teamer())]);
}
/**
* Editing an application later is not a second application.
*/
@@ -423,6 +531,7 @@ class StatisticsChangeSetListenerTest extends TestCase
new CallOffCollector(),
new DispositionDeletedCollector(),
new ApplicationCreatedCollector(),
new ApplicationDeletedCollector(),
]);
}
+92
View File
@@ -0,0 +1,92 @@
<?php
declare(strict_types=1);
namespace App\Tests\Model;
use App\Entity\Upload;
use App\Model\UploadDto;
use App\Model\UploadSessionDto;
use PHPUnit\Framework\TestCase;
/**
* The upload session is one flat list shared by every dropzone on a page, and the teamer's
* disposition detail page has two of them at once: the Honorarnote and its Belege. Before the
* uploads carried their mapping, a consumer could only take getUploads()->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<int, UploadDto> $uploads
*
* @return array<int, string>
*/
private function filenames(iterable $uploads): array
{
$filenames = [];
foreach ($uploads as $upload) {
$filenames[] = $upload->getFilename();
}
return $filenames;
}
}
@@ -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<string, array{string, bool}>
*/
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<string, array{bool, bool}>
*/
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(
+1
View File
@@ -20,6 +20,7 @@ label:
certificate: Lizenznachweis
photo: Foto
driver_license: Führerschein
receipt: Beleg
status:
new: neu
pending: in Bearbeitung