feat: assignments with disabled formalities
addresses #869dnvm8c
This commit is contained in:
@@ -11,6 +11,7 @@ namespace App\Config;
|
||||
enum EmailTextKey: string
|
||||
{
|
||||
case APPLICATION_ACCEPTED = 'application_accepted';
|
||||
case APPLICATION_ACCEPTED_NO_CONTRACT = 'application_accepted_no_contract';
|
||||
case APPLICATION_REJECTED = 'application_rejected';
|
||||
case ASSIGNMENT_CALLED_OFF = 'assignment_called_off';
|
||||
case DISPOSITION_CALLED_OFF = 'disposition_called_off';
|
||||
|
||||
@@ -57,6 +57,13 @@ class Assignment implements BlameableEntityInterface, TimestampableEntityInterfa
|
||||
#[ORM\Column]
|
||||
private ?bool $showAvailableDispositions = false;
|
||||
|
||||
/**
|
||||
* Ends the process for a placed teamer right after the disposition is created: no contract,
|
||||
* no invoice, no feedback. See Disposition::isSkipFormalities() for the per-placement view.
|
||||
*/
|
||||
#[ORM\Column]
|
||||
private bool $skipFormalities = false;
|
||||
|
||||
#[ORM\Column(type: Types::DATE_IMMUTABLE, nullable: true)]
|
||||
private ?\DateTimeImmutable $dateFrom = null;
|
||||
|
||||
@@ -125,6 +132,7 @@ class Assignment implements BlameableEntityInterface, TimestampableEntityInterfa
|
||||
->setJobProfile($assignment->getJobProfile())
|
||||
->setAvailableDispositions($assignment->getAvailableDispositions())
|
||||
->setShowAvailableDispositions($assignment->isShowAvailableDispositions())
|
||||
->setSkipFormalities($assignment->isSkipFormalities())
|
||||
->setDateFrom($assignment->getDateFrom())
|
||||
->setDateTo($assignment->getDateTo())
|
||||
->setPickupDate($assignment->getPickupDate())
|
||||
@@ -246,6 +254,18 @@ class Assignment implements BlameableEntityInterface, TimestampableEntityInterfa
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function isSkipFormalities(): bool
|
||||
{
|
||||
return $this->skipFormalities;
|
||||
}
|
||||
|
||||
public function setSkipFormalities(bool $skipFormalities): static
|
||||
{
|
||||
$this->skipFormalities = $skipFormalities;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getDateFrom(): ?\DateTimeImmutable
|
||||
{
|
||||
return $this->dateFrom;
|
||||
|
||||
@@ -75,7 +75,11 @@ class Disposition implements BlameableEntityInterface, TimestampableEntityInterf
|
||||
public function __construct(Application $application)
|
||||
{
|
||||
$this->uuid = Uuid::v4();
|
||||
$this->status = static::STATUS_NEW;
|
||||
// A skip-formalities assignment has no contract step to wait for, so the placement starts
|
||||
// out where a signed and checked contract would otherwise have put it.
|
||||
$this->status = true === $application->getAssignment()?->isSkipFormalities()
|
||||
? static::STATUS_CONFIRMED
|
||||
: static::STATUS_NEW;
|
||||
$this->assignment = $application->getAssignment();
|
||||
$this->teamer = $application->getTeamer();
|
||||
$this->pickup = $application->getPickup();
|
||||
@@ -218,12 +222,25 @@ class Disposition implements BlameableEntityInterface, TimestampableEntityInterf
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the owning assignment switches off the contract, invoice and feedback process.
|
||||
* Kept here so call sites do not have to repeat the null check on the assignment.
|
||||
*/
|
||||
public function isSkipFormalities(): bool
|
||||
{
|
||||
return true === $this->getAssignment()?->isSkipFormalities();
|
||||
}
|
||||
|
||||
/**
|
||||
* The period is passed in rather than hardcoded so that this, the reminder mail and the
|
||||
* admin overdue list all work from the same %contract_upload_deadline_days%.
|
||||
*/
|
||||
public function isContractDue(int $deadlineDays): bool
|
||||
{
|
||||
if (true === $this->isSkipFormalities()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (null !== $this->getDocumentByType(Upload::TYPE_CONTRACT)) {
|
||||
return false;
|
||||
}
|
||||
@@ -251,6 +268,10 @@ class Disposition implements BlameableEntityInterface, TimestampableEntityInterf
|
||||
|
||||
public function isContractRejected(): bool
|
||||
{
|
||||
if (true === $this->isSkipFormalities()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (null === $contractDocument = $this->getDocumentByType(Upload::TYPE_CONTRACT)) {
|
||||
return false;
|
||||
}
|
||||
@@ -267,6 +288,10 @@ class Disposition implements BlameableEntityInterface, TimestampableEntityInterf
|
||||
*/
|
||||
public function isInvoiceDue(int $deadlineDays): bool
|
||||
{
|
||||
if (true === $this->isSkipFormalities()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (null !== $this->getDocumentByType(Upload::TYPE_INVOICE)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -61,6 +61,19 @@ class EmailNotificationSubscriber implements EventSubscriberInterface
|
||||
$assignment = $disposition->getAssignment();
|
||||
$destination = $assignment->getDestination();
|
||||
|
||||
// Same notification, minus everything about the contract: the teamer still has to learn
|
||||
// that they were placed, but on a skip-formalities assignment there is nothing to sign.
|
||||
if (true === $disposition->isSkipFormalities()) {
|
||||
$this->mailer->createAndSendText(EmailTextKey::APPLICATION_ACCEPTED_NO_CONTRACT, [
|
||||
'destination' => (string) $destination,
|
||||
'specialAgreements' => $disposition->getSpecialAgreements(),
|
||||
], [
|
||||
'to' => $teamer->getCommunication()->getEmail(),
|
||||
]);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$filename = sprintf('Honorarvertrag_%s_%s.pdf', $teamer, $destination);
|
||||
|
||||
$contractPdf = $this->contractRenderer->render($disposition);
|
||||
|
||||
@@ -11,6 +11,7 @@ use Doctrine\ORM\EntityManagerInterface;
|
||||
use Doctrine\ORM\EntityRepository;
|
||||
use Symfony\Bridge\Doctrine\Form\Type\EntityType;
|
||||
use Symfony\Component\Form\AbstractType;
|
||||
use Symfony\Component\Form\Extension\Core\Type\CheckboxType;
|
||||
use Symfony\Component\Form\Extension\Core\Type\ChoiceType;
|
||||
use Symfony\Component\Form\Extension\Core\Type\IntegerType;
|
||||
use Symfony\Component\Form\Extension\Core\Type\TextareaType;
|
||||
@@ -147,6 +148,7 @@ class AssignmentType extends AbstractType
|
||||
$data = $event->getData();
|
||||
|
||||
$this->addStatusField($data, $form);
|
||||
$this->addSkipFormalitiesField($data, $form);
|
||||
|
||||
$destination = $data->getDestination();
|
||||
|
||||
@@ -212,6 +214,36 @@ class AssignmentType extends AbstractType
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the skip-formalities flag is frozen for a given assignment.
|
||||
*
|
||||
* It decides which status a Disposition is created in, so flipping it once placements
|
||||
* exist would leave them stranded halfway through a process that no longer applies -
|
||||
* a teamer confirmed without a contract, or one still being chased for a contract on an
|
||||
* assignment that no longer wants one.
|
||||
*
|
||||
* Enforced through the form's `disabled` option, which makes Symfony ignore whatever is
|
||||
* submitted for the field, so this holds against a hand-crafted POST too.
|
||||
*/
|
||||
public static function isSkipFormalitiesLocked(?Assignment $assignment): bool
|
||||
{
|
||||
return 0 < ($assignment?->getDispositions()->count() ?? 0);
|
||||
}
|
||||
|
||||
private function addSkipFormalitiesField(?Assignment $assignment, FormInterface $form): void
|
||||
{
|
||||
$isLocked = self::isSkipFormalitiesLocked($assignment);
|
||||
|
||||
$form->add('skipFormalities', CheckboxType::class, [
|
||||
'label' => '"Partymodus": Ohne Vertrags-, Honorarnoten- und Feedbackprozess',
|
||||
'required' => false,
|
||||
'disabled' => $isLocked,
|
||||
'help' => $isLocked
|
||||
? 'Nicht mehr änderbar, sobald Teamer:innen eingeteilt sind.'
|
||||
: 'Teamer:innen bewerben sich und werden eingeteilt; danach ist der Einsatz abgeschlossen.',
|
||||
]);
|
||||
}
|
||||
|
||||
private function addPickupField(Destination $destination, FormInterface $form): void
|
||||
{
|
||||
$choices = [];
|
||||
|
||||
@@ -9,6 +9,7 @@ use App\Entity\Upload;
|
||||
use App\Repository\Filter\SeasonPeriodFilter;
|
||||
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
|
||||
use Doctrine\ORM\Query;
|
||||
use Doctrine\ORM\Query\Expr\Comparison;
|
||||
use Doctrine\ORM\Query\Expr\Join;
|
||||
use Doctrine\ORM\Query\Expr\Orx;
|
||||
use Doctrine\ORM\QueryBuilder;
|
||||
@@ -217,7 +218,8 @@ class DispositionRepository extends ServiceEntityRepository
|
||||
),
|
||||
$qb->expr()->lte('destination.dateTo', ':dateTo')
|
||||
),
|
||||
$qb->expr()->isNull('feedback')
|
||||
$qb->expr()->isNull('feedback'),
|
||||
$this->excludesSkipFormalities($qb)
|
||||
))
|
||||
->setParameter('status', [Assignment::STATUS_CALLED_OFF, Assignment::STATUS_DELETED])
|
||||
;
|
||||
@@ -306,6 +308,47 @@ class DispositionRepository extends ServiceEntityRepository
|
||||
;
|
||||
}
|
||||
|
||||
/**
|
||||
* The raw per-hotel feedback tally, before the missing/percentage columns are derived.
|
||||
*
|
||||
* Split out from getFeedbackStatisticsByHotel() so the rules carried in the DQL can be
|
||||
* asserted without a database, the same way the reminder queries are.
|
||||
*/
|
||||
public function getFeedbackStatisticsByHotelQuery(
|
||||
?\DateTimeImmutable $dateFrom = null,
|
||||
?\DateTimeImmutable $dateTo = null,
|
||||
): Query {
|
||||
$qb = $this->createQueryBuilder('disposition');
|
||||
|
||||
$qb
|
||||
->select(
|
||||
'destination.hotelCode AS hotelCode',
|
||||
'destination.hotel AS hotel',
|
||||
'COUNT(disposition.id) AS totalDispositions',
|
||||
'SUM(CASE WHEN disposition.feedback IS NOT NULL THEN 1 ELSE 0 END) AS providedFeedbacks'
|
||||
)
|
||||
->innerJoin('disposition.assignment', 'assignment')
|
||||
->innerJoin('assignment.destination', 'destination')
|
||||
->leftJoin('disposition.feedback', 'feedback')
|
||||
->where($qb->expr()->andX(
|
||||
$qb->expr()->in('disposition.status', ':statuses'),
|
||||
// no feedback is expected here, so counting these would inflate missingFeedbacks
|
||||
$this->excludesSkipFormalities($qb)
|
||||
))
|
||||
->setParameter('statuses', [
|
||||
Disposition::STATUS_ENDED,
|
||||
Disposition::STATUS_PAID,
|
||||
Disposition::STATUS_COMPLETED,
|
||||
])
|
||||
->groupBy('destination.hotelCode', 'destination.hotel')
|
||||
->orderBy('destination.hotel', 'ASC')
|
||||
;
|
||||
|
||||
SeasonPeriodFilter::apply($qb, $dateFrom, $dateTo);
|
||||
|
||||
return $qb->getQuery();
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns feedback statistics per hotel for dispositions with status 'ended' or 'completed'.
|
||||
*
|
||||
@@ -322,32 +365,11 @@ class DispositionRepository extends ServiceEntityRepository
|
||||
?\DateTimeImmutable $dateFrom = null,
|
||||
?\DateTimeImmutable $dateTo = null,
|
||||
): array {
|
||||
$qb = $this->createQueryBuilder('disposition');
|
||||
|
||||
$qb
|
||||
->select(
|
||||
'destination.hotelCode AS hotelCode',
|
||||
'destination.hotel AS hotel',
|
||||
'COUNT(disposition.id) AS totalDispositions',
|
||||
'SUM(CASE WHEN disposition.feedback IS NOT NULL THEN 1 ELSE 0 END) AS providedFeedbacks'
|
||||
)
|
||||
->innerJoin('disposition.assignment', 'assignment')
|
||||
->innerJoin('assignment.destination', 'destination')
|
||||
->leftJoin('disposition.feedback', 'feedback')
|
||||
->where($qb->expr()->in('disposition.status', ':statuses'))
|
||||
->setParameter('statuses', [
|
||||
Disposition::STATUS_ENDED,
|
||||
Disposition::STATUS_PAID,
|
||||
Disposition::STATUS_COMPLETED,
|
||||
])
|
||||
->groupBy('destination.hotelCode', 'destination.hotel')
|
||||
->orderBy('destination.hotel', 'ASC')
|
||||
$results = $this
|
||||
->getFeedbackStatisticsByHotelQuery($dateFrom, $dateTo)
|
||||
->getResult()
|
||||
;
|
||||
|
||||
SeasonPeriodFilter::apply($qb, $dateFrom, $dateTo);
|
||||
|
||||
$results = $qb->getQuery()->getResult();
|
||||
|
||||
// Calculate missing feedbacks and percentage
|
||||
return array_map(function (array $row): array {
|
||||
$total = (int) $row['totalDispositions'];
|
||||
@@ -367,20 +389,15 @@ class DispositionRepository extends ServiceEntityRepository
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns feedback statistics grouped by normalized hotel code (base 3-char code).
|
||||
* The raw tally grouped by normalized hotel code, before the derived columns.
|
||||
*
|
||||
* @return array<int, array{
|
||||
* hotelCode: string,
|
||||
* totalDispositions: int,
|
||||
* providedFeedbacks: int,
|
||||
* missingFeedbacks: int,
|
||||
* percentageProvided: float
|
||||
* }>
|
||||
* Kept in step with getFeedbackStatisticsByHotelQuery(): the two back different screens over
|
||||
* the same numbers, so a rule added to one belongs in the other.
|
||||
*/
|
||||
public function getFeedbackStatisticsByNormalizedHotelCode(
|
||||
public function getFeedbackStatisticsByNormalizedHotelCodeQuery(
|
||||
?\DateTimeImmutable $dateFrom = null,
|
||||
?\DateTimeImmutable $dateTo = null,
|
||||
): array {
|
||||
): Query {
|
||||
$qb = $this->createQueryBuilder('disposition');
|
||||
|
||||
// Normalize hotel code: strip SER prefix to get base 3-char code
|
||||
@@ -395,7 +412,11 @@ class DispositionRepository extends ServiceEntityRepository
|
||||
->innerJoin('disposition.assignment', 'assignment')
|
||||
->innerJoin('assignment.destination', 'destination')
|
||||
->leftJoin('disposition.feedback', 'feedback')
|
||||
->where($qb->expr()->in('disposition.status', ':statuses'))
|
||||
->where($qb->expr()->andX(
|
||||
$qb->expr()->in('disposition.status', ':statuses'),
|
||||
// no feedback is expected here, so counting these would inflate missingFeedbacks
|
||||
$this->excludesSkipFormalities($qb)
|
||||
))
|
||||
->setParameter('statuses', [
|
||||
Disposition::STATUS_ENDED,
|
||||
Disposition::STATUS_PAID,
|
||||
@@ -407,7 +428,28 @@ class DispositionRepository extends ServiceEntityRepository
|
||||
|
||||
SeasonPeriodFilter::apply($qb, $dateFrom, $dateTo);
|
||||
|
||||
$results = $qb->getQuery()->getResult();
|
||||
return $qb->getQuery();
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns feedback statistics grouped by normalized hotel code (base 3-char code).
|
||||
*
|
||||
* @return array<int, array{
|
||||
* hotelCode: string,
|
||||
* totalDispositions: int,
|
||||
* providedFeedbacks: int,
|
||||
* missingFeedbacks: int,
|
||||
* percentageProvided: float
|
||||
* }>
|
||||
*/
|
||||
public function getFeedbackStatisticsByNormalizedHotelCode(
|
||||
?\DateTimeImmutable $dateFrom = null,
|
||||
?\DateTimeImmutable $dateTo = null,
|
||||
): array {
|
||||
$results = $this
|
||||
->getFeedbackStatisticsByNormalizedHotelCodeQuery($dateFrom, $dateTo)
|
||||
->getResult()
|
||||
;
|
||||
|
||||
return array_map(function (array $row): array {
|
||||
$total = (int) $row['totalDispositions'];
|
||||
@@ -522,6 +564,10 @@ class DispositionRepository extends ServiceEntityRepository
|
||||
$qb->expr()->isNull('teamer.deletedAt'),
|
||||
$qb->expr()->notIn('disposition.status', ':dispositionStatus'),
|
||||
$qb->expr()->notIn('assignment.status', ':assignmentStatus'),
|
||||
// Cannot be left to disposition.status: this runs before DispositionStatusService
|
||||
// in CronCommand, so a skip-formalities placement is still `confirmed` here and
|
||||
// would slip past the status filter on the day after the assignment ends.
|
||||
$this->excludesSkipFormalities($qb),
|
||||
$this->effectiveDateToEquals($qb, ':endDate'),
|
||||
))
|
||||
->setParameter('documentType', Upload::TYPE_INVOICE)
|
||||
@@ -558,6 +604,24 @@ class DispositionRepository extends ServiceEntityRepository
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Restricts a query to assignments that still run the contract, invoice and feedback process.
|
||||
*
|
||||
* Four queries carry this rule - the invoice reminder, the pending-feedback list and both
|
||||
* feedback statistics - and each needs the parameter bound as well as the comparison added,
|
||||
* so this does both. Kept together because the halves are useless apart: the comparison
|
||||
* without the bind is an "Invalid parameter number" at execution time, and the bind without
|
||||
* the comparison is a silently unfiltered result, which is the worse of the two.
|
||||
*
|
||||
* Requires the `assignment` alias to be joined by the caller.
|
||||
*/
|
||||
private function excludesSkipFormalities(QueryBuilder $qb): Comparison
|
||||
{
|
||||
$qb->setParameter('skipFormalities', false);
|
||||
|
||||
return $qb->expr()->eq('assignment.skipFormalities', ':skipFormalities');
|
||||
}
|
||||
|
||||
/**
|
||||
* Same fallback as effectiveDateToEquals(), but for "later than $parameter".
|
||||
*/
|
||||
|
||||
@@ -50,11 +50,17 @@ class DispositionVoter extends Voter
|
||||
$disposition = $subject;
|
||||
|
||||
return match ($attribute) {
|
||||
static::VIEW, static::EDIT, static::CONTRACT, static::INVOICE => $this->security->isGranted('ROLE_ADMINISTRATIVE')
|
||||
static::VIEW, static::EDIT => $this->security->isGranted('ROLE_ADMINISTRATIVE')
|
||||
|| $this->assertTeamerAccess($token, $disposition),
|
||||
// The documents themselves do not exist on a skip-formalities assignment, so neither
|
||||
// the blank PDFs nor the upload screens may be reachable for it.
|
||||
static::CONTRACT, static::INVOICE => false === $disposition->isSkipFormalities()
|
||||
&& ($this->security->isGranted('ROLE_ADMINISTRATIVE')
|
||||
|| $this->assertTeamerAccess($token, $disposition)),
|
||||
static::DELETE => $this->security->isGranted('ROLE_ADMIN'),
|
||||
static::FEEDBACK => $this->security->isGranted('ROLE_ADMINISTRATIVE')
|
||||
|| $this->assertHouseManagerAccess($token, $disposition),
|
||||
static::FEEDBACK => false === $disposition->isSkipFormalities()
|
||||
&& ($this->security->isGranted('ROLE_ADMINISTRATIVE')
|
||||
|| $this->assertHouseManagerAccess($token, $disposition)),
|
||||
static::CONTRACT_SUPPLEMENTARY => $this->assertContractUploadAllowed($disposition),
|
||||
static::ADMIN_DOCUMENT_UPLOAD => $this->assertAdminDocumentUploadAllowed($disposition),
|
||||
static::CALL_OFF => $this->security->isGranted('ROLE_ADMINISTRATIVE')
|
||||
@@ -105,6 +111,10 @@ class DispositionVoter extends Voter
|
||||
return false;
|
||||
}
|
||||
|
||||
if (true === $disposition->isSkipFormalities()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (Disposition::STATUS_CALLED_OFF === $disposition->getStatus()) {
|
||||
return false;
|
||||
}
|
||||
@@ -121,6 +131,10 @@ class DispositionVoter extends Voter
|
||||
return false;
|
||||
}
|
||||
|
||||
if (true === $disposition->isSkipFormalities()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return Disposition::STATUS_CALLED_OFF !== $disposition->getStatus();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -38,14 +38,21 @@ class DispositionReminderService
|
||||
->select('disposition', 'assignment', 'destination')
|
||||
->innerJoin('disposition.assignment', 'assignment')
|
||||
->innerJoin('assignment.destination', 'destination')
|
||||
->innerJoin('disposition.documents', 'document', Join::WITH, 'document.type = :documentType')
|
||||
->leftJoin('disposition.documents', 'document', Join::WITH, 'document.type = :documentType')
|
||||
->innerJoin('disposition.teamer', 'teamer')
|
||||
->where($qb->expr()->andX(
|
||||
$qb->expr()->isNull('teamer.deletedAt'),
|
||||
$qb->expr()->notIn('assignment.status', ':statusAssignment'),
|
||||
$qb->expr()->eq('destination.dateFrom', ':dateFrom'),
|
||||
$qb->expr()->eq('document.status', ':statusContract'),
|
||||
// A checked contract is what normally marks a placement as settled enough to be
|
||||
// reminded about. A skip-formalities assignment never produces one, so it has to
|
||||
// qualify on the flag alone - otherwise its teamers get no trip reminder at all.
|
||||
$qb->expr()->orX(
|
||||
$qb->expr()->eq('document.status', ':statusContract'),
|
||||
$qb->expr()->eq('assignment.skipFormalities', ':skipFormalities'),
|
||||
),
|
||||
))
|
||||
->setParameter('skipFormalities', true)
|
||||
->setParameter('documentType', Upload::TYPE_CONTRACT)
|
||||
->setParameter('statusAssignment', [Assignment::STATUS_DELETED, Assignment::STATUS_CALLED_OFF])
|
||||
->setParameter('dateFrom', $dateFrom->format('Y-m-d'))
|
||||
|
||||
@@ -26,13 +26,29 @@ class DispositionStatusService
|
||||
return 'No ended dispositions to update';
|
||||
}
|
||||
|
||||
$completed = 0;
|
||||
|
||||
foreach ($endedDispositions as $disposition) {
|
||||
// A skip-formalities placement has no invoice step left to run through, so ending it
|
||||
// means it is done - anything else would park it in `ended` forever and keep asking
|
||||
// the teamer for a Honorarnote that is not wanted.
|
||||
if (true === $disposition->isSkipFormalities()) {
|
||||
$disposition->setStatus(Disposition::STATUS_COMPLETED);
|
||||
++$completed;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$disposition->setStatus(Disposition::STATUS_ENDED);
|
||||
}
|
||||
|
||||
$this->entityManager->flush();
|
||||
|
||||
$message = 'Ended '.$count.' dispositions';
|
||||
$message = sprintf(
|
||||
'Ended %d dispositions (%d of them completed without formalities)',
|
||||
$count,
|
||||
$completed
|
||||
);
|
||||
|
||||
$this->logger->info($message);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user