fix(insurance): let a clinic owner save the visit price of a member doctor
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) <noreply@anthropic.com>
This commit is contained in:
@@ -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
|
||||
{
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace DoctrineMigrations;
|
||||
|
||||
use Doctrine\DBAL\Schema\Schema;
|
||||
use Doctrine\Migrations\AbstractMigration;
|
||||
|
||||
/**
|
||||
* پاکسازی ردیفهای تکراریِ «قیمت ویزیت آزاد».
|
||||
*
|
||||
* قید یکتای (entity_type, entity_id, insurance_id) ردیف ویزیت آزاد را نمیپوشاند،
|
||||
* چون `insurance_id` آنجا NULL است و MySQL چند NULL را تکراری نمیشمارد. تا وقتی
|
||||
* خواندنِ ردیفِ موجود زیر TenantFilter کور بود، هر بار ذخیره یک ردیف تازه میساخت.
|
||||
* تازهترین ردیف همان چیزی است که کاربر آخرینبار ثبت کرده، پس بقیه حذف میشوند.
|
||||
*/
|
||||
final class Version20260818131524 extends AbstractMigration
|
||||
{
|
||||
public function getDescription(): string
|
||||
{
|
||||
return 'Collapse duplicate free-visit pricing rows to the newest one per tenant';
|
||||
}
|
||||
|
||||
public function up(Schema $schema): void
|
||||
{
|
||||
$this->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.');
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
<?php
|
||||
|
||||
namespace App\Tests\Insurance;
|
||||
|
||||
use App\Auth\Entity\User;
|
||||
use App\Auth\Entity\UserActiveContext;
|
||||
use App\Clinic\Entity\Clinic;
|
||||
use App\Doctor\Entity\Doctor;
|
||||
use App\Tests\ApiTestCase;
|
||||
|
||||
/**
|
||||
* قیمت ویزیت آزادِ پزشکِ عضو کلینیک، وقتی مالک کلینیک محیطِ خودش را انتخاب کرده.
|
||||
*
|
||||
* محیطِ فعالِ مالک «کلینیک» است ولی مقصدِ این تنظیم «پزشک» — و TenantFilter روی
|
||||
* `EntityInsurancePricing` بسته میشد. کوئریِ خواندن ردیفِ موجود را نمیدید، پس هر
|
||||
* ذخیره ردیفِ تازهای میساخت (قید یکتا ردیفِ ویزیت آزاد را نمیگیرد چون
|
||||
* `insurance_id` آنجا NULL است) و خواندنِ بعدی هم همانطور کور بود: از دید کاربر
|
||||
* «ثبت نمیشود».
|
||||
*/
|
||||
class VisitPriceForMemberDoctorTest extends ApiTestCase
|
||||
{
|
||||
/** @return array{0: User, 1: Doctor} */
|
||||
private function clinicOwnerWithMemberDoctor(): array
|
||||
{
|
||||
$doctorUser = $this->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']);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user