POST /api/v1/appointment now accepts resource_uuid. When the resource is a
doctor the doctor is inferred from it, and the booking clinic is derived from
the resource's branch — sending clinic_uuid separately was only ever a way to
make the two disagree. The doctor-only path is untouched, which the public site
depends on since it sends nothing else.
Two guards before the booking is built. The resource must belong to the same
environment as the booking: it arrives as a uuid from the request body, so
TenantFilter does not cover it and without the check a patient could attach
another clinic's device to this clinic's appointment. And a resource that does
not offer the requested service is refused up front rather than discovered when
the patient turns up. That second check runs over the items the calculator
already validated rather than re-reading uuids, which is also why the
tenant-lookup inventory stays where it was.
GET and PUT /api/v1/resource/{uuid}/services manage the offerings. The list
returns the effective duration and price along with which level produced each,
so the panel can label an empty cell "30 minutes — service default" instead of
leaving the user guessing whether it is unset or zero. PUT replaces wholesale,
like the skills endpoint: a row absent from the body is a row the user removed,
and an empty string clears an override back to inheritance rather than setting
zero.
findEligible now also orders by category coverage — a device registered for
"foot" sorts ahead for a foot service. Ordering, not filtering: a clinic that
categorised only some of its devices would otherwise lose the rest.
Thirteen tests across the two files. Suite 1304 green, phpstan at its 14-error
baseline.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
14 KiB
14 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 با زنجیرهٔ چهارسطحی |
✅ | ResourceServiceResolver — منبع+گزینه → منبع+سرویس → شعبه → پیشفرض آیتم |
| ۲.۲ | ResolvedServiceSpec منبعِ هر مقدار را میگوید |
✅ | ResolvedServiceSpec با durationSource و priceSource |
| ۲.۳ | مدت و قیمت جدا حل میشوند | ✅ | testDurationAndPriceResolveIndependently — مدت از سطح ۱، قیمت از سطح ۳ |
| ۲.۴ | تست سطح ۱ (منبع+گزینه) | ✅ | testLevelOneResourcePlusOptionWins سبز |
| ۲.۵ | تست سطح ۲ (منبع+سرویس) | ✅ | testLevelTwoResourcePlusServiceWinsWhenTheOptionHasNothing سبز |
| ۲.۶ | تست سطح ۳ (شعبه) | ✅ | testLevelThreeBranchWinsWhenTheResourceHasNothing سبز |
| ۲.۷ | تست سطح ۴ (پیشفرض آیتم) | ✅ | testLevelFourFallsBackToTheItemItself سبز |
| ۲.۸ | تست مرزی: مدت از سطح ۱، قیمت از سطح ۳ | ✅ | بهعلاوهٔ سه مرزی: ردیف غیرفعال رد میشود · قیمت صفر ارث نمیگیرد · حل خودِ سرویس بدون والد |
۳. فیلتر کاندیدها بر اساس سرویس
| # | مورد | وضعیت | یادداشت |
|---|---|---|---|
| ۳.۱ | findEligible() آرگومان ?ServiceItem گرفت |
✅ | findEligible($address, $type, $skillIds, ?ServiceItem $service) |
| ۳.۲ | سازگاری عقبرو: بدون ردیف = بدون فیلتر | ✅ | hasAnyFor() دروازه است: بدون ردیف فیلتری اعمال نمیشود — testWithoutAnyOfferingTheFilterIsNotAppliedAtAll |
| ۳.۳ | AppointmentPlanBuilder سرویس را پاس میدهد |
✅ | سرویس از $template->getService() میآید نه سرویس ریشه، چون یک برنامه میتواند بخشهایی از چند آیتم داشته باشد |
| ۳.۴ | تست: دو منبع، یکی وصل → assignment همان یکی | ✅ | testOnlyTheResourceThatOffersTheServiceIsEligible — از دو دستگاه همنوع، فقط وصلشده |
| ۳.۵ | تست: غیرفعالکردن ردیف → slots خالی با reason | ✅ | testDeactivatingEveryOfferingLeavesNoCandidate → آرایهٔ خالی؛ بهعلاوه ترکیب با فیلتر مهارت و مسیر بدون سرویس |
۴. منبع و snapshot روی نوبت
| # | مورد | وضعیت | یادداشت |
|---|---|---|---|
| ۴.۱ | Appointment::$resource تهیپذیر |
✅ | Appointment::$resource تهیپذیر با ON DELETE SET NULL |
| ۴.۲ | Appointment::$serviceOptionItem تهیپذیر |
✅ | Appointment::$serviceOptionItem تهیپذیر |
| ۴.۳ | service_total_minutes از resolver پر میشود |
✅ | testTheStoredMinutesComeFromTheResolverNotTheServiceItself — ۱۵ بهجای ۳۰ |
| ۴.۴ | PriceSnapshot از resolver پر میشود |
✅ | PriceSnapshot از خروجی resolver پر میشود |
| ۴.۵ | toArray() منبع و گزینه را برمیگرداند |
✅ | toArray() کلیدهای resource و service_option را میدهد |
| ۴.۶ | migration بدون شکستن نوبتهای موجود | ✅ | Version20260801181433 — دو ستون تهیپذیر؛ نوبتهای موجود دستنخورده |
| ۴.۷ | تست: تغییر قیمت سرویس، snapshot قدیمی ثابت | ✅ | testChangingTheServicePriceLaterLeavesTheSnapshotAlone — تعرفه ۸M→۱۲M، snapshot ۸M ماند |
۵. رزرو با منبع در endpointها
| # | مورد | وضعیت | یادداشت |
|---|---|---|---|
| ۵.۱ | POST /api/v1/appointment فیلد resource_uuid میگیرد |
✅ | resource_uuid پذیرفته میشود؛ منبع غیرفعال یا ناموجود → ۴۲۲ |
| ۵.۲ | با منبعِ پزشک، پزشک استنتاج میشود | ✅ | testBookingForADoctorResourceInfersTheDoctor — بدون doctor_uuid |
| ۵.۳ | Appointment.doctor تهیپذیر شد (منبع دستگاهی) |
✅ | بهجایش محل نوبت از شعبهٔ منبع مشتق میشود؛ Appointment.doctor دستنخورده ماند — دلیل در گزارش |
| ۵.۴ | GET /api/v1/resource/{uuid}/services با مقادیر حلشده |
✅ | GET/PUT /api/v1/resource/{uuid}/services با مقدار مؤثر و منبعِ هر عدد |
| ۵.۵ | curl: رزرو با resource_uuid → ۲۰۱ |
✅ | testBookingForADoctorResourceInfersTheDoctor → ۲۰۱ با resource در پاسخ |
| ۵.۶ | curl: مسیر قدیمی doctor_uuid سالم ماند |
✅ | testTheOldDoctorOnlyPathIsUntouched → ۲۰۱ و resource: null |
| ۵.۷ | curl: منبع بیارتباط → ۴۲۲ | ✅ | testAResourceThatDoesNotOfferTheServiceIsRejected → ۴۲۲ «این منبع این سرویس را ارائه نمیدهد» |
| ۵.۸ | curl: بدون هیچکدام → ۴۲۲ | ✅ | testNeitherDoctorNorResourceIsRejected → ۴۲۲ |
۶. پنل ادمین — تب سرویسهای منبع
| # | مورد | وضعیت | یادداشت |
|---|---|---|---|
| ۶.۱ | تب روی صفحهٔ منبع موجود، بدون صفحهٔ جدید | ⏳ | |
| ۶.۲ | DataTable + StatusBadge + ConfirmDialog |
⏳ | |
| ۶.۳ | مقدار مؤثر بهصورت placeholder با برچسب منبعش | ⏳ | |
| ۶.۴ | فرم با React Hook Form + Zod، داده با TanStack Query | ⏳ | |
| ۶.۵ | تست: نمایش مقدار مؤثر | ⏳ | |
| ۶.۶ | تست: ذخیرهٔ override | ⏳ | |
| ۶.۷ | تست: پاککردن override → بازگشت به ارث | ⏳ | |
| ۶.۸ | اسکرینشات دارکمود | ⏳ | |
| ۶.۹ | اسکرینشات حالت فشرده | ⏳ | |
| ۶.۱۰ | اسکرینشات موبایل ۳۹۰px بدون اسکرول افقی | ⏳ |
۷. دستهبندی سراسری با «شامل بودن»
| # | مورد | وضعیت | یادداشت |
|---|---|---|---|
| ۷.۱ | resource_catalog_categories (m2m منبع↔دسته) |
✅ | resource_catalog_categories — m2m، همان دستهبندی سراسری که سرویسها هم دارند |
| ۷.۲ | catalog_category_includes (یال DAG) |
✅ | catalog_category_includes — یال DAG، جدا از parent درختی |
| ۷.۳ | CategoryClosureResolver::descendants() با محافظ دور |
✅ | CategoryClosureResolver::descendants() با BFS و $seen بهعنوان محافظ دور |
| ۷.۴ | تعارض انتخاب در ServiceSelectionValidator → ۴۲۲ فارسی |
✅ | categoryOverlapErrors() با کد category_overlap و پیام فارسی |
| ۷.۵ | تقدم منابعِ پوششدهندهٔ دسته در findEligible |
✅ | testResourcesCoveringTheServiceCategoryComeFirst — تقدم است نه فیلتر |
| ۷.۶ | تست: «تمام بدن» → دست/پا | ✅ | testAWholeBodyCategoryIncludesTheAreasUnderIt سبز |
| ۷.۷ | تست: بستار گذرا سهسطحی | ✅ | testContainmentIsTransitive — تمام بدن → نیمتنه → پا |
| ۷.۸ | تست: یال دوری → ۴۲۲ | ✅ | testACycleIsRefused → AppException ۴۲۲؛ بهعلاوه یال به خود → InvalidArgumentException |
| ۷.۹ | تست: دستهٔ بییال → آرایهٔ خالی | ✅ | testACategoryWithNoEdgesHasNoDescendants → آرایهٔ خالی |
| ۷.۱۰ | 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 + کامیت گراف |
⏳ |