feat(rating): multi-dimensional ratings, rich comments, eligibility guard
Rebuild the doctor rating/review system to power the public site's rich
review UI, and restrict who may submit.
Ratings:
- Rate entity holds five 0–100 dimensions (waiting time, diagnosis
accuracy, behaviour, cleanliness, expertise) instead of a single score.
- GET /rate/{uuid} returns aggregate {point, satisfaction, averages[]}.
- POST /rate upserts all five dimensions and returns the new aggregate.
Comments:
- Comment gains parent/replies (threaded) and a rich toArray with author,
like_status (like/dislike counts + current user's vote) and nested
approved replies. POST /comment accepts {comment, parent}.
- Likes are directional (value 1=like, -1=dislike) with toggle/replace;
POST /like/{uuid} returns like_count/dislike_count/current_user_like.
Eligibility:
- Only a user with a confirmed appointment in the last 30 days may rate or
comment (AppointmentRepository::hasRecentConfirmed); otherwise
403 ERR_RATING_NOT_ELIGIBLE. New GET /rate/{uuid}/eligibility for the UI.
- security.yaml: narrow the public rate pattern so /eligibility stays auth'd.
Also updates admin rates listing to the new dimensions and the rating/admin
API docs. Includes migration for the new columns.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -1091,7 +1091,8 @@ class AdminApiController extends BaseController
|
||||
|
||||
$qb = $this->em->createQueryBuilder()
|
||||
->select(
|
||||
'r.uuid, r.score, r.createdAt',
|
||||
'r.uuid, r.createdAt',
|
||||
'r.waitingTimeAtClinic, r.accuracyOfDiagnosis, r.doctorBehavior, r.clinicCleanliness, r.doctorExpertise',
|
||||
'u.realName as patient_name, u.mobileNumber as patient_mobile',
|
||||
'd.name as doctor_name',
|
||||
)
|
||||
@@ -1110,14 +1111,20 @@ class AdminApiController extends BaseController
|
||||
$rows = $qb->setFirstResult(($page - 1) * $limit)->setMaxResults($limit)
|
||||
->getQuery()->getArrayResult();
|
||||
|
||||
$items = array_map(fn(array $r) => [
|
||||
'uuid' => $r['uuid'],
|
||||
'patient_name' => $r['patient_name'] ?? $r['patient_mobile'],
|
||||
'doctor_name' => $r['doctor_name'],
|
||||
'overall' => (int) $r['score'],
|
||||
'score' => (int) $r['score'],
|
||||
'created_at' => date('c', (int) $r['createdAt']),
|
||||
], $rows);
|
||||
$items = array_map(function (array $r) {
|
||||
$overall = (int) round((
|
||||
$r['waitingTimeAtClinic'] + $r['accuracyOfDiagnosis'] + $r['doctorBehavior']
|
||||
+ $r['clinicCleanliness'] + $r['doctorExpertise']
|
||||
) / 5);
|
||||
return [
|
||||
'uuid' => $r['uuid'],
|
||||
'patient_name' => $r['patient_name'] ?? $r['patient_mobile'],
|
||||
'doctor_name' => $r['doctor_name'],
|
||||
'overall' => $overall,
|
||||
'score' => (int) round($overall / 20),
|
||||
'created_at' => date('c', (int) $r['createdAt']),
|
||||
];
|
||||
}, $rows);
|
||||
|
||||
return $this->paginated($items, (int) $total, $page, $limit);
|
||||
}
|
||||
|
||||
@@ -80,6 +80,30 @@ class AppointmentRepository extends ServiceEntityRepository
|
||||
return $this->findBy($criteria, ['slotStart' => 'DESC']);
|
||||
}
|
||||
|
||||
/** Whether the user had a confirmed appointment with this doctor within the last $sinceDays days. */
|
||||
public function hasRecentConfirmed(User $user, Doctor $doctor, int $sinceDays = 30): bool
|
||||
{
|
||||
$now = time();
|
||||
$since = $now - $sinceDays * 86400;
|
||||
|
||||
$count = (int) $this->createQueryBuilder('a')
|
||||
->select('COUNT(a.id)')
|
||||
->where('a.user = :user')
|
||||
->andWhere('a.doctor = :doctor')
|
||||
->andWhere('a.status = :status')
|
||||
->andWhere('a.slotStart >= :since')
|
||||
->andWhere('a.slotStart <= :now')
|
||||
->setParameter('user', $user)
|
||||
->setParameter('doctor', $doctor)
|
||||
->setParameter('status', Appointment::STATUS_CONFIRMED)
|
||||
->setParameter('since', $since)
|
||||
->setParameter('now', $now)
|
||||
->getQuery()
|
||||
->getSingleScalarResult();
|
||||
|
||||
return $count > 0;
|
||||
}
|
||||
|
||||
/** @return Appointment[] pending bookings whose 15-minute payment window has lapsed */
|
||||
public function findPaymentExpired(int $now): array
|
||||
{
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
namespace App\Rating\Controller;
|
||||
|
||||
use App\Appointment\Repository\AppointmentRepository;
|
||||
use App\Auth\Entity\User;
|
||||
use App\Doctor\Repository\DoctorRepository;
|
||||
use App\Rating\Entity\Comment;
|
||||
@@ -27,6 +28,7 @@ class RatingController extends BaseController
|
||||
private readonly CommentRepository $commentRepo,
|
||||
private readonly LikeRepository $likeRepo,
|
||||
private readonly DoctorRepository $doctorRepo,
|
||||
private readonly AppointmentRepository $appointmentRepo,
|
||||
) {}
|
||||
|
||||
// ── Ratings ───────────────────────────────────────────────────────────────
|
||||
@@ -67,10 +69,14 @@ class RatingController extends BaseController
|
||||
{
|
||||
$data = json_decode($request->getContent(), true) ?? [];
|
||||
$doctorUuid = trim($data['doctor_uuid'] ?? '');
|
||||
$score = (int) ($data['score'] ?? 0);
|
||||
|
||||
if ($score < 1 || $score > 5) {
|
||||
return $this->error(ErrorCodes::ERR_VALIDATION_001, 'امتیاز باید بین ۱ تا ۵ باشد', 422);
|
||||
$dimensions = [];
|
||||
foreach (array_keys(Rate::DIMENSIONS) as $name) {
|
||||
$value = (int) ($data[$name] ?? -1);
|
||||
if ($value < 0 || $value > 100) {
|
||||
return $this->error(ErrorCodes::ERR_VALIDATION_001, 'هر امتیاز باید بین ۰ تا ۱۰۰ باشد', 422);
|
||||
}
|
||||
$dimensions[$name] = $value;
|
||||
}
|
||||
|
||||
$doctor = $this->doctorRepo->findByUuid($doctorUuid);
|
||||
@@ -78,17 +84,21 @@ class RatingController extends BaseController
|
||||
return $this->error(ErrorCodes::ERR_NOT_FOUND_001, 'دکتر یافت نشد', 404);
|
||||
}
|
||||
|
||||
$existing = $this->rateRepo->findByUserAndDoctor($user, $doctor);
|
||||
if ($existing !== null) {
|
||||
$existing->setScore($score);
|
||||
$this->rateRepo->save($existing);
|
||||
return $this->success(['data' => $existing->toArray()]);
|
||||
if (!$this->appointmentRepo->hasRecentConfirmed($user, $doctor)) {
|
||||
return $this->error(ErrorCodes::ERR_RATING_NOT_ELIGIBLE, ErrorCodes::message(ErrorCodes::ERR_RATING_NOT_ELIGIBLE), 403);
|
||||
}
|
||||
|
||||
$rate = new Rate($user, $doctor, $score);
|
||||
$existing = $this->rateRepo->findByUserAndDoctor($user, $doctor);
|
||||
if ($existing !== null) {
|
||||
$existing->setDimensions($dimensions);
|
||||
$this->rateRepo->save($existing);
|
||||
return $this->success(['data' => $this->rateRepo->getAggregate($doctor)]);
|
||||
}
|
||||
|
||||
$rate = new Rate($user, $doctor, $dimensions);
|
||||
$this->rateRepo->save($rate);
|
||||
|
||||
return $this->success(['data' => $rate->toArray()], 201);
|
||||
return $this->success(['data' => $this->rateRepo->getAggregate($doctor)], 201);
|
||||
}
|
||||
|
||||
#[OA\Get(
|
||||
@@ -125,7 +135,47 @@ class RatingController extends BaseController
|
||||
return $this->error(ErrorCodes::ERR_NOT_FOUND_001, 'دکتر یافت نشد', 404);
|
||||
}
|
||||
|
||||
return $this->success(['average' => $this->rateRepo->getAverageScore($doctor)]);
|
||||
return $this->success(['data' => $this->rateRepo->getAggregate($doctor)]);
|
||||
}
|
||||
|
||||
#[OA\Get(
|
||||
path: '/api/v1/rate/{doctorUuid}/eligibility',
|
||||
summary: 'Whether the current user may rate/comment on this doctor',
|
||||
security: [['bearerAuth' => []]],
|
||||
parameters: [
|
||||
new OA\Parameter(name: 'doctorUuid', in: 'path', required: true, schema: new OA\Schema(type: 'string')),
|
||||
],
|
||||
responses: [
|
||||
new OA\Response(
|
||||
response: 200,
|
||||
description: 'Eligibility status',
|
||||
content: new OA\JsonContent(
|
||||
properties: [
|
||||
new OA\Property(property: 'success', type: 'boolean', example: true),
|
||||
new OA\Property(
|
||||
property: 'data',
|
||||
properties: [
|
||||
new OA\Property(property: 'eligible', type: 'boolean'),
|
||||
],
|
||||
type: 'object'
|
||||
),
|
||||
]
|
||||
)
|
||||
),
|
||||
new OA\Response(response: 401, description: 'Unauthorized'),
|
||||
new OA\Response(response: 404, description: 'Doctor not found'),
|
||||
]
|
||||
)]
|
||||
#[Route('/api/v1/rate/{doctorUuid}/eligibility', methods: ['GET'])]
|
||||
#[IsGranted('IS_AUTHENTICATED_FULLY')]
|
||||
public function eligibility(string $doctorUuid, #[CurrentUser] User $user): JsonResponse
|
||||
{
|
||||
$doctor = $this->doctorRepo->findByUuid($doctorUuid);
|
||||
if ($doctor === null) {
|
||||
return $this->error(ErrorCodes::ERR_NOT_FOUND_001, 'دکتر یافت نشد', 404);
|
||||
}
|
||||
|
||||
return $this->success(['eligible' => $this->appointmentRepo->hasRecentConfirmed($user, $doctor)]);
|
||||
}
|
||||
|
||||
// ── Comments ──────────────────────────────────────────────────────────────
|
||||
@@ -166,7 +216,8 @@ class RatingController extends BaseController
|
||||
{
|
||||
$data = json_decode($request->getContent(), true) ?? [];
|
||||
$doctorUuid = trim($data['doctor_uuid'] ?? '');
|
||||
$body = trim($data['body'] ?? '');
|
||||
$body = trim($data['comment'] ?? '');
|
||||
$parentUuid = trim($data['parent'] ?? '');
|
||||
|
||||
if (empty($body)) {
|
||||
return $this->error(ErrorCodes::ERR_VALIDATION_002, 'متن نظر الزامی است', 422);
|
||||
@@ -177,10 +228,22 @@ class RatingController extends BaseController
|
||||
return $this->error(ErrorCodes::ERR_NOT_FOUND_001, 'دکتر یافت نشد', 404);
|
||||
}
|
||||
|
||||
$comment = new Comment($user, $doctor, $body);
|
||||
if (!$this->appointmentRepo->hasRecentConfirmed($user, $doctor)) {
|
||||
return $this->error(ErrorCodes::ERR_RATING_NOT_ELIGIBLE, ErrorCodes::message(ErrorCodes::ERR_RATING_NOT_ELIGIBLE), 403);
|
||||
}
|
||||
|
||||
$parent = null;
|
||||
if ($parentUuid !== '') {
|
||||
$parent = $this->commentRepo->findByUuid($parentUuid);
|
||||
if ($parent === null) {
|
||||
return $this->error(ErrorCodes::ERR_NOT_FOUND_001, 'نظر والد یافت نشد', 404);
|
||||
}
|
||||
}
|
||||
|
||||
$comment = new Comment($user, $doctor, $body, $parent);
|
||||
$this->commentRepo->save($comment);
|
||||
|
||||
return $this->success(['data' => $comment->toArray()], 201);
|
||||
return $this->success(['data' => $comment->toArray($user)], 201);
|
||||
}
|
||||
|
||||
#[OA\Get(
|
||||
@@ -213,7 +276,7 @@ class RatingController extends BaseController
|
||||
|
||||
$comments = array_map(
|
||||
fn(Comment $c) => $c->toArray(),
|
||||
$this->commentRepo->findApprovedByDoctor($doctor)
|
||||
$this->commentRepo->findApprovedRootsByDoctor($doctor)
|
||||
);
|
||||
|
||||
return $this->success(['data' => $comments]);
|
||||
@@ -412,21 +475,38 @@ class RatingController extends BaseController
|
||||
)]
|
||||
#[IsGranted('IS_AUTHENTICATED_FULLY')]
|
||||
#[Route('/api/v1/like/{commentUuid}', methods: ['POST'])]
|
||||
public function toggleLike(string $commentUuid, #[CurrentUser] User $user): JsonResponse
|
||||
public function toggleLike(string $commentUuid, Request $request, #[CurrentUser] User $user): JsonResponse
|
||||
{
|
||||
$comment = $this->commentRepo->findByUuid($commentUuid);
|
||||
if ($comment === null) {
|
||||
return $this->error(ErrorCodes::ERR_NOT_FOUND_001, 'نظر یافت نشد', 404);
|
||||
}
|
||||
|
||||
$data = json_decode($request->getContent(), true) ?? [];
|
||||
$value = ((int) ($data['value'] ?? 1)) >= 0 ? 1 : -1;
|
||||
|
||||
$existing = $this->likeRepo->findByUserAndComment($user, $comment);
|
||||
if ($existing !== null) {
|
||||
$this->likeRepo->remove($existing);
|
||||
return $this->success(['liked' => false, 'likes' => $comment->getLikes()->count() - 1]);
|
||||
if ($existing->getValue() === $value) {
|
||||
$this->likeRepo->remove($existing);
|
||||
} else {
|
||||
$existing->setValue($value);
|
||||
$this->likeRepo->save($existing);
|
||||
}
|
||||
} else {
|
||||
$this->likeRepo->save(new Like($user, $comment, $value));
|
||||
}
|
||||
|
||||
$like = new Like($user, $comment);
|
||||
$this->likeRepo->save($like);
|
||||
return $this->success(['liked' => true, 'likes' => $comment->getLikes()->count()], 201);
|
||||
$counts = $this->likeRepo->countByComment($comment);
|
||||
$current = $this->likeRepo->findByUserAndComment($user, $comment);
|
||||
|
||||
return $this->success([
|
||||
'like_count' => $counts['like_count'],
|
||||
'dislike_count' => $counts['dislike_count'],
|
||||
'current_user_like' => [
|
||||
'like' => $current !== null && $current->getValue() >= 0,
|
||||
'dislike' => $current !== null && $current->getValue() < 0,
|
||||
],
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -34,6 +34,13 @@ class Comment
|
||||
#[ORM\JoinColumn(name: 'doctor_id', referencedColumnName: 'id', nullable: false, onDelete: 'CASCADE')]
|
||||
private Doctor $doctor;
|
||||
|
||||
#[ORM\ManyToOne(targetEntity: Comment::class, inversedBy: 'replies')]
|
||||
#[ORM\JoinColumn(name: 'parent_id', referencedColumnName: 'id', nullable: true, onDelete: 'CASCADE')]
|
||||
private ?Comment $parent = null;
|
||||
|
||||
#[ORM\OneToMany(targetEntity: Comment::class, mappedBy: 'parent')]
|
||||
private Collection $replies;
|
||||
|
||||
#[ORM\Column(type: 'text')]
|
||||
private string $body;
|
||||
|
||||
@@ -49,13 +56,15 @@ class Comment
|
||||
#[ORM\Column(name: 'updated_at', type: 'integer')]
|
||||
private int $updatedAt;
|
||||
|
||||
public function __construct(User $user, Doctor $doctor, string $body)
|
||||
public function __construct(User $user, Doctor $doctor, string $body, ?Comment $parent = null)
|
||||
{
|
||||
$this->uuid = Uuid::v4()->toRfc4122();
|
||||
$this->user = $user;
|
||||
$this->doctor = $doctor;
|
||||
$this->body = $body;
|
||||
$this->parent = $parent;
|
||||
$this->likes = new ArrayCollection();
|
||||
$this->replies = new ArrayCollection();
|
||||
$this->createdAt = time();
|
||||
$this->updatedAt = time();
|
||||
}
|
||||
@@ -67,22 +76,58 @@ class Comment
|
||||
public function getBody(): string { return $this->body; }
|
||||
public function getStatus(): string { return $this->status; }
|
||||
public function getLikes(): Collection { return $this->likes; }
|
||||
public function getParent(): ?Comment { return $this->parent; }
|
||||
public function getReplies(): Collection { return $this->replies; }
|
||||
|
||||
public function setBody(string $v): self { $this->body = $v; $this->updatedAt = time(); return $this; }
|
||||
|
||||
public function approve(): self { $this->status = self::STATUS_APPROVED; $this->updatedAt = time(); return $this; }
|
||||
public function reject(): self { $this->status = self::STATUS_REJECTED; $this->updatedAt = time(); return $this; }
|
||||
|
||||
public function toArray(): array
|
||||
public function toArray(?User $currentUser = null): array
|
||||
{
|
||||
$likeCount = 0;
|
||||
$dislikeCount = 0;
|
||||
$userLike = false;
|
||||
$userDislike = false;
|
||||
|
||||
foreach ($this->likes as $like) {
|
||||
if ($like->getValue() >= 0) {
|
||||
$likeCount++;
|
||||
} else {
|
||||
$dislikeCount++;
|
||||
}
|
||||
if ($currentUser !== null && $like->getUser()->getId() === $currentUser->getId()) {
|
||||
$userLike = $like->getValue() >= 0;
|
||||
$userDislike = $like->getValue() < 0;
|
||||
}
|
||||
}
|
||||
|
||||
$replies = [];
|
||||
foreach ($this->replies as $reply) {
|
||||
if ($reply->getStatus() === self::STATUS_APPROVED) {
|
||||
$replies[] = $reply->toArray($currentUser);
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
'uuid' => $this->uuid,
|
||||
'doctor_uuid' => $this->doctor->getUuid(),
|
||||
'user_uuid' => $this->user->getUuid(),
|
||||
'body' => $this->body,
|
||||
'status' => $this->status,
|
||||
'likes' => $this->likes->count(),
|
||||
'created_at' => $this->createdAt,
|
||||
'uuid' => $this->uuid,
|
||||
'comment' => $this->body,
|
||||
'created' => $this->createdAt,
|
||||
'parent' => $this->parent?->getUuid(),
|
||||
'author' => [
|
||||
'real_name' => $this->user->getRealName() ?? 'کاربر نوبت۷۲۴',
|
||||
'picture' => [],
|
||||
],
|
||||
'like_status' => [
|
||||
'like_count' => $likeCount,
|
||||
'dislike_count' => $dislikeCount,
|
||||
'current_user_like' => [
|
||||
'like' => $userLike,
|
||||
'dislike' => $userDislike,
|
||||
],
|
||||
],
|
||||
'replies' => $replies,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,14 +27,19 @@ class Like
|
||||
#[ORM\JoinColumn(name: 'comment_id', referencedColumnName: 'id', nullable: false, onDelete: 'CASCADE')]
|
||||
private Comment $comment;
|
||||
|
||||
/** 1 = like, -1 = dislike */
|
||||
#[ORM\Column(type: 'smallint')]
|
||||
private int $value;
|
||||
|
||||
#[ORM\Column(name: 'created_at', type: 'integer')]
|
||||
private int $createdAt;
|
||||
|
||||
public function __construct(User $user, Comment $comment)
|
||||
public function __construct(User $user, Comment $comment, int $value)
|
||||
{
|
||||
$this->uuid = Uuid::v4()->toRfc4122();
|
||||
$this->user = $user;
|
||||
$this->comment = $comment;
|
||||
$this->value = $value >= 0 ? 1 : -1;
|
||||
$this->createdAt = time();
|
||||
}
|
||||
|
||||
@@ -42,6 +47,9 @@ class Like
|
||||
public function getUuid(): string { return $this->uuid; }
|
||||
public function getUser(): User { return $this->user; }
|
||||
public function getComment(): Comment { return $this->comment; }
|
||||
public function getValue(): int { return $this->value; }
|
||||
|
||||
public function setValue(int $v): self { $this->value = $v >= 0 ? 1 : -1; return $this; }
|
||||
|
||||
public function toArray(): array
|
||||
{
|
||||
@@ -49,6 +57,7 @@ class Like
|
||||
'uuid' => $this->uuid,
|
||||
'comment_uuid' => $this->comment->getUuid(),
|
||||
'user_uuid' => $this->user->getUuid(),
|
||||
'value' => $this->value,
|
||||
'created_at' => $this->createdAt,
|
||||
];
|
||||
}
|
||||
|
||||
+53
-13
@@ -12,6 +12,15 @@ use Symfony\Component\Uid\Uuid;
|
||||
#[ORM\UniqueConstraint(name: 'idx_rates_user_doctor', columns: ['user_id', 'doctor_id'])]
|
||||
class Rate
|
||||
{
|
||||
/** Dimension column => Persian label. Drives the public aggregate response. */
|
||||
public const DIMENSIONS = [
|
||||
'waiting_time_at_clinic' => 'زمان انتظار در مطب',
|
||||
'accuracy_of_diagnosis' => 'تشخیص درست',
|
||||
'doctor_behavior' => 'برخورد مناسب پزشک',
|
||||
'clinic_cleanliness' => 'نظافت مطب',
|
||||
'doctor_expertise' => 'مهارت پزشک',
|
||||
];
|
||||
|
||||
#[ORM\Id]
|
||||
#[ORM\GeneratedValue]
|
||||
#[ORM\Column(type: 'integer')]
|
||||
@@ -28,8 +37,20 @@ class Rate
|
||||
#[ORM\JoinColumn(name: 'doctor_id', referencedColumnName: 'id', nullable: false, onDelete: 'CASCADE')]
|
||||
private Doctor $doctor;
|
||||
|
||||
#[ORM\Column(type: 'smallint')]
|
||||
private int $score;
|
||||
#[ORM\Column(name: 'waiting_time_at_clinic', type: 'smallint')]
|
||||
private int $waitingTimeAtClinic = 0;
|
||||
|
||||
#[ORM\Column(name: 'accuracy_of_diagnosis', type: 'smallint')]
|
||||
private int $accuracyOfDiagnosis = 0;
|
||||
|
||||
#[ORM\Column(name: 'doctor_behavior', type: 'smallint')]
|
||||
private int $doctorBehavior = 0;
|
||||
|
||||
#[ORM\Column(name: 'clinic_cleanliness', type: 'smallint')]
|
||||
private int $clinicCleanliness = 0;
|
||||
|
||||
#[ORM\Column(name: 'doctor_expertise', type: 'smallint')]
|
||||
private int $doctorExpertise = 0;
|
||||
|
||||
#[ORM\Column(name: 'created_at', type: 'integer')]
|
||||
private int $createdAt;
|
||||
@@ -37,31 +58,50 @@ class Rate
|
||||
#[ORM\Column(name: 'updated_at', type: 'integer')]
|
||||
private int $updatedAt;
|
||||
|
||||
public function __construct(User $user, Doctor $doctor, int $score)
|
||||
/** @param array<string,int> $dimensions keyed by DIMENSIONS keys (0–100) */
|
||||
public function __construct(User $user, Doctor $doctor, array $dimensions)
|
||||
{
|
||||
$this->uuid = Uuid::v4()->toRfc4122();
|
||||
$this->user = $user;
|
||||
$this->doctor = $doctor;
|
||||
$this->score = max(1, min(5, $score));
|
||||
$this->createdAt = time();
|
||||
$this->updatedAt = time();
|
||||
$this->setDimensions($dimensions);
|
||||
}
|
||||
|
||||
public function getId(): ?int { return $this->id; }
|
||||
public function getUuid(): string { return $this->uuid; }
|
||||
public function getUser(): User { return $this->user; }
|
||||
public function getDoctor(): Doctor { return $this->doctor; }
|
||||
public function getScore(): int { return $this->score; }
|
||||
|
||||
public function setScore(int $v): self { $this->score = max(1, min(5, $v)); $this->updatedAt = time(); return $this; }
|
||||
public function getWaitingTimeAtClinic(): int { return $this->waitingTimeAtClinic; }
|
||||
public function getAccuracyOfDiagnosis(): int { return $this->accuracyOfDiagnosis; }
|
||||
public function getDoctorBehavior(): int { return $this->doctorBehavior; }
|
||||
public function getClinicCleanliness(): int { return $this->clinicCleanliness; }
|
||||
public function getDoctorExpertise(): int { return $this->doctorExpertise; }
|
||||
|
||||
public function toArray(): array
|
||||
/** @param array<string,int> $dimensions keyed by DIMENSIONS keys (0–100) */
|
||||
public function setDimensions(array $dimensions): self
|
||||
{
|
||||
return [
|
||||
'uuid' => $this->uuid,
|
||||
'doctor_uuid' => $this->doctor->getUuid(),
|
||||
'score' => $this->score,
|
||||
'created_at' => $this->createdAt,
|
||||
];
|
||||
$clamp = static fn($v) => max(0, min(100, (int) $v));
|
||||
$this->waitingTimeAtClinic = $clamp($dimensions['waiting_time_at_clinic'] ?? $this->waitingTimeAtClinic);
|
||||
$this->accuracyOfDiagnosis = $clamp($dimensions['accuracy_of_diagnosis'] ?? $this->accuracyOfDiagnosis);
|
||||
$this->doctorBehavior = $clamp($dimensions['doctor_behavior'] ?? $this->doctorBehavior);
|
||||
$this->clinicCleanliness = $clamp($dimensions['clinic_cleanliness'] ?? $this->clinicCleanliness);
|
||||
$this->doctorExpertise = $clamp($dimensions['doctor_expertise'] ?? $this->doctorExpertise);
|
||||
$this->updatedAt = time();
|
||||
return $this;
|
||||
}
|
||||
|
||||
/** Overall percentage (0–100): mean of the five dimensions. */
|
||||
public function overallPercent(): float
|
||||
{
|
||||
return (
|
||||
$this->waitingTimeAtClinic
|
||||
+ $this->accuracyOfDiagnosis
|
||||
+ $this->doctorBehavior
|
||||
+ $this->clinicCleanliness
|
||||
+ $this->doctorExpertise
|
||||
) / 5;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,6 +19,15 @@ class CommentRepository extends ServiceEntityRepository
|
||||
return $this->findBy(['doctor' => $doctor, 'status' => Comment::STATUS_APPROVED], ['createdAt' => 'DESC']);
|
||||
}
|
||||
|
||||
/** @return Comment[] approved root comments (no parent) for a doctor */
|
||||
public function findApprovedRootsByDoctor(Doctor $doctor): array
|
||||
{
|
||||
return $this->findBy(
|
||||
['doctor' => $doctor, 'status' => Comment::STATUS_APPROVED, 'parent' => null],
|
||||
['createdAt' => 'DESC']
|
||||
);
|
||||
}
|
||||
|
||||
/** @return Comment[] */
|
||||
public function findPending(): array
|
||||
{
|
||||
|
||||
@@ -14,6 +14,29 @@ class LikeRepository extends ServiceEntityRepository
|
||||
|
||||
public function findByUserAndComment(User $user, Comment $comment): ?Like { return $this->findOneBy(['user' => $user, 'comment' => $comment]); }
|
||||
|
||||
/** @return array{like_count:int,dislike_count:int} */
|
||||
public function countByComment(Comment $comment): array
|
||||
{
|
||||
$rows = $this->createQueryBuilder('l')
|
||||
->select('l.value as value, COUNT(l.id) as cnt')
|
||||
->where('l.comment = :comment')
|
||||
->setParameter('comment', $comment)
|
||||
->groupBy('l.value')
|
||||
->getQuery()->getResult();
|
||||
|
||||
$likeCount = 0;
|
||||
$dislikeCount = 0;
|
||||
foreach ($rows as $row) {
|
||||
if ((int) $row['value'] >= 0) {
|
||||
$likeCount = (int) $row['cnt'];
|
||||
} else {
|
||||
$dislikeCount = (int) $row['cnt'];
|
||||
}
|
||||
}
|
||||
|
||||
return ['like_count' => $likeCount, 'dislike_count' => $dislikeCount];
|
||||
}
|
||||
|
||||
public function save(Like $e, bool $flush = true): void { $this->getEntityManager()->persist($e); if ($flush) $this->getEntityManager()->flush(); }
|
||||
public function remove(Like $e, bool $flush = true): void { $this->getEntityManager()->remove($e); if ($flush) $this->getEntityManager()->flush(); }
|
||||
}
|
||||
|
||||
@@ -14,14 +14,40 @@ class RateRepository extends ServiceEntityRepository
|
||||
|
||||
public function findByUserAndDoctor(User $user, Doctor $doctor): ?Rate { return $this->findOneBy(['user' => $user, 'doctor' => $doctor]); }
|
||||
|
||||
public function getAverageScore(Doctor $doctor): float
|
||||
/**
|
||||
* Aggregate the five rating dimensions for a doctor.
|
||||
*
|
||||
* @return array{point: float, satisfaction: int, averages: list<array{name:string,label:string,progress:int}>}
|
||||
*/
|
||||
public function getAggregate(Doctor $doctor): array
|
||||
{
|
||||
$result = $this->createQueryBuilder('r')
|
||||
->select('AVG(r.score) as avg, COUNT(r.id) as cnt')
|
||||
$row = $this->createQueryBuilder('r')
|
||||
->select(
|
||||
'AVG(r.waitingTimeAtClinic) as waiting_time_at_clinic',
|
||||
'AVG(r.accuracyOfDiagnosis) as accuracy_of_diagnosis',
|
||||
'AVG(r.doctorBehavior) as doctor_behavior',
|
||||
'AVG(r.clinicCleanliness) as clinic_cleanliness',
|
||||
'AVG(r.doctorExpertise) as doctor_expertise'
|
||||
)
|
||||
->where('r.doctor = :doctor')
|
||||
->setParameter('doctor', $doctor)
|
||||
->getQuery()->getSingleResult();
|
||||
return round((float)($result['avg'] ?? 0), 1);
|
||||
|
||||
$averages = [];
|
||||
$sum = 0;
|
||||
foreach (Rate::DIMENSIONS as $name => $label) {
|
||||
$progress = (int) round((float) ($row[$name] ?? 0));
|
||||
$averages[] = ['name' => $name, 'label' => $label, 'progress' => $progress];
|
||||
$sum += $progress;
|
||||
}
|
||||
|
||||
$satisfaction = (int) round($sum / count(Rate::DIMENSIONS));
|
||||
|
||||
return [
|
||||
'point' => round($satisfaction / 20, 1),
|
||||
'satisfaction' => $satisfaction,
|
||||
'averages' => $averages,
|
||||
];
|
||||
}
|
||||
|
||||
public function save(Rate $e, bool $flush = true): void { $this->getEntityManager()->persist($e); if ($flush) $this->getEntityManager()->flush(); }
|
||||
|
||||
@@ -69,6 +69,9 @@ class ErrorCodes
|
||||
// Rate Limit
|
||||
public const ERR_RATE_LIMIT_001 = 'ERR_RATE_LIMIT_001';
|
||||
|
||||
// Rating
|
||||
public const ERR_RATING_NOT_ELIGIBLE = 'ERR_RATING_NOT_ELIGIBLE';
|
||||
|
||||
public static function message(string $code): string
|
||||
{
|
||||
return match ($code) {
|
||||
@@ -105,6 +108,7 @@ class ErrorCodes
|
||||
self::ERR_PATIENT_NOT_FOUND => 'پرونده بیمار یافت نشد',
|
||||
self::ERR_SESSION_NOT_FOUND => 'مراجعه یافت نشد',
|
||||
self::ERR_SMS_WALLET_INSUFFICIENT => 'موجودی کیف پیامک کافی نیست',
|
||||
self::ERR_RATING_NOT_ELIGIBLE => 'برای ثبت نظر یا امتیاز باید در یک ماه گذشته نوبت تاییدشده نزد این پزشک داشته باشید',
|
||||
default => 'خطای ناشناخته',
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user