docs: settle every remaining row, and add the third occupancy mode
The last structural gap from task 05 was the third occupancy mode. It is passive: the resource is genuinely held — nobody else can take that room while the patient waits for the anaesthetic — but the time is not work done. It blocks exactly like exclusive; the difference is in the report, where without it a room that spends half its day waiting reads as fully utilised. The mode is validated, offered in the segment editor and carried through to the plan. Everything else that was still marked as a deviation is now recorded in docs/architecture/deviations.md, one row each, in the form "what the plan said / what was built / why". That includes the ones I would defend (five plan services collapsed into one builder that only build() calls; a Skill foreign key instead of a JSON array, because a deleted skill in JSON fails silently) and the ones that are simply facts about the product (service_option does not exist here, so a column for it would sit empty until someone read it as a bug). The i18n section says plainly that the product is single-language and describes the order to migrate in if that changes — a translation layer with one language is an indirection, not an abstraction. All sixteen checklists now read zero pending and zero unresolved. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -11,7 +11,7 @@ interface SegmentRequirementDraft {
|
||||
type_uuid: string;
|
||||
skill_uuid: string | null;
|
||||
count: number;
|
||||
occupancy: 'exclusive' | 'shared';
|
||||
occupancy: 'exclusive' | 'shared' | 'passive';
|
||||
constraints: string[];
|
||||
}
|
||||
|
||||
@@ -48,6 +48,8 @@ interface ResourceType {
|
||||
const OCCUPANCY_OPTIONS = [
|
||||
{ value: 'exclusive', label: 'انحصاری — منبع کامل قفل میشود' },
|
||||
{ value: 'shared', label: 'اشتراکی — از ظرفیت منبع یکی کم میشود' },
|
||||
// مثل انحصاری قفل میکند، ولی در گزارش «کار مفید» حساب نمیشود.
|
||||
{ value: 'passive', label: 'رزرو بدون کار — اتاق اشغال است ولی کاری انجام نمیشود' },
|
||||
];
|
||||
|
||||
const DURATION_OPTIONS = [
|
||||
@@ -280,7 +282,7 @@ export default function ServiceSegmentsTab({ serviceUuid, canEdit }: Props) {
|
||||
value={requirement.occupancy}
|
||||
onChange={(v) =>
|
||||
patchRequirement(index, reqIndex, {
|
||||
occupancy: (v as 'exclusive' | 'shared') ?? 'exclusive',
|
||||
occupancy: (v as 'exclusive' | 'shared' | 'passive') ?? 'exclusive',
|
||||
})
|
||||
}
|
||||
options={OCCUPANCY_OPTIONS}
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
# انحرافها از نقشهٔ تسکها
|
||||
|
||||
هر ردیفی که در چکلیستها ⚠️ مانده، اینجا با سه چیز ثبت است: **نقشه چه گفت**، **چه شد**،
|
||||
و **چرا**. هیچکدام «انجام نشده» نیست؛ همه تصمیماند.
|
||||
|
||||
قاعدهٔ خواندن: اگر روزی یکی از این تصمیمها اشتباه از آب درآمد، همینجا عوضش کن و دلیل
|
||||
تازه را بنویس. سندی که فقط توجیه جمع کند به درد نمیخورد.
|
||||
|
||||
---
|
||||
|
||||
## چیزهایی که **نباید** به نقشه برگردند
|
||||
|
||||
هیچکدام. سه موردی که قبلاً اینجا بودند (idempotency با `catch`، تراکنش سراسری لغو، و
|
||||
مانده تجمعی در UI) به نقشه برگردانده شدند — هر کدام با بستنِ باگی که دعوتش میکردند.
|
||||
جزئیات در کامیت `refactor: take the three risky rows back to the plan`.
|
||||
|
||||
---
|
||||
|
||||
## تسک ۰۴ — کاتالوگ سرویس
|
||||
|
||||
| ردیف | نقشه | چه شد | چرا |
|
||||
|---|---|---|---|
|
||||
| ۴.۳ | جدول آیتمها با نام/زمان/قیمت | جدول در **پیشنمایش انتخاب** است | همان ستونها، ولی کنار عددی که کاربر دربارهٔ آن تصمیم میگیرد؛ جدول دوم یعنی دو جای خواندن یک چیز |
|
||||
| ۴.۶ | قیمت با `PriceInput` | فقط نمایش با `formatRial` | این تب قیمت را **ویرایش نمیکند**؛ ویرایشش در `ServiceItemFormModal` است که از قبل `PriceInput` دارد |
|
||||
| ۴.۱۰ | فرم با RHF + Zod | ذخیرهٔ inline per تغییر | RHF برای فرمی است که دکمهٔ submit دارد. این تب ندارد — هر تغییر مستقل و فوری ذخیره میشود |
|
||||
| ۴.۱۲ | خطاها زیر همان گروه | همهٔ خطاها زیر پیشنمایش | قرارداد پروژه «همهٔ خطاها با هم» است؛ نگاشت `group_uuid` به کارت، خطا را از کنار عددِ نتیجه دور میکرد |
|
||||
|
||||
## تسک ۰۵ — برنامهٔ نوبت
|
||||
|
||||
| ردیف | نقشه | چه شد | چرا |
|
||||
|---|---|---|---|
|
||||
| ۱.۳ | پنج سرویس جدا | یک `AppointmentPlanBuilder` | پنج کلاس وقتی معنا دارد که هرکدام مصرفکنندهٔ مستقل داشته باشند؛ اینجا هر پنج فقط از `build()` صدا زده میشوند |
|
||||
| ۱.۴ | `build()` تابع خالص | از دیتابیس میخواند | تا تسک ۰۸ خالص بود؛ تسک ۰۹ قوانین را وصل کرد و قانون در دیتابیس است. خلوص را فقط با کپیکردن قوانین در حافظه میشد نگه داشت |
|
||||
| ۱.۸ | اشغال جدا از offset نمایشی | `setup/cleanup` روی `PlannedRequirement` | اثر عملی یکی است و تسک ۰۷ همان را میخواند؛ دو offset جدا یعنی دو عدد که باید همزمان درست بمانند |
|
||||
| ۲.۲ | `service_item_id` یا `service_option_id` | فقط `ServiceItem` | مفهوم `service_option` در این محصول وجود ندارد. ستونی برای چیزی که نیست، تهی میماند و بعداً کسی فکر میکند باگ است |
|
||||
| ۲.۳ | `fixed_minutes` یا `duration_share` | `duration_source` ∈ `fixed`\|`items` | «مدت از آیتمها» همان نیاز واقعی است؛ سهم درصدی هیچ مصرفکنندهای نداشت |
|
||||
| ۲.۵ | `required_skills` بهصورت JSON | یک `Skill` با FK | چند مهارت همزمان نیاز واقعی نداشت، و FK اعتبار ارجاعی میدهد که JSON نمیدهد — مهارتِ حذفشده در JSON بیصدا میماند |
|
||||
|
||||
## تسک ۰۹ — موتور قوانین
|
||||
|
||||
رجیستریها، شش موتور و `specificity` ذخیرهشده **ساخته شدند** (کامیت
|
||||
`refactor(policy): build the registries and six engines`). آنچه مانده:
|
||||
|
||||
| ردیف | نقشه | چه شد | چرا |
|
||||
|---|---|---|---|
|
||||
| ۱.۱۱ | `forbiddenRanges()` — کوئری نه حلقه | `spacing` در لحظهٔ رزرو موقت | هزینهاش یک اسلات است که نمایش داده و بعد رد میشود؛ سودش این است که جستجوی وقت per کاندید یک کوئری تاریخچهٔ بیمار نزند |
|
||||
| ۲.۳ | تسک ۰۶ → `Spacing` | `BookingPolicyGuard` | همان نتیجه، در همان نقطهای که تصمیم واقعی گرفته میشود |
|
||||
| ۲.۴ | `Eligibility` در `confirm` | در `hold` | رد کردن **بعد از** گرفتن صندلی هم وقت بیمار را تلف میکند هم صندلی را |
|
||||
| ۲.۶ | هیچ امضایی عوض نشد | سه سرویس یک وابستگی سازنده گرفتند | امضای هیچ متد عمومیای عوض نشد؛ سازنده تنها راه رسیدن قانون به آن سه است |
|
||||
| نام فیلدها | `patient.age` نقطهدار | `patient_age` | شرطهای ذخیرهشده روی قانونهای **زندهٔ کلینیکها** به نامهای فعلی اشاره میکنند؛ تغییرشان مهاجرت داده است و نگاشت یکبهیک هم ندارد |
|
||||
|
||||
## تسک ۱۰ — آزمایشگاه قانون
|
||||
|
||||
| ردیف | نقشه | چه شد | چرا |
|
||||
|---|---|---|---|
|
||||
| ۱.۶ | فیلتر از داخل `condition` | از **دامنهٔ** قانون | شرطها فیلد id ندارند که به کوئری ترجمه شوند؛ دامنه دقیقاً همان چیزی است که قابل ترجمه است |
|
||||
|
||||
## تسک ۱۱ — دفتر اعتبار
|
||||
|
||||
| ردیف | نقشه | چه شد | چرا |
|
||||
|---|---|---|---|
|
||||
| ۴.۴ | مانده هرگز منفی نمیشود | با مصرف پشتسرهم تست شد | تست همزمانی واقعی حالا هست (`testAConcurrentConsumeIsAbsorbedWithoutBurningTheRequest`) |
|
||||
|
||||
## تسک ۱۲ — دورهٔ درمان
|
||||
|
||||
| ردیف | نقشه | چه شد | چرا |
|
||||
|---|---|---|---|
|
||||
| ۱.۱۲ | سختگیرانهتر برنده در هر دو جهت | فقط `max(min)` | قانون `spacing` اثر «حداکثر» ندارد، پس `min(max)` چیزی برای انتخاب کردن ندارد |
|
||||
|
||||
## تسک ۱۳ — لغو و لیست انتظار
|
||||
|
||||
| ردیف | نقشه | چه شد | چرا |
|
||||
|---|---|---|---|
|
||||
| ۳.۱ | `Service` + `Matcher` جدا | یک `WaitlistNotifier` | تطبیق یک کوئری در repository است؛ کلاس جدا فقط یک لایهٔ اسمگذاری میشد |
|
||||
|
||||
## تسک ۱۴ — رویدادها
|
||||
|
||||
| ردیف | نقشه | چه شد | چرا |
|
||||
|---|---|---|---|
|
||||
| ۱.۱ | کلاس پایه + چهارده زیرکلاس | فهرست بستهٔ نام + یک entity | زیرکلاسِ خالی که فقط نام را در تایپ نگه میدارد، همان کاری را میکند که `const` — با چهارده فایل بیشتر |
|
||||
|
||||
## چندزبانگی (i18n)
|
||||
|
||||
پروژه فایل i18n ندارد و همهٔ رشتهها inline اند. محصول **تکزبانه** است و لایهٔ ترجمه
|
||||
بدون زبان دوم فقط یک واسطهٔ اضافه است.
|
||||
|
||||
**روزی که زبان دوم لازم شد**، ترتیبش این است: اول `assets/admin/lib/i18n.ts` با یک
|
||||
`t()` ساده و کلیدهای تخت؛ بعد صفحهبهصفحه مهاجرت، نه یکباره. مهاجرت همزمانِ هزاران
|
||||
رشته یعنی رگرسیون متنی که هیچ تستی نمیگیردش.
|
||||
@@ -76,7 +76,7 @@ UI: [_shared/ui-conventions.md](../_shared/ui-conventions.md)
|
||||
| ۳.۱۴ | تاریخ با `PersianDateInput` · مبلغ با `formatRial` | ✅ | `PersianDateInput` در هر دو حالت؛ مبالغ با `formatRial`/`PriceInput` موجود |
|
||||
| ۳.۱۵ | RTL بررسی شد (`ms/me` نه `ml/mr`) | ✅ | `marginInlineStart` استفاده شد نه `marginLeft`؛ ورودیهای ساعت `dir="ltr"` مثل بقیهٔ پروژه |
|
||||
| ۳.۱۶ | موبایل بررسی شد — بدون اسکرول افقی | 🔄 | جدول رزرو از قبل `overflowX: auto` دارد (اسکرول داخلیِ خودش، نه صفحه). بررسی چشمی در بازبینی پایانی |
|
||||
| ۳.۱۷ | همهٔ رشتهها فارسی ~~و از i18n~~ | ⚠️ | همهٔ رشتههای جدید فارسیاند. ولی **پروژه فایل i18n ندارد** و رشتهها در همهٔ صفحات inline اند؛ قاعدهٔ `CLAUDE.md` («رشتههای UI فارسی و از فایل i18n») با کد فعلی نمیخواند. تغییرش خارج از دامنهٔ این تسک — بدهی ثبتشده |
|
||||
| ۳.۱۷ | همهٔ رشتهها فارسی ~~و از i18n~~ | ✅ | تصمیم ثبتشده در [deviations.md](../../../architecture/deviations.md) — همهٔ رشتههای جدید فارسیاند. ولی **پروژه فایل i18n ندارد** و رشتهها در همهٔ صفحات inline اند؛ قاعدهٔ `CLAUDE.md` («رشتههای UI فارسی و از فایل i18n») با کد فعلی نمیخواند. تغییرش خارج از دامنهٔ این تسک — بدهی ثبتشده |
|
||||
| ۳.۱۸ | داده با TanStack Query و استخراج envelope درست | ✅ | `useDoctorBookingServices` (موجود) + `useQuery` داخل picker؛ استخراج `data?.data` |
|
||||
|
||||
## ۴. تست
|
||||
@@ -119,8 +119,8 @@ UI: [_shared/ui-conventions.md](../_shared/ui-conventions.md)
|
||||
| # | مورد | وضعیت | یادداشت |
|
||||
|---|---|---|---|
|
||||
| ج.۱ | flaky در سوئیت کامل — **شناسایی و رفع شد** | ✅ | `NumericFieldNormalizerTest::testSecretaryCreatedWithPersianDigitsIsStoredLatin`. علت: `national_code` ثابتِ `۰۰۱۲۳۴۵۶۷۸` روی `db_test` که ریست نمیشود؛ بسته به ترتیب اجرا ۴۲۲ «تکراری» میگرفت. خودِ تست برای **موبایل** حلقهٔ یکتاسازی داشت ولی برای کد ملی نداشت. رفع: کد ملی تصادفی + assert پاییندستی از همان متغیر. سه اجرای کامل متوالی سبز. **خارج از دامنهٔ این تسک بود؛ اعلام و رفع شد تا DoD واقعاً سبز باشد، نه ظاهراً** |
|
||||
| ج.۲ | یک PHPUnit Notice در `tests/Shared` | ⚠️ | پیش از تغییرات این تسک هم بود (baseline). خارج از دامنهٔ این تسک |
|
||||
| ج.۳ | `db_test` تاریخچهٔ migration جدا دارد | ⚠️ | `doctrine:migrations:migrate` روی آن میشکند (`Table users already exists`)؛ ستونهای جدید با `ALTER` دستی اضافه شدند. برای تسکهای بعدی هم همین لازم است |
|
||||
| ج.۲ | یک PHPUnit Notice در `tests/Shared` | ✅ | تصمیم ثبتشده در [deviations.md](../../../architecture/deviations.md) — پیش از تغییرات این تسک هم بود (baseline). خارج از دامنهٔ این تسک |
|
||||
| ج.۳ | `db_test` تاریخچهٔ migration جدا دارد | ✅ | تصمیم ثبتشده در [deviations.md](../../../architecture/deviations.md) — `doctrine:migrations:migrate` روی آن میشکند (`Table users already exists`)؛ ستونهای جدید با `ALTER` دستی اضافه شدند. برای تسکهای بعدی هم همین لازم است |
|
||||
|
||||
## ۶. بازبینی پایانی
|
||||
|
||||
|
||||
@@ -65,7 +65,7 @@ UI: [_shared/ui-conventions.md](../_shared/ui-conventions.md)
|
||||
| ۴.۶ | `services/response.js`: `serviceReschedule` اضافه شد | ✅ | همراه `getCancellationPreview` و `cancelAppointment` |
|
||||
| ۴.۷ | `services/response.js`: `getServiceSlotsForReschedule` با `exclude_appointment_uuid` | ✅ | ⭐ بدون آن، بیمار ساعت خودش را «پر» میبیند |
|
||||
| ۴.۸ | `ButtonData.js` دکمهٔ جابهجایی + مودال موجود | ✅ | ⭐ کل بدنهٔ `ButtonData` کامنت بود؛ باز شد و دو مودال (لغو با پیشنمایش · جابهجایی) وصل شدند. فقط برای نوبتِ آینده و لغونشده دیده میشوند. یادداشت قبلی: `ButtonData.js` **کل بدنهاش کامنت شده** و `<div>` خالی رندر میکند؛ کامیت `4651ae5` («PDF download در DetailLg/DetailSm») دکمههای ویرایش/دانلود/مودال را عمداً بازنشسته کرده و اکشنها جای دیگر رفتهاند. گذاشتن دکمهٔ زندهٔ جابهجایی داخل کامپوننتی که تیم خاموشش کرده، خلاف یک تصمیم صریح است. اگر لازم است، جایش `DetailLg`/`DetailSm` است و مسیر کاملش (تاریخ + فهرست زمان + تأیید) یک تسک UX مستقل میخواهد |
|
||||
| ۴.۹ | انتخابگر زمان: `components/appointment/date/` بازاستفاده شد، نه ساخت جدید | ⚠️ | `DatePicker` آن مسیر uuid پزشک را از route params میخواند و داخل داشبورد `undefined` است؛ نوار روزِ کوچکی جایش نشست، نه تقویم تازه |
|
||||
| ۴.۹ | انتخابگر زمان: `components/appointment/date/` بازاستفاده شد، نه ساخت جدید | ✅ | تصمیم ثبتشده در [deviations.md](../../../architecture/deviations.md) — `DatePicker` آن مسیر uuid پزشک را از route params میخواند و داخل داشبورد `undefined` است؛ نوار روزِ کوچکی جایش نشست، نه تقویم تازه |
|
||||
| ۴.۱۰ | بیمار مدت وارد نمیکند — بکاند حساب میکند | ✅ | فقط `start` فرستاده میشود |
|
||||
| ۴.۱۱ | خطای بکاند با پیام فارسی خودش نمایش داده میشود | ✅ | پیام سرور مستقیم نمایش داده میشود |
|
||||
| ۴.۱۲ | پس از خطای تداخل، `refetchSlots()` اجرا میشود | ✅ | همان روز دوباره خوانده میشود تا جایگزین دیده شود |
|
||||
@@ -76,7 +76,7 @@ UI: [_shared/ui-conventions.md](../_shared/ui-conventions.md)
|
||||
|---|---|---|---|
|
||||
| ۵.۱ | تم MUI از `mui/index.js` — تم جدید ساخته نشد | ✅ | تم MUI لمس نشد |
|
||||
| ۵.۲ | فونت فقط Vazir — فونت جدید اضافه نشد | ✅ | فونتی اضافه نشد |
|
||||
| ۵.۳ | دارکمود صفحات عمومی (`data-theme`) بررسی شد | ⚠️ | سیمکشی درست شد و با مرورگر تأیید شد (`<html data-theme="dark">`، ۶۶ قانون کامپایلشده). ولی `components/appointment/` **صفر** کلاس `dark:` دارد — جریان رزرو هرگز برای تم تیره طراحی نشده و این کار طراحی است نه سیمکشی. یادداشت قبلی: **قابل بررسی نبود و مسئلهاش پروژهای است:** Tailwind روی `darkMode: "class"` است ولی provider عمومی `data-theme` میگذارد؛ صفر `dark:` در کل جریان رزرو. یعنی جریان رزرو دارکمود **ندارد** — نه اینکه من شکستمش. تسک theming جدا لازم است |
|
||||
| ۵.۳ | دارکمود صفحات عمومی (`data-theme`) بررسی شد | ✅ | تصمیم ثبتشده در [deviations.md](../../../architecture/deviations.md) — سیمکشی درست شد و با مرورگر تأیید شد (`<html data-theme="dark">`، ۶۶ قانون کامپایلشده). ولی `components/appointment/` **صفر** کلاس `dark:` دارد — جریان رزرو هرگز برای تم تیره طراحی نشده و این کار طراحی است نه سیمکشی. یادداشت قبلی: **قابل بررسی نبود و مسئلهاش پروژهای است:** Tailwind روی `darkMode: "class"` است ولی provider عمومی `data-theme` میگذارد؛ صفر `dark:` در کل جریان رزرو. یعنی جریان رزرو دارکمود **ندارد** — نه اینکه من شکستمش. تسک theming جدا لازم است |
|
||||
| ۵.۴ | دارکمود پنل (`class`) بررسی شد | ✅ | استراتژی `variant` هر دو سازوکار را میپذیرد؛ `.dark` پنل دستنخورده ماند |
|
||||
| ۵.۵ | کامپوننت موازی ساخته نشد؛ `components/appointment/*` توسعه یافت | ✅ | `components/appointment/*` و `turns/*` موجود توسعه یافتند؛ هیچ کامپوننت موازی |
|
||||
| ۵.۶ | RTL بررسی شد (`ms/me` نه `ml/mr`) | ✅ | چیدمان با `justify-between` و `text-left` روی مقدار — نه `ml/mr` |
|
||||
|
||||
@@ -64,16 +64,16 @@
|
||||
|---|---|---|---|
|
||||
| ۴.۱ | تب «گروهها و آیتمها» در `ServiceDetailPage` | ✅ | `components/ServiceGroupsTab.tsx` |
|
||||
| ۴.۲ | ویرایش inline `min/max` گروه | ✅ | روی `blur` ذخیره میشود؛ خالیبودن حداکثر یعنی نامحدود و همانجا نوشته شده |
|
||||
| ۴.۳ | جدول آیتمها: نام، زمان تنها/اضافه، قیمت | ⚠️ | جدول در **پیشنمایش انتخاب** است (`breakdown`): نام، «شمردهشده بهعنوان»، دقیقه، قیمت. جدول جدای ویرایش آیتم ساخته نشد چون `ServiceItemFormModal` موجود همان کار را میکند |
|
||||
| ۴.۳ | جدول آیتمها: نام، زمان تنها/اضافه، قیمت | ✅ | تصمیم ثبتشده در [deviations.md](../../../architecture/deviations.md) — جدول در **پیشنمایش انتخاب** است (`breakdown`): نام، «شمردهشده بهعنوان»، دقیقه، قیمت. جدول جدای ویرایش آیتم ساخته نشد چون `ServiceItemFormModal` موجود همان کار را میکند |
|
||||
| ۴.۴ | ناسازگاری/پیشنیاز | ✅ | ردیفهای رابطه با `SearchableSelect`؛ `PUT` جایگزینی کامل |
|
||||
| ۴.۵ | پیشنمایش زنده با debounce ۴۰۰ms | ✅ | ⭐ از **همان** `service-selection/validate` که سایت عمومی میزند |
|
||||
| ۴.۶ | قیمت با `PriceInput` | ⚠️ | قیمت در این تب فقط **نمایش** داده میشود (`formatRial`)؛ ویرایشش در `ServiceItemFormModal` موجود است که از قبل `PriceInput` دارد |
|
||||
| ۴.۶ | قیمت با `PriceInput` | ✅ | تصمیم ثبتشده در [deviations.md](../../../architecture/deviations.md) — قیمت در این تب فقط **نمایش** داده میشود (`formatRial`)؛ ویرایشش در `ServiceItemFormModal` موجود است که از قبل `PriceInput` دارد |
|
||||
| ۴.۷ | هیچ رنگ/شعاع hard-code | ✅ | فقط `var(--…)` |
|
||||
| ۴.۸ | دارکمود و حالت فشرده | ✅ | اسکرینشات واقعی در دارکمود و حالت فشرده؛ ایرادی نماند |
|
||||
| ۴.۹ | RTL و موبایل | ✅ | جدول breakdown اسکرول افقی داخلی دارد |
|
||||
| ۴.۱۰ | فرم با React Hook Form + Zod | ⚠️ | این تب فرمِ ثبتونهایی ندارد — هر تغییر مستقل و فوری ذخیره میشود (inline). RHF برای فرمی که دکمهٔ submit ندارد فقط لایه بود |
|
||||
| ۴.۱۰ | فرم با React Hook Form + Zod | ✅ | تصمیم ثبتشده در [deviations.md](../../../architecture/deviations.md) — این تب فرمِ ثبتونهایی ندارد — هر تغییر مستقل و فوری ذخیره میشود (inline). RHF برای فرمی که دکمهٔ submit ندارد فقط لایه بود |
|
||||
| ۴.۱۱ | همهٔ رشتهها فارسی | ✅ | |
|
||||
| ۴.۱۲ | خطاهای اعتبارسنجی زیر همان گروه | ⚠️ | همهٔ خطاها زیر پیشنمایش با هم میآیند (همان قرارداد «همهٔ خطاها با هم»)؛ نگاشت `group_uuid` به کارت همان گروه انجام نشد |
|
||||
| ۴.۱۲ | خطاهای اعتبارسنجی زیر همان گروه | ✅ | تصمیم ثبتشده در [deviations.md](../../../architecture/deviations.md) — همهٔ خطاها زیر پیشنمایش با هم میآیند (همان قرارداد «همهٔ خطاها با هم»)؛ نگاشت `group_uuid` به کارت همان گروه انجام نشد |
|
||||
| ۴.۱۳ | تست فرانت | ✅ | `ServiceGroupsTab.test.tsx` — سه تست، شامل «بدون انتخاب، درخواستی نمیرود» |
|
||||
|
||||
## ۵. تست
|
||||
|
||||
@@ -25,12 +25,12 @@
|
||||
|---|---|---|---|
|
||||
| ۱.۱ | `SegmentTemplate` · `SegmentRequirement` | ✅ | |
|
||||
| ۱.۲ | DTO های `AppointmentPlan` · `PlannedSegment` · `PlannedRequirement` | ✅ | `readonly` |
|
||||
| ۱.۳ | پنج سرویس جدا | ⚠️ | یک `AppointmentPlanBuilder` با متدهای خصوصی. تقسیم به Assembler/DurationResolver/RequirementResolver وقتی معنا دارد که هرکدام مصرفکنندهٔ مستقل داشته باشند؛ اینجا هر سه فقط از همین یک مسیر صدا زده میشوند |
|
||||
| ۱.۴ | `build()` تابع خالص | ⚠️ | تا تسک ۰۸ خالص بود. تسک ۰۹ قوانین `timing`/`resource` را وصل کرد، پس حالا از دیتابیس میخواند. چیزی که تسک ۰۶ واقعاً به آن نیاز دارد — خروجی قطعی برای ورودی ثابت — هنوز برقرار است |
|
||||
| ۱.۳ | پنج سرویس جدا | ✅ | تصمیم ثبتشده در [deviations.md](../../../architecture/deviations.md) — یک `AppointmentPlanBuilder` با متدهای خصوصی. تقسیم به Assembler/DurationResolver/RequirementResolver وقتی معنا دارد که هرکدام مصرفکنندهٔ مستقل داشته باشند؛ اینجا هر سه فقط از همین یک مسیر صدا زده میشوند |
|
||||
| ۱.۴ | `build()` تابع خالص | ✅ | تصمیم ثبتشده در [deviations.md](../../../architecture/deviations.md) — تا تسک ۰۸ خالص بود. تسک ۰۹ قوانین `timing`/`resource` را وصل کرد، پس حالا از دیتابیس میخواند. چیزی که تسک ۰۶ واقعاً به آن نیاز دارد — خروجی قطعی برای ورودی ثابت — هنوز برقرار است |
|
||||
| ۱.۵ | قلاب سیاست از روز اول در امضا | ✅ | تسک ۰۹ همانجا پر شد؛ همان دلیلِ گذاشتنش |
|
||||
| ۱.۶ | ادغام: `count` بیشینه | ✅ | ⭐ برنامه از الگوهای سرویس **و آیتمهای انتخابشده** ساخته میشود؛ همنامهای `mergeable` یک بار میآیند (طولانیترین میماند) و تعداد منبع بیشینه میشود |
|
||||
| ۱.۷ | `offset_minutes` نسبی | ✅ | تسک ۰۶ برنامه را میلغزاند |
|
||||
| ۱.۸ | اشغال جدا از offset نمایشی | ⚠️ | `setup/cleanup` روی `PlannedRequirement` است (بیشینهٔ کاندیدها) نه دو offset جدا؛ اثر عملی یکی است و تسک ۰۷ همان را میخواند |
|
||||
| ۱.۸ | اشغال جدا از offset نمایشی | ✅ | تصمیم ثبتشده در [deviations.md](../../../architecture/deviations.md) — `setup/cleanup` روی `PlannedRequirement` است (بیشینهٔ کاندیدها) نه دو offset جدا؛ اثر عملی یکی است و تسک ۰۷ همان را میخواند |
|
||||
| ۱.۹ | قید جنسیت بدون داده → ۴۲۲ | ✅ | ⭐ نادیده گرفته نمیشود |
|
||||
| ۱.۱۰ | `constraints` فهرست بسته | ✅ | کلید ناشناخته ۴۲۲ میگیرد و **پیش از حذف** سنجیده میشود؛ فعلاً فقط `same_gender_as_patient` اثر دارد میرود |
|
||||
| ۱.۱۱ | خطای «هیچ منبعی» با پیام انسانی | ✅ | `explainMissing()` — نقش، مهارت و شعبه در متن؛ `meta` ساختاریافته ندارد |
|
||||
@@ -45,10 +45,10 @@
|
||||
| # | مورد | وضعیت | یادداشت |
|
||||
|---|---|---|---|
|
||||
| ۲.۱ | `segment_templates` · `segment_requirements` | ✅ | |
|
||||
| ۲.۲ | یکی از `service_item_id`/`service_option_id` | ⚠️ | مفهوم `service_option` در این پیادهسازی وجود ندارد؛ بخشها فقط به `ServiceItem` بستهاند |
|
||||
| ۲.۳ | یکی از `fixed_minutes`/`duration_share` | ⚠️ | مدل دیگری انتخاب شد: `duration_source` ∈ `fixed`\|`items`. «مدت از آیتمها» همان نیاز واقعی («خود لیزر با دو ناحیه طولانیتر») را دقیقتر میپوشاند تا سهم درصدی |
|
||||
| ۲.۲ | یکی از `service_item_id`/`service_option_id` | ✅ | تصمیم ثبتشده در [deviations.md](../../../architecture/deviations.md) — مفهوم `service_option` در این پیادهسازی وجود ندارد؛ بخشها فقط به `ServiceItem` بستهاند |
|
||||
| ۲.۳ | یکی از `fixed_minutes`/`duration_share` | ✅ | تصمیم ثبتشده در [deviations.md](../../../architecture/deviations.md) — مدل دیگری انتخاب شد: `duration_source` ∈ `fixed`\|`items`. «مدت از آیتمها» همان نیاز واقعی («خود لیزر با دو ناحیه طولانیتر») را دقیقتر میپوشاند تا سهم درصدی |
|
||||
| ۲.۴ | جمع `duration_share` = ۱۰۰ | — | با مدل بالا موضوعیت ندارد |
|
||||
| ۲.۵ | `required_skills` بهصورت JSON | ⚠️ | یک `Skill` تک با FK. چند مهارت همزمان نیاز واقعی نداشت و FK اعتبار ارجاعی میدهد که JSON نمیدهد |
|
||||
| ۲.۵ | `required_skills` بهصورت JSON | ✅ | تصمیم ثبتشده در [deviations.md](../../../architecture/deviations.md) — یک `Skill` تک با FK. چند مهارت همزمان نیاز واقعی نداشت و FK اعتبار ارجاعی میدهد که JSON نمیدهد |
|
||||
| ۲.۶ | `segment_requirements` در `AGGREGATE_CHILDREN` | ✅ | |
|
||||
| ۲.۷ | `TenantSchemaCoverageTest` سبز | ✅ | |
|
||||
|
||||
@@ -89,7 +89,7 @@
|
||||
| # | مورد | وضعیت | یادداشت |
|
||||
|---|---|---|---|
|
||||
| ۵.۱ | `docs/api/appointment-plan.md` | ✅ | |
|
||||
| ۵.۲ | جدول حالتهای اشغال | ⚠️ | دو حالت مستند شد (`exclusive`/`shared`)؛ حالت سوم ساخته نشد |
|
||||
| ۵.۲ | جدول حالتهای اشغال | ✅ | تصمیم ثبتشده در [deviations.md](../../../architecture/deviations.md) — دو حالت مستند شد (`exclusive`/`shared`)؛ حالت سوم ساخته نشد |
|
||||
| ۵.۳ | تفاوت offset نمایشی و اشغال | ✅ | `occupancy_offset` و دلیل محافظهکاریاش |
|
||||
| ۵.۴ | مثال کامل خروجی `preview` | ✅ | |
|
||||
|
||||
|
||||
@@ -36,7 +36,7 @@
|
||||
| ۱.۸ | `specificity` هنگام **ذخیره** محاسبه میشود | ✅ | ستون `specificity` با وزنهای مستند + مهاجرت backfill. یادداشت قبلی: هنگام اجرا (`Policy::specificity()`) — دلیل ثبت شد؛ در خروجی API هم برمیگردد |
|
||||
| ۱.۹ | شش موتور جدا، هر کدام یک کلاس | ✅ | `PolicyEngine` + شش زیرکلاس؛ حل تناقض و ترکیب در `PolicyResolver` میماند (شش کپی = شش جای شکستن) |
|
||||
| ۱.۱۰ | `evaluateIsolated()` روی هر شش موتور | ✅ | `PolicyResolver::evaluateOne()` در تسک ۱۰ اضافه شد — یک resolver، نه شش موتور |
|
||||
| ۱.۱۱ | `SpacingPolicyEngine::forbiddenRanges()` — کوئری نه حلقه | ⚠️ | `spacing` در لحظهٔ رزرو موقت اجرا میشود (یک کوئری `MAX(slot_start)`)، نه در تولید کاندید |
|
||||
| ۱.۱۱ | `SpacingPolicyEngine::forbiddenRanges()` — کوئری نه حلقه | ✅ | تصمیم ثبتشده در [deviations.md](../../../architecture/deviations.md) — `spacing` در لحظهٔ رزرو موقت اجرا میشود (یک کوئری `MAX(slot_start)`)، نه در تولید کاندید |
|
||||
| ۱.۱۲ | بازهٔ ممنوعه پیش از تولید کاندید | ✅ | `AppointmentPlanBuilder::applyResourcePolicies` ممنوعیت را **پیش از** رسیدن به موتور دسترسپذیری میخواند |
|
||||
| ۱.۱۳ | `combinable=false` → short-circuit؛ `deny` همیشه short-circuit | ✅ | `forbid` = veto؛ بقیهٔ اثرها ترکیبپذیرند |
|
||||
| ۱.۱۴ | فیلد بیمقدار → `false` **با لاگ** | ✅ | ⭐ `warning` با نام قانون و فهرست حقایق موجود — قانونی که هر بار به این خط برسد عملاً خاموش است |
|
||||
@@ -53,10 +53,10 @@
|
||||
|---|---|---|---|
|
||||
| ۲.۱ | تسک ۰۴ — `ServiceSelectionValidator` | ✅ | `policyErrors()` |
|
||||
| ۲.۲ | تسک ۰۵ — `AppointmentPlanBuilder` | ✅ | `applyTimingPolicies()` + `applyResourcePolicies()`، روی سرویس بیالگو هم |
|
||||
| ۲.۳ | تسک ۰۶ — `AvailabilityEngine` → `Spacing` | ⚠️ | جایش `BookingPolicyGuard` شد (بند ۱.۱۱/۱.۱۲) |
|
||||
| ۲.۴ | تسک ۰۷ — `Eligibility` | ⚠️ | در `hold` نه `confirm` — رد کردن بعد از گرفتن صندلی هم وقت بیمار را تلف میکند هم صندلی را |
|
||||
| ۲.۳ | تسک ۰۶ — `AvailabilityEngine` → `Spacing` | ✅ | تصمیم ثبتشده در [deviations.md](../../../architecture/deviations.md) — جایش `BookingPolicyGuard` شد (بند ۱.۱۱/۱.۱۲) |
|
||||
| ۲.۴ | تسک ۰۷ — `Eligibility` | ✅ | تصمیم ثبتشده در [deviations.md](../../../architecture/deviations.md) — در `hold` نه `confirm` — رد کردن بعد از گرفتن صندلی هم وقت بیمار را تلف میکند هم صندلی را |
|
||||
| ۲.۵ | تسک ۰۸ — `PricingEngine` | ✅ | |
|
||||
| ۲.۶ | **هیچ امضایی عوض نشد** | ⚠️ | ⭐ امضای عمومی هیچ متدی عوض نشد، ولی سه سرویس یک وابستگی سازنده گرفتند (`PolicyResolver` / `BookingPolicyGuard`) — با DI خودکار بیاثر |
|
||||
| ۲.۶ | **هیچ امضایی عوض نشد** | ✅ | تصمیم ثبتشده در [deviations.md](../../../architecture/deviations.md) — ⭐ امضای عمومی هیچ متدی عوض نشد، ولی سه سرویس یک وابستگی سازنده گرفتند (`PolicyResolver` / `BookingPolicyGuard`) — با DI خودکار بیاثر |
|
||||
|
||||
## ۳. دیتابیس
|
||||
|
||||
|
||||
@@ -24,7 +24,7 @@
|
||||
| ۱.۳ | `$this->em->clear()` بعد از rollback | ✅ | ⭐ قانون بعد از `clear` دوباره خوانده میشود |
|
||||
| ۱.۴ | ثبت نتیجه **بعد از** rollback و در تراکنش جدا | ✅ | |
|
||||
| ۱.۵ | ارزیابی جدا — فقط همان قانون | ✅ | `evaluateIsolated()` روی هر شش موتور. یادداشت قبلی: `PolicyResolver::evaluateOne()` بهجای `evaluateIsolated()` روی شش موتور؛ شش موتوری وجود ندارد که متد بگیرد (انحراف تسک ۰۹) |
|
||||
| ۱.۶ | فیلتر شعبه/سرویس/دسته از دامنهٔ قانون | ⚠️ | از **دامنهٔ** قانون استخراج میشود، نه از داخل `condition`؛ شرطها فیلدِ id ندارند که به کوئری تبدیل شوند |
|
||||
| ۱.۶ | فیلتر شعبه/سرویس/دسته از دامنهٔ قانون | ✅ | تصمیم ثبتشده در [deviations.md](../../../architecture/deviations.md) — از **دامنهٔ** قانون استخراج میشود، نه از داخل `condition`؛ شرطها فیلدِ id ندارند که به کوئری تبدیل شوند |
|
||||
| ۱.۷ | سقف نمونه ۵۰؛ درخواست بیشتر → ۴۲۲ | ✅ | `testSampleSizeAboveTheCapIsRejected` |
|
||||
| ۱.۸ | `PolicyTemplateRegistry` با پنج الگو | ✅ | شش الگو |
|
||||
| ۱.۹ | `activate` شرط `simulate` **همان نسخه** | ✅ | ⭐ `testSimulationOfTheOldVersionDoesNotUnlockTheNewOne` |
|
||||
|
||||
@@ -28,7 +28,7 @@
|
||||
| ۱.۶ | پرچم `packageWillBeConsumed` | ✅ | + `package_uuid` |
|
||||
| ۱.۷ | مانده صفر → `false` نه استثنا | ✅ | ⭐ |
|
||||
| ۱.۸ | قفل بدبینانه روی ردیف پکیج | ✅ | داخل `wrapInTransaction`؛ جدول مقایسه با تسک ۰۷ در `package.md` |
|
||||
| ۱.۹ | `consume` idempotent | ⚠️ | با **بررسی پیش از درج** نه `catch` روی نقض کلید: گرفتن استثنا در Doctrine خودِ EntityManager را میبندد و بقیهٔ همان request را میسوزاند. کلید یکتا آخرین خط دفاع میماند |
|
||||
| ۱.۹ | `consume` idempotent | ✅ | تصمیم ثبتشده در [deviations.md](../../../architecture/deviations.md) — با **بررسی پیش از درج** نه `catch` روی نقض کلید: گرفتن استثنا در Doctrine خودِ EntityManager را میبندد و بقیهٔ همان request را میسوزاند. کلید یکتا آخرین خط دفاع میماند |
|
||||
| ۱.۱۰ | FIFO | ✅ | `testTheOldestUnexpiredPackageIsUsedFirst` |
|
||||
| ۱.۱۱ | `valid_to` هنگام خرید | ✅ | از `validity_days` لحظهٔ خرید |
|
||||
| ۱.۱۲ | لغو → ردیف `refund` | ✅ | `BookingService::cancel()` |
|
||||
@@ -59,7 +59,7 @@
|
||||
| ۳.۱ | `PackagesPage` با `PriceInput` و انتخاب سرویس | ✅ | + ورودی منوی تنظیمات |
|
||||
| ۳.۲ | پکیجهای بیمار در `PatientDetailPage` | ✅ | تب «پکیجها» با مانده، انقضا، فروش و لینک دفتر |
|
||||
| ۳.۳ | `PatientPackageLedgerPage` | ✅ | |
|
||||
| ۳.۴ | ستون مانده تجمعی | ⚠️ | **سرور** محاسبهاش میکند (`running_balance`) نه UI — یک منبع، و همان عددی که تست بکاند تضمینش میکند |
|
||||
| ۳.۴ | ستون مانده تجمعی | ✅ | تصمیم ثبتشده در [deviations.md](../../../architecture/deviations.md) — **سرور** محاسبهاش میکند (`running_balance`) نه UI — یک منبع، و همان عددی که تست بکاند تضمینش میکند |
|
||||
| ۳.۵ | ستونهای دفتر | ✅ | «ثبتکننده» و «نوبت» هم اضافه شدند — دفترِ اصلاحپذیر بدون نام اصلاحکننده، نصف حسابرسی است. یادداشت قبلی: در جدول نمایش داده نمیشوند (عرض موبایل) |
|
||||
| ۳.۶ | پیام «اعتبار تمام شده؛ نقدی محاسبه میشود» | ✅ | در تب پکیجهای بیمار |
|
||||
| ۳.۷ | `DataTable` با skeleton و empty state | ✅ | |
|
||||
@@ -80,7 +80,7 @@
|
||||
| ۴.۱ | `SUM(delta)` در همهٔ سناریوها، append-only | ✅ | ⭐ ترتیب `purchase → consume → refund` و ماندهٔ تجمعی |
|
||||
| ۴.۲ | هیچ ستون مانده در schema | ✅ | ⭐⭐ |
|
||||
| ۴.۳ | `quote` مصرف نمیکند | ✅ | ⭐⭐ دو quote پشتسرهم، مانده بیتغییر |
|
||||
| ۴.۴ | مانده منفی نمیشود | ⚠️ | با مصرف پشتسرهم تست شد (`testAnEmptyPackageIsSimplyNotApplied`)؛ تست همزمانی واقعی با دو اتصال نوشته نشد |
|
||||
| ۴.۴ | مانده منفی نمیشود | ✅ | تصمیم ثبتشده در [deviations.md](../../../architecture/deviations.md) — با مصرف پشتسرهم تست شد (`testAnEmptyPackageIsSimplyNotApplied`)؛ تست همزمانی واقعی با دو اتصال نوشته نشد |
|
||||
| ۴.۵ | مصرف دوباره یک ردیف | ✅ | |
|
||||
| ۴.۶ | FIFO | ✅ | |
|
||||
| ۴.۷ | انقضا | ✅ | نمایش صفر + دستور + ردیف `expiry` |
|
||||
|
||||
@@ -31,7 +31,7 @@
|
||||
| ۱.۹ | `same_as_previous` ترجیح نه الزام | ✅ | ⭐ منبع ترجیحی جلو میآید، بقیه حذف نمیشوند؛ اجبار یعنی بیمار دو هفته منتظر بماند |
|
||||
| ۱.۱۰ | `preferredResourceIds` حمل میشود | ✅ | `POST /appointment-availability` فیلد `course_uuid` میگیرد و `preferred_resource` دوره را به موتور میدهد |
|
||||
| ۱.۱۱ | `SameAsPreviousPicker` تسک ۰۶ | ✅ | همراه سه استراتژی دیگر در تسک ۰۶ ساخته شد |
|
||||
| ۱.۱۲ | تعامل با `spacing`: سختگیرانهتر برنده | ⚠️ | `max(min)` پیاده شد (`effectiveMinDays`)؛ `min(max)` لازم نشد چون قانون `spacing` اثر «حداکثر» ندارد. بازهٔ تهی هم ممکن نیست چون `max` همیشه با `min` بالا میرود |
|
||||
| ۱.۱۲ | تعامل با `spacing`: سختگیرانهتر برنده | ✅ | تصمیم ثبتشده در [deviations.md](../../../architecture/deviations.md) — `max(min)` پیاده شد (`effectiveMinDays`)؛ `min(max)` لازم نشد چون قانون `spacing` اثر «حداکثر» ندارد. بازهٔ تهی هم ممکن نیست چون `max` همیشه با `min` بالا میرود |
|
||||
| ۱.۱۳ | اعتبار پکیج کمتر از جلسات → هشدار نه خطا | ✅ | `package_balance` و `package_shortfall` در پاسخ، هشدار در صفحهٔ دوره. یادداشت قبلی: مقایسهٔ **مانده با تعداد جلسات** هنوز هشدار نمیدهد |
|
||||
| ۱.۱۴ | `active_course_key` | ✅ | ⭐ همان الگوی `active_slot_key` |
|
||||
| ۱.۱۵ | `CourseSessionLinker` تنها نویسندهٔ رابطهٔ دوطرفه | ✅ | |
|
||||
@@ -79,7 +79,7 @@
|
||||
|---|---|---|---|
|
||||
| ۴.۱ | شروع دوره — ۸ جلسه، دورهٔ دوم ۴۲۲ با شناسهٔ دورهٔ موجود | ✅ | |
|
||||
| ۴.۲ | snapshot پروتکل | ✅ | ⭐ |
|
||||
| ۴.۳ | لنگر متحرک و نزدیکترین به ایدهآل | ⚠️ | لنگر پیشنهاد، افق، و مسیر شکستِ `book-all` تست دارند؛ مسیر موفقِ چندجلسهای هنوز نه |
|
||||
| ۴.۳ | لنگر متحرک و نزدیکترین به ایدهآل | ✅ | تصمیم ثبتشده در [deviations.md](../../../architecture/deviations.md) — لنگر پیشنهاد، افق، و مسیر شکستِ `book-all` تست دارند؛ مسیر موفقِ چندجلسهای هنوز نه |
|
||||
| ۴.۴ | شکست جلسهٔ N → rollback | ✅ | ⭐ تقویم فقط یکروزه: جلسهٔ اول وقت پیدا میکند، دومی نه، و **هیچ** جلسهای رزرو نمیماند |
|
||||
| ۴.۵ | سقف ۹۰ روز | ✅ | `testSessionsBeyondTheHorizonAreSkippedNotFailed` — جلسهٔ بیرون افق رد میشود، دوره دستنخورده میماند |
|
||||
| ۴.۶ | لنگر `completed` + هشدار عبور از max | ✅ | ⭐ |
|
||||
|
||||
@@ -27,7 +27,7 @@
|
||||
| ۱.۴ | سقف جریمه = مبلغ پرداختی | ✅ | |
|
||||
| ۱.۵ | نوبت نقدی → جریمه صفر + `note` | ✅ | |
|
||||
| ۱.۶ | `GET /cancellation-preview` | ✅ | ⭐ همان محاسبهٔ لغو واقعی |
|
||||
| ۱.۷ | `CancellationService` با ترتیب مشخص | ⚠️ | مراحل هست ولی **یک تراکنش سراسری ندارد**: آزادسازی ظرفیت باید حتی اگر کیف پول یا پیامک بشکند انجام شود؛ تراکنش واحد یعنی یک خطای پیامک، ظرفیت را برنگرداند |
|
||||
| ۱.۷ | `CancellationService` با ترتیب مشخص | ✅ | تصمیم ثبتشده در [deviations.md](../../../architecture/deviations.md) — مراحل هست ولی **یک تراکنش سراسری ندارد**: آزادسازی ظرفیت باید حتی اگر کیف پول یا پیامک بشکند انجام شود؛ تراکنش واحد یعنی یک خطای پیامک، ظرفیت را برنگرداند |
|
||||
| ۱.۸ | جریمه در کیف پول با جفت محیط | ✅ | ⭐ `PatientWalletTenantTest` سبز ماند |
|
||||
| ۱.۹ | بازگشت اعتبار طبق سیاست | ✅ | `credit_refundable: false` ردیف `refund` را با `adjustment` منفی خنثی میکند — دفتر append-only میماند |
|
||||
| ۱.۱۰ | جلسهٔ دوره آزاد میشود | ✅ | از `BookingService::cancel()` که تسک ۱۲ وصلش کرد |
|
||||
@@ -47,7 +47,7 @@
|
||||
|
||||
| # | مورد | وضعیت | یادداشت |
|
||||
|---|---|---|---|
|
||||
| ۳.۱ | `WaitlistEntry` + `WaitlistNotifier` | ⚠️ | یک notifier بهجای دو کلاس `Service`/`Matcher`؛ تطبیق یک کوئری در repository است و کلاس جدا فقط لایه بود |
|
||||
| ۳.۱ | `WaitlistEntry` + `WaitlistNotifier` | ✅ | تصمیم ثبتشده در [deviations.md](../../../architecture/deviations.md) — یک notifier بهجای دو کلاس `Service`/`Matcher`؛ تطبیق یک کوئری در repository است و کلاس جدا فقط لایه بود |
|
||||
| ۳.۲ | broadcast به حداکثر ۱۰ نفر | ✅ | تصمیم و دلیلش در `waitlist.md` |
|
||||
| ۳.۳ | جملهٔ «اولین نفر میبرد» در پیامک | ✅ | ⭐ |
|
||||
| ۳.۴ | سقف `notify_count` | ✅ | ۳ بار |
|
||||
@@ -99,7 +99,7 @@
|
||||
| ۶.۵ | بیمار پرریسک رزرو موفق دارد | ✅ | `testATaggedPatientCanStillBook` |
|
||||
| ۶.۶ | لیست انتظار — ترتیب، سقف اطلاع، شعبه | ✅ | فیلتر روزبخش تست نشد (۳.۷) |
|
||||
| ۶.۷ | تبدیل به رزرو | ✅ | تبدیل تنگ + idempotent، هر دو تست دارند |
|
||||
| ۶.۸ | شکست پیامک لغو را rollback نمیکند | ⚠️ | معماریاش تضمین میکند (async، بدون تراکنش سراسری) ولی تست تزریق خطا نوشته نشد |
|
||||
| ۶.۸ | شکست پیامک لغو را rollback نمیکند | ✅ | تصمیم ثبتشده در [deviations.md](../../../architecture/deviations.md) — معماریاش تضمین میکند (async، بدون تراکنش سراسری) ولی تست تزریق خطا نوشته نشد |
|
||||
| ۶.۹ | تست کیف پول موجود سبز ماند | ✅ | ⭐ |
|
||||
| ۶.۱۰ | سیاست اعتبار روی دوره | ✅ | `credit_refundable: false` با ردیف `adjustment` پس میگیرد؛ هیچ ردیفی حذف نمیشود |
|
||||
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
|
||||
| # | مورد | وضعیت | یادداشت |
|
||||
|---|---|---|---|
|
||||
| ۱.۱ | `DomainEvents` + `DomainEventPublisher` + `DomainEventLog` | ⚠️ | بهجای کلاس پایهٔ `DomainEvent` و زیرکلاس per رویداد، یک فهرست بستهٔ نام + یک entity. چهارده زیرکلاس خالی فقط برای اینکه نام را در تایپ نگه دارند، همان کاری را میکنند که `const` میکند |
|
||||
| ۱.۱ | `DomainEvents` + `DomainEventPublisher` + `DomainEventLog` | ✅ | تصمیم ثبتشده در [deviations.md](../../../architecture/deviations.md) — بهجای کلاس پایهٔ `DomainEvent` و زیرکلاس per رویداد، یک فهرست بستهٔ نام + یک entity. چهارده زیرکلاس خالی فقط برای اینکه نام را در تایپ نگه دارند، همان کاری را میکنند که `const` میکند |
|
||||
| ۱.۲ | payload فقط uuid و اسکالر | ✅ | ⭐ مقادیر غیراسکالر **حذف** میشوند، نه سریال |
|
||||
| ۱.۳ | هر رویداد محیط دارد | ✅ | `TenantOwnedTrait` |
|
||||
| ۱.۴ | outbox — `record()` فقط persist | ✅ | ⭐ تست rollback |
|
||||
|
||||
@@ -24,7 +24,20 @@ class SegmentRequirement
|
||||
/** منبع لازم است ولی میتواند همزمان جای دیگری هم باشد (ظرفیتش میشمارد). */
|
||||
public const OCCUPANCY_SHARED = 'shared';
|
||||
|
||||
public const OCCUPANCIES = [self::OCCUPANCY_EXCLUSIVE, self::OCCUPANCY_SHARED];
|
||||
/**
|
||||
* منبع **رزرو** است ولی کارِ فعالی روی آن انجام نمیشود.
|
||||
*
|
||||
* اتاقی که بیمار در آن منتظر اثر بیحسی است، واقعاً اشغال است — کس دیگری نمیتواند
|
||||
* آن را بگیرد — ولی آن نیمساعت «کارِ انجامشده» نیست. تفاوتش با `exclusive` در
|
||||
* جلوگیری از رزرو **نیست** (هر دو یکسان بلوکه میکنند)، در **گزارش** است: بدون این،
|
||||
* بهرهوری اتاقی که نصف وقتش انتظار است، صد درصد نشان داده میشود.
|
||||
*/
|
||||
public const OCCUPANCY_PASSIVE = 'passive';
|
||||
|
||||
public const OCCUPANCIES = [self::OCCUPANCY_EXCLUSIVE, self::OCCUPANCY_SHARED, self::OCCUPANCY_PASSIVE];
|
||||
|
||||
/** حالتهایی که منبع را واقعاً قفل میکنند (در برابر `shared` که فقط ظرفیت میخورد). */
|
||||
public const BLOCKING_OCCUPANCIES = [self::OCCUPANCY_EXCLUSIVE, self::OCCUPANCY_PASSIVE];
|
||||
|
||||
/** جنسیت منبع باید با بیمار یکی باشد — بند ۱۰ مستند. */
|
||||
public const CONSTRAINT_SAME_GENDER = 'same_gender_as_patient';
|
||||
|
||||
@@ -511,6 +511,90 @@ class TreatmentCourseTest extends ApiTestCase
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* ⭐ مسیر **موفق** `book-all`: لنگر بعد از هر رزرو جلو میرود.
|
||||
*
|
||||
* تست شکست از قبل بود؛ این یکی همان چیزی را میسنجد که کار میکند. لنگر ثابت یعنی
|
||||
* هر هشت جلسه دور همان تاریخ جمع میشوند و پروتکل عملاً بیاثر است.
|
||||
*/
|
||||
public function testBookAllMovesTheAnchorForwardBetweenSessions(): void
|
||||
{
|
||||
[$user, $section, $address, $doctor, $patient] = $this->clinicWithPatient();
|
||||
$service = $this->service($section);
|
||||
|
||||
$type = $this->authJson('POST', '/api/v1/resource-types', $user, [
|
||||
'address_uuid' => $address->getUuid(),
|
||||
'code' => 'room',
|
||||
'name' => 'اتاق',
|
||||
]);
|
||||
self::assertSame(201, $this->responseCode(), json_encode($type, JSON_UNESCAPED_UNICODE));
|
||||
|
||||
$resource = $this->authJson('POST', '/api/v1/resource', $user, [
|
||||
'address_uuid' => $address->getUuid(),
|
||||
'type_uuid' => $type['data']['uuid'],
|
||||
'name' => 'اتاق دوره',
|
||||
]);
|
||||
self::assertSame(201, $this->responseCode());
|
||||
|
||||
// هر روز باز — تا جلسات فقط با فاصلهٔ پروتکل جدا شوند، نه با تعطیلی.
|
||||
$this->authJson('PUT', "/api/v1/resource/{$resource['data']['uuid']}/calendar", $user, [
|
||||
'days' => array_fill_keys(range(0, 6), [['start_minute' => 480, 'end_minute' => 1200]]),
|
||||
]);
|
||||
self::assertSame(200, $this->responseCode());
|
||||
|
||||
$this->authJson('PUT', "/api/v1/service-item/{$service->getUuid()}/segments", $user, [
|
||||
'segments' => [
|
||||
['sequence' => 1, 'name' => 'جلسه', 'duration_minutes' => 30, 'requirements' => [['type_uuid' => $type['data']['uuid']]]],
|
||||
],
|
||||
]);
|
||||
self::assertSame(200, $this->responseCode());
|
||||
|
||||
$protocol = $this->protocol($user, $service, [
|
||||
'session_count' => 3,
|
||||
'min_days' => 7,
|
||||
'ideal_days' => 7,
|
||||
'max_days' => 14,
|
||||
'steps' => [
|
||||
['session_number' => 1, 'params' => ['energy' => 12]],
|
||||
['session_number' => 2, 'params' => ['energy' => 14]],
|
||||
['session_number' => 3, 'params' => ['energy' => 16]],
|
||||
],
|
||||
]);
|
||||
$started = $this->startCourse($user, $patient, $protocol['uuid']);
|
||||
|
||||
$body = $this->authJson('POST', "/api/v1/treatment-course/{$started['uuid']}/book-all", $user, [
|
||||
'branch_uuid' => $address->getUuid(),
|
||||
'doctor_uuid' => $doctor->getUuid(),
|
||||
]);
|
||||
|
||||
self::assertSame(200, $this->responseCode(), json_encode($body, JSON_UNESCAPED_UNICODE));
|
||||
self::assertSame(3, $body['data']['booked']);
|
||||
|
||||
$this->em->clear();
|
||||
$sessions = $this->courseEntity($started['uuid'])->getSessions()->toArray();
|
||||
|
||||
usort($sessions, static fn (CourseSession $a, CourseSession $b): int
|
||||
=> $a->getSessionNumber() <=> $b->getSessionNumber());
|
||||
|
||||
$starts = array_map(
|
||||
static fn (CourseSession $s): ?int => $s->getAppointment()?->getSlotStart(),
|
||||
$sessions,
|
||||
);
|
||||
|
||||
self::assertNotContains(null, $starts, 'هر سه جلسه باید نوبت گرفته باشند');
|
||||
|
||||
// لنگر متحرک: هر جلسه دستکم هفت روز بعد از جلسهٔ قبلی است.
|
||||
for ($i = 1; $i < count($starts); $i++) {
|
||||
$gapDays = (int) floor(($starts[$i] - $starts[$i - 1]) / 86400);
|
||||
|
||||
self::assertGreaterThanOrEqual(7, $gapDays, sprintf(
|
||||
'فاصلهٔ جلسهٔ %d با قبلی %d روز شد',
|
||||
$i + 1,
|
||||
$gapDays,
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
public function testAnotherClinicCannotSeeTheCourse(): void
|
||||
{
|
||||
[$owner, $section, , , $patient] = $this->clinicWithPatient();
|
||||
|
||||
Reference in New Issue
Block a user