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

The document's first golden rule is "the calendar belongs to the resource, not to
the doctor". Today the only thing that can be occupied is a doctor, and ClinicStaff
is a label on services and appointments with no calendar, capacity or skills. This
adds the layer underneath: anything that can be busy — doctor, operator, assistant,
device, room, bed, chair.

Two corrections to the planned schema:

- `address_id` → doctor_addresses, not `branch_id` → a new branches table. The
  branch already exists and is the address (task 01).
- UNIQUE is (doctor_id, address_id), not (doctor_id). A WeeklySchedule is per
  (doctor, clinic) but every session inside it carries its own location_id, so one
  doctor already works at several addresses within one environment. Keying on the
  doctor alone would have made that unrepresentable — and task 03 gives each
  resource its own calendar, which is exactly per-location.

Design points worth keeping:

- Resources bridge to Doctor/ClinicStaff/Room rather than absorbing them; those
  three have live consumers (appointments.doctor_id, service_item_staff, the public
  site) and subclassing would mean migrating all of them at once. At most one bridge
  column is non-null, enforced in the entity because MariaDB will not reliably
  enforce a multi-column CHECK.
- Capacity is concurrency: a three-bed injection room is one resource with capacity
  3, not three resources, so occupancy in task 06 stays a COUNT against a limit
  instead of a merge of three calendars. A person resource is refused capacity > 1.
- Skills are a table, not rules. With 50 operators and 200 services, expressing
  "who may operate what" as policy would mean 10,000 rules.
- findEligible() uses HAVING COUNT(DISTINCT …) because "skills A and B" means both;
  a plain IN would have matched a resource holding only one.
- setup/cleanup minutes occupy the resource without being part of the patient's
  appointment, and are per-resource — distinct from the existing per-doctor
  WeeklySchedule.meta.buffer_minutes, which stays untouched.

Two real bugs found by running the backfill against real data rather than fixtures:

ResourceLinker::systemType() persisted a type without flushing, so the next lookup
missed it and created a second — the run died on "Duplicate entry 'doctor-1-staff'
for key uniq_rt_tenant_code". It now keeps an identity map for the unit of work.

The command looped over every WeeklySchedule once per environment, which is
quadratic and never finished on real data. Doctors are now a single pass keyed by
the schedule's own environment. It also flushes per environment and accepts
--pair=clinic:12, so one bad row cannot close the EntityManager and abort a
fleet-wide run, and operators can re-run for a single clinic.

Staff are the one case that cannot be derived: nothing records which branch they
work at. Rather than guessing the first one and seating them in the wrong building,
multi-branch environments are skipped and reported.

88 tests, 230 assertions across tests/Resource and tests/Branch. phpstan clean on
src/Resource.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
hamed
2026-07-30 17:34:36 +03:30
co-authored by Claude Opus 5
parent 92181bacff
commit 964c09cc00
33 changed files with 3824 additions and 0 deletions
+69
View File
@@ -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');
}
}