fix: correctly apply due dates for contract and invoice upload reminders

This commit is contained in:
Björn Fromme
2026-08-17 11:55:02 +02:00
parent 3e08965e48
commit 489aafed3e
22 changed files with 732 additions and 156 deletions
+1
View File
@@ -17,5 +17,6 @@ enum EmailTextKey: string
case DOCUMENT_REJECTED = 'document_rejected';
case REMINDER_CONTRACT_UPLOAD = 'reminder_contract_upload';
case REMINDER_INVOICE_UPLOAD = 'reminder_invoice_upload';
case REMINDER_INVOICE_UPLOAD_FINAL = 'reminder_invoice_upload_final';
case REMINDER_DISPOSITION = 'reminder_disposition';
}
+3 -1
View File
@@ -48,7 +48,9 @@ class IndexController extends AbstractController
$newDriverLicensesCount = $uploadRepository->getCountByStatusAndType(Upload::STATUS_NEW, Upload::TYPE_DRIVER_LICENSE);
$dispositionRepository = $this->entityManager->getRepository(Disposition::class);
$overdueContracts = $dispositionRepository->findOverdueContracts();
$overdueContracts = $dispositionRepository->findOverdueContracts(
(int) $this->getParameter('contract_upload_deadline_days')
);
$newDispositions = $dispositionRepository->getNew();
$overdueFeedbacks = $dispositionRepository->findDispositionsWithOverdueFeedback(7);
+6 -1
View File
@@ -80,11 +80,16 @@ class IndexController extends AbstractController
private function processDocuments(array $upcomingDispositions): array
{
$contractUploadDeadlineDays = (int) $this->getParameter('contract_upload_deadline_days');
$invoiceUploadDeadlineDays = (int) $this->getParameter('invoice_upload_deadline_days');
$dispositions = [];
foreach ($upcomingDispositions as $disposition) {
/** @var Disposition $disposition */
if ($disposition->isContractDue() || $disposition->isContractRejected() || $disposition->isInvoiceDue()) {
if ($disposition->isContractDue($contractUploadDeadlineDays)
|| $disposition->isContractRejected()
|| $disposition->isInvoiceDue($invoiceUploadDeadlineDays)
) {
$dispositions[] = $disposition;
}
}
+9 -3
View File
@@ -6,7 +6,7 @@ use App\Entity\Traits\BlameableEntity;
use App\Entity\Traits\SoftDeletableEntity;
use App\Entity\Traits\TimestampableEntity;
use App\Repository\AssignmentRepository;
use Carbon\CarbonPeriod;
use Carbon\CarbonPeriodImmutable;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\DBAL\Types\Types;
@@ -270,13 +270,19 @@ class Assignment implements BlameableEntityInterface, TimestampableEntityInterfa
return $this;
}
public function getEffectivePeriod(): ?CarbonPeriod
/**
* 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 CarbonPeriod($dateFrom, $dateTo);
return new CarbonPeriodImmutable($dateFrom, $dateTo);
}
return null;
+23 -7
View File
@@ -5,7 +5,7 @@ namespace App\Entity;
use App\Entity\Traits\BlameableEntity;
use App\Entity\Traits\TimestampableEntity;
use App\Repository\DispositionRepository;
use Carbon\CarbonPeriod;
use Carbon\CarbonPeriodImmutable;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\DBAL\Types\Types;
@@ -218,7 +218,11 @@ class Disposition implements BlameableEntityInterface, TimestampableEntityInterf
return $this;
}
public function isContractDue(): bool
/**
* 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%.
*/
public function isContractDue(int $deadlineDays): bool
{
if (null !== $this->getDocumentByType(Upload::TYPE_CONTRACT)) {
return false;
@@ -226,12 +230,15 @@ class Disposition implements BlameableEntityInterface, TimestampableEntityInterf
$assignment = $this->getAssignment();
$assignmentPeriod = $assignment->getEffectivePeriod();
$dueDateFrom = $this->getCreatedAt()->modify('+7 days');
$dueDateFrom = $this->getCreatedAt()->modify(sprintf('+%d days', $deadlineDays));
$dueDateTo = $assignmentPeriod->end->modify('-1 day');
$period = CarbonPeriod::create($dueDateFrom, $dueDateTo);
$period = CarbonPeriodImmutable::create($dueDateFrom, $dueDateTo);
return $period->isStarted();
// isStarted() alone stays true once the period has begun, so the dashboard kept
// asking for a contract on the last day of the assignment - by which point
// DispositionWorkflowGuardSubscriber already refuses the upload.
return $period->isStarted() && false === $period->isEnded();
}
public function isContractRejected(): bool
@@ -243,7 +250,14 @@ class Disposition implements BlameableEntityInterface, TimestampableEntityInterf
return $contractDocument->isRejected();
}
public function isInvoiceDue(): bool
/**
* The period is passed in rather than hardcoded so that this and the two invoice reminder
* mails all work from the same %invoice_upload_deadline_days%.
*
* Unlike isContractDue() this deliberately stays true once the period has run out: nothing
* blocks a late invoice upload, so the teamer should keep being asked for it.
*/
public function isInvoiceDue(int $deadlineDays): bool
{
if (null !== $this->getDocumentByType(Upload::TYPE_INVOICE)) {
return false;
@@ -251,8 +265,10 @@ class Disposition implements BlameableEntityInterface, TimestampableEntityInterf
$assignment = $this->getAssignment();
$assignmentPeriod = $assignment->getEffectivePeriod();
$dueDateFrom = $assignmentPeriod->getEndDate();
$dueDateTo = $dueDateFrom->addDays($deadlineDays);
$period = CarbonPeriod::create($assignmentPeriod->getEndDate(), $assignmentPeriod->getEndDate()->modify('+14 days'));
$period = CarbonPeriodImmutable::create($dueDateFrom, $dueDateTo);
return $period->isStarted();
}
@@ -15,8 +15,10 @@ use Symfony\Contracts\Translation\TranslatorInterface;
*/
class DispositionWorkflowGuardSubscriber implements EventSubscriberInterface
{
public function __construct(private readonly TranslatorInterface $translator)
{
public function __construct(
private readonly TranslatorInterface $translator,
private readonly int $contractUploadDeadlineDays,
) {
}
/**
@@ -59,8 +61,12 @@ class DispositionWorkflowGuardSubscriber implements EventSubscriberInterface
/**
* Guards the contract upload transition.
* Blocks upload if the deadline has passed (day before assignment starts).
* Contracts must be uploaded within 7 days of disposition creation until assignment start.
*
* The only thing blocked here is an upload from the day before the assignment *ends* -
* deliberately late, so a teamer who is behind can still hand the contract in rather than
* having to go through the office. Passing the upload deadline is a soft signal
* (dashboard flag, admin overdue list, reminder mail), never a block, which is why
* $dueDateFrom below only feeds the message and takes no part in the decision.
*
* @param GuardEvent $event The workflow guard event
*/
@@ -72,7 +78,10 @@ class DispositionWorkflowGuardSubscriber implements EventSubscriberInterface
$assignmentPeriod = $assignment->getEffectivePeriod();
$today = new \DateTimeImmutable();
$dueDateFrom = $disposition->getCreatedAt()->modify('+ 7days');
$dueDateFrom = $disposition
->getCreatedAt()
->modify(sprintf('+%d days', $this->contractUploadDeadlineDays))
;
$dueDateTo = $assignmentPeriod->end->modify('-1 day');
// Block upload if deadline has passed
+4 -4
View File
@@ -2,12 +2,12 @@
namespace App\Model;
use Carbon\CarbonPeriod;
use Carbon\CarbonPeriodImmutable;
class TimelineFilterDto extends AssignmentFilterDto
{
protected bool $includePast = true;
protected ?CarbonPeriod $_period = null;
protected ?CarbonPeriodImmutable $_period = null;
public function getDateFrom(): ?\DateTimeImmutable
{
@@ -27,12 +27,12 @@ class TimelineFilterDto extends AssignmentFilterDto
return $this->dateTo;
}
public function getPeriod(): ?CarbonPeriod
public function getPeriod(): ?CarbonPeriodImmutable
{
return $this->_period;
}
public function setPeriod(?CarbonPeriod $_period): static
public function setPeriod(?CarbonPeriodImmutable $_period): static
{
$this->_period = $_period;
+3 -3
View File
@@ -8,7 +8,7 @@ use App\Entity\Teamer;
use App\Model\ApplicationFilterDto;
use App\Model\AssignmentFilterDto;
use App\Repository\Traits\QueryHelperTrait;
use Carbon\CarbonPeriod;
use Carbon\CarbonPeriodImmutable;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\ORM\Query;
use Doctrine\Persistence\ManagerRegistry;
@@ -172,7 +172,7 @@ class ApplicationRepository extends ServiceEntityRepository
*
* @return array<Application>
*/
public function findOverlappingForTeamer(Teamer $teamer, CarbonPeriod $period): array
public function findOverlappingForTeamer(Teamer $teamer, CarbonPeriodImmutable $period): array
{
return $this
->getOverlappingForTeamerQuery($teamer, $period)
@@ -180,7 +180,7 @@ class ApplicationRepository extends ServiceEntityRepository
;
}
public function getOverlappingForTeamerQuery(Teamer $teamer, CarbonPeriod $period): Query
public function getOverlappingForTeamerQuery(Teamer $teamer, CarbonPeriodImmutable $period): Query
{
$qb = $this->createQueryBuilder('application');
+130 -3
View File
@@ -5,8 +5,12 @@ namespace App\Repository;
use App\Entity\Assignment;
use App\Entity\Disposition;
use App\Entity\Teamer;
use App\Entity\Upload;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\ORM\Query;
use Doctrine\ORM\Query\Expr\Join;
use Doctrine\ORM\Query\Expr\Orx;
use Doctrine\ORM\QueryBuilder;
use Doctrine\Persistence\ManagerRegistry;
/**
@@ -481,9 +485,129 @@ class DispositionRepository extends ServiceEntityRepository
}, $results);
}
public function findOverdueContracts(): array
/**
* Dispositions to remind about a missing contract, on the day the upload period runs out.
*
* Anchored on the creation of the disposition, which is what the upload period counts
* from. The range is a half-open day rather than an equality: disposition.createdAt is a
* timestamp, and the cron runs at 01:00, so "=" would only ever match a disposition that
* happened to be created at that exact second.
*
* @param int $deadlineDays %contract_upload_deadline_days%
*/
public function findDispositionsForContractReminder(int $deadlineDays): array
{
$dueDate = (new \DateTimeImmutable())->modify('-7 days');
return $this->getContractReminderQuery($deadlineDays)->getResult();
}
public function getContractReminderQuery(int $deadlineDays): Query
{
$dayStart = (new \DateTimeImmutable('today'))->modify(sprintf('-%d days', $deadlineDays));
$qb = $this->createQueryBuilder('disposition');
return $qb
->select('disposition', 'assignment', 'destination', 'teamer')
->innerJoin('disposition.assignment', 'assignment')
->innerJoin('assignment.destination', 'destination')
// restricted to the contract, so an unrelated upload does not suppress the reminder
->leftJoin('disposition.documents', 'document', Join::WITH, 'document.type = :documentType')
->innerJoin('disposition.teamer', 'teamer')
->where($qb->expr()->andX(
$qb->expr()->isNull('document'),
// deleted teamers are excluded here rather than when sending, so that the
// count the caller reports stays truthful
$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'),
))
->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'))
->getQuery()
;
}
/**
* Dispositions to remind about a missing invoice, for assignments ending on one given day.
*
* Both sends - the day after the assignment ends and again on the last day of the upload
* period - come through here with a different $endDate, which is what keeps each of them
* a one-shot without needing a record of what has already been sent.
*
* Anchored on the end date rather than Disposition::STATUS_ENDED on purpose: there is no
* column recording when a disposition ended, and DispositionStatusService runs after the
* reminders in CronCommand, so a status-based rule would always be one run late.
*/
public function findDispositionsForInvoiceReminder(\DateTimeImmutable $endDate): array
{
return $this->getInvoiceReminderQuery($endDate)->getResult();
}
public function getInvoiceReminderQuery(\DateTimeImmutable $endDate): Query
{
$qb = $this->createQueryBuilder('disposition');
return $qb
->select('disposition', 'assignment', 'destination', 'teamer')
->innerJoin('disposition.assignment', 'assignment')
->innerJoin('assignment.destination', 'destination')
->leftJoin('disposition.documents', 'document', Join::WITH, 'document.type = :documentType')
->innerJoin('disposition.teamer', 'teamer')
->where($qb->expr()->andX(
$qb->expr()->isNull('document'),
$qb->expr()->isNull('teamer.deletedAt'),
$qb->expr()->notIn('disposition.status', ':dispositionStatus'),
$qb->expr()->notIn('assignment.status', ':assignmentStatus'),
$this->effectiveDateToEquals($qb, ':endDate'),
))
->setParameter('documentType', Upload::TYPE_INVOICE)
->setParameter('dispositionStatus', [Disposition::STATUS_CALLED_OFF, Disposition::STATUS_COMPLETED])
->setParameter('assignmentStatus', [Assignment::STATUS_CALLED_OFF, Assignment::STATUS_DELETED])
// bound as a date string, not as a DateTimeImmutable: both columns are DATE, and
// Doctrine would otherwise bind 'Y-m-d H:i:s', which a DATE never equals
->setParameter('endDate', $endDate->format('Y-m-d'))
->getQuery()
;
}
/**
* 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)
),
);
}
/**
* Dispositions whose contract upload period has run out, for the admin dashboard.
*
* @param int $deadlineDays %contract_upload_deadline_days%, so this list marks a
* disposition overdue on the same day the reminder mail goes out
*/
public function findOverdueContracts(int $deadlineDays): array
{
$dueDate = (new \DateTimeImmutable())->modify(sprintf('-%d days', $deadlineDays));
$qb = $this->createQueryBuilder('disposition');
@@ -492,7 +616,9 @@ class DispositionRepository extends ServiceEntityRepository
->innerJoin('disposition.assignment', 'assignment')
->innerJoin('assignment.jobProfile', 'job_profile')
->innerJoin('assignment.destination', 'destination')
->leftJoin('disposition.documents', 'document')
// the join is restricted to the contract, so that an unrelated upload - a
// driver's licence, say - does not hide a disposition from this list
->leftJoin('disposition.documents', 'document', Join::WITH, 'document.type = :documentType')
->innerJoin('disposition.teamer', 'teamer')
->innerJoin('teamer.user', 'user')
->where($qb->expr()->andX(
@@ -500,6 +626,7 @@ class DispositionRepository extends ServiceEntityRepository
$qb->expr()->eq('disposition.status', ':status'),
$qb->expr()->isNull('document')
))
->setParameter('documentType', Upload::TYPE_CONTRACT)
->setParameter('dueDate', $dueDate)
->setParameter('status', Disposition::STATUS_NEW)
->orderBy('disposition.createdAt', 'ASC')
+4 -4
View File
@@ -4,7 +4,7 @@ namespace App\Service\Assignment;
use App\Entity\Assignment;
use App\Model\TimelineItem;
use Carbon\CarbonPeriod;
use Carbon\CarbonPeriodImmutable;
/**
* Service for processing assignments into timeline visualization data.
@@ -110,13 +110,13 @@ class TimelineService
* Returns a period from 6 weeks ago to 6 months in the future,
* providing context for both recent and upcoming assignments.
*
* @return CarbonPeriod The active time window for timeline display
* @return CarbonPeriodImmutable The active time window for timeline display
*/
public function getActiveWindow(): CarbonPeriod
public function getActiveWindow(): CarbonPeriodImmutable
{
$dateFrom = (new \DateTimeImmutable())->modify('-6 weeks');
$dateTo = (new \DateTimeImmutable())->modify('+6 months');
return CarbonPeriod::create($dateFrom, $dateTo);
return CarbonPeriodImmutable::create($dateFrom, $dateTo);
}
}
+4 -4
View File
@@ -4,16 +4,16 @@ namespace App\Service\Common;
use App\Model\AbstractFilterDto;
use App\Model\TimelineFilterDto;
use Carbon\CarbonPeriod;
use Carbon\CarbonPeriodImmutable;
class TimelineFilterHandler extends AbstractFilterHandler
{
protected string $namespace = 'filter:timeline';
protected string $modelClass = TimelineFilterDto::class;
protected ?CarbonPeriod $period = null;
protected ?CarbonPeriodImmutable $period = null;
public function setPeriod(CarbonPeriod $period): void
public function setPeriod(CarbonPeriodImmutable $period): void
{
$this->period = $period;
}
@@ -44,7 +44,7 @@ class TimelineFilterHandler extends AbstractFilterHandler
$filterDto->setHotels($data['hotels']);
}
$filterDto->setPeriod(new CarbonPeriod($periodFrom, $periodTo));
$filterDto->setPeriod(new CarbonPeriodImmutable($periodFrom, $periodTo));
return $filterDto;
}
+59 -96
View File
@@ -5,61 +5,35 @@ namespace App\Service\Cron;
use App\Config\EmailTextKey;
use App\Email\Mailer;
use App\Entity\Disposition;
use App\Entity\Upload;
use App\Repository\DispositionRepository;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\ORM\Query\Expr\Join;
use Doctrine\ORM\Query\Parameter;
use Psr\Log\LoggerInterface;
/**
* The nightly reminders about documents a teamer still owes us.
*
* Both rules pick out exactly one day, which is what makes them idempotent: the cron can run
* twice without mailing anyone twice, and nothing has to record what was already sent.
*/
class UploadReminderService
{
/**
* How long before the invoice deadline the reminder goes out.
*/
private const INVOICE_REMINDER_LEAD_DAYS = 3;
public function __construct(
private readonly DispositionRepository $dispositionRepository,
private readonly Mailer $mailer,
private readonly LoggerInterface $logger,
private readonly int $contractUploadDeadlineDays,
private readonly int $invoiceUploadDeadlineDays,
) {
}
/**
* Reminds about a missing contract on the last day of the upload period, counted from the
* creation of the disposition.
*/
public function sendContractUploadReminders(): string
{
// Contracts have to be uploaded before assignments' begin date. Reminder is
// sent 7 and 14 days after disposition has been created.
$contractDueDateFirst = (new \DateTimeImmutable())->modify('-7 days');
$contractDueDateSecond = (new \DateTimeImmutable())->modify('-14 days');
// Find dispositions of assignments with due contract upload
$qb = $this->dispositionRepository->createQueryBuilder('disposition');
$dispositions = $qb
->select('disposition', 'assignment', 'destination')
->innerJoin('disposition.assignment', 'assignment')
->innerJoin('assignment.destination', 'destination')
->leftJoin('disposition.documents', 'document', Join::WITH, 'document.type = :documentType')
->innerJoin('disposition.teamer', 'teamer')
->where($qb->expr()->andX(
$qb->expr()->isNull('document'),
// deleted teamers are excluded here rather than when sending, so that the
// count reported below stays truthful
$qb->expr()->isNull('teamer.deletedAt'),
$qb->expr()->orX(
$qb->expr()->eq('document.createdAt', ':contractDueDateFirst'),
$qb->expr()->eq('document.createdAt', ':contractDueDateSecond'),
)
))
->setParameters(new ArrayCollection([
new Parameter('documentType', Upload::TYPE_CONTRACT),
new Parameter('contractDueDateFirst', $contractDueDateFirst),
new Parameter('contractDueDateSecond', $contractDueDateSecond),
]))
->getQuery()
->getResult()
$dispositions = $this
->dispositionRepository
->findDispositionsForContractReminder($this->contractUploadDeadlineDays)
;
if (0 === $count = count($dispositions)) {
@@ -70,12 +44,8 @@ class UploadReminderService
}
foreach ($dispositions as $disposition) {
$teamer = $disposition->getTeamer();
$this->mailer->createAndSendText(EmailTextKey::REMINDER_CONTRACT_UPLOAD, [
$this->send(EmailTextKey::REMINDER_CONTRACT_UPLOAD, $disposition, [
'destination' => (string) $disposition->getAssignment()->getDestination(),
], [
'to' => $teamer->getCommunication()->getEmail(),
]);
}
@@ -85,65 +55,58 @@ class UploadReminderService
return $message;
}
/**
* Reminds about a missing invoice twice: the day after the assignment ended, and again on
* the last day of the upload period. The second mail has its own wording, because by then
* there is no time left to promise.
*/
public function sendInvoiceUploadReminders(): string
{
// Invoices have to be uploaded until invoiceUploadDeadlineDays after end of
// assignment. Reminder is sent three days before end of this period, so the
// offset follows the deadline the mail itself quotes.
$invoiceDueDate = (new \DateTimeImmutable())
->modify(sprintf('-%d days', $this->invoiceUploadDeadlineDays - self::INVOICE_REMINDER_LEAD_DAYS))
;
$today = new \DateTimeImmutable('today');
// Find dispositions of assignments with due invoice upload
$qb = $this->dispositionRepository->createQueryBuilder('disposition');
$count = $this->sendInvoiceUploadRemindersForEndDate(
$today->modify('-1 day'),
EmailTextKey::REMINDER_INVOICE_UPLOAD
);
$dispositions = $qb
->select('disposition', 'assignment', 'destination')
->innerJoin('disposition.assignment', 'assignment')
->innerJoin('assignment.destination', 'destination')
->leftJoin('disposition.documents', 'document', Join::WITH, 'document.type = :documentType')
->innerJoin('disposition.teamer', 'teamer')
->where($qb->expr()->andX(
$qb->expr()->isNull('document'),
$qb->expr()->isNull('teamer.deletedAt'),
$qb->expr()->orX(
// Due date can be determined by assignment's date which potentially overrides destination date
$qb->expr()->andX(
$qb->expr()->isNotNull('assignment.dateTo'),
$qb->expr()->eq('assignment.dateTo', ':dateTo')
),
$qb->expr()->eq('destination.dateTo', ':dateTo'),
)
))
->setParameters(new ArrayCollection([
new Parameter('documentType', Upload::TYPE_INVOICE),
new Parameter('dateTo', $invoiceDueDate),
]))
->getQuery()
->getResult()
;
$count += $this->sendInvoiceUploadRemindersForEndDate(
$today->modify(sprintf('-%d days', $this->invoiceUploadDeadlineDays)),
EmailTextKey::REMINDER_INVOICE_UPLOAD_FINAL
);
if (0 === $count = count($dispositions)) {
$message = 'No due invoice upload reminders to be sent to teamers';
$this->logger->info($message);
$message = 0 === $count
? 'No due invoice upload reminders to be sent to teamers'
: 'Sent '.$count.' due invoice upload reminders to teamers';
return $message;
}
foreach ($dispositions as $disposition) {
$teamer = $disposition->getTeamer();
$this->mailer->createAndSendText(EmailTextKey::REMINDER_INVOICE_UPLOAD, [
'destination' => (string) $disposition->getAssignment()->getDestination(),
'invoiceUploadDeadlineDays' => $this->invoiceUploadDeadlineDays,
], [
'to' => $teamer->getCommunication()->getEmail(),
]);
}
$message = 'Sent '.$count.' due invoice upload reminders to teamers';
$this->logger->info($message);
return $message;
}
private function sendInvoiceUploadRemindersForEndDate(\DateTimeImmutable $endDate, EmailTextKey $key): int
{
$dispositions = $this
->dispositionRepository
->findDispositionsForInvoiceReminder($endDate)
;
foreach ($dispositions as $disposition) {
$this->send($key, $disposition, [
'destination' => (string) $disposition->getAssignment()->getDestination(),
'invoiceUploadDeadlineDays' => $this->invoiceUploadDeadlineDays,
]);
}
return count($dispositions);
}
/**
* @param array<string, string|int|null> $placeholders
*/
private function send(EmailTextKey $key, Disposition $disposition, array $placeholders): void
{
$this->mailer->createAndSendText($key, $placeholders, [
'to' => $disposition->getTeamer()->getCommunication()->getEmail(),
]);
}
}
+2 -2
View File
@@ -3,7 +3,7 @@
namespace App\Service\Teamer;
use App\Entity\Availability;
use Carbon\Carbon;
use Carbon\CarbonImmutable;
class AvailabilityProcessor
{
@@ -28,7 +28,7 @@ class AvailabilityProcessor
/** @var Availability $record */
$dateFrom = $record->getDateFrom();
$year = $dateFrom->format('Y');
$month = (new Carbon($dateFrom))->locale('de_DE')->monthName;
$month = (new CarbonImmutable($dateFrom))->locale('de_DE')->monthName;
if (false === isset($availabilities[$year])) {
$availabilities[$year] = [];
+5 -5
View File
@@ -8,7 +8,7 @@ use App\Entity\Teamer;
use App\Entity\Upload;
use App\Service\Teamer\PickupResolver;
use App\Service\Upload\UploadHandler;
use Carbon\Carbon;
use Carbon\CarbonImmutable;
use Symfony\Component\HttpFoundation\RequestStack;
use Symfony\Contracts\Translation\TranslatorInterface;
use Twig\Environment;
@@ -222,13 +222,13 @@ class AppRuntime implements RuntimeExtensionInterface
public function dateDiffForHumans(\DateTimeInterface $dateTime, ?\DateTimeInterface $other = null): string
{
$oldLocale = Carbon::getLocale();
$oldLocale = CarbonImmutable::getLocale();
$locale = $this->requestStack->getMainRequest()->getLocale();
Carbon::setLocale($locale);
CarbonImmutable::setLocale($locale);
$result = Carbon::instance($dateTime)->diffForHumans($other);
$result = CarbonImmutable::instance($dateTime)->diffForHumans($other);
Carbon::setLocale($oldLocale);
CarbonImmutable::setLocale($oldLocale);
return $result;
}