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']); + } +}