From 3e5dee0ad58be73610c84b82d356ccbbff3a4751 Mon Sep 17 00:00:00 2001 From: hamed <15238-genius.ha@users.noreply.drupalcode.org> Date: Wed, 15 Jul 2026 14:32:29 +0330 Subject: [PATCH] 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. --- .../inventory-unit-select-and-category.md | 247 ++++++++++++++++++ .../components/inventory/AddItemModal.tsx | 56 +++- .../inventory/InventoryItemsTable.tsx | 4 +- assets/admin/hooks/useInventory.ts | 17 ++ assets/admin/pages/InventoryPage.test.tsx | 21 +- assets/admin/pages/InventoryPage.tsx | 5 +- docs/api/inventory.md | 32 ++- migrations/Version20260715105351.php | 31 +++ .../Controller/InventoryController.php | 24 +- src/Inventory/Entity/InventoryItem.php | 44 +++- .../Repository/InventoryItemRepository.php | 12 +- tests/Inventory/InventoryApiTest.php | 56 +++- 12 files changed, 513 insertions(+), 36 deletions(-) create mode 100644 .claude/prompt/inventory-unit-select-and-category.md create mode 100644 migrations/Version20260715105351.php diff --git a/.claude/prompt/inventory-unit-select-and-category.md b/.claude/prompt/inventory-unit-select-and-category.md new file mode 100644 index 00000000..3d303ff4 --- /dev/null +++ b/.claude/prompt/inventory-unit-select-and-category.md @@ -0,0 +1,247 @@ +# واحد کالا به‌صورت 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). diff --git a/assets/admin/components/inventory/AddItemModal.tsx b/assets/admin/components/inventory/AddItemModal.tsx index a707ffcc..b26aab8a 100644 --- a/assets/admin/components/inventory/AddItemModal.tsx +++ b/assets/admin/components/inventory/AddItemModal.tsx @@ -1,11 +1,13 @@ import React, { useEffect, useState } from 'react'; import Modal from '../ui/Modal'; +import SearchableSelect from '../ui/SearchableSelect'; import { rialToToman, tomanToRial, toEnglishDigits } from '../../lib/utils'; -import type { InventoryItem, ItemPayload } from '../../hooks/useInventory'; +import type { InventoryItem, InventoryMeta, ItemPayload } from '../../hooks/useInventory'; interface Props { open: boolean; editing: InventoryItem | null; + meta: InventoryMeta; saving: boolean; onClose: () => void; onSave: (payload: ItemPayload, uuid?: string) => void; @@ -14,20 +16,22 @@ interface Props { interface FormState { name: string; consumable: string; + category: string; unit: string; price: string; // Toman, as typed stock: string; alertThreshold: string; } -const BLANK: FormState = { name: '', consumable: '', unit: '', price: '', stock: '', alertThreshold: '' }; +const DEFAULT_UNIT = 'عدد'; +const BLANK: FormState = { name: '', consumable: '', category: '', unit: DEFAULT_UNIT, price: '', stock: '', alertThreshold: '' }; const digits = (v: string) => toEnglishDigits(v).replace(/\D/g, ''); // group thousands: "1200000" → "1,200,000" const group = (v: string) => v.replace(/\B(?=(\d{3})+(?!\d))/g, ','); -/** «افزودن/ویرایش کالای جدید» — mirrors tauri ModalAddInventory field-for-field. */ -export default function AddItemModal({ open, editing, saving, onClose, onSave }: Props) { +/** «افزودن/ویرایش کالای جدید» — واحد و دسته‌بندی از لیست استاندارد Backend. */ +export default function AddItemModal({ open, editing, meta, saving, onClose, onSave }: Props) { const [form, setForm] = useState(BLANK); const [error, setError] = useState(''); @@ -38,7 +42,8 @@ export default function AddItemModal({ open, editing, saving, onClose, onSave }: ? { name: editing.name, consumable: editing.consumable ?? '', - unit: editing.unit, + category: editing.category ?? '', + unit: editing.unit || DEFAULT_UNIT, price: editing.price ? String(rialToToman(editing.price)) : '', stock: String(editing.stock), alertThreshold: String(editing.alertThreshold), @@ -50,13 +55,17 @@ export default function AddItemModal({ open, editing, saving, onClose, onSave }: setForm((f) => ({ ...f, [k]: e.target.value })); const setNum = (k: keyof FormState) => (e: React.ChangeEvent) => setForm((f) => ({ ...f, [k]: digits(e.target.value) })); + const setSelect = (k: keyof FormState) => (v: string | number | null) => + setForm((f) => ({ ...f, [k]: v == null ? '' : String(v) })); const submit = () => { if (form.name.trim() === '') { setError('نام کالا الزامی است'); return; } + if (form.category === '') { setError('دسته‌بندی کالا الزامی است'); return; } const payload: ItemPayload = { name: form.name.trim(), consumable: form.consumable.trim() || null, - unit: form.unit.trim() || 'عدد', + category: form.category, + unit: form.unit || DEFAULT_UNIT, price: form.price ? tomanToRial(Number(form.price)) : 0, stock: form.stock ? Number(form.stock) : 0, alertThreshold: form.alertThreshold ? Number(form.alertThreshold) : 0, @@ -64,19 +73,47 @@ export default function AddItemModal({ open, editing, saving, onClose, onSave }: onSave(payload, editing?.uuid); }; - const fields: { key: keyof FormState; label: string; placeholder: string; numeric?: boolean }[] = [ + const inputs: { key: keyof FormState; label: string; placeholder: string; numeric?: boolean }[] = [ { key: 'name', label: 'نام کالا', placeholder: 'نام کالا' }, { key: 'consumable', label: 'مصرفی', placeholder: 'مصرفی' }, - { key: 'unit', label: 'واحد', placeholder: 'عدد' }, { key: 'price', label: 'قیمت (تومان)', placeholder: 'قیمت', numeric: true }, { key: 'stock', label: 'موجودی', placeholder: 'موجودی', numeric: true }, { key: 'alertThreshold', label: 'هشدار اتمام', placeholder: 'هشدار اتمام', numeric: true }, ]; + const toOptions = (list: string[]) => list.map((v) => ({ value: v, label: v })); + return (
- {fields.map((f) => ( +
+ +
+ +
+
+ +
+ + +
+ +
+ + +
+ + {inputs.filter((f) => f.key !== 'name').map((f) => (
@@ -85,7 +122,6 @@ export default function AddItemModal({ open, editing, saving, onClose, onSave }: onChange={f.numeric ? setNum(f.key) : set(f.key)} placeholder={f.placeholder} inputMode={f.numeric ? 'numeric' : undefined} - autoFocus={f.key === 'name'} />
diff --git a/assets/admin/components/inventory/InventoryItemsTable.tsx b/assets/admin/components/inventory/InventoryItemsTable.tsx index 2fd116d6..b676119c 100644 --- a/assets/admin/components/inventory/InventoryItemsTable.tsx +++ b/assets/admin/components/inventory/InventoryItemsTable.tsx @@ -11,7 +11,7 @@ interface Props { onDelete: (item: InventoryItem) => void; } -const HEAD = ['نام کالا', 'موجودی', 'واحد', 'قیمت', 'وضعیت', 'عملیات']; +const HEAD = ['نام کالا', 'دسته‌بندی', 'موجودی', 'واحد', 'قیمت', 'وضعیت', 'عملیات']; /** Consumable-items list: desktop table + mobile card grid (tauri InventoryList). */ export default function InventoryItemsTable({ items, onEdit, onDelete }: Props) { @@ -30,6 +30,7 @@ export default function InventoryItemsTable({ items, onEdit, onDelete }: Props) {items.map((item) => ( {item.name} + {item.category ?? '—'} {formatNumber(item.stock)} {item.unit} {formatRial(item.price)} @@ -59,6 +60,7 @@ export default function InventoryItemsTable({ items, onEdit, onDelete }: Props)
{[ + ['دسته‌بندی:', item.category ?? '—'], ['موجودی:', formatNumber(item.stock)], ['واحد:', item.unit], ['قیمت:', formatRial(item.price)], diff --git a/assets/admin/hooks/useInventory.ts b/assets/admin/hooks/useInventory.ts index ea5cffd5..7f01bc6d 100644 --- a/assets/admin/hooks/useInventory.ts +++ b/assets/admin/hooks/useInventory.ts @@ -11,6 +11,7 @@ export interface InventoryItem { uuid: string; name: string; consumable: string | null; + category: string | null; unit: string; price: number; // Rial stock: number; @@ -18,6 +19,12 @@ export interface InventoryItem { status: InventoryStatus; } +/** Backend-owned option lists (GET /api/v1/inventory-meta) — never hardcode client-side. */ +export interface InventoryMeta { + units: string[]; + categories: string[]; +} + export interface InventoryStats { total: number; low: number; @@ -44,6 +51,7 @@ export interface InventoryPackage { export interface ItemPayload { name: string; consumable?: string | null; + category?: string | null; unit?: string; price?: number; stock?: number; @@ -59,6 +67,7 @@ const EMPTY_STATS: InventoryStats = { total: 0, low: 0, inStock: 0, outOfStock: const EMPTY_ITEMS: InventoryItem[] = []; const EMPTY_PACKAGES: InventoryPackage[] = []; const EMPTY_CATS: string[] = []; +const EMPTY_META: InventoryMeta = { units: [], categories: [] }; // ── Hook ───────────────────────────────────────────────────────────────────── @@ -81,6 +90,13 @@ export function useInventory() { queryFn: () => api.get('/api/v1/inventory-categories'), }); + // Option lists are effectively static — fetch once, never refetch. + const metaQuery = useQuery>({ + queryKey: ['inventory-meta'], + queryFn: () => api.get('/api/v1/inventory-meta'), + staleTime: Infinity, + }); + const invalidateItems = () => { qc.invalidateQueries({ queryKey: ['inventory-items'] }); qc.invalidateQueries({ queryKey: ['inventory-categories'] }); @@ -125,6 +141,7 @@ export function useInventory() { stats: itemsQuery.data?.data?.stats ?? EMPTY_STATS, packages: packagesQuery.data?.data ?? EMPTY_PACKAGES, categories: categoriesQuery.data?.data ?? EMPTY_CATS, + meta: metaQuery.data?.data ?? EMPTY_META, itemsLoading: itemsQuery.isLoading, packagesLoading: packagesQuery.isLoading, createItem, updateItem, deleteItem, diff --git a/assets/admin/pages/InventoryPage.test.tsx b/assets/admin/pages/InventoryPage.test.tsx index f2632d29..5241bd51 100644 --- a/assets/admin/pages/InventoryPage.test.tsx +++ b/assets/admin/pages/InventoryPage.test.tsx @@ -14,18 +14,20 @@ import InventoryPage from './InventoryPage'; const get = api.get as ReturnType; const ITEM = { - uuid: 'i-1', name: 'دستکش جراحی', consumable: 'جراحی', unit: 'عدد', + uuid: 'i-1', name: 'دستکش جراحی', consumable: 'جراحی', category: 'لوازم مصرفی و تزریقات', unit: 'عدد', price: 250000, stock: 150, alertThreshold: 20, status: 'in_stock', }; +const META = { units: ['عدد', 'بسته', 'ویال'], categories: ['دارو', 'لوازم مصرفی و تزریقات', 'سایر'] }; const STATS = { total: 1, low: 0, inStock: 1, outOfStock: 0 }; const PKG = { uuid: 'p-1', title: 'پکیج شماره یک', total: 2400000, available: true, items: [{ itemUuid: 'i-1', name: 'ژل', unit: 'سی‌سی', price: 1200000, amount: 2 }], }; -function mockApi(opts: { items?: any[]; stats?: any; packages?: any[]; categories?: string[] } = {}) { +function mockApi(opts: { items?: any[]; stats?: any; packages?: any[]; categories?: string[]; meta?: any } = {}) { get.mockImplementation((url: string = '') => { if (url.includes('/inventory-packages')) return Promise.resolve({ success: true, data: opts.packages ?? [] }); + if (url.includes('/inventory-meta')) return Promise.resolve({ success: true, data: opts.meta ?? META }); if (url.includes('/inventory-categories')) return Promise.resolve({ success: true, data: opts.categories ?? [] }); if (url.includes('/inventory-items')) return Promise.resolve({ success: true, data: { items: opts.items ?? [], stats: opts.stats ?? { total: 0, low: 0, inStock: 0, outOfStock: 0 } } }); return Promise.resolve({ success: true, data: { items: [], stats: { total: 0, low: 0, inStock: 0, outOfStock: 0 } } }); @@ -69,6 +71,21 @@ describe('InventoryPage', () => { expect(price.value).toBe('1,200,000'); }); + it('blocks submit when category is missing and unit is a picker, not free text', async () => { + mockApi({ items: [], stats: { total: 0, low: 0, inStock: 0, outOfStock: 0 } }); + renderWithProviders(, { route: '/admin/inventory' }); + + fireEvent.click(await screen.findByRole('button', { name: /افزودن کالا/ })); + await screen.findByText('افزودن کالای جدید'); + + // unit is now a select — no free-text input with the old placeholder + expect(screen.queryByPlaceholderText('عدد')).not.toBeInTheDocument(); + + fireEvent.change(screen.getByPlaceholderText('نام کالا'), { target: { value: 'ماسک' } }); + fireEvent.click(screen.getByRole('button', { name: 'اضافه کردن کالا' })); + expect(await screen.findByText('دسته‌بندی کالا الزامی است')).toBeInTheDocument(); + }); + it('switches to the packages tab and lists a package with its price', async () => { mockApi({ packages: [PKG] }); renderWithProviders(, { route: '/admin/inventory' }); diff --git a/assets/admin/pages/InventoryPage.tsx b/assets/admin/pages/InventoryPage.tsx index 84034d40..5a5517bf 100644 --- a/assets/admin/pages/InventoryPage.tsx +++ b/assets/admin/pages/InventoryPage.tsx @@ -14,7 +14,7 @@ type Tab = 'stock' | 'packages'; /** انبارداری — consumable stock items + packages. Ported from clinic-pro-tauri /inventory. */ export default function InventoryPage() { const { - items, stats, packages, categories, itemsLoading, packagesLoading, + items, stats, packages, categories, meta, itemsLoading, packagesLoading, createItem, updateItem, deleteItem, createPackage, updatePackage, deletePackage, } = useInventory(); @@ -31,7 +31,7 @@ export default function InventoryPage() { const q = search.trim(); return items.filter((it) => (q === '' || it.name.includes(q)) && - (category === '' || it.consumable === category) + (category === '' || it.category === category) ); }, [items, search, category]); @@ -120,6 +120,7 @@ export default function InventoryPage() { setItemModal({ open: false, editing: null })} onSave={saveItem} diff --git a/docs/api/inventory.md b/docs/api/inventory.md index 109ddffc..8c708ed3 100644 --- a/docs/api/inventory.md +++ b/docs/api/inventory.md @@ -31,6 +31,7 @@ List the tenant's items plus the four derived stat counters. "uuid": "…", "name": "دستکش جراحی", "consumable": "جراحی", + "category": "لوازم مصرفی و تزریقات", "unit": "عدد", "price": 250000, "stock": 150, @@ -45,11 +46,29 @@ List the tenant's items plus the four derived stat counters. ### GET `/api/v1/inventory-categories` -Distinct non-empty `consumable` values for the tenant — powers the filter dropdown. +Distinct non-empty `category` values **actually in use** by the tenant — powers the +filter dropdown. For the full list of allowed categories use `inventory-meta`. #### Response `200` ```json -{ "success": true, "data": ["جراحی", "دندانپزشکی"] } +{ "success": true, "data": ["دارو", "لوازم آزمایشگاهی"] } +``` + +### GET `/api/v1/inventory-meta` + +Backend-owned option lists for the item form. **Single source of truth** — the +admin never hardcodes units/categories. Values are plain Persian strings stored +as-is; the create/update endpoints validate against these lists. + +#### Response `200` +```json +{ + "success": true, + "data": { + "units": ["عدد", "بسته", "…", "سی‌سی", "میلی‌لیتر", "…"], + "categories": ["دارو", "لوازم مصرفی و تزریقات", "…", "سایر"] + } +} ``` ### POST `/api/v1/inventory-item` @@ -60,8 +79,9 @@ Create an item. | Field | Type | Required | Notes | |-------|------|----------|-------| | `name` | string | ✅ | نام کالا | -| `consumable` | string | ❌ | «مصرفی» / گروه فیلتر | -| `unit` | string | ❌ | Default `عدد` | +| `consumable` | string | ❌ | «مصرفی» — یادداشت آزاد (سازگاری عقب‌رو) | +| `category` | string | ❌ | دسته‌بندی؛ باید یکی از `inventory-meta.categories` باشد. خالی → `null`. فرم ادمین آن را الزامی می‌کند | +| `unit` | string | ❌ | باید یکی از `inventory-meta.units` باشد. خالی → پیش‌فرض `عدد` | | `price` | integer | ❌ | Rial, Default `0` | | `stock` | integer | ❌ | Default `0` | | `alertThreshold` | integer | ❌ | Default `0` | @@ -74,7 +94,9 @@ Create an item. #### Errors | Status | Code | Cause | |--------|------|-------| -| `422` | `ERR_VALIDATION_001` | `name` خالی است | +| `422` | `ERR_VALIDATION_001` | `name` خالی است (field `name`) | +| `422` | `ERR_VALIDATION_001` | `unit` خارج از لیست مجاز (field `unit`) | +| `422` | `ERR_VALIDATION_001` | `category` خارج از لیست مجاز (field `category`) | | `403` | `ERR_FORBIDDEN_001` | پروفایل tenant یافت نشد | ### PATCH `/api/v1/inventory-item/{uuid}` diff --git a/migrations/Version20260715105351.php b/migrations/Version20260715105351.php new file mode 100644 index 00000000..056a1118 --- /dev/null +++ b/migrations/Version20260715105351.php @@ -0,0 +1,31 @@ +addSql('ALTER TABLE inventory_items ADD category VARCHAR(60) DEFAULT NULL'); + } + + public function down(Schema $schema): void + { + // this down() migration is auto-generated, please modify it to your needs + $this->addSql('ALTER TABLE inventory_items DROP category'); + } +} diff --git a/src/Inventory/Controller/InventoryController.php b/src/Inventory/Controller/InventoryController.php index 6488a5c4..48338d1a 100644 --- a/src/Inventory/Controller/InventoryController.php +++ b/src/Inventory/Controller/InventoryController.php @@ -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']); diff --git a/src/Inventory/Entity/InventoryItem.php b/src/Inventory/Entity/InventoryItem.php index f466f9b4..8a8d023a 100644 --- a/src/Inventory/Entity/InventoryItem.php +++ b/src/Inventory/Entity/InventoryItem.php @@ -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, diff --git a/src/Inventory/Repository/InventoryItemRepository.php b/src/Inventory/Repository/InventoryItemRepository.php index d3119a41..e9109630 100644 --- a/src/Inventory/Repository/InventoryItemRepository.php +++ b/src/Inventory/Repository/InventoryItemRepository.php @@ -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 diff --git a/tests/Inventory/InventoryApiTest.php b/tests/Inventory/InventoryApiTest.php index 1834caf3..8dc10753 100644 --- a/tests/Inventory/InventoryApiTest.php +++ b/tests/Inventory/InventoryApiTest.php @@ -86,18 +86,62 @@ class InventoryApiTest extends ApiTestCase ); } - public function testCategoriesReturnsDistinctConsumables(): void + public function testCategoriesReturnsDistinctUsedCategories(): void { [$user] = $this->doctorUser(); - $this->authJson('POST', '/api/v1/inventory-item', $user, ['name' => 'a', 'consumable' => 'جراحی']); - $this->authJson('POST', '/api/v1/inventory-item', $user, ['name' => 'b', 'consumable' => 'جراحی']); - $this->authJson('POST', '/api/v1/inventory-item', $user, ['name' => 'c', 'consumable' => 'دندان']); + $this->authJson('POST', '/api/v1/inventory-item', $user, ['name' => 'a', 'category' => 'دارو']); + $this->authJson('POST', '/api/v1/inventory-item', $user, ['name' => 'b', 'category' => 'دارو']); + $this->authJson('POST', '/api/v1/inventory-item', $user, ['name' => 'c', 'category' => 'لوازم آزمایشگاهی']); $cats = $this->authJson('GET', '/api/v1/inventory-categories', $user); self::assertSame(200, $this->responseCode()); self::assertCount(2, $cats['data']); - self::assertContains('جراحی', $cats['data']); - self::assertContains('دندان', $cats['data']); + self::assertContains('دارو', $cats['data']); + self::assertContains('لوازم آزمایشگاهی', $cats['data']); + } + + public function testMetaReturnsUnitAndCategoryLists(): void + { + [$user] = $this->doctorUser(); + $meta = $this->authJson('GET', '/api/v1/inventory-meta', $user); + self::assertSame(200, $this->responseCode()); + self::assertContains('عدد', $meta['data']['units']); + self::assertContains('سی‌سی', $meta['data']['units']); + self::assertContains('دارو', $meta['data']['categories']); + self::assertContains('سایر', $meta['data']['categories']); + } + + public function testCreateStoresValidUnitAndCategory(): void + { + [$user] = $this->doctorUser(); + $item = $this->authJson('POST', '/api/v1/inventory-item', $user, [ + 'name' => 'سرنگ', 'unit' => 'بسته', 'category' => 'لوازم مصرفی و تزریقات', + ]); + self::assertSame(201, $this->responseCode()); + self::assertSame('بسته', $item['data']['unit']); + self::assertSame('لوازم مصرفی و تزریقات', $item['data']['category']); + } + + public function testCreateRejectsInvalidUnit(): void + { + [$user] = $this->doctorUser(); + $this->authJson('POST', '/api/v1/inventory-item', $user, ['name' => 'x', 'unit' => 'واحد‌جعلی']); + self::assertSame(422, $this->responseCode()); + } + + public function testCreateRejectsInvalidCategory(): void + { + [$user] = $this->doctorUser(); + $this->authJson('POST', '/api/v1/inventory-item', $user, ['name' => 'x', 'category' => 'دسته‌جعلی']); + self::assertSame(422, $this->responseCode()); + } + + public function testEmptyUnitFallsBackToDefault(): void + { + [$user] = $this->doctorUser(); + $item = $this->authJson('POST', '/api/v1/inventory-item', $user, ['name' => 'x', 'unit' => '']); + self::assertSame(201, $this->responseCode()); + self::assertSame('عدد', $item['data']['unit']); } public function testCannotTouchAnotherTenantsItem(): void