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:
@@ -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(),
|
||||
),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
];
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user