"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>
134 lines
5.4 KiB
PHP
134 lines
5.4 KiB
PHP
<?php
|
|
|
|
namespace App\Package\Service;
|
|
|
|
use App\Appointment\Entity\Appointment;
|
|
use App\Auth\Entity\User;
|
|
use App\ClinicService\Entity\ServiceItem;
|
|
use App\Package\Entity\PatientPackage;
|
|
use App\Package\Entity\SessionCreditLedger;
|
|
use App\Package\Repository\SessionCreditLedgerRepository;
|
|
use Doctrine\ORM\EntityManagerInterface;
|
|
use Doctrine\DBAL\LockMode;
|
|
|
|
/**
|
|
* تنها نویسندهٔ دفتر اعتبار.
|
|
*
|
|
* هیچ کلاس دیگری نباید در `session_credit_ledger` بنویسد؛ اگر بنویسد، قواعد این کلاس
|
|
* (مصرف یکتا per نوبت، ماندهای که منفی نمیشود) دور زده میشوند و دفتر همان چیزی
|
|
* میشود که قرار بود نباشد: عددی که کسی نمیداند از کجا آمده.
|
|
*/
|
|
final class CreditLedgerService
|
|
{
|
|
public function __construct(
|
|
private readonly SessionCreditLedgerRepository $ledger,
|
|
private readonly EntityManagerInterface $em,
|
|
) {}
|
|
|
|
/** مانده = جمع delta ها. هیچ ستون ذخیرهشدهای نیست. */
|
|
public function balance(PatientPackage $package): int
|
|
{
|
|
return $this->ledger->sumDelta($package);
|
|
}
|
|
|
|
public function record(
|
|
PatientPackage $package,
|
|
string $kind,
|
|
int $delta,
|
|
?Appointment $appointment = null,
|
|
?ServiceItem $service = null,
|
|
?string $reason = null,
|
|
?User $by = null,
|
|
bool $flush = true,
|
|
): SessionCreditLedger {
|
|
$row = new SessionCreditLedger($package, $kind, $delta, $appointment, $service, $reason, $by);
|
|
|
|
$this->em->persist($row);
|
|
|
|
if ($flush) {
|
|
$this->em->flush();
|
|
}
|
|
|
|
return $row;
|
|
}
|
|
|
|
/**
|
|
* مصرف یک جلسه — `false` یعنی «اعتباری نبود»، نه خطا.
|
|
*
|
|
* بیمار بدون اعتبار باید بتواند نقدی بپردازد؛ استثنا پرتاب کردن اینجا یعنی
|
|
* رزروِ کاملاً معتبر شکست بخورد.
|
|
*
|
|
* قفل بدبینانه روی همان یک ردیف پکیج است. برخلاف اسلاتهای تسک ۰۷ — که نرخ رقابت
|
|
* بالا و دهها ردیف درگیر دارند — اینجا یک بیمار و یک پکیج است، پس هزینهٔ قفل
|
|
* ناچیز و سادگیاش برنده است.
|
|
*/
|
|
public function consume(PatientPackage $package, Appointment $appointment, ?ServiceItem $service = null): bool
|
|
{
|
|
// قفل بدون تراکنش معنا ندارد؛ خواندن و نوشتن باید در یک واحد اتمی باشند
|
|
// وگرنه دو درخواست همزمان هر دو ماندهٔ ۱ را میبینند.
|
|
return $this->em->wrapInTransaction(function () use ($package, $appointment, $service): bool {
|
|
$locked = $this->em->find(PatientPackage::class, $package->getId(), LockMode::PESSIMISTIC_WRITE);
|
|
|
|
if ($locked === null || $locked->isExpired()) {
|
|
return false;
|
|
}
|
|
|
|
// همین نوبت قبلاً مصرف کرده؟ `confirm` idempotent است و اجرای دومش نباید
|
|
// جلسهٔ دوم بخورد. بررسی **پیش از** درج است نه گرفتنِ استثنا: نقض کلید
|
|
// یکتا در Doctrine خودِ EntityManager را میبندد و بقیهٔ همان request را
|
|
// هم میسوزاند. کلید یکتا آخرین خط دفاع میماند، نه مسیر عادی.
|
|
if ($this->ledger->findForAppointment($appointment, SessionCreditLedger::KIND_CONSUME) !== null) {
|
|
return true;
|
|
}
|
|
|
|
if ($this->balance($locked) <= 0) {
|
|
return false;
|
|
}
|
|
|
|
$this->record($locked, SessionCreditLedger::KIND_CONSUME, -1, $appointment, $service);
|
|
|
|
return true;
|
|
});
|
|
}
|
|
|
|
/**
|
|
* بازگشت اعتبار هنگام لغو — ردیف `consume` **حذف نمیشود**.
|
|
*
|
|
* فعلاً هر لغوی اعتبار را کامل برمیگرداند. سیاست واقعی (لغو دیرهنگام، جریمه،
|
|
* عدمحضور) کارِ تسک ۱۳ است و همانجا این متد یک پارامتر سیاست میگیرد؛ پرچم
|
|
* نیمکاره اینجا فقط رفتاری میساخت که هیچکس تنظیمش نمیکند.
|
|
*
|
|
* @return bool `false` یعنی این نوبت اصلاً از پکیج مصرف نکرده بود
|
|
*/
|
|
public function refund(Appointment $appointment, ?User $by = null): bool
|
|
{
|
|
$consumed = $this->ledger->findForAppointment($appointment, SessionCreditLedger::KIND_CONSUME);
|
|
|
|
if ($consumed === null) {
|
|
return false;
|
|
}
|
|
|
|
if ($this->ledger->findForAppointment($appointment, SessionCreditLedger::KIND_REFUND) !== null) {
|
|
return false;
|
|
}
|
|
|
|
$this->record(
|
|
$consumed->getPatientPackage(),
|
|
SessionCreditLedger::KIND_REFUND,
|
|
-$consumed->getDelta(),
|
|
$appointment,
|
|
$consumed->getServiceItem(),
|
|
'بازگشت اعتبار با لغو نوبت',
|
|
$by,
|
|
);
|
|
|
|
return true;
|
|
}
|
|
|
|
/** @return SessionCreditLedger[] */
|
|
public function history(PatientPackage $package): array
|
|
{
|
|
return $this->ledger->historyFor($package);
|
|
}
|
|
}
|