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