Files
clinicpro/tests/Package/PackageLedgerTest.php
T
hamedandClaude Opus 5 b3c331f0cb perf(reports): read every resource's calendar in one batch, and close the owed tests
Writing the query-count test that task 14 owed showed the growth was real: one
resource cost 10 queries, six cost 33 — about five per resource, because the
available-minutes figure walked each resource's calendar on its own.

Holidays, tenant overrides and branch hours are identical for every resource in
a report, so they now load once outside the loop; shifts and exceptions load for
all resources in one query each. The batched path is a new method rather than a
change to rawAvailability, which the booking engine also calls. The test pins
the shape of the growth, not an exact count.

Also landed:

- app:segment:seed-templates with beauty, dental and physio presets. Building
  four segments and their requirements by hand is the first thing a new clinic
  must do and the most tedious; this gives them something to edit instead of an
  empty page. It refuses to touch a service that already has segments unless
  --force, and it will not invent resource types the tenant never defined.
- book-all is all-or-nothing, proven rather than asserted: with a calendar open
  one day a week and a 1-2 day protocol gap, session one finds a slot and
  session two cannot, and every session must come back planned.
- credit_refundable: false takes the credit back with a negative adjustment and
  deletes nothing — the ledger stays append-only.
- the segments editor has frontend tests, including that it sends back what the
  user sees and renders read-only without the permission.

useBranches now returns [] for a non-array payload instead of throwing
"branches.map is not a function" and taking the page down with it.

BookingLocationsScanTest built a Clinic around a Doctor loaded from a different
manager, which Doctrine treats as a new entity; it flushed fine most runs and
failed on cascade in others. It now loads the doctor from the same manager.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-01 14:22:22 +03:30

514 lines
23 KiB
PHP

