Files
clinicpro/src/Appointment/Booking/Service/BookingService.php
T
hamedandClaude Opus 5 ca9648732d feat(package): session packages backed by a credit ledger
"Six laser sessions" is the common case in an aesthetics clinic: the patient
pays once and books the sessions later.

Credit is a ledger, not a counter. No table has a remaining/used_count column
and a schema test enforces that — the balance is always SUM(delta) over
append-only rows, so every number a patient sees has a full history behind it.
Corrections are new rows, never edits.

- purchase / consume / refund / adjustment / expiry, each with a reason, an
  author and the appointment it belongs to
- consume happens in confirm(), never in quote(): if the preview consumed, a
  page refresh would cost the patient a session
- cancelling adds a refund row; the consume row stays
- FIFO across a patient's packages — the oldest is closest to expiring
- an empty package is not an error, it just does not apply and the patient pays
- adjust/expire need a doctor or clinic role, and adjust always needs a reason
- app:package:expire writes the closing row so "where did my 3 sessions go?"
  always has an answer

Consume takes a pessimistic lock on the one package row. That is the opposite
of task 07's slot buckets, and docs/api/package.md carries the table explaining
why, so nobody unifies them later.

Idempotency checks for an existing consume row before inserting rather than
catching the unique violation: in Doctrine that exception closes the
EntityManager and burns the rest of the request. The unique key stays as the
last line of defence.

Admin: PackagesPage, a packages tab on the patient record, and a ledger page
whose running-balance column shows where the final number came from.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 11:11:03 +03:30

122 lines
4.7 KiB
PHP

<?php
namespace App\Appointment\Booking\Service;
use App\Appointment\Availability\Entity\ResourceOccupancy;
use App\Appointment\Booking\Entity\AppointmentHold;
use App\Appointment\Booking\Entity\AppointmentSegment;
use App\Appointment\Entity\Appointment;
use App\Package\Service\CreditLedgerService;
use App\Package\Service\PackageConsumptionService;
use App\Shared\Constant\ErrorCodes;
use App\Shared\Exception\AppException;
use Doctrine\ORM\EntityManagerInterface;
/**
* ثبت نهایی از یک رزرو موقت، و آزادسازی هنگام لغو.
*
* تبدیل `hold → booked` هیچ منبعی را دوباره نمی‌گیرد: صندلی‌ها از لحظهٔ رزرو موقت
* گرفته شده‌اند و اینجا فقط برچسبشان عوض می‌شود. اگر ثبت نهایی دوباره رزرو می‌کرد،
* همان پنجرهٔ مسابقه‌ای که hold حذفش کرده بود برمی‌گشت.
*/
final class BookingService
{
public function __construct(
private readonly HoldService $holds,
private readonly PackageConsumptionService $packages,
private readonly CreditLedgerService $credits,
private readonly EntityManagerInterface $em,
) {}
/**
* @throws AppException ۴۰۹ روی رزروِ منقضی یا ثبت‌شده
*/
public function confirm(AppointmentHold $hold, Appointment $appointment, ?int $now = null): Appointment
{
$now = $now ?? time();
if ($hold->isConfirmed()) {
throw new AppException(ErrorCodes::ERR_SLOT_TAKEN, 'این رزرو قبلاً ثبت شده است', 409);
}
if ($hold->isExpired($now)) {
throw new AppException(ErrorCodes::ERR_HOLD_EXPIRED, 'مهلت رزرو موقت تمام شده است', 409);
}
$occupancies = $this->holds->occupanciesOfHold($hold);
if ($occupancies === []) {
throw new AppException(ErrorCodes::ERR_HOLD_EXPIRED, 'رزرو موقت دیگر معتبر نیست', 409);
}
foreach ($occupancies as $occupancy) {
$occupancy->markBooked()->setAppointmentId($appointment->getId());
}
$this->writeSegments($hold, $appointment);
$hold->markConfirmed($now);
$this->em->flush();
// مصرف اعتبار **اینجا**ست نه در پیش‌نمایش قیمت: تنها لحظه‌ای که نوبت واقعاً
// وجود دارد. کلید یکتای دفتر هم تضمین می‌کند اجرای دوباره جلسهٔ دوم نخورد.
$this->packages->consumeFor($appointment);
return $appointment;
}
/**
* بخش‌های نوبت از همان `payload` رزرو ساخته می‌شوند، نه از الگوی امروزِ سرویس:
* الگو ممکن است بین رزرو و ثبت عوض شده باشد و نوبت باید همان چیزی بماند که کاربر
* دیده و پذیرفته.
*/
private function writeSegments(AppointmentHold $hold, Appointment $appointment): void
{
$segments = $hold->getPayload()['plan']['segments'] ?? [];
foreach ($segments as $segment) {
$start = $hold->getStartsAt() + (int) ($segment['offset_minutes'] ?? 0) * 60;
$end = $start + (int) ($segment['duration_minutes'] ?? 0) * 60;
if ($end <= $start) {
continue;
}
$this->em->persist(new AppointmentSegment(
$appointment,
(int) ($segment['sequence'] ?? 1),
(string) ($segment['name'] ?? '—'),
$start,
$end,
(bool) ($segment['patient_present'] ?? true),
));
}
}
/**
* لغو: ردیف‌های اشغال `released` می‌شوند، **حذف فیزیکی نمی‌شوند**.
* تاریخچه ورودی گزارش بهره‌وری است.
*/
public function cancel(Appointment $appointment): int
{
$occupancies = $this->em->getRepository(ResourceOccupancy::class)
->findBy(['appointmentId' => $appointment->getId()]);
$this->holds->release($occupancies);
// ردیف `consume` **حذف نمی‌شود**؛ بازگشت یک ردیف تازه است تا تاریخچه بماند.
$this->credits->refund($appointment);
return count($occupancies);
}
/** رزروِ منقضی: همان آزادسازی، ولی از سمت رزرو موقت. */
public function releaseHold(AppointmentHold $hold): int
{
$occupancies = $this->holds->occupanciesOfHold($hold);
$this->holds->release($occupancies);
return count($occupancies);
}
}