- 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.
16 KiB
واحد کالا بهصورت Select + سیستم دستهبندی اصولی کالا (انبارداری)
پروژه
clinicpro (Backend Symfony + پنل ادمین React). صفحه هدف: /admin/inventory.
زمینه
بخش انبارداری (InventoryPage) اجازه ایجاد/ویرایش «کالا» را میدهد. دو ضعف طراحی وجود دارد:
-
واحد (
unit) بهصورت متن آزاد وارد میشود (AddItemModalفقط یک<input>متنی است، پیشفرض'عدد'). نتیجه: داده ناهمگون («cc»، «سی سی»، «سیسی»، «میلی لیتر»، «ml» و …) که گزارشگیری و یکپارچگی را خراب میکند. -
دستهبندی وجود ندارد. چیزی که امروز بهعنوان «دسته» کار میکند در واقع فیلد متنآزاد
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 (واحد متنآزاد، بدون دسته):
#[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 — اعمال فیلدها بدون اعتبارسنجی مقدار مجاز:
if (array_key_exists('unit', $data)) {
$unit = trim((string) $data['unit']);
$item->setUnit($unit === '' ? 'عدد' : $unit);
}
«دستهها» امروز = مقادیر متمایز consumable:
// 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 متنی:
const fields = [
{ key: 'name', label: 'نام کالا', placeholder: 'نام کالا' },
{ key: 'consumable', label: 'مصرفی', placeholder: 'مصرفی' },
{ key: 'unit', label: 'واحد', placeholder: 'عدد' }, // ← متن آزاد
...
];
صفحه — فیلتر بر اساس consumable:
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:
#[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 جدید متادیتا (لیستها را به فرانت بده):
#[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(«دستهبندی نامعتبر است»).
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:
->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.
- type
AddItemModal:- از کامپوننت طراحیسیستم
SearchableSelect(components/ui/) استفاده کن (react-select زیر آن است) برایunitوcategory— هماهنگ با CLAUDE.md. unitالزامی با پیشفرضعدد؛categoryانتخابی (میتواند خالی بماند مگر بخواهی الزامی کنی — طبق خواسته کاربر «هر کالا باید دسته داشته باشد» → الزامیاش کن و درsubmitمثلnameاعتبارسنجی کن: پیام «دستهبندی کالا الزامی است»).- آرایهی
fieldsرا طوری بازسازی کن کهunitوcategoryاز حلقهی input جدا و بهصورت Select رندر شوند (SRP: input متنی جدا از Select). - در حالت ویرایش، مقدار فعلی pre-select شود.
- از کامپوننت طراحیسیستم
// نمونه
<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:
// قبل:
(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.
- Backend (
- debug اول: پیش از ساخت هر چیز، مطمئن شو endpoint موجودی برای متادیتا نیست (نیست — تأیید شد). قاعده «اول بگرد، بعد توسعه، آخر بساز».
- مستندسازی:
docs/api/inventory.mdرا در همان session بهروزرسانی کن: endpoint جدیدinventory-meta، فیلد جدیدcategoryدر بدنه create/update و در پاسخ، و قرارداد اعتبارسنجی. - بعد از تغییر کد:
graphify update .(پس از commit).