feat: port inventory (انبارداری) page from tauri to admin dashboard
Add a per-tenant (doctor/clinic) Inventory domain and admin page, ported from clinic-pro-tauri /inventory (which was static/mock) into a real feature. Backend (src/Inventory/): - Entities InventoryItem, InventoryPackage, InventoryPackageItem, scoped via entity_type/entity_id like TenantTag. Item status is derived, package total and availability derived at read time. - InventoryService (stats, package assembly, availability), thin InventoryController with CRUD for items and packages + categories endpoint. - Migration + docs/api/inventory.md + functional tests (10 tests, 42 assertions). Frontend (assets/admin/): - InventoryPage with two tabs (کالاهای مصرفی / پکیج), stat cards, items table (desktop + mobile cards), packages accordion, add/edit item and package modals, search + category filter — pixel-matched to the tauri source. - useInventory hook (TanStack Query), route + sidebar link for doctor/clinic. - Vitest coverage (real data, empty state, modal, packages tab). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,196 @@
|
||||
<?php
|
||||
|
||||
namespace App\Tests\Inventory;
|
||||
|
||||
use App\Doctor\Entity\Doctor;
|
||||
use App\Tests\ApiTestCase;
|
||||
|
||||
/**
|
||||
* Per-tenant inventory CRUD (items + packages), scoped to the caller's doctor
|
||||
* entity. Covers success, derived aggregates, validation, empty state and
|
||||
* cross-tenant isolation.
|
||||
*/
|
||||
class InventoryApiTest extends ApiTestCase
|
||||
{
|
||||
private function doctorUser(): array
|
||||
{
|
||||
$user = $this->createUser(['ROLE_DOCTOR']);
|
||||
$doctor = new Doctor($user, 'دکتر');
|
||||
$this->em->persist($doctor);
|
||||
$this->em->flush();
|
||||
return [$user, $doctor];
|
||||
}
|
||||
|
||||
// ── Items ────────────────────────────────────────────────────────────────
|
||||
|
||||
public function testItemCreateListUpdateDelete(): void
|
||||
{
|
||||
[$user] = $this->doctorUser();
|
||||
|
||||
$created = $this->authJson('POST', '/api/v1/inventory-item', $user, [
|
||||
'name' => 'دستکش', 'unit' => 'عدد', 'price' => 250000, 'stock' => 150, 'alertThreshold' => 20,
|
||||
]);
|
||||
self::assertSame(201, $this->responseCode());
|
||||
self::assertSame('دستکش', $created['data']['name']);
|
||||
self::assertSame('in_stock', $created['data']['status']);
|
||||
$uuid = $created['data']['uuid'];
|
||||
|
||||
$list = $this->authJson('GET', '/api/v1/inventory-items', $user);
|
||||
self::assertSame(200, $this->responseCode());
|
||||
self::assertCount(1, $list['data']['items']);
|
||||
self::assertSame(1, $list['data']['stats']['total']);
|
||||
self::assertSame(1, $list['data']['stats']['inStock']);
|
||||
|
||||
// drop stock below threshold → low_stock
|
||||
$this->authJson('PATCH', '/api/v1/inventory-item/' . $uuid, $user, ['stock' => 5]);
|
||||
self::assertSame(200, $this->responseCode());
|
||||
$afterPatch = $this->authJson('GET', '/api/v1/inventory-items', $user);
|
||||
self::assertSame('low_stock', $afterPatch['data']['items'][0]['status']);
|
||||
self::assertSame(1, $afterPatch['data']['stats']['low']);
|
||||
|
||||
$this->authJson('DELETE', '/api/v1/inventory-item/' . $uuid, $user);
|
||||
self::assertSame(200, $this->responseCode());
|
||||
$after = $this->authJson('GET', '/api/v1/inventory-items', $user);
|
||||
self::assertCount(0, $after['data']['items']);
|
||||
}
|
||||
|
||||
public function testStatusDerivation(): void
|
||||
{
|
||||
[$user] = $this->doctorUser();
|
||||
|
||||
// zero stock → out_of_stock, regardless of threshold
|
||||
$out = $this->authJson('POST', '/api/v1/inventory-item', $user, ['name' => 'تمامشده', 'stock' => 0, 'alertThreshold' => 5]);
|
||||
self::assertSame('out_of_stock', $out['data']['status']);
|
||||
|
||||
// stock above threshold → in_stock
|
||||
$in = $this->authJson('POST', '/api/v1/inventory-item', $user, ['name' => 'موجود', 'stock' => 100, 'alertThreshold' => 10]);
|
||||
self::assertSame('in_stock', $in['data']['status']);
|
||||
}
|
||||
|
||||
public function testItemRejectsEmptyName(): void
|
||||
{
|
||||
[$user] = $this->doctorUser();
|
||||
$this->authJson('POST', '/api/v1/inventory-item', $user, ['name' => '']);
|
||||
self::assertSame(422, $this->responseCode());
|
||||
}
|
||||
|
||||
public function testEmptyStateReturnsZeroStats(): void
|
||||
{
|
||||
[$user] = $this->doctorUser();
|
||||
$list = $this->authJson('GET', '/api/v1/inventory-items', $user);
|
||||
self::assertSame(200, $this->responseCode());
|
||||
self::assertCount(0, $list['data']['items']);
|
||||
self::assertSame(
|
||||
['total' => 0, 'low' => 0, 'inStock' => 0, 'outOfStock' => 0],
|
||||
$list['data']['stats']
|
||||
);
|
||||
}
|
||||
|
||||
public function testCategoriesReturnsDistinctConsumables(): void
|
||||
{
|
||||
[$user] = $this->doctorUser();
|
||||
$this->authJson('POST', '/api/v1/inventory-item', $user, ['name' => 'a', 'consumable' => 'جراحی']);
|
||||
$this->authJson('POST', '/api/v1/inventory-item', $user, ['name' => 'b', 'consumable' => 'جراحی']);
|
||||
$this->authJson('POST', '/api/v1/inventory-item', $user, ['name' => 'c', 'consumable' => 'دندان']);
|
||||
|
||||
$cats = $this->authJson('GET', '/api/v1/inventory-categories', $user);
|
||||
self::assertSame(200, $this->responseCode());
|
||||
self::assertCount(2, $cats['data']);
|
||||
self::assertContains('جراحی', $cats['data']);
|
||||
self::assertContains('دندان', $cats['data']);
|
||||
}
|
||||
|
||||
public function testCannotTouchAnotherTenantsItem(): void
|
||||
{
|
||||
[$ownerA] = $this->doctorUser();
|
||||
$created = $this->authJson('POST', '/api/v1/inventory-item', $ownerA, ['name' => 'مال A']);
|
||||
$uuid = $created['data']['uuid'];
|
||||
|
||||
[$ownerB] = $this->doctorUser();
|
||||
$this->authJson('PATCH', '/api/v1/inventory-item/' . $uuid, $ownerB, ['name' => 'دزدی']);
|
||||
self::assertSame(404, $this->responseCode());
|
||||
$this->authJson('DELETE', '/api/v1/inventory-item/' . $uuid, $ownerB);
|
||||
self::assertSame(404, $this->responseCode());
|
||||
}
|
||||
|
||||
// ── Packages ─────────────────────────────────────────────────────────────
|
||||
|
||||
public function testPackageCreateComputesTotalAndAvailability(): void
|
||||
{
|
||||
[$user] = $this->doctorUser();
|
||||
|
||||
$gel = $this->authJson('POST', '/api/v1/inventory-item', $user, ['name' => 'ژل', 'price' => 1200000, 'stock' => 10])['data']['uuid'];
|
||||
$glove = $this->authJson('POST', '/api/v1/inventory-item', $user, ['name' => 'دستکش', 'price' => 500000, 'stock' => 1])['data']['uuid'];
|
||||
|
||||
// 2×ژل (stock 10, ok) + 3×دستکش (stock 1, NOT enough) → unavailable
|
||||
$pkg = $this->authJson('POST', '/api/v1/inventory-package', $user, [
|
||||
'title' => 'پکیج یک',
|
||||
'items' => [
|
||||
['itemUuid' => $gel, 'amount' => 2],
|
||||
['itemUuid' => $glove, 'amount' => 3],
|
||||
],
|
||||
]);
|
||||
self::assertSame(201, $this->responseCode());
|
||||
self::assertSame(2 * 1200000 + 3 * 500000, $pkg['data']['total']);
|
||||
self::assertFalse($pkg['data']['available']);
|
||||
self::assertCount(2, $pkg['data']['items']);
|
||||
|
||||
// list reflects the same package
|
||||
$list = $this->authJson('GET', '/api/v1/inventory-packages', $user);
|
||||
self::assertCount(1, $list['data']);
|
||||
self::assertSame('پکیج یک', $list['data'][0]['title']);
|
||||
}
|
||||
|
||||
public function testPackageUpdateReplacesItemsAndDelete(): void
|
||||
{
|
||||
[$user] = $this->doctorUser();
|
||||
$a = $this->authJson('POST', '/api/v1/inventory-item', $user, ['name' => 'A', 'price' => 1000, 'stock' => 50])['data']['uuid'];
|
||||
|
||||
$uuid = $this->authJson('POST', '/api/v1/inventory-package', $user, [
|
||||
'title' => 'p', 'items' => [['itemUuid' => $a, 'amount' => 1]],
|
||||
])['data']['uuid'];
|
||||
|
||||
$updated = $this->authJson('PATCH', '/api/v1/inventory-package/' . $uuid, $user, [
|
||||
'title' => 'p2', 'items' => [['itemUuid' => $a, 'amount' => 5]],
|
||||
]);
|
||||
self::assertSame(200, $this->responseCode());
|
||||
self::assertSame('p2', $updated['data']['title']);
|
||||
self::assertCount(1, $updated['data']['items']);
|
||||
self::assertSame(5, $updated['data']['items'][0]['amount']);
|
||||
self::assertSame(5000, $updated['data']['total']);
|
||||
|
||||
$this->authJson('DELETE', '/api/v1/inventory-package/' . $uuid, $user);
|
||||
self::assertSame(200, $this->responseCode());
|
||||
$after = $this->authJson('GET', '/api/v1/inventory-packages', $user);
|
||||
self::assertCount(0, $after['data']);
|
||||
}
|
||||
|
||||
public function testPackageSkipsForeignItemReferences(): void
|
||||
{
|
||||
[$ownerA] = $this->doctorUser();
|
||||
$foreign = $this->authJson('POST', '/api/v1/inventory-item', $ownerA, ['name' => 'خارجی', 'price' => 999, 'stock' => 5])['data']['uuid'];
|
||||
|
||||
[$ownerB] = $this->doctorUser();
|
||||
$mine = $this->authJson('POST', '/api/v1/inventory-item', $ownerB, ['name' => 'مال من', 'price' => 100, 'stock' => 5])['data']['uuid'];
|
||||
|
||||
// package for B referencing A's item → foreign line dropped, only B's stays
|
||||
$pkg = $this->authJson('POST', '/api/v1/inventory-package', $ownerB, [
|
||||
'title' => 'mix',
|
||||
'items' => [
|
||||
['itemUuid' => $foreign, 'amount' => 1],
|
||||
['itemUuid' => $mine, 'amount' => 2],
|
||||
],
|
||||
]);
|
||||
self::assertSame(201, $this->responseCode());
|
||||
self::assertCount(1, $pkg['data']['items']);
|
||||
self::assertSame($mine, $pkg['data']['items'][0]['itemUuid']);
|
||||
self::assertSame(200, $pkg['data']['total']);
|
||||
}
|
||||
|
||||
public function testPackageRejectsEmptyTitle(): void
|
||||
{
|
||||
[$user] = $this->doctorUser();
|
||||
$this->authJson('POST', '/api/v1/inventory-package', $user, ['title' => '']);
|
||||
self::assertSame(422, $this->responseCode());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user