Files
clinicpro/src/Secretary/Service/SecretaryService.php
T
hamed 1779e0d6de 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.
2026-07-18 08:49:04 +03:30

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',
]);
}
}