feat: Implement category and unit selection for inventory items

- Added a new 'category' field to the InventoryItem entity and updated the database schema.
- Replaced free-text input for 'unit' and 'category' with select dropdowns in the AddItemModal.
- Introduced a new API endpoint to fetch metadata for units and categories.
- Updated inventory filtering logic to use the new 'category' field instead of 'consumable'.
- Enhanced validation for item creation and updates to ensure valid unit and category values.
- Updated tests to cover new functionality and ensure proper validation.
This commit is contained in:
hamed
2026-07-15 14:32:29 +03:30
parent defa0db023
commit 3e5dee0ad5
12 changed files with 513 additions and 36 deletions
@@ -13,6 +13,7 @@ use App\Inventory\Repository\InventoryPackageRepository;
use App\Inventory\Service\InventoryService;
use App\Shared\Constant\ErrorCodes;
use App\Shared\Controller\BaseController;
use App\Shared\Exception\AppException;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\Routing\Attribute\Route;
@@ -64,7 +65,16 @@ class InventoryController extends BaseController
return $this->error(ErrorCodes::ERR_FORBIDDEN_001, 'پروفایل یافت نشد', 403);
}
return $this->success($this->itemRepo->findConsumables($type, $id));
return $this->success($this->itemRepo->findCategories($type, $id));
}
#[Route('/api/v1/inventory-meta', methods: ['GET'])]
public function meta(): JsonResponse
{
return $this->success([
'units' => InventoryItem::UNITS,
'categories' => InventoryItem::CATEGORIES,
]);
}
#[Route('/api/v1/inventory-item', methods: ['POST'])]
@@ -210,7 +220,17 @@ class InventoryController extends BaseController
}
if (array_key_exists('unit', $data)) {
$unit = trim((string) $data['unit']);
$item->setUnit($unit === '' ? 'عدد' : $unit);
if ($unit !== '' && !in_array($unit, InventoryItem::UNITS, true)) {
throw new AppException(ErrorCodes::ERR_VALIDATION_001, 'واحد نامعتبر است', 422, 'unit');
}
$item->setUnit($unit === '' ? InventoryItem::DEFAULT_UNIT : $unit);
}
if (array_key_exists('category', $data)) {
$category = trim((string) $data['category']);
if ($category !== '' && !in_array($category, InventoryItem::CATEGORIES, true)) {
throw new AppException(ErrorCodes::ERR_VALIDATION_001, 'دسته‌بندی نامعتبر است', 422, 'category');
}
$item->setCategory($category === '' ? null : $category);
}
if (array_key_exists('price', $data)) {
$item->setPrice((int) $data['price']);
+42 -2
View File
@@ -23,6 +23,39 @@ class InventoryItem
public const STATUS_LOW_STOCK = 'low_stock';
public const STATUS_OUT_OF_STOCK = 'out_of_stock';
public const DEFAULT_UNIT = 'عدد';
/**
* Allowed measurement units for a stock item. Backend is the single source of
* truth (served via GET /api/v1/inventory-meta); the admin never hardcodes these.
* Extend by appending — values are plain Persian strings stored as-is, so no
* migration is needed. Most-used units are listed first for the picker.
*/
public const UNITS = [
'عدد', 'بسته', 'جعبه', 'قوطی', 'جفت', 'دست',
'ویال', 'آمپول', 'قرص', 'کپسول', 'ورق (بلیستر)', 'ساشه', 'تیوب',
'سی‌سی', 'میلی‌لیتر', 'لیتر', 'میلی‌گرم', 'گرم', 'کیلوگرم',
'رول', 'متر', 'سانتی‌متر', 'کیسه',
];
/**
* Allowed inventory categories for a clinic/office. Same contract as {@see self::UNITS}:
* backend-owned, plain Persian strings, extend by appending (no migration).
*/
public const CATEGORIES = [
'دارو',
'لوازم مصرفی و تزریقات',
'لوازم پانسمان و بخیه',
'مواد ضدعفونی و استریلیزاسیون',
'تجهیزات پزشکی',
'بیهوشی و بی‌حسی',
'لوازم زیبایی و پوست',
'لوازم آزمایشگاهی',
'لوازم دندان‌پزشکی',
'ملزومات اداری و مصرفی دفتری',
'سایر',
];
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column(type: 'integer')]
@@ -40,12 +73,16 @@ class InventoryItem
#[ORM\Column(type: 'string', length: 120)]
private string $name;
/** Free-text "مصرفی" classifier from the source modal; doubles as filter group. */
/** Free-text "مصرفی" note carried over from the source modal (kept for compatibility). */
#[ORM\Column(type: 'string', length: 120, nullable: true)]
private ?string $consumable = null;
/** Standard category from {@see self::CATEGORIES}; primary grouping/filter dimension. */
#[ORM\Column(type: 'string', length: 60, nullable: true)]
private ?string $category = null;
#[ORM\Column(type: 'string', length: 30)]
private string $unit = 'عدد';
private string $unit = self::DEFAULT_UNIT;
/** Unit price in Rial (integer), consistent with the rest of ClinicPro. */
#[ORM\Column(type: 'integer')]
@@ -80,6 +117,7 @@ class InventoryItem
public function getEntityId(): int { return $this->entityId; }
public function getName(): string { return $this->name; }
public function getConsumable(): ?string { return $this->consumable; }
public function getCategory(): ?string { return $this->category; }
public function getUnit(): string { return $this->unit; }
public function getPrice(): int { return $this->price; }
public function getStock(): int { return $this->stock; }
@@ -87,6 +125,7 @@ class InventoryItem
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 setCategory(?string $v): self { $this->category = $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(); }
@@ -110,6 +149,7 @@ class InventoryItem
'uuid' => $this->uuid,
'name' => $this->name,
'consumable' => $this->consumable,
'category' => $this->category,
'unit' => $this->unit,
'price' => $this->price,
'stock' => $this->stock,
@@ -31,24 +31,24 @@ class InventoryItemRepository extends ServiceEntityRepository
}
/**
* Distinct non-empty "consumable" values for the tenant — powers the
* Distinct non-empty category values actually in use by the tenant — powers the
* category filter dropdown on the inventory page.
*
* @return string[]
*/
public function findConsumables(string $entityType, int $entityId): array
public function findCategories(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')
->select('DISTINCT i.category AS category')
->where('i.entityType = :type AND i.entityId = :id AND i.category IS NOT NULL AND i.category != :empty')
->setParameter('type', $entityType)
->setParameter('id', $entityId)
->setParameter('empty', '')
->orderBy('i.consumable', 'ASC')
->orderBy('i.category', 'ASC')
->getQuery()
->getArrayResult();
return array_map(static fn(array $r): string => $r['consumable'], $rows);
return array_map(static fn(array $r): string => $r['category'], $rows);
}
public function save(InventoryItem $item): void