Files
clinicpro/.claude/prompt/inventory-unit-select-and-category.md
T
hamed 3e5dee0ad5 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.
2026-07-15 14:32:29 +03:30

16 KiB
Raw Blame History

واحد کالا به‌صورت 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 (واحد متن‌آزاد، بدون دسته):

#[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.
  • 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.
  • debug اول: پیش از ساخت هر چیز، مطمئن شو endpoint موجودی برای متادیتا نیست (نیست — تأیید شد). قاعده «اول بگرد، بعد توسعه، آخر بساز».
  • مستندسازی: docs/api/inventory.md را در همان session به‌روزرسانی کن: endpoint جدید inventory-meta، فیلد جدید category در بدنه create/update و در پاسخ، و قرارداد اعتبارسنجی.
  • بعد از تغییر کد: graphify update . (پس از commit).