fix(treatment): open the treatment case when confirming from the panel

confirmWithPayments — the path behind POST /appointment/{uuid}/confirm, which
is how a secretary actually confirms — created the patient session but never
called TreatmentCaseStarter. Only onConfirmed did. So an appointment on a
service with an active protocol was confirmed and paid, and no treatment case
or sessions were ever created; the staff panel had nothing to list.

Every existing test in OpenCaseOnConfirmTest drove onConfirmed, which is why
the gap survived. Added one that drives confirmWithPayments; it fails without
the fix.

Also adds app:treatment:backfill-cases, mirroring
app:appointment:backfill-sessions: it reports confirmed appointments on a
protocol service that have no case, and with --fix replays the starter and
prints the exception the logger would otherwise keep to itself.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
hamed
2026-08-07 12:24:29 +03:30
co-authored by Claude Opus 5
parent b437390e06
commit a331aab2b8
4 changed files with 193 additions and 2 deletions
@@ -136,6 +136,12 @@ class AppointmentConfirmationService
);
}
// همان کاری که onConfirmed می‌کند. نبودنش یعنی نوبتی که از پنل و با
// پرداخت قطعی شده — یعنی مسیر عادیِ منشی — هرگز پروندهٔ درمان نمی‌گیرد و
// جلسه‌ای هم ساخته نمی‌شود که در پنل پرسنل دیده شود.
// starter خودش خطا را می‌گیرد و لاگ می‌کند، پس تراکنش پرداخت را نمی‌شکند.
$this->treatmentCases->onAppointmentConfirmed($appointment, $session);
return $session;
});
}
@@ -0,0 +1,150 @@
<?php
namespace App\Treatment\Command;
use App\Appointment\Entity\Appointment;
use App\Appointment\Repository\AppointmentRepository;
use App\Patient\Repository\PatientSessionRepository;
use App\Treatment\Repository\TreatmentProtocolRepository;
use App\Treatment\Service\TreatmentCaseStarter;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
/**
* Finds confirmed appointments on a service that has an active treatment
* protocol but never got a treatment case.
*
* Two ways to end up here, and neither is a bug in the confirm path:
* the appointment was confirmed *before* the protocol was switched on, or the
* case failed to open and TreatmentCaseStarter swallowed the error by design
* (the appointment is booked and paid; it must not roll back). Either way the
* staff panel shows nothing, because sessions are what it lists.
*
* Reports by default. `--fix` replays TreatmentCaseStarter for each one and, on
* failure, prints the exception the logger would otherwise have kept to itself.
*/
#[AsCommand(
name: 'app:treatment:backfill-cases',
description: 'Report (and optionally open) missing treatment cases for confirmed appointments',
)]
final class BackfillTreatmentCasesCommand extends Command
{
private const FILED_STATUSES = [
Appointment::STATUS_CONFIRMED,
Appointment::STATUS_COMPLETED,
];
public function __construct(
private readonly AppointmentRepository $appointments,
private readonly PatientSessionRepository $sessions,
private readonly TreatmentProtocolRepository $protocols,
private readonly TreatmentCaseStarter $starter,
private readonly EntityManagerInterface $em,
) {
parent::__construct();
}
protected function configure(): void
{
$this->addOption('fix', null, InputOption::VALUE_NONE, 'Open the missing cases instead of only reporting them');
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$io = new SymfonyStyle($input, $output);
$fix = (bool) $input->getOption('fix');
/** @var Appointment[] $candidates */
$candidates = $this->appointments->createQueryBuilder('a')
->where('a.status IN (:statuses)')
->andWhere('a.isReserve = false')
->andWhere('a.serviceItem IS NOT NULL')
->setParameter('statuses', self::FILED_STATUSES)
->orderBy('a.id', 'ASC')
->getQuery()
->getResult();
$rows = [];
$opened = 0;
$failed = 0;
foreach ($candidates as $appointment) {
$service = $appointment->getServiceItem();
if ($service === null || $this->protocols->findActiveForService($service) === null) {
continue;
}
$clinic = $appointment->getClinic();
[$entityType, $entityId] = $clinic !== null
? ['clinic', (int) $clinic->getId()]
: ['doctor', (int) $appointment->getDoctor()->getId()];
$session = $this->sessions->findByAppointmentAndEntity($appointment, $entityType, $entityId);
// بدون مراجعه، پرونده جایی برای نشستن ندارد؛ آن یکی را
// app:appointment:backfill-sessions درست می‌کند، نه این.
if ($session === null) {
$rows[] = [$appointment->getId(), $service->getName(), 'no patient session', '—'];
continue;
}
if ($this->hasTreatmentSession($appointment)) {
continue;
}
if (!$fix) {
$rows[] = [$appointment->getId(), $service->getName(), 'no treatment case', 'would open'];
continue;
}
try {
$case = $this->starter->onAppointmentConfirmed($appointment, $session);
$this->em->flush();
if ($case === null) {
++$failed;
$rows[] = [$appointment->getId(), $service->getName(), 'starter returned null', 'see log'];
continue;
}
++$opened;
$rows[] = [$appointment->getId(), $service->getName(), 'case ' . $case->getUuid(), 'opened'];
} catch (\Throwable $e) {
++$failed;
$rows[] = [$appointment->getId(), $service->getName(), $e::class . ': ' . $e->getMessage(), 'failed'];
}
}
if ($rows === []) {
$io->success('Every confirmed appointment on a protocol service already has its treatment case.');
return Command::SUCCESS;
}
$io->table(['appointment', 'service', 'detail', 'action'], $rows);
if (!$fix) {
$io->note(sprintf('%d appointment(s) need a case. Re-run with --fix to open them.', count($rows)));
return Command::SUCCESS;
}
$io->writeln(sprintf('opened: %d — failed: %d', $opened, $failed));
return $failed > 0 ? Command::FAILURE : Command::SUCCESS;
}
/** یک نوبت حداکثر به یک جلسهٔ درمان وصل است، پس وجودش یعنی پرونده ساخته شده. */
private function hasTreatmentSession(Appointment $appointment): bool
{
return (int) $this->em->createQuery(
'SELECT COUNT(s.id) FROM App\Treatment\Entity\TreatmentSession s WHERE s.appointment = :a',
)->setParameter('a', $appointment)->getSingleScalarResult() > 0;
}
}