Files
clinicpro/src/Shared/Tenant/GlobalTables.php
T
hamedandClaude Opus 5 6d7c54508c Let categories contain other categories, and share them with resources
Two gaps against the spec. Resources could not be categorised at all — only
services carried a catalog category — so "this device is for hands and feet"
was unsayable. And CatalogCategory::$parent is a tree built for menu ordering:
one parent per category. Laser areas overlap, so "hand" belongs under both
"whole body" and "upper limb" at once, which a tree cannot express.

Containment is therefore a separate directed acyclic graph
(catalog_category_includes) sitting beside the display hierarchy, and resources
join the existing clinic-wide categories through a many-to-many rather than
growing a parallel list of their own.

CategoryClosureResolver walks it transitively: whole body includes lower body
includes foot, so whole body includes foot without anyone writing that pair
down. The walk reads every edge of the environment in one query and traverses
in memory — a query per level would tie round-trips to graph depth. The visited
set doubles as the cycle guard, so even data that already contains a loop
cannot hang the traversal, and assertNoCycle refuses to create one.

Selection now rejects picking an area together with a category that contains
it: "whole body laser" and "hand laser" in one appointment is a 422 with a
Persian message naming both. This replaces hand-written incompatible_with pairs
for the area case — defined once on the category instead of per item pair —
while that relation stays for incompatibilities that have nothing to do with
areas.

Nine tests, including the two-parents case a tree could not hold, the cycle
refusal, the self-edge, and the empty-graph boundary. TenantSchemaCoverageTest
caught the new edge entity as unclassified; it is registered as an aggregate
child of the parent category, which is what the constructor already enforces.

Suite 1286 green, phpstan at its 14-error baseline.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-01 21:43:12 +03:30

