feat(tenant): mark the booking tables with their owning environment

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>
This commit is contained in:
hamed
2026-07-28 11:22:37 +03:30
co-authored by Claude Opus 5
parent 1a7bf53577
commit d53874ff50
42 changed files with 455 additions and 69 deletions
+106
View File
@@ -0,0 +1,106 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Phase 2 of the tenant-marking series: store the owning environment explicitly
* on the booking tables as the (entity_type, entity_id) pair already used by
* service_sections, patient_records and clinic_staff.
*
* Statements run through $this->connection instead of addSql() because the
* backfill has to happen between adding the nullable columns and tightening
* them to NOT NULL — addSql() defers everything to the end of up(), which would
* run the guard before the data exists.
*
* MariaDB commits implicitly on DDL, so this migration cannot roll back as a
* unit. The order below is what keeps it safe: the replacement unique index is
* created before the old one is dropped, so the tables are never left without
* uniqueness protection.
*/
final class Version20260728074119 extends AbstractMigration
{
/** clinic wins when set; NULL means the doctor's own practice. */
private const BACKFILL = "SET entity_type = IF(clinic_id IS NULL, 'doctor', 'clinic'),
entity_id = IFNULL(clinic_id, doctor_id)";
public function getDescription(): string
{
return 'Mark appointments, weekly_schedules and date_overrides with their owning tenant';
}
public function up(Schema $schema): void
{
foreach (['appointments', 'weekly_schedules', 'date_overrides'] as $table) {
$this->connection->executeStatement(
"ALTER TABLE {$table} ADD entity_type VARCHAR(10) NULL, ADD entity_id INT NULL"
);
$this->connection->executeStatement("UPDATE {$table} " . self::BACKFILL);
// Defensive: a clinic_id pointing at a deleted clinic falls back to
// the doctor's own practice rather than aborting the migration.
$this->connection->executeStatement(
"UPDATE {$table} t LEFT JOIN clinics c ON c.id = t.clinic_id
SET t.entity_type = 'doctor', t.entity_id = t.doctor_id
WHERE t.clinic_id IS NOT NULL AND c.id IS NULL"
);
$remaining = (int) $this->connection->fetchOne(
"SELECT COUNT(*) FROM {$table} WHERE entity_type IS NULL OR entity_id IS NULL"
);
$this->abortIf($remaining > 0, "Backfill left {$remaining} rows in {$table} without a tenant.");
$this->connection->executeStatement(
"ALTER TABLE {$table} MODIFY entity_type VARCHAR(10) NOT NULL, MODIFY entity_id INT NOT NULL"
);
}
$this->connection->executeStatement(
'CREATE INDEX idx_appointments_tenant_slot ON appointments (entity_type, entity_id, slot_start)'
);
$this->connection->executeStatement(
'CREATE INDEX idx_appointments_tenant_status ON appointments (entity_type, entity_id, status)'
);
// New unique key first, old one and its generated column afterwards.
$this->connection->executeStatement(
'CREATE UNIQUE INDEX uniq_weekly_schedule_doctor_tenant ON weekly_schedules (doctor_id, entity_type, entity_id)'
);
$this->connection->executeStatement('DROP INDEX idx_weekly_schedules_doctor_clinic ON weekly_schedules');
$this->connection->executeStatement('ALTER TABLE weekly_schedules DROP clinic_key');
$this->connection->executeStatement(
'CREATE UNIQUE INDEX uniq_date_override_doctor_tenant_date ON date_overrides (doctor_id, entity_type, entity_id, date)'
);
$this->connection->executeStatement('DROP INDEX uniq_date_override_doctor_clinic_date ON date_overrides');
$this->connection->executeStatement('ALTER TABLE date_overrides DROP clinic_key');
}
public function down(Schema $schema): void
{
$this->addSql("ALTER TABLE weekly_schedules ADD clinic_key INT AS (IFNULL(clinic_id, 0)) STORED");
$this->addSql('CREATE UNIQUE INDEX idx_weekly_schedules_doctor_clinic ON weekly_schedules (doctor_id, clinic_key)');
$this->addSql('DROP INDEX uniq_weekly_schedule_doctor_tenant ON weekly_schedules');
$this->addSql("ALTER TABLE date_overrides ADD clinic_key INT AS (IFNULL(clinic_id, 0)) STORED");
$this->addSql('CREATE UNIQUE INDEX uniq_date_override_doctor_clinic_date ON date_overrides (doctor_id, clinic_key, date)');
$this->addSql('DROP INDEX uniq_date_override_doctor_tenant_date ON date_overrides');
$this->addSql('DROP INDEX idx_appointments_tenant_slot ON appointments');
$this->addSql('DROP INDEX idx_appointments_tenant_status ON appointments');
foreach (['appointments', 'weekly_schedules', 'date_overrides'] as $table) {
$this->addSql("ALTER TABLE {$table} DROP entity_type, DROP entity_id");
}
}
/** DDL on MariaDB commits implicitly; wrapping up() in a transaction would be a lie. */
public function isTransactional(): bool
{
return false;
}
}
@@ -1018,6 +1018,7 @@ class AdminApiController extends BaseController
} }
$bookingClinic = $this->bookingContext->resolve($doctor, $data['clinic_uuid'] ?? null); $bookingClinic = $this->bookingContext->resolve($doctor, $data['clinic_uuid'] ?? null);
$appointment->setClinic($bookingClinic); $appointment->setClinic($bookingClinic);
$appointment->assignTenant(\App\Shared\Context\EntityContext::forBooking($doctor, $bookingClinic));
$locationId = $this->slotCalculator->resolveSlotLocationId($doctor, $slotStart, $bookingClinic, true); $locationId = $this->slotCalculator->resolveSlotLocationId($doctor, $slotStart, $bookingClinic, true);
if ($locationId !== null) $appointment->setAddressId($locationId); if ($locationId !== null) $appointment->setAddressId($locationId);
@@ -519,6 +519,7 @@ class AppointmentController extends BaseController
// آدرس نوبت از روی session متناظر در برنامه‌ی هفتگی تعیین می‌شود (location_id). // آدرس نوبت از روی session متناظر در برنامه‌ی هفتگی تعیین می‌شود (location_id).
$appointment->setClinic($bookingClinic); $appointment->setClinic($bookingClinic);
$appointment->assignTenant(\App\Shared\Context\EntityContext::forBooking($doctor, $bookingClinic));
$locationId = $this->slotCalculator->resolveSlotLocationId($doctor, $slotStart, $bookingClinic); $locationId = $this->slotCalculator->resolveSlotLocationId($doctor, $slotStart, $bookingClinic);
if ($locationId !== null) { if ($locationId !== null) {
$appointment->setAddressId($locationId); $appointment->setAddressId($locationId);
@@ -152,6 +152,7 @@ class AppointmentSettingsController extends BaseController
$schedule->setSetting($data['schedule'] ?? []); $schedule->setSetting($data['schedule'] ?? []);
} else { } else {
$schedule = new WeeklySchedule($doctor, $data['schedule'] ?? [], $clinic); $schedule = new WeeklySchedule($doctor, $data['schedule'] ?? [], $clinic);
$schedule->assignTenant(EntityContext::forBooking($doctor, $clinic));
} }
if (isset($data['meta']) && is_array($data['meta'])) { if (isset($data['meta']) && is_array($data['meta'])) {
@@ -310,6 +311,7 @@ class AppointmentSettingsController extends BaseController
} }
$override = new DateOverride($doctor, $timestamp, (bool) ($data['active'] ?? false), $clinic); $override = new DateOverride($doctor, $timestamp, (bool) ($data['active'] ?? false), $clinic);
$override->assignTenant(EntityContext::forBooking($doctor, $clinic));
if (isset($data['reason'])) $override->setReason($data['reason']); if (isset($data['reason'])) $override->setReason($data['reason']);
if (isset($data['custom_slots'])) $override->setSetting($data['custom_slots']); if (isset($data['custom_slots'])) $override->setSetting($data['custom_slots']);
@@ -3,6 +3,7 @@
namespace App\Appointment\Controller; namespace App\Appointment\Controller;
use App\Appointment\Entity\Appointment; use App\Appointment\Entity\Appointment;
use App\Shared\Context\EntityContext;
use App\Shared\Constant\ErrorCodes; use App\Shared\Constant\ErrorCodes;
use App\Appointment\Repository\AppointmentRepository; use App\Appointment\Repository\AppointmentRepository;
use App\Appointment\Repository\SlotTakenException; use App\Appointment\Repository\SlotTakenException;
@@ -182,6 +183,7 @@ class MyAppointmentsController extends BaseController
// یعنی مطب شخصی، نه «هر برنامه‌ای که پیدا شد». // یعنی مطب شخصی، نه «هر برنامه‌ای که پیدا شد».
$bookingClinic = $this->bookingContext->resolve($doctor, $data['clinic_uuid'] ?? null); $bookingClinic = $this->bookingContext->resolve($doctor, $data['clinic_uuid'] ?? null);
$appointment->setClinic($bookingClinic); $appointment->setClinic($bookingClinic);
$appointment->assignTenant(EntityContext::forBooking($doctor, $bookingClinic));
$locationId = $this->slotCalculator->resolveSlotLocationId($doctor, $slotStart, $bookingClinic, true); $locationId = $this->slotCalculator->resolveSlotLocationId($doctor, $slotStart, $bookingClinic, true);
if ($locationId !== null) $appointment->setAddressId($locationId); if ($locationId !== null) $appointment->setAddressId($locationId);
+13 -1
View File
@@ -5,6 +5,7 @@ namespace App\Appointment\Entity;
use App\Auth\Entity\User; use App\Auth\Entity\User;
use App\Doctor\Entity\Doctor; use App\Doctor\Entity\Doctor;
use App\Insurance\Enum\ServiceCategory; use App\Insurance\Enum\ServiceCategory;
use App\Shared\Tenant\TenantOwnedTrait;
use Doctrine\Common\Collections\ArrayCollection; use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection; use Doctrine\Common\Collections\Collection;
use Doctrine\ORM\Mapping as ORM; use Doctrine\ORM\Mapping as ORM;
@@ -13,11 +14,22 @@ use Symfony\Component\Uid\Uuid;
#[ORM\Entity(repositoryClass: AppointmentRepository::class)] #[ORM\Entity(repositoryClass: AppointmentRepository::class)]
#[ORM\Table(name: 'appointments')] #[ORM\Table(name: 'appointments')]
// tenant پیشرو — لیست‌های پنل همیشه محیط‌محورند
#[ORM\Index(columns: ['entity_type', 'entity_id', 'slot_start'], name: 'idx_appointments_tenant_slot')]
#[ORM\Index(columns: ['entity_type', 'entity_id', 'status'], name: 'idx_appointments_tenant_status')]
// بدون tenant — عمدی: یکتاییِ اسلات و تقویم سطح پزشک‌اند، نه محیط
#[ORM\Index(columns: ['doctor_id', 'slot_start'], name: 'idx_appointments_doctor_slot')] #[ORM\Index(columns: ['doctor_id', 'slot_start'], name: 'idx_appointments_doctor_slot')]
#[ORM\Index(columns: ['user_id', 'status'], name: 'idx_appointments_user_status')] #[ORM\Index(columns: ['user_id', 'status'], name: 'idx_appointments_user_status')]
#[ORM\Index(columns: ['status', 'expires_at'], name: 'idx_appointments_status_expires')] #[ORM\Index(columns: ['status', 'expires_at'], name: 'idx_appointments_status_expires')]
class Appointment class Appointment
{ {
/**
* محیطِ مالکِ نوبت. denormalization عمدی روی clinic/doctor موجود: فیلترِ خودکارِ
* tenant و ایندکسِ tenant-پیشرو هر دو به ستون واقعی نیاز دارند و با شرطِ
* IF(clinic_id IS NULL, …) ساخته نمی‌شوند.
*/
use TenantOwnedTrait;
// Status machine: pending → confirmed → completed // Status machine: pending → confirmed → completed
// ↘ cancelled_by_doctor / cancelled_by_user // ↘ cancelled_by_doctor / cancelled_by_user
// pending → expired (cron) // pending → expired (cron)
@@ -214,7 +226,7 @@ class Appointment
* while the appointment occupies the slot; null once it is cancelled. * while the appointment occupies the slot; null once it is cancelled.
* *
* کلید عمداً clinic ندارد و فقط doctor+slotStart است: برنامهٔ هفتگی هر محیط * کلید عمداً clinic ندارد و فقط doctor+slotStart است: برنامهٔ هفتگی هر محیط
* جداست (WeeklySchedule با UNIQUE(doctor_id, clinic_key)) و می‌تواند با محیط * جداست (WeeklySchedule با UNIQUE(doctor_id, entity_type, entity_id)) و می‌تواند با محیط
* دیگر هم‌پوشانی داشته باشد، ولی پزشک یک نفر است. افزودن clinic به کلید یعنی * دیگر هم‌پوشانی داشته باشد، ولی پزشک یک نفر است. افزودن clinic به کلید یعنی
* اجازهٔ رزرو هم‌زمان همان پزشک در مطب و کلینیک — نه رفع باگ. * اجازهٔ رزرو هم‌زمان همان پزشک در مطب و کلینیک — نه رفع باگ.
*/ */
+6 -5
View File
@@ -4,6 +4,8 @@ namespace App\Appointment\Entity;
use App\Clinic\Entity\Clinic; use App\Clinic\Entity\Clinic;
use App\Doctor\Entity\Doctor; use App\Doctor\Entity\Doctor;
use App\Shared\Context\EntityContext;
use App\Shared\Tenant\TenantOwnedTrait;
use Doctrine\ORM\Mapping as ORM; use Doctrine\ORM\Mapping as ORM;
use App\Appointment\Repository\DateOverrideRepository; use App\Appointment\Repository\DateOverrideRepository;
use Symfony\Component\Uid\Uuid; use Symfony\Component\Uid\Uuid;
@@ -14,9 +16,12 @@ use Symfony\Component\Uid\Uuid;
*/ */
#[ORM\Entity(repositoryClass: DateOverrideRepository::class)] #[ORM\Entity(repositoryClass: DateOverrideRepository::class)]
#[ORM\Table(name: 'date_overrides')] #[ORM\Table(name: 'date_overrides')]
#[ORM\UniqueConstraint(name: 'uniq_date_override_doctor_clinic_date', columns: ['doctor_id', 'clinic_key', 'date'])] // doctor_id در کلید می‌ماند: چند پزشکِ یک کلینیک هر کدام استثنای روزِ خودشان را دارند.
#[ORM\UniqueConstraint(name: 'uniq_date_override_doctor_tenant_date', columns: ['doctor_id', 'entity_type', 'entity_id', 'date'])]
class DateOverride class DateOverride
{ {
use TenantOwnedTrait;
#[ORM\Id] #[ORM\Id]
#[ORM\GeneratedValue] #[ORM\GeneratedValue]
#[ORM\Column(type: 'integer')] #[ORM\Column(type: 'integer')]
@@ -34,10 +39,6 @@ class DateOverride
#[ORM\JoinColumn(name: 'clinic_id', referencedColumnName: 'id', nullable: true, onDelete: 'CASCADE')] #[ORM\JoinColumn(name: 'clinic_id', referencedColumnName: 'id', nullable: true, onDelete: 'CASCADE')]
private ?Clinic $clinic = null; private ?Clinic $clinic = null;
/** ستون تولیدشده: IFNULL(clinic_id, 0) — تا یکتایی با clinic_id تهی هم برقرار بماند. */
#[ORM\Column(name: 'clinic_key', type: 'integer', insertable: false, updatable: false, generated: 'ALWAYS', columnDefinition: 'INT AS (IFNULL(clinic_id, 0)) STORED')]
private int $clinicKey = 0;
#[ORM\Column(type: 'integer')] #[ORM\Column(type: 'integer')]
private int $date; private int $date;
+4
View File
@@ -12,6 +12,10 @@ use Symfony\Component\Uid\Uuid;
* تعطیلی پزشک. برخلاف WeeklySchedule و DateOverride، تعطیلی پیش‌فرضاً سراسری است: * تعطیلی پزشک. برخلاف WeeklySchedule و DateOverride، تعطیلی پیش‌فرضاً سراسری است:
* «پزشک آن روز نیست» یک واقعیت فیزیکی است و هم‌زمان روی مطب شخصی و همهٔ کلینیک‌ها * «پزشک آن روز نیست» یک واقعیت فیزیکی است و هم‌زمان روی مطب شخصی و همهٔ کلینیک‌ها
* اثر می‌گذارد (clinic = null). مقدار غیر-NULL یعنی پزشک فقط در همان کلینیک نیست. * اثر می‌گذارد (clinic = null). مقدار غیر-NULL یعنی پزشک فقط در همان کلینیک نیست.
*
* عمداً جفت (entity_type, entity_id) ندارد: در این جدول clinic = NULL یعنی «همهٔ
* محیط‌ها»، نه «مطب شخصی». جفت tenant نمی‌تواند «همه» را بیان کند و تبدیلش، تعطیلی
* سراسری را به تعطیلی مطب شخصی تنزل می‌دهد. باید در whitelist فیلتر tenant بماند.
*/ */
#[ORM\Entity(repositoryClass: HolidayRepository::class)] #[ORM\Entity(repositoryClass: HolidayRepository::class)]
#[ORM\Table(name: 'holidays')] #[ORM\Table(name: 'holidays')]
+8 -11
View File
@@ -4,6 +4,8 @@ namespace App\Appointment\Entity;
use App\Clinic\Entity\Clinic; use App\Clinic\Entity\Clinic;
use App\Doctor\Entity\Doctor; use App\Doctor\Entity\Doctor;
use App\Shared\Context\EntityContext;
use App\Shared\Tenant\TenantOwnedTrait;
use Doctrine\ORM\Mapping as ORM; use Doctrine\ORM\Mapping as ORM;
use App\Appointment\Repository\WeeklyScheduleRepository; use App\Appointment\Repository\WeeklyScheduleRepository;
use Symfony\Component\Uid\Uuid; use Symfony\Component\Uid\Uuid;
@@ -17,9 +19,13 @@ use Symfony\Component\Uid\Uuid;
*/ */
#[ORM\Entity(repositoryClass: WeeklyScheduleRepository::class)] #[ORM\Entity(repositoryClass: WeeklyScheduleRepository::class)]
#[ORM\Table(name: 'weekly_schedules')] #[ORM\Table(name: 'weekly_schedules')]
#[ORM\UniqueConstraint(name: 'idx_weekly_schedules_doctor_clinic', columns: ['doctor_id', 'clinic_key'])] // doctor_id در کلید می‌ماند: در یک کلینیک چند پزشک هستند و هر کدام برنامهٔ خودش را
// دارد، پس (entity_type, entity_id) به‌تنهایی برای پزشک دوم نقض یکتایی می‌سازد.
#[ORM\UniqueConstraint(name: 'uniq_weekly_schedule_doctor_tenant', columns: ['doctor_id', 'entity_type', 'entity_id'])]
class WeeklySchedule class WeeklySchedule
{ {
use TenantOwnedTrait;
public const DAYS = ['saturday', 'sunday', 'monday', 'tuesday', 'wednesday', 'thursday', 'friday']; public const DAYS = ['saturday', 'sunday', 'monday', 'tuesday', 'wednesday', 'thursday', 'friday'];
public const META_KEY = 'meta'; public const META_KEY = 'meta';
@@ -55,16 +61,6 @@ class WeeklySchedule
#[ORM\JoinColumn(name: 'clinic_id', referencedColumnName: 'id', nullable: true, onDelete: 'CASCADE')] #[ORM\JoinColumn(name: 'clinic_id', referencedColumnName: 'id', nullable: true, onDelete: 'CASCADE')]
private ?Clinic $clinic = null; private ?Clinic $clinic = null;
/**
* ستون تولیدشدهٔ پایگاه‌داده: IFNULL(clinic_id, 0).
*
* MySQL/MariaDB مقادیر NULL را در unique index متمایز می‌شمارند، پس
* UNIQUE(doctor_id, clinic_id) جلوی دو برنامهٔ شخصی برای یک پزشک را نمی‌گرفت.
* این ستون NULL را به 0 نگاشت می‌کند تا یکتایی در سطح دیتابیس تضمین شود.
*/
#[ORM\Column(name: 'clinic_key', type: 'integer', insertable: false, updatable: false, generated: 'ALWAYS', columnDefinition: 'INT AS (IFNULL(clinic_id, 0)) STORED')]
private int $clinicKey = 0;
#[ORM\Column(type: 'json')] #[ORM\Column(type: 'json')]
private array $setting = []; private array $setting = [];
@@ -94,6 +90,7 @@ class WeeklySchedule
{ {
$this->clinic = $clinic; $this->clinic = $clinic;
$this->updatedAt = time(); $this->updatedAt = time();
$this->assignTenant(EntityContext::forBooking($this->doctor, $clinic));
return $this; return $this;
} }
public function getSetting(): array { return $this->setting; } public function getSetting(): array { return $this->setting; }
+9
View File
@@ -35,6 +35,15 @@ final class EntityContext
return new self(self::TYPE_CLINIC, $clinic->getId(), $clinic); return new self(self::TYPE_CLINIC, $clinic->getId(), $clinic);
} }
/**
* محیط یک رزرو: کلینیکِ داده‌شده، وگرنه مطب شخصی همان پزشک — همان قراردادی که
* {@see \App\Appointment\Service\BookingContextResolver} با ?Clinic بیان می‌کند.
*/
public static function forBooking(Doctor $doctor, ?Clinic $clinic): self
{
return $clinic !== null ? self::forClinic($clinic) : self::forDoctor($doctor);
}
public static function unknown(): self public static function unknown(): self
{ {
return new self(self::TYPE_UNKNOWN, null); return new self(self::TYPE_UNKNOWN, null);
+46
View File
@@ -0,0 +1,46 @@
<?php
namespace App\Shared\Tenant;
use App\Shared\Context\EntityContext;
use Doctrine\ORM\Mapping as ORM;
/**
* جفت مالکیتِ محیط — همان قراردادی که ServiceSection و PatientRecord از قبل دارند:
* entity_type ∈ {doctor, clinic} به‌همراه شناسهٔ همان موجودیت.
*
* مقدارها فقط از EntityContext::toEntityPair() می‌آیند تا «کدام محیط» یک منبع
* حقیقت بیشتر نداشته باشد ({@see \App\Shared\Context\EntityContextResolver}).
*
* هیچ‌کدام از دو ستون مقدار پیش‌فرض ندارند: اگر سازنده assignTenant() را فراموش
* کند، flush با خطای «typed property must not be accessed before initialization»
* می‌شکند. این عمدی است — ردیفِ بی‌محیط بی‌صدا از دید همه پنهان می‌شود.
*
* طول ۱۰ با service_sections، patient_records و clinic_staff یکی است تا JOIN بین
* جدول‌ها به collation mismatch نخورد.
*/
trait TenantOwnedTrait
{
#[ORM\Column(name: 'entity_type', type: 'string', length: 10)]
private string $entityType;
#[ORM\Column(name: 'entity_id', type: 'integer')]
private int $entityId;
public function getEntityType(): string { return $this->entityType; }
public function getEntityId(): int { return $this->entityId; }
/** @throws \InvalidArgumentException اگر محیط حل نشده باشد */
public function assignTenant(EntityContext $context): void
{
if (!$context->isResolved()) {
throw new \InvalidArgumentException(sprintf(
'Cannot assign an unresolved tenant context to %s.',
static::class,
));
}
[$this->entityType, $this->entityId] = $context->toEntityPair();
}
}
+74
View File
@@ -2,7 +2,13 @@
namespace App\Tests; namespace App\Tests;
use App\Appointment\Entity\Appointment;
use App\Appointment\Entity\DateOverride;
use App\Appointment\Entity\WeeklySchedule;
use App\Auth\Entity\User; use App\Auth\Entity\User;
use App\Clinic\Entity\Clinic;
use App\Doctor\Entity\Doctor;
use App\Shared\Context\EntityContext;
use App\Subscription\Entity\SubscriptionPlan; use App\Subscription\Entity\SubscriptionPlan;
use Doctrine\ORM\EntityManagerInterface; use Doctrine\ORM\EntityManagerInterface;
use Lexik\Bundle\JWTAuthenticationBundle\Services\JWTTokenManagerInterface; use Lexik\Bundle\JWTAuthenticationBundle\Services\JWTTokenManagerInterface;
@@ -70,6 +76,74 @@ abstract class ApiTestCase extends WebTestCase
return $user; return $user;
} }
/**
* A booking with its owning tenant already assigned — the test-side mirror of
* what the booking controllers do after resolving the environment. Constructing
* an Appointment without it fails at flush, since entity_type/entity_id are NOT
* NULL and have no default.
*/
protected function newAppointment(
Doctor $doctor,
User $patient,
int $slotStart,
int $slotEnd,
?Clinic $clinic = null,
): Appointment {
$appointment = new Appointment($doctor, $patient, $slotStart, $slotEnd);
$appointment->setClinic($clinic);
$appointment->assignTenant(EntityContext::forBooking($doctor, $clinic));
return $appointment;
}
/**
* A weekly schedule with its owning tenant assigned. The doctor is flushed
* first when it has no id yet: the tenant pair stores scalars, so the owner
* must already exist in the database.
*/
protected function newWeeklySchedule(Doctor $doctor, array $setting, ?Clinic $clinic = null): WeeklySchedule
{
$this->flushIfNew($doctor, $clinic);
$schedule = new WeeklySchedule($doctor, $setting, $clinic);
$schedule->assignTenant(EntityContext::forBooking($doctor, $clinic));
return $schedule;
}
protected function newDateOverride(Doctor $doctor, int $date, bool $active = false, ?Clinic $clinic = null): DateOverride
{
$this->flushIfNew($doctor, $clinic);
$override = new DateOverride($doctor, $date, $active, $clinic);
$override->assignTenant(EntityContext::forBooking($doctor, $clinic));
return $override;
}
/**
* The tenant pair stores scalars, so the owner needs a database id before it
* can be assigned. persist() on an already-managed entity is a no-op, which
* makes this safe for owners the test never persisted itself.
*/
private function flushIfNew(Doctor $doctor, ?Clinic $clinic): void
{
$pending = array_filter(
[$doctor, $clinic],
static fn(?object $owner) => $owner !== null && $owner->getId() === null,
);
if ($pending === []) {
return;
}
foreach ($pending as $owner) {
$this->em->persist($owner);
}
$this->em->flush();
}
protected function jwtFor(User $user): string protected function jwtFor(User $user): string
{ {
return static::getContainer() return static::getContainer()
@@ -54,8 +54,7 @@ class AppointmentConfirmFlowTest extends ApiTestCase
// اسلات یکتا به‌ازای هر نوبت: db_test بین اجراها پاک نمی‌شود. // اسلات یکتا به‌ازای هر نوبت: db_test بین اجراها پاک نمی‌شود.
$start = strtotime('+30 days') + random_int(0, 500_000) * 7; $start = strtotime('+30 days') + random_int(0, 500_000) * 7;
$appointment = new Appointment($doctor, $patient, $start, $start + 900); $appointment = $this->newAppointment($doctor, $patient, $start, $start + 900, $clinic);
$appointment->setClinic($clinic);
$appointment->setVisitPriceRials($visitPriceRials); $appointment->setVisitPriceRials($visitPriceRials);
$this->em->persist($appointment); $this->em->persist($appointment);
$this->em->flush(); $this->em->flush();
@@ -35,7 +35,7 @@ class AppointmentConfirmInsuranceSharesTest extends ApiTestCase
private function makeAppointment(Doctor $doctor): Appointment private function makeAppointment(Doctor $doctor): Appointment
{ {
$start = strtotime('+30 days') + random_int(0, 500_000) * 7; $start = strtotime('+30 days') + random_int(0, 500_000) * 7;
$appointment = new Appointment($doctor, $this->createUser(), $start, $start + 900); $appointment = $this->newAppointment($doctor, $this->createUser(), $start, $start + 900);
$appointment->setVisitPriceRials(self::VISIT_RIALS); $appointment->setVisitPriceRials(self::VISIT_RIALS);
$this->em->persist($appointment); $this->em->persist($appointment);
$this->em->flush(); $this->em->flush();
@@ -27,7 +27,7 @@ class AppointmentExpiryServiceTest extends ApiTestCase
$patient = $this->createUser(['ROLE_USER']); $patient = $this->createUser(['ROLE_USER']);
// distinct past slots — one live booking per (doctor, slot) // distinct past slots — one live booking per (doctor, slot)
$slotStart = $past - $i * 1000; $slotStart = $past - $i * 1000;
$appt = new Appointment($doctor, $patient, $slotStart, $slotStart + 900); $appt = $this->newAppointment($doctor, $patient, $slotStart, $slotStart + 900);
// مثل مسیر واقعیِ رزرو آنلاین: نگه‌داشتِ موقت تا پرداخت درگاه. // مثل مسیر واقعیِ رزرو آنلاین: نگه‌داشتِ موقت تا پرداخت درگاه.
$appt->markPendingWithTtl(-1); $appt->markPendingWithTtl(-1);
$this->em->persist($appt); $this->em->persist($appt);
@@ -67,7 +67,7 @@ class AppointmentExpiryServiceTest extends ApiTestCase
$this->em->persist($doctor); $this->em->persist($doctor);
$slotStart = time() - 7200; $slotStart = time() - 7200;
$appt = new Appointment($doctor, $this->createUser(['ROLE_USER']), $slotStart, $slotStart + 900); $appt = $this->newAppointment($doctor, $this->createUser(['ROLE_USER']), $slotStart, $slotStart + 900);
$this->em->persist($appt); $this->em->persist($appt);
$this->em->flush(); $this->em->flush();
@@ -31,7 +31,7 @@ class AppointmentInsuranceSelectionTest extends ApiTestCase
{ {
// اسلات یکتا به‌ازای هر نوبت: db_test بین اجراها پاک نمی‌شود. // اسلات یکتا به‌ازای هر نوبت: db_test بین اجراها پاک نمی‌شود.
$start = strtotime('+30 days') + random_int(0, 500_000) * 7; $start = strtotime('+30 days') + random_int(0, 500_000) * 7;
$appointment = new Appointment($doctor, $this->createUser(), $start, $start + 900); $appointment = $this->newAppointment($doctor, $this->createUser(), $start, $start + 900);
$appointment->setVisitPriceRials(5_952_000); $appointment->setVisitPriceRials(5_952_000);
$this->em->persist($appointment); $this->em->persist($appointment);
$this->em->flush(); $this->em->flush();
+2 -2
View File
@@ -23,7 +23,7 @@ class AppointmentUpdateTest extends ApiTestCase
$this->em->persist($doctor); $this->em->persist($doctor);
$this->em->flush(); $this->em->flush();
$appointment = new Appointment($doctor, $this->createUser(), time() + 86_400, time() + 86_400 + 1_800); $appointment = $this->newAppointment($doctor, $this->createUser(), time() + 86_400, time() + 86_400 + 1_800);
$this->em->persist($appointment); $this->em->persist($appointment);
$this->em->flush(); $this->em->flush();
@@ -77,7 +77,7 @@ class AppointmentUpdateTest extends ApiTestCase
[$owner, $doctor, $appointment] = $this->booking(); [$owner, $doctor, $appointment] = $this->booking();
$otherStart = time() + 3 * 86_400; $otherStart = time() + 3 * 86_400;
$this->em->persist(new Appointment($doctor, $this->createUser(), $otherStart, $otherStart + 1_800)); $this->em->persist($this->newAppointment($doctor, $this->createUser(), $otherStart, $otherStart + 1_800));
$this->em->flush(); $this->em->flush();
$this->authJson('PATCH', '/api/v1/appointment/' . $appointment->getUuid(), $owner, [ $this->authJson('PATCH', '/api/v1/appointment/' . $appointment->getUuid(), $owner, [
@@ -24,7 +24,7 @@ class AppointmentWorkflowFieldsTest extends ApiTestCase
private function newBooking(Doctor $doctor, int $start): Appointment private function newBooking(Doctor $doctor, int $start): Appointment
{ {
$a = new Appointment($doctor, $this->createUser(), $start, $start + 1_800); $a = $this->newAppointment($doctor, $this->createUser(), $start, $start + 1_800);
$this->em->persist($a); $this->em->persist($a);
$this->em->flush(); $this->em->flush();
@@ -50,7 +50,7 @@ class BookingLocationValidityTest extends ApiTestCase
'duration_per_patient' => 20, 'duration_per_patient' => 20,
], fn($v) => $v !== null)]]; ], fn($v) => $v !== null)]];
$schedule = new WeeklySchedule( $schedule = $this->newWeeklySchedule(
$doctor, $doctor,
array_fill_keys(array_map('strval', range(0, 6)), $day), array_fill_keys(array_map('strval', range(0, 6)), $day),
$clinic $clinic
@@ -128,7 +128,7 @@ class BookingLocationValidityTest extends ApiTestCase
]]]; ]]];
$setting = array_fill_keys(array_map('strval', range(0, 6)), ['sessions' => []]); $setting = array_fill_keys(array_map('strval', range(0, 6)), ['sessions' => []]);
$setting['0'] = $active; $setting['0'] = $active;
$this->em->persist(new WeeklySchedule($doctor, $setting)); $this->em->persist($this->newWeeklySchedule($doctor, $setting));
$this->em->flush(); $this->em->flush();
$saturday = $this->nextWeekday(6); // date('w'): 6 = Saturday $saturday = $this->nextWeekday(6); // date('w'): 6 = Saturday
@@ -165,7 +165,7 @@ class BookingLocationValidityTest extends ApiTestCase
$this->em->flush(); $this->em->flush();
$setting = array_fill_keys(array_map('strval', range(0, 6)), ['sessions' => []]); $setting = array_fill_keys(array_map('strval', range(0, 6)), ['sessions' => []]);
$this->em->persist(new WeeklySchedule($doctor, $setting)); $this->em->persist($this->newWeeklySchedule($doctor, $setting));
$this->em->flush(); $this->em->flush();
$body = $this->authJson('GET', "/api/v1/appointment-slots?doctor_uuid={$doctor->getUuid()}&date={$today}", $doctor->getUser()); $body = $this->authJson('GET', "/api/v1/appointment-slots?doctor_uuid={$doctor->getUuid()}&date={$today}", $doctor->getUser());
@@ -43,7 +43,7 @@ class BookingLocationsScanTest extends ApiTestCase
$this->em->persist($personalAddress); $this->em->persist($personalAddress);
$this->em->flush(); $this->em->flush();
$this->em->persist(new WeeklySchedule( $this->em->persist($this->newWeeklySchedule(
$doctor, $doctor,
$this->weekOfSessions($personalAddress->getId(), '09:00', '13:00') $this->weekOfSessions($personalAddress->getId(), '09:00', '13:00')
)); ));
@@ -59,7 +59,7 @@ class BookingLocationsScanTest extends ApiTestCase
$this->em->persist($address); $this->em->persist($address);
$this->em->flush(); $this->em->flush();
$this->em->persist(new WeeklySchedule( $this->em->persist($this->newWeeklySchedule(
$doctor, $doctor,
$this->weekOfSessions($address->getId(), '16:00', '20:00'), $this->weekOfSessions($address->getId(), '16:00', '20:00'),
$clinic $clinic
@@ -29,7 +29,7 @@ class BookingServicesPublicTest extends ApiTestCase
$this->em->persist($bookable); $this->em->persist($bookable);
$this->em->persist($hidden); $this->em->persist($hidden);
$schedule = new WeeklySchedule($doctor, []); $schedule = $this->newWeeklySchedule($doctor, []);
$schedule->setMeta(['booking_mode' => 'service', 'buffer_minutes' => 5]); $schedule->setMeta(['booking_mode' => 'service', 'buffer_minutes' => 5]);
$this->em->persist($schedule); $this->em->persist($schedule);
$this->em->flush(); $this->em->flush();
+137
View File
@@ -0,0 +1,137 @@
<?php
namespace App\Tests\Appointment;
use App\Appointment\Entity\Appointment;
use App\Appointment\Entity\WeeklySchedule;
use App\Clinic\Entity\Clinic;
use App\Doctor\Entity\Doctor;
use App\Shared\Context\EntityContext;
use App\Tests\ApiTestCase;
use Doctrine\DBAL\Exception\UniqueConstraintViolationException;
/**
* Phase 2 of tenant marking: appointments, weekly schedules and date overrides
* carry their owning environment as the (entity_type, entity_id) pair rather
* than leaving it implicit in a nullable clinic_id.
*/
class BookingTenantTest extends ApiTestCase
{
private function makeDoctor(): Doctor
{
$doctor = new Doctor($this->createUser(['ROLE_DOCTOR']), 'دکتر تست محیط');
$this->em->persist($doctor);
$this->em->flush();
return $doctor;
}
private function makeClinic(): Clinic
{
$clinic = new Clinic($this->createUser(['ROLE_CLINIC']));
$clinic->setName('کلینیک تست محیط');
$this->em->persist($clinic);
$this->em->flush();
return $clinic;
}
private function uniqueSlot(): int
{
return strtotime('+90 days') + random_int(0, 500_000) * 7;
}
public function testPersonalBookingIsOwnedByTheDoctor(): void
{
$doctor = $this->makeDoctor();
$start = $this->uniqueSlot();
$appointment = $this->newAppointment($doctor, $this->createUser(), $start, $start + 900);
$this->em->persist($appointment);
$this->em->flush();
self::assertSame('doctor', $appointment->getEntityType());
self::assertSame($doctor->getId(), $appointment->getEntityId());
}
public function testClinicBookingIsOwnedByTheClinic(): void
{
$doctor = $this->makeDoctor();
$clinic = $this->makeClinic();
$clinic->getDoctors()->add($doctor);
$this->em->flush();
$start = $this->uniqueSlot();
$appointment = $this->newAppointment($doctor, $this->createUser(), $start, $start + 900, $clinic);
$this->em->persist($appointment);
$this->em->flush();
self::assertSame('clinic', $appointment->getEntityType());
self::assertSame($clinic->getId(), $appointment->getEntityId());
}
/** یک پزشک در دو محیط = دو مالک متفاوت برای دو نوبت. */
public function testSameDoctorGetsDifferentOwnersInDifferentEnvironments(): void
{
$doctor = $this->makeDoctor();
$clinic = $this->makeClinic();
$clinic->getDoctors()->add($doctor);
$this->em->flush();
$personal = $this->newAppointment($doctor, $this->createUser(), $this->uniqueSlot(), $this->uniqueSlot() + 900);
$inClinic = $this->newAppointment($doctor, $this->createUser(), $this->uniqueSlot(), $this->uniqueSlot() + 900, $clinic);
$this->em->persist($personal);
$this->em->persist($inClinic);
$this->em->flush();
self::assertSame(['doctor', $doctor->getId()], [$personal->getEntityType(), $personal->getEntityId()]);
self::assertSame(['clinic', $clinic->getId()], [$inClinic->getEntityType(), $inClinic->getEntityId()]);
}
/** ❌ محیط حل‌نشده هرگز ذخیره نمی‌شود — نه با شناسهٔ صفر، نه بی‌صدا. */
public function testAssigningAnUnresolvedContextIsRejected(): void
{
$doctor = $this->makeDoctor();
$start = $this->uniqueSlot();
$appointment = new Appointment($doctor, $this->createUser(), $start, $start + 900);
$this->expectException(\InvalidArgumentException::class);
$appointment->assignTenant(EntityContext::unknown());
}
/**
* ⚠️ حالت مرزی: دو پزشکِ متفاوت در یک کلینیک، هر کدام برنامهٔ هفتگی خودشان —
* کلید یکتا باید doctor_id را هم داشته باشد وگرنه پزشک دوم رد می‌شود.
*/
public function testTwoDoctorsInOneClinicEachKeepTheirOwnSchedule(): void
{
$clinic = $this->makeClinic();
$doctorA = $this->makeDoctor();
$doctorB = $this->makeDoctor();
$clinic->getDoctors()->add($doctorA);
$clinic->getDoctors()->add($doctorB);
$this->em->flush();
$this->em->persist($this->newWeeklySchedule($doctorA, [], $clinic));
$this->em->persist($this->newWeeklySchedule($doctorB, [], $clinic));
$this->em->flush();
$rows = $this->em->getRepository(WeeklySchedule::class)
->findBy(['entityType' => 'clinic', 'entityId' => $clinic->getId()]);
self::assertCount(2, $rows);
}
/** همان پزشک، همان محیط، دو برنامه — هنوز غیرممکن است. */
public function testOneSchedulePerEnvironmentPerDoctorIsStillEnforced(): void
{
$doctor = $this->makeDoctor();
$this->em->persist($this->newWeeklySchedule($doctor, []));
$this->em->flush();
$this->em->persist($this->newWeeklySchedule($doctor, []));
$this->expectException(UniqueConstraintViolationException::class);
$this->em->flush();
}
}
+2 -2
View File
@@ -30,7 +30,7 @@ class BookingWindowUnitTest extends ApiTestCase
]]]; ]]];
} }
$schedule = new WeeklySchedule($doctor, $days); $schedule = $this->newWeeklySchedule($doctor, $days);
if ($meta !== []) { if ($meta !== []) {
$schedule->setMeta($meta); $schedule->setMeta($meta);
} }
@@ -94,7 +94,7 @@ class BookingWindowUnitTest extends ApiTestCase
{ {
$owner = $this->createUser(['ROLE_DOCTOR']); $owner = $this->createUser(['ROLE_DOCTOR']);
$doctor = new Doctor($owner, 'دکتر واحد نامعتبر'); $doctor = new Doctor($owner, 'دکتر واحد نامعتبر');
$schedule = new WeeklySchedule($doctor, []); $schedule = $this->newWeeklySchedule($doctor, []);
$schedule->setMeta(['booking_window_unit' => 'day', 'booking_window_value' => 10]); $schedule->setMeta(['booking_window_unit' => 'day', 'booking_window_value' => 10]);
$schedule->setMeta(['booking_window_unit' => 'year']); $schedule->setMeta(['booking_window_unit' => 'year']);
@@ -49,8 +49,7 @@ class ClinicAppointmentAccessTest extends ApiTestCase
$patient ??= $this->createUser(); $patient ??= $this->createUser();
$start = strtotime('+3 days 10:00'); $start = strtotime('+3 days 10:00');
$appointment = new Appointment($doctor, $patient, $start, $start + 900); $appointment = $this->newAppointment($doctor, $patient, $start, $start + 900, $clinic);
$appointment->setClinic($clinic);
$this->em->persist($appointment); $this->em->persist($appointment);
$this->em->flush(); $this->em->flush();
@@ -16,7 +16,7 @@ class DateOverrideOwnershipTest extends ApiTestCase
$owner = $this->createUser(['ROLE_DOCTOR']); $owner = $this->createUser(['ROLE_DOCTOR']);
$doctor = new Doctor($owner, 'دکتر تست'); $doctor = new Doctor($owner, 'دکتر تست');
$this->em->persist($doctor); $this->em->persist($doctor);
$override = new DateOverride($doctor, time(), true); $override = $this->newDateOverride($doctor, time(), true);
$this->em->persist($override); $this->em->persist($override);
$this->em->flush(); $this->em->flush();
@@ -29,7 +29,7 @@ class DoctorAppointmentFilterTest extends ApiTestCase
private function booking(Doctor $doctor, int $start, ?string $status = null, ?string $name = null): Appointment private function booking(Doctor $doctor, int $start, ?string $status = null, ?string $name = null): Appointment
{ {
$a = new Appointment($doctor, $this->createUser(), $start, $start + 1_800); $a = $this->newAppointment($doctor, $this->createUser(), $start, $start + 1_800);
if ($name !== null) { if ($name !== null) {
$a->setPatientName($name); $a->setPatientName($name);
} }
@@ -26,7 +26,7 @@ class OnlineBookingManagementTest extends ApiTestCase
$date = date('Y-m-d', strtotime('tomorrow')); $date = date('Y-m-d', strtotime('tomorrow'));
$dayKey = (string)(((int) date('w', strtotime($date)) + 1) % 7); $dayKey = (string)(((int) date('w', strtotime($date)) + 1) % 7);
$schedule = new WeeklySchedule($doctor, [ $schedule = $this->newWeeklySchedule($doctor, [
$dayKey => ['sessions' => [[ $dayKey => ['sessions' => [[
'active' => true, 'active' => true,
'start_time' => '10:00', 'start_time' => '10:00',
@@ -143,7 +143,7 @@ class OnlineBookingManagementTest extends ApiTestCase
$dayKey = (string)(((int) date('w', strtotime($date)) + 1) % 7); $dayKey = (string)(((int) date('w', strtotime($date)) + 1) % 7);
// برنامهٔ نوبت‌دهی در محیط کلینیک با نوبت‌دهی آنلاینِ خاموش. // برنامهٔ نوبت‌دهی در محیط کلینیک با نوبت‌دهی آنلاینِ خاموش.
$schedule = new WeeklySchedule($doctor, [ $schedule = $this->newWeeklySchedule($doctor, [
$dayKey => ['sessions' => [[ $dayKey => ['sessions' => [[
'active' => true, 'start_time' => '10:00', 'end_time' => '12:00', 'active' => true, 'start_time' => '10:00', 'end_time' => '12:00',
'duration_per_patient' => 30, 'location_id' => 1, 'duration_per_patient' => 30, 'location_id' => 1,
+1 -1
View File
@@ -18,7 +18,7 @@ class ScheduleOwnershipTest extends ApiTestCase
$owner = $this->createUser(['ROLE_DOCTOR']); $owner = $this->createUser(['ROLE_DOCTOR']);
$doctor = new Doctor($owner, 'دکتر تست'); $doctor = new Doctor($owner, 'دکتر تست');
$this->em->persist($doctor); $this->em->persist($doctor);
$schedule = new WeeklySchedule($doctor, ['sat' => []]); $schedule = $this->newWeeklySchedule($doctor, ['sat' => []]);
$this->em->persist($schedule); $this->em->persist($schedule);
$this->em->flush(); $this->em->flush();
+3 -3
View File
@@ -24,7 +24,7 @@ class ServiceBasedSlotsTest extends ApiTestCase
$date = date('Y-m-d', strtotime('tomorrow')); $date = date('Y-m-d', strtotime('tomorrow'));
$dayKey = (string)(((int) date('w', strtotime($date)) + 1) % 7); $dayKey = (string)(((int) date('w', strtotime($date)) + 1) % 7);
$schedule = new WeeklySchedule($doctor, [ $schedule = $this->newWeeklySchedule($doctor, [
$dayKey => ['sessions' => [[ $dayKey => ['sessions' => [[
'active' => true, 'active' => true,
'start_time' => '15:00', 'start_time' => '15:00',
@@ -61,7 +61,7 @@ class ServiceBasedSlotsTest extends ApiTestCase
$patient = $this->createUser(['ROLE_USER']); $patient = $this->createUser(['ROLE_USER']);
$start = strtotime($date . ' 15:00'); $start = strtotime($date . ' 15:00');
$appt = new Appointment($doctor, $patient, $start, $start + 30 * 60); $appt = $this->newAppointment($doctor, $patient, $start, $start + 30 * 60);
$appt->transitionTo(Appointment::STATUS_CONFIRMED); $appt->transitionTo(Appointment::STATUS_CONFIRMED);
$this->em->persist($appt); $this->em->persist($appt);
$this->em->flush(); $this->em->flush();
@@ -87,7 +87,7 @@ class ServiceBasedSlotsTest extends ApiTestCase
{ {
$owner = $this->createUser(['ROLE_DOCTOR']); $owner = $this->createUser(['ROLE_DOCTOR']);
$doctor = new Doctor($owner, 'دکتر متا'); $doctor = new Doctor($owner, 'دکتر متا');
$schedule = new WeeklySchedule($doctor, []); $schedule = $this->newWeeklySchedule($doctor, []);
// پیش‌فرض = اسلاتی // پیش‌فرض = اسلاتی
$this->assertSame(WeeklySchedule::MODE_SLOT, $schedule->getMeta()['booking_mode']); $this->assertSame(WeeklySchedule::MODE_SLOT, $schedule->getMeta()['booking_mode']);
@@ -24,7 +24,7 @@ class ServiceModeSectionDurationTest extends ApiTestCase
$date = date('Y-m-d', strtotime('tomorrow')); $date = date('Y-m-d', strtotime('tomorrow'));
$dayKey = (string) (((int) date('w', strtotime($date)) + 1) % 7); $dayKey = (string) (((int) date('w', strtotime($date)) + 1) % 7);
$schedule = new WeeklySchedule($doctor, [ $schedule = $this->newWeeklySchedule($doctor, [
$dayKey => ['sessions' => [[ $dayKey => ['sessions' => [[
'active' => true, 'start_time' => '15:00', 'end_time' => '19:00', 'active' => true, 'start_time' => '15:00', 'end_time' => '19:00',
'duration_per_patient' => 20, 'location_id' => 1, 'duration_per_patient' => 20, 'location_id' => 1,
+1 -1
View File
@@ -27,7 +27,7 @@ class SlotUniquenessTest extends ApiTestCase
private function newBooking(Doctor $doctor, int $start): Appointment private function newBooking(Doctor $doctor, int $start): Appointment
{ {
return new Appointment($doctor, $this->createUser(), $start, $start + 1_800); return $this->newAppointment($doctor, $this->createUser(), $start, $start + 1_800);
} }
public function testTwoLiveBookingsOnSameSlotViolateUniqueKey(): void public function testTwoLiveBookingsOnSameSlotViolateUniqueKey(): void
+1 -1
View File
@@ -88,7 +88,7 @@ class DashboardChartPeriodTest extends ApiTestCase
$patient = $this->createUser(['ROLE_USER']); $patient = $this->createUser(['ROLE_USER']);
$start = time(); $start = time();
$this->em->persist(new Appointment($doctor, $patient, $start, $start + 1_800)); $this->em->persist($this->newAppointment($doctor, $patient, $start, $start + 1_800));
$this->em->flush(); $this->em->flush();
$res = $this->authJson('GET', '/api/v1/dashboard/clinic', $owner); $res = $this->authJson('GET', '/api/v1/dashboard/clinic', $owner);
@@ -24,7 +24,7 @@ class DashboardTodayAppointmentsTest extends ApiTestCase
{ {
// now() is guaranteed within [today midnight, tomorrow midnight-1] // now() is guaranteed within [today midnight, tomorrow midnight-1]
$start = time(); $start = time();
$appt = new Appointment($doctor, $patient, $start, $start + 1_800); $appt = $this->newAppointment($doctor, $patient, $start, $start + 1_800);
$this->em->persist($appt); $this->em->persist($appt);
$this->em->flush(); $this->em->flush();
+2 -2
View File
@@ -47,8 +47,8 @@ class UniqueConstraintsTest extends ApiTestCase
$this->em->flush(); $this->em->flush();
$date = time(); $date = time();
$this->em->persist(new DateOverride($doctor, $date)); $this->em->persist($this->newDateOverride($doctor, $date));
$this->em->persist(new DateOverride($doctor, $date)); $this->em->persist($this->newDateOverride($doctor, $date));
$this->expectException(UniqueConstraintViolationException::class); $this->expectException(UniqueConstraintViolationException::class);
$this->em->flush(); $this->em->flush();
@@ -60,8 +60,8 @@ class DoctorBookingStateAggregationTest extends ApiTestCase
$this->em->persist($clinicAddress); $this->em->persist($clinicAddress);
$this->em->flush(); $this->em->flush();
$personal = new WeeklySchedule($doctor, $this->week($this->session(false, $personalAddress->getId()))); $personal = $this->newWeeklySchedule($doctor, $this->week($this->session(false, $personalAddress->getId())));
$clinicSchedule = new WeeklySchedule( $clinicSchedule = $this->newWeeklySchedule(
$doctor, $doctor,
$this->week($this->session(true, $clinicAddress->getId())), $this->week($this->session(true, $clinicAddress->getId())),
$clinic $clinic
@@ -39,10 +39,10 @@ class DoctorListBookableSortFilterTest extends ApiTestCase
$this->flagOff->setActiveDoctorAppointment(false); $this->flagOff->setActiveDoctorAppointment(false);
$days = [['sessions' => [['active' => true, 'start_time' => '09:00', 'end_time' => '13:00']]]]; $days = [['sessions' => [['active' => true, 'start_time' => '09:00', 'end_time' => '13:00']]]];
$this->em->persist(new WeeklySchedule($this->bookable, $days)); $this->em->persist($this->newWeeklySchedule($this->bookable, $days));
$this->em->persist(new WeeklySchedule($this->flagOff, $days)); $this->em->persist($this->newWeeklySchedule($this->flagOff, $days));
$disabled = new WeeklySchedule($this->bookingDisabled, $days); $disabled = $this->newWeeklySchedule($this->bookingDisabled, $days);
$disabled->setMeta(['online_booking_enabled' => false]); $disabled->setMeta(['online_booking_enabled' => false]);
$this->em->persist($disabled); $this->em->persist($disabled);
@@ -47,8 +47,7 @@ class AutoCreateSessionOnConfirmTest extends ApiTestCase
private function makeAppointment(Doctor $doctor, ?Clinic $clinic = null): Appointment private function makeAppointment(Doctor $doctor, ?Clinic $clinic = null): Appointment
{ {
$appointment = new Appointment($doctor, $this->createUser(['ROLE_USER']), 1_790_000_000, 1_790_001_800); $appointment = $this->newAppointment($doctor, $this->createUser(['ROLE_USER']), 1_790_000_000, 1_790_001_800, $clinic);
$appointment->setClinic($clinic);
$appointment->transitionTo(Appointment::STATUS_CONFIRMED); $appointment->transitionTo(Appointment::STATUS_CONFIRMED);
$this->em->persist($appointment); $this->em->persist($appointment);
$this->em->flush(); $this->em->flush();
@@ -110,7 +109,7 @@ class AutoCreateSessionOnConfirmTest extends ApiTestCase
$this->confirmation()->onConfirmed($first); $this->confirmation()->onConfirmed($first);
$this->em->flush(); $this->em->flush();
$second = new Appointment($doctor, $patient, 1_790_100_000, 1_790_101_800); $second = $this->newAppointment($doctor, $patient, 1_790_100_000, 1_790_101_800);
$second->transitionTo(Appointment::STATUS_CONFIRMED); $second->transitionTo(Appointment::STATUS_CONFIRMED);
$this->em->persist($second); $this->em->persist($second);
$this->em->flush(); $this->em->flush();
+2 -4
View File
@@ -61,8 +61,7 @@ class ClinicRecordAccessTest extends ApiTestCase
$this->em->persist($record); $this->em->persist($record);
$start = strtotime('+60 days') + random_int(0, 500_000) * 7; $start = strtotime('+60 days') + random_int(0, 500_000) * 7;
$appointment = new Appointment($doctor, $patient, $start, $start + 900); $appointment = $this->newAppointment($doctor, $patient, $start, $start + 900, $clinic);
$appointment->setClinic($clinic);
$this->em->persist($appointment); $this->em->persist($appointment);
$this->em->flush(); $this->em->flush();
@@ -221,8 +220,7 @@ class ClinicRecordAccessTest extends ApiTestCase
$patient = $this->createUser(); $patient = $this->createUser();
$start = strtotime('+70 days') + random_int(0, 500_000) * 7; $start = strtotime('+70 days') + random_int(0, 500_000) * 7;
$appointment = new Appointment($doctor, $patient, $start, $start + 900); $appointment = $this->newAppointment($doctor, $patient, $start, $start + 900, $clinic);
$appointment->setClinic($clinic);
$appointment->setVisitPriceRials(3_000_000); $appointment->setVisitPriceRials(3_000_000);
$this->em->persist($appointment); $this->em->persist($appointment);
$this->em->flush(); $this->em->flush();
+1 -1
View File
@@ -31,7 +31,7 @@ class PatientAppointmentsTest extends ApiTestCase
private function appointment(Doctor $doctor, PatientRecord $record, int $slotStart): Appointment private function appointment(Doctor $doctor, PatientRecord $record, int $slotStart): Appointment
{ {
$appt = new Appointment($doctor, $record->getUser(), $slotStart, $slotStart + 1800); $appt = $this->newAppointment($doctor, $record->getUser(), $slotStart, $slotStart + 1800);
$this->em->persist($appt); $this->em->persist($appt);
$this->em->flush(); $this->em->flush();
@@ -37,7 +37,7 @@ class AppointmentPaidConfirmFilesSessionTest extends ApiTestCase
$this->em->flush(); $this->em->flush();
$slotStart = strtotime('+30 days') + random_int(0, 500_000) * 7; $slotStart = strtotime('+30 days') + random_int(0, 500_000) * 7;
$appointment = new Appointment($doctor, $this->createUser(['ROLE_USER']), $slotStart, $slotStart + 1_800); $appointment = $this->newAppointment($doctor, $this->createUser(['ROLE_USER']), $slotStart, $slotStart + 1_800);
// رزرو آنلاین با پنجرهٔ پرداخت ثبت می‌شود؛ پرداخت باید همین را پاک کند. // رزرو آنلاین با پنجرهٔ پرداخت ثبت می‌شود؛ پرداخت باید همین را پاک کند.
$appointment->markPendingWithTtl(Appointment::PAYMENT_TTL); $appointment->markPendingWithTtl(Appointment::PAYMENT_TTL);
if ($withClinic !== null) { if ($withClinic !== null) {
@@ -39,8 +39,8 @@ class SecretaryAppointmentScopeTest extends ApiTestCase
// یک نوبت برای هر پزشک // یک نوبت برای هر پزشک
$patient = $this->createUser(['ROLE_USER']); $patient = $this->createUser(['ROLE_USER']);
$start = time() + 3600; $start = time() + 3600;
$this->em->persist(new Appointment($doctorA, $patient, $start, $start + 900)); $this->em->persist($this->newAppointment($doctorA, $patient, $start, $start + 900));
$this->em->persist(new Appointment($doctorB, $patient, $start + 1800, $start + 2700)); $this->em->persist($this->newAppointment($doctorB, $patient, $start + 1800, $start + 2700));
$this->em->flush(); $this->em->flush();
$body = $this->authJson('GET', '/api/v1/my/appointments', $secretaryUser); $body = $this->authJson('GET', '/api/v1/my/appointments', $secretaryUser);
@@ -66,7 +66,7 @@ class SecretaryAppointmentScopeTest extends ApiTestCase
$patient = $this->createUser(['ROLE_USER']); $patient = $this->createUser(['ROLE_USER']);
$start = time() + 3600; $start = time() + 3600;
$this->em->persist(new Appointment($doctor, $patient, $start, $start + 900)); $this->em->persist($this->newAppointment($doctor, $patient, $start, $start + 900));
$this->em->flush(); $this->em->flush();
$body = $this->authJson('GET', '/api/v1/my/appointments', $secretaryUser); $body = $this->authJson('GET', '/api/v1/my/appointments', $secretaryUser);
+1 -2
View File
@@ -62,8 +62,7 @@ class SecretaryOnlineShareTest extends ApiTestCase
private function makeOnlinePayment(Doctor $doctor, ?Clinic $clinic = null): Payment private function makeOnlinePayment(Doctor $doctor, ?Clinic $clinic = null): Payment
{ {
$start = strtotime('+30 days') + random_int(0, 500_000) * 7; $start = strtotime('+30 days') + random_int(0, 500_000) * 7;
$appointment = new Appointment($doctor, $this->createUser(), $start, $start + 900); $appointment = $this->newAppointment($doctor, $this->createUser(), $start, $start + 900, $clinic);
$appointment->setClinic($clinic);
$this->em->persist($appointment); $this->em->persist($appointment);
$payment = new Payment($this->createUser(), self::GROSS, 'mock', Payment::TYPE_APPOINTMENT, ''); $payment = new Payment($this->createUser(), self::GROSS, 'mock', Payment::TYPE_APPOINTMENT, '');