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>
313 lines
13 KiB
PHP
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, 'کاربر نباید سه بار رفتوبرگشت کند');
|
|
}
|
|
}
|