"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>
304 lines
12 KiB
PHP
304 lines
12 KiB
PHP
<?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);
|
||
}
|
||
}
|