feat(plan): build the plan from the selected items too, so mergeable finally means something

The mergeable flag was stored, returned by the API and rendered in the editor
while changing nothing. The reason was upstream: the builder only ever read the
primary service's templates, and within one service two segments with the same
name do not occur — so the dedupe it already had could never fire.

Templates now come from the primary service plus every selected item, and
same-named mergeable segments collapse to one. Rules, with their reasons:

- the longest of the same-named segments survives — prepping two areas is not
  shorter than prepping the longer one alone
- a duration_source: "items" segment also appears once even when it is not
  marked mergeable, because DurationCalculator has already summed every item
  and repeating the segment counts that time twice
- the merged requirement count is the maximum, not the sum and not the first
  one seen: two areas do not need two rooms, but if one of them needed two
  operators, merging must not quietly demote that to one

Also pins that the plan is deterministic: two previews of the same input are
compared byte for byte. A plan that shifts between preview and booking means
the user confirmed something that was not what got booked.

Unrelated but found by running the suite on a Saturday: testPastStartsAreExcluded
searched "last week's Saturday", which is today when today is Saturday, so this
afternoon's slots were legitimately not in the past. It now searches two weeks
back, which is unambiguous on every weekday.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
hamed
2026-08-01 13:40:05 +03:30
co-authored by Claude Opus 5
parent c7811120d7
commit 47a40e2021
5 changed files with 230 additions and 20 deletions
+21 -3
View File
@@ -117,8 +117,26 @@
**۳. قید جنسیت وقتی جنسیت بیمار نامشخص است نادیده گرفته نمی‌شود.** ۴۲۲ می‌دهد، چون رد
کردن بی‌صدا یعنی بیمار به منبعی می‌رسد که قرار نبود.
`mergeable` هم‌نام‌ها یک بار می‌آیند: «آماده‌سازی» با دو ناحیه یک بار انجام می‌شود.
تکرار شدنِ کارِ اصلی با `duration_source: "items"` بیان می‌شود، نه با تکرار بخش.
## ادغام بخش‌ها
برنامه از الگوهای **سرویس اصلی به‌علاوهٔ آیتم‌های انتخاب‌شده** ساخته می‌شود. تا پیش از
این فقط الگوهای سرویس اصلی خوانده می‌شد، پس بخش‌هایی که کلینیک روی خودِ ناحیه تعریف کرده
بود بی‌صدا نادیده می‌ماند و `mergeable` هرگز کاری نمی‌کرد — در یک سرویس، دو بخشِ هم‌نام
معنا ندارد.
| قاعده | چرا |
|---|---|
| هم‌نام‌های `mergeable` یک بار می‌آیند | «آماده‌سازی» برای دو ناحیه یک بار انجام می‌شود |
| از میان هم‌نام‌ها **طولانی‌ترین** می‌ماند | آماده‌سازی دو ناحیه کوتاه‌تر از طولانی‌ترینشان نیست |
| `duration_source: "items"` هم یک بار می‌آید | `DurationCalculator` از قبل مجموع همهٔ آیتم‌ها را داده؛ تکرارش یعنی دوبار شمردن |
| تعداد منبع پس از ادغام **بیشینه** است | دو ناحیه با هم دو اتاق نمی‌خواهند، ولی اگر یکی دو اپراتور لازم داشت ادغام نباید به یک تنزلش بدهد |
مثال: «لیزر صورت» با آماده‌سازی ۵ دقیقه و «لیزر بیکینی» با آماده‌سازی ۱۲ دقیقه، هر دو
انتخاب شوند → یک آماده‌سازیِ ۱۲ دقیقه‌ای، و کارِ اصلی به اندازهٔ مجموع دو ناحیه.
**برنامه قطعی است:** دو `preview` با همان ورودی خروجیِ بایت‌به‌بایت یکسان می‌دهند (تست
دارد). برنامه‌ای که بین پیش‌نمایش و رزرو جابه‌جا شود یعنی کاربر چیزی را تأیید کرده که
رزرو نشد.
---
@@ -132,7 +150,7 @@
## تست‌ها
```bash
ddev exec php bin/phpunit tests/Appointment/AppointmentPlanTest.php # ۱۱ تست
ddev exec php bin/phpunit tests/Appointment/AppointmentPlanTest.php # ۱۴ تست
```
---
@@ -28,7 +28,7 @@
| ۱.۳ | پنج سرویس جدا | ⚠️ | یک `AppointmentPlanBuilder` با متدهای خصوصی. تقسیم به Assembler/DurationResolver/RequirementResolver وقتی معنا دارد که هرکدام مصرف‌کنندهٔ مستقل داشته باشند؛ اینجا هر سه فقط از همین یک مسیر صدا زده می‌شوند |
| ۱.۴ | `build()` تابع خالص | ⚠️ | تا تسک ۰۸ خالص بود. تسک ۰۹ قوانین `timing`/`resource` را وصل کرد، پس حالا از دیتابیس می‌خواند. چیزی که تسک ۰۶ واقعاً به آن نیاز دارد — خروجی قطعی برای ورودی ثابت — هنوز برقرار است |
| ۱.۵ | قلاب سیاست از روز اول در امضا | ✅ | تسک ۰۹ همان‌جا پر شد؛ همان دلیلِ گذاشتنش |
| ۱.۶ | ادغام: `count` بیشینه | | ادغام بخش‌های `mergeable` پیاده نشد؛ پرچمش ذخیره و در API برگردانده می‌شود ولی رفتاری ندارد |
| ۱.۶ | ادغام: `count` بیشینه | | ⭐ برنامه از الگوهای سرویس **و آیتم‌های انتخاب‌شده** ساخته می‌شود؛ هم‌نام‌های `mergeable` یک بار می‌آیند (طولانی‌ترین می‌ماند) و تعداد منبع بیشینه می‌شود |
| ۱.۷ | `offset_minutes` نسبی | ✅ | تسک ۰۶ برنامه را می‌لغزاند |
| ۱.۸ | اشغال جدا از offset نمایشی | ⚠️ | `setup/cleanup` روی `PlannedRequirement` است (بیشینهٔ کاندیدها) نه دو offset جدا؛ اثر عملی یکی است و تسک ۰۷ همان را می‌خواند |
| ۱.۹ | قید جنسیت بدون داده → ۴۲۲ | ✅ | ⭐ نادیده گرفته نمی‌شود |
@@ -73,10 +73,10 @@
| # | مورد | وضعیت | یادداشت |
|---|---|---|---|
| ۴.۱ | ادغام بخش‌ها | | با ۱.۶ یک بسته است |
| ۴.۱ | ادغام بخش‌ها | | دو تست: ادغام هم‌نام دو ناحیه · بیشینه‌بودن تعداد |
| ۴.۲ | حل مدت — ثابت و از آیتم‌ها | ✅ | داخل `AppointmentPlanTest` |
| ۴.۳ | سناریوی مرجع مستند | ✅ | ⭐ آفست‌های ۰/۵/۳۵/۵۵ و مجموع ۶۰ |
| ۴.۴ | قطعیت — دو build یکسان | | تست صریح ندارد |
| ۴.۴ | قطعیت — دو build یکسان | | مقایسهٔ JSON دو `preview` پیاپی |
| ۴.۵ | سرویس بدون الگو | ✅ | ⭐ |
| ۴.۶ | حل نیازمندی — مهارت، بی‌کاندید، جنسیت، محیط دیگر | ✅ | |
| ۴.۷ | سقف‌ها → ۴۲۲ | ⚠️ | سقف ۴۸۰ دقیقه تست شد؛ بقیه سقف ندارند (۱.۱۴) |
@@ -48,7 +48,7 @@ final class AppointmentPlanBuilder
?string $patientGender = null,
): AppointmentPlan {
$itemMinutes = $this->durations->totalMinutes($selectedItems !== [] ? $selectedItems : [$service]);
$templates = $this->templates->findForService($service);
$templates = $this->gatherTemplates($service, $selectedItems);
// سرویسی که الگوی بخش ندارد، همان رفتار امروز را می‌گیرد: یک بخش پیوسته که
// پزشک را می‌گیرد. بدون این، هر سرویس موجود بی‌برنامه می‌شد.
@@ -61,6 +61,7 @@ final class AppointmentPlanBuilder
$segments = [];
$offset = 0;
$counts = $this->mergedCounts($templates);
foreach ($this->orderedTemplates($templates) as $template) {
$duration = $template->getDurationSource() === SegmentTemplate::DURATION_FROM_ITEMS
@@ -83,7 +84,7 @@ final class AppointmentPlanBuilder
durationMinutes: $duration,
patientPresent: $template->isPatientPresent(),
mergeable: $template->isMergeable(),
requirements: $this->planRequirements($template, $address, $patientGender),
requirements: $this->planRequirements($template, $address, $patientGender, $counts[$template->getName()] ?? []),
);
$offset += $duration;
@@ -184,30 +185,77 @@ final class AppointmentPlanBuilder
return $target;
}
/**
* الگوهای سرویس اصلی **به‌علاوهٔ** الگوهای آیتم‌های انتخاب‌شده.
*
* بدون این، بخش‌هایی که کلینیک روی خودِ ناحیه تعریف کرده بی‌صدا نادیده می‌ماندند و
* پرچم `mergeable` هرگز کاری نمی‌کرد — چون در یک سرویس، دو بخشِ هم‌نام معنا ندارد.
*
* @param ServiceItem[] $selectedItems
* @return SegmentTemplate[]
*/
private function gatherTemplates(ServiceItem $service, array $selectedItems): array
{
$ids = [(int) $service->getId()];
foreach ($selectedItems as $item) {
$ids[] = (int) $item->getId();
}
$ids = array_values(array_unique(array_filter($ids)));
if (count($ids) === 1) {
return $this->templates->findForService($service);
}
$gathered = [];
foreach ($this->templates->findForServices($ids) as $forService) {
foreach ($forService as $template) {
$gathered[(int) $template->getId()] = $template;
}
}
return array_values($gathered);
}
/**
* بخش‌های `mergeable` هم‌نام یک بار می‌آیند: «آماده‌سازی» با دو ناحیه یک بار انجام
* می‌شود، ولی «خود لیزر» به‌ازای هر ناحیه طولانی‌تر می‌شود (و آن با
* `DURATION_FROM_ITEMS` بیان شده، نه با تکرار بخش).
*
* از میان هم‌نام‌ها **طولانی‌ترین** می‌ماند: آماده‌سازیِ دو ناحیه کوتاه‌تر از
* طولانی‌ترینشان نیست. بخشی که مدتش از آیتم‌ها می‌آید هم یک بار می‌آید حتی اگر
* `mergeable` نباشد، چون `DurationCalculator` از قبل مجموع همهٔ آیتم‌ها را داده و
* تکرارش یعنی دوبار شمردن همان زمان.
*
* @param SegmentTemplate[] $templates
* @return SegmentTemplate[]
*/
private function orderedTemplates(array $templates): array
{
$seenMergeable = [];
$ordered = [];
$best = [];
$rest = [];
foreach ($templates as $template) {
if ($template->isMergeable()) {
if (isset($seenMergeable[$template->getName()])) {
continue;
}
$seenMergeable[$template->getName()] = true;
$collapses = $template->isMergeable()
|| $template->getDurationSource() === SegmentTemplate::DURATION_FROM_ITEMS;
if (!$collapses) {
$rest[] = $template;
continue;
}
$ordered[] = $template;
$key = $template->getName();
$current = $best[$key] ?? null;
if ($current === null || $template->getDurationMinutes() > $current->getDurationMinutes()) {
$best[$key] = $template;
}
}
$ordered = array_merge(array_values($best), $rest);
usort(
$ordered,
static fn (SegmentTemplate $a, SegmentTemplate $b): int => $a->getSequence() <=> $b->getSequence(),
@@ -216,6 +264,32 @@ final class AppointmentPlanBuilder
return $ordered;
}
/**
* تعداد منبعِ هر نقش پس از ادغام: **بیشینه**، نه جمع و نه اولی.
*
* دو ناحیه‌ای که هرکدام یک اتاق می‌خواهند، با هم دو اتاق نمی‌خواهند — همان یک اتاق
* است. ولی اگر یکی دو اپراتور لازم داشت، ادغام نباید آن را به یک تنزل بدهد؛
* کم گرفتنش یعنی نوبتی که سرِ وقتش نیروی کافی ندارد.
*
* @param SegmentTemplate[] $templates
* @return array<string, array<string, int>> `[نامِ بخش => [کدِ نقش => تعداد]]`
*/
private function mergedCounts(array $templates): array
{
$counts = [];
foreach ($templates as $template) {
foreach ($template->getRequirements() as $requirement) {
$name = $template->getName();
$role = $requirement->getResourceType()->getCode();
$counts[$name][$role] = max($counts[$name][$role] ?? 0, $requirement->getCount());
}
}
return $counts;
}
/**
* قوانین «منبع»: نقشی که قانون لازم می‌داند، اگر الگو نداشته باشد، اضافه می‌شود.
*
@@ -363,13 +437,18 @@ final class AppointmentPlanBuilder
SegmentTemplate $template,
DoctorAddress $address,
?string $patientGender,
array $mergedCounts = [],
): array {
$planned = [];
foreach ($template->getRequirements() as $requirement) {
$eligible = $this->eligibleFor($requirement, $address, $patientGender, $template);
$count = max(
$requirement->getCount(),
$mergedCounts[$requirement->getResourceType()->getCode()] ?? 0,
);
if (count($eligible) < $requirement->getCount()) {
if (count($eligible) < $count) {
throw new AppException(
ErrorCodes::ERR_NO_ELIGIBLE_RESOURCE,
$this->explainMissing($requirement, $address, $patientGender),
@@ -384,7 +463,7 @@ final class AppointmentPlanBuilder
$planned[] = new PlannedRequirement(
role: $requirement->getResourceType()->getCode(),
roleName: $requirement->getResourceType()->getName(),
count: $requirement->getCount(),
count: $count,
occupancy: $requirement->getOccupancy(),
constraints: $requirement->getConstraints(),
eligible: $eligible,
+111
View File
@@ -324,6 +324,117 @@ class AppointmentPlanTest extends ApiTestCase
self::assertSame(25, $plan['data']['total_minutes']);
}
/**
* ⭐ ادغام واقعی: دو ناحیه که هرکدام «آماده‌سازی» خودشان را دارند.
*
* پیش از این، الگوهای آیتم‌های انتخاب‌شده اصلاً خوانده نمی‌شدند و پرچم `mergeable`
* هیچ کاری نمی‌کرد — در یک سرویس، دو بخشِ هم‌نام معنا ندارد.
*/
public function testSegmentsOfSelectedItemsAreMergedByName(): void
{
[$user, $section, $address] = $this->clinicWithBranch();
$face = $this->service($section, 'لیزر صورت', 20);
$bikini = $this->service($section, 'لیزر بیکینی', 30);
$room = $this->resourceType($address, 'room', 'اتاق');
$this->resource($user, $address, $room, 'اتاق ۱');
// آماده‌سازیِ بیکینی طولانی‌تر است؛ ادغام باید طولانی‌ترین را نگه دارد.
$this->setSegments($user, $face, [
['sequence' => 1, 'name' => 'آماده‌سازی', 'duration_minutes' => 5, 'mergeable' => true, 'requirements' => [['type_uuid' => $room->getUuid()]]],
['sequence' => 2, 'name' => 'کار اصلی', 'duration_minutes' => 0, 'duration_source' => 'items', 'requirements' => [['type_uuid' => $room->getUuid()]]],
]);
$this->setSegments($user, $bikini, [
['sequence' => 1, 'name' => 'آماده‌سازی', 'duration_minutes' => 12, 'mergeable' => true, 'requirements' => [['type_uuid' => $room->getUuid()]]],
]);
$plan = $this->preview($user, $face, $address, [
'item_uuids' => [$face->getUuid(), $bikini->getUuid()],
]);
$segments = $plan['data']['segments'];
$names = array_column($segments, 'name');
self::assertSame(['آماده‌سازی', 'کار اصلی'], $names, 'آماده‌سازی یک بار می‌آید');
self::assertSame(12, $segments[0]['duration_minutes'], 'طولانی‌ترین آماده‌سازی می‌ماند');
// کار اصلی از `DurationCalculator` می‌آید: ۲۰ + ۳۰.
self::assertSame(50, $segments[1]['duration_minutes']);
self::assertSame(62, $plan['data']['total_minutes']);
}
/**
* ⭐ تعداد منبع پس از ادغام **بیشینه** است، نه جمع و نه اولی.
*
* دو ناحیه با هم دو اتاق نمی‌خواهند؛ ولی اگر یکی دو نفر لازم داشت، ادغام نباید آن
* را به یک تنزل بدهد — نوبتی که نیروی کافی ندارد بدتر از نوبتِ نگرفته است.
*/
public function testMergingTakesTheLargestRequiredCount(): void
{
[$user, $section, $address] = $this->clinicWithBranch();
$face = $this->service($section, 'ناحیهٔ یک', 20);
$bikini = $this->service($section, 'ناحیهٔ دو', 20);
$operator = $this->resourceType($address, 'operator', 'اپراتور');
$this->resource($user, $address, $operator, 'اپراتور ۱');
$this->resource($user, $address, $operator, 'اپراتور ۲');
$this->setSegments($user, $face, [
['sequence' => 1, 'name' => 'آماده‌سازی', 'duration_minutes' => 10, 'mergeable' => true, 'requirements' => [['type_uuid' => $operator->getUuid(), 'count' => 1]]],
]);
$this->setSegments($user, $bikini, [
['sequence' => 1, 'name' => 'آماده‌سازی', 'duration_minutes' => 8, 'mergeable' => true, 'requirements' => [['type_uuid' => $operator->getUuid(), 'count' => 2]]],
]);
$plan = $this->preview($user, $face, $address, [
'item_uuids' => [$face->getUuid(), $bikini->getUuid()],
]);
$segments = $plan['data']['segments'];
self::assertCount(1, $segments);
self::assertSame(2, $segments[0]['requirements'][0]['count'], 'بیشینه، نه اولی');
}
/**
* ⭐ قطعیت: دو build با همان ورودی باید **بایت‌به‌بایت** یکی باشند.
*
* ترتیب منابع و بخش‌ها از کوئری می‌آید و کوئریِ بدون `ORDER BY` قطعی نیست. برنامه‌ای
* که بین پیش‌نمایش و رزرو جابه‌جا شود، یعنی کاربر چیزی را تأیید کرده که رزرو نشد.
*/
public function testTwoIdenticalBuildsProduceTheSamePlan(): void
{
[$user, $section, $address] = $this->clinicWithBranch();
$service = $this->service($section, 'لیزر', 20);
$room = $this->resourceType($address, 'room', 'اتاق');
$operator = $this->resourceType($address, 'operator', 'اپراتور');
foreach (['اتاق ۱', 'اتاق ۲', 'اتاق ۳'] as $name) {
$this->resource($user, $address, $room, $name);
}
foreach (['اپراتور ۱', 'اپراتور ۲'] as $name) {
$this->resource($user, $address, $operator, $name);
}
$this->setSegments($user, $service, [
['sequence' => 1, 'name' => 'آماده‌سازی', 'duration_minutes' => 5, 'requirements' => [['type_uuid' => $room->getUuid()], ['type_uuid' => $operator->getUuid()]]],
['sequence' => 2, 'name' => 'کار اصلی', 'duration_minutes' => 20, 'requirements' => [['type_uuid' => $room->getUuid()]]],
]);
$first = $this->preview($user, $service, $address)['data'];
$second = $this->preview($user, $service, $address)['data'];
self::assertSame(
json_encode($first, JSON_UNESCAPED_UNICODE),
json_encode($second, JSON_UNESCAPED_UNICODE),
);
}
public function testForeignServiceIsNotFound(): void
{
[$user, , $address] = $this->clinicWithBranch();
+4 -2
View File
@@ -277,8 +277,10 @@ class AvailabilityEngineTest extends ApiTestCase
['sequence' => 1, 'name' => 'ویزیت', 'duration_minutes' => 20, 'requirements' => [['type_uuid' => $room->getUuid()]]],
]);
$lastWeek = $this->nextSaturday() - 7 * 86400;
$body = $this->search($user, $service, $address, $lastWeek, $lastWeek);
// دو هفته عقب، نه یک هفته: وقتی امروز خودش شنبه باشد، «شنبهٔ هفتهٔ پیش» همین
// امروز است و ساعت‌های بعدازظهرش هنوز گذشته نیستند.
$past = $this->nextSaturday() - 14 * 86400;
$body = $this->search($user, $service, $address, $past, $past);
self::assertSame(200, $this->responseCode());
self::assertSame([], $body['data']['slots']);