Files
clinicpro/tests/ClinicService/ServiceSelectionTest.php
T
hamedandClaude Opus 5 b1b06c1b36 feat(catalog): dual durations, item groups, relations and branch overrides
Section 5 of the design document rejects summing service durations. "Face + bikini"
is not 15+12=27 minutes but 15+8=23 — preparation and settling the patient do not
happen twice. Seven wasted minutes times twenty appointments a day is an hour of
capacity lost daily, and AppointmentController was doing exactly that plain sum.

Each item now carries a solo duration and an additional duration. One item counts at
its solo duration and the rest at their additional; the anchor is the item with the
*largest* solo duration rather than the first one selected. Anchoring on selection
order would have let the same basket cost different amounts depending on click order,
so a patient could buy a shorter appointment by reordering. Largest-first is also
conservative: no combination is ever under-estimated, and under-estimating pushes the
next appointment on top of this one.

additional_duration_minutes stays NULL by default and the entity reads NULL as "same
as solo", so every existing service keeps behaving exactly as before — the 236
appointment-domain tests pass unchanged. The old duration_minutes column is kept and
written in step rather than renamed, because other consumers still read it.

ServiceBookingCalculator now delegates to DurationCalculator, which is the one-line
change task 00 predicted when it deliberately preserved the naive sum.

Selection rules are data, not policy: min/max per group is a number, and "bikini does
not combine with full body" is a relation. Putting either in a rules engine means
several rules per service and nobody able to explain a rejection. Validation returns
*all* errors at once rather than the first, since a user with three problems should
not make three round trips. Prerequisite cycles are rejected at write time — storing
both "A requires B" and "B requires A" would make every selection permanently invalid.

Named CatalogCategory, not ServiceCategory: that name is already an insurance enum
(outpatient/inpatient) living on ServiceItem itself, so the two would have collided in
the same file's imports.

Also fixed a defect the tests caught: breakdown() used $overrides[$id]?->… on a key
that may not exist, which warns instead of yielding null.

1175 tests / 3289 assertions. phpstan measured at 14 errors both with and without
this change (verified by stashing). Slot-mode frozen contract green.

The admin UI tab for groups and relations is not built; the checklist records it as
outstanding with a target. The backend is complete and
POST /service-selection/validate is consumable without it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 18:44:02 +03:30

313 lines
12 KiB
PHP

