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,274 @@
|
||||
<?php
|
||||
|
||||
namespace App\Inventory\Controller;
|
||||
|
||||
use App\Auth\Entity\User;
|
||||
use App\Auth\Repository\UserActiveContextRepository;
|
||||
use App\Clinic\Repository\ClinicRepository;
|
||||
use App\Doctor\Repository\DoctorRepository;
|
||||
use App\Inventory\Entity\InventoryItem;
|
||||
use App\Inventory\Entity\InventoryPackage;
|
||||
use App\Inventory\Repository\InventoryItemRepository;
|
||||
use App\Inventory\Repository\InventoryPackageRepository;
|
||||
use App\Inventory\Service\InventoryService;
|
||||
use App\Shared\Constant\ErrorCodes;
|
||||
use App\Shared\Controller\BaseController;
|
||||
use Symfony\Component\HttpFoundation\JsonResponse;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\Routing\Attribute\Route;
|
||||
use Symfony\Component\Security\Http\Attribute\CurrentUser;
|
||||
use Symfony\Component\Security\Http\Attribute\IsGranted;
|
||||
use OpenApi\Attributes as OA;
|
||||
|
||||
/**
|
||||
* Per-tenant (doctor/clinic) inventory: consumable items and their packages.
|
||||
* Every row is scoped to the caller's resolved entity; a tenant can only see and
|
||||
* mutate its own inventory. Scoping mirrors {@see \App\Tag\Controller\TenantTagController}.
|
||||
*/
|
||||
#[OA\Tag(name: 'Inventory')]
|
||||
#[IsGranted('IS_AUTHENTICATED_FULLY')]
|
||||
class InventoryController extends BaseController
|
||||
{
|
||||
public function __construct(
|
||||
private readonly InventoryItemRepository $itemRepo,
|
||||
private readonly InventoryPackageRepository $packageRepo,
|
||||
private readonly InventoryService $service,
|
||||
private readonly DoctorRepository $doctorRepo,
|
||||
private readonly ClinicRepository $clinicRepo,
|
||||
private readonly UserActiveContextRepository $contextRepo,
|
||||
) {}
|
||||
|
||||
// ── Items ────────────────────────────────────────────────────────────────
|
||||
|
||||
#[Route('/api/v1/inventory-items', methods: ['GET'])]
|
||||
public function listItems(#[CurrentUser] User $user): JsonResponse
|
||||
{
|
||||
[$type, $id] = $this->resolveEntity($user);
|
||||
if ($id === null) {
|
||||
return $this->error(ErrorCodes::ERR_FORBIDDEN_001, 'پروفایل یافت نشد', 403);
|
||||
}
|
||||
|
||||
$items = $this->itemRepo->findByEntity($type, $id);
|
||||
|
||||
return $this->success([
|
||||
'items' => array_map(fn(InventoryItem $i) => $i->toArray(), $items),
|
||||
'stats' => $this->service->stats($items),
|
||||
]);
|
||||
}
|
||||
|
||||
#[Route('/api/v1/inventory-categories', methods: ['GET'])]
|
||||
public function listCategories(#[CurrentUser] User $user): JsonResponse
|
||||
{
|
||||
[$type, $id] = $this->resolveEntity($user);
|
||||
if ($id === null) {
|
||||
return $this->error(ErrorCodes::ERR_FORBIDDEN_001, 'پروفایل یافت نشد', 403);
|
||||
}
|
||||
|
||||
return $this->success($this->itemRepo->findConsumables($type, $id));
|
||||
}
|
||||
|
||||
#[Route('/api/v1/inventory-item', methods: ['POST'])]
|
||||
public function createItem(Request $request, #[CurrentUser] User $user): JsonResponse
|
||||
{
|
||||
[$type, $id] = $this->resolveEntity($user);
|
||||
if ($id === null) {
|
||||
return $this->error(ErrorCodes::ERR_FORBIDDEN_001, 'پروفایل یافت نشد', 403);
|
||||
}
|
||||
|
||||
$data = json_decode($request->getContent(), true) ?? [];
|
||||
$name = trim($data['name'] ?? '');
|
||||
if ($name === '') {
|
||||
return $this->error(ErrorCodes::ERR_VALIDATION_001, 'نام کالا الزامی است', 422, 'name');
|
||||
}
|
||||
|
||||
$item = new InventoryItem($type, $id, $name);
|
||||
$this->applyItemFields($item, $data);
|
||||
$this->itemRepo->save($item);
|
||||
|
||||
return $this->success($item->toArray(), 201);
|
||||
}
|
||||
|
||||
#[Route('/api/v1/inventory-item/{uuid}', methods: ['PATCH'])]
|
||||
public function updateItem(string $uuid, Request $request, #[CurrentUser] User $user): JsonResponse
|
||||
{
|
||||
$item = $this->ownedItem($uuid, $user);
|
||||
if ($item === null) {
|
||||
return $this->error(ErrorCodes::ERR_NOT_FOUND_001, 'کالا یافت نشد', 404);
|
||||
}
|
||||
|
||||
$data = json_decode($request->getContent(), true) ?? [];
|
||||
if (array_key_exists('name', $data)) {
|
||||
$name = trim($data['name']);
|
||||
if ($name === '') {
|
||||
return $this->error(ErrorCodes::ERR_VALIDATION_001, 'نام کالا الزامی است', 422, 'name');
|
||||
}
|
||||
$item->setName($name);
|
||||
}
|
||||
$this->applyItemFields($item, $data);
|
||||
$this->itemRepo->save($item);
|
||||
|
||||
return $this->success($item->toArray());
|
||||
}
|
||||
|
||||
#[Route('/api/v1/inventory-item/{uuid}', methods: ['DELETE'])]
|
||||
public function deleteItem(string $uuid, #[CurrentUser] User $user): JsonResponse
|
||||
{
|
||||
$item = $this->ownedItem($uuid, $user);
|
||||
if ($item === null) {
|
||||
return $this->error(ErrorCodes::ERR_NOT_FOUND_001, 'کالا یافت نشد', 404);
|
||||
}
|
||||
|
||||
$this->itemRepo->remove($item);
|
||||
|
||||
return $this->success(['message' => 'کالا حذف شد']);
|
||||
}
|
||||
|
||||
// ── Packages ─────────────────────────────────────────────────────────────
|
||||
|
||||
#[Route('/api/v1/inventory-packages', methods: ['GET'])]
|
||||
public function listPackages(#[CurrentUser] User $user): JsonResponse
|
||||
{
|
||||
[$type, $id] = $this->resolveEntity($user);
|
||||
if ($id === null) {
|
||||
return $this->error(ErrorCodes::ERR_FORBIDDEN_001, 'پروفایل یافت نشد', 403);
|
||||
}
|
||||
|
||||
return $this->success(array_map(
|
||||
fn(InventoryPackage $p) => $this->service->packageToArray($p),
|
||||
$this->packageRepo->findByEntity($type, $id)
|
||||
));
|
||||
}
|
||||
|
||||
#[Route('/api/v1/inventory-package', methods: ['POST'])]
|
||||
public function createPackage(Request $request, #[CurrentUser] User $user): JsonResponse
|
||||
{
|
||||
[$type, $id] = $this->resolveEntity($user);
|
||||
if ($id === null) {
|
||||
return $this->error(ErrorCodes::ERR_FORBIDDEN_001, 'پروفایل یافت نشد', 403);
|
||||
}
|
||||
|
||||
$data = json_decode($request->getContent(), true) ?? [];
|
||||
$title = trim($data['title'] ?? '');
|
||||
if ($title === '') {
|
||||
return $this->error(ErrorCodes::ERR_VALIDATION_001, 'نام پکیج الزامی است', 422, 'title');
|
||||
}
|
||||
|
||||
$package = new InventoryPackage($type, $id, $title);
|
||||
$this->service->syncPackageItems($package, $data['items'] ?? [], $type, $id);
|
||||
$this->packageRepo->save($package);
|
||||
|
||||
return $this->success($this->service->packageToArray($package), 201);
|
||||
}
|
||||
|
||||
#[Route('/api/v1/inventory-package/{uuid}', methods: ['PATCH'])]
|
||||
public function updatePackage(string $uuid, Request $request, #[CurrentUser] User $user): JsonResponse
|
||||
{
|
||||
$package = $this->ownedPackage($uuid, $user);
|
||||
if ($package === null) {
|
||||
return $this->error(ErrorCodes::ERR_NOT_FOUND_001, 'پکیج یافت نشد', 404);
|
||||
}
|
||||
|
||||
[$type, $id] = $this->resolveEntity($user);
|
||||
$data = json_decode($request->getContent(), true) ?? [];
|
||||
|
||||
if (array_key_exists('title', $data)) {
|
||||
$title = trim($data['title']);
|
||||
if ($title === '') {
|
||||
return $this->error(ErrorCodes::ERR_VALIDATION_001, 'نام پکیج الزامی است', 422, 'title');
|
||||
}
|
||||
$package->setTitle($title);
|
||||
}
|
||||
if (array_key_exists('items', $data)) {
|
||||
$this->service->syncPackageItems($package, $data['items'], $type, (int) $id);
|
||||
}
|
||||
$this->packageRepo->save($package);
|
||||
|
||||
return $this->success($this->service->packageToArray($package));
|
||||
}
|
||||
|
||||
#[Route('/api/v1/inventory-package/{uuid}', methods: ['DELETE'])]
|
||||
public function deletePackage(string $uuid, #[CurrentUser] User $user): JsonResponse
|
||||
{
|
||||
$package = $this->ownedPackage($uuid, $user);
|
||||
if ($package === null) {
|
||||
return $this->error(ErrorCodes::ERR_NOT_FOUND_001, 'پکیج یافت نشد', 404);
|
||||
}
|
||||
|
||||
$this->packageRepo->remove($package);
|
||||
|
||||
return $this->success(['message' => 'پکیج حذف شد']);
|
||||
}
|
||||
|
||||
// ── Helpers ──────────────────────────────────────────────────────────────
|
||||
|
||||
/** Apply optional mutable item fields present in the payload. */
|
||||
private function applyItemFields(InventoryItem $item, array $data): void
|
||||
{
|
||||
if (array_key_exists('consumable', $data)) {
|
||||
$c = trim((string) $data['consumable']);
|
||||
$item->setConsumable($c === '' ? null : $c);
|
||||
}
|
||||
if (array_key_exists('unit', $data)) {
|
||||
$unit = trim((string) $data['unit']);
|
||||
$item->setUnit($unit === '' ? 'عدد' : $unit);
|
||||
}
|
||||
if (array_key_exists('price', $data)) {
|
||||
$item->setPrice((int) $data['price']);
|
||||
}
|
||||
if (array_key_exists('stock', $data)) {
|
||||
$item->setStock((int) $data['stock']);
|
||||
}
|
||||
if (array_key_exists('alertThreshold', $data)) {
|
||||
$item->setAlertThreshold((int) $data['alertThreshold']);
|
||||
}
|
||||
}
|
||||
|
||||
/** The item only if it belongs to the caller's entity, else null. */
|
||||
private function ownedItem(string $uuid, User $user): ?InventoryItem
|
||||
{
|
||||
[$type, $id] = $this->resolveEntity($user);
|
||||
$item = $this->itemRepo->findByUuid($uuid);
|
||||
if ($item === null || $id === null || $item->getEntityType() !== $type || $item->getEntityId() !== $id) {
|
||||
return null;
|
||||
}
|
||||
return $item;
|
||||
}
|
||||
|
||||
/** The package only if it belongs to the caller's entity, else null. */
|
||||
private function ownedPackage(string $uuid, User $user): ?InventoryPackage
|
||||
{
|
||||
[$type, $id] = $this->resolveEntity($user);
|
||||
$package = $this->packageRepo->findByUuid($uuid);
|
||||
if ($package === null || $id === null || $package->getEntityType() !== $type || $package->getEntityId() !== $id) {
|
||||
return null;
|
||||
}
|
||||
return $package;
|
||||
}
|
||||
|
||||
/** @return array{0: string, 1: int|null} [entityType, entityId] */
|
||||
private function resolveEntity(User $user): array
|
||||
{
|
||||
if ($user->hasRole('ROLE_DOCTOR')) {
|
||||
$doctor = $this->doctorRepo->findByUser($user);
|
||||
return ['doctor', $doctor?->getId()];
|
||||
}
|
||||
if ($user->hasRole('ROLE_CLINIC')) {
|
||||
$clinic = $this->clinicRepo->findByUser($user);
|
||||
return ['clinic', $clinic?->getId()];
|
||||
}
|
||||
if ($user->hasRole('ROLE_SECRETARY')) {
|
||||
$dbUuid = $this->contextRepo->findByUser($user)?->getDbUuid();
|
||||
if ($dbUuid !== null) {
|
||||
$clinic = $this->clinicRepo->findByUuid($dbUuid);
|
||||
if ($clinic !== null) {
|
||||
return ['clinic', $clinic->getId()];
|
||||
}
|
||||
$doctor = $this->doctorRepo->findByUuid($dbUuid);
|
||||
if ($doctor !== null) {
|
||||
return ['doctor', $doctor->getId()];
|
||||
}
|
||||
}
|
||||
}
|
||||
return ['unknown', null];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
<?php
|
||||
|
||||
namespace App\Inventory\Entity;
|
||||
|
||||
use App\Inventory\Repository\InventoryItemRepository;
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
use Symfony\Component\Uid\Uuid;
|
||||
|
||||
/**
|
||||
* A consumable stock item owned by a tenant (doctor/clinic). Scoped through the
|
||||
* polymorphic entity_type/entity_id pair, mirroring {@see \App\Tag\Entity\TenantTag}.
|
||||
*
|
||||
* Availability status is derived, never stored: an item with zero stock is
|
||||
* "out_of_stock", one at or below its alert threshold is "low_stock", otherwise
|
||||
* "in_stock".
|
||||
*/
|
||||
#[ORM\Entity(repositoryClass: InventoryItemRepository::class)]
|
||||
#[ORM\Table(name: 'inventory_items')]
|
||||
#[ORM\Index(columns: ['entity_type', 'entity_id'], name: 'idx_inventory_items_owner')]
|
||||
class InventoryItem
|
||||
{
|
||||
public const STATUS_IN_STOCK = 'in_stock';
|
||||
public const STATUS_LOW_STOCK = 'low_stock';
|
||||
public const STATUS_OUT_OF_STOCK = 'out_of_stock';
|
||||
|
||||
#[ORM\Id]
|
||||
#[ORM\GeneratedValue]
|
||||
#[ORM\Column(type: 'integer')]
|
||||
private ?int $id = null;
|
||||
|
||||
#[ORM\Column(type: 'string', length: 36, unique: true)]
|
||||
private string $uuid;
|
||||
|
||||
#[ORM\Column(name: 'entity_type', type: 'string', length: 20)]
|
||||
private string $entityType;
|
||||
|
||||
#[ORM\Column(name: 'entity_id', type: 'integer')]
|
||||
private int $entityId;
|
||||
|
||||
#[ORM\Column(type: 'string', length: 120)]
|
||||
private string $name;
|
||||
|
||||
/** Free-text "مصرفی" classifier from the source modal; doubles as filter group. */
|
||||
#[ORM\Column(type: 'string', length: 120, nullable: true)]
|
||||
private ?string $consumable = null;
|
||||
|
||||
#[ORM\Column(type: 'string', length: 30)]
|
||||
private string $unit = 'عدد';
|
||||
|
||||
/** Unit price in Rial (integer), consistent with the rest of ClinicPro. */
|
||||
#[ORM\Column(type: 'integer')]
|
||||
private int $price = 0;
|
||||
|
||||
#[ORM\Column(type: 'integer')]
|
||||
private int $stock = 0;
|
||||
|
||||
/** At or below this stock level the item is flagged "low". */
|
||||
#[ORM\Column(name: 'alert_threshold', type: 'integer')]
|
||||
private int $alertThreshold = 0;
|
||||
|
||||
#[ORM\Column(name: 'created_at', type: 'integer')]
|
||||
private int $createdAt;
|
||||
|
||||
#[ORM\Column(name: 'updated_at', type: 'integer')]
|
||||
private int $updatedAt;
|
||||
|
||||
public function __construct(string $entityType, int $entityId, string $name)
|
||||
{
|
||||
$this->uuid = Uuid::v4()->toRfc4122();
|
||||
$this->entityType = $entityType;
|
||||
$this->entityId = $entityId;
|
||||
$this->name = $name;
|
||||
$this->createdAt = time();
|
||||
$this->updatedAt = time();
|
||||
}
|
||||
|
||||
public function getId(): ?int { return $this->id; }
|
||||
public function getUuid(): string { return $this->uuid; }
|
||||
public function getEntityType(): string { return $this->entityType; }
|
||||
public function getEntityId(): int { return $this->entityId; }
|
||||
public function getName(): string { return $this->name; }
|
||||
public function getConsumable(): ?string { return $this->consumable; }
|
||||
public function getUnit(): string { return $this->unit; }
|
||||
public function getPrice(): int { return $this->price; }
|
||||
public function getStock(): int { return $this->stock; }
|
||||
public function getAlertThreshold(): int { return $this->alertThreshold; }
|
||||
|
||||
public function setName(string $v): self { $this->name = $v; return $this->touch(); }
|
||||
public function setConsumable(?string $v): self { $this->consumable = $v; return $this->touch(); }
|
||||
public function setUnit(string $v): self { $this->unit = $v; return $this->touch(); }
|
||||
public function setPrice(int $v): self { $this->price = max(0, $v); return $this->touch(); }
|
||||
public function setStock(int $v): self { $this->stock = max(0, $v); return $this->touch(); }
|
||||
public function setAlertThreshold(int $v): self { $this->alertThreshold = max(0, $v); return $this->touch(); }
|
||||
|
||||
/** Derived availability — see class docblock. */
|
||||
public function getStatus(): string
|
||||
{
|
||||
if ($this->stock <= 0) {
|
||||
return self::STATUS_OUT_OF_STOCK;
|
||||
}
|
||||
if ($this->stock <= $this->alertThreshold) {
|
||||
return self::STATUS_LOW_STOCK;
|
||||
}
|
||||
return self::STATUS_IN_STOCK;
|
||||
}
|
||||
|
||||
public function toArray(): array
|
||||
{
|
||||
return [
|
||||
'uuid' => $this->uuid,
|
||||
'name' => $this->name,
|
||||
'consumable' => $this->consumable,
|
||||
'unit' => $this->unit,
|
||||
'price' => $this->price,
|
||||
'stock' => $this->stock,
|
||||
'alertThreshold' => $this->alertThreshold,
|
||||
'status' => $this->getStatus(),
|
||||
];
|
||||
}
|
||||
|
||||
private function touch(): self
|
||||
{
|
||||
$this->updatedAt = time();
|
||||
return $this;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
<?php
|
||||
|
||||
namespace App\Inventory\Entity;
|
||||
|
||||
use App\Inventory\Repository\InventoryPackageRepository;
|
||||
use Doctrine\Common\Collections\ArrayCollection;
|
||||
use Doctrine\Common\Collections\Collection;
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
use Symfony\Component\Uid\Uuid;
|
||||
|
||||
/**
|
||||
* A named bundle of consumable items owned by a tenant (doctor/clinic). The
|
||||
* package price and its availability are derived from its component items at
|
||||
* read time — never stored — so they always reflect current item prices/stock.
|
||||
*/
|
||||
#[ORM\Entity(repositoryClass: InventoryPackageRepository::class)]
|
||||
#[ORM\Table(name: 'inventory_packages')]
|
||||
#[ORM\Index(columns: ['entity_type', 'entity_id'], name: 'idx_inventory_packages_owner')]
|
||||
class InventoryPackage
|
||||
{
|
||||
#[ORM\Id]
|
||||
#[ORM\GeneratedValue]
|
||||
#[ORM\Column(type: 'integer')]
|
||||
private ?int $id = null;
|
||||
|
||||
#[ORM\Column(type: 'string', length: 36, unique: true)]
|
||||
private string $uuid;
|
||||
|
||||
#[ORM\Column(name: 'entity_type', type: 'string', length: 20)]
|
||||
private string $entityType;
|
||||
|
||||
#[ORM\Column(name: 'entity_id', type: 'integer')]
|
||||
private int $entityId;
|
||||
|
||||
#[ORM\Column(type: 'string', length: 120)]
|
||||
private string $title;
|
||||
|
||||
/** @var Collection<int, InventoryPackageItem> */
|
||||
#[ORM\OneToMany(mappedBy: 'package', targetEntity: InventoryPackageItem::class, cascade: ['persist', 'remove'], orphanRemoval: true)]
|
||||
private Collection $items;
|
||||
|
||||
#[ORM\Column(name: 'created_at', type: 'integer')]
|
||||
private int $createdAt;
|
||||
|
||||
#[ORM\Column(name: 'updated_at', type: 'integer')]
|
||||
private int $updatedAt;
|
||||
|
||||
public function __construct(string $entityType, int $entityId, string $title)
|
||||
{
|
||||
$this->uuid = Uuid::v4()->toRfc4122();
|
||||
$this->entityType = $entityType;
|
||||
$this->entityId = $entityId;
|
||||
$this->title = $title;
|
||||
$this->items = new ArrayCollection();
|
||||
$this->createdAt = time();
|
||||
$this->updatedAt = time();
|
||||
}
|
||||
|
||||
public function getId(): ?int { return $this->id; }
|
||||
public function getUuid(): string { return $this->uuid; }
|
||||
public function getEntityType(): string { return $this->entityType; }
|
||||
public function getEntityId(): int { return $this->entityId; }
|
||||
public function getTitle(): string { return $this->title; }
|
||||
|
||||
public function setTitle(string $v): self { $this->title = $v; $this->updatedAt = time(); return $this; }
|
||||
|
||||
/** @return Collection<int, InventoryPackageItem> */
|
||||
public function getItems(): Collection { return $this->items; }
|
||||
|
||||
public function addItem(InventoryPackageItem $item): self
|
||||
{
|
||||
if (!$this->items->contains($item)) {
|
||||
$this->items->add($item);
|
||||
$item->setPackage($this);
|
||||
}
|
||||
$this->updatedAt = time();
|
||||
return $this;
|
||||
}
|
||||
|
||||
/** Drop every component item (used before re-populating on update). */
|
||||
public function clearItems(): self
|
||||
{
|
||||
$this->items->clear();
|
||||
$this->updatedAt = time();
|
||||
return $this;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
<?php
|
||||
|
||||
namespace App\Inventory\Entity;
|
||||
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
|
||||
/**
|
||||
* A line in an {@see InventoryPackage}: a reference to an {@see InventoryItem}
|
||||
* plus the quantity of it the package contains.
|
||||
*/
|
||||
#[ORM\Entity]
|
||||
#[ORM\Table(name: 'inventory_package_items')]
|
||||
class InventoryPackageItem
|
||||
{
|
||||
#[ORM\Id]
|
||||
#[ORM\GeneratedValue]
|
||||
#[ORM\Column(type: 'integer')]
|
||||
private ?int $id = null;
|
||||
|
||||
#[ORM\ManyToOne(targetEntity: InventoryPackage::class, inversedBy: 'items')]
|
||||
#[ORM\JoinColumn(name: 'package_id', nullable: false, onDelete: 'CASCADE')]
|
||||
private InventoryPackage $package;
|
||||
|
||||
#[ORM\ManyToOne(targetEntity: InventoryItem::class)]
|
||||
#[ORM\JoinColumn(name: 'item_id', nullable: false, onDelete: 'CASCADE')]
|
||||
private InventoryItem $item;
|
||||
|
||||
#[ORM\Column(type: 'integer')]
|
||||
private int $amount = 1;
|
||||
|
||||
public function __construct(InventoryItem $item, int $amount)
|
||||
{
|
||||
$this->item = $item;
|
||||
$this->amount = max(1, $amount);
|
||||
}
|
||||
|
||||
public function getId(): ?int { return $this->id; }
|
||||
public function getPackage(): InventoryPackage { return $this->package; }
|
||||
public function getItem(): InventoryItem { return $this->item; }
|
||||
public function getAmount(): int { return $this->amount; }
|
||||
|
||||
public function setPackage(InventoryPackage $p): self { $this->package = $p; return $this; }
|
||||
public function setAmount(int $v): self { $this->amount = max(1, $v); return $this; }
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
<?php
|
||||
|
||||
namespace App\Inventory\Repository;
|
||||
|
||||
use App\Inventory\Entity\InventoryItem;
|
||||
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
|
||||
use Doctrine\Persistence\ManagerRegistry;
|
||||
|
||||
class InventoryItemRepository extends ServiceEntityRepository
|
||||
{
|
||||
public function __construct(ManagerRegistry $registry)
|
||||
{
|
||||
parent::__construct($registry, InventoryItem::class);
|
||||
}
|
||||
|
||||
public function findByUuid(string $uuid): ?InventoryItem
|
||||
{
|
||||
return $this->findOneBy(['uuid' => $uuid]);
|
||||
}
|
||||
|
||||
/** @return InventoryItem[] */
|
||||
public function findByEntity(string $entityType, int $entityId): array
|
||||
{
|
||||
return $this->createQueryBuilder('i')
|
||||
->where('i.entityType = :type AND i.entityId = :id')
|
||||
->setParameter('type', $entityType)
|
||||
->setParameter('id', $entityId)
|
||||
->orderBy('i.name', 'ASC')
|
||||
->getQuery()
|
||||
->getResult();
|
||||
}
|
||||
|
||||
/**
|
||||
* Distinct non-empty "consumable" values for the tenant — powers the
|
||||
* category filter dropdown on the inventory page.
|
||||
*
|
||||
* @return string[]
|
||||
*/
|
||||
public function findConsumables(string $entityType, int $entityId): array
|
||||
{
|
||||
$rows = $this->createQueryBuilder('i')
|
||||
->select('DISTINCT i.consumable AS consumable')
|
||||
->where('i.entityType = :type AND i.entityId = :id AND i.consumable IS NOT NULL AND i.consumable != :empty')
|
||||
->setParameter('type', $entityType)
|
||||
->setParameter('id', $entityId)
|
||||
->setParameter('empty', '')
|
||||
->orderBy('i.consumable', 'ASC')
|
||||
->getQuery()
|
||||
->getArrayResult();
|
||||
|
||||
return array_map(static fn(array $r): string => $r['consumable'], $rows);
|
||||
}
|
||||
|
||||
public function save(InventoryItem $item): void
|
||||
{
|
||||
$this->getEntityManager()->persist($item);
|
||||
$this->getEntityManager()->flush();
|
||||
}
|
||||
|
||||
public function remove(InventoryItem $item): void
|
||||
{
|
||||
$this->getEntityManager()->remove($item);
|
||||
$this->getEntityManager()->flush();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
<?php
|
||||
|
||||
namespace App\Inventory\Repository;
|
||||
|
||||
use App\Inventory\Entity\InventoryPackage;
|
||||
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
|
||||
use Doctrine\Persistence\ManagerRegistry;
|
||||
|
||||
class InventoryPackageRepository extends ServiceEntityRepository
|
||||
{
|
||||
public function __construct(ManagerRegistry $registry)
|
||||
{
|
||||
parent::__construct($registry, InventoryPackage::class);
|
||||
}
|
||||
|
||||
public function findByUuid(string $uuid): ?InventoryPackage
|
||||
{
|
||||
return $this->findOneBy(['uuid' => $uuid]);
|
||||
}
|
||||
|
||||
/** @return InventoryPackage[] */
|
||||
public function findByEntity(string $entityType, int $entityId): array
|
||||
{
|
||||
return $this->createQueryBuilder('p')
|
||||
->leftJoin('p.items', 'pi')->addSelect('pi')
|
||||
->leftJoin('pi.item', 'it')->addSelect('it')
|
||||
->where('p.entityType = :type AND p.entityId = :id')
|
||||
->setParameter('type', $entityType)
|
||||
->setParameter('id', $entityId)
|
||||
->orderBy('p.createdAt', 'DESC')
|
||||
->getQuery()
|
||||
->getResult();
|
||||
}
|
||||
|
||||
public function save(InventoryPackage $package): void
|
||||
{
|
||||
$this->getEntityManager()->persist($package);
|
||||
$this->getEntityManager()->flush();
|
||||
}
|
||||
|
||||
public function remove(InventoryPackage $package): void
|
||||
{
|
||||
$this->getEntityManager()->remove($package);
|
||||
$this->getEntityManager()->flush();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
<?php
|
||||
|
||||
namespace App\Inventory\Service;
|
||||
|
||||
use App\Inventory\Entity\InventoryItem;
|
||||
use App\Inventory\Entity\InventoryPackage;
|
||||
use App\Inventory\Entity\InventoryPackageItem;
|
||||
use App\Inventory\Repository\InventoryItemRepository;
|
||||
use App\Inventory\Repository\InventoryPackageRepository;
|
||||
|
||||
/**
|
||||
* Inventory domain logic: derived aggregates (stat counters, package totals and
|
||||
* availability) and package assembly from item references. Controllers stay thin
|
||||
* and delegate every non-HTTP decision here.
|
||||
*/
|
||||
class InventoryService
|
||||
{
|
||||
public function __construct(
|
||||
private readonly InventoryItemRepository $itemRepo,
|
||||
private readonly InventoryPackageRepository $packageRepo,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* The four headline counters shown as stat cards, derived from item statuses.
|
||||
*
|
||||
* @param InventoryItem[] $items
|
||||
* @return array{total:int, low:int, inStock:int, outOfStock:int}
|
||||
*/
|
||||
public function stats(array $items): array
|
||||
{
|
||||
$low = $inStock = $out = 0;
|
||||
foreach ($items as $item) {
|
||||
match ($item->getStatus()) {
|
||||
InventoryItem::STATUS_LOW_STOCK => $low++,
|
||||
InventoryItem::STATUS_IN_STOCK => $inStock++,
|
||||
InventoryItem::STATUS_OUT_OF_STOCK => $out++,
|
||||
default => null,
|
||||
};
|
||||
}
|
||||
|
||||
return [
|
||||
'total' => count($items),
|
||||
'low' => $low,
|
||||
'inStock' => $inStock,
|
||||
'outOfStock' => $out,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Serialize a package with its component items, derived total price (Rial)
|
||||
* and availability (true only if every component has enough stock).
|
||||
*/
|
||||
public function packageToArray(InventoryPackage $package): array
|
||||
{
|
||||
$items = [];
|
||||
$total = 0;
|
||||
$available = true;
|
||||
|
||||
foreach ($package->getItems() as $line) {
|
||||
/** @var InventoryPackageItem $line */
|
||||
$item = $line->getItem();
|
||||
$amount = $line->getAmount();
|
||||
$total += $item->getPrice() * $amount;
|
||||
|
||||
if ($item->getStock() < $amount) {
|
||||
$available = false;
|
||||
}
|
||||
|
||||
$items[] = [
|
||||
'itemUuid' => $item->getUuid(),
|
||||
'name' => $item->getName(),
|
||||
'unit' => $item->getUnit(),
|
||||
'price' => $item->getPrice(),
|
||||
'amount' => $amount,
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
'uuid' => $package->getUuid(),
|
||||
'title' => $package->getTitle(),
|
||||
'items' => $items,
|
||||
'total' => $total,
|
||||
'available' => $available,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Replace a package's component lines from a list of {itemUuid, amount}.
|
||||
* Silently skips references the tenant does not own. Returns the number of
|
||||
* lines actually attached.
|
||||
*
|
||||
* @param array<int, array{itemUuid?:string, amount?:int|string}> $lines
|
||||
*/
|
||||
public function syncPackageItems(InventoryPackage $package, array $lines, string $entityType, int $entityId): int
|
||||
{
|
||||
$package->clearItems();
|
||||
$count = 0;
|
||||
|
||||
foreach ($lines as $line) {
|
||||
$uuid = trim((string) ($line['itemUuid'] ?? ''));
|
||||
if ($uuid === '') {
|
||||
continue;
|
||||
}
|
||||
$item = $this->itemRepo->findByUuid($uuid);
|
||||
// Only attach items the caller owns — never leak another tenant's stock.
|
||||
if ($item === null || $item->getEntityType() !== $entityType || $item->getEntityId() !== $entityId) {
|
||||
continue;
|
||||
}
|
||||
$amount = (int) ($line['amount'] ?? 1);
|
||||
$package->addItem(new InventoryPackageItem($item, $amount));
|
||||
$count++;
|
||||
}
|
||||
|
||||
return $count;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user