feat(appointments): resources share the doctors' timeline

Three views become two. The resource lanes were a separate tab, which meant
reading a doctor's free hour on one screen and the laser's on another and
matching them by eye — while in the resource-first model it is the device and
the room that decide whether that hour is really free. They now sit under the
same "زمانبندی" view, below the doctor's slots.

Each lane says how much of its shift is still free, and that number respects
capacity: a minute counts as busy only once the overlapping bookings reach
the resource's capacity, so a three-bed room with two appointments is still
open. Treating it otherwise would silently turn every multi-capacity resource
into a single-capacity one. ResourceFreeTimeCalculator does the sweep and
carries nine cases of its own.

only_bookable=1 keeps resources with no service offering out of the view;
they could only ever render an empty lane. On the seeded clinic that is five
resources down to two.

Two ruler defects the screenshot caught: hours rendered in Latin digits, and
the last label was half-clipped by the container so 21 read as 2.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
hamed
2026-08-02 16:14:04 +03:30
co-authored by Claude Opus 5
parent b8e8580867
commit 5e87bbc18b
12 changed files with 455 additions and 46 deletions
+32 -11
View File
@@ -33,6 +33,8 @@ class ResourceController extends BaseController
private readonly \App\Resource\Service\ResourceServiceAssignmentService $serviceOfferings,
private readonly \App\Resource\Repository\ResourceCalendarRepository $calendars,
private readonly \App\Appointment\Repository\AppointmentRepository $appointments,
private readonly \App\Resource\Repository\ResourceServiceOfferingRepository $offerings,
private readonly \App\Resource\Service\ResourceFreeTimeCalculator $freeTime,
) {}
#[Route('/api/v1/resources', name: 'resource_list', methods: ['GET'])]
@@ -212,35 +214,54 @@ class ResourceController extends BaseController
'skillUuid' => null,
]);
// منبعی که هنوز به هیچ سرویسی وصل نیست، در تایم‌لاینِ نوبت‌دهی ردیفِ همیشه‌خالی
// می‌سازد؛ با `only_bookable=1` کنار گذاشته می‌شود.
if ($request->query->getBoolean('only_bookable')) {
$bookable = $this->offerings->bookableResourceIds(
array_map(static fn (ClinicResource $r): int => (int) $r->getId(), $resources),
);
$resources = array_values(array_filter(
$resources,
static fn (ClinicResource $r): bool => in_array((int) $r->getId(), $bookable, true),
));
}
$ids = array_map(static fn (ClinicResource $r): int => (int) $r->getId(), $resources);
$occupancy = $this->occupancy->dayByResource($ids, $from, $to);
$shifts = $this->calendars->findForResources($ids);
$calendars = $this->calendars->findForResources($ids);
$dayOfWeek = $this->dayOfWeek($from);
$patients = $this->patientLabels($occupancy);
return $this->success([
'date' => $from,
'day_of_week' => $dayOfWeek,
'resources' => array_map(function (ClinicResource $r) use ($occupancy, $shifts, $dayOfWeek, $patients): array {
'resources' => array_map(function (ClinicResource $r) use ($occupancy, $calendars, $dayOfWeek, $patients, $from): array {
$id = (int) $r->getId();
return [
'uuid' => $r->getUuid(),
'name' => $r->getName(),
'type_name' => $r->getType()->getName(),
'address_name' => $r->getAddress()->getName(),
'capacity' => $r->getCapacity(),
'shifts' => array_values(array_map(
$shifts = array_values(array_map(
static fn (\App\Resource\Entity\ResourceCalendar $c): array => [
'start_minute' => $c->getStartMinute(),
'end_minute' => $c->getEndMinute(),
],
array_filter(
$shifts[$id] ?? [],
$calendars[$id] ?? [],
static fn (\App\Resource\Entity\ResourceCalendar $c): bool => $c->getDayOfWeek() === $dayOfWeek,
),
)),
));
$load = $this->freeTime->forDay($shifts, $occupancy[$id] ?? [], $from, $r->getCapacity());
return [
'uuid' => $r->getUuid(),
'name' => $r->getName(),
'type_name' => $r->getType()->getName(),
'address_name' => $r->getAddress()->getName(),
'capacity' => $r->getCapacity(),
'shifts' => $shifts,
'shift_minutes' => $load['shift_minutes'],
'busy_minutes' => $load['busy_minutes'],
'free_minutes' => $load['free_minutes'],
'items' => array_map(
static fn (array $row): array => $row + ($patients[$row['appointment_id']] ?? [
'patient_name' => null,
@@ -67,6 +67,33 @@ class ResourceServiceOfferingRepository extends ServiceEntityRepository
}
/** @return int[] شناسهٔ منابعی که این سرویس را فعال ارائه می‌دهند */
/**
* کدام‌یک از این منابع دست‌کم یک سرویسِ فعال ارائه می‌دهند — یعنی «قابل رزرو»اند.
*
* عکسِ `activeResourceIdsFor()`: آن از سرویس به منبع می‌رود، این از منبع به سرویس.
* تایم‌لاین نوبت‌ها با همین فیلتر می‌کند تا دستگاهی که هنوز به هیچ سرویسی وصل نشده،
* ردیفِ همیشه‌خالی نسازد.
*
* @param int[] $resourceIds
* @return int[]
*/
public function bookableResourceIds(array $resourceIds): array
{
if ($resourceIds === []) {
return [];
}
$rows = $this->createQueryBuilder('o')
->select('DISTINCT IDENTITY(o.resource) AS resource_id')
->where('o.resource IN (:ids)')
->andWhere('o.active = true')
->setParameter('ids', $resourceIds)
->getQuery()
->getArrayResult();
return array_map(static fn (array $r): int => (int) $r['resource_id'], $rows);
}
public function activeResourceIdsFor(ServiceItem $item, ?ResourceType $type = null): array
{
$qb = $this->createQueryBuilder('o')
@@ -0,0 +1,80 @@
<?php
namespace App\Resource\Service;
/**
* وقت آزادِ یک منبع در یک روز، با **ظرفیت** حساب می‌شود نه با «پر/خالی».
*
* منبعی که ظرفیتش ۳ است و در یک ساعت دو نوبت دارد، هنوز یک جا دارد؛ اگر آن ساعت «پر»
* شمرده شود، اتاق سه‌تخته عملاً یک‌تخته می‌شود. پس دقیقه‌ای اشغال است که تعداد
* بازه‌های هم‌پوشانش به ظرفیت رسیده باشد.
*
* جدا از `ResourceAvailabilityService` است: آن می‌گوید منبع کِی **باز** است (شیفت منهای
* تعطیلات)، این می‌گوید از آن بازهٔ باز، چقدرش هنوز جا دارد.
*/
final class ResourceFreeTimeCalculator
{
/**
* @param list<array{start_minute: int, end_minute: int}> $shifts دقیقه از نیمه‌شب
* @param list<array{starts_at: int, ends_at: int}> $occupancy timestamp مطلق
*
* @return array{shift_minutes: int, busy_minutes: int, free_minutes: int}
*/
public function forDay(array $shifts, array $occupancy, int $dayStart, int $capacity): array
{
$shiftMinutes = 0;
foreach ($shifts as $shift) {
$shiftMinutes += max(0, $shift['end_minute'] - $shift['start_minute']);
}
if ($shiftMinutes === 0 || $occupancy === []) {
return [
'shift_minutes' => $shiftMinutes,
'busy_minutes' => 0,
'free_minutes' => $shiftMinutes,
];
}
$capacity = max(1, $capacity);
// جاروی خطی روی مرزها: هر بازه یک +۱ در شروع و یک −۱ در پایان می‌گذارد. بین دو
// مرزِ متوالی تعداد هم‌پوشانی ثابت است، پس همان‌جا یک‌بار سنجیده می‌شود. شمارشِ
// دقیقه‌به‌دقیقه هم جواب می‌داد ولی ۱۴۴۰ گام به‌ازای هر منبع است.
$delta = [];
foreach ($occupancy as $row) {
$from = (int) floor(($row['starts_at'] - $dayStart) / 60);
$to = (int) ceil(($row['ends_at'] - $dayStart) / 60);
$delta[$from] = ($delta[$from] ?? 0) + 1;
$delta[$to] = ($delta[$to] ?? 0) - 1;
}
ksort($delta);
/** @var list<array{0: int, 1: int}> $fullRanges بازه‌هایی که ظرفیت در آن‌ها تمام است */
$fullRanges = [];
$points = array_keys($delta);
$open = 0;
foreach ($points as $i => $minute) {
$open += $delta[$minute];
$next = $points[$i + 1] ?? null;
if ($next !== null && $open >= $capacity) {
$fullRanges[] = [$minute, $next];
}
}
$busy = 0;
foreach ($shifts as $shift) {
foreach ($fullRanges as [$from, $to]) {
$busy += max(0, min($shift['end_minute'], $to) - max($shift['start_minute'], $from));
}
}
return [
'shift_minutes' => $shiftMinutes,
'busy_minutes' => $busy,
'free_minutes' => max(0, $shiftMinutes - $busy),
];
}
}