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