Files
clinicpro/tests/ClinicService/ServiceSelectionTest.php
T
hamedandClaude Opus 5 4fe0c4f9bf refactor(pricing): make the service the only price source
Price lists, annual tariffs and per-branch price overrides each answered
"what does this service cost?" differently, so a single date could carry
several answers and nobody could say which one was right. Price now lives
only on ServiceItem.price_rials, edited from the services page.

- drop PriceList/PriceListItem, their repositories and the seven
  /api/v1/price-list(s) endpoints; PricingController keeps only quote and
  the appointment price snapshot
- drop Tariff, TariffRepository, TariffService and the two
  /service-items/{uuid}/tariffs endpoints; creating or repricing a service
  no longer upserts a current-year tariff
- drop price_rials from ServiceBranchOverride; the entity stays for its
  duration columns, which DurationCalculator and ServiceSelectionValidator
  still read
- InvoiceService reads the item price directly
- PricingEngine collapses to a single source; breakdown.sources always
  reports service_item, keeping the response contract intact
- remove the price-lists admin page, its route and settings-menu entry, the
  tariff modal and the service detail tariffs tab; useAppointmentInvoice
  moves to its own hook file

Migration drops price_lists, price_list_items, service_tariffs and the
override price column.

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

313 lines
13 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 testBranchOverrideChangesDurationButNotPrice(): 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(),
'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(500_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, 'کاربر نباید سه بار رفت‌وبرگشت کند');
}
}