From 1f64b516d2fd1bb66db0989a9836703f30e6ab6c Mon Sep 17 00:00:00 2001 From: hamed <15238-genius.ha@users.noreply.drupalcode.org> Date: Tue, 18 Aug 2026 16:52:04 +0330 Subject: [PATCH] fix(insurance): let a clinic owner save the visit price of a member doctor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The pricing rows are keyed by the *target* tenant, but the tenant filter pins every read to the environment of whoever is asking. A clinic owner setting the free-visit price for one of their doctors was therefore blind to the row that already existed: each save inserted another one — the unique key does not stop it, because insurance_id is NULL for the free-visit row and MySQL does not treat NULLs as equal — and the following read was blind in the same way, so the panel kept showing the old value. From the outside it simply looked like the field would not save. Reads now run outside the filter, the same exception the tenant-insurance repository already makes for the same reason, with authorization still coming from resolveTargetEntity(). findOneForInsurance() takes the newest row so a tenant that already accumulated duplicates converges on the last value the user entered, and a migration collapses those leftovers. Co-Authored-By: Claude Opus 5 (1M context) --- docs/api/insurance.md | 6 ++ migrations/Version20260818131524.php | 48 ++++++++++ .../EntityInsurancePricingRepository.php | 46 ++++++++-- .../VisitPriceForMemberDoctorTest.php | 90 +++++++++++++++++++ 4 files changed, 183 insertions(+), 7 deletions(-) create mode 100644 migrations/Version20260818131524.php create mode 100644 tests/Insurance/VisitPriceForMemberDoctorTest.php 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']); + } +}