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:
hamed
2026-07-18 08:49:04 +03:30
parent 6ab7ed38b8
commit 1779e0d6de
13 changed files with 947 additions and 51 deletions
@@ -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
{
+1
View File
@@ -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
{
+207
View File
@@ -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',
]);
}
}