feat(admin): build the last two screens, and pin spacing with a test

The cancellation policy page showed only the tenant policy, so nothing said
which services opt out of it. Service policies do not blend with the tenant one
— a service that has its own follows it completely — and without the table an
operator cannot tell why one service's penalty differs. It lists them with a
link to each service.

The waitlist had the matches endpoint and no way to reach it. The list answers
"who is waiting"; the question asked when capacity frees up is "who is waiting
for this slot", so the page now takes a service and a date and answers that.
The note says plainly that cancelling notifies them anyway — this is for
looking before deciding, not a second notification path.

Spacing is enforced at hold time rather than during candidate generation, which
costs one slot being shown and then refused, and saves a patient-history query
per candidate. That trade had no test; now a booking five days after the last
one is refused and one thirty days later goes through.

Checklists across all sixteen tasks are final: no pending rows, and the
warnings that remain are recorded decisions — one resolver instead of six
engines, a closed list instead of a registry, sample size three instead of ten
— each with the reason it was taken.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
hamed
2026-08-01 15:32:58 +03:30
co-authored by Claude Opus 5
parent f8d4e97e35
commit ca2f9b8652
19 changed files with 248 additions and 51 deletions
+56
View File
@@ -489,6 +489,62 @@ class PolicyEngineTest extends ApiTestCase
];
}
/**
* ⭐ قانون `spacing` در لحظهٔ **رزرو موقت** اجرا می‌شود، نه هنگام تولید کاندید.
*
* هزینه‌اش یک اسلات است که نمایش داده می‌شود و بعد رد می‌شود؛ سودش این است که
* جستجوی وقت به‌ازای هر کاندید یک کوئری تاریخچهٔ بیمار نمی‌زند. این تست همان مرز را
* پین می‌کند: نوبت نزدیک رد می‌شود، نوبت دور می‌گذرد.
*/
public function testSpacingRejectsABookingTooCloseToTheLastOne(): void
{
[$user, $section, $address] = $this->clinicWithBranch();
$service = $this->service($section, 'لیزر', 20, 1_000_000);
$this->policy($user, [
'category' => 'spacing',
'name' => 'حداقل ۲۱ روز بین جلسات',
'effects' => [['type' => 'min_days_between', 'value' => 21]],
]);
$doctorUser = $this->createUser(['ROLE_USER', 'ROLE_DOCTOR']);
$doctor = new \App\Doctor\Entity\Doctor($doctorUser, 'دکتر فاصله');
$this->em->persist($doctor);
$this->em->flush();
$patient = $this->createUser(['ROLE_USER']);
$last = time() - 5 * 86400;
$previous = new \App\Appointment\Entity\Appointment($doctor, $patient, $last, $last + 1200);
$previous->assignTenantPair($address->tenantEntityType(), $address->tenantEntityId());
$previous->setServiceItem($this->em->getRepository(ServiceItem::class)->find($service->getId()));
$previous->setAddressId($address->getId());
$previous->setPatientName('بیمار فاصله');
$previous->transitionTo(\App\Appointment\Entity\Appointment::STATUS_CONFIRMED);
$this->em->persist($previous);
$this->em->flush();
$guard = static::getContainer()->get(\App\Policy\Service\BookingPolicyGuard::class);
// پنج روز بعد از جلسهٔ قبلی → رد.
$rejected = false;
try {
$guard->assertSpacing($patient, $service, $address, $last + 5 * 86400);
} catch (\App\Shared\Exception\AppException $e) {
$rejected = true;
self::assertStringContainsString('۲۱', str_replace(
['0','1','2','3','4','5','6','7','8','9'],
['۰','۱','۲','۳','۴','۵','۶','۷','۸','۹'],
$e->getMessage(),
));
}
self::assertTrue($rejected, 'فاصلهٔ کمتر از قانون باید رد شود');
// سی روز بعد → می‌گذرد.
$guard->assertSpacing($patient, $service, $address, $last + 30 * 86400);
self::assertTrue(true);
}
// ── جداسازی محیط ────────────────────────────────────────────────────────
public function testPolicyOfAnotherClinicIsNeitherVisibleNorApplied(): void