Four pages on the existing design system: a resources list whose branch/type/skill/
status filters live in the URL and go straight to the server, and three supporting
pages for types, skills and pools. Filtering client-side over a list the server had
already filtered would have been a second source of truth, so the page does neither.
The pool members dialog only offers resources from the pool's own branch and type —
the same rule the server enforces with 422, applied early so the user never reaches
the error. Skill assignment and pool membership are both full replacements, and both
say so in the dialog, because a partial-looking save that silently drops rows is
worse than an explicit one.
Wiring that was missing: deactivating a staff member through
PATCH /api/v1/staff/{uuid}/toggle now closes their resource too. Without it an
inactive operator would still have shown up in availability search. It is an explicit
call rather than a Doctrine lifecycle callback, since callbacks do not fire for
getArrayResult() — which is how every admin list is built — and that asymmetry is
its own bug. The reverse does not hold: closing a resource does not deactivate the
person, who may be purely administrative.
docs/api/resource.md documents all sixteen endpoints with responses captured from
real curl runs against ddev, including the 422 bodies for person-capacity and
non-scalar attributes. staff.md gains a "relationship to resources" section stating
that job_title is not a skill. tenancy.md contrasts these aggregate children —
whose roots do carry a tenant pair — with the branch_working_hours case from task 01,
where the root was global and the classification was wrong.
Also fixed a pre-existing flaky test: NumericFieldNormalizerTest guarded its random
mobile against collision on the never-reset db_test but not its random national code,
so a full-suite run could fail with 422 and close the EntityManager, taking an
unrelated test down with it. Both are now guarded, and the assertion prints the
server's response instead of a bare "422 is not 201".
Verified: phpunit 1119 tests / 3113 assertions green; slot-mode frozen contract green;
phpstan 14 errors before and after, none in touched files; tsc clean; vitest 88 files
/ 617 tests green.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
247 lines
10 KiB
PHP
247 lines
10 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);
|
|
}
|
|
|
|
/**
|
|
* توگلِ پرسنل از راه 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());
|
|
}
|
|
|
|
/** حداکثر یک پل: قید در سازنده است، چون 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));
|
|
}
|
|
}
|