Files
clinicpro/migrations/Version20260730125038.php
T
hamedandClaude Opus 5 eebb363b9f feat(branch): branch working hours and rooms on the existing address entity
Task 01 planned a new `branches` table with `doctor_addresses.branch_id` bridging
to it. That plan was wrong: the branch already exists and is called
`DoctorAddress`. It carries name, address, telephone, coordinates, city/province
FKs and an owner (`forDoctor` / `forClinic` + `type`), and the whole system
already consumes it with exactly that meaning — `WeeklySchedule.sessions[].location_id`
points at `doctor_addresses.id`, `appointment-booking-locations` calls each row a
booking location, and nine CRUD endpoints plus four admin pages manage them.
A parallel table would mean two sources of truth for one physical place and a
branch that `location_id` never references.

So no `branches` table and no duplicate branch CRUD. Only the three genuinely
missing pieces:

- `doctor_addresses.active` / `.timezone`, both NOT NULL with a default so
  existing rows need no backfill and no current behaviour changes. `active` is
  stored only — applying it to slot calculation is task 03, since touching
  `SlotCalculatorService` is off limits in this phase.
- `branch_working_hours`, keyed to `doctor_addresses.id`. Minutes from midnight
  rather than "09:00" strings so range intersection stays arithmetic. PUT
  replaces all seven days; validation of the whole week runs before any DELETE,
  so an invalid sixth day cannot wipe the five valid ones and then answer 422.
- `rooms`, with `capacity` as concurrency (a three-bed injection room is one
  resource with capacity 3, not three resources) and a deletion-guard iterator
  so tasks 02 and 07 can add reasons without editing RoomService.

`BranchWorkingHours` first registered as an aggregate child of `DoctorAddress`;
TenantSchemaCoverageTest rejected it correctly, because that root is itself
declared global. It now carries a real tenant pair instead, derived in the
constructor from the address's `type` — a total mapping, and the address is only
ever listed in its own context, so nothing is hidden wrongly.

RoomController checks ownership explicitly rather than trusting TenantFilter:
hard isolation only applies to a *chosen* context, so a doctor who had not
selected one could PATCH another clinic's room. Caught by
RoomCrudTest::testForeignRoomIsNotFound, which failed with 200 before the fix.

35 tests, 97 assertions. Slot-mode frozen contract still green.

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

44 lines
3.0 KiB
PHP

<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Branch working hours and rooms, both keyed to doctor_addresses — the existing
* "branch" entity. No parallel branches table is created; see
* docs/new_feture/taskes/_shared/branch-is-doctor-address.md.
*
* Both new doctor_addresses columns are NOT NULL with a default, so existing rows
* become correct without a backfill and no current behaviour changes.
*/
final class Version20260730125038 extends AbstractMigration
{
public function getDescription(): string
{
return 'Add branch working hours and rooms; add active/timezone to doctor_addresses';
}
public function up(Schema $schema): void
{
$this->addSql('CREATE TABLE branch_working_hours (id INT AUTO_INCREMENT NOT NULL, day_of_week SMALLINT NOT NULL, sequence SMALLINT DEFAULT 0 NOT NULL, start_minute SMALLINT NOT NULL, end_minute SMALLINT NOT NULL, active TINYINT DEFAULT 1 NOT NULL, entity_type VARCHAR(10) NOT NULL, entity_id INT NOT NULL, address_id INT NOT NULL, INDEX IDX_E8C43E37F5B7AF75 (address_id), INDEX idx_bwh_address_day (address_id, day_of_week, active), INDEX idx_bwh_tenant (entity_type, entity_id), UNIQUE INDEX uniq_bwh_address_day_seq (address_id, day_of_week, sequence), PRIMARY KEY (id)) DEFAULT CHARACTER SET utf8mb4');
$this->addSql('CREATE TABLE rooms (id INT AUTO_INCREMENT NOT NULL, uuid VARCHAR(36) NOT NULL, name VARCHAR(120) NOT NULL, room_type VARCHAR(60) DEFAULT NULL, capacity SMALLINT DEFAULT 1 NOT NULL, floor VARCHAR(20) 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, UNIQUE INDEX UNIQ_7CA11A96D17F50A6 (uuid), INDEX IDX_7CA11A96F5B7AF75 (address_id), INDEX idx_rooms_tenant (entity_type, entity_id, active), INDEX idx_rooms_address (address_id, active), PRIMARY KEY (id)) DEFAULT CHARACTER SET utf8mb4');
$this->addSql('ALTER TABLE branch_working_hours ADD CONSTRAINT FK_E8C43E37F5B7AF75 FOREIGN KEY (address_id) REFERENCES doctor_addresses (id) ON DELETE CASCADE');
$this->addSql('ALTER TABLE rooms ADD CONSTRAINT FK_7CA11A96F5B7AF75 FOREIGN KEY (address_id) REFERENCES doctor_addresses (id) ON DELETE CASCADE');
$this->addSql('ALTER TABLE doctor_addresses ADD active TINYINT DEFAULT 1 NOT NULL, ADD timezone VARCHAR(40) DEFAULT \'Asia/Tehran\' NOT NULL');
}
public function down(Schema $schema): void
{
// this down() migration is auto-generated, please modify it to your needs
$this->addSql('ALTER TABLE branch_working_hours DROP FOREIGN KEY FK_E8C43E37F5B7AF75');
$this->addSql('ALTER TABLE rooms DROP FOREIGN KEY FK_7CA11A96F5B7AF75');
$this->addSql('DROP TABLE branch_working_hours');
$this->addSql('DROP TABLE rooms');
$this->addSql('ALTER TABLE doctor_addresses DROP active, DROP timezone');
}
}