feat(pricing): date-ranged price lists and immutable appointment invoices

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>
This commit is contained in:
hamed
2026-07-31 09:42:21 +03:30
co-authored by Claude Opus 5
parent cd12fabe14
commit 34b07421bd
17 changed files with 1765 additions and 66 deletions
+125
View File
@@ -0,0 +1,125 @@
<?php
namespace App\Pricing\Entity;
use App\Doctor\Entity\DoctorAddress;
use App\Pricing\Repository\PriceListRepository;
use App\Shared\Tenant\TenantOwnedTrait;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Uid\Uuid;
/**
* لیست قیمت با **بازهٔ تاریخ** — بند ۱۲ مستند.
*
* `Tariff` موجود فقط «سال» دارد، پس تغییر تعرفه از اول مهر قابل بیان نیست. این جدول
* بازهٔ دقیق می‌گیرد و `Tariff` به‌عنوان لایهٔ پشتیبان سرِ جایش می‌ماند.
*
* `address` تهی‌پذیر است: `null` یعنی «همهٔ شعبه‌های این محیط». قیمت اختصاصی یک شعبه
* از {@see \App\ClinicService\Entity\ServiceBranchOverride} می‌آید که بر این مقدم است.
*/
#[ORM\Entity(repositoryClass: PriceListRepository::class)]
#[ORM\Table(name: 'price_lists')]
#[ORM\Index(columns: ['entity_type', 'entity_id', 'active'], name: 'idx_price_list_tenant')]
#[ORM\Index(columns: ['starts_at', 'ends_at'], name: 'idx_price_list_range')]
class PriceList
{
use TenantOwnedTrait;
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column(type: 'integer')]
private ?int $id = null;
#[ORM\Column(type: 'string', length: 36, unique: true)]
private string $uuid;
#[ORM\ManyToOne(targetEntity: DoctorAddress::class)]
#[ORM\JoinColumn(name: 'address_id', referencedColumnName: 'id', nullable: true, onDelete: 'CASCADE')]
private ?DoctorAddress $address = null;
#[ORM\Column(type: 'string', length: 150)]
private string $name;
#[ORM\Column(name: 'starts_at', type: 'integer')]
private int $startsAt;
#[ORM\Column(name: 'ends_at', type: 'integer')]
private int $endsAt;
/** تا فعال نشده هیچ اثری ندارد؛ ساختنِ پیش‌نویس نباید قیمت امروز را عوض کند. */
#[ORM\Column(type: 'boolean', options: ['default' => false])]
private bool $active = false;
#[ORM\Column(name: 'created_at', type: 'integer')]
private int $createdAt;
#[ORM\Column(name: 'updated_at', type: 'integer')]
private int $updatedAt;
/** @var Collection<int, PriceListItem> */
#[ORM\OneToMany(targetEntity: PriceListItem::class, mappedBy: 'priceList', cascade: ['persist', 'remove'], orphanRemoval: true)]
private Collection $items;
public function __construct(string $entityType, int $entityId, string $name, int $startsAt, int $endsAt)
{
if ($endsAt <= $startsAt) {
throw new \InvalidArgumentException('Price list end must be after its start.');
}
$this->uuid = Uuid::v4()->toRfc4122();
$this->name = $name;
$this->startsAt = $startsAt;
$this->endsAt = $endsAt;
$this->createdAt = time();
$this->updatedAt = time();
$this->items = new ArrayCollection();
$this->assignTenantPair($entityType, $entityId);
}
public function getId(): ?int { return $this->id; }
public function getUuid(): string { return $this->uuid; }
public function getAddress(): ?DoctorAddress { return $this->address; }
public function getName(): string { return $this->name; }
public function getStartsAt(): int { return $this->startsAt; }
public function getEndsAt(): int { return $this->endsAt; }
public function isActive(): bool { return $this->active; }
/** @return Collection<int, PriceListItem> */
public function getItems(): Collection { return $this->items; }
public function setAddress(?DoctorAddress $v): self { $this->address = $v; $this->touch(); return $this; }
public function setName(string $v): self { $this->name = $v; $this->touch(); return $this; }
public function setActive(bool $v): self { $this->active = $v; $this->touch(); return $this; }
public function covers(int $at): bool
{
return $this->active && $at >= $this->startsAt && $at < $this->endsAt;
}
public function overlaps(int $startsAt, int $endsAt): bool
{
return $startsAt < $this->endsAt && $endsAt > $this->startsAt;
}
public function touch(): void { $this->updatedAt = time(); }
public function toArray(): array
{
return [
'uuid' => $this->uuid,
'name' => $this->name,
'address_uuid' => $this->address?->getUuid(),
'address_name' => $this->address?->getName(),
'starts_at' => $this->startsAt,
'ends_at' => $this->endsAt,
'active' => $this->active,
'items' => array_map(
static fn (PriceListItem $i): array => $i->toArray(),
$this->items->toArray(),
),
];
}
}
+58
View File
@@ -0,0 +1,58 @@
<?php
namespace App\Pricing\Entity;
use App\ClinicService\Entity\ServiceItem;
use App\Pricing\Repository\PriceListItemRepository;
use Doctrine\ORM\Mapping as ORM;
/**
* قیمت یک سرویس در یک لیست قیمت. فرزند aggregate با ریشهٔ {@see PriceList} که خودش
* جفت محیط دارد؛ uuid از request نمی‌گیرد.
*/
#[ORM\Entity(repositoryClass: PriceListItemRepository::class)]
#[ORM\Table(name: 'price_list_items')]
#[ORM\UniqueConstraint(name: 'uniq_price_list_service', columns: ['price_list_id', 'service_item_id'])]
class PriceListItem
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column(type: 'integer')]
private ?int $id = null;
#[ORM\ManyToOne(targetEntity: PriceList::class, inversedBy: 'items')]
#[ORM\JoinColumn(name: 'price_list_id', referencedColumnName: 'id', nullable: false, onDelete: 'CASCADE')]
private PriceList $priceList;
#[ORM\ManyToOne(targetEntity: ServiceItem::class)]
#[ORM\JoinColumn(name: 'service_item_id', referencedColumnName: 'id', nullable: false, onDelete: 'CASCADE')]
private ServiceItem $serviceItem;
#[ORM\Column(name: 'price_rials', type: 'bigint')]
private int $priceRials;
public function __construct(PriceList $priceList, ServiceItem $serviceItem, int $priceRials)
{
if ($priceRials < 0) {
throw new \InvalidArgumentException('Price cannot be negative.');
}
$this->priceList = $priceList;
$this->serviceItem = $serviceItem;
$this->priceRials = $priceRials;
}
public function getId(): ?int { return $this->id; }
public function getPriceList(): PriceList { return $this->priceList; }
public function getServiceItem(): ServiceItem { return $this->serviceItem; }
public function getPriceRials(): int { return (int) $this->priceRials; }
public function toArray(): array
{
return [
'service_uuid' => $this->serviceItem->getUuid(),
'service_name' => $this->serviceItem->getName(),
'price_rials' => $this->getPriceRials(),
];
}
}
+129
View File
@@ -0,0 +1,129 @@
<?php
namespace App\Pricing\Entity;
use App\Appointment\Entity\Appointment;
use App\Pricing\Repository\PriceSnapshotRepository;
use App\Shared\Tenant\TenantOwnedTrait;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Uid\Uuid;
/**
* فاکتور تفکیک‌شدهٔ **لحظهٔ ثبت** نوبت.
*
* قانون پنجم مستند: «تغییر قیمت هرگز نوبت‌های ثبت‌شده را عوض نمی‌کند.» امروز روی نوبت
* فقط یک عدد (`visit_price_rials`) هست، پس بعد از تغییر تعرفه یا تخفیف نمی‌شود گفت آن
* ۲٬۴۰۰٬۰۰۰ ریال از چه تشکیل شده بود.
*
* هیچ ستونی از این جدول بعد از ساخت تغییر نمی‌کند و عمداً هیچ setter ای ندارد:
* snapshot ای که ویرایش شود دیگر snapshot نیست. اصلاح قیمت با ردیف تازه و ابطال
* قبلی انجام می‌شود، نه با بازنویسی.
*/
#[ORM\Entity(repositoryClass: PriceSnapshotRepository::class)]
#[ORM\Table(name: 'price_snapshots')]
#[ORM\UniqueConstraint(name: 'uniq_snapshot_appointment', columns: ['appointment_id'])]
#[ORM\Index(columns: ['entity_type', 'entity_id'], name: 'idx_snapshot_tenant')]
class PriceSnapshot
{
use TenantOwnedTrait;
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column(type: 'integer')]
private ?int $id = null;
#[ORM\Column(type: 'string', length: 36, unique: true)]
private string $uuid;
#[ORM\ManyToOne(targetEntity: Appointment::class)]
#[ORM\JoinColumn(name: 'appointment_id', referencedColumnName: 'id', nullable: false, onDelete: 'CASCADE')]
private Appointment $appointment;
#[ORM\Column(name: 'base_rials', type: 'bigint')]
private int $baseRials;
#[ORM\Column(name: 'items_rials', type: 'bigint')]
private int $itemsRials;
#[ORM\Column(name: 'discount_rials', type: 'bigint')]
private int $discountRials;
#[ORM\Column(name: 'insurance_base_rials', type: 'bigint')]
private int $insuranceBaseRials;
#[ORM\Column(name: 'insurance_supplementary_rials', type: 'bigint')]
private int $insuranceSupplementaryRials;
#[ORM\Column(name: 'tax_rials', type: 'bigint')]
private int $taxRials;
#[ORM\Column(name: 'final_rials', type: 'bigint')]
private int $finalRials;
#[ORM\Column(name: 'deposit_rials', type: 'bigint')]
private int $depositRials;
/** ریز تخفیف‌ها و مأخذشان — «۲۰٪ تخفیف» بدون نام، سه ماه بعد قابل توضیح نیست. */
#[ORM\Column(type: 'json', nullable: true)]
private ?array $breakdown = null;
#[ORM\Column(name: 'computed_at', type: 'integer')]
private int $computedAt;
/** @param array<string, mixed> $breakdown */
public function __construct(
Appointment $appointment,
string $entityType,
int $entityId,
int $baseRials,
int $itemsRials,
int $discountRials,
int $insuranceBaseRials,
int $insuranceSupplementaryRials,
int $taxRials,
int $finalRials,
int $depositRials,
array $breakdown = [],
?int $computedAt = null,
) {
$this->uuid = Uuid::v4()->toRfc4122();
$this->appointment = $appointment;
$this->baseRials = $baseRials;
$this->itemsRials = $itemsRials;
$this->discountRials = $discountRials;
$this->insuranceBaseRials = $insuranceBaseRials;
$this->insuranceSupplementaryRials = $insuranceSupplementaryRials;
$this->taxRials = $taxRials;
$this->finalRials = $finalRials;
$this->depositRials = $depositRials;
$this->breakdown = $breakdown === [] ? null : $breakdown;
$this->computedAt = $computedAt ?? time();
$this->assignTenantPair($entityType, $entityId);
}
public function getId(): ?int { return $this->id; }
public function getUuid(): string { return $this->uuid; }
public function getAppointment(): Appointment { return $this->appointment; }
public function getFinalRials(): int { return (int) $this->finalRials; }
public function getDepositRials(): int { return (int) $this->depositRials; }
public function getComputedAt(): int { return $this->computedAt; }
public function toArray(): array
{
return [
'uuid' => $this->uuid,
'appointment_uuid' => $this->appointment->getUuid(),
'base_rials' => (int) $this->baseRials,
'items_rials' => (int) $this->itemsRials,
'discount_rials' => (int) $this->discountRials,
'insurance_base_rials' => (int) $this->insuranceBaseRials,
'insurance_supplementary_rials' => (int) $this->insuranceSupplementaryRials,
'tax_rials' => (int) $this->taxRials,
'final_rials' => (int) $this->finalRials,
'deposit_rials' => (int) $this->depositRials,
'breakdown' => $this->breakdown ?? [],
'computed_at' => $this->computedAt,
];
}
}