feat: assignments with disabled formalities
addresses #869dnvm8c
This commit is contained in:
@@ -33,6 +33,24 @@ texts:
|
|||||||
|
|
||||||
Bitte wende dich bei Rückfragen an [email protected].
|
Bitte wende dich bei Rückfragen an [email protected].
|
||||||
|
|
||||||
|
application_accepted_no_contract:
|
||||||
|
label: 'Bewerbung angenommen (ohne Vertrag)'
|
||||||
|
placeholders: ['destination', 'specialAgreements']
|
||||||
|
subject: 'Dein Einsatz wurde angenommen'
|
||||||
|
headline: 'Herzlichen Glückwunsch!'
|
||||||
|
body: |-
|
||||||
|
**Dein Einsatz {destination} wurde angenommen!**
|
||||||
|
|
||||||
|
Zusätzliche Absprachen: {specialAgreements}
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
Schön, dass du dabei bist und ganz viel Spaß in den Bergen! 😊
|
||||||
|
|
||||||
|
Bitte wende dich bei Rückfragen an [email protected].
|
||||||
|
|
||||||
application_rejected:
|
application_rejected:
|
||||||
label: 'Bewerbung abgelehnt'
|
label: 'Bewerbung abgelehnt'
|
||||||
placeholders: ['destination']
|
placeholders: ['destination']
|
||||||
|
|||||||
@@ -0,0 +1,30 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace DoctrineMigrations;
|
||||||
|
|
||||||
|
use Doctrine\DBAL\Schema\Schema;
|
||||||
|
use Doctrine\Migrations\AbstractMigration;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hand-written rather than diffed: doctrine:migrations:diff omits the DEFAULT 0, which makes the
|
||||||
|
* ALTER fail on a table that already has rows.
|
||||||
|
*/
|
||||||
|
final class Version20260826153820 extends AbstractMigration
|
||||||
|
{
|
||||||
|
public function getDescription(): string
|
||||||
|
{
|
||||||
|
return 'Add skip_formalities column to assignment table';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function up(Schema $schema): void
|
||||||
|
{
|
||||||
|
$this->addSql('ALTER TABLE assignment ADD skip_formalities TINYINT(1) NOT NULL DEFAULT 0');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(Schema $schema): void
|
||||||
|
{
|
||||||
|
$this->addSql('ALTER TABLE assignment DROP skip_formalities');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -11,6 +11,7 @@ namespace App\Config;
|
|||||||
enum EmailTextKey: string
|
enum EmailTextKey: string
|
||||||
{
|
{
|
||||||
case APPLICATION_ACCEPTED = 'application_accepted';
|
case APPLICATION_ACCEPTED = 'application_accepted';
|
||||||
|
case APPLICATION_ACCEPTED_NO_CONTRACT = 'application_accepted_no_contract';
|
||||||
case APPLICATION_REJECTED = 'application_rejected';
|
case APPLICATION_REJECTED = 'application_rejected';
|
||||||
case ASSIGNMENT_CALLED_OFF = 'assignment_called_off';
|
case ASSIGNMENT_CALLED_OFF = 'assignment_called_off';
|
||||||
case DISPOSITION_CALLED_OFF = 'disposition_called_off';
|
case DISPOSITION_CALLED_OFF = 'disposition_called_off';
|
||||||
|
|||||||
@@ -57,6 +57,13 @@ class Assignment implements BlameableEntityInterface, TimestampableEntityInterfa
|
|||||||
#[ORM\Column]
|
#[ORM\Column]
|
||||||
private ?bool $showAvailableDispositions = false;
|
private ?bool $showAvailableDispositions = false;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ends the process for a placed teamer right after the disposition is created: no contract,
|
||||||
|
* no invoice, no feedback. See Disposition::isSkipFormalities() for the per-placement view.
|
||||||
|
*/
|
||||||
|
#[ORM\Column]
|
||||||
|
private bool $skipFormalities = false;
|
||||||
|
|
||||||
#[ORM\Column(type: Types::DATE_IMMUTABLE, nullable: true)]
|
#[ORM\Column(type: Types::DATE_IMMUTABLE, nullable: true)]
|
||||||
private ?\DateTimeImmutable $dateFrom = null;
|
private ?\DateTimeImmutable $dateFrom = null;
|
||||||
|
|
||||||
@@ -125,6 +132,7 @@ class Assignment implements BlameableEntityInterface, TimestampableEntityInterfa
|
|||||||
->setJobProfile($assignment->getJobProfile())
|
->setJobProfile($assignment->getJobProfile())
|
||||||
->setAvailableDispositions($assignment->getAvailableDispositions())
|
->setAvailableDispositions($assignment->getAvailableDispositions())
|
||||||
->setShowAvailableDispositions($assignment->isShowAvailableDispositions())
|
->setShowAvailableDispositions($assignment->isShowAvailableDispositions())
|
||||||
|
->setSkipFormalities($assignment->isSkipFormalities())
|
||||||
->setDateFrom($assignment->getDateFrom())
|
->setDateFrom($assignment->getDateFrom())
|
||||||
->setDateTo($assignment->getDateTo())
|
->setDateTo($assignment->getDateTo())
|
||||||
->setPickupDate($assignment->getPickupDate())
|
->setPickupDate($assignment->getPickupDate())
|
||||||
@@ -246,6 +254,18 @@ class Assignment implements BlameableEntityInterface, TimestampableEntityInterfa
|
|||||||
return $this;
|
return $this;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function isSkipFormalities(): bool
|
||||||
|
{
|
||||||
|
return $this->skipFormalities;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setSkipFormalities(bool $skipFormalities): static
|
||||||
|
{
|
||||||
|
$this->skipFormalities = $skipFormalities;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
public function getDateFrom(): ?\DateTimeImmutable
|
public function getDateFrom(): ?\DateTimeImmutable
|
||||||
{
|
{
|
||||||
return $this->dateFrom;
|
return $this->dateFrom;
|
||||||
|
|||||||
@@ -75,7 +75,11 @@ class Disposition implements BlameableEntityInterface, TimestampableEntityInterf
|
|||||||
public function __construct(Application $application)
|
public function __construct(Application $application)
|
||||||
{
|
{
|
||||||
$this->uuid = Uuid::v4();
|
$this->uuid = Uuid::v4();
|
||||||
$this->status = static::STATUS_NEW;
|
// A skip-formalities assignment has no contract step to wait for, so the placement starts
|
||||||
|
// out where a signed and checked contract would otherwise have put it.
|
||||||
|
$this->status = true === $application->getAssignment()?->isSkipFormalities()
|
||||||
|
? static::STATUS_CONFIRMED
|
||||||
|
: static::STATUS_NEW;
|
||||||
$this->assignment = $application->getAssignment();
|
$this->assignment = $application->getAssignment();
|
||||||
$this->teamer = $application->getTeamer();
|
$this->teamer = $application->getTeamer();
|
||||||
$this->pickup = $application->getPickup();
|
$this->pickup = $application->getPickup();
|
||||||
@@ -218,12 +222,25 @@ class Disposition implements BlameableEntityInterface, TimestampableEntityInterf
|
|||||||
return $this;
|
return $this;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether the owning assignment switches off the contract, invoice and feedback process.
|
||||||
|
* Kept here so call sites do not have to repeat the null check on the assignment.
|
||||||
|
*/
|
||||||
|
public function isSkipFormalities(): bool
|
||||||
|
{
|
||||||
|
return true === $this->getAssignment()?->isSkipFormalities();
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The period is passed in rather than hardcoded so that this, the reminder mail and the
|
* The period is passed in rather than hardcoded so that this, the reminder mail and the
|
||||||
* admin overdue list all work from the same %contract_upload_deadline_days%.
|
* admin overdue list all work from the same %contract_upload_deadline_days%.
|
||||||
*/
|
*/
|
||||||
public function isContractDue(int $deadlineDays): bool
|
public function isContractDue(int $deadlineDays): bool
|
||||||
{
|
{
|
||||||
|
if (true === $this->isSkipFormalities()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
if (null !== $this->getDocumentByType(Upload::TYPE_CONTRACT)) {
|
if (null !== $this->getDocumentByType(Upload::TYPE_CONTRACT)) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@@ -251,6 +268,10 @@ class Disposition implements BlameableEntityInterface, TimestampableEntityInterf
|
|||||||
|
|
||||||
public function isContractRejected(): bool
|
public function isContractRejected(): bool
|
||||||
{
|
{
|
||||||
|
if (true === $this->isSkipFormalities()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
if (null === $contractDocument = $this->getDocumentByType(Upload::TYPE_CONTRACT)) {
|
if (null === $contractDocument = $this->getDocumentByType(Upload::TYPE_CONTRACT)) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@@ -267,6 +288,10 @@ class Disposition implements BlameableEntityInterface, TimestampableEntityInterf
|
|||||||
*/
|
*/
|
||||||
public function isInvoiceDue(int $deadlineDays): bool
|
public function isInvoiceDue(int $deadlineDays): bool
|
||||||
{
|
{
|
||||||
|
if (true === $this->isSkipFormalities()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
if (null !== $this->getDocumentByType(Upload::TYPE_INVOICE)) {
|
if (null !== $this->getDocumentByType(Upload::TYPE_INVOICE)) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -61,6 +61,19 @@ class EmailNotificationSubscriber implements EventSubscriberInterface
|
|||||||
$assignment = $disposition->getAssignment();
|
$assignment = $disposition->getAssignment();
|
||||||
$destination = $assignment->getDestination();
|
$destination = $assignment->getDestination();
|
||||||
|
|
||||||
|
// Same notification, minus everything about the contract: the teamer still has to learn
|
||||||
|
// that they were placed, but on a skip-formalities assignment there is nothing to sign.
|
||||||
|
if (true === $disposition->isSkipFormalities()) {
|
||||||
|
$this->mailer->createAndSendText(EmailTextKey::APPLICATION_ACCEPTED_NO_CONTRACT, [
|
||||||
|
'destination' => (string) $destination,
|
||||||
|
'specialAgreements' => $disposition->getSpecialAgreements(),
|
||||||
|
], [
|
||||||
|
'to' => $teamer->getCommunication()->getEmail(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
$filename = sprintf('Honorarvertrag_%s_%s.pdf', $teamer, $destination);
|
$filename = sprintf('Honorarvertrag_%s_%s.pdf', $teamer, $destination);
|
||||||
|
|
||||||
$contractPdf = $this->contractRenderer->render($disposition);
|
$contractPdf = $this->contractRenderer->render($disposition);
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ use Doctrine\ORM\EntityManagerInterface;
|
|||||||
use Doctrine\ORM\EntityRepository;
|
use Doctrine\ORM\EntityRepository;
|
||||||
use Symfony\Bridge\Doctrine\Form\Type\EntityType;
|
use Symfony\Bridge\Doctrine\Form\Type\EntityType;
|
||||||
use Symfony\Component\Form\AbstractType;
|
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\ChoiceType;
|
||||||
use Symfony\Component\Form\Extension\Core\Type\IntegerType;
|
use Symfony\Component\Form\Extension\Core\Type\IntegerType;
|
||||||
use Symfony\Component\Form\Extension\Core\Type\TextareaType;
|
use Symfony\Component\Form\Extension\Core\Type\TextareaType;
|
||||||
@@ -147,6 +148,7 @@ class AssignmentType extends AbstractType
|
|||||||
$data = $event->getData();
|
$data = $event->getData();
|
||||||
|
|
||||||
$this->addStatusField($data, $form);
|
$this->addStatusField($data, $form);
|
||||||
|
$this->addSkipFormalitiesField($data, $form);
|
||||||
|
|
||||||
$destination = $data->getDestination();
|
$destination = $data->getDestination();
|
||||||
|
|
||||||
@@ -212,6 +214,36 @@ class AssignmentType extends AbstractType
|
|||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether the skip-formalities flag is frozen for a given assignment.
|
||||||
|
*
|
||||||
|
* It decides which status a Disposition is created in, so flipping it once placements
|
||||||
|
* exist would leave them stranded halfway through a process that no longer applies -
|
||||||
|
* a teamer confirmed without a contract, or one still being chased for a contract on an
|
||||||
|
* assignment that no longer wants one.
|
||||||
|
*
|
||||||
|
* Enforced through the form's `disabled` option, which makes Symfony ignore whatever is
|
||||||
|
* submitted for the field, so this holds against a hand-crafted POST too.
|
||||||
|
*/
|
||||||
|
public static function isSkipFormalitiesLocked(?Assignment $assignment): bool
|
||||||
|
{
|
||||||
|
return 0 < ($assignment?->getDispositions()->count() ?? 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function addSkipFormalitiesField(?Assignment $assignment, FormInterface $form): void
|
||||||
|
{
|
||||||
|
$isLocked = self::isSkipFormalitiesLocked($assignment);
|
||||||
|
|
||||||
|
$form->add('skipFormalities', CheckboxType::class, [
|
||||||
|
'label' => '"Partymodus": Ohne Vertrags-, Honorarnoten- und Feedbackprozess',
|
||||||
|
'required' => false,
|
||||||
|
'disabled' => $isLocked,
|
||||||
|
'help' => $isLocked
|
||||||
|
? 'Nicht mehr änderbar, sobald Teamer:innen eingeteilt sind.'
|
||||||
|
: 'Teamer:innen bewerben sich und werden eingeteilt; danach ist der Einsatz abgeschlossen.',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
private function addPickupField(Destination $destination, FormInterface $form): void
|
private function addPickupField(Destination $destination, FormInterface $form): void
|
||||||
{
|
{
|
||||||
$choices = [];
|
$choices = [];
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ use App\Entity\Upload;
|
|||||||
use App\Repository\Filter\SeasonPeriodFilter;
|
use App\Repository\Filter\SeasonPeriodFilter;
|
||||||
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
|
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
|
||||||
use Doctrine\ORM\Query;
|
use Doctrine\ORM\Query;
|
||||||
|
use Doctrine\ORM\Query\Expr\Comparison;
|
||||||
use Doctrine\ORM\Query\Expr\Join;
|
use Doctrine\ORM\Query\Expr\Join;
|
||||||
use Doctrine\ORM\Query\Expr\Orx;
|
use Doctrine\ORM\Query\Expr\Orx;
|
||||||
use Doctrine\ORM\QueryBuilder;
|
use Doctrine\ORM\QueryBuilder;
|
||||||
@@ -217,7 +218,8 @@ class DispositionRepository extends ServiceEntityRepository
|
|||||||
),
|
),
|
||||||
$qb->expr()->lte('destination.dateTo', ':dateTo')
|
$qb->expr()->lte('destination.dateTo', ':dateTo')
|
||||||
),
|
),
|
||||||
$qb->expr()->isNull('feedback')
|
$qb->expr()->isNull('feedback'),
|
||||||
|
$this->excludesSkipFormalities($qb)
|
||||||
))
|
))
|
||||||
->setParameter('status', [Assignment::STATUS_CALLED_OFF, Assignment::STATUS_DELETED])
|
->setParameter('status', [Assignment::STATUS_CALLED_OFF, Assignment::STATUS_DELETED])
|
||||||
;
|
;
|
||||||
@@ -306,6 +308,47 @@ class DispositionRepository extends ServiceEntityRepository
|
|||||||
;
|
;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The raw per-hotel feedback tally, before the missing/percentage columns are derived.
|
||||||
|
*
|
||||||
|
* Split out from getFeedbackStatisticsByHotel() so the rules carried in the DQL can be
|
||||||
|
* asserted without a database, the same way the reminder queries are.
|
||||||
|
*/
|
||||||
|
public function getFeedbackStatisticsByHotelQuery(
|
||||||
|
?\DateTimeImmutable $dateFrom = null,
|
||||||
|
?\DateTimeImmutable $dateTo = null,
|
||||||
|
): Query {
|
||||||
|
$qb = $this->createQueryBuilder('disposition');
|
||||||
|
|
||||||
|
$qb
|
||||||
|
->select(
|
||||||
|
'destination.hotelCode AS hotelCode',
|
||||||
|
'destination.hotel AS hotel',
|
||||||
|
'COUNT(disposition.id) AS totalDispositions',
|
||||||
|
'SUM(CASE WHEN disposition.feedback IS NOT NULL THEN 1 ELSE 0 END) AS providedFeedbacks'
|
||||||
|
)
|
||||||
|
->innerJoin('disposition.assignment', 'assignment')
|
||||||
|
->innerJoin('assignment.destination', 'destination')
|
||||||
|
->leftJoin('disposition.feedback', 'feedback')
|
||||||
|
->where($qb->expr()->andX(
|
||||||
|
$qb->expr()->in('disposition.status', ':statuses'),
|
||||||
|
// no feedback is expected here, so counting these would inflate missingFeedbacks
|
||||||
|
$this->excludesSkipFormalities($qb)
|
||||||
|
))
|
||||||
|
->setParameter('statuses', [
|
||||||
|
Disposition::STATUS_ENDED,
|
||||||
|
Disposition::STATUS_PAID,
|
||||||
|
Disposition::STATUS_COMPLETED,
|
||||||
|
])
|
||||||
|
->groupBy('destination.hotelCode', 'destination.hotel')
|
||||||
|
->orderBy('destination.hotel', 'ASC')
|
||||||
|
;
|
||||||
|
|
||||||
|
SeasonPeriodFilter::apply($qb, $dateFrom, $dateTo);
|
||||||
|
|
||||||
|
return $qb->getQuery();
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Returns feedback statistics per hotel for dispositions with status 'ended' or 'completed'.
|
* Returns feedback statistics per hotel for dispositions with status 'ended' or 'completed'.
|
||||||
*
|
*
|
||||||
@@ -322,32 +365,11 @@ class DispositionRepository extends ServiceEntityRepository
|
|||||||
?\DateTimeImmutable $dateFrom = null,
|
?\DateTimeImmutable $dateFrom = null,
|
||||||
?\DateTimeImmutable $dateTo = null,
|
?\DateTimeImmutable $dateTo = null,
|
||||||
): array {
|
): array {
|
||||||
$qb = $this->createQueryBuilder('disposition');
|
$results = $this
|
||||||
|
->getFeedbackStatisticsByHotelQuery($dateFrom, $dateTo)
|
||||||
$qb
|
->getResult()
|
||||||
->select(
|
|
||||||
'destination.hotelCode AS hotelCode',
|
|
||||||
'destination.hotel AS hotel',
|
|
||||||
'COUNT(disposition.id) AS totalDispositions',
|
|
||||||
'SUM(CASE WHEN disposition.feedback IS NOT NULL THEN 1 ELSE 0 END) AS providedFeedbacks'
|
|
||||||
)
|
|
||||||
->innerJoin('disposition.assignment', 'assignment')
|
|
||||||
->innerJoin('assignment.destination', 'destination')
|
|
||||||
->leftJoin('disposition.feedback', 'feedback')
|
|
||||||
->where($qb->expr()->in('disposition.status', ':statuses'))
|
|
||||||
->setParameter('statuses', [
|
|
||||||
Disposition::STATUS_ENDED,
|
|
||||||
Disposition::STATUS_PAID,
|
|
||||||
Disposition::STATUS_COMPLETED,
|
|
||||||
])
|
|
||||||
->groupBy('destination.hotelCode', 'destination.hotel')
|
|
||||||
->orderBy('destination.hotel', 'ASC')
|
|
||||||
;
|
;
|
||||||
|
|
||||||
SeasonPeriodFilter::apply($qb, $dateFrom, $dateTo);
|
|
||||||
|
|
||||||
$results = $qb->getQuery()->getResult();
|
|
||||||
|
|
||||||
// Calculate missing feedbacks and percentage
|
// Calculate missing feedbacks and percentage
|
||||||
return array_map(function (array $row): array {
|
return array_map(function (array $row): array {
|
||||||
$total = (int) $row['totalDispositions'];
|
$total = (int) $row['totalDispositions'];
|
||||||
@@ -367,20 +389,15 @@ class DispositionRepository extends ServiceEntityRepository
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Returns feedback statistics grouped by normalized hotel code (base 3-char code).
|
* The raw tally grouped by normalized hotel code, before the derived columns.
|
||||||
*
|
*
|
||||||
* @return array<int, array{
|
* Kept in step with getFeedbackStatisticsByHotelQuery(): the two back different screens over
|
||||||
* hotelCode: string,
|
* the same numbers, so a rule added to one belongs in the other.
|
||||||
* totalDispositions: int,
|
|
||||||
* providedFeedbacks: int,
|
|
||||||
* missingFeedbacks: int,
|
|
||||||
* percentageProvided: float
|
|
||||||
* }>
|
|
||||||
*/
|
*/
|
||||||
public function getFeedbackStatisticsByNormalizedHotelCode(
|
public function getFeedbackStatisticsByNormalizedHotelCodeQuery(
|
||||||
?\DateTimeImmutable $dateFrom = null,
|
?\DateTimeImmutable $dateFrom = null,
|
||||||
?\DateTimeImmutable $dateTo = null,
|
?\DateTimeImmutable $dateTo = null,
|
||||||
): array {
|
): Query {
|
||||||
$qb = $this->createQueryBuilder('disposition');
|
$qb = $this->createQueryBuilder('disposition');
|
||||||
|
|
||||||
// Normalize hotel code: strip SER prefix to get base 3-char code
|
// Normalize hotel code: strip SER prefix to get base 3-char code
|
||||||
@@ -395,7 +412,11 @@ class DispositionRepository extends ServiceEntityRepository
|
|||||||
->innerJoin('disposition.assignment', 'assignment')
|
->innerJoin('disposition.assignment', 'assignment')
|
||||||
->innerJoin('assignment.destination', 'destination')
|
->innerJoin('assignment.destination', 'destination')
|
||||||
->leftJoin('disposition.feedback', 'feedback')
|
->leftJoin('disposition.feedback', 'feedback')
|
||||||
->where($qb->expr()->in('disposition.status', ':statuses'))
|
->where($qb->expr()->andX(
|
||||||
|
$qb->expr()->in('disposition.status', ':statuses'),
|
||||||
|
// no feedback is expected here, so counting these would inflate missingFeedbacks
|
||||||
|
$this->excludesSkipFormalities($qb)
|
||||||
|
))
|
||||||
->setParameter('statuses', [
|
->setParameter('statuses', [
|
||||||
Disposition::STATUS_ENDED,
|
Disposition::STATUS_ENDED,
|
||||||
Disposition::STATUS_PAID,
|
Disposition::STATUS_PAID,
|
||||||
@@ -407,7 +428,28 @@ class DispositionRepository extends ServiceEntityRepository
|
|||||||
|
|
||||||
SeasonPeriodFilter::apply($qb, $dateFrom, $dateTo);
|
SeasonPeriodFilter::apply($qb, $dateFrom, $dateTo);
|
||||||
|
|
||||||
$results = $qb->getQuery()->getResult();
|
return $qb->getQuery();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns feedback statistics grouped by normalized hotel code (base 3-char code).
|
||||||
|
*
|
||||||
|
* @return array<int, array{
|
||||||
|
* hotelCode: string,
|
||||||
|
* totalDispositions: int,
|
||||||
|
* providedFeedbacks: int,
|
||||||
|
* missingFeedbacks: int,
|
||||||
|
* percentageProvided: float
|
||||||
|
* }>
|
||||||
|
*/
|
||||||
|
public function getFeedbackStatisticsByNormalizedHotelCode(
|
||||||
|
?\DateTimeImmutable $dateFrom = null,
|
||||||
|
?\DateTimeImmutable $dateTo = null,
|
||||||
|
): array {
|
||||||
|
$results = $this
|
||||||
|
->getFeedbackStatisticsByNormalizedHotelCodeQuery($dateFrom, $dateTo)
|
||||||
|
->getResult()
|
||||||
|
;
|
||||||
|
|
||||||
return array_map(function (array $row): array {
|
return array_map(function (array $row): array {
|
||||||
$total = (int) $row['totalDispositions'];
|
$total = (int) $row['totalDispositions'];
|
||||||
@@ -522,6 +564,10 @@ class DispositionRepository extends ServiceEntityRepository
|
|||||||
$qb->expr()->isNull('teamer.deletedAt'),
|
$qb->expr()->isNull('teamer.deletedAt'),
|
||||||
$qb->expr()->notIn('disposition.status', ':dispositionStatus'),
|
$qb->expr()->notIn('disposition.status', ':dispositionStatus'),
|
||||||
$qb->expr()->notIn('assignment.status', ':assignmentStatus'),
|
$qb->expr()->notIn('assignment.status', ':assignmentStatus'),
|
||||||
|
// Cannot be left to disposition.status: this runs before DispositionStatusService
|
||||||
|
// in CronCommand, so a skip-formalities placement is still `confirmed` here and
|
||||||
|
// would slip past the status filter on the day after the assignment ends.
|
||||||
|
$this->excludesSkipFormalities($qb),
|
||||||
$this->effectiveDateToEquals($qb, ':endDate'),
|
$this->effectiveDateToEquals($qb, ':endDate'),
|
||||||
))
|
))
|
||||||
->setParameter('documentType', Upload::TYPE_INVOICE)
|
->setParameter('documentType', Upload::TYPE_INVOICE)
|
||||||
@@ -558,6 +604,24 @@ class DispositionRepository extends ServiceEntityRepository
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Restricts a query to assignments that still run the contract, invoice and feedback process.
|
||||||
|
*
|
||||||
|
* Four queries carry this rule - the invoice reminder, the pending-feedback list and both
|
||||||
|
* feedback statistics - and each needs the parameter bound as well as the comparison added,
|
||||||
|
* so this does both. Kept together because the halves are useless apart: the comparison
|
||||||
|
* without the bind is an "Invalid parameter number" at execution time, and the bind without
|
||||||
|
* the comparison is a silently unfiltered result, which is the worse of the two.
|
||||||
|
*
|
||||||
|
* Requires the `assignment` alias to be joined by the caller.
|
||||||
|
*/
|
||||||
|
private function excludesSkipFormalities(QueryBuilder $qb): Comparison
|
||||||
|
{
|
||||||
|
$qb->setParameter('skipFormalities', false);
|
||||||
|
|
||||||
|
return $qb->expr()->eq('assignment.skipFormalities', ':skipFormalities');
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Same fallback as effectiveDateToEquals(), but for "later than $parameter".
|
* Same fallback as effectiveDateToEquals(), but for "later than $parameter".
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -50,11 +50,17 @@ class DispositionVoter extends Voter
|
|||||||
$disposition = $subject;
|
$disposition = $subject;
|
||||||
|
|
||||||
return match ($attribute) {
|
return match ($attribute) {
|
||||||
static::VIEW, static::EDIT, static::CONTRACT, static::INVOICE => $this->security->isGranted('ROLE_ADMINISTRATIVE')
|
static::VIEW, static::EDIT => $this->security->isGranted('ROLE_ADMINISTRATIVE')
|
||||||
|| $this->assertTeamerAccess($token, $disposition),
|
|| $this->assertTeamerAccess($token, $disposition),
|
||||||
|
// The documents themselves do not exist on a skip-formalities assignment, so neither
|
||||||
|
// the blank PDFs nor the upload screens may be reachable for it.
|
||||||
|
static::CONTRACT, static::INVOICE => false === $disposition->isSkipFormalities()
|
||||||
|
&& ($this->security->isGranted('ROLE_ADMINISTRATIVE')
|
||||||
|
|| $this->assertTeamerAccess($token, $disposition)),
|
||||||
static::DELETE => $this->security->isGranted('ROLE_ADMIN'),
|
static::DELETE => $this->security->isGranted('ROLE_ADMIN'),
|
||||||
static::FEEDBACK => $this->security->isGranted('ROLE_ADMINISTRATIVE')
|
static::FEEDBACK => false === $disposition->isSkipFormalities()
|
||||||
|| $this->assertHouseManagerAccess($token, $disposition),
|
&& ($this->security->isGranted('ROLE_ADMINISTRATIVE')
|
||||||
|
|| $this->assertHouseManagerAccess($token, $disposition)),
|
||||||
static::CONTRACT_SUPPLEMENTARY => $this->assertContractUploadAllowed($disposition),
|
static::CONTRACT_SUPPLEMENTARY => $this->assertContractUploadAllowed($disposition),
|
||||||
static::ADMIN_DOCUMENT_UPLOAD => $this->assertAdminDocumentUploadAllowed($disposition),
|
static::ADMIN_DOCUMENT_UPLOAD => $this->assertAdminDocumentUploadAllowed($disposition),
|
||||||
static::CALL_OFF => $this->security->isGranted('ROLE_ADMINISTRATIVE')
|
static::CALL_OFF => $this->security->isGranted('ROLE_ADMINISTRATIVE')
|
||||||
@@ -105,6 +111,10 @@ class DispositionVoter extends Voter
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (true === $disposition->isSkipFormalities()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
if (Disposition::STATUS_CALLED_OFF === $disposition->getStatus()) {
|
if (Disposition::STATUS_CALLED_OFF === $disposition->getStatus()) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@@ -121,6 +131,10 @@ class DispositionVoter extends Voter
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (true === $disposition->isSkipFormalities()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
return Disposition::STATUS_CALLED_OFF !== $disposition->getStatus();
|
return Disposition::STATUS_CALLED_OFF !== $disposition->getStatus();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -38,14 +38,21 @@ class DispositionReminderService
|
|||||||
->select('disposition', 'assignment', 'destination')
|
->select('disposition', 'assignment', 'destination')
|
||||||
->innerJoin('disposition.assignment', 'assignment')
|
->innerJoin('disposition.assignment', 'assignment')
|
||||||
->innerJoin('assignment.destination', 'destination')
|
->innerJoin('assignment.destination', 'destination')
|
||||||
->innerJoin('disposition.documents', 'document', Join::WITH, 'document.type = :documentType')
|
->leftJoin('disposition.documents', 'document', Join::WITH, 'document.type = :documentType')
|
||||||
->innerJoin('disposition.teamer', 'teamer')
|
->innerJoin('disposition.teamer', 'teamer')
|
||||||
->where($qb->expr()->andX(
|
->where($qb->expr()->andX(
|
||||||
$qb->expr()->isNull('teamer.deletedAt'),
|
$qb->expr()->isNull('teamer.deletedAt'),
|
||||||
$qb->expr()->notIn('assignment.status', ':statusAssignment'),
|
$qb->expr()->notIn('assignment.status', ':statusAssignment'),
|
||||||
$qb->expr()->eq('destination.dateFrom', ':dateFrom'),
|
$qb->expr()->eq('destination.dateFrom', ':dateFrom'),
|
||||||
$qb->expr()->eq('document.status', ':statusContract'),
|
// A checked contract is what normally marks a placement as settled enough to be
|
||||||
|
// reminded about. A skip-formalities assignment never produces one, so it has to
|
||||||
|
// qualify on the flag alone - otherwise its teamers get no trip reminder at all.
|
||||||
|
$qb->expr()->orX(
|
||||||
|
$qb->expr()->eq('document.status', ':statusContract'),
|
||||||
|
$qb->expr()->eq('assignment.skipFormalities', ':skipFormalities'),
|
||||||
|
),
|
||||||
))
|
))
|
||||||
|
->setParameter('skipFormalities', true)
|
||||||
->setParameter('documentType', Upload::TYPE_CONTRACT)
|
->setParameter('documentType', Upload::TYPE_CONTRACT)
|
||||||
->setParameter('statusAssignment', [Assignment::STATUS_DELETED, Assignment::STATUS_CALLED_OFF])
|
->setParameter('statusAssignment', [Assignment::STATUS_DELETED, Assignment::STATUS_CALLED_OFF])
|
||||||
->setParameter('dateFrom', $dateFrom->format('Y-m-d'))
|
->setParameter('dateFrom', $dateFrom->format('Y-m-d'))
|
||||||
|
|||||||
@@ -26,13 +26,29 @@ class DispositionStatusService
|
|||||||
return 'No ended dispositions to update';
|
return 'No ended dispositions to update';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$completed = 0;
|
||||||
|
|
||||||
foreach ($endedDispositions as $disposition) {
|
foreach ($endedDispositions as $disposition) {
|
||||||
|
// A skip-formalities placement has no invoice step left to run through, so ending it
|
||||||
|
// means it is done - anything else would park it in `ended` forever and keep asking
|
||||||
|
// the teamer for a Honorarnote that is not wanted.
|
||||||
|
if (true === $disposition->isSkipFormalities()) {
|
||||||
|
$disposition->setStatus(Disposition::STATUS_COMPLETED);
|
||||||
|
++$completed;
|
||||||
|
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
$disposition->setStatus(Disposition::STATUS_ENDED);
|
$disposition->setStatus(Disposition::STATUS_ENDED);
|
||||||
}
|
}
|
||||||
|
|
||||||
$this->entityManager->flush();
|
$this->entityManager->flush();
|
||||||
|
|
||||||
$message = 'Ended '.$count.' dispositions';
|
$message = sprintf(
|
||||||
|
'Ended %d dispositions (%d of them completed without formalities)',
|
||||||
|
$count,
|
||||||
|
$completed
|
||||||
|
);
|
||||||
|
|
||||||
$this->logger->info($message);
|
$this->logger->info($message);
|
||||||
|
|
||||||
|
|||||||
@@ -19,6 +19,9 @@
|
|||||||
{{ form_row(form.status) }}
|
{{ form_row(form.status) }}
|
||||||
{{ form_row(form.availableDispositions) }}
|
{{ form_row(form.availableDispositions) }}
|
||||||
</div>
|
</div>
|
||||||
|
<div class="grid lg:grid-cols-2 gap-x-8 gap-y-4">
|
||||||
|
{{ form_row(form.skipFormalities) }}
|
||||||
|
</div>
|
||||||
<div class="grid lg:grid-cols-2 gap-x-8 gap-y-4">
|
<div class="grid lg:grid-cols-2 gap-x-8 gap-y-4">
|
||||||
{{ form_row(form.jobProfile) }}
|
{{ form_row(form.jobProfile) }}
|
||||||
{{ form_row(form.jobProfileInfo) }}
|
{{ form_row(form.jobProfileInfo) }}
|
||||||
|
|||||||
@@ -90,6 +90,7 @@
|
|||||||
{{ form.vars.label }}
|
{{ form.vars.label }}
|
||||||
</label>
|
</label>
|
||||||
{{- form_errors(form) -}}
|
{{- form_errors(form) -}}
|
||||||
|
{{- form_help(form) -}}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
{% block content %}
|
{% block content %}
|
||||||
{% set assignment = disposition.assignment %}
|
{% set assignment = disposition.assignment %}
|
||||||
<h1 class="text-2xl font-bold pb-8">
|
<h1 class="text-2xl font-bold pb-8">
|
||||||
{% if workflow_has_marked_place(disposition, 'ended') %}
|
{% if workflow_has_marked_place(disposition, 'ended') or workflow_has_marked_place(disposition, 'completed') %}
|
||||||
Dein Einsatz ist abgeschlossen
|
Dein Einsatz ist abgeschlossen
|
||||||
{% else %}
|
{% else %}
|
||||||
Dein Einsatz wurde bestätigt!
|
Dein Einsatz wurde bestätigt!
|
||||||
@@ -146,17 +146,27 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
{% if workflow_has_marked_place(disposition, 'confirmed') %}
|
{% if workflow_has_marked_place(disposition, 'confirmed') %}
|
||||||
<h3 class="text-xl font-bold pb-4">
|
{% if assignment.skipFormalities %}
|
||||||
Danke für den Upload des Honorarvertrags
|
<h3 class="text-xl font-bold pb-4">
|
||||||
</h3>
|
Dein Einsatz ist bestätigt
|
||||||
{% set contract = disposition.documentByType('contract') %}
|
</h3>
|
||||||
{% if contract %}
|
<p class="pb-4">
|
||||||
<div class="pb-4">
|
Es sind keine weiteren Schritte nötig - für diesen Einsatz brauchen wir
|
||||||
<a href="{{ path('app_common_download', { 'uuid': contract.uuid }) }}"
|
weder einen Honorarvertrag noch eine Honorarnote von dir.
|
||||||
class="underline">
|
</p>
|
||||||
Download Vertrag
|
{% else %}
|
||||||
</a>
|
<h3 class="text-xl font-bold pb-4">
|
||||||
</div>
|
Danke für den Upload des Honorarvertrags
|
||||||
|
</h3>
|
||||||
|
{% set contract = disposition.documentByType('contract') %}
|
||||||
|
{% if contract %}
|
||||||
|
<div class="pb-4">
|
||||||
|
<a href="{{ path('app_common_download', { 'uuid': contract.uuid }) }}"
|
||||||
|
class="underline">
|
||||||
|
Download Vertrag
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
@@ -183,12 +193,21 @@
|
|||||||
|
|
||||||
{# Invoice paid #}
|
{# Invoice paid #}
|
||||||
{% if workflow_has_marked_place(disposition, 'completed') %}
|
{% if workflow_has_marked_place(disposition, 'completed') %}
|
||||||
<h3 class="text-xl font-bold pb-4">
|
{% if assignment.skipFormalities %}
|
||||||
Danke für die Honorarnote
|
<h3 class="text-xl font-bold pb-4">
|
||||||
</h3>
|
Dein Einsatz ist abgeschlossen
|
||||||
<p class="pb-4">
|
</h3>
|
||||||
Wir werden sie schnellstmöglich bearbeiten.
|
<p class="pb-4">
|
||||||
</p>
|
Danke, dass du dabei warst!
|
||||||
|
</p>
|
||||||
|
{% else %}
|
||||||
|
<h3 class="text-xl font-bold pb-4">
|
||||||
|
Danke für die Honorarnote
|
||||||
|
</h3>
|
||||||
|
<p class="pb-4">
|
||||||
|
Wir werden sie schnellstmöglich bearbeiten.
|
||||||
|
</p>
|
||||||
|
{% endif %}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
<div class="pt-4">
|
<div class="pt-4">
|
||||||
|
|||||||
@@ -92,17 +92,77 @@ class DispositionTest extends TestCase
|
|||||||
$this->assertTrue($disposition->isInvoiceDue(self::INVOICE_DEADLINE_DAYS));
|
$this->assertTrue($disposition->isInvoiceDue(self::INVOICE_DEADLINE_DAYS));
|
||||||
}
|
}
|
||||||
|
|
||||||
private function createDisposition(string $createdAt, string $assignmentEndsIn): Disposition
|
/**
|
||||||
|
* A skip-formalities assignment has no contract step at all, so a placement on one starts
|
||||||
|
* where a signed and checked contract would otherwise have put it.
|
||||||
|
*/
|
||||||
|
public function testAPlacementOnASkipFormalitiesAssignmentIsConfirmedRightAway(): void
|
||||||
{
|
{
|
||||||
|
$disposition = $this->createDisposition(
|
||||||
|
createdAt: 'today',
|
||||||
|
assignmentEndsIn: '+30 days',
|
||||||
|
skipFormalities: true,
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->assertSame(Disposition::STATUS_CONFIRMED, $disposition->getStatus());
|
||||||
|
$this->assertTrue($disposition->isSkipFormalities());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testAPlacementOnANormalAssignmentStillStartsAsNew(): void
|
||||||
|
{
|
||||||
|
$disposition = $this->createDisposition(createdAt: 'today', assignmentEndsIn: '+30 days');
|
||||||
|
|
||||||
|
$this->assertSame(Disposition::STATUS_NEW, $disposition->getStatus());
|
||||||
|
$this->assertFalse($disposition->isSkipFormalities());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Both dates are chosen so the unflagged disposition would be due - what is pinned here is
|
||||||
|
* that the flag wins over the period maths, not that the period happens to be closed.
|
||||||
|
*/
|
||||||
|
public function testNoDocumentIsEverDueOnASkipFormalitiesAssignment(): void
|
||||||
|
{
|
||||||
|
$disposition = $this->createDisposition(
|
||||||
|
createdAt: 'today -60 days',
|
||||||
|
assignmentEndsIn: '-1 day',
|
||||||
|
skipFormalities: true,
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->assertFalse($disposition->isContractDue(self::CONTRACT_DEADLINE_DAYS));
|
||||||
|
$this->assertFalse($disposition->isInvoiceDue(self::INVOICE_DEADLINE_DAYS));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testARejectedContractIsNotReportedOnASkipFormalitiesAssignment(): void
|
||||||
|
{
|
||||||
|
$disposition = $this->createDisposition(
|
||||||
|
createdAt: 'today -60 days',
|
||||||
|
assignmentEndsIn: '+30 days',
|
||||||
|
skipFormalities: true,
|
||||||
|
);
|
||||||
|
$disposition->addDocument(
|
||||||
|
(new Upload())->setType(Upload::TYPE_CONTRACT)->setStatus(Upload::STATUS_REJECTED)
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->assertFalse($disposition->isContractRejected());
|
||||||
|
}
|
||||||
|
|
||||||
|
private function createDisposition(
|
||||||
|
string $createdAt,
|
||||||
|
string $assignmentEndsIn,
|
||||||
|
bool $skipFormalities = false,
|
||||||
|
): Disposition {
|
||||||
$destination = (new Destination())
|
$destination = (new Destination())
|
||||||
->setProduct('Skireise')
|
->setProduct('Skireise')
|
||||||
->setDateFrom(new \DateTimeImmutable($assignmentEndsIn.' -7 days'))
|
->setDateFrom(new \DateTimeImmutable($assignmentEndsIn.' -7 days'))
|
||||||
->setDateTo(new \DateTimeImmutable($assignmentEndsIn))
|
->setDateTo(new \DateTimeImmutable($assignmentEndsIn))
|
||||||
;
|
;
|
||||||
|
|
||||||
$disposition = new Disposition(
|
$assignment = (new Assignment())
|
||||||
new Application((new Assignment())->setDestination($destination), new Teamer())
|
->setDestination($destination)
|
||||||
);
|
->setSkipFormalities($skipFormalities)
|
||||||
|
;
|
||||||
|
|
||||||
|
$disposition = new Disposition(new Application($assignment, new Teamer()));
|
||||||
|
|
||||||
return $disposition->setCreatedAt(new \DateTimeImmutable($createdAt));
|
return $disposition->setCreatedAt(new \DateTimeImmutable($createdAt));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,16 +4,19 @@ declare(strict_types=1);
|
|||||||
|
|
||||||
namespace App\Tests\EventListener;
|
namespace App\Tests\EventListener;
|
||||||
|
|
||||||
|
use App\Config\EmailTextKey;
|
||||||
use App\Email\Mailer;
|
use App\Email\Mailer;
|
||||||
use App\Email\MailPlaceholderFactory;
|
use App\Email\MailPlaceholderFactory;
|
||||||
use App\Entity\Application;
|
use App\Entity\Application;
|
||||||
use App\Entity\Assignment;
|
use App\Entity\Assignment;
|
||||||
|
use App\Entity\Destination;
|
||||||
use App\Entity\Disposition;
|
use App\Entity\Disposition;
|
||||||
use App\Entity\Embeddable\Communication;
|
use App\Entity\Embeddable\Communication;
|
||||||
use App\Entity\Teamer;
|
use App\Entity\Teamer;
|
||||||
use App\Event\ApplicationStatusEvent;
|
use App\Event\ApplicationStatusEvent;
|
||||||
use App\Event\AssignmentCalledOffEvent;
|
use App\Event\AssignmentCalledOffEvent;
|
||||||
use App\Event\DispositionCalledOffEvent;
|
use App\Event\DispositionCalledOffEvent;
|
||||||
|
use App\Event\DispositionCreatedEvent;
|
||||||
use App\EventListener\EmailNotificationSubscriber;
|
use App\EventListener\EmailNotificationSubscriber;
|
||||||
use App\Model\ApplicationStatusDto;
|
use App\Model\ApplicationStatusDto;
|
||||||
use App\Repository\UserRepository;
|
use App\Repository\UserRepository;
|
||||||
@@ -28,16 +31,18 @@ use PHPUnit\Framework\TestCase;
|
|||||||
class EmailNotificationSubscriberTest extends TestCase
|
class EmailNotificationSubscriberTest extends TestCase
|
||||||
{
|
{
|
||||||
private Mailer&MockObject $mailer;
|
private Mailer&MockObject $mailer;
|
||||||
|
private ContractRenderer&MockObject $contractRenderer;
|
||||||
private EmailNotificationSubscriber $subscriber;
|
private EmailNotificationSubscriber $subscriber;
|
||||||
|
|
||||||
protected function setUp(): void
|
protected function setUp(): void
|
||||||
{
|
{
|
||||||
$this->mailer = $this->createMock(Mailer::class);
|
$this->mailer = $this->createMock(Mailer::class);
|
||||||
|
$this->contractRenderer = $this->createMock(ContractRenderer::class);
|
||||||
|
|
||||||
$this->subscriber = new EmailNotificationSubscriber(
|
$this->subscriber = new EmailNotificationSubscriber(
|
||||||
$this->mailer,
|
$this->mailer,
|
||||||
$this->createMock(UserRepository::class),
|
$this->createMock(UserRepository::class),
|
||||||
$this->createMock(ContractRenderer::class),
|
$this->contractRenderer,
|
||||||
$this->createMock(MailPlaceholderFactory::class),
|
$this->createMock(MailPlaceholderFactory::class),
|
||||||
7,
|
7,
|
||||||
);
|
);
|
||||||
@@ -88,6 +93,52 @@ class EmailNotificationSubscriberTest extends TestCase
|
|||||||
$this->subscriber->onAssignmentCalledOff(new AssignmentCalledOffEvent($assignment));
|
$this->subscriber->onAssignmentCalledOff(new AssignmentCalledOffEvent($assignment));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The placement itself still has to be announced - what falls away is only the contract:
|
||||||
|
* no PDF is rendered, nothing is attached, and the wording comes from a separate text that
|
||||||
|
* does not ask for a signature or name an upload deadline.
|
||||||
|
*/
|
||||||
|
public function testASkipFormalitiesPlacementIsAnnouncedWithoutAContract(): void
|
||||||
|
{
|
||||||
|
$disposition = $this->createDisposition($this->createTeamer(), $this->createAssignment(true));
|
||||||
|
|
||||||
|
$this->contractRenderer->expects($this->never())->method('render');
|
||||||
|
$this->mailer
|
||||||
|
->expects($this->once())
|
||||||
|
->method('createAndSendText')
|
||||||
|
->with(
|
||||||
|
EmailTextKey::APPLICATION_ACCEPTED_NO_CONTRACT,
|
||||||
|
$this->anything(),
|
||||||
|
$this->callback(fn (array $options) => false === isset($options['attachments'])),
|
||||||
|
)
|
||||||
|
;
|
||||||
|
|
||||||
|
$this->subscriber->onDispositionCreated(new DispositionCreatedEvent($disposition));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testADeletedTeamerIsNotNotifiedAboutASkipFormalitiesPlacement(): void
|
||||||
|
{
|
||||||
|
$disposition = $this->createDisposition($this->createDeletedTeamer(), $this->createAssignment(true));
|
||||||
|
|
||||||
|
$this->mailer->expects($this->never())->method('createAndSendText');
|
||||||
|
|
||||||
|
$this->subscriber->onDispositionCreated(new DispositionCreatedEvent($disposition));
|
||||||
|
}
|
||||||
|
|
||||||
|
private function createAssignment(bool $skipFormalities): Assignment
|
||||||
|
{
|
||||||
|
$destination = (new Destination())
|
||||||
|
->setProduct('Skireise')
|
||||||
|
->setDateFrom(new \DateTimeImmutable('+30 days'))
|
||||||
|
->setDateTo(new \DateTimeImmutable('+37 days'))
|
||||||
|
;
|
||||||
|
|
||||||
|
return (new Assignment())
|
||||||
|
->setDestination($destination)
|
||||||
|
->setSkipFormalities($skipFormalities)
|
||||||
|
;
|
||||||
|
}
|
||||||
|
|
||||||
private function createDisposition(Teamer $teamer, ?Assignment $assignment = null): Disposition
|
private function createDisposition(Teamer $teamer, ?Assignment $assignment = null): Disposition
|
||||||
{
|
{
|
||||||
return new Disposition(new Application($assignment ?? new Assignment(), $teamer));
|
return new Disposition(new Application($assignment ?? new Assignment(), $teamer));
|
||||||
|
|||||||
@@ -4,7 +4,10 @@ declare(strict_types=1);
|
|||||||
|
|
||||||
namespace App\Tests\Form;
|
namespace App\Tests\Form;
|
||||||
|
|
||||||
|
use App\Entity\Application;
|
||||||
use App\Entity\Assignment;
|
use App\Entity\Assignment;
|
||||||
|
use App\Entity\Disposition;
|
||||||
|
use App\Entity\Teamer;
|
||||||
use App\Form\AssignmentType;
|
use App\Form\AssignmentType;
|
||||||
use PHPUnit\Framework\TestCase;
|
use PHPUnit\Framework\TestCase;
|
||||||
|
|
||||||
@@ -63,6 +66,25 @@ class AssignmentTypeTest extends TestCase
|
|||||||
$this->assertNotContains(Assignment::STATUS_CALLED_OFF, array_values(AssignmentType::statusChoices(null)));
|
$this->assertNotContains(Assignment::STATUS_CALLED_OFF, array_values(AssignmentType::statusChoices(null)));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function testTheSkipFormalitiesFlagIsOpenOnANewAssignment(): void
|
||||||
|
{
|
||||||
|
$this->assertFalse(AssignmentType::isSkipFormalitiesLocked(null));
|
||||||
|
$this->assertFalse(AssignmentType::isSkipFormalitiesLocked(new Assignment()));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The flag decides which status a Disposition is created in, so once placements exist it
|
||||||
|
* can no longer be answered for them - a teamer confirmed without a contract, or one still
|
||||||
|
* being chased for a contract the assignment no longer wants.
|
||||||
|
*/
|
||||||
|
public function testTheSkipFormalitiesFlagIsFrozenOnceTeamersArePlaced(): void
|
||||||
|
{
|
||||||
|
$assignment = new Assignment();
|
||||||
|
$assignment->addDisposition(new Disposition(new Application($assignment, new Teamer())));
|
||||||
|
|
||||||
|
$this->assertTrue(AssignmentType::isSkipFormalitiesLocked($assignment));
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @return array<int, string>
|
* @return array<int, string>
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -194,6 +194,61 @@ class DispositionRepositoryTest extends KernelTestCase
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A skip-formalities placement is still `confirmed` when this query runs: CronCommand sends
|
||||||
|
* the invoice reminders before DispositionStatusService promotes it to `completed`, so the
|
||||||
|
* status filter alone would let it through on the day after the assignment ends. The flag
|
||||||
|
* has to be part of the DQL.
|
||||||
|
*/
|
||||||
|
public function testInvoiceReminderSkipsSkipFormalitiesAssignments(): void
|
||||||
|
{
|
||||||
|
$query = $this->invoiceQuery();
|
||||||
|
|
||||||
|
$this->assertStringContainsString(
|
||||||
|
'assignment.skipFormalities = :skipFormalities',
|
||||||
|
$query->getDQL()
|
||||||
|
);
|
||||||
|
$this->assertFalse($query->getParameter('skipFormalities')->getValue());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Feeds the house-manager list, both dashboards' "Überfällige Feedbacks" tiles and the
|
||||||
|
* reminder mail, so one condition here covers every surface that chases a feedback.
|
||||||
|
*/
|
||||||
|
public function testPendingFeedbackSkipsSkipFormalitiesAssignments(): void
|
||||||
|
{
|
||||||
|
$query = $this->repository()->getDispositionsWithPendingFeedbackQuery();
|
||||||
|
|
||||||
|
$this->assertStringContainsString(
|
||||||
|
'assignment.skipFormalities = :skipFormalities',
|
||||||
|
$query->getDQL()
|
||||||
|
);
|
||||||
|
$this->assertFalse($query->getParameter('skipFormalities')->getValue());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Otherwise placements nobody is expected to rate would be counted as missingFeedbacks and
|
||||||
|
* drag every hotel's percentage down.
|
||||||
|
*/
|
||||||
|
public function testFeedbackStatisticsDoNotCountSkipFormalitiesPlacementsAsMissing(): void
|
||||||
|
{
|
||||||
|
// the by-hotel and normalized-code variants back two different statistics screens and are
|
||||||
|
// maintained as a pair, so both are pinned - dropping the condition from one only would
|
||||||
|
// make the two disagree about the same hotel
|
||||||
|
$queries = [
|
||||||
|
$this->repository()->getFeedbackStatisticsByHotelQuery(),
|
||||||
|
$this->repository()->getFeedbackStatisticsByNormalizedHotelCodeQuery(),
|
||||||
|
];
|
||||||
|
|
||||||
|
foreach ($queries as $query) {
|
||||||
|
$this->assertStringContainsString(
|
||||||
|
'assignment.skipFormalities = :skipFormalities',
|
||||||
|
$query->getDQL()
|
||||||
|
);
|
||||||
|
$this->assertFalse($query->getParameter('skipFormalities')->getValue());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private function contractQuery(int $deadlineDays = 5): Query
|
private function contractQuery(int $deadlineDays = 5): Query
|
||||||
{
|
{
|
||||||
return $this->repository()->getContractReminderQuery($deadlineDays);
|
return $this->repository()->getContractReminderQuery($deadlineDays);
|
||||||
|
|||||||
@@ -0,0 +1,103 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Tests\Security\Voter;
|
||||||
|
|
||||||
|
use App\Entity\Application;
|
||||||
|
use App\Entity\Assignment;
|
||||||
|
use App\Entity\Disposition;
|
||||||
|
use App\Entity\Teamer;
|
||||||
|
use App\Security\Voter\DispositionVoter;
|
||||||
|
use PHPUnit\Framework\MockObject\MockObject;
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
use Symfony\Bundle\SecurityBundle\Security;
|
||||||
|
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
|
||||||
|
use Symfony\Component\Security\Core\Authorization\Voter\VoterInterface;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The documents and the feedback simply do not exist on a skip-formalities assignment, so the
|
||||||
|
* voter is where that is enforced: it closes the blank-PDF routes and the upload and feedback
|
||||||
|
* screens at once, and every template that gates on is_granted() follows without a change.
|
||||||
|
*/
|
||||||
|
class DispositionVoterTest extends TestCase
|
||||||
|
{
|
||||||
|
private Security&MockObject $security;
|
||||||
|
|
||||||
|
protected function setUp(): void
|
||||||
|
{
|
||||||
|
$this->security = $this->createMock(Security::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @dataProvider documentAndFeedbackAttributes
|
||||||
|
*/
|
||||||
|
public function testAnAdminIsDeniedOnASkipFormalitiesAssignment(string $attribute): void
|
||||||
|
{
|
||||||
|
$this->security->method('isGranted')->willReturn(true);
|
||||||
|
|
||||||
|
$this->assertSame(
|
||||||
|
VoterInterface::ACCESS_DENIED,
|
||||||
|
$this->vote($attribute, $this->createDisposition(skipFormalities: true)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The same admin on a normal assignment must still get through - the flag is the only thing
|
||||||
|
* that may take these away.
|
||||||
|
*
|
||||||
|
* @dataProvider documentAndFeedbackAttributes
|
||||||
|
*/
|
||||||
|
public function testTheSameAdminIsGrantedOnANormalAssignment(string $attribute): void
|
||||||
|
{
|
||||||
|
$this->security->method('isGranted')->willReturn(true);
|
||||||
|
|
||||||
|
$this->assertSame(
|
||||||
|
VoterInterface::ACCESS_GRANTED,
|
||||||
|
$this->vote($attribute, $this->createDisposition(skipFormalities: false)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, array{string}>
|
||||||
|
*/
|
||||||
|
public static function documentAndFeedbackAttributes(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'contract pdf and upload' => [DispositionVoter::CONTRACT],
|
||||||
|
'invoice pdf and upload' => [DispositionVoter::INVOICE],
|
||||||
|
'office upload on behalf of the teamer' => [DispositionVoter::CONTRACT_SUPPLEMENTARY],
|
||||||
|
'office upload of either document' => [DispositionVoter::ADMIN_DOCUMENT_UPLOAD],
|
||||||
|
'providing the feedback' => [DispositionVoter::FEEDBACK],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Viewing the placement and calling it off are unrelated to the formalities and have to keep
|
||||||
|
* working, or an admin could no longer cancel a teamer on such an assignment.
|
||||||
|
*/
|
||||||
|
public function testViewingAndCallingOffAreUnaffected(): void
|
||||||
|
{
|
||||||
|
$this->security->method('isGranted')->willReturn(true);
|
||||||
|
$disposition = $this->createDisposition(skipFormalities: true);
|
||||||
|
|
||||||
|
$this->assertSame(VoterInterface::ACCESS_GRANTED, $this->vote(DispositionVoter::VIEW, $disposition));
|
||||||
|
$this->assertSame(VoterInterface::ACCESS_GRANTED, $this->vote(DispositionVoter::CALL_OFF, $disposition));
|
||||||
|
}
|
||||||
|
|
||||||
|
private function vote(string $attribute, Disposition $disposition): int
|
||||||
|
{
|
||||||
|
return (new DispositionVoter($this->security))->vote(
|
||||||
|
$this->createMock(TokenInterface::class),
|
||||||
|
$disposition,
|
||||||
|
[$attribute],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function createDisposition(bool $skipFormalities): Disposition
|
||||||
|
{
|
||||||
|
$assignment = (new Assignment())->setSkipFormalities($skipFormalities);
|
||||||
|
|
||||||
|
return new Disposition(new Application($assignment, new Teamer()));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Tests\Service\Cron;
|
||||||
|
|
||||||
|
use App\Entity\Application;
|
||||||
|
use App\Entity\Assignment;
|
||||||
|
use App\Entity\Disposition;
|
||||||
|
use App\Entity\Teamer;
|
||||||
|
use App\Repository\DispositionRepository;
|
||||||
|
use App\Service\Cron\DispositionStatusService;
|
||||||
|
use Doctrine\ORM\EntityManagerInterface;
|
||||||
|
use PHPUnit\Framework\MockObject\MockObject;
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
use Psr\Log\NullLogger;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Where a placement lands once its assignment is over.
|
||||||
|
*
|
||||||
|
* The normal one stops at `ended`, which is what opens the invoice step. A skip-formalities
|
||||||
|
* placement has no invoice step to open, so ending it means it is done - anything else would
|
||||||
|
* park it in `ended` forever and keep asking the teamer for a Honorarnote nobody wants.
|
||||||
|
*/
|
||||||
|
class DispositionStatusServiceTest extends TestCase
|
||||||
|
{
|
||||||
|
private DispositionRepository&MockObject $repository;
|
||||||
|
private DispositionStatusService $service;
|
||||||
|
|
||||||
|
protected function setUp(): void
|
||||||
|
{
|
||||||
|
$this->repository = $this->createMock(DispositionRepository::class);
|
||||||
|
|
||||||
|
$entityManager = $this->createMock(EntityManagerInterface::class);
|
||||||
|
$entityManager
|
||||||
|
->method('getRepository')
|
||||||
|
->willReturn($this->repository)
|
||||||
|
;
|
||||||
|
|
||||||
|
$this->service = new DispositionStatusService($entityManager, new NullLogger());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testANormalPlacementEnds(): void
|
||||||
|
{
|
||||||
|
$disposition = $this->createDisposition(skipFormalities: false);
|
||||||
|
|
||||||
|
$this->givenEndedDispositions($disposition);
|
||||||
|
$this->service->setEndedStatus();
|
||||||
|
|
||||||
|
$this->assertSame(Disposition::STATUS_ENDED, $disposition->getStatus());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testASkipFormalitiesPlacementIsCompletedInsteadOfEnded(): void
|
||||||
|
{
|
||||||
|
$disposition = $this->createDisposition(skipFormalities: true);
|
||||||
|
|
||||||
|
$this->givenEndedDispositions($disposition);
|
||||||
|
$this->service->setEndedStatus();
|
||||||
|
|
||||||
|
$this->assertSame(Disposition::STATUS_COMPLETED, $disposition->getStatus());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Both kinds come out of the same query, so the split has to happen per disposition.
|
||||||
|
*/
|
||||||
|
public function testAMixedBatchIsSplitPerDisposition(): void
|
||||||
|
{
|
||||||
|
$normal = $this->createDisposition(skipFormalities: false);
|
||||||
|
$skipped = $this->createDisposition(skipFormalities: true);
|
||||||
|
|
||||||
|
$this->givenEndedDispositions($normal, $skipped);
|
||||||
|
$this->service->setEndedStatus();
|
||||||
|
|
||||||
|
$this->assertSame(Disposition::STATUS_ENDED, $normal->getStatus());
|
||||||
|
$this->assertSame(Disposition::STATUS_COMPLETED, $skipped->getStatus());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testNothingIsFlushedWhenThereIsNothingToEnd(): void
|
||||||
|
{
|
||||||
|
$this->givenEndedDispositions();
|
||||||
|
|
||||||
|
$this->assertSame('No ended dispositions to update', $this->service->setEndedStatus());
|
||||||
|
}
|
||||||
|
|
||||||
|
private function givenEndedDispositions(Disposition ...$dispositions): void
|
||||||
|
{
|
||||||
|
$this->repository
|
||||||
|
->method('findEndedDispositions')
|
||||||
|
->willReturn($dispositions)
|
||||||
|
;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function createDisposition(bool $skipFormalities): Disposition
|
||||||
|
{
|
||||||
|
$assignment = (new Assignment())->setSkipFormalities($skipFormalities);
|
||||||
|
|
||||||
|
return new Disposition(new Application($assignment, new Teamer()));
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user