feat(discount): apply a discount rule to a session with audit

Extend PatientService::applyDiscount to carry the source rule id/label and
add applyDiscountRule, which computes the rial amount via the engine
(DiscountEngine::computeForRule now public) and records the rule for audit.
updateSession accepts discount_rule_uuid (owner-scoped, takes precedence over
the manual discount; ''/null clears). Verified end-to-end.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
hamed
2026-07-17 11:55:16 +03:30
co-authored by Claude Fable 5
parent 6cc42c941e
commit b3b1dad832
4 changed files with 62 additions and 15 deletions
+2 -1
View File
@@ -485,7 +485,8 @@ Updates mutable fields on a session.
```
- `payment_method: "wallet"` روی مراجعه‌ی تسویه‌نشده، کل مبلغ نهایی را از کیف پول بیمار کسر می‌کند (موجودی ناکافی → `422 ERR_WALLET_INSUFFICIENT`).
- **تخفیف تسویه:** `discount_type` = `percent` (۰..۱۰۰) یا `fixed` (ریال، حداکثر برابر مبلغ نهایی) یا `null` (حذف تخفیف). مبلغ محاسبه‌شده در `discount_rials` برمی‌گردد. تخفیف نمی‌تواند از «مبلغ نهایی منهای پرداخت‌های ثبت‌شده» بیشتر شود. تخفیفی که مانده را صفر کند مراجعه را تسویه‌شده می‌کند (`is_paid`, `paid_at`).
- **تخفیف تسویه (دستی):** `discount_type` = `percent` (۰..۱۰۰) یا `fixed` (ریال، حداکثر برابر مبلغ نهایی) یا `null` (حذف تخفیف). مبلغ محاسبه‌شده در `discount_rials` برمی‌گردد. تخفیف نمی‌تواند از «مبلغ نهایی منهای پرداخت‌های ثبت‌شده» بیشتر شود. تخفیفی که مانده را صفر کند مراجعه را تسویه‌شده می‌کند (`is_paid`, `paid_at`).
- **تخفیف بر اساس قانون:** `discount_rule_uuid` (رشته) → قانون تخفیف (owner-scoped) اعمال می‌شود؛ مقدار ریالی از خود قانون توسط موتور محاسبه می‌گردد (نوع/مبنا بر اساس قانون). `''`/`null` → حذف تخفیف. اولویت بر `discount_type` دستی. قانونِ نامعتبر → `404`. منبع اعمال‌شده در پاسخ به‌صورت `applied_discount_rule_id` و `applied_discount_rule_label` (audit) برمی‌گردد. قوانین قابل‌اعمال از `GET /api/v1/session/{uuid}/discount-suggestions` (نگاه کنید به `discount.md`).
- `paid_at`: unix timestamp زمان تسویه.
**Response 200:**
+19 -8
View File
@@ -36,14 +36,7 @@ class DiscountEngine
$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);
$rials = $this->computeForRule($session, $rule, $now, $remaining);
if ($rials <= 0) {
continue;
}
@@ -61,6 +54,24 @@ class DiscountEngine
return $out;
}
/**
* مبلغ تخفیف ریالیِ یک قانون برای یک پرونده؛ 0 اگر برقرار نباشد.
* $remaining را ندهی، از خود session محاسبه می‌شود.
*/
public function computeForRule(PatientSession $session, DiscountRule $rule, ?int $now = null, ?int $remaining = null): int
{
$now ??= time();
$remaining ??= max(0, $session->getFinalPriceRials() - $session->getPaidTotalRials());
if (!$this->withinValidity($rule, $now)) {
return 0;
}
$base = $this->applicableBase($rule, $session, $session->getFinalPriceRials(), $now);
if ($base === null) {
return 0;
}
return min($this->computeRials($rule, $base), $remaining);
}
private function withinValidity(DiscountRule $rule, int $now): bool
{
if ($rule->getValidFrom() !== null && $now < $rule->getValidFrom()) {
+16 -2
View File
@@ -60,6 +60,7 @@ class PatientController extends BaseController
private readonly \App\Settlement\Repository\WalletTransactionRepository $walletRepo,
private readonly \App\Settlement\Repository\SettlementRepository $settlementRepo,
private readonly \App\Settlement\Service\WalletService $walletService,
private readonly \App\Discount\Repository\DiscountRuleRepository $discountRuleRepo,
private readonly LoggerInterface $logger,
) {}
@@ -1046,8 +1047,21 @@ class PatientController extends BaseController
if (isset($data['notes'])) { $session->setNotes($data['notes']); }
// تخفیف تسویه: discount_type = percent|fixed|null (null = حذف تخفیف)
if (array_key_exists('discount_type', $data)) {
// تخفیف بر اساس قانون (discount_rule_uuid): مقدار از خود قانون، با ثبت منبع.
// '' یا null → حذف تخفیف. اولویت بر تخفیف دستی.
if (array_key_exists('discount_rule_uuid', $data)) {
$ruleUuid = $data['discount_rule_uuid'];
if ($ruleUuid === null || $ruleUuid === '') {
$this->patientService->applyDiscount($session, null, 0);
} else {
$rule = $this->discountRuleRepo->findByUuidForOwner((string) $ruleUuid, $entityType, $entityId);
if ($rule === null) {
return $this->error(ErrorCodes::ERR_VALIDATION_002, 'قانون تخفیف یافت نشد', 404, 'discount_rule_uuid');
}
$this->patientService->applyDiscountRule($session, $rule);
}
} elseif (array_key_exists('discount_type', $data)) {
// تخفیف دستی: discount_type = percent|fixed|null (null = حذف تخفیف)
$type = $data['discount_type'] !== null ? (string) $data['discount_type'] : null;
$this->patientService->applyDiscount($session, $type, (int) ($data['discount_value'] ?? 0));
}
+25 -4
View File
@@ -50,6 +50,7 @@ class PatientService
private readonly BillingCalculator $billingCalculator,
private readonly WalletService $walletService,
private readonly EntityInsurancePricingRepository $pricingRepo,
private readonly \App\Discount\Service\DiscountEngine $discountEngine,
) {}
/**
@@ -268,7 +269,7 @@ class PatientService
* type=null → حذف تخفیف. percent باید 0..100 و fixed حداکثر برابر مبلغ نهایی باشد.
* تخفیف نمی‌تواند از مانده‌ی قابل‌تخفیف (مبلغ نهایی منهای پرداخت‌های ثبت‌شده) بیشتر شود.
*/
public function applyDiscount(PatientSession $session, ?string $type, int $value): PatientSession
public function applyDiscount(PatientSession $session, ?string $type, int $value, ?int $ruleId = null, ?string $ruleLabel = null): PatientSession
{
if ($type === null) {
$session->setDiscount(null, 0, 0);
@@ -298,15 +299,35 @@ class PatientService
throw new AppException(ErrorCodes::ERR_SESSION_DISCOUNT_INVALID, null, 422, 'discount_value');
}
$session->setDiscount($type, $value, $rials);
$this->persistDiscount($session, $type, $value, $rials, $ruleId, $ruleLabel);
return $session;
}
/**
* اعمال یک قانون تخفیف روی مراجعه؛ مبلغ ریالی از موتور (با در نظر گرفتن نوع/مبنا)
* محاسبه و منبع قانون برای audit ثبت می‌شود.
*/
public function applyDiscountRule(PatientSession $session, \App\Discount\Entity\DiscountRule $rule): PatientSession
{
$rials = $this->discountEngine->computeForRule($session, $rule);
if ($rials <= 0) {
throw new AppException(ErrorCodes::ERR_SESSION_DISCOUNT_INVALID, null, 422, 'discount_rule_uuid');
}
$this->persistDiscount($session, $rule->getDiscountType(), $rule->getValue(), $rials, $rule->getId(), $rule->getName());
return $session;
}
private function persistDiscount(PatientSession $session, string $type, int $value, int $rials, ?int $ruleId, ?string $ruleLabel): void
{
$session->setDiscount($type, $value, $rials, $ruleId, $ruleLabel);
if ($session->getRemainingRials() === 0 && $session->getPaymentMethod() === 'pending') {
// تخفیف صددرصدی بدهی را صفر کرد — مراجعه تسویه‌شده تلقی می‌شود
$session->setPaymentMethod('cash');
$session->setPaidAt(time());
}
$this->sessionRepo->save($session);
return $session;
}
/**