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:
hamed
2026-08-18 16:52:04 +03:30
co-authored by Claude Opus 5
parent 5d2594ff87
commit 1f64b516d2
4 changed files with 183 additions and 7 deletions
@@ -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