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
+218
View File
@@ -0,0 +1,218 @@
<?php
namespace App\Pricing\Service;
use App\ClinicService\Entity\ServiceItem;
use App\ClinicService\Repository\ServiceBranchOverrideRepository;
use App\ClinicService\Repository\TariffRepository;
use App\Doctor\Entity\DoctorAddress;
use App\Pricing\Repository\PriceListItemRepository;
use App\Pricing\Repository\PriceListRepository;
use App\Pricing\ValueObject\PriceQuote;
use App\Representation\Service\JalaliDateService;
/**
* زنجیرهٔ قیمت‌گذاری بند ۱۲ مستند.
*
* ```
* قیمت پایه → + آیتم‌ها → − تخفیف → − بیمهٔ پایه → − تکمیلی → + مالیات → بیعانه
* ```
*
* ## زنجیرهٔ منبع قیمت
*
* برای هر سرویس، اولین چیزی که پیدا شود برنده است:
*
* ۱. override شعبه ({@see \App\ClinicService\Entity\ServiceBranchOverride}) — تسک ۰۴
* ۲. لیست قیمتِ حاکم بر آن تاریخ — همین تسک
* ۳. `Tariff` سال — لایهٔ موجود
* ۴. `ServiceItem::priceRials` — همیشه هست
*
* مرحلهٔ چهارم ضامن است که **هرگز صفر یا خطا** برنگردد: تاریخی که هیچ لیستی نمی‌پوشاند
* باید قیمت بدهد، نه استثنا.
*/
final class PricingEngine
{
public function __construct(
private readonly PriceListRepository $priceLists,
private readonly PriceListItemRepository $priceListItems,
private readonly ServiceBranchOverrideRepository $overrides,
private readonly TariffRepository $tariffs,
private readonly JalaliDateService $jalali,
) {}
/**
* @param ServiceItem[] $items آیتم‌های انتخاب‌شده (بدون خودِ سرویس)
* @param array{
* discount_percent?: float, discount_rials?: int, discount_label?: string,
* max_total_discount_percent?: float,
* insurance_base_percent?: float, insurance_supplementary_percent?: float,
* tax_percent?: float, deposit_percent?: float, deposit_rials?: int
* } $policy
*/
public function quote(
ServiceItem $service,
array $items,
DoctorAddress $address,
int $at,
array $policy = [],
): PriceQuote {
$entityType = $address->tenantEntityType();
$entityId = $address->tenantEntityId();
$list = $this->priceLists->findCovering($entityType, $entityId, $address, $at);
$sources = [];
$base = $this->priceFor($service, $address, $list, $at, $sources);
$itemsTotal = 0;
foreach ($items as $item) {
$itemsTotal += $this->priceFor($item, $address, $list, $at, $sources);
}
$subtotal = $base + $itemsTotal;
// ── تخفیف ─────────────────────────────────────────────────────────────
[$discount, $discounts] = $this->discountFor($subtotal, $policy);
// تخفیف بیشتر از مبلغ، مبلغ را **صفر** می‌کند نه منفی: بدهی منفی یعنی کلینیک
// به بیمار پول بدهکار شود، که هیچ‌جای این جریان معنا ندارد.
$discount = min($discount, $subtotal);
$afterDiscount = $subtotal - $discount;
// ── بیمه ──────────────────────────────────────────────────────────────
$insuranceBase = $this->percentOf($afterDiscount, $policy['insurance_base_percent'] ?? 0.0);
$insuranceBase = min($insuranceBase, $afterDiscount);
$remaining = $afterDiscount - $insuranceBase;
$supplementary = min($this->percentOf($remaining, $policy['insurance_supplementary_percent'] ?? 0.0), $remaining);
$patientShare = $remaining - $supplementary;
// ── مالیات ────────────────────────────────────────────────────────────
// روی سهم بیمار حساب می‌شود، نه روی کل: بیمار مالیاتِ سهمی که بیمه می‌دهد را
// نمی‌پردازد.
$tax = $this->percentOf($patientShare, $policy['tax_percent'] ?? 0.0);
$final = $patientShare + $tax;
// ── بیعانه ────────────────────────────────────────────────────────────
$deposit = isset($policy['deposit_rials'])
? (int) $policy['deposit_rials']
: $this->percentOf($final, $policy['deposit_percent'] ?? 0.0);
$deposit = max(0, min($deposit, $final));
return new PriceQuote(
baseRials: $base,
itemsRials: $itemsTotal,
discountRials: $discount,
insuranceBaseRials: $insuranceBase,
insuranceSupplementaryRials: $supplementary,
taxRials: $tax,
finalRials: $final,
depositRials: $deposit,
discounts: $discounts,
sources: $sources,
);
}
/**
* @param array<string, string> $sources
*/
private function priceFor(
ServiceItem $service,
DoctorAddress $address,
?\App\Pricing\Entity\PriceList $list,
int $at,
array &$sources,
): int {
$override = $this->overrides->mapForAddress([(int) $service->getId()], $address)[(int) $service->getId()] ?? null;
if ($override?->getPriceRials() !== null) {
$sources[$service->getUuid()] = 'branch_override';
return $override->getPriceRials();
}
if ($list !== null) {
$price = $this->priceListItems->priceMap($list, [$service])[(int) $service->getId()] ?? null;
if ($price !== null) {
$sources[$service->getUuid()] = 'price_list';
return $price;
}
}
$tariff = $this->tariffs->findForServiceYear((int) $service->getId(), $this->jalali->jalaliYear($at));
if ($tariff !== null) {
$sources[$service->getUuid()] = 'tariff';
return (int) $tariff->getPriceRials();
}
$sources[$service->getUuid()] = 'service_item';
return $service->getPriceRials();
}
/**
* @param array<string, mixed> $policy
* @return array{0: int, 1: list<array<string, mixed>>}
*/
private function discountFor(int $subtotal, array $policy): array
{
$discounts = [];
$total = 0;
if (($policy['discount_percent'] ?? 0.0) > 0) {
$amount = $this->percentOf($subtotal, (float) $policy['discount_percent']);
$total += $amount;
$discounts[] = [
'label' => $policy['discount_label'] ?? 'تخفیف درصدی',
'percent' => $policy['discount_percent'],
'rials' => $amount,
];
}
if (($policy['discount_rials'] ?? 0) > 0) {
$amount = (int) $policy['discount_rials'];
$total += $amount;
$discounts[] = [
'label' => $policy['discount_label'] ?? 'تخفیف مبلغی',
'rials' => $amount,
];
}
// سقف جمع تخفیف‌ها per محیط: چند تخفیفِ جداگانه که هرکدام منطقی‌اند، با هم
// می‌توانند مبلغ را بی‌معنا کنند.
$cap = $policy['max_total_discount_percent'] ?? null;
if ($cap !== null && $cap >= 0) {
$maxAllowed = $this->percentOf($subtotal, (float) $cap);
if ($total > $maxAllowed) {
$discounts[] = [
'label' => sprintf('سقف تخفیف %s٪ اعمال شد', $cap),
'rials' => $maxAllowed - $total,
];
$total = $maxAllowed;
}
}
return [$total, $discounts];
}
/** ریال واحد صحیح است؛ گرد کردن به پایین از اضافه‌گرفتن جلوگیری می‌کند. */
private function percentOf(int $amount, float $percent): int
{
if ($percent <= 0) {
return 0;
}
return (int) floor($amount * $percent / 100);
}
}