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