feat(resource): resource types, resources, skills and pools
The document's first golden rule is "the calendar belongs to the resource, not to the doctor". Today the only thing that can be occupied is a doctor, and ClinicStaff is a label on services and appointments with no calendar, capacity or skills. This adds the layer underneath: anything that can be busy — doctor, operator, assistant, device, room, bed, chair. Two corrections to the planned schema: - `address_id` → doctor_addresses, not `branch_id` → a new branches table. The branch already exists and is the address (task 01). - UNIQUE is (doctor_id, address_id), not (doctor_id). A WeeklySchedule is per (doctor, clinic) but every session inside it carries its own location_id, so one doctor already works at several addresses within one environment. Keying on the doctor alone would have made that unrepresentable — and task 03 gives each resource its own calendar, which is exactly per-location. Design points worth keeping: - Resources bridge to Doctor/ClinicStaff/Room rather than absorbing them; those three have live consumers (appointments.doctor_id, service_item_staff, the public site) and subclassing would mean migrating all of them at once. At most one bridge column is non-null, enforced in the entity because MariaDB will not reliably enforce a multi-column CHECK. - Capacity is concurrency: a three-bed injection room is one resource with capacity 3, not three resources, so occupancy in task 06 stays a COUNT against a limit instead of a merge of three calendars. A person resource is refused capacity > 1. - Skills are a table, not rules. With 50 operators and 200 services, expressing "who may operate what" as policy would mean 10,000 rules. - findEligible() uses HAVING COUNT(DISTINCT …) because "skills A and B" means both; a plain IN would have matched a resource holding only one. - setup/cleanup minutes occupy the resource without being part of the patient's appointment, and are per-resource — distinct from the existing per-doctor WeeklySchedule.meta.buffer_minutes, which stays untouched. Two real bugs found by running the backfill against real data rather than fixtures: ResourceLinker::systemType() persisted a type without flushing, so the next lookup missed it and created a second — the run died on "Duplicate entry 'doctor-1-staff' for key uniq_rt_tenant_code". It now keeps an identity map for the unit of work. The command looped over every WeeklySchedule once per environment, which is quadratic and never finished on real data. Doctors are now a single pass keyed by the schedule's own environment. It also flushes per environment and accepts --pair=clinic:12, so one bad row cannot close the EntityManager and abort a fleet-wide run, and operators can re-run for a single clinic. Staff are the one case that cannot be derived: nothing records which branch they work at. Rather than guessing the first one and seating them in the wrong building, multi-branch environments are skipped and reported. 88 tests, 230 assertions across tests/Resource and tests/Branch. phpstan clean on src/Resource. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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<string, string | number | boolean>;
|
||||
/** `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<string, string | number | boolean>;
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace DoctrineMigrations;
|
||||
|
||||
use Doctrine\DBAL\Schema\Schema;
|
||||
use Doctrine\Migrations\AbstractMigration;
|
||||
|
||||
/**
|
||||
* Resource layer: types, resources, skills and pools.
|
||||
*
|
||||
* Resources hang off doctor_addresses, not a branches table — the branch already
|
||||
* exists and is the address (see docs/new_feture/taskes/_shared/branch-is-doctor-address.md).
|
||||
*
|
||||
* UNIQUE is (doctor_id, address_id) rather than (doctor_id): a WeeklySchedule is per
|
||||
* (doctor, clinic) but each of its sessions carries its own location_id, so one doctor
|
||||
* already works at several addresses inside one environment. Keying on doctor alone
|
||||
* would have made that unrepresentable.
|
||||
*/
|
||||
final class Version20260730132948 extends AbstractMigration
|
||||
{
|
||||
public function getDescription(): string
|
||||
{
|
||||
return 'Add resource types, resources, skills and resource pools';
|
||||
}
|
||||
|
||||
public function up(Schema $schema): void
|
||||
{
|
||||
$this->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');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,248 @@
|
||||
<?php
|
||||
|
||||
namespace App\Resource\Command;
|
||||
|
||||
use App\Appointment\Entity\WeeklySchedule;
|
||||
use App\Branch\Entity\Room;
|
||||
use App\Doctor\Entity\DoctorAddress;
|
||||
use App\Resource\Entity\ResourceType;
|
||||
use App\Resource\Repository\ClinicResourceRepository;
|
||||
use App\Resource\Service\ResourceLinker;
|
||||
use App\Staff\Entity\ClinicStaff;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Symfony\Component\Console\Attribute\AsCommand;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
use Symfony\Component\Console\Input\InputOption;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
use Symfony\Component\Console\Command\Command;
|
||||
use Symfony\Component\Console\Style\SymfonyStyle;
|
||||
|
||||
/**
|
||||
* هر موجودیت قابلاشغالِ موجود را به یک `ClinicResource` پل میزند تا تسکهای ۰۳ به بعد
|
||||
* روی دادهای واقعی بنشینند، نه روی جدول خالی.
|
||||
*
|
||||
* dry-run پیشفرض است و `--force` مینویسد. idempotent: تکیهگاهش وجود یا نبودِ منبعِ
|
||||
* متناظر است، نه یک پرچم جداگانه — پس اجرای دوباره چیزی دوباره نمیسازد.
|
||||
*/
|
||||
#[AsCommand(
|
||||
name: 'app:resource:backfill',
|
||||
description: 'Bridge existing doctors, staff and rooms to clinic resources',
|
||||
)]
|
||||
class BackfillResourceCommand extends Command
|
||||
{
|
||||
public function __construct(
|
||||
private readonly EntityManagerInterface $em,
|
||||
private readonly ResourceLinker $linker,
|
||||
private readonly ClinicResourceRepository $resources,
|
||||
) {
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
protected function configure(): void
|
||||
{
|
||||
$this->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<array{0: string, 1: string, 2: string}> $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<string, array<int, DoctorAddress>> $addressesByPair
|
||||
* @param list<array{0: string, 1: string, 2: string}> $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<string, array<int, DoctorAddress>> "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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
<?php
|
||||
|
||||
namespace App\Resource\Controller;
|
||||
|
||||
use App\Auth\Entity\User;
|
||||
use App\Resource\Entity\ClinicResource;
|
||||
use App\Resource\Repository\ClinicResourceRepository;
|
||||
use App\Resource\Service\ResourceContext;
|
||||
use App\Resource\Service\ResourceService;
|
||||
use App\Resource\Service\SkillAssignmentService;
|
||||
use App\Shared\Constant\ErrorCodes;
|
||||
use App\Shared\Controller\BaseController;
|
||||
use OpenApi\Attributes as OA;
|
||||
use Symfony\Component\HttpFoundation\JsonResponse;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\Routing\Attribute\Route;
|
||||
use Symfony\Component\Security\Http\Attribute\CurrentUser;
|
||||
use Symfony\Component\Security\Http\Attribute\IsGranted;
|
||||
|
||||
#[OA\Tag(name: 'Resource')]
|
||||
#[IsGranted('IS_AUTHENTICATED_FULLY')]
|
||||
class ResourceController extends BaseController
|
||||
{
|
||||
use ResourcePermissionTrait;
|
||||
|
||||
public function __construct(
|
||||
private readonly ResourceContext $context,
|
||||
private readonly ClinicResourceRepository $resources,
|
||||
private readonly ResourceService $service,
|
||||
private readonly SkillAssignmentService $skills,
|
||||
) {}
|
||||
|
||||
#[Route('/api/v1/resources', name: 'resource_list', methods: ['GET'])]
|
||||
public function list(#[CurrentUser] User $user, Request $request): JsonResponse
|
||||
{
|
||||
$this->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());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
<?php
|
||||
|
||||
namespace App\Resource\Controller;
|
||||
|
||||
use App\Auth\Entity\User;
|
||||
use App\Clinic\Security\ClinicDoctorAccessChecker;
|
||||
use App\Secretary\Security\SecretaryAccessChecker;
|
||||
use Symfony\Contracts\Service\Attribute\Required;
|
||||
|
||||
/**
|
||||
* گِیتِ مشترک چهار کنترلر این دامنه.
|
||||
*
|
||||
* مجوز `appointment_settings` بازاستفاده میشود و مجوز تازهای ساخته نمیشود: منابع
|
||||
* بخشی از پیکربندی نوبتدهیاند و افزودن یک کلید تازه یعنی یک ستون تازه در جدول
|
||||
* مجوزهای هر منشی و هر پزشکِ عضو، بدون اینکه کسی خواسته باشد آنها را جدا کند.
|
||||
*/
|
||||
trait ResourcePermissionTrait
|
||||
{
|
||||
private SecretaryAccessChecker $secretaryAccess;
|
||||
private ClinicDoctorAccessChecker $clinicDoctorAccess;
|
||||
|
||||
#[Required]
|
||||
public function setResourceAccessCheckers(
|
||||
SecretaryAccessChecker $secretaryAccess,
|
||||
ClinicDoctorAccessChecker $clinicDoctorAccess,
|
||||
): void {
|
||||
$this->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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
<?php
|
||||
|
||||
namespace App\Resource\Controller;
|
||||
|
||||
use App\Auth\Entity\User;
|
||||
use App\Resource\Entity\ResourcePool;
|
||||
use App\Resource\Repository\ResourcePoolRepository;
|
||||
use App\Resource\Service\ResourceContext;
|
||||
use App\Resource\Service\ResourcePoolService;
|
||||
use App\Shared\Constant\ErrorCodes;
|
||||
use App\Shared\Controller\BaseController;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use OpenApi\Attributes as OA;
|
||||
use Symfony\Component\HttpFoundation\JsonResponse;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\Routing\Attribute\Route;
|
||||
use Symfony\Component\Security\Http\Attribute\CurrentUser;
|
||||
use Symfony\Component\Security\Http\Attribute\IsGranted;
|
||||
|
||||
#[OA\Tag(name: 'Resource')]
|
||||
#[IsGranted('IS_AUTHENTICATED_FULLY')]
|
||||
class ResourcePoolController extends BaseController
|
||||
{
|
||||
use ResourcePermissionTrait;
|
||||
|
||||
public function __construct(
|
||||
private readonly ResourceContext $context,
|
||||
private readonly ResourcePoolRepository $pools,
|
||||
private readonly ResourcePoolService $service,
|
||||
private readonly EntityManagerInterface $em,
|
||||
) {}
|
||||
|
||||
#[Route('/api/v1/resource-pools', name: 'resource_pool_list', methods: ['GET'])]
|
||||
public function list(#[CurrentUser] User $user): JsonResponse
|
||||
{
|
||||
$this->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());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
<?php
|
||||
|
||||
namespace App\Resource\Controller;
|
||||
|
||||
use App\Auth\Entity\User;
|
||||
use App\Resource\Entity\ResourceType;
|
||||
use App\Resource\Repository\ClinicResourceRepository;
|
||||
use App\Resource\Repository\ResourceTypeRepository;
|
||||
use App\Resource\Service\ResourceContext;
|
||||
use App\Shared\Constant\ErrorCodes;
|
||||
use App\Shared\Controller\BaseController;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use OpenApi\Attributes as OA;
|
||||
use Symfony\Component\HttpFoundation\JsonResponse;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\Routing\Attribute\Route;
|
||||
use Symfony\Component\Security\Http\Attribute\CurrentUser;
|
||||
use Symfony\Component\Security\Http\Attribute\IsGranted;
|
||||
|
||||
#[OA\Tag(name: 'Resource')]
|
||||
#[IsGranted('IS_AUTHENTICATED_FULLY')]
|
||||
class ResourceTypeController extends BaseController
|
||||
{
|
||||
use ResourcePermissionTrait;
|
||||
|
||||
public function __construct(
|
||||
private readonly ResourceContext $context,
|
||||
private readonly ResourceTypeRepository $types,
|
||||
private readonly ClinicResourceRepository $resources,
|
||||
private readonly EntityManagerInterface $em,
|
||||
) {}
|
||||
|
||||
#[Route('/api/v1/resource-types', name: 'resource_type_list', methods: ['GET'])]
|
||||
public function list(#[CurrentUser] User $user): JsonResponse
|
||||
{
|
||||
$this->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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
<?php
|
||||
|
||||
namespace App\Resource\Controller;
|
||||
|
||||
use App\Auth\Entity\User;
|
||||
use App\Resource\Entity\Skill;
|
||||
use App\Resource\Repository\ResourceSkillRepository;
|
||||
use App\Resource\Repository\SkillRepository;
|
||||
use App\Resource\Service\ResourceContext;
|
||||
use App\Resource\Service\SkillAssignmentService;
|
||||
use App\Shared\Constant\ErrorCodes;
|
||||
use App\Shared\Controller\BaseController;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use OpenApi\Attributes as OA;
|
||||
use Symfony\Component\HttpFoundation\JsonResponse;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\Routing\Attribute\Route;
|
||||
use Symfony\Component\Security\Http\Attribute\CurrentUser;
|
||||
use Symfony\Component\Security\Http\Attribute\IsGranted;
|
||||
|
||||
#[OA\Tag(name: 'Resource')]
|
||||
#[IsGranted('IS_AUTHENTICATED_FULLY')]
|
||||
class SkillController extends BaseController
|
||||
{
|
||||
use ResourcePermissionTrait;
|
||||
|
||||
public function __construct(
|
||||
private readonly ResourceContext $context,
|
||||
private readonly SkillRepository $skills,
|
||||
private readonly ResourceSkillRepository $assignments,
|
||||
private readonly SkillAssignmentService $service,
|
||||
private readonly EntityManagerInterface $em,
|
||||
) {}
|
||||
|
||||
#[Route('/api/v1/skills', name: 'skill_list', methods: ['GET'])]
|
||||
public function list(#[CurrentUser] User $user): JsonResponse
|
||||
{
|
||||
$this->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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,247 @@
|
||||
<?php
|
||||
|
||||
namespace App\Resource\Entity;
|
||||
|
||||
use App\Doctor\Entity\Doctor;
|
||||
use App\Doctor\Entity\DoctorAddress;
|
||||
use App\Branch\Entity\Room;
|
||||
use App\Resource\Repository\ClinicResourceRepository;
|
||||
use App\Shared\Tenant\TenantOwnedTrait;
|
||||
use App\Staff\Entity\ClinicStaff;
|
||||
use Doctrine\Common\Collections\ArrayCollection;
|
||||
use Doctrine\Common\Collections\Collection;
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
use Symfony\Component\Uid\Uuid;
|
||||
|
||||
/**
|
||||
* هر چیزی که ممکن است اشغال باشد: پزشک، اپراتور، دستیار، دستگاه، اتاق، تخت، یونیت.
|
||||
* قانون طلایی مستند: «تقویم مال منبع است، نه مال پزشک» — تقویمش در تسک ۰۳ میآید.
|
||||
*
|
||||
* نام کلاس عمداً `Resource` نیست: در این کدبیس با مفهوم «منبع API» قاطی میشود و
|
||||
* جستجوی کد را پر نویز میکند.
|
||||
*
|
||||
* ## پل، نه ادغام
|
||||
*
|
||||
* `Doctor`، `ClinicStaff` و `Room` هرکدام هویت مستقل و مصرفکنندهٔ زنده دارند
|
||||
* (`appointments.doctor_id`، `service_item_staff`، سایت عمومی). تبدیلشان به زیرکلاس
|
||||
* یعنی مهاجرت همزمان همهٔ آن مسیرها. بهجایش حداکثر **یکی** از سه ستون پل پر است؛
|
||||
* منبعِ بدون پل یعنی دستگاه یا تجهیزات.
|
||||
*/
|
||||
#[ORM\Entity(repositoryClass: ClinicResourceRepository::class)]
|
||||
#[ORM\Table(name: 'clinic_resources')]
|
||||
#[ORM\Index(columns: ['entity_type', 'entity_id', 'active'], name: 'idx_resources_tenant')]
|
||||
#[ORM\Index(columns: ['address_id', 'resource_type_id', 'active'], name: 'idx_resources_address_type')]
|
||||
#[ORM\UniqueConstraint(name: 'uniq_resource_doctor_address', columns: ['doctor_id', 'address_id'])]
|
||||
#[ORM\UniqueConstraint(name: 'uniq_resource_staff_address', columns: ['staff_id', 'address_id'])]
|
||||
#[ORM\UniqueConstraint(name: 'uniq_resource_room', columns: ['room_id'])]
|
||||
class ClinicResource
|
||||
{
|
||||
use TenantOwnedTrait;
|
||||
|
||||
public const MAX_ATTRIBUTES = 20;
|
||||
|
||||
#[ORM\Id]
|
||||
#[ORM\GeneratedValue]
|
||||
#[ORM\Column(type: 'integer')]
|
||||
private ?int $id = null;
|
||||
|
||||
#[ORM\Column(type: 'string', length: 36, unique: true)]
|
||||
private string $uuid;
|
||||
|
||||
/**
|
||||
* منابع همیشه مال یک شعبهاند، و «شعبه» همان آدرس محل نوبتدهی است
|
||||
* ({@see docs/new_feture/taskes/_shared/branch-is-doctor-address.md}).
|
||||
*/
|
||||
#[ORM\ManyToOne(targetEntity: DoctorAddress::class)]
|
||||
#[ORM\JoinColumn(name: 'address_id', referencedColumnName: 'id', nullable: false, onDelete: 'RESTRICT')]
|
||||
private DoctorAddress $address;
|
||||
|
||||
#[ORM\ManyToOne(targetEntity: ResourceType::class)]
|
||||
#[ORM\JoinColumn(name: 'resource_type_id', referencedColumnName: 'id', nullable: false, onDelete: 'RESTRICT')]
|
||||
private ResourceType $type;
|
||||
|
||||
#[ORM\Column(type: 'string', length: 150)]
|
||||
private string $name;
|
||||
|
||||
/** ظرفیت همزمان: اتاق تزریق سهتخته یک ردیف با ظرفیت ۳ است، نه سه ردیف. */
|
||||
#[ORM\Column(type: 'smallint', options: ['default' => 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<int, ResourceSkill> */
|
||||
#[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<int, ResourceSkill> */
|
||||
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,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
<?php
|
||||
|
||||
namespace App\Resource\Entity;
|
||||
|
||||
use App\Doctor\Entity\DoctorAddress;
|
||||
use App\Resource\Repository\ResourcePoolRepository;
|
||||
use App\Shared\Tenant\TenantOwnedTrait;
|
||||
use Doctrine\Common\Collections\ArrayCollection;
|
||||
use Doctrine\Common\Collections\Collection;
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
use Symfony\Component\Uid\Uuid;
|
||||
|
||||
/**
|
||||
* گروهی از منابع که **جایگزین کامل** یکدیگرند: «لیزرهای آلکساندرایت»، «اتاقهای معاینه».
|
||||
*
|
||||
* استخر درون یک شعبه است و اعضایش همنوعاند. تسک ۰۶ فرض میکند هر عضو جایگزین کامل
|
||||
* دیگری است؛ اگر عضوی در شعبهٔ دیگری باشد، بیمار در ساختمان اشتباه میایستد.
|
||||
*/
|
||||
#[ORM\Entity(repositoryClass: ResourcePoolRepository::class)]
|
||||
#[ORM\Table(name: 'resource_pools')]
|
||||
#[ORM\Index(columns: ['entity_type', 'entity_id', 'active'], name: 'idx_pools_tenant')]
|
||||
#[ORM\Index(columns: ['address_id', 'resource_type_id'], name: 'idx_pools_address')]
|
||||
class ResourcePool
|
||||
{
|
||||
use TenantOwnedTrait;
|
||||
|
||||
#[ORM\Id]
|
||||
#[ORM\GeneratedValue]
|
||||
#[ORM\Column(type: 'integer')]
|
||||
private ?int $id = null;
|
||||
|
||||
#[ORM\Column(type: 'string', length: 36, unique: true)]
|
||||
private string $uuid;
|
||||
|
||||
#[ORM\ManyToOne(targetEntity: DoctorAddress::class)]
|
||||
#[ORM\JoinColumn(name: 'address_id', referencedColumnName: 'id', nullable: false, onDelete: 'CASCADE')]
|
||||
private DoctorAddress $address;
|
||||
|
||||
#[ORM\ManyToOne(targetEntity: ResourceType::class)]
|
||||
#[ORM\JoinColumn(name: 'resource_type_id', referencedColumnName: 'id', nullable: false, onDelete: 'RESTRICT')]
|
||||
private ResourceType $type;
|
||||
|
||||
#[ORM\Column(type: 'string', length: 150)]
|
||||
private string $name;
|
||||
|
||||
#[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<int, ResourcePoolMember> */
|
||||
#[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<int, ResourcePoolMember> */
|
||||
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,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
<?php
|
||||
|
||||
namespace App\Resource\Entity;
|
||||
|
||||
use App\Resource\Repository\ResourcePoolMemberRepository;
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
|
||||
/**
|
||||
* عضویت یک منبع در یک استخر. فرزند aggregate با ریشهٔ {@see ResourcePool} که خودش
|
||||
* جفت محیط دارد؛ uuid ندارد و فقط از `PUT /resource-pool/{uuid}/members` نوشته میشود.
|
||||
*/
|
||||
#[ORM\Entity(repositoryClass: ResourcePoolMemberRepository::class)]
|
||||
#[ORM\Table(name: 'resource_pool_members')]
|
||||
#[ORM\UniqueConstraint(name: 'uniq_pool_resource', columns: ['pool_id', 'resource_id'])]
|
||||
#[ORM\Index(columns: ['resource_id'], name: 'idx_pool_members_resource')]
|
||||
class ResourcePoolMember
|
||||
{
|
||||
#[ORM\Id]
|
||||
#[ORM\GeneratedValue]
|
||||
#[ORM\Column(type: 'integer')]
|
||||
private ?int $id = null;
|
||||
|
||||
#[ORM\ManyToOne(targetEntity: ResourcePool::class, inversedBy: 'members')]
|
||||
#[ORM\JoinColumn(name: 'pool_id', referencedColumnName: 'id', nullable: false, onDelete: 'CASCADE')]
|
||||
private ResourcePool $pool;
|
||||
|
||||
#[ORM\ManyToOne(targetEntity: ClinicResource::class)]
|
||||
#[ORM\JoinColumn(name: 'resource_id', referencedColumnName: 'id', nullable: false, onDelete: 'CASCADE')]
|
||||
private ClinicResource $resource;
|
||||
|
||||
/** ترتیب ترجیح در استراتژی انتخابِ تسک ۰۶؛ کوچکتر = زودتر. */
|
||||
#[ORM\Column(type: 'smallint', options: ['default' => 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(),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
<?php
|
||||
|
||||
namespace App\Resource\Entity;
|
||||
|
||||
use App\Resource\Repository\ResourceSkillRepository;
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
|
||||
/**
|
||||
* «این منبع این مهارت را در این سطح دارد.»
|
||||
*
|
||||
* فرزند aggregate با ریشهٔ {@see ClinicResource} — برخلاف پروندهٔ تسک ۰۱، اینجا ریشه
|
||||
* خودش جفت محیط دارد، پس ارثبری واقعی است. uuid هم ندارد: تنها راه رسیدن به آن
|
||||
* `PUT /api/v1/resource/{uuid}/skills` است.
|
||||
*/
|
||||
#[ORM\Entity(repositoryClass: ResourceSkillRepository::class)]
|
||||
#[ORM\Table(name: 'resource_skills')]
|
||||
#[ORM\UniqueConstraint(name: 'uniq_resource_skill', columns: ['resource_id', 'skill_id'])]
|
||||
#[ORM\Index(columns: ['skill_id', 'level'], name: 'idx_resource_skills_skill')]
|
||||
class ResourceSkill
|
||||
{
|
||||
public const MIN_LEVEL = 1;
|
||||
public const MAX_LEVEL = 5;
|
||||
|
||||
#[ORM\Id]
|
||||
#[ORM\GeneratedValue]
|
||||
#[ORM\Column(type: 'integer')]
|
||||
private ?int $id = null;
|
||||
|
||||
#[ORM\ManyToOne(targetEntity: ClinicResource::class, inversedBy: 'skills')]
|
||||
#[ORM\JoinColumn(name: 'resource_id', referencedColumnName: 'id', nullable: false, onDelete: 'CASCADE')]
|
||||
private ClinicResource $resource;
|
||||
|
||||
#[ORM\ManyToOne(targetEntity: Skill::class)]
|
||||
#[ORM\JoinColumn(name: 'skill_id', referencedColumnName: 'id', nullable: false, onDelete: 'RESTRICT')]
|
||||
private Skill $skill;
|
||||
|
||||
/**
|
||||
* ۱..۵. از روز اول هست چون تسک ۰۶ استراتژی «حفظ متخصصها» را روی همین میسازد و
|
||||
* افزودنش بعداً یعنی backfill با حدس.
|
||||
*/
|
||||
#[ORM\Column(type: 'smallint', options: ['default' => 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,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
<?php
|
||||
|
||||
namespace App\Resource\Entity;
|
||||
|
||||
use App\Resource\Repository\ResourceTypeRepository;
|
||||
use App\Shared\Tenant\TenantOwnedTrait;
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
use Symfony\Component\Uid\Uuid;
|
||||
|
||||
/**
|
||||
* نوع منبع — کلینیک خودش تعریفش میکند: پزشک، اپراتور، اتاق، دستگاه لیزر، یونیت.
|
||||
*
|
||||
* سه نوعِ `doctor`/`staff`/`room` را backfill میسازد و `isSystem` علامتشان میزند،
|
||||
* چون `ResourceLinker` با همین کدها پل میزند؛ حذفشان یعنی شکستن آن نگاشت.
|
||||
*/
|
||||
#[ORM\Entity(repositoryClass: ResourceTypeRepository::class)]
|
||||
#[ORM\Table(name: 'resource_types')]
|
||||
#[ORM\Index(columns: ['entity_type', 'entity_id', 'active'], name: 'idx_resource_types_tenant')]
|
||||
#[ORM\UniqueConstraint(name: 'uniq_rt_tenant_code', columns: ['entity_type', 'entity_id', 'code'])]
|
||||
class ResourceType
|
||||
{
|
||||
use TenantOwnedTrait;
|
||||
|
||||
public const CODE_DOCTOR = 'doctor';
|
||||
public const CODE_STAFF = 'staff';
|
||||
public const CODE_ROOM = 'room';
|
||||
|
||||
/** کدهایی که ResourceLinker به آنها تکیه دارد و backfill میسازدشان. */
|
||||
public const SYSTEM_CODES = [
|
||||
self::CODE_DOCTOR => 'پزشک',
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
<?php
|
||||
|
||||
namespace App\Resource\Entity;
|
||||
|
||||
use App\Resource\Repository\SkillRepository;
|
||||
use App\Shared\Tenant\TenantOwnedTrait;
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
use Symfony\Component\Uid\Uuid;
|
||||
|
||||
/**
|
||||
* مهارت — «کدام اپراتور مجاز است با کدام دستگاه کار کند» یک **اطلاعات** است، نه یک
|
||||
* قانون (بند ۶ مستند). با ۵۰ اپراتور و ۲۰۰ سرویس، سپردنش به موتور قوانین یعنی
|
||||
* ۱۰٬۰۰۰ قانون؛ اینجا یک جدول واسط ساده است.
|
||||
*
|
||||
* با `ClinicStaff::$jobTitle` قاطی نشود: آن متن آزاد و فقط برای نمایش است و هیچجا
|
||||
* برای تصمیمگیری parse نمیشود.
|
||||
*/
|
||||
#[ORM\Entity(repositoryClass: SkillRepository::class)]
|
||||
#[ORM\Table(name: 'skills')]
|
||||
#[ORM\Index(columns: ['entity_type', 'entity_id', 'active'], name: 'idx_skills_tenant')]
|
||||
class Skill
|
||||
{
|
||||
use TenantOwnedTrait;
|
||||
|
||||
#[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: 120)]
|
||||
private string $name;
|
||||
|
||||
#[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 $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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
<?php
|
||||
|
||||
namespace App\Resource\Repository;
|
||||
|
||||
use App\Doctor\Entity\Doctor;
|
||||
use App\Doctor\Entity\DoctorAddress;
|
||||
use App\Branch\Entity\Room;
|
||||
use App\Resource\Entity\ClinicResource;
|
||||
use App\Resource\Entity\ResourceType;
|
||||
use App\Staff\Entity\ClinicStaff;
|
||||
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
|
||||
use Doctrine\Persistence\ManagerRegistry;
|
||||
|
||||
/**
|
||||
* @extends ServiceEntityRepository<ClinicResource>
|
||||
*/
|
||||
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<int, int> شناسهٔ نوع => تعداد منبع
|
||||
*/
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
<?php
|
||||
|
||||
namespace App\Resource\Repository;
|
||||
|
||||
use App\Resource\Entity\ResourcePool;
|
||||
use App\Resource\Entity\ResourcePoolMember;
|
||||
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
|
||||
use Doctrine\Persistence\ManagerRegistry;
|
||||
|
||||
/**
|
||||
* @extends ServiceEntityRepository<ResourcePoolMember>
|
||||
*/
|
||||
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();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
<?php
|
||||
|
||||
namespace App\Resource\Repository;
|
||||
|
||||
use App\Resource\Entity\ResourcePool;
|
||||
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
|
||||
use Doctrine\Persistence\ManagerRegistry;
|
||||
|
||||
/**
|
||||
* @extends ServiceEntityRepository<ResourcePool>
|
||||
*/
|
||||
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();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
<?php
|
||||
|
||||
namespace App\Resource\Repository;
|
||||
|
||||
use App\Resource\Entity\ClinicResource;
|
||||
use App\Resource\Entity\ResourceSkill;
|
||||
use App\Resource\Entity\Skill;
|
||||
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
|
||||
use Doctrine\Persistence\ManagerRegistry;
|
||||
|
||||
/**
|
||||
* @extends ServiceEntityRepository<ResourceSkill>
|
||||
*/
|
||||
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<int, int> شناسهٔ مهارت => تعداد منبعی که دارد
|
||||
*/
|
||||
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();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
<?php
|
||||
|
||||
namespace App\Resource\Repository;
|
||||
|
||||
use App\Resource\Entity\ResourceType;
|
||||
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
|
||||
use Doctrine\Persistence\ManagerRegistry;
|
||||
|
||||
/**
|
||||
* @extends ServiceEntityRepository<ResourceType>
|
||||
*/
|
||||
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]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
<?php
|
||||
|
||||
namespace App\Resource\Repository;
|
||||
|
||||
use App\Resource\Entity\Skill;
|
||||
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
|
||||
use Doctrine\Persistence\ManagerRegistry;
|
||||
|
||||
/**
|
||||
* @extends ServiceEntityRepository<Skill>
|
||||
*/
|
||||
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();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
<?php
|
||||
|
||||
namespace App\Resource\Service;
|
||||
|
||||
use App\Auth\Entity\User;
|
||||
use App\Branch\Service\BranchResolver;
|
||||
use App\Doctor\Entity\DoctorAddress;
|
||||
use App\Resource\Entity\ClinicResource;
|
||||
use App\Resource\Entity\ResourcePool;
|
||||
use App\Resource\Entity\ResourceType;
|
||||
use App\Resource\Entity\Skill;
|
||||
use App\Resource\Repository\ClinicResourceRepository;
|
||||
use App\Resource\Repository\ResourcePoolRepository;
|
||||
use App\Resource\Repository\ResourceTypeRepository;
|
||||
use App\Resource\Repository\SkillRepository;
|
||||
use App\Shared\Constant\ErrorCodes;
|
||||
use App\Shared\Exception\AppException;
|
||||
use App\Shared\Tenant\TenantOwnershipChecker;
|
||||
|
||||
/**
|
||||
* تبدیل uuidهای درخواست به موجودیتهای **محیط جاری**.
|
||||
*
|
||||
* مالکیت صریح سنجیده میشود و به `TenantFilter` تکیه نمیکنیم: جداسازی سختِ فیلتر فقط
|
||||
* روی محیطِ «انتخابشده» اعمال میشود، پس کاربری که هنوز محیطی برنگزیده، دادهٔ محیط
|
||||
* دیگر را میدید. همان درسی که در تسک ۰۱ با `RoomCrudTest::testForeignRoomIsNotFound`
|
||||
* گرفته شد ({@see docs/architecture/tenancy.md}).
|
||||
*
|
||||
* همهجا ۴۰۴ میدهد نه ۴۰۳ — وجود دادهٔ محیط بیگانه لو نمیرود.
|
||||
*/
|
||||
final class ResourceContext
|
||||
{
|
||||
public function __construct(
|
||||
private readonly BranchResolver $branches,
|
||||
private readonly ResourceTypeRepository $types,
|
||||
private readonly ClinicResourceRepository $resources,
|
||||
private readonly SkillRepository $skills,
|
||||
private readonly ResourcePoolRepository $pools,
|
||||
private readonly TenantOwnershipChecker $ownership,
|
||||
) {}
|
||||
|
||||
/** @return array{0: string, 1: int} */
|
||||
public function pair(User $user): array
|
||||
{
|
||||
return $this->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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
<?php
|
||||
|
||||
namespace App\Resource\Service;
|
||||
|
||||
use App\Branch\Entity\Room;
|
||||
use App\Doctor\Entity\Doctor;
|
||||
use App\Doctor\Entity\DoctorAddress;
|
||||
use App\Resource\Entity\ClinicResource;
|
||||
use App\Resource\Entity\ResourceType;
|
||||
use App\Resource\Repository\ClinicResourceRepository;
|
||||
use App\Resource\Repository\ResourceTypeRepository;
|
||||
use App\Staff\Entity\ClinicStaff;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
|
||||
/**
|
||||
* تنها نقطهای که نگاشت «موجودیت موجود ↔ منبع» را میداند.
|
||||
*
|
||||
* هیچ سرویس دیگری نباید `$resource->getDoctor()` را برای تصمیمگیری بخواند؛ وگرنه
|
||||
* افزودن نوع پل بعدی (مثلاً تجهیزات اجارهای) یعنی گشتن دنبال همهٔ آن نقطهها.
|
||||
*/
|
||||
final class ResourceLinker
|
||||
{
|
||||
/**
|
||||
* نوعهای ساختهشده و هنوز flush-نشده در همین واحدِ کار.
|
||||
*
|
||||
* بدون این، `systemType()` نوعِ persist-شدهای را که هنوز به دیتابیس نرفته
|
||||
* نمیدید و دوباره میساخت — که در اجرای واقعی backfill با
|
||||
* «Duplicate entry 'doctor-1-staff' for key uniq_rt_tenant_code» میشکست.
|
||||
*
|
||||
* @var array<string, ResourceType>
|
||||
*/
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
<?php
|
||||
|
||||
namespace App\Resource\Service;
|
||||
|
||||
use App\Auth\Entity\User;
|
||||
use App\Resource\Entity\ClinicResource;
|
||||
use App\Resource\Entity\ResourcePool;
|
||||
use App\Resource\Entity\ResourcePoolMember;
|
||||
use App\Resource\Repository\ResourcePoolMemberRepository;
|
||||
use App\Shared\Constant\ErrorCodes;
|
||||
use App\Shared\Exception\AppException;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
|
||||
final class ResourcePoolService
|
||||
{
|
||||
public function __construct(
|
||||
private readonly ResourcePoolMemberRepository $members,
|
||||
private readonly ResourceContext $context,
|
||||
private readonly EntityManagerInterface $em,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* جایگزینی کامل اعضای استخر.
|
||||
*
|
||||
* هر عضو باید **همان شعبه و همان نوع** استخر را داشته باشد. تسک ۰۶ فرض میکند هر
|
||||
* عضو جایگزین کامل دیگری است: عضوی از شعبهٔ دیگر یعنی بیمار در ساختمان اشتباه
|
||||
* میایستد، و عضوی از نوع دیگر یعنی صندلی بهجای دستگاه لیزر پیشنهاد میشود.
|
||||
*
|
||||
* @param array<int, mixed> $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<int, mixed> $rows
|
||||
* @return list<array{resource: ClinicResource, priority: int}>
|
||||
*/
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
<?php
|
||||
|
||||
namespace App\Resource\Service;
|
||||
|
||||
use App\Doctor\Entity\DoctorAddress;
|
||||
use App\Resource\Entity\ClinicResource;
|
||||
use App\Resource\Entity\ResourceType;
|
||||
use App\Shared\Constant\ErrorCodes;
|
||||
use App\Shared\Exception\AppException;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
|
||||
final class ResourceService
|
||||
{
|
||||
/** کلیدهای شناختهشدهٔ `attributes` — قرارداد، نه اجبار. */
|
||||
public const KNOWN_ATTRIBUTES = ['gender', 'device_model', 'floor', 'brand'];
|
||||
|
||||
public function __construct(
|
||||
private readonly EntityManagerInterface $em,
|
||||
) {}
|
||||
|
||||
/** @param array<string, mixed> $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<string, mixed> $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<string, mixed> $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<string, scalar>
|
||||
*/
|
||||
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 => 'ورودی نامعتبر است',
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
<?php
|
||||
|
||||
namespace App\Resource\Service;
|
||||
|
||||
use App\Auth\Entity\User;
|
||||
use App\Resource\Entity\ClinicResource;
|
||||
use App\Resource\Entity\ResourceSkill;
|
||||
use App\Resource\Entity\Skill;
|
||||
use App\Resource\Repository\ResourceSkillRepository;
|
||||
use App\Shared\Constant\ErrorCodes;
|
||||
use App\Shared\Exception\AppException;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
|
||||
final class SkillAssignmentService
|
||||
{
|
||||
public function __construct(
|
||||
private readonly ResourceSkillRepository $resourceSkills,
|
||||
private readonly ResourceContext $context,
|
||||
private readonly EntityManagerInterface $em,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* جایگزینی **کامل** مهارتهای یک منبع: مهارتی که در بدنه نیست، برداشته میشود.
|
||||
*
|
||||
* مثل ساعت کاری شعبه، اعتبارسنجی همهٔ ورودی پیش از هر حذفی انجام میشود تا یک
|
||||
* ورودی نامعتبر در انتهای فهرست، مهارتهای درستِ قبلی را پاک نکند و بعد ۴۲۲ بدهد.
|
||||
*
|
||||
* @param array<int, mixed> $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<int, mixed> $rows
|
||||
* @return list<array{skill: Skill, level: int}>
|
||||
*/
|
||||
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();
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -0,0 +1,209 @@
|
||||
<?php
|
||||
|
||||
namespace App\Tests\Resource;
|
||||
|
||||
use App\Appointment\Entity\WeeklySchedule;
|
||||
use App\Doctor\Entity\Doctor;
|
||||
use App\Doctor\Entity\DoctorAddress;
|
||||
use App\Resource\Entity\ClinicResource;
|
||||
use App\Resource\Entity\ResourceType;
|
||||
use App\Resource\Repository\ClinicResourceRepository;
|
||||
use App\Resource\Service\ResourceLinker;
|
||||
use Symfony\Bundle\FrameworkBundle\Console\Application;
|
||||
use Symfony\Component\Console\Tester\CommandTester;
|
||||
|
||||
class BackfillResourceTest extends ResourceTestCase
|
||||
{
|
||||
/**
|
||||
* همیشه با `--pair` اجرا میشود: `db_test` هرگز ریست نمیشود و هزاران محیط از
|
||||
* تستهای دیگر دارد، پس اجرای بیدامنه هم کند است و هم به دادهٔ نامرتبط وابسته.
|
||||
*/
|
||||
private function runBackfill(bool $force, string $pair): string
|
||||
{
|
||||
$application = new Application(static::$kernel);
|
||||
$tester = new CommandTester($application->find('app:resource:backfill'));
|
||||
|
||||
$args = ['--pair' => $pair];
|
||||
|
||||
if ($force) {
|
||||
$args['--force'] = true;
|
||||
}
|
||||
|
||||
$tester->execute($args);
|
||||
|
||||
return $tester->getDisplay();
|
||||
}
|
||||
|
||||
private function pairOf(DoctorAddress $address): string
|
||||
{
|
||||
return $address->tenantEntityType() . ':' . $address->tenantEntityId();
|
||||
}
|
||||
|
||||
private function resources(): ClinicResourceRepository
|
||||
{
|
||||
return static::getContainer()->get(ClinicResourceRepository::class);
|
||||
}
|
||||
|
||||
/** dry-run پیشفرض است: بدون `--force` هیچ ردیفی نوشته نمیشود. */
|
||||
public function testDryRunWritesNothing(): void
|
||||
{
|
||||
[, , $address] = $this->clinicWithAddress();
|
||||
$room = $this->room($address, 'اتاق دراِیران');
|
||||
|
||||
$output = $this->runBackfill(false, $this->pairOf($address));
|
||||
|
||||
self::assertStringContainsString('Dry run', $output);
|
||||
self::assertNull($this->resources()->findForSubject($room));
|
||||
}
|
||||
|
||||
public function testRoomBecomesAResourceCarryingItsCapacity(): void
|
||||
{
|
||||
[, , $address] = $this->clinicWithAddress();
|
||||
$room = $this->room($address, 'اتاق تزریق سهتخته', 3);
|
||||
|
||||
$this->runBackfill(true, $this->pairOf($address));
|
||||
$this->em->clear();
|
||||
|
||||
$resource = $this->resources()->findForSubject(
|
||||
$this->em->getRepository(\App\Branch\Entity\Room::class)->find($room->getId())
|
||||
);
|
||||
|
||||
self::assertNotNull($resource);
|
||||
self::assertSame(3, $resource->getCapacity(), 'اتاق سهتخته یک منبع با ظرفیت ۳ است، نه سه منبع');
|
||||
self::assertSame(ResourceType::CODE_ROOM, $resource->getType()->getCode());
|
||||
self::assertTrue($resource->getType()->isSystem());
|
||||
}
|
||||
|
||||
public function testStaffOfASingleBranchEnvironmentIsBridged(): void
|
||||
{
|
||||
[, , $address] = $this->clinicWithAddress();
|
||||
$staff = $this->staff($address, 'اپراتور تکشعبه');
|
||||
|
||||
$this->runBackfill(true, $this->pairOf($address));
|
||||
$this->em->clear();
|
||||
|
||||
$resource = $this->resources()->findForSubject(
|
||||
$this->em->getRepository(\App\Staff\Entity\ClinicStaff::class)->find($staff->getId())
|
||||
);
|
||||
|
||||
self::assertNotNull($resource);
|
||||
self::assertSame(ResourceType::CODE_STAFF, $resource->getType()->getCode());
|
||||
self::assertSame(1, $resource->getCapacity());
|
||||
}
|
||||
|
||||
/**
|
||||
* پرسنل هیچ ستونی ندارد که آدرسش را بگوید. با چند شعبه، انتخاب یکی حدس است و
|
||||
* او را در ساختمان اشتباه مینشاند — پس رد و **گزارش** میشود، نه حدس بیصدا.
|
||||
*/
|
||||
public function testStaffOfAMultiBranchEnvironmentIsReportedNotGuessed(): void
|
||||
{
|
||||
[, $clinic, $address] = $this->clinicWithAddress();
|
||||
$this->extraAddress($clinic);
|
||||
$staff = $this->staff($address, 'اپراتور چندشعبه');
|
||||
|
||||
$output = $this->runBackfill(true, $this->pairOf($address));
|
||||
$this->em->clear();
|
||||
|
||||
self::assertStringContainsString('شعبهٔ یکتا ندارد', $output);
|
||||
self::assertNull($this->resources()->findForSubject(
|
||||
$this->em->getRepository(\App\Staff\Entity\ClinicStaff::class)->find($staff->getId())
|
||||
));
|
||||
}
|
||||
|
||||
/**
|
||||
* منبعِ پزشک از `location_id` شیفتهای برنامهٔ هفتگی مشتق میشود — آنجا دقیقاً
|
||||
* نوشته در کدام آدرسها شیفت دارد. «اولین شعبهٔ محیط» حدس میبود.
|
||||
*/
|
||||
public function testDoctorIsBridgedOncePerScheduledLocation(): void
|
||||
{
|
||||
[$user, $doctor, $address] = $this->doctorWithAddress();
|
||||
$second = DoctorAddress::forDoctor($doctor);
|
||||
$second->setName('مطب دوم');
|
||||
$this->em->persist($second);
|
||||
|
||||
$third = DoctorAddress::forDoctor($doctor);
|
||||
$third->setName('مطب بیشیفت');
|
||||
$this->em->persist($third);
|
||||
$this->em->flush();
|
||||
|
||||
// شیفت فعال روی دو آدرس اول، هیچ شیفتی روی سومی.
|
||||
$this->em->persist($this->newWeeklySchedule($doctor, [
|
||||
'0' => ['sessions' => [
|
||||
['active' => true, 'location_id' => $address->getId(), 'start_time' => '09:00', 'end_time' => '13:00', 'duration_per_patient' => 20],
|
||||
['active' => true, 'location_id' => $second->getId(), 'start_time' => '16:00', 'end_time' => '20:00', 'duration_per_patient' => 20],
|
||||
]],
|
||||
]));
|
||||
$this->em->flush();
|
||||
|
||||
$this->runBackfill(true, $this->pairOf($address));
|
||||
$this->em->clear();
|
||||
|
||||
$doctorEntity = $this->em->getRepository(Doctor::class)->find($doctor->getId());
|
||||
$all = $this->resources()->findAllForSubject($doctorEntity);
|
||||
|
||||
self::assertCount(2, $all, 'یک منبع per آدرسی که شیفت دارد');
|
||||
|
||||
$addressIds = array_map(static fn (ClinicResource $r): int => (int) $r->getAddress()->getId(), $all);
|
||||
sort($addressIds);
|
||||
$expected = [(int) $address->getId(), (int) $second->getId()];
|
||||
sort($expected);
|
||||
self::assertSame($expected, $addressIds);
|
||||
}
|
||||
|
||||
/** شیفت غیرفعال منبع نمیسازد. */
|
||||
public function testInactiveSessionDoesNotBridgeTheDoctor(): void
|
||||
{
|
||||
[, $doctor, $address] = $this->doctorWithAddress();
|
||||
|
||||
$this->em->persist($this->newWeeklySchedule($doctor, [
|
||||
'0' => ['sessions' => [
|
||||
['active' => false, 'location_id' => $address->getId(), 'start_time' => '09:00', 'end_time' => '13:00', 'duration_per_patient' => 20],
|
||||
]],
|
||||
]));
|
||||
$this->em->flush();
|
||||
|
||||
$this->runBackfill(true, $this->pairOf($address));
|
||||
$this->em->clear();
|
||||
|
||||
$doctorEntity = $this->em->getRepository(Doctor::class)->find($doctor->getId());
|
||||
self::assertSame([], $this->resources()->findAllForSubject($doctorEntity));
|
||||
}
|
||||
|
||||
/** idempotent: تکیهگاهش وجود منبع است، نه یک پرچم جداگانه. */
|
||||
public function testRunningTwiceCreatesNothingNew(): void
|
||||
{
|
||||
[, , $address] = $this->clinicWithAddress();
|
||||
$this->room($address, 'اتاق تکراری');
|
||||
$this->staff($address, 'اپراتور تکراری');
|
||||
|
||||
$this->runBackfill(true, $this->pairOf($address));
|
||||
$this->em->clear();
|
||||
|
||||
$secondOutput = $this->runBackfill(true, $this->pairOf($address));
|
||||
|
||||
self::assertStringContainsString('اتاق: 0', $secondOutput);
|
||||
self::assertStringContainsString('پرسنل: 0', $secondOutput);
|
||||
}
|
||||
|
||||
/**
|
||||
* غیرفعال شدن پرسنل باید منبعش را هم ببندد، وگرنه در جستجوی وقتِ تسک ۰۶ ظاهر
|
||||
* میشود. عکسش برقرار نیست.
|
||||
*/
|
||||
public function testSyncActiveClosesEveryResourceOfASubject(): void
|
||||
{
|
||||
[, , $address] = $this->clinicWithAddress();
|
||||
$staff = $this->staff($address, 'اپراتور خاموششونده');
|
||||
|
||||
$this->runBackfill(true, $this->pairOf($address));
|
||||
$this->em->clear();
|
||||
|
||||
$linker = static::getContainer()->get(ResourceLinker::class);
|
||||
$staffEntity = $this->em->getRepository(\App\Staff\Entity\ClinicStaff::class)->find($staff->getId());
|
||||
|
||||
self::assertSame(1, $linker->syncActive($staffEntity, false));
|
||||
self::assertFalse($this->resources()->findForSubject($staffEntity)->isActive());
|
||||
|
||||
// دوباره صدا زدن چیزی را عوض نمیکند.
|
||||
self::assertSame(0, $linker->syncActive($staffEntity, false));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,210 @@
|
||||
<?php
|
||||
|
||||
namespace App\Tests\Resource;
|
||||
|
||||
use App\Resource\Entity\ClinicResource;
|
||||
|
||||
class ResourceCrudTest extends ResourceTestCase
|
||||
{
|
||||
public function testResourceIsCreatedWithTenantPairDerivedFromTheAddress(): void
|
||||
{
|
||||
[$user, $clinic, $address] = $this->clinicWithAddress();
|
||||
$type = $this->resourceType($address);
|
||||
|
||||
$body = $this->createResource($user, $address, $type, ['capacity' => 2, 'setup_minutes' => 5]);
|
||||
|
||||
self::assertSame(201, $this->responseCode(), json_encode($body, JSON_UNESCAPED_UNICODE));
|
||||
self::assertSame(2, $body['data']['capacity']);
|
||||
self::assertSame(5, $body['data']['setup_minutes']);
|
||||
self::assertSame($address->getUuid(), $body['data']['address_uuid']);
|
||||
self::assertNull($body['data']['subject_kind'], 'منبع بدون پل یعنی دستگاه');
|
||||
|
||||
$resource = $this->em->getRepository(ClinicResource::class)->findOneBy(['uuid' => $body['data']['uuid']]);
|
||||
self::assertSame('clinic', $resource->getEntityType());
|
||||
self::assertSame($clinic->getId(), $resource->getEntityId());
|
||||
}
|
||||
|
||||
public function testDefaultsAreOneCapacityAndZeroBuffers(): void
|
||||
{
|
||||
[$user, , $address] = $this->clinicWithAddress();
|
||||
$body = $this->createResource($user, $address, $this->resourceType($address));
|
||||
|
||||
self::assertSame(1, $body['data']['capacity']);
|
||||
self::assertSame(0, $body['data']['setup_minutes']);
|
||||
self::assertSame(0, $body['data']['cleanup_minutes']);
|
||||
self::assertTrue($body['data']['active']);
|
||||
}
|
||||
|
||||
public function testZeroCapacityIsRejected(): void
|
||||
{
|
||||
[$user, , $address] = $this->clinicWithAddress();
|
||||
|
||||
$body = $this->createResource($user, $address, $this->resourceType($address), ['capacity' => 0]);
|
||||
|
||||
self::assertSame(422, $this->responseCode());
|
||||
self::assertSame('capacity', $body['errors'][0]['field']);
|
||||
}
|
||||
|
||||
/** پزشک همزمان دو بیمار ندارد — قید در خودِ entity است، نه فقط در سرویس. */
|
||||
public function testCapacityAboveOneOnAPersonTypeIsRejected(): void
|
||||
{
|
||||
[$user, , $address] = $this->clinicWithAddress();
|
||||
$doctorType = $this->resourceType($address, 'doctor', 'پزشک');
|
||||
|
||||
$body = $this->createResource($user, $address, $doctorType, ['capacity' => 2]);
|
||||
|
||||
self::assertSame(422, $this->responseCode());
|
||||
self::assertSame('capacity', $body['errors'][0]['field']);
|
||||
self::assertStringContainsString('شخص', $body['errors'][0]['message']);
|
||||
}
|
||||
|
||||
/** ۰ و ۱۰ هر دو معتبرند: آمادهسازی میتواند صفر باشد و تمیزکاری نباشد یا برعکس. */
|
||||
public function testZeroSetupWithNonZeroCleanupIsValid(): void
|
||||
{
|
||||
[$user, , $address] = $this->clinicWithAddress();
|
||||
|
||||
$body = $this->createResource($user, $address, $this->resourceType($address), [
|
||||
'setup_minutes' => 0,
|
||||
'cleanup_minutes' => 10,
|
||||
]);
|
||||
|
||||
self::assertSame(201, $this->responseCode());
|
||||
self::assertSame(10, $body['data']['cleanup_minutes']);
|
||||
}
|
||||
|
||||
public function testUnknownAttributeKeyIsAcceptedButNonScalarValueIsNot(): void
|
||||
{
|
||||
[$user, , $address] = $this->clinicWithAddress();
|
||||
$type = $this->resourceType($address);
|
||||
|
||||
$ok = $this->createResource($user, $address, $type, [
|
||||
'attributes' => ['gender' => 'female', 'anything_else' => 42],
|
||||
]);
|
||||
self::assertSame(201, $this->responseCode(), 'کلید ناشناخته عمداً پذیرفته میشود');
|
||||
self::assertSame('female', $ok['data']['attributes']['gender']);
|
||||
self::assertSame(42, $ok['data']['attributes']['anything_else']);
|
||||
|
||||
$bad = $this->createResource($user, $address, $type, [
|
||||
'name' => 'دستگاه دوم',
|
||||
'attributes' => ['nested' => ['a' => 1]],
|
||||
]);
|
||||
self::assertSame(422, $this->responseCode());
|
||||
self::assertSame('attributes', $bad['errors'][0]['field']);
|
||||
}
|
||||
|
||||
public function testAttributeKeyMustBeSnakeCase(): void
|
||||
{
|
||||
[$user, , $address] = $this->clinicWithAddress();
|
||||
|
||||
$body = $this->createResource($user, $address, $this->resourceType($address), [
|
||||
'attributes' => ['Device Model' => 'x'],
|
||||
]);
|
||||
|
||||
self::assertSame(422, $this->responseCode());
|
||||
self::assertSame('attributes', $body['errors'][0]['field']);
|
||||
}
|
||||
|
||||
public function testForeignAddressIsNotFound(): void
|
||||
{
|
||||
[$user, , $address] = $this->clinicWithAddress();
|
||||
$type = $this->resourceType($address);
|
||||
|
||||
[, , $foreignAddress] = $this->clinicWithAddress('شعبهٔ بیگانه');
|
||||
|
||||
$this->authJson('POST', '/api/v1/resource', $user, [
|
||||
'address_uuid' => $foreignAddress->getUuid(),
|
||||
'type_uuid' => $type->getUuid(),
|
||||
'name' => 'دستگاه',
|
||||
]);
|
||||
|
||||
self::assertSame(404, $this->responseCode());
|
||||
}
|
||||
|
||||
public function testForeignResourceIsNotFound(): void
|
||||
{
|
||||
[$ownerUser, , $ownerAddress] = $this->clinicWithAddress();
|
||||
$created = $this->createResource($ownerUser, $ownerAddress, $this->resourceType($ownerAddress));
|
||||
|
||||
[$otherUser] = $this->clinicWithAddress('شعبهٔ دیگر');
|
||||
|
||||
$this->authJson('GET', "/api/v1/resource/{$created['data']['uuid']}", $otherUser);
|
||||
self::assertSame(404, $this->responseCode());
|
||||
|
||||
$this->authJson('PATCH', "/api/v1/resource/{$created['data']['uuid']}", $otherUser, ['name' => 'دزدیدهشده']);
|
||||
self::assertSame(404, $this->responseCode());
|
||||
|
||||
$this->authJson('DELETE', "/api/v1/resource/{$created['data']['uuid']}", $otherUser);
|
||||
self::assertSame(404, $this->responseCode());
|
||||
}
|
||||
|
||||
public function testResourceIsUpdatedAndDeleted(): void
|
||||
{
|
||||
[$user, , $address] = $this->clinicWithAddress();
|
||||
$created = $this->createResource($user, $address, $this->resourceType($address));
|
||||
|
||||
$body = $this->authJson('PATCH', "/api/v1/resource/{$created['data']['uuid']}", $user, [
|
||||
'name' => 'لیزر دو',
|
||||
'cleanup_minutes' => 15,
|
||||
'active' => false,
|
||||
]);
|
||||
|
||||
self::assertSame(200, $this->responseCode());
|
||||
self::assertSame('لیزر دو', $body['data']['name']);
|
||||
self::assertSame(15, $body['data']['cleanup_minutes']);
|
||||
self::assertFalse($body['data']['active']);
|
||||
|
||||
$this->authJson('DELETE', "/api/v1/resource/{$created['data']['uuid']}", $user);
|
||||
self::assertSame(200, $this->responseCode());
|
||||
|
||||
$this->authJson('GET', "/api/v1/resource/{$created['data']['uuid']}", $user);
|
||||
self::assertSame(404, $this->responseCode());
|
||||
}
|
||||
|
||||
public function testListIsFilteredByAddressTypeAndActive(): void
|
||||
{
|
||||
[$user, $clinic, $address] = $this->clinicWithAddress();
|
||||
$second = $this->extraAddress($clinic);
|
||||
$device = $this->resourceType($address, 'device', 'دستگاه');
|
||||
$chair = $this->resourceType($address, 'chair', 'صندلی');
|
||||
|
||||
$this->createResource($user, $address, $device, ['name' => 'دستگاه شعبهٔ یک']);
|
||||
$this->createResource($user, $second, $device, ['name' => 'دستگاه شعبهٔ دو']);
|
||||
$off = $this->createResource($user, $address, $chair, ['name' => 'صندلی خاموش']);
|
||||
$this->authJson('PATCH', "/api/v1/resource/{$off['data']['uuid']}", $user, ['active' => false]);
|
||||
|
||||
$all = $this->authJson('GET', '/api/v1/resources', $user);
|
||||
self::assertCount(3, $all['data']);
|
||||
|
||||
$byAddress = $this->authJson('GET', "/api/v1/resources?address_uuid={$address->getUuid()}", $user);
|
||||
self::assertCount(2, $byAddress['data']);
|
||||
|
||||
$byType = $this->authJson('GET', "/api/v1/resources?type_uuid={$device->getUuid()}", $user);
|
||||
self::assertCount(2, $byType['data']);
|
||||
|
||||
$activeOnly = $this->authJson('GET', '/api/v1/resources?active=1', $user);
|
||||
self::assertCount(2, $activeOnly['data']);
|
||||
}
|
||||
|
||||
public function testListShowsOnlyTheCurrentEnvironment(): void
|
||||
{
|
||||
[$user, , $address] = $this->clinicWithAddress();
|
||||
$this->createResource($user, $address, $this->resourceType($address));
|
||||
|
||||
[$otherUser, , $otherAddress] = $this->clinicWithAddress('شعبهٔ دیگر');
|
||||
$this->createResource($otherUser, $otherAddress, $this->resourceType($otherAddress));
|
||||
|
||||
$body = $this->authJson('GET', '/api/v1/resources', $user);
|
||||
|
||||
self::assertCount(1, $body['data']);
|
||||
}
|
||||
|
||||
public function testBlankNameIsRejected(): void
|
||||
{
|
||||
[$user, , $address] = $this->clinicWithAddress();
|
||||
|
||||
$body = $this->createResource($user, $address, $this->resourceType($address), ['name' => ' ']);
|
||||
|
||||
self::assertSame(422, $this->responseCode());
|
||||
self::assertSame('name', $body['errors'][0]['field']);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
<?php
|
||||
|
||||
namespace App\Tests\Resource;
|
||||
|
||||
use App\Resource\Entity\ClinicResource;
|
||||
use App\Resource\Entity\Skill;
|
||||
use App\Resource\Repository\ClinicResourceRepository;
|
||||
|
||||
/**
|
||||
* `findEligible()` — پرسوجوی داغِ تسک ۰۶. جدا از HTTP تست میشود چون قرارداد
|
||||
* «همهٔ مهارتها، نه یکی» یک تصمیم SQL است و باید مستقل از کنترلر قفل بماند.
|
||||
*/
|
||||
class ResourceEligibilityTest extends ResourceTestCase
|
||||
{
|
||||
private function repo(): ClinicResourceRepository
|
||||
{
|
||||
return static::getContainer()->get(ClinicResourceRepository::class);
|
||||
}
|
||||
|
||||
private function skillIdOf(string $uuid): int
|
||||
{
|
||||
return (int) $this->em->getRepository(Skill::class)->findOneBy(['uuid' => $uuid])->getId();
|
||||
}
|
||||
|
||||
private function assign(\App\Auth\Entity\User $user, string $resourceUuid, array $skillUuids): void
|
||||
{
|
||||
$this->authJson('PUT', "/api/v1/resource/$resourceUuid/skills", $user, [
|
||||
'skills' => array_map(static fn (string $u): array => ['skill_uuid' => $u], $skillUuids),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* نیازمندی «مهارت الف و ب» یعنی هر دو. با یک `IN` ساده، منبعی که فقط یکی را
|
||||
* دارد هم برمیگشت — به همین دلیل `HAVING COUNT(DISTINCT …)` هست.
|
||||
*/
|
||||
public function testAllSkillsAreRequiredNotAny(): void
|
||||
{
|
||||
[$user, , $address] = $this->clinicWithAddress();
|
||||
$type = $this->resourceType($address, 'operator', 'اپراتور');
|
||||
|
||||
$laser = $this->createSkill($user, 'لیزر آلکساندرایت');
|
||||
$botox = $this->createSkill($user, 'بوتاکس');
|
||||
|
||||
$both = $this->createResource($user, $address, $type, ['name' => 'اپراتور کامل']);
|
||||
$one = $this->createResource($user, $address, $type, ['name' => 'اپراتور نصفه']);
|
||||
|
||||
$this->assign($user, $both['data']['uuid'], [$laser['data']['uuid'], $botox['data']['uuid']]);
|
||||
$this->assign($user, $one['data']['uuid'], [$laser['data']['uuid']]);
|
||||
|
||||
$this->em->clear();
|
||||
|
||||
$eligible = $this->repo()->findEligible(
|
||||
$this->em->getRepository(\App\Doctor\Entity\DoctorAddress::class)->find($address->getId()),
|
||||
$this->em->getRepository(\App\Resource\Entity\ResourceType::class)->find($type->getId()),
|
||||
[$this->skillIdOf($laser['data']['uuid']), $this->skillIdOf($botox['data']['uuid'])],
|
||||
);
|
||||
|
||||
self::assertCount(1, $eligible);
|
||||
self::assertSame('اپراتور کامل', $eligible[0]->getName());
|
||||
}
|
||||
|
||||
public function testSingleSkillMatchesEveryResourceThatHasIt(): void
|
||||
{
|
||||
[$user, , $address] = $this->clinicWithAddress();
|
||||
$type = $this->resourceType($address, 'operator', 'اپراتور');
|
||||
$laser = $this->createSkill($user, 'لیزر');
|
||||
|
||||
foreach (['اپراتور ۱', 'اپراتور ۲'] as $name) {
|
||||
$r = $this->createResource($user, $address, $type, ['name' => $name]);
|
||||
$this->assign($user, $r['data']['uuid'], [$laser['data']['uuid']]);
|
||||
}
|
||||
$this->createResource($user, $address, $type, ['name' => 'اپراتور بیمهارت']);
|
||||
|
||||
$this->em->clear();
|
||||
|
||||
$eligible = $this->repo()->findEligible(
|
||||
$this->em->getRepository(\App\Doctor\Entity\DoctorAddress::class)->find($address->getId()),
|
||||
$this->em->getRepository(\App\Resource\Entity\ResourceType::class)->find($type->getId()),
|
||||
[$this->skillIdOf($laser['data']['uuid'])],
|
||||
);
|
||||
|
||||
self::assertCount(2, $eligible);
|
||||
}
|
||||
|
||||
public function testInactiveResourceIsNeverEligible(): void
|
||||
{
|
||||
[$user, , $address] = $this->clinicWithAddress();
|
||||
$type = $this->resourceType($address, 'operator', 'اپراتور');
|
||||
$laser = $this->createSkill($user, 'لیزر');
|
||||
|
||||
$resource = $this->createResource($user, $address, $type, ['name' => 'اپراتور خاموش']);
|
||||
$this->assign($user, $resource['data']['uuid'], [$laser['data']['uuid']]);
|
||||
$this->authJson('PATCH', "/api/v1/resource/{$resource['data']['uuid']}", $user, ['active' => false]);
|
||||
|
||||
$this->em->clear();
|
||||
|
||||
$eligible = $this->repo()->findEligible(
|
||||
$this->em->getRepository(\App\Doctor\Entity\DoctorAddress::class)->find($address->getId()),
|
||||
$this->em->getRepository(\App\Resource\Entity\ResourceType::class)->find($type->getId()),
|
||||
[$this->skillIdOf($laser['data']['uuid'])],
|
||||
);
|
||||
|
||||
self::assertSame([], $eligible);
|
||||
}
|
||||
|
||||
/** بدون شرط مهارت، همهٔ منابع فعالِ آن شعبه و نوع برمیگردند. */
|
||||
public function testNoSkillFilterReturnsEveryActiveResourceOfThatType(): void
|
||||
{
|
||||
[$user, $clinic, $address] = $this->clinicWithAddress();
|
||||
$second = $this->extraAddress($clinic);
|
||||
$type = $this->resourceType($address, 'operator', 'اپراتور');
|
||||
|
||||
$this->createResource($user, $address, $type, ['name' => 'اینجا ۱']);
|
||||
$this->createResource($user, $address, $type, ['name' => 'اینجا ۲']);
|
||||
$this->createResource($user, $second, $type, ['name' => 'شعبهٔ دیگر']);
|
||||
|
||||
$this->em->clear();
|
||||
|
||||
$eligible = $this->repo()->findEligible(
|
||||
$this->em->getRepository(\App\Doctor\Entity\DoctorAddress::class)->find($address->getId()),
|
||||
$this->em->getRepository(\App\Resource\Entity\ResourceType::class)->find($type->getId()),
|
||||
);
|
||||
|
||||
self::assertCount(2, $eligible);
|
||||
}
|
||||
|
||||
/** فیلتر مهارت روی endpoint فهرست هم همان معنا را میدهد. */
|
||||
public function testListEndpointFiltersBySkill(): void
|
||||
{
|
||||
[$user, , $address] = $this->clinicWithAddress();
|
||||
$type = $this->resourceType($address, 'operator', 'اپراتور');
|
||||
$laser = $this->createSkill($user, 'لیزر');
|
||||
|
||||
$withSkill = $this->createResource($user, $address, $type, ['name' => 'با مهارت']);
|
||||
$this->assign($user, $withSkill['data']['uuid'], [$laser['data']['uuid']]);
|
||||
$this->createResource($user, $address, $type, ['name' => 'بدون مهارت']);
|
||||
|
||||
$body = $this->authJson('GET', "/api/v1/resources?skill_uuid={$laser['data']['uuid']}", $user);
|
||||
|
||||
self::assertSame(200, $this->responseCode());
|
||||
self::assertCount(1, $body['data']);
|
||||
self::assertSame('با مهارت', $body['data'][0]['name']);
|
||||
}
|
||||
|
||||
/** مهارت محیط دیگر نباید بیصدا «هیچ نتیجه» بدهد. */
|
||||
public function testListEndpointRejectsAForeignSkillFilter(): void
|
||||
{
|
||||
[$user] = $this->clinicWithAddress();
|
||||
[$otherUser] = $this->clinicWithAddress('شعبهٔ دیگر');
|
||||
$foreign = $this->createSkill($otherUser, 'مهارت بیگانه');
|
||||
|
||||
$this->authJson('GET', "/api/v1/resources?skill_uuid={$foreign['data']['uuid']}", $user);
|
||||
|
||||
self::assertSame(404, $this->responseCode());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
<?php
|
||||
|
||||
namespace App\Tests\Resource;
|
||||
|
||||
class ResourcePoolTest extends ResourceTestCase
|
||||
{
|
||||
public function testPoolIsCreatedAndMembersAreReplacedWholesale(): void
|
||||
{
|
||||
[$user, , $address] = $this->clinicWithAddress();
|
||||
$type = $this->resourceType($address, 'laser', 'دستگاه لیزر');
|
||||
|
||||
$one = $this->createResource($user, $address, $type, ['name' => 'لیزر ۱']);
|
||||
$two = $this->createResource($user, $address, $type, ['name' => 'لیزر ۲']);
|
||||
$three = $this->createResource($user, $address, $type, ['name' => 'لیزر ۳']);
|
||||
|
||||
$pool = $this->authJson('POST', '/api/v1/resource-pools', $user, [
|
||||
'address_uuid' => $address->getUuid(),
|
||||
'type_uuid' => $type->getUuid(),
|
||||
'name' => 'لیزرهای آلکساندرایت',
|
||||
]);
|
||||
self::assertSame(201, $this->responseCode(), json_encode($pool, JSON_UNESCAPED_UNICODE));
|
||||
self::assertSame([], $pool['data']['members'], 'استخر تازه هیچ عضوی ندارد و این معتبر است');
|
||||
|
||||
$filled = $this->authJson('PUT', "/api/v1/resource-pool/{$pool['data']['uuid']}/members", $user, [
|
||||
'members' => [
|
||||
['resource_uuid' => $one['data']['uuid'], 'priority' => 0],
|
||||
['resource_uuid' => $two['data']['uuid'], 'priority' => 1],
|
||||
['resource_uuid' => $three['data']['uuid'], 'priority' => 2],
|
||||
],
|
||||
]);
|
||||
|
||||
self::assertSame(200, $this->responseCode());
|
||||
self::assertCount(3, $filled['data']['members']);
|
||||
self::assertSame($address->getUuid(), $filled['data']['address_uuid']);
|
||||
|
||||
// جایگزینی کامل: عضوی که نیامده، برداشته میشود.
|
||||
$shrunk = $this->authJson('PUT', "/api/v1/resource-pool/{$pool['data']['uuid']}/members", $user, [
|
||||
'members' => [['resource_uuid' => $one['data']['uuid']]],
|
||||
]);
|
||||
self::assertCount(1, $shrunk['data']['members']);
|
||||
}
|
||||
|
||||
public function testMemberFromAnotherBranchIsRejected(): void
|
||||
{
|
||||
[$user, $clinic, $address] = $this->clinicWithAddress();
|
||||
$second = $this->extraAddress($clinic);
|
||||
$type = $this->resourceType($address, 'laser', 'دستگاه لیزر');
|
||||
|
||||
$elsewhere = $this->createResource($user, $second, $type, ['name' => 'لیزر شعبهٔ دو']);
|
||||
|
||||
$pool = $this->authJson('POST', '/api/v1/resource-pools', $user, [
|
||||
'address_uuid' => $address->getUuid(),
|
||||
'type_uuid' => $type->getUuid(),
|
||||
'name' => 'استخر شعبهٔ یک',
|
||||
]);
|
||||
|
||||
$body = $this->authJson('PUT', "/api/v1/resource-pool/{$pool['data']['uuid']}/members", $user, [
|
||||
'members' => [['resource_uuid' => $elsewhere['data']['uuid']]],
|
||||
]);
|
||||
|
||||
self::assertSame(422, $this->responseCode());
|
||||
self::assertStringContainsString('یک شعبه', $body['errors'][0]['message']);
|
||||
}
|
||||
|
||||
public function testMemberOfAnotherTypeIsRejected(): void
|
||||
{
|
||||
[$user, , $address] = $this->clinicWithAddress();
|
||||
$laser = $this->resourceType($address, 'laser', 'دستگاه لیزر');
|
||||
$chair = $this->resourceType($address, 'chair', 'صندلی');
|
||||
|
||||
$wrongType = $this->createResource($user, $address, $chair, ['name' => 'صندلی ۱']);
|
||||
|
||||
$pool = $this->authJson('POST', '/api/v1/resource-pools', $user, [
|
||||
'address_uuid' => $address->getUuid(),
|
||||
'type_uuid' => $laser->getUuid(),
|
||||
'name' => 'استخر لیزر',
|
||||
]);
|
||||
|
||||
$body = $this->authJson('PUT', "/api/v1/resource-pool/{$pool['data']['uuid']}/members", $user, [
|
||||
'members' => [['resource_uuid' => $wrongType['data']['uuid']]],
|
||||
]);
|
||||
|
||||
self::assertSame(422, $this->responseCode());
|
||||
self::assertStringContainsString('یک نوع', $body['errors'][0]['message']);
|
||||
}
|
||||
|
||||
/** عضو نامعتبر نباید اعضای درستِ قبلی را پاک کند. */
|
||||
public function testInvalidMemberLeavesTheStoredMembersUntouched(): void
|
||||
{
|
||||
[$user, $clinic, $address] = $this->clinicWithAddress();
|
||||
$second = $this->extraAddress($clinic);
|
||||
$type = $this->resourceType($address, 'laser', 'دستگاه لیزر');
|
||||
|
||||
$good = $this->createResource($user, $address, $type, ['name' => 'لیزر خوب']);
|
||||
$elsewhere = $this->createResource($user, $second, $type, ['name' => 'لیزر شعبهٔ دو']);
|
||||
|
||||
$pool = $this->authJson('POST', '/api/v1/resource-pools', $user, [
|
||||
'address_uuid' => $address->getUuid(),
|
||||
'type_uuid' => $type->getUuid(),
|
||||
'name' => 'استخر',
|
||||
]);
|
||||
|
||||
$this->authJson('PUT', "/api/v1/resource-pool/{$pool['data']['uuid']}/members", $user, [
|
||||
'members' => [['resource_uuid' => $good['data']['uuid']]],
|
||||
]);
|
||||
|
||||
$this->authJson('PUT', "/api/v1/resource-pool/{$pool['data']['uuid']}/members", $user, [
|
||||
'members' => [
|
||||
['resource_uuid' => $good['data']['uuid']],
|
||||
['resource_uuid' => $elsewhere['data']['uuid']],
|
||||
],
|
||||
]);
|
||||
self::assertSame(422, $this->responseCode());
|
||||
|
||||
$read = $this->authJson('GET', "/api/v1/resource-pool/{$pool['data']['uuid']}", $user);
|
||||
self::assertCount(1, $read['data']['members']);
|
||||
}
|
||||
|
||||
public function testForeignPoolIsNotFound(): void
|
||||
{
|
||||
[$user, , $address] = $this->clinicWithAddress();
|
||||
$pool = $this->authJson('POST', '/api/v1/resource-pools', $user, [
|
||||
'address_uuid' => $address->getUuid(),
|
||||
'type_uuid' => $this->resourceType($address)->getUuid(),
|
||||
'name' => 'استخر',
|
||||
]);
|
||||
|
||||
[$otherUser] = $this->clinicWithAddress('شعبهٔ دیگر');
|
||||
|
||||
$this->authJson('GET', "/api/v1/resource-pool/{$pool['data']['uuid']}", $otherUser);
|
||||
self::assertSame(404, $this->responseCode());
|
||||
|
||||
$this->authJson('PUT', "/api/v1/resource-pool/{$pool['data']['uuid']}/members", $otherUser, ['members' => []]);
|
||||
self::assertSame(404, $this->responseCode());
|
||||
}
|
||||
|
||||
/** حذف استخر اعضا را میبرد ولی خودِ منابع باید سرِ جایشان بمانند. */
|
||||
public function testDeletingAPoolKeepsItsResources(): void
|
||||
{
|
||||
[$user, , $address] = $this->clinicWithAddress();
|
||||
$type = $this->resourceType($address, 'laser', 'لیزر');
|
||||
$resource = $this->createResource($user, $address, $type, ['name' => 'لیزر ۱']);
|
||||
|
||||
$pool = $this->authJson('POST', '/api/v1/resource-pools', $user, [
|
||||
'address_uuid' => $address->getUuid(),
|
||||
'type_uuid' => $type->getUuid(),
|
||||
'name' => 'استخر',
|
||||
]);
|
||||
$this->authJson('PUT', "/api/v1/resource-pool/{$pool['data']['uuid']}/members", $user, [
|
||||
'members' => [['resource_uuid' => $resource['data']['uuid']]],
|
||||
]);
|
||||
|
||||
$this->authJson('DELETE', "/api/v1/resource-pool/{$pool['data']['uuid']}", $user);
|
||||
self::assertSame(200, $this->responseCode());
|
||||
|
||||
$this->authJson('GET', "/api/v1/resource/{$resource['data']['uuid']}", $user);
|
||||
self::assertSame(200, $this->responseCode(), 'منبع نباید با استخر حذف شود');
|
||||
}
|
||||
|
||||
public function testDuplicateMemberIsRejected(): void
|
||||
{
|
||||
[$user, , $address] = $this->clinicWithAddress();
|
||||
$type = $this->resourceType($address, 'laser', 'لیزر');
|
||||
$resource = $this->createResource($user, $address, $type, ['name' => 'لیزر ۱']);
|
||||
|
||||
$pool = $this->authJson('POST', '/api/v1/resource-pools', $user, [
|
||||
'address_uuid' => $address->getUuid(),
|
||||
'type_uuid' => $type->getUuid(),
|
||||
'name' => 'استخر',
|
||||
]);
|
||||
|
||||
$this->authJson('PUT', "/api/v1/resource-pool/{$pool['data']['uuid']}/members", $user, [
|
||||
'members' => [
|
||||
['resource_uuid' => $resource['data']['uuid']],
|
||||
['resource_uuid' => $resource['data']['uuid']],
|
||||
],
|
||||
]);
|
||||
|
||||
self::assertSame(422, $this->responseCode());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
<?php
|
||||
|
||||
namespace App\Tests\Resource;
|
||||
|
||||
use App\Auth\Entity\User;
|
||||
use App\Branch\Entity\Room;
|
||||
use App\Clinic\Entity\Clinic;
|
||||
use App\Doctor\Entity\Doctor;
|
||||
use App\Doctor\Entity\DoctorAddress;
|
||||
use App\Resource\Entity\ResourceType;
|
||||
use App\Staff\Entity\ClinicStaff;
|
||||
use App\Tests\ApiTestCase;
|
||||
|
||||
/**
|
||||
* فیکسچرهای مشترک دامنهٔ منبع. هر تست به یک محیط با آدرس و یک نوع منبع نیاز دارد،
|
||||
* و برای سنجش مرز ۴۰۴ به یک محیط بیگانه.
|
||||
*/
|
||||
abstract class ResourceTestCase extends ApiTestCase
|
||||
{
|
||||
/** @return array{0: User, 1: Clinic, 2: DoctorAddress} */
|
||||
protected function clinicWithAddress(string $addressName = 'شعبهٔ مرکزی'): array
|
||||
{
|
||||
$user = $this->createUser(['ROLE_USER', 'ROLE_CLINIC']);
|
||||
$clinic = new Clinic($user);
|
||||
$clinic->setName('کلینیک منابع');
|
||||
$this->em->persist($clinic);
|
||||
$this->em->flush();
|
||||
|
||||
$address = DoctorAddress::forClinic($clinic->getId());
|
||||
$address->setName($addressName);
|
||||
$this->em->persist($address);
|
||||
$this->em->flush();
|
||||
|
||||
return [$user, $clinic, $address];
|
||||
}
|
||||
|
||||
/** آدرس دوم همان محیط — برای سنجش قاعدهٔ «همهٔ اعضای استخر در یک شعبه». */
|
||||
protected function extraAddress(Clinic $clinic, string $name = 'شعبهٔ دوم'): DoctorAddress
|
||||
{
|
||||
$address = DoctorAddress::forClinic($clinic->getId());
|
||||
$address->setName($name);
|
||||
$this->em->persist($address);
|
||||
$this->em->flush();
|
||||
|
||||
return $address;
|
||||
}
|
||||
|
||||
/** @return array{0: User, 1: Doctor, 2: DoctorAddress} */
|
||||
protected function doctorWithAddress(): array
|
||||
{
|
||||
$user = $this->createUser(['ROLE_USER', 'ROLE_DOCTOR']);
|
||||
$doctor = new Doctor($user, 'دکتر منبع');
|
||||
$doctor->setMobileNumber($user->getMobileNumber());
|
||||
$this->em->persist($doctor);
|
||||
$this->em->flush();
|
||||
|
||||
$address = DoctorAddress::forDoctor($doctor);
|
||||
$address->setName('مطب شخصی');
|
||||
$this->em->persist($address);
|
||||
$this->em->flush();
|
||||
|
||||
return [$user, $doctor, $address];
|
||||
}
|
||||
|
||||
protected function resourceType(DoctorAddress $address, string $code = 'device', string $name = 'دستگاه'): ResourceType
|
||||
{
|
||||
$type = new ResourceType($address->tenantEntityType(), $address->tenantEntityId(), $code, $name);
|
||||
$this->em->persist($type);
|
||||
$this->em->flush();
|
||||
|
||||
return $type;
|
||||
}
|
||||
|
||||
protected function staff(DoctorAddress $address, string $name = 'اپراتور یک'): ClinicStaff
|
||||
{
|
||||
$staff = new ClinicStaff($address->tenantEntityType(), $address->tenantEntityId(), $name);
|
||||
$this->em->persist($staff);
|
||||
$this->em->flush();
|
||||
|
||||
return $staff;
|
||||
}
|
||||
|
||||
protected function room(DoctorAddress $address, string $name = 'اتاق تزریق', int $capacity = 1): Room
|
||||
{
|
||||
$room = new Room($address, $name);
|
||||
$room->setCapacity($capacity);
|
||||
$this->em->persist($room);
|
||||
$this->em->flush();
|
||||
|
||||
return $room;
|
||||
}
|
||||
|
||||
/** @param array<string, mixed> $body */
|
||||
protected function createResource(User $user, DoctorAddress $address, ResourceType $type, array $body = []): array
|
||||
{
|
||||
return $this->authJson('POST', '/api/v1/resource', $user, $body + [
|
||||
'address_uuid' => $address->getUuid(),
|
||||
'type_uuid' => $type->getUuid(),
|
||||
'name' => 'لیزر آلکساندرایت ۱',
|
||||
]);
|
||||
}
|
||||
|
||||
protected function createSkill(User $user, string $name = 'لیزر آلکساندرایت'): array
|
||||
{
|
||||
return $this->authJson('POST', '/api/v1/skills', $user, ['name' => $name]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
<?php
|
||||
|
||||
namespace App\Tests\Resource;
|
||||
|
||||
class ResourceTypeTest extends ResourceTestCase
|
||||
{
|
||||
public function testTypeIsCreatedAndListedWithUsageCount(): void
|
||||
{
|
||||
[$user, , $address] = $this->clinicWithAddress();
|
||||
|
||||
$created = $this->authJson('POST', '/api/v1/resource-types', $user, [
|
||||
'code' => 'laser',
|
||||
'name' => 'دستگاه لیزر',
|
||||
]);
|
||||
|
||||
self::assertSame(201, $this->responseCode(), json_encode($created, JSON_UNESCAPED_UNICODE));
|
||||
self::assertFalse($created['data']['is_system']);
|
||||
self::assertSame(0, $created['data']['resources_count']);
|
||||
|
||||
$this->createResource($user, $address, $this->typeEntity($created['data']['uuid']));
|
||||
|
||||
$list = $this->authJson('GET', '/api/v1/resource-types', $user);
|
||||
self::assertSame(1, $list['data'][0]['resources_count']);
|
||||
}
|
||||
|
||||
public function testDuplicateCodeInOneEnvironmentIsRejected(): void
|
||||
{
|
||||
[$user] = $this->clinicWithAddress();
|
||||
|
||||
$this->authJson('POST', '/api/v1/resource-types', $user, ['code' => 'laser', 'name' => 'لیزر']);
|
||||
$body = $this->authJson('POST', '/api/v1/resource-types', $user, ['code' => 'laser', 'name' => 'لیزر دوم']);
|
||||
|
||||
self::assertSame(422, $this->responseCode());
|
||||
self::assertSame('code', $body['errors'][0]['field']);
|
||||
}
|
||||
|
||||
/** همان کد در محیط دیگر مجاز است — یکتایی per محیط است، نه سراسری. */
|
||||
public function testSameCodeInAnotherEnvironmentIsAllowed(): void
|
||||
{
|
||||
[$user] = $this->clinicWithAddress();
|
||||
$this->authJson('POST', '/api/v1/resource-types', $user, ['code' => 'laser', 'name' => 'لیزر']);
|
||||
|
||||
[$otherUser] = $this->clinicWithAddress('شعبهٔ دیگر');
|
||||
$this->authJson('POST', '/api/v1/resource-types', $otherUser, ['code' => 'laser', 'name' => 'لیزر']);
|
||||
|
||||
self::assertSame(201, $this->responseCode());
|
||||
}
|
||||
|
||||
public function testInvalidCodeIsRejected(): void
|
||||
{
|
||||
[$user] = $this->clinicWithAddress();
|
||||
|
||||
$body = $this->authJson('POST', '/api/v1/resource-types', $user, ['code' => 'Laser Device', 'name' => 'x']);
|
||||
|
||||
self::assertSame(422, $this->responseCode());
|
||||
self::assertSame('code', $body['errors'][0]['field']);
|
||||
}
|
||||
|
||||
public function testTypeInUseCannotBeDeleted(): void
|
||||
{
|
||||
[$user, , $address] = $this->clinicWithAddress();
|
||||
$created = $this->authJson('POST', '/api/v1/resource-types', $user, ['code' => 'laser', 'name' => 'لیزر']);
|
||||
$this->createResource($user, $address, $this->typeEntity($created['data']['uuid']));
|
||||
|
||||
$body = $this->authJson('DELETE', "/api/v1/resource-type/{$created['data']['uuid']}", $user);
|
||||
|
||||
self::assertSame(422, $this->responseCode());
|
||||
self::assertStringContainsString('1 منبع', $body['errors'][0]['message']);
|
||||
}
|
||||
|
||||
public function testUnusedTypeIsDeleted(): void
|
||||
{
|
||||
[$user] = $this->clinicWithAddress();
|
||||
$created = $this->authJson('POST', '/api/v1/resource-types', $user, ['code' => 'laser', 'name' => 'لیزر']);
|
||||
|
||||
$this->authJson('DELETE', "/api/v1/resource-type/{$created['data']['uuid']}", $user);
|
||||
|
||||
self::assertSame(200, $this->responseCode());
|
||||
}
|
||||
|
||||
/** نوع سیستمی هرگز حذف نمیشود: ResourceLinker با همان کد پل میزند. */
|
||||
public function testSystemTypeCannotBeDeletedEvenWhenUnused(): void
|
||||
{
|
||||
[$user, , $address] = $this->clinicWithAddress();
|
||||
|
||||
$type = $this->resourceType($address, 'room', 'اتاق');
|
||||
$type->markSystem();
|
||||
$this->em->flush();
|
||||
|
||||
$body = $this->authJson('DELETE', "/api/v1/resource-type/{$type->getUuid()}", $user);
|
||||
|
||||
self::assertSame(422, $this->responseCode());
|
||||
self::assertStringContainsString('سیستمی', $body['errors'][0]['message']);
|
||||
}
|
||||
|
||||
public function testForeignTypeIsNotFound(): void
|
||||
{
|
||||
[$user] = $this->clinicWithAddress();
|
||||
$created = $this->authJson('POST', '/api/v1/resource-types', $user, ['code' => 'laser', 'name' => 'لیزر']);
|
||||
|
||||
[$otherUser] = $this->clinicWithAddress('شعبهٔ دیگر');
|
||||
|
||||
$this->authJson('PATCH', "/api/v1/resource-type/{$created['data']['uuid']}", $otherUser, ['name' => 'x']);
|
||||
self::assertSame(404, $this->responseCode());
|
||||
}
|
||||
|
||||
/** `code` عوض نمیشود: منابع موجود و ResourceLinker با همان کد پیدا میشوند. */
|
||||
public function testCodeIsImmutable(): void
|
||||
{
|
||||
[$user] = $this->clinicWithAddress();
|
||||
$created = $this->authJson('POST', '/api/v1/resource-types', $user, ['code' => 'laser', 'name' => 'لیزر']);
|
||||
|
||||
$body = $this->authJson('PATCH', "/api/v1/resource-type/{$created['data']['uuid']}", $user, [
|
||||
'code' => 'something_else',
|
||||
'name' => 'نام تازه',
|
||||
]);
|
||||
|
||||
self::assertSame(200, $this->responseCode());
|
||||
self::assertSame('laser', $body['data']['code']);
|
||||
self::assertSame('نام تازه', $body['data']['name']);
|
||||
}
|
||||
|
||||
private function typeEntity(string $uuid): \App\Resource\Entity\ResourceType
|
||||
{
|
||||
return $this->em->getRepository(\App\Resource\Entity\ResourceType::class)->findOneBy(['uuid' => $uuid]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
<?php
|
||||
|
||||
namespace App\Tests\Resource;
|
||||
|
||||
class SkillAssignmentTest extends ResourceTestCase
|
||||
{
|
||||
public function testSkillsAreReplacedWholesale(): void
|
||||
{
|
||||
[$user, , $address] = $this->clinicWithAddress();
|
||||
$resource = $this->createResource($user, $address, $this->resourceType($address));
|
||||
$laser = $this->createSkill($user, 'لیزر آلکساندرایت');
|
||||
$botox = $this->createSkill($user, 'بوتاکس');
|
||||
|
||||
$withBoth = $this->authJson('PUT', "/api/v1/resource/{$resource['data']['uuid']}/skills", $user, [
|
||||
'skills' => [
|
||||
['skill_uuid' => $laser['data']['uuid'], 'level' => 4],
|
||||
['skill_uuid' => $botox['data']['uuid']],
|
||||
],
|
||||
]);
|
||||
|
||||
self::assertSame(200, $this->responseCode(), json_encode($withBoth, JSON_UNESCAPED_UNICODE));
|
||||
self::assertCount(2, $withBoth['data']['skills']);
|
||||
self::assertSame(1, $withBoth['data']['skills'][1]['level'], 'سطح پیشفرض ۱ است');
|
||||
|
||||
// PUT جایگزینی کامل است: مهارتی که نیامده، برداشته میشود.
|
||||
$onlyLaser = $this->authJson('PUT', "/api/v1/resource/{$resource['data']['uuid']}/skills", $user, [
|
||||
'skills' => [['skill_uuid' => $laser['data']['uuid'], 'level' => 5]],
|
||||
]);
|
||||
|
||||
self::assertCount(1, $onlyLaser['data']['skills']);
|
||||
self::assertSame($laser['data']['uuid'], $onlyLaser['data']['skills'][0]['skill_uuid']);
|
||||
self::assertSame(5, $onlyLaser['data']['skills'][0]['level']);
|
||||
}
|
||||
|
||||
public function testEmptyListClearsAllSkills(): void
|
||||
{
|
||||
[$user, , $address] = $this->clinicWithAddress();
|
||||
$resource = $this->createResource($user, $address, $this->resourceType($address));
|
||||
$skill = $this->createSkill($user);
|
||||
|
||||
$this->authJson('PUT', "/api/v1/resource/{$resource['data']['uuid']}/skills", $user, [
|
||||
'skills' => [['skill_uuid' => $skill['data']['uuid']]],
|
||||
]);
|
||||
|
||||
$body = $this->authJson('PUT', "/api/v1/resource/{$resource['data']['uuid']}/skills", $user, ['skills' => []]);
|
||||
|
||||
self::assertSame(200, $this->responseCode());
|
||||
self::assertSame([], $body['data']['skills']);
|
||||
}
|
||||
|
||||
public function testLevelOutsideOneToFiveIsRejected(): void
|
||||
{
|
||||
[$user, , $address] = $this->clinicWithAddress();
|
||||
$resource = $this->createResource($user, $address, $this->resourceType($address));
|
||||
$skill = $this->createSkill($user);
|
||||
|
||||
foreach ([0, 6] as $level) {
|
||||
$body = $this->authJson('PUT', "/api/v1/resource/{$resource['data']['uuid']}/skills", $user, [
|
||||
'skills' => [['skill_uuid' => $skill['data']['uuid'], 'level' => $level]],
|
||||
]);
|
||||
|
||||
self::assertSame(422, $this->responseCode(), "level=$level باید رد شود");
|
||||
self::assertSame('level', $body['errors'][0]['field']);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* اعتبارسنجی پیش از حذف: ورودی نامعتبر در انتهای فهرست نباید مهارتهای درستِ
|
||||
* قبلی را پاک کند و بعد ۴۲۲ برگرداند.
|
||||
*/
|
||||
public function testInvalidRowLeavesTheStoredSkillsUntouched(): void
|
||||
{
|
||||
[$user, , $address] = $this->clinicWithAddress();
|
||||
$resource = $this->createResource($user, $address, $this->resourceType($address));
|
||||
$laser = $this->createSkill($user, 'لیزر');
|
||||
$botox = $this->createSkill($user, 'بوتاکس');
|
||||
|
||||
$this->authJson('PUT', "/api/v1/resource/{$resource['data']['uuid']}/skills", $user, [
|
||||
'skills' => [['skill_uuid' => $laser['data']['uuid'], 'level' => 3]],
|
||||
]);
|
||||
|
||||
$this->authJson('PUT', "/api/v1/resource/{$resource['data']['uuid']}/skills", $user, [
|
||||
'skills' => [
|
||||
['skill_uuid' => $botox['data']['uuid']],
|
||||
['skill_uuid' => $laser['data']['uuid'], 'level' => 99],
|
||||
],
|
||||
]);
|
||||
self::assertSame(422, $this->responseCode());
|
||||
|
||||
$read = $this->authJson('GET', "/api/v1/resource/{$resource['data']['uuid']}", $user);
|
||||
self::assertCount(1, $read['data']['skills']);
|
||||
self::assertSame(3, $read['data']['skills'][0]['level']);
|
||||
}
|
||||
|
||||
public function testDuplicateSkillInOnePayloadIsRejected(): void
|
||||
{
|
||||
[$user, , $address] = $this->clinicWithAddress();
|
||||
$resource = $this->createResource($user, $address, $this->resourceType($address));
|
||||
$skill = $this->createSkill($user);
|
||||
|
||||
$this->authJson('PUT', "/api/v1/resource/{$resource['data']['uuid']}/skills", $user, [
|
||||
'skills' => [
|
||||
['skill_uuid' => $skill['data']['uuid']],
|
||||
['skill_uuid' => $skill['data']['uuid'], 'level' => 2],
|
||||
],
|
||||
]);
|
||||
|
||||
self::assertSame(422, $this->responseCode());
|
||||
}
|
||||
|
||||
public function testForeignSkillIsNotFound(): void
|
||||
{
|
||||
[$user, , $address] = $this->clinicWithAddress();
|
||||
$resource = $this->createResource($user, $address, $this->resourceType($address));
|
||||
|
||||
[$otherUser] = $this->clinicWithAddress('شعبهٔ دیگر');
|
||||
$foreignSkill = $this->createSkill($otherUser, 'مهارت بیگانه');
|
||||
|
||||
$this->authJson('PUT', "/api/v1/resource/{$resource['data']['uuid']}/skills", $user, [
|
||||
'skills' => [['skill_uuid' => $foreignSkill['data']['uuid']]],
|
||||
]);
|
||||
|
||||
self::assertSame(404, $this->responseCode());
|
||||
}
|
||||
|
||||
/** مهارتی که روی منبعی نشسته حذف نمیشود؛ وگرنه FK خطای خام دیتابیس میداد. */
|
||||
public function testSkillInUseCannotBeDeleted(): void
|
||||
{
|
||||
[$user, , $address] = $this->clinicWithAddress();
|
||||
$resource = $this->createResource($user, $address, $this->resourceType($address));
|
||||
$skill = $this->createSkill($user);
|
||||
|
||||
$this->authJson('PUT', "/api/v1/resource/{$resource['data']['uuid']}/skills", $user, [
|
||||
'skills' => [['skill_uuid' => $skill['data']['uuid']]],
|
||||
]);
|
||||
|
||||
$body = $this->authJson('DELETE', "/api/v1/skill/{$skill['data']['uuid']}", $user);
|
||||
self::assertSame(422, $this->responseCode());
|
||||
// رقم لاتین عمدی است: قرارداد پیامهای درونریزیِ بکاند همین است
|
||||
// (BackfillServiceDurationCommand، WorkingHoursService و …) و قالببندی فارسی
|
||||
// کارِ نمایش در کلاینت است.
|
||||
self::assertStringContainsString('1 منبع', $body['errors'][0]['message']);
|
||||
|
||||
// بعد از برداشتن از منبع، حذف مجاز است.
|
||||
$this->authJson('PUT', "/api/v1/resource/{$resource['data']['uuid']}/skills", $user, ['skills' => []]);
|
||||
$this->authJson('DELETE', "/api/v1/skill/{$skill['data']['uuid']}", $user);
|
||||
self::assertSame(200, $this->responseCode());
|
||||
}
|
||||
|
||||
public function testSkillListReportsUsageCount(): void
|
||||
{
|
||||
[$user, , $address] = $this->clinicWithAddress();
|
||||
$resource = $this->createResource($user, $address, $this->resourceType($address));
|
||||
$skill = $this->createSkill($user);
|
||||
|
||||
$before = $this->authJson('GET', '/api/v1/skills', $user);
|
||||
self::assertSame(0, $before['data'][0]['resources_count']);
|
||||
|
||||
$this->authJson('PUT', "/api/v1/resource/{$resource['data']['uuid']}/skills", $user, [
|
||||
'skills' => [['skill_uuid' => $skill['data']['uuid']]],
|
||||
]);
|
||||
|
||||
$after = $this->authJson('GET', '/api/v1/skills', $user);
|
||||
self::assertSame(1, $after['data'][0]['resources_count']);
|
||||
}
|
||||
|
||||
public function testBlankSkillNameIsRejected(): void
|
||||
{
|
||||
[$user] = $this->clinicWithAddress();
|
||||
|
||||
$body = $this->authJson('POST', '/api/v1/skills', $user, ['name' => ' ']);
|
||||
|
||||
self::assertSame(422, $this->responseCode());
|
||||
self::assertSame('name', $body['errors'][0]['field']);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user