diff --git a/docs/api/insurance.md b/docs/api/insurance.md index a9292336..973e5f9d 100644 --- a/docs/api/insurance.md +++ b/docs/api/insurance.md @@ -410,6 +410,12 @@ entity جاری از `#[CurrentUser]` resolve می‌شود: نقش `ROLE_DOCTOR با `doctor_uuid`، دسترسی این‌گونه بررسی می‌شود: `ROLE_ADMIN`، خودِ پزشک، مالک کلینیکی که پزشک عضو آن است، یا پزشکِ عضو همان کلینیک با مجوز `services.view` (برای `PUT`: `services.update`). در غیر این صورت `403 ERR_ACCESS_DENIED`؛ پزشکِ ناموجود `404 ERR_NOT_FOUND_001`. بدون این پارامتر رفتار قبلی (موجودیت کاربر جاری) دست‌نخورده است. +> ℹ️ ردیف‌های قیمت‌گذاری با `doctor_uuid` بیرون از TenantFilter خوانده می‌شوند: مقصدِ این +> تنظیم پزشک است در حالی که محیط فعالِ مالکِ کلینیک، خودِ کلینیک است. مجوزش همان بررسی +> بالاست. تا پیش از این، خواندن به محیط کاربر محدود می‌شد و ردیف موجود دیده نمی‌شد — +> هر ذخیره یک ردیف تازه می‌ساخت (قیدِ یکتا ردیفِ ویزیت آزاد را نمی‌گیرد چون `insurance_id` +> آنجا `NULL` است) و مقدار ذخیره‌شده هرگز به پنل برنمی‌گشت. + ### Response `200` ```json { diff --git a/migrations/Version20260818131524.php b/migrations/Version20260818131524.php new file mode 100644 index 00000000..ed931771 --- /dev/null +++ b/migrations/Version20260818131524.php @@ -0,0 +1,48 @@ +addSql(<<<'SQL' + DELETE p FROM entity_insurance_pricing p + JOIN ( + SELECT entity_type, entity_id, MAX(id) AS keep_id + FROM entity_insurance_pricing + WHERE insurance_id IS NULL + GROUP BY entity_type, entity_id + HAVING COUNT(*) > 1 + ) newest + ON newest.entity_type = p.entity_type + AND newest.entity_id = p.entity_id + WHERE p.insurance_id IS NULL + AND p.id <> newest.keep_id + SQL); + } + + public function down(Schema $schema): void + { + // ردیف‌های حذف‌شده تکراری بودند؛ برگرداندنشان همان وضعیت خراب را باز می‌سازد. + $this->throwIrreversibleMigrationException('Duplicate pricing rows are not restored.'); + } +} diff --git a/src/Insurance/Repository/EntityInsurancePricingRepository.php b/src/Insurance/Repository/EntityInsurancePricingRepository.php index 73067076..e133e741 100644 --- a/src/Insurance/Repository/EntityInsurancePricingRepository.php +++ b/src/Insurance/Repository/EntityInsurancePricingRepository.php @@ -3,29 +3,61 @@ namespace App\Insurance\Repository; use App\Insurance\Entity\EntityInsurancePricing; +use App\Shared\Tenant\TenantFilterScope; use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository; use Doctrine\Persistence\ManagerRegistry; class EntityInsurancePricingRepository extends ServiceEntityRepository { - public function __construct(ManagerRegistry $registry) + public function __construct(ManagerRegistry $registry, private readonly TenantFilterScope $tenantScope) { parent::__construct($registry, EntityInsurancePricing::class); } + /** + * قیمت ویزیت به محیطِ *مقصد* تعلق دارد، نه محیطِ کاربرِ درخواست‌دهنده. + * + * مالک کلینیک قیمت ویزیت پزشکِ زیرمجموعه را ثبت می‌کند؛ با TenantFilter روشن این + * کوئری‌ها به محیط خودِ او محدود می‌شدند و ردیفِ موجود را نمی‌دیدند. نتیجه‌اش این + * بود که هر ذخیره ردیفِ تازه‌ای می‌ساخت — و چون `insurance_id` برای «ویزیت آزاد» + * NULL است، قیدِ یکتا هم جلویش را نمی‌گرفت (MySQL چند NULL را تکراری نمی‌شمارد) — + * و خواندنِ بعدی هم همان‌طور کور بود، پس کاربر می‌دید «ثبت نمی‌شود». + * + * مجوزِ دیدنِ آن محیط قبلاً در `InsuranceController::resolveTargetEntity()` سنجیده + * شده است. + * + * @template T + * @param callable():T $query + * @return T + */ + private function unscoped(callable $query): mixed + { + return $this->tenantScope->withoutFilter($query); + } + /** @return EntityInsurancePricing[] */ public function findByEntity(string $entityType, int $entityId): array { - return $this->findBy(['entityType' => $entityType, 'entityId' => $entityId]); + // ترتیب صریح روی id: خواننده‌ها روی حلقه «آخری برنده» حساب می‌کنند و بدون + // ترتیب، ردیف‌های تکراریِ به‌جامانده می‌توانستند قیمت قدیمی را برگردانند. + return $this->unscoped(fn (): array => $this->findBy( + ['entityType' => $entityType, 'entityId' => $entityId], + ['id' => 'ASC'], + )); } public function findOneForInsurance(string $entityType, int $entityId, ?int $insuranceId): ?EntityInsurancePricing { - return $this->findOneBy([ - 'entityType' => $entityType, - 'entityId' => $entityId, - 'insuranceId' => $insuranceId, - ]); + return $this->unscoped(fn (): ?EntityInsurancePricing => $this->findOneBy( + [ + 'entityType' => $entityType, + 'entityId' => $entityId, + 'insuranceId' => $insuranceId, + ], + // ردیف‌های تکراریِ به‌جامانده از دورهٔ باگ: تازه‌ترین معتبر است، وگرنه + // findOneBy می‌توانست به قیمتِ قدیمی برگردد و اصلاح دوباره گم شود. + ['id' => 'DESC'], + )); } public function save(EntityInsurancePricing $entity, bool $flush = true): void diff --git a/tests/Insurance/VisitPriceForMemberDoctorTest.php b/tests/Insurance/VisitPriceForMemberDoctorTest.php new file mode 100644 index 00000000..aa72f07e --- /dev/null +++ b/tests/Insurance/VisitPriceForMemberDoctorTest.php @@ -0,0 +1,90 @@ +createUser(['ROLE_USER', 'ROLE_DOCTOR']); + $doctor = new Doctor($doctorUser, 'دکتر عضو کلینیک'); + $doctor->setMobileNumber($doctorUser->getMobileNumber()); + $this->em->persist($doctor); + + $owner = $this->createUser(['ROLE_USER', 'ROLE_CLINIC']); + $clinic = new Clinic($owner); + $clinic->setName('کلینیک قیمت ویزیت'); + $clinic->getDoctors()->add($doctor); + $this->em->persist($clinic); + $this->em->flush(); + + // محیطِ فعالِ مالک: خودِ کلینیک. همین است که فیلتر را روشن می‌کند. + $this->em->persist(new UserActiveContext( + $this->em->find(User::class, $owner->getId()), + $clinic->getUuid(), + 'clinic', + )); + $this->em->flush(); + + return [$owner, $doctor]; + } + + private function save(User $owner, Doctor $doctor, int $rials): array + { + return $this->authJson('PUT', '/api/v1/insurance-pricing', $owner, [ + 'doctor_uuid' => $doctor->getUuid(), + 'free_visit_price_rials' => $rials, + ]); + } + + private function read(User $owner, Doctor $doctor): array + { + return $this->authJson('GET', '/api/v1/insurance-pricing?doctor_uuid=' . $doctor->getUuid(), $owner); + } + + public function testTheSavedVisitPriceComesBack(): void + { + [$owner, $doctor] = $this->clinicOwnerWithMemberDoctor(); + + $this->save($owner, $doctor, 3_000_000); + self::assertSame(200, $this->responseCode()); + + $body = $this->read($owner, $doctor); + + self::assertSame(3_000_000, $body['data']['free_visit_price_rials']); + } + + public function testSavingTwiceUpdatesTheSameRowInsteadOfPilingUpNewOnes(): void + { + [$owner, $doctor] = $this->clinicOwnerWithMemberDoctor(); + + $this->save($owner, $doctor, 3_000_000); + $this->save($owner, $doctor, 4_500_000); + + $rows = $this->em->getConnection()->fetchAllAssociative( + 'SELECT patient_share_rials FROM entity_insurance_pricing + WHERE entity_type = ? AND entity_id = ? AND insurance_id IS NULL', + ['doctor', $doctor->getId()], + ); + + self::assertCount(1, $rows, 'ذخیرهٔ دوباره نباید ردیف تازه بسازد'); + self::assertSame(4_500_000, (int) $rows[0]['patient_share_rials']); + self::assertSame(4_500_000, $this->read($owner, $doctor)['data']['free_visit_price_rials']); + } +}