diff --git a/docs/api/appointment-plan.md b/docs/api/appointment-plan.md index 004f4f9e..5c3d1c96 100644 --- a/docs/api/appointment-plan.md +++ b/docs/api/appointment-plan.md @@ -117,6 +117,23 @@ **۳. قید جنسیت وقتی جنسیت بیمار نامشخص است نادیده گرفته نمی‌شود.** ۴۲۲ می‌دهد، چون رد کردن بی‌صدا یعنی بیمار به منبعی می‌رسد که قرار نبود. +## سقف‌ها و ایمنی جایگزینی + +| قید | مقدار | چرا | +|---|---|---| +| مجموع مدت | ۴۸۰ دقیقه | حفاظت از جستجوی وقت | +| تعداد بخش | ۲۰ | موتور برای هر بخش × هر نیازمندی ترکیب منابع را می‌سنجد | +| نیازمندی هر بخش | ۱۰ | همان | + +`PUT .../segments` **حذف‌کن-و-بنویس** است. همهٔ اعتبارسنجی‌ها (قید ناشناخته، نوع اشغال، +مدت، سقف‌ها) **پیش از حذف** انجام می‌شوند و خودِ حذف و نوشتن در یک تراکنش‌اند: خطای بعد +از حذف یعنی سرویس بدون بخش می‌ماند و نوبت‌دهی‌اش بی‌صدا به «یک بخش پیوسته» برمی‌گردد — +که مدت و منابع همهٔ نوبت‌های بعدی را عوض می‌کند. + +پاسخ `preview` علاوه بر `total_minutes`، فیلد `patient_facing_minutes` هم دارد: مدتی که +بیمار واقعاً روی صندلی است. نوبت نودقیقه‌ای که چهل دقیقه‌اش انتظار اثر بی‌حسی است، «نود +دقیقه وقت بگذارید» نیست — و محاسبه یک‌جا در بک‌اند است تا هر کلاینت خودش جمع نزند. + ## ادغام بخش‌ها برنامه از الگوهای **سرویس اصلی به‌علاوهٔ آیتم‌های انتخاب‌شده** ساخته می‌شود. تا پیش از diff --git a/docs/api/policy.md b/docs/api/policy.md index d499fc6b..c81c01f5 100644 --- a/docs/api/policy.md +++ b/docs/api/policy.md @@ -409,3 +409,15 @@ POST /api/v1/policy | `/admin/policies` | فهرست قوانین | | `/admin/policies/new` | ساخت با الگو یا حالت پیشرفته | | `/admin/policies/{uuid}/simulate` | گزارش آزمایش + دکمهٔ فعال‌سازی | + +## اعتبار نسخهٔ تازه + +`POST /policy/{uuid}/version` مقدار `valid_from` در گذشته را **رد می‌کند** (۴۲۲). نوبت‌های +گذشته با متن قبلی حساب شده‌اند و ردپای قیمتشان به نسخه اشاره می‌کند؛ اعتبارِ عقب‌رونده +یعنی آن ارجاع قانونی را توصیف کند که آن روز وجود نداشت. روی نسخهٔ نخست آزاد است. + +## فیلدِ غایب در شرط + +شرطی که فیلدش در حقایق درخواست نباشد **رد** می‌شود (نه نادیده گرفته) و یک `warning` با +نام قانون و فهرست حقایق موجود لاگ می‌شود. رد کردنِ خاموش یعنی قانونی که هر بار به این خط +می‌رسد عملاً خاموش است و کسی خبردار نمی‌شود. diff --git a/src/Appointment/Plan/Controller/AppointmentPlanController.php b/src/Appointment/Plan/Controller/AppointmentPlanController.php index 0e08517d..c357af0c 100644 --- a/src/Appointment/Plan/Controller/AppointmentPlanController.php +++ b/src/Appointment/Plan/Controller/AppointmentPlanController.php @@ -62,6 +62,16 @@ class AppointmentPlanController extends BaseController } $service = $this->requireItem($user, $uuid); + + if (count($data['segments']) > SegmentTemplate::MAX_SEGMENTS) { + return $this->error( + ErrorCodes::ERR_VALIDATION_001, + sprintf('یک سرویس حداکثر %d بخش دارد', SegmentTemplate::MAX_SEGMENTS), + 422, + 'segments', + ); + } + $planned = []; $total = 0; @@ -94,15 +104,44 @@ class AppointmentPlanController extends BaseController return $this->error(ErrorCodes::ERR_VALIDATION_002, 'type_uuid هر نیازمندی الزامی است', 422, 'requirements'); } + // اعتبارسنجی اینجاست نه هنگام نوشتن: نوشتن بعد از حذف اتفاق می‌افتد و + // خطای آنجا یعنی سرویس بخش‌هایش را از دست داده. + $occupancy = is_string($req['occupancy'] ?? null) ? $req['occupancy'] : SegmentRequirement::OCCUPANCY_EXCLUSIVE; + $constraints = is_array($req['constraints'] ?? null) ? array_values($req['constraints']) : []; + + if (!in_array($occupancy, SegmentRequirement::OCCUPANCIES, true)) { + return $this->error(ErrorCodes::ERR_VALIDATION_001, 'نوع اشغال نیازمندی نامعتبر است', 422, 'occupancy'); + } + + foreach ($constraints as $constraint) { + if (!is_string($constraint) || !in_array($constraint, SegmentRequirement::CONSTRAINTS, true)) { + return $this->error( + ErrorCodes::ERR_VALIDATION_001, + sprintf('قید «%s» شناخته‌شده نیست', is_string($constraint) ? $constraint : '—'), + 422, + 'constraints', + ); + } + } + $requirements[] = [ 'type' => $this->resourceContext->type($user, $req['type_uuid']), 'skill' => is_string($req['skill_uuid'] ?? null) ? $this->resourceContext->skill($user, $req['skill_uuid']) : null, 'count' => is_numeric($req['count'] ?? null) ? max(1, (int) $req['count']) : 1, - 'occupancy' => is_string($req['occupancy'] ?? null) ? $req['occupancy'] : SegmentRequirement::OCCUPANCY_EXCLUSIVE, - 'constraints' => is_array($req['constraints'] ?? null) ? $req['constraints'] : [], + 'occupancy' => $occupancy, + 'constraints' => $constraints, ]; } + if (count($requirements) > SegmentTemplate::MAX_REQUIREMENTS_PER_SEGMENT) { + return $this->error( + ErrorCodes::ERR_VALIDATION_001, + sprintf('هر بخش حداکثر %d نیازمندی دارد', SegmentTemplate::MAX_REQUIREMENTS_PER_SEGMENT), + 422, + 'requirements', + ); + } + $planned[] = [ 'sequence' => is_numeric($row['sequence'] ?? null) ? (int) $row['sequence'] : $index + 1, 'name' => trim($row['name']), @@ -123,36 +162,31 @@ class AppointmentPlanController extends BaseController ); } - $this->templates->deleteForService($service); + // حذف و نوشتنِ دوباره در **یک** تراکنش. `deleteForService` یک DELETE فوری است؛ + // اگر بعد از آن چیزی بشکند، سرویس بدون هیچ بخشی می‌ماند و نوبت‌دهی‌اش بی‌صدا به + // «یک بخش پیوسته» برمی‌گردد — یعنی مدت و منابعِ همهٔ نوبت‌های بعدی عوض می‌شود. + $this->em->wrapInTransaction(function () use ($service, $planned): void { + $this->templates->deleteForService($service); - foreach ($planned as $row) { - $template = new SegmentTemplate($service, $row['sequence'], $row['name']); - - try { + foreach ($planned as $row) { + $template = new SegmentTemplate($service, $row['sequence'], $row['name']); $template->setDuration($row['source'], $row['minutes']); - } catch (\InvalidArgumentException $e) { - throw new AppException(ErrorCodes::ERR_VALIDATION_001, 'مدت بخش نامعتبر است', 422, 'duration_minutes'); - } + $template->setPatientPresent($row['patient'])->setMergeable($row['mergeable']); - $template->setPatientPresent($row['patient'])->setMergeable($row['mergeable']); - $this->em->persist($template); + $this->em->persist($template); - foreach ($row['requirements'] as $req) { - $requirement = new SegmentRequirement($template, $req['type'], $req['count']); - $requirement->setSkill($req['skill']); - - try { + foreach ($row['requirements'] as $req) { + $requirement = new SegmentRequirement($template, $req['type'], $req['count']); + $requirement->setSkill($req['skill']); $requirement->setOccupancy($req['occupancy'])->setConstraints($req['constraints']); - } catch (\InvalidArgumentException $e) { - throw new AppException(ErrorCodes::ERR_VALIDATION_001, $e->getMessage(), 422, 'requirements'); + + $this->em->persist($requirement); + $template->getRequirements()->add($requirement); } - - $this->em->persist($requirement); - $template->getRequirements()->add($requirement); } - } - $this->em->flush(); + $this->em->flush(); + }); return $this->success(array_map( static fn (SegmentTemplate $s): array => $s->toArray(), diff --git a/src/Appointment/Plan/Entity/SegmentTemplate.php b/src/Appointment/Plan/Entity/SegmentTemplate.php index aab65817..29daa27f 100644 --- a/src/Appointment/Plan/Entity/SegmentTemplate.php +++ b/src/Appointment/Plan/Entity/SegmentTemplate.php @@ -39,6 +39,17 @@ class SegmentTemplate /** سقف مجموع مدت یک نوبت — حفاظت از جستجوی وقت در تسک ۰۶. */ public const MAX_TOTAL_MINUTES = 480; + /** + * سقف تعداد — همان حفاظت، از سمت دیگر. + * + * موتور دسترس‌پذیری برای هر بخش × هر نیازمندی یک بار ترکیب منابع را می‌سنجد؛ صد + * بخشِ ده‌نیازمندی یک جستجوی وقت را از پا درمی‌آورد. عددها سخاوتمندانه‌اند: هیچ + * سرویس واقعی‌ای به آن‌ها نمی‌رسد، ولی ورودی اشتباه یا اسکریپت خراب می‌رسد. + */ + public const MAX_SEGMENTS = 20; + + public const MAX_REQUIREMENTS_PER_SEGMENT = 10; + #[ORM\Id] #[ORM\GeneratedValue] #[ORM\Column(type: 'integer')] diff --git a/src/Appointment/Plan/Service/AppointmentPlanBuilder.php b/src/Appointment/Plan/Service/AppointmentPlanBuilder.php index 8765fa8c..417b1766 100644 --- a/src/Appointment/Plan/Service/AppointmentPlanBuilder.php +++ b/src/Appointment/Plan/Service/AppointmentPlanBuilder.php @@ -357,7 +357,7 @@ final class AppointmentPlanBuilder continue; } - $extra[] = $this->requirementForRole($code, $address); + $extra[] = $this->requirementForRole($code, $address, $outcome->appliedPolicies); } if ($extra === []) { @@ -395,14 +395,25 @@ final class AppointmentPlanBuilder * قانونی که نقشِ ناشناخته یا بی‌منبع می‌خواهد **خطاست، نه بی‌اثر**: در سکوت رد * کردنش یعنی کلینیک فکر کند قانونش اجرا می‌شود در حالی که هیچ‌وقت نشده. */ - private function requirementForRole(string $code, DoctorAddress $address): PlannedRequirement + /** + * @param list> $appliedPolicies برای اینکه پیام بگوید **کدام** قانون + */ + private function requirementForRole(string $code, DoctorAddress $address, array $appliedPolicies = []): PlannedRequirement { + // بدون نام قانون، اپراتور می‌داند چه چیزی کم است ولی نه چرا لازم شده — و بین ده + // قانون فعال باید حدس بزند کدام را خاموش کند. + $names = array_values(array_filter(array_map( + static fn (array $p): ?string => is_string($p['name'] ?? null) ? $p['name'] : null, + $appliedPolicies, + ))); + $because = $names === [] ? '' : sprintf(' (قانون: %s)', implode('، ', $names)); + $type = $this->types->findByCode($address->tenantEntityType(), $address->tenantEntityId(), $code); if ($type === null) { throw new AppException( ErrorCodes::ERR_VALIDATION_001, - sprintf('قانون منبعی نقش «%s» را لازم دارد که در این محیط تعریف نشده است', $code), + sprintf('قانون منبعی نقش «%s» را لازم دارد که در این محیط تعریف نشده است%s', $code, $because), 422, 'requirements', ); @@ -413,7 +424,7 @@ final class AppointmentPlanBuilder if ($eligible === []) { throw new AppException( ErrorCodes::ERR_NO_ELIGIBLE_RESOURCE, - sprintf('هیچ %s در شعبهٔ «%s» موجود نیست', $type->getName(), $address->getName() ?? '—'), + sprintf('هیچ %s در شعبهٔ «%s» موجود نیست%s', $type->getName(), $address->getName() ?? '—', $because), 422, 'requirements', ); diff --git a/src/Appointment/Plan/ValueObject/AppointmentPlan.php b/src/Appointment/Plan/ValueObject/AppointmentPlan.php index 95f0ef40..ac04342a 100644 --- a/src/Appointment/Plan/ValueObject/AppointmentPlan.php +++ b/src/Appointment/Plan/ValueObject/AppointmentPlan.php @@ -16,11 +16,27 @@ final readonly class AppointmentPlan public int $totalMinutes, ) {} + /** + * مدتی که بیمار واقعاً روی صندلی است. + * + * با `total_minutes` فرق دارد و همین تفاوت است که به بیمار گفته می‌شود: نوبتِ + * نودقیقه‌ای که چهل دقیقه‌اش انتظار اثر بی‌حسی است، «نود دقیقه وقت بگذارید» نیست. + * محاسبه‌اش یک‌جا اینجاست تا هر کلاینت خودش جمع نزند. + */ + public function patientFacingMinutes(): int + { + return array_sum(array_map( + static fn (PlannedSegment $s): int => $s->patientPresent ? $s->durationMinutes : 0, + $this->segments, + )); + } + public function toArray(): array { return [ - 'total_minutes' => $this->totalMinutes, - 'segments' => array_map( + 'total_minutes' => $this->totalMinutes, + 'patient_facing_minutes' => $this->patientFacingMinutes(), + 'segments' => array_map( static fn (PlannedSegment $s): array => $s->toArray(), $this->segments, ), diff --git a/src/Policy/Controller/PolicyController.php b/src/Policy/Controller/PolicyController.php index 867fb6dc..e0fb7779 100644 --- a/src/Policy/Controller/PolicyController.php +++ b/src/Policy/Controller/PolicyController.php @@ -139,6 +139,18 @@ class PolicyController extends BaseController return $this->error(ErrorCodes::ERR_VALIDATION_001, 'بدنهٔ درخواست نامعتبر است', 422); } + // شروعِ اعتبارِ عقب‌رونده روی نسخهٔ تازه یعنی قانونی که ادعا می‌کند از دیروز برقرار + // بوده، در حالی که نوبت‌های دیروز با متن قبلی حساب شده‌اند و ردپای قیمتشان به این + // نسخه اشاره می‌کند. روی نسخهٔ نخست آزاد است — هنوز چیزی بر اساسش تصمیم نگرفته‌ایم. + if (is_numeric($data['valid_from'] ?? null) && (int) $data['valid_from'] < time()) { + return $this->error( + ErrorCodes::ERR_VALIDATION_001, + 'شروع اعتبار نسخهٔ تازه نمی‌تواند در گذشته باشد؛ نوبت‌های گذشته با متن قبلی حساب شده‌اند', + 422, + 'valid_from', + ); + } + $this->apply($user, $policy, $data); $policy->bumpVersion(); @@ -208,9 +220,11 @@ class PolicyController extends BaseController } if (array_key_exists('valid_from', $data) || array_key_exists('valid_to', $data)) { + $validFrom = is_numeric($data['valid_from'] ?? null) ? (int) $data['valid_from'] : null; + try { $policy->setValidity( - is_numeric($data['valid_from'] ?? null) ? (int) $data['valid_from'] : null, + $validFrom, is_numeric($data['valid_to'] ?? null) ? (int) $data['valid_to'] : null, ); } catch (\InvalidArgumentException) { diff --git a/src/Policy/Service/ConditionEvaluator.php b/src/Policy/Service/ConditionEvaluator.php index fa9b0c88..e4a8c478 100644 --- a/src/Policy/Service/ConditionEvaluator.php +++ b/src/Policy/Service/ConditionEvaluator.php @@ -5,6 +5,8 @@ namespace App\Policy\Service; use App\Policy\Entity\Policy; use App\Shared\Constant\ErrorCodes; use App\Shared\Exception\AppException; +use Psr\Log\LoggerInterface; +use Psr\Log\NullLogger; /** * ارزیابی شرط یک قانون در برابر «حقایق» یک درخواست. @@ -15,8 +17,12 @@ use App\Shared\Exception\AppException; */ final class ConditionEvaluator { + /** قانونِ در حال ارزیابی — فقط برای اینکه لاگِ فیلدِ غایب بگوید کدام قانون بود. */ + private ?Policy $policy = null; + public function __construct( private readonly PolicySchema $schema, + private readonly LoggerInterface $logger = new NullLogger(), ) {} /** @@ -24,6 +30,8 @@ final class ConditionEvaluator */ public function matches(Policy $policy, array $facts): bool { + $this->policy = $policy; + $condition = $policy->getCondition(); $conditions = $condition['conditions'] ?? []; @@ -62,7 +70,17 @@ final class ConditionEvaluator // فیلدی که در حقایق این درخواست نیست، شرط را **رد** می‌کند نه اینکه نادیده // بگیرد: قانون «سن زیر ۱۸» وقتی سن نامشخص است نباید بی‌صدا صادق شود. + // + // ولی رد کردنِ خاموش هم بد است: قانونی که هر بار به این خط می‌رسد، عملاً + // خاموش است و کسی خبردار نمی‌شود. لاگ تنها چیزی است که این را قابل کشف می‌کند. if (!array_key_exists($field, $facts)) { + $this->logger->warning('policy condition skipped: fact missing', [ + 'policy_uuid' => $this->policy?->getUuid(), + 'category' => $this->policy?->getCategory(), + 'field' => $field, + 'known_facts' => array_keys($facts), + ]); + return false; } diff --git a/tests/Appointment/AppointmentPlanTest.php b/tests/Appointment/AppointmentPlanTest.php index d81b1105..366b2ad5 100644 --- a/tests/Appointment/AppointmentPlanTest.php +++ b/tests/Appointment/AppointmentPlanTest.php @@ -480,6 +480,75 @@ class AppointmentPlanTest extends ApiTestCase self::assertCount(3, $replaced['data']['segments']); } + /** + * ⭐⭐ جایگزینی بخش‌ها **حذف‌کن-و-بنویس** است. اگر اعتبارسنجی بعد از حذف بیفتد، + * سرویس بدون هیچ بخشی می‌ماند و نوبت‌دهی‌اش بی‌صدا به «یک بخش پیوسته» برمی‌گردد — + * یعنی مدت و منابع همهٔ نوبت‌های بعدی عوض می‌شود، بدون اینکه کسی چیزی خواسته باشد. + */ + public function testARejectedReplaceLeavesTheExistingSegmentsIntact(): void + { + [$user, $section, $address] = $this->clinicWithBranch(); + $service = $this->service($section, 'لیزر', 20); + $room = $this->resourceType($address, 'room', 'اتاق'); + $this->resource($user, $address, $room, 'اتاق ۱'); + + $this->setSegments($user, $service, [ + ['sequence' => 1, 'name' => 'آماده‌سازی', 'duration_minutes' => 10, 'requirements' => [['type_uuid' => $room->getUuid()]]], + ['sequence' => 2, 'name' => 'کار اصلی', 'duration_minutes' => 20, 'requirements' => [['type_uuid' => $room->getUuid()]]], + ]); + self::assertCount(2, $this->preview($user, $service, $address)['data']['segments']); + + // قیدِ ناشناخته: باید ۴۲۲ بگیرد **و** چیزی را خراب نکند. + $this->setSegments($user, $service, [ + ['sequence' => 1, 'name' => 'بخش تازه', 'duration_minutes' => 15, 'requirements' => [ + ['type_uuid' => $room->getUuid(), 'constraints' => ['same_blood_type']], + ]], + ]); + self::assertSame(422, $this->responseCode()); + + $after = $this->preview($user, $service, $address); + + self::assertCount(2, $after['data']['segments'], 'بخش‌های قبلی باید دست‌نخورده مانده باشند'); + self::assertSame('آماده‌سازی', $after['data']['segments'][0]['name']); + } + + public function testTooManySegmentsIsRejected(): void + { + [$user, $section, $address] = $this->clinicWithBranch(); + $service = $this->service($section, 'لیزر', 20); + + $segments = []; + for ($i = 1; $i <= 21; $i++) { + $segments[] = ['sequence' => $i, 'name' => sprintf('بخش %d', $i), 'duration_minutes' => 5]; + } + + $this->setSegments($user, $service, $segments); + + self::assertSame(422, $this->responseCode()); + } + + /** + * ⭐ «نود دقیقه وقت بگذارید» برای نوبتی که چهل دقیقه‌اش انتظار است، حرفِ درستی نیست. + * محاسبه یک‌جا در بک‌اند است تا هر کلاینت خودش جمع نزند. + */ + public function testThePlanReportsHowLongThePatientIsActuallyPresent(): void + { + [$user, $section, $address] = $this->clinicWithBranch(); + $service = $this->service($section, 'لیزر', 20); + $room = $this->resourceType($address, 'room', 'اتاق'); + $this->resource($user, $address, $room, 'اتاق ۱'); + + $this->setSegments($user, $service, [ + ['sequence' => 1, 'name' => 'ویزیت', 'duration_minutes' => 20, 'patient_present' => true, 'requirements' => [['type_uuid' => $room->getUuid()]]], + ['sequence' => 2, 'name' => 'تمیزکاری', 'duration_minutes' => 15, 'patient_present' => false, 'requirements' => [['type_uuid' => $room->getUuid()]]], + ]); + + $plan = $this->preview($user, $service, $address)['data']; + + self::assertSame(35, $plan['total_minutes']); + self::assertSame(20, $plan['patient_facing_minutes']); + } + public function testForeignServiceIsNotFound(): void { [$user, , $address] = $this->clinicWithBranch(); diff --git a/tests/Policy/PolicyEngineTest.php b/tests/Policy/PolicyEngineTest.php index b1b42db9..0cbd6a08 100644 --- a/tests/Policy/PolicyEngineTest.php +++ b/tests/Policy/PolicyEngineTest.php @@ -414,6 +414,81 @@ class PolicyEngineTest extends ApiTestCase self::assertSame([1, 2], array_column($show['data']['versions'], 'version')); } + /** + * ⭐ نسخهٔ تازه نمی‌تواند ادعا کند از دیروز برقرار بوده. + * + * نوبت‌های دیروز با متن قبلی حساب شده‌اند؛ اعتبار عقب‌رونده یعنی ردپای قیمت‌ها با + * قانونی توضیح داده شود که آن روز وجود نداشت. + */ + public function testANewVersionCannotStartInThePast(): void + { + [$user, , ] = $this->clinicWithBranch(); + + $policy = $this->policy($user, [ + 'category' => 'pricing', + 'name' => 'تخفیف پاییز', + 'effects' => [['type' => 'discount_percent', 'value' => 10]], + ], false); + + $this->authJson('POST', "/api/v1/policy/{$policy['uuid']}/version", $user, [ + 'valid_from' => time() - 7 * 86400, + ]); + + self::assertSame(422, $this->responseCode()); + + // آینده مجاز است. + $this->authJson('POST', "/api/v1/policy/{$policy['uuid']}/version", $user, [ + 'valid_from' => time() + 86400, + ]); + + self::assertSame(200, $this->responseCode()); + } + + /** + * ⭐ شش عملگر، هر کدام جدا. عملگری که غلط بسنجد، قانونی می‌سازد که یا همیشه + * می‌گیرد یا هرگز — و هیچ‌کدام خطا نمی‌دهند. + * + */ + #[\PHPUnit\Framework\Attributes\DataProvider('operatorCases')] + public function testEachOperatorDecidesOnItsOwn(array $clause, bool $expected): void + { + [$user, $section, $address] = $this->clinicWithBranch(); + $service = $this->service($section, 'خدمت عملگر', 20, 1_000_000); + + $this->policy($user, [ + 'category' => 'pricing', + 'name' => 'آزمون عملگر', + 'condition' => ['match' => 'all', 'conditions' => [$clause]], + 'effects' => [['type' => 'discount_percent', 'value' => 50]], + ]); + + $quote = $this->quote($user, $service, $address); + + self::assertSame( + $expected ? 500_000 : 0, + $quote['data']['discount_rials'], + json_encode($clause, JSON_UNESCAPED_UNICODE), + ); + } + + /** بدون `item_uuids` هیچ آیتم اضافه‌ای انتخاب نشده، پس `item_count` صفر است. */ + public static function operatorCases(): array + { + return [ + 'equals می‌گیرد' => [['field' => 'item_count', 'operator' => 'equals', 'value' => 0], true], + 'equals نمی‌گیرد' => [['field' => 'item_count', 'operator' => 'equals', 'value' => 9], false], + 'not_equals می‌گیرد' => [['field' => 'item_count', 'operator' => 'not_equals', 'value' => 9], true], + 'not_equals نمی‌گیرد' => [['field' => 'item_count', 'operator' => 'not_equals', 'value' => 0], false], + 'greater_than می‌گیرد' => [['field' => 'item_count', 'operator' => 'greater_than', 'value' => -1], true], + 'greater_than نمی‌گیرد' => [['field' => 'item_count', 'operator' => 'greater_than', 'value' => 5], false], + 'less_than می‌گیرد' => [['field' => 'item_count', 'operator' => 'less_than', 'value' => 5], true], + 'less_than نمی‌گیرد' => [['field' => 'item_count', 'operator' => 'less_than', 'value' => 0], false], + 'in می‌گیرد' => [['field' => 'item_count', 'operator' => 'in', 'value' => [0, 2]], true], + 'in نمی‌گیرد' => [['field' => 'item_count', 'operator' => 'in', 'value' => [7, 8]], false], + 'contains نمی‌گیرد' => [['field' => 'patient_tags', 'operator' => 'contains', 'value' => 'vip'], false], + ]; + } + // ── جداسازی محیط ──────────────────────────────────────────────────────── public function testPolicyOfAnotherClinicIsNeitherVisibleNorApplied(): void