fix: correctly calculate due dates for contract and invoice uploads

This commit is contained in:
Björn Fromme
2026-08-17 12:49:36 +02:00
parent 0e1df6da7c
commit 31149fdd13
4 changed files with 145 additions and 7 deletions
+57 -7
View File
@@ -502,10 +502,27 @@ class DispositionRepository extends ServiceEntityRepository
public function getContractReminderQuery(int $deadlineDays): Query
{
$dayStart = (new \DateTimeImmutable('today'))->modify(sprintf('-%d days', $deadlineDays));
$today = new \DateTimeImmutable('today');
$dayStart = $today->modify(sprintf('-%d days', $deadlineDays));
$tomorrow = $today->modify('+1 day');
$qb = $this->createQueryBuilder('disposition');
// A short-notice assignment can end before the normal $deadlineDays window would even
// start (DispositionWorkflowGuardSubscriber::guardUploadContract blocks uploads from
// assignment.end - 1 day on). For those, remind on the block day instead of the normal
// day, which would otherwise fall on or after the block and describe an upload that is
// already refused. isContractDue() caps the same way.
$normalDay = $qb->expr()->andX(
$qb->expr()->gte('disposition.createdAt', ':dayStart'),
$qb->expr()->lt('disposition.createdAt', ':dayEnd'),
$this->effectiveDateToGreaterThan($qb, ':tomorrow'),
);
$shortNoticeDay = $qb->expr()->andX(
$this->effectiveDateToEquals($qb, ':tomorrow'),
$qb->expr()->gte('disposition.createdAt', ':dayStart'),
);
return $qb
->select('disposition', 'assignment', 'destination', 'teamer')
->innerJoin('disposition.assignment', 'assignment')
@@ -520,14 +537,16 @@ class DispositionRepository extends ServiceEntityRepository
$qb->expr()->isNull('teamer.deletedAt'),
$qb->expr()->eq('disposition.status', ':dispositionStatus'),
$qb->expr()->notIn('assignment.status', ':assignmentStatus'),
$qb->expr()->gte('disposition.createdAt', ':dayStart'),
$qb->expr()->lt('disposition.createdAt', ':dayEnd'),
$qb->expr()->orX($normalDay, $shortNoticeDay),
))
->setParameter('documentType', Upload::TYPE_CONTRACT)
->setParameter('dispositionStatus', Disposition::STATUS_NEW)
->setParameter('assignmentStatus', [Assignment::STATUS_CALLED_OFF, Assignment::STATUS_DELETED])
->setParameter('dayStart', $dayStart)
->setParameter('dayEnd', $dayStart->modify('+1 day'))
// bound as date strings, not DateTimeImmutable: both columns are DATE, and
// Doctrine would otherwise bind 'Y-m-d H:i:s', which a DATE never equals
->setParameter('tomorrow', $tomorrow->format('Y-m-d'))
->getQuery()
;
}
@@ -599,6 +618,23 @@ class DispositionRepository extends ServiceEntityRepository
);
}
/**
* 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.
*
@@ -607,7 +643,17 @@ class DispositionRepository extends ServiceEntityRepository
*/
public function findOverdueContracts(int $deadlineDays): array
{
$dueDate = (new \DateTimeImmutable())->modify(sprintf('-%d days', $deadlineDays));
return $this->getOverdueContractsQuery($deadlineDays)->getResult();
}
public function getOverdueContractsQuery(int $deadlineDays): Query
{
// anchored on midnight, like getContractReminderQuery()'s dayStart/dayEnd: a
// now()-relative anchor would disagree with the reminder query for part of each day.
// +1 day so a disposition is included from the same calendar day its reminder mail
// goes out, matching that query's dayStart (== reminderDay), not the day after it.
$reminderDay = (new \DateTimeImmutable('today'))->modify(sprintf('-%d days', $deadlineDays));
$dueDate = $reminderDay->modify('+1 day');
$qb = $this->createQueryBuilder('disposition');
@@ -622,16 +668,20 @@ class DispositionRepository extends ServiceEntityRepository
->innerJoin('disposition.teamer', 'teamer')
->innerJoin('teamer.user', 'user')
->where($qb->expr()->andX(
$qb->expr()->lte('disposition.createdAt', ':dueDate'),
$qb->expr()->lt('disposition.createdAt', ':dueDate'),
$qb->expr()->eq('disposition.status', ':status'),
$qb->expr()->isNull('document')
$qb->expr()->isNull('document'),
// kept in sync with getContractReminderQuery(), so the dashboard and the
// reminder mail agree on which dispositions are overdue
$qb->expr()->isNull('teamer.deletedAt'),
$qb->expr()->notIn('assignment.status', ':assignmentStatus'),
))
->setParameter('documentType', Upload::TYPE_CONTRACT)
->setParameter('dueDate', $dueDate)
->setParameter('status', Disposition::STATUS_NEW)
->setParameter('assignmentStatus', [Assignment::STATUS_CALLED_OFF, Assignment::STATUS_DELETED])
->orderBy('disposition.createdAt', 'ASC')
->getQuery()
->getResult()
;
}
}