Merge branch 'master' into develop

This commit is contained in:
Björn Fromme
2025-01-02 13:03:36 +01:00
33 changed files with 633 additions and 378 deletions
+66
View File
@@ -0,0 +1,66 @@
<?php
namespace App\Command;
use App\Entity\Teamer;
use Doctrine\ORM\EntityManagerInterface;
use Liip\ImagineBundle\Imagine\Cache\CacheManager;
use Liip\ImagineBundle\Service\FilterService;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
#[AsCommand(
name: 'app:generate-thumbnails',
description: 'Generates thumbnails of profile images',
)]
class GenerateThumbnailsCommand extends Command
{
public function __construct(
private readonly EntityManagerInterface $entityManager,
private readonly CacheManager $cacheManager,
private readonly FilterService $filterService,
) {
parent::__construct();
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$io = new SymfonyStyle($input, $output);
$images = $this
->entityManager
->getRepository(Teamer::class)
->createQueryBuilder('t')
->select('t.id', 'p.filename')
->innerJoin('t.photo', 'p')
->getQuery()
->getArrayResult()
;
$imagesCount = count($images);
$io->info('Generating thumbnails for '.$imagesCount.' profile images...');
$progressbar = $io->createProgressBar($imagesCount);
$progressbar->start();
foreach ($images as $image) {
try {
$this->filterService->warmUpCache($image['filename'], 'thumbnail');
$this->filterService->warmUpCache($image['filename'], 'profile');
$this->cacheManager->getBrowserPath($image['filename'], 'thumbnail');
$this->cacheManager->getBrowserPath($image['filename'], 'profile');
} catch (\Exception $e) {
}
$progressbar->advance();
}
$progressbar->finish();
return Command::SUCCESS;
}
}
+2
View File
@@ -45,6 +45,7 @@ class IndexController extends AbstractController
$dispositionRepository = $this->entityManager->getRepository(Disposition::class);
$overdueContracts = $dispositionRepository->findOverdueContracts();
$newDispositions = $dispositionRepository->getNew();
$overdueFeedbacks = $dispositionRepository->findDispositionsWithOverdueFeedback(7);
return $this->render('admin/index.html.twig', [
'applications' => $applications,
@@ -59,6 +60,7 @@ class IndexController extends AbstractController
'pendingDocumentsCount' => $pendingDocumentsCount,
'overdueContracts' => $overdueContracts,
'newDispositions' => $newDispositions,
'overdueFeedbacks' => $overdueFeedbacks,
]);
}
}
@@ -5,6 +5,7 @@ namespace App\Controller\Administrative\Teamer;
use App\Entity\Teamer;
use App\Repository\DispositionRepository;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\ExpressionLanguage\Expression;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;
@@ -15,7 +16,7 @@ class InfoController extends AbstractController
{}
#[Route('/administrative/teamer/info/{uuid}', name: 'app_administrative_teamer_info')]
#[IsGranted('ROLE_ADMINISTRATIVE')]
#[IsGranted(new Expression('is_granted("ROLE_ADMINISTRATIVE") or is_granted("ROLE_HOUSE_MANAGER")'))]
public function index(Teamer $teamer): Response
{
$recentDispositions = $this->dispositionRepository->findRecentDispositionsByTeamer($teamer);
@@ -2,8 +2,6 @@
namespace App\Controller\HouseManager\Feedback;
use App\BusProNet\DataProvider\HotelDataProvider;
use App\BusProNet\Model\Hotel;
use App\Entity\User;
use App\Repository\DispositionRepository;
use Knp\Component\Pager\PaginatorInterface;
@@ -17,7 +15,6 @@ class IndexController extends AbstractController
{
public function __construct(
private readonly DispositionRepository $dispositionRepository,
private readonly HotelDataProvider $hotelDataProvider,
private readonly PaginatorInterface $paginator
) {
}
@@ -29,18 +26,9 @@ class IndexController extends AbstractController
/** @var User $user */
$user = $this->getUser();
$hotels = $this
->hotelDataProvider
->findByCode($user->getHotelCode())
;
$hotelBusProIds = array_map(function (Hotel $hotel) {
return $hotel->getBusProId();
}, $hotels);
$query = $this
->dispositionRepository
->getDispositionsWithPendingFeedbackQuery($hotelBusProIds)
->getDispositionsWithPendingFeedbackQuery($user->getHotelCode())
;
$pagination = $this->paginator->paginate(
@@ -3,7 +3,6 @@
namespace App\Controller\HouseManager;
use App\BusProNet\DataProvider\HotelDataProvider;
use App\BusProNet\Model\Hotel;
use App\Entity\User;
use App\Repository\DispositionRepository;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
@@ -31,18 +30,14 @@ class IndexController extends AbstractController
->findByCode($user->getHotelCode())
;
$hotelBusProIds = array_map(function (Hotel $hotel) {
return $hotel->getBusProId();
}, $hotels);
$newDispositions = $this
->dispositionRepository
->findNewDispositionsByHotelBusProIds($hotelBusProIds)
->findNewDispositionsByHotelCode($user->getHotelCode())
;
$pendingFeedbacks = $this
->dispositionRepository
->findDispositionsWithPendingFeedbackByHotelBusProIds($hotelBusProIds)
->findDispositionsWithPendingFeedbackByHotelCode($user->getHotelCode())
;
return $this->render('house_manager/index.html.twig', [
@@ -10,6 +10,7 @@ use Symfony\Component\HttpFoundation\HeaderUtils;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;
use Symfony\Component\String\Slugger\AsciiSlugger;
class ContractPdfController extends AbstractController
{
@@ -26,12 +27,19 @@ class ContractPdfController extends AbstractController
{
$pdf = $this->renderer->render($disposition);
$teamer = $disposition->getTeamer();
$assignment = $disposition->getAssignment();
$destination = $assignment->getDestination();
$slugger = new AsciiSlugger('de');
$filename = sprintf('Honorarvertrag_%s_%s.pdf', $teamer, $destination);
$disposition = HeaderUtils::makeDisposition(HeaderUtils::DISPOSITION_ATTACHMENT, $filename, md5($filename));
$teamer = $disposition->getTeamer();
$teamerPart = $slugger->slug($teamer);
$assignment = $disposition->getAssignment();
$destination = $slugger->slug($assignment->getDestination());
$filename = sprintf('Honorarvertrag_%s_%s.pdf', $teamerPart, $destination);
$disposition = HeaderUtils::makeDisposition(
HeaderUtils::DISPOSITION_ATTACHMENT,
$filename,
md5($filename)
);
$response = new Response($pdf->Output('S'));
$response->setPrivate();
@@ -10,6 +10,7 @@ use Symfony\Component\HttpFoundation\HeaderUtils;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;
use Symfony\Component\String\Slugger\AsciiSlugger;
class InvoicePdfController extends AbstractController
{
@@ -26,12 +27,19 @@ class InvoicePdfController extends AbstractController
{
$pdf = $this->renderer->render($disposition);
$assignment = $disposition->getAssignment();
$destination = $assignment->getDestination();
$teamer = $disposition->getTeamer();
$slugger = new AsciiSlugger('de');
$filename = sprintf('Honorarnote_%s_%s.pdf', $teamer, $disposition->getAssignment()->getDestination());
$contentDisposition = HeaderUtils::makeDisposition(HeaderUtils::DISPOSITION_ATTACHMENT, $filename, md5($filename));
$teamer = $disposition->getTeamer();
$teamerPart = $slugger->slug($teamer);
$assignment = $disposition->getAssignment();
$destination = $slugger->slug($assignment->getDestination());
$filename = sprintf('Honorarnote_%s_%s.pdf', $teamerPart, $disposition->getAssignment()->getDestination());
$contentDisposition = HeaderUtils::makeDisposition(
HeaderUtils::DISPOSITION_ATTACHMENT,
$filename,
md5($filename)
);
$response = new Response($pdf->Output('S'));
$response->setPrivate();
@@ -55,7 +55,6 @@ class IndexController extends AbstractController
$form = $this->createForm(TeamerProfileType::class, $teamer, [
'upload_session' => $uploadSession,
'admin_mode' => $this->isGranted('IS_IMPERSONATOR'),
]);
$form->handleRequest($request);
+5 -1
View File
@@ -175,8 +175,12 @@ class Destination implements TimestampableEntityInterface, SoftDeletableEntityIn
return $this;
}
public function getPickup(int $id): ?Pickup
public function getPickup(?int $id): ?Pickup
{
if (null === $id) {
return null;
}
foreach ($this->getPickups() as $pickup) {
if ($id === $pickup['busProId']) {
return Pickup::fromArray($pickup);
@@ -0,0 +1,57 @@
<?php
namespace App\EventListener;
use App\Entity\Application;
use App\Entity\Upload;
use App\Event\ApplicationStatusEvent;
use App\Event\DocumentConfirmedEvent;
use App\Model\ApplicationStatusDto;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\EventDispatcher\Attribute\AsEventListener;
use Symfony\Contracts\EventDispatcher\EventDispatcherInterface;
#[AsEventListener(event: DocumentConfirmedEvent::NAME, method: 'onDocumentConfirmed')]
class RejectApplicationListener
{
public function __construct(
private readonly EventDispatcherInterface $eventDispatcher,
private readonly EntityManagerInterface $entityManager,
) {
}
public function onDocumentConfirmed(DocumentConfirmedEvent $event): void
{
$document = $event->getDocument();
if (Upload::TYPE_CONTRACT !== $document->getType()) {
return;
}
$disposition = $document->getDisposition();
$teamer = $disposition->getTeamer();
$otherApplications = $disposition
->getAssignment()
->getApplications()
->filter(function (Application $application) use ($teamer) {
return $application->getTeamer() !== $teamer;
})
;
foreach ($otherApplications as $application) {
$application
->setStatus(Application::STATUS_REJECTED)
->setComment('Abgelehnt, da Einsatz bereits anderweitig besetzt')
;
}
$this->entityManager->flush();
foreach ($otherApplications as $application) {
$statusDto = new ApplicationStatusDto($application);
$this->eventDispatcher->dispatch(
new ApplicationStatusEvent($statusDto),
ApplicationStatusEvent::NAME
);
}
}
}
+20 -11
View File
@@ -12,6 +12,7 @@ use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\CheckboxType;
use Symfony\Component\Form\Extension\Core\Type\ChoiceType;
use Symfony\Component\Form\Extension\Core\Type\IntegerType;
use Symfony\Component\Form\Extension\Core\Type\SubmitType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
@@ -27,6 +28,7 @@ class AssignmentFilterType extends AbstractType
$builder
->add('dateFrom', DatepickerType::class, [
'label' => 'Zeitraum von',
'required' => false,
'min_date' => $options['min_date'],
'max_date' => $options['max_date'],
'attr' => [
@@ -35,6 +37,7 @@ class AssignmentFilterType extends AbstractType
])
->add('dateTo', DatepickerType::class, [
'label' => 'Zeitraum bis',
'required' => false,
'min_date' => $options['min_date'],
'max_date' => $options['max_date'],
'attr' => [
@@ -65,17 +68,23 @@ class AssignmentFilterType extends AbstractType
;
if ($this->security->isGranted('ROLE_ADMINISTRATIVE')) {
$builder->add('status', MultiselectType::class, [
'label' => 'Status',
'required' => false,
'empty_label' => 'nicht filtern',
'choices' => [
'voll besetzt' => Assignment::STATUS_STAFFED,
'teilweise besetzt' => Assignment::STATUS_PARTLY_STAFFED,
'unbesetzt' => Assignment::STATUS_UNSTAFFED,
'mit Bewerbungen' => Assignment::STATUS_STAFFING,
],
]);
$builder
->add('id', IntegerType::class, [
'label' => 'ID',
'required' => false,
])
->add('status', MultiselectType::class, [
'label' => 'Status',
'required' => false,
'empty_label' => 'nicht filtern',
'choices' => [
'voll besetzt' => Assignment::STATUS_STAFFED,
'teilweise besetzt' => Assignment::STATUS_PARTLY_STAFFED,
'unbesetzt' => Assignment::STATUS_UNSTAFFED,
'mit Bewerbungen' => Assignment::STATUS_STAFFING,
],
])
;
}
if (0 < count($options['job_profiles'])) {
+1
View File
@@ -79,6 +79,7 @@ class TeamerProfileType extends AbstractType
'entry_type' => BpnPickupType::class,
'allow_add' => true,
'allow_delete' => true,
'delete_empty' => true,
])
->add('language', TextType::class, [
'label' => 'Sprache(n)',
+13
View File
@@ -12,6 +12,7 @@ class AssignmentFilterDto extends AbstractFilterDto
public const DURATION_FULL_WEEK = 3;
public const DURATION_MORE = 4;
protected ?int $id = null;
protected ?\DateTimeImmutable $dateFrom = null;
protected ?\DateTimeImmutable $dateTo = null;
protected array $jobProfiles = [];
@@ -20,6 +21,18 @@ class AssignmentFilterDto extends AbstractFilterDto
protected array $status = [];
protected bool $includePast = false;
public function getId(): ?int
{
return $this->id;
}
public function setId(?int $id): static
{
$this->id = $id;
return $this;
}
public function getDateFrom(): ?\DateTimeImmutable
{
return $this->dateFrom;
+7
View File
@@ -85,6 +85,13 @@ class AssignmentRepository extends ServiceEntityRepository
private function applyFilterSettings(AssignmentFilterDto $filterDto, QueryBuilder $qb): void
{
if (null !== $filterDto->getId()) {
$qb
->andWhere($qb->expr()->eq('assignment.id', ':id'))
->setParameter('id', $filterDto->getId())
;
}
if (false === $filterDto->isIncludePast()) {
$qb
->andWhere($qb->expr()->gte('destination.dateFrom', ':now'))
+22 -17
View File
@@ -5,10 +5,8 @@ namespace App\Repository;
use App\Entity\Assignment;
use App\Entity\Disposition;
use App\Entity\Teamer;
use App\Entity\Upload;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\ORM\Query;
use Doctrine\ORM\Query\Expr\Join;
use Doctrine\Persistence\ManagerRegistry;
/**
@@ -150,7 +148,7 @@ class DispositionRepository extends ServiceEntityRepository
;
}
public function findNewDispositionsByHotelBusProIds(array $hotelBusProIds): array
public function findNewDispositionsByHotelCode(string $hotelCode): array
{
$qb = $this->createQueryBuilder('disposition');
@@ -159,12 +157,12 @@ class DispositionRepository extends ServiceEntityRepository
->innerJoin('disposition.assignment', 'assignment')
->innerJoin('assignment.destination', 'destination')
->where($qb->expr()->andX(
$qb->expr()->in('destination.hotelBusProId', ':hotelBusProIds'),
$qb->expr()->like('destination.hotelCode', ':hotelCode'),
$qb->expr()->gte('destination.dateTo', ':dateTo'),
$qb->expr()->eq('disposition.status', ':status')
))
->orderBy('destination.dateFrom', 'ASC')
->setParameter('hotelBusProIds', $hotelBusProIds)
->setParameter('hotelCode', '%'.$hotelCode.'%')
->setParameter('dateTo', new \DateTimeImmutable())
->setParameter('status', Disposition::STATUS_CONFIRMED)
->getQuery()
@@ -172,7 +170,7 @@ class DispositionRepository extends ServiceEntityRepository
;
}
public function getDispositionsWithPendingFeedbackQuery(array $hotelBusProIds = null): Query
public function getDispositionsWithPendingFeedbackQuery(?string $hotelCode = null, ?int $offsetDays = null): Query
{
$qb = $this->createQueryBuilder('disposition');
@@ -182,6 +180,7 @@ class DispositionRepository extends ServiceEntityRepository
->innerJoin('assignment.destination', 'destination')
->leftJoin('disposition.feedback', 'feedback')
->where($qb->expr()->andX(
$qb->expr()->notIn('assignment.status', ':status'),
$qb->expr()->orX(
$qb->expr()->andX(
$qb->expr()->isNotNull('assignment.dateTo'),
@@ -191,31 +190,37 @@ class DispositionRepository extends ServiceEntityRepository
),
$qb->expr()->isNull('feedback')
))
->setParameter('dateTo', new \DateTimeImmutable())
->setParameter('status', [Assignment::STATUS_CALLED_OFF, Assignment::STATUS_DELETED])
;
if (null !== $hotelBusProIds) {
$dateTo = new \DateTimeImmutable();
if (null !== $offsetDays) {
$dateTo = $dateTo->modify('-'.$offsetDays.' days');
}
$qb->setParameter('dateTo', $dateTo);
if (null !== $hotelCode) {
$qb
->andWhere($qb->expr()->in('destination.hotelBusProId', ':hotelBusProIds'))
->setParameter('hotelBusProIds', $hotelBusProIds)
->andWhere($qb->expr()->like('destination.hotelCode', ':hotelCode'))
->setParameter('hotelCode', '%'.$hotelCode.'%')
;
}
return $qb->getQuery();
}
public function findDispositionsWithPendingFeedback(): array
public function findDispositionsWithOverdueFeedback(?int $offsetDays = null): array
{
return $this
->getDispositionsWithPendingFeedbackQuery()
->getDispositionsWithPendingFeedbackQuery(null, $offsetDays)
->getResult()
;
}
public function findDispositionsWithPendingFeedbackByHotelBusProIds(array $hotelBusProIds): array
public function findDispositionsWithPendingFeedbackByHotelCode(string $hotelCode): array
{
return $this
->getDispositionsWithPendingFeedbackQuery($hotelBusProIds)
->getDispositionsWithPendingFeedbackQuery($hotelCode)
->getResult()
;
}
@@ -279,15 +284,15 @@ class DispositionRepository extends ServiceEntityRepository
->select('disposition', 'assignment', 'destination', 'document')
->innerJoin('disposition.assignment', 'assignment')
->innerJoin('assignment.destination', 'destination')
->leftJoin('disposition.documents', 'document', Join::WITH, 'document.type = :documentType')
->leftJoin('disposition.documents', 'document')
->where($qb->expr()->andX(
$qb->expr()->lte('disposition.createdAt', ':dueDate'),
$qb->expr()->eq('disposition.status', ':status'),
$qb->expr()->isNull('document')
))
->setParameter('documentType', Upload::TYPE_CONTRACT)
->setParameter('dueDate', $dueDate)
->setParameter('status', Disposition::STATUS_CONFIRMED)
->setParameter('status', Disposition::STATUS_NEW)
->orderBy('disposition.createdAt', 'ASC')
->getQuery()
->getResult()
;
+7 -5
View File
@@ -54,12 +54,14 @@ class ApplicationVoter extends Voter
if ($this->security->isGranted('ROLE_TEAMER') && in_array($attribute, $teamerAttributes)) {
$teamer = $user->getTeamer();
if ($teamer !== $application->getTeamer()) {
return false;
}
return match ($attribute) {
static::WITHDRAW => $teamer === $application->getTeamer(),
static::DELETE => $teamer === $application->getTeamer()
&& Application::STATUS_REJECTED === $status,
static::VIEW => $teamer === $application->getTeamer()
&& Application::STATUS_REJECTED !== $status,
static::DELETE => Application::STATUS_REJECTED === $status,
static::VIEW, static::WITHDRAW => Application::STATUS_REJECTED !== $status,
default => false,
};
}
@@ -18,6 +18,9 @@ class AssignmentFilterHandler extends AbstractFilterHandler
$filterDto = new AssignmentFilterDto();
if (isset($data['id'])) {
$filterDto->setId($data['id']);
}
if (isset($data['date_from'])) {
$filterDto->setDateFrom($data['date_from']);
}
@@ -52,6 +55,7 @@ class AssignmentFilterHandler extends AbstractFilterHandler
{
/** @var AssignmentFilterDto $filterDto */
$this->getSession()->set($this->namespace, [
'id' => $filterDto->getId(),
'date_from' => $filterDto->getDateFrom(),
'date_to' => $filterDto->getDateTo(),
'job_profiles' => array_map(function (JobProfile $jobProfile) {
+1 -1
View File
@@ -24,7 +24,7 @@ class FeedbackReminderService
public function sendFeedbackReminders(): string
{
/** @var Disposition[] $dispositionsWithPendingFeedbacks */
$dispositionsWithPendingFeedbacks = $this->dispositionRepository->findDispositionsWithPendingFeedback();
$dispositionsWithPendingFeedbacks = $this->dispositionRepository->findDispositionsWithOverdueFeedback();
$sortedFeedbacks = [];
+5 -2
View File
@@ -23,11 +23,14 @@ class PickupResolver
return false;
}
$teamerPickups = array_map(function (int $id) {
return $this->pickupDataProvider->get($id);
$teamerPickups = array_map(function (mixed $id) {
return $this->pickupDataProvider->get((int) $id);
}, $teamer->getPickups());
foreach ($teamerPickups as $teamerPickup) {
if (null === $teamerPickup) {
continue;
}
$teamerPickupCity = trim($teamerPickup->getCity());
$pickupCity = trim($pickup->getCity());
if ($teamerPickupCity === $pickupCity) {