feat: add support for individual consumable items in service items

- 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.
This commit is contained in:
hamed
2026-07-18 12:34:42 +03:30
parent c4a661b542
commit c13cc57c48
11 changed files with 530 additions and 40 deletions
@@ -6,6 +6,7 @@ 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;
@@ -114,6 +115,140 @@ class ServiceItemPackageAndAuditTest extends ApiTestCase
$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