Files
clinicpro/src/Pricing/Service/PricingEngine.php
T
hamedandClaude Opus 5 4fe0c4f9bf refactor(pricing): make the service the only price source
Price lists, annual tariffs and per-branch price overrides each answered
"what does this service cost?" differently, so a single date could carry
several answers and nobody could say which one was right. Price now lives
only on ServiceItem.price_rials, edited from the services page.

- drop PriceList/PriceListItem, their repositories and the seven
  /api/v1/price-list(s) endpoints; PricingController keeps only quote and
  the appointment price snapshot
- drop Tariff, TariffRepository, TariffService and the two
  /service-items/{uuid}/tariffs endpoints; creating or repricing a service
  no longer upserts a current-year tariff
- drop price_rials from ServiceBranchOverride; the entity stays for its
  duration columns, which DurationCalculator and ServiceSelectionValidator
  still read
- InvoiceService reads the item price directly
- PricingEngine collapses to a single source; breakdown.sources always
  reports service_item, keeping the response contract intact
- remove the price-lists admin page, its route and settings-menu entry, the
  tariff modal and the service detail tariffs tab; useAppointmentInvoice
  moves to its own hook file

Migration drops price_lists, price_list_items, service_tariffs and the
override price column.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-02 18:00:48 +03:30

166 lines
6.3 KiB
PHP

<?php
namespace App\Pricing\Service;
use App\ClinicService\Entity\ServiceItem;
use App\Doctor\Entity\DoctorAddress;
use App\Pricing\ValueObject\PriceQuote;
/**
* زنجیرهٔ قیمت‌گذاری بند ۱۲ مستند.
*
* ```
* قیمت پایه → + آیتم‌ها → − تخفیف → − بیمهٔ پایه → − تکمیلی → + مالیات → بیعانه
* ```
*
* ## منبع قیمت
*
* تنها منبع قیمت، `ServiceItem::priceRials` است — قیمت روی خودِ سرویس مدیریت می‌شود.
* لایه‌های پیشینِ «لیست قیمت»، «تعرفهٔ سالانه» و «قیمت اختصاصی شعبه» حذف شده‌اند تا یک
* تاریخ هرگز دو قیمت نداشته باشد.
*/
final class PricingEngine
{
/**
* @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 {
$sources = [];
$base = $this->priceFor($service, $sources);
$itemsTotal = 0;
foreach ($items as $item) {
$itemsTotal += $this->priceFor($item, $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, array &$sources): int
{
$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);
}
}