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;
}
}
@@ -31,6 +31,8 @@ class ResourceController extends BaseController
private readonly SkillAssignmentService $skills,
private readonly ResourceOccupancyRepository $occupancy,
private readonly \App\Resource\Service\ResourceServiceAssignmentService $serviceOfferings,
private readonly \App\Resource\Repository\ResourceCalendarRepository $calendars,
private readonly \App\Appointment\Repository\AppointmentRepository $appointments,
) {}
#[Route('/api/v1/resources', name: 'resource_list', methods: ['GET'])]
@@ -177,6 +179,135 @@ class ResourceController extends BaseController
return $this->success($resource->toArray());
}
/**
* تایم‌لاین یک روز به تفکیک منبع — «کدام دستگاه/اتاق/پزشک کِی گرفته است».
*
* نمای نوبت‌ها تا امروز فقط ردیف پزشک را نشان می‌داد؛ در مدل منبع‌محور، یک نوبت
* می‌تواند هم‌زمان اتاق و دستگاه را بگیرد و همان چیزی است که ظرفیت را تمام می‌کند.
*
* ردیف اشغال از `resource_occupancy` می‌آید، نه از خودِ نوبت: بازهٔ آن شاملِ
* آماده‌سازی و تمیزکاری منبع هم هست و همان بازه‌ای است که موتور جستجو می‌بیند.
*/
#[Route('/api/v1/resources/timeline', name: 'resource_timeline', methods: ['GET'])]
public function timeline(#[CurrentUser] User $user, Request $request): JsonResponse
{
$this->denyUnlessGranted($user, 'view');
[$entityType, $entityId] = $this->context->pair($user);
$dateParam = $request->query->get('date');
$from = $this->dayStart(is_string($dateParam) ? $dateParam : null);
if ($from === null) {
return $this->error(ErrorCodes::ERR_VALIDATION_002, 'تاریخ باید به شکل YYYY-MM-DD باشد', 422, 'date');
}
$to = $from + 86400;
$addressUuid = $request->query->get('address_uuid');
$resources = $this->resources->findForPair($entityType, $entityId, [
'address' => is_string($addressUuid) && $addressUuid !== '' ? $this->context->address($user, $addressUuid) : null,
'type' => null,
'active' => true,
'skillUuid' => null,
]);
$ids = array_map(static fn (ClinicResource $r): int => (int) $r->getId(), $resources);
$occupancy = $this->occupancy->dayByResource($ids, $from, $to);
$shifts = $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 {
$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(
static fn (\App\Resource\Entity\ResourceCalendar $c): array => [
'start_minute' => $c->getStartMinute(),
'end_minute' => $c->getEndMinute(),
],
array_filter(
$shifts[$id] ?? [],
static fn (\App\Resource\Entity\ResourceCalendar $c): bool => $c->getDayOfWeek() === $dayOfWeek,
),
)),
'items' => array_map(
static fn (array $row): array => $row + ($patients[$row['appointment_id']] ?? [
'patient_name' => null,
'appointment_uuid' => null,
'appointment_status' => null,
]),
$occupancy[$id] ?? [],
),
];
}, $resources),
]);
}
/** نیمه‌شبِ روز خواسته‌شده؛ بدون پارامتر یعنی امروز. `null` یعنی قالب تاریخ غلط بود. */
private function dayStart(?string $date): ?int
{
if ($date === null || $date === '') {
return strtotime('today midnight') ?: null;
}
if (preg_match('/^\d{4}-\d{2}-\d{2}$/', $date) !== 1) {
return null;
}
return strtotime($date . ' midnight') ?: null;
}
/** ۰ = شنبه — همان قرارداد تقویم منبع و ساعت کاری شعبه. */
private function dayOfWeek(int $timestamp): int
{
// date('w') یکشنبه را ۰ می‌گیرد؛ شنبه باید ۰ شود.
return (int) ((((int) date('w', $timestamp)) + 1) % 7);
}
/**
* نام بیمار و وضعیت نوبتِ هر ردیف اشغال — با **یک** کوئری برای کل روز.
*
* @param array<int, list<array<string, mixed>>> $occupancy
* @return array<int, array{patient_name: ?string, appointment_uuid: string, appointment_status: string}>
*/
private function patientLabels(array $occupancy): array
{
$ids = [];
foreach ($occupancy as $rows) {
foreach ($rows as $row) {
if ($row['appointment_id'] !== null) {
$ids[(int) $row['appointment_id']] = true;
}
}
}
if ($ids === []) {
return [];
}
$labels = [];
foreach ($this->appointments->findBy(['id' => array_keys($ids)]) as $appointment) {
$labels[(int) $appointment->getId()] = [
'patient_name' => $appointment->getPatientName(),
'appointment_uuid' => $appointment->getUuid(),
'appointment_status' => $appointment->getStatus(),
];
}
return $labels;
}
/** جایگزینی کامل مهارت‌های منبع: مهارتی که در بدنه نیست، برداشته می‌شود. */
#[Route('/api/v1/resource/{uuid}/skills', name: 'resource_skills_replace', methods: ['PUT'])]
public function replaceSkills(#[CurrentUser] User $user, string $uuid, Request $request): JsonResponse