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>
74 lines
7.4 KiB
PHP
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');
|
|
}
|
|
}
|