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:
@@ -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();
|
||||
|
||||
@@ -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']);
|
||||
|
||||
Reference in New Issue
Block a user