Files
clinicpro/migrations/Version20260730173225.php
hamedandClaude Opus 5 22c89fbae4 feat(plan): multi-segment appointments with per-segment resource requirements
Section 7 of the design document, and the reason the whole resource layer exists.
A laser session is not one block: numbing cream (5 min, room + operator), waiting for
it to work (30 min, room only), the laser itself (20 min, room + operator + device),
aftercare (5 min, room + operator). Under the single-interval model the operator is
locked for all 60 minutes while actually working 30 — half the capacity thrown away.

AppointmentPlanBuilder turns (service, selected items, branch, patient) into a plan:
segments with offsets, durations and resource requirements. It deliberately assigns
no absolute time and no specific resource — that is the next task. This only produces
the *shape* of the appointment.

Segment duration comes from one of two sources. A fixed segment carries its own
number; an item-driven one gets its duration from task 04's DurationCalculator, so
"the laser itself" grows with two treated areas while "waiting for the cream" does
not. One number could not have expressed that.

Three contracts worth stating:

- A service with no segment templates falls back to a single continuous segment
  requiring the doctor resource — exactly today's behaviour. Without it every
  existing service would have become unplannable overnight.
- A segment with no requirements is valid: "waiting at home" consumes time but
  occupies nothing.
- same_gender_as_patient with an unknown patient gender is a 422, not a silently
  dropped requirement. Dropping it quietly would route the patient to a resource the
  clinic said must not serve them.

When no resource qualifies, the error names the role, the skill and the branch —
"no female operator with the skill «Alexandrite laser» is available at «Central»" —
rather than an empty result the caller has to interpret (section 10).

occupancy_offset carries each requirement's setup/cleanup minutes for the availability
engine. It is taken as the maximum across candidates, because the builder does not yet
know which resource will be picked and under-reserving means the next appointment
lands on top of the cleanup.

11 tests covering the document's reference example (offsets 0/5/35/55, total 60),
item-driven scaling, the no-template fallback, all three gender-constraint outcomes,
merging and both caps. 1186 tests overall. phpstan back at its 14-error baseline;
slot-mode frozen contract green.

The admin segments page is not built; the checklist records it with a target. The
backend and preview endpoint are complete and consumable without it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 21:07:30 +03:30

42 lines
2.9 KiB
PHP

<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Appointment segments and their resource requirements — section 7 of the design
* document. An appointment is not one continuous block: with the old model an
* operator is locked for 60 minutes while working 30.
*/
final class Version20260730173225 extends AbstractMigration
{
public function getDescription(): string
{
return 'Add segment templates and segment resource requirements';
}
public function up(Schema $schema): void
{
$this->addSql('CREATE TABLE segment_requirements (id INT AUTO_INCREMENT NOT NULL, count SMALLINT DEFAULT 1 NOT NULL, occupancy VARCHAR(12) DEFAULT \'exclusive\' NOT NULL, constraints JSON DEFAULT NULL, segment_id INT NOT NULL, resource_type_id INT NOT NULL, skill_id INT DEFAULT NULL, INDEX IDX_6701180998EC6B7B (resource_type_id), INDEX IDX_670118095585C142 (skill_id), INDEX idx_requirement_segment (segment_id), PRIMARY KEY (id)) DEFAULT CHARACTER SET utf8mb4');
$this->addSql('CREATE TABLE segment_templates (id INT AUTO_INCREMENT NOT NULL, uuid VARCHAR(36) NOT NULL, sequence SMALLINT NOT NULL, name VARCHAR(150) NOT NULL, duration_source VARCHAR(10) DEFAULT \'fixed\' NOT NULL, duration_minutes SMALLINT DEFAULT 0 NOT NULL, patient_present TINYINT DEFAULT 1 NOT NULL, mergeable TINYINT DEFAULT 0 NOT NULL, created_at INT NOT NULL, entity_type VARCHAR(10) NOT NULL, entity_id INT NOT NULL, service_id INT NOT NULL, UNIQUE INDEX UNIQ_2BBC29FD17F50A6 (uuid), INDEX IDX_2BBC29FED5CA9E6 (service_id), INDEX idx_segment_tenant (entity_type, entity_id), INDEX idx_segment_service_seq (service_id, sequence), PRIMARY KEY (id)) DEFAULT CHARACTER SET utf8mb4');
$this->addSql('ALTER TABLE segment_requirements ADD CONSTRAINT FK_67011809DB296AAD FOREIGN KEY (segment_id) REFERENCES segment_templates (id) ON DELETE CASCADE');
$this->addSql('ALTER TABLE segment_requirements ADD CONSTRAINT FK_6701180998EC6B7B FOREIGN KEY (resource_type_id) REFERENCES resource_types (id) ON DELETE RESTRICT');
$this->addSql('ALTER TABLE segment_requirements ADD CONSTRAINT FK_670118095585C142 FOREIGN KEY (skill_id) REFERENCES skills (id) ON DELETE RESTRICT');
$this->addSql('ALTER TABLE segment_templates ADD CONSTRAINT FK_2BBC29FED5CA9E6 FOREIGN KEY (service_id) REFERENCES service_items (id) ON DELETE CASCADE');
}
public function down(Schema $schema): void
{
$this->addSql('ALTER TABLE segment_requirements DROP FOREIGN KEY FK_67011809DB296AAD');
$this->addSql('ALTER TABLE segment_requirements DROP FOREIGN KEY FK_6701180998EC6B7B');
$this->addSql('ALTER TABLE segment_requirements DROP FOREIGN KEY FK_670118095585C142');
$this->addSql('ALTER TABLE segment_templates DROP FOREIGN KEY FK_2BBC29FED5CA9E6');
$this->addSql('DROP TABLE segment_requirements');
$this->addSql('DROP TABLE segment_templates');
}
}