diff --git a/assets/admin/components/ResourceBlocksModal.tsx b/assets/admin/components/ResourceBlocksModal.tsx new file mode 100644 index 00000000..0b930e8f --- /dev/null +++ b/assets/admin/components/ResourceBlocksModal.tsx @@ -0,0 +1,197 @@ +import React, { useState } from 'react'; +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import { toast } from 'sonner'; +import { TrashIcon } from '@heroicons/react/24/outline'; +import Modal from './ui/Modal'; +import PersianDateInput from './ui/PersianDateInput'; +import { api, ApiError, type ApiResponse } from '../lib/api'; +import { formatDate, isoToUnix, unixToIso } from '../lib/utils'; + +interface Block { + uuid: string; + starts_at: number; + ends_at: number; + segment_name: string | null; +} + +interface Props { + resourceUuid: string | null; + resourceName: string; + onClose: () => void; +} + +const HOUR = 3600; + +/** + * مسدودسازی موردی یک منبع. + * + * عمداً از «استثنای تقویم» جداست و همین‌جا هم گفته می‌شود: آن الگوی کاری منبع را عوض + * می‌کند و ماندگار است، این فقط یک بازهٔ مشخص را می‌بندد. اپراتوری که این تفاوت را + * نداند، تعطیلی یک بعدازظهر را برای همیشه در تقویم ثبت می‌کند. + */ +export default function ResourceBlocksModal({ resourceUuid, resourceName, onClose }: Props) { + const qc = useQueryClient(); + const key = ['resource-blocks', resourceUuid]; + + const [day, setDay] = useState(() => unixToIso(Math.floor(Date.now() / 1000) + 86400)); + const [fromHour, setFromHour] = useState(9); + const [toHour, setToHour] = useState(13); + const [reason, setReason] = useState(''); + + const { data, isLoading } = useQuery({ + queryKey: key, + queryFn: () => api.get>(`/api/v1/resource/${resourceUuid}/blocks`), + enabled: !!resourceUuid, + }); + + const blocks = data?.data ?? []; + + const create = useMutation({ + mutationFn: (body: { starts_at: number; ends_at: number; reason: string }) => + api.post>(`/api/v1/resource/${resourceUuid}/blocks`, body), + onSuccess: () => { + toast.success('بازه مسدود شد'); + qc.invalidateQueries({ queryKey: key }); + setReason(''); + }, + // ۴۰۹ یعنی آن بازه نوبت دارد؛ پیام سرور دقیقاً می‌گوید اول چه باید کرد. + onError: (e) => toast.error(e instanceof ApiError ? e.message : 'مسدودسازی ناموفق بود'), + }); + + const remove = useMutation({ + mutationFn: (uuid: string) => api.delete>(`/api/v1/resource-block/${uuid}`), + onSuccess: () => { + toast.success('مسدودسازی برداشته شد'); + qc.invalidateQueries({ queryKey: key }); + }, + onError: (e) => toast.error(e instanceof ApiError ? e.message : 'حذف ناموفق بود'), + }); + + const dayStart = isoToUnix(day) ?? 0; + const rangeInvalid = toHour <= fromHour; + + return ( + + بستن + + } + > +
+

+ این یک بازهٔ مشخص را می‌بندد و تا وقتی حذفش نکنید می‌ماند. برای تغییر + الگوی کاری + منبع (مثلاً «پنجشنبه‌ها تعطیل») از استثنای تقویم استفاده کنید، نه از اینجا. +

+ +
+
+ + +
+ + + + +
+ +
+ + setReason(e.target.value)} + placeholder="مثلاً: سرویس دوره‌ای دستگاه" + /> +
+ + {rangeInvalid && ( + + ساعت پایان باید بعد از ساعت شروع باشد. + + )} + +
+ +
+ +
+

مسدودسازی‌های فعلی

+ + {isLoading ? ( + در حال بارگذاری… + ) : blocks.length === 0 ? ( + + این منبع مسدودسازی موردی ندارد. + + ) : ( + blocks.map((block) => ( +
+ {formatDate(block.starts_at)} + + {new Date(block.starts_at * 1000).toLocaleTimeString('fa-IR', { hour: '2-digit', minute: '2-digit' })} + {' – '} + {new Date(block.ends_at * 1000).toLocaleTimeString('fa-IR', { hour: '2-digit', minute: '2-digit' })} + + {block.segment_name ?? '—'} + +
+ )) + )} +
+
+
+ ); +} diff --git a/assets/admin/pages/ResourceBookingPage.tsx b/assets/admin/pages/ResourceBookingPage.tsx index 1ff582f6..36a64a88 100644 --- a/assets/admin/pages/ResourceBookingPage.tsx +++ b/assets/admin/pages/ResourceBookingPage.tsx @@ -67,7 +67,7 @@ export default function ResourceBookingPage() { return { from, to: from + Number(days) * DAY }; }, [days]); - const { result, loading, error } = useAvailabilitySearch( + const { result, loading, error, refetch } = useAvailabilitySearch( { serviceUuid, branchUuid, from: range.from, to: range.to }, searching, ); @@ -95,15 +95,25 @@ export default function ResourceBookingPage() { const takeHold = async () => { if (!pickedSlot || !picked) return; - const created = await create.mutateAsync({ - service_uuid: serviceUuid, - branch_uuid: branchUuid, - start: pickedSlot.start, - assignment: picked, - }); + try { + const created = await create.mutateAsync({ + service_uuid: serviceUuid, + branch_uuid: branchUuid, + start: pickedSlot.start, + assignment: picked, + }); - setHold(created.data); - setExpired(false); + setHold(created.data); + setExpired(false); + } catch (e) { + // ۴۰۹ یعنی همین لحظه کس دیگری گرفت. گفتنش کافی نیست: کاربر باید بلافاصله + // جایگزین ببیند، وگرنه باید دستی دوباره جستجو بزند و بختش را از نو امتحان کند. + if (e instanceof ApiError && e.status === 409) { + setPickedSlot(null); + setPicked(null); + await refetch(); + } + } }; const reasonText = result?.reason ? REASON_LABELS[result.reason] ?? result.reason : null; diff --git a/assets/admin/pages/ResourcesPage.tsx b/assets/admin/pages/ResourcesPage.tsx index 9cbb632c..d618255d 100644 --- a/assets/admin/pages/ResourcesPage.tsx +++ b/assets/admin/pages/ResourcesPage.tsx @@ -3,6 +3,7 @@ import { Link } from 'react-router-dom'; import { PlusIcon } from '@heroicons/react/24/outline'; import PageHeader from '../components/ui/PageHeader'; import DataTable, { type Column } from '../components/ui/DataTable'; +import ResourceBlocksModal from '../components/ResourceBlocksModal'; import ConfirmDialog from '../components/ui/ConfirmDialog'; import SearchableSelect from '../components/ui/SearchableSelect'; import { ActiveBadge } from '../components/ui/StatusBadge'; @@ -48,6 +49,7 @@ export default function ResourcesPage() { const [editing, setEditing] = useState<{ open: boolean; resource: ClinicResource | null }>({ open: false, resource: null }); const [skillsFor, setSkillsFor] = useState(null); + const [blocksFor, setBlocksFor] = useState(null); const [toDelete, setToDelete] = useState(null); const rows = useMemo(() => { @@ -186,6 +188,11 @@ export default function ResourcesPage() { تقویم + {/* مسدودسازی موردی از تقویم جداست: آن الگوی کاری را عوض می‌کند، + این یک بازهٔ مشخص را می‌بندد. */} + @@ -195,6 +202,12 @@ export default function ResourcesPage() { } /> + setBlocksFor(null)} + /> + requireResource($user, $uuid); + + $from = $request->query->has('from') ? $request->query->getInt('from') : time(); + $to = $request->query->has('to') ? $request->query->getInt('to') : $from + 30 * 86400; + + return $this->success(array_map( + static fn (ResourceOccupancy $o): array => $o->toArray(), + $this->occupancy->findManualBlocks((int) $resource->getId(), $from, $to), + )); + } + + #[Route('/api/v1/resource/{uuid}/blocks', name: 'resource_block_create', methods: ['POST'])] + public function create(#[CurrentUser] User $user, string $uuid, Request $request): JsonResponse + { + $resource = $this->requireResource($user, $uuid); + $data = json_decode($request->getContent(), true); + + if (!is_array($data) || !is_numeric($data['starts_at'] ?? null) || !is_numeric($data['ends_at'] ?? null)) { + return $this->error(ErrorCodes::ERR_VALIDATION_002, 'بازهٔ مسدودسازی الزامی است', 422, 'starts_at'); + } + + $startsAt = (int) $data['starts_at']; + $endsAt = (int) $data['ends_at']; + + if ($endsAt <= $startsAt) { + return $this->error(ErrorCodes::ERR_VALIDATION_001, 'پایان بازه باید بعد از شروع آن باشد', 422, 'ends_at'); + } + + // مسدودسازی روی بازه‌ای که نوبت دارد، ظرفیت را پس نمی‌گیرد — نوبت همچنان سرجایش + // است و کاربر باید اول تکلیفش را روشن کند. + if ($this->occupancy->hasAppointmentInRange((int) $resource->getId(), $startsAt, $endsAt)) { + return $this->error( + ErrorCodes::ERR_SLOT_TAKEN, + 'در این بازه نوبت ثبت‌شده وجود دارد؛ اول آن را جابه‌جا یا لغو کنید', + 409, + ); + } + + $block = new ResourceOccupancy($resource, $startsAt, $endsAt, ResourceOccupancy::STATUS_BOOKED); + $block->setSegmentName( + is_string($data['reason'] ?? null) && trim($data['reason']) !== '' + ? trim($data['reason']) + : 'مسدودسازی دستی', + ); + + $this->em->persist($block); + $this->em->flush(); + + return $this->success($block->toArray(), 201); + } + + #[Route('/api/v1/resource-block/{uuid}', name: 'resource_block_delete', methods: ['DELETE'])] + public function delete(#[CurrentUser] User $user, string $uuid): JsonResponse + { + $block = $this->occupancy->findOneBy(['uuid' => $uuid]); + [$entityType, $entityId] = $this->branches->pair($user); + + if ($block === null || !$this->ownership->belongsToPair($entityType, $entityId, $block)) { + return $this->error(ErrorCodes::ERR_NOT_FOUND_001, 'مسدودسازی یافت نشد', 404); + } + + // اشغالِ یک نوبت واقعی از اینجا حذف نمی‌شود؛ وگرنه نوبت بیمار بی‌صدا منبعش را + // از دست می‌دهد و هیچ‌جا هم ثبت نمی‌شود. + if ($block->getAppointmentId() !== null || $block->getHoldId() !== null) { + return $this->error( + ErrorCodes::ERR_VALIDATION_001, + 'این اشغال متعلق به یک نوبت است و از اینجا حذف نمی‌شود', + 422, + ); + } + + $this->em->remove($block); + $this->em->flush(); + + return $this->success(null); + } + + private function requireResource(User $user, string $uuid): ClinicResource + { + $resource = $this->resources->findOneBy(['uuid' => $uuid]); + [$entityType, $entityId] = $this->branches->pair($user); + + if ($resource === null || !$this->ownership->belongsToPair($entityType, $entityId, $resource)) { + throw new AppException(ErrorCodes::ERR_NOT_FOUND_001, 'منبع یافت نشد', 404); + } + + return $resource; + } +} diff --git a/src/Appointment/Availability/Repository/ResourceOccupancyRepository.php b/src/Appointment/Availability/Repository/ResourceOccupancyRepository.php index 7e271539..5817c9ec 100644 --- a/src/Appointment/Availability/Repository/ResourceOccupancyRepository.php +++ b/src/Appointment/Availability/Repository/ResourceOccupancyRepository.php @@ -57,6 +57,55 @@ class ResourceOccupancyRepository extends ServiceEntityRepository } /** @return ResourceOccupancy[] */ + /** + * آیا در این بازه نوبت یا رزرو موقتی روی این منبع هست؟ + * + * فقط ردیف‌هایی که به نوبت یا hold وصل‌اند؛ مسدودسازی دستیِ دیگر مانع نیست — + * دو بازهٔ سرویس دستگاه می‌توانند هم‌پوشان باشند و آن مشکلی نیست. + */ + public function hasAppointmentInRange(int $resourceId, int $from, int $to): bool + { + return (int) $this->createQueryBuilder('o') + ->select('COUNT(o.id)') + ->where('IDENTITY(o.resource) = :resource') + ->andWhere('o.appointmentId IS NOT NULL OR o.holdId IS NOT NULL') + ->andWhere('o.status IN (:blocking)') + ->andWhere('o.startsAt < :to') + ->andWhere('o.endsAt > :from') + ->setParameter('resource', $resourceId) + ->setParameter('blocking', ResourceOccupancy::BLOCKING_STATUSES) + ->setParameter('from', $from) + ->setParameter('to', $to) + ->getQuery() + ->getSingleScalarResult() > 0; + } + + /** + * مسدودسازی‌های دستیِ یک منبع در یک بازه. + * + * فقط ردیف‌های بدون نوبت: بقیه اشغالِ رزرو واقعی‌اند و از صفحهٔ منابع مدیریت + * نمی‌شوند — حذفشان از آنجا یعنی نوبت بیمار بی‌صدا منبعش را از دست بدهد. + * + * @return ResourceOccupancy[] + */ + public function findManualBlocks(int $resourceId, int $from, int $to): array + { + return $this->createQueryBuilder('o') + ->where('IDENTITY(o.resource) = :resource') + ->andWhere('o.appointmentId IS NULL') + ->andWhere('o.holdId IS NULL') + ->andWhere('o.status IN (:blocking)') + ->andWhere('o.startsAt < :to') + ->andWhere('o.endsAt > :from') + ->setParameter('resource', $resourceId) + ->setParameter('blocking', ResourceOccupancy::BLOCKING_STATUSES) + ->setParameter('from', $from) + ->setParameter('to', $to) + ->orderBy('o.startsAt', 'ASC') + ->getQuery() + ->getResult(); + } + public function findForAppointment(int $appointmentId): array { return $this->findBy(['appointmentId' => $appointmentId]); diff --git a/src/Appointment/Availability/Service/AvailabilityEngine.php b/src/Appointment/Availability/Service/AvailabilityEngine.php index 6df15e84..24b2f4e7 100644 --- a/src/Appointment/Availability/Service/AvailabilityEngine.php +++ b/src/Appointment/Availability/Service/AvailabilityEngine.php @@ -261,7 +261,7 @@ final class AvailabilityEngine // استراتژی فقط **ترتیب** را تعیین می‌کند؛ شرط جا داشتن و برداشته‌نشدن // همچنان اینجاست، چون فقط موتور هر دو را می‌داند. $ordered = $picker->order( - array_values($requirement->eligible), + $requirement->eligible, new PickContext($free, $needed, $start, $preferredResourceIds), ); diff --git a/tests/ApiTestCase.php b/tests/ApiTestCase.php index 4575c1d1..37931eb9 100644 --- a/tests/ApiTestCase.php +++ b/tests/ApiTestCase.php @@ -38,6 +38,19 @@ abstract class ApiTestCase extends WebTestCase protected function setUp(): void { $this->client = static::createClient(); + + // هر تستی که عمداً یا تصادفی یک قید یکتا را می‌شکند، EntityManager را می‌بندد + // و آن نمونهٔ بسته به تست بعدی ارث می‌رسد — چون کانتینر همان را برمی‌گرداند. + // نتیجه‌اش خطای «EntityManager is closed» روی تستی کاملاً بی‌ربط بود که هر بار + // جای دیگری می‌افتاد و در اجرای زیرمجموعه هرگز تکرار نمی‌شد. + // + // ریست اینجا ارزان است و تضمین می‌کند شروع هر تست مستقل از خرابیِ تست قبلی باشد. + $registry = static::getContainer()->get('doctrine'); + + if (!$registry->getManager()->isOpen()) { + $registry->resetManager(); + } + $this->em = static::getContainer()->get(EntityManagerInterface::class); $this->ensureFreePlan(); } diff --git a/tests/Appointment/ResourceBlockTest.php b/tests/Appointment/ResourceBlockTest.php new file mode 100644 index 00000000..da5cf29e --- /dev/null +++ b/tests/Appointment/ResourceBlockTest.php @@ -0,0 +1,164 @@ +createUser(['ROLE_USER', 'ROLE_CLINIC']); + $clinic = new Clinic($user); + $clinic->setName('کلینیک مسدودسازی'); + $this->em->persist($clinic); + $this->em->flush(); + + $address = DoctorAddress::forClinic($clinic->getId()); + $address->setName('شعبه'); + $this->em->persist($address); + + $type = new ResourceType('clinic', (int) $clinic->getId(), 'device', 'دستگاه'); + $this->em->persist($type); + $this->em->flush(); + + $resource = new ClinicResource($address, $type, 'لیزر ۱'); + $this->em->persist($resource); + $this->em->flush(); + + return [$user, $resource]; + } + + public function testBlockingAFreeRangeSucceedsAndIsListed(): void + { + [$user, $resource] = $this->clinicWithResource(); + + $start = time() + 86400; + + $body = $this->authJson('POST', "/api/v1/resource/{$resource->getUuid()}/blocks", $user, [ + 'starts_at' => $start, + 'ends_at' => $start + 4 * 3600, + 'reason' => 'سرویس دوره‌ای دستگاه', + ]); + + self::assertSame(201, $this->responseCode(), json_encode($body, JSON_UNESCAPED_UNICODE)); + self::assertSame('سرویس دوره‌ای دستگاه', $body['data']['segment_name']); + + $list = $this->authJson( + 'GET', + sprintf('/api/v1/resource/%s/blocks?from=%d&to=%d', $resource->getUuid(), $start - 3600, $start + 86400), + $user, + ); + + self::assertCount(1, $list['data']); + } + + public function testAnInvertedRangeIsRejected(): void + { + [$user, $resource] = $this->clinicWithResource(); + + $start = time() + 86400; + + $this->authJson('POST', "/api/v1/resource/{$resource->getUuid()}/blocks", $user, [ + 'starts_at' => $start, + 'ends_at' => $start - 3600, + ]); + + self::assertSame(422, $this->responseCode()); + } + + /** ⭐ مسدودسازی روی بازه‌ای که نوبت دارد، ظرفیت را پس نمی‌گیرد. */ + public function testBlockingOverABookedRangeIsRejected(): void + { + [$user, $resource] = $this->clinicWithResource(); + + $start = time() + 2 * 86400; + + $em = static::getContainer()->get(EntityManagerInterface::class); + $reloaded = $em->getRepository(ClinicResource::class)->find($resource->getId()); + + $booked = new ResourceOccupancy($reloaded, $start, $start + 3600); + $booked->setAppointmentId(4242); + $em->persist($booked); + $em->flush(); + + $this->authJson('POST', "/api/v1/resource/{$resource->getUuid()}/blocks", $user, [ + 'starts_at' => $start, + 'ends_at' => $start + 7200, + ]); + + self::assertSame(409, $this->responseCode()); + } + + /** ⭐ اشغالِ یک نوبت واقعی از این مسیر حذف نمی‌شود. */ + public function testAnAppointmentOccupancyCannotBeDeletedAsABlock(): void + { + [$user, $resource] = $this->clinicWithResource(); + + $start = time() + 3 * 86400; + + $em = static::getContainer()->get(EntityManagerInterface::class); + $reloaded = $em->getRepository(ClinicResource::class)->find($resource->getId()); + + $booked = new ResourceOccupancy($reloaded, $start, $start + 3600); + $booked->setAppointmentId(777); + $em->persist($booked); + $em->flush(); + + $this->authJson('DELETE', "/api/v1/resource-block/{$booked->getUuid()}", $user); + + self::assertSame(422, $this->responseCode()); + } + + public function testAManualBlockCanBeDeleted(): void + { + [$user, $resource] = $this->clinicWithResource(); + + $start = time() + 86400; + + $created = $this->authJson('POST', "/api/v1/resource/{$resource->getUuid()}/blocks", $user, [ + 'starts_at' => $start, + 'ends_at' => $start + 3600, + ]); + + $this->authJson('DELETE', "/api/v1/resource-block/{$created['data']['uuid']}", $user); + self::assertSame(200, $this->responseCode()); + + $list = $this->authJson( + 'GET', + sprintf('/api/v1/resource/%s/blocks?from=%d&to=%d', $resource->getUuid(), $start - 3600, $start + 86400), + $user, + ); + + self::assertCount(0, $list['data']); + } + + public function testAnotherClinicCannotBlockTheResource(): void + { + [, $resource] = $this->clinicWithResource(); + [$other] = $this->clinicWithResource(); + + $start = time() + 86400; + + $this->authJson('POST', "/api/v1/resource/{$resource->getUuid()}/blocks", $other, [ + 'starts_at' => $start, + 'ends_at' => $start + 3600, + ]); + + self::assertSame(404, $this->responseCode()); + } +}