<?php
namespace App\Tests\Package;
use App\Appointment\Entity\Appointment;
use App\Auth\Entity\User;
use App\Clinic\Entity\Clinic;
use App\ClinicService\Entity\ServiceItem;
use App\ClinicService\Entity\ServiceSection;
use App\Doctor\Entity\Doctor;
use App\Doctor\Entity\DoctorAddress;
use App\Package\Entity\SessionCreditLedger;
use App\Package\Repository\PatientPackageRepository;
use App\Package\Service\CreditLedgerService;
use App\Patient\Entity\PatientRecord;
use App\Tests\ApiTestCase;
/**
* پکیج و دفتر اعتبار جلسات — تسک ۱۱.
*
* محور همهٔ تست‌ها یک جمله از مستند است: «اعتبار را به صورت دفتر حساب نگه می‌داریم،
* نه یک عدد شمارنده.» پس مانده هیچ‌جا ذخیره نمی‌شود و هر تغییر یک ردیف است.
*/
class PackageLedgerTest extends ApiTestCase
{
private int $slotCursor = 0;
/** @return array{0: User, 1: ServiceSection, 2: DoctorAddress, 3: Doctor, 4: PatientRecord} */
private function clinicWithPatient(): array
{
$user = $this->createUser(['ROLE_USER', 'ROLE_CLINIC']);
$clinic = new Clinic($user);
$clinic->setName('کلینیک پکیج');
$this->em->persist($clinic);
$this->em->flush();
$section = new ServiceSection('clinic', $clinic->getId(), 'لیزر');
$this->em->persist($section);
$address = DoctorAddress::forClinic($clinic->getId());
$address->setName('شعبهٔ مرکزی');
$this->em->persist($address);
$doctorUser = $this->createUser(['ROLE_USER', 'ROLE_DOCTOR']);
$doctor = new Doctor($doctorUser, 'دکتر پکیج');
$this->em->persist($doctor);
$patientUser = $this->createUser(['ROLE_USER']);
$patient = new PatientRecord('clinic', (int) $clinic->getId(), $patientUser, 'clinic', (int) $clinic->getId());
$this->em->persist($patient);
$this->em->flush();
return [$user, $section, $address, $doctor, $patient];
}
private function service(ServiceSection $section, string $name, int $price = 5_000_000): ServiceItem
{
$item = new ServiceItem($section, $name);
$item->setSoloDurationMinutes(20);
$item->setPriceRials($price);
$this->em->persist($item);
$this->em->flush();
return $item;
}
/** @param array<string, mixed> $extra */
private function definePackage(User $user, ServiceItem $service, int $sessions = 6, array $extra = []): array
{
$body = $this->authJson('POST', '/api/v1/packages', $user, $extra + [
'name' => '۶ جلسه لیزر فول‌بادی',
'session_count' => $sessions,
'price_rials' => 25_000_000,
'service_uuids' => [$service->getUuid()],
]);
self::assertSame(201, $this->responseCode(), json_encode($body, JSON_UNESCAPED_UNICODE));
return $body['data'];
}
private function sell(User $user, PatientRecord $patient, string $packageUuid): array
{
$body = $this->authJson('POST', "/api/v1/patient/{$patient->getUuid()}/package", $user, [
'package_uuid' => $packageUuid,
]);
self::assertSame(201, $this->responseCode(), json_encode($body, JSON_UNESCAPED_UNICODE));
return $body['data'];
}
/**
* هر درخواست HTTP کرنل را ری‌بوت می‌کند و EntityManager تازه می‌شود، پس entity های
* قبلی detached اند و باید دوباره خوانده شوند.
*/
private function appointment(Doctor $doctor, PatientRecord $patient, ServiceItem $service, int $clinicId): Appointment
{
// یک اسلات یکتا per فراخوانی: پزشک کلید یکتای (doctor, slot_start) دارد.
$start = time() + 86400 + (++$this->slotCursor) * 3600;
$doctor = $this->em->getRepository(Doctor::class)->find($doctor->getId());
$patient = $this->em->getRepository(PatientRecord::class)->find($patient->getId());
$service = $this->em->getRepository(ServiceItem::class)->find($service->getId());
$appointment = new Appointment($doctor, $patient->getUser(), $start, $start + 1200);
$appointment->assignTenantPair('clinic', $clinicId);
$appointment->setServiceItem($service);
$appointment->setPatientName('بیمار پکیج');
$this->em->persist($appointment);
$this->em->flush();
return $appointment;
}
private function ledgerService(): CreditLedgerService
{
return static::getContainer()->get(CreditLedgerService::class);
}
/**
* سرویس‌های کانتینر با EntityManager خودشان کار می‌کنند؛ entity ساخته‌شده در تست
* باید از همان EM دوباره خوانده شود وگرنه «موجودیت جدیدِ persist نشده» می‌شود.
*/
private function reload(Appointment $appointment): Appointment
{
return static::getContainer()->get(\Doctrine\ORM\EntityManagerInterface::class)
->getRepository(Appointment::class)
->find($appointment->getId());
}
private function consumption(): \App\Package\Service\PackageConsumptionService
{
return static::getContainer()->get(\App\Package\Service\PackageConsumptionService::class);
}
// ── تعریف و فروش ────────────────────────────────────────────────────────
public function testSellingAPackageOpensTheLedgerWithItsSessionCount(): void
{
[$user, $section, , , $patient] = $this->clinicWithPatient();
$service = $this->service($section, 'لیزر فول‌بادی');
$package = $this->definePackage($user, $service);
$sold = $this->sell($user, $patient, $package['uuid']);
self::assertSame(6, $sold['balance']);
$list = $this->authJson('GET', "/api/v1/patient/{$patient->getUuid()}/packages", $user);
self::assertSame(6, $list['data'][0]['balance']);
$ledger = $this->authJson('GET', "/api/v1/patient-package/{$sold['uuid']}/ledger", $user);
self::assertCount(1, $ledger['data']['rows']);
self::assertSame('purchase', $ledger['data']['rows'][0]['kind']);
self::assertSame(6, $ledger['data']['rows'][0]['delta']);
self::assertSame(6, $ledger['data']['rows'][0]['running_balance']);
}
/** پکیجی که هیچ سرویسی را پوشش نمی‌دهد هرگز قابل مصرف نیست. */
public function testAPackageWithoutServicesIsRejected(): void
{
[$user] = $this->clinicWithPatient();
$this->authJson('POST', '/api/v1/packages', $user, [
'name' => 'پکیج بی‌سرویس',
'session_count' => 3,
'price_rials' => 1_000_000,
'service_uuids' => [],
]);
self::assertSame(422, $this->responseCode());
}
/** ⭐ مانده باید محاسبه شود، نه ذخیره — همین جلوی «بهینه‌سازی» شش ماه بعد را می‌گیرد. */
public function testNoStoredBalanceColumnExists(): void
{
$columns = $this->em->getConnection()
->createSchemaManager()
->listTableColumns('patient_packages');
$names = array_map(static fn ($c): string => strtolower($c->getName()), $columns);
foreach (['remaining', 'remaining_sessions', 'used_count', 'balance'] as $forbidden) {
self::assertNotContains($forbidden, $names, 'مانده باید از دفتر محاسبه شود، نه ذخیره');
}
}
// ── مصرف و بازگشت ───────────────────────────────────────────────────────
public function testConsumingLeavesARowAndCancellingAddsAnotherOne(): void
{
[$user, $section, $address, $doctor, $patient] = $this->clinicWithPatient();
$service = $this->service($section, 'لیزر');
$package = $this->definePackage($user, $service);
$sold = $this->sell($user, $patient, $package['uuid']);
$appointment = $this->appointment($doctor, $patient, $service, (int) $address->getClinicId());
$appointment = $this->reload($appointment);
self::assertTrue($this->consumption()->consumeFor($appointment));
$entity = static::getContainer()->get(PatientPackageRepository::class)->findByUuid($sold['uuid']);
self::assertSame(5, $this->ledgerService()->balance($entity));
self::assertTrue($this->ledgerService()->refund($appointment));
self::assertSame(6, $this->ledgerService()->balance($entity));
// ردیف `consume` **حذف نمی‌شود** — دفتر append-only است.
$ledger = $this->authJson('GET', "/api/v1/patient-package/{$sold['uuid']}/ledger", $user);
$kinds = array_column($ledger['data']['rows'], 'kind');
self::assertSame(['purchase', 'consume', 'refund'], $kinds);
self::assertSame([6, 5, 6], array_column($ledger['data']['rows'], 'running_balance'));
}
/**
* ⭐ `credit_refundable: false` اعتبار برگشته را پس می‌گیرد — **بدون** حذف ردیف.
*
* دفتر append-only است، پس «پس گرفتن» یک ردیف `adjustment` منفی است نه پاک کردن
* `refund`. تاریخچه باید نشان بدهد اعتبار برگشت و بعد طبق سیاست پس گرفته شد.
*/
public function testAPolicyThatDoesNotRefundCreditTakesItBackWithAnAdjustment(): void
{
[$user, $section, $address, $doctor, $patient] = $this->clinicWithPatient();
$service = $this->service($section, 'لیزر');
// نوبت حدود یک روز دیگر است؛ پنجرهٔ ۹۶ ساعته یعنی این لغو **بیرون** بازهٔ رایگان
// نیست بلکه درونِ محدودهٔ جریمه می‌افتد — تنها حالتی که سیاست اعتبار اثر دارد.
$saved = $this->authJson('PUT', '/api/v1/cancellation-policy', $user, [
'free_window_hours' => 96,
'penalty_mode' => 'none',
'credit_refundable' => false,
]);
self::assertSame(200, $this->responseCode(), json_encode($saved, JSON_UNESCAPED_UNICODE));
$sold = $this->sell($user, $patient, $this->definePackage($user, $service)['uuid']);
$appointment = $this->reload($this->appointment($doctor, $patient, $service, (int) $address->getClinicId()));
self::assertTrue($this->consumption()->consumeFor($appointment));
$body = $this->authJson('POST', "/api/v1/appointment/{$appointment->getUuid()}/cancel", $user, ['by' => 'user']);
self::assertSame(200, $this->responseCode(), json_encode($body, JSON_UNESCAPED_UNICODE));
self::assertFalse($body['data']['credit_refundable']);
$ledger = $this->authJson('GET', "/api/v1/patient-package/{$sold['uuid']}/ledger", $user);
$kinds = array_column($ledger['data']['rows'], 'kind');
self::assertSame(['purchase', 'consume', 'refund', 'adjustment'], $kinds, 'هیچ ردیفی حذف نمی‌شود');
$entity = static::getContainer()->get(PatientPackageRepository::class)->findByUuid($sold['uuid']);
self::assertSame(5, $this->ledgerService()->balance($entity), 'جلسه پس گرفته شد');
}
/** `confirm` idempotent است؛ اجرای دومش نباید جلسهٔ دوم بخورد. */
public function testConsumingTwiceForTheSameAppointmentTakesOnlyOneSession(): void
{
[$user, $section, $address, $doctor, $patient] = $this->clinicWithPatient();
$service = $this->service($section, 'لیزر');
$sold = $this->sell($user, $patient, $this->definePackage($user, $service)['uuid']);
$appointment = $this->appointment($doctor, $patient, $service, (int) $address->getClinicId());
$appointment = $this->reload($appointment);
$this->consumption()->consumeFor($appointment);
$this->consumption()->consumeFor($appointment);
$entity = static::getContainer()->get(PatientPackageRepository::class)->findByUuid($sold['uuid']);
self::assertSame(5, $this->ledgerService()->balance($entity));
}
/** ماندهٔ صفر خطا نیست: بیمار نقدی می‌پردازد. */
public function testAnEmptyPackageIsSimplyNotApplied(): void
{
[$user, $section, $address, $doctor, $patient] = $this->clinicWithPatient();
$service = $this->service($section, 'لیزر');
$sold = $this->sell($user, $patient, $this->definePackage($user, $service, 1)['uuid']);
$first = $this->reload($this->appointment($doctor, $patient, $service, (int) $address->getClinicId()));
$second = $this->reload($this->appointment($doctor, $patient, $service, (int) $address->getClinicId()));
self::assertTrue($this->consumption()->consumeFor($first));
self::assertFalse($this->consumption()->consumeFor($second), 'ماندهٔ صفر باید بی‌سروصدا رد شود');
$entity = static::getContainer()->get(PatientPackageRepository::class)->findByUuid($sold['uuid']);
self::assertSame(0, $this->ledgerService()->balance($entity), 'مانده هرگز منفی نمی‌شود');
}
// ── قیمت ────────────────────────────────────────────────────────────────
public function testQuoteAnnouncesThePackageWithoutConsumingIt(): void
{
[$user, $section, $address, , $patient] = $this->clinicWithPatient();
$service = $this->service($section, 'لیزر', 5_000_000);
$sold = $this->sell($user, $patient, $this->definePackage($user, $service)['uuid']);
$quote = $this->authJson('POST', '/api/v1/pricing/quote', $user, [
'service_uuid' => $service->getUuid(),
'branch_uuid' => $address->getUuid(),
'patient_uuid' => $patient->getUuid(),
]);
self::assertSame(200, $this->responseCode(), json_encode($quote, JSON_UNESCAPED_UNICODE));
self::assertTrue($quote['data']['package_will_be_consumed']);
self::assertSame(0, $quote['data']['final_rials']);
// پیش‌نمایش هرگز مصرف نمی‌کند؛ وگرنه هر رفرش یک جلسه می‌خورد.
$this->authJson('POST', '/api/v1/pricing/quote', $user, [
'service_uuid' => $service->getUuid(),
'branch_uuid' => $address->getUuid(),
'patient_uuid' => $patient->getUuid(),
]);
$entity = static::getContainer()->get(PatientPackageRepository::class)->findByUuid($sold['uuid']);
self::assertSame(6, $this->ledgerService()->balance($entity));
}
public function testQuoteWithoutAPatientChargesTheFullPrice(): void
{
[$user, $section, $address, , $patient] = $this->clinicWithPatient();
$service = $this->service($section, 'لیزر', 5_000_000);
$this->sell($user, $patient, $this->definePackage($user, $service)['uuid']);
$quote = $this->authJson('POST', '/api/v1/pricing/quote', $user, [
'service_uuid' => $service->getUuid(),
'branch_uuid' => $address->getUuid(),
]);
self::assertFalse($quote['data']['package_will_be_consumed']);
self::assertSame(5_000_000, $quote['data']['final_rials']);
}
// ── FIFO و انقضا ────────────────────────────────────────────────────────
/** قدیمی‌ترین اول، چون به انقضا نزدیک‌تر است. */
public function testTheOldestUnexpiredPackageIsUsedFirst(): void
{
[$user, $section, , , $patient] = $this->clinicWithPatient();
$service = $this->service($section, 'لیزر');
$definition = $this->definePackage($user, $service);
$older = $this->sell($user, $patient, $definition['uuid']);
$newer = $this->sell($user, $patient, $definition['uuid']);
$repo = static::getContainer()->get(PatientPackageRepository::class);
$olderE = $repo->findByUuid($older['uuid']);
// خرید دوم را عمداً تازه‌تر می‌کنیم تا ترتیب قطعی باشد.
$this->em->getConnection()->executeStatement(
'UPDATE patient_packages SET purchased_at = purchased_at + 100 WHERE uuid = ?',
[$newer['uuid']],
);
$this->em->clear();
$chosen = $this->consumption()->firstUsable(
$this->em->getRepository(PatientRecord::class)->find($patient->getId()),
$this->em->getRepository(ServiceItem::class)->find($service->getId()),
);
self::assertSame($olderE->getUuid(), $chosen?->getUuid());
}
public function testAnExpiredPackageShowsZeroBalanceButKeepsItsLedger(): void
{
[$user, $section, , , $patient] = $this->clinicWithPatient();
$service = $this->service($section, 'لیزر');
$sold = $this->sell($user, $patient, $this->definePackage($user, $service)['uuid']);
$this->em->getConnection()->executeStatement(
'UPDATE patient_packages SET valid_to = ? WHERE uuid = ?',
[time() - 86400, $sold['uuid']],
);
$this->em->clear();
$list = $this->authJson('GET', "/api/v1/patient/{$patient->getUuid()}/packages", $user);
self::assertTrue($list['data'][0]['expired']);
self::assertSame(0, $list['data'][0]['balance']);
// دفتر دست‌نخورده است: «۶ جلسه‌ام چه شد؟» هنوز جواب دارد.
$ledger = $this->authJson('GET', "/api/v1/patient-package/{$sold['uuid']}/ledger", $user);
self::assertSame(6, $ledger['data']['rows'][0]['running_balance']);
}
public function testExpiryCommandWritesTheClosingRow(): void
{
[$user, $section, , , $patient] = $this->clinicWithPatient();
$service = $this->service($section, 'لیزر');
$sold = $this->sell($user, $patient, $this->definePackage($user, $service)['uuid']);
$this->em->getConnection()->executeStatement(
'UPDATE patient_packages SET valid_to = ? WHERE uuid = ?',
[time() - 86400, $sold['uuid']],
);
$this->em->clear();
$command = static::getContainer()->get(\App\Package\Command\ExpirePackagesCommand::class);
$tester = new \Symfony\Component\Console\Tester\CommandTester($command);
$tester->execute([]);
$ledger = $this->authJson('GET', "/api/v1/patient-package/{$sold['uuid']}/ledger", $user);
$kinds = array_column($ledger['data']['rows'], 'kind');
self::assertSame(['purchase', 'expiry'], $kinds);
self::assertSame(-6, $ledger['data']['rows'][1]['delta']);
self::assertSame(0, $ledger['data']['rows'][1]['running_balance']);
}
// ── اصلاح دستی و جداسازی محیط ───────────────────────────────────────────
public function testAdjustmentNeedsAReasonAndIsRecorded(): void
{
[$user, $section, , , $patient] = $this->clinicWithPatient();
$service = $this->service($section, 'لیزر');
$sold = $this->sell($user, $patient, $this->definePackage($user, $service)['uuid']);
$this->authJson('POST', "/api/v1/patient-package/{$sold['uuid']}/adjust", $user, ['delta' => 1]);
self::assertSame(422, $this->responseCode(), 'اصلاح بدون دلیل نباید پذیرفته شود');
$this->authJson('POST', "/api/v1/patient-package/{$sold['uuid']}/adjust", $user, [
'delta' => 2,
'reason' => 'جبران جلسهٔ لغوشده توسط کلینیک',
]);
self::assertSame(201, $this->responseCode());
$ledger = $this->authJson('GET', "/api/v1/patient-package/{$sold['uuid']}/ledger", $user);
$row = $ledger['data']['rows'][1];
self::assertSame('adjustment', $row['kind']);
self::assertSame(2, $row['delta']);
self::assertSame('جبران جلسهٔ لغوشده توسط کلینیک', $row['reason']);
self::assertNotNull($row['created_by']);
self::assertSame(8, $row['running_balance']);
}
public function testAdjustmentCannotDriveTheBalanceNegative(): void
{
[$user, $section, , , $patient] = $this->clinicWithPatient();
$service = $this->service($section, 'لیزر');
$sold = $this->sell($user, $patient, $this->definePackage($user, $service)['uuid']);
$this->authJson('POST', "/api/v1/patient-package/{$sold['uuid']}/adjust", $user, [
'delta' => -10,
'reason' => 'اشتباه اپراتور',
]);
self::assertSame(422, $this->responseCode());
}
public function testAnotherClinicCannotSeeOrTouchThePackage(): void
{
[$owner, $section, , , $patient] = $this->clinicWithPatient();
[$other] = $this->clinicWithPatient();
$service = $this->service($section, 'لیزر');
$sold = $this->sell($owner, $patient, $this->definePackage($owner, $service)['uuid']);
$this->authJson('GET', "/api/v1/patient-package/{$sold['uuid']}/ledger", $other);
self::assertSame(404, $this->responseCode());
$this->authJson('POST', "/api/v1/patient-package/{$sold['uuid']}/adjust", $other, [
'delta' => 5,
'reason' => 'تلاش از محیط دیگر',
]);
self::assertSame(404, $this->responseCode());
}
/** منشی نباید بتواند اعتبار را دستی عوض کند. */
public function testASecretaryCannotAdjustTheLedger(): void
{
[$owner, $section, , , $patient] = $this->clinicWithPatient();
$service = $this->service($section, 'لیزر');
$sold = $this->sell($owner, $patient, $this->definePackage($owner, $service)['uuid']);
$secretary = $this->createUser(['ROLE_USER', 'ROLE_SECRETARY']);
$this->authJson('POST', "/api/v1/patient-package/{$sold['uuid']}/adjust", $secretary, [
'delta' => 5,
'reason' => 'تلاش منشی',
]);
self::assertContains($this->responseCode(), [403, 404]);
}
public function testLedgerRowsNeverHaveAZeroDelta(): void
{
[$user, $section, , , $patient] = $this->clinicWithPatient();
$service = $this->service($section, 'لیزر');
$sold = $this->sell($user, $patient, $this->definePackage($user, $service)['uuid']);
$this->authJson('POST', "/api/v1/patient-package/{$sold['uuid']}/adjust", $user, [
'delta' => 0,
'reason' => 'بی‌اثر',
]);
self::assertSame(422, $this->responseCode());
$entity = static::getContainer()->get(PatientPackageRepository::class)->findByUuid($sold['uuid']);
self::expectException(\InvalidArgumentException::class);
new SessionCreditLedger($entity, SessionCreditLedger::KIND_ADJUSTMENT, 0);
}
}