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:
@@ -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); }
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user