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:
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user