diff --git a/assets/admin/pages/PatientPackageLedgerPage.tsx b/assets/admin/pages/PatientPackageLedgerPage.tsx index b424bb7d..8d05c362 100644 --- a/assets/admin/pages/PatientPackageLedgerPage.tsx +++ b/assets/admin/pages/PatientPackageLedgerPage.tsx @@ -1,4 +1,4 @@ -import React, { useState } from 'react'; +import React, { useMemo, useState } from 'react'; import { Link, useParams } from 'react-router-dom'; import PageHeader from '../components/ui/PageHeader'; import DataTable, { type Column } from '../components/ui/DataTable'; @@ -32,6 +32,24 @@ export default function PatientPackageLedgerPage() { const [delta, setDelta] = useState(1); const [reason, setReason] = useState(''); + /** + * مانده تجمعی از روی همان ردیف‌هایی که نمایش داده می‌شوند. + * + * دفتر append-only و به ترتیب زمان است، پس جمعِ تجمعی روی همین آرایه دقیقاً همان + * چیزی است که سرور می‌گوید — و اگر نبود، یعنی فهرست ناقص رسیده. + */ + const runningBalance = useMemo(() => { + const out: Record = {}; + let total = 0; + + for (const row of [...(ledger?.rows ?? [])].sort((a, b) => a.created_at - b.created_at)) { + total += row.delta; + out[row.uuid] = total; + } + + return out; + }, [ledger?.rows]); + const columns: Column[] = [ { key: 'created_at', @@ -55,7 +73,12 @@ export default function PatientPackageLedgerPage() { { key: 'running_balance', header: 'مانده', - render: (r) => {r.running_balance}, + // ⚠️ در UI جمع می‌شود، نه از سرور خوانده. + // + // سرور `running_balance` را هم می‌فرستد و همان مرجع است؛ این ستون **بازتاب** + // همان ردیف‌هایی است که کاربر می‌بیند. اگر این عدد با عددِ سرور نخواند، یعنی + // فهرست ناقص است — و همان اختلاف، خودش نشانه است. + render: (r) => {runningBalance[r.uuid] ?? r.running_balance}, }, { key: 'reason', diff --git a/src/Cancellation/Service/CancellationService.php b/src/Cancellation/Service/CancellationService.php index cede13a9..7cdb306f 100644 --- a/src/Cancellation/Service/CancellationService.php +++ b/src/Cancellation/Service/CancellationService.php @@ -55,22 +55,38 @@ final class CancellationService $penalty = $this->calculator->calculate($appointment, $status, $now); - $appointment->transitionTo($status); - $this->em->flush(); + /** + * همهٔ نوشتن‌های دیتابیس در **یک** تراکنش. + * + * وضعیت نوبت، آزادسازی ظرفیت، بازگشت اعتبار، جریمه و ردیف تایم‌لاین یک واقعه‌اند: + * نوبتی که «لغو» شده ولی ظرفیتش آزاد نشده، بدترین حالت ممکن است — هم بیمار نوبت + * ندارد هم کسی نمی‌تواند آن وقت را بگیرد. + * + * اطلاع‌رسانی **بیرون** این بلوک است و بعد از commit اجرا می‌شود: پیامک قابل + * برگرداندن نیست، پس نباید داخل چیزی باشد که ممکن است برگردد. + */ + [$released, $charged] = $this->em->wrapInTransaction( + function () use ($appointment, $status, $penalty, $actor, $reason): array { + $appointment->transitionTo($status); + $this->em->flush(); - // آزادسازی منابع و بازگشت اعتبار پکیج و جلسهٔ دوره — همه در `cancel` بوکینگ. - $released = $this->booking->cancel($appointment); + // آزادسازی منابع و بازگشت اعتبار پکیج و جلسهٔ دوره — همه در `cancel` بوکینگ. + $released = $this->booking->cancel($appointment); - if (!$penalty->creditRefundable) { - $this->revokeRefundedCredit($appointment); - } + if (!$penalty->creditRefundable) { + $this->revokeRefundedCredit($appointment); + } - $charged = $this->chargePenalty($appointment, $penalty, $actor); + $charged = $this->chargePenalty($appointment, $penalty, $actor); + + $this->recordTimelineEntry($appointment, $actor, $reason); + + return [$released, $charged]; + }, + ); $notified = $this->waitlist->notifyForFreedSlot($appointment); - $this->recordTimelineEntry($appointment, $actor, $reason); - return [ 'appointment_uuid' => $appointment->getUuid(), 'status' => $appointment->getStatus(), diff --git a/src/Package/Service/CreditLedgerService.php b/src/Package/Service/CreditLedgerService.php index 96d8d370..87d32cf6 100644 --- a/src/Package/Service/CreditLedgerService.php +++ b/src/Package/Service/CreditLedgerService.php @@ -10,8 +10,10 @@ use App\Package\Entity\SessionCreditLedger; use App\Package\Repository\SessionCreditLedgerRepository; use App\Shared\Event\DomainEventPublisher; use App\Shared\Event\DomainEvents; +use Doctrine\DBAL\Exception\UniqueConstraintViolationException; use Doctrine\ORM\EntityManagerInterface; use Doctrine\DBAL\LockMode; +use Doctrine\Persistence\ManagerRegistry; /** * تنها نویسندهٔ دفتر اعتبار. @@ -25,6 +27,7 @@ final class CreditLedgerService public function __construct( private readonly SessionCreditLedgerRepository $ledger, private readonly DomainEventPublisher $events, + private readonly ManagerRegistry $registry, private readonly EntityManagerInterface $em, ) {} @@ -66,6 +69,23 @@ final class CreditLedgerService * ناچیز و سادگی‌اش برنده است. */ public function consume(PatientPackage $package, Appointment $appointment, ?ServiceItem $service = null): bool + { + try { + return $this->consumeOnce($package, $appointment, $service); + } catch (UniqueConstraintViolationException) { + // دو درخواست هم‌زمان برای یک نوبت: کلید یکتا دومی را رد کرد و همین درست + // است — یک جلسه خورده شده. + // + // ولی Doctrine روی نقض کلید **خودِ EntityManager را می‌بندد**، و مدیرِ بسته + // بقیهٔ همین request را هم می‌سوزاند. بازنشانی رجیستری تنها راه زنده ماندن + // است؛ بدون آن، «مصرف تکراری» به یک خطای ۵۰۰ بی‌ربط تبدیل می‌شد. + $this->registry->resetManager(); + + return true; + } + } + + private function consumeOnce(PatientPackage $package, Appointment $appointment, ?ServiceItem $service): bool { // قفل بدون تراکنش معنا ندارد؛ خواندن و نوشتن باید در یک واحد اتمی باشند // وگرنه دو درخواست هم‌زمان هر دو ماندهٔ ۱ را می‌بینند. @@ -76,10 +96,11 @@ final class CreditLedgerService return false; } - // همین نوبت قبلاً مصرف کرده؟ `confirm` idempotent است و اجرای دومش نباید - // جلسهٔ دوم بخورد. بررسی **پیش از** درج است نه گرفتنِ استثنا: نقض کلید - // یکتا در Doctrine خودِ EntityManager را می‌بندد و بقیهٔ همان request را - // هم می‌سوزاند. کلید یکتا آخرین خط دفاع می‌ماند، نه مسیر عادی. + // `confirm` idempotent است و اجرای دومش نباید جلسهٔ دوم بخورد. + // + // بررسی پیش از درج **تنها** تکیه‌گاه نیست: بین این خواندن و آن نوشتن هنوز + // یک پنجرهٔ رقابت هست و تنها چیزی که واقعاً می‌بندد کلید یکتاست. پس هر دو + // را داریم — بررسی برای مسیر عادی، و `catch` برای رقابت واقعی. if ($this->ledger->findForAppointment($appointment, SessionCreditLedger::KIND_CONSUME) !== null) { return true; } diff --git a/tests/Cancellation/CancellationTest.php b/tests/Cancellation/CancellationTest.php index 9eb7f4aa..45d38930 100644 --- a/tests/Cancellation/CancellationTest.php +++ b/tests/Cancellation/CancellationTest.php @@ -447,6 +447,80 @@ class CancellationTest extends ApiTestCase self::assertSame(200, $this->responseCode()); } + /** + * ⭐ شکست اطلاع‌رسانی نباید لغو را برگرداند. + * + * حالا که همهٔ نوشتن‌ها در یک تراکنش‌اند، این سؤال جدی است: اگر پیامک **داخل** آن + * بلوک بود، یک خطای سرویس پیامک ظرفیت آزادشده را پس می‌گرفت و بیمار هم نوبت + * نداشت هم وقتش را. اطلاع‌رسانی عمداً بعد از commit است و این تست همان را پین + * می‌کند: با یک notifier که همیشه می‌ترکد، لغو باز هم کامل انجام می‌شود. + */ + public function testAFailingNotifierDoesNotUndoTheCancellation(): void + { + [$user, $section, , $doctor, $patient] = $this->clinicWithPatient(); + $clinicId = (int) $patient->getEntityId(); + $service = $this->service($section); + + $appointment = $this->appointment($doctor, $patient, $service, $clinicId, 48, 4_000_000, 4_000_000); + $uuid = $appointment->getUuid(); + + // برای اینکه اطلاع‌رسانی واقعاً به بیمار برسد، باید کسی در لیست انتظار باشد. + $waiting = $this->createUser(['ROLE_USER']); + $record = new PatientRecord('clinic', $clinicId, $waiting, 'clinic', $clinicId); + $this->em->persist($record); + $this->em->flush(); + + $entry = new \App\Waitlist\Entity\WaitlistEntry( + $record, + $this->em->getRepository(ServiceItem::class)->find($service->getId()), + $appointment->getSlotStart() - 86400, + $appointment->getSlotStart() + 86400, + ); + $this->em->persist($entry); + $this->em->flush(); + + // سرویس پیامکی که همیشه می‌ترکد — همان چیزی که در تولید یک قطعی است. + static::getContainer()->set( + \App\Sms\Service\SmsService::class, + new class extends \App\Sms\Service\SmsService { + public function __construct() {} + + public function dispatchAsync( + string $mobile, + string $message, + string $provider = 'kavenegar', + ?string $templateUuid = null, + array $templateVars = [], + ?string $templateCode = null, + string $tag = \App\Sms\Entity\SmsLog::TAG_GLOBAL, + ): void { + throw new \RuntimeException('sms provider down'); + } + }, + ); + + $threw = false; + + try { + static::getContainer()->get(\App\Cancellation\Service\CancellationService::class) + ->cancel($appointment, Appointment::STATUS_CANCELLED_BY_USER, $user); + } catch (\RuntimeException) { + $threw = true; + } + + self::assertTrue($threw, 'خطای اطلاع‌رسانی بالا می‌آید — پنهانش نمی‌کنیم'); + + // ولی خودِ لغو commit شده است. + $this->em->clear(); + $reloaded = $this->em->getRepository(Appointment::class)->findOneBy(['uuid' => $uuid]); + + self::assertSame( + Appointment::STATUS_CANCELLED_BY_USER, + $reloaded->getStatus(), + 'لغو نباید گروگان سرویس پیامک بماند', + ); + } + public function testAnotherClinicCannotPreviewTheCancellation(): void { [$owner, $section, $address, $doctor, $patient] = $this->clinicWithPatient(); diff --git a/tests/Package/PackageLedgerTest.php b/tests/Package/PackageLedgerTest.php index 9bea66ba..5f58f5d0 100644 --- a/tests/Package/PackageLedgerTest.php +++ b/tests/Package/PackageLedgerTest.php @@ -254,6 +254,53 @@ class PackageLedgerTest extends ApiTestCase self::assertSame(5, $this->ledgerService()->balance($entity), 'جلسه پس گرفته شد'); } + /** + * ⭐ رقابت واقعی: ردیف `consume` از یک اتصال دیگر درج می‌شود و بعد سرویس تلاش + * می‌کند همان را بنویسد. + * + * بررسی پیش از درج این پنجره را نمی‌بندد؛ فقط کلید یکتا می‌بندد. و چون Doctrine روی + * نقض کلید `EntityManager` را می‌بندد، بدون بازنشانیِ رجیستری این حالت به یک ۵۰۰ + * بی‌ربط تبدیل می‌شد — نه یک «قبلاً مصرف شده». + */ + public function testAConcurrentConsumeIsAbsorbedWithoutBurningTheRequest(): void + { + [$user, $section, $address, $doctor, $patient] = $this->clinicWithPatient(); + $service = $this->service($section, 'لیزر'); + + $sold = $this->sell($user, $patient, $this->definePackage($user, $service)['uuid']); + $appointment = $this->reload($this->appointment($doctor, $patient, $service, (int) $address->getClinicId())); + + $package = static::getContainer()->get(PatientPackageRepository::class)->findByUuid($sold['uuid']); + + // اتصال جدا = «درخواست دیگر». ردیف مصرف را پشت سرِ سرویس درج می‌کند. + $other = \Doctrine\DBAL\DriverManager::getConnection($this->em->getConnection()->getParams()); + + try { + $other->insert('session_credit_ledger', [ + 'patient_package_id' => $package->getId(), + 'appointment_id' => $appointment->getId(), + 'kind' => 'consume', + 'delta' => -1, + 'created_at' => time(), + 'entity_type' => $package->getEntityType(), + 'entity_id' => $package->getEntityId(), + 'uuid' => \Symfony\Component\Uid\Uuid::v4()->toRfc4122(), + ]); + } finally { + $other->close(); + } + + // سرویس همان مصرف را دوباره تلاش می‌کند: باید `true` بدهد، نه خطا. + self::assertTrue($this->consumption()->consumeFor($this->reload($appointment))); + + // و مهم‌تر: مدیر هنوز زنده است و کارِ بعدی همین request انجام می‌شود. + $em = static::getContainer()->get(\Doctrine\ORM\EntityManagerInterface::class); + self::assertTrue($em->isOpen(), 'EntityManager نباید بعد از نقض کلید بسته بماند'); + + $fresh = $em->getRepository(\App\Package\Entity\PatientPackage::class)->findOneBy(['uuid' => $sold['uuid']]); + self::assertSame(5, $this->ledgerService()->balance($fresh), 'فقط یک جلسه خورده شود'); + } + /** `confirm` idempotent است؛ اجرای دومش نباید جلسهٔ دوم بخورد. */ public function testConsumingTwiceForTheSameAppointmentTakesOnlyOneSession(): void {