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
@@ -0,0 +1,247 @@
# واحد کالا به‌صورت Select + سیستم دسته‌بندی اصولی کالا (انبارداری)
## پروژه
`clinicpro` (Backend Symfony + پنل ادمین React). صفحه هدف: `/admin/inventory`.
## زمینه
بخش انبارداری (`InventoryPage`) اجازه ایجاد/ویرایش «کالا» را می‌دهد. دو ضعف طراحی وجود دارد:
1. **واحد (`unit`)** به‌صورت متن آزاد وارد می‌شود (`AddItemModal` فقط یک `<input>` متنی است، پیش‌فرض `'عدد'`). نتیجه: داده ناهمگون («cc»، «سی سی»، «سیسی»، «میلی لیتر»، «ml» و …) که گزارش‌گیری و یکپارچگی را خراب می‌کند.
2. **دسته‌بندی وجود ندارد.** چیزی که امروز به‌عنوان «دسته» کار می‌کند در واقع فیلد متن‌آزاد `consumable` («مصرفی») است: اندپوینت `GET /api/v1/inventory-categories` مقادیر متمایز همین ستون را برمی‌گرداند (`InventoryItemRepository::findConsumables`)، و صفحه با `it.consumable === category` فیلتر می‌کند. این یعنی «دسته‌بندی» عملاً متن آزاد و بی‌ساختار است.
هدف: هر دو فیلد را به لیست‌های استاندارد و **محدودشده (bounded)** تبدیل کنیم که **منبعِ صدق‌شان Backend** باشد، تا فرانت و بک هرگز از هم جدا نیفتند.
## مشکل / هدف
- `unit`: تبدیل به Select از واحدهای استاندارد و پرکاربرد مطب/کلینیک.
- افزودن `category`: فیلد دسته‌بندی واقعی و اصولی، از یک لیست ثابت استاندارد، جایگزینِ نقشِ فیلترِ `consumable`.
- لیست هر دو باید در Backend تعریف شود و از طریق یک اندپوینت واحد به فرانت داده شود (بدون هاردکد دوباره در فرانت → جلوگیری از drift).
## فایل‌های مرتبط
| فایل | نقش | تغییر |
|------|-----|-------|
| `src/Inventory/Entity/InventoryItem.php` | Entity کالا | افزودن ستون `category`؛ نگهدارنده لیست‌های مجاز |
| `src/Inventory/Controller/InventoryController.php` | endpointها | endpoint متادیتا + اعتبارسنجی `unit`/`category` |
| `src/Inventory/Repository/InventoryItemRepository.php` | کوئری‌ها | `findConsumables` → مبتنی بر `category` |
| `src/Inventory/Service/InventoryService.php` | منطق دامنه | جای مناسب برای منبع لیست‌ها (Vocabulary) |
| `assets/admin/components/inventory/AddItemModal.tsx` | فرم افزودن/ویرایش | دو `<input>` → دو Select |
| `assets/admin/hooks/useInventory.ts` | data hook | type `category`، کوئری متادیتا |
| `assets/admin/pages/InventoryPage.tsx` | صفحه | فیلتر بر اساس `category` |
| `migrations/VersionXX; docs/api/inventory.md` | مهاجرت + مستند | ستون جدید + قرارداد endpoint |
## وضعیت فعلی (کد واقعی)
**Entity — `InventoryItem.php`** (واحد متن‌آزاد، بدون دسته):
```php
#[ORM\Column(type: 'string', length: 30)]
private string $unit = 'عدد';
/** Free-text "مصرفی" classifier from the source modal; doubles as filter group. */
#[ORM\Column(type: 'string', length: 120, nullable: true)]
private ?string $consumable = null;
```
**Controller — اعمال فیلدها بدون اعتبارسنجی مقدار مجاز:**
```php
if (array_key_exists('unit', $data)) {
$unit = trim((string) $data['unit']);
$item->setUnit($unit === '' ? 'عدد' : $unit);
}
```
**«دسته‌ها» امروز = مقادیر متمایز `consumable`:**
```php
// InventoryItemRepository::findConsumables
->select('DISTINCT i.consumable AS consumable')
->where('i.entityType = :type AND i.entityId = :id AND i.consumable IS NOT NULL AND i.consumable != :empty')
```
**Modal — واحد به‌صورت input متنی:**
```tsx
const fields = [
{ key: 'name', label: 'نام کالا', placeholder: 'نام کالا' },
{ key: 'consumable', label: 'مصرفی', placeholder: 'مصرفی' },
{ key: 'unit', label: 'واحد', placeholder: 'عدد' }, // ← متن آزاد
...
];
```
**صفحه — فیلتر بر اساس `consumable`:**
```tsx
const [category, setCategory] = useState('');
const filteredItems = items.filter((it) =>
... && (category === '' || it.consumable === category) // ← consumable نقش دسته
);
```
## وظایف
### ۱. تعریف Vocabulary استاندارد در Backend (منبع صدق)
یک منبع واحد برای لیست واحدها و دسته‌ها بساز. جای پیشنهادی: constant روی `InventoryItem` (یا کلاس کوچک `InventoryVocabulary` در `src/Inventory/`). ساختار پیشنهادی: آرایه‌ی `value => label`؛ `value` انگلیسی پایدار (برای ذخیره)، `label` فارسی (برای نمایش). این هم i18n را تمیز نگه می‌دارد هم داده را پایدار.
> اگر ترجیح می‌دهی ساده‌تر بمانی و مقدارِ ذخیره‌شده همان برچسب فارسی باشد (هم‌راستا با وضعیت فعلی که `unit` فارسی ذخیره می‌شود)، می‌توانی فقط لیست فارسی مسطح نگه داری. **در این صورت حتماً یک لیست ثابت واحد در Backend داشته باش و فرانت آن را از endpoint بگیرد — نه هاردکد جدا.** تصمیم را در همان session بگیر و در `docs/api/inventory.md` مستند کن.
**واحدهای استاندارد (کلینیک/مطب) — لیست پیشنهادی:**
```
عدد، جفت، دست، بسته، جعبه، قوطی، تیوب، ویال، آمپول،
قرص، کپسول، ورق (بلیستر)، ساشه، رول، متر، سانتی‌متر،
سی‌سی، میلی‌لیتر، لیتر، میلی‌گرم، گرم، کیلوگرم، کیسه، عدد استریل
```
پیشنهاد نهایی مرتب و بدون تکرار (حدود ۱۸–۲۰ واحد). واحدهای پرکاربرد را بالای لیست بگذار (عدد، بسته، ویال، آمپول، سی‌سی، میلی‌لیتر).
**دسته‌بندی‌های استاندارد کلینیک/مطب — لیست پیشنهادی:**
```
دارو
لوازم مصرفی و تزریقات (سرنگ، سرسوزن، گاز، پنبه)
لوازم پانسمان و بخیه
مواد ضدعفونی و استریلیزاسیون
تجهیزات پزشکی
بیهوشی و بی‌حسی
لوازم زیبایی و پوست (بوتاکس، فیلر، مزو)
لوازم آزمایشگاهی
لوازم دندان‌پزشکی
ملزومات اداری و مصرفی دفتری
سایر
```
این لیست‌ها را در Backend به‌صورت constant قابل‌توسعه بگذار و در docblock توضیح بده که افزودن گزینه = افزودن به همین آرایه (بدون migration، چون مقدار در ستون string ذخیره می‌شود).
### ۲. Entity: افزودن ستون `category` + اعتبارسنجی مقدار
- ستون جدید در `InventoryItem`:
```php
#[ORM\Column(type: 'string', length: 60, nullable: true)]
private ?string $category = null;
public function getCategory(): ?string { return $this->category; }
public function setCategory(?string $v): self { $this->category = $v; return $this->touch(); }
```
- `category` را به `toArray()` اضافه کن.
- constantهای لیست مجاز (`UNITS`, `CATEGORIES`) را روی همین کلاس (یا Vocabulary) قرار بده و در docblock کلاس، توضیح `consumable` را اصلاح کن (دیگر «doubles as filter group» نیست).
> `consumable` را حذف نکن — سازگاری عقب‌رو و کلاینت tauri را نشکن. آن را همان فیلد یادداشت/طبقه‌بندی آزاد باقی بگذار، اما نقش «دسته/فیلتر» را از آن بردار.
### ۳. Controller: endpoint متادیتا + اعتبارسنجی نوشتن
- **endpoint جدید متادیتا** (لیست‌ها را به فرانت بده):
```php
#[Route('/api/v1/inventory-meta', methods: ['GET'])]
public function meta(): JsonResponse
{
return $this->success([
'units' => InventoryItem::UNITS, // یا Vocabulary::units()
'categories' => InventoryItem::CATEGORIES,
]);
}
```
- در `applyItemFields()`:
- `unit`: اگر مقدار در لیست مجاز نبود → یا `ERR_VALIDATION_001` با فیلد `unit`، یا fallback به `'عدد'`. اعتبارسنجی سخت‌گیرانه ترجیح داده می‌شود (پیام فارسی: «واحد نامعتبر است»).
- `category`: کلید جدید؛ خالی → `null`؛ مقدار نامعتبر → `ERR_VALIDATION_001` فیلد `category` («دسته‌بندی نامعتبر است»).
```php
if (array_key_exists('unit', $data)) {
$unit = trim((string) $data['unit']);
if ($unit !== '' && !array_key_exists($unit, InventoryItem::UNITS)) {
throw new AppException(ErrorCodes::ERR_VALIDATION_001, 'واحد نامعتبر است', 422);
}
$item->setUnit($unit === '' ? 'عدد' : $unit);
}
if (array_key_exists('category', $data)) {
$cat = trim((string) $data['category']);
if ($cat !== '' && !array_key_exists($cat, InventoryItem::CATEGORIES)) {
throw new AppException(ErrorCodes::ERR_VALIDATION_001, 'دسته‌بندی نامعتبر است', 422);
}
$item->setCategory($cat === '' ? null : $cat);
}
```
> اگر لیستِ مسطحِ فارسی را انتخاب کردی، `array_key_exists` را با `in_array($v, InventoryItem::UNITS, true)` جایگزین کن. الگوی پاسخ‌ها را با `BaseController` (`$this->success/$this->error`) و پرتاب `AppException` هم‌راستا نگه دار.
### ۴. Repository: تغییر منبع فیلتر دسته به `category`
`findConsumables` (یا نام بهتر `findCategories`) باید مقادیر متمایز `category` را برگرداند، نه `consumable`:
```php
->select('DISTINCT i.category AS category')
->where('i.entityType = :type AND i.entityId = :id AND i.category IS NOT NULL AND i.category != :empty')
```
> نکته: با endpoint متادیتا (وظیفه ۳) که کل لیست ثابت را می‌دهد، فیلترِ صفحه بهتر است از **لیست ثابت کامل** استفاده کند (نه فقط دسته‌های استفاده‌شده). اما اگر می‌خواهی «فقط دسته‌هایی که کالا دارند» را در dropdown فیلتر نشان دهی، همین کوئری اصلاح‌شده کافی است. تصمیم را در پرامپت‌اجرا بگیر و ثابت بمان.
### ۵. مهاجرت (Migration)
- `ddev exec php bin/console doctrine:migrations:diff --no-interaction` سپس `migrate`.
- (اختیاری، توصیه‌شده) Backfill: اگر مقدار `consumable` فعلی دقیقاً با یکی از دسته‌های استاندارد یکی بود، در همان migration به `category` منتقل شود؛ در غیر این صورت `category` نال بماند.
### ۶. Frontend — Modal: دو Select به‌جای input
- `useInventory` را گسترش بده:
- type `InventoryItem` و `ItemPayload`: افزودن `category?: string | null`.
- کوئری جدید `metaQuery` روی `GET /api/v1/inventory-meta` (staleTime بالا / `Infinity`، چون تقریباً ثابت است). خروجی: `units`, `categories`.
- `AddItemModal`:
- از کامپوننت طراحی‌سیستم `SearchableSelect` (`components/ui/`) استفاده کن (react-select زیر آن است) برای `unit` و `category` — هماهنگ با CLAUDE.md.
- `unit` الزامی با پیش‌فرض `عدد`؛ `category` انتخابی (می‌تواند خالی بماند مگر بخواهی الزامی کنی — طبق خواسته کاربر «هر کالا باید دسته داشته باشد» → **الزامی‌اش کن** و در `submit` مثل `name` اعتبارسنجی کن: پیام «دسته‌بندی کالا الزامی است»).
- آرایه‌ی `fields` را طوری بازسازی کن که `unit` و `category` از حلقه‌ی input جدا و به‌صورت Select رندر شوند (SRP: input متنی جدا از Select).
- در حالت ویرایش، مقدار فعلی pre-select شود.
```tsx
// نمونه
<SearchableSelect
label="واحد"
value={form.unit || 'عدد'}
options={meta.units.map(u => ({ value: u.value, label: u.label }))}
onChange={(v) => setForm(f => ({ ...f, unit: v }))}
/>
<SearchableSelect
label="دسته‌بندی"
value={form.category}
options={meta.categories.map(c => ({ value: c.value, label: c.label }))}
onChange={(v) => setForm(f => ({ ...f, category: v }))}
/>
```
> ساختار خروجی endpoint (`value/label` یا لیست مسطح فارسی) باید با تصمیم وظیفه ۱ یکی باشد. اگر مسطح فارسی است، `options={meta.units.map(u => ({ value: u, label: u }))}`.
### ۷. Frontend — صفحه: فیلتر بر اساس `category`
`InventoryPage.tsx`:
```tsx
// قبل:
(category === '' || it.consumable === category)
// بعد:
(category === '' || it.category === category)
```
- dropdown فیلتر بالای جدول از `meta.categories` (لیست کامل ثابت) یا از `categories` هوک (دسته‌های استفاده‌شده) پر شود — طبق تصمیم وظیفه ۴.
- اگر ستون «دسته» در جدول (`InventoryItemsTable`) وجود ندارد، افزودن ستون «دسته‌بندی» را در نظر بگیر (نمایش `label` فارسی).
## نکات مهم
- **قرارداد API / کلاینت‌های دیگر:** `InventoryItem::toArray()` مصرف‌کننده دارد؛ افزودن `category` امن است، اما **حذف/تغییر `consumable`** کلاینت `clinic-pro-tauri` (`src/service/response.js`) و مدل tauri را می‌شکند. فقط **اضافه کن**، حذف نکن.
- **منبع واحد لیست‌ها:** فرانت هرگز لیست واحد/دسته را هاردکد نکند؛ همیشه از `inventory-meta`. این تنها راه جلوگیری از drift بین بک و فرانت است (CLAUDE.md: قرارداد API).
- **BaseController pattern:** پاسخ‌ها با `$this->success()`؛ خطاها با `AppException(ErrorCodes::ERR_VALIDATION_001, 'پیام فارسی', 422)` که `ExceptionSubscriber` فرمت می‌کند. کد ولیدیشن فیلددار را با امضای موجود `error(..., 'field')` هماهنگ نگه دار.
- **رشته‌های UI فارسی**، مقدار ذخیره‌شده (value) ترجیحاً انگلیسی پایدار.
- **تست‌ها (الزامی — موفق/خطا/مرزی):**
- Backend (`ApiTestCase`): ساخت کالا با `unit`/`category` معتبر → 201؛ با `unit` نامعتبر → 422 فیلد `unit`؛ با `category` نامعتبر → 422؛ خالی گذاشتن category (اگر nullable) → قبول؛ `inventory-meta` لیست‌ها را برمی‌گرداند.
- Frontend (`InventoryPage.test.tsx` موجود + تست Modal): رندر Selectها، الزامی بودن دسته، فیلتر بر اساس `category`.
- **debug اول:** پیش از ساخت هر چیز، مطمئن شو endpoint موجودی برای متادیتا نیست (نیست — تأیید شد). قاعده «اول بگرد، بعد توسعه، آخر بساز».
- **مستندسازی:** `docs/api/inventory.md` را در همان session به‌روزرسانی کن: endpoint جدید `inventory-meta`، فیلد جدید `category` در بدنه create/update و در پاسخ، و قرارداد اعتبارسنجی.
- **بعد از تغییر کد:** `graphify update .` (پس از commit).