feat: port inventory (انبارداری) page from tauri to admin dashboard

Add a per-tenant (doctor/clinic) Inventory domain and admin page, ported
from clinic-pro-tauri /inventory (which was static/mock) into a real feature.

Backend (src/Inventory/):
- Entities InventoryItem, InventoryPackage, InventoryPackageItem, scoped via
  entity_type/entity_id like TenantTag. Item status is derived, package total
  and availability derived at read time.
- InventoryService (stats, package assembly, availability), thin
  InventoryController with CRUD for items and packages + categories endpoint.
- Migration + docs/api/inventory.md + functional tests (10 tests, 42 assertions).

Frontend (assets/admin/):
- InventoryPage with two tabs (کالاهای مصرفی / پکیج), stat cards, items table
  (desktop + mobile cards), packages accordion, add/edit item and package
  modals, search + category filter — pixel-matched to the tauri source.
- useInventory hook (TanStack Query), route + sidebar link for doctor/clinic.
- Vitest coverage (real data, empty state, modal, packages tab).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
hamed
2026-07-15 13:48:29 +03:30
co-authored by Claude Opus 4.8
parent f1e9129a2c
commit 519bc8d7f6
23 changed files with 2124 additions and 0 deletions
+2
View File
@@ -58,6 +58,7 @@ import AccountSettingsPage from './pages/AccountSettingsPage';
import TagsSettingsPage from './pages/TagsSettingsPage';
import AppointmentSettingsPage from './pages/AppointmentSettingsPage';
import PatientsListPage from './pages/PatientsListPage';
import InventoryPage from './pages/InventoryPage';
import PatientRecordFormPage from './pages/PatientRecordFormPage';
import PatientDetailPage from './pages/PatientDetailPage';
import PaymentSuccessPage from './pages/PaymentSuccessPage';
@@ -221,6 +222,7 @@ export default function App() {
<Route path="subscription" element={<RoleRoute roles={['doctor', 'clinic']} blockClinicScope><SubscriptionPage /></RoleRoute>} />
<Route path="subscription/success" element={<RoleRoute roles={['doctor', 'clinic']} blockClinicScope><PaymentSuccessPage /></RoleRoute>} />
<Route path="clinic-services" element={<RoleRoute roles={['doctor', 'clinic']} blockClinicScope><ClinicServicesPage /></RoleRoute>} />
<Route path="inventory" element={<RoleRoute roles={['doctor', 'clinic']} blockClinicScope><InventoryPage /></RoleRoute>} />
<Route path="sms-wallet" element={<RoleRoute roles={['doctor', 'clinic']} blockClinicScope><SmsWalletPage /></RoleRoute>} />
<Route path="my-secretaries" element={<RoleRoute roles={['doctor', 'clinic']} blockClinicScope><MySecretariesPage /></RoleRoute>} />
<Route path="admin-subscription" element={<RoleRoute roles={['admin']}><AdminSubscriptionPage /></RoleRoute>} />
@@ -0,0 +1,103 @@
import React, { useEffect, useState } from 'react';
import Modal from '../ui/Modal';
import { rialToToman, tomanToRial, toEnglishDigits } from '../../lib/utils';
import type { InventoryItem, ItemPayload } from '../../hooks/useInventory';
interface Props {
open: boolean;
editing: InventoryItem | null;
saving: boolean;
onClose: () => void;
onSave: (payload: ItemPayload, uuid?: string) => void;
}
interface FormState {
name: string;
consumable: string;
unit: string;
price: string; // Toman, as typed
stock: string;
alertThreshold: string;
}
const BLANK: FormState = { name: '', consumable: '', unit: '', price: '', stock: '', alertThreshold: '' };
const digits = (v: string) => toEnglishDigits(v).replace(/\D/g, '');
/** «افزودن/ویرایش کالای جدید» — mirrors tauri ModalAddInventory field-for-field. */
export default function AddItemModal({ open, editing, saving, onClose, onSave }: Props) {
const [form, setForm] = useState<FormState>(BLANK);
const [error, setError] = useState('');
useEffect(() => {
if (!open) return;
setError('');
setForm(editing
? {
name: editing.name,
consumable: editing.consumable ?? '',
unit: editing.unit,
price: editing.price ? String(rialToToman(editing.price)) : '',
stock: String(editing.stock),
alertThreshold: String(editing.alertThreshold),
}
: BLANK);
}, [open, editing]);
const set = (k: keyof FormState) => (e: React.ChangeEvent<HTMLInputElement>) =>
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 submit = () => {
if (form.name.trim() === '') { setError('نام کالا الزامی است'); return; }
const payload: ItemPayload = {
name: form.name.trim(),
consumable: form.consumable.trim() || null,
unit: form.unit.trim() || 'عدد',
price: form.price ? tomanToRial(Number(form.price)) : 0,
stock: form.stock ? Number(form.stock) : 0,
alertThreshold: form.alertThreshold ? Number(form.alertThreshold) : 0,
};
onSave(payload, editing?.uuid);
};
const fields: { 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 },
];
return (
<Modal open={open} onClose={onClose} title={editing ? 'ویرایش کالا' : 'افزودن کالای جدید'} size="lg">
<div style={{ display: 'flex', flexDirection: 'column', gap: 20 }}>
{fields.map((f) => (
<div key={f.key}>
<label className="field-label">{f.label}</label>
<div className="field">
<input
value={form[f.key]}
onChange={f.numeric ? setNum(f.key) : set(f.key)}
placeholder={f.placeholder}
inputMode={f.numeric ? 'numeric' : undefined}
autoFocus={f.key === 'name'}
/>
</div>
</div>
))}
{error && <span className="field-error">{error}</span>}
<button
className="btn primary"
style={{ width: '100%', justifyContent: 'center', height: 46 }}
onClick={submit}
disabled={saving}
>
{editing ? 'ذخیره تغییرات' : 'اضافه کردن کالا'}
</button>
</div>
</Modal>
);
}
@@ -0,0 +1,136 @@
import React, { useEffect, useMemo, useState } from 'react';
import { TrashIcon } from '@heroicons/react/24/outline';
import Modal from '../ui/Modal';
import { formatRial } from '../../lib/utils';
import type { InventoryItem, InventoryPackage, PackagePayload } from '../../hooks/useInventory';
interface Props {
open: boolean;
editing: InventoryPackage | null;
items: InventoryItem[]; // the tenant's available items to pick from
saving: boolean;
onClose: () => void;
onSave: (payload: PackagePayload, uuid?: string) => void;
}
interface Line { itemUuid: string; name: string; unit: string; price: number; amount: number }
/** «افزودن/ویرایش پکیج» — mirrors tauri ModalAddPackage (two-column builder). */
export default function AddPackageModal({ open, editing, items, saving, onClose, onSave }: Props) {
const [title, setTitle] = useState('');
const [pickUuid, setPickUuid] = useState('');
const [amount, setAmount] = useState('1');
const [lines, setLines] = useState<Line[]>([]);
const [error, setError] = useState('');
useEffect(() => {
if (!open) return;
setError('');
setPickUuid(items[0]?.uuid ?? '');
setAmount('1');
if (editing) {
setTitle(editing.title);
setLines(editing.items.map((l) => ({ itemUuid: l.itemUuid, name: l.name, unit: l.unit, price: l.price, amount: l.amount })));
} else {
setTitle('');
setLines([]);
}
}, [open, editing, items]);
const total = useMemo(() => lines.reduce((s, l) => s + l.price * l.amount, 0), [lines]);
const addLine = () => {
const item = items.find((i) => i.uuid === pickUuid);
if (!item) return;
const qty = Math.max(1, Number(amount) || 1);
setLines((prev) => [...prev, { itemUuid: item.uuid, name: item.name, unit: item.unit, price: item.price, amount: qty }]);
setAmount('1');
};
const changeAmount = (idx: number, delta: number) =>
setLines((prev) => prev.map((l, i) => (i === idx ? { ...l, amount: Math.max(1, l.amount + delta) } : l)));
const removeLine = (idx: number) => setLines((prev) => prev.filter((_, i) => i !== idx));
const submit = () => {
if (title.trim() === '') { setError('نام پکیج الزامی است'); return; }
onSave({ title: title.trim(), items: lines.map((l) => ({ itemUuid: l.itemUuid, amount: l.amount })) }, editing?.uuid);
};
const footer = (
<div style={{ display: 'flex', gap: 16, justifyContent: 'flex-end', width: '100%' }}>
<button className="btn ghost" style={{ height: 48, width: 160, justifyContent: 'center' }} onClick={onClose}>انصراف</button>
<button className="btn primary" style={{ height: 48, width: 160, justifyContent: 'center' }} onClick={submit} disabled={saving}>
{editing ? 'ذخیره تغییرات' : 'افزودن پکیج'}
</button>
</div>
);
return (
<Modal open={open} onClose={onClose} title={editing ? 'ویرایش پکیج' : 'افزودن پکیج'} size="xl" footer={footer}>
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 32 }}>
{/* Left — builder */}
<div style={{ flex: '1 1 300px', display: 'flex', flexDirection: 'column', gap: 20 }}>
<div>
<label className="field-label">نام پکیج</label>
<div className="field"><input value={title} onChange={(e) => setTitle(e.target.value)} placeholder="نام پکیج" /></div>
</div>
<div>
<label className="field-label">اجزای پکیج</label>
<div className="field">
<select value={pickUuid} onChange={(e) => setPickUuid(e.target.value)} disabled={items.length === 0}>
{items.length === 0 && <option value="">ابتدا کالا اضافه کنید</option>}
{items.map((i) => <option key={i.uuid} value={i.uuid}>{i.name}</option>)}
</select>
</div>
</div>
<div>
<label className="field-label">مقدار</label>
<div className="field">
<input value={amount} inputMode="numeric" onChange={(e) => setAmount(e.target.value.replace(/[^0-9]/g, ''))} placeholder="1" />
</div>
</div>
<button className="btn ghost" style={{ justifyContent: 'center', height: 46 }} onClick={addLine} disabled={items.length === 0}>
افزودن کالا
</button>
</div>
{/* Right — selected items */}
<div
style={{
flex: '1 1 300px', minHeight: 320, borderRadius: 8, padding: 24,
border: '1px dashed #636ed0', display: 'flex', flexDirection: 'column', justifyContent: 'space-between',
}}
>
<div>
<div style={{ fontSize: 16, fontWeight: 600, color: 'var(--text)', marginBottom: 16 }}>کالاهای انتخاب شده:</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
{lines.length === 0 ? (
<span style={{ fontSize: 14, color: 'var(--text-3)' }}>هنوز کالایی به پکیج اضافه نشده است.</span>
) : (
lines.map((l, idx) => (
<div key={idx} style={{ display: 'flex', alignItems: 'center', gap: 16 }}>
<span style={{ flex: 1, minWidth: 0, fontSize: 14, fontWeight: 600, color: 'var(--text-2)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{l.name}</span>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', width: 97, height: 40, borderRadius: 4, border: '1px solid var(--border-2)', padding: '0 8px' }}>
<button type="button" onClick={() => changeAmount(idx, 1)} style={{ background: 'none', border: 'none', cursor: 'pointer', color: '#f0753b', fontSize: 18, fontWeight: 600 }}>+</button>
<span style={{ color: '#f0753b', fontSize: 14, fontWeight: 500 }}>{l.amount}</span>
<button type="button" onClick={() => changeAmount(idx, -1)} style={{ background: 'none', border: 'none', cursor: 'pointer', color: '#f0753b', fontSize: 18, fontWeight: 600 }}>-</button>
</div>
<span style={{ fontSize: 14, color: 'var(--text-2)', whiteSpace: 'nowrap' }}>{formatRial(l.price)}</span>
<button type="button" onClick={() => removeLine(idx)} aria-label="حذف" style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'var(--danger)' }}>
<TrashIcon style={{ width: 18 }} />
</button>
</div>
))
)}
</div>
</div>
<div style={{ marginTop: 24, paddingTop: 16, borderTop: '1px solid var(--border)' }}>
<span style={{ fontSize: 14, fontWeight: 600, color: 'var(--text)' }}>قیمت کل پکیج: {formatRial(total)}</span>
</div>
</div>
</div>
{error && <span className="field-error">{error}</span>}
</div>
</Modal>
);
}
@@ -0,0 +1,75 @@
import React, { useState } from 'react';
import { EllipsisHorizontalCircleIcon, PencilSquareIcon, TrashIcon } from '@heroicons/react/24/outline';
import type { InventoryItem } from '../../hooks/useInventory';
interface Props {
item: InventoryItem;
onEdit: (item: InventoryItem) => void;
onDelete: (item: InventoryItem) => void;
}
/** «عملیات» trigger + dropdown — mirrors the tauri InventoryActionsPopover. */
export default function InventoryActionsMenu({ item, onEdit, onDelete }: Props) {
const [open, setOpen] = useState(false);
return (
<div style={{ position: 'relative', display: 'inline-block' }}>
<button
type="button"
onClick={() => setOpen((v) => !v)}
style={{
display: 'flex', alignItems: 'center', gap: 4, cursor: 'pointer',
borderRadius: 6, padding: '4px 8px', background: 'none',
color: '#5559ce', fontSize: 12, fontWeight: 500,
}}
>
<EllipsisHorizontalCircleIcon style={{ width: 18, height: 18 }} />
عملیات
</button>
{open && (
<>
<div
onClick={() => setOpen(false)}
style={{ position: 'fixed', inset: 0, zIndex: 30 }}
/>
<div
role="menu"
style={{
position: 'absolute', top: '100%', insetInlineStart: 0, marginTop: 4, zIndex: 31,
width: 160, background: 'var(--surface)', border: '1px solid var(--border)',
borderRadius: 12, boxShadow: 'var(--shadow-lg)', overflow: 'hidden',
}}
>
<button
type="button"
role="menuitem"
onClick={() => { setOpen(false); onEdit(item); }}
style={{
display: 'flex', alignItems: 'center', gap: 8, width: '100%',
padding: '12px 16px', background: 'var(--primary-soft)', border: 'none',
cursor: 'pointer', fontSize: 14, fontWeight: 600, color: 'var(--text)',
}}
>
<PencilSquareIcon style={{ width: 20, height: 20 }} />
ویرایش
</button>
<button
type="button"
role="menuitem"
onClick={() => { setOpen(false); onDelete(item); }}
style={{
display: 'flex', alignItems: 'center', gap: 8, width: '100%',
padding: '12px 16px', background: 'transparent', border: 'none',
cursor: 'pointer', fontSize: 14, color: 'var(--danger)',
}}
>
<TrashIcon style={{ width: 20, height: 20 }} />
حذف
</button>
</div>
</>
)}
</div>
);
}
@@ -0,0 +1,89 @@
import React from 'react';
import { PencilSquareIcon, TrashIcon } from '@heroicons/react/24/outline';
import { formatRial, formatNumber } from '../../lib/utils';
import type { InventoryItem } from '../../hooks/useInventory';
import InventoryStatusBadge from './InventoryStatusBadge';
import InventoryActionsMenu from './InventoryActionsMenu';
interface Props {
items: InventoryItem[];
onEdit: (item: InventoryItem) => void;
onDelete: (item: InventoryItem) => void;
}
const HEAD = ['نام کالا', 'موجودی', 'واحد', 'قیمت', 'وضعیت', 'عملیات'];
/** Consumable-items list: desktop table + mobile card grid (tauri InventoryList). */
export default function InventoryItemsTable({ items, onEdit, onDelete }: Props) {
return (
<div style={{ width: '100%' }}>
{/* Desktop table */}
<div
className="inv-desktop"
style={{ border: '1px solid #E7E7E7', borderRadius: 8, overflow: 'hidden' }}
>
<table className="inv-table">
<thead>
<tr>{HEAD.map((h) => <th key={h}>{h}</th>)}</tr>
</thead>
<tbody>
{items.map((item) => (
<tr key={item.uuid}>
<td style={{ color: 'var(--text-2)' }}>{item.name}</td>
<td>{formatNumber(item.stock)}</td>
<td>{item.unit}</td>
<td>{formatRial(item.price)}</td>
<td><InventoryStatusBadge status={item.status} /></td>
<td><InventoryActionsMenu item={item} onEdit={onEdit} onDelete={onDelete} /></td>
</tr>
))}
</tbody>
</table>
</div>
{/* Mobile cards */}
<ul
className="inv-mobile"
style={{ gridTemplateColumns: 'repeat(auto-fill, minmax(260px, 1fr))', gap: 16, margin: 0, padding: 0, listStyle: 'none' }}
>
{items.map((item) => (
<li
key={item.uuid}
style={{
padding: 12, background: 'var(--surface)', border: '1px solid var(--border)',
borderRadius: 8, boxShadow: 'var(--shadow-sm)',
}}
>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 12 }}>
<span style={{ color: 'var(--text)', fontSize: 14, fontWeight: 600 }}>{item.name}</span>
<InventoryStatusBadge status={item.status} />
</div>
{[
['موجودی:', formatNumber(item.stock)],
['واحد:', item.unit],
['قیمت:', formatRial(item.price)],
].map(([label, value], i, arr) => (
<div key={label}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 12 }}>
<span style={{ color: 'var(--text-3)', fontSize: 14 }}>{label}</span>
<span style={{ color: 'var(--text-2)', fontSize: 14, fontWeight: 500 }}>{value}</span>
</div>
{i < arr.length - 1 && (
<div style={{ height: 1, background: 'var(--border)', margin: '8px auto', width: 'calc(100% - 20px)' }} />
)}
</div>
))}
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: 8, marginTop: 12 }}>
<button className="btn sm ghost" aria-label="ویرایش" onClick={() => onEdit(item)} style={{ color: 'var(--text-2)' }}>
<PencilSquareIcon style={{ width: 16 }} />
</button>
<button className="btn sm ghost" aria-label="حذف" onClick={() => onDelete(item)} style={{ color: 'var(--danger)' }}>
<TrashIcon style={{ width: 16 }} />
</button>
</div>
</li>
))}
</ul>
</div>
);
}
@@ -0,0 +1,26 @@
import React from 'react';
import { ArchiveBoxIcon } from '@heroicons/react/24/outline';
import StatCard from '../ui/StatCard';
import { formatNumber } from '../../lib/utils';
import type { InventoryStats } from '../../hooks/useInventory';
const ICON = <ArchiveBoxIcon style={{ width: 20, height: 20 }} />;
/** Four headline counters — order/colors mirror the tauri InventoryCards. */
export default function InventoryStatCards({ stats }: { stats: InventoryStats }) {
return (
<div
style={{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fit, minmax(220px, 1fr))',
gap: 20,
padding: '20px 0',
}}
>
<StatCard tone="violet" icon={ICON} label="تعداد کل کالاها" value={formatNumber(stats.total)} />
<StatCard tone="amber" icon={ICON} label="کالاهای کم موجود" value={formatNumber(stats.low)} />
<StatCard tone="green" icon={ICON} label="کالاهای موجود" value={formatNumber(stats.inStock)} />
<StatCard tone="pink" icon={ICON} label="کالاهای اتمام یافته" value={formatNumber(stats.outOfStock)} />
</div>
);
}
@@ -0,0 +1,14 @@
import React from 'react';
import type { InventoryStatus } from '../../hooks/useInventory';
const CONFIG: Record<InventoryStatus, { label: string; cls: string }> = {
in_stock: { label: 'موجود', cls: 'in-stock' },
low_stock: { label: 'کم موجود', cls: 'low-stock' },
out_of_stock: { label: 'اتمام یافته', cls: 'out-of-stock' },
};
/** Colored availability pill — colors mirror the tauri InventoryStatus component. */
export default function InventoryStatusBadge({ status }: { status: InventoryStatus }) {
const c = CONFIG[status] ?? CONFIG.out_of_stock;
return <span className={`inv-badge ${c.cls}`}>{c.label}</span>;
}
@@ -0,0 +1,112 @@
import React, { useState } from 'react';
import { ChevronDownIcon, PencilSquareIcon, TrashIcon } from '@heroicons/react/24/outline';
import { formatRial } from '../../lib/utils';
import type { InventoryPackage } from '../../hooks/useInventory';
interface Props {
packages: InventoryPackage[];
onEdit: (pkg: InventoryPackage) => void;
onDelete: (pkg: InventoryPackage) => void;
}
/** Package cards with an expandable component list — mirrors tauri AddStockView. */
export default function PackagesView({ packages, onEdit, onDelete }: Props) {
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
{packages.map((pkg) => (
<PackageCard key={pkg.uuid} pkg={pkg} onEdit={onEdit} onDelete={onDelete} />
))}
</div>
);
}
function PackageCard({ pkg, onEdit, onDelete }: { pkg: InventoryPackage } & Pick<Props, 'onEdit' | 'onDelete'>) {
const [expanded, setExpanded] = useState(false);
const divider = <div style={{ height: 1, background: '#d7d7d7', margin: '8px 0' }} className="inv-divider" />;
return (
<div style={{ background: 'var(--surface)', border: '1px solid var(--border)', borderRadius: 8, padding: 16 }}>
{/* Header */}
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<span style={{ width: 20, height: 20, background: '#f0753b', borderRadius: '50%', flexShrink: 0 }} />
<span style={{ fontWeight: 500, fontSize: 16, color: 'var(--text)' }}>{pkg.title}</span>
</div>
<span
style={{
width: 122, height: 34, display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
borderRadius: 4, fontWeight: 600, fontSize: 16,
color: pkg.available ? '#3f9954' : '#d32f2f',
background: pkg.available ? '#f0faf2' : '#fdebed',
}}
>
{pkg.available ? 'موجودی کافی' : 'موجودی ناکافی'}
</span>
</div>
{divider}
{/* Accordion */}
<button
type="button"
onClick={() => setExpanded((v) => !v)}
style={{
display: 'flex', alignItems: 'center', gap: 12, width: '100%', padding: '8px 0',
background: 'none', border: 'none', cursor: 'pointer',
}}
>
<ChevronDownIcon
style={{ width: 20, height: 20, color: 'var(--text-2)', transition: 'transform .2s', transform: expanded ? 'rotate(180deg)' : 'none' }}
/>
<span style={{ fontWeight: 500, fontSize: 14, color: 'var(--text)' }}>اجزای پکیج:</span>
</button>
{expanded && (
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 40, padding: '4px 0 16px' }}>
{pkg.items.length === 0 ? (
<span style={{ fontSize: 14, color: 'var(--text-3)' }}>این پکیج کالایی ندارد.</span>
) : (
pkg.items.map((it) => (
<div key={it.itemUuid} style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 4 }}>
<span style={{ color: 'var(--text)', fontWeight: 700, fontSize: 16 }}>{it.name}</span>
<span
style={{
height: 24, display: 'inline-flex', alignItems: 'center', padding: '0 10px',
background: 'var(--surface-3)', color: 'var(--text-2)', borderRadius: 6, fontWeight: 500, fontSize: 14,
}}
>
{it.amount} {it.unit}
</span>
</div>
))
)}
</div>
)}
{divider}
{/* Footer */}
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<span style={{ fontWeight: 700, fontSize: 14, color: 'var(--text)' }}>
قیمت پکیج: {formatRial(pkg.total)}
</span>
<div style={{ display: 'flex', gap: 8 }}>
<button
className="btn sm ghost"
onClick={() => onDelete(pkg)}
style={{ height: 40, width: 78, border: '1px solid var(--border)', color: 'var(--danger)', gap: 6 }}
>
<TrashIcon style={{ width: 16 }} /> حذف
</button>
<button
className="btn sm ghost"
onClick={() => onEdit(pkg)}
style={{ height: 40, width: 78, border: '1px solid var(--border)', color: 'var(--text-2)', gap: 6 }}
>
<PencilSquareIcon style={{ width: 16 }} /> ویرایش
</button>
</div>
</div>
</div>
);
}
@@ -1,4 +1,5 @@
import {
ArchiveBoxIcon,
ArrowLeftOnRectangleIcon,
ArrowsRightLeftIcon,
BanknotesIcon,
@@ -222,6 +223,11 @@ function buildSections(
label: "مطالبات بیمه",
feature: "insurance",
},
{
to: "/admin/inventory",
icon: ArchiveBoxIcon,
label: "انبارداری",
},
],
},
{
@@ -286,6 +292,11 @@ function buildSections(
label: "مطالبات بیمه",
feature: "insurance",
},
{
to: "/admin/inventory",
icon: ArchiveBoxIcon,
label: "انبارداری",
},
],
},
{
+133
View File
@@ -0,0 +1,133 @@
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { toast } from 'sonner';
import { api } from '../lib/api';
import type { ApiResponse } from '../lib/api';
// ── Types ────────────────────────────────────────────────────────────────────
export type InventoryStatus = 'in_stock' | 'low_stock' | 'out_of_stock';
export interface InventoryItem {
uuid: string;
name: string;
consumable: string | null;
unit: string;
price: number; // Rial
stock: number;
alertThreshold: number;
status: InventoryStatus;
}
export interface InventoryStats {
total: number;
low: number;
inStock: number;
outOfStock: number;
}
export interface PackageLine {
itemUuid: string;
name: string;
unit: string;
price: number; // Rial
amount: number;
}
export interface InventoryPackage {
uuid: string;
title: string;
items: PackageLine[];
total: number; // Rial
available: boolean;
}
export interface ItemPayload {
name: string;
consumable?: string | null;
unit?: string;
price?: number;
stock?: number;
alertThreshold?: number;
}
export interface PackagePayload {
title: string;
items: { itemUuid: string; amount: number }[];
}
const EMPTY_STATS: InventoryStats = { total: 0, low: 0, inStock: 0, outOfStock: 0 };
const EMPTY_ITEMS: InventoryItem[] = [];
const EMPTY_PACKAGES: InventoryPackage[] = [];
const EMPTY_CATS: string[] = [];
// ── Hook ─────────────────────────────────────────────────────────────────────
/** Per-tenant inventory: items (+stats), packages and category filter options. */
export function useInventory() {
const qc = useQueryClient();
const itemsQuery = useQuery<ApiResponse<{ items: InventoryItem[]; stats: InventoryStats }>>({
queryKey: ['inventory-items'],
queryFn: () => api.get('/api/v1/inventory-items'),
});
const packagesQuery = useQuery<ApiResponse<InventoryPackage[]>>({
queryKey: ['inventory-packages'],
queryFn: () => api.get('/api/v1/inventory-packages'),
});
const categoriesQuery = useQuery<ApiResponse<string[]>>({
queryKey: ['inventory-categories'],
queryFn: () => api.get('/api/v1/inventory-categories'),
});
const invalidateItems = () => {
qc.invalidateQueries({ queryKey: ['inventory-items'] });
qc.invalidateQueries({ queryKey: ['inventory-categories'] });
qc.invalidateQueries({ queryKey: ['inventory-packages'] });
};
const invalidatePackages = () => qc.invalidateQueries({ queryKey: ['inventory-packages'] });
const createItem = useMutation({
mutationFn: (d: ItemPayload) => api.post('/api/v1/inventory-item', d),
onSuccess: () => { invalidateItems(); toast.success('کالا اضافه شد'); },
onError: (e: any) => toast.error(e.message),
});
const updateItem = useMutation({
mutationFn: ({ uuid, d }: { uuid: string; d: ItemPayload }) => api.patch(`/api/v1/inventory-item/${uuid}`, d),
onSuccess: () => { invalidateItems(); toast.success('کالا ویرایش شد'); },
onError: (e: any) => toast.error(e.message),
});
const deleteItem = useMutation({
mutationFn: (uuid: string) => api.delete(`/api/v1/inventory-item/${uuid}`),
onSuccess: () => { invalidateItems(); toast.success('کالا حذف شد'); },
onError: (e: any) => toast.error(e.message),
});
const createPackage = useMutation({
mutationFn: (d: PackagePayload) => api.post('/api/v1/inventory-package', d),
onSuccess: () => { invalidatePackages(); toast.success('پکیج اضافه شد'); },
onError: (e: any) => toast.error(e.message),
});
const updatePackage = useMutation({
mutationFn: ({ uuid, d }: { uuid: string; d: PackagePayload }) => api.patch(`/api/v1/inventory-package/${uuid}`, d),
onSuccess: () => { invalidatePackages(); toast.success('پکیج ویرایش شد'); },
onError: (e: any) => toast.error(e.message),
});
const deletePackage = useMutation({
mutationFn: (uuid: string) => api.delete(`/api/v1/inventory-package/${uuid}`),
onSuccess: () => { invalidatePackages(); toast.success('پکیج حذف شد'); },
onError: (e: any) => toast.error(e.message),
});
return {
items: itemsQuery.data?.data?.items ?? EMPTY_ITEMS,
stats: itemsQuery.data?.data?.stats ?? EMPTY_STATS,
packages: packagesQuery.data?.data ?? EMPTY_PACKAGES,
categories: categoriesQuery.data?.data ?? EMPTY_CATS,
itemsLoading: itemsQuery.isLoading,
packagesLoading: packagesQuery.isLoading,
createItem, updateItem, deleteItem,
createPackage, updatePackage, deletePackage,
};
}
+77
View File
@@ -0,0 +1,77 @@
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { screen, fireEvent, waitFor } from '@testing-library/react';
import { renderWithProviders } from '../test/utils';
vi.mock('sonner', () => ({ toast: { success: vi.fn(), error: vi.fn() } }));
vi.mock('../lib/api', () => ({
api: { get: vi.fn(), post: vi.fn(), patch: vi.fn(), put: vi.fn(), delete: vi.fn() },
ApiError: class extends Error {},
}));
import { api } from '../lib/api';
import InventoryPage from './InventoryPage';
const get = api.get as ReturnType<typeof vi.fn>;
const ITEM = {
uuid: 'i-1', name: 'دستکش جراحی', consumable: 'جراحی', unit: 'عدد',
price: 250000, stock: 150, alertThreshold: 20, status: 'in_stock',
};
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[] } = {}) {
get.mockImplementation((url: string = '') => {
if (url.includes('/inventory-packages')) return Promise.resolve({ success: true, data: opts.packages ?? [] });
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 } } });
});
}
beforeEach(() => get.mockReset());
describe('InventoryPage', () => {
it('renders the header, tabs and item row with real data', async () => {
mockApi({ items: [ITEM], stats: STATS, categories: ['جراحی'] });
renderWithProviders(<InventoryPage />, { route: '/admin/inventory' });
expect(screen.getByText('انبارداری')).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'کالاهای مصرفی' })).toBeInTheDocument();
// desktop table + mobile card both render in jsdom (no CSS media) → allow both
expect((await screen.findAllByText('دستکش جراحی')).length).toBeGreaterThan(0);
// stat card counter + status badge
expect(screen.getByText('کالاهای موجود')).toBeInTheDocument();
expect(screen.getAllByText('موجود').length).toBeGreaterThan(0);
});
it('shows the empty state when there are no items', async () => {
mockApi({ items: [], stats: { total: 0, low: 0, inStock: 0, outOfStock: 0 } });
renderWithProviders(<InventoryPage />, { route: '/admin/inventory' });
expect(await screen.findByText('هنوز کالایی ثبت نشده است.')).toBeInTheDocument();
});
it('opens the add-item modal from the header button', async () => {
mockApi({ items: [], stats: { total: 0, low: 0, inStock: 0, outOfStock: 0 } });
renderWithProviders(<InventoryPage />, { route: '/admin/inventory' });
fireEvent.click(await screen.findByRole('button', { name: /افزودن کالا/ }));
expect(await screen.findByText('افزودن کالای جدید')).toBeInTheDocument();
// the source's distinctive "هشدار اتمام" field is present
expect(screen.getByText('هشدار اتمام')).toBeInTheDocument();
});
it('switches to the packages tab and lists a package with its price', async () => {
mockApi({ packages: [PKG] });
renderWithProviders(<InventoryPage />, { route: '/admin/inventory' });
fireEvent.click(screen.getByRole('button', { name: 'پکیج' }));
expect(await screen.findByText('پکیج شماره یک')).toBeInTheDocument();
expect(screen.getByText('موجودی کافی')).toBeInTheDocument();
// 2,400,000 Rial → 240,000 Toman
await waitFor(() => expect(screen.getByText(/۲۴۰٬۰۰۰ تومان/)).toBeInTheDocument());
});
});
+165
View File
@@ -0,0 +1,165 @@
import React, { useMemo, useState } from 'react';
import { PlusIcon, MagnifyingGlassIcon, ArchiveBoxIcon } from '@heroicons/react/24/outline';
import ConfirmDialog from '../components/ui/ConfirmDialog';
import { useInventory } from '../hooks/useInventory';
import type { InventoryItem, InventoryPackage, ItemPayload, PackagePayload } from '../hooks/useInventory';
import InventoryStatCards from '../components/inventory/InventoryStatCards';
import InventoryItemsTable from '../components/inventory/InventoryItemsTable';
import PackagesView from '../components/inventory/PackagesView';
import AddItemModal from '../components/inventory/AddItemModal';
import AddPackageModal from '../components/inventory/AddPackageModal';
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,
createItem, updateItem, deleteItem, createPackage, updatePackage, deletePackage,
} = useInventory();
const [tab, setTab] = useState<Tab>('stock');
const [search, setSearch] = useState('');
const [category, setCategory] = useState('');
const [itemModal, setItemModal] = useState<{ open: boolean; editing: InventoryItem | null }>({ open: false, editing: null });
const [pkgModal, setPkgModal] = useState<{ open: boolean; editing: InventoryPackage | null }>({ open: false, editing: null });
const [itemToDelete, setItemToDelete] = useState<InventoryItem | null>(null);
const [pkgToDelete, setPkgToDelete] = useState<InventoryPackage | null>(null);
const filteredItems = useMemo(() => {
const q = search.trim();
return items.filter((it) =>
(q === '' || it.name.includes(q)) &&
(category === '' || it.consumable === category)
);
}, [items, search, category]);
const saveItem = (payload: ItemPayload, uuid?: string) => {
const opts = { onSuccess: () => setItemModal({ open: false, editing: null }) };
if (uuid) updateItem.mutate({ uuid, d: payload }, opts);
else createItem.mutate(payload, opts);
};
const savePackage = (payload: PackagePayload, uuid?: string) => {
const opts = { onSuccess: () => setPkgModal({ open: false, editing: null }) };
if (uuid) updatePackage.mutate({ uuid, d: payload }, opts);
else createPackage.mutate(payload, opts);
};
return (
<div className="fade-in" style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
{/* Header */}
<div>
<h1 style={{ fontSize: 18, fontWeight: 800, color: 'var(--text)', marginBottom: 12 }}>انبارداری</h1>
<div className="inv-tabs">
<button className={`inv-tab${tab === 'stock' ? ' active' : ''}`} onClick={() => setTab('stock')}>کالاهای مصرفی</button>
<button className={`inv-tab${tab === 'packages' ? ' active' : ''}`} onClick={() => setTab('packages')}>پکیج</button>
</div>
{tab === 'stock' ? (
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 16, flexWrap: 'wrap' }}>
<div style={{ display: 'flex', gap: 16, flexWrap: 'wrap', flex: 1 }}>
<div className="field" style={{ display: 'flex', alignItems: 'center', gap: 8, minWidth: 240, flex: '1 1 300px', maxWidth: 442 }}>
<MagnifyingGlassIcon style={{ width: 18, height: 18, color: 'var(--text-3)', flexShrink: 0 }} />
<input
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="جستجو در کالاهای مصرفی..."
style={{ border: 'none', background: 'transparent', flex: 1, padding: 0 }}
/>
</div>
<div className="field" style={{ minWidth: 200, flex: '1 1 260px', maxWidth: 397 }}>
<select value={category} onChange={(e) => setCategory(e.target.value)}>
<option value="">دستهبندی کالا را انتخاب کنید...</option>
{categories.map((c) => <option key={c} value={c}>{c}</option>)}
</select>
</div>
</div>
<button className="btn primary" onClick={() => setItemModal({ open: true, editing: null })}>
<PlusIcon style={{ width: 16 }} /> افزودن کالا
</button>
</div>
) : (
<div style={{ display: 'flex', justifyContent: 'flex-end' }}>
<button className="btn primary" onClick={() => setPkgModal({ open: true, editing: null })}>
<PlusIcon style={{ width: 16 }} /> افزودن پکیج
</button>
</div>
)}
</div>
{/* Body */}
{tab === 'stock' ? (
<>
<InventoryStatCards stats={stats} />
{itemsLoading ? (
<div style={{ padding: 16, color: 'var(--text-3)', fontSize: 13 }}>در حال بارگذاری...</div>
) : filteredItems.length === 0 ? (
<EmptyState label={items.length === 0 ? 'هنوز کالایی ثبت نشده است.' : 'کالایی با این فیلتر یافت نشد.'} />
) : (
<InventoryItemsTable
items={filteredItems}
onEdit={(item) => setItemModal({ open: true, editing: item })}
onDelete={setItemToDelete}
/>
)}
</>
) : packagesLoading ? (
<div style={{ padding: 16, color: 'var(--text-3)', fontSize: 13 }}>در حال بارگذاری...</div>
) : packages.length === 0 ? (
<EmptyState label="هنوز پکیجی ثبت نشده است." />
) : (
<PackagesView
packages={packages}
onEdit={(pkg) => setPkgModal({ open: true, editing: pkg })}
onDelete={setPkgToDelete}
/>
)}
{/* Modals */}
<AddItemModal
open={itemModal.open}
editing={itemModal.editing}
saving={createItem.isPending || updateItem.isPending}
onClose={() => setItemModal({ open: false, editing: null })}
onSave={saveItem}
/>
<AddPackageModal
open={pkgModal.open}
editing={pkgModal.editing}
items={items}
saving={createPackage.isPending || updatePackage.isPending}
onClose={() => setPkgModal({ open: false, editing: null })}
onSave={savePackage}
/>
<ConfirmDialog
open={!!itemToDelete}
title="حذف کالا"
message={`آیا از حذف «${itemToDelete?.name}» مطمئن هستید؟`}
confirmLabel="حذف"
loading={deleteItem.isPending}
onConfirm={() => itemToDelete && deleteItem.mutate(itemToDelete.uuid, { onSuccess: () => setItemToDelete(null) })}
onCancel={() => setItemToDelete(null)}
/>
<ConfirmDialog
open={!!pkgToDelete}
title="حذف پکیج"
message={`آیا از حذف «${pkgToDelete?.title}» مطمئن هستید؟`}
confirmLabel="حذف"
loading={deletePackage.isPending}
onConfirm={() => pkgToDelete && deletePackage.mutate(pkgToDelete.uuid, { onSuccess: () => setPkgToDelete(null) })}
onCancel={() => setPkgToDelete(null)}
/>
</div>
);
}
function EmptyState({ label }: { label: string }) {
return (
<div className="card" style={{ padding: '56px 0', textAlign: 'center', color: 'var(--text-3)' }}>
<ArchiveBoxIcon style={{ width: 48, margin: '0 auto 14px', display: 'block', opacity: 0.3 }} />
<div style={{ fontSize: 14, color: 'var(--text-2)' }}>{label}</div>
</div>
);
}
+53
View File
@@ -852,3 +852,56 @@ html, body { max-width: 100%; overflow-x: hidden; }
@media (max-width: 560px) {
.settings-grid { grid-template-columns: 1fr; }
}
/* ── Inventory (انبارداری) ─────────────────────────────────────────────────
Status pill colors mirror clinic-pro-tauri src/components/inventory/list/
InventoryStatus.jsx exactly, in both light and dark. */
.inv-badge {
display: inline-block; width: 100px; text-align: center;
padding: 4px 8px; border-radius: 4px; font-size: 14px; font-weight: 500;
}
.inv-badge.in-stock { background: #E8FADD; color: #3c9a4f; }
.inv-badge.low-stock { background: #FFF3DD; color: #FDBA35; }
.inv-badge.out-of-stock { background: #FFE3E2; color: #FF5450; }
.dark .inv-badge.in-stock { background: #324A32; color: #75E22E; }
.dark .inv-badge.low-stock { background: #4E4234; color: #FDBA35; }
.dark .inv-badge.out-of-stock { background: rgba(255,84,80,0.20); color: #FF5450; }
/* Tab row — mirrors inventory/TabsRow + TabItem. */
.inv-tabs {
display: flex; align-items: flex-end; gap: 24px;
border-bottom: 1px solid #EDEDED; margin: 4px 0 18px; width: 100%;
overflow-x: auto; scrollbar-width: none;
}
.inv-tabs::-webkit-scrollbar { display: none; }
.dark .inv-tabs { border-color: #35343D; }
.inv-tab {
display: flex; align-items: center; gap: 6px; padding: 0 8px 6px;
font-size: 16px; cursor: pointer; white-space: nowrap;
border-bottom: 2px solid transparent; color: #6B7280; background: none;
}
.dark .inv-tab { color: #A1A1A1; }
.inv-tab.active { border-bottom: 3px solid #5559ce; color: #5559ce; }
.dark .inv-tab.active { color: #5559ce; }
/* Inventory responsive table/card switch (md breakpoint = 768px, like tauri). */
.inv-desktop { display: block; }
.inv-mobile { display: none; }
@media (max-width: 767px) {
.inv-desktop { display: none; }
.inv-mobile { display: grid; }
}
.inv-table { width: 100%; border-collapse: collapse; }
.inv-table thead tr { background: #e1e1e1; }
.inv-table th {
color: #616161; font-size: 14px; font-weight: 400;
padding: 10px 18px; text-align: start; white-space: nowrap;
}
.inv-table td {
padding: 12px 18px; text-align: start; white-space: nowrap;
font-size: 16px; font-weight: 500; color: var(--text-2);
border-bottom: 1px solid #DBDBDB;
}
.dark .inv-table td { border-color: var(--border); }
.inv-table tbody tr:hover { background: #f4f5fd; }
.dark .inv-table tbody tr:hover { background: var(--surface-2); }
+135
View File
@@ -0,0 +1,135 @@
# Inventory API
> **Prefix:** `/api/v1/inventory-*`
Per-tenant (doctor/clinic) consumable-stock management: **items** and **packages**
(bundles of items). Every row is scoped to the caller's resolved entity
(`doctor` / `clinic`), exactly like Tenant Tags — a tenant only ever sees and
mutates its own inventory. Prices are stored and returned in **Rial** (integer).
**Permission (all routes):** `IS_AUTHENTICATED_FULLY` (roles `doctor`, `clinic`;
`secretary` resolves to its active clinic/doctor context).
Item `status` is **derived, never stored**:
`stock <= 0``out_of_stock`; `stock <= alertThreshold``low_stock`; else `in_stock`.
---
## Items
### GET `/api/v1/inventory-items`
List the tenant's items plus the four derived stat counters.
#### Response `200`
```json
{
"success": true,
"data": {
"items": [
{
"uuid": "…",
"name": "دستکش جراحی",
"consumable": "جراحی",
"unit": "عدد",
"price": 250000,
"stock": 150,
"alertThreshold": 20,
"status": "in_stock"
}
],
"stats": { "total": 1, "low": 0, "inStock": 1, "outOfStock": 0 }
}
}
```
### GET `/api/v1/inventory-categories`
Distinct non-empty `consumable` values for the tenant — powers the filter dropdown.
#### Response `200`
```json
{ "success": true, "data": ["جراحی", "دندانپزشکی"] }
```
### POST `/api/v1/inventory-item`
Create an item.
#### Body
| Field | Type | Required | Notes |
|-------|------|----------|-------|
| `name` | string | ✅ | نام کالا |
| `consumable` | string | ❌ | «مصرفی» / گروه فیلتر |
| `unit` | string | ❌ | Default `عدد` |
| `price` | integer | ❌ | Rial, Default `0` |
| `stock` | integer | ❌ | Default `0` |
| `alertThreshold` | integer | ❌ | Default `0` |
#### Response `201`
```json
{ "success": true, "data": { "uuid": "…", "name": "دستکش جراحی", "status": "in_stock", "...": "..." } }
```
#### Errors
| Status | Code | Cause |
|--------|------|-------|
| `422` | `ERR_VALIDATION_001` | `name` خالی است |
| `403` | `ERR_FORBIDDEN_001` | پروفایل tenant یافت نشد |
### PATCH `/api/v1/inventory-item/{uuid}`
Partial update. Any of the create fields may be sent. Returns `200` with the item,
`404 ERR_NOT_FOUND_001` if the item does not belong to the caller, `422` on empty `name`.
### DELETE `/api/v1/inventory-item/{uuid}`
Delete an item. `200` with `{ "message": "کالا حذف شد" }`, or `404` if not owned.
Deleting an item also removes it from any package lines (FK `ON DELETE CASCADE`).
---
## Packages
A package's `total` (Rial) and `available` (boolean) are **derived at read time**
from its component items — `available` is `true` only if every component item has
`stock >= amount`.
### GET `/api/v1/inventory-packages`
#### Response `200`
```json
{
"success": true,
"data": [
{
"uuid": "…",
"title": "پکیج شماره یک",
"items": [
{ "itemUuid": "…", "name": "ژل", "unit": "سی‌سی", "price": 1200000, "amount": 2 }
],
"total": 2400000,
"available": true
}
]
}
```
### POST `/api/v1/inventory-package`
#### Body
| Field | Type | Required | Notes |
|-------|------|----------|-------|
| `title` | string | ✅ | نام پکیج |
| `items` | array | ❌ | `[{ "itemUuid": "…", "amount": 2 }]` — references not owned by the caller are silently skipped |
#### Response `201` — the serialized package (same shape as list). Errors: `422 ERR_VALIDATION_001` (empty `title`), `403 ERR_FORBIDDEN_001`.
### PATCH `/api/v1/inventory-package/{uuid}`
Partial update. `title` renames; sending `items` **replaces** all component lines.
`200` with the package, `404 ERR_NOT_FOUND_001` if not owned, `422` on empty `title`.
### DELETE `/api/v1/inventory-package/{uuid}`
`200` with `{ "message": "پکیج حذف شد" }`, or `404` if not owned.
+39
View File
@@ -0,0 +1,39 @@
<?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 Version20260715100324 extends AbstractMigration
{
public function getDescription(): string
{
return 'Create inventory tables: items, packages and package items (per-tenant).';
}
public function up(Schema $schema): void
{
// this up() migration is auto-generated, please modify it to your needs
$this->addSql('CREATE TABLE inventory_items (id INT AUTO_INCREMENT NOT NULL, uuid VARCHAR(36) NOT NULL, entity_type VARCHAR(20) NOT NULL, entity_id INT NOT NULL, name VARCHAR(120) NOT NULL, consumable VARCHAR(120) DEFAULT NULL, unit VARCHAR(30) NOT NULL, price INT NOT NULL, stock INT NOT NULL, alert_threshold INT NOT NULL, created_at INT NOT NULL, updated_at INT NOT NULL, UNIQUE INDEX UNIQ_3D82424DD17F50A6 (uuid), INDEX idx_inventory_items_owner (entity_type, entity_id), PRIMARY KEY (id)) DEFAULT CHARACTER SET utf8mb4');
$this->addSql('CREATE TABLE inventory_package_items (id INT AUTO_INCREMENT NOT NULL, amount INT NOT NULL, package_id INT NOT NULL, item_id INT NOT NULL, INDEX IDX_17172D5EF44CABFF (package_id), INDEX IDX_17172D5E126F525E (item_id), PRIMARY KEY (id)) DEFAULT CHARACTER SET utf8mb4');
$this->addSql('CREATE TABLE inventory_packages (id INT AUTO_INCREMENT NOT NULL, uuid VARCHAR(36) NOT NULL, entity_type VARCHAR(20) NOT NULL, entity_id INT NOT NULL, title VARCHAR(120) NOT NULL, created_at INT NOT NULL, updated_at INT NOT NULL, UNIQUE INDEX UNIQ_2FFF7A36D17F50A6 (uuid), INDEX idx_inventory_packages_owner (entity_type, entity_id), PRIMARY KEY (id)) DEFAULT CHARACTER SET utf8mb4');
$this->addSql('ALTER TABLE inventory_package_items ADD CONSTRAINT FK_17172D5EF44CABFF FOREIGN KEY (package_id) REFERENCES inventory_packages (id) ON DELETE CASCADE');
$this->addSql('ALTER TABLE inventory_package_items ADD CONSTRAINT FK_17172D5E126F525E FOREIGN KEY (item_id) REFERENCES inventory_items (id) ON DELETE CASCADE');
}
public function down(Schema $schema): void
{
// this down() migration is auto-generated, please modify it to your needs
$this->addSql('ALTER TABLE inventory_package_items DROP FOREIGN KEY FK_17172D5EF44CABFF');
$this->addSql('ALTER TABLE inventory_package_items DROP FOREIGN KEY FK_17172D5E126F525E');
$this->addSql('DROP TABLE inventory_items');
$this->addSql('DROP TABLE inventory_package_items');
$this->addSql('DROP TABLE inventory_packages');
}
}
@@ -0,0 +1,274 @@
<?php
namespace App\Inventory\Controller;
use App\Auth\Entity\User;
use App\Auth\Repository\UserActiveContextRepository;
use App\Clinic\Repository\ClinicRepository;
use App\Doctor\Repository\DoctorRepository;
use App\Inventory\Entity\InventoryItem;
use App\Inventory\Entity\InventoryPackage;
use App\Inventory\Repository\InventoryItemRepository;
use App\Inventory\Repository\InventoryPackageRepository;
use App\Inventory\Service\InventoryService;
use App\Shared\Constant\ErrorCodes;
use App\Shared\Controller\BaseController;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\CurrentUser;
use Symfony\Component\Security\Http\Attribute\IsGranted;
use OpenApi\Attributes as OA;
/**
* Per-tenant (doctor/clinic) inventory: consumable items and their packages.
* Every row is scoped to the caller's resolved entity; a tenant can only see and
* mutate its own inventory. Scoping mirrors {@see \App\Tag\Controller\TenantTagController}.
*/
#[OA\Tag(name: 'Inventory')]
#[IsGranted('IS_AUTHENTICATED_FULLY')]
class InventoryController extends BaseController
{
public function __construct(
private readonly InventoryItemRepository $itemRepo,
private readonly InventoryPackageRepository $packageRepo,
private readonly InventoryService $service,
private readonly DoctorRepository $doctorRepo,
private readonly ClinicRepository $clinicRepo,
private readonly UserActiveContextRepository $contextRepo,
) {}
// ── Items ────────────────────────────────────────────────────────────────
#[Route('/api/v1/inventory-items', methods: ['GET'])]
public function listItems(#[CurrentUser] User $user): JsonResponse
{
[$type, $id] = $this->resolveEntity($user);
if ($id === null) {
return $this->error(ErrorCodes::ERR_FORBIDDEN_001, 'پروفایل یافت نشد', 403);
}
$items = $this->itemRepo->findByEntity($type, $id);
return $this->success([
'items' => array_map(fn(InventoryItem $i) => $i->toArray(), $items),
'stats' => $this->service->stats($items),
]);
}
#[Route('/api/v1/inventory-categories', methods: ['GET'])]
public function listCategories(#[CurrentUser] User $user): JsonResponse
{
[$type, $id] = $this->resolveEntity($user);
if ($id === null) {
return $this->error(ErrorCodes::ERR_FORBIDDEN_001, 'پروفایل یافت نشد', 403);
}
return $this->success($this->itemRepo->findConsumables($type, $id));
}
#[Route('/api/v1/inventory-item', methods: ['POST'])]
public function createItem(Request $request, #[CurrentUser] User $user): JsonResponse
{
[$type, $id] = $this->resolveEntity($user);
if ($id === null) {
return $this->error(ErrorCodes::ERR_FORBIDDEN_001, 'پروفایل یافت نشد', 403);
}
$data = json_decode($request->getContent(), true) ?? [];
$name = trim($data['name'] ?? '');
if ($name === '') {
return $this->error(ErrorCodes::ERR_VALIDATION_001, 'نام کالا الزامی است', 422, 'name');
}
$item = new InventoryItem($type, $id, $name);
$this->applyItemFields($item, $data);
$this->itemRepo->save($item);
return $this->success($item->toArray(), 201);
}
#[Route('/api/v1/inventory-item/{uuid}', methods: ['PATCH'])]
public function updateItem(string $uuid, Request $request, #[CurrentUser] User $user): JsonResponse
{
$item = $this->ownedItem($uuid, $user);
if ($item === null) {
return $this->error(ErrorCodes::ERR_NOT_FOUND_001, 'کالا یافت نشد', 404);
}
$data = json_decode($request->getContent(), true) ?? [];
if (array_key_exists('name', $data)) {
$name = trim($data['name']);
if ($name === '') {
return $this->error(ErrorCodes::ERR_VALIDATION_001, 'نام کالا الزامی است', 422, 'name');
}
$item->setName($name);
}
$this->applyItemFields($item, $data);
$this->itemRepo->save($item);
return $this->success($item->toArray());
}
#[Route('/api/v1/inventory-item/{uuid}', methods: ['DELETE'])]
public function deleteItem(string $uuid, #[CurrentUser] User $user): JsonResponse
{
$item = $this->ownedItem($uuid, $user);
if ($item === null) {
return $this->error(ErrorCodes::ERR_NOT_FOUND_001, 'کالا یافت نشد', 404);
}
$this->itemRepo->remove($item);
return $this->success(['message' => 'کالا حذف شد']);
}
// ── Packages ─────────────────────────────────────────────────────────────
#[Route('/api/v1/inventory-packages', methods: ['GET'])]
public function listPackages(#[CurrentUser] User $user): JsonResponse
{
[$type, $id] = $this->resolveEntity($user);
if ($id === null) {
return $this->error(ErrorCodes::ERR_FORBIDDEN_001, 'پروفایل یافت نشد', 403);
}
return $this->success(array_map(
fn(InventoryPackage $p) => $this->service->packageToArray($p),
$this->packageRepo->findByEntity($type, $id)
));
}
#[Route('/api/v1/inventory-package', methods: ['POST'])]
public function createPackage(Request $request, #[CurrentUser] User $user): JsonResponse
{
[$type, $id] = $this->resolveEntity($user);
if ($id === null) {
return $this->error(ErrorCodes::ERR_FORBIDDEN_001, 'پروفایل یافت نشد', 403);
}
$data = json_decode($request->getContent(), true) ?? [];
$title = trim($data['title'] ?? '');
if ($title === '') {
return $this->error(ErrorCodes::ERR_VALIDATION_001, 'نام پکیج الزامی است', 422, 'title');
}
$package = new InventoryPackage($type, $id, $title);
$this->service->syncPackageItems($package, $data['items'] ?? [], $type, $id);
$this->packageRepo->save($package);
return $this->success($this->service->packageToArray($package), 201);
}
#[Route('/api/v1/inventory-package/{uuid}', methods: ['PATCH'])]
public function updatePackage(string $uuid, Request $request, #[CurrentUser] User $user): JsonResponse
{
$package = $this->ownedPackage($uuid, $user);
if ($package === null) {
return $this->error(ErrorCodes::ERR_NOT_FOUND_001, 'پکیج یافت نشد', 404);
}
[$type, $id] = $this->resolveEntity($user);
$data = json_decode($request->getContent(), true) ?? [];
if (array_key_exists('title', $data)) {
$title = trim($data['title']);
if ($title === '') {
return $this->error(ErrorCodes::ERR_VALIDATION_001, 'نام پکیج الزامی است', 422, 'title');
}
$package->setTitle($title);
}
if (array_key_exists('items', $data)) {
$this->service->syncPackageItems($package, $data['items'], $type, (int) $id);
}
$this->packageRepo->save($package);
return $this->success($this->service->packageToArray($package));
}
#[Route('/api/v1/inventory-package/{uuid}', methods: ['DELETE'])]
public function deletePackage(string $uuid, #[CurrentUser] User $user): JsonResponse
{
$package = $this->ownedPackage($uuid, $user);
if ($package === null) {
return $this->error(ErrorCodes::ERR_NOT_FOUND_001, 'پکیج یافت نشد', 404);
}
$this->packageRepo->remove($package);
return $this->success(['message' => 'پکیج حذف شد']);
}
// ── Helpers ──────────────────────────────────────────────────────────────
/** Apply optional mutable item fields present in the payload. */
private function applyItemFields(InventoryItem $item, array $data): void
{
if (array_key_exists('consumable', $data)) {
$c = trim((string) $data['consumable']);
$item->setConsumable($c === '' ? null : $c);
}
if (array_key_exists('unit', $data)) {
$unit = trim((string) $data['unit']);
$item->setUnit($unit === '' ? 'عدد' : $unit);
}
if (array_key_exists('price', $data)) {
$item->setPrice((int) $data['price']);
}
if (array_key_exists('stock', $data)) {
$item->setStock((int) $data['stock']);
}
if (array_key_exists('alertThreshold', $data)) {
$item->setAlertThreshold((int) $data['alertThreshold']);
}
}
/** The item only if it belongs to the caller's entity, else null. */
private function ownedItem(string $uuid, User $user): ?InventoryItem
{
[$type, $id] = $this->resolveEntity($user);
$item = $this->itemRepo->findByUuid($uuid);
if ($item === null || $id === null || $item->getEntityType() !== $type || $item->getEntityId() !== $id) {
return null;
}
return $item;
}
/** The package only if it belongs to the caller's entity, else null. */
private function ownedPackage(string $uuid, User $user): ?InventoryPackage
{
[$type, $id] = $this->resolveEntity($user);
$package = $this->packageRepo->findByUuid($uuid);
if ($package === null || $id === null || $package->getEntityType() !== $type || $package->getEntityId() !== $id) {
return null;
}
return $package;
}
/** @return array{0: string, 1: int|null} [entityType, entityId] */
private function resolveEntity(User $user): array
{
if ($user->hasRole('ROLE_DOCTOR')) {
$doctor = $this->doctorRepo->findByUser($user);
return ['doctor', $doctor?->getId()];
}
if ($user->hasRole('ROLE_CLINIC')) {
$clinic = $this->clinicRepo->findByUser($user);
return ['clinic', $clinic?->getId()];
}
if ($user->hasRole('ROLE_SECRETARY')) {
$dbUuid = $this->contextRepo->findByUser($user)?->getDbUuid();
if ($dbUuid !== null) {
$clinic = $this->clinicRepo->findByUuid($dbUuid);
if ($clinic !== null) {
return ['clinic', $clinic->getId()];
}
$doctor = $this->doctorRepo->findByUuid($dbUuid);
if ($doctor !== null) {
return ['doctor', $doctor->getId()];
}
}
}
return ['unknown', null];
}
}
+126
View File
@@ -0,0 +1,126 @@
<?php
namespace App\Inventory\Entity;
use App\Inventory\Repository\InventoryItemRepository;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Uid\Uuid;
/**
* A consumable stock item owned by a tenant (doctor/clinic). Scoped through the
* polymorphic entity_type/entity_id pair, mirroring {@see \App\Tag\Entity\TenantTag}.
*
* Availability status is derived, never stored: an item with zero stock is
* "out_of_stock", one at or below its alert threshold is "low_stock", otherwise
* "in_stock".
*/
#[ORM\Entity(repositoryClass: InventoryItemRepository::class)]
#[ORM\Table(name: 'inventory_items')]
#[ORM\Index(columns: ['entity_type', 'entity_id'], name: 'idx_inventory_items_owner')]
class InventoryItem
{
public const STATUS_IN_STOCK = 'in_stock';
public const STATUS_LOW_STOCK = 'low_stock';
public const STATUS_OUT_OF_STOCK = 'out_of_stock';
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column(type: 'integer')]
private ?int $id = null;
#[ORM\Column(type: 'string', length: 36, unique: true)]
private string $uuid;
#[ORM\Column(name: 'entity_type', type: 'string', length: 20)]
private string $entityType;
#[ORM\Column(name: 'entity_id', type: 'integer')]
private int $entityId;
#[ORM\Column(type: 'string', length: 120)]
private string $name;
/** Free-text "مصرفی" classifier from the source modal; doubles as filter group. */
#[ORM\Column(type: 'string', length: 120, nullable: true)]
private ?string $consumable = null;
#[ORM\Column(type: 'string', length: 30)]
private string $unit = 'عدد';
/** Unit price in Rial (integer), consistent with the rest of ClinicPro. */
#[ORM\Column(type: 'integer')]
private int $price = 0;
#[ORM\Column(type: 'integer')]
private int $stock = 0;
/** At or below this stock level the item is flagged "low". */
#[ORM\Column(name: 'alert_threshold', type: 'integer')]
private int $alertThreshold = 0;
#[ORM\Column(name: 'created_at', type: 'integer')]
private int $createdAt;
#[ORM\Column(name: 'updated_at', type: 'integer')]
private int $updatedAt;
public function __construct(string $entityType, int $entityId, string $name)
{
$this->uuid = Uuid::v4()->toRfc4122();
$this->entityType = $entityType;
$this->entityId = $entityId;
$this->name = $name;
$this->createdAt = time();
$this->updatedAt = time();
}
public function getId(): ?int { return $this->id; }
public function getUuid(): string { return $this->uuid; }
public function getEntityType(): string { return $this->entityType; }
public function getEntityId(): int { return $this->entityId; }
public function getName(): string { return $this->name; }
public function getConsumable(): ?string { return $this->consumable; }
public function getUnit(): string { return $this->unit; }
public function getPrice(): int { return $this->price; }
public function getStock(): int { return $this->stock; }
public function getAlertThreshold(): int { return $this->alertThreshold; }
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 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(); }
public function setAlertThreshold(int $v): self { $this->alertThreshold = max(0, $v); return $this->touch(); }
/** Derived availability — see class docblock. */
public function getStatus(): string
{
if ($this->stock <= 0) {
return self::STATUS_OUT_OF_STOCK;
}
if ($this->stock <= $this->alertThreshold) {
return self::STATUS_LOW_STOCK;
}
return self::STATUS_IN_STOCK;
}
public function toArray(): array
{
return [
'uuid' => $this->uuid,
'name' => $this->name,
'consumable' => $this->consumable,
'unit' => $this->unit,
'price' => $this->price,
'stock' => $this->stock,
'alertThreshold' => $this->alertThreshold,
'status' => $this->getStatus(),
];
}
private function touch(): self
{
$this->updatedAt = time();
return $this;
}
}
+87
View File
@@ -0,0 +1,87 @@
<?php
namespace App\Inventory\Entity;
use App\Inventory\Repository\InventoryPackageRepository;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Uid\Uuid;
/**
* A named bundle of consumable items owned by a tenant (doctor/clinic). The
* package price and its availability are derived from its component items at
* read time — never stored — so they always reflect current item prices/stock.
*/
#[ORM\Entity(repositoryClass: InventoryPackageRepository::class)]
#[ORM\Table(name: 'inventory_packages')]
#[ORM\Index(columns: ['entity_type', 'entity_id'], name: 'idx_inventory_packages_owner')]
class InventoryPackage
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column(type: 'integer')]
private ?int $id = null;
#[ORM\Column(type: 'string', length: 36, unique: true)]
private string $uuid;
#[ORM\Column(name: 'entity_type', type: 'string', length: 20)]
private string $entityType;
#[ORM\Column(name: 'entity_id', type: 'integer')]
private int $entityId;
#[ORM\Column(type: 'string', length: 120)]
private string $title;
/** @var Collection<int, InventoryPackageItem> */
#[ORM\OneToMany(mappedBy: 'package', targetEntity: InventoryPackageItem::class, cascade: ['persist', 'remove'], orphanRemoval: true)]
private Collection $items;
#[ORM\Column(name: 'created_at', type: 'integer')]
private int $createdAt;
#[ORM\Column(name: 'updated_at', type: 'integer')]
private int $updatedAt;
public function __construct(string $entityType, int $entityId, string $title)
{
$this->uuid = Uuid::v4()->toRfc4122();
$this->entityType = $entityType;
$this->entityId = $entityId;
$this->title = $title;
$this->items = new ArrayCollection();
$this->createdAt = time();
$this->updatedAt = time();
}
public function getId(): ?int { return $this->id; }
public function getUuid(): string { return $this->uuid; }
public function getEntityType(): string { return $this->entityType; }
public function getEntityId(): int { return $this->entityId; }
public function getTitle(): string { return $this->title; }
public function setTitle(string $v): self { $this->title = $v; $this->updatedAt = time(); return $this; }
/** @return Collection<int, InventoryPackageItem> */
public function getItems(): Collection { return $this->items; }
public function addItem(InventoryPackageItem $item): self
{
if (!$this->items->contains($item)) {
$this->items->add($item);
$item->setPackage($this);
}
$this->updatedAt = time();
return $this;
}
/** Drop every component item (used before re-populating on update). */
public function clearItems(): self
{
$this->items->clear();
$this->updatedAt = time();
return $this;
}
}
@@ -0,0 +1,44 @@
<?php
namespace App\Inventory\Entity;
use Doctrine\ORM\Mapping as ORM;
/**
* A line in an {@see InventoryPackage}: a reference to an {@see InventoryItem}
* plus the quantity of it the package contains.
*/
#[ORM\Entity]
#[ORM\Table(name: 'inventory_package_items')]
class InventoryPackageItem
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column(type: 'integer')]
private ?int $id = null;
#[ORM\ManyToOne(targetEntity: InventoryPackage::class, inversedBy: 'items')]
#[ORM\JoinColumn(name: 'package_id', nullable: false, onDelete: 'CASCADE')]
private InventoryPackage $package;
#[ORM\ManyToOne(targetEntity: InventoryItem::class)]
#[ORM\JoinColumn(name: 'item_id', nullable: false, onDelete: 'CASCADE')]
private InventoryItem $item;
#[ORM\Column(type: 'integer')]
private int $amount = 1;
public function __construct(InventoryItem $item, int $amount)
{
$this->item = $item;
$this->amount = max(1, $amount);
}
public function getId(): ?int { return $this->id; }
public function getPackage(): InventoryPackage { return $this->package; }
public function getItem(): InventoryItem { return $this->item; }
public function getAmount(): int { return $this->amount; }
public function setPackage(InventoryPackage $p): self { $this->package = $p; return $this; }
public function setAmount(int $v): self { $this->amount = max(1, $v); return $this; }
}
@@ -0,0 +1,65 @@
<?php
namespace App\Inventory\Repository;
use App\Inventory\Entity\InventoryItem;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
class InventoryItemRepository extends ServiceEntityRepository
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, InventoryItem::class);
}
public function findByUuid(string $uuid): ?InventoryItem
{
return $this->findOneBy(['uuid' => $uuid]);
}
/** @return InventoryItem[] */
public function findByEntity(string $entityType, int $entityId): array
{
return $this->createQueryBuilder('i')
->where('i.entityType = :type AND i.entityId = :id')
->setParameter('type', $entityType)
->setParameter('id', $entityId)
->orderBy('i.name', 'ASC')
->getQuery()
->getResult();
}
/**
* Distinct non-empty "consumable" values for the tenant — powers the
* category filter dropdown on the inventory page.
*
* @return string[]
*/
public function findConsumables(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')
->setParameter('type', $entityType)
->setParameter('id', $entityId)
->setParameter('empty', '')
->orderBy('i.consumable', 'ASC')
->getQuery()
->getArrayResult();
return array_map(static fn(array $r): string => $r['consumable'], $rows);
}
public function save(InventoryItem $item): void
{
$this->getEntityManager()->persist($item);
$this->getEntityManager()->flush();
}
public function remove(InventoryItem $item): void
{
$this->getEntityManager()->remove($item);
$this->getEntityManager()->flush();
}
}
@@ -0,0 +1,46 @@
<?php
namespace App\Inventory\Repository;
use App\Inventory\Entity\InventoryPackage;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
class InventoryPackageRepository extends ServiceEntityRepository
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, InventoryPackage::class);
}
public function findByUuid(string $uuid): ?InventoryPackage
{
return $this->findOneBy(['uuid' => $uuid]);
}
/** @return InventoryPackage[] */
public function findByEntity(string $entityType, int $entityId): array
{
return $this->createQueryBuilder('p')
->leftJoin('p.items', 'pi')->addSelect('pi')
->leftJoin('pi.item', 'it')->addSelect('it')
->where('p.entityType = :type AND p.entityId = :id')
->setParameter('type', $entityType)
->setParameter('id', $entityId)
->orderBy('p.createdAt', 'DESC')
->getQuery()
->getResult();
}
public function save(InventoryPackage $package): void
{
$this->getEntityManager()->persist($package);
$this->getEntityManager()->flush();
}
public function remove(InventoryPackage $package): void
{
$this->getEntityManager()->remove($package);
$this->getEntityManager()->flush();
}
}
+116
View File
@@ -0,0 +1,116 @@
<?php
namespace App\Inventory\Service;
use App\Inventory\Entity\InventoryItem;
use App\Inventory\Entity\InventoryPackage;
use App\Inventory\Entity\InventoryPackageItem;
use App\Inventory\Repository\InventoryItemRepository;
use App\Inventory\Repository\InventoryPackageRepository;
/**
* Inventory domain logic: derived aggregates (stat counters, package totals and
* availability) and package assembly from item references. Controllers stay thin
* and delegate every non-HTTP decision here.
*/
class InventoryService
{
public function __construct(
private readonly InventoryItemRepository $itemRepo,
private readonly InventoryPackageRepository $packageRepo,
) {}
/**
* The four headline counters shown as stat cards, derived from item statuses.
*
* @param InventoryItem[] $items
* @return array{total:int, low:int, inStock:int, outOfStock:int}
*/
public function stats(array $items): array
{
$low = $inStock = $out = 0;
foreach ($items as $item) {
match ($item->getStatus()) {
InventoryItem::STATUS_LOW_STOCK => $low++,
InventoryItem::STATUS_IN_STOCK => $inStock++,
InventoryItem::STATUS_OUT_OF_STOCK => $out++,
default => null,
};
}
return [
'total' => count($items),
'low' => $low,
'inStock' => $inStock,
'outOfStock' => $out,
];
}
/**
* Serialize a package with its component items, derived total price (Rial)
* and availability (true only if every component has enough stock).
*/
public function packageToArray(InventoryPackage $package): array
{
$items = [];
$total = 0;
$available = true;
foreach ($package->getItems() as $line) {
/** @var InventoryPackageItem $line */
$item = $line->getItem();
$amount = $line->getAmount();
$total += $item->getPrice() * $amount;
if ($item->getStock() < $amount) {
$available = false;
}
$items[] = [
'itemUuid' => $item->getUuid(),
'name' => $item->getName(),
'unit' => $item->getUnit(),
'price' => $item->getPrice(),
'amount' => $amount,
];
}
return [
'uuid' => $package->getUuid(),
'title' => $package->getTitle(),
'items' => $items,
'total' => $total,
'available' => $available,
];
}
/**
* Replace a package's component lines from a list of {itemUuid, amount}.
* Silently skips references the tenant does not own. Returns the number of
* lines actually attached.
*
* @param array<int, array{itemUuid?:string, amount?:int|string}> $lines
*/
public function syncPackageItems(InventoryPackage $package, array $lines, string $entityType, int $entityId): int
{
$package->clearItems();
$count = 0;
foreach ($lines as $line) {
$uuid = trim((string) ($line['itemUuid'] ?? ''));
if ($uuid === '') {
continue;
}
$item = $this->itemRepo->findByUuid($uuid);
// Only attach items the caller owns — never leak another tenant's stock.
if ($item === null || $item->getEntityType() !== $entityType || $item->getEntityId() !== $entityId) {
continue;
}
$amount = (int) ($line['amount'] ?? 1);
$package->addItem(new InventoryPackageItem($item, $amount));
$count++;
}
return $count;
}
}
+196
View File
@@ -0,0 +1,196 @@
<?php
namespace App\Tests\Inventory;
use App\Doctor\Entity\Doctor;
use App\Tests\ApiTestCase;
/**
* Per-tenant inventory CRUD (items + packages), scoped to the caller's doctor
* entity. Covers success, derived aggregates, validation, empty state and
* cross-tenant isolation.
*/
class InventoryApiTest extends ApiTestCase
{
private function doctorUser(): array
{
$user = $this->createUser(['ROLE_DOCTOR']);
$doctor = new Doctor($user, 'دکتر');
$this->em->persist($doctor);
$this->em->flush();
return [$user, $doctor];
}
// ── Items ────────────────────────────────────────────────────────────────
public function testItemCreateListUpdateDelete(): void
{
[$user] = $this->doctorUser();
$created = $this->authJson('POST', '/api/v1/inventory-item', $user, [
'name' => 'دستکش', 'unit' => 'عدد', 'price' => 250000, 'stock' => 150, 'alertThreshold' => 20,
]);
self::assertSame(201, $this->responseCode());
self::assertSame('دستکش', $created['data']['name']);
self::assertSame('in_stock', $created['data']['status']);
$uuid = $created['data']['uuid'];
$list = $this->authJson('GET', '/api/v1/inventory-items', $user);
self::assertSame(200, $this->responseCode());
self::assertCount(1, $list['data']['items']);
self::assertSame(1, $list['data']['stats']['total']);
self::assertSame(1, $list['data']['stats']['inStock']);
// drop stock below threshold → low_stock
$this->authJson('PATCH', '/api/v1/inventory-item/' . $uuid, $user, ['stock' => 5]);
self::assertSame(200, $this->responseCode());
$afterPatch = $this->authJson('GET', '/api/v1/inventory-items', $user);
self::assertSame('low_stock', $afterPatch['data']['items'][0]['status']);
self::assertSame(1, $afterPatch['data']['stats']['low']);
$this->authJson('DELETE', '/api/v1/inventory-item/' . $uuid, $user);
self::assertSame(200, $this->responseCode());
$after = $this->authJson('GET', '/api/v1/inventory-items', $user);
self::assertCount(0, $after['data']['items']);
}
public function testStatusDerivation(): void
{
[$user] = $this->doctorUser();
// zero stock → out_of_stock, regardless of threshold
$out = $this->authJson('POST', '/api/v1/inventory-item', $user, ['name' => 'تمام‌شده', 'stock' => 0, 'alertThreshold' => 5]);
self::assertSame('out_of_stock', $out['data']['status']);
// stock above threshold → in_stock
$in = $this->authJson('POST', '/api/v1/inventory-item', $user, ['name' => 'موجود', 'stock' => 100, 'alertThreshold' => 10]);
self::assertSame('in_stock', $in['data']['status']);
}
public function testItemRejectsEmptyName(): void
{
[$user] = $this->doctorUser();
$this->authJson('POST', '/api/v1/inventory-item', $user, ['name' => '']);
self::assertSame(422, $this->responseCode());
}
public function testEmptyStateReturnsZeroStats(): void
{
[$user] = $this->doctorUser();
$list = $this->authJson('GET', '/api/v1/inventory-items', $user);
self::assertSame(200, $this->responseCode());
self::assertCount(0, $list['data']['items']);
self::assertSame(
['total' => 0, 'low' => 0, 'inStock' => 0, 'outOfStock' => 0],
$list['data']['stats']
);
}
public function testCategoriesReturnsDistinctConsumables(): 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' => 'دندان']);
$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']);
}
public function testCannotTouchAnotherTenantsItem(): void
{
[$ownerA] = $this->doctorUser();
$created = $this->authJson('POST', '/api/v1/inventory-item', $ownerA, ['name' => 'مال A']);
$uuid = $created['data']['uuid'];
[$ownerB] = $this->doctorUser();
$this->authJson('PATCH', '/api/v1/inventory-item/' . $uuid, $ownerB, ['name' => 'دزدی']);
self::assertSame(404, $this->responseCode());
$this->authJson('DELETE', '/api/v1/inventory-item/' . $uuid, $ownerB);
self::assertSame(404, $this->responseCode());
}
// ── Packages ─────────────────────────────────────────────────────────────
public function testPackageCreateComputesTotalAndAvailability(): void
{
[$user] = $this->doctorUser();
$gel = $this->authJson('POST', '/api/v1/inventory-item', $user, ['name' => 'ژل', 'price' => 1200000, 'stock' => 10])['data']['uuid'];
$glove = $this->authJson('POST', '/api/v1/inventory-item', $user, ['name' => 'دستکش', 'price' => 500000, 'stock' => 1])['data']['uuid'];
// 2×ژل (stock 10, ok) + 3×دستکش (stock 1, NOT enough) → unavailable
$pkg = $this->authJson('POST', '/api/v1/inventory-package', $user, [
'title' => 'پکیج یک',
'items' => [
['itemUuid' => $gel, 'amount' => 2],
['itemUuid' => $glove, 'amount' => 3],
],
]);
self::assertSame(201, $this->responseCode());
self::assertSame(2 * 1200000 + 3 * 500000, $pkg['data']['total']);
self::assertFalse($pkg['data']['available']);
self::assertCount(2, $pkg['data']['items']);
// list reflects the same package
$list = $this->authJson('GET', '/api/v1/inventory-packages', $user);
self::assertCount(1, $list['data']);
self::assertSame('پکیج یک', $list['data'][0]['title']);
}
public function testPackageUpdateReplacesItemsAndDelete(): void
{
[$user] = $this->doctorUser();
$a = $this->authJson('POST', '/api/v1/inventory-item', $user, ['name' => 'A', 'price' => 1000, 'stock' => 50])['data']['uuid'];
$uuid = $this->authJson('POST', '/api/v1/inventory-package', $user, [
'title' => 'p', 'items' => [['itemUuid' => $a, 'amount' => 1]],
])['data']['uuid'];
$updated = $this->authJson('PATCH', '/api/v1/inventory-package/' . $uuid, $user, [
'title' => 'p2', 'items' => [['itemUuid' => $a, 'amount' => 5]],
]);
self::assertSame(200, $this->responseCode());
self::assertSame('p2', $updated['data']['title']);
self::assertCount(1, $updated['data']['items']);
self::assertSame(5, $updated['data']['items'][0]['amount']);
self::assertSame(5000, $updated['data']['total']);
$this->authJson('DELETE', '/api/v1/inventory-package/' . $uuid, $user);
self::assertSame(200, $this->responseCode());
$after = $this->authJson('GET', '/api/v1/inventory-packages', $user);
self::assertCount(0, $after['data']);
}
public function testPackageSkipsForeignItemReferences(): void
{
[$ownerA] = $this->doctorUser();
$foreign = $this->authJson('POST', '/api/v1/inventory-item', $ownerA, ['name' => 'خارجی', 'price' => 999, 'stock' => 5])['data']['uuid'];
[$ownerB] = $this->doctorUser();
$mine = $this->authJson('POST', '/api/v1/inventory-item', $ownerB, ['name' => 'مال من', 'price' => 100, 'stock' => 5])['data']['uuid'];
// package for B referencing A's item → foreign line dropped, only B's stays
$pkg = $this->authJson('POST', '/api/v1/inventory-package', $ownerB, [
'title' => 'mix',
'items' => [
['itemUuid' => $foreign, 'amount' => 1],
['itemUuid' => $mine, 'amount' => 2],
],
]);
self::assertSame(201, $this->responseCode());
self::assertCount(1, $pkg['data']['items']);
self::assertSame($mine, $pkg['data']['items'][0]['itemUuid']);
self::assertSame(200, $pkg['data']['total']);
}
public function testPackageRejectsEmptyTitle(): void
{
[$user] = $this->doctorUser();
$this->authJson('POST', '/api/v1/inventory-package', $user, ['title' => '']);
self::assertSame(422, $this->responseCode());
}
}