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
+6
View File
@@ -610,6 +610,12 @@ Confirm an appointment («ثبت شده» → «قطعی شده») and register
case file — status transition, case file / visit, and payments in **one atomic
transaction**. If any step fails nothing is committed.
> **پروندهٔ درمان هم اینجا باز می‌شود.** اگر سرویسِ نوبت «طول درمان» فعال داشته باشد،
> همین اندپوینت پروندهٔ چندجلسه‌ای را می‌سازد و نوبت را به اولین جلسهٔ بدون‌نوبت وصل
> می‌کند — دقیقاً مثل `PATCH /api/v1/appointment/{uuid}/status`.
> شکستِ این مرحله نوبت و پرداخت را برنمی‌گرداند؛ فقط لاگ می‌شود. پرونده‌های
> جامانده را `php bin/console app:treatment:backfill-cases --fix` می‌سازد.
**Permission:** `AUTH``appointments.update_status` per the
[single-appointment access model](#single-appointment-access-model).
@@ -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;
}
}
+31 -2
View File
@@ -29,7 +29,7 @@ class OpenCaseOnConfirmTest extends ApiTestCase
return static::getContainer()->get(AppointmentConfirmationService::class);
}
private function scenario(bool $withProtocol, ?string $domainCode = 'beauty'): array
private function scenario(bool $withProtocol, ?string $domainCode = 'beauty', bool $confirm = true): array
{
$doctor = new Doctor($this->createUser(['ROLE_DOCTOR']), 'دکتر آزمون');
$this->em->persist($doctor);
@@ -82,7 +82,9 @@ class OpenCaseOnConfirmTest extends ApiTestCase
$clinic,
);
$appointment->addServiceItem($service);
$appointment->transitionTo(Appointment::STATUS_CONFIRMED);
if ($confirm) {
$appointment->transitionTo(Appointment::STATUS_CONFIRMED);
}
$this->em->persist($appointment);
$this->em->flush();
@@ -111,6 +113,33 @@ class OpenCaseOnConfirmTest extends ApiTestCase
));
}
/**
* مسیر واقعیِ منشی: قطعی‌کردن از پنل با مودال پرداخت.
*
* این مسیر جدا از onConfirmed است و مدتی پروندهٔ درمان را نمی‌ساخت — نوبت قطعی
* می‌شد، پول ثبت می‌شد، و پنل پرسنل خالی می‌ماند چون هیچ جلسه‌ای وجود نداشت.
*/
public function testConfirmingFromThePanelWithPaymentsOpensACase(): void
{
[$appointment, $service] = $this->scenario(withProtocol: true, confirm: false);
$this->confirmation()->confirmWithPayments(
$appointment,
$appointment->getVersion(),
[['method' => 'cash', 'amount_rials' => 5_000_000]],
$this->createUser(['ROLE_CLINIC']),
);
$cases = $this->casesFor($service);
self::assertCount(1, $cases);
self::assertCount(3, $cases[0]->getSessions());
$sessions = $cases[0]->getSessions()->toArray();
usort($sessions, static fn (TreatmentSession $a, TreatmentSession $b): int
=> $a->getSessionNumber() <=> $b->getSessionNumber());
self::assertSame($appointment->getId(), $sessions[0]->getAppointment()?->getId());
}
/** سرویس بدون پروتکل همان نوبت تک‌جلسه‌ای است — نباید پرونده‌ای ساخته شود. */
public function testConfirmingAPlainServiceOpensNoCase(): void
{