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
@@ -39,10 +39,10 @@
| ۱.۱۱ | `SpacingPolicyEngine::forbiddenRanges()` — کوئری نه حلقه | ⚠️ | `spacing` در لحظهٔ رزرو موقت اجرا می‌شود (یک کوئری `MAX(slot_start)`)، نه در تولید کاندید |
| ۱.۱۲ | بازهٔ ممنوعه پیش از تولید کاندید | ✅ | `AppointmentPlanBuilder::applyResourcePolicies` ممنوعیت را **پیش از** رسیدن به موتور دسترس‌پذیری می‌خواند |
| ۱.۱۳ | `combinable=false` → short-circuit؛ `deny` همیشه short-circuit | ✅ | `forbid` = veto؛ بقیهٔ اثرها ترکیب‌پذیرند |
| ۱.۱۴ | فیلد بی‌مقدار → `false` **با لاگ** | ⚠️ | رد می‌شود (`array_key_exists` صریح) ولی **لاگ ندارد** — تسک ۱۰ |
| ۱.۱۴ | فیلد بی‌مقدار → `false` **با لاگ** | | `warning` با نام قانون و فهرست حقایق موجود — قانونی که هر بار به این خط برسد عملاً خاموش است |
| ۱.۱۵ | `PATCH` محتوای قانون وجود ندارد | ✅ | فقط `POST /version` و `activate`/`deactivate` |
| ۱.۱۶ | `policy_version_log` snapshot کامل نگه می‌دارد | ✅ | `toArray()` کامل، نه diff |
| ۱.۱۷ | `valid_from` گذشته در نسخهٔ جدید → ۴۲۲ | ⚠️ | فقط `valid_to < valid_from` رد می‌شود؛ گذشته‌بودن `valid_from` مجاز است چون snapshot نسخهٔ قبلی دست‌نخورده می‌ماند |
| ۱.۱۷ | `valid_from` گذشته در نسخهٔ جدید → ۴۲۲ | | روی `POST /version` رد می‌شود؛ روی نسخهٔ نخست آزاد است. یادداشت قبلی: گذشته‌بودن `valid_from` مجاز است چون snapshot نسخهٔ قبلی دست‌نخورده می‌ماند |
| ۱.۱۸ | شش endpoint شامل `GET /policy-schema` | ✅ | schema · index · create · show · version · activate · deactivate |
| ۱.۱۹ | `PricingPolicyEngine` هر دو منبع را ترکیب می‌کند | ✅ | `mergePolicyDiscounts()` روی سیاست دستی می‌نشیند، سقف روی جمع |
| ۱.۲۰ | `TenantOwnershipChecker` روی هر uuid از request | ✅ | `requirePolicy`/`requireItem`/`requireCategory` — تست ۴۰۴ |
@@ -85,16 +85,16 @@
| # | مورد | وضعیت | یادداشت |
|---|---|---|---|
| ۵.۱ | پیام‌های `forbid` فارسی و قابل فهم بیمار | ✅ | `reason` دلخواه؛ نبودنش → «قانون «X» این عملیات را مجاز نمی‌داند» |
| ۵.۲ | خطای `require_resource` بدون منبع شامل نام نقش | ⚠️ | نام **نقش** و شعبه می‌آید، نام قانون نمی‌آید — تسک ۱۰ |
| ۵.۲ | خطای `require_resource` بدون منبع شامل نام نقش | | نام نقش، شعبه **و نام قانون** — بین ده قانون فعال، حدس زدن کار اپراتور نیست |
## ۶. تست
| # | مورد | وضعیت | یادداشت |
|---|---|---|---|
| ۶.۱ | همهٔ عملگرها × نوع‌ها، `all`/`any`، فیلد ناموجود | ⚠️ | فیلد ناموجود و `all` پوشش دارند؛ تست واحدِ هر شش عملگر ندارد |
| ۶.۱ | همهٔ عملگرها × نوع‌ها، `all`/`any`، فیلد ناموجود | | یازده حالت با dataProvider: هر شش عملگر روی هر دو نتیجه. یادداشت قبلی: تست واحدِ هر شش عملگر ندارد |
| ۶.۲ | جدول ترکیب | ✅ | max · sum · veto تست شدند (union در ۱.۱۳ غیرمستقیم) |
| ۶.۳ | حل تناقض | ✅ | اختصاصی‌بودن و اولویت هر دو |
| ۶.۴ | `spacing` — بازهٔ ممنوعه + کوئری ثابت | ⚠️ | مسیرش تغییر کرد؛ تست اختصاصی ندارد — تسک ۱۳ |
| ۶.۴ | `spacing` — بازهٔ ممنوعه + کوئری ثابت | | `testSpacingRejectsABookingTooCloseToTheLastOne` — نزدیک رد، دور می‌گذرد |
| ۶.۵ | نسخه‌بندی (قانون پنجم) | ✅ | ⭐ `testEditingAPolicyCreatesANewVersionAndTheQuoteRecordsIt` |
| ۶.۶ | یکپارچگی چند دسته end-to-end | ✅ | timing · selection · pricing |
| ۶.۷ | هر فیلد schema قابل extract است | ✅ | ⭐ `PolicyFieldCoverageTest` — فیلدی که هیچ‌جا پر نشود، قانونی می‌سازد که بی‌صدا هرگز مطابقت نمی‌کند |
@@ -123,6 +123,6 @@
| ۸.۶ | `npx tsc --noEmit` و `yarn test` سبز | ✅ | این تسک هیچ فایل فرانتی عوض نکرد |
| ۸.۷ | تست‌های tenant سبز | ✅ | |
| ۸.۸ | `docs/api/*` به‌روز | ✅ | |
| ۸.۹ | دو کلاینت دیگر بررسی شدند | ⚠️ | هیچ قرارداد موجودی تغییر نکرد (فقط کلید افزوده در `breakdown.sources` و خطای جدید در `errors[]`)؛ نمایش پیام‌های `forbid` در `nobat724_front` دیده نشد — تسک ۱۰ |
| ۸.۹ | دو کلاینت دیگر بررسی شدند | ✅ | با graphify بررسی شدند؛ هیچ‌کدام اندپوینت قانون را مصرف نمی‌کنند. یادداشت قبلی: هیچ قرارداد موجودی تغییر نکرد (فقط کلید افزوده در `breakdown.sources` و خطای جدید در `errors[]`)؛ نمایش پیام‌های `forbid` در `nobat724_front` دیده نشد — تسک ۱۰ |
| ۸.۱۰ | commit، سپس `graphify update .` | ✅ | دو کامیت جدا |
| ۸.۱۱ | موارد به‌تعویق با دلیل و تسک مقصد | ✅ | تسک ۱۰: ۱.۱۰، ۱.۱۴، ۳.۳، ۳.۶، ۵.۲، ۶.۷، ۸.۹ · تسک ۱۳: ۱.۱۲، ۶.۴ |