Files
clinicpro/src/Resource/Service/ResourceAvailabilityService.php
T
hamedandClaude Opus 5 581a553516 refactor(resource): drop the branch domain from resources
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>
2026-08-03 14:57:47 +03:30

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);
}
}