Files
clinicpro/migrations/Version20260731081142.php
T
hamedandClaude Opus 5 fba1555f22 feat(cancellation): cancellation policy, no-show tracking and a waitlist
Cancelling worked but had no policy behind it: no window, no penalty, nothing
happened to the deposit, and the no_show status had no effect at all.

Two rules that are expensive to get wrong, and both are load-bearing:
- The clinic cancelling its own appointment is never charged. That check is the
  first line of the calculation, not somewhere in the middle, so a later
  refactor cannot reorder it into charging patients for the clinic's decision.
- A penalty never exceeds what was actually paid. Anything above that is a
  debt, and debt belongs to billing, not to cancellation. An unpaid appointment
  is charged nothing and the response says why.

The default is no penalty at all — a penalising default would have made every
patient with a near appointment liable the moment this deployed.

No-shows are rows, not a counter on the patient: a counter loses which
appointment and when, which makes the 12-month window impossible. Crossing the
threshold adds an existing TenantTag; it never blocks the patient, because
blocking is an eligibility policy (task 09) written on top of that same tag.

Waitlist notifies up to ten matching people and the first to book wins. An
exclusive queue reads fairer but means a freed slot sits locked for half an
hour while someone ignores their phone — so the SMS says so explicitly instead.

Insufficient wallet balance does not fail the cancellation: the slot is freed
either way. A slot should not be held hostage to money.

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

50 lines
5.1 KiB
PHP

<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Auto-generated Migration: Please modify to your needs!
*/
final class Version20260731081142 extends AbstractMigration
{
public function getDescription(): string
{
return '';
}
public function up(Schema $schema): void
{
// this up() migration is auto-generated, please modify it to your needs
$this->addSql('CREATE TABLE cancellation_policies (id INT AUTO_INCREMENT NOT NULL, uuid VARCHAR(36) NOT NULL, free_window_hours SMALLINT DEFAULT 24 NOT NULL, penalty_mode VARCHAR(10) DEFAULT \'none\' NOT NULL, penalty_value INT DEFAULT 0 NOT NULL, deposit_refundable TINYINT DEFAULT 0 NOT NULL, credit_refundable TINYINT DEFAULT 1 NOT NULL, no_show_threshold SMALLINT DEFAULT 3 NOT NULL, risk_tag_uuid VARCHAR(36) 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, service_item_id INT DEFAULT NULL, UNIQUE INDEX UNIQ_52BE2A1D17F50A6 (uuid), INDEX IDX_52BE2A1DDEB00C2 (service_item_id), INDEX idx_cancel_policies_tenant (entity_type, entity_id, active), UNIQUE INDEX uniq_cancel_policy_scope (entity_type, entity_id, service_item_id), PRIMARY KEY (id)) DEFAULT CHARACTER SET utf8mb4');
$this->addSql('CREATE TABLE no_show_records (id INT AUTO_INCREMENT NOT NULL, uuid VARCHAR(36) NOT NULL, recorded_at INT NOT NULL, entity_type VARCHAR(10) NOT NULL, entity_id INT NOT NULL, patient_record_id INT NOT NULL, appointment_id INT NOT NULL, recorded_by INT DEFAULT NULL, UNIQUE INDEX UNIQ_C373420D17F50A6 (uuid), INDEX IDX_C373420EB76A733 (patient_record_id), INDEX IDX_C37342082D4278B (recorded_by), INDEX idx_no_show_patient (patient_record_id, recorded_at), INDEX idx_no_show_tenant (entity_type, entity_id, recorded_at), UNIQUE INDEX uniq_no_show_appointment (appointment_id), PRIMARY KEY (id)) DEFAULT CHARACTER SET utf8mb4');
$this->addSql('CREATE TABLE waitlist_entries (id INT AUTO_INCREMENT NOT NULL, uuid VARCHAR(36) NOT NULL, branch_id INT DEFAULT NULL, desired_from INT NOT NULL, desired_to INT NOT NULL, preferred_day_parts JSON DEFAULT NULL, priority SMALLINT DEFAULT 0 NOT NULL, status VARCHAR(12) DEFAULT \'waiting\' NOT NULL, notified_at INT DEFAULT NULL, notify_count 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, patient_record_id INT NOT NULL, service_item_id INT NOT NULL, converted_appointment_id INT DEFAULT NULL, UNIQUE INDEX UNIQ_E74550EED17F50A6 (uuid), INDEX IDX_E74550EEEB76A733 (patient_record_id), INDEX IDX_E74550EEDDEB00C2 (service_item_id), INDEX IDX_E74550EE4B79C8F8 (converted_appointment_id), INDEX idx_waitlist_match (service_item_id, branch_id, status, desired_from, desired_to), INDEX idx_waitlist_tenant (entity_type, entity_id, status, created_at), INDEX idx_waitlist_patient (patient_record_id, status), PRIMARY KEY (id)) DEFAULT CHARACTER SET utf8mb4');
$this->addSql('ALTER TABLE cancellation_policies ADD CONSTRAINT FK_52BE2A1DDEB00C2 FOREIGN KEY (service_item_id) REFERENCES service_items (id) ON DELETE CASCADE');
$this->addSql('ALTER TABLE no_show_records ADD CONSTRAINT FK_C373420EB76A733 FOREIGN KEY (patient_record_id) REFERENCES patient_records (id) ON DELETE CASCADE');
$this->addSql('ALTER TABLE no_show_records ADD CONSTRAINT FK_C373420E5B533F9 FOREIGN KEY (appointment_id) REFERENCES appointments (id) ON DELETE CASCADE');
$this->addSql('ALTER TABLE no_show_records ADD CONSTRAINT FK_C37342082D4278B FOREIGN KEY (recorded_by) REFERENCES users (id) ON DELETE SET NULL');
$this->addSql('ALTER TABLE waitlist_entries ADD CONSTRAINT FK_E74550EEEB76A733 FOREIGN KEY (patient_record_id) REFERENCES patient_records (id) ON DELETE CASCADE');
$this->addSql('ALTER TABLE waitlist_entries ADD CONSTRAINT FK_E74550EEDDEB00C2 FOREIGN KEY (service_item_id) REFERENCES service_items (id) ON DELETE CASCADE');
$this->addSql('ALTER TABLE waitlist_entries ADD CONSTRAINT FK_E74550EE4B79C8F8 FOREIGN KEY (converted_appointment_id) REFERENCES appointments (id) ON DELETE SET NULL');
}
public function down(Schema $schema): void
{
// this down() migration is auto-generated, please modify it to your needs
$this->addSql('ALTER TABLE cancellation_policies DROP FOREIGN KEY FK_52BE2A1DDEB00C2');
$this->addSql('ALTER TABLE no_show_records DROP FOREIGN KEY FK_C373420EB76A733');
$this->addSql('ALTER TABLE no_show_records DROP FOREIGN KEY FK_C373420E5B533F9');
$this->addSql('ALTER TABLE no_show_records DROP FOREIGN KEY FK_C37342082D4278B');
$this->addSql('ALTER TABLE waitlist_entries DROP FOREIGN KEY FK_E74550EEEB76A733');
$this->addSql('ALTER TABLE waitlist_entries DROP FOREIGN KEY FK_E74550EEDDEB00C2');
$this->addSql('ALTER TABLE waitlist_entries DROP FOREIGN KEY FK_E74550EE4B79C8F8');
$this->addSql('DROP TABLE cancellation_policies');
$this->addSql('DROP TABLE no_show_records');
$this->addSql('DROP TABLE waitlist_entries');
}
}