Book for a resource, and manage the services a resource offers

POST /api/v1/appointment now accepts resource_uuid. When the resource is a
doctor the doctor is inferred from it, and the booking clinic is derived from
the resource's branch — sending clinic_uuid separately was only ever a way to
make the two disagree. The doctor-only path is untouched, which the public site
depends on since it sends nothing else.

Two guards before the booking is built. The resource must belong to the same
environment as the booking: it arrives as a uuid from the request body, so
TenantFilter does not cover it and without the check a patient could attach
another clinic's device to this clinic's appointment. And a resource that does
not offer the requested service is refused up front rather than discovered when
the patient turns up. That second check runs over the items the calculator
already validated rather than re-reading uuids, which is also why the
tenant-lookup inventory stays where it was.

GET and PUT /api/v1/resource/{uuid}/services manage the offerings. The list
returns the effective duration and price along with which level produced each,
so the panel can label an empty cell "30 minutes — service default" instead of
leaving the user guessing whether it is unset or zero. PUT replaces wholesale,
like the skills endpoint: a row absent from the body is a row the user removed,
and an empty string clears an override back to inheritance rather than setting
zero.

findEligible now also orders by category coverage — a device registered for
"foot" sorts ahead for a foot service. Ordering, not filtering: a clinic that
categorised only some of its devices would otherwise lose the rest.

