feat(discount): add DiscountEngine to evaluate rules for a session

Evaluates all active owner rules against a session across the six types
(patient tag, invoice amount, specific patient, occasion incl. birthday,
service, visit count), honoring validity windows and capping each computed
discount at the session's unpaid remainder. Returns priority-ordered
suggestions with computed rials.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
hamed
2026-07-17 11:49:08 +03:30
co-authored by Claude Fable 5
parent f0e1f43d51
commit cf69369231
+150
View File
@@ -0,0 +1,150 @@
<?php
namespace App\Discount\Service;
use App\Discount\Entity\DiscountRule;
use App\Discount\Repository\DiscountRuleRepository;
use App\Patient\Entity\PatientSession;
use App\Patient\Entity\SessionService;
use App\Patient\Repository\PatientSessionRepository;
use App\UserProfile\Repository\UserProfileRepository;
/**
* موتور تخفیف: برای یک پرونده، قوانین قابل‌اعمالِ owner را ارزیابی و مبلغ تخفیف
* هرکدام را محاسبه می‌کند. اعمال نهایی در PatientService::applyDiscount انجام می‌شود.
*/
class DiscountEngine
{
public function __construct(
private readonly DiscountRuleRepository $ruleRepo,
private readonly PatientSessionRepository $sessionRepo,
private readonly UserProfileRepository $profileRepo,
) {}
/**
* @return array<int, array{rule_uuid: string, rule_name: string, type: string, discount_type: string, value: int, discount_rials: int, combinable: bool, priority: int}>
* مرتب بر priority نزولی (از repository). فقط قوانینِ برقرار با discount_rials > 0.
*/
public function evaluate(PatientSession $session): array
{
$record = $session->getRecord();
$rules = $this->ruleRepo->findActiveForOwner($record->getEntityType(), $record->getEntityId());
$final = $session->getFinalPriceRials();
$remaining = max(0, $final - $session->getPaidTotalRials());
$now = time();
$out = [];
foreach ($rules as $rule) {
if (!$this->withinValidity($rule, $now)) {
continue;
}
$base = $this->applicableBase($rule, $session, $final, $now);
if ($base === null) {
continue;
}
$rials = min($this->computeRials($rule, $base), $remaining);
if ($rials <= 0) {
continue;
}
$out[] = [
'rule_uuid' => $rule->getUuid(),
'rule_name' => $rule->getName(),
'type' => $rule->getType(),
'discount_type' => $rule->getDiscountType(),
'value' => $rule->getValue(),
'discount_rials' => $rials,
'combinable' => $rule->isCombinable(),
'priority' => $rule->getPriority(),
];
}
return $out;
}
private function withinValidity(DiscountRule $rule, int $now): bool
{
if ($rule->getValidFrom() !== null && $now < $rule->getValidFrom()) {
return false;
}
if ($rule->getValidTo() !== null && $now > $rule->getValidTo()) {
return false;
}
return true;
}
/**
* مبنای محاسبه‌ی تخفیف اگر قانون برقرار باشد؛ null یعنی قانون اعمال نمی‌شود.
*/
private function applicableBase(DiscountRule $rule, PatientSession $session, int $final, int $now): ?int
{
$record = $session->getRecord();
return match ($rule->getType()) {
DiscountRule::TYPE_PATIENT_TAG => $this->hasTag($session, $rule->getTargetTagId()) ? $final : null,
DiscountRule::TYPE_INVOICE_AMOUNT => ($rule->getMinAmountRials() !== null && $final >= $rule->getMinAmountRials()) ? $final : null,
DiscountRule::TYPE_SPECIFIC_PATIENT => ($rule->getTargetRecordId() !== null && $record->getId() === $rule->getTargetRecordId()) ? $final : null,
DiscountRule::TYPE_OCCASION => $this->occasionMatches($rule, $session, $now) ? $final : null,
DiscountRule::TYPE_SERVICE => $this->serviceBase($session, $rule->getTargetServiceItemId()),
DiscountRule::TYPE_VISIT_COUNT => ($rule->getMinVisitCount() !== null
&& $this->sessionRepo->countByRecord($record) >= $rule->getMinVisitCount()) ? $final : null,
default => null,
};
}
private function hasTag(PatientSession $session, ?int $tagId): bool
{
if ($tagId === null) {
return false;
}
foreach ($session->getRecord()->getTags() as $tag) {
if ($tag->getId() === $tagId) {
return true;
}
}
return false;
}
/** مبنای تخفیف سرویس = جمع خطوطِ همان سرویس؛ null اگر سرویس در پرونده نباشد. */
private function serviceBase(PatientSession $session, ?int $serviceItemId): ?int
{
if ($serviceItemId === null) {
return null;
}
$sum = 0;
foreach ($session->getServices() as $line) {
/** @var SessionService $line */
if ($line->getServiceItem()->getId() === $serviceItemId) {
$sum += $line->getLineTotalRials();
}
}
return $sum > 0 ? $sum : null;
}
private function occasionMatches(DiscountRule $rule, PatientSession $session, int $now): bool
{
if ($rule->getOccasionKind() !== DiscountRule::OCCASION_BIRTHDAY) {
// مناسبت مبتنی بر بازه‌ی تاریخی؛ withinValidity قبلاً چک شده.
return true;
}
$profile = $this->profileRepo->findByUser($session->getRecord()->getUser());
$dob = $profile?->getDateOfBirth();
if ($dob === null) {
return false;
}
return date('m-d', $dob) === date('m-d', $now);
}
private function computeRials(DiscountRule $rule, int $base): int
{
if ($rule->getDiscountType() === DiscountRule::DISCOUNT_PERCENT) {
return (int) round($base * min(100, $rule->getValue()) / 100);
}
return min($rule->getValue(), $base);
}
}