Files
clinicpro/src/Shared/Tenant/GlobalTables.php
T
hamedandClaude Opus 5 6847a473d4 feat(treatment): open a treatment case with snapshotted areas and its sessions
Opening a case copies what must not move afterwards — the session count and the
list of body areas, each with its category name — because a treatment record is
a medical document and editing settings tomorrow must not rewrite what was done
yesterday. The areas are the leaf categories under the service's own category:
"توتال" contains bikini, leg and hand, and treatment happens on those three, not
on the grouping node above them. A category with no children is its own single
area, so "لیزر دست" gets one area rather than none.

Every session in the course is created up front so that "session 5 of 8" has
somewhere to live, but none of them is booked: creating eight real appointments
would lock eight months of slots for a patient who may not attend session three.

CategoryClosureResolver gains leaves(); the graph walk it already does is what
tells a leaf from a grouping node, so this belongs next to descendants() rather
than in a second traversal elsewhere.

TreatmentCase and TreatmentSession carry no money field, and must not: billing
lives on PatientSession, which is created when an appointment is confirmed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-06 16:53:50 +03:30

159 lines
13 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\PracticeDomain\Entity\PracticeDomain::class => 'تاکسونومی سراسری حوزهٔ فعالیت؛ محیط آن را انتخاب می‌کند نه مالکش، و کدش لنگرِ پیاده‌سازی‌های TreatmentWorkflow است',
\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\ItemGroupMember::class => \App\ClinicService\Entity\ItemGroup::class,
// «طول درمانِ این سرویس» جزئی از تعریف همان سرویس است و uuid خودش هیچ‌جا از
// درخواست نمی‌آید — تنها راه رسیدن به آن، uuid سرویس است.
\App\Treatment\Entity\TreatmentProtocol::class => \App\ClinicService\Entity\ServiceItem::class,
\App\Treatment\Entity\TreatmentProtocolStep::class => \App\Treatment\Entity\TreatmentProtocol::class,
\App\Treatment\Entity\TreatmentProtocolStaff::class => \App\Treatment\Entity\TreatmentProtocol::class,
// فهرست نواحیِ یک پرونده فقط از خودِ پرونده پیمایش می‌شود و uuidش هیچ‌جا از
// درخواست نمی‌آید. جلسه و رکورد ناحیه برعکس‌اند — پنل پرسنل uuidشان را مستقیم
// می‌فرستد — پس آن دو جفت محیط خودشان را دارند، نه اینجا.
\App\Treatment\Entity\TreatmentCaseArea::class => \App\Treatment\Entity\TreatmentCase::class,
// یال «این دسته شامل آن دسته است» جزئی از تعریف دستهٔ والد است؛ هر دو سرِ یال
// در یک محیط‌اند و سازندهٔ یال همین را اجبار می‌کند.
\App\ClinicService\Entity\CatalogCategoryInclude::class => \App\ClinicService\Entity\CatalogCategory::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 = [];
}