149 lines
12 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
namespace App\Shared\Tenant;
/**
* طبقه‌بندی هر entity نسبت به جداسازی محیط. هر کلاس باید دقیقاً در یکی از این
* چهار وضعیت باشد، وگرنه TenantSchemaCoverageTest قرمز می‌شود:
*
* ۱. خودش جفت (entity_type, entity_id) دارد → TenantFilter پوششش می‌دهد
* ۲. {@see self::ENTITIES} → عمداً سراسری است
* ۳. {@see self::AGGREGATE_CHILDREN} → محیط را از ریشه به ارث می‌برد
* ۴. {@see self::DEFERRED} → هنوز طبقه‌بندی نشده، بدهی ثبت‌شده
*
* این فهرست تنها راه فرار از پوشش tenant است؛ افزودن به آن باید دلیل داشته باشد.
*/
final class GlobalTables
{
/**
* Entityهایی که به هیچ محیطی تعلق ندارند.
*
* @var array<class-string, string> کلاس => دلیل
*/
public const ENTITIES = [
// دادهٔ مرجع مشترک بین همهٔ محیط‌ها
\App\Location\Entity\Province::class => 'تقسیمات کشوری',
\App\Location\Entity\City::class => 'تقسیمات کشوری',
\App\Specialty\Entity\Specialty::class => 'تاکسونومی سراسری تخصص‌ها',
\App\DoctorService\Entity\DoctorService::class => 'تاکسونومی سراسری خدمات، وابسته به تخصص نه به محیط',
\App\Insurance\Entity\Insurance::class => 'فهرست بیمه‌های کشور',
\App\Insurance\Entity\InsuranceCoverageDefault::class => 'پیش‌فرض پوشش بیمه در سطح کشور؛ هر محیط با TenantInsurance بازنویسی‌اش می‌کند',
\App\Tag\Entity\Tag::class => 'تاکسونومی سراسری برچسب — قرینهٔ per-tenant آن TenantTag است',
\App\Config\Entity\SiteConfig::class => 'تنظیمات کل سامانه',
\App\Config\Entity\TaxRateHistory::class => 'نرخ مالیات کشور',
\App\Subscription\Entity\SubscriptionPlan::class => 'پلن‌های فروش، مشترک بین همهٔ مشتریان',
\App\Subscription\Entity\SubscriptionPeriod::class => 'دوره‌های قیمتی همان پلن‌ها',
\App\Sms\Entity\SmsTemplate::class => 'قالب پیامک سامانه',
\App\Sms\Entity\SmsMessageTemplate::class => 'متن پیامک سامانه',
\App\Sms\Entity\SmsLog::class => 'لاگ ارسال؛ فقط شماره و قالب دارد، مالک ندارد',
\App\Shared\Logging\AppLog::class => 'لاگ سراسری برنامه',
\App\Blog\Entity\Blog::class => 'محتوای عمومی مارکت‌پلیس',
\App\Resource\Entity\NationalHoliday::class => 'تعطیلات رسمی کشور؛ per محیط کردنش معنای «کشوری» را از بین می‌برد — محیطی که خلافش کار می‌کند TenantHolidayOverride می‌زند',
// هویت — یک شخص می‌تواند در چند محیط حضور داشته باشد
\App\Auth\Entity\User::class => 'هویت سراسری؛ رابطهٔ بیمار با محیط از patient_records می‌آید',
\App\UserProfile\Entity\UserProfile::class => 'پروفایل شخص، نه دادهٔ محیط',
\App\Auth\Entity\PreRegistration::class => 'پیش‌ثبت‌نام، هنوز به هیچ محیطی وصل نیست',
\App\Auth\Entity\UserActiveContext::class => 'خودش تعیین‌کنندهٔ محیط است؛ فیلتر کردنش حلقه می‌سازد',
// خودِ محیط‌ها
\App\Doctor\Entity\Doctor::class => 'خودش یک محیط است',
\App\Clinic\Entity\Clinic::class => 'خودش یک محیط است',
// دادهٔ عمومی مارکت‌پلیس دربارهٔ پزشک — بیمار می‌نویسد، نه محیط
\App\Rating\Entity\Comment::class => 'نظر عمومی بیمار روی پروفایل پزشک',
\App\Rating\Entity\Like::class => 'لایک عمومی روی همان نظرها',
\App\Rating\Entity\Rate::class => 'امتیاز عمومی بیمار به پزشک',
\App\Representation\Entity\Representation::class => 'نمایندهٔ فروش؛ بالادستِ محیط‌هاست نه داخل یکی',
// رابطهٔ بین دو محیط — فیلتر کردن با یک طرف، طرف دیگر را کور می‌کند
\App\Clinic\Entity\ClinicDoctorPermission::class => 'مجوز پزشکِ عضو در یک کلینیک؛ هویتش خودِ جفت (کلینیک، پزشک) است',
\App\ClinicInvitation\Entity\ClinicDoctorInvitation::class => 'دعوت کلینیک از پزشک؛ پیش از عضویت هر دو طرف باید ببینندش',
\App\Doctor\Entity\DoctorClaimRequest::class => 'درخواست تصاحب پروفایل پزشک؛ متقاضی هنوز صاحب محیط نیست',
// دادهٔ خودِ پزشک، مستقل از اینکه در کدام کلینیک کار می‌کند
\App\Doctor\Entity\DoctorAddress::class => 'آدرس‌های پزشک؛ در همهٔ محیط‌های او یکسان است',
\App\Insurance\Entity\DoctorInsurance::class => 'بیمه‌های طرف قرارداد خودِ پزشک',
// استثنای مستندشده در فاز ۲
\App\Appointment\Entity\Holiday::class => 'clinic=NULL یعنی «همهٔ محیط‌ها»، نه «مطب شخصی» — جفت tenant این را نمی‌تواند بیان کند',
// کیف پولِ شخص — استثنای مستندشده در فاز ۶
\App\Settlement\Entity\WalletTransaction::class => 'کیف پول خودِ شخص است نه محیط: موجودی از مجموع credit−debitِ همان کاربر مشتق می‌شود و payment_id تهی‌پذیر است، پس تفکیک به محیط، موجودی را بی‌معنا می‌کند',
\App\Settlement\Entity\Settlement::class => 'برداشت از همان کیف پولِ شخصی (SettlementController موجودی را با getWalletBalance(user) می‌سنجد)؛ محیط ندارد چون کیف پول ندارد',
];
/**
* فرزندان aggregate: ستون tenant ندارند و محیط را از ریشه به ارث می‌برند.
* ریشه صریح اعلام می‌شود چون بعضی‌شان با FK اسکالر وصل‌اند (نه رابطهٔ Doctrine)
* و از metadata قابل استنتاج نیستند.
*
* ⚠️ TenantFilter روی این‌ها اعمال نمی‌شود. کوئری مستقیم روی این جدول‌ها بدون
* JOIN به ریشه، cross-tenant است — همیشه از ریشه شروع کن.
*
* فرزندی که uuidش از خودِ درخواست می‌آید نباید اینجا بماند: چنین جست‌وجویی
* ذاتاً بی‌لنگر است و تور ایمنی ندارد. هشت مورد از این دست جفت محیط خودشان را
* گرفتند (فاز ۸)؛ باقی‌مانده‌ها فقط از ریشه پیمایش می‌شوند.
*
* @var array<class-string, class-string> فرزند => ریشه
*/
public const AGGREGATE_CHILDREN = [
\App\Appointment\Entity\AppointmentEvent::class => \App\Appointment\Entity\Appointment::class,
\App\Appointment\Plan\Entity\SegmentRequirement::class => \App\Appointment\Plan\Entity\SegmentTemplate::class,
\App\Appointment\Booking\Entity\AppointmentSegment::class => \App\Appointment\Entity\Appointment::class,
// سطل‌ها فقط قیدِ یکتاییِ ردیف اشغال‌اند و هیچ‌وقت مستقیم پرس‌وجو نمی‌شوند.
\App\Appointment\Booking\Entity\OccupancyBucket::class => \App\Appointment\Availability\Entity\ResourceOccupancy::class,
// ریشه‌هاشان خودشان جفت محیط دارند (برخلاف پروندهٔ branch_working_hours در
// تسک ۰۱)، پس ارث‌بری اینجا واقعی است. هیچ‌کدام uuid از request نمی‌گیرند:
// تنها راهشان PUT روی /resource/{uuid}/skills و /resource-pool/{uuid}/members است.
\App\Resource\Entity\ResourceSkill::class => \App\Resource\Entity\ClinicResource::class,
// «این منبع این سرویس را می‌دهد» جزئی از تعریف همان منبع است، نه دادهٔ مستقل.
\App\Resource\Entity\ResourceServiceOffering::class => \App\Resource\Entity\ClinicResource::class,
\App\Resource\Entity\ResourceCalendar::class => \App\Resource\Entity\ClinicResource::class,
\App\Resource\Entity\ResourcePoolMember::class => \App\Resource\Entity\ResourcePool::class,
\App\Patient\Entity\SessionAuditLog::class => \App\Patient\Entity\PatientSession::class,
\App\Patient\Entity\SessionConsumable::class => \App\Patient\Entity\PatientSession::class,
\App\Patient\Entity\SessionService::class => \App\Patient\Entity\PatientSession::class,
\App\ClinicService\Entity\ServiceItemAuditLog::class => \App\ClinicService\Entity\ServiceItem::class,
\App\ClinicService\Entity\ServiceItemConsumable::class => \App\ClinicService\Entity\ServiceItem::class,
\App\ClinicService\Entity\Tariff::class => \App\ClinicService\Entity\ServiceItem::class,
\App\ClinicService\Entity\ItemGroupMember::class => \App\ClinicService\Entity\ItemGroup::class,
// یال «این دسته شامل آن دسته است» جزئی از تعریف دستهٔ والد است؛ هر دو سرِ یال
// در یک محیط‌اند و سازندهٔ یال همین را اجبار می‌کند.
\App\ClinicService\Entity\CatalogCategoryInclude::class => \App\ClinicService\Entity\CatalogCategory::class,
\App\Pricing\Entity\PriceListItem::class => \App\Pricing\Entity\PriceList::class,
\App\Billing\Entity\ClaimItem::class => \App\Billing\Entity\Claim::class,
\App\Billing\Entity\ClaimStatusLog::class => \App\Billing\Entity\Claim::class,
\App\Billing\Entity\InvoiceItem::class => \App\Billing\Entity\Invoice::class,
\App\Inventory\Entity\InventoryPackageItem::class => \App\Inventory\Entity\InventoryPackage::class,
\App\Insurance\Entity\TenantInsuranceCategoryCoverage::class => \App\Insurance\Entity\TenantInsurance::class,
\App\Insurance\Entity\TenantServiceCoverage::class => \App\Insurance\Entity\TenantInsurance::class,
\App\Sms\Entity\SmsWalletTransaction::class => \App\Sms\Entity\SmsWallet::class,
\App\Payment\Entity\PaymentLog::class => \App\Payment\Entity\Payment::class,
\App\Settlement\Entity\FinancialBreakdown::class => \App\Payment\Entity\Payment::class,
\App\Secretary\Entity\SecretaryEarning::class => \App\Settlement\Entity\FinancialBreakdown::class,
];
/**
* بدهیِ طبقه‌بندی: کلاسی که هنوز تصمیمی درباره‌اش گرفته نشده.
*
* فاز ۶ آخرین هشت موردش را تعیین تکلیف کرد و اکنون خالی است. خالی بماند:
* هر افزوده‌ای یعنی جدولی بیرون از هر تضمینی مانده. اگر تصمیم واقعاً به تحلیل
* بیشتری نیاز دارد، همین‌جا با دلیل ثبتش کن — ولی TenantSchemaCoverageTest
* خالی‌بودن را اجبار می‌کند تا این کار بی‌صدا نگذرد.
*
* @var array<class-string, string>
*/
public const DEFERRED = [];
}