Files
clinicpro/src/Pricing/Service/PricingEngine.php
T
hamedandClaude Opus 5 ca9648732d feat(package): session packages backed by a credit ledger
"Six laser sessions" is the common case in an aesthetics clinic: the patient
pays once and books the sessions later.

Credit is a ledger, not a counter. No table has a remaining/used_count column
and a schema test enforces that — the balance is always SUM(delta) over
append-only rows, so every number a patient sees has a full history behind it.
Corrections are new rows, never edits.

- purchase / consume / refund / adjustment / expiry, each with a reason, an
  author and the appointment it belongs to
- consume happens in confirm(), never in quote(): if the preview consumed, a
  page refresh would cost the patient a session
- cancelling adds a refund row; the consume row stays
- FIFO across a patient's packages — the oldest is closest to expiring
- an empty package is not an error, it just does not apply and the patient pays
- adjust/expire need a doctor or clinic role, and adjust always needs a reason
- app:package:expire writes the closing row so "where did my 3 sessions go?"
  always has an answer

Consume takes a pessimistic lock on the one package row. That is the opposite
of task 07's slot buckets, and docs/api/package.md carries the table explaining
why, so nobody unifies them later.

Idempotency checks for an existing consume row before inserting rather than
catching the unique violation: in Doctrine that exception closes the
EntityManager and burns the rest of the request. The unique key stays as the
last line of defence.

Admin: PackagesPage, a packages tab on the patient record, and a ledger page
whose running-balance column shows where the final number came from.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 11:11:03 +03:30

304 lines
12 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?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\Package\Service\PackageConsumptionService;
use App\Patient\Entity\PatientRecord;
use App\Pricing\Repository\PriceListItemRepository;
use App\Pricing\Repository\PriceListRepository;
use App\Pricing\ValueObject\PriceQuote;
use App\Policy\Entity\Policy;
use App\Policy\Service\PolicyResolver;
use App\Policy\Service\PolicySchema;
use App\Representation\Service\JalaliDateService;
/**
* زنجیرهٔ قیمت‌گذاری بند ۱۲ مستند.
*
* ```
* قیمت پایه → + آیتم‌ها → − تخفیف → − بیمهٔ پایه → − تکمیلی → + مالیات → بیعانه
* ```
*
* ## زنجیرهٔ منبع قیمت
*
* برای هر سرویس، اولین چیزی که پیدا شود برنده است:
*
* ۱. override شعبه ({@see \App\ClinicService\Entity\ServiceBranchOverride}) — تسک ۰۴
* ۲. لیست قیمتِ حاکم بر آن تاریخ — همین تسک
* ۳. `Tariff` سال — لایهٔ موجود
* ۴. `ServiceItem::priceRials` — همیشه هست
*
* مرحلهٔ چهارم ضامن است که **هرگز صفر یا خطا** برنگردد: تاریخی که هیچ لیستی نمی‌پوشاند
* باید قیمت بدهد، نه استثنا.
*/
final class PricingEngine
{
public function __construct(
private readonly PolicyResolver $policies,
private readonly PackageConsumptionService $packages,
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 = [],
?PatientRecord $patient = null,
): 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;
// ── پکیج ──────────────────────────────────────────────────────────────
// پکیج **قیمت پایهٔ سرویس** را می‌پوشاند، نه آیتم‌های اضافه: «شش جلسه لیزر»
// یعنی شش بار خودِ لیزر، نه هر چیزی که کنارش انتخاب شود.
$usable = $patient === null ? null : $this->packages->firstUsable($patient, $service, $at);
$covered = 0;
if ($usable !== null) {
$covered = min($base, $subtotal);
$subtotal -= $covered;
}
// ── تخفیف ─────────────────────────────────────────────────────────────
// قوانین دستهٔ «قیمت» کنار سیاست دستیِ درخواست می‌نشینند، نه به‌جایش: تخفیفی
// که اپراتور دستی می‌دهد و تخفیفی که قانون می‌دهد هر دو واقعی‌اند.
$policy = $this->mergePolicyDiscounts($service, $items, $address, $at, $subtotal, $policy, $sources);
[$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));
if ($covered > 0) {
$discounts[] = [
'label' => sprintf('پوشش پکیج «%s»', $usable?->getPackage()->getName() ?? '—'),
'rials' => $covered,
'kind' => 'package',
];
}
return new PriceQuote(
baseRials: $base,
itemsRials: $itemsTotal,
discountRials: $discount,
insuranceBaseRials: $insuranceBase,
insuranceSupplementaryRials: $supplementary,
taxRials: $tax,
finalRials: $final,
depositRials: $deposit,
discounts: $discounts,
sources: $sources,
packageWillBeConsumed: $usable !== null,
packageUuid: $usable?->getUuid(),
);
}
/**
* اثر قوانین «قیمت» را به سیاست درخواست اضافه می‌کند.
*
* شناسه و **نسخهٔ** هر قانون در `sources` ثبت می‌شود تا فاکتور بتواند سه ماه بعد
* بگوید کدام نسخه رویش اعمال شده بود.
*
* @param ServiceItem[] $items
* @param array<string, mixed> $policy
* @param array<string, mixed> $sources
* @return array<string, mixed>
*/
private function mergePolicyDiscounts(
ServiceItem $service,
array $items,
DoctorAddress $address,
int $at,
int $subtotal,
array $policy,
array &$sources,
): array {
$outcome = $this->policies->resolve(
Policy::CATEGORY_PRICING,
$address->tenantEntityType(),
$address->tenantEntityId(),
[
'item_count' => count($items),
'subtotal_rials' => $subtotal,
'patient_tags' => $policy['patient_tags'] ?? [],
'visit_count' => $policy['visit_count'] ?? 0,
],
$address,
$service,
$at,
);
if ($outcome->appliedPolicies === []) {
return $policy;
}
$sources['applied_policies'] = $outcome->appliedPolicies;
$policy['discount_percent'] = (float) ($policy['discount_percent'] ?? 0)
+ (float) $outcome->effect(PolicySchema::EFFECT_DISCOUNT_PERCENT, 0);
$policy['discount_rials'] = (int) ($policy['discount_rials'] ?? 0)
+ (int) $outcome->effect(PolicySchema::EFFECT_DISCOUNT_RIALS, 0);
$policy['discount_label'] ??= $outcome->appliedPolicies[0]['name'];
return $policy;
}
/**
* @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);
}
}