Phase 2 of the tenant-marking series. appointments, weekly_schedules and date_overrides kept their environment implicit in a nullable clinic_id, so every query that wanted "this environment's rows" had to rebuild clinic_id IS NULL ? doctor : clinic itself. The two calendar tables also depended on a MariaDB-only generated column, clinic_key = IFNULL(clinic_id, 0), purely to make a unique key work across NULLs. All three now carry the (entity_type, entity_id) pair that service_sections, patient_records and clinic_staff already use, via a shared TenantOwnedTrait. The pair is a deliberate denormalisation of clinic_id/doctor_id: the automatic tenant filter and the tenant-leading indexes both need a real column, and neither can be built on an IF() expression. - Unique keys keep doctor_id alongside the pair. A clinic has several doctors and each has their own schedule, so (entity_type, entity_id) alone would reject the second doctor. - clinic_key is gone from both calendar tables. - appointments gained tenant-leading indexes; EXPLAIN on the panel's list query now picks idx_appointments_tenant_slot. Deliberately unchanged, both with the reason already recorded in the code: active_slot_key stays keyed on doctor + slot, since adding the environment would let one doctor be booked in their own practice and a clinic at the same moment. holidays keeps its nullable clinic_id, where NULL means "every environment" rather than "personal practice" — a meaning the pair cannot carry. The migration adds the columns nullable, backfills, aborts if any row is left without an owner, and only then tightens to NOT NULL. It creates each replacement unique index before dropping the old one, so the tables are never left unprotected — MariaDB commits implicitly on DDL, so ordering is the only safety net. It runs its statements through the connection rather than addSql() because the guard has to sit between the backfill and the NOT NULL change. Columns are NOT NULL with no default on purpose: a construction site that forgets assignTenant() fails at flush instead of silently writing entity_id 0, which the phase 4 filter would then hide from everyone. Verified on the dev database: 0 rows without a tenant, 0 personal bookings mismatched against their doctor, 0 clinic bookings mismatched against their clinic. Tests: 819 passing (813 + 6 new in BookingTenantTest). PHPStan clean on every changed file. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
329 lines
14 KiB
PHP
329 lines
14 KiB
PHP
<?php
|
|
|
|
namespace App\Tests\Appointment;
|
|
|
|
use App\Appointment\Entity\Appointment;
|
|
use App\Auth\Entity\User;
|
|
use App\Clinic\Entity\Clinic;
|
|
use App\Doctor\Entity\Doctor;
|
|
use App\Patient\Entity\PatientRecord;
|
|
use App\Patient\Entity\PatientSession;
|
|
use App\Tests\ApiTestCase;
|
|
|
|
/**
|
|
* «ثبتشده» → «قطعی»: POST /api/v1/appointment/{uuid}/confirm.
|
|
*
|
|
* یک عملِ اتمیک — وضعیت نوبت، پروندهٔ همان محیط با سرویسهای نوبت، و پرداخت کامل یا
|
|
* جزئی روی همان مراجعه.
|
|
*/
|
|
class AppointmentConfirmFlowTest extends ApiTestCase
|
|
{
|
|
private function makeDoctor(string $name = 'دکتر تست'): Doctor
|
|
{
|
|
$user = $this->createUser(['ROLE_USER', 'ROLE_DOCTOR']);
|
|
$doctor = new Doctor($user, $name);
|
|
$doctor->setMobileNumber($user->getMobileNumber());
|
|
$this->em->persist($doctor);
|
|
$this->em->flush();
|
|
|
|
return $doctor;
|
|
}
|
|
|
|
/** @return array{0: User, 1: Clinic} */
|
|
private function makeClinicWith(Doctor ...$doctors): array
|
|
{
|
|
$owner = $this->createUser(['ROLE_USER', 'ROLE_CLINIC']);
|
|
$clinic = new Clinic($owner);
|
|
$clinic->setName('کلینیک تست');
|
|
foreach ($doctors as $d) {
|
|
$clinic->getDoctors()->add($d);
|
|
}
|
|
$this->em->persist($clinic);
|
|
$this->em->flush();
|
|
|
|
return [$owner, $clinic];
|
|
}
|
|
|
|
private function makeAppointment(
|
|
Doctor $doctor,
|
|
?Clinic $clinic = null,
|
|
?User $patient = null,
|
|
int $visitPriceRials = 5_000_000,
|
|
): Appointment {
|
|
$patient ??= $this->createUser();
|
|
// اسلات یکتا بهازای هر نوبت: db_test بین اجراها پاک نمیشود.
|
|
$start = strtotime('+30 days') + random_int(0, 500_000) * 7;
|
|
|
|
$appointment = $this->newAppointment($doctor, $patient, $start, $start + 900, $clinic);
|
|
$appointment->setVisitPriceRials($visitPriceRials);
|
|
$this->em->persist($appointment);
|
|
$this->em->flush();
|
|
|
|
return $appointment;
|
|
}
|
|
|
|
private function reload(Appointment $a): Appointment
|
|
{
|
|
$this->em->clear();
|
|
|
|
return $this->em->getRepository(Appointment::class)->find($a->getId());
|
|
}
|
|
|
|
// ── ساخت پنلی «ثبتشده» است، نه قطعی ─────────────────────────────────────
|
|
|
|
public function testPanelBookingIsCreatedPending(): void
|
|
{
|
|
$doctor = $this->makeDoctor();
|
|
$start = strtotime('+40 days') + random_int(0, 500_000) * 7;
|
|
|
|
$res = $this->authJson('POST', '/api/v1/my/appointment', $doctor->getUser(), [
|
|
'doctor_uuid' => $doctor->getUuid(),
|
|
'slot_start' => $start,
|
|
'slot_end' => $start + 1_800,
|
|
'patient_mobile' => '09' . str_pad((string) random_int(0, 999_999_999), 9, '0', STR_PAD_LEFT),
|
|
'patient_name' => 'بیمار تست',
|
|
'patient_national_code' => '00' . str_pad((string) random_int(0, 99_999_999), 8, '0', STR_PAD_LEFT),
|
|
]);
|
|
|
|
self::assertSame(201, $this->responseCode());
|
|
self::assertSame(Appointment::STATUS_PENDING, $res['data']['status']);
|
|
}
|
|
|
|
// ── قطعیکردن: بدون پرداخت / جزئی / کامل ─────────────────────────────────
|
|
|
|
public function testConfirmWithoutPaymentMovesToConfirmedAndOpensSession(): void
|
|
{
|
|
$doctor = $this->makeDoctor();
|
|
$appointment = $this->makeAppointment($doctor);
|
|
|
|
$res = $this->authJson('POST', "/api/v1/appointment/{$appointment->getUuid()}/confirm", $doctor->getUser(), [
|
|
'version' => $appointment->getVersion(),
|
|
]);
|
|
|
|
self::assertSame(200, $this->responseCode());
|
|
self::assertSame(Appointment::STATUS_CONFIRMED, $res['data']['appointment']['status']);
|
|
self::assertSame(5_000_000, $res['data']['session']['final_price_rials']);
|
|
self::assertSame(0, $res['data']['session']['paid_total_rials']);
|
|
self::assertSame(5_000_000, $res['data']['session']['remaining_rials']);
|
|
self::assertFalse($res['data']['session']['is_paid']);
|
|
}
|
|
|
|
public function testConfirmWithPartialPaymentLeavesRemainder(): void
|
|
{
|
|
$doctor = $this->makeDoctor();
|
|
$appointment = $this->makeAppointment($doctor);
|
|
|
|
$res = $this->authJson('POST', "/api/v1/appointment/{$appointment->getUuid()}/confirm", $doctor->getUser(), [
|
|
'version' => $appointment->getVersion(),
|
|
'payments' => [['method' => 'cash', 'amount_rials' => 2_000_000]],
|
|
]);
|
|
|
|
self::assertSame(200, $this->responseCode());
|
|
self::assertSame(2_000_000, $res['data']['session']['paid_total_rials']);
|
|
self::assertSame(3_000_000, $res['data']['session']['remaining_rials']);
|
|
self::assertFalse($res['data']['session']['is_paid']);
|
|
}
|
|
|
|
public function testConfirmWithFullPaymentSettlesSession(): void
|
|
{
|
|
$doctor = $this->makeDoctor();
|
|
$appointment = $this->makeAppointment($doctor);
|
|
|
|
$res = $this->authJson('POST', "/api/v1/appointment/{$appointment->getUuid()}/confirm", $doctor->getUser(), [
|
|
'version' => $appointment->getVersion(),
|
|
'payments' => [['method' => 'pos', 'amount_rials' => 5_000_000]],
|
|
]);
|
|
|
|
self::assertSame(200, $this->responseCode());
|
|
self::assertSame(0, $res['data']['session']['remaining_rials']);
|
|
self::assertTrue($res['data']['session']['is_paid']);
|
|
}
|
|
|
|
public function testConfirmAcceptsSeveralPaymentRows(): void
|
|
{
|
|
$doctor = $this->makeDoctor();
|
|
$appointment = $this->makeAppointment($doctor);
|
|
|
|
$res = $this->authJson('POST', "/api/v1/appointment/{$appointment->getUuid()}/confirm", $doctor->getUser(), [
|
|
'version' => $appointment->getVersion(),
|
|
'payments' => [
|
|
['method' => 'cash', 'amount_rials' => 1_000_000],
|
|
['method' => 'pos', 'amount_rials' => 4_000_000],
|
|
],
|
|
]);
|
|
|
|
self::assertSame(200, $this->responseCode());
|
|
self::assertSame(5_000_000, $res['data']['session']['paid_total_rials']);
|
|
self::assertTrue($res['data']['session']['is_paid']);
|
|
}
|
|
|
|
public function testConfirmPersistsPerPaymentMethodDetails(): void
|
|
{
|
|
$doctor = $this->makeDoctor();
|
|
$appointment = $this->makeAppointment($doctor);
|
|
|
|
$res = $this->authJson('POST', "/api/v1/appointment/{$appointment->getUuid()}/confirm", $doctor->getUser(), [
|
|
'version' => $appointment->getVersion(),
|
|
'payments' => [
|
|
['method' => 'pos', 'amount_rials' => 3_000_000, 'payment_method_uuid' => 'pos-uuid-1', 'reference' => 'TRX-42'],
|
|
['method' => 'cash', 'amount_rials' => 2_000_000],
|
|
],
|
|
]);
|
|
self::assertSame(200, $this->responseCode());
|
|
|
|
$session = $this->em->getRepository(PatientSession::class)->findOneBy(['uuid' => $res['data']['session']['uuid']]);
|
|
$payments = $this->em->getRepository(\App\Patient\Entity\SessionPayment::class)->findBy(['session' => $session]);
|
|
self::assertCount(2, $payments);
|
|
|
|
$pos = array_values(array_filter($payments, fn($p) => $p->getMethod() === 'pos'))[0];
|
|
$cash = array_values(array_filter($payments, fn($p) => $p->getMethod() === 'cash'))[0];
|
|
|
|
self::assertSame('pos-uuid-1', $pos->getPaymentMethodUuid());
|
|
self::assertSame('TRX-42', $pos->getReference());
|
|
self::assertNull($cash->getPaymentMethodUuid(), 'روش نقدی جزئیاتِ روش ندارد');
|
|
self::assertNull($cash->getReference());
|
|
}
|
|
|
|
// ── پرونده: استفادهٔ مجدد یا ساخت ────────────────────────────────────────
|
|
|
|
public function testConfirmReusesExistingRecordOfSameDoctor(): void
|
|
{
|
|
$doctor = $this->makeDoctor();
|
|
$patient = $this->createUser();
|
|
|
|
$first = $this->makeAppointment($doctor, null, $patient);
|
|
$this->authJson('POST', "/api/v1/appointment/{$first->getUuid()}/confirm", $doctor->getUser(), [
|
|
'version' => $first->getVersion(),
|
|
]);
|
|
self::assertSame(200, $this->responseCode());
|
|
|
|
$second = $this->makeAppointment($doctor, null, $patient);
|
|
$this->authJson('POST', "/api/v1/appointment/{$second->getUuid()}/confirm", $doctor->getUser(), [
|
|
'version' => $second->getVersion(),
|
|
]);
|
|
self::assertSame(200, $this->responseCode());
|
|
|
|
$records = $this->em->getRepository(PatientRecord::class)->findBy([
|
|
'entityType' => 'doctor',
|
|
'entityId' => $doctor->getId(),
|
|
'user' => $patient,
|
|
]);
|
|
|
|
self::assertCount(1, $records, 'پروندهٔ همان پزشک دوباره ساخته نمیشود');
|
|
|
|
$sessions = $this->em->getRepository(PatientSession::class)->findBy(['record' => $records[0]]);
|
|
self::assertCount(2, $sessions, 'هر نوبت مراجعهٔ خودش را دارد');
|
|
}
|
|
|
|
public function testConfirmInClinicFilesUnderClinicRecord(): void
|
|
{
|
|
$doctor = $this->makeDoctor();
|
|
[$owner, $clinic] = $this->makeClinicWith($doctor);
|
|
$patient = $this->createUser();
|
|
$appointment = $this->makeAppointment($doctor, $clinic, $patient);
|
|
|
|
$this->authJson('POST', "/api/v1/appointment/{$appointment->getUuid()}/confirm", $owner, [
|
|
'version' => $appointment->getVersion(),
|
|
]);
|
|
self::assertSame(200, $this->responseCode());
|
|
|
|
$clinicRecord = $this->em->getRepository(PatientRecord::class)->findOneBy([
|
|
'entityType' => 'clinic',
|
|
'entityId' => $clinic->getId(),
|
|
'user' => $patient,
|
|
]);
|
|
$doctorRecord = $this->em->getRepository(PatientRecord::class)->findOneBy([
|
|
'entityType' => 'doctor',
|
|
'entityId' => $doctor->getId(),
|
|
'user' => $patient,
|
|
]);
|
|
|
|
self::assertNotNull($clinicRecord, 'نوبت کلینیکی در پروندهٔ کلینیک مینشیند');
|
|
self::assertNull($doctorRecord, 'و در مطب شخصی پزشک پروندهٔ موازی نمیسازد');
|
|
}
|
|
|
|
// ── خطاها ────────────────────────────────────────────────────────────────
|
|
|
|
public function testPaymentAboveTotalIsRejectedAndNothingIsCommitted(): void
|
|
{
|
|
$doctor = $this->makeDoctor();
|
|
$appointment = $this->makeAppointment($doctor);
|
|
|
|
$this->authJson('POST', "/api/v1/appointment/{$appointment->getUuid()}/confirm", $doctor->getUser(), [
|
|
'version' => $appointment->getVersion(),
|
|
'payments' => [['method' => 'cash', 'amount_rials' => 9_000_000]],
|
|
]);
|
|
|
|
self::assertSame(422, $this->responseCode());
|
|
self::assertSame(Appointment::STATUS_PENDING, $this->reload($appointment)->getStatus(), 'تراکنش برگشته');
|
|
}
|
|
|
|
public function testUnknownPaymentMethodIs422(): void
|
|
{
|
|
$doctor = $this->makeDoctor();
|
|
$appointment = $this->makeAppointment($doctor);
|
|
|
|
$this->authJson('POST', "/api/v1/appointment/{$appointment->getUuid()}/confirm", $doctor->getUser(), [
|
|
'version' => $appointment->getVersion(),
|
|
'payments' => [['method' => 'bitcoin', 'amount_rials' => 1_000]],
|
|
]);
|
|
|
|
self::assertSame(422, $this->responseCode());
|
|
self::assertSame(Appointment::STATUS_PENDING, $this->reload($appointment)->getStatus());
|
|
}
|
|
|
|
public function testStaleVersionIs409(): void
|
|
{
|
|
$doctor = $this->makeDoctor();
|
|
$appointment = $this->makeAppointment($doctor);
|
|
|
|
$this->authJson('POST', "/api/v1/appointment/{$appointment->getUuid()}/confirm", $doctor->getUser(), [
|
|
'version' => $appointment->getVersion() + 5,
|
|
]);
|
|
|
|
self::assertSame(409, $this->responseCode());
|
|
}
|
|
|
|
public function testConfirmingAnAlreadyConfirmedAppointmentIs422(): void
|
|
{
|
|
$doctor = $this->makeDoctor();
|
|
$appointment = $this->makeAppointment($doctor);
|
|
|
|
$this->authJson('POST', "/api/v1/appointment/{$appointment->getUuid()}/confirm", $doctor->getUser(), [
|
|
'version' => $appointment->getVersion(),
|
|
]);
|
|
self::assertSame(200, $this->responseCode());
|
|
|
|
$fresh = $this->reload($appointment);
|
|
$this->authJson('POST', "/api/v1/appointment/{$fresh->getUuid()}/confirm", $doctor->getUser(), [
|
|
'version' => $fresh->getVersion(),
|
|
]);
|
|
|
|
self::assertSame(422, $this->responseCode(), 'confirmed → confirmed گذار مجاز نیست');
|
|
}
|
|
|
|
public function testStrangerCannotConfirm(): void
|
|
{
|
|
$doctor = $this->makeDoctor();
|
|
$appointment = $this->makeAppointment($doctor);
|
|
|
|
$this->authJson('POST', "/api/v1/appointment/{$appointment->getUuid()}/confirm", $this->createUser(), [
|
|
'version' => $appointment->getVersion(),
|
|
]);
|
|
|
|
self::assertSame(403, $this->responseCode());
|
|
}
|
|
|
|
public function testDetailExposesServicePricesForTheModal(): void
|
|
{
|
|
$doctor = $this->makeDoctor();
|
|
$appointment = $this->makeAppointment($doctor);
|
|
|
|
$res = $this->authJson('GET', "/api/v1/appointment/{$appointment->getUuid()}", $doctor->getUser());
|
|
|
|
self::assertSame(200, $this->responseCode());
|
|
self::assertSame(5_000_000, $res['data']['data']['visit_price_rials']);
|
|
self::assertArrayHasKey('service_items', $res['data']['data']);
|
|
}
|
|
}
|