fix(secretary): scope plan limit to the acting environment

Secretary limit always read the doctor's plan, so a clinic on the
professional plan hit the doctor's free-plan cap of one secretary.

Limit and count are now per environment: clinic env uses the clinic
subscription and counts the clinic's active secretaries, personal env
uses the doctor's own plan. Counting is per person, not per row, so a
secretary linked to several doctors of one clinic counts once, and
re-linking an already-active person never trips the cap.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
hamed
2026-08-20 14:38:40 +03:30
co-authored by Claude Opus 5
parent 5fdc232e65
commit 25a12e9a06
4 changed files with 89 additions and 18 deletions
+3 -3
View File
@@ -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
@@ -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'])) {
@@ -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();
}
+56 -5
View File
@@ -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;
}