- 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.
208 lines
7.8 KiB
PHP
208 lines
7.8 KiB
PHP
<?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',
|
|
]);
|
|
}
|
|
}
|