Files
clinicpro/src/Appointment/Service/AppointmentConfirmationService.php
T
hamedandClaude Opus 5 252e20bfe9 feat(treatment): select treatment behaviour by practice domain, not by if-branch
Everything that differs between specialties as data is already stored as data.
What is left is behaviour — when a case opens, what happens once a session ends —
so it becomes a TreatmentWorkflow resolved through a tagged-service registry.
The booking path calls one collaborator and never names a specialty; adding
dentistry is a new class, not an edit to confirmation.

A clinic that has chosen no practice domain still gets working multi-session
courses: DefaultTreatmentWorkflow answers for null and for any code without a
dedicated implementation, keeping "unset means behave as today, not error".
LaserTreatmentWorkflow is deliberately empty beyond claiming `beauty` — it is the
seam where laser-specific behaviour will land without disturbing anyone else.

Session due dates are anchored to the previous session's actual finish, so a
patient who comes twenty days late shifts the rest of their course instead of
getting the next session while it can still do nothing. Only the next session is
recomputed; later ones keep their estimate because they are anchored to nothing
yet.

Attachment targets the first session without an appointment rather than the
first open one: a patient booking again mid-course was otherwise matched to the
session that already had a booking, and the second appointment went nowhere.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-06 17:23:51 +03:30

143 lines
6.3 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,
);
}
return $session;
});
}
}