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>
166 lines
6.3 KiB
PHP
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);
|
|
}
|
|
}
|