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>
143 lines
6.3 KiB
PHP
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;
|
|
});
|
|
}
|
|
}
|