feat(discount): admin CRUD + session discount-suggestions endpoints

DiscountController exposes owner-scoped CRUD for discount rules
(/api/v1/admin/discount-rules) and GET /api/v1/session/{uuid}/discount-
suggestions which runs the engine for a session. Owner resolved from the
doctor/clinic user. Verified end-to-end (create, list, suggestions). Adds
docs/api/discount.md.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
hamed
2026-07-17 11:52:03 +03:30
co-authored by Claude Fable 5
parent cf69369231
commit 6cc42c941e
2 changed files with 267 additions and 0 deletions
+96
View File
@@ -0,0 +1,96 @@
# Discount API
Generic per-tenant discount rules and their evaluation for a patient session.
Owner is resolved from the authenticated user (`ROLE_DOCTOR` → doctor, `ROLE_CLINIC` → clinic). All responses use the shared envelope; single/object payloads are double-nested (`data.data`).
## Rule types (`type`)
| type | target field(s) | meaning |
|------|-----------------|---------|
| `patient_tag` | `target_tag_id` (TenantTag id) | patient carries the tag |
| `invoice_amount` | `min_amount_rials` | session `final_price_rials` ≥ threshold |
| `specific_patient` | `target_record_id` (PatientRecord id) | a specific patient's record |
| `occasion` | `valid_from`/`valid_to`, optional `occasion_kind: birthday` | date window; `birthday` also requires today == patient birthday (month/day) |
| `service` | `target_service_item_id` (ServiceItem id) | session contains that service (discount base = that service's line total) |
| `visit_count` | `min_visit_count` | patient's session count ≥ threshold |
Shared fields: `discount_type` (`percent`|`fixed`), `value` (percent 0..100 or rials), `priority` (int, higher first), `combinable` (bool), `active` (bool), `valid_from`/`valid_to` (unix, nullable).
---
## GET `/api/v1/admin/discount-rules`
List the owner's rules (array hydration, priority desc). **Auth:** doctor/clinic.
### Response `200`
```json
{ "success": true, "data": { "data": [ { "uuid": "…", "name": "بالای ۱۰۰ هزار → ۱۰٪", "type": "invoice_amount", "discount_type": "percent", "value": 10, "priority": 5, "combinable": false, "active": true, "min_amount_rials": 1000000 } ] } }
```
## POST `/api/v1/admin/discount-rules`
Create a rule. **Auth:** doctor/clinic.
### Request Body
| Field | Type | Required | Notes |
|-------|------|----------|-------|
| `name` | string | ✅ | |
| `type` | string | ✅ | one of the rule types above |
| `discount_type` | string | ❌ | `percent` (default) or `fixed` |
| `value` | int | ❌ | percent or rials |
| `priority` | int | ❌ | default 0 |
| `combinable` | bool | ❌ | default false |
| `active` | bool | ❌ | default true |
| `valid_from` / `valid_to` | int (unix) | ❌ | validity window |
| `target_tag_id` / `target_record_id` / `target_service_item_id` | int | ❌ | per-type target |
| `min_amount_rials` / `min_visit_count` | int | ❌ | per-type threshold |
| `occasion_kind` | string | ❌ | `birthday` or null |
### Response `201`
Created rule object (double-nested `data.data`).
### Errors
| Code | HTTP | Description |
|------|------|-------------|
| `ERR_VALIDATION_001` | 422 | missing name / invalid type |
| `ERR_AUTH_006` | 403 | user is not a doctor/clinic owner |
## PATCH `/api/v1/admin/discount-rules/{uuid}`
Update a rule (owner-scoped). Same body fields (all optional). **Auth:** doctor/clinic.
`404 ERR_VALIDATION_002` if the rule is not found for this owner.
## DELETE `/api/v1/admin/discount-rules/{uuid}`
Delete a rule (owner-scoped). **Auth:** doctor/clinic. Response `200``{ data: { data: { deleted: true } } }`.
---
## GET `/api/v1/session/{uuid}/discount-suggestions`
Evaluate all active owner rules against a session and return applicable discounts. **Auth:** doctor/clinic; the session's record must belong to the caller's owner.
### Response `200`
```json
{
"success": true,
"data": { "data": [
{
"rule_uuid": "740bcfc4-…",
"rule_name": "بالای ۱۰۰ هزار → ۱۰٪",
"type": "invoice_amount",
"discount_type": "percent",
"value": 10,
"discount_rials": 700000,
"combinable": false,
"priority": 5
}
] }
}
```
Ordered by priority desc. Each `discount_rials` is capped at the session's unpaid remainder. Rules that don't apply (or compute to 0) are omitted.
### Errors
| Code | HTTP | Description |
|------|------|-------------|
| `ERR_VALIDATION_002` | 404 | session not found |
| `ERR_AUTH_006` | 403 | not the owner / not a doctor-clinic user |
---
> Applying a suggested discount goes through `PATCH /api/v1/session/{uuid}` with `discount_rule_uuid` (see `patient.md`); the applied rule is recorded on the session as `applied_discount_rule_id` / `applied_discount_rule_label` for audit and reporting.
@@ -0,0 +1,171 @@
<?php
namespace App\Discount\Controller;
use App\Auth\Entity\User;
use App\Clinic\Repository\ClinicRepository;
use App\Discount\Entity\DiscountRule;
use App\Discount\Repository\DiscountRuleRepository;
use App\Discount\Service\DiscountEngine;
use App\Doctor\Repository\DoctorRepository;
use App\Patient\Repository\PatientSessionRepository;
use App\Shared\Constant\ErrorCodes;
use App\Shared\Controller\BaseController;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\CurrentUser;
use Symfony\Component\Security\Http\Attribute\IsGranted;
class DiscountController extends BaseController
{
public function __construct(
private readonly DiscountRuleRepository $ruleRepo,
private readonly DiscountEngine $engine,
private readonly DoctorRepository $doctorRepo,
private readonly ClinicRepository $clinicRepo,
private readonly PatientSessionRepository $sessionRepo,
) {}
/** @return array{0: string, 1: ?int} */
private function resolveOwner(User $user): array
{
if ($user->hasRole('ROLE_DOCTOR')) {
return ['doctor', $this->doctorRepo->findByUser($user)?->getId()];
}
if ($user->hasRole('ROLE_CLINIC')) {
return ['clinic', $this->clinicRepo->findByUser($user)?->getId()];
}
return ['unknown', null];
}
// ── Admin CRUD ────────────────────────────────────────────────────────────
#[Route('/api/v1/admin/discount-rules', methods: ['GET'])]
#[IsGranted('IS_AUTHENTICATED_FULLY')]
public function list(#[CurrentUser] User $user): JsonResponse
{
[$ownerType, $ownerId] = $this->resolveOwner($user);
if ($ownerId === null) {
return $this->error(ErrorCodes::ERR_AUTH_006, 'دسترسی ممنوع', 403);
}
return $this->success(['data' => $this->ruleRepo->listForOwner($ownerType, $ownerId)]);
}
#[Route('/api/v1/admin/discount-rules', methods: ['POST'])]
#[IsGranted('IS_AUTHENTICATED_FULLY')]
public function create(Request $request, #[CurrentUser] User $user): JsonResponse
{
[$ownerType, $ownerId] = $this->resolveOwner($user);
if ($ownerId === null) {
return $this->error(ErrorCodes::ERR_AUTH_006, 'دسترسی ممنوع', 403);
}
$data = json_decode($request->getContent(), true) ?? [];
$name = trim((string) ($data['name'] ?? ''));
$type = (string) ($data['type'] ?? '');
if ($name === '') {
return $this->error(ErrorCodes::ERR_VALIDATION_001, 'نام قانون الزامی است', 422, 'name');
}
if (!in_array($type, DiscountRule::TYPES, true)) {
return $this->error(ErrorCodes::ERR_VALIDATION_001, 'نوع قانون نامعتبر است', 422, 'type');
}
$rule = new DiscountRule($ownerType, $ownerId, $name, $type);
$this->applyPayload($rule, $data);
$this->ruleRepo->save($rule);
return $this->success(['data' => $rule->toArray()], 201);
}
#[Route('/api/v1/admin/discount-rules/{uuid}', methods: ['PATCH'])]
#[IsGranted('IS_AUTHENTICATED_FULLY')]
public function update(string $uuid, Request $request, #[CurrentUser] User $user): JsonResponse
{
[$ownerType, $ownerId] = $this->resolveOwner($user);
if ($ownerId === null) {
return $this->error(ErrorCodes::ERR_AUTH_006, 'دسترسی ممنوع', 403);
}
$rule = $this->ruleRepo->findByUuidForOwner($uuid, $ownerType, $ownerId);
if ($rule === null) {
return $this->error(ErrorCodes::ERR_VALIDATION_002, 'قانون یافت نشد', 404);
}
$data = json_decode($request->getContent(), true) ?? [];
if (array_key_exists('name', $data)) {
$name = trim((string) $data['name']);
if ($name === '') {
return $this->error(ErrorCodes::ERR_VALIDATION_001, 'نام قانون الزامی است', 422, 'name');
}
$rule->setName($name);
}
if (array_key_exists('type', $data)) {
if (!in_array((string) $data['type'], DiscountRule::TYPES, true)) {
return $this->error(ErrorCodes::ERR_VALIDATION_001, 'نوع قانون نامعتبر است', 422, 'type');
}
$rule->setType((string) $data['type']);
}
$this->applyPayload($rule, $data);
$this->ruleRepo->save($rule);
return $this->success(['data' => $rule->toArray()]);
}
#[Route('/api/v1/admin/discount-rules/{uuid}', methods: ['DELETE'])]
#[IsGranted('IS_AUTHENTICATED_FULLY')]
public function delete(string $uuid, #[CurrentUser] User $user): JsonResponse
{
[$ownerType, $ownerId] = $this->resolveOwner($user);
if ($ownerId === null) {
return $this->error(ErrorCodes::ERR_AUTH_006, 'دسترسی ممنوع', 403);
}
$rule = $this->ruleRepo->findByUuidForOwner($uuid, $ownerType, $ownerId);
if ($rule === null) {
return $this->error(ErrorCodes::ERR_VALIDATION_002, 'قانون یافت نشد', 404);
}
$this->ruleRepo->remove($rule);
return $this->success(['data' => ['deleted' => true]]);
}
// ── Suggestions for a session ─────────────────────────────────────────────
#[Route('/api/v1/session/{uuid}/discount-suggestions', methods: ['GET'])]
#[IsGranted('IS_AUTHENTICATED_FULLY')]
public function suggestions(string $uuid, #[CurrentUser] User $user): JsonResponse
{
[$ownerType, $ownerId] = $this->resolveOwner($user);
if ($ownerId === null) {
return $this->error(ErrorCodes::ERR_AUTH_006, 'دسترسی ممنوع', 403);
}
$session = $this->sessionRepo->findByUuid($uuid);
if ($session === null) {
return $this->error(ErrorCodes::ERR_VALIDATION_002, 'پرونده یافت نشد', 404);
}
$record = $session->getRecord();
if ($record->getEntityType() !== $ownerType || $record->getEntityId() !== $ownerId) {
return $this->error(ErrorCodes::ERR_AUTH_006, 'دسترسی ممنوع', 403);
}
return $this->success(['data' => $this->engine->evaluate($session)]);
}
private function applyPayload(DiscountRule $rule, array $data): void
{
if (array_key_exists('discount_type', $data)) {
$dt = (string) $data['discount_type'];
$rule->setDiscountType($dt === DiscountRule::DISCOUNT_FIXED ? DiscountRule::DISCOUNT_FIXED : DiscountRule::DISCOUNT_PERCENT);
}
if (array_key_exists('value', $data)) { $rule->setValue((int) $data['value']); }
if (array_key_exists('priority', $data)) { $rule->setPriority((int) $data['priority']); }
if (array_key_exists('combinable', $data)) { $rule->setCombinable((bool) $data['combinable']); }
if (array_key_exists('active', $data)) { $rule->setActive((bool) $data['active']); }
if (array_key_exists('valid_from', $data)) { $rule->setValidFrom($data['valid_from'] !== null ? (int) $data['valid_from'] : null); }
if (array_key_exists('valid_to', $data)) { $rule->setValidTo($data['valid_to'] !== null ? (int) $data['valid_to'] : null); }
if (array_key_exists('target_tag_id', $data)) { $rule->setTargetTagId($data['target_tag_id'] !== null ? (int) $data['target_tag_id'] : null); }
if (array_key_exists('target_record_id', $data)) { $rule->setTargetRecordId($data['target_record_id'] !== null ? (int) $data['target_record_id'] : null); }
if (array_key_exists('target_service_item_id', $data)) { $rule->setTargetServiceItemId($data['target_service_item_id'] !== null ? (int) $data['target_service_item_id'] : null); }
if (array_key_exists('min_amount_rials', $data)) { $rule->setMinAmountRials($data['min_amount_rials'] !== null ? (int) $data['min_amount_rials'] : null); }
if (array_key_exists('min_visit_count', $data)) { $rule->setMinVisitCount($data['min_visit_count'] !== null ? (int) $data['min_visit_count'] : null); }
if (array_key_exists('occasion_kind', $data)) { $rule->setOccasionKind($data['occasion_kind'] !== null ? (string) $data['occasion_kind'] : null); }
}
}