feat(migrations): add clinic_id context to weekly_schedules, date_overrides, and holidays

- Introduced clinic_id to weekly_schedules, date_overrides, and holidays to differentiate between personal and clinic schedules.
- Updated unique constraints and indexes to accommodate the new clinic context.

feat(command): create AssignScheduleClinicCommand to move schedules

- Added a command to move a doctor's personal weekly schedule into a clinic context.
- Implemented checks to ensure sessions align with the target clinic.

feat(context): implement EntityContext and EntityContextResolver

- Created EntityContext to represent the effective working environment of a request (doctor or clinic).
- Developed EntityContextResolver to determine the execution context based on user roles and active contexts.

test: add ServiceModeContextTest for appointment scheduling

- Implemented tests to ensure service booking respects clinic and personal contexts.
- Verified that financial data is omitted in clinic contexts in InvitedDoctorDashboardScopeTest.
This commit is contained in:
hamed
2026-07-18 13:32:56 +03:30
parent 2553b45990
commit f1258d206d
28 changed files with 2126 additions and 276 deletions
+112 -60
View File
@@ -11,6 +11,7 @@ use App\Patient\Repository\PatientSessionRepository;
use App\Secretary\Entity\DoctorSecretary;
use App\Secretary\Repository\DoctorSecretaryRepository;
use App\Shared\Constant\ErrorCodes;
use App\Shared\Context\EntityContextResolver;
use App\Shared\Controller\BaseController;
use App\Sms\Service\SmsWalletService;
use Doctrine\ORM\EntityManagerInterface;
@@ -33,6 +34,9 @@ class DashboardController extends BaseController
private readonly PatientRecordRepository $patientRecordRepo,
private readonly PatientSessionRepository $patientSessionRepo,
private readonly UserActiveContextRepository $contextRepo,
private readonly EntityContextResolver $contextResolver,
private readonly \App\Clinic\Security\ClinicDoctorPermissionChecker $permChecker,
private readonly \App\Doctor\Repository\DoctorAddressRepository $addressRepo,
) {}
// ── Clinic Dashboard ────────────────────────────────────────────────────
@@ -186,6 +190,11 @@ class DashboardController extends BaseController
return $this->error(ErrorCodes::ERR_NOT_FOUND_001, 'دکتر یافت نشد', 404);
}
// محیط فعال تعیین می‌کند این داشبورد شخصی است یا داخل یک کلینیک. بدون این،
// پزشکِ دعوت‌شده در محیط کلینیک آمار و درآمد مطب شخصی خودش را می‌دید.
$context = $this->contextResolver->tryResolve($user, $request->query->get('clinic_uuid'));
$clinic = $context?->clinic;
$doctorId = $doctor->getId();
$todayStart = strtotime('today midnight');
$todayEnd = strtotime('tomorrow midnight') - 1;
@@ -197,23 +206,9 @@ class DashboardController extends BaseController
$to = $request->query->get('to') ? (int) $request->query->get('to') : time();
// آمار
$todayCount = (int) $this->em->createQuery('
SELECT COUNT(a.id) FROM App\Appointment\Entity\Appointment a
WHERE a.doctor = :doctor AND a.slotStart >= :s AND a.slotStart <= :e
')->setParameters(['doctor' => $doctor, 's' => $todayStart, 'e' => $todayEnd])
->getSingleScalarResult();
$tmrCount = (int) $this->em->createQuery('
SELECT COUNT(a.id) FROM App\Appointment\Entity\Appointment a
WHERE a.doctor = :doctor AND a.slotStart >= :s AND a.slotStart <= :e
')->setParameters(['doctor' => $doctor, 's' => $tmrStart, 'e' => $tmrEnd])
->getSingleScalarResult();
$monthCount = (int) $this->em->createQuery('
SELECT COUNT(a.id) FROM App\Appointment\Entity\Appointment a
WHERE a.doctor = :doctor AND a.slotStart >= :s
')->setParameters(['doctor' => $doctor, 's' => $monthStart])
->getSingleScalarResult();
$todayCount = $this->countAppointments($doctor, $clinic, $todayStart, $todayEnd);
$tmrCount = $this->countAppointments($doctor, $clinic, $tmrStart, $tmrEnd);
$monthCount = $this->countAppointments($doctor, $clinic, $monthStart, PHP_INT_MAX);
// میانگین و تعداد امتیاز
$ratingRow = $this->em->createQuery('
@@ -223,21 +218,24 @@ class DashboardController extends BaseController
')->setParameter('doctor', $doctor)->getOneOrNullResult() ?? [];
// نوبت‌های امروز
$todayAppts = $this->em->createQuery('
SELECT a.uuid, u.realName AS patient_name, u.mobileNumber AS patient_mobile,
d.name AS doctor_name, si.name AS service_name,
a.slotStart AS slot_start, a.slotEnd AS slot_end, a.status
FROM App\Appointment\Entity\Appointment a
JOIN a.user u
JOIN a.doctor d
LEFT JOIN a.serviceItem si
WHERE a.doctor = :doctor AND a.slotStart >= :s AND a.slotStart <= :e
ORDER BY a.slotStart ASC
')->setMaxResults(10)->setParameters([
'doctor' => $doctor,
's' => $todayStart,
'e' => $todayEnd,
])->getArrayResult();
$todayApptsQb = $this->em->createQueryBuilder()
->select('a.uuid, u.realName AS patient_name, u.mobileNumber AS patient_mobile,
d.name AS doctor_name, si.name AS service_name,
a.slotStart AS slot_start, a.slotEnd AS slot_end, a.status')
->from(\App\Appointment\Entity\Appointment::class, 'a')
->join('a.user', 'u')
->join('a.doctor', 'd')
->leftJoin('a.serviceItem', 'si')
->where('a.doctor = :doctor')
->andWhere('a.slotStart >= :s AND a.slotStart <= :e')
->setParameter('doctor', $doctor)
->setParameter('s', $todayStart)
->setParameter('e', $todayEnd)
->orderBy('a.slotStart', 'ASC')
->setMaxResults(10);
$this->restrictToClinicAddresses($todayApptsQb, $clinic);
$todayAppts = $todayApptsQb->getQuery()->getArrayResult();
// کلینیک‌های عضو
$clinics = $this->em->createQuery('
@@ -247,18 +245,35 @@ class DashboardController extends BaseController
WHERE d.id = :doctorId
')->setParameter('doctorId', $doctorId)->getArrayResult();
$smsBalance = $this->smsWalletService->getBalance('doctor', $doctorId);
$uniquePatients = $this->patientRecordRepo->countUnique('doctor', $doctorId, $from, $to);
$revenuePeriod = $this->patientSessionRepo->sumRevenue('doctor', $doctorId, $from, $to);
$totalPatients = $this->patientRecordRepo->countUnique('doctor', $doctorId, 0, time());
$apptByDay = $this->appointmentsDaily(
fn(int $ds, int $de): int => $this->countAppointments($doctor, $clinic, $ds, $de)
);
$rev = $this->revenueDaily('doctor', $doctorId);
$apptByDay = $this->appointmentsDaily(function (int $ds, int $de) use ($doctor): int {
return (int) $this->em->createQuery('
SELECT COUNT(a.id) FROM App\Appointment\Entity\Appointment a
WHERE a.doctor = :d AND a.slotStart >= :s AND a.slotStart <= :e
')->setParameters(['d' => $doctor, 's' => $ds, 'e' => $de])->getSingleScalarResult();
});
$stats = [
'today_appointments' => $todayCount,
'tomorrow_appointments' => $tmrCount,
'this_month_appointments' => $monthCount,
'avg_rating' => $ratingRow['avg_score'] ? round((float) $ratingRow['avg_score'], 1) : null,
'total_ratings' => (int) ($ratingRow['total'] ?? 0),
];
$charts = ['appointments_by_day' => $apptByDay];
// ارقام مالی و کیف پول پیامک به مطب شخصی تعلق دارند. در محیط کلینیک اصلاً
// برگردانده نمی‌شوند مگر کاربر مالک همان کلینیک باشد — مخفی‌کردن در UI کافی
// نیست، چون endpoint مستقیماً قابل صدا زدن است.
if ($this->maySeeFinancials($user, $clinic)) {
$rev = $this->revenueDaily('doctor', $doctorId);
$stats['sms_wallet_balance'] = $this->smsWalletService->getBalance('doctor', $doctorId);
$stats['unique_patients_count'] = $this->patientRecordRepo->countUnique('doctor', $doctorId, $from, $to);
$stats['total_patients'] = $this->patientRecordRepo->countUnique('doctor', $doctorId, 0, time());
$stats['revenue_period_rials'] = $this->patientSessionRepo->sumRevenue('doctor', $doctorId, $from, $to);
$stats['today_payments_rials'] = $rev['today_payments_rials'];
$stats['week_payments_rials'] = $rev['week_payments_rials'];
$charts['revenue_by_day'] = $rev['revenue'];
}
return $this->success([
'doctor' => [
@@ -266,29 +281,66 @@ class DashboardController extends BaseController
'name' => $doctor->getName(),
'degree' => $doctor->getDegree(),
],
'stats' => [
'today_appointments' => $todayCount,
'tomorrow_appointments' => $tmrCount,
'this_month_appointments' => $monthCount,
'avg_rating' => $ratingRow['avg_score'] ? round((float) $ratingRow['avg_score'], 1) : null,
'total_ratings' => (int) ($ratingRow['total'] ?? 0),
'sms_wallet_balance' => $smsBalance,
'unique_patients_count' => $uniquePatients,
'total_patients' => $totalPatients,
'revenue_period_rials' => $revenuePeriod,
'today_payments_rials' => $rev['today_payments_rials'],
'week_payments_rials' => $rev['week_payments_rials'],
],
'charts' => [
'revenue_by_day' => $rev['revenue'],
'appointments_by_day' => $apptByDay,
'context' => [
'type' => $clinic === null ? 'personal' : 'clinic',
'clinic_uuid' => $clinic?->getUuid(),
'clinic_name' => $clinic?->getName(),
],
'stats' => $stats,
'charts' => $charts,
'period' => ['from' => $from, 'to' => $to],
'today_appointments' => $todayAppts,
'clinics' => $clinics,
// فهرست کلینیک‌ها فقط در محیط شخصی معنا دارد.
'clinics' => $clinic === null ? $clinics : [],
]);
}
/**
* نوبت‌های پزشک در یک بازه، محدود به محیط جاری. در محیط کلینیک فقط نوبت‌هایی
* شمرده می‌شوند که آدرسشان متعلق به همان کلینیک است.
*/
private function countAppointments(\App\Doctor\Entity\Doctor $doctor, ?\App\Clinic\Entity\Clinic $clinic, int $from, int $to): int
{
$qb = $this->em->createQueryBuilder()
->select('COUNT(a.id)')
->from(\App\Appointment\Entity\Appointment::class, 'a')
->where('a.doctor = :doctor')
->andWhere('a.slotStart >= :s AND a.slotStart <= :e')
->setParameter('doctor', $doctor)
->setParameter('s', $from)
->setParameter('e', $to);
$this->restrictToClinicAddresses($qb, $clinic);
return (int) $qb->getQuery()->getSingleScalarResult();
}
private function restrictToClinicAddresses(\Doctrine\ORM\QueryBuilder $qb, ?\App\Clinic\Entity\Clinic $clinic): void
{
if ($clinic === null) {
return;
}
$addressIds = array_map(
fn(\App\Doctor\Entity\DoctorAddress $a): int => (int) $a->getId(),
$this->addressRepo->findForContext($qb->getParameter('doctor')->getValue(), $clinic->getId())
);
$qb->andWhere('a.addressId IN (:addressIds)')
->setParameter('addressIds', $addressIds ?: [0]);
}
/** فقط محیط شخصی خود پزشک، یا مالک همان کلینیک. */
private function maySeeFinancials(User $user, ?\App\Clinic\Entity\Clinic $clinic): bool
{
if ($clinic === null) {
return true;
}
return $clinic->getUser()->getId() === $user->getId()
&& $this->permChecker->can($user, $clinic, 'payments', 'view');
}
/**
* سری ۷ روز اخیر درآمد (بر اساس PatientSession) + پرداختی امروز و هفته.
* @return array{revenue: array<int, array{label:string, amount_rials:int}>, today_payments_rials:int, week_payments_rials:int}