<?php
namespace App\Tests\ClinicService;
use App\Auth\Entity\User;
use App\Clinic\Entity\Clinic;
use App\ClinicService\Entity\ServiceItem;
use App\ClinicService\Entity\ServiceItemRelation;
use App\ClinicService\Entity\ServiceSection;
use App\Doctor\Entity\DoctorAddress;
use App\Tests\ApiTestCase;
/**
* `POST /service-selection/validate` — مهم‌ترین اندپوینت کاتالوگ نسخهٔ ۲.
* سایت عمومی و پنل هر دو پیش از مرحلهٔ انتخاب زمان صدایش می‌زنند.
*/
class ServiceSelectionTest extends ApiTestCase
{
/** @return array{0: User, 1: Clinic, 2: ServiceSection, 3: DoctorAddress} */
private function clinicWithSection(): array
{
$user = $this->createUser(['ROLE_USER', 'ROLE_CLINIC']);
$clinic = new Clinic($user);
$clinic->setName('کلینیک کاتالوگ');
$this->em->persist($clinic);
$this->em->flush();
$section = new ServiceSection('clinic', $clinic->getId(), 'لیزر');
$this->em->persist($section);
$address = DoctorAddress::forClinic($clinic->getId());
$address->setName('شعبهٔ مرکزی');
$this->em->persist($address);
$this->em->flush();
return [$user, $clinic, $section, $address];
}
private function item(ServiceSection $section, string $name, ?int $solo, ?int $additional, int $price = 0): ServiceItem
{
$item = new ServiceItem($section, $name);
$item->setSoloDurationMinutes($solo);
$item->setAdditionalDurationMinutes($additional);
$item->setPriceRials($price);
$this->em->persist($item);
$this->em->flush();
return $item;
}
/** @param string[] $itemUuids */
private function validate(User $user, array $itemUuids, array $extra = []): array
{
return $this->authJson('POST', '/api/v1/service-selection/validate', $user, $extra + [
'item_uuids' => $itemUuids,
]);
}
private function group(User $user, ServiceItem $service, string $name, int $min, ?int $max, array $items): string
{
$created = $this->authJson('POST', "/api/v1/service-item/{$service->getUuid()}/groups", $user, [
'name' => $name,
'min_select' => $min,
'max_select' => $max,
]);
self::assertSame(201, $this->responseCode(), json_encode($created, JSON_UNESCAPED_UNICODE));
$uuid = $created['data']['uuid'];
$this->authJson('PUT', "/api/v1/item-group/$uuid/items", $user, [
'items' => array_map(static fn (ServiceItem $i): array => ['item_uuid' => $i->getUuid()], $items),
]);
self::assertSame(200, $this->responseCode());
return $uuid;
}
public function testDocumentExampleTwentyThreeMinutes(): void
{
[$user, , $section] = $this->clinicWithSection();
$face = $this->item($section, 'صورت', 15, 8);
$bikini = $this->item($section, 'بیکینی', 12, 8);
$body = $this->validate($user, [$face->getUuid(), $bikini->getUuid()]);
self::assertSame(200, $this->responseCode(), json_encode($body, JSON_UNESCAPED_UNICODE));
self::assertTrue($body['data']['valid']);
self::assertSame(23, $body['data']['total_duration_minutes'], 'نه ۲۷ — جمع ساده رد شده است');
}
public function testSingleItemUsesSoloDuration(): void
{
[$user, , $section] = $this->clinicWithSection();
$bikini = $this->item($section, 'بیکینی', 12, 8);
$body = $this->validate($user, [$bikini->getUuid()]);
self::assertSame(12, $body['data']['total_duration_minutes']);
}
public function testMinSelectIsEnforced(): void
{
[$user, , $section] = $this->clinicWithSection();
$service = $this->item($section, 'لیزر', 0, 0);
$face = $this->item($section, 'صورت', 15, 8);
$groupUuid = $this->group($user, $service, 'نواحی', 1, 8, [$face]);
$body = $this->validate($user, [], ['service_uuid' => $service->getUuid()]);
self::assertFalse($body['data']['valid']);
self::assertSame('min_select', $body['data']['errors'][0]['code']);
self::assertSame($groupUuid, $body['data']['errors'][0]['group_uuid']);
self::assertStringContainsString('نواحی', $body['data']['errors'][0]['message']);
}
public function testMaxSelectIsEnforced(): void
{
[$user, , $section] = $this->clinicWithSection();
$service = $this->item($section, 'لیزر', 0, 0);
$items = [];
foreach (range(1, 3) as $n) {
$items[] = $this->item($section, "ناحیه $n", 10, 5);
}
$this->group($user, $service, 'نواحی', 1, 2, $items);
$body = $this->validate(
$user,
array_map(static fn (ServiceItem $i): string => $i->getUuid(), $items),
['service_uuid' => $service->getUuid()],
);
self::assertFalse($body['data']['valid']);
self::assertSame('max_select', $body['data']['errors'][0]['code']);
}
/** `max_select = null` یعنی نامحدود، نه صفر. */
public function testNullMaxSelectMeansUnlimited(): void
{
[$user, , $section] = $this->clinicWithSection();
$service = $this->item($section, 'لیزر', 0, 0);
$items = [];
foreach (range(1, 5) as $n) {
$items[] = $this->item($section, "ناحیه $n", 10, 5);
}
$this->group($user, $service, 'نواحی', 1, null, $items);
$body = $this->validate(
$user,
array_map(static fn (ServiceItem $i): string => $i->getUuid(), $items),
['service_uuid' => $service->getUuid()],
);
self::assertTrue($body['data']['valid']);
}
/** `min_select = 0` یعنی گروه اختیاری است. */
public function testZeroMinSelectMeansOptional(): void
{
[$user, , $section] = $this->clinicWithSection();
$service = $this->item($section, 'لیزر', 0, 0);
$this->group($user, $service, 'افزودنی‌ها', 0, 3, [$this->item($section, 'ژل', 5, 5)]);
$body = $this->validate($user, [], ['service_uuid' => $service->getUuid()]);
self::assertTrue($body['data']['valid']);
}
public function testIncompatibleItemsAreRejectedOnceNotTwice(): void
{
[$user, , $section] = $this->clinicWithSection();
$bikini = $this->item($section, 'بیکینی', 12, 8);
$fullBody = $this->item($section, 'فول‌بادی', 60, 40);
$this->authJson('PUT', "/api/v1/service-item/{$bikini->getUuid()}/relations", $user, [
'relations' => [[
'related_item_uuid' => $fullBody->getUuid(),
'type' => ServiceItemRelation::TYPE_INCOMPATIBLE,
]],
]);
self::assertSame(200, $this->responseCode());
$body = $this->validate($user, [$bikini->getUuid(), $fullBody->getUuid()]);
self::assertFalse($body['data']['valid']);
self::assertCount(1, $body['data']['errors'], 'یک جفت، یک خطا');
self::assertSame('incompatible', $body['data']['errors'][0]['code']);
self::assertStringContainsString('بیکینی', $body['data']['errors'][0]['message']);
self::assertStringContainsString('فول‌بادی', $body['data']['errors'][0]['message']);
}
/** ناسازگاری فقط وقتی خطاست که هر دو انتخاب شده باشند. */
public function testIncompatibilityIsSilentWhenOnlyOneIsSelected(): void
{
[$user, , $section] = $this->clinicWithSection();
$bikini = $this->item($section, 'بیکینی', 12, 8);
$fullBody = $this->item($section, 'فول‌بادی', 60, 40);
$this->authJson('PUT', "/api/v1/service-item/{$bikini->getUuid()}/relations", $user, [
'relations' => [[
'related_item_uuid' => $fullBody->getUuid(),
'type' => ServiceItemRelation::TYPE_INCOMPATIBLE,
]],
]);
$body = $this->validate($user, [$bikini->getUuid()]);
self::assertTrue($body['data']['valid']);
}
public function testMissingPrerequisiteIsReported(): void
{
[$user, , $section] = $this->clinicWithSection();
$peel = $this->item($section, 'پیلینگ', 20, 10);
$cleanse = $this->item($section, 'پاک‌سازی', 10, 5);
$this->authJson('PUT', "/api/v1/service-item/{$peel->getUuid()}/relations", $user, [
'relations' => [[
'related_item_uuid' => $cleanse->getUuid(),
'type' => ServiceItemRelation::TYPE_REQUIRES,
]],
]);
self::assertSame(200, $this->responseCode());
$missing = $this->validate($user, [$peel->getUuid()]);
self::assertFalse($missing['data']['valid']);
self::assertSame('missing_prerequisite', $missing['data']['errors'][0]['code']);
$satisfied = $this->validate($user, [$peel->getUuid(), $cleanse->getUuid()]);
self::assertTrue($satisfied['data']['valid']);
}
/** حلقهٔ پیش‌نیاز باید هنگام **ثبت** رد شود، نه در اعتبارسنجی انتخاب. */
public function testPrerequisiteCycleIsRejectedAtWriteTime(): void
{
[$user, , $section] = $this->clinicWithSection();
$a = $this->item($section, 'الف', 10, 5);
$b = $this->item($section, 'ب', 10, 5);
$this->authJson('PUT', "/api/v1/service-item/{$a->getUuid()}/relations", $user, [
'relations' => [['related_item_uuid' => $b->getUuid(), 'type' => ServiceItemRelation::TYPE_REQUIRES]],
]);
self::assertSame(200, $this->responseCode());
$body = $this->authJson('PUT', "/api/v1/service-item/{$b->getUuid()}/relations", $user, [
'relations' => [['related_item_uuid' => $a->getUuid(), 'type' => ServiceItemRelation::TYPE_REQUIRES]],
]);
self::assertSame(422, $this->responseCode());
self::assertStringContainsString('حلقه', $body['errors'][0]['message']);
}
public function testBranchOverrideChangesPriceAndDuration(): void
{
[$user, , $section, $address] = $this->clinicWithSection();
$face = $this->item($section, 'صورت', 15, 8, 500_000);
$this->authJson('PUT', "/api/v1/service-item/{$face->getUuid()}/branch-overrides", $user, [
'overrides' => [[
'address_uuid' => $address->getUuid(),
'price_rials' => 900_000,
'solo_duration_minutes' => 25,
]],
]);
self::assertSame(200, $this->responseCode());
$plain = $this->validate($user, [$face->getUuid()]);
self::assertSame(500_000, $plain['data']['total_price_rials']);
self::assertSame(15, $plain['data']['total_duration_minutes']);
$atBranch = $this->validate($user, [$face->getUuid()], ['branch_uuid' => $address->getUuid()]);
self::assertSame(900_000, $atBranch['data']['total_price_rials']);
self::assertSame(25, $atBranch['data']['total_duration_minutes']);
}
/** آیتم محیط دیگر ۴۰۴ می‌دهد نه ۴۲۲ — وجودش نباید لو برود. */
public function testForeignItemIsNotFound(): void
{
[$user] = $this->clinicWithSection();
[, , $otherSection] = $this->clinicWithSection();
$foreign = $this->item($otherSection, 'آیتم بیگانه', 10, 5);
$this->validate($user, [$foreign->getUuid()]);
self::assertSame(404, $this->responseCode());
}
public function testAllErrorsAreReportedTogether(): void
{
[$user, , $section] = $this->clinicWithSection();
$service = $this->item($section, 'لیزر', 0, 0);
$a = $this->item($section, 'الف', 10, 5);
$b = $this->item($section, 'ب', 10, 5);
$this->group($user, $service, 'نواحی', 1, 1, [$a, $b]);
$this->authJson('PUT', "/api/v1/service-item/{$a->getUuid()}/relations", $user, [
'relations' => [['related_item_uuid' => $b->getUuid(), 'type' => ServiceItemRelation::TYPE_INCOMPATIBLE]],
]);
$body = $this->validate($user, [$a->getUuid(), $b->getUuid()], ['service_uuid' => $service->getUuid()]);
self::assertFalse($body['data']['valid']);
$codes = array_column($body['data']['errors'], 'code');
self::assertContains('max_select', $codes);
self::assertContains('incompatible', $codes, 'کاربر نباید سه بار رفت‌وبرگشت کند');
}
}