From 964c09cc0048f772658612fbff249f9e1add2eb7 Mon Sep 17 00:00:00 2001 From: hamed <15238-genius.ha@users.noreply.drupalcode.org> Date: Thu, 30 Jul 2026 17:34:36 +0330 Subject: [PATCH] feat(resource): resource types, resources, skills and pools MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- assets/admin/types/index.ts | 87 ++++++ migrations/Version20260730132948.php | 69 +++++ .../Command/BackfillResourceCommand.php | 248 ++++++++++++++++++ .../Controller/ResourceController.php | 138 ++++++++++ .../Controller/ResourcePermissionTrait.php | 37 +++ .../Controller/ResourcePoolController.php | 141 ++++++++++ .../Controller/ResourceTypeController.php | 142 ++++++++++ src/Resource/Controller/SkillController.php | 110 ++++++++ src/Resource/Entity/ClinicResource.php | 247 +++++++++++++++++ src/Resource/Entity/ResourcePool.php | 109 ++++++++ src/Resource/Entity/ResourcePoolMember.php | 56 ++++ src/Resource/Entity/ResourceSkill.php | 73 ++++++ src/Resource/Entity/ResourceType.php | 105 ++++++++ src/Resource/Entity/Skill.php | 83 ++++++ .../Repository/ClinicResourceRepository.php | 172 ++++++++++++ .../ResourcePoolMemberRepository.php | 29 ++ .../Repository/ResourcePoolRepository.php | 36 +++ .../Repository/ResourceSkillRepository.php | 66 +++++ .../Repository/ResourceTypeRepository.php | 42 +++ src/Resource/Repository/SkillRepository.php | 53 ++++ src/Resource/Service/ResourceContext.php | 87 ++++++ src/Resource/Service/ResourceLinker.php | 133 ++++++++++ src/Resource/Service/ResourcePoolService.php | 105 ++++++++ src/Resource/Service/ResourceService.php | 179 +++++++++++++ .../Service/SkillAssignmentService.php | 105 ++++++++ src/Shared/Tenant/GlobalTables.php | 6 + tests/Resource/BackfillResourceTest.php | 209 +++++++++++++++ tests/Resource/ResourceCrudTest.php | 210 +++++++++++++++ tests/Resource/ResourceEligibilityTest.php | 156 +++++++++++ tests/Resource/ResourcePoolTest.php | 181 +++++++++++++ tests/Resource/ResourceTestCase.php | 107 ++++++++ tests/Resource/ResourceTypeTest.php | 127 +++++++++ tests/Resource/SkillAssignmentTest.php | 176 +++++++++++++ 33 files changed, 3824 insertions(+) create mode 100644 migrations/Version20260730132948.php create mode 100644 src/Resource/Command/BackfillResourceCommand.php create mode 100644 src/Resource/Controller/ResourceController.php create mode 100644 src/Resource/Controller/ResourcePermissionTrait.php create mode 100644 src/Resource/Controller/ResourcePoolController.php create mode 100644 src/Resource/Controller/ResourceTypeController.php create mode 100644 src/Resource/Controller/SkillController.php create mode 100644 src/Resource/Entity/ClinicResource.php create mode 100644 src/Resource/Entity/ResourcePool.php create mode 100644 src/Resource/Entity/ResourcePoolMember.php create mode 100644 src/Resource/Entity/ResourceSkill.php create mode 100644 src/Resource/Entity/ResourceType.php create mode 100644 src/Resource/Entity/Skill.php create mode 100644 src/Resource/Repository/ClinicResourceRepository.php create mode 100644 src/Resource/Repository/ResourcePoolMemberRepository.php create mode 100644 src/Resource/Repository/ResourcePoolRepository.php create mode 100644 src/Resource/Repository/ResourceSkillRepository.php create mode 100644 src/Resource/Repository/ResourceTypeRepository.php create mode 100644 src/Resource/Repository/SkillRepository.php create mode 100644 src/Resource/Service/ResourceContext.php create mode 100644 src/Resource/Service/ResourceLinker.php create mode 100644 src/Resource/Service/ResourcePoolService.php create mode 100644 src/Resource/Service/ResourceService.php create mode 100644 src/Resource/Service/SkillAssignmentService.php create mode 100644 tests/Resource/BackfillResourceTest.php create mode 100644 tests/Resource/ResourceCrudTest.php create mode 100644 tests/Resource/ResourceEligibilityTest.php create mode 100644 tests/Resource/ResourcePoolTest.php create mode 100644 tests/Resource/ResourceTestCase.php create mode 100644 tests/Resource/ResourceTypeTest.php create mode 100644 tests/Resource/SkillAssignmentTest.php diff --git a/assets/admin/types/index.ts b/assets/admin/types/index.ts index d540896b..b41418b7 100644 --- a/assets/admin/types/index.ts +++ b/assets/admin/types/index.ts @@ -938,3 +938,90 @@ export interface RoomPayload { floor?: string | null; active?: boolean; } + +// ── منابع: نوع، منبع، مهارت، استخر ─────────────────────────────────────────── +// «تقویم مال منبع است، نه مال پزشک» — منبع هر چیزی است که ممکن است اشغال باشد. +// هر منبع مال یک شعبه است، و شعبه همان آدرس محل نوبت‌دهی است. + +export interface ResourceType { + uuid: string; + code: string; + name: string; + /** نوع‌های doctor/staff/room را backfill می‌سازد و حذف نمی‌شوند */ + is_system: boolean; + active: boolean; + resources_count?: number; + created_at: number; + updated_at: number; +} + +export interface Skill { + uuid: string; + name: string; + active: boolean; + resources_count?: number; + created_at: number; + updated_at: number; +} + +export interface ResourceSkillLine { + skill_uuid: string; + skill_name: string; + /** ۱..۵ — تسک بعدی استراتژی «حفظ متخصص‌ها» را روی همین می‌سازد */ + level: number; +} + +export interface ClinicResource { + uuid: string; + name: string; + address_uuid: string; + address_name: string | null; + type_uuid: string; + type_code: string; + type_name: string; + /** ظرفیت هم‌زمان: اتاق سه‌تخته یک منبع با ظرفیت ۳ است، نه سه منبع */ + capacity: number; + /** جزو نوبت بیمار نیست، ولی منبع را اشغال می‌کند */ + setup_minutes: number; + cleanup_minutes: number; + attributes: Record; + /** `null` یعنی دستگاه/تجهیزات — منبعی که پل به موجودیت دیگری ندارد */ + subject_kind: 'doctor' | 'staff' | 'room' | null; + subject_uuid: string | null; + skills: ResourceSkillLine[]; + active: boolean; + created_at: number; + updated_at: number; +} + +export interface ResourcePayload { + name: string; + address_uuid?: string; + type_uuid?: string; + capacity?: number; + setup_minutes?: number; + cleanup_minutes?: number; + attributes?: Record; + active?: boolean; +} + +export interface ResourcePoolMemberLine { + resource_uuid: string; + resource_name: string; + priority: number; + active: boolean; +} + +export interface ResourcePool { + uuid: string; + name: string; + address_uuid: string; + address_name: string | null; + type_uuid: string; + type_code: string; + type_name: string; + members: ResourcePoolMemberLine[]; + active: boolean; + created_at: number; + updated_at: number; +} diff --git a/migrations/Version20260730132948.php b/migrations/Version20260730132948.php new file mode 100644 index 00000000..32f6b5e9 --- /dev/null +++ b/migrations/Version20260730132948.php @@ -0,0 +1,69 @@ +addSql('CREATE TABLE clinic_resources (id INT AUTO_INCREMENT NOT NULL, uuid VARCHAR(36) NOT NULL, name VARCHAR(150) NOT NULL, capacity SMALLINT DEFAULT 1 NOT NULL, setup_minutes SMALLINT DEFAULT 0 NOT NULL, cleanup_minutes SMALLINT DEFAULT 0 NOT NULL, attributes JSON DEFAULT NULL, active TINYINT DEFAULT 1 NOT NULL, created_at INT NOT NULL, updated_at INT NOT NULL, entity_type VARCHAR(10) NOT NULL, entity_id INT NOT NULL, address_id INT NOT NULL, resource_type_id INT NOT NULL, doctor_id INT DEFAULT NULL, staff_id INT DEFAULT NULL, room_id INT DEFAULT NULL, UNIQUE INDEX UNIQ_10DABCC5D17F50A6 (uuid), INDEX IDX_10DABCC5F5B7AF75 (address_id), INDEX IDX_10DABCC598EC6B7B (resource_type_id), INDEX IDX_10DABCC587F4FB17 (doctor_id), INDEX IDX_10DABCC5D4D57CD (staff_id), INDEX idx_resources_tenant (entity_type, entity_id, active), INDEX idx_resources_address_type (address_id, resource_type_id, active), UNIQUE INDEX uniq_resource_doctor_address (doctor_id, address_id), UNIQUE INDEX uniq_resource_staff_address (staff_id, address_id), UNIQUE INDEX uniq_resource_room (room_id), PRIMARY KEY (id)) DEFAULT CHARACTER SET utf8mb4'); + $this->addSql('CREATE TABLE resource_pool_members (id INT AUTO_INCREMENT NOT NULL, priority SMALLINT DEFAULT 0 NOT NULL, pool_id INT NOT NULL, resource_id INT NOT NULL, INDEX IDX_D7A36F257B3406DF (pool_id), INDEX idx_pool_members_resource (resource_id), UNIQUE INDEX uniq_pool_resource (pool_id, resource_id), PRIMARY KEY (id)) DEFAULT CHARACTER SET utf8mb4'); + $this->addSql('CREATE TABLE resource_pools (id INT AUTO_INCREMENT NOT NULL, uuid VARCHAR(36) NOT NULL, name VARCHAR(150) NOT NULL, active TINYINT DEFAULT 1 NOT NULL, created_at INT NOT NULL, updated_at INT NOT NULL, entity_type VARCHAR(10) NOT NULL, entity_id INT NOT NULL, address_id INT NOT NULL, resource_type_id INT NOT NULL, UNIQUE INDEX UNIQ_34C10285D17F50A6 (uuid), INDEX IDX_34C10285F5B7AF75 (address_id), INDEX IDX_34C1028598EC6B7B (resource_type_id), INDEX idx_pools_tenant (entity_type, entity_id, active), INDEX idx_pools_address (address_id, resource_type_id), PRIMARY KEY (id)) DEFAULT CHARACTER SET utf8mb4'); + $this->addSql('CREATE TABLE resource_skills (id INT AUTO_INCREMENT NOT NULL, level SMALLINT DEFAULT 1 NOT NULL, created_at INT NOT NULL, resource_id INT NOT NULL, skill_id INT NOT NULL, INDEX IDX_1DCDFC8A89329D25 (resource_id), INDEX IDX_1DCDFC8A5585C142 (skill_id), INDEX idx_resource_skills_skill (skill_id, level), UNIQUE INDEX uniq_resource_skill (resource_id, skill_id), PRIMARY KEY (id)) DEFAULT CHARACTER SET utf8mb4'); + $this->addSql('CREATE TABLE resource_types (id INT AUTO_INCREMENT NOT NULL, uuid VARCHAR(36) NOT NULL, code VARCHAR(40) NOT NULL, name VARCHAR(100) NOT NULL, is_system TINYINT DEFAULT 0 NOT NULL, active TINYINT DEFAULT 1 NOT NULL, created_at INT NOT NULL, updated_at INT NOT NULL, entity_type VARCHAR(10) NOT NULL, entity_id INT NOT NULL, UNIQUE INDEX UNIQ_728BF302D17F50A6 (uuid), INDEX idx_resource_types_tenant (entity_type, entity_id, active), UNIQUE INDEX uniq_rt_tenant_code (entity_type, entity_id, code), PRIMARY KEY (id)) DEFAULT CHARACTER SET utf8mb4'); + $this->addSql('CREATE TABLE skills (id INT AUTO_INCREMENT NOT NULL, uuid VARCHAR(36) NOT NULL, name VARCHAR(120) NOT NULL, active TINYINT DEFAULT 1 NOT NULL, created_at INT NOT NULL, updated_at INT NOT NULL, entity_type VARCHAR(10) NOT NULL, entity_id INT NOT NULL, UNIQUE INDEX UNIQ_D5311670D17F50A6 (uuid), INDEX idx_skills_tenant (entity_type, entity_id, active), PRIMARY KEY (id)) DEFAULT CHARACTER SET utf8mb4'); + $this->addSql('ALTER TABLE clinic_resources ADD CONSTRAINT FK_10DABCC5F5B7AF75 FOREIGN KEY (address_id) REFERENCES doctor_addresses (id) ON DELETE RESTRICT'); + $this->addSql('ALTER TABLE clinic_resources ADD CONSTRAINT FK_10DABCC598EC6B7B FOREIGN KEY (resource_type_id) REFERENCES resource_types (id) ON DELETE RESTRICT'); + $this->addSql('ALTER TABLE clinic_resources ADD CONSTRAINT FK_10DABCC587F4FB17 FOREIGN KEY (doctor_id) REFERENCES doctors (id) ON DELETE CASCADE'); + $this->addSql('ALTER TABLE clinic_resources ADD CONSTRAINT FK_10DABCC5D4D57CD FOREIGN KEY (staff_id) REFERENCES clinic_staff (id) ON DELETE CASCADE'); + $this->addSql('ALTER TABLE clinic_resources ADD CONSTRAINT FK_10DABCC554177093 FOREIGN KEY (room_id) REFERENCES rooms (id) ON DELETE CASCADE'); + $this->addSql('ALTER TABLE resource_pool_members ADD CONSTRAINT FK_D7A36F257B3406DF FOREIGN KEY (pool_id) REFERENCES resource_pools (id) ON DELETE CASCADE'); + $this->addSql('ALTER TABLE resource_pool_members ADD CONSTRAINT FK_D7A36F2589329D25 FOREIGN KEY (resource_id) REFERENCES clinic_resources (id) ON DELETE CASCADE'); + $this->addSql('ALTER TABLE resource_pools ADD CONSTRAINT FK_34C10285F5B7AF75 FOREIGN KEY (address_id) REFERENCES doctor_addresses (id) ON DELETE CASCADE'); + $this->addSql('ALTER TABLE resource_pools ADD CONSTRAINT FK_34C1028598EC6B7B FOREIGN KEY (resource_type_id) REFERENCES resource_types (id) ON DELETE RESTRICT'); + $this->addSql('ALTER TABLE resource_skills ADD CONSTRAINT FK_1DCDFC8A89329D25 FOREIGN KEY (resource_id) REFERENCES clinic_resources (id) ON DELETE CASCADE'); + $this->addSql('ALTER TABLE resource_skills ADD CONSTRAINT FK_1DCDFC8A5585C142 FOREIGN KEY (skill_id) REFERENCES skills (id) ON DELETE RESTRICT'); + } + + public function down(Schema $schema): void + { + $this->addSql('ALTER TABLE clinic_resources DROP FOREIGN KEY FK_10DABCC5F5B7AF75'); + $this->addSql('ALTER TABLE clinic_resources DROP FOREIGN KEY FK_10DABCC598EC6B7B'); + $this->addSql('ALTER TABLE clinic_resources DROP FOREIGN KEY FK_10DABCC587F4FB17'); + $this->addSql('ALTER TABLE clinic_resources DROP FOREIGN KEY FK_10DABCC5D4D57CD'); + $this->addSql('ALTER TABLE clinic_resources DROP FOREIGN KEY FK_10DABCC554177093'); + $this->addSql('ALTER TABLE resource_pool_members DROP FOREIGN KEY FK_D7A36F257B3406DF'); + $this->addSql('ALTER TABLE resource_pool_members DROP FOREIGN KEY FK_D7A36F2589329D25'); + $this->addSql('ALTER TABLE resource_pools DROP FOREIGN KEY FK_34C10285F5B7AF75'); + $this->addSql('ALTER TABLE resource_pools DROP FOREIGN KEY FK_34C1028598EC6B7B'); + $this->addSql('ALTER TABLE resource_skills DROP FOREIGN KEY FK_1DCDFC8A89329D25'); + $this->addSql('ALTER TABLE resource_skills DROP FOREIGN KEY FK_1DCDFC8A5585C142'); + $this->addSql('DROP TABLE clinic_resources'); + $this->addSql('DROP TABLE resource_pool_members'); + $this->addSql('DROP TABLE resource_pools'); + $this->addSql('DROP TABLE resource_skills'); + $this->addSql('DROP TABLE resource_types'); + $this->addSql('DROP TABLE skills'); + } +} diff --git a/src/Resource/Command/BackfillResourceCommand.php b/src/Resource/Command/BackfillResourceCommand.php new file mode 100644 index 00000000..f80431a4 --- /dev/null +++ b/src/Resource/Command/BackfillResourceCommand.php @@ -0,0 +1,248 @@ +addOption('force', null, InputOption::VALUE_NONE, 'Actually write; without it the command only reports'); + $this->addOption('pair', null, InputOption::VALUE_REQUIRED, 'Limit to one environment, e.g. clinic:12 or doctor:7'); + } + + protected function execute(InputInterface $input, OutputInterface $output): int + { + $io = new SymfonyStyle($input, $output); + $force = (bool) $input->getOption('force'); + + if (!$force) { + $io->note('Dry run — nothing will be written. Re-run with --force to apply.'); + } + + $created = ['room' => 0, 'staff' => 0, 'doctor' => 0]; + $skipped = []; + /** @var list $rows */ + $rows = []; + + $only = $input->getOption('pair'); + $addressesByPair = $this->addressesByPair(); + + if (is_string($only) && $only !== '') { + $addressesByPair = array_intersect_key($addressesByPair, [$only => true]); + + if ($addressesByPair === []) { + $io->warning(sprintf('محیط «%s» هیچ شعبه‌ای ندارد.', $only)); + + return Command::SUCCESS; + } + } + + foreach ($addressesByPair as $pairKey => $addresses) { + [$entityType, $entityId] = explode(':', $pairKey); + + foreach (ResourceType::SYSTEM_CODES as $code => $_) { + $this->linker->systemType($entityType, (int) $entityId, $code); + } + + // ── اتاق‌ها: آدرسشان را خودشان دارند، پس بی‌ابهام‌اند ────────────────── + foreach ($addresses as $address) { + foreach ($this->em->getRepository(Room::class)->findForAddress($address) as $room) { + if ($this->resources->findForSubject($room) !== null) { + continue; + } + + $rows[] = ['room', (string) $room->getName(), (string) ($address->getName() ?? '—')]; + $created['room']++; + + if ($force) { + $this->linker->link($room, $address, $room->getName()); + } + } + } + + // ── پرسنل: هیچ ستونی آدرسش را نمی‌گوید ──────────────────────────────── + $staffMembers = $this->em->getRepository(ClinicStaff::class) + ->findBy(['entityType' => $entityType, 'entityId' => (int) $entityId, 'active' => true]); + + foreach ($staffMembers as $staff) { + // با بیش از یک آدرس، انتخاب یکی حدس است و پرسنل را در ساختمان اشتباه + // می‌نشاند. گزارش می‌شود تا کاربر خودش تعیین کند، نه حدس بی‌صدا. + if (count($addresses) !== 1) { + $skipped[] = sprintf( + 'پرسنل «%s» — محیط %s:%s شعبهٔ یکتا ندارد (%d شعبه)', + $staff->getFullName(), + $entityType, + $entityId, + count($addresses), + ); + continue; + } + + $address = $addresses[array_key_first($addresses)]; + + if ($this->resources->findForSubject($staff, $address) !== null) { + continue; + } + + $rows[] = ['staff', (string) $staff->getFullName(), (string) ($address->getName() ?? '—')]; + $created['staff']++; + + if ($force) { + $this->linker->link($staff, $address, $staff->getFullName()); + } + } + + // flush per محیط: یک ردیفِ خرابِ یک کلینیک نباید کل اجرای چندهزارمحیطی را + // با EntityManagerClosed از پا بیندازد. + if ($force) { + $this->em->flush(); + $this->linker->forgetPendingTypes(); + } + } + + // پزشکان یک پاسِ جدا دارند: برنامهٔ هفتگی خودش محیط و آدرس‌هایش را می‌گوید، + // پس یک بار روی همهٔ برنامه‌ها می‌رویم. حلقه‌زدن روی برنامه‌ها *به‌ازای هر محیط* + // ضرب‌درِ تعداد محیط‌ها بود و روی دادهٔ واقعی هرگز تمام نمی‌شد. + $created['doctor'] = $this->backfillDoctors($addressesByPair, $force, $rows); + + if ($force) { + $this->em->flush(); + $this->linker->forgetPendingTypes(); + } + + if ($rows !== []) { + $io->table(['نوع', 'نام', 'شعبه'], $rows); + } + + foreach ($skipped as $reason) { + $io->warning($reason); + } + + $io->success(sprintf( + '%s — اتاق: %d · پرسنل: %d · پزشک: %d', + $force ? 'ساخته شد' : 'ساخته می‌شود', + $created['room'], + $created['staff'], + $created['doctor'], + )); + + return Command::SUCCESS; + } + + /** + * منبعِ پزشک از `location_id`های برنامهٔ هفتگی مشتق می‌شود: آنجا دقیقاً نوشته که + * این پزشک در کدام آدرس‌ها شیفت دارد. «اولین شعبهٔ محیط» حدس می‌بود. + * + * یک پاس روی همهٔ برنامه‌ها، نه یک پاس به‌ازای هر محیط: محیطِ هر برنامه از خودش + * خوانده می‌شود. + * + * @param array> $addressesByPair + * @param list $rows + */ + private function backfillDoctors(array $addressesByPair, bool $force, array &$rows): int + { + $created = 0; + + foreach ($this->em->getRepository(WeeklySchedule::class)->findAll() as $schedule) { + $clinic = $schedule->getClinic(); + $doctor = $schedule->getDoctor(); + $pairKey = $clinic !== null + ? 'clinic:' . $clinic->getId() + : 'doctor:' . $doctor->getId(); + + $addresses = $addressesByPair[$pairKey] ?? null; + + if ($addresses === null) { + continue; + } + + foreach ($this->locationIdsOf($schedule) as $locationId) { + $address = $addresses[$locationId] ?? null; + + if ($address === null || $this->resources->findForSubject($doctor, $address) !== null) { + continue; + } + + $rows[] = ['doctor', (string) $doctor->getName(), (string) ($address->getName() ?? '—')]; + $created++; + + if ($force) { + $this->linker->link($doctor, $address, $doctor->getName()); + } + } + } + + return $created; + } + + /** @return int[] شناسهٔ آدرس‌هایی که این برنامه شیفت فعالی رویشان دارد */ + private function locationIdsOf(WeeklySchedule $schedule): array + { + $ids = []; + + foreach ($schedule->getSetting() as $day) { + foreach (($day['sessions'] ?? []) as $session) { + $locationId = $session['location_id'] ?? null; + + if (($session['active'] ?? false) && is_numeric($locationId)) { + $ids[(int) $locationId] = true; + } + } + } + + return array_keys($ids); + } + + /** @return array> "type:id" => [شناسهٔ آدرس => آدرس] */ + private function addressesByPair(): array + { + $grouped = []; + + foreach ($this->em->getRepository(DoctorAddress::class)->findAll() as $address) { + try { + $key = $address->tenantEntityType() . ':' . $address->tenantEntityId(); + } catch (\LogicException) { + // آدرس بی‌مالک؛ نه منبعی می‌گیرد نه محیطی دارد. + continue; + } + + $grouped[$key][(int) $address->getId()] = $address; + } + + return $grouped; + } +} diff --git a/src/Resource/Controller/ResourceController.php b/src/Resource/Controller/ResourceController.php new file mode 100644 index 00000000..785c08fc --- /dev/null +++ b/src/Resource/Controller/ResourceController.php @@ -0,0 +1,138 @@ +denyUnlessGranted($user, 'view'); + + [$entityType, $entityId] = $this->context->pair($user); + + $addressUuid = $request->query->get('address_uuid'); + $typeUuid = $request->query->get('type_uuid'); + $activeParam = $request->query->get('active'); + + $filters = [ + 'address' => is_string($addressUuid) && $addressUuid !== '' ? $this->context->address($user, $addressUuid) : null, + 'type' => is_string($typeUuid) && $typeUuid !== '' ? $this->context->type($user, $typeUuid) : null, + 'active' => $activeParam === null || $activeParam === '' ? null : filter_var($activeParam, FILTER_VALIDATE_BOOL), + 'skillUuid' => $request->query->get('skill_uuid') ?: null, + ]; + + // مهارتِ محیط دیگر نباید بی‌صدا «هیچ نتیجه» بدهد؛ ۴۰۴ صریح است. + if ($filters['skillUuid'] !== null) { + $this->context->skill($user, $filters['skillUuid']); + } + + return $this->success(array_map( + static fn (ClinicResource $r): array => $r->toArray(), + $this->resources->findForPair($entityType, $entityId, $filters), + )); + } + + #[Route('/api/v1/resource', name: 'resource_create', methods: ['POST'])] + public function create(#[CurrentUser] User $user, Request $request): JsonResponse + { + $this->denyUnlessGranted($user, 'update'); + + $data = json_decode($request->getContent(), true); + + if (!is_array($data) || !is_string($data['address_uuid'] ?? null)) { + return $this->error(ErrorCodes::ERR_VALIDATION_002, 'فیلد address_uuid الزامی است', 422, 'address_uuid'); + } + + if (!is_string($data['type_uuid'] ?? null)) { + return $this->error(ErrorCodes::ERR_VALIDATION_002, 'فیلد type_uuid الزامی است', 422, 'type_uuid'); + } + + $address = $this->context->address($user, $data['address_uuid']); + $type = $this->context->type($user, $data['type_uuid']); + + return $this->success($this->service->create($address, $type, $data)->toArray(), 201); + } + + #[Route('/api/v1/resource/{uuid}', name: 'resource_show', methods: ['GET'])] + public function show(#[CurrentUser] User $user, string $uuid): JsonResponse + { + $this->denyUnlessGranted($user, 'view'); + + return $this->success($this->context->resource($user, $uuid)->toArray()); + } + + #[Route('/api/v1/resource/{uuid}', name: 'resource_update', methods: ['PATCH'])] + public function update(#[CurrentUser] User $user, string $uuid, Request $request): JsonResponse + { + $this->denyUnlessGranted($user, 'update'); + + $data = json_decode($request->getContent(), true); + + if (!is_array($data)) { + return $this->error(ErrorCodes::ERR_VALIDATION_001, 'بدنهٔ درخواست نامعتبر است', 422); + } + + $resource = $this->context->resource($user, $uuid); + + if (is_string($data['type_uuid'] ?? null)) { + $resource->setType($this->context->type($user, $data['type_uuid'])); + } + + return $this->success($this->service->update($resource, $data)->toArray()); + } + + #[Route('/api/v1/resource/{uuid}', name: 'resource_delete', methods: ['DELETE'])] + public function delete(#[CurrentUser] User $user, string $uuid): JsonResponse + { + $this->denyUnlessGranted($user, 'update'); + + $this->service->delete($this->context->resource($user, $uuid)); + + return $this->success(null); + } + + /** جایگزینی کامل مهارت‌های منبع: مهارتی که در بدنه نیست، برداشته می‌شود. */ + #[Route('/api/v1/resource/{uuid}/skills', name: 'resource_skills_replace', methods: ['PUT'])] + public function replaceSkills(#[CurrentUser] User $user, string $uuid, Request $request): JsonResponse + { + $this->denyUnlessGranted($user, 'update'); + + $data = json_decode($request->getContent(), true); + + if (!is_array($data) || !is_array($data['skills'] ?? null)) { + return $this->error(ErrorCodes::ERR_VALIDATION_002, 'فیلد skills الزامی است', 422, 'skills'); + } + + $resource = $this->context->resource($user, $uuid); + $this->skills->replace($user, $resource, $data['skills']); + + return $this->success($resource->toArray()); + } +} diff --git a/src/Resource/Controller/ResourcePermissionTrait.php b/src/Resource/Controller/ResourcePermissionTrait.php new file mode 100644 index 00000000..1ec61c3c --- /dev/null +++ b/src/Resource/Controller/ResourcePermissionTrait.php @@ -0,0 +1,37 @@ +secretaryAccess = $secretaryAccess; + $this->clinicDoctorAccess = $clinicDoctorAccess; + } + + /** @param 'view'|'update' $action */ + private function denyUnlessGranted(User $user, string $action): void + { + $this->secretaryAccess->denyUnlessGranted($user, 'appointment_settings', $action); + $this->clinicDoctorAccess->denyUnlessGranted($user, 'appointment_settings', $action); + } +} diff --git a/src/Resource/Controller/ResourcePoolController.php b/src/Resource/Controller/ResourcePoolController.php new file mode 100644 index 00000000..63e73ac7 --- /dev/null +++ b/src/Resource/Controller/ResourcePoolController.php @@ -0,0 +1,141 @@ +denyUnlessGranted($user, 'view'); + + [$entityType, $entityId] = $this->context->pair($user); + + return $this->success(array_map( + static fn (ResourcePool $p): array => $p->toArray(), + $this->pools->findForPair($entityType, $entityId), + )); + } + + #[Route('/api/v1/resource-pools', name: 'resource_pool_create', methods: ['POST'])] + public function create(#[CurrentUser] User $user, Request $request): JsonResponse + { + $this->denyUnlessGranted($user, 'update'); + + $data = json_decode($request->getContent(), true); + + if (!is_array($data) || !is_string($data['address_uuid'] ?? null)) { + return $this->error(ErrorCodes::ERR_VALIDATION_002, 'فیلد address_uuid الزامی است', 422, 'address_uuid'); + } + + if (!is_string($data['type_uuid'] ?? null)) { + return $this->error(ErrorCodes::ERR_VALIDATION_002, 'فیلد type_uuid الزامی است', 422, 'type_uuid'); + } + + $name = is_string($data['name'] ?? null) ? trim($data['name']) : ''; + + if ($name === '') { + return $this->error(ErrorCodes::ERR_VALIDATION_002, 'نام استخر الزامی است', 422, 'name'); + } + + $pool = new ResourcePool( + $this->context->address($user, $data['address_uuid']), + $this->context->type($user, $data['type_uuid']), + $name, + ); + + $this->em->persist($pool); + $this->em->flush(); + + return $this->success($pool->toArray(), 201); + } + + #[Route('/api/v1/resource-pool/{uuid}', name: 'resource_pool_show', methods: ['GET'])] + public function show(#[CurrentUser] User $user, string $uuid): JsonResponse + { + $this->denyUnlessGranted($user, 'view'); + + return $this->success($this->context->pool($user, $uuid)->toArray()); + } + + #[Route('/api/v1/resource-pool/{uuid}', name: 'resource_pool_update', methods: ['PATCH'])] + public function update(#[CurrentUser] User $user, string $uuid, Request $request): JsonResponse + { + $this->denyUnlessGranted($user, 'update'); + + $data = json_decode($request->getContent(), true); + + if (!is_array($data)) { + return $this->error(ErrorCodes::ERR_VALIDATION_001, 'بدنهٔ درخواست نامعتبر است', 422); + } + + $pool = $this->context->pool($user, $uuid); + + if (is_string($data['name'] ?? null) && trim($data['name']) !== '') { + $pool->setName(trim($data['name'])); + } + + if (array_key_exists('active', $data)) { + $pool->setActive((bool) $data['active']); + } + + $this->em->flush(); + + return $this->success($pool->toArray()); + } + + #[Route('/api/v1/resource-pool/{uuid}', name: 'resource_pool_delete', methods: ['DELETE'])] + public function delete(#[CurrentUser] User $user, string $uuid): JsonResponse + { + $this->denyUnlessGranted($user, 'update'); + + // اعضا فرزند aggregate اند و با CASCADE می‌روند؛ خودِ منابع دست‌نخورده می‌مانند. + $this->em->remove($this->context->pool($user, $uuid)); + $this->em->flush(); + + return $this->success(null); + } + + #[Route('/api/v1/resource-pool/{uuid}/members', name: 'resource_pool_members_replace', methods: ['PUT'])] + public function replaceMembers(#[CurrentUser] User $user, string $uuid, Request $request): JsonResponse + { + $this->denyUnlessGranted($user, 'update'); + + $data = json_decode($request->getContent(), true); + + if (!is_array($data) || !is_array($data['members'] ?? null)) { + return $this->error(ErrorCodes::ERR_VALIDATION_002, 'فیلد members الزامی است', 422, 'members'); + } + + $pool = $this->context->pool($user, $uuid); + $this->service->replaceMembers($user, $pool, $data['members']); + + return $this->success($pool->toArray()); + } +} diff --git a/src/Resource/Controller/ResourceTypeController.php b/src/Resource/Controller/ResourceTypeController.php new file mode 100644 index 00000000..c9180877 --- /dev/null +++ b/src/Resource/Controller/ResourceTypeController.php @@ -0,0 +1,142 @@ +denyUnlessGranted($user, 'view'); + + [$entityType, $entityId] = $this->context->pair($user); + + $types = $this->types->findForPair($entityType, $entityId); + // یک کوئری گروهی، نه یکی per نوع. + $counts = $this->resources->countByTypeIds(array_map( + static fn (ResourceType $t): int => (int) $t->getId(), + $types, + )); + + return $this->success(array_map( + static fn (ResourceType $t): array => $t->toArray($counts[(int) $t->getId()] ?? 0), + $types, + )); + } + + #[Route('/api/v1/resource-types', name: 'resource_type_create', methods: ['POST'])] + public function create(#[CurrentUser] User $user, Request $request): JsonResponse + { + $this->denyUnlessGranted($user, 'update'); + + $data = json_decode($request->getContent(), true); + + if (!is_array($data)) { + return $this->error(ErrorCodes::ERR_VALIDATION_001, 'بدنهٔ درخواست نامعتبر است', 422); + } + + $code = is_string($data['code'] ?? null) ? trim($data['code']) : ''; + $name = is_string($data['name'] ?? null) ? trim($data['name']) : ''; + + if (preg_match('/^[a-z0-9_]{1,40}$/', $code) !== 1) { + return $this->error(ErrorCodes::ERR_VALIDATION_001, 'کد فقط حروف کوچک انگلیسی، عدد و زیرخط می‌پذیرد', 422, 'code'); + } + + if ($name === '') { + return $this->error(ErrorCodes::ERR_VALIDATION_002, 'نام نوع منبع الزامی است', 422, 'name'); + } + + [$entityType, $entityId] = $this->context->pair($user); + + if ($this->types->findByCode($entityType, $entityId, $code) !== null) { + return $this->error(ErrorCodes::ERR_VALIDATION_001, 'نوع منبعی با این کد از قبل وجود دارد', 422, 'code'); + } + + $type = new ResourceType($entityType, $entityId, $code, $name); + $this->em->persist($type); + $this->em->flush(); + + return $this->success($type->toArray(0), 201); + } + + #[Route('/api/v1/resource-type/{uuid}', name: 'resource_type_update', methods: ['PATCH'])] + public function update(#[CurrentUser] User $user, string $uuid, Request $request): JsonResponse + { + $this->denyUnlessGranted($user, 'update'); + + $data = json_decode($request->getContent(), true); + + if (!is_array($data)) { + return $this->error(ErrorCodes::ERR_VALIDATION_001, 'بدنهٔ درخواست نامعتبر است', 422); + } + + $type = $this->context->type($user, $uuid); + + // `code` تغییر نمی‌کند حتی روی نوع غیرسیستمی: ResourceLinker و منابع موجود با + // همان کد پیدا می‌شوند و عوض کردنش نگاشت را بی‌صدا می‌شکند. + if (is_string($data['name'] ?? null) && trim($data['name']) !== '') { + $type->setName(trim($data['name'])); + } + + if (array_key_exists('active', $data)) { + $type->setActive((bool) $data['active']); + } + + $this->em->flush(); + + return $this->success($type->toArray($this->resources->countForType($type))); + } + + #[Route('/api/v1/resource-type/{uuid}', name: 'resource_type_delete', methods: ['DELETE'])] + public function delete(#[CurrentUser] User $user, string $uuid): JsonResponse + { + $this->denyUnlessGranted($user, 'update'); + + $type = $this->context->type($user, $uuid); + + if ($type->isSystem()) { + return $this->error(ErrorCodes::ERR_VALIDATION_001, 'نوع منبع سیستمی حذف نمی‌شود', 422); + } + + $inUse = $this->resources->countForType($type); + + if ($inUse > 0) { + return $this->error( + ErrorCodes::ERR_VALIDATION_001, + sprintf('این نوع روی %d منبع استفاده شده است', $inUse), + 422, + ); + } + + $this->em->remove($type); + $this->em->flush(); + + return $this->success(null); + } +} diff --git a/src/Resource/Controller/SkillController.php b/src/Resource/Controller/SkillController.php new file mode 100644 index 00000000..2e31e33e --- /dev/null +++ b/src/Resource/Controller/SkillController.php @@ -0,0 +1,110 @@ +denyUnlessGranted($user, 'view'); + + [$entityType, $entityId] = $this->context->pair($user); + + $skills = $this->skills->findForPair($entityType, $entityId); + $counts = $this->assignments->countBySkillIds(array_map( + static fn (Skill $s): int => (int) $s->getId(), + $skills, + )); + + return $this->success(array_map( + static fn (Skill $s): array => $s->toArray($counts[(int) $s->getId()] ?? 0), + $skills, + )); + } + + #[Route('/api/v1/skills', name: 'skill_create', methods: ['POST'])] + public function create(#[CurrentUser] User $user, Request $request): JsonResponse + { + $this->denyUnlessGranted($user, 'update'); + + $data = json_decode($request->getContent(), true); + $name = is_array($data) && is_string($data['name'] ?? null) ? trim($data['name']) : ''; + + if ($name === '') { + return $this->error(ErrorCodes::ERR_VALIDATION_002, 'نام مهارت الزامی است', 422, 'name'); + } + + [$entityType, $entityId] = $this->context->pair($user); + + $skill = new Skill($entityType, $entityId, $name); + $this->em->persist($skill); + $this->em->flush(); + + return $this->success($skill->toArray(0), 201); + } + + #[Route('/api/v1/skill/{uuid}', name: 'skill_update', methods: ['PATCH'])] + public function update(#[CurrentUser] User $user, string $uuid, Request $request): JsonResponse + { + $this->denyUnlessGranted($user, 'update'); + + $data = json_decode($request->getContent(), true); + + if (!is_array($data)) { + return $this->error(ErrorCodes::ERR_VALIDATION_001, 'بدنهٔ درخواست نامعتبر است', 422); + } + + $skill = $this->context->skill($user, $uuid); + + if (is_string($data['name'] ?? null) && trim($data['name']) !== '') { + $skill->setName(trim($data['name'])); + } + + if (array_key_exists('active', $data)) { + $skill->setActive((bool) $data['active']); + } + + $this->em->flush(); + + return $this->success($skill->toArray($this->assignments->countForSkill($skill))); + } + + #[Route('/api/v1/skill/{uuid}', name: 'skill_delete', methods: ['DELETE'])] + public function delete(#[CurrentUser] User $user, string $uuid): JsonResponse + { + $this->denyUnlessGranted($user, 'update'); + + $this->service->deleteSkill($this->context->skill($user, $uuid)); + + return $this->success(null); + } +} diff --git a/src/Resource/Entity/ClinicResource.php b/src/Resource/Entity/ClinicResource.php new file mode 100644 index 00000000..e0df6c71 --- /dev/null +++ b/src/Resource/Entity/ClinicResource.php @@ -0,0 +1,247 @@ + 1])] + private int $capacity = 1; + + /** آماده‌سازی پیش از بیمار — جزو نوبت بیمار نیست، ولی منبع را اشغال می‌کند. */ + #[ORM\Column(name: 'setup_minutes', type: 'smallint', options: ['default' => 0])] + private int $setupMinutes = 0; + + #[ORM\Column(name: 'cleanup_minutes', type: 'smallint', options: ['default' => 0])] + private int $cleanupMinutes = 0; + + /** JSON آزاد ولی فقط اسکالر — {@see \App\Resource\Service\ResourceService::normalizeAttributes()} */ + #[ORM\Column(type: 'json', nullable: true)] + private ?array $attributes = null; + + #[ORM\ManyToOne(targetEntity: Doctor::class)] + #[ORM\JoinColumn(name: 'doctor_id', referencedColumnName: 'id', nullable: true, onDelete: 'CASCADE')] + private ?Doctor $doctor = null; + + #[ORM\ManyToOne(targetEntity: ClinicStaff::class)] + #[ORM\JoinColumn(name: 'staff_id', referencedColumnName: 'id', nullable: true, onDelete: 'CASCADE')] + private ?ClinicStaff $staff = null; + + #[ORM\ManyToOne(targetEntity: Room::class)] + #[ORM\JoinColumn(name: 'room_id', referencedColumnName: 'id', nullable: true, onDelete: 'CASCADE')] + private ?Room $room = null; + + #[ORM\Column(type: 'boolean', options: ['default' => true])] + private bool $active = true; + + #[ORM\Column(name: 'created_at', type: 'integer')] + private int $createdAt; + + #[ORM\Column(name: 'updated_at', type: 'integer')] + private int $updatedAt; + + /** @var Collection */ + #[ORM\OneToMany(targetEntity: ResourceSkill::class, mappedBy: 'resource', cascade: ['persist', 'remove'], orphanRemoval: true)] + private Collection $skills; + + public function __construct(DoctorAddress $address, ResourceType $type, string $name) + { + $this->uuid = Uuid::v4()->toRfc4122(); + $this->address = $address; + $this->type = $type; + $this->name = $name; + $this->createdAt = time(); + $this->updatedAt = time(); + $this->skills = new ArrayCollection(); + + // جفت از آدرس مشتق می‌شود، نه از بدنهٔ درخواست — پس هیچ نقطهٔ ساختی + // نمی‌تواند فراموشش کند و کلاینت هم نمی‌تواند منبع را به محیط دیگری بچسباند. + $this->assignTenantPair($address->tenantEntityType(), $address->tenantEntityId()); + } + + public function getId(): ?int { return $this->id; } + public function getUuid(): string { return $this->uuid; } + public function getAddress(): DoctorAddress { return $this->address; } + public function getType(): ResourceType { return $this->type; } + public function getName(): string { return $this->name; } + public function getCapacity(): int { return $this->capacity; } + public function getSetupMinutes(): int { return $this->setupMinutes; } + public function getCleanupMinutes(): int { return $this->cleanupMinutes; } + public function getAttributes(): array { return $this->attributes ?? []; } + public function getDoctor(): ?Doctor { return $this->doctor; } + public function getStaff(): ?ClinicStaff { return $this->staff; } + public function getRoom(): ?Room { return $this->room; } + public function isActive(): bool { return $this->active; } + public function getCreatedAt(): int { return $this->createdAt; } + public function getUpdatedAt(): int { return $this->updatedAt; } + + /** @return Collection */ + public function getSkills(): Collection { return $this->skills; } + + public function setName(string $v): self { $this->name = $v; $this->touch(); return $this; } + public function setSetupMinutes(int $v): self { $this->setupMinutes = $this->assertMinutes($v, 'setup_minutes'); $this->touch(); return $this; } + public function setCleanupMinutes(int $v): self { $this->cleanupMinutes = $this->assertMinutes($v, 'cleanup_minutes'); $this->touch(); return $this; } + public function setAttributes(array $v): self { $this->attributes = $v === [] ? null : $v; $this->touch(); return $this; } + public function setActive(bool $v): self { $this->active = $v; $this->touch(); return $this; } + public function setType(ResourceType $v): self { $this->type = $v; $this->touch(); return $this; } + + /** + * @throws \InvalidArgumentException ظرفیت کمتر از ۱، یا بزرگ‌تر از ۱ روی منبعی که + * یک شخص است — پزشک و اپراتور هم‌زمان دو بیمار ندارند. + */ + public function setCapacity(int $v): self + { + if ($v < 1) { + throw new \InvalidArgumentException('Resource capacity must be at least 1.'); + } + + if ($v > 1 && $this->isPerson()) { + throw new \InvalidArgumentException('A person resource cannot serve more than one patient at a time.'); + } + + $this->capacity = $v; + $this->touch(); + + return $this; + } + + /** منبعی که یک انسان است: ظرفیتش همیشه ۱ می‌ماند. */ + public function isPerson(): bool + { + return $this->doctor !== null + || $this->staff !== null + || in_array($this->type->getCode(), [ResourceType::CODE_DOCTOR, ResourceType::CODE_STAFF], true); + } + + /** + * پل به موجودیت اصلی. حداکثر یکی مجاز است — MariaDB قید چندستونی `CHECK` را + * قابل اتکا اجرا نمی‌کند، پس اجبارش اینجاست. + * + * @throws \InvalidArgumentException روی پل دوم + */ + public function linkTo(Doctor|ClinicStaff|Room $subject): self + { + if ($this->subject() !== null) { + throw new \InvalidArgumentException('A resource can bridge to at most one subject.'); + } + + match (true) { + $subject instanceof Doctor => $this->doctor = $subject, + $subject instanceof ClinicStaff => $this->staff = $subject, + $subject instanceof Room => $this->room = $subject, + }; + + $this->touch(); + + return $this; + } + + /** موجودیت اصلی پشت این منبع؛ `null` یعنی دستگاه/تجهیزات. */ + public function subject(): Doctor|ClinicStaff|Room|null + { + return $this->doctor ?? $this->staff ?? $this->room; + } + + private function assertMinutes(int $v, string $field): int + { + if ($v < 0 || $v > 480) { + throw new \InvalidArgumentException(sprintf('%s must be between 0 and 480.', $field)); + } + + return $v; + } + + private function touch(): void { $this->updatedAt = time(); } + + public function toArray(): array + { + $subject = $this->subject(); + + return [ + 'uuid' => $this->uuid, + 'name' => $this->name, + 'address_uuid' => $this->address->getUuid(), + 'address_name' => $this->address->getName(), + 'type_uuid' => $this->type->getUuid(), + 'type_code' => $this->type->getCode(), + 'type_name' => $this->type->getName(), + 'capacity' => $this->capacity, + 'setup_minutes' => $this->setupMinutes, + 'cleanup_minutes' => $this->cleanupMinutes, + 'attributes' => (object) $this->getAttributes(), + 'subject_kind' => match (true) { + $this->doctor !== null => 'doctor', + $this->staff !== null => 'staff', + $this->room !== null => 'room', + default => null, + }, + 'subject_uuid' => $subject?->getUuid(), + 'skills' => array_map( + static fn (ResourceSkill $rs): array => $rs->toArray(), + $this->skills->toArray(), + ), + 'active' => $this->active, + 'created_at' => $this->createdAt, + 'updated_at' => $this->updatedAt, + ]; + } +} diff --git a/src/Resource/Entity/ResourcePool.php b/src/Resource/Entity/ResourcePool.php new file mode 100644 index 00000000..30023642 --- /dev/null +++ b/src/Resource/Entity/ResourcePool.php @@ -0,0 +1,109 @@ + true])] + private bool $active = true; + + #[ORM\Column(name: 'created_at', type: 'integer')] + private int $createdAt; + + #[ORM\Column(name: 'updated_at', type: 'integer')] + private int $updatedAt; + + /** @var Collection */ + #[ORM\OneToMany(targetEntity: ResourcePoolMember::class, mappedBy: 'pool', cascade: ['persist', 'remove'], orphanRemoval: true)] + #[ORM\OrderBy(['priority' => 'ASC'])] + private Collection $members; + + public function __construct(DoctorAddress $address, ResourceType $type, string $name) + { + $this->uuid = Uuid::v4()->toRfc4122(); + $this->address = $address; + $this->type = $type; + $this->name = $name; + $this->createdAt = time(); + $this->updatedAt = time(); + $this->members = new ArrayCollection(); + + $this->assignTenantPair($address->tenantEntityType(), $address->tenantEntityId()); + } + + public function getId(): ?int { return $this->id; } + public function getUuid(): string { return $this->uuid; } + public function getAddress(): DoctorAddress { return $this->address; } + public function getType(): ResourceType { return $this->type; } + public function getName(): string { return $this->name; } + public function isActive(): bool { return $this->active; } + public function getCreatedAt(): int { return $this->createdAt; } + public function getUpdatedAt(): int { return $this->updatedAt; } + + /** @return Collection */ + public function getMembers(): Collection { return $this->members; } + + public function setName(string $v): self { $this->name = $v; $this->touch(); return $this; } + public function setActive(bool $v): self { $this->active = $v; $this->touch(); return $this; } + + public function touch(): void { $this->updatedAt = time(); } + + public function toArray(): array + { + return [ + 'uuid' => $this->uuid, + 'name' => $this->name, + 'address_uuid' => $this->address->getUuid(), + 'address_name' => $this->address->getName(), + 'type_uuid' => $this->type->getUuid(), + 'type_code' => $this->type->getCode(), + 'type_name' => $this->type->getName(), + 'members' => array_map( + static fn (ResourcePoolMember $m): array => $m->toArray(), + $this->members->toArray(), + ), + 'active' => $this->active, + 'created_at' => $this->createdAt, + 'updated_at' => $this->updatedAt, + ]; + } +} diff --git a/src/Resource/Entity/ResourcePoolMember.php b/src/Resource/Entity/ResourcePoolMember.php new file mode 100644 index 00000000..6b241d6b --- /dev/null +++ b/src/Resource/Entity/ResourcePoolMember.php @@ -0,0 +1,56 @@ + 0])] + private int $priority = 0; + + public function __construct(ResourcePool $pool, ClinicResource $resource, int $priority = 0) + { + $this->pool = $pool; + $this->resource = $resource; + $this->priority = $priority; + } + + public function getId(): ?int { return $this->id; } + public function getPool(): ResourcePool { return $this->pool; } + public function getResource(): ClinicResource { return $this->resource; } + public function getPriority(): int { return $this->priority; } + + public function toArray(): array + { + return [ + 'resource_uuid' => $this->resource->getUuid(), + 'resource_name' => $this->resource->getName(), + 'priority' => $this->priority, + 'active' => $this->resource->isActive(), + ]; + } +} diff --git a/src/Resource/Entity/ResourceSkill.php b/src/Resource/Entity/ResourceSkill.php new file mode 100644 index 00000000..a8da83d8 --- /dev/null +++ b/src/Resource/Entity/ResourceSkill.php @@ -0,0 +1,73 @@ + 1])] + private int $level = 1; + + #[ORM\Column(name: 'created_at', type: 'integer')] + private int $createdAt; + + public function __construct(ClinicResource $resource, Skill $skill, int $level = 1) + { + if ($level < self::MIN_LEVEL || $level > self::MAX_LEVEL) { + throw new \InvalidArgumentException('Skill level must be between 1 and 5.'); + } + + $this->resource = $resource; + $this->skill = $skill; + $this->level = $level; + $this->createdAt = time(); + } + + public function getId(): ?int { return $this->id; } + public function getResource(): ClinicResource { return $this->resource; } + public function getSkill(): Skill { return $this->skill; } + public function getLevel(): int { return $this->level; } + public function getCreatedAt(): int { return $this->createdAt; } + + public function toArray(): array + { + return [ + 'skill_uuid' => $this->skill->getUuid(), + 'skill_name' => $this->skill->getName(), + 'level' => $this->level, + ]; + } +} diff --git a/src/Resource/Entity/ResourceType.php b/src/Resource/Entity/ResourceType.php new file mode 100644 index 00000000..cd9b75f6 --- /dev/null +++ b/src/Resource/Entity/ResourceType.php @@ -0,0 +1,105 @@ + 'پزشک', + self::CODE_STAFF => 'پرسنل', + self::CODE_ROOM => 'اتاق', + ]; + + #[ORM\Id] + #[ORM\GeneratedValue] + #[ORM\Column(type: 'integer')] + private ?int $id = null; + + #[ORM\Column(type: 'string', length: 36, unique: true)] + private string $uuid; + + #[ORM\Column(type: 'string', length: 40)] + private string $code; + + #[ORM\Column(type: 'string', length: 100)] + private string $name; + + #[ORM\Column(name: 'is_system', type: 'boolean', options: ['default' => false])] + private bool $isSystem = false; + + #[ORM\Column(type: 'boolean', options: ['default' => true])] + private bool $active = true; + + #[ORM\Column(name: 'created_at', type: 'integer')] + private int $createdAt; + + #[ORM\Column(name: 'updated_at', type: 'integer')] + private int $updatedAt; + + public function __construct(string $entityType, int $entityId, string $code, string $name) + { + $this->uuid = Uuid::v4()->toRfc4122(); + $this->code = $code; + $this->name = $name; + $this->createdAt = time(); + $this->updatedAt = time(); + + $this->assignTenantPair($entityType, $entityId); + } + + public function getId(): ?int { return $this->id; } + public function getUuid(): string { return $this->uuid; } + public function getCode(): string { return $this->code; } + public function getName(): string { return $this->name; } + public function isSystem(): bool { return $this->isSystem; } + public function isActive(): bool { return $this->active; } + public function getCreatedAt(): int { return $this->createdAt; } + public function getUpdatedAt(): int { return $this->updatedAt; } + + public function markSystem(): self { $this->isSystem = true; return $this; } + public function setName(string $v): self { $this->name = $v; $this->touch(); return $this; } + public function setActive(bool $v): self { $this->active = $v; $this->touch(); return $this; } + + private function touch(): void { $this->updatedAt = time(); } + + public function toArray(?int $resourcesCount = null): array + { + $row = [ + 'uuid' => $this->uuid, + 'code' => $this->code, + 'name' => $this->name, + 'is_system' => $this->isSystem, + 'active' => $this->active, + 'created_at' => $this->createdAt, + 'updated_at' => $this->updatedAt, + ]; + + if ($resourcesCount !== null) { + $row['resources_count'] = $resourcesCount; + } + + return $row; + } +} diff --git a/src/Resource/Entity/Skill.php b/src/Resource/Entity/Skill.php new file mode 100644 index 00000000..274506b4 --- /dev/null +++ b/src/Resource/Entity/Skill.php @@ -0,0 +1,83 @@ + true])] + private bool $active = true; + + #[ORM\Column(name: 'created_at', type: 'integer')] + private int $createdAt; + + #[ORM\Column(name: 'updated_at', type: 'integer')] + private int $updatedAt; + + public function __construct(string $entityType, int $entityId, string $name) + { + $this->uuid = Uuid::v4()->toRfc4122(); + $this->name = $name; + $this->createdAt = time(); + $this->updatedAt = time(); + + $this->assignTenantPair($entityType, $entityId); + } + + public function getId(): ?int { return $this->id; } + public function getUuid(): string { return $this->uuid; } + public function getName(): string { return $this->name; } + public function isActive(): bool { return $this->active; } + public function getCreatedAt(): int { return $this->createdAt; } + public function getUpdatedAt(): int { return $this->updatedAt; } + + public function setName(string $v): self { $this->name = $v; $this->touch(); return $this; } + public function setActive(bool $v): self { $this->active = $v; $this->touch(); return $this; } + + private function touch(): void { $this->updatedAt = time(); } + + public function toArray(?int $resourcesCount = null): array + { + $row = [ + 'uuid' => $this->uuid, + 'name' => $this->name, + 'active' => $this->active, + 'created_at' => $this->createdAt, + 'updated_at' => $this->updatedAt, + ]; + + if ($resourcesCount !== null) { + $row['resources_count'] = $resourcesCount; + } + + return $row; + } +} diff --git a/src/Resource/Repository/ClinicResourceRepository.php b/src/Resource/Repository/ClinicResourceRepository.php new file mode 100644 index 00000000..f9d0719c --- /dev/null +++ b/src/Resource/Repository/ClinicResourceRepository.php @@ -0,0 +1,172 @@ + + */ +class ClinicResourceRepository extends ServiceEntityRepository +{ + public function __construct(ManagerRegistry $registry) + { + parent::__construct($registry, ClinicResource::class); + } + + public function findByUuid(string $uuid): ?ClinicResource + { + return $this->findOneBy(['uuid' => $uuid]); + } + + /** + * فهرست منابع یک محیط با فیلترهای اختیاری. + * + * @param array{address?: ?DoctorAddress, type?: ?ResourceType, active?: ?bool, skillUuid?: ?string} $filters + * @return ClinicResource[] + */ + public function findForPair(string $entityType, int $entityId, array $filters = []): array + { + $qb = $this->createQueryBuilder('r') + ->where('r.entityType = :tenantType') + ->andWhere('r.entityId = :tenantId') + ->setParameter('tenantType', $entityType) + ->setParameter('tenantId', $entityId); + + if (($filters['address'] ?? null) !== null) { + $qb->andWhere('r.address = :address')->setParameter('address', $filters['address']); + } + + if (($filters['type'] ?? null) !== null) { + $qb->andWhere('r.type = :type')->setParameter('type', $filters['type']); + } + + if (($filters['active'] ?? null) !== null) { + $qb->andWhere('r.active = :active')->setParameter('active', $filters['active']); + } + + if (($filters['skillUuid'] ?? null) !== null) { + $qb->join('r.skills', 'flt_rs') + ->join('flt_rs.skill', 'flt_s') + ->andWhere('flt_s.uuid = :skillUuid') + ->setParameter('skillUuid', $filters['skillUuid']); + } + + return $qb->orderBy('r.name', 'ASC')->getQuery()->getResult(); + } + + /** + * پرس‌وجوی داغِ تسک ۰۶: «منابع فعالِ این شعبه از این نوع که **همهٔ** این مهارت‌ها + * را دارند». + * + * `HAVING COUNT(DISTINCT …)` عمدی است: نیازمندی «مهارت الف و ب» یعنی هر دو، نه + * یکی — که با یک `IN` ساده اشتباه پاسخ می‌گرفت. + * + * @param int[] $skillIds خالی یعنی بدون شرط مهارت + * @return ClinicResource[] + */ + public function findEligible(DoctorAddress $address, ResourceType $type, array $skillIds = []): array + { + $qb = $this->createQueryBuilder('r') + ->where('r.address = :address') + ->andWhere('r.type = :type') + ->andWhere('r.active = true') + ->setParameter('address', $address) + ->setParameter('type', $type); + + if ($skillIds !== []) { + $qb->join('r.skills', 'rs') + ->andWhere('rs.skill IN (:skills)') + ->setParameter('skills', $skillIds) + ->groupBy('r.id') + ->having('COUNT(DISTINCT rs.skill) = :skillCount') + ->setParameter('skillCount', count(array_unique($skillIds))); + } + + return $qb->orderBy('r.name', 'ASC')->getQuery()->getResult(); + } + + public function findForSubject(Doctor|ClinicStaff|Room $subject, ?DoctorAddress $address = null): ?ClinicResource + { + $field = match (true) { + $subject instanceof Doctor => 'doctor', + $subject instanceof ClinicStaff => 'staff', + $subject instanceof Room => 'room', + }; + + $qb = $this->createQueryBuilder('r') + ->where("r.$field = :subject") + ->setParameter('subject', $subject); + + // اتاق فقط در یک آدرس است، پس آدرس برایش شرط اضافه نیست. + if ($address !== null && !$subject instanceof Room) { + $qb->andWhere('r.address = :address')->setParameter('address', $address); + } + + return $qb->setMaxResults(1)->getQuery()->getOneOrNullResult(); + } + + /** + * همهٔ منابعِ یک موجودیت در همهٔ شعبه‌ها — پزشکی که در دو شعبه کار می‌کند دو منبع + * دارد و غیرفعال شدنش باید هر دو را ببندد. + * + * @return ClinicResource[] + */ + public function findAllForSubject(Doctor|ClinicStaff|Room $subject): array + { + $field = match (true) { + $subject instanceof Doctor => 'doctor', + $subject instanceof ClinicStaff => 'staff', + $subject instanceof Room => 'room', + }; + + return $this->createQueryBuilder('r') + ->where("r.$field = :subject") + ->setParameter('subject', $subject) + ->getQuery() + ->getResult(); + } + + public function countForType(ResourceType $type): int + { + return (int) $this->createQueryBuilder('r') + ->select('COUNT(r.id)') + ->where('r.type = :type') + ->setParameter('type', $type) + ->getQuery() + ->getSingleScalarResult(); + } + + /** + * @param int[] $typeIds + * @return array شناسهٔ نوع => تعداد منبع + */ + public function countByTypeIds(array $typeIds): array + { + if ($typeIds === []) { + return []; + } + + $rows = $this->createQueryBuilder('r') + ->select('IDENTITY(r.type) AS type_id, COUNT(r.id) AS total') + ->where('r.type IN (:ids)') + ->setParameter('ids', $typeIds) + ->groupBy('r.type') + ->getQuery() + ->getArrayResult(); + + $counts = []; + foreach ($rows as $row) { + $counts[(int) $row['type_id']] = (int) $row['total']; + } + + return $counts; + } +} diff --git a/src/Resource/Repository/ResourcePoolMemberRepository.php b/src/Resource/Repository/ResourcePoolMemberRepository.php new file mode 100644 index 00000000..9981bfce --- /dev/null +++ b/src/Resource/Repository/ResourcePoolMemberRepository.php @@ -0,0 +1,29 @@ + + */ +class ResourcePoolMemberRepository extends ServiceEntityRepository +{ + public function __construct(ManagerRegistry $registry) + { + parent::__construct($registry, ResourcePoolMember::class); + } + + public function deleteForPool(ResourcePool $pool): int + { + return (int) $this->createQueryBuilder('m') + ->delete() + ->where('m.pool = :pool') + ->setParameter('pool', $pool) + ->getQuery() + ->execute(); + } +} diff --git a/src/Resource/Repository/ResourcePoolRepository.php b/src/Resource/Repository/ResourcePoolRepository.php new file mode 100644 index 00000000..0541eabe --- /dev/null +++ b/src/Resource/Repository/ResourcePoolRepository.php @@ -0,0 +1,36 @@ + + */ +class ResourcePoolRepository extends ServiceEntityRepository +{ + public function __construct(ManagerRegistry $registry) + { + parent::__construct($registry, ResourcePool::class); + } + + public function findByUuid(string $uuid): ?ResourcePool + { + return $this->findOneBy(['uuid' => $uuid]); + } + + /** @return ResourcePool[] */ + public function findForPair(string $entityType, int $entityId): array + { + return $this->createQueryBuilder('p') + ->where('p.entityType = :type') + ->andWhere('p.entityId = :id') + ->setParameter('type', $entityType) + ->setParameter('id', $entityId) + ->orderBy('p.name', 'ASC') + ->getQuery() + ->getResult(); + } +} diff --git a/src/Resource/Repository/ResourceSkillRepository.php b/src/Resource/Repository/ResourceSkillRepository.php new file mode 100644 index 00000000..e27445fa --- /dev/null +++ b/src/Resource/Repository/ResourceSkillRepository.php @@ -0,0 +1,66 @@ + + */ +class ResourceSkillRepository extends ServiceEntityRepository +{ + public function __construct(ManagerRegistry $registry) + { + parent::__construct($registry, ResourceSkill::class); + } + + public function countForSkill(Skill $skill): int + { + return (int) $this->createQueryBuilder('rs') + ->select('COUNT(rs.id)') + ->where('rs.skill = :skill') + ->setParameter('skill', $skill) + ->getQuery() + ->getSingleScalarResult(); + } + + /** + * @param int[] $skillIds + * @return array شناسهٔ مهارت => تعداد منبعی که دارد + */ + public function countBySkillIds(array $skillIds): array + { + if ($skillIds === []) { + return []; + } + + $rows = $this->createQueryBuilder('rs') + ->select('IDENTITY(rs.skill) AS skill_id, COUNT(rs.id) AS total') + ->where('rs.skill IN (:ids)') + ->setParameter('ids', $skillIds) + ->groupBy('rs.skill') + ->getQuery() + ->getArrayResult(); + + $counts = []; + foreach ($rows as $row) { + $counts[(int) $row['skill_id']] = (int) $row['total']; + } + + return $counts; + } + + public function deleteForResource(ClinicResource $resource): int + { + return (int) $this->createQueryBuilder('rs') + ->delete() + ->where('rs.resource = :resource') + ->setParameter('resource', $resource) + ->getQuery() + ->execute(); + } +} diff --git a/src/Resource/Repository/ResourceTypeRepository.php b/src/Resource/Repository/ResourceTypeRepository.php new file mode 100644 index 00000000..03ef32d4 --- /dev/null +++ b/src/Resource/Repository/ResourceTypeRepository.php @@ -0,0 +1,42 @@ + + */ +class ResourceTypeRepository extends ServiceEntityRepository +{ + public function __construct(ManagerRegistry $registry) + { + parent::__construct($registry, ResourceType::class); + } + + public function findByUuid(string $uuid): ?ResourceType + { + return $this->findOneBy(['uuid' => $uuid]); + } + + /** @return ResourceType[] */ + public function findForPair(string $entityType, int $entityId): array + { + return $this->createQueryBuilder('t') + ->where('t.entityType = :type') + ->andWhere('t.entityId = :id') + ->setParameter('type', $entityType) + ->setParameter('id', $entityId) + ->orderBy('t.isSystem', 'DESC') + ->addOrderBy('t.name', 'ASC') + ->getQuery() + ->getResult(); + } + + public function findByCode(string $entityType, int $entityId, string $code): ?ResourceType + { + return $this->findOneBy(['entityType' => $entityType, 'entityId' => $entityId, 'code' => $code]); + } +} diff --git a/src/Resource/Repository/SkillRepository.php b/src/Resource/Repository/SkillRepository.php new file mode 100644 index 00000000..982997ce --- /dev/null +++ b/src/Resource/Repository/SkillRepository.php @@ -0,0 +1,53 @@ + + */ +class SkillRepository extends ServiceEntityRepository +{ + public function __construct(ManagerRegistry $registry) + { + parent::__construct($registry, Skill::class); + } + + public function findByUuid(string $uuid): ?Skill + { + return $this->findOneBy(['uuid' => $uuid]); + } + + /** @return Skill[] */ + public function findForPair(string $entityType, int $entityId): array + { + return $this->createQueryBuilder('s') + ->where('s.entityType = :type') + ->andWhere('s.entityId = :id') + ->setParameter('type', $entityType) + ->setParameter('id', $entityId) + ->orderBy('s.name', 'ASC') + ->getQuery() + ->getResult(); + } + + /** + * @param string[] $uuids + * @return Skill[] + */ + public function findByUuids(array $uuids): array + { + if ($uuids === []) { + return []; + } + + return $this->createQueryBuilder('s') + ->where('s.uuid IN (:uuids)') + ->setParameter('uuids', $uuids) + ->getQuery() + ->getResult(); + } +} diff --git a/src/Resource/Service/ResourceContext.php b/src/Resource/Service/ResourceContext.php new file mode 100644 index 00000000..b3c7f379 --- /dev/null +++ b/src/Resource/Service/ResourceContext.php @@ -0,0 +1,87 @@ +branches->pair($user); + } + + public function address(User $user, string $addressUuid): DoctorAddress + { + return $this->branches->resolve($user, $addressUuid); + } + + public function type(User $user, string $uuid): ResourceType + { + return $this->owned($user, $this->types->findByUuid($uuid), 'نوع منبع یافت نشد'); + } + + public function resource(User $user, string $uuid): ClinicResource + { + return $this->owned($user, $this->resources->findByUuid($uuid), 'منبع یافت نشد'); + } + + public function skill(User $user, string $uuid): Skill + { + return $this->owned($user, $this->skills->findByUuid($uuid), 'مهارت یافت نشد'); + } + + public function pool(User $user, string $uuid): ResourcePool + { + return $this->owned($user, $this->pools->findByUuid($uuid), 'استخر منابع یافت نشد'); + } + + /** + * @template T of object + * @param T|null $entity + * @return T + */ + private function owned(User $user, ?object $entity, string $message): object + { + [$entityType, $entityId] = $this->pair($user); + + if ($entity === null || !$this->ownership->belongsToPair($entityType, $entityId, $entity)) { + throw new AppException(ErrorCodes::ERR_NOT_FOUND_001, $message, 404); + } + + return $entity; + } +} diff --git a/src/Resource/Service/ResourceLinker.php b/src/Resource/Service/ResourceLinker.php new file mode 100644 index 00000000..44c4b45c --- /dev/null +++ b/src/Resource/Service/ResourceLinker.php @@ -0,0 +1,133 @@ +getDoctor()` را برای تصمیم‌گیری بخواند؛ وگرنه + * افزودن نوع پل بعدی (مثلاً تجهیزات اجاره‌ای) یعنی گشتن دنبال همهٔ آن نقطه‌ها. + */ +final class ResourceLinker +{ + /** + * نوع‌های ساخته‌شده و هنوز flush-نشده در همین واحدِ کار. + * + * بدون این، `systemType()` نوعِ persist-شده‌ای را که هنوز به دیتابیس نرفته + * نمی‌دید و دوباره می‌ساخت — که در اجرای واقعی backfill با + * «Duplicate entry 'doctor-1-staff' for key uniq_rt_tenant_code» می‌شکست. + * + * @var array + */ + private array $pendingTypes = []; + + public function __construct( + private readonly ClinicResourceRepository $resources, + private readonly ResourceTypeRepository $types, + private readonly EntityManagerInterface $em, + ) {} + + /** + * نوع سیستمی محیط را برمی‌گرداند و اگر نبود می‌سازد. بدون flush — فراخوان تصمیم + * می‌گیرد کِی بنویسد (backfill per محیط flush می‌کند). + */ + public function systemType(string $entityType, int $entityId, string $code): ResourceType + { + $key = "$entityType:$entityId:$code"; + + if (isset($this->pendingTypes[$key])) { + return $this->pendingTypes[$key]; + } + + $type = $this->types->findByCode($entityType, $entityId, $code); + + if ($type === null) { + $type = new ResourceType($entityType, $entityId, $code, ResourceType::SYSTEM_CODES[$code] ?? $code); + $type->markSystem(); + $this->em->persist($type); + } + + return $this->pendingTypes[$key] = $type; + } + + /** بعد از flush صدا زده می‌شود؛ نگه‌داشتن نمونه‌های قدیمی بعد از clear() خطرناک است. */ + public function forgetPendingTypes(): void + { + $this->pendingTypes = []; + } + + /** منبعِ متناظر با یک موجودیت در یک شعبه؛ اگر نبود می‌سازد. */ + public function link(Doctor|ClinicStaff|Room $subject, DoctorAddress $address, string $name): ClinicResource + { + $existing = $this->resources->findForSubject($subject, $address); + + if ($existing !== null) { + return $existing; + } + + $code = match (true) { + $subject instanceof Doctor => ResourceType::CODE_DOCTOR, + $subject instanceof ClinicStaff => ResourceType::CODE_STAFF, + $subject instanceof Room => ResourceType::CODE_ROOM, + }; + + $type = $this->systemType($address->tenantEntityType(), $address->tenantEntityId(), $code); + $resource = new ClinicResource($address, $type, $name); + $resource->linkTo($subject); + + // اتاق ظرفیت خودش را دارد؛ شخص همیشه ظرفیت ۱. + if ($subject instanceof Room) { + $resource->setCapacity($subject->getCapacity()); + $resource->setActive($subject->isActive()); + } + + $this->em->persist($resource); + + return $resource; + } + + /** برعکس: منبع → موجودیت اصلی. `null` یعنی دستگاه/تجهیزات. */ + public function subject(ClinicResource $resource): Doctor|ClinicStaff|Room|null + { + return $resource->subject(); + } + + /** + * غیرفعال شدن پرسنل/اتاق باید منبعش را هم غیرفعال کند، وگرنه در جستجوی وقتِ تسک ۰۶ + * ظاهر می‌شود. + * + * عمداً فراخوانی صریح است و نه Doctrine lifecycle callback: آن callback در + * `getArrayResult()` — که لیست‌های ادمین با آن ساخته می‌شوند — اجرا نمی‌شود و + * رفتار نامتقارن می‌سازد. + * + * عکسش برقرار نیست: غیرفعال کردن منبع، پرسنل را غیرفعال نمی‌کند (پرسنل ممکن است + * فقط نقش اداری داشته باشد). + */ + public function syncActive(Doctor|ClinicStaff|Room $subject, bool $active): int + { + $touched = 0; + + foreach ($this->resources->findAllForSubject($subject) as $resource) { + if ($resource->isActive() !== $active) { + $resource->setActive($active); + $touched++; + } + } + + if ($touched > 0) { + $this->em->flush(); + } + + return $touched; + } +} diff --git a/src/Resource/Service/ResourcePoolService.php b/src/Resource/Service/ResourcePoolService.php new file mode 100644 index 00000000..f9b14dc7 --- /dev/null +++ b/src/Resource/Service/ResourcePoolService.php @@ -0,0 +1,105 @@ + $rows + * @return ResourcePoolMember[] + */ + public function replaceMembers(User $user, ResourcePool $pool, array $rows): array + { + $validated = $this->validate($user, $pool, $rows); + + $this->members->deleteForPool($pool); + $pool->getMembers()->clear(); + + $created = []; + foreach ($validated as $row) { + $member = new ResourcePoolMember($pool, $row['resource'], $row['priority']); + $this->em->persist($member); + $pool->getMembers()->add($member); + $created[] = $member; + } + + $pool->touch(); + $this->em->flush(); + + return $created; + } + + /** + * @param array $rows + * @return list + */ + private function validate(User $user, ResourcePool $pool, array $rows): array + { + $seen = []; + $out = []; + + foreach ($rows as $index => $row) { + $uuid = is_array($row) ? ($row['resource_uuid'] ?? null) : $row; + + if (!is_string($uuid) || $uuid === '') { + throw new AppException(ErrorCodes::ERR_VALIDATION_002, 'فیلد resource_uuid الزامی است', 422, 'resource_uuid'); + } + + if (isset($seen[$uuid])) { + throw new AppException(ErrorCodes::ERR_VALIDATION_001, 'یک منبع دو بار فرستاده شده است', 422, 'resource_uuid'); + } + $seen[$uuid] = true; + + $resource = $this->context->resource($user, $uuid); + + if ($resource->getAddress()->getId() !== $pool->getAddress()->getId()) { + throw new AppException( + ErrorCodes::ERR_VALIDATION_001, + 'همهٔ اعضای استخر باید در یک شعبه باشند', + 422, + 'resource_uuid', + ); + } + + if ($resource->getType()->getId() !== $pool->getType()->getId()) { + throw new AppException( + ErrorCodes::ERR_VALIDATION_001, + 'همهٔ اعضای استخر باید از یک نوع منبع باشند', + 422, + 'resource_uuid', + ); + } + + $priority = is_array($row) ? ($row['priority'] ?? $index) : $index; + + if (!is_numeric($priority)) { + throw new AppException(ErrorCodes::ERR_VALIDATION_001, 'اولویت باید عدد باشد', 422, 'priority'); + } + + $out[] = ['resource' => $resource, 'priority' => (int) $priority]; + } + + return $out; + } +} diff --git a/src/Resource/Service/ResourceService.php b/src/Resource/Service/ResourceService.php new file mode 100644 index 00000000..d877fc38 --- /dev/null +++ b/src/Resource/Service/ResourceService.php @@ -0,0 +1,179 @@ + $data */ + public function create(DoctorAddress $address, ResourceType $type, array $data): ClinicResource + { + $resource = new ClinicResource($address, $type, $this->assertName($data['name'] ?? null)); + $this->applyOptional($resource, $data); + + $this->em->persist($resource); + $this->em->flush(); + + return $resource; + } + + /** @param array $data */ + public function update(ClinicResource $resource, array $data): ClinicResource + { + if (array_key_exists('name', $data)) { + $resource->setName($this->assertName($data['name'])); + } + + $this->applyOptional($resource, $data); + $this->em->flush(); + + return $resource; + } + + public function delete(ClinicResource $resource): void + { + $this->em->remove($resource); + $this->em->flush(); + } + + /** @param array $data */ + private function applyOptional(ClinicResource $resource, array $data): void + { + if (array_key_exists('capacity', $data)) { + $this->guard('capacity', fn () => $resource->setCapacity($this->assertInt($data['capacity'], 'capacity'))); + } + + if (array_key_exists('setup_minutes', $data)) { + $this->guard('setup_minutes', fn () => $resource->setSetupMinutes($this->assertInt($data['setup_minutes'], 'setup_minutes'))); + } + + if (array_key_exists('cleanup_minutes', $data)) { + $this->guard('cleanup_minutes', fn () => $resource->setCleanupMinutes($this->assertInt($data['cleanup_minutes'], 'cleanup_minutes'))); + } + + if (array_key_exists('attributes', $data)) { + $resource->setAttributes($this->normalizeAttributes($data['attributes'])); + } + + if (array_key_exists('active', $data)) { + $resource->setActive((bool) $data['active']); + } + } + + /** + * `attributes` آزاد است ولی نه بی‌قید: کلید `[a-z_]{1,40}`، مقدار فقط اسکالر، + * حداکثر ۲۰ کلید. + * + * محدودیت اسکالر عمدی است: تسک ۰۵ قید `same_gender` و تسک ۰۹ شرط‌های منبع را با + * مقایسهٔ ساده روی همین مقادیر می‌سنجند. آرایهٔ تودرتو یعنی مقایسهٔ دلخواه — همان + * چیزی که بند ۸ مستند ممنوع کرده. + * + * @return array + */ + public function normalizeAttributes(mixed $raw): array + { + if ($raw === null || $raw === []) { + return []; + } + + if (!is_array($raw)) { + throw new AppException(ErrorCodes::ERR_VALIDATION_001, 'ویژگی‌ها باید یک شیء باشد', 422, 'attributes'); + } + + if (count($raw) > ClinicResource::MAX_ATTRIBUTES) { + throw new AppException( + ErrorCodes::ERR_VALIDATION_001, + sprintf('حداکثر %d ویژگی مجاز است', ClinicResource::MAX_ATTRIBUTES), + 422, + 'attributes', + ); + } + + $out = []; + foreach ($raw as $key => $value) { + if (!is_string($key) || preg_match('/^[a-z_]{1,40}$/', $key) !== 1) { + throw new AppException( + ErrorCodes::ERR_VALIDATION_001, + 'نام ویژگی فقط حروف کوچک انگلیسی و زیرخط می‌پذیرد', + 422, + 'attributes', + ); + } + + if ($value !== null && !is_scalar($value)) { + throw new AppException( + ErrorCodes::ERR_VALIDATION_001, + sprintf('مقدار ویژگی «%s» باید یک مقدار ساده باشد', $key), + 422, + 'attributes', + ); + } + + if ($value !== null) { + $out[$key] = $value; + } + } + + return $out; + } + + /** + * قواعد عددی در خودِ entity زندگی می‌کنند تا هیچ مسیری دورشان نزند؛ اینجا فقط + * استثنای انگلیسیِ آن‌ها به خطای HTTP فارسی با **فیلد درست** ترجمه می‌شود. + */ + private function guard(string $field, callable $apply): void + { + try { + $apply(); + } catch (\InvalidArgumentException $e) { + throw new AppException(ErrorCodes::ERR_VALIDATION_001, $this->persianFor($e->getMessage()), 422, $field); + } + } + + private function assertName(mixed $value): string + { + $name = is_string($value) ? trim($value) : ''; + + if ($name === '') { + throw new AppException(ErrorCodes::ERR_VALIDATION_002, 'نام منبع الزامی است', 422, 'name'); + } + + if (mb_strlen($name) > 150) { + throw new AppException(ErrorCodes::ERR_VALIDATION_001, 'نام منبع حداکثر ۱۵۰ نویسه است', 422, 'name'); + } + + return $name; + } + + private function assertInt(mixed $value, string $field): int + { + if (!is_numeric($value)) { + throw new AppException(ErrorCodes::ERR_VALIDATION_001, sprintf('%s باید عدد باشد', $field), 422, $field); + } + + return (int) $value; + } + + private function persianFor(string $englishMessage): string + { + return match (true) { + str_contains($englishMessage, 'at least 1') => 'ظرفیت منبع حداقل ۱ است', + str_contains($englishMessage, 'more than one') => 'منبعی که یک شخص است نمی‌تواند ظرفیت بیش از ۱ داشته باشد', + str_contains($englishMessage, 'between 0 and') => 'زمان آماده‌سازی/تمیزکاری باید بین ۰ تا ۴۸۰ دقیقه باشد', + default => 'ورودی نامعتبر است', + }; + } +} diff --git a/src/Resource/Service/SkillAssignmentService.php b/src/Resource/Service/SkillAssignmentService.php new file mode 100644 index 00000000..4e7bdd1c --- /dev/null +++ b/src/Resource/Service/SkillAssignmentService.php @@ -0,0 +1,105 @@ + $rows + * @return ResourceSkill[] + */ + public function replace(User $user, ClinicResource $resource, array $rows): array + { + $validated = $this->validate($user, $rows); + + $this->resourceSkills->deleteForResource($resource); + $resource->getSkills()->clear(); + + $created = []; + foreach ($validated as $row) { + $assignment = new ResourceSkill($resource, $row['skill'], $row['level']); + $this->em->persist($assignment); + $resource->getSkills()->add($assignment); + $created[] = $assignment; + } + + $this->em->flush(); + + return $created; + } + + /** + * @param array $rows + * @return list + */ + private function validate(User $user, array $rows): array + { + $seen = []; + $out = []; + + foreach ($rows as $row) { + if (!is_array($row) || !is_string($row['skill_uuid'] ?? null)) { + throw new AppException(ErrorCodes::ERR_VALIDATION_002, 'فیلد skill_uuid الزامی است', 422, 'skill_uuid'); + } + + $uuid = $row['skill_uuid']; + + if (isset($seen[$uuid])) { + throw new AppException(ErrorCodes::ERR_VALIDATION_001, 'یک مهارت دو بار فرستاده شده است', 422, 'skill_uuid'); + } + $seen[$uuid] = true; + + // مهارت محیط دیگر → ۴۰۴، پیش از هر تغییری در دیتابیس. + $skill = $this->context->skill($user, $uuid); + $level = $row['level'] ?? ResourceSkill::MIN_LEVEL; + + if (!is_numeric($level) || (int) $level < ResourceSkill::MIN_LEVEL || (int) $level > ResourceSkill::MAX_LEVEL) { + throw new AppException(ErrorCodes::ERR_VALIDATION_001, 'سطح مهارت باید بین ۱ تا ۵ باشد', 422, 'level'); + } + + $out[] = ['skill' => $skill, 'level' => (int) $level]; + } + + return $out; + } + + /** + * مهارتی که روی منبعی نشسته حذف نمی‌شود — اول باید از منابع برداشته شود، وگرنه + * `ON DELETE RESTRICT` خطای خام دیتابیس می‌داد. + */ + public function deleteSkill(Skill $skill): void + { + $inUse = $this->resourceSkills->countForSkill($skill); + + if ($inUse > 0) { + throw new AppException( + ErrorCodes::ERR_VALIDATION_001, + sprintf('این مهارت به %d منبع داده شده است؛ اول از آن‌ها برداشته شود', $inUse), + 422, + ); + } + + $this->em->remove($skill); + $this->em->flush(); + } +} diff --git a/src/Shared/Tenant/GlobalTables.php b/src/Shared/Tenant/GlobalTables.php index ddd32667..7b8dccf9 100644 --- a/src/Shared/Tenant/GlobalTables.php +++ b/src/Shared/Tenant/GlobalTables.php @@ -89,6 +89,12 @@ final class GlobalTables public const AGGREGATE_CHILDREN = [ \App\Appointment\Entity\AppointmentEvent::class => \App\Appointment\Entity\Appointment::class, + // ریشه‌هاشان خودشان جفت محیط دارند (برخلاف پروندهٔ branch_working_hours در + // تسک ۰۱)، پس ارث‌بری اینجا واقعی است. هیچ‌کدام uuid از request نمی‌گیرند: + // تنها راهشان PUT روی /resource/{uuid}/skills و /resource-pool/{uuid}/members است. + \App\Resource\Entity\ResourceSkill::class => \App\Resource\Entity\ClinicResource::class, + \App\Resource\Entity\ResourcePoolMember::class => \App\Resource\Entity\ResourcePool::class, + \App\Patient\Entity\SessionAuditLog::class => \App\Patient\Entity\PatientSession::class, \App\Patient\Entity\SessionConsumable::class => \App\Patient\Entity\PatientSession::class, \App\Patient\Entity\SessionService::class => \App\Patient\Entity\PatientSession::class, diff --git a/tests/Resource/BackfillResourceTest.php b/tests/Resource/BackfillResourceTest.php new file mode 100644 index 00000000..9605ccfc --- /dev/null +++ b/tests/Resource/BackfillResourceTest.php @@ -0,0 +1,209 @@ +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)); + } +} diff --git a/tests/Resource/ResourceCrudTest.php b/tests/Resource/ResourceCrudTest.php new file mode 100644 index 00000000..8ba72281 --- /dev/null +++ b/tests/Resource/ResourceCrudTest.php @@ -0,0 +1,210 @@ +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']); + } +} diff --git a/tests/Resource/ResourceEligibilityTest.php b/tests/Resource/ResourceEligibilityTest.php new file mode 100644 index 00000000..44429d2f --- /dev/null +++ b/tests/Resource/ResourceEligibilityTest.php @@ -0,0 +1,156 @@ +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()); + } +} diff --git a/tests/Resource/ResourcePoolTest.php b/tests/Resource/ResourcePoolTest.php new file mode 100644 index 00000000..9d2b8a2d --- /dev/null +++ b/tests/Resource/ResourcePoolTest.php @@ -0,0 +1,181 @@ +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()); + } +} diff --git a/tests/Resource/ResourceTestCase.php b/tests/Resource/ResourceTestCase.php new file mode 100644 index 00000000..64a93778 --- /dev/null +++ b/tests/Resource/ResourceTestCase.php @@ -0,0 +1,107 @@ +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 $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]); + } +} diff --git a/tests/Resource/ResourceTypeTest.php b/tests/Resource/ResourceTypeTest.php new file mode 100644 index 00000000..436a7edf --- /dev/null +++ b/tests/Resource/ResourceTypeTest.php @@ -0,0 +1,127 @@ +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]); + } +} diff --git a/tests/Resource/SkillAssignmentTest.php b/tests/Resource/SkillAssignmentTest.php new file mode 100644 index 00000000..3dec990a --- /dev/null +++ b/tests/Resource/SkillAssignmentTest.php @@ -0,0 +1,176 @@ +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']); + } +}