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:
+2
-1
@@ -485,7 +485,8 @@ Updates mutable fields on a session.
|
|||||||
```
|
```
|
||||||
|
|
||||||
- `payment_method: "wallet"` روی مراجعهی تسویهنشده، کل مبلغ نهایی را از کیف پول بیمار کسر میکند (موجودی ناکافی → `422 ERR_WALLET_INSUFFICIENT`).
|
- `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 زمان تسویه.
|
- `paid_at`: unix timestamp زمان تسویه.
|
||||||
|
|
||||||
**Response 200:**
|
**Response 200:**
|
||||||
|
|||||||
@@ -36,14 +36,7 @@ class DiscountEngine
|
|||||||
|
|
||||||
$out = [];
|
$out = [];
|
||||||
foreach ($rules as $rule) {
|
foreach ($rules as $rule) {
|
||||||
if (!$this->withinValidity($rule, $now)) {
|
$rials = $this->computeForRule($session, $rule, $now, $remaining);
|
||||||
continue;
|
|
||||||
}
|
|
||||||
$base = $this->applicableBase($rule, $session, $final, $now);
|
|
||||||
if ($base === null) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
$rials = min($this->computeRials($rule, $base), $remaining);
|
|
||||||
if ($rials <= 0) {
|
if ($rials <= 0) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -61,6 +54,24 @@ class DiscountEngine
|
|||||||
return $out;
|
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
|
private function withinValidity(DiscountRule $rule, int $now): bool
|
||||||
{
|
{
|
||||||
if ($rule->getValidFrom() !== null && $now < $rule->getValidFrom()) {
|
if ($rule->getValidFrom() !== null && $now < $rule->getValidFrom()) {
|
||||||
|
|||||||
@@ -60,6 +60,7 @@ class PatientController extends BaseController
|
|||||||
private readonly \App\Settlement\Repository\WalletTransactionRepository $walletRepo,
|
private readonly \App\Settlement\Repository\WalletTransactionRepository $walletRepo,
|
||||||
private readonly \App\Settlement\Repository\SettlementRepository $settlementRepo,
|
private readonly \App\Settlement\Repository\SettlementRepository $settlementRepo,
|
||||||
private readonly \App\Settlement\Service\WalletService $walletService,
|
private readonly \App\Settlement\Service\WalletService $walletService,
|
||||||
|
private readonly \App\Discount\Repository\DiscountRuleRepository $discountRuleRepo,
|
||||||
private readonly LoggerInterface $logger,
|
private readonly LoggerInterface $logger,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
@@ -1046,8 +1047,21 @@ class PatientController extends BaseController
|
|||||||
|
|
||||||
if (isset($data['notes'])) { $session->setNotes($data['notes']); }
|
if (isset($data['notes'])) { $session->setNotes($data['notes']); }
|
||||||
|
|
||||||
// تخفیف تسویه: discount_type = percent|fixed|null (null = حذف تخفیف)
|
// تخفیف بر اساس قانون (discount_rule_uuid): مقدار از خود قانون، با ثبت منبع.
|
||||||
if (array_key_exists('discount_type', $data)) {
|
// '' یا 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;
|
$type = $data['discount_type'] !== null ? (string) $data['discount_type'] : null;
|
||||||
$this->patientService->applyDiscount($session, $type, (int) ($data['discount_value'] ?? 0));
|
$this->patientService->applyDiscount($session, $type, (int) ($data['discount_value'] ?? 0));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -50,6 +50,7 @@ class PatientService
|
|||||||
private readonly BillingCalculator $billingCalculator,
|
private readonly BillingCalculator $billingCalculator,
|
||||||
private readonly WalletService $walletService,
|
private readonly WalletService $walletService,
|
||||||
private readonly EntityInsurancePricingRepository $pricingRepo,
|
private readonly EntityInsurancePricingRepository $pricingRepo,
|
||||||
|
private readonly \App\Discount\Service\DiscountEngine $discountEngine,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -268,7 +269,7 @@ class PatientService
|
|||||||
* type=null → حذف تخفیف. percent باید 0..100 و fixed حداکثر برابر مبلغ نهایی باشد.
|
* 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) {
|
if ($type === null) {
|
||||||
$session->setDiscount(null, 0, 0);
|
$session->setDiscount(null, 0, 0);
|
||||||
@@ -298,15 +299,35 @@ class PatientService
|
|||||||
throw new AppException(ErrorCodes::ERR_SESSION_DISCOUNT_INVALID, null, 422, 'discount_value');
|
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') {
|
if ($session->getRemainingRials() === 0 && $session->getPaymentMethod() === 'pending') {
|
||||||
// تخفیف صددرصدی بدهی را صفر کرد — مراجعه تسویهشده تلقی میشود
|
// تخفیف صددرصدی بدهی را صفر کرد — مراجعه تسویهشده تلقی میشود
|
||||||
$session->setPaymentMethod('cash');
|
$session->setPaymentMethod('cash');
|
||||||
$session->setPaidAt(time());
|
$session->setPaidAt(time());
|
||||||
}
|
}
|
||||||
$this->sessionRepo->save($session);
|
$this->sessionRepo->save($session);
|
||||||
|
|
||||||
return $session;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
Reference in New Issue
Block a user