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:
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user