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:
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user