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:
@@ -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']);
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user