# واحد کالا به‌صورت Select + سیستم دسته‌بندی اصولی کالا (انبارداری) ## پروژه `clinicpro` (Backend Symfony + پنل ادمین React). صفحه هدف: `/admin/inventory`. ## زمینه بخش انبارداری (`InventoryPage`) اجازه ایجاد/ویرایش «کالا» را می‌دهد. دو ضعف طراحی وجود دارد: 1. **واحد (`unit`)** به‌صورت متن آزاد وارد می‌شود (`AddItemModal` فقط یک `` متنی است، پیش‌فرض `'عدد'`). نتیجه: داده ناهمگون («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` | فرم افزودن/ویرایش | دو `` → دو 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 // نمونه ({ value: u.value, label: u.label }))} onChange={(v) => setForm(f => ({ ...f, unit: v }))} /> ({ 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).