feat(secretary): implement multi-doctor assignment for clinic secretaries
- Added functionality to assign a single secretary to multiple doctors within a clinic, allowing for scoped access to appointments. - Introduced `SecretaryService` to handle the logic for assigning and syncing doctors for a secretary. - Updated `SecretaryController` to support multi-doctor assignment via new endpoints and modified existing ones. - Enhanced `DoctorSecretary` entity to include secretary UUID in its serialized output. - Implemented repository methods to facilitate the retrieval and management of doctor-secretary relationships. - Adjusted appointment filtering in `MyAppointmentsController` to ensure secretaries only see appointments for assigned doctors. - Created tests to validate the new multi-doctor assignment functionality and appointment access restrictions. - Updated frontend components to support multi-select for doctors in the secretary management UI.
This commit is contained in:
@@ -301,9 +301,12 @@ class MyAppointmentsController extends BaseController
|
||||
return $this->paginated([], 0, $page, $limit);
|
||||
}
|
||||
if ($filterType === 'clinic') {
|
||||
$qb->join('App\Clinic\Entity\Clinic', 'c', 'WITH', 'd MEMBER OF c.doctors')
|
||||
->andWhere('c = :clinic')
|
||||
->setParameter('clinic', $filterValue);
|
||||
// $filterValue = آرایهی idهای پزشکانِ تخصیصیافته به این منشی در کلینیک
|
||||
if (empty($filterValue)) {
|
||||
return $this->paginated([], 0, $page, $limit);
|
||||
}
|
||||
$qb->andWhere('a.doctor IN (:doctorIds)')
|
||||
->setParameter('doctorIds', $filterValue);
|
||||
} else {
|
||||
$qb->andWhere('a.doctor = :doctor')
|
||||
->setParameter('doctor', $filterValue);
|
||||
@@ -479,10 +482,8 @@ class MyAppointmentsController extends BaseController
|
||||
|
||||
$clinic = $this->clinicRepo->findByUuid($dbUuid);
|
||||
if ($clinic !== null) {
|
||||
if (!$clinic->getDoctors()->contains($doctor)) {
|
||||
return false;
|
||||
}
|
||||
$rel = $this->secretaryRepo->findActiveBySecretaryForClinic($user, $clinic);
|
||||
// منشی فقط برای پزشکانِ تخصیصیافتهی خودش میتواند رزرو کند، نه کل کلینیک
|
||||
$rel = $this->secretaryRepo->findActiveClinicRow($user, $clinic, $doctor);
|
||||
return $rel !== null && (bool) ($rel->getPermissions()['resources']['appointments']['create'] ?? false);
|
||||
}
|
||||
|
||||
@@ -510,13 +511,17 @@ class MyAppointmentsController extends BaseController
|
||||
return null;
|
||||
}
|
||||
|
||||
// بررسی scope کلینیک
|
||||
// بررسی scope کلینیک — فقط پزشکانِ تخصیصیافته به این منشی، نه کل کلینیک
|
||||
$clinic = $this->clinicRepo->findByUuid($dbUuid);
|
||||
if ($clinic !== null) {
|
||||
$rel = $this->secretaryRepo->findActiveBySecretaryForClinic($user, $clinic);
|
||||
if ($rel === null) return null;
|
||||
$canView = (bool) ($rel->getPermissions()['resources']['appointments']['view'] ?? false);
|
||||
return ['clinic', $clinic, $canView];
|
||||
$canView = (bool) ($rel->getPermissions()['resources']['appointments']['view'] ?? false);
|
||||
$doctorIds = array_map(
|
||||
fn(Doctor $d) => $d->getId(),
|
||||
$this->secretaryRepo->findDoctorsBySecretaryInClinic($user, $clinic)
|
||||
);
|
||||
return ['clinic', $doctorIds, $canView];
|
||||
}
|
||||
|
||||
// بررسی scope مطب شخصی
|
||||
|
||||
@@ -8,6 +8,7 @@ use App\Clinic\Repository\ClinicRepository;
|
||||
use App\Doctor\Repository\DoctorRepository;
|
||||
use App\Secretary\Entity\DoctorSecretary;
|
||||
use App\Secretary\Repository\DoctorSecretaryRepository;
|
||||
use App\Secretary\Service\SecretaryService;
|
||||
use App\Shared\Constant\ErrorCodes;
|
||||
use App\Shared\Controller\BaseController;
|
||||
use App\Sms\Entity\SmsLog;
|
||||
@@ -33,6 +34,7 @@ class SecretaryController extends BaseController
|
||||
private readonly UserPasswordHasherInterface $hasher,
|
||||
private readonly SubscriptionService $subscriptionService,
|
||||
private readonly SmsService $smsService,
|
||||
private readonly SecretaryService $secretaryService,
|
||||
private readonly string $appUrl,
|
||||
) {}
|
||||
|
||||
@@ -43,6 +45,12 @@ class SecretaryController extends BaseController
|
||||
$doctorUuid = trim($data['doctor_uuid'] ?? '');
|
||||
$mobile = trim($data['mobile_number'] ?? '');
|
||||
|
||||
// تخصیص چندپزشکی توسط مدیر کلینیک
|
||||
$doctorUuids = $data['doctor_uuids'] ?? null;
|
||||
if (is_array($doctorUuids) && $doctorUuids !== []) {
|
||||
return $this->createForClinicDoctors($currentUser, $mobile, $doctorUuids, $data);
|
||||
}
|
||||
|
||||
if (empty($doctorUuid) || empty($mobile)) {
|
||||
return $this->error(ErrorCodes::ERR_VALIDATION_001, 'doctor_uuid و mobile_number الزامی است', 422);
|
||||
}
|
||||
@@ -258,6 +266,71 @@ class SecretaryController extends BaseController
|
||||
return $this->success($secretaries);
|
||||
}
|
||||
|
||||
/** تخصیص یک منشی به چند پزشکِ کلینیک (فقط مدیر کلینیک) */
|
||||
private function createForClinicDoctors(User $currentUser, string $mobile, array $doctorUuids, array $data): JsonResponse
|
||||
{
|
||||
if (empty($mobile)) {
|
||||
return $this->error(ErrorCodes::ERR_VALIDATION_001, 'mobile_number الزامی است', 422);
|
||||
}
|
||||
if (!$currentUser->hasRole('ROLE_CLINIC')) {
|
||||
return $this->error(ErrorCodes::ERR_AUTH_006, 'دسترسی ممنوع', 403);
|
||||
}
|
||||
$clinic = $this->clinicRepo->findByUser($currentUser);
|
||||
if ($clinic === null) {
|
||||
return $this->error(ErrorCodes::ERR_AUTH_006, 'دسترسی ممنوع', 403);
|
||||
}
|
||||
|
||||
$result = $this->secretaryService->assignToClinicDoctors($clinic, $mobile, $doctorUuids, [
|
||||
'name' => $data['name'] ?? null,
|
||||
'password' => $data['password'] ?? null,
|
||||
'national_code' => $data['national_code'] ?? null,
|
||||
'address' => $data['address'] ?? null,
|
||||
'permissions' => $data['permissions'] ?? null,
|
||||
]);
|
||||
|
||||
return $this->success([
|
||||
'secretary_uuid' => $result['secretary']->getUuid(),
|
||||
'created' => array_map(fn(DoctorSecretary $s) => $s->toArray(), $result['created']),
|
||||
'skipped_duplicate' => $result['skipped_duplicate'],
|
||||
'skipped_limit' => $result['skipped_limit'],
|
||||
'skipped_not_in_clinic' => $result['skipped_not_in_clinic'],
|
||||
], 201);
|
||||
}
|
||||
|
||||
/** همگامسازی مجموعهی پزشکانِ یک منشیِ کلینیک */
|
||||
#[Route('/api/v1/secretaries/clinic/{clinicUuid}/doctors', methods: ['PUT'])]
|
||||
public function syncClinicDoctors(string $clinicUuid, Request $request, #[CurrentUser] User $currentUser): JsonResponse
|
||||
{
|
||||
$clinic = $this->clinicRepo->findByUuid($clinicUuid);
|
||||
if ($clinic === null) {
|
||||
return $this->error(ErrorCodes::ERR_VALIDATION_002, 'کلینیک یافت نشد', 404);
|
||||
}
|
||||
if ($clinic->getUser()->getId() !== $currentUser->getId() && !$currentUser->hasRole('ROLE_ADMIN')) {
|
||||
return $this->error(ErrorCodes::ERR_AUTH_006, 'دسترسی ممنوع', 403);
|
||||
}
|
||||
|
||||
$data = json_decode($request->getContent(), true) ?? [];
|
||||
$secretaryUuid = trim($data['secretary_uuid'] ?? '');
|
||||
$doctorUuids = $data['doctor_uuids'] ?? null;
|
||||
if (empty($secretaryUuid) || !is_array($doctorUuids)) {
|
||||
return $this->error(ErrorCodes::ERR_VALIDATION_001, 'secretary_uuid و doctor_uuids الزامی است', 422);
|
||||
}
|
||||
|
||||
$secretaryUser = $this->userRepo->findByUuid($secretaryUuid);
|
||||
if ($secretaryUser === null) {
|
||||
return $this->error(ErrorCodes::ERR_VALIDATION_002, 'منشی یافت نشد', 404);
|
||||
}
|
||||
|
||||
$result = $this->secretaryService->syncClinicDoctors($clinic, $secretaryUser, $doctorUuids);
|
||||
|
||||
return $this->success([
|
||||
'added' => count($result['added']),
|
||||
'removed' => count($result['removed']),
|
||||
'skipped_limit' => $result['skipped_limit'],
|
||||
'skipped_not_in_clinic' => $result['skipped_not_in_clinic'],
|
||||
]);
|
||||
}
|
||||
|
||||
/** بررسی دسترسی برای ویرایش/حذف یک رابطه منشی — scope-aware */
|
||||
private function canManage(DoctorSecretary $secretary, User $user): bool
|
||||
{
|
||||
|
||||
@@ -127,6 +127,7 @@ class DoctorSecretary
|
||||
{
|
||||
return [
|
||||
'uuid' => $this->uuid,
|
||||
'secretary_uuid' => $this->secretary->getUuid(),
|
||||
'user_name' => $this->secretary->getRealName(),
|
||||
'mobile_number' => $this->secretary->getMobileNumber(),
|
||||
'doctor_name' => $this->doctor->getName(),
|
||||
|
||||
@@ -88,12 +88,25 @@ class DoctorSecretaryRepository extends ServiceEntityRepository
|
||||
->getOneOrNullResult();
|
||||
}
|
||||
|
||||
/** رابطه فعالِ owner=clinic برای یک (منشی، کلینیک، پزشک) مشخص */
|
||||
public function findActiveClinicRow(User $user, Clinic $clinic, Doctor $doctor): ?DoctorSecretary
|
||||
{
|
||||
return $this->findOneBy([
|
||||
'secretary' => $user,
|
||||
'clinic' => $clinic,
|
||||
'doctor' => $doctor,
|
||||
'ownerType' => DoctorSecretary::OWNER_CLINIC,
|
||||
'active' => true,
|
||||
]);
|
||||
}
|
||||
|
||||
/** همه دکترهای کلینیک که این منشی به آنها متصل است */
|
||||
public function findDoctorsBySecretaryInClinic(User $user, Clinic $clinic): array
|
||||
{
|
||||
return $this->createQueryBuilder('s')
|
||||
return $this->getEntityManager()->createQueryBuilder()
|
||||
->select('d')
|
||||
->join('s.doctor', 'd')
|
||||
->from(Doctor::class, 'd')
|
||||
->join(DoctorSecretary::class, 's', 'WITH', 's.doctor = d')
|
||||
->where('s.secretary = :user')
|
||||
->andWhere('s.clinic = :clinic')
|
||||
->andWhere('s.ownerType = :type')
|
||||
@@ -122,6 +135,22 @@ class DoctorSecretaryRepository extends ServiceEntityRepository
|
||||
return $clinic->getDoctors()->contains($doctor);
|
||||
}
|
||||
|
||||
/** روابط owner_type='clinic' یک منشی مشخص در یک کلینیک (فعال و غیرفعال، برای sync) */
|
||||
public function findByClinicAndSecretary(Clinic $clinic, User $secretary): array
|
||||
{
|
||||
return $this->createQueryBuilder('s')
|
||||
->addSelect('doc')
|
||||
->join('s.doctor', 'doc')
|
||||
->where('s.clinic = :clinic')
|
||||
->andWhere('s.secretary = :secretary')
|
||||
->andWhere('s.ownerType = :type')
|
||||
->setParameter('clinic', $clinic)
|
||||
->setParameter('secretary', $secretary)
|
||||
->setParameter('type', DoctorSecretary::OWNER_CLINIC)
|
||||
->getQuery()
|
||||
->getResult();
|
||||
}
|
||||
|
||||
/** همه منشی ها کلینیک (owner_type='clinic') */
|
||||
public function findByClinic(Clinic $clinic): array
|
||||
{
|
||||
|
||||
@@ -0,0 +1,207 @@
|
||||
<?php
|
||||
|
||||
namespace App\Secretary\Service;
|
||||
|
||||
use App\Auth\Entity\User;
|
||||
use App\Auth\Repository\UserRepository;
|
||||
use App\Clinic\Entity\Clinic;
|
||||
use App\Doctor\Entity\Doctor;
|
||||
use App\Doctor\Repository\DoctorRepository;
|
||||
use App\Secretary\Entity\DoctorSecretary;
|
||||
use App\Secretary\Repository\DoctorSecretaryRepository;
|
||||
use App\Sms\Entity\SmsLog;
|
||||
use App\Sms\Service\SmsService;
|
||||
use App\Subscription\Service\SubscriptionService;
|
||||
use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface;
|
||||
|
||||
/**
|
||||
* Secretary assignment logic shared by single- and multi-doctor flows.
|
||||
*
|
||||
* A secretary is a User linked to one or more doctors via DoctorSecretary rows
|
||||
* (one row per doctor). Clinic-owned assignment lets a clinic manager attach the
|
||||
* same secretary to several of the clinic's doctors at once and later re-sync
|
||||
* that set; the secretary's access is scoped to exactly those rows.
|
||||
*/
|
||||
class SecretaryService
|
||||
{
|
||||
public function __construct(
|
||||
private readonly DoctorSecretaryRepository $secretaryRepo,
|
||||
private readonly DoctorRepository $doctorRepo,
|
||||
private readonly UserRepository $userRepo,
|
||||
private readonly UserPasswordHasherInterface $hasher,
|
||||
private readonly SubscriptionService $subscriptionService,
|
||||
private readonly SmsService $smsService,
|
||||
private readonly string $appUrl,
|
||||
) {}
|
||||
|
||||
/** Find the secretary User by mobile or create it; ensure ROLE_SECRETARY, apply name/password. */
|
||||
public function resolveSecretaryUser(string $mobile, ?string $name = null, ?string $password = null): User
|
||||
{
|
||||
$user = $this->userRepo->findByMobile($mobile);
|
||||
if ($user === null) {
|
||||
$user = new User($mobile);
|
||||
if (!empty($password)) {
|
||||
$user->setPasswordHash($this->hasher->hashPassword($user, $password));
|
||||
}
|
||||
}
|
||||
if (!empty($name)) {
|
||||
$user->setRealName(trim($name));
|
||||
}
|
||||
|
||||
$roles = $user->getRoles();
|
||||
if (!in_array('ROLE_SECRETARY', $roles, true)) {
|
||||
$roles[] = 'ROLE_SECRETARY';
|
||||
$user->setRoles(array_values(array_unique($roles)));
|
||||
}
|
||||
$this->userRepo->save($user);
|
||||
|
||||
return $user;
|
||||
}
|
||||
|
||||
public function doctorAtSecretaryLimit(Doctor $doctor): bool
|
||||
{
|
||||
$limit = $this->subscriptionService->getSecretaryLimit('doctor', $doctor->getId());
|
||||
|
||||
return $this->secretaryRepo->countActiveByDoctor($doctor) >= $limit;
|
||||
}
|
||||
|
||||
/**
|
||||
* Assign a secretary (by mobile) to several clinic doctors atomically.
|
||||
*
|
||||
* @param string[] $doctorUuids
|
||||
* @return array{secretary: User, created: DoctorSecretary[], skipped_duplicate: string[], skipped_limit: string[], skipped_not_in_clinic: string[]}
|
||||
*/
|
||||
public function assignToClinicDoctors(Clinic $clinic, string $mobile, array $doctorUuids, array $meta = []): array
|
||||
{
|
||||
$secretary = $this->resolveSecretaryUser($mobile, $meta['name'] ?? null, $meta['password'] ?? null);
|
||||
|
||||
$created = $skippedDup = $skippedLimit = $skippedNotInClinic = [];
|
||||
|
||||
foreach (array_unique($doctorUuids) as $doctorUuid) {
|
||||
$doctor = $this->doctorRepo->findByUuid($doctorUuid);
|
||||
if ($doctor === null || !$this->secretaryRepo->isDoctorInClinic($doctor, $clinic)) {
|
||||
$skippedNotInClinic[] = $doctorUuid;
|
||||
continue;
|
||||
}
|
||||
|
||||
$existing = $this->secretaryRepo->findOneBy([
|
||||
'doctor' => $doctor,
|
||||
'secretary' => $secretary,
|
||||
'ownerType' => DoctorSecretary::OWNER_CLINIC,
|
||||
]);
|
||||
if ($existing !== null) {
|
||||
if ($existing->isActive()) {
|
||||
$skippedDup[] = $doctorUuid;
|
||||
continue;
|
||||
}
|
||||
$existing->setActive(true);
|
||||
$this->applyMeta($existing, $meta);
|
||||
$created[] = $existing;
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($this->doctorAtSecretaryLimit($doctor)) {
|
||||
$skippedLimit[] = $doctorUuid;
|
||||
continue;
|
||||
}
|
||||
$row = new DoctorSecretary($doctor, $secretary, DoctorSecretary::OWNER_CLINIC, $clinic);
|
||||
$this->applyMeta($row, $meta);
|
||||
$this->secretaryRepo->save($row, false);
|
||||
$created[] = $row;
|
||||
}
|
||||
|
||||
$this->secretaryRepo->getEntityManager()->flush(); // flush the batch of new rows
|
||||
|
||||
if (!empty($created)) {
|
||||
$this->sendWelcomeSms($mobile, $clinic->getName() ?? 'کلینیک');
|
||||
}
|
||||
|
||||
return [
|
||||
'secretary' => $secretary,
|
||||
'created' => $created,
|
||||
'skipped_duplicate' => $skippedDup,
|
||||
'skipped_limit' => $skippedLimit,
|
||||
'skipped_not_in_clinic' => $skippedNotInClinic,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-sync a clinic secretary's assigned doctors to exactly $doctorUuids:
|
||||
* activate/create the wanted set, deactivate the rest.
|
||||
*
|
||||
* @param string[] $doctorUuids
|
||||
* @return array{added: DoctorSecretary[], removed: DoctorSecretary[], skipped_limit: string[], skipped_not_in_clinic: string[]}
|
||||
*/
|
||||
public function syncClinicDoctors(Clinic $clinic, User $secretary, array $doctorUuids): array
|
||||
{
|
||||
$wanted = array_values(array_unique($doctorUuids));
|
||||
$existing = $this->secretaryRepo->findByClinicAndSecretary($clinic, $secretary);
|
||||
$byDoctorUuid = [];
|
||||
foreach ($existing as $row) {
|
||||
$byDoctorUuid[$row->getDoctor()->getUuid()] = $row;
|
||||
}
|
||||
|
||||
$added = $removed = $skippedLimit = $skippedNotInClinic = [];
|
||||
|
||||
foreach ($wanted as $doctorUuid) {
|
||||
$doctor = $this->doctorRepo->findByUuid($doctorUuid);
|
||||
if ($doctor === null || !$this->secretaryRepo->isDoctorInClinic($doctor, $clinic)) {
|
||||
$skippedNotInClinic[] = $doctorUuid;
|
||||
continue;
|
||||
}
|
||||
$current = $byDoctorUuid[$doctorUuid] ?? null;
|
||||
if ($current !== null) {
|
||||
if (!$current->isActive()) {
|
||||
$current->setActive(true);
|
||||
$added[] = $current;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if ($this->doctorAtSecretaryLimit($doctor)) {
|
||||
$skippedLimit[] = $doctorUuid;
|
||||
continue;
|
||||
}
|
||||
$row = new DoctorSecretary($doctor, $secretary, DoctorSecretary::OWNER_CLINIC, $clinic);
|
||||
$this->secretaryRepo->save($row, false);
|
||||
$added[] = $row;
|
||||
}
|
||||
|
||||
foreach ($existing as $row) {
|
||||
if ($row->isActive() && !in_array($row->getDoctor()->getUuid(), $wanted, true)) {
|
||||
$row->setActive(false);
|
||||
$removed[] = $row;
|
||||
}
|
||||
}
|
||||
|
||||
$this->secretaryRepo->getEntityManager()->flush();
|
||||
|
||||
return [
|
||||
'added' => $added,
|
||||
'removed' => $removed,
|
||||
'skipped_limit' => $skippedLimit,
|
||||
'skipped_not_in_clinic' => $skippedNotInClinic,
|
||||
];
|
||||
}
|
||||
|
||||
private function applyMeta(DoctorSecretary $row, array $meta): void
|
||||
{
|
||||
if (array_key_exists('national_code', $meta)) {
|
||||
$row->setNationalCode($meta['national_code'] !== null ? trim((string) $meta['national_code']) : null);
|
||||
}
|
||||
if (array_key_exists('address', $meta)) {
|
||||
$row->setAddress($meta['address'] !== null ? trim((string) $meta['address']) : null);
|
||||
}
|
||||
if (!empty($meta['permissions'])) {
|
||||
$row->mergePermissions($meta['permissions']);
|
||||
}
|
||||
}
|
||||
|
||||
private function sendWelcomeSms(string $mobile, string $ownerName): void
|
||||
{
|
||||
$this->smsService->dispatchTemplate(SmsLog::TAG_SECRETARY, $mobile, [
|
||||
'owner' => $ownerName,
|
||||
'username' => $mobile,
|
||||
'link' => rtrim($this->appUrl, '/') . '/login',
|
||||
]);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user