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:
@@ -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)],
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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' });
|
||||
|
||||
@@ -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
@@ -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}`
|
||||
|
||||
@@ -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']);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user