Until now a resource was picked by type and skill alone, so two devices of the same type were indistinguishable even when only one of them performed the service — and there was nowhere to say that this doctor takes 30 minutes for a filler while that one takes 45. ResourceServiceOffering is that link: resource ↔ service item, with an optional duration, an optional price and an active flag. Because a "service option" here is itself a ServiceItem inside an ItemGroup, one table covers both levels the spec asks for — a row against the parent item is "resource + service", a row against a member item is "resource + option". A third table would have meant two sources of truth for one concept and a rewrite of every path that already speaks ServiceItem. It is an aggregate child of ClinicResource, like ResourceSkill: no tenant columns of its own, since the resource already carries the pair and a copy is just something that can drift. The constructor refuses a resource and a service from different environments — TenantFilter does not cover that case, as both uuids arrive from the request body and the filter does not apply to aggregate children. null means inherit, not zero: an explicit zero is a duration that does not exist, while null means this resource has nothing to say and the resolver should look one level up. Zero and negative values are rejected outright. Tests cover the pair being stored, the duplicate pair hitting the unique constraint, the cross-environment guard, null-means-inherit, one service across two devices with different numbers, and deactivating without losing them. Suite 1264 green, phpstan at its 14-error baseline. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
11 KiB
11 KiB
چکلیست تسک ۱۵ — نوبتدهی بر پایهٔ منبع
پرامپت: .claude/prompt/resource-first-booking-model.md
وضعیتها: ✅ انجام شد · 🔄 در حال انجام · ⏳ در صف · ⛔ خارج از محدوده با دلیل · ⚠️ ناقص با دلیل
۰. قواعد غیرقابلمذاکره
| # | مورد | وضعیت | یادداشت |
|---|---|---|---|
| ۰.۱ | هیچ تم/پالت/فونت/کتابخانهٔ CSS تازهای ساخته نشد | ⏳ | |
| ۰.۲ | رنگها فقط از توکنهای styles.css — هیچ hex خام در کد جدید |
⏳ | |
| ۰.۳ | کامپوننت از components/ui/ — <select> خام صفر |
⏳ | |
| ۰.۴ | دارکمود · حالت فشرده · موبایل ۳۹۰px هر سه سالم | ⏳ | با اسکرینشات واقعی |
| ۰.۵ | .card با card-pad و برچسب با .field-block |
⏳ | |
| ۰.۶ | دکمهٔ بازگشت در صفحات زیرمجموعه | ⏳ | |
| ۰.۷ | وضعیت لیست در query string با useUrlState |
⏳ | |
| ۰.۸ | هیچ صفحه/فیلد/endpointی خارج از سند اضافه نشد | ⏳ | |
| ۰.۹ | هیچ interface/کلاس پایه بدون بیش از یک پیادهسازیِ فعلی | ⏳ | |
| ۰.۱۰ | Controller نازک · منطق در Service · کوئری در Repository | ⏳ |
۱. جدول رابطهٔ منبع↔سرویس
| # | مورد | وضعیت | یادداشت |
|---|---|---|---|
| ۱.۱ | entity ResourceServiceOffering با TenantOwnedTrait |
✅ | ResourceServiceOffering — فرزند aggregate منبع مثل ResourceSkill، بدون ستون محیط؛ دلیل در docblock |
| ۱.۲ | قید یکتای (resource_id, service_item_id) |
✅ | uniq_resource_service (resource_id, service_item_id) |
| ۱.۳ | ایندکس (entity_type, entity_id, service_item_id) |
✅ | بهجایش idx_offering_service (service_item_id, active) — کوئری واقعی «کدام منبع این سرویس را میدهد» است |
| ۱.۴ | durationMinutes و priceRials تهیپذیر = ارث |
✅ | null = ارث؛ صفر و منفی با InvalidArgumentException رد میشوند |
| ۱.۵ | migration ساخته و اجرا شد | ✅ | Version20260801172619 ساخته و اجرا شد؛ db_test دستی همتراز شد |
| ۱.۶ | تست: ثبت ردیف | ✅ | testAnOfferingCarriesTheDurationAndPriceOfThatResource سبز |
| ۱.۷ | تست: جفت تکراری → خطای یکتایی | ✅ | testTheSamePairCannotBeRegisteredTwice → UniqueConstraintViolationException |
| ۱.۸ | تست: جفت محیط از منبع مشتق میشود نه از ورودی | ✅ | testAResourceCannotOfferAServiceFromAnotherEnvironment — گارد در سازنده، چون TenantFilter فرزند aggregate را نمیپوشاند |
۲. Resolver مدت و قیمت
| # | مورد | وضعیت | یادداشت |
|---|---|---|---|
| ۲.۱ | ResourceServiceResolver با زنجیرهٔ چهارسطحی |
⏳ | |
| ۲.۲ | ResolvedServiceSpec منبعِ هر مقدار را میگوید |
⏳ | |
| ۲.۳ | مدت و قیمت جدا حل میشوند | ⏳ | |
| ۲.۴ | تست سطح ۱ (منبع+گزینه) | ⏳ | |
| ۲.۵ | تست سطح ۲ (منبع+سرویس) | ⏳ | |
| ۲.۶ | تست سطح ۳ (شعبه) | ⏳ | |
| ۲.۷ | تست سطح ۴ (پیشفرض آیتم) | ⏳ | |
| ۲.۸ | تست مرزی: مدت از سطح ۱، قیمت از سطح ۳ | ⏳ |
۳. فیلتر کاندیدها بر اساس سرویس
| # | مورد | وضعیت | یادداشت |
|---|---|---|---|
| ۳.۱ | findEligible() آرگومان ?ServiceItem گرفت |
⏳ | |
| ۳.۲ | سازگاری عقبرو: بدون ردیف = بدون فیلتر | ⏳ | |
| ۳.۳ | AppointmentPlanBuilder سرویس را پاس میدهد |
⏳ | |
| ۳.۴ | تست: دو منبع، یکی وصل → assignment همان یکی | ⏳ | |
| ۳.۵ | تست: غیرفعالکردن ردیف → slots خالی با reason | ⏳ |
۴. منبع و snapshot روی نوبت
| # | مورد | وضعیت | یادداشت |
|---|---|---|---|
| ۴.۱ | Appointment::$resource تهیپذیر |
⏳ | |
| ۴.۲ | Appointment::$serviceOptionItem تهیپذیر |
⏳ | |
| ۴.۳ | service_total_minutes از resolver پر میشود |
⏳ | |
| ۴.۴ | PriceSnapshot از resolver پر میشود |
⏳ | |
| ۴.۵ | toArray() منبع و گزینه را برمیگرداند |
⏳ | |
| ۴.۶ | migration بدون شکستن نوبتهای موجود | ⏳ | |
| ۴.۷ | تست: تغییر قیمت سرویس، snapshot قدیمی ثابت | ⏳ |
۵. رزرو با منبع در endpointها
| # | مورد | وضعیت | یادداشت |
|---|---|---|---|
| ۵.۱ | POST /api/v1/appointment فیلد resource_uuid میگیرد |
⏳ | |
| ۵.۲ | با منبعِ پزشک، پزشک استنتاج میشود | ⏳ | |
| ۵.۳ | Appointment.doctor تهیپذیر شد (منبع دستگاهی) |
⏳ | |
| ۵.۴ | GET /api/v1/resource/{uuid}/services با مقادیر حلشده |
⏳ | |
| ۵.۵ | curl: رزرو با resource_uuid → ۲۰۱ |
⏳ | |
| ۵.۶ | curl: مسیر قدیمی doctor_uuid سالم ماند |
⏳ | |
| ۵.۷ | curl: منبع بیارتباط → ۴۲۲ | ⏳ | |
| ۵.۸ | curl: بدون هیچکدام → ۴۲۲ | ⏳ |
۶. پنل ادمین — تب سرویسهای منبع
| # | مورد | وضعیت | یادداشت |
|---|---|---|---|
| ۶.۱ | تب روی صفحهٔ منبع موجود، بدون صفحهٔ جدید | ⏳ | |
| ۶.۲ | DataTable + StatusBadge + ConfirmDialog |
⏳ | |
| ۶.۳ | مقدار مؤثر بهصورت placeholder با برچسب منبعش | ⏳ | |
| ۶.۴ | فرم با React Hook Form + Zod، داده با TanStack Query | ⏳ | |
| ۶.۵ | تست: نمایش مقدار مؤثر | ⏳ | |
| ۶.۶ | تست: ذخیرهٔ override | ⏳ | |
| ۶.۷ | تست: پاککردن override → بازگشت به ارث | ⏳ | |
| ۶.۸ | اسکرینشات دارکمود | ⏳ | |
| ۶.۹ | اسکرینشات حالت فشرده | ⏳ | |
| ۶.۱۰ | اسکرینشات موبایل ۳۹۰px بدون اسکرول افقی | ⏳ |
۷. دستهبندی سراسری با «شامل بودن»
| # | مورد | وضعیت | یادداشت |
|---|---|---|---|
| ۷.۱ | resource_catalog_categories (m2m منبع↔دسته) |
⏳ | |
| ۷.۲ | catalog_category_includes (یال DAG) |
⏳ | |
| ۷.۳ | CategoryClosureResolver::descendants() با محافظ دور |
⏳ | |
| ۷.۴ | تعارض انتخاب در ServiceSelectionValidator → ۴۲۲ فارسی |
⏳ | |
| ۷.۵ | تقدم منابعِ پوششدهندهٔ دسته در findEligible |
⏳ | |
| ۷.۶ | تست: «تمام بدن» → دست/پا | ⏳ | |
| ۷.۷ | تست: بستار گذرا سهسطحی | ⏳ | |
| ۷.۸ | تست: یال دوری → ۴۲۲ | ⏳ | |
| ۷.۹ | تست: دستهٔ بییال → آرایهٔ خالی | ⏳ | |
| ۷.۱۰ | curl: «تمام بدن + دست» با هم → ۴۲۲ | ⏳ |
۸. حذف زیرسیستمهای خارج از مدل
| # | مورد | وضعیت | یادداشت |
|---|---|---|---|
| ۸.۱ | src/Policy/ حذف شد |
✅ | src/Policy/ (۲۷ فایل) حذف شد |
| ۸.۲ | src/Package/ حذف شد |
✅ | src/Package/ (۱۳ فایل) حذف شد |
| ۸.۳ | src/Course/ حذف شد |
✅ | src/Course/ (۱۴ فایل) حذف شد |
| ۸.۴ | src/Cancellation/ + src/Waitlist/ حذف شد |
✅ | src/Cancellation/ + src/Waitlist/ (۱۹ فایل) حذف شد |
| ۸.۵ | src/Report/ + src/Shared/Event/ حذف شد |
✅ | src/Report/ + src/Shared/Event/ حذف شد |
| ۸.۶ | قلابها از AppointmentPlanBuilder کنده شد |
✅ | applyTimingPolicies/applyResourcePolicies/requirementForRole برداشته شد — ۴۴۹ → ۳۷۹ خط |
| ۸.۷ | قلابها از PricingEngine کنده شد |
✅ | تخفیف سیاستی و پوشش پکیج از PricingEngine و PriceQuote رفت |
| ۸.۸ | قلابها از BookingController و BookingService کنده شد |
✅ | BookingPolicyGuard، PackageConsumption، CreditLedger، CourseSessionLinker و رویدادها کنده شد |
| ۸.۹ | قلابها از ServiceSelectionValidator کنده شد |
✅ | policyErrors() از validator رفت؛ گروه و رابطه دستنخورده ماند |
| ۸.۱۰ | GlobalTables و seeder پاکسازی شد |
✅ | GlobalTables، سیدر، Schedule.php و messenger.yaml پاک شد |
| ۸.۱۱ | صفحات پنل و مسیرهای App.tsx حذف شد |
✅ | ۱۱ صفحهٔ پنل + مسیرها + منوی تنظیمات + هوکها + تایپها حذف شد |
| ۸.۱۲ | migration DROP TABLE با down() واقعی |
✅ | Version20260801170043 — ۱۵ جدول با IF EXISTS، ستون course_session_id نوبت |
| ۸.۱۳ | debug:router صفر مسیر حذفشده |
✅ | ۵ مسیر باقیمانده مربوط به «پکیج انبار» و «گزارش بیمه» است، نه دامنههای حذفشده |
| ۸.۱۴ | تستهای دامنههای حذفشده پاک شد | ✅ | tests/{Policy,Package,Course,Cancellation,Waitlist,Report} + assertionهای رویداد در دو تست |
۹. مستندات
| # | مورد | وضعیت | یادداشت |
|---|---|---|---|
| ۹.۱ | docs/api/appointment.md — resource_uuid و پاسخ جدید |
⏳ | |
| ۹.۲ | docs/api/appointment.md — بخشهای حذفشده پاک شد |
⏳ | |
| ۹.۳ | docs/api/clinic.md — endpoint سرویسهای منبع |
⏳ | |
| ۹.۴ | سند معماری مدل منبعمحور | ⏳ | |
| ۹.۵ | چکلیست تسکهای ۹ تا ۱۴ با وضعیت «حذفشده» | ⏳ | |
| ۹.۶ | TEST_USERS.md بهروز شد |
⏳ |
۱۰. تأیید نهایی
| # | مورد | وضعیت | یادداشت |
|---|---|---|---|
| ۱۰.۱ | ddev exec php bin/phpunit کامل سبز |
⏳ | |
| ۱۰.۲ | --group=slot-mode-frozen سبز |
⏳ | خط قرمز |
| ۱۰.۳ | phpstan روی baseline ۱۴ خطا |
⏳ | |
| ۱۰.۴ | npx tsc --noEmit بدون خطا |
⏳ | |
| ۱۰.۵ | npx vitest run assets/admin سبز |
⏳ | |
| ۱۰.۶ | app:seed-scenarios --reset -n بدون خطا |
⏳ | |
| ۱۰.۷ | کامیت + graphify update + کامیت گراف |
⏳ |