"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>
122 lines
4.7 KiB
PHP
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);
|
|
}
|
|
}
|