The document's first golden rule is "the calendar belongs to the resource, not to the doctor". Today the only thing that can be occupied is a doctor, and ClinicStaff is a label on services and appointments with no calendar, capacity or skills. This adds the layer underneath: anything that can be busy — doctor, operator, assistant, device, room, bed, chair. Two corrections to the planned schema: - `address_id` → doctor_addresses, not `branch_id` → a new branches table. The branch already exists and is the address (task 01). - UNIQUE is (doctor_id, address_id), not (doctor_id). A WeeklySchedule is per (doctor, clinic) but every session inside it carries its own location_id, so one doctor already works at several addresses within one environment. Keying on the doctor alone would have made that unrepresentable — and task 03 gives each resource its own calendar, which is exactly per-location. Design points worth keeping: - Resources bridge to Doctor/ClinicStaff/Room rather than absorbing them; those three have live consumers (appointments.doctor_id, service_item_staff, the public site) and subclassing would mean migrating all of them at once. At most one bridge column is non-null, enforced in the entity because MariaDB will not reliably enforce a multi-column CHECK. - Capacity is concurrency: a three-bed injection room is one resource with capacity 3, not three resources, so occupancy in task 06 stays a COUNT against a limit instead of a merge of three calendars. A person resource is refused capacity > 1. - Skills are a table, not rules. With 50 operators and 200 services, expressing "who may operate what" as policy would mean 10,000 rules. - findEligible() uses HAVING COUNT(DISTINCT …) because "skills A and B" means both; a plain IN would have matched a resource holding only one. - setup/cleanup minutes occupy the resource without being part of the patient's appointment, and are per-resource — distinct from the existing per-doctor WeeklySchedule.meta.buffer_minutes, which stays untouched. Two real bugs found by running the backfill against real data rather than fixtures: ResourceLinker::systemType() persisted a type without flushing, so the next lookup missed it and created a second — the run died on "Duplicate entry 'doctor-1-staff' for key uniq_rt_tenant_code". It now keeps an identity map for the unit of work. The command looped over every WeeklySchedule once per environment, which is quadratic and never finished on real data. Doctors are now a single pass keyed by the schedule's own environment. It also flushes per environment and accepts --pair=clinic:12, so one bad row cannot close the EntityManager and abort a fleet-wide run, and operators can re-run for a single clinic. Staff are the one case that cannot be derived: nothing records which branch they work at. Rather than guessing the first one and seating them in the wrong building, multi-branch environments are skipped and reported. 88 tests, 230 assertions across tests/Resource and tests/Branch. phpstan clean on src/Resource. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
210 lines
8.5 KiB
PHP
210 lines
8.5 KiB
PHP
<?php
|
|
|
|
namespace App\Tests\Resource;
|
|
|
|
use App\Appointment\Entity\WeeklySchedule;
|
|
use App\Doctor\Entity\Doctor;
|
|
use App\Doctor\Entity\DoctorAddress;
|
|
use App\Resource\Entity\ClinicResource;
|
|
use App\Resource\Entity\ResourceType;
|
|
use App\Resource\Repository\ClinicResourceRepository;
|
|
use App\Resource\Service\ResourceLinker;
|
|
use Symfony\Bundle\FrameworkBundle\Console\Application;
|
|
use Symfony\Component\Console\Tester\CommandTester;
|
|
|
|
class BackfillResourceTest extends ResourceTestCase
|
|
{
|
|
/**
|
|
* همیشه با `--pair` اجرا میشود: `db_test` هرگز ریست نمیشود و هزاران محیط از
|
|
* تستهای دیگر دارد، پس اجرای بیدامنه هم کند است و هم به دادهٔ نامرتبط وابسته.
|
|
*/
|
|
private function runBackfill(bool $force, string $pair): string
|
|
{
|
|
$application = new Application(static::$kernel);
|
|
$tester = new CommandTester($application->find('app:resource:backfill'));
|
|
|
|
$args = ['--pair' => $pair];
|
|
|
|
if ($force) {
|
|
$args['--force'] = true;
|
|
}
|
|
|
|
$tester->execute($args);
|
|
|
|
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);
|
|
}
|
|
|
|
/**
|
|
* غیرفعال شدن پرسنل باید منبعش را هم ببندد، وگرنه در جستجوی وقتِ تسک ۰۶ ظاهر
|
|
* میشود. عکسش برقرار نیست.
|
|
*/
|
|
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));
|
|
}
|
|
}
|