feat(resource): resource types, resources, skills and pools

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>
This commit is contained in:
hamed
2026-07-30 17:34:36 +03:30
co-authored by Claude Opus 5
parent 92181bacff
commit 964c09cc00
33 changed files with 3824 additions and 0 deletions
+209
View File
@@ -0,0 +1,209 @@
<?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));
}
}
+210
View File
@@ -0,0 +1,210 @@
<?php
namespace App\Tests\Resource;
use App\Resource\Entity\ClinicResource;
class ResourceCrudTest extends ResourceTestCase
{
public function testResourceIsCreatedWithTenantPairDerivedFromTheAddress(): void
{
[$user, $clinic, $address] = $this->clinicWithAddress();
$type = $this->resourceType($address);
$body = $this->createResource($user, $address, $type, ['capacity' => 2, 'setup_minutes' => 5]);
self::assertSame(201, $this->responseCode(), json_encode($body, JSON_UNESCAPED_UNICODE));
self::assertSame(2, $body['data']['capacity']);
self::assertSame(5, $body['data']['setup_minutes']);
self::assertSame($address->getUuid(), $body['data']['address_uuid']);
self::assertNull($body['data']['subject_kind'], 'منبع بدون پل یعنی دستگاه');
$resource = $this->em->getRepository(ClinicResource::class)->findOneBy(['uuid' => $body['data']['uuid']]);
self::assertSame('clinic', $resource->getEntityType());
self::assertSame($clinic->getId(), $resource->getEntityId());
}
public function testDefaultsAreOneCapacityAndZeroBuffers(): void
{
[$user, , $address] = $this->clinicWithAddress();
$body = $this->createResource($user, $address, $this->resourceType($address));
self::assertSame(1, $body['data']['capacity']);
self::assertSame(0, $body['data']['setup_minutes']);
self::assertSame(0, $body['data']['cleanup_minutes']);
self::assertTrue($body['data']['active']);
}
public function testZeroCapacityIsRejected(): void
{
[$user, , $address] = $this->clinicWithAddress();
$body = $this->createResource($user, $address, $this->resourceType($address), ['capacity' => 0]);
self::assertSame(422, $this->responseCode());
self::assertSame('capacity', $body['errors'][0]['field']);
}
/** پزشک هم‌زمان دو بیمار ندارد — قید در خودِ entity است، نه فقط در سرویس. */
public function testCapacityAboveOneOnAPersonTypeIsRejected(): void
{
[$user, , $address] = $this->clinicWithAddress();
$doctorType = $this->resourceType($address, 'doctor', 'پزشک');
$body = $this->createResource($user, $address, $doctorType, ['capacity' => 2]);
self::assertSame(422, $this->responseCode());
self::assertSame('capacity', $body['errors'][0]['field']);
self::assertStringContainsString('شخص', $body['errors'][0]['message']);
}
/** ۰ و ۱۰ هر دو معتبرند: آماده‌سازی می‌تواند صفر باشد و تمیزکاری نباشد یا برعکس. */
public function testZeroSetupWithNonZeroCleanupIsValid(): void
{
[$user, , $address] = $this->clinicWithAddress();
$body = $this->createResource($user, $address, $this->resourceType($address), [
'setup_minutes' => 0,
'cleanup_minutes' => 10,
]);
self::assertSame(201, $this->responseCode());
self::assertSame(10, $body['data']['cleanup_minutes']);
}
public function testUnknownAttributeKeyIsAcceptedButNonScalarValueIsNot(): void
{
[$user, , $address] = $this->clinicWithAddress();
$type = $this->resourceType($address);
$ok = $this->createResource($user, $address, $type, [
'attributes' => ['gender' => 'female', 'anything_else' => 42],
]);
self::assertSame(201, $this->responseCode(), 'کلید ناشناخته عمداً پذیرفته می‌شود');
self::assertSame('female', $ok['data']['attributes']['gender']);
self::assertSame(42, $ok['data']['attributes']['anything_else']);
$bad = $this->createResource($user, $address, $type, [
'name' => 'دستگاه دوم',
'attributes' => ['nested' => ['a' => 1]],
]);
self::assertSame(422, $this->responseCode());
self::assertSame('attributes', $bad['errors'][0]['field']);
}
public function testAttributeKeyMustBeSnakeCase(): void
{
[$user, , $address] = $this->clinicWithAddress();
$body = $this->createResource($user, $address, $this->resourceType($address), [
'attributes' => ['Device Model' => 'x'],
]);
self::assertSame(422, $this->responseCode());
self::assertSame('attributes', $body['errors'][0]['field']);
}
public function testForeignAddressIsNotFound(): void
{
[$user, , $address] = $this->clinicWithAddress();
$type = $this->resourceType($address);
[, , $foreignAddress] = $this->clinicWithAddress('شعبهٔ بیگانه');
$this->authJson('POST', '/api/v1/resource', $user, [
'address_uuid' => $foreignAddress->getUuid(),
'type_uuid' => $type->getUuid(),
'name' => 'دستگاه',
]);
self::assertSame(404, $this->responseCode());
}
public function testForeignResourceIsNotFound(): void
{
[$ownerUser, , $ownerAddress] = $this->clinicWithAddress();
$created = $this->createResource($ownerUser, $ownerAddress, $this->resourceType($ownerAddress));
[$otherUser] = $this->clinicWithAddress('شعبهٔ دیگر');
$this->authJson('GET', "/api/v1/resource/{$created['data']['uuid']}", $otherUser);
self::assertSame(404, $this->responseCode());
$this->authJson('PATCH', "/api/v1/resource/{$created['data']['uuid']}", $otherUser, ['name' => 'دزدیده‌شده']);
self::assertSame(404, $this->responseCode());
$this->authJson('DELETE', "/api/v1/resource/{$created['data']['uuid']}", $otherUser);
self::assertSame(404, $this->responseCode());
}
public function testResourceIsUpdatedAndDeleted(): void
{
[$user, , $address] = $this->clinicWithAddress();
$created = $this->createResource($user, $address, $this->resourceType($address));
$body = $this->authJson('PATCH', "/api/v1/resource/{$created['data']['uuid']}", $user, [
'name' => 'لیزر دو',
'cleanup_minutes' => 15,
'active' => false,
]);
self::assertSame(200, $this->responseCode());
self::assertSame('لیزر دو', $body['data']['name']);
self::assertSame(15, $body['data']['cleanup_minutes']);
self::assertFalse($body['data']['active']);
$this->authJson('DELETE', "/api/v1/resource/{$created['data']['uuid']}", $user);
self::assertSame(200, $this->responseCode());
$this->authJson('GET', "/api/v1/resource/{$created['data']['uuid']}", $user);
self::assertSame(404, $this->responseCode());
}
public function testListIsFilteredByAddressTypeAndActive(): void
{
[$user, $clinic, $address] = $this->clinicWithAddress();
$second = $this->extraAddress($clinic);
$device = $this->resourceType($address, 'device', 'دستگاه');
$chair = $this->resourceType($address, 'chair', 'صندلی');
$this->createResource($user, $address, $device, ['name' => 'دستگاه شعبهٔ یک']);
$this->createResource($user, $second, $device, ['name' => 'دستگاه شعبهٔ دو']);
$off = $this->createResource($user, $address, $chair, ['name' => 'صندلی خاموش']);
$this->authJson('PATCH', "/api/v1/resource/{$off['data']['uuid']}", $user, ['active' => false]);
$all = $this->authJson('GET', '/api/v1/resources', $user);
self::assertCount(3, $all['data']);
$byAddress = $this->authJson('GET', "/api/v1/resources?address_uuid={$address->getUuid()}", $user);
self::assertCount(2, $byAddress['data']);
$byType = $this->authJson('GET', "/api/v1/resources?type_uuid={$device->getUuid()}", $user);
self::assertCount(2, $byType['data']);
$activeOnly = $this->authJson('GET', '/api/v1/resources?active=1', $user);
self::assertCount(2, $activeOnly['data']);
}
public function testListShowsOnlyTheCurrentEnvironment(): void
{
[$user, , $address] = $this->clinicWithAddress();
$this->createResource($user, $address, $this->resourceType($address));
[$otherUser, , $otherAddress] = $this->clinicWithAddress('شعبهٔ دیگر');
$this->createResource($otherUser, $otherAddress, $this->resourceType($otherAddress));
$body = $this->authJson('GET', '/api/v1/resources', $user);
self::assertCount(1, $body['data']);
}
public function testBlankNameIsRejected(): void
{
[$user, , $address] = $this->clinicWithAddress();
$body = $this->createResource($user, $address, $this->resourceType($address), ['name' => ' ']);
self::assertSame(422, $this->responseCode());
self::assertSame('name', $body['errors'][0]['field']);
}
}
+156
View File
@@ -0,0 +1,156 @@
<?php
namespace App\Tests\Resource;
use App\Resource\Entity\ClinicResource;
use App\Resource\Entity\Skill;
use App\Resource\Repository\ClinicResourceRepository;
/**
* `findEligible()` — پرس‌وجوی داغِ تسک ۰۶. جدا از HTTP تست می‌شود چون قرارداد
* «همهٔ مهارت‌ها، نه یکی» یک تصمیم SQL است و باید مستقل از کنترلر قفل بماند.
*/
class ResourceEligibilityTest extends ResourceTestCase
{
private function repo(): ClinicResourceRepository
{
return static::getContainer()->get(ClinicResourceRepository::class);
}
private function skillIdOf(string $uuid): int
{
return (int) $this->em->getRepository(Skill::class)->findOneBy(['uuid' => $uuid])->getId();
}
private function assign(\App\Auth\Entity\User $user, string $resourceUuid, array $skillUuids): void
{
$this->authJson('PUT', "/api/v1/resource/$resourceUuid/skills", $user, [
'skills' => array_map(static fn (string $u): array => ['skill_uuid' => $u], $skillUuids),
]);
}
/**
* نیازمندی «مهارت الف و ب» یعنی هر دو. با یک `IN` ساده، منبعی که فقط یکی را
* دارد هم برمی‌گشت — به همین دلیل `HAVING COUNT(DISTINCT …)` هست.
*/
public function testAllSkillsAreRequiredNotAny(): void
{
[$user, , $address] = $this->clinicWithAddress();
$type = $this->resourceType($address, 'operator', 'اپراتور');
$laser = $this->createSkill($user, 'لیزر آلکساندرایت');
$botox = $this->createSkill($user, 'بوتاکس');
$both = $this->createResource($user, $address, $type, ['name' => 'اپراتور کامل']);
$one = $this->createResource($user, $address, $type, ['name' => 'اپراتور نصفه']);
$this->assign($user, $both['data']['uuid'], [$laser['data']['uuid'], $botox['data']['uuid']]);
$this->assign($user, $one['data']['uuid'], [$laser['data']['uuid']]);
$this->em->clear();
$eligible = $this->repo()->findEligible(
$this->em->getRepository(\App\Doctor\Entity\DoctorAddress::class)->find($address->getId()),
$this->em->getRepository(\App\Resource\Entity\ResourceType::class)->find($type->getId()),
[$this->skillIdOf($laser['data']['uuid']), $this->skillIdOf($botox['data']['uuid'])],
);
self::assertCount(1, $eligible);
self::assertSame('اپراتور کامل', $eligible[0]->getName());
}
public function testSingleSkillMatchesEveryResourceThatHasIt(): void
{
[$user, , $address] = $this->clinicWithAddress();
$type = $this->resourceType($address, 'operator', 'اپراتور');
$laser = $this->createSkill($user, 'لیزر');
foreach (['اپراتور ۱', 'اپراتور ۲'] as $name) {
$r = $this->createResource($user, $address, $type, ['name' => $name]);
$this->assign($user, $r['data']['uuid'], [$laser['data']['uuid']]);
}
$this->createResource($user, $address, $type, ['name' => 'اپراتور بی‌مهارت']);
$this->em->clear();
$eligible = $this->repo()->findEligible(
$this->em->getRepository(\App\Doctor\Entity\DoctorAddress::class)->find($address->getId()),
$this->em->getRepository(\App\Resource\Entity\ResourceType::class)->find($type->getId()),
[$this->skillIdOf($laser['data']['uuid'])],
);
self::assertCount(2, $eligible);
}
public function testInactiveResourceIsNeverEligible(): void
{
[$user, , $address] = $this->clinicWithAddress();
$type = $this->resourceType($address, 'operator', 'اپراتور');
$laser = $this->createSkill($user, 'لیزر');
$resource = $this->createResource($user, $address, $type, ['name' => 'اپراتور خاموش']);
$this->assign($user, $resource['data']['uuid'], [$laser['data']['uuid']]);
$this->authJson('PATCH', "/api/v1/resource/{$resource['data']['uuid']}", $user, ['active' => false]);
$this->em->clear();
$eligible = $this->repo()->findEligible(
$this->em->getRepository(\App\Doctor\Entity\DoctorAddress::class)->find($address->getId()),
$this->em->getRepository(\App\Resource\Entity\ResourceType::class)->find($type->getId()),
[$this->skillIdOf($laser['data']['uuid'])],
);
self::assertSame([], $eligible);
}
/** بدون شرط مهارت، همهٔ منابع فعالِ آن شعبه و نوع برمی‌گردند. */
public function testNoSkillFilterReturnsEveryActiveResourceOfThatType(): void
{
[$user, $clinic, $address] = $this->clinicWithAddress();
$second = $this->extraAddress($clinic);
$type = $this->resourceType($address, 'operator', 'اپراتور');
$this->createResource($user, $address, $type, ['name' => 'اینجا ۱']);
$this->createResource($user, $address, $type, ['name' => 'اینجا ۲']);
$this->createResource($user, $second, $type, ['name' => 'شعبهٔ دیگر']);
$this->em->clear();
$eligible = $this->repo()->findEligible(
$this->em->getRepository(\App\Doctor\Entity\DoctorAddress::class)->find($address->getId()),
$this->em->getRepository(\App\Resource\Entity\ResourceType::class)->find($type->getId()),
);
self::assertCount(2, $eligible);
}
/** فیلتر مهارت روی endpoint فهرست هم همان معنا را می‌دهد. */
public function testListEndpointFiltersBySkill(): void
{
[$user, , $address] = $this->clinicWithAddress();
$type = $this->resourceType($address, 'operator', 'اپراتور');
$laser = $this->createSkill($user, 'لیزر');
$withSkill = $this->createResource($user, $address, $type, ['name' => 'با مهارت']);
$this->assign($user, $withSkill['data']['uuid'], [$laser['data']['uuid']]);
$this->createResource($user, $address, $type, ['name' => 'بدون مهارت']);
$body = $this->authJson('GET', "/api/v1/resources?skill_uuid={$laser['data']['uuid']}", $user);
self::assertSame(200, $this->responseCode());
self::assertCount(1, $body['data']);
self::assertSame('با مهارت', $body['data'][0]['name']);
}
/** مهارت محیط دیگر نباید بی‌صدا «هیچ نتیجه» بدهد. */
public function testListEndpointRejectsAForeignSkillFilter(): void
{
[$user] = $this->clinicWithAddress();
[$otherUser] = $this->clinicWithAddress('شعبهٔ دیگر');
$foreign = $this->createSkill($otherUser, 'مهارت بیگانه');
$this->authJson('GET', "/api/v1/resources?skill_uuid={$foreign['data']['uuid']}", $user);
self::assertSame(404, $this->responseCode());
}
}
+181
View File
@@ -0,0 +1,181 @@
<?php
namespace App\Tests\Resource;
class ResourcePoolTest extends ResourceTestCase
{
public function testPoolIsCreatedAndMembersAreReplacedWholesale(): void
{
[$user, , $address] = $this->clinicWithAddress();
$type = $this->resourceType($address, 'laser', 'دستگاه لیزر');
$one = $this->createResource($user, $address, $type, ['name' => 'لیزر ۱']);
$two = $this->createResource($user, $address, $type, ['name' => 'لیزر ۲']);
$three = $this->createResource($user, $address, $type, ['name' => 'لیزر ۳']);
$pool = $this->authJson('POST', '/api/v1/resource-pools', $user, [
'address_uuid' => $address->getUuid(),
'type_uuid' => $type->getUuid(),
'name' => 'لیزرهای آلکساندرایت',
]);
self::assertSame(201, $this->responseCode(), json_encode($pool, JSON_UNESCAPED_UNICODE));
self::assertSame([], $pool['data']['members'], 'استخر تازه هیچ عضوی ندارد و این معتبر است');
$filled = $this->authJson('PUT', "/api/v1/resource-pool/{$pool['data']['uuid']}/members", $user, [
'members' => [
['resource_uuid' => $one['data']['uuid'], 'priority' => 0],
['resource_uuid' => $two['data']['uuid'], 'priority' => 1],
['resource_uuid' => $three['data']['uuid'], 'priority' => 2],
],
]);
self::assertSame(200, $this->responseCode());
self::assertCount(3, $filled['data']['members']);
self::assertSame($address->getUuid(), $filled['data']['address_uuid']);
// جایگزینی کامل: عضوی که نیامده، برداشته می‌شود.
$shrunk = $this->authJson('PUT', "/api/v1/resource-pool/{$pool['data']['uuid']}/members", $user, [
'members' => [['resource_uuid' => $one['data']['uuid']]],
]);
self::assertCount(1, $shrunk['data']['members']);
}
public function testMemberFromAnotherBranchIsRejected(): void
{
[$user, $clinic, $address] = $this->clinicWithAddress();
$second = $this->extraAddress($clinic);
$type = $this->resourceType($address, 'laser', 'دستگاه لیزر');
$elsewhere = $this->createResource($user, $second, $type, ['name' => 'لیزر شعبهٔ دو']);
$pool = $this->authJson('POST', '/api/v1/resource-pools', $user, [
'address_uuid' => $address->getUuid(),
'type_uuid' => $type->getUuid(),
'name' => 'استخر شعبهٔ یک',
]);
$body = $this->authJson('PUT', "/api/v1/resource-pool/{$pool['data']['uuid']}/members", $user, [
'members' => [['resource_uuid' => $elsewhere['data']['uuid']]],
]);
self::assertSame(422, $this->responseCode());
self::assertStringContainsString('یک شعبه', $body['errors'][0]['message']);
}
public function testMemberOfAnotherTypeIsRejected(): void
{
[$user, , $address] = $this->clinicWithAddress();
$laser = $this->resourceType($address, 'laser', 'دستگاه لیزر');
$chair = $this->resourceType($address, 'chair', 'صندلی');
$wrongType = $this->createResource($user, $address, $chair, ['name' => 'صندلی ۱']);
$pool = $this->authJson('POST', '/api/v1/resource-pools', $user, [
'address_uuid' => $address->getUuid(),
'type_uuid' => $laser->getUuid(),
'name' => 'استخر لیزر',
]);
$body = $this->authJson('PUT', "/api/v1/resource-pool/{$pool['data']['uuid']}/members", $user, [
'members' => [['resource_uuid' => $wrongType['data']['uuid']]],
]);
self::assertSame(422, $this->responseCode());
self::assertStringContainsString('یک نوع', $body['errors'][0]['message']);
}
/** عضو نامعتبر نباید اعضای درستِ قبلی را پاک کند. */
public function testInvalidMemberLeavesTheStoredMembersUntouched(): void
{
[$user, $clinic, $address] = $this->clinicWithAddress();
$second = $this->extraAddress($clinic);
$type = $this->resourceType($address, 'laser', 'دستگاه لیزر');
$good = $this->createResource($user, $address, $type, ['name' => 'لیزر خوب']);
$elsewhere = $this->createResource($user, $second, $type, ['name' => 'لیزر شعبهٔ دو']);
$pool = $this->authJson('POST', '/api/v1/resource-pools', $user, [
'address_uuid' => $address->getUuid(),
'type_uuid' => $type->getUuid(),
'name' => 'استخر',
]);
$this->authJson('PUT', "/api/v1/resource-pool/{$pool['data']['uuid']}/members", $user, [
'members' => [['resource_uuid' => $good['data']['uuid']]],
]);
$this->authJson('PUT', "/api/v1/resource-pool/{$pool['data']['uuid']}/members", $user, [
'members' => [
['resource_uuid' => $good['data']['uuid']],
['resource_uuid' => $elsewhere['data']['uuid']],
],
]);
self::assertSame(422, $this->responseCode());
$read = $this->authJson('GET', "/api/v1/resource-pool/{$pool['data']['uuid']}", $user);
self::assertCount(1, $read['data']['members']);
}
public function testForeignPoolIsNotFound(): void
{
[$user, , $address] = $this->clinicWithAddress();
$pool = $this->authJson('POST', '/api/v1/resource-pools', $user, [
'address_uuid' => $address->getUuid(),
'type_uuid' => $this->resourceType($address)->getUuid(),
'name' => 'استخر',
]);
[$otherUser] = $this->clinicWithAddress('شعبهٔ دیگر');
$this->authJson('GET', "/api/v1/resource-pool/{$pool['data']['uuid']}", $otherUser);
self::assertSame(404, $this->responseCode());
$this->authJson('PUT', "/api/v1/resource-pool/{$pool['data']['uuid']}/members", $otherUser, ['members' => []]);
self::assertSame(404, $this->responseCode());
}
/** حذف استخر اعضا را می‌برد ولی خودِ منابع باید سرِ جایشان بمانند. */
public function testDeletingAPoolKeepsItsResources(): void
{
[$user, , $address] = $this->clinicWithAddress();
$type = $this->resourceType($address, 'laser', 'لیزر');
$resource = $this->createResource($user, $address, $type, ['name' => 'لیزر ۱']);
$pool = $this->authJson('POST', '/api/v1/resource-pools', $user, [
'address_uuid' => $address->getUuid(),
'type_uuid' => $type->getUuid(),
'name' => 'استخر',
]);
$this->authJson('PUT', "/api/v1/resource-pool/{$pool['data']['uuid']}/members", $user, [
'members' => [['resource_uuid' => $resource['data']['uuid']]],
]);
$this->authJson('DELETE', "/api/v1/resource-pool/{$pool['data']['uuid']}", $user);
self::assertSame(200, $this->responseCode());
$this->authJson('GET', "/api/v1/resource/{$resource['data']['uuid']}", $user);
self::assertSame(200, $this->responseCode(), 'منبع نباید با استخر حذف شود');
}
public function testDuplicateMemberIsRejected(): void
{
[$user, , $address] = $this->clinicWithAddress();
$type = $this->resourceType($address, 'laser', 'لیزر');
$resource = $this->createResource($user, $address, $type, ['name' => 'لیزر ۱']);
$pool = $this->authJson('POST', '/api/v1/resource-pools', $user, [
'address_uuid' => $address->getUuid(),
'type_uuid' => $type->getUuid(),
'name' => 'استخر',
]);
$this->authJson('PUT', "/api/v1/resource-pool/{$pool['data']['uuid']}/members", $user, [
'members' => [
['resource_uuid' => $resource['data']['uuid']],
['resource_uuid' => $resource['data']['uuid']],
],
]);
self::assertSame(422, $this->responseCode());
}
}
+107
View File
@@ -0,0 +1,107 @@
<?php
namespace App\Tests\Resource;
use App\Auth\Entity\User;
use App\Branch\Entity\Room;
use App\Clinic\Entity\Clinic;
use App\Doctor\Entity\Doctor;
use App\Doctor\Entity\DoctorAddress;
use App\Resource\Entity\ResourceType;
use App\Staff\Entity\ClinicStaff;
use App\Tests\ApiTestCase;
/**
* فیکسچرهای مشترک دامنهٔ منبع. هر تست به یک محیط با آدرس و یک نوع منبع نیاز دارد،
* و برای سنجش مرز ۴۰۴ به یک محیط بیگانه.
*/
abstract class ResourceTestCase extends ApiTestCase
{
/** @return array{0: User, 1: Clinic, 2: DoctorAddress} */
protected function clinicWithAddress(string $addressName = 'شعبهٔ مرکزی'): array
{
$user = $this->createUser(['ROLE_USER', 'ROLE_CLINIC']);
$clinic = new Clinic($user);
$clinic->setName('کلینیک منابع');
$this->em->persist($clinic);
$this->em->flush();
$address = DoctorAddress::forClinic($clinic->getId());
$address->setName($addressName);
$this->em->persist($address);
$this->em->flush();
return [$user, $clinic, $address];
}
/** آدرس دوم همان محیط — برای سنجش قاعدهٔ «همهٔ اعضای استخر در یک شعبه». */
protected function extraAddress(Clinic $clinic, string $name = 'شعبهٔ دوم'): DoctorAddress
{
$address = DoctorAddress::forClinic($clinic->getId());
$address->setName($name);
$this->em->persist($address);
$this->em->flush();
return $address;
}
/** @return array{0: User, 1: Doctor, 2: DoctorAddress} */
protected function doctorWithAddress(): array
{
$user = $this->createUser(['ROLE_USER', 'ROLE_DOCTOR']);
$doctor = new Doctor($user, 'دکتر منبع');
$doctor->setMobileNumber($user->getMobileNumber());
$this->em->persist($doctor);
$this->em->flush();
$address = DoctorAddress::forDoctor($doctor);
$address->setName('مطب شخصی');
$this->em->persist($address);
$this->em->flush();
return [$user, $doctor, $address];
}
protected function resourceType(DoctorAddress $address, string $code = 'device', string $name = 'دستگاه'): ResourceType
{
$type = new ResourceType($address->tenantEntityType(), $address->tenantEntityId(), $code, $name);
$this->em->persist($type);
$this->em->flush();
return $type;
}
protected function staff(DoctorAddress $address, string $name = 'اپراتور یک'): ClinicStaff
{
$staff = new ClinicStaff($address->tenantEntityType(), $address->tenantEntityId(), $name);
$this->em->persist($staff);
$this->em->flush();
return $staff;
}
protected function room(DoctorAddress $address, string $name = 'اتاق تزریق', int $capacity = 1): Room
{
$room = new Room($address, $name);
$room->setCapacity($capacity);
$this->em->persist($room);
$this->em->flush();
return $room;
}
/** @param array<string, mixed> $body */
protected function createResource(User $user, DoctorAddress $address, ResourceType $type, array $body = []): array
{
return $this->authJson('POST', '/api/v1/resource', $user, $body + [
'address_uuid' => $address->getUuid(),
'type_uuid' => $type->getUuid(),
'name' => 'لیزر آلکساندرایت ۱',
]);
}
protected function createSkill(User $user, string $name = 'لیزر آلکساندرایت'): array
{
return $this->authJson('POST', '/api/v1/skills', $user, ['name' => $name]);
}
}
+127
View File
@@ -0,0 +1,127 @@
<?php
namespace App\Tests\Resource;
class ResourceTypeTest extends ResourceTestCase
{
public function testTypeIsCreatedAndListedWithUsageCount(): void
{
[$user, , $address] = $this->clinicWithAddress();
$created = $this->authJson('POST', '/api/v1/resource-types', $user, [
'code' => 'laser',
'name' => 'دستگاه لیزر',
]);
self::assertSame(201, $this->responseCode(), json_encode($created, JSON_UNESCAPED_UNICODE));
self::assertFalse($created['data']['is_system']);
self::assertSame(0, $created['data']['resources_count']);
$this->createResource($user, $address, $this->typeEntity($created['data']['uuid']));
$list = $this->authJson('GET', '/api/v1/resource-types', $user);
self::assertSame(1, $list['data'][0]['resources_count']);
}
public function testDuplicateCodeInOneEnvironmentIsRejected(): void
{
[$user] = $this->clinicWithAddress();
$this->authJson('POST', '/api/v1/resource-types', $user, ['code' => 'laser', 'name' => 'لیزر']);
$body = $this->authJson('POST', '/api/v1/resource-types', $user, ['code' => 'laser', 'name' => 'لیزر دوم']);
self::assertSame(422, $this->responseCode());
self::assertSame('code', $body['errors'][0]['field']);
}
/** همان کد در محیط دیگر مجاز است — یکتایی per محیط است، نه سراسری. */
public function testSameCodeInAnotherEnvironmentIsAllowed(): void
{
[$user] = $this->clinicWithAddress();
$this->authJson('POST', '/api/v1/resource-types', $user, ['code' => 'laser', 'name' => 'لیزر']);
[$otherUser] = $this->clinicWithAddress('شعبهٔ دیگر');
$this->authJson('POST', '/api/v1/resource-types', $otherUser, ['code' => 'laser', 'name' => 'لیزر']);
self::assertSame(201, $this->responseCode());
}
public function testInvalidCodeIsRejected(): void
{
[$user] = $this->clinicWithAddress();
$body = $this->authJson('POST', '/api/v1/resource-types', $user, ['code' => 'Laser Device', 'name' => 'x']);
self::assertSame(422, $this->responseCode());
self::assertSame('code', $body['errors'][0]['field']);
}
public function testTypeInUseCannotBeDeleted(): void
{
[$user, , $address] = $this->clinicWithAddress();
$created = $this->authJson('POST', '/api/v1/resource-types', $user, ['code' => 'laser', 'name' => 'لیزر']);
$this->createResource($user, $address, $this->typeEntity($created['data']['uuid']));
$body = $this->authJson('DELETE', "/api/v1/resource-type/{$created['data']['uuid']}", $user);
self::assertSame(422, $this->responseCode());
self::assertStringContainsString('1 منبع', $body['errors'][0]['message']);
}
public function testUnusedTypeIsDeleted(): void
{
[$user] = $this->clinicWithAddress();
$created = $this->authJson('POST', '/api/v1/resource-types', $user, ['code' => 'laser', 'name' => 'لیزر']);
$this->authJson('DELETE', "/api/v1/resource-type/{$created['data']['uuid']}", $user);
self::assertSame(200, $this->responseCode());
}
/** نوع سیستمی هرگز حذف نمی‌شود: ResourceLinker با همان کد پل می‌زند. */
public function testSystemTypeCannotBeDeletedEvenWhenUnused(): void
{
[$user, , $address] = $this->clinicWithAddress();
$type = $this->resourceType($address, 'room', 'اتاق');
$type->markSystem();
$this->em->flush();
$body = $this->authJson('DELETE', "/api/v1/resource-type/{$type->getUuid()}", $user);
self::assertSame(422, $this->responseCode());
self::assertStringContainsString('سیستمی', $body['errors'][0]['message']);
}
public function testForeignTypeIsNotFound(): void
{
[$user] = $this->clinicWithAddress();
$created = $this->authJson('POST', '/api/v1/resource-types', $user, ['code' => 'laser', 'name' => 'لیزر']);
[$otherUser] = $this->clinicWithAddress('شعبهٔ دیگر');
$this->authJson('PATCH', "/api/v1/resource-type/{$created['data']['uuid']}", $otherUser, ['name' => 'x']);
self::assertSame(404, $this->responseCode());
}
/** `code` عوض نمی‌شود: منابع موجود و ResourceLinker با همان کد پیدا می‌شوند. */
public function testCodeIsImmutable(): void
{
[$user] = $this->clinicWithAddress();
$created = $this->authJson('POST', '/api/v1/resource-types', $user, ['code' => 'laser', 'name' => 'لیزر']);
$body = $this->authJson('PATCH', "/api/v1/resource-type/{$created['data']['uuid']}", $user, [
'code' => 'something_else',
'name' => 'نام تازه',
]);
self::assertSame(200, $this->responseCode());
self::assertSame('laser', $body['data']['code']);
self::assertSame('نام تازه', $body['data']['name']);
}
private function typeEntity(string $uuid): \App\Resource\Entity\ResourceType
{
return $this->em->getRepository(\App\Resource\Entity\ResourceType::class)->findOneBy(['uuid' => $uuid]);
}
}
+176
View File
@@ -0,0 +1,176 @@
<?php
namespace App\Tests\Resource;
class SkillAssignmentTest extends ResourceTestCase
{
public function testSkillsAreReplacedWholesale(): void
{
[$user, , $address] = $this->clinicWithAddress();
$resource = $this->createResource($user, $address, $this->resourceType($address));
$laser = $this->createSkill($user, 'لیزر آلکساندرایت');
$botox = $this->createSkill($user, 'بوتاکس');
$withBoth = $this->authJson('PUT', "/api/v1/resource/{$resource['data']['uuid']}/skills", $user, [
'skills' => [
['skill_uuid' => $laser['data']['uuid'], 'level' => 4],
['skill_uuid' => $botox['data']['uuid']],
],
]);
self::assertSame(200, $this->responseCode(), json_encode($withBoth, JSON_UNESCAPED_UNICODE));
self::assertCount(2, $withBoth['data']['skills']);
self::assertSame(1, $withBoth['data']['skills'][1]['level'], 'سطح پیش‌فرض ۱ است');
// PUT جایگزینی کامل است: مهارتی که نیامده، برداشته می‌شود.
$onlyLaser = $this->authJson('PUT', "/api/v1/resource/{$resource['data']['uuid']}/skills", $user, [
'skills' => [['skill_uuid' => $laser['data']['uuid'], 'level' => 5]],
]);
self::assertCount(1, $onlyLaser['data']['skills']);
self::assertSame($laser['data']['uuid'], $onlyLaser['data']['skills'][0]['skill_uuid']);
self::assertSame(5, $onlyLaser['data']['skills'][0]['level']);
}
public function testEmptyListClearsAllSkills(): void
{
[$user, , $address] = $this->clinicWithAddress();
$resource = $this->createResource($user, $address, $this->resourceType($address));
$skill = $this->createSkill($user);
$this->authJson('PUT', "/api/v1/resource/{$resource['data']['uuid']}/skills", $user, [
'skills' => [['skill_uuid' => $skill['data']['uuid']]],
]);
$body = $this->authJson('PUT', "/api/v1/resource/{$resource['data']['uuid']}/skills", $user, ['skills' => []]);
self::assertSame(200, $this->responseCode());
self::assertSame([], $body['data']['skills']);
}
public function testLevelOutsideOneToFiveIsRejected(): void
{
[$user, , $address] = $this->clinicWithAddress();
$resource = $this->createResource($user, $address, $this->resourceType($address));
$skill = $this->createSkill($user);
foreach ([0, 6] as $level) {
$body = $this->authJson('PUT', "/api/v1/resource/{$resource['data']['uuid']}/skills", $user, [
'skills' => [['skill_uuid' => $skill['data']['uuid'], 'level' => $level]],
]);
self::assertSame(422, $this->responseCode(), "level=$level باید رد شود");
self::assertSame('level', $body['errors'][0]['field']);
}
}
/**
* اعتبارسنجی پیش از حذف: ورودی نامعتبر در انتهای فهرست نباید مهارت‌های درستِ
* قبلی را پاک کند و بعد ۴۲۲ برگرداند.
*/
public function testInvalidRowLeavesTheStoredSkillsUntouched(): void
{
[$user, , $address] = $this->clinicWithAddress();
$resource = $this->createResource($user, $address, $this->resourceType($address));
$laser = $this->createSkill($user, 'لیزر');
$botox = $this->createSkill($user, 'بوتاکس');
$this->authJson('PUT', "/api/v1/resource/{$resource['data']['uuid']}/skills", $user, [
'skills' => [['skill_uuid' => $laser['data']['uuid'], 'level' => 3]],
]);
$this->authJson('PUT', "/api/v1/resource/{$resource['data']['uuid']}/skills", $user, [
'skills' => [
['skill_uuid' => $botox['data']['uuid']],
['skill_uuid' => $laser['data']['uuid'], 'level' => 99],
],
]);
self::assertSame(422, $this->responseCode());
$read = $this->authJson('GET', "/api/v1/resource/{$resource['data']['uuid']}", $user);
self::assertCount(1, $read['data']['skills']);
self::assertSame(3, $read['data']['skills'][0]['level']);
}
public function testDuplicateSkillInOnePayloadIsRejected(): void
{
[$user, , $address] = $this->clinicWithAddress();
$resource = $this->createResource($user, $address, $this->resourceType($address));
$skill = $this->createSkill($user);
$this->authJson('PUT', "/api/v1/resource/{$resource['data']['uuid']}/skills", $user, [
'skills' => [
['skill_uuid' => $skill['data']['uuid']],
['skill_uuid' => $skill['data']['uuid'], 'level' => 2],
],
]);
self::assertSame(422, $this->responseCode());
}
public function testForeignSkillIsNotFound(): void
{
[$user, , $address] = $this->clinicWithAddress();
$resource = $this->createResource($user, $address, $this->resourceType($address));
[$otherUser] = $this->clinicWithAddress('شعبهٔ دیگر');
$foreignSkill = $this->createSkill($otherUser, 'مهارت بیگانه');
$this->authJson('PUT', "/api/v1/resource/{$resource['data']['uuid']}/skills", $user, [
'skills' => [['skill_uuid' => $foreignSkill['data']['uuid']]],
]);
self::assertSame(404, $this->responseCode());
}
/** مهارتی که روی منبعی نشسته حذف نمی‌شود؛ وگرنه FK خطای خام دیتابیس می‌داد. */
public function testSkillInUseCannotBeDeleted(): void
{
[$user, , $address] = $this->clinicWithAddress();
$resource = $this->createResource($user, $address, $this->resourceType($address));
$skill = $this->createSkill($user);
$this->authJson('PUT', "/api/v1/resource/{$resource['data']['uuid']}/skills", $user, [
'skills' => [['skill_uuid' => $skill['data']['uuid']]],
]);
$body = $this->authJson('DELETE', "/api/v1/skill/{$skill['data']['uuid']}", $user);
self::assertSame(422, $this->responseCode());
// رقم لاتین عمدی است: قرارداد پیام‌های درون‌ریزیِ بک‌اند همین است
// (BackfillServiceDurationCommand، WorkingHoursService و …) و قالب‌بندی فارسی
// کارِ نمایش در کلاینت است.
self::assertStringContainsString('1 منبع', $body['errors'][0]['message']);
// بعد از برداشتن از منبع، حذف مجاز است.
$this->authJson('PUT', "/api/v1/resource/{$resource['data']['uuid']}/skills", $user, ['skills' => []]);
$this->authJson('DELETE', "/api/v1/skill/{$skill['data']['uuid']}", $user);
self::assertSame(200, $this->responseCode());
}
public function testSkillListReportsUsageCount(): void
{
[$user, , $address] = $this->clinicWithAddress();
$resource = $this->createResource($user, $address, $this->resourceType($address));
$skill = $this->createSkill($user);
$before = $this->authJson('GET', '/api/v1/skills', $user);
self::assertSame(0, $before['data'][0]['resources_count']);
$this->authJson('PUT', "/api/v1/resource/{$resource['data']['uuid']}/skills", $user, [
'skills' => [['skill_uuid' => $skill['data']['uuid']]],
]);
$after = $this->authJson('GET', '/api/v1/skills', $user);
self::assertSame(1, $after['data'][0]['resources_count']);
}
public function testBlankSkillNameIsRejected(): void
{
[$user] = $this->clinicWithAddress();
$body = $this->authJson('POST', '/api/v1/skills', $user, ['name' => ' ']);
self::assertSame(422, $this->responseCode());
self::assertSame('name', $body['errors'][0]['field']);
}
}