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
@@ -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); }
}
}