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>
159 lines
13 KiB
PHP
159 lines
13 KiB
PHP
<?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 = [];
|
||
}
|