find('app:resource:backfill')); $args = ['--pair' => $pair]; if ($force) { $args['--force'] = true; } $tester->execute($args); return $tester->getDisplay(); } private function runCalendarBackfill(): string { $application = new Application(static::$kernel); $tester = new CommandTester($application->find('app:resource:calendar:backfill')); $tester->execute(['--force' => true]); return $tester->getDisplay(); } private function pairOf(DoctorAddress $address): string { return $address->tenantEntityType() . ':' . $address->tenantEntityId(); } private function resources(): ClinicResourceRepository { return static::getContainer()->get(ClinicResourceRepository::class); } /** dry-run پیش‌فرض است: بدون `--force` هیچ ردیفی نوشته نمی‌شود. */ public function testDryRunWritesNothing(): void { [, , $address] = $this->clinicWithAddress(); $room = $this->room($address, 'اتاق دراِی‌ران'); $output = $this->runBackfill(false, $this->pairOf($address)); self::assertStringContainsString('Dry run', $output); self::assertNull($this->resources()->findForSubject($room)); } public function testRoomBecomesAResourceCarryingItsCapacity(): void { [, , $address] = $this->clinicWithAddress(); $room = $this->room($address, 'اتاق تزریق سه‌تخته', 3); $this->runBackfill(true, $this->pairOf($address)); $this->em->clear(); $resource = $this->resources()->findForSubject( $this->em->getRepository(\App\Branch\Entity\Room::class)->find($room->getId()) ); self::assertNotNull($resource); self::assertSame(3, $resource->getCapacity(), 'اتاق سه‌تخته یک منبع با ظرفیت ۳ است، نه سه منبع'); self::assertSame(ResourceType::CODE_ROOM, $resource->getType()->getCode()); self::assertTrue($resource->getType()->isSystem()); } public function testStaffOfASingleBranchEnvironmentIsBridged(): void { [, , $address] = $this->clinicWithAddress(); $staff = $this->staff($address, 'اپراتور تک‌شعبه'); $this->runBackfill(true, $this->pairOf($address)); $this->em->clear(); $resource = $this->resources()->findForSubject( $this->em->getRepository(\App\Staff\Entity\ClinicStaff::class)->find($staff->getId()) ); self::assertNotNull($resource); self::assertSame(ResourceType::CODE_STAFF, $resource->getType()->getCode()); self::assertSame(1, $resource->getCapacity()); } /** * پرسنل هیچ ستونی ندارد که آدرسش را بگوید. با چند شعبه، انتخاب یکی حدس است و * او را در ساختمان اشتباه می‌نشاند — پس رد و **گزارش** می‌شود، نه حدس بی‌صدا. */ public function testStaffOfAMultiBranchEnvironmentIsReportedNotGuessed(): void { [, $clinic, $address] = $this->clinicWithAddress(); $this->extraAddress($clinic); $staff = $this->staff($address, 'اپراتور چندشعبه'); $output = $this->runBackfill(true, $this->pairOf($address)); $this->em->clear(); self::assertStringContainsString('شعبهٔ یکتا ندارد', $output); self::assertNull($this->resources()->findForSubject( $this->em->getRepository(\App\Staff\Entity\ClinicStaff::class)->find($staff->getId()) )); } /** * منبعِ پزشک از `location_id` شیفت‌های برنامهٔ هفتگی مشتق می‌شود — آنجا دقیقاً * نوشته در کدام آدرس‌ها شیفت دارد. «اولین شعبهٔ محیط» حدس می‌بود. */ public function testDoctorIsBridgedOncePerScheduledLocation(): void { [$user, $doctor, $address] = $this->doctorWithAddress(); $second = DoctorAddress::forDoctor($doctor); $second->setName('مطب دوم'); $this->em->persist($second); $third = DoctorAddress::forDoctor($doctor); $third->setName('مطب بی‌شیفت'); $this->em->persist($third); $this->em->flush(); // شیفت فعال روی دو آدرس اول، هیچ شیفتی روی سومی. $this->em->persist($this->newWeeklySchedule($doctor, [ '0' => ['sessions' => [ ['active' => true, 'location_id' => $address->getId(), 'start_time' => '09:00', 'end_time' => '13:00', 'duration_per_patient' => 20], ['active' => true, 'location_id' => $second->getId(), 'start_time' => '16:00', 'end_time' => '20:00', 'duration_per_patient' => 20], ]], ])); $this->em->flush(); $this->runBackfill(true, $this->pairOf($address)); $this->em->clear(); $doctorEntity = $this->em->getRepository(Doctor::class)->find($doctor->getId()); $all = $this->resources()->findAllForSubject($doctorEntity); self::assertCount(2, $all, 'یک منبع per آدرسی که شیفت دارد'); $addressIds = array_map(static fn (ClinicResource $r): int => (int) $r->getAddress()->getId(), $all); sort($addressIds); $expected = [(int) $address->getId(), (int) $second->getId()]; sort($expected); self::assertSame($expected, $addressIds); } /** شیفت غیرفعال منبع نمی‌سازد. */ public function testInactiveSessionDoesNotBridgeTheDoctor(): void { [, $doctor, $address] = $this->doctorWithAddress(); $this->em->persist($this->newWeeklySchedule($doctor, [ '0' => ['sessions' => [ ['active' => false, 'location_id' => $address->getId(), 'start_time' => '09:00', 'end_time' => '13:00', 'duration_per_patient' => 20], ]], ])); $this->em->flush(); $this->runBackfill(true, $this->pairOf($address)); $this->em->clear(); $doctorEntity = $this->em->getRepository(Doctor::class)->find($doctor->getId()); self::assertSame([], $this->resources()->findAllForSubject($doctorEntity)); } /** idempotent: تکیه‌گاهش وجود منبع است، نه یک پرچم جداگانه. */ public function testRunningTwiceCreatesNothingNew(): void { [, , $address] = $this->clinicWithAddress(); $this->room($address, 'اتاق تکراری'); $this->staff($address, 'اپراتور تکراری'); $this->runBackfill(true, $this->pairOf($address)); $this->em->clear(); $secondOutput = $this->runBackfill(true, $this->pairOf($address)); self::assertStringContainsString('اتاق: 0', $secondOutput); self::assertStringContainsString('پرسنل: 0', $secondOutput); } /** * توگلِ پرسنل از راه endpoint واقعی باید منبعش را هم ببندد — نه فقط سرویس. * بدون این سیم‌کشی، پرسنل غیرفعال همچنان در جستجوی وقتِ تسک ۰۶ ظاهر می‌شد. */ public function testTogglingStaffThroughTheEndpointClosesItsResource(): void { [$owner, , $address] = $this->clinicWithAddress(); $staff = $this->staff($address, 'اپراتور توگل'); $this->runBackfill(true, $this->pairOf($address)); $this->em->clear(); $this->authJson('PATCH', "/api/v1/staff/{$staff->getUuid()}/toggle", $owner); self::assertSame(200, $this->responseCode()); $this->em->clear(); $reloaded = $this->em->getRepository(\App\Staff\Entity\ClinicStaff::class)->find($staff->getId()); self::assertFalse($reloaded->isActive()); self::assertFalse($this->resources()->findForSubject($reloaded)->isActive()); } /** * شیفت منابعِ پزشک از برنامهٔ هفتگی ساخته می‌شود، وگرنه منبعِ تازه‌ساخته هیچ * ساعتی ندارد و لایهٔ منبع تا ورود دستی بی‌اثر می‌ماند. */ public function testCalendarBackfillDerivesShiftsFromTheWeeklySchedule(): void { [, $doctor, $address] = $this->doctorWithAddress(); $this->em->persist($this->newWeeklySchedule($doctor, [ '0' => ['sessions' => [ ['active' => true, 'location_id' => $address->getId(), 'start_time' => '09:00', 'end_time' => '13:00', 'duration_per_patient' => 20], ['active' => true, 'location_id' => $address->getId(), 'start_time' => '16:00', 'end_time' => '20:00', 'duration_per_patient' => 20], ]], '1' => ['sessions' => [ ['active' => false, 'location_id' => $address->getId(), 'start_time' => '09:00', 'end_time' => '13:00', 'duration_per_patient' => 20], ]], ])); $this->em->flush(); $this->runBackfill(true, $this->pairOf($address)); $this->runCalendarBackfill(); $this->em->clear(); $doctorEntity = $this->em->getRepository(Doctor::class)->find($doctor->getId()); $resource = $this->resources()->findForSubject($doctorEntity); $calendars = static::getContainer() ->get(\App\Resource\Repository\ResourceCalendarRepository::class) ->findForResource($resource); self::assertCount(2, $calendars, 'دو شیفت فعالِ شنبه؛ شیفت غیرفعال یکشنبه نمی‌آید'); self::assertSame([0, 0], array_map(static fn ($c) => $c->getDayOfWeek(), $calendars)); self::assertSame([540, 960], array_map(static fn ($c) => $c->getStartMinute(), $calendars)); self::assertSame([0, 1], array_map(static fn ($c) => $c->getSequence(), $calendars)); } /** منبعی که کاربر خودش تنظیم کرده دست نمی‌خورد — همین idempotent بودن را می‌دهد. */ public function testCalendarBackfillLeavesAnAlreadyConfiguredResourceAlone(): void { [$user, $doctor, $address] = $this->doctorWithAddress(); $this->em->persist($this->newWeeklySchedule($doctor, [ '0' => ['sessions' => [ ['active' => true, 'location_id' => $address->getId(), 'start_time' => '09:00', 'end_time' => '13:00', 'duration_per_patient' => 20], ]], ])); $this->em->flush(); $this->runBackfill(true, $this->pairOf($address)); $this->em->clear(); $doctorEntity = $this->em->getRepository(Doctor::class)->find($doctor->getId()); $resource = $this->resources()->findForSubject($doctorEntity); $this->authJson('PUT', "/api/v1/resource/{$resource->getUuid()}/calendar", $user, [ 'days' => [3 => [['start_minute' => 600, 'end_minute' => 660]]], ]); self::assertSame(200, $this->responseCode()); $output = $this->runCalendarBackfill(); self::assertStringContainsString('دست‌نخورده ماند', $output); $this->em->clear(); $calendars = static::getContainer() ->get(\App\Resource\Repository\ResourceCalendarRepository::class) ->findForResource($this->resources()->findByUuid($resource->getUuid())); self::assertCount(1, $calendars); self::assertSame(3, $calendars[0]->getDayOfWeek(), 'تنظیم دستی کاربر سرِ جایش است'); } /** حداکثر یک پل: قید در سازنده است، چون MariaDB `CHECK` چندستونی را اجرا نمی‌کند. */ public function testASecondBridgeIsRefused(): void { [, , $address] = $this->clinicWithAddress(); $room = $this->room($address, 'اتاق دوپل'); $staff = $this->staff($address, 'پرسنل دوپل'); $type = $this->resourceType($address, 'mixed', 'ترکیبی'); $resource = new \App\Resource\Entity\ClinicResource($address, $type, 'منبع دوپل'); $resource->linkTo($room); $this->expectException(\InvalidArgumentException::class); $resource->linkTo($staff); } /** * غیرفعال شدن پرسنل باید منبعش را هم ببندد، وگرنه در جستجوی وقتِ تسک ۰۶ ظاهر * می‌شود. عکسش برقرار نیست. */ public function testSyncActiveClosesEveryResourceOfASubject(): void { [, , $address] = $this->clinicWithAddress(); $staff = $this->staff($address, 'اپراتور خاموش‌شونده'); $this->runBackfill(true, $this->pairOf($address)); $this->em->clear(); $linker = static::getContainer()->get(ResourceLinker::class); $staffEntity = $this->em->getRepository(\App\Staff\Entity\ClinicStaff::class)->find($staff->getId()); self::assertSame(1, $linker->syncActive($staffEntity, false)); self::assertFalse($this->resources()->findForSubject($staffEntity)->isActive()); // دوباره صدا زدن چیزی را عوض نمی‌کند. self::assertSame(0, $linker->syncActive($staffEntity, false)); } }