Resources never needed a branch: devices and rooms belong to the clinic itself, and the picker always had exactly one option — a mandatory click that decided nothing. - `address_uuid` is now optional on resource and pool creation; when it is missing the environment's own address is used. Clients still sending it keep working. - The panel no longer asks for or displays a branch anywhere: resource form, list column and filter, pool form and column, detail row, and the resource-first booking page. - Availability no longer gates on `doctor_addresses.active`. That gate shut down every device of a clinic whose address row happened to be inactive, with a message no page in the panel could act on — no endpoint writes that column at all. `address_id` stays on the resource: the timezone and the tenant pair are derived from it. It is simply no longer the user's decision. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
277 lines
10 KiB
PHP
277 lines
10 KiB
PHP
<?php
|
|
|
|
namespace App\Resource\Service;
|
|
|
|
use App\Resource\Entity\ClinicResource;
|
|
use App\Resource\Entity\ResourceException;
|
|
use App\Resource\Repository\NationalHolidayRepository;
|
|
use App\Resource\Repository\ResourceCalendarRepository;
|
|
use App\Resource\Repository\ResourceExceptionRepository;
|
|
use App\Resource\Repository\TenantHolidayOverrideRepository;
|
|
use App\Resource\ValueObject\DayAvailability;
|
|
use App\Shared\Time\TimeInterval;
|
|
|
|
/**
|
|
* «ساعت آزادِ خامِ یک منبع در یک بازه» — لایههای ۱ تا ۴ از کسرِ بند ۹ مستند:
|
|
*
|
|
* ساعت کاری شعبه ∩ شیفت منبع − تعطیلات رسمی − استثناهای منبع
|
|
*
|
|
* **نوبتهای ثبتشده و رزروهای موقت اینجا کسر نمیشوند** — آنها تسک ۰۶/۰۷ هستند و
|
|
* تقاطع چند منبع هم همانجاست. اسم متد `rawAvailability` عمدی است تا کسی این خروجی
|
|
* را «وقت قابل رزرو» نپندارد.
|
|
*
|
|
* این سرویس هیچ ارتباطی با `SlotCalculatorService` ندارد و آن را صدا نمیزند: مسیر
|
|
* اسلاتیِ موجود در این فاز قفل است ({@see docs/new_feture/taskes/_shared/red-lines.md}).
|
|
*/
|
|
final class ResourceAvailabilityService
|
|
{
|
|
/** حداکثر بازهٔ قابل پرسوجو — بیحد گذاشتنش یعنی یک درخواست میتواند سالها را بسازد. */
|
|
public const MAX_DAYS = 92;
|
|
|
|
public const DAY_SECONDS = 86400;
|
|
|
|
public function __construct(
|
|
private readonly ResourceCalendarRepository $calendars,
|
|
private readonly ResourceExceptionRepository $exceptions,
|
|
private readonly NationalHolidayRepository $holidays,
|
|
private readonly TenantHolidayOverrideRepository $overrides,
|
|
) {}
|
|
|
|
/**
|
|
* @param int $from نیمهشبِ روز آغاز (به وقت محلی شعبه)
|
|
* @param int $to نیمهشبِ روز پایان — خودِ این روز هم شامل است
|
|
* @return DayAvailability[]
|
|
*/
|
|
public function rawAvailability(ClinicResource $resource, int $from, int $to): array
|
|
{
|
|
$timezone = new \DateTimeZone($resource->getAddress()->getTimezone());
|
|
$startDay = $this->midnight($from, $timezone);
|
|
$endDay = $this->midnight($to, $timezone);
|
|
|
|
// شیفتها و ساعت شعبه یک بار خوانده میشوند، نه per روز.
|
|
$shiftsByDay = $this->shiftsByDay($resource);
|
|
|
|
$holidayMap = $this->holidays->mapForRange($startDay, $endDay);
|
|
$overrideMap = $this->overrides->mapForRange(
|
|
$resource->getEntityType(),
|
|
$resource->getEntityId(),
|
|
$startDay,
|
|
$endDay,
|
|
);
|
|
|
|
$exceptions = $this->exceptions->findOverlapping(
|
|
$resource,
|
|
$startDay,
|
|
$endDay + self::DAY_SECONDS,
|
|
);
|
|
|
|
$days = [];
|
|
|
|
for ($day = $startDay; $day <= $endDay; $day = $this->nextMidnight($day, $timezone)) {
|
|
$days[] = $this->buildDay(
|
|
$resource,
|
|
$day,
|
|
$timezone,
|
|
$shiftsByDay,
|
|
$holidayMap,
|
|
$overrideMap,
|
|
$exceptions,
|
|
);
|
|
}
|
|
|
|
return $days;
|
|
}
|
|
|
|
/**
|
|
* همان `rawAvailability` برای چند منبع، ولی با خواندنِ دستهای.
|
|
*
|
|
* تعطیلات و استثناهای محیط و ساعت شعبه برای همهٔ منابع یکیاند و بیرون حلقه خوانده
|
|
* میشوند؛ شیفت و استثنای هر منبع هم با یک کوئری برای همه میآید. بدون این، گزارشِ
|
|
* چهل منبع دویست کوئری میزد.
|
|
*
|
|
* @param ClinicResource[] $resources همهٔ آنها باید یک شعبه داشته باشند
|
|
* @return array<int, list<DayAvailability>> کلید: شناسهٔ منبع
|
|
*/
|
|
public function rawAvailabilityForAll(array $resources, int $from, int $to): array
|
|
{
|
|
if ($resources === []) {
|
|
return [];
|
|
}
|
|
|
|
$first = $resources[array_key_first($resources)];
|
|
$timezone = new \DateTimeZone($first->getAddress()->getTimezone());
|
|
$startDay = $this->midnight($from, $timezone);
|
|
$endDay = $this->midnight($to, $timezone);
|
|
|
|
$holidayMap = $this->holidays->mapForRange($startDay, $endDay);
|
|
$overrideMap = $this->overrides->mapForRange(
|
|
$first->getEntityType(),
|
|
$first->getEntityId(),
|
|
$startDay,
|
|
$endDay,
|
|
);
|
|
|
|
|
|
$ids = array_map(static fn (ClinicResource $r): int => (int) $r->getId(), $resources);
|
|
$shiftsById = $this->calendars->findForResources($ids);
|
|
$exceptionsById = $this->exceptions->findOverlappingForResources($ids, $startDay, $endDay + self::DAY_SECONDS);
|
|
|
|
$out = [];
|
|
|
|
foreach ($resources as $resource) {
|
|
$id = (int) $resource->getId();
|
|
$byDay = [];
|
|
|
|
foreach ($shiftsById[$id] ?? [] as $shift) {
|
|
if ($shift->isActive()) {
|
|
$byDay[$shift->getDayOfWeek()][] = new TimeInterval($shift->getStartMinute(), $shift->getEndMinute());
|
|
}
|
|
}
|
|
|
|
$shiftsByDay = array_map(TimeInterval::mergeAll(...), $byDay);
|
|
$days = [];
|
|
|
|
for ($day = $startDay; $day <= $endDay; $day = $this->nextMidnight($day, $timezone)) {
|
|
$days[] = $this->buildDay(
|
|
$resource,
|
|
$day,
|
|
$timezone,
|
|
$shiftsByDay,
|
|
$holidayMap,
|
|
$overrideMap,
|
|
$exceptionsById[$id] ?? [],
|
|
);
|
|
}
|
|
|
|
$out[$id] = $days;
|
|
}
|
|
|
|
return $out;
|
|
}
|
|
|
|
/**
|
|
* @param array<int, list<TimeInterval>> $shiftsByDay
|
|
* @param array<int, \App\Resource\Entity\NationalHoliday> $holidayMap
|
|
* @param array<int, \App\Resource\Entity\TenantHolidayOverride> $overrideMap
|
|
* @param ResourceException[] $exceptions
|
|
*/
|
|
private function buildDay(
|
|
ClinicResource $resource,
|
|
int $midnight,
|
|
\DateTimeZone $timezone,
|
|
array $shiftsByDay,
|
|
array $holidayMap,
|
|
array $overrideMap,
|
|
array $exceptions,
|
|
): DayAvailability {
|
|
$reasons = [];
|
|
$dayOfWeek = $this->dayOfWeek($midnight, $timezone);
|
|
|
|
if (!$resource->isActive()) {
|
|
return new DayAvailability($midnight, $dayOfWeek, [], ['resource_inactive']);
|
|
}
|
|
|
|
/**
|
|
* فعالبودنِ آدرس اینجا سنجیده **نمیشود**.
|
|
*
|
|
* منابع دامنهٔ شعبه ندارند: آدرس فقط حاملِ منطقهٔ زمانی و جفتِ محیط است و هیچ
|
|
* جای پنل هم روشن/خاموشش نمیکند. وقتی میشد، یک ردیفِ قدیمیِ `active = 0` کلِ
|
|
* دستگاههای کلینیک را با پیامی خاموش میکرد که کاربر راهی برای رفعش نداشت.
|
|
*/
|
|
|
|
$override = $overrideMap[$midnight] ?? null;
|
|
$holiday = $holidayMap[$midnight] ?? null;
|
|
|
|
// استثنای محیط بر تقویم رسمی مقدم است — در هر دو جهت.
|
|
$closedByHoliday = $override !== null ? !$override->isWorking() : $holiday !== null;
|
|
|
|
if ($closedByHoliday) {
|
|
$reasons[] = $override !== null ? 'tenant_holiday' : 'national_holiday';
|
|
|
|
return new DayAvailability($midnight, $dayOfWeek, [], $reasons);
|
|
}
|
|
|
|
$shifts = $shiftsByDay[$dayOfWeek] ?? [];
|
|
|
|
if ($shifts === []) {
|
|
return new DayAvailability($midnight, $dayOfWeek, [], ['no_shift']);
|
|
}
|
|
|
|
$absolute = array_map(
|
|
static fn (TimeInterval $i): TimeInterval => $i->minutesToAbsolute($midnight),
|
|
$shifts,
|
|
);
|
|
|
|
$blocking = [];
|
|
foreach ($exceptions as $exception) {
|
|
if ($exception->getStartsAt() < $this->endOfDay($midnight, $timezone)
|
|
&& $exception->getEndsAt() > $midnight
|
|
) {
|
|
$blocking[] = new TimeInterval($exception->getStartsAt(), $exception->getEndsAt());
|
|
}
|
|
}
|
|
|
|
if ($blocking !== []) {
|
|
$before = $absolute;
|
|
$absolute = TimeInterval::subtractAll($absolute, $blocking);
|
|
|
|
if ($absolute !== $before) {
|
|
$reasons[] = 'exception';
|
|
}
|
|
}
|
|
|
|
return new DayAvailability($midnight, $dayOfWeek, $absolute, $reasons);
|
|
}
|
|
|
|
/** @return array<int, list<TimeInterval>> روز هفته => بازههای دقیقهای */
|
|
private function shiftsByDay(ClinicResource $resource): array
|
|
{
|
|
$byDay = [];
|
|
|
|
foreach ($this->calendars->findForResource($resource) as $shift) {
|
|
if (!$shift->isActive()) {
|
|
continue;
|
|
}
|
|
|
|
$byDay[$shift->getDayOfWeek()][] = new TimeInterval($shift->getStartMinute(), $shift->getEndMinute());
|
|
}
|
|
|
|
return array_map(TimeInterval::mergeAll(...), $byDay);
|
|
}
|
|
|
|
|
|
/** ۰=شنبه … ۶=جمعه — همان قرارداد بقیهٔ سامانه، نه `w` استاندارد PHP. */
|
|
public function dayOfWeek(int $timestamp, \DateTimeZone $timezone): int
|
|
{
|
|
$date = (new \DateTimeImmutable('@' . $timestamp))->setTimezone($timezone);
|
|
|
|
return ((int) $date->format('w') + 1) % 7;
|
|
}
|
|
|
|
public function midnight(int $timestamp, \DateTimeZone $timezone): int
|
|
{
|
|
return (new \DateTimeImmutable('@' . $timestamp))
|
|
->setTimezone($timezone)
|
|
->setTime(0, 0)
|
|
->getTimestamp();
|
|
}
|
|
|
|
/**
|
|
* روز بعد از روی تقویم گرفته میشود نه با `+86400`: در منطقههایی که ساعت تابستانی
|
|
* دارند، روز ۲۳ یا ۲۵ ساعته میشود و جمعِ ثابت، نیمهشب را جابهجا میکند.
|
|
*/
|
|
private function nextMidnight(int $midnight, \DateTimeZone $timezone): int
|
|
{
|
|
return (new \DateTimeImmutable('@' . $midnight))
|
|
->setTimezone($timezone)
|
|
->modify('+1 day')
|
|
->setTime(0, 0)
|
|
->getTimestamp();
|
|
}
|
|
|
|
private function endOfDay(int $midnight, \DateTimeZone $timezone): int
|
|
{
|
|
return $this->nextMidnight($midnight, $timezone);
|
|
}
|
|
}
|