From c4a661b542c4a422540160acc435650a5619f093 Mon Sep 17 00:00:00 2001 From: hamed <15238-genius.ha@users.noreply.drupalcode.org> Date: Sat, 18 Jul 2026 12:26:15 +0330 Subject: [PATCH] feat: add optional inventory package association to service items and implement audit logging - Added `inventory_package_uuid` and `inventory_package_title` fields to the `ServiceItem` interface. - Updated API documentation to reflect new fields in service item responses. - Implemented methods in `ClinicServiceController` to handle inventory package associations. - Created `ServiceItemAuditLog` entity and repository for tracking changes to service items. - Added functionality to log changes to service items, including inventory package associations. - Implemented tests for attaching/detaching inventory packages and auditing changes. - Created database migrations for new fields and audit log table. --- .../admin/components/ServiceItemFormModal.tsx | 44 +++- assets/admin/pages/ServiceDetailPage.test.tsx | 39 ++++ assets/admin/pages/ServiceDetailPage.tsx | 161 +++++++++++++- assets/admin/types/index.ts | 3 + docs/api/clinic-services.md | 42 +++- migrations/Version20260718084301.php | 32 +++ migrations/Version20260718084524.php | 31 +++ .../Controller/ClinicServiceController.php | 107 +++++++-- src/ClinicService/Entity/ServiceItem.php | 11 + .../Entity/ServiceItemAuditLog.php | 81 +++++++ .../ServiceItemAuditLogRepository.php | 42 ++++ .../Service/ServiceItemAuditService.php | 89 ++++++++ .../Repository/InventoryPackageRepository.php | 22 ++ .../ServiceItemPackageAndAuditTest.php | 205 ++++++++++++++++++ 14 files changed, 885 insertions(+), 24 deletions(-) create mode 100644 migrations/Version20260718084301.php create mode 100644 migrations/Version20260718084524.php create mode 100644 src/ClinicService/Entity/ServiceItemAuditLog.php create mode 100644 src/ClinicService/Repository/ServiceItemAuditLogRepository.php create mode 100644 src/ClinicService/Service/ServiceItemAuditService.php create mode 100644 tests/ClinicService/ServiceItemPackageAndAuditTest.php diff --git a/assets/admin/components/ServiceItemFormModal.tsx b/assets/admin/components/ServiceItemFormModal.tsx index 964e21f0..d2886357 100644 --- a/assets/admin/components/ServiceItemFormModal.tsx +++ b/assets/admin/components/ServiceItemFormModal.tsx @@ -8,6 +8,7 @@ import { toast } from 'sonner'; import { api } from '../lib/api'; import type { ApiResponse } from '../lib/api'; import type { ServiceItem, ClinicStaff } from '../types'; +import type { InventoryPackage } from '../hooks/useInventory'; import { rialToToman, tomanToRial } from '../lib/utils'; import { numericField } from '../lib/forms'; import Modal from './ui/Modal'; @@ -20,10 +21,15 @@ const itemSchema = z.object({ staff_uuids: z.array(z.string()).optional(), duration_minutes: z.coerce.number().min(0).optional(), bookable: z.boolean().optional(), + /** پکیج کالای مصرفی؛ رشته‌ی خالی یعنی بدون پکیج. */ + inventory_package_uuid: z.string().optional(), }); type ItemForm = z.infer; -const EMPTY_FORM: ItemForm = { name: '', price_rials: 0, staff_uuids: [], duration_minutes: undefined, bookable: false }; +const EMPTY_FORM: ItemForm = { + name: '', price_rials: 0, staff_uuids: [], duration_minutes: undefined, + bookable: false, inventory_package_uuid: '', +}; interface Props { /** `'create'` برای سرویس جدید، شیء سرویس برای ویرایش، `null` یعنی بسته. */ @@ -52,6 +58,13 @@ export default function ServiceItemFormModal({ item, sectionUuid, onClose, onMan }); const allStaff = staffData?.data ?? []; + const { data: packagesData } = useQuery>({ + queryKey: ['inventory-packages'], + queryFn: () => api.get('/api/v1/inventory-packages'), + enabled: item !== null, + }); + const packages = packagesData?.data ?? []; + const form = useForm({ resolver: zodResolver(itemSchema), defaultValues: EMPTY_FORM }); useEffect(() => { @@ -63,6 +76,7 @@ export default function ServiceItemFormModal({ item, sectionUuid, onClose, onMan staff_uuids: (editing.staff_members ?? (editing.staff ? [editing.staff] : [])).map((s) => s.uuid), duration_minutes: editing.duration_minutes ?? undefined, bookable: editing.bookable ?? false, + inventory_package_uuid: editing.inventory_package_uuid ?? '', } : EMPTY_FORM); }, [item]); @@ -73,21 +87,24 @@ export default function ServiceItemFormModal({ item, sectionUuid, onClose, onMan if (editing) qc.invalidateQueries({ queryKey: ['service-item', editing.uuid] }); }; + // رشته‌ی خالی در payload یعنی «بدون پکیج»؛ بک‌اند null می‌خواهد. + const toPayload = (body: ItemForm) => ({ + ...body, + price_rials: tomanToRial(body.price_rials), + inventory_package_uuid: body.inventory_package_uuid || null, + }); + const createItem = useMutation({ mutationFn: (body: ItemForm) => api.post('/api/v1/service-item', { - ...body, + ...toPayload(body), section_uuid: sectionUuid, - price_rials: tomanToRial(body.price_rials), }), onSuccess: () => { invalidate(); onClose(); toast.success('سرویس ایجاد شد'); }, onError: (e: Error) => toast.error(e.message), }); const editItem = useMutation({ - mutationFn: (body: ItemForm) => api.patch(`/api/v1/service-item/${editing!.uuid}`, { - ...body, - price_rials: tomanToRial(body.price_rials), - }), + mutationFn: (body: ItemForm) => api.patch(`/api/v1/service-item/${editing!.uuid}`, toPayload(body)), onSuccess: () => { invalidate(); onClose(); toast.success('سرویس ویرایش شد'); }, onError: (e: Error) => toast.error(e.message), }); @@ -204,6 +221,19 @@ export default function ServiceItemFormModal({ item, sectionUuid, onClose, onMan نمایش در نوبت‌دهی + +
+ + ({ value: p.uuid, label: p.title }))} + value={form.watch('inventory_package_uuid') || null} + onChange={(v) => form.setValue('inventory_package_uuid', v == null ? '' : String(v))} + placeholder="بدون پکیج (اختیاری)" + noOptionsMessage="پکیجی تعریف نشده است" + isClearable + height={42} + /> +
{/* بیمه — تنظیمات فقط در «پوشش بیمه» مدیریت می‌شود تا داده‌ی تکراری ساخته نشود. */} diff --git a/assets/admin/pages/ServiceDetailPage.test.tsx b/assets/admin/pages/ServiceDetailPage.test.tsx index e29b872c..6a5d7c47 100644 --- a/assets/admin/pages/ServiceDetailPage.test.tsx +++ b/assets/admin/pages/ServiceDetailPage.test.tsx @@ -38,6 +38,10 @@ const mockApi = (item: unknown = ITEM, notFound = false) => { effective_plan: { name: 'professional', level: 2, max_secretaries: 10, features: { services: true } }, } }); if (url.includes('/payment/config')) return Promise.resolve({ success: true, data: { test_mode: true, gateways: [] } }); + if (url.includes('/audit-logs')) return Promise.resolve({ success: true, data: [ + { uuid: 'l1', field: 'price_rials', operation: 'update', old_value: '7000000', new_value: '8500000', actor_name: 'دکتر تست', created_at: 1_800_000_000 }, + { uuid: 'l2', field: 'name', operation: 'create', old_value: null, new_value: 'سرم ۵۰۰cc', actor_name: 'دکتر تست', created_at: 1_700_000_000 }, + ] }); if (url.includes('/service-item/')) { return notFound ? Promise.reject(new Error('یافت نشد')) : Promise.resolve({ success: true, data: item }); } @@ -51,6 +55,12 @@ const mockApi = (item: unknown = ITEM, notFound = false) => { if (url.includes('/tenant-insurances')) return Promise.resolve({ data: { data: [ { uuid: 'ins1', insurance_name: 'بیمه ایران', insurance_kind: 'basic', coverage_percent: 70 }, ] } }); + if (url.includes('/inventory-packages')) return Promise.resolve({ success: true, data: [ + { + uuid: 'pkg1', title: 'پکیج سرم', total: 1_200_000, available: true, + items: [{ itemUuid: 'i1', name: 'سرم رینگر', unit: 'عدد', price: 600_000, amount: 2 }], + }, + ] }); return Promise.resolve({ success: true, data: [] }); }); }; @@ -125,6 +135,35 @@ describe('ServiceDetailPage (جزئیات سرویس)', () => { expect(screen.queryByText('این خدمت شامل بیمه می‌شود')).not.toBeInTheDocument(); }); + it('تب کالاهای مرتبط، اقلام پکیج متصل را نشان می‌دهد', async () => { + mockApi({ ...ITEM, inventory_package_uuid: 'pkg1', inventory_package_title: 'پکیج سرم' }); + render(); + fireEvent.click(await screen.findByText('کالاهای مرتبط')); + + expect(await screen.findByText('پکیج سرم')).toBeInTheDocument(); + expect(screen.getByText('سرم رینگر')).toBeInTheDocument(); + expect(screen.getByText('۲ عدد')).toBeInTheDocument(); + }); + + it('سرویس بدون پکیج، حالت خالی با لینک انبار می‌دهد', async () => { + render(); + fireEvent.click(await screen.findByText('کالاهای مرتبط')); + + expect(await screen.findByText('پکیج کالایی به این سرویس متصل نیست')).toBeInTheDocument(); + expect(screen.getByText('مدیریت انبار')).toBeInTheDocument(); + }); + + it('تب لاگ تغییرات، مقدار قبل و بعد را خوانا نشان می‌دهد', async () => { + render(); + fireEvent.click(await screen.findByText('لاگ تغییرات')); + + expect(await screen.findByText('قیمت پایه')).toBeInTheDocument(); + expect(screen.getByText('۷۰۰٬۰۰۰ تومان')).toBeInTheDocument(); + expect(screen.getByText('۸۵۰٬۰۰۰ تومان')).toBeInTheDocument(); + expect(screen.getByText('ایجاد')).toBeInTheDocument(); + expect(screen.getAllByText(/توسط دکتر تست/).length).toBeGreaterThan(0); + }); + it('سرویس غیرفعال نشان «غیرفعال» و دکمه فعال‌کردن دارد', async () => { mockApi({ ...ITEM, active: false }); render(); diff --git a/assets/admin/pages/ServiceDetailPage.tsx b/assets/admin/pages/ServiceDetailPage.tsx index f6c0aab0..75583337 100644 --- a/assets/admin/pages/ServiceDetailPage.tsx +++ b/assets/admin/pages/ServiceDetailPage.tsx @@ -1,5 +1,5 @@ import { useState } from 'react'; -import { useParams, useNavigate } from 'react-router-dom'; +import { useParams, useNavigate, Link } from 'react-router-dom'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { BanknotesIcon, ShieldCheckIcon, ClockIcon, UsersIcon, PencilIcon, @@ -9,6 +9,7 @@ import { toast } from 'sonner'; import { api } from '../lib/api'; import type { ApiResponse } from '../lib/api'; import type { ServiceItem } from '../types'; +import type { InventoryPackage } from '../hooks/useInventory'; import { formatRial, formatNumber, formatYear, formatDateTime } from '../lib/utils'; import PageHeader from '../components/ui/PageHeader'; import FeatureGate from '../components/ui/FeatureGate'; @@ -49,9 +50,42 @@ const TABS = [ { id: 'info', label: 'اطلاعات سرویس' }, { id: 'tariffs', label: 'تعرفه‌ها' }, { id: 'insurance', label: 'بیمه‌ها' }, + { id: 'goods', label: 'کالاهای مرتبط' }, + { id: 'history', label: 'لاگ تغییرات' }, ] as const; type TabId = typeof TABS[number]['id']; +interface AuditLog { + uuid: string; + field: string; + operation: 'create' | 'update'; + old_value: string | null; + new_value: string | null; + actor_name: string | null; + created_at: number; +} + +/** برچسب فارسی فیلدهای لاگ — باید با ServiceItemAuditService::TRACKED هم‌تراز بماند. */ +const FIELD_LABELS: Record = { + name: 'نام سرویس', + price_rials: 'قیمت پایه', + active: 'وضعیت', + duration_minutes: 'زمان متوسط', + bookable: 'نمایش در نوبت‌دهی', + insurance_covered: 'پوشش بیمه', + inventory_package: 'پکیج کالا', +}; + +/** مقدار خام لاگ را برای نمایش قابل‌فهم می‌کند (بولین، مبلغ، خالی). */ +function auditValue(field: string, raw: string | null): string { + if (raw === null || raw === '') return '—'; + if (['active', 'bookable', 'insurance_covered'].includes(field)) return raw === '1' ? 'بله' : 'خیر'; + if (field === 'price_rials') return formatRial(Number(raw)); + if (field === 'duration_minutes') return `${formatNumber(Number(raw))} دقیقه`; + if (field === 'inventory_package') return 'پکیج متصل'; + return raw; +} + const KIND = { basic: { label: 'پایه', cls: 'blue' }, supplementary: { label: 'تکمیلی', cls: 'violet' }, @@ -252,6 +286,129 @@ function ContractCoverageRow({ contract, itemUuid }: { contract: TenantInsurance ); } +function GoodsTab({ item, onEdit }: { item: ServiceItem; onEdit: () => void }) { + const { data, isLoading } = useQuery>({ + queryKey: ['inventory-packages'], + queryFn: () => api.get('/api/v1/inventory-packages'), + }); + + const linked = (data?.data ?? []).find((p) => p.uuid === item.inventory_package_uuid); + + return ( +
+
+
+ کالاهای مرتبط +
+ پکیج کالای مصرفی این خدمت؛ در «انبار» ساخته و در فرم سرویس انتخاب می‌شود. +
+
+ +
+ + {isLoading ? ( +
در حال بارگذاری...
+ ) : !item.inventory_package_uuid ? ( +
+ +

پکیج کالایی به این سرویس متصل نیست

+ مدیریت انبار +
+ ) : !linked ? ( +
+ {item.inventory_package_title ?? 'پکیج متصل'} — جزئیات در دسترس نیست +
+ ) : ( +
+
+ + + {linked.title} + {!linked.available && موجودی ناکافی} + + {formatRial(linked.total)} +
+ +
+ {linked.items.map((line) => ( +
+ {line.name} + + {formatNumber(line.amount)} {line.unit} + {formatRial(line.price * line.amount)} + +
+ ))} +
+
+ )} +
+ ); +} + +function HistoryTab({ item }: { item: ServiceItem }) { + const { data, isLoading } = useQuery>({ + queryKey: ['service-item-audit', item.uuid], + queryFn: () => api.get(`/api/v1/service-item/${item.uuid}/audit-logs`), + }); + + const logs = data?.data ?? []; + + return ( +
+
+
+ لاگ تغییرات +
+ چه کسی، چه فیلدی را، کِی و از چه مقداری به چه مقداری تغییر داد. +
+
+
+ + {isLoading ? ( +
در حال بارگذاری...
+ ) : logs.length === 0 ? ( +
+ +

تغییری ثبت نشده است

+
+ ) : ( +
+ {logs.map((log) => ( +
+
+ + {FIELD_LABELS[log.field] ?? log.field} + {log.operation === 'create' && ایجاد} + + {formatDateTime(log.created_at)} +
+ {log.operation === 'update' && ( +
+ {auditValue(log.field, log.old_value)} + + {auditValue(log.field, log.new_value)} +
+ )} + {log.actor_name && ( +
توسط {log.actor_name}
+ )} +
+ ))} +
+ )} +
+ ); +} + function ServiceDetailPageInner() { const { uuid } = useParams<{ uuid: string }>(); const navigate = useNavigate(); @@ -339,6 +496,8 @@ function ServiceDetailPageInner() { {tab === 'info' && } {tab === 'tariffs' && setTariffOpen(true)} />} {tab === 'insurance' && setInsuranceOpen(true)} />} + {tab === 'goods' && setEditOpen(true)} />} + {tab === 'history' && } `section_name` تازه به `ServiceItem::toArray()` اضافه شده و در **همه‌ی** پاسخ‌های این فایل هست، نه فقط این endpoint. +> `section_name`، `inventory_package_id`، `inventory_package_uuid` و `inventory_package_title` در **همه‌ی** +> پاسخ‌های سرویس این فایل هستند، نه فقط این endpoint. دو فیلد آخر توسط کنترلر اضافه می‌شوند (نه `toArray()`) +> و پکیج‌ها با یک کوئری batch واکشی می‌شوند تا فهرست سرویس‌ها به N+1 نیفتد. + +--- + +## GET /api/v1/service-item/{uuid}/audit-logs + +تاریخچه‌ی تغییرات یک خدمت — تازه‌ترین رویداد اول، حداکثر ۱۰۰ ردیف. + +**Permission:** `IS_AUTHENTICATED_FULLY` + پنل Basic+ · فقط سرویس‌های همان مطب/کلینیک. + +**Response 200:** +```json +{ + "success": true, + "data": [ + { + "uuid": "...", + "field": "price_rials", + "operation": "update", + "old_value": "700000", + "new_value": "850000", + "actor_name": "دکتر رضایی", + "created_at": 1718000000 + } + ] +} +``` + +**فیلدهای رهگیری‌شده** (`ServiceItemAuditService::TRACKED`): `name`، `price_rials`، `active`، +`duration_minutes`، `bookable`، `insurance_covered`، `inventory_package`. + +- هر فیلدِ تغییریافته **یک ردیف جدا** می‌سازد؛ فیلد بدون تغییر ردیف نمی‌سازد. +- `operation`: `create` (هنگام ساخت خدمت، فقط یک ردیف روی فیلد `name`) یا `update`. +- مقادیر به‌صورت رشته ذخیره می‌شوند: بولین‌ها `"1"`/`"0"`، مبالغ ریال، و `null` یعنی «بدون مقدار». + ترجمه‌ی نمایشی سمت پنل انجام می‌شود (`FIELD_LABELS` و `auditValue` در `ServiceDetailPage.tsx`). +- جدول `service_item_audit_logs` با `ON DELETE CASCADE` به `service_items` وصل است. + +**خطاها:** `404 ERR_SERVICE_NOT_FOUND` · `401`. --- @@ -186,6 +225,7 @@ refresh مستقیم هم کار کند، بنابراین فیلترکردن س | insurance_price_rials | integer\|null | ❌ — **deprecated.** سهم تقریبی بیمار؛ از فرم سرویس حذف شد. محاسبه‌ی دقیق سهم بیمار از `TenantServiceCoverage` انجام می‌شود | | duration_minutes | integer\|null | ❌ — «زمان متوسط» انجام خدمت به دقیقه (`""`/`null` = بدون مقدار) | | bookable | boolean | ❌ (پیش‌فرض false) — «نمایش در نوبت‌دهی». فقط سرویس‌های `bookable=true` در حالت نوبت‌دهی سرویسی قابل‌انتخاب‌اند | +| inventory_package_uuid | UUID\|null | ❌ — پکیج کالای مصرفی این خدمت ([inventory.md](inventory.md)). `null`/`""` یعنی قطع اتصال. پکیج باید متعلق به همان مطب/کلینیک باشد وگرنه `422 ERR_VALIDATION_001` با فیلد `inventory_package_uuid` | > `bookable` در `PATCH /api/v1/service-item/{uuid}` هم به همین شکل پذیرفته می‌شود. diff --git a/migrations/Version20260718084301.php b/migrations/Version20260718084301.php new file mode 100644 index 00000000..90c7c8e9 --- /dev/null +++ b/migrations/Version20260718084301.php @@ -0,0 +1,32 @@ +addSql('ALTER TABLE service_items ADD inventory_package_id INT DEFAULT NULL'); + } + + public function down(Schema $schema): void + { + $this->addSql('ALTER TABLE service_items DROP inventory_package_id'); + } +} diff --git a/migrations/Version20260718084524.php b/migrations/Version20260718084524.php new file mode 100644 index 00000000..827cf3ef --- /dev/null +++ b/migrations/Version20260718084524.php @@ -0,0 +1,31 @@ +addSql('CREATE TABLE service_item_audit_logs (id INT AUTO_INCREMENT NOT NULL, uuid VARCHAR(36) NOT NULL, field VARCHAR(40) NOT NULL, operation VARCHAR(10) NOT NULL, old_value LONGTEXT DEFAULT NULL, new_value LONGTEXT DEFAULT NULL, actor_user_id INT DEFAULT NULL, actor_name VARCHAR(191) DEFAULT NULL, created_at INT NOT NULL, service_item_id INT NOT NULL, UNIQUE INDEX UNIQ_499F70B6D17F50A6 (uuid), INDEX IDX_499F70B6DDEB00C2 (service_item_id), INDEX idx_service_item_audit_item (service_item_id, created_at), PRIMARY KEY (id)) DEFAULT CHARACTER SET utf8mb4'); + $this->addSql('ALTER TABLE service_item_audit_logs ADD CONSTRAINT FK_499F70B6DDEB00C2 FOREIGN KEY (service_item_id) REFERENCES service_items (id) ON DELETE CASCADE'); + } + + public function down(Schema $schema): void + { + $this->addSql('ALTER TABLE service_item_audit_logs DROP FOREIGN KEY FK_499F70B6DDEB00C2'); + $this->addSql('DROP TABLE service_item_audit_logs'); + } +} diff --git a/src/ClinicService/Controller/ClinicServiceController.php b/src/ClinicService/Controller/ClinicServiceController.php index 0a30dea6..6856da9f 100644 --- a/src/ClinicService/Controller/ClinicServiceController.php +++ b/src/ClinicService/Controller/ClinicServiceController.php @@ -4,16 +4,20 @@ namespace App\ClinicService\Controller; use App\Auth\Entity\User; use App\ClinicService\Entity\ServiceItem; +use App\ClinicService\Entity\ServiceItemAuditLog; use App\ClinicService\Entity\ServiceSection; use App\Insurance\Entity\TenantServiceCoverage; use App\ClinicService\Entity\Tariff; use Doctrine\ORM\EntityManagerInterface; +use App\ClinicService\Repository\ServiceItemAuditLogRepository; use App\ClinicService\Repository\ServiceItemRepository; use App\ClinicService\Repository\ServiceSectionRepository; use App\ClinicService\Repository\TariffRepository; +use App\ClinicService\Service\ServiceItemAuditService; use App\ClinicService\Service\TariffService; use App\Clinic\Repository\ClinicRepository; use App\Doctor\Repository\DoctorRepository; +use App\Inventory\Repository\InventoryPackageRepository; use App\Shared\Constant\ErrorCodes; use App\Shared\Controller\BaseController; use App\Shared\Exception\AppException; @@ -39,9 +43,61 @@ class ClinicServiceController extends BaseController private readonly ClinicRepository $clinicRepo, private readonly TariffRepository $tariffRepo, private readonly TariffService $tariffService, + private readonly InventoryPackageRepository $packageRepo, + private readonly ServiceItemAuditService $auditService, + private readonly ServiceItemAuditLogRepository $auditLogRepo, private readonly EntityManagerInterface $em, ) {} + /** + * uuid و عنوان پکیج کالای هر سرویس را به آرایه‌ی خروجی اضافه می‌کند. پکیج‌ها با یک + * کوئری واکشی می‌شوند تا فهرست سرویس‌ها به N+1 نیفتد. + * + * @param ServiceItem[] $items + * @return array> + */ + private function serializeItems(array $items): array + { + $packages = $this->packageRepo->findMapByIds( + array_map(fn(ServiceItem $i) => $i->getInventoryPackageId(), $items) + ); + + return array_map(function (ServiceItem $i) use ($packages) { + $row = $i->toArray(); + $package = $packages[$i->getInventoryPackageId()] ?? null; + $row['inventory_package_uuid'] = $package?->getUuid(); + $row['inventory_package_title'] = $package?->getTitle(); + + return $row; + }, $items); + } + + /** + * `inventory_package_uuid` را به id داخلی تبدیل و روی سرویس ست می‌کند. + * مقدار null یعنی قطع اتصال. خطای دسترسی/نبود پکیج را برمی‌گرداند. + */ + private function applyInventoryPackage(ServiceItem $item, array $data, string $entityType, ?int $entityId): ?JsonResponse + { + if (!array_key_exists('inventory_package_uuid', $data)) { + return null; + } + + $uuid = $data['inventory_package_uuid']; + if ($uuid === null || $uuid === '') { + $item->setInventoryPackageId(null); + return null; + } + + $package = $this->packageRepo->findByUuid((string) $uuid); + if ($package === null || $package->getEntityType() !== $entityType || $package->getEntityId() !== $entityId) { + return $this->error(ErrorCodes::ERR_VALIDATION_001, 'پکیج کالا یافت نشد', 422, 'inventory_package_uuid'); + } + + $item->setInventoryPackageId($package->getId()); + + return null; + } + // ── Service Sections ───────────────────────────────────────────────────── #[Route('/api/v1/service-sections', methods: ['GET'])] @@ -128,12 +184,9 @@ class ClinicServiceController extends BaseController { [$entityType, $entityId] = $this->resolveEntity($user); - $items = array_map( - fn(ServiceItem $i) => $i->toArray(), + return $this->success($this->serializeItems( $this->itemRepo->findByEntity($entityType, $entityId) - ); - - return $this->success($items); + )); } #[Route('/api/v1/service-items/{sectionUuid}', methods: ['GET'])] @@ -146,12 +199,7 @@ class ClinicServiceController extends BaseController return $this->error(ErrorCodes::ERR_SERVICE_NOT_FOUND, ErrorCodes::message(ErrorCodes::ERR_SERVICE_NOT_FOUND), 404); } - $items = array_map( - fn(ServiceItem $i) => $i->toArray(), - $this->itemRepo->findBySection($section) - ); - - return $this->success($items); + return $this->success($this->serializeItems($this->itemRepo->findBySection($section))); } #[Route('/api/v1/service-item/{uuid}', methods: ['GET'])] @@ -164,7 +212,24 @@ class ClinicServiceController extends BaseController return $this->error(ErrorCodes::ERR_SERVICE_NOT_FOUND, ErrorCodes::message(ErrorCodes::ERR_SERVICE_NOT_FOUND), 404); } - return $this->success($item->toArray()); + return $this->success($this->serializeItems([$item])[0]); + } + + /** تاریخچه‌ی تغییرات یک خدمت — تازه‌ترین رویداد اول. */ + #[Route('/api/v1/service-item/{uuid}/audit-logs', methods: ['GET'])] + public function listItemAuditLogs(string $uuid, #[CurrentUser] User $user): JsonResponse + { + [$entityType, $entityId] = $this->resolveEntity($user); + + $item = $this->itemRepo->findByUuid($uuid); + if ($item === null || !$this->ownsSection($item->getSection(), $entityType, $entityId)) { + return $this->error(ErrorCodes::ERR_SERVICE_NOT_FOUND, ErrorCodes::message(ErrorCodes::ERR_SERVICE_NOT_FOUND), 404); + } + + return $this->success(array_map( + fn(ServiceItemAuditLog $l) => $l->toArray(), + $this->auditLogRepo->findByItem($item) + )); } #[Route('/api/v1/service-item', methods: ['POST'])] @@ -206,13 +271,18 @@ class ClinicServiceController extends BaseController if (array_key_exists('bookable', $data)) { $item->setBookable((bool) $data['bookable']); } + $packageError = $this->applyInventoryPackage($item, $data, $entityType, $entityId); + if ($packageError !== null) { + return $packageError; + } $this->itemRepo->save($item); // قیمت سرویس همان تعرفه‌ی سال جاری است؛ هنگام ساخت، تعرفه‌ی سال جاری ثبت می‌شود. $this->tariffService->upsert($item->getId(), $this->tariffService->currentJalaliYear(), $item->getPriceRials()); + $this->auditService->logCreate($item, $user); - return $this->success($item->toArray(), 201); + return $this->success($this->serializeItems([$item])[0], 201); } #[Route('/api/v1/service-item/{uuid}', methods: ['PATCH'])] @@ -225,7 +295,8 @@ class ClinicServiceController extends BaseController return $this->error(ErrorCodes::ERR_SERVICE_NOT_FOUND, ErrorCodes::message(ErrorCodes::ERR_SERVICE_NOT_FOUND), 404); } - $data = json_decode($request->getContent(), true) ?? []; + $data = json_decode($request->getContent(), true) ?? []; + $before = $this->auditService->snapshot($item); $priceChanged = false; if (isset($data['name']) && trim($data['name']) !== '') { $item->setName(trim($data['name'])); } @@ -250,6 +321,10 @@ class ClinicServiceController extends BaseController if (array_key_exists('bookable', $data)) { $item->setBookable((bool) $data['bookable']); } + $packageError = $this->applyInventoryPackage($item, $data, $entityType, $entityId); + if ($packageError !== null) { + return $packageError; + } $this->itemRepo->save($item); @@ -258,7 +333,9 @@ class ClinicServiceController extends BaseController $this->tariffService->upsert($item->getId(), $this->tariffService->currentJalaliYear(), $item->getPriceRials()); } - return $this->success($item->toArray()); + $this->auditService->logChanges($item, $before, $this->auditService->snapshot($item), $user); + + return $this->success($this->serializeItems([$item])[0]); } #[Route('/api/v1/service-item/{uuid}', methods: ['DELETE'])] diff --git a/src/ClinicService/Entity/ServiceItem.php b/src/ClinicService/Entity/ServiceItem.php index f0fff694..2ebf1567 100644 --- a/src/ClinicService/Entity/ServiceItem.php +++ b/src/ClinicService/Entity/ServiceItem.php @@ -62,6 +62,14 @@ class ServiceItem #[ORM\Column(type: 'boolean', options: ['default' => false])] private bool $bookable = false; + /** + * پکیج کالای مصرفی این خدمت ({@see \App\Inventory\Entity\InventoryPackage}). + * ارجاع خام int بدون FK — همان الگوی Tariff و TenantServiceCoverage — تا دامنهٔ + * ClinicService به Inventory وابسته نشود. + */ + #[ORM\Column(name: 'inventory_package_id', type: 'integer', nullable: true)] + private ?int $inventoryPackageId = null; + #[ORM\Column(name: 'created_at', type: 'integer')] private int $createdAt; @@ -90,6 +98,7 @@ class ServiceItem public function getInsurancePriceRials(): ?int { return $this->insurancePriceRials; } public function getDurationMinutes(): ?int { return $this->durationMinutes; } public function isBookable(): bool { return $this->bookable; } + public function getInventoryPackageId(): ?int { return $this->inventoryPackageId; } public function getCreatedAt(): int { return $this->createdAt; } public function getUpdatedAt(): int { return $this->updatedAt; } @@ -128,6 +137,7 @@ class ServiceItem public function setInsurancePriceRials(?int $v): self { $this->insurancePriceRials = $v; $this->updatedAt = time(); return $this; } public function setDurationMinutes(?int $v): self { $this->durationMinutes = $v; $this->updatedAt = time(); return $this; } public function setBookable(bool $v): self { $this->bookable = $v; $this->updatedAt = time(); return $this; } + public function setInventoryPackageId(?int $v): self { $this->inventoryPackageId = $v; $this->updatedAt = time(); return $this; } public function toArray(): array { @@ -159,6 +169,7 @@ class ServiceItem 'insurance_price_rials' => $this->insurancePriceRials, 'duration_minutes' => $this->durationMinutes, 'bookable' => $this->bookable, + 'inventory_package_id' => $this->inventoryPackageId, 'created_at' => $this->createdAt, 'updated_at' => $this->updatedAt, ]; diff --git a/src/ClinicService/Entity/ServiceItemAuditLog.php b/src/ClinicService/Entity/ServiceItemAuditLog.php new file mode 100644 index 00000000..75df8e4a --- /dev/null +++ b/src/ClinicService/Entity/ServiceItemAuditLog.php @@ -0,0 +1,81 @@ +uuid = Uuid::v4()->toRfc4122(); + $this->serviceItem = $serviceItem; + $this->field = $field; + $this->operation = $operation; + $this->createdAt = time(); + } + + public function setActor(?int $userId, ?string $name): self { $this->actorUserId = $userId; $this->actorName = $name; return $this; } + public function setValues(?string $old, ?string $new): self { $this->oldValue = $old; $this->newValue = $new; return $this; } + + public function getUuid(): string { return $this->uuid; } + + public function toArray(): array + { + return [ + 'uuid' => $this->uuid, + 'field' => $this->field, + 'operation' => $this->operation, + 'old_value' => $this->oldValue, + 'new_value' => $this->newValue, + 'actor_name' => $this->actorName, + 'created_at' => $this->createdAt, + ]; + } +} diff --git a/src/ClinicService/Repository/ServiceItemAuditLogRepository.php b/src/ClinicService/Repository/ServiceItemAuditLogRepository.php new file mode 100644 index 00000000..67d1b17f --- /dev/null +++ b/src/ClinicService/Repository/ServiceItemAuditLogRepository.php @@ -0,0 +1,42 @@ +getEntityManager()->persist($log); + if ($flush) { + $this->getEntityManager()->flush(); + } + } + + public function flush(): void + { + $this->getEntityManager()->flush(); + } + + /** @return ServiceItemAuditLog[] تازه‌ترین رویداد اول. */ + public function findByItem(ServiceItem $item, int $limit = 100): array + { + return $this->createQueryBuilder('l') + ->where('l.serviceItem = :item') + ->setParameter('item', $item) + ->orderBy('l.createdAt', 'DESC') + ->addOrderBy('l.id', 'DESC') + ->setMaxResults($limit) + ->getQuery() + ->getResult(); + } +} diff --git a/src/ClinicService/Service/ServiceItemAuditService.php b/src/ClinicService/Service/ServiceItemAuditService.php new file mode 100644 index 00000000..fd00bbbb --- /dev/null +++ b/src/ClinicService/Service/ServiceItemAuditService.php @@ -0,0 +1,89 @@ + 'نام سرویس', + 'price_rials' => 'قیمت پایه', + 'active' => 'وضعیت', + 'duration_minutes' => 'زمان متوسط', + 'bookable' => 'نمایش در نوبت‌دهی', + 'insurance_covered' => 'پوشش بیمه', + 'inventory_package' => 'پکیج کالا', + ]; + + public function __construct(private readonly ServiceItemAuditLogRepository $repo) {} + + /** @return array snapshot قابل مقایسه از وضعیت فعلی خدمت. */ + public function snapshot(ServiceItem $item): array + { + return [ + 'name' => $item->getName(), + 'price_rials' => (string) $item->getPriceRials(), + 'active' => $item->isActive() ? '1' : '0', + 'duration_minutes' => $item->getDurationMinutes() === null ? null : (string) $item->getDurationMinutes(), + 'bookable' => $item->isBookable() ? '1' : '0', + 'insurance_covered' => $item->isInsuranceCovered() ? '1' : '0', + 'inventory_package' => $item->getInventoryPackageId() === null ? null : (string) $item->getInventoryPackageId(), + ]; + } + + public function logCreate(ServiceItem $item, ?User $actor): void + { + $this->repo->save( + (new ServiceItemAuditLog($item, 'name', ServiceItemAuditLog::OP_CREATE)) + ->setActor($actor?->getId(), $this->actorName($actor)) + ->setValues(null, $item->getName()) + ); + } + + /** + * تفاوت دو snapshot را لاگ می‌کند. فیلد بدون تغییر ردیف نمی‌سازد. + * + * @param array $before + * @param array $after + */ + public function logChanges(ServiceItem $item, array $before, array $after, ?User $actor): void + { + $logged = false; + + foreach (array_keys(self::TRACKED) as $field) { + if (($before[$field] ?? null) === ($after[$field] ?? null)) { + continue; + } + + $this->repo->save( + (new ServiceItemAuditLog($item, $field, ServiceItemAuditLog::OP_UPDATE)) + ->setActor($actor?->getId(), $this->actorName($actor)) + ->setValues($before[$field] ?? null, $after[$field] ?? null), + false, + ); + $logged = true; + } + + if ($logged) { + $this->repo->flush(); + } + } + + private function actorName(?User $actor): ?string + { + if ($actor === null) { + return null; + } + + return $actor->getRealName() ?: $actor->getMobileNumber(); + } +} diff --git a/src/Inventory/Repository/InventoryPackageRepository.php b/src/Inventory/Repository/InventoryPackageRepository.php index 92954d2e..547faa9b 100644 --- a/src/Inventory/Repository/InventoryPackageRepository.php +++ b/src/Inventory/Repository/InventoryPackageRepository.php @@ -32,6 +32,28 @@ class InventoryPackageRepository extends ServiceEntityRepository ->getResult(); } + /** + * پکیج‌ها را با یک کوئری برمی‌گرداند (کلید = id) تا سریالایز کردن فهرست سرویس‌ها + * به find()-per-row نیفتد. + * + * @param int[] $ids + * @return array + */ + public function findMapByIds(array $ids): array + { + $ids = array_values(array_unique(array_filter($ids))); + if ($ids === []) { + return []; + } + + $map = []; + foreach ($this->findBy(['id' => $ids]) as $package) { + $map[$package->getId()] = $package; + } + + return $map; + } + public function save(InventoryPackage $package): void { $this->getEntityManager()->persist($package); diff --git a/tests/ClinicService/ServiceItemPackageAndAuditTest.php b/tests/ClinicService/ServiceItemPackageAndAuditTest.php new file mode 100644 index 00000000..086cde35 --- /dev/null +++ b/tests/ClinicService/ServiceItemPackageAndAuditTest.php @@ -0,0 +1,205 @@ +createUser(['ROLE_DOCTOR']); + $doctor = new Doctor($owner, 'دکتر تست پکیج'); + $this->em->persist($doctor); + $this->em->flush(); + + $section = new ServiceSection('doctor', $doctor->getId(), 'تزریقات'); + $this->em->persist($section); + $this->em->flush(); + + return [$owner, $doctor, $section]; + } + + private function makePackage(Doctor $doctor, string $title = 'پکیج سرم'): InventoryPackage + { + $package = new InventoryPackage('doctor', $doctor->getId(), $title); + $this->em->persist($package); + $this->em->flush(); + + return $package; + } + + private function makeItem(ServiceSection $section, string $name = 'سرم ۵۰۰cc'): ServiceItem + { + $item = new ServiceItem($section, $name, 850_000); + $this->em->persist($item); + $this->em->flush(); + + return $item; + } + + // ── کالاهای مرتبط ──────────────────────────────────────────────────────── + + public function testAttachingAPackageIsReflectedInTheDetailResponse(): void + { + [$owner, $doctor, $section] = $this->makeDoctorWithSection(); + $package = $this->makePackage($doctor); + $item = $this->makeItem($section); + + $this->authJson('PATCH', '/api/v1/service-item/' . $item->getUuid(), $owner, [ + 'inventory_package_uuid' => $package->getUuid(), + ]); + $this->assertSame(200, $this->responseCode()); + + $body = $this->authJson('GET', '/api/v1/service-item/' . $item->getUuid(), $owner); + $this->assertSame($package->getUuid(), $body['data']['inventory_package_uuid']); + $this->assertSame('پکیج سرم', $body['data']['inventory_package_title']); + } + + public function testDetachingWithNullClearsThePackage(): void + { + [$owner, $doctor, $section] = $this->makeDoctorWithSection(); + $package = $this->makePackage($doctor); + $item = $this->makeItem($section); + + $this->authJson('PATCH', '/api/v1/service-item/' . $item->getUuid(), $owner, [ + 'inventory_package_uuid' => $package->getUuid(), + ]); + $this->authJson('PATCH', '/api/v1/service-item/' . $item->getUuid(), $owner, [ + 'inventory_package_uuid' => null, + ]); + + $body = $this->authJson('GET', '/api/v1/service-item/' . $item->getUuid(), $owner); + $this->assertNull($body['data']['inventory_package_uuid']); + } + + public function testPackageOfAnotherTenantIsRejected(): void + { + [$owner, , $section] = $this->makeDoctorWithSection(); + $item = $this->makeItem($section); + + $stranger = $this->createUser(['ROLE_DOCTOR']); + $other = new Doctor($stranger, 'دکتر غریبه'); + $this->em->persist($other); + $this->em->flush(); + $foreignPackage = $this->makePackage($other, 'پکیج غریبه'); + + $this->authJson('PATCH', '/api/v1/service-item/' . $item->getUuid(), $owner, [ + 'inventory_package_uuid' => $foreignPackage->getUuid(), + ]); + + $this->assertSame(422, $this->responseCode()); + } + + public function testUnknownPackageUuidIsRejected(): void + { + [$owner, , $section] = $this->makeDoctorWithSection(); + $item = $this->makeItem($section); + + $this->authJson('PATCH', '/api/v1/service-item/' . $item->getUuid(), $owner, [ + 'inventory_package_uuid' => 'no-such-package', + ]); + + $this->assertSame(422, $this->responseCode()); + } + + // ── لاگ تغییرات ────────────────────────────────────────────────────────── + + public function testCreatingAServiceWritesACreateLog(): void + { + [$owner, , $section] = $this->makeDoctorWithSection(); + + $created = $this->authJson('POST', '/api/v1/service-item', $owner, [ + 'section_uuid' => $section->getUuid(), + 'name' => 'خدمت تازه', + 'price_rials' => 100_000, + ]); + $this->assertSame(201, $this->responseCode()); + + $body = $this->authJson('GET', '/api/v1/service-item/' . $created['data']['uuid'] . '/audit-logs', $owner); + $this->assertSame(200, $this->responseCode()); + $logs = $body['data']; + $this->assertCount(1, $logs); + $this->assertSame('create', $logs[0]['operation']); + $this->assertSame('خدمت تازه', $logs[0]['new_value']); + } + + public function testEachChangedFieldGetsItsOwnLogRow(): void + { + [$owner, , $section] = $this->makeDoctorWithSection(); + $item = $this->makeItem($section); + + $this->authJson('PATCH', '/api/v1/service-item/' . $item->getUuid(), $owner, [ + 'name' => 'نام جدید', + 'price_rials' => 990_000, + ]); + + $body = $this->authJson('GET', '/api/v1/service-item/' . $item->getUuid() . '/audit-logs', $owner); + $fields = array_column($body['data'], 'field'); + sort($fields); + + $this->assertSame(['name', 'price_rials'], $fields); + + $byField = array_column($body['data'], null, 'field'); + $this->assertSame('سرم ۵۰۰cc', $byField['name']['old_value']); + $this->assertSame('نام جدید', $byField['name']['new_value']); + $this->assertSame('850000', $byField['price_rials']['old_value']); + $this->assertSame('990000', $byField['price_rials']['new_value']); + } + + public function testUnchangedFieldsAreNotLogged(): void + { + [$owner, , $section] = $this->makeDoctorWithSection(); + $item = $this->makeItem($section); + + // همان مقادیر فعلی دوباره ارسال می‌شوند + $this->authJson('PATCH', '/api/v1/service-item/' . $item->getUuid(), $owner, [ + 'name' => 'سرم ۵۰۰cc', + 'price_rials' => 850_000, + ]); + + $body = $this->authJson('GET', '/api/v1/service-item/' . $item->getUuid() . '/audit-logs', $owner); + $this->assertSame([], $body['data']); + } + + public function testAttachingAPackageIsLogged(): void + { + [$owner, $doctor, $section] = $this->makeDoctorWithSection(); + $package = $this->makePackage($doctor); + $item = $this->makeItem($section); + + $this->authJson('PATCH', '/api/v1/service-item/' . $item->getUuid(), $owner, [ + 'inventory_package_uuid' => $package->getUuid(), + ]); + + $body = $this->authJson('GET', '/api/v1/service-item/' . $item->getUuid() . '/audit-logs', $owner); + $fields = array_column($body['data'], 'field'); + + $this->assertContains('inventory_package', $fields); + } + + public function testAuditLogsOfAnotherTenantAreNotReadable(): void + { + [, , $section] = $this->makeDoctorWithSection(); + $item = $this->makeItem($section); + + $stranger = $this->createUser(['ROLE_DOCTOR']); + $this->em->persist(new Doctor($stranger, 'دکتر غریبه')); + $this->em->flush(); + + $this->authJson('GET', '/api/v1/service-item/' . $item->getUuid() . '/audit-logs', $stranger); + + $this->assertSame(404, $this->responseCode()); + } +}