Files
clinicpro/src/Appointment/Service/AppointmentConfirmationService.php
T
hamedandClaude Opus 5 a331aab2b8 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>
2026-08-07 12:24:29 +03:30

149 lines
6.9 KiB
PHP

<?php
namespace App\Appointment\Service;
use App\Appointment\Entity\Appointment;
use App\Appointment\Repository\AppointmentRepository;
use App\Auth\Entity\User;
use App\Patient\Entity\PatientSession;
use App\Patient\Service\PatientService;
use App\Payment\Repository\PaymentRepository;
use App\Representation\Service\DomainContextResolver;
use App\Settlement\Service\CommissionService;
use App\Shared\Constant\ErrorCodes;
use App\Shared\Exception\AppException;
use Doctrine\ORM\EntityManagerInterface;
use Psr\Log\LoggerInterface;
/**
* عوارض جانبیِ قطعی‌شدن نوبت، در یک نقطه.
*
* قطعی‌شدن پنج مسیر دارد (پرداخت آنلاین، دو مسیر PATCH، رزرو پنل، رزرو ادمین) و
* تا امروز فقط دو تای آن‌ها پرونده می‌ساختند — نوبت‌های سایت عمومی که با پرداخت
* قطعی می‌شوند هیچ‌وقت پرونده نداشتند. هر مسیر جدیدی هم باید همین را صدا بزند.
*/
class AppointmentConfirmationService
{
public function __construct(
private readonly PatientService $patientService,
private readonly AppointmentRepository $appointmentRepo,
private readonly PaymentRepository $paymentRepo,
private readonly CommissionService $commissionService,
private readonly DomainContextResolver $domainResolver,
private readonly \App\Treatment\Service\TreatmentCaseStarter $treatmentCases,
private readonly EntityManagerInterface $em,
private readonly LoggerInterface $logger,
) {}
/**
* تقسیم مالیِ نوبت آنلاین در لحظهٔ **تأیید** انجام می‌شود، نه لحظهٔ پرداخت: تا وقتی
* نوبت قطعی نشده، پورسانت نماینده و سهم منشی هم اعتبار نمی‌شوند. برای نوبتی که
* پرداخت آنلاین ندارد (ثبت‌شده در پنل) کاری انجام نمی‌شود. ثبت idempotent است.
*/
private function splitPaymentShares(Appointment $appointment): void
{
$payment = $this->paymentRepo->findSuccessfulByAppointment($appointment);
if ($payment === null) {
return;
}
$doctor = $appointment->getDoctor();
$this->commissionService->processAppointment(
$payment,
$doctor->getRepresentationId(),
$this->domainResolver->resolve($payment->getFrontendAddress())->representationId(),
$doctor->getId(),
);
}
/**
* idempotent: فراخوانی دوباره برای همان نوبت چیزی نمی‌سازد.
*
* شکست ساخت پرونده نباید قطعی‌شدن نوبت یا تأیید پرداخت را برگرداند — نوبت
* رزرو شده و پول پرداخت شده است؛ پرونده را می‌شود با
* `app:appointment:backfill-sessions` ساخت، ولی رول‌بکِ پرداخت برگشت‌ناپذیر است.
*/
public function onConfirmed(Appointment $appointment): ?PatientSession
{
// نوبت رزروِ روز-محور اسلات و ساعت مشخص ندارد؛ مراجعهٔ زمان‌دار برایش معنا ندارد.
if ($appointment->isReserve()) {
return null;
}
$this->splitPaymentShares($appointment);
try {
$session = $this->patientService->autoCreateOnAppointmentConfirm($appointment);
// هسته نمی‌داند دورهٔ درمان چیست: اگر سرویسِ نوبت پروتکل داشته باشد،
// workflowِ حوزهٔ فعالیت تصمیم می‌گیرد چه اتفاقی بیفتد.
if ($session !== null) {
$this->treatmentCases->onAppointmentConfirmed($appointment, $session);
}
return $session;
} catch (\Throwable $e) {
$this->logger->error('Auto-creating the patient record on confirm failed', [
'appointment_uuid' => $appointment->getUuid(),
'exception' => $e,
]);
return null;
}
}
/**
* قطعی‌کردنِ صریح از پنل: انتقال وضعیت، ثبت پرونده/مراجعه و ثبت پرداخت‌ها — همه
* در یک تراکنش. برخلاف onConfirmed اینجا شکست خاموش نمی‌ماند: کاربر روبه‌روی
* مودالی ایستاده که مبلغ نشان داده و منتظر تأیید است؛ «قطعی شد ولی پول ثبت نشد»
* بدترین خروجیِ ممکن است.
*
* @param array<int, array{method: string, amount_rials: int, payment_method_uuid?: ?string, reference?: ?string}> $payments
* @return PatientSession|null null یعنی این tenant قابلیت پرونده را ندارد
* (فقط وقتی مجاز است که پرداختی هم ارسال نشده باشد)
*/
public function confirmWithPayments(
Appointment $appointment,
int $expectedVersion,
array $payments,
User $actor,
): ?PatientSession {
return $this->em->wrapInTransaction(function () use ($appointment, $expectedVersion, $payments, $actor) {
$appointment->transitionTo(Appointment::STATUS_CONFIRMED);
$this->appointmentRepo->saveWithLock($appointment, $expectedVersion);
$this->splitPaymentShares($appointment);
$session = $this->patientService->autoCreateOnAppointmentConfirm($appointment);
if ($session === null) {
if ($payments !== []) {
throw new AppException(ErrorCodes::ERR_SUBSCRIPTION_REQUIRED, null, 403);
}
return null;
}
foreach ($payments as $payment) {
$this->patientService->addSessionPayment(
$session,
$payment['method'],
$payment['amount_rials'],
null,
$actor,
$payment['payment_method_uuid'] ?? null,
$payment['reference'] ?? null,
);
}
// همان کاری که onConfirmed می‌کند. نبودنش یعنی نوبتی که از پنل و با
// پرداخت قطعی شده — یعنی مسیر عادیِ منشی — هرگز پروندهٔ درمان نمی‌گیرد و
// جلسه‌ای هم ساخته نمی‌شود که در پنل پرسنل دیده شود.
// starter خودش خطا را می‌گیرد و لاگ می‌کند، پس تراکنش پرداخت را نمی‌شکند.
$this->treatmentCases->onAppointmentConfirmed($appointment, $session);
return $session;
});
}
}