feat(appointments): a resource-first view on the timeline

The appointments page only ever showed one doctor's row, but in the
resource-first model a single appointment can hold a room and a device at
the same time, and that — not the doctor's schedule — is what runs the
capacity out. An hour could look free on the doctor's lane while the only
alexandrite laser was already taken.

A third view, "منابع", draws one lane per resource for the selected day.
Blocks come from resource_occupancy rather than the appointment: that range
includes the device's setup and cleanup minutes and is the same range the
availability engine treats as busy. A multi-segment appointment therefore
shows up on every resource it holds, and each block links to the
appointment it belongs to.

GET /api/v1/resources/timeline keeps a fixed query count — one for
occupancy, one for shifts, one for the patient names — instead of one per
resource.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
hamed
2026-08-02 14:14:10 +03:30
co-authored by Claude Opus 5
parent eeb9ae851a
commit 444ebc897a
9 changed files with 824 additions and 36 deletions
@@ -132,4 +132,58 @@ class ResourceOccupancyRepository extends ServiceEntityRepository
{
return $this->findBy(['appointmentId' => $appointmentId]);
}
/**
* ردیف‌های اشغالِ یک روز برای تایم‌لاین منابع — **یک کوئری برای همهٔ منابع**.
*
* برخلاف `busyByResource()` که فقط بازه می‌خواهد، اینجا شناسهٔ نوبت و نام بخش هم
* لازم است تا هر بلوک بگوید مالِ کدام بیمار و کدام مرحله است. آرایه برمی‌گردد نه
* entity، چون هیچ‌کدام از این ردیف‌ها قرار نیست تغییر کند.
*
* @param int[] $resourceIds
* @return array<int, list<array{uuid: string, starts_at: int, ends_at: int, status: string, segment_name: ?string, appointment_id: ?int}>>
*/
public function dayByResource(array $resourceIds, int $from, int $to): array
{
if ($resourceIds === []) {
return [];
}
$rows = $this->createQueryBuilder('o')
->select(
'IDENTITY(o.resource) AS resource_id',
'o.uuid AS uuid',
'o.startsAt AS starts_at',
'o.endsAt AS ends_at',
'o.status AS status',
'o.segmentName AS segment_name',
'o.appointmentId AS appointment_id',
)
->where('o.resource IN (:ids)')
->andWhere('o.startsAt < :to')
->andWhere('o.endsAt > :from')
// ردیف آزادشده تاریخچه است؛ در تایم‌لاینِ «الان چه چیزی گرفته است» جا ندارد.
->andWhere('o.status IN (:blocking)')
->setParameter('blocking', ResourceOccupancy::BLOCKING_STATUSES)
->setParameter('ids', $resourceIds)
->setParameter('from', $from)
->setParameter('to', $to)
->orderBy('o.startsAt', 'ASC')
->getQuery()
->getArrayResult();
$byResource = [];
foreach ($rows as $row) {
$byResource[(int) $row['resource_id']][] = [
'uuid' => (string) $row['uuid'],
'starts_at' => (int) $row['starts_at'],
'ends_at' => (int) $row['ends_at'],
'status' => (string) $row['status'],
'segment_name' => $row['segment_name'] !== null ? (string) $row['segment_name'] : null,
'appointment_id' => $row['appointment_id'] !== null ? (int) $row['appointment_id'] : null,
];
}
return $byResource;
}
}