diff --git a/docs/api/secretary.md b/docs/api/secretary.md index 768908eb..6790e7fc 100644 --- a/docs/api/secretary.md +++ b/docs/api/secretary.md @@ -128,7 +128,7 @@ Create a secretary for a doctor. } ``` -- `created`: ردیف‌های تازه‌ساخته/فعال‌شده · `skipped_duplicate`: قبلاً متصل بوده · `skipped_limit`: سقفِ پلنِ آن پزشک پر است · `skipped_not_in_clinic`: پزشک عضو کلینیک نیست. حلقه اتمیک است و بقیه‌ی پزشکان ادامه می‌یابند. +- `created`: ردیف‌های تازه‌ساخته/فعال‌شده · `skipped_duplicate`: قبلاً متصل بوده · `skipped_limit`: سقفِ منشیِ آن محیط پر است · `skipped_not_in_clinic`: پزشک عضو کلینیک نیست. حلقه اتمیک است و بقیه‌ی پزشکان ادامه می‌یابند. **Permissions Structure:** @@ -255,7 +255,7 @@ Create a secretary for a doctor. | `ERR_AUTH_006` | 403 | Not the doctor owner / clinic owner / admin | | `ERR_NOT_FOUND_001` | 404 | Doctor not found | | `ERR_CONFLICT_001` | 409 | Secretary already added for this doctor **in this same environment** — همان منشی برای همان پزشک در کلینیکِ دیگر ۴۰۹ نمی‌گیرد | -| `ERR_SECRETARY_001` | 422 | Plan limit for secretaries reached | +| `ERR_SECRETARY_001` | 422 | Plan limit for secretaries reached — سقف در هر محیط جداست: محیط کلینیک با پلن کلینیک، مطب شخصی با پلن خود پزشک. شمارش به تفکیک **شخص** است، نه ردیف؛ منشیِ متصل به چند پزشکِ یک کلینیک یک نفر شمرده می‌شود. | --- @@ -506,7 +506,7 @@ Get all secretaries across **all doctors** of a clinic. | ----------------------- | -------- | ------------------------------------------------ | | `added` | int | تعداد ردیف‌های افزوده/فعال‌شده | | `removed` | int | تعداد ردیف‌های غیرفعال‌شده | -| `skipped_limit` | string[] | uuid پزشکانی که به سقفِ پلن رسیده‌اند (نادیده گرفته) | +| `skipped_limit` | string[] | uuid پزشکانی که افزودنشان از سقفِ منشیِ محیط عبور می‌کرد (نادیده گرفته) | | `skipped_not_in_clinic` | string[] | uuid پزشکانی که عضو این کلینیک نیستند | ### Errors diff --git a/src/Secretary/Controller/SecretaryController.php b/src/Secretary/Controller/SecretaryController.php index d7b0a35e..28223646 100644 --- a/src/Secretary/Controller/SecretaryController.php +++ b/src/Secretary/Controller/SecretaryController.php @@ -14,7 +14,6 @@ use App\Shared\Constant\ErrorCodes; use App\Shared\Controller\BaseController; use App\Sms\Entity\SmsLog; use App\Sms\Service\SmsService; -use App\Subscription\Service\SubscriptionService; use Symfony\Component\HttpFoundation\JsonResponse; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface; @@ -33,7 +32,6 @@ class SecretaryController extends BaseController private readonly ClinicRepository $clinicRepo, private readonly UserRepository $userRepo, private readonly UserPasswordHasherInterface $hasher, - private readonly SubscriptionService $subscriptionService, private readonly SmsService $smsService, private readonly SecretaryService $secretaryService, private readonly \App\Secretary\Repository\SecretaryEarningRepository $earningRepo, @@ -241,15 +239,15 @@ class SecretaryController extends BaseController } } - // Check plan limit - $limit = $this->subscriptionService->getSecretaryLimit('doctor', $doctor->getId()); - $activeCount = $this->secretaryRepo->countActiveByDoctor($doctor); - if ($activeCount >= $limit) { + // Find or create secretary user — پیش از سنجش سقف، چون اگر همین شخص از قبل در + // این محیط فعال باشد نفر تازه‌ای اضافه نمی‌شود و نباید سقف بخورد. + $secretaryUser = $this->userRepo->findByMobile($mobile); + + // سقف پلنِ همین محیط: کلینیک با پلن کلینیک، مطب شخصی با پلن خود پزشک + if ($this->secretaryService->atSecretaryLimit($doctor, $ownerClinic, $secretaryUser)) { return $this->error(ErrorCodes::ERR_SECRETARY_001, ErrorCodes::message(ErrorCodes::ERR_SECRETARY_001), 422); } - // Find or create secretary user - $secretaryUser = $this->userRepo->findByMobile($mobile); if ($secretaryUser === null) { $secretaryUser = new User($mobile); if (!empty($data['password'])) { diff --git a/src/Secretary/Repository/DoctorSecretaryRepository.php b/src/Secretary/Repository/DoctorSecretaryRepository.php index d963a901..a6bebe39 100644 --- a/src/Secretary/Repository/DoctorSecretaryRepository.php +++ b/src/Secretary/Repository/DoctorSecretaryRepository.php @@ -21,13 +21,35 @@ class DoctorSecretaryRepository extends ServiceEntityRepository return $this->findOneBy(['uuid' => $uuid]); } - public function countActiveByDoctor(Doctor $doctor): int + /** + * تعداد منشیِ **فعالِ** یک کلینیک — به تفکیک شخص، نه ردیف. + * + * یک منشی که به سه پزشکِ کلینیک وصل است سه ردیف دارد ولی یک نفر است و سقف پلن + * روی «نفر» بسته می‌شود. + */ + public function countActiveDistinctSecretariesByClinic(Clinic $clinic): int { return (int) $this->createQueryBuilder('s') - ->select('COUNT(s.id)') + ->select('COUNT(DISTINCT IDENTITY(s.secretary))') + ->where('s.clinic = :clinic') + ->andWhere('s.entityType = :type') + ->andWhere('s.active = true') + ->setParameter('clinic', $clinic) + ->setParameter('type', DoctorSecretary::OWNER_CLINIC) + ->getQuery() + ->getSingleScalarResult(); + } + + /** تعداد منشیِ فعالِ مطب شخصی یک پزشک (owner_type='doctor'), به تفکیک شخص. */ + public function countActiveDistinctSecretariesByDoctorScope(Doctor $doctor): int + { + return (int) $this->createQueryBuilder('s') + ->select('COUNT(DISTINCT IDENTITY(s.secretary))') ->where('s.doctor = :doctor') + ->andWhere('s.entityType = :type') ->andWhere('s.active = true') ->setParameter('doctor', $doctor) + ->setParameter('type', DoctorSecretary::OWNER_DOCTOR) ->getQuery() ->getSingleScalarResult(); } diff --git a/src/Secretary/Service/SecretaryService.php b/src/Secretary/Service/SecretaryService.php index 3b7cf0b8..8dc68ac0 100644 --- a/src/Secretary/Service/SecretaryService.php +++ b/src/Secretary/Service/SecretaryService.php @@ -11,6 +11,7 @@ use App\Secretary\Entity\DoctorSecretary; use App\Secretary\Repository\DoctorSecretaryRepository; use App\Sms\Entity\SmsLog; use App\Sms\Service\SmsService; +use App\Subscription\Entity\SubscriptionPlan; use App\Subscription\Service\SubscriptionService; use Doctrine\ORM\EntityManagerInterface; use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface; @@ -60,11 +61,61 @@ class SecretaryService return $user; } - public function doctorAtSecretaryLimit(Doctor $doctor): bool + /** + * سقف منشیِ **محیط**؛ محیط کلینیک و محیط مطب شخصی هرکدام سقف و شمارش جدا دارند. + * + * منشی روی ردیفِ پزشک ثبت می‌شود، ولی صاحبِ سقف محیط است: در کلینیک پلنِ کلینیک، + * در مطب شخصی پلنِ خودِ پزشک. بدون این، کلینیکِ حرفه‌ای به سقف پلن free پزشک + * می‌خورد و پلنِ کلینیک بی‌اثر می‌ماند. + */ + public function secretaryLimitFor(?Clinic $clinic, ?Doctor $doctor = null): int { - $limit = $this->subscriptionService->getSecretaryLimit('doctor', $doctor->getId()); + if ($clinic !== null) { + return $this->subscriptionService->getSecretaryLimit('clinic', $clinic->getId()); + } - return $this->secretaryRepo->countActiveByDoctor($doctor) >= $limit; + return $doctor !== null + ? $this->subscriptionService->getSecretaryLimit('doctor', $doctor->getId()) + : SubscriptionPlan::UNLIMITED; + } + + /** تعداد منشیِ فعالِ محیط، به تفکیک شخص (نه ردیف). */ + public function secretaryCountFor(?Clinic $clinic, ?Doctor $doctor = null): int + { + if ($clinic !== null) { + return $this->secretaryRepo->countActiveDistinctSecretariesByClinic($clinic); + } + + return $doctor !== null + ? $this->secretaryRepo->countActiveDistinctSecretariesByDoctorScope($doctor) + : 0; + } + + /** + * آیا افزودنِ این منشی به این محیط از سقف پلن عبور می‌کند؟ + * + * $secretary وقتی داده شود و همان شخص از قبل در همین محیط فعال باشد، نفر تازه‌ای + * اضافه نمی‌شود؛ وصل‌کردن او به پزشکِ دومِ همان کلینیک نباید سقف بخورد. + */ + public function atSecretaryLimit(Doctor $doctor, ?Clinic $clinic = null, ?User $secretary = null): bool + { + $limit = $this->secretaryLimitFor($clinic, $doctor); + if ($limit === SubscriptionPlan::UNLIMITED) { + return false; + } + + if ($secretary !== null && $this->secretaryIsActiveIn($secretary, $doctor, $clinic)) { + return false; + } + + return $this->secretaryCountFor($clinic, $doctor) >= $limit; + } + + private function secretaryIsActiveIn(User $secretary, Doctor $doctor, ?Clinic $clinic): bool + { + return $clinic !== null + ? $this->secretaryRepo->findActiveBySecretaryForClinic($secretary, $clinic) !== null + : $this->secretaryRepo->findActiveBySecretaryForDoctor($secretary, $doctor) !== null; } /** @@ -102,7 +153,7 @@ class SecretaryService continue; } - if ($this->doctorAtSecretaryLimit($doctor)) { + if ($this->atSecretaryLimit($doctor, $clinic, $secretary)) { $skippedLimit[] = $doctorUuid; continue; } @@ -162,7 +213,7 @@ class SecretaryService } continue; } - if ($this->doctorAtSecretaryLimit($doctor)) { + if ($this->atSecretaryLimit($doctor, $clinic, $secretary)) { $skippedLimit[] = $doctorUuid; continue; }