Section 12 and the fifth closing rule: changing a price never changes an already-booked appointment. The pricing chain already existed and worked. Two things were missing. Tariff only carries a year, so a rate change starting in Mehr could not be expressed — PriceList now takes an explicit date range and Tariff remains the layer beneath it. And an appointment stored a single number, so after a price change or a discount nobody could say what those 2,400,000 rials were made of. Price resolution walks four layers per service and takes the first hit: branch override, then the covering price list, then the yearly tariff, then the service's own price. The last one is the guarantee that a date no list covers still returns a price rather than zero or an exception. breakdown.sources reports which layer answered, so a surprising number can be traced instead of guessed at. Two calculation decisions worth stating. Tax is computed on the patient's share, not the gross — a patient does not pay tax on the portion the insurer covers. And a discount larger than the amount floors the total at zero rather than going negative, because a negative balance would mean the clinic owes the patient money, which nothing downstream is built to mean. A branch-specific list deliberately does not count as overlapping a general one; it takes precedence instead. Treating them as a conflict would have made per-branch exceptions impossible to express. Lists have no effect until activated, so drafting next quarter's prices cannot disturb today's. PriceSnapshot has no setters and a unique key on appointment_id: a snapshot that can be edited is not a snapshot, and two invoices for one appointment would be two truths. Corrections are a new row plus voiding the old one. Invoices are written during confirm with the prices of that moment — computing later would let a rate change between booking and invoicing produce a different number, which is exactly what rule five forbids. 12 tests. The one that matters is testBookedAppointmentKeepsItsOriginalInvoiceAfterAPriceChange: book, double the service price, watch quote return the new number while the appointment's invoice returns the old one. Without it rule five is only a claim. 1220 tests / 3551 assertions. phpstan back at its 14-error baseline. Frozen slot contract green. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
47 lines
3.6 KiB
PHP
47 lines
3.6 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace DoctrineMigrations;
|
|
|
|
use Doctrine\DBAL\Schema\Schema;
|
|
use Doctrine\Migrations\AbstractMigration;
|
|
|
|
/**
|
|
* Date-ranged price lists and the itemised snapshot taken when an appointment is
|
|
* booked — section 12 and the fifth closing rule: changing a price never changes an
|
|
* already-booked appointment.
|
|
*
|
|
* The existing Tariff table only carries a year, so a mid-year rate change cannot be
|
|
* expressed. It stays as a fallback layer beneath the new lists.
|
|
*/
|
|
final class Version20260731060549 extends AbstractMigration
|
|
{
|
|
public function getDescription(): string
|
|
{
|
|
return 'Add date-ranged price lists and appointment price snapshots';
|
|
}
|
|
|
|
public function up(Schema $schema): void
|
|
{
|
|
$this->addSql('CREATE TABLE price_list_items (id INT AUTO_INCREMENT NOT NULL, price_rials BIGINT NOT NULL, price_list_id INT NOT NULL, service_item_id INT NOT NULL, INDEX IDX_8C05724A5688DED7 (price_list_id), INDEX IDX_8C05724ADDEB00C2 (service_item_id), UNIQUE INDEX uniq_price_list_service (price_list_id, service_item_id), PRIMARY KEY (id)) DEFAULT CHARACTER SET utf8mb4');
|
|
$this->addSql('CREATE TABLE price_lists (id INT AUTO_INCREMENT NOT NULL, uuid VARCHAR(36) NOT NULL, name VARCHAR(150) NOT NULL, starts_at INT NOT NULL, ends_at INT NOT NULL, active TINYINT 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, address_id INT DEFAULT NULL, UNIQUE INDEX UNIQ_23EF97C5D17F50A6 (uuid), INDEX IDX_23EF97C5F5B7AF75 (address_id), INDEX idx_price_list_tenant (entity_type, entity_id, active), INDEX idx_price_list_range (starts_at, ends_at), PRIMARY KEY (id)) DEFAULT CHARACTER SET utf8mb4');
|
|
$this->addSql('CREATE TABLE price_snapshots (id INT AUTO_INCREMENT NOT NULL, uuid VARCHAR(36) NOT NULL, base_rials BIGINT NOT NULL, items_rials BIGINT NOT NULL, discount_rials BIGINT NOT NULL, insurance_base_rials BIGINT NOT NULL, insurance_supplementary_rials BIGINT NOT NULL, tax_rials BIGINT NOT NULL, final_rials BIGINT NOT NULL, deposit_rials BIGINT NOT NULL, breakdown JSON DEFAULT NULL, computed_at INT NOT NULL, entity_type VARCHAR(10) NOT NULL, entity_id INT NOT NULL, appointment_id INT NOT NULL, UNIQUE INDEX UNIQ_CE2075C1D17F50A6 (uuid), INDEX idx_snapshot_tenant (entity_type, entity_id), UNIQUE INDEX uniq_snapshot_appointment (appointment_id), PRIMARY KEY (id)) DEFAULT CHARACTER SET utf8mb4');
|
|
$this->addSql('ALTER TABLE price_list_items ADD CONSTRAINT FK_8C05724A5688DED7 FOREIGN KEY (price_list_id) REFERENCES price_lists (id) ON DELETE CASCADE');
|
|
$this->addSql('ALTER TABLE price_list_items ADD CONSTRAINT FK_8C05724ADDEB00C2 FOREIGN KEY (service_item_id) REFERENCES service_items (id) ON DELETE CASCADE');
|
|
$this->addSql('ALTER TABLE price_lists ADD CONSTRAINT FK_23EF97C5F5B7AF75 FOREIGN KEY (address_id) REFERENCES doctor_addresses (id) ON DELETE CASCADE');
|
|
$this->addSql('ALTER TABLE price_snapshots ADD CONSTRAINT FK_CE2075C1E5B533F9 FOREIGN KEY (appointment_id) REFERENCES appointments (id) ON DELETE CASCADE');
|
|
}
|
|
|
|
public function down(Schema $schema): void
|
|
{
|
|
$this->addSql('ALTER TABLE price_list_items DROP FOREIGN KEY FK_8C05724A5688DED7');
|
|
$this->addSql('ALTER TABLE price_list_items DROP FOREIGN KEY FK_8C05724ADDEB00C2');
|
|
$this->addSql('ALTER TABLE price_lists DROP FOREIGN KEY FK_23EF97C5F5B7AF75');
|
|
$this->addSql('ALTER TABLE price_snapshots DROP FOREIGN KEY FK_CE2075C1E5B533F9');
|
|
$this->addSql('DROP TABLE price_list_items');
|
|
$this->addSql('DROP TABLE price_lists');
|
|
$this->addSql('DROP TABLE price_snapshots');
|
|
}
|
|
}
|