Thirteen tests across the two files. Suite 1304 green, phpstan at its 14-error
baseline.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
hamed
2026-08-01 22:20:41 +03:30
co-authored by Claude Opus 5
parent 0a2ba88808
commit a6acf3bfe2
8 changed files with 608 additions and 11 deletions
@@ -16,6 +16,7 @@ use App\Auth\Entity\User;
use App\Doctor\Repository\DoctorRepository;
use App\Shared\Service\InputValidator;
use App\Shared\Constant\ErrorCodes;
use App\Shared\Context\EntityContext;
use App\Shared\Controller\BaseController;
use Doctrine\ORM\OptimisticLockException;
use OpenApi\Attributes as OA;
@@ -41,6 +42,9 @@ class AppointmentController extends BaseController
private readonly \App\ClinicService\Repository\ServiceItemRepository $itemRepo,
private readonly \App\Staff\Repository\ClinicStaffRepository $staffRepo,
private readonly \App\Appointment\Repository\AppointmentEventRepository $eventRepo,
private readonly \App\Resource\Repository\ClinicResourceRepository $resources,
private readonly \App\Resource\Repository\ResourceServiceOfferingRepository $offerings,
private readonly \App\Clinic\Repository\ClinicRepository $clinicRepo,
private readonly \App\Appointment\Security\AppointmentAccessChecker $accessChecker,
private readonly \App\Appointment\Service\AppointmentInsuranceService $appointmentInsurance,
private readonly \App\Appointment\Service\ServiceBookingCalculator $serviceCalculator,
@@ -450,9 +454,34 @@ class AppointmentController extends BaseController
$serviceUuids = array_values(array_filter(array_map('trim', (array) ($data['service_item_uuids'] ?? []))));
$hasServices = $serviceUuids !== [];
/**
* منبع می‌تواند جای پزشک بنشیند: نوبتِ «دستگاه لیزر ۲» پزشکی ندارد که uuidش
* فرستاده شود. اگر منبع خودش پزشک باشد، پزشک از آن استنتاج می‌شود.
*/
$resourceUuid = trim((string) ($data['resource_uuid'] ?? ''));
$resource = null;
if ($resourceUuid !== '') {
$resource = $this->resources->findByUuid($resourceUuid);
if ($resource === null || !$resource->isActive()) {
return $this->error(ErrorCodes::ERR_VALIDATION_002, 'منبع یافت نشد', 422, 'resource_uuid');
}
if ($doctorUuid === '' && $resource->subject() instanceof \App\Doctor\Entity\Doctor) {
$doctorUuid = $resource->subject()->getUuid();
}
// رزرو **برای یک منبع** یعنی رزرو در شعبهٔ همان منبع؛ فرستادن جداگانهٔ
// `clinic_uuid` فقط راهی برای ناسازگار کردن این دو بود.
if ($clinicUuid === null && $resource->getAddress()->getClinicId() !== null) {
$clinicUuid = $this->clinicUuidOf($resource->getAddress()->getClinicId());
}
}
// در حالت سرویسی `slot_end` از سرویس‌ها ساخته می‌شود، پس نبودنش خطا نیست.
if (empty($doctorUuid) || $slotStart <= 0 || (!$hasServices && $slotEnd <= $slotStart)) {
return $this->error(ErrorCodes::ERR_VALIDATION_001, 'doctor_uuid، slot_start و slot_end الزامی است', 422);
if ($doctorUuid === '' || $slotStart <= 0 || (!$hasServices && $slotEnd <= $slotStart)) {
return $this->error(ErrorCodes::ERR_VALIDATION_001, 'doctor_uuid یا resource_uuid به‌همراه slot_start الزامی است', 422);
}
if ($slotStart < time()) {
@@ -481,6 +510,36 @@ class AppointmentController extends BaseController
$slotEnd = $duration->endFor($slotStart);
}
if ($resource !== null) {
/**
* منبع با uuid از بدنهٔ درخواست می‌آید و `TenantFilter` پوششش نمی‌دهد، پس
* بدون این بررسی بیمار می‌توانست دستگاه کلینیک دیگری را روی نوبت این کلینیک
* بنشاند.
*/
[$bookingType, $bookingId] = EntityContext::forBooking($doctor, $bookingClinic)->toEntityPair();
if ($resource->getEntityType() !== $bookingType || $resource->getEntityId() !== $bookingId) {
return $this->error(ErrorCodes::ERR_VALIDATION_002, 'منبع یافت نشد', 422, 'resource_uuid');
}
/**
* منبعی که این سرویس را نمی‌دهد، همین‌جا رد می‌شود نه وقتی بیمار سرِ قرار
* حاضر شده. روی `serviceItems`ِ خروجی calculator کار می‌کند نه uuidهای خام:
* مالکیت محیطشان همان‌جا سنجیده شده، پس جست‌وجوی تازه‌ای لازم نیست.
*/
foreach ($duration === null ? [] : $duration->serviceItems as $item) {
if ($this->offerings->hasAnyFor($item)
&& !in_array((int) $resource->getId(), $this->offerings->activeResourceIdsFor($item), true)) {
return $this->error(
ErrorCodes::ERR_VALIDATION_001,
'این منبع این سرویس را ارائه نمی‌دهد',
422,
'resource_uuid',
);
}
}
}
$forSelf = (bool) ($data['for_self'] ?? true);
// کد ملی و جنسیت بیمار همیشه الزامی است (چه برای خود، چه برای دیگری).
@@ -505,6 +564,7 @@ class AppointmentController extends BaseController
$appointment = new Appointment($doctor, $user, $slotStart, $slotEnd);
$appointment->setPatientNationalCode($nationalCode);
$appointment->setPatientGender($gender);
$appointment->setResource($resource);
/**
* سرویس‌ها و مدت **ذخیره** می‌شوند، نه فقط برای حساب‌کردن `slot_end` استفاده.
*
@@ -762,6 +822,12 @@ class AppointmentController extends BaseController
* که پیدا شد»: با چند برنامهٔ هم‌زمان، حدس‌زدن محل یعنی ثبت خاموشِ نوبت در جای
* اشتباه.
*/
/** uuid کلینیکِ یک شعبه — برای وقتی محل نوبت از منبع مشتق می‌شود. */
private function clinicUuidOf(int $clinicId): ?string
{
return $this->clinicRepo->find($clinicId)?->getUuid();
}
private function bookingClinic(Doctor $doctor, ?string $clinicUuid): ?Clinic
{
return $this->bookingContext->resolve($doctor, $clinicUuid);
@@ -30,6 +30,7 @@ class ResourceController extends BaseController
private readonly ResourceService $service,
private readonly SkillAssignmentService $skills,
private readonly ResourceOccupancyRepository $occupancy,
private readonly \App\Resource\Service\ResourceServiceAssignmentService $serviceOfferings,
) {}
#[Route('/api/v1/resources', name: 'resource_list', methods: ['GET'])]
@@ -126,6 +127,33 @@ class ResourceController extends BaseController
return $this->success(null);
}
/** سرویس‌هایی که این منبع می‌دهد، با مدت و قیمتِ حل‌شده و منبعِ هر عدد. */
#[Route('/api/v1/resource/{uuid}/services', name: 'resource_services_list', methods: ['GET'])]
public function services(#[CurrentUser] User $user, string $uuid): JsonResponse
{
$this->denyUnlessGranted($user, 'view');
return $this->success($this->serviceOfferings->listFor($this->context->resource($user, $uuid)));
}
/** جایگزینی کامل: سرویسی که در بدنه نیست، از این منبع برداشته می‌شود. */
#[Route('/api/v1/resource/{uuid}/services', name: 'resource_services_replace', methods: ['PUT'])]
public function replaceServices(#[CurrentUser] User $user, string $uuid, Request $request): JsonResponse
{
$this->denyUnlessGranted($user, 'update');
$data = json_decode($request->getContent(), true);
if (!is_array($data) || !is_array($data['services'] ?? null)) {
return $this->error(ErrorCodes::ERR_VALIDATION_002, 'فیلد services الزامی است', 422, 'services');
}
$resource = $this->context->resource($user, $uuid);
$this->serviceOfferings->replace($resource, $data['services']);
return $this->success($this->serviceOfferings->listFor($resource));
}
/** جایگزینی کامل مهارت‌های منبع: مهارتی که در بدنه نیست، برداشته می‌شود. */
#[Route('/api/v1/resource/{uuid}/skills', name: 'resource_skills_replace', methods: ['PUT'])]
public function replaceSkills(#[CurrentUser] User $user, string $uuid, Request $request): JsonResponse
@@ -141,6 +141,22 @@ class ClinicResourceRepository extends ServiceEntityRepository
->setParameter('skillCount', count(array_unique($skillIds)));
}
/**
* منابعی که دستهٔ این سرویس را پوشش می‌دهند مقدم‌اند — «این دستگاه برای پا است»
* وقتی سرویس هم زیر «پا» است. ترتیب است نه فیلتر: منبعی که دسته‌اش ثبت نشده
* همچنان کاندید می‌ماند، وگرنه کلینیکی که فقط بعضی دستگاه‌ها را دسته‌بندی کرده
* بقیه را از دست می‌داد.
*/
if ($service?->getCatalogCategory() !== null) {
$qb->leftJoin('r.categories', 'rc', 'WITH', 'rc = :serviceCategory')
->setParameter('serviceCategory', $service->getCatalogCategory())
->addSelect('CASE WHEN rc.id IS NULL THEN 1 ELSE 0 END AS HIDDEN category_rank')
->orderBy('category_rank', 'ASC')
->addOrderBy('r.name', 'ASC');
return $qb->getQuery()->getResult();
}
return $qb->orderBy('r.name', 'ASC')->getQuery()->getResult();
}
@@ -0,0 +1,127 @@
<?php
namespace App\Resource\Service;
use App\ClinicService\Repository\ServiceItemRepository;
use App\ClinicService\Service\ResourceServiceResolver;
use App\Resource\Entity\ClinicResource;
use App\Resource\Entity\ResourceServiceOffering;
use App\Resource\Repository\ResourceServiceOfferingRepository;
use App\Shared\Constant\ErrorCodes;
use App\Shared\Exception\AppException;
use Doctrine\ORM\EntityManagerInterface;
/**
* سرویس‌هایی که یک منبع ارائه می‌دهد — خواندن با مقادیرِ **حل‌شده** و جایگزینی کامل.
*
* جایگزینی کامل است نه افزودنی: تب پنل فهرست را همان‌طور که هست می‌فرستد، و ردیفی که
* در بدنه نیست یعنی کاربر برش داشته. همان قرارداد `PUT .../skills`.
*/
final class ResourceServiceAssignmentService
{
public function __construct(
private readonly ResourceServiceOfferingRepository $offerings,
private readonly ServiceItemRepository $items,
private readonly ResourceServiceResolver $resolver,
private readonly EntityManagerInterface $em,
) {}
/**
* فهرست سرویس‌های منبع، با مقدارِ مؤثر و اینکه هر عدد از کجا آمده.
*
* پنل باید کنار خانهٔ خالی بنویسد «۳۰ دقیقه — پیش‌فرض سرویس»؛ بدون مقدار مؤثر،
* کاربر تفاوت «تنظیم نشده» و «صفر» را نمی‌فهمد.
*
* @return list<array<string, mixed>>
*/
public function listFor(ClinicResource $resource): array
{
$rows = [];
foreach ($this->offerings->findForResource($resource) as $offering) {
$item = $offering->getServiceItem();
$spec = $this->resolver->resolve($resource, $item, $resource->getAddress());
$rows[] = $offering->toArray() + [
'effective_duration_minutes' => $spec->durationMinutes,
'effective_price_rials' => $spec->priceRials,
'duration_source' => $spec->durationSource,
'price_source' => $spec->priceSource,
];
}
return $rows;
}
/**
* @param list<array<string, mixed>> $rows هر ردیف: service_uuid و به‌اختیار duration_minutes/price_rials/active
*
* @throws AppException ۴۲۲ روی سرویس ناموجود یا سرویسِ محیط دیگر
*/
public function replace(ClinicResource $resource, array $rows): void
{
$keep = [];
foreach ($rows as $row) {
$uuid = is_string($row['service_uuid'] ?? null) ? trim($row['service_uuid']) : '';
if ($uuid === '') {
throw new AppException(ErrorCodes::ERR_VALIDATION_002, 'فیلد service_uuid الزامی است', 422, 'service_uuid');
}
$item = $this->items->findByUuid($uuid);
if ($item === null) {
throw new AppException(ErrorCodes::ERR_VALIDATION_002, 'سرویس یافت نشد', 422, 'service_uuid');
}
$offering = $this->offerings->findOneFor($resource, $item);
if ($offering === null) {
// سازندهٔ رابطه خودش محیط را می‌سنجد و سرویسِ کلینیک دیگر را رد می‌کند.
try {
$offering = new ResourceServiceOffering($resource, $item);
} catch (\InvalidArgumentException) {
throw new AppException(
ErrorCodes::ERR_VALIDATION_001,
'سرویس انتخاب‌شده به این محل نوبت‌دهی تعلق ندارد',
422,
'service_uuid',
);
}
$this->em->persist($offering);
}
$offering
->setDurationMinutes($this->intOrNull($row['duration_minutes'] ?? null))
->setPriceRials($this->intOrNull($row['price_rials'] ?? null))
->setActive((bool) ($row['active'] ?? true));
$keep[(int) $item->getId()] = true;
}
// ردیفی که در بدنه نیامده، برداشته می‌شود — جایگزینی کامل.
foreach ($this->offerings->findForResource($resource) as $existing) {
if (!isset($keep[(int) $existing->getServiceItem()->getId()])) {
$this->em->remove($existing);
}
}
$this->em->flush();
}
/** رشتهٔ خالی و `null` هر دو یعنی «ارث از سطح بالاتر»، نه صفر. */
private function intOrNull(mixed $value): ?int
{
if ($value === null || $value === '') {
return null;
}
if (!is_numeric($value)) {
throw new AppException(ErrorCodes::ERR_VALIDATION_001, 'مقدار عددی نامعتبر است', 422);
}
return (int) $value;
}
}