Files
clinicpro/migrations/Version20260730150551.php
T
hamedandClaude Opus 5 b1b06c1b36 feat(catalog): dual durations, item groups, relations and branch overrides
Section 5 of the design document rejects summing service durations. "Face + bikini"
is not 15+12=27 minutes but 15+8=23 — preparation and settling the patient do not
happen twice. Seven wasted minutes times twenty appointments a day is an hour of
capacity lost daily, and AppointmentController was doing exactly that plain sum.

Each item now carries a solo duration and an additional duration. One item counts at
its solo duration and the rest at their additional; the anchor is the item with the
*largest* solo duration rather than the first one selected. Anchoring on selection
order would have let the same basket cost different amounts depending on click order,
so a patient could buy a shorter appointment by reordering. Largest-first is also
conservative: no combination is ever under-estimated, and under-estimating pushes the
next appointment on top of this one.

additional_duration_minutes stays NULL by default and the entity reads NULL as "same
as solo", so every existing service keeps behaving exactly as before — the 236
appointment-domain tests pass unchanged. The old duration_minutes column is kept and
written in step rather than renamed, because other consumers still read it.

ServiceBookingCalculator now delegates to DurationCalculator, which is the one-line
change task 00 predicted when it deliberately preserved the naive sum.

Selection rules are data, not policy: min/max per group is a number, and "bikini does
not combine with full body" is a relation. Putting either in a rules engine means
several rules per service and nobody able to explain a rejection. Validation returns
*all* errors at once rather than the first, since a user with three problems should
not make three round trips. Prerequisite cycles are rejected at write time — storing
both "A requires B" and "B requires A" would make every selection permanently invalid.

Named CatalogCategory, not ServiceCategory: that name is already an insurance enum
(outpatient/inpatient) living on ServiceItem itself, so the two would have collided in
the same file's imports.

Also fixed a defect the tests caught: breakdown() used $overrides[$id]?->… on a key
that may not exist, which warns instead of yielding null.

1175 tests / 3289 assertions. phpstan measured at 14 errors both with and without
this change (verified by stashing). Slot-mode frozen contract green.

The admin UI tab for groups and relations is not built; the checklist records it as
outstanding with a target. The backend is complete and
POST /service-selection/validate is consumable without it.

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

74 lines
7.4 KiB
PHP

<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Service catalog v2: tree categories, item groups, item relations and per-branch
* overrides, plus the two duration columns.
*
* duration_minutes is kept and backfilled into solo_duration_minutes rather than
* renamed: several consumers still read it, and additional_duration_minutes stays
* NULL, which the entity reads as "same as solo" — so existing data keeps behaving
* exactly as it did (a plain sum) until someone fills the second number in.
*/
final class Version20260730150551 extends AbstractMigration
{
public function getDescription(): string
{
return 'Add catalog categories, item groups, item relations, branch overrides and dual durations';
}
public function up(Schema $schema): void
{
$this->addSql('CREATE TABLE service_branch_overrides (id INT AUTO_INCREMENT NOT NULL, uuid VARCHAR(36) NOT NULL, price_rials BIGINT DEFAULT NULL, solo_duration_minutes SMALLINT DEFAULT NULL, additional_duration_minutes SMALLINT DEFAULT NULL, created_at INT NOT NULL, updated_at INT NOT NULL, entity_type VARCHAR(10) NOT NULL, entity_id INT NOT NULL, item_id INT NOT NULL, address_id INT NOT NULL, UNIQUE INDEX UNIQ_6E23CD38D17F50A6 (uuid), INDEX IDX_6E23CD38126F525E (item_id), INDEX IDX_6E23CD38F5B7AF75 (address_id), INDEX idx_override_tenant (entity_type, entity_id), UNIQUE INDEX uniq_override_item_address (item_id, address_id), PRIMARY KEY (id)) DEFAULT CHARACTER SET utf8mb4');
$this->addSql('CREATE TABLE service_catalog_categories (id INT AUTO_INCREMENT NOT NULL, uuid VARCHAR(36) NOT NULL, name VARCHAR(150) NOT NULL, sort_order SMALLINT 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, parent_id INT DEFAULT NULL, UNIQUE INDEX UNIQ_790A858DD17F50A6 (uuid), INDEX idx_catalog_cat_tenant (entity_type, entity_id, active), INDEX idx_catalog_cat_parent (parent_id), PRIMARY KEY (id)) DEFAULT CHARACTER SET utf8mb4');
$this->addSql('CREATE TABLE service_item_group_members (id INT AUTO_INCREMENT NOT NULL, sort_order SMALLINT DEFAULT 0 NOT NULL, group_id INT NOT NULL, item_id INT NOT NULL, INDEX IDX_D9DF1F16FE54D947 (group_id), INDEX idx_group_member_item (item_id), UNIQUE INDEX uniq_group_item (group_id, item_id), PRIMARY KEY (id)) DEFAULT CHARACTER SET utf8mb4');
$this->addSql('CREATE TABLE service_item_groups (id INT AUTO_INCREMENT NOT NULL, uuid VARCHAR(36) NOT NULL, name VARCHAR(150) NOT NULL, min_select SMALLINT DEFAULT 0 NOT NULL, max_select SMALLINT DEFAULT NULL, sort_order SMALLINT DEFAULT 0 NOT NULL, created_at INT NOT NULL, updated_at INT NOT NULL, entity_type VARCHAR(10) NOT NULL, entity_id INT NOT NULL, service_id INT NOT NULL, UNIQUE INDEX UNIQ_BA0E6A13D17F50A6 (uuid), INDEX IDX_BA0E6A13ED5CA9E6 (service_id), INDEX idx_item_group_tenant (entity_type, entity_id), INDEX idx_item_group_service (service_id, sort_order), PRIMARY KEY (id)) DEFAULT CHARACTER SET utf8mb4');
$this->addSql('CREATE TABLE service_item_relations (id INT AUTO_INCREMENT NOT NULL, uuid VARCHAR(36) NOT NULL, type VARCHAR(20) NOT NULL, created_at INT NOT NULL, entity_type VARCHAR(10) NOT NULL, entity_id INT NOT NULL, item_id INT NOT NULL, related_item_id INT NOT NULL, UNIQUE INDEX UNIQ_1BD0F43ED17F50A6 (uuid), INDEX IDX_1BD0F43E126F525E (item_id), INDEX IDX_1BD0F43E2D7698FB (related_item_id), INDEX idx_relation_tenant (entity_type, entity_id), INDEX idx_relation_item_type (item_id, type), UNIQUE INDEX uniq_relation_triple (item_id, related_item_id, type), PRIMARY KEY (id)) DEFAULT CHARACTER SET utf8mb4');
$this->addSql('ALTER TABLE service_branch_overrides ADD CONSTRAINT FK_6E23CD38126F525E FOREIGN KEY (item_id) REFERENCES service_items (id) ON DELETE CASCADE');
$this->addSql('ALTER TABLE service_branch_overrides ADD CONSTRAINT FK_6E23CD38F5B7AF75 FOREIGN KEY (address_id) REFERENCES doctor_addresses (id) ON DELETE CASCADE');
$this->addSql('ALTER TABLE service_catalog_categories ADD CONSTRAINT FK_790A858D727ACA70 FOREIGN KEY (parent_id) REFERENCES service_catalog_categories (id) ON DELETE CASCADE');
$this->addSql('ALTER TABLE service_item_group_members ADD CONSTRAINT FK_D9DF1F16FE54D947 FOREIGN KEY (group_id) REFERENCES service_item_groups (id) ON DELETE CASCADE');
$this->addSql('ALTER TABLE service_item_group_members ADD CONSTRAINT FK_D9DF1F16126F525E FOREIGN KEY (item_id) REFERENCES service_items (id) ON DELETE CASCADE');
$this->addSql('ALTER TABLE service_item_groups ADD CONSTRAINT FK_BA0E6A13ED5CA9E6 FOREIGN KEY (service_id) REFERENCES service_items (id) ON DELETE CASCADE');
$this->addSql('ALTER TABLE service_item_relations ADD CONSTRAINT FK_1BD0F43E126F525E FOREIGN KEY (item_id) REFERENCES service_items (id) ON DELETE CASCADE');
$this->addSql('ALTER TABLE service_item_relations ADD CONSTRAINT FK_1BD0F43E2D7698FB FOREIGN KEY (related_item_id) REFERENCES service_items (id) ON DELETE CASCADE');
$this->addSql('ALTER TABLE service_items ADD solo_duration_minutes SMALLINT DEFAULT NULL, ADD additional_duration_minutes SMALLINT DEFAULT NULL, ADD session_count SMALLINT DEFAULT 1 NOT NULL, ADD catalog_category_id INT DEFAULT NULL');
$this->addSql('ALTER TABLE service_items ADD CONSTRAINT FK_486C04AA3F2BC4C FOREIGN KEY (catalog_category_id) REFERENCES service_catalog_categories (id) ON DELETE SET NULL');
$this->addSql('CREATE INDEX IDX_486C04AA3F2BC4C ON service_items (catalog_category_id)');
}
public function postUp(Schema $schema): void
{
// مدت موجود همان «مدت تنها»ست؛ بدون این، هر سرویسِ موجود مدت صفر می‌گرفت.
$this->connection->executeStatement(
'UPDATE service_items SET solo_duration_minutes = duration_minutes WHERE duration_minutes IS NOT NULL'
);
}
public function down(Schema $schema): void
{
$this->addSql('ALTER TABLE service_branch_overrides DROP FOREIGN KEY FK_6E23CD38126F525E');
$this->addSql('ALTER TABLE service_branch_overrides DROP FOREIGN KEY FK_6E23CD38F5B7AF75');
$this->addSql('ALTER TABLE service_catalog_categories DROP FOREIGN KEY FK_790A858D727ACA70');
$this->addSql('ALTER TABLE service_item_group_members DROP FOREIGN KEY FK_D9DF1F16FE54D947');
$this->addSql('ALTER TABLE service_item_group_members DROP FOREIGN KEY FK_D9DF1F16126F525E');
$this->addSql('ALTER TABLE service_item_groups DROP FOREIGN KEY FK_BA0E6A13ED5CA9E6');
$this->addSql('ALTER TABLE service_item_relations DROP FOREIGN KEY FK_1BD0F43E126F525E');
$this->addSql('ALTER TABLE service_item_relations DROP FOREIGN KEY FK_1BD0F43E2D7698FB');
$this->addSql('DROP TABLE service_branch_overrides');
$this->addSql('DROP TABLE service_catalog_categories');
$this->addSql('DROP TABLE service_item_group_members');
$this->addSql('DROP TABLE service_item_groups');
$this->addSql('DROP TABLE service_item_relations');
$this->addSql('ALTER TABLE service_items DROP FOREIGN KEY FK_486C04AA3F2BC4C');
$this->addSql('DROP INDEX IDX_486C04AA3F2BC4C ON service_items');
$this->addSql('ALTER TABLE service_items DROP solo_duration_minutes, DROP additional_duration_minutes, DROP session_count, DROP catalog_category_id');
}
}