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:
hamed
2026-07-15 14:32:29 +03:30
parent defa0db023
commit 3e5dee0ad5
12 changed files with 513 additions and 36 deletions
@@ -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).
@@ -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<FormState>(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<HTMLInputElement>) =>
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 (
<Modal open={open} onClose={onClose} title={editing ? 'ویرایش کالا' : 'افزودن کالای جدید'} size="lg">
<div style={{ display: 'flex', flexDirection: 'column', gap: 20 }}>
{fields.map((f) => (
<div>
<label className="field-label">نام کالا</label>
<div className="field">
<input value={form.name} onChange={set('name')} placeholder="نام کالا" autoFocus />
</div>
</div>
<div>
<label className="field-label">دستهبندی</label>
<SearchableSelect
options={toOptions(meta.categories)}
value={form.category || null}
onChange={setSelect('category')}
placeholder="انتخاب دسته‌بندی"
/>
</div>
<div>
<label className="field-label">واحد</label>
<SearchableSelect
options={toOptions(meta.units)}
value={form.unit || DEFAULT_UNIT}
onChange={setSelect('unit')}
placeholder="انتخاب واحد"
/>
</div>
{inputs.filter((f) => f.key !== 'name').map((f) => (
<div key={f.key}>
<label className="field-label">{f.label}</label>
<div className="field">
@@ -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'}
/>
</div>
</div>
@@ -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) => (
<tr key={item.uuid}>
<td style={{ color: 'var(--text-2)' }}>{item.name}</td>
<td style={{ color: 'var(--text-3)' }}>{item.category ?? '—'}</td>
<td>{formatNumber(item.stock)}</td>
<td>{item.unit}</td>
<td>{formatRial(item.price)}</td>
@@ -59,6 +60,7 @@ export default function InventoryItemsTable({ items, onEdit, onDelete }: Props)
<InventoryStatusBadge status={item.status} />
</div>
{[
['دسته‌بندی:', item.category ?? '—'],
['موجودی:', formatNumber(item.stock)],
['واحد:', item.unit],
['قیمت:', formatRial(item.price)],
+17
View File
@@ -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<ApiResponse<InventoryMeta>>({
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,
+19 -2
View File
@@ -14,18 +14,20 @@ import InventoryPage from './InventoryPage';
const get = api.get as ReturnType<typeof vi.fn>;
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(<InventoryPage />, { 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(<InventoryPage />, { route: '/admin/inventory' });
+3 -2
View File
@@ -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() {
<AddItemModal
open={itemModal.open}
editing={itemModal.editing}
meta={meta}
saving={createItem.isPending || updateItem.isPending}
onClose={() => setItemModal({ open: false, editing: null })}
onSave={saveItem}
+27 -5
View File
@@ -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}`
+31
View File
@@ -0,0 +1,31 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Auto-generated Migration: Please modify to your needs!
*/
final class Version20260715105351 extends AbstractMigration
{
public function getDescription(): string
{
return 'Add category column to inventory_items';
}
public function up(Schema $schema): void
{
// this up() migration is auto-generated, please modify it to your needs
$this->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');
}
}
@@ -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']);
+42 -2
View File
@@ -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,
@@ -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
+50 -6
View File
@@ -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