feat: assignments with disabled formalities

addresses #869dnvm8c
This commit is contained in:
2026-08-26 16:15:55 +02:00
parent 5979988dcc
commit c33c1b6bf0
20 changed files with 720 additions and 67 deletions
+64 -4
View File
@@ -92,17 +92,77 @@ class DispositionTest extends TestCase
$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())
->setProduct('Skireise')
->setDateFrom(new \DateTimeImmutable($assignmentEndsIn.' -7 days'))
->setDateTo(new \DateTimeImmutable($assignmentEndsIn))
;
$disposition = new Disposition(
new Application((new Assignment())->setDestination($destination), new Teamer())
);
$assignment = (new Assignment())
->setDestination($destination)
->setSkipFormalities($skipFormalities)
;
$disposition = new Disposition(new Application($assignment, new Teamer()));
return $disposition->setCreatedAt(new \DateTimeImmutable($createdAt));
}
@@ -4,16 +4,19 @@ declare(strict_types=1);
namespace App\Tests\EventListener;
use App\Config\EmailTextKey;
use App\Email\Mailer;
use App\Email\MailPlaceholderFactory;
use App\Entity\Application;
use App\Entity\Assignment;
use App\Entity\Destination;
use App\Entity\Disposition;
use App\Entity\Embeddable\Communication;
use App\Entity\Teamer;
use App\Event\ApplicationStatusEvent;
use App\Event\AssignmentCalledOffEvent;
use App\Event\DispositionCalledOffEvent;
use App\Event\DispositionCreatedEvent;
use App\EventListener\EmailNotificationSubscriber;
use App\Model\ApplicationStatusDto;
use App\Repository\UserRepository;
@@ -28,16 +31,18 @@ use PHPUnit\Framework\TestCase;
class EmailNotificationSubscriberTest extends TestCase
{
private Mailer&MockObject $mailer;
private ContractRenderer&MockObject $contractRenderer;
private EmailNotificationSubscriber $subscriber;
protected function setUp(): void
{
$this->mailer = $this->createMock(Mailer::class);
$this->contractRenderer = $this->createMock(ContractRenderer::class);
$this->subscriber = new EmailNotificationSubscriber(
$this->mailer,
$this->createMock(UserRepository::class),
$this->createMock(ContractRenderer::class),
$this->contractRenderer,
$this->createMock(MailPlaceholderFactory::class),
7,
);
@@ -88,6 +93,52 @@ class EmailNotificationSubscriberTest extends TestCase
$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
{
return new Disposition(new Application($assignment ?? new Assignment(), $teamer));
+22
View File
@@ -4,7 +4,10 @@ declare(strict_types=1);
namespace App\Tests\Form;
use App\Entity\Application;
use App\Entity\Assignment;
use App\Entity\Disposition;
use App\Entity\Teamer;
use App\Form\AssignmentType;
use PHPUnit\Framework\TestCase;
@@ -63,6 +66,25 @@ class AssignmentTypeTest extends TestCase
$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>
*/
@@ -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
{
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()));
}
}