From fe48b10fb5858d1f1aea21c213dc8233ee0cbefe Mon Sep 17 00:00:00 2001 From: hamed <15238-genius.ha@users.noreply.drupalcode.org> Date: Sat, 1 Aug 2026 15:01:38 +0330 Subject: [PATCH] fix(plan): stop the segment replace from destroying segments when it rejects MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PUT /service-item/{uuid}/segments deletes and rewrites. deleteForService issues a DQL DELETE that runs immediately, and three validations — duration, occupancy and constraints — only ran afterwards, while building the new rows. A rejected request therefore deleted the service's segments and saved nothing, and the service silently fell back to "one continuous block": different duration, different resources, on every future appointment, with a 422 as the only clue. Validation now happens before the delete, and the delete plus rewrite are one transaction. A test pins it: an unknown constraint is refused and the previous two segments are still there afterwards. While in there, the caps the task asked for and never got: 20 segments and 10 requirements per segment. The availability engine evaluates resource combinations per segment per requirement, so the numbers protect the search rather than the table. They are generous — no real service reaches them, but a bad payload does. The plan response now carries patient_facing_minutes. "Set aside 90 minutes" is wrong for an appointment where 40 of them are waiting for anaesthetic to take effect, and computing it once in the backend stops each client summing it differently. A condition on a fact the request never supplies still evaluates to false — that part was right — but it now logs a warning naming the policy and listing the facts that were available. A rule that hits that line every time is effectively switched off, and nothing said so. A new policy version can no longer start in the past: yesterday's appointments were priced under the previous text, and their price trace points at the version. Backdating makes that trace describe a rule that did not exist. require_resource errors name the policy that demanded the role. Knowing a room is missing does not tell an operator which of ten active rules to look at. Six operators now have a test each. An operator that compares wrongly produces a rule that always matches or never does, and neither raises anything. Co-Authored-By: Claude Opus 5 (1M context) --- docs/api/appointment-plan.md | 17 ++++ docs/api/policy.md | 12 +++ .../Controller/AppointmentPlanController.php | 82 +++++++++++++------ .../Plan/Entity/SegmentTemplate.php | 11 +++ .../Plan/Service/AppointmentPlanBuilder.php | 19 ++++- .../Plan/ValueObject/AppointmentPlan.php | 20 ++++- src/Policy/Controller/PolicyController.php | 16 +++- src/Policy/Service/ConditionEvaluator.php | 18 ++++ tests/Appointment/AppointmentPlanTest.php | 69 ++++++++++++++++ tests/Policy/PolicyEngineTest.php | 75 +++++++++++++++++ 10 files changed, 308 insertions(+), 31 deletions(-) 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