- Introduced `consumables` field in `ServiceItem` to allow multiple individual items alongside inventory packages. - Created `ServiceItemConsumable` entity to manage individual consumable items linked to a service. - Updated `ServiceItemController` to handle CRUD operations for consumables. - Enhanced `ServiceDetailPage` and `ServiceItemFormModal` to display and manage consumables. - Added tests to ensure functionality for adding, updating, and validating consumables. - Updated API documentation to reflect changes in service item structure and consumables.
341 lines
14 KiB
PHP
341 lines
14 KiB
PHP
<?php
|
||
|
||
namespace App\Tests\ClinicService;
|
||
|
||
use App\Auth\Entity\User;
|
||
use App\ClinicService\Entity\ServiceItem;
|
||
use App\ClinicService\Entity\ServiceSection;
|
||
use App\Doctor\Entity\Doctor;
|
||
use App\Inventory\Entity\InventoryItem;
|
||
use App\Inventory\Entity\InventoryPackage;
|
||
use App\Tests\ApiTestCase;
|
||
|
||
/**
|
||
* دو قابلیت صفحهی جزئیات سرویس:
|
||
* ۱. اتصال اختیاری خدمت به یک پکیج کالای مصرفی (کالاهای مرتبط)
|
||
* ۲. تاریخچهی تغییرات خدمت (service_item_audit_logs)
|
||
*/
|
||
class ServiceItemPackageAndAuditTest extends ApiTestCase
|
||
{
|
||
/** @return array{0: User, 1: Doctor, 2: ServiceSection} */
|
||
private function makeDoctorWithSection(): array
|
||
{
|
||
$owner = $this->createUser(['ROLE_DOCTOR']);
|
||
$doctor = new Doctor($owner, 'دکتر تست پکیج');
|
||
$this->em->persist($doctor);
|
||
$this->em->flush();
|
||
|
||
$section = new ServiceSection('doctor', $doctor->getId(), 'تزریقات');
|
||
$this->em->persist($section);
|
||
$this->em->flush();
|
||
|
||
return [$owner, $doctor, $section];
|
||
}
|
||
|
||
private function makePackage(Doctor $doctor, string $title = 'پکیج سرم'): InventoryPackage
|
||
{
|
||
$package = new InventoryPackage('doctor', $doctor->getId(), $title);
|
||
$this->em->persist($package);
|
||
$this->em->flush();
|
||
|
||
return $package;
|
||
}
|
||
|
||
private function makeItem(ServiceSection $section, string $name = 'سرم ۵۰۰cc'): ServiceItem
|
||
{
|
||
$item = new ServiceItem($section, $name, 850_000);
|
||
$this->em->persist($item);
|
||
$this->em->flush();
|
||
|
||
return $item;
|
||
}
|
||
|
||
// ── کالاهای مرتبط ────────────────────────────────────────────────────────
|
||
|
||
public function testAttachingAPackageIsReflectedInTheDetailResponse(): void
|
||
{
|
||
[$owner, $doctor, $section] = $this->makeDoctorWithSection();
|
||
$package = $this->makePackage($doctor);
|
||
$item = $this->makeItem($section);
|
||
|
||
$this->authJson('PATCH', '/api/v1/service-item/' . $item->getUuid(), $owner, [
|
||
'inventory_package_uuid' => $package->getUuid(),
|
||
]);
|
||
$this->assertSame(200, $this->responseCode());
|
||
|
||
$body = $this->authJson('GET', '/api/v1/service-item/' . $item->getUuid(), $owner);
|
||
$this->assertSame($package->getUuid(), $body['data']['inventory_package_uuid']);
|
||
$this->assertSame('پکیج سرم', $body['data']['inventory_package_title']);
|
||
}
|
||
|
||
public function testDetachingWithNullClearsThePackage(): void
|
||
{
|
||
[$owner, $doctor, $section] = $this->makeDoctorWithSection();
|
||
$package = $this->makePackage($doctor);
|
||
$item = $this->makeItem($section);
|
||
|
||
$this->authJson('PATCH', '/api/v1/service-item/' . $item->getUuid(), $owner, [
|
||
'inventory_package_uuid' => $package->getUuid(),
|
||
]);
|
||
$this->authJson('PATCH', '/api/v1/service-item/' . $item->getUuid(), $owner, [
|
||
'inventory_package_uuid' => null,
|
||
]);
|
||
|
||
$body = $this->authJson('GET', '/api/v1/service-item/' . $item->getUuid(), $owner);
|
||
$this->assertNull($body['data']['inventory_package_uuid']);
|
||
}
|
||
|
||
public function testPackageOfAnotherTenantIsRejected(): void
|
||
{
|
||
[$owner, , $section] = $this->makeDoctorWithSection();
|
||
$item = $this->makeItem($section);
|
||
|
||
$stranger = $this->createUser(['ROLE_DOCTOR']);
|
||
$other = new Doctor($stranger, 'دکتر غریبه');
|
||
$this->em->persist($other);
|
||
$this->em->flush();
|
||
$foreignPackage = $this->makePackage($other, 'پکیج غریبه');
|
||
|
||
$this->authJson('PATCH', '/api/v1/service-item/' . $item->getUuid(), $owner, [
|
||
'inventory_package_uuid' => $foreignPackage->getUuid(),
|
||
]);
|
||
|
||
$this->assertSame(422, $this->responseCode());
|
||
}
|
||
|
||
public function testUnknownPackageUuidIsRejected(): void
|
||
{
|
||
[$owner, , $section] = $this->makeDoctorWithSection();
|
||
$item = $this->makeItem($section);
|
||
|
||
$this->authJson('PATCH', '/api/v1/service-item/' . $item->getUuid(), $owner, [
|
||
'inventory_package_uuid' => 'no-such-package',
|
||
]);
|
||
|
||
$this->assertSame(422, $this->responseCode());
|
||
}
|
||
|
||
// ── کالاهای تکی (مکمل پکیج) ──────────────────────────────────────────────
|
||
|
||
private function makeInventoryItem(Doctor $doctor, string $name, int $price = 50_000): InventoryItem
|
||
{
|
||
$inventoryItem = new InventoryItem('doctor', $doctor->getId(), $name);
|
||
$inventoryItem->setPrice($price)->setStock(100);
|
||
$this->em->persist($inventoryItem);
|
||
$this->em->flush();
|
||
|
||
return $inventoryItem;
|
||
}
|
||
|
||
public function testAServiceCanHaveBothAPackageAndIndividualItems(): void
|
||
{
|
||
[$owner, $doctor, $section] = $this->makeDoctorWithSection();
|
||
$package = $this->makePackage($doctor);
|
||
$gauze = $this->makeInventoryItem($doctor, 'گاز استریل');
|
||
$syringe = $this->makeInventoryItem($doctor, 'سرنگ ۵cc');
|
||
$item = $this->makeItem($section);
|
||
|
||
$this->authJson('PATCH', '/api/v1/service-item/' . $item->getUuid(), $owner, [
|
||
'inventory_package_uuid' => $package->getUuid(),
|
||
'consumables' => [
|
||
['item_uuid' => $gauze->getUuid(), 'amount' => 2],
|
||
['item_uuid' => $syringe->getUuid(), 'amount' => 1],
|
||
],
|
||
]);
|
||
$this->assertSame(200, $this->responseCode());
|
||
|
||
$body = $this->authJson('GET', '/api/v1/service-item/' . $item->getUuid(), $owner);
|
||
$row = $body['data'];
|
||
|
||
$this->assertSame($package->getUuid(), $row['inventory_package_uuid'], 'پکیج باید کنار اقلام تکی بماند');
|
||
$this->assertCount(2, $row['consumables']);
|
||
|
||
$byName = array_column($row['consumables'], null, 'name');
|
||
$this->assertSame(2, $byName['گاز استریل']['amount']);
|
||
$this->assertSame(1, $byName['سرنگ ۵cc']['amount']);
|
||
$this->assertSame(50_000, $byName['گاز استریل']['price']);
|
||
}
|
||
|
||
public function testResendingConsumablesReplacesTheWholeList(): void
|
||
{
|
||
[$owner, $doctor, $section] = $this->makeDoctorWithSection();
|
||
$gauze = $this->makeInventoryItem($doctor, 'گاز استریل');
|
||
$syringe = $this->makeInventoryItem($doctor, 'سرنگ ۵cc');
|
||
$item = $this->makeItem($section);
|
||
|
||
$this->authJson('PATCH', '/api/v1/service-item/' . $item->getUuid(), $owner, [
|
||
'consumables' => [
|
||
['item_uuid' => $gauze->getUuid(), 'amount' => 2],
|
||
['item_uuid' => $syringe->getUuid(), 'amount' => 1],
|
||
],
|
||
]);
|
||
|
||
// فقط یک قلم با تعداد جدید ارسال میشود
|
||
$this->authJson('PATCH', '/api/v1/service-item/' . $item->getUuid(), $owner, [
|
||
'consumables' => [['item_uuid' => $gauze->getUuid(), 'amount' => 5]],
|
||
]);
|
||
|
||
$body = $this->authJson('GET', '/api/v1/service-item/' . $item->getUuid(), $owner);
|
||
$this->assertCount(1, $body['data']['consumables']);
|
||
$this->assertSame('گاز استریل', $body['data']['consumables'][0]['name']);
|
||
$this->assertSame(5, $body['data']['consumables'][0]['amount']);
|
||
}
|
||
|
||
public function testEmptyConsumablesArrayClearsThem(): void
|
||
{
|
||
[$owner, $doctor, $section] = $this->makeDoctorWithSection();
|
||
$gauze = $this->makeInventoryItem($doctor, 'گاز استریل');
|
||
$item = $this->makeItem($section);
|
||
|
||
$this->authJson('PATCH', '/api/v1/service-item/' . $item->getUuid(), $owner, [
|
||
'consumables' => [['item_uuid' => $gauze->getUuid(), 'amount' => 2]],
|
||
]);
|
||
$this->authJson('PATCH', '/api/v1/service-item/' . $item->getUuid(), $owner, ['consumables' => []]);
|
||
|
||
$body = $this->authJson('GET', '/api/v1/service-item/' . $item->getUuid(), $owner);
|
||
$this->assertSame([], $body['data']['consumables']);
|
||
}
|
||
|
||
public function testConsumableOfAnotherTenantIsRejected(): void
|
||
{
|
||
[$owner, , $section] = $this->makeDoctorWithSection();
|
||
$item = $this->makeItem($section);
|
||
|
||
$stranger = $this->createUser(['ROLE_DOCTOR']);
|
||
$other = new Doctor($stranger, 'دکتر غریبه');
|
||
$this->em->persist($other);
|
||
$this->em->flush();
|
||
$foreignItem = $this->makeInventoryItem($other, 'کالای غریبه');
|
||
|
||
$this->authJson('PATCH', '/api/v1/service-item/' . $item->getUuid(), $owner, [
|
||
'consumables' => [['item_uuid' => $foreignItem->getUuid(), 'amount' => 1]],
|
||
]);
|
||
|
||
$this->assertSame(422, $this->responseCode());
|
||
}
|
||
|
||
public function testConsumablesCanBeSetAtCreationTime(): void
|
||
{
|
||
[$owner, $doctor, $section] = $this->makeDoctorWithSection();
|
||
$gauze = $this->makeInventoryItem($doctor, 'گاز استریل');
|
||
|
||
$created = $this->authJson('POST', '/api/v1/service-item', $owner, [
|
||
'section_uuid' => $section->getUuid(),
|
||
'name' => 'پانسمان',
|
||
'price_rials' => 200_000,
|
||
'consumables' => [['item_uuid' => $gauze->getUuid(), 'amount' => 3]],
|
||
]);
|
||
|
||
$this->assertSame(201, $this->responseCode());
|
||
$this->assertCount(1, $created['data']['consumables']);
|
||
$this->assertSame(3, $created['data']['consumables'][0]['amount']);
|
||
}
|
||
|
||
public function testChangingConsumablesIsLogged(): void
|
||
{
|
||
[$owner, $doctor, $section] = $this->makeDoctorWithSection();
|
||
$gauze = $this->makeInventoryItem($doctor, 'گاز استریل');
|
||
$item = $this->makeItem($section);
|
||
|
||
$this->authJson('PATCH', '/api/v1/service-item/' . $item->getUuid(), $owner, [
|
||
'consumables' => [['item_uuid' => $gauze->getUuid(), 'amount' => 2]],
|
||
]);
|
||
|
||
$body = $this->authJson('GET', '/api/v1/service-item/' . $item->getUuid() . '/audit-logs', $owner);
|
||
$byField = array_column($body['data'], null, 'field');
|
||
|
||
$this->assertArrayHasKey('consumables', $byField);
|
||
$this->assertNull($byField['consumables']['old_value']);
|
||
$this->assertSame('گاز استریل×2', $byField['consumables']['new_value']);
|
||
}
|
||
|
||
// ── لاگ تغییرات ──────────────────────────────────────────────────────────
|
||
|
||
public function testCreatingAServiceWritesACreateLog(): void
|
||
{
|
||
[$owner, , $section] = $this->makeDoctorWithSection();
|
||
|
||
$created = $this->authJson('POST', '/api/v1/service-item', $owner, [
|
||
'section_uuid' => $section->getUuid(),
|
||
'name' => 'خدمت تازه',
|
||
'price_rials' => 100_000,
|
||
]);
|
||
$this->assertSame(201, $this->responseCode());
|
||
|
||
$body = $this->authJson('GET', '/api/v1/service-item/' . $created['data']['uuid'] . '/audit-logs', $owner);
|
||
$this->assertSame(200, $this->responseCode());
|
||
$logs = $body['data'];
|
||
$this->assertCount(1, $logs);
|
||
$this->assertSame('create', $logs[0]['operation']);
|
||
$this->assertSame('خدمت تازه', $logs[0]['new_value']);
|
||
}
|
||
|
||
public function testEachChangedFieldGetsItsOwnLogRow(): void
|
||
{
|
||
[$owner, , $section] = $this->makeDoctorWithSection();
|
||
$item = $this->makeItem($section);
|
||
|
||
$this->authJson('PATCH', '/api/v1/service-item/' . $item->getUuid(), $owner, [
|
||
'name' => 'نام جدید',
|
||
'price_rials' => 990_000,
|
||
]);
|
||
|
||
$body = $this->authJson('GET', '/api/v1/service-item/' . $item->getUuid() . '/audit-logs', $owner);
|
||
$fields = array_column($body['data'], 'field');
|
||
sort($fields);
|
||
|
||
$this->assertSame(['name', 'price_rials'], $fields);
|
||
|
||
$byField = array_column($body['data'], null, 'field');
|
||
$this->assertSame('سرم ۵۰۰cc', $byField['name']['old_value']);
|
||
$this->assertSame('نام جدید', $byField['name']['new_value']);
|
||
$this->assertSame('850000', $byField['price_rials']['old_value']);
|
||
$this->assertSame('990000', $byField['price_rials']['new_value']);
|
||
}
|
||
|
||
public function testUnchangedFieldsAreNotLogged(): void
|
||
{
|
||
[$owner, , $section] = $this->makeDoctorWithSection();
|
||
$item = $this->makeItem($section);
|
||
|
||
// همان مقادیر فعلی دوباره ارسال میشوند
|
||
$this->authJson('PATCH', '/api/v1/service-item/' . $item->getUuid(), $owner, [
|
||
'name' => 'سرم ۵۰۰cc',
|
||
'price_rials' => 850_000,
|
||
]);
|
||
|
||
$body = $this->authJson('GET', '/api/v1/service-item/' . $item->getUuid() . '/audit-logs', $owner);
|
||
$this->assertSame([], $body['data']);
|
||
}
|
||
|
||
public function testAttachingAPackageIsLogged(): void
|
||
{
|
||
[$owner, $doctor, $section] = $this->makeDoctorWithSection();
|
||
$package = $this->makePackage($doctor);
|
||
$item = $this->makeItem($section);
|
||
|
||
$this->authJson('PATCH', '/api/v1/service-item/' . $item->getUuid(), $owner, [
|
||
'inventory_package_uuid' => $package->getUuid(),
|
||
]);
|
||
|
||
$body = $this->authJson('GET', '/api/v1/service-item/' . $item->getUuid() . '/audit-logs', $owner);
|
||
$fields = array_column($body['data'], 'field');
|
||
|
||
$this->assertContains('inventory_package', $fields);
|
||
}
|
||
|
||
public function testAuditLogsOfAnotherTenantAreNotReadable(): void
|
||
{
|
||
[, , $section] = $this->makeDoctorWithSection();
|
||
$item = $this->makeItem($section);
|
||
|
||
$stranger = $this->createUser(['ROLE_DOCTOR']);
|
||
$this->em->persist(new Doctor($stranger, 'دکتر غریبه'));
|
||
$this->em->flush();
|
||
|
||
$this->authJson('GET', '/api/v1/service-item/' . $item->getUuid() . '/audit-logs', $stranger);
|
||
|
||
$this->assertSame(404, $this->responseCode());
|
||
}
|
||
}
|