feat: remove obsolete date range override

This commit is contained in:
2026-08-31 16:56:36 +02:00
parent 86c5d23181
commit 127ced4378
42 changed files with 152 additions and 353 deletions
+2 -3
View File
@@ -204,9 +204,8 @@ pinned in tests without a database.
### A period means the season, not when the row was written ### A period means the season, not when the row was written
`StatisticsDateBasis::SEASON` (the default) filters on the assignment's dates, falling back to `StatisticsDateBasis::SEASON` (the default) filters on the destination's date range through
the destination's — the translation of `Assignment::getEffectivePeriod()` into DQL, which `src/Repository/Filter/SeasonPeriodFilter.php`, which is shared with
lives in `src/Repository/Filter/SeasonPeriodFilter.php` and is shared with
`DispositionRepository`. This matches every other statistics screen and `DispositionRepository`. This matches every other statistics screen and
`StatisticsFilterType`. Change what a season means there, once, not per screen. `StatisticsFilterType`. Change what a season means there, once, not per screen.
+32
View File
@@ -0,0 +1,32 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Drops the per-assignment date range override (assignment.date_from / date_to).
*
* The columns were never writable from the UI and hold no data; every consumer now reads the
* destination's dates directly. See .ddev/plans/remove-assignment-date-override.md.
*/
final class Version20260831144511 extends AbstractMigration
{
public function getDescription(): string
{
return 'Drop the unused per-assignment date range override columns';
}
public function up(Schema $schema): void
{
$this->addSql('ALTER TABLE assignment DROP date_from, DROP date_to');
}
public function down(Schema $schema): void
{
$this->addSql('ALTER TABLE assignment ADD date_from DATE DEFAULT NULL COMMENT \'(DC2Type:date_immutable)\', ADD date_to DATE DEFAULT NULL COMMENT \'(DC2Type:date_immutable)\'');
}
}
@@ -36,7 +36,7 @@ class IndexController extends AbstractController
$request->query->getInt('page', 1), $request->query->getInt('page', 1),
$request->query->getInt('limit', 50), $request->query->getInt('limit', 50),
[ [
'defaultSortFieldName' => 'assignment.dateFrom', 'defaultSortFieldName' => 'destination.dateFrom',
'defaultSortDirection' => 'asc', 'defaultSortDirection' => 'asc',
] ]
); );
@@ -37,7 +37,7 @@ class IndexController extends AbstractController
$request->query->getInt('page', 1), $request->query->getInt('page', 1),
9, 9,
[ [
'defaultSortFieldName' => 'assignment.dateFrom', 'defaultSortFieldName' => 'destination.dateFrom',
'defaultSortDirection' => 'asc', 'defaultSortDirection' => 'asc',
] ]
); );
@@ -37,7 +37,7 @@ class IndexController extends AbstractController
$request->query->getInt('page', 1), $request->query->getInt('page', 1),
9, 9,
[ [
'defaultSortFieldName' => 'assignment.dateFrom', 'defaultSortFieldName' => 'destination.dateFrom',
'defaultSortDirection' => 'asc', 'defaultSortDirection' => 'asc',
] ]
); );
@@ -35,8 +35,8 @@ class IcsController extends AbstractController
$filename = sprintf( $filename = sprintf(
'Einteilung_%s_%s-%s.ics', 'Einteilung_%s_%s-%s.ics',
$jobProfile->getName(), $jobProfile->getName(),
$assignment->getEffectivePeriod()->start->format('d.m.Y'), $destination->getDateFrom()->format('d.m.Y'),
$assignment->getEffectivePeriod()->end->format('d.m.Y') $destination->getDateTo()->format('d.m.Y')
); );
$slugger = new AsciiSlugger(); $slugger = new AsciiSlugger();
$filename = $slugger->slug($filename); $filename = $slugger->slug($filename);
@@ -37,7 +37,7 @@ class RecentController extends AbstractController
$request->query->getInt('page', 1), $request->query->getInt('page', 1),
9, 9,
[ [
'defaultSortFieldName' => 'assignment.dateFrom', 'defaultSortFieldName' => 'destination.dateFrom',
'defaultSortDirection' => 'asc', 'defaultSortDirection' => 'asc',
] ]
); );
@@ -37,7 +37,7 @@ class UpcomingController extends AbstractController
$request->query->getInt('page', 1), $request->query->getInt('page', 1),
9, 9,
[ [
'defaultSortFieldName' => 'assignment.dateFrom', 'defaultSortFieldName' => 'destination.dateFrom',
'defaultSortDirection' => 'asc', 'defaultSortDirection' => 'asc',
] ]
); );
-70
View File
@@ -6,7 +6,6 @@ use App\Entity\Traits\BlameableEntity;
use App\Entity\Traits\SoftDeletableEntity; use App\Entity\Traits\SoftDeletableEntity;
use App\Entity\Traits\TimestampableEntity; use App\Entity\Traits\TimestampableEntity;
use App\Repository\AssignmentRepository; use App\Repository\AssignmentRepository;
use Carbon\CarbonPeriodImmutable;
use Doctrine\Common\Collections\ArrayCollection; use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection; use Doctrine\Common\Collections\Collection;
use Doctrine\DBAL\Types\Types; use Doctrine\DBAL\Types\Types;
@@ -64,13 +63,6 @@ class Assignment implements BlameableEntityInterface, TimestampableEntityInterfa
#[ORM\Column] #[ORM\Column]
private bool $skipFormalities = false; private bool $skipFormalities = false;
#[ORM\Column(type: Types::DATE_IMMUTABLE, nullable: true)]
private ?\DateTimeImmutable $dateFrom = null;
#[ORM\Column(type: Types::DATE_IMMUTABLE, nullable: true)]
#[Assert\GreaterThan(propertyPath: 'dateFrom', message: 'Das Enddatum muss nach dem Beginndatum liegen')]
private ?\DateTimeImmutable $dateTo = null;
#[ORM\Column(type: Types::DATE_IMMUTABLE, nullable: true)] #[ORM\Column(type: Types::DATE_IMMUTABLE, nullable: true)]
private ?\DateTimeImmutable $pickupDate = null; private ?\DateTimeImmutable $pickupDate = null;
@@ -133,8 +125,6 @@ class Assignment implements BlameableEntityInterface, TimestampableEntityInterfa
->setAvailableDispositions($assignment->getAvailableDispositions()) ->setAvailableDispositions($assignment->getAvailableDispositions())
->setShowAvailableDispositions($assignment->isShowAvailableDispositions()) ->setShowAvailableDispositions($assignment->isShowAvailableDispositions())
->setSkipFormalities($assignment->isSkipFormalities()) ->setSkipFormalities($assignment->isSkipFormalities())
->setDateFrom($assignment->getDateFrom())
->setDateTo($assignment->getDateTo())
->setPickupDate($assignment->getPickupDate()) ->setPickupDate($assignment->getPickupDate())
->setPickup($assignment->getPickup()) ->setPickup($assignment->getPickup())
->setBenefits($assignment->getBenefits()) ->setBenefits($assignment->getBenefits())
@@ -266,66 +256,6 @@ class Assignment implements BlameableEntityInterface, TimestampableEntityInterfa
return $this; return $this;
} }
public function getDateFrom(): ?\DateTimeImmutable
{
return $this->dateFrom;
}
public function setDateFrom(?\DateTimeImmutable $dateFrom): static
{
$this->dateFrom = $dateFrom;
return $this;
}
public function getDateTo(): ?\DateTimeImmutable
{
return $this->dateTo;
}
public function setDateTo(?\DateTimeImmutable $dateTo): static
{
$this->dateTo = $dateTo;
return $this;
}
/**
* Immutable on purpose: a plain CarbonPeriod hands out mutable Carbon instances, so a caller
* deriving one date from another - `$end = $period->getEndDate(); $end->addDays(14)` - moves
* the original as well and silently collapses its own period. CarbonPeriodImmutable yields
* CarbonImmutable from start, end and getEndDate(), which makes that mistake impossible.
*/
public function getEffectivePeriod(): ?CarbonPeriodImmutable
{
if (null !== $destination = $this->getDestination()) {
$dateFrom = $this->getDateFrom() ?? $destination->getDateFrom();
$dateTo = $this->getDateTo() ?? $destination->getDateTo();
return new CarbonPeriodImmutable($dateFrom, $dateTo);
}
return null;
}
public function getEffectiveDateFrom(): ?\DateTimeImmutable
{
if (null !== $effectivePeriod = $this->getEffectivePeriod()) {
return $effectivePeriod->start->toDateTimeImmutable();
}
return null;
}
public function getEffectiveDateTo(): ?\DateTimeImmutable
{
if (null !== $effectivePeriod = $this->getEffectivePeriod()) {
return $effectivePeriod->end->toDateTimeImmutable();
}
return null;
}
public function getEffectivePickupDate(): ?\DateTimeImmutable public function getEffectivePickupDate(): ?\DateTimeImmutable
{ {
if (null !== $this->pickupDate) { if (null !== $this->pickupDate) {
+3 -5
View File
@@ -246,9 +246,8 @@ class Disposition implements BlameableEntityInterface, TimestampableEntityInterf
} }
$assignment = $this->getAssignment(); $assignment = $this->getAssignment();
$assignmentPeriod = $assignment->getEffectivePeriod();
$dueDateFrom = $this->getCreatedAt()->modify(sprintf('+%d days', $deadlineDays)); $dueDateFrom = $this->getCreatedAt()->modify(sprintf('+%d days', $deadlineDays));
$dueDateTo = $assignmentPeriod->end->modify('-1 day'); $dueDateTo = $assignment->getDestination()->getDateTo()->modify('-1 day');
// A short-notice assignment can end before the normal $deadlineDays window would even // A short-notice assignment can end before the normal $deadlineDays window would even
// start. Left uncapped, dueDateFrom >= dueDateTo makes isStarted() and isEnded() equal // start. Left uncapped, dueDateFrom >= dueDateTo makes isStarted() and isEnded() equal
@@ -297,9 +296,8 @@ class Disposition implements BlameableEntityInterface, TimestampableEntityInterf
} }
$assignment = $this->getAssignment(); $assignment = $this->getAssignment();
$assignmentPeriod = $assignment->getEffectivePeriod(); $dueDateFrom = $assignment->getDestination()->getDateTo();
$dueDateFrom = $assignmentPeriod->getEndDate(); $dueDateTo = $dueDateFrom->modify(sprintf('+%d days', $deadlineDays));
$dueDateTo = $dueDateFrom->addDays($deadlineDays);
$period = CarbonPeriodImmutable::create($dueDateFrom, $dueDateTo); $period = CarbonPeriodImmutable::create($dueDateFrom, $dueDateTo);
+2 -2
View File
@@ -98,8 +98,8 @@ class Feedback implements BlameableEntityInterface, TimestampableEntityInterface
$instance $instance
->setAssignment($assignment) ->setAssignment($assignment)
->setAssignmentDateFrom($assignment->getEffectiveDateFrom()) ->setAssignmentDateFrom($destination->getDateFrom())
->setAssignmentDateTo($assignment->getEffectiveDateTo()) ->setAssignmentDateTo($destination->getDateTo())
->setDestinationName($destination->getProduct()) ->setDestinationName($destination->getProduct())
->setHotel($destination->getHotel()) ->setHotel($destination->getHotel())
->setHotelCode($destination->getHotelCode()) ->setHotelCode($destination->getHotelCode())
@@ -75,14 +75,13 @@ class DispositionWorkflowGuardSubscriber implements EventSubscriberInterface
/** @var Disposition $disposition */ /** @var Disposition $disposition */
$disposition = $event->getSubject(); $disposition = $event->getSubject();
$assignment = $disposition->getAssignment(); $assignment = $disposition->getAssignment();
$assignmentPeriod = $assignment->getEffectivePeriod();
$today = new \DateTimeImmutable(); $today = new \DateTimeImmutable();
$dueDateFrom = $disposition $dueDateFrom = $disposition
->getCreatedAt() ->getCreatedAt()
->modify(sprintf('+%d days', $this->contractUploadDeadlineDays)) ->modify(sprintf('+%d days', $this->contractUploadDeadlineDays))
; ;
$dueDateTo = $assignmentPeriod->end->modify('-1 day'); $dueDateTo = $assignment->getDestination()->getDateTo()->modify('-1 day');
// Block upload if deadline has passed // Block upload if deadline has passed
if ($dueDateTo < $today) { if ($dueDateTo < $today) {
@@ -6,6 +6,7 @@ use App\Entity\Upload;
use App\Event\ApplicationDeletedEvent; use App\Event\ApplicationDeletedEvent;
use App\Event\DocumentConfirmedEvent; use App\Event\DocumentConfirmedEvent;
use App\Repository\ApplicationRepository; use App\Repository\ApplicationRepository;
use Carbon\CarbonPeriodImmutable;
use Doctrine\ORM\EntityManagerInterface; use Doctrine\ORM\EntityManagerInterface;
use Psr\Log\LoggerInterface; use Psr\Log\LoggerInterface;
use Symfony\Component\EventDispatcher\Attribute\AsEventListener; use Symfony\Component\EventDispatcher\Attribute\AsEventListener;
@@ -37,10 +38,12 @@ class InvalidateApplicationsListener
return; return;
} }
if (null === $period = $disposition->getAssignment()->getEffectivePeriod()) { if (null === $destination = $disposition->getAssignment()->getDestination()) {
return; return;
} }
$period = new CarbonPeriodImmutable($destination->getDateFrom(), $destination->getDateTo());
$applications = $this->applicationRepository->findOverlappingForTeamer($teamer, $period); $applications = $this->applicationRepository->findOverlappingForTeamer($teamer, $period);
if (0 === count($applications)) { if (0 === count($applications)) {
@@ -55,8 +58,8 @@ class InvalidateApplicationsListener
'teamer' => $teamer->getUuid(), 'teamer' => $teamer->getUuid(),
'teamer_name' => (string) $teamer, 'teamer_name' => (string) $teamer,
'destination' => $assignment->getDestination()->getHotelCode(), 'destination' => $assignment->getDestination()->getHotelCode(),
'date_from' => $assignment->getEffectiveDateFrom()->format('Y-m-d'), 'date_from' => $assignment->getDestination()->getDateFrom()->format('Y-m-d'),
'date_to' => $assignment->getEffectiveDateTo()->format('Y-m-d'), 'date_to' => $assignment->getDestination()->getDateTo()->format('Y-m-d'),
]); ]);
$this->entityManager->remove($application); $this->entityManager->remove($application);
} }
-8
View File
@@ -79,14 +79,6 @@ class AssignmentType extends AbstractType
'rows' => 3, 'rows' => 3,
], ],
]) ])
// ->add('dateFrom', DatepickerType::class, [
// 'label' => 'abweichender Einsatztermin von',
// 'required' => false,
// ])
// ->add('dateTo', DatepickerType::class, [
// 'label' => 'abweichender Einsatztermin bis',
// 'required' => false,
// ])
// ->add('pickupDate', DatepickerType::class, [ // ->add('pickupDate', DatepickerType::class, [
// 'label' => 'abweichender Zustieg bei Busbegleitung', // 'label' => 'abweichender Zustieg bei Busbegleitung',
// 'required' => false, // 'required' => false,
+5 -25
View File
@@ -163,12 +163,10 @@ class ApplicationRepository extends ServiceEntityRepository
} }
/** /**
* Finds all applications of a teamer whose effective assignment period overlaps the given period. * Finds all applications of a teamer whose assignment period overlaps the given period.
* *
* The effective period of an assignment is its own date range, falling back to the date range of * The assignment period is the date range of its destination. Boundaries count as an
* its destination whenever the assignment does not override it - the query mirrors * overlap: a teamer cannot end one assignment on the same day another one begins.
* {@see Assignment::getEffectivePeriod()}. Boundaries count as an overlap: a teamer cannot end one
* assignment on the same day another one begins.
* *
* @return array<Application> * @return array<Application>
*/ */
@@ -193,26 +191,8 @@ class ApplicationRepository extends ServiceEntityRepository
$qb->expr()->neq('assignment.status', ':assignment_called_off'), $qb->expr()->neq('assignment.status', ':assignment_called_off'),
$qb->expr()->isNull('assignment.deletedAt'), $qb->expr()->isNull('assignment.deletedAt'),
$qb->expr()->isNull('destination.deletedAt'), $qb->expr()->isNull('destination.deletedAt'),
$qb->expr()->orX( $qb->expr()->lte('destination.dateFrom', ':dateTo'),
$qb->expr()->andX( $qb->expr()->gte('destination.dateTo', ':dateFrom')
$qb->expr()->isNull('assignment.dateFrom'),
$qb->expr()->lte('destination.dateFrom', ':dateTo')
),
$qb->expr()->andX(
$qb->expr()->isNotNull('assignment.dateFrom'),
$qb->expr()->lte('assignment.dateFrom', ':dateTo')
)
),
$qb->expr()->orX(
$qb->expr()->andX(
$qb->expr()->isNull('assignment.dateTo'),
$qb->expr()->gte('destination.dateTo', ':dateFrom')
),
$qb->expr()->andX(
$qb->expr()->isNotNull('assignment.dateTo'),
$qb->expr()->gte('assignment.dateTo', ':dateFrom')
)
)
)) ))
->setParameter('teamer', $teamer) ->setParameter('teamer', $teamer)
->setParameter('application_rejected', Application::STATUS_REJECTED) ->setParameter('application_rejected', Application::STATUS_REJECTED)
+2 -14
View File
@@ -73,13 +73,7 @@ class AssignmentRepository extends ServiceEntityRepository
->innerJoin('assignment.destination', 'destination') ->innerJoin('assignment.destination', 'destination')
->innerJoin('assignment.jobProfile', 'job_profile') ->innerJoin('assignment.jobProfile', 'job_profile')
->where($qb->expr()->andX( ->where($qb->expr()->andX(
$qb->expr()->orX( $qb->expr()->gt('destination.dateFrom', ':now'),
$qb->expr()->gt('destination.dateFrom', ':now'),
$qb->expr()->andX(
$qb->expr()->isNotNull('assignment.dateFrom'),
$qb->expr()->gt('assignment.dateFrom', ':now')
)
),
$qb->expr()->isNull('assignment.deletedAt'), $qb->expr()->isNull('assignment.deletedAt'),
$qb->expr()->notIn('assignment.status', ':status'), $qb->expr()->notIn('assignment.status', ':status'),
$qb->expr()->in('assignment.id', $availableAssignmentsIds) $qb->expr()->in('assignment.id', $availableAssignmentsIds)
@@ -399,13 +393,7 @@ class AssignmentRepository extends ServiceEntityRepository
) )
->innerJoin('assignment.destination', 'destination') ->innerJoin('assignment.destination', 'destination')
->where($qb->expr()->andX( ->where($qb->expr()->andX(
$qb->expr()->orX( $qb->expr()->gt('destination.dateFrom', ':now'),
$qb->expr()->gt('destination.dateFrom', ':now'),
$qb->expr()->andX(
$qb->expr()->isNotNull('assignment.dateFrom'),
$qb->expr()->gt('assignment.dateFrom', ':now')
)
),
$qb->expr()->notIn('assignment.status', ':status'), $qb->expr()->notIn('assignment.status', ':status'),
$qb->expr()->isNull('assignment.deletedAt') $qb->expr()->isNull('assignment.deletedAt')
)) ))
+12 -84
View File
@@ -11,7 +11,6 @@ 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\Comparison;
use Doctrine\ORM\Query\Expr\Join; use Doctrine\ORM\Query\Expr\Join;
use Doctrine\ORM\Query\Expr\Orx;
use Doctrine\ORM\QueryBuilder; use Doctrine\ORM\QueryBuilder;
use Doctrine\Persistence\ManagerRegistry; use Doctrine\Persistence\ManagerRegistry;
@@ -43,16 +42,7 @@ class DispositionRepository extends ServiceEntityRepository
$qb->expr()->eq('disposition.teamer', ':teamer'), $qb->expr()->eq('disposition.teamer', ':teamer'),
$qb->expr()->neq('assignment.status', ':assignmentStatus'), $qb->expr()->neq('assignment.status', ':assignmentStatus'),
$qb->expr()->neq('disposition.status', ':dispositionStatus'), $qb->expr()->neq('disposition.status', ':dispositionStatus'),
$qb->expr()->orX( $qb->expr()->gte('destination.dateTo', ':date')
$qb->expr()->andX(
$qb->expr()->isNull('assignment.dateTo'),
$qb->expr()->gte('destination.dateTo', ':date')
),
$qb->expr()->andX(
$qb->expr()->isNotNull('assignment.dateTo'),
$qb->expr()->gte('assignment.dateTo', ':date')
)
)
)) ))
->setParameter('teamer', $teamer) ->setParameter('teamer', $teamer)
->setParameter('assignmentStatus', Assignment::STATUS_CALLED_OFF) ->setParameter('assignmentStatus', Assignment::STATUS_CALLED_OFF)
@@ -85,16 +75,7 @@ class DispositionRepository extends ServiceEntityRepository
$qb->expr()->eq('disposition.teamer', ':teamer'), $qb->expr()->eq('disposition.teamer', ':teamer'),
$qb->expr()->neq('assignment.status', ':assignmentStatus'), $qb->expr()->neq('assignment.status', ':assignmentStatus'),
$qb->expr()->neq('disposition.status', ':dispositionStatus'), $qb->expr()->neq('disposition.status', ':dispositionStatus'),
$qb->expr()->orX( $qb->expr()->lte('destination.dateTo', ':date')
$qb->expr()->andX(
$qb->expr()->isNull('assignment.dateTo'),
$qb->expr()->lte('destination.dateTo', ':date')
),
$qb->expr()->andX(
$qb->expr()->isNotNull('assignment.dateTo'),
$qb->expr()->lte('assignment.dateTo', ':date')
)
)
)) ))
->setParameter('teamer', $teamer) ->setParameter('teamer', $teamer)
->setParameter('assignmentStatus', Assignment::STATUS_CALLED_OFF) ->setParameter('assignmentStatus', Assignment::STATUS_CALLED_OFF)
@@ -145,12 +126,12 @@ class DispositionRepository extends ServiceEntityRepository
} }
/** /**
* The effective start date of every non-called-off disposition of a teamer, one row * The start date of every non-called-off disposition of a teamer, one row each, for the
* each, for the per-season tally in the info modal. * per-season tally in the info modal.
* *
* Same teamer/called-off filtering as findRecentDispositionsByTeamer(), but unbounded * Same teamer/called-off filtering as findRecentDispositionsByTeamer(), but unbounded
* and stripped to the one date it needs: assignment.dateFrom falling back to the * and stripped to the one date it needs: the destination's start, the season convention
* destination's, the season convention shared with SeasonPeriodFilter. * shared with SeasonPeriodFilter.
* *
* @return array<int, array{seasonDate: string}> * @return array<int, array{seasonDate: string}>
*/ */
@@ -159,7 +140,7 @@ class DispositionRepository extends ServiceEntityRepository
$qb = $this->createQueryBuilder('disposition'); $qb = $this->createQueryBuilder('disposition');
return $qb return $qb
->select('COALESCE(assignment.dateFrom, destination.dateFrom) AS seasonDate') ->select('destination.dateFrom AS seasonDate')
->innerJoin('disposition.assignment', 'assignment') ->innerJoin('disposition.assignment', 'assignment')
->innerJoin('assignment.destination', 'destination') ->innerJoin('assignment.destination', 'destination')
->where($qb->expr()->andX( ->where($qb->expr()->andX(
@@ -242,13 +223,7 @@ class DispositionRepository extends ServiceEntityRepository
->leftJoin('disposition.feedback', 'feedback') ->leftJoin('disposition.feedback', 'feedback')
->where($qb->expr()->andX( ->where($qb->expr()->andX(
$qb->expr()->notIn('assignment.status', ':status'), $qb->expr()->notIn('assignment.status', ':status'),
$qb->expr()->orX( $qb->expr()->lte('destination.dateTo', ':dateTo'),
$qb->expr()->andX(
$qb->expr()->isNotNull('assignment.dateTo'),
$qb->expr()->lte('assignment.dateTo', ':dateTo')
),
$qb->expr()->lte('destination.dateTo', ':dateTo')
),
$qb->expr()->isNull('feedback'), $qb->expr()->isNull('feedback'),
$this->excludesSkipFormalities($qb) $this->excludesSkipFormalities($qb)
)) ))
@@ -302,13 +277,7 @@ class DispositionRepository extends ServiceEntityRepository
->innerJoin('assignment.destination', 'destination') ->innerJoin('assignment.destination', 'destination')
->where($qb->expr()->andX( ->where($qb->expr()->andX(
$qb->expr()->in('disposition.status', ':status'), $qb->expr()->in('disposition.status', ':status'),
$qb->expr()->orX( $qb->expr()->lte('destination.dateTo', ':dateTo')
$qb->expr()->andX(
$qb->expr()->isNotNull('assignment.dateTo'),
$qb->expr()->lte('assignment.dateTo', ':dateTo')
),
$qb->expr()->lte('destination.dateTo', ':dateTo')
)
)) ))
->setParameter('status', [ ->setParameter('status', [
Disposition::STATUS_NEW, Disposition::STATUS_NEW,
@@ -529,10 +498,10 @@ class DispositionRepository extends ServiceEntityRepository
$normalDay = $qb->expr()->andX( $normalDay = $qb->expr()->andX(
$qb->expr()->gte('disposition.createdAt', ':dayStart'), $qb->expr()->gte('disposition.createdAt', ':dayStart'),
$qb->expr()->lt('disposition.createdAt', ':dayEnd'), $qb->expr()->lt('disposition.createdAt', ':dayEnd'),
$this->effectiveDateToGreaterThan($qb, ':tomorrow'), $qb->expr()->gt('destination.dateTo', ':tomorrow'),
); );
$shortNoticeDay = $qb->expr()->andX( $shortNoticeDay = $qb->expr()->andX(
$this->effectiveDateToEquals($qb, ':tomorrow'), $qb->expr()->eq('destination.dateTo', ':tomorrow'),
$qb->expr()->gte('disposition.createdAt', ':dayStart'), $qb->expr()->gte('disposition.createdAt', ':dayStart'),
); );
@@ -599,7 +568,7 @@ class DispositionRepository extends ServiceEntityRepository
// in CronCommand, so a skip-formalities placement is still `confirmed` here and // 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. // would slip past the status filter on the day after the assignment ends.
$this->excludesSkipFormalities($qb), $this->excludesSkipFormalities($qb),
$this->effectiveDateToEquals($qb, ':endDate'), $qb->expr()->eq('destination.dateTo', ':endDate'),
)) ))
->setParameter('documentType', Upload::TYPE_INVOICE) ->setParameter('documentType', Upload::TYPE_INVOICE)
->setParameter('dispositionStatus', [Disposition::STATUS_CALLED_OFF, Disposition::STATUS_COMPLETED]) ->setParameter('dispositionStatus', [Disposition::STATUS_CALLED_OFF, Disposition::STATUS_COMPLETED])
@@ -611,30 +580,6 @@ class DispositionRepository extends ServiceEntityRepository
; ;
} }
/**
* Compares the assignment's end date against $parameter, falling back to the destination's
* when the assignment does not override it.
*
* The fallback is guarded on both sides - without the isNull() on the second branch an
* assignment that moves the end date into the future would still match on the
* destination's date. Kept in one place because the per-assignment date override is due to
* be removed (docs/remove-assignment-date-override.md), and this then collapses to a plain
* comparison on destination.dateTo.
*/
private function effectiveDateToEquals(QueryBuilder $qb, string $parameter): Orx
{
return $qb->expr()->orX(
$qb->expr()->andX(
$qb->expr()->isNotNull('assignment.dateTo'),
$qb->expr()->eq('assignment.dateTo', $parameter)
),
$qb->expr()->andX(
$qb->expr()->isNull('assignment.dateTo'),
$qb->expr()->eq('destination.dateTo', $parameter)
),
);
}
/** /**
* Restricts a query to assignments that still run the contract, invoice and feedback process. * Restricts a query to assignments that still run the contract, invoice and feedback process.
* *
@@ -653,23 +598,6 @@ class DispositionRepository extends ServiceEntityRepository
return $qb->expr()->eq('assignment.skipFormalities', ':skipFormalities'); return $qb->expr()->eq('assignment.skipFormalities', ':skipFormalities');
} }
/**
* Same fallback as effectiveDateToEquals(), but for "later than $parameter".
*/
private function effectiveDateToGreaterThan(QueryBuilder $qb, string $parameter): Orx
{
return $qb->expr()->orX(
$qb->expr()->andX(
$qb->expr()->isNotNull('assignment.dateTo'),
$qb->expr()->gt('assignment.dateTo', $parameter)
),
$qb->expr()->andX(
$qb->expr()->isNull('assignment.dateTo'),
$qb->expr()->gt('destination.dateTo', $parameter)
),
);
}
/** /**
* Dispositions whose contract upload period has run out, for the admin dashboard. * Dispositions whose contract upload period has run out, for the admin dashboard.
* *
+8 -28
View File
@@ -5,15 +5,14 @@ namespace App\Repository\Filter;
use Doctrine\ORM\QueryBuilder; use Doctrine\ORM\QueryBuilder;
/** /**
* Narrows a query to the season an assignment runs in. * Narrows a query to the season an assignment runs in, which is the date range of its
* destination.
* *
* The season is assignment.dateFrom/dateTo falling back to the destination's, mirroring * Kept in one place because every statistics screen means the same thing by "a season":
* Assignment::getEffectivePeriod(), which DQL cannot call. Every statistics screen means * the expression carried by hand in each repository is one more chance for it to come to
* this by a date range, so it lives in one place: three repositories carrying the same * mean something slightly different on one screen than on the one beside it.
* expression by hand is three chances for "a season" to come to mean something slightly
* different on one screen than on the one beside it.
* *
* Both aliases must already be joined by the caller - this only adds the conditions. * The destination alias must already be joined by the caller - this only adds the conditions.
*/ */
final class SeasonPeriodFilter final class SeasonPeriodFilter
{ {
@@ -21,37 +20,18 @@ final class SeasonPeriodFilter
QueryBuilder $qb, QueryBuilder $qb,
?\DateTimeImmutable $dateFrom, ?\DateTimeImmutable $dateFrom,
?\DateTimeImmutable $dateTo, ?\DateTimeImmutable $dateTo,
string $assignmentAlias = 'assignment',
string $destinationAlias = 'destination', string $destinationAlias = 'destination',
): void { ): void {
if (null !== $dateFrom) { if (null !== $dateFrom) {
$qb $qb
->andWhere($qb->expr()->orX( ->andWhere($qb->expr()->gte($destinationAlias.'.dateFrom', ':dateFrom'))
$qb->expr()->andX(
$qb->expr()->isNotNull($assignmentAlias.'.dateFrom'),
$qb->expr()->gte($assignmentAlias.'.dateFrom', ':dateFrom')
),
$qb->expr()->andX(
$qb->expr()->isNull($assignmentAlias.'.dateFrom'),
$qb->expr()->gte($destinationAlias.'.dateFrom', ':dateFrom')
)
))
->setParameter('dateFrom', $dateFrom) ->setParameter('dateFrom', $dateFrom)
; ;
} }
if (null !== $dateTo) { if (null !== $dateTo) {
$qb $qb
->andWhere($qb->expr()->orX( ->andWhere($qb->expr()->lte($destinationAlias.'.dateTo', ':dateTo'))
$qb->expr()->andX(
$qb->expr()->isNotNull($assignmentAlias.'.dateTo'),
$qb->expr()->lte($assignmentAlias.'.dateTo', ':dateTo')
),
$qb->expr()->andX(
$qb->expr()->isNull($assignmentAlias.'.dateTo'),
$qb->expr()->lte($destinationAlias.'.dateTo', ':dateTo')
)
))
->setParameter('dateTo', $dateTo) ->setParameter('dateTo', $dateTo)
; ;
} }
+4 -2
View File
@@ -21,9 +21,11 @@ class IcsGenerator
$label = sprintf('E&P: Einteilung als %s', $assignment->getJobProfile()->getName()); $label = sprintf('E&P: Einteilung als %s', $assignment->getJobProfile()->getName());
$address = sprintf("%s\n%s", $assignment->getDestination()->getProduct(), $assignment->getDestination()->getHotel()); $address = sprintf("%s\n%s", $assignment->getDestination()->getProduct(), $assignment->getDestination()->getHotel());
$destination = $assignment->getDestination();
$event = Event::create($label) $event = Event::create($label)
->startsAt($assignment->getEffectivePeriod()->start) ->startsAt($destination->getDateFrom())
->endsAt($assignment->getEffectivePeriod()->end->modify('+1 day')) ->endsAt($destination->getDateTo()->modify('+1 day'))
->address($address) ->address($address)
->description($label) ->description($label)
; ;
+2 -2
View File
@@ -39,8 +39,8 @@ class ContractRenderer extends AbstractPdfRenderer
$pdf->Text(21, 104.5, $pdf->encodeString($assignment->getJobProfile()->getName())); $pdf->Text(21, 104.5, $pdf->encodeString($assignment->getJobProfile()->getName()));
// Period // Period
$dateFrom = $assignment->getEffectivePeriod()->start->format('d.m.Y'); $dateFrom = $destination->getDateFrom()->format('d.m.Y');
$dateTo = $assignment->getEffectivePeriod()->end->format('d.m.Y'); $dateTo = $destination->getDateTo()->format('d.m.Y');
$period = sprintf('%s - %s', $dateFrom, $dateTo); $period = sprintf('%s - %s', $dateFrom, $dateTo);
$pdf->Text(57, 113, $period); $pdf->Text(57, 113, $period);
+1 -1
View File
@@ -37,7 +37,7 @@ class InvoiceRenderer extends AbstractPdfRenderer
// Invoice number // Invoice number
$pdf->SetFont('Arial', '', 12); $pdf->SetFont('Arial', '', 12);
$date = $assignment->getEffectivePeriod()->start->format('d/m/Y'); $date = $assignment->getDestination()->getDateFrom()->format('d/m/Y');
$suffix = $assignment->getId(); $suffix = $assignment->getId();
$number = sprintf('%s-%05d', $date, $suffix); $number = sprintf('%s-%05d', $date, $suffix);
$pdf->Text(85, 116, $number); $pdf->Text(85, 116, $number);
@@ -37,12 +37,12 @@ class ApplicationValidator extends ConstraintValidator
return; return;
} }
if (null === $period = $application->getAssignment()->getEffectivePeriod()) { if (null === $destination = $application->getAssignment()->getDestination()) {
return; return;
} }
$applicationDateFrom = $period->start->toDateTimeImmutable(); $applicationDateFrom = $destination->getDateFrom();
$applicationDateTo = $period->end->toDateTimeImmutable(); $applicationDateTo = $destination->getDateTo();
$qb = $this $qb = $this
->dispositionRepository ->dispositionRepository
@@ -62,26 +62,8 @@ class ApplicationValidator extends ConstraintValidator
$qb->expr()->neq('assignment.status', ':assignment_called_off'), $qb->expr()->neq('assignment.status', ':assignment_called_off'),
$qb->expr()->isNull('assignment.deletedAt'), $qb->expr()->isNull('assignment.deletedAt'),
$qb->expr()->isNull('destination.deletedAt'), $qb->expr()->isNull('destination.deletedAt'),
$qb->expr()->orX( $qb->expr()->lte('destination.dateFrom', ':application_date_to'),
$qb->expr()->andX( $qb->expr()->gte('destination.dateTo', ':application_date_from')
$qb->expr()->isNull('assignment.dateFrom'),
$qb->expr()->lte('destination.dateFrom', ':application_date_to')
),
$qb->expr()->andX(
$qb->expr()->isNotNull('assignment.dateFrom'),
$qb->expr()->lte('assignment.dateFrom', ':application_date_to')
)
),
$qb->expr()->orX(
$qb->expr()->andX(
$qb->expr()->isNull('assignment.dateTo'),
$qb->expr()->gte('destination.dateTo', ':application_date_from')
),
$qb->expr()->andX(
$qb->expr()->isNotNull('assignment.dateTo'),
$qb->expr()->gte('assignment.dateTo', ':application_date_from')
)
)
)) ))
->setParameter('teamer', $teamer) ->setParameter('teamer', $teamer)
->setParameter('disposition_called_off', Disposition::STATUS_CALLED_OFF) ->setParameter('disposition_called_off', Disposition::STATUS_CALLED_OFF)
@@ -25,7 +25,7 @@
Einsatztermin Einsatztermin
</dt> </dt>
<dd class="mt-1 text-sm leading-6 text-gray-700 sm:col-span-2 sm:mt-0"> <dd class="mt-1 text-sm leading-6 text-gray-700 sm:col-span-2 sm:mt-0">
{{ assignment.effectivePeriod.start|date('d.m.Y') }} - {{ assignment.effectivePeriod.end|date('d.m.Y') }} {{ assignment.destination.dateFrom|date('d.m.Y') }} - {{ assignment.destination.dateTo|date('d.m.Y') }}
</dd> </dd>
</div> </div>
{% if assignment.pickup %} {% if assignment.pickup %}
@@ -26,7 +26,7 @@
Einsatztermin Einsatztermin
</dt> </dt>
<dd class="mt-1 text-sm leading-6 text-gray-700 sm:col-span-2 sm:mt-0"> <dd class="mt-1 text-sm leading-6 text-gray-700 sm:col-span-2 sm:mt-0">
{{ assignment.effectivePeriod.start|date('d.m.Y') }} - {{ assignment.effectivePeriod.end|date('d.m.Y') }} {{ assignment.destination.dateFrom|date('d.m.Y') }} - {{ assignment.destination.dateTo|date('d.m.Y') }}
</dd> </dd>
</div> </div>
<div class="py-6 first:pt-0 last:pb-0 sm:grid sm:grid-cols-3 sm:gap-4"> <div class="py-6 first:pt-0 last:pb-0 sm:grid sm:grid-cols-3 sm:gap-4">
@@ -2,7 +2,7 @@
<span class="whitespace-nowrap">{{ disposition.teamer.fullName(true) }}</span> <span class="whitespace-nowrap">{{ disposition.teamer.fullName(true) }}</span>
{% include '_partials/_teamer_deleted_badge.html.twig' with { 'teamer': disposition.teamer } %} {% include '_partials/_teamer_deleted_badge.html.twig' with { 'teamer': disposition.teamer } %}
{% include '_partials/_dot.html.twig' %} {% include '_partials/_dot.html.twig' %}
<span class="whitespace-nowrap">{{ assignment.effectivePeriod.start|date('d.m.y') }} - {{ assignment.effectivePeriod.end|date('d.m.y') }}</span> <span class="whitespace-nowrap">{{ assignment.destination.dateFrom|date('d.m.y') }} - {{ assignment.destination.dateTo|date('d.m.y') }}</span>
{% include '_partials/_dot.html.twig' %} {% include '_partials/_dot.html.twig' %}
<span class="whitespace-nowrap">{{ assignment.destination.hotel }}</span> <span class="whitespace-nowrap">{{ assignment.destination.hotel }}</span>
{% include '_partials/_dot.html.twig' %} {% include '_partials/_dot.html.twig' %}
+2 -2
View File
@@ -67,8 +67,8 @@
</td> </td>
<td> <td>
<a href="{{ path('app_administrative_assignment_detail', { 'uuid': assignment.uuid, 'r': return_url() }) }}"> <a href="{{ path('app_administrative_assignment_detail', { 'uuid': assignment.uuid, 'r': return_url() }) }}">
{{ assignment.effectivePeriod.start|date('d.m.Y') }} - {{ assignment.destination.dateFrom|date('d.m.Y') }} -
{{ assignment.effectivePeriod.end|date('d.m.Y') }} {{ assignment.destination.dateTo|date('d.m.Y') }}
</a> </a>
</td> </td>
<td> <td>
+5 -5
View File
@@ -18,7 +18,7 @@
{{ icon('hand', 'w-4 h-4 shrink-0') }} {{ icon('hand', 'w-4 h-4 shrink-0') }}
<span class="whitespace-nowrap">{{ application.teamer.fullName(true) }}</span> <span class="whitespace-nowrap">{{ application.teamer.fullName(true) }}</span>
{% include '_partials/_dot.html.twig' %} {% include '_partials/_dot.html.twig' %}
<span class="whitespace-nowrap">{{ assignment.effectivePeriod.start|date('d.m.y') }} - {{ assignment.effectivePeriod.end|date('d.m.y') }}</span> <span class="whitespace-nowrap">{{ assignment.destination.dateFrom|date('d.m.y') }} - {{ assignment.destination.dateTo|date('d.m.y') }}</span>
{% include '_partials/_dot.html.twig' %} {% include '_partials/_dot.html.twig' %}
<span class="whitespace-nowrap">{{ assignment.destination.hotel }}</span> <span class="whitespace-nowrap">{{ assignment.destination.hotel }}</span>
{% include '_partials/_dot.html.twig' %} {% include '_partials/_dot.html.twig' %}
@@ -74,7 +74,7 @@
{{ icon('download', 'w-4 h-4 shrink-0') }} {{ icon('download', 'w-4 h-4 shrink-0') }}
<span>{{ document.owner.teamer.fullName(true) }}</span> <span>{{ document.owner.teamer.fullName(true) }}</span>
{% include '_partials/_dot.html.twig' %} {% include '_partials/_dot.html.twig' %}
<span class="whitespace-nowrap">{{ assignment.effectivePeriod.start|date('d.m.y') }} - {{ assignment.effectivePeriod.end|date('d.m.y') }}</span> <span class="whitespace-nowrap">{{ assignment.destination.dateFrom|date('d.m.y') }} - {{ assignment.destination.dateTo|date('d.m.y') }}</span>
{% include '_partials/_dot.html.twig' %} {% include '_partials/_dot.html.twig' %}
<span class="whitespace-nowrap">{{ assignment.destination.hotel }}</span> <span class="whitespace-nowrap">{{ assignment.destination.hotel }}</span>
</button> </button>
@@ -107,7 +107,7 @@
hx-swap="beforeend"> hx-swap="beforeend">
{{ icon('download', 'w-4 h-4 shrink-0') }} {{ icon('download', 'w-4 h-4 shrink-0') }}
<span>{{ document.owner.teamer.fullName(true) }}</span> <span>{{ document.owner.teamer.fullName(true) }}</span>
<span class="whitespace-nowrap">{{ assignment.effectivePeriod.start|date('d.m.y') }} - {{ assignment.effectivePeriod.end|date('d.m.y') }}</span> <span class="whitespace-nowrap">{{ assignment.destination.dateFrom|date('d.m.y') }} - {{ assignment.destination.dateTo|date('d.m.y') }}</span>
{% include '_partials/_dot.html.twig' %} {% include '_partials/_dot.html.twig' %}
<span class="whitespace-nowrap">{{ assignment.destination.hotel }}</span> <span class="whitespace-nowrap">{{ assignment.destination.hotel }}</span>
</button> </button>
@@ -162,7 +162,7 @@
<a href="{{ path('app_administrative_assignment_detail', { 'uuid': assignment.uuid, 'r': return_url() }) }}" <a href="{{ path('app_administrative_assignment_detail', { 'uuid': assignment.uuid, 'r': return_url() }) }}"
class="flex items-center space-x-1"> class="flex items-center space-x-1">
{{ icon('calendar', 'w-4 h-4 shrink-0') }} {{ icon('calendar', 'w-4 h-4 shrink-0') }}
<span class="whitespace-nowrap">{{ assignment.effectivePeriod.start|date('d.m.y') }} - {{ assignment.effectivePeriod.end|date('d.m.y') }}</span> <span class="whitespace-nowrap">{{ assignment.destination.dateFrom|date('d.m.y') }} - {{ assignment.destination.dateTo|date('d.m.y') }}</span>
{% include '_partials/_dot.html.twig' %} {% include '_partials/_dot.html.twig' %}
<span class="whitespace-nowrap">{{ assignment.jobProfile.name }}</span> <span class="whitespace-nowrap">{{ assignment.jobProfile.name }}</span>
{% include '_partials/_dot.html.twig' %} {% include '_partials/_dot.html.twig' %}
@@ -286,7 +286,7 @@
<a href="{{ path('app_administrative_assignment_detail', { 'uuid': assignment.uuid, 'r': return_url() }) }}" <a href="{{ path('app_administrative_assignment_detail', { 'uuid': assignment.uuid, 'r': return_url() }) }}"
class="flex items-center space-x-1"> class="flex items-center space-x-1">
{{ icon('calendar', 'w-4 h-4 shrink-0') }} {{ icon('calendar', 'w-4 h-4 shrink-0') }}
<span class="whitespace-nowrap">{{ assignment.effectivePeriod.start|date('d.m.y') }} - {{ assignment.effectivePeriod.end|date('d.m.y') }}</span> <span class="whitespace-nowrap">{{ assignment.destination.dateFrom|date('d.m.y') }} - {{ assignment.destination.dateTo|date('d.m.y') }}</span>
{% include '_partials/_dot.html.twig' %} {% include '_partials/_dot.html.twig' %}
<span class="whitespace-nowrap">{{ assignment.jobProfile.name }}</span> <span class="whitespace-nowrap">{{ assignment.jobProfile.name }}</span>
{% include '_partials/_dot.html.twig' %} {% include '_partials/_dot.html.twig' %}
@@ -96,9 +96,9 @@
</td> </td>
<td> <td>
<a href="{{ path('app_administrative_assignment_detail', { 'uuid': assignment.uuid, 'r': return_url() }) }}"> <a href="{{ path('app_administrative_assignment_detail', { 'uuid': assignment.uuid, 'r': return_url() }) }}">
{{ assignment.effectivePeriod.start|date('d.m.Y') }} - {{ assignment.destination.dateFrom|date('d.m.Y') }} -
<br> <br>
{{ assignment.effectivePeriod.end|date('d.m.Y') }} {{ assignment.destination.dateTo|date('d.m.Y') }}
</a> </a>
</td> </td>
<td> <td>
@@ -31,7 +31,7 @@
{% for disposition in pagination %} {% for disposition in pagination %}
<tr> <tr>
<td> <td>
{{ disposition.assignment.effectivePeriod.start|date('d.m.Y') }} - {{ disposition.assignment.effectivePeriod.end|date('d.m.Y') }} {{ disposition.assignment.destination.dateFrom|date('d.m.Y') }} - {{ disposition.assignment.destination.dateTo|date('d.m.Y') }}
</td> </td>
<td> <td>
{{ disposition.assignment.destination.product }} {{ disposition.assignment.destination.product }}
@@ -63,9 +63,9 @@
<td> <td>
<a href="{{ path('app_administrative_feedback_provide', { 'uuid': disposition.uuid }) }}" <a href="{{ path('app_administrative_feedback_provide', { 'uuid': disposition.uuid }) }}"
title="Feedback abgeben"> title="Feedback abgeben">
{{ assignment.effectivePeriod.start|date('d.m.Y') }} - {{ assignment.destination.dateFrom|date('d.m.Y') }} -
<br> <br>
{{ assignment.effectivePeriod.end|date('d.m.Y') }} {{ assignment.destination.dateTo|date('d.m.Y') }}
</a> </a>
</td> </td>
<td> <td>
+1 -1
View File
@@ -12,7 +12,7 @@
{{ icon('info', 'w-4 h-4') }} {{ icon('info', 'w-4 h-4') }}
</button> </button>
{% include '_partials/_dot.html.twig' %} {% include '_partials/_dot.html.twig' %}
<span class="whitespace-nowrap">{{ assignment.effectivePeriod.start|date('d.m.y') }} - {{ assignment.effectivePeriod.end|date('d.m.y') }}</span> <span class="whitespace-nowrap">{{ assignment.destination.dateFrom|date('d.m.y') }} - {{ assignment.destination.dateTo|date('d.m.y') }}</span>
{% include '_partials/_dot.html.twig' %} {% include '_partials/_dot.html.twig' %}
<span class="whitespace-nowrap">{{ assignment.destination.hotel }}</span> <span class="whitespace-nowrap">{{ assignment.destination.hotel }}</span>
{% include '_partials/_dot.html.twig' %} {% include '_partials/_dot.html.twig' %}
+2 -2
View File
@@ -22,7 +22,7 @@
{{ icon('download', 'w-4 h-4 shrink-0') }} {{ icon('download', 'w-4 h-4 shrink-0') }}
<span>{{ document.owner.teamer.fullName(true) }}</span> <span>{{ document.owner.teamer.fullName(true) }}</span>
{% include '_partials/_dot.html.twig' %} {% include '_partials/_dot.html.twig' %}
<span class="whitespace-nowrap">{{ assignment.effectivePeriod.start|date('d.m.y') }} - {{ assignment.effectivePeriod.end|date('d.m.y') }}</span> <span class="whitespace-nowrap">{{ assignment.destination.dateFrom|date('d.m.y') }} - {{ assignment.destination.dateTo|date('d.m.y') }}</span>
{% include '_partials/_dot.html.twig' %} {% include '_partials/_dot.html.twig' %}
<span class="whitespace-nowrap">{{ assignment.destination.hotel }}</span> <span class="whitespace-nowrap">{{ assignment.destination.hotel }}</span>
</button> </button>
@@ -54,7 +54,7 @@
hx-swap="beforeend"> hx-swap="beforeend">
{{ icon('download', 'w-4 h-4 shrink-0') }} {{ icon('download', 'w-4 h-4 shrink-0') }}
<span>{{ document.owner.teamer.fullName(true) }}</span> <span>{{ document.owner.teamer.fullName(true) }}</span>
<span class="whitespace-nowrap">{{ assignment.effectivePeriod.start|date('d.m.y') }} - {{ assignment.effectivePeriod.end|date('d.m.y') }}</span> <span class="whitespace-nowrap">{{ assignment.destination.dateFrom|date('d.m.y') }} - {{ assignment.destination.dateTo|date('d.m.y') }}</span>
{% include '_partials/_dot.html.twig' %} {% include '_partials/_dot.html.twig' %}
<span class="whitespace-nowrap">{{ assignment.destination.hotel }}</span> <span class="whitespace-nowrap">{{ assignment.destination.hotel }}</span>
</button> </button>
+1 -1
View File
@@ -17,7 +17,7 @@
</div> </div>
<div class="flex items-start space-x-2"> <div class="flex items-start space-x-2">
{{ icon('calendar', 'w-5 h-5 shrink-0') }} {{ icon('calendar', 'w-5 h-5 shrink-0') }}
<span class="flex-1">{{ assignment.effectivePeriod.start|date('d.m.Y') }} - {{ assignment.effectivePeriod.end|date('d.m.Y') }}</span> <span class="flex-1">{{ assignment.destination.dateFrom|date('d.m.Y') }} - {{ assignment.destination.dateTo|date('d.m.Y') }}</span>
</div> </div>
<div class="flex items-start space-x-2"> <div class="flex items-start space-x-2">
{{ icon('profile', 'w-5 h-5 shrink-0') }} {{ icon('profile', 'w-5 h-5 shrink-0') }}
+1 -1
View File
@@ -38,7 +38,7 @@
</div> </div>
<div class="flex items-start space-x-2"> <div class="flex items-start space-x-2">
{{ icon('calendar', 'w-5 h-5 shrink-0') }} {{ icon('calendar', 'w-5 h-5 shrink-0') }}
<span class="flex-1">{{ assignment.effectivePeriod.start|date('d.m.Y') }} - {{ assignment.effectivePeriod.end|date('d.m.Y') }}</span> <span class="flex-1">{{ assignment.destination.dateFrom|date('d.m.Y') }} - {{ assignment.destination.dateTo|date('d.m.Y') }}</span>
</div> </div>
<div class="flex items-start space-x-2"> <div class="flex items-start space-x-2">
{{ icon('profile', 'w-5 h-5 shrink-0') }} {{ icon('profile', 'w-5 h-5 shrink-0') }}
+1 -1
View File
@@ -16,7 +16,7 @@
</div> </div>
<div class="flex items-start space-x-2"> <div class="flex items-start space-x-2">
{{ icon('calendar', 'w-5 h-5 shrink-0') }} {{ icon('calendar', 'w-5 h-5 shrink-0') }}
<span class="flex-1">{{ assignment.effectivePeriod.start|date('d.m.Y') }} - {{ assignment.effectivePeriod.end|date('d.m.Y') }}</span> <span class="flex-1">{{ assignment.destination.dateFrom|date('d.m.Y') }} - {{ assignment.destination.dateTo|date('d.m.Y') }}</span>
</div> </div>
<div class="flex items-start space-x-2"> <div class="flex items-start space-x-2">
{{ icon('profile', 'w-5 h-5 shrink-0') }} {{ icon('profile', 'w-5 h-5 shrink-0') }}
+1 -1
View File
@@ -9,7 +9,7 @@
</div> </div>
<div class="flex items-start space-x-2"> <div class="flex items-start space-x-2">
{{ icon('calendar', 'w-5 h-5 shrink-0') }} {{ icon('calendar', 'w-5 h-5 shrink-0') }}
<span class="flex-1">{{ assignment.effectivePeriod.start|date('d.m.Y') }} - {{ assignment.effectivePeriod.end|date('d.m.Y') }}</span> <span class="flex-1">{{ assignment.destination.dateFrom|date('d.m.Y') }} - {{ assignment.destination.dateTo|date('d.m.Y') }}</span>
</div> </div>
<div class="flex items-start space-x-2"> <div class="flex items-start space-x-2">
{{ icon('profile', 'w-5 h-5 shrink-0') }} {{ icon('profile', 'w-5 h-5 shrink-0') }}
+4 -4
View File
@@ -28,7 +28,7 @@
<div class="flex items-center space-x-1"> <div class="flex items-center space-x-1">
{{ icon('hand', 'w-4 h-4 shrink-0') }} {{ icon('hand', 'w-4 h-4 shrink-0') }}
<div class="flex items-center space-x-1 truncate"> <div class="flex items-center space-x-1 truncate">
<span class="whitespace-nowrap">{{ assignment.effectivePeriod.start|date('d.m.y') }} - {{ assignment.effectivePeriod.end|date('d.m.y') }}</span> <span class="whitespace-nowrap">{{ assignment.destination.dateFrom|date('d.m.y') }} - {{ assignment.destination.dateTo|date('d.m.y') }}</span>
{% include '_partials/_dot.html.twig' %} {% include '_partials/_dot.html.twig' %}
<span class="whitespace-nowrap">{{ assignment.destination.hotel }}</span> <span class="whitespace-nowrap">{{ assignment.destination.hotel }}</span>
</div> </div>
@@ -56,7 +56,7 @@
<div class="flex items-center space-x-1"> <div class="flex items-center space-x-1">
{{ icon('alert', 'w-4 h-4 shrink-0') }} {{ icon('alert', 'w-4 h-4 shrink-0') }}
<div class="flex items-center space-x-1 truncate"> <div class="flex items-center space-x-1 truncate">
<span class="whitespace-nowrap">{{ assignment.effectivePeriod.start|date('d.m.y') }} - {{ assignment.effectivePeriod.end|date('d.m.y') }}</span> <span class="whitespace-nowrap">{{ assignment.destination.dateFrom|date('d.m.y') }} - {{ assignment.destination.dateTo|date('d.m.y') }}</span>
{% include '_partials/_dot.html.twig' %} {% include '_partials/_dot.html.twig' %}
<span class="whitespace-nowrap">{{ assignment.destination.hotel }}</span> <span class="whitespace-nowrap">{{ assignment.destination.hotel }}</span>
</div> </div>
@@ -124,7 +124,7 @@
<div class="flex items-center space-x-1"> <div class="flex items-center space-x-1">
{{ icon('check', 'w-4 h-4 shrink-0') }} {{ icon('check', 'w-4 h-4 shrink-0') }}
<div class="flex items-center space-x-1 truncate"> <div class="flex items-center space-x-1 truncate">
<span class="whitespace-nowrap">{{ assignment.effectivePeriod.start|date('d.m.y') }} - {{ assignment.effectivePeriod.end|date('d.m.y') }}</span> <span class="whitespace-nowrap">{{ assignment.destination.dateFrom|date('d.m.y') }} - {{ assignment.destination.dateTo|date('d.m.y') }}</span>
{% include '_partials/_dot.html.twig' %} {% include '_partials/_dot.html.twig' %}
<span class="whitespace-nowrap">{{ assignment.destination.hotel }}</span> <span class="whitespace-nowrap">{{ assignment.destination.hotel }}</span>
</div> </div>
@@ -149,7 +149,7 @@
<a href="{{ path('app_teamer_assignment_detail', { 'uuid': assignment.uuid, 'r': return_url() }) }}"> <a href="{{ path('app_teamer_assignment_detail', { 'uuid': assignment.uuid, 'r': return_url() }) }}">
<div class="flex items-center space-x-1 truncate"> <div class="flex items-center space-x-1 truncate">
{{ icon('calendar', 'w-4 h-4 shrink-0') }} {{ icon('calendar', 'w-4 h-4 shrink-0') }}
<span class="whitespace-nowrap">{{ assignment.effectivePeriod.start|date('d.m.y') }} - {{ assignment.effectivePeriod.end|date('d.m.y') }}</span> <span class="whitespace-nowrap">{{ assignment.destination.dateFrom|date('d.m.y') }} - {{ assignment.destination.dateTo|date('d.m.y') }}</span>
{% include '_partials/_dot.html.twig' %} {% include '_partials/_dot.html.twig' %}
<span class="whitespace-nowrap">{{ assignment.destination.hotel }}</span> <span class="whitespace-nowrap">{{ assignment.destination.hotel }}</span>
</div> </div>
@@ -95,7 +95,7 @@ class InvalidateApplicationsListenerTest extends TestCase
$this->assertSame([], $this->removedApplications); $this->assertSame([], $this->removedApplications);
} }
public function testIgnoresAssignmentsWithoutEffectivePeriod(): void public function testIgnoresAssignmentsWithoutDestination(): void
{ {
$document = $this->createContract(null, $this->createTeamer()); $document = $this->createContract(null, $this->createTeamer());
@@ -125,7 +125,7 @@ class InvalidateApplicationsListenerTest extends TestCase
$this->assertSame([], $this->dispatchedEvents); $this->assertSame([], $this->dispatchedEvents);
} }
public function testQueriesOverlappingApplicationsForTheTeamerAndTheEffectivePeriod(): void public function testQueriesOverlappingApplicationsForTheTeamerAndTheDestinationPeriod(): void
{ {
$teamer = $this->createTeamer(); $teamer = $this->createTeamer();
$period = new CarbonPeriodImmutable('2025-01-10', '2025-01-20'); $period = new CarbonPeriodImmutable('2025-01-10', '2025-01-20');
@@ -134,7 +134,13 @@ class InvalidateApplicationsListenerTest extends TestCase
$this->applicationRepository $this->applicationRepository
->expects($this->once()) ->expects($this->once())
->method('findOverlappingForTeamer') ->method('findOverlappingForTeamer')
->with($teamer, $period) ->with(
$this->identicalTo($teamer),
$this->callback(static function (CarbonPeriodImmutable $actual) use ($period): bool {
return $actual->getStartDate()->equalTo($period->getStartDate())
&& $actual->getEndDate()->equalTo($period->getEndDate());
})
)
->willReturn([]) ->willReturn([])
; ;
@@ -194,7 +200,15 @@ class InvalidateApplicationsListenerTest extends TestCase
private function createContract(?CarbonPeriodImmutable $period, Teamer $teamer): Upload&MockObject private function createContract(?CarbonPeriodImmutable $period, Teamer $teamer): Upload&MockObject
{ {
$assignment = $this->createMock(Assignment::class); $assignment = $this->createMock(Assignment::class);
$assignment->method('getEffectivePeriod')->willReturn($period);
if (null === $period) {
$assignment->method('getDestination')->willReturn(null);
} else {
$destination = $this->createMock(Destination::class);
$destination->method('getDateFrom')->willReturn($period->getStartDate()->toDateTimeImmutable());
$destination->method('getDateTo')->willReturn($period->getEndDate()->toDateTimeImmutable());
$assignment->method('getDestination')->willReturn($destination);
}
$disposition = $this->createMock(Disposition::class); $disposition = $this->createMock(Disposition::class);
$disposition->method('getAssignment')->willReturn($assignment); $disposition->method('getAssignment')->willReturn($assignment);
@@ -211,12 +225,12 @@ class InvalidateApplicationsListenerTest extends TestCase
{ {
$destination = $this->createMock(Destination::class); $destination = $this->createMock(Destination::class);
$destination->method('getHotelCode')->willReturn('TST'); $destination->method('getHotelCode')->willReturn('TST');
$destination->method('getDateFrom')->willReturn(new \DateTimeImmutable('2025-01-12'));
$destination->method('getDateTo')->willReturn(new \DateTimeImmutable('2025-01-18'));
$assignment = $this->createMock(Assignment::class); $assignment = $this->createMock(Assignment::class);
$assignment->method('getDestination')->willReturn($destination); $assignment->method('getDestination')->willReturn($destination);
$assignment->method('getUuid')->willReturn('assignment-uuid'); $assignment->method('getUuid')->willReturn('assignment-uuid');
$assignment->method('getEffectiveDateFrom')->willReturn(new \DateTimeImmutable('2025-01-12'));
$assignment->method('getEffectiveDateTo')->willReturn(new \DateTimeImmutable('2025-01-18'));
$application = $this->createMock(Application::class); $application = $this->createMock(Application::class);
$application->method('getUuid')->willReturn('application-uuid'); $application->method('getUuid')->willReturn('application-uuid');
+4 -12
View File
@@ -20,21 +20,13 @@ use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
*/ */
class ApplicationRepositoryTest extends KernelTestCase class ApplicationRepositoryTest extends KernelTestCase
{ {
public function testOverlappingQueryUsesInclusiveBoundariesAndEffectiveDates(): void public function testOverlappingQueryUsesInclusiveBoundariesOnTheDestinationDates(): void
{ {
$dql = $this->createOverlappingQuery()->getDQL(); $dql = $this->createOverlappingQuery()->getDQL();
// an assignment overrides the date range of its destination, so both have to be considered // the assignment period is the date range of its destination
$this->assertStringContainsString( $this->assertStringContainsString('destination.dateFrom <= :dateTo', $dql);
'(assignment.dateFrom IS NULL AND destination.dateFrom <= :dateTo)' $this->assertStringContainsString('destination.dateTo >= :dateFrom', $dql);
.' OR (assignment.dateFrom IS NOT NULL AND assignment.dateFrom <= :dateTo)',
$dql
);
$this->assertStringContainsString(
'(assignment.dateTo IS NULL AND destination.dateTo >= :dateFrom)'
.' OR (assignment.dateTo IS NOT NULL AND assignment.dateTo >= :dateFrom)',
$dql
);
// strict comparisons would let assignments that touch on a boundary pass and could never // strict comparisons would let assignments that touch on a boundary pass and could never
// match a single day assignment at all // match a single day assignment at all
+7 -21
View File
@@ -93,14 +93,8 @@ class DispositionRepositoryTest extends KernelTestCase
{ {
$dql = $this->contractQuery()->getDQL(); $dql = $this->contractQuery()->getDQL();
$this->assertStringContainsString( $this->assertStringContainsString('destination.dateTo > :tomorrow', $dql);
'assignment.dateTo IS NOT NULL AND assignment.dateTo > :tomorrow', $this->assertStringContainsString('destination.dateTo = :tomorrow', $dql);
$dql
);
$this->assertStringContainsString(
'assignment.dateTo IS NOT NULL AND assignment.dateTo = :tomorrow',
$dql
);
} }
public function testContractReminderBindsTomorrowAsADateAndNotATimestamp(): void public function testContractReminderBindsTomorrowAsADateAndNotATimestamp(): void
@@ -114,8 +108,8 @@ class DispositionRepositoryTest extends KernelTestCase
} }
/** /**
* The bug that killed this query: destination.dateTo and assignment.dateTo are DATE columns, * The bug that killed this query: destination.dateTo is a DATE column, and Doctrine binds a
* and Doctrine binds a DateTimeImmutable as 'Y-m-d H:i:s', which a DATE never equals. * DateTimeImmutable as 'Y-m-d H:i:s', which a DATE never equals.
*/ */
public function testInvoiceReminderBindsADateAndNotATimestamp(): void public function testInvoiceReminderBindsADateAndNotATimestamp(): void
{ {
@@ -129,21 +123,13 @@ class DispositionRepositoryTest extends KernelTestCase
} }
/** /**
* Without the IS NULL guard on the second branch, an assignment that moves the end date into * The reminder fires for placements whose destination ends on exactly $endDate.
* the future still matches on its destination's date.
*/ */
public function testInvoiceReminderGuardsTheDestinationFallback(): void public function testInvoiceReminderComparesTheDestinationEndDate(): void
{ {
$dql = $this->invoiceQuery()->getDQL(); $dql = $this->invoiceQuery()->getDQL();
$this->assertStringContainsString( $this->assertStringContainsString('destination.dateTo = :endDate', $dql);
'assignment.dateTo IS NOT NULL AND assignment.dateTo = :endDate',
$dql
);
$this->assertStringContainsString(
'assignment.dateTo IS NULL AND destination.dateTo = :endDate',
$dql
);
} }
public function testInvoiceReminderOnlyJoinsTheInvoiceAndSkipsFinishedDispositions(): void public function testInvoiceReminderOnlyJoinsTheInvoiceAndSkipsFinishedDispositions(): void
@@ -24,25 +24,21 @@ class StatisticsEventRepositoryTest extends KernelTestCase
{ {
$dql = $this->query(dateFrom: new \DateTimeImmutable('2026-07-01'))->getDQL(); $dql = $this->query(dateFrom: new \DateTimeImmutable('2026-07-01'))->getDQL();
$this->assertStringContainsString('assignment.dateFrom >= :dateFrom', $dql); $this->assertStringContainsString('destination.dateFrom >= :dateFrom', $dql);
$this->assertStringNotContainsString('event.occurredAt', $dql); $this->assertStringNotContainsString('event.occurredAt', $dql);
} }
/** /**
* The season start is assignment.dateFrom falling back to the destination's, per * The season is the destination's date range, the convention shared with SeasonPeriodFilter.
* Assignment::getEffectivePeriod(). Losing the fallback would silently drop every
* assignment that inherits its dates.
*/ */
public function testTheSeasonFallsBackToTheDestinationDates(): void public function testTheSeasonIsTheDestinationDateRange(): void
{ {
$dql = $this->query( $dql = $this->query(
dateFrom: new \DateTimeImmutable('2026-07-01'), dateFrom: new \DateTimeImmutable('2026-07-01'),
dateTo: new \DateTimeImmutable('2027-06-30'), dateTo: new \DateTimeImmutable('2027-06-30'),
)->getDQL(); )->getDQL();
$this->assertStringContainsString('assignment.dateFrom IS NULL', $dql);
$this->assertStringContainsString('destination.dateFrom >= :dateFrom', $dql); $this->assertStringContainsString('destination.dateFrom >= :dateFrom', $dql);
$this->assertStringContainsString('assignment.dateTo IS NULL', $dql);
$this->assertStringContainsString('destination.dateTo <= :dateTo', $dql); $this->assertStringContainsString('destination.dateTo <= :dateTo', $dql);
} }
@@ -17,7 +17,6 @@ use App\Service\Pdf\InvoiceApprover;
use App\Service\Pdf\InvoiceRenderer; use App\Service\Pdf\InvoiceRenderer;
use App\Service\Pdf\Pdf; use App\Service\Pdf\Pdf;
use App\Service\Upload\UploadHandler; use App\Service\Upload\UploadHandler;
use Carbon\CarbonPeriodImmutable;
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase; use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
use Symfony\Contracts\Translation\TranslatorInterface; use Symfony\Contracts\Translation\TranslatorInterface;
@@ -84,7 +83,6 @@ class InvoiceRendererTest extends WebTestCase
; ;
$assignment = $this->createMock(Assignment::class); $assignment = $this->createMock(Assignment::class);
$assignment->method('getEffectivePeriod')->willReturn(CarbonPeriodImmutable::create('2025-02-01', '2025-02-08'));
$assignment->method('getId')->willReturn(1234); $assignment->method('getId')->willReturn(1234);
$assignment->method('getDestination')->willReturn($destination); $assignment->method('getDestination')->willReturn($destination);
$assignment->method('getJobProfile')->willReturn($jobProfile); $assignment->method('getJobProfile')->willReturn($jobProfile);