feat(resource): resource calendars, exceptions and national holidays

Section 9 of the design document builds free time by subtracting seven layers.
Four existed and all of them hung off the doctor. This adds the missing ones and
puts them on the resource:

  branch hours ∩ resource shifts − national holidays − resource exceptions

Booked appointments and holds are deliberately NOT subtracted here — those are
tasks 06/07, as is intersecting several resources. The method is called
rawAvailability() so nobody mistakes the output for bookable time. Nothing in this
change calls SlotCalculatorService; the existing slot path stays frozen.

Four types of exception (leave, absence, maintenance, ad-hoc closure) share one
table because all four are "an interval subtracted from a resource's calendar";
splitting them would mean four queries per availability lookup instead of one.
Holiday overrides work in both directions: a clinic that opens on a public holiday,
and a clinic that closes on an ordinary day.

Every empty day carries a reason (national_holiday, no_shift, branch_closed,
outside_branch_hours, exception, …). Without it an empty response is
indistinguishable from a bug and the first person debugging has to read four tables
by hand.

Three real defects found on the way:

JalaliDateService.gregorianToJalali() was wrong — it returned [3006, 7, 3] for
2026-07-30 instead of [1405, 5, 8], roughly 1601 years off. jalaliYear(),
jalaliMonth(), jalaliMonthRange() and jalaliYearRange() all inherit that, so the
representation reports built on them have been filtering by nonsense ranges. The
class's own formatDateTime() was already correct because it used IntlDateFormatter,
so both conversions now go through the same mechanism, and JalaliDateServiceTest
pins Nowruz and the 6/31→7/1 boundary. There were no tests before, which is why
nobody noticed.

TimeInterval added a seconds-based midnight to a minutes-based interval, turning an
eight-hour shift into eight seconds. The conversion is now an explicitly named
minutesToAbsolute() so the unit change cannot happen silently again.

HolidayService.upsertNational() persisted but left flushing to the caller. Every
HTTP request reboots the kernel, so the caller often held a different
EntityManager: persist landed on one, flush on the other, and nothing was written
with no error at all. The write is now self-contained.

119 tests across tests/Resource, tests/Branch and tests/Representation. phpstan
clean on both touched domains.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
hamed
2026-07-30 18:14:23 +03:30
co-authored by Claude Opus 5
parent 73456447b2
commit 1fdfdf9e48
21 changed files with 2330 additions and 42 deletions
@@ -0,0 +1,55 @@
<?php
namespace App\Resource\ValueObject;
/**
* ساعت آزادِ خامِ یک منبع در یک روز.
*
* `reasons` وقتی روز خالی است می‌گوید **چرا** — بدونش، پاسخِ خالی از یک باگ قابل
* تشخیص نیست و اولین کسی که دیباگ می‌کند باید چهار جدول را دستی بخواند.
*/
final readonly class DayAvailability
{
/**
* @param list<TimeInterval> $intervals بازه‌های آزاد، به‌صورت timestamp مطلق
* @param list<string> $reasons `national_holiday`، `tenant_holiday`، `no_shift`،
* `branch_closed`، `outside_branch_hours`، `exception`،
* `resource_inactive`، `branch_inactive`
*/
public function __construct(
public int $date,
public int $dayOfWeek,
public array $intervals,
public array $reasons = [],
) {}
public function isEmpty(): bool
{
return $this->intervals === [];
}
public function totalMinutes(): int
{
$total = 0;
foreach ($this->intervals as $interval) {
$total += intdiv($interval->end - $interval->start, 60);
}
return $total;
}
public function toArray(): array
{
return [
'date' => $this->date,
'day_of_week' => $this->dayOfWeek,
'intervals' => array_map(
static fn (TimeInterval $i): array => $i->toArray(),
$this->intervals,
),
'total_minutes' => $this->totalMinutes(),
'reasons' => $this->reasons,
];
}
}
+131
View File
@@ -0,0 +1,131 @@
<?php
namespace App\Resource\ValueObject;
/**
* یک بازهٔ نیم‌باز `[start, end)`.
*
* واحدش عمداً تعیین نشده: در تقویم هفتگی «دقیقه از نیمه‌شب» است و بعد از
* `shiftedBy()` می‌شود timestamp مطلق. جبرِ زیر در هر دو حالت یکسان کار می‌کند، و
* همین باعث می‌شود تقاطع ساعت شعبه با شیفت منبع و کسر مرخصی، یک کد باشند نه دو تا.
*/
final readonly class TimeInterval
{
public function __construct(
public int $start,
public int $end,
) {
if ($end <= $start) {
throw new \InvalidArgumentException(sprintf('Interval end (%d) must be after start (%d).', $end, $start));
}
}
/**
* بازه را از «دقیقه از نیمه‌شب» به timestamp مطلق می‌برد.
*
* نامش عمداً `shiftedBy` نیست: جمع ساده، دقیقه را با ثانیه قاطی می‌کرد و بازهٔ
* هشت‌ساعته را هشت **ثانیه** می‌ساخت — دقیقاً همان باگی که
* ResourceAvailabilityTest اولین بار گرفت.
*/
public function minutesToAbsolute(int $midnight): self
{
return new self($midnight + $this->start * 60, $midnight + $this->end * 60);
}
public function toArray(): array
{
return ['start' => $this->start, 'end' => $this->end];
}
/**
* بازه‌های هم‌پوشان یا چسبیده را یکی می‌کند. خروجی مرتب است.
*
* @param list<self> $intervals
* @return list<self>
*/
public static function mergeAll(array $intervals): array
{
if ($intervals === []) {
return [];
}
usort($intervals, static fn (self $a, self $b): int => $a->start <=> $b->start);
$merged = [array_shift($intervals)];
foreach ($intervals as $next) {
$last = $merged[count($merged) - 1];
if ($next->start <= $last->end) {
// چسبیده هم ادغام می‌شود: [9,13) و [13,17) یعنی [9,17)، نه دو بازه.
$merged[count($merged) - 1] = new self($last->start, max($last->end, $next->end));
continue;
}
$merged[] = $next;
}
return $merged;
}
/**
* تقاطع دو مجموعه بازه.
*
* @param list<self> $left
* @param list<self> $right
* @return list<self>
*/
public static function intersectAll(array $left, array $right): array
{
$out = [];
foreach (self::mergeAll($left) as $a) {
foreach (self::mergeAll($right) as $b) {
$start = max($a->start, $b->start);
$end = min($a->end, $b->end);
if ($end > $start) {
$out[] = new self($start, $end);
}
}
}
return self::mergeAll($out);
}
/**
* `$from` منهای `$blocks`. بازهٔ نیم‌روزه فقط همان تکه را می‌بُرد و بقیهٔ روز
* سرِ جایش می‌ماند.
*
* @param list<self> $from
* @param list<self> $blocks
* @return list<self>
*/
public static function subtractAll(array $from, array $blocks): array
{
$result = self::mergeAll($from);
foreach (self::mergeAll($blocks) as $block) {
$next = [];
foreach ($result as $interval) {
if ($block->end <= $interval->start || $block->start >= $interval->end) {
$next[] = $interval; // بی‌تداخل
continue;
}
if ($block->start > $interval->start) {
$next[] = new self($interval->start, $block->start);
}
if ($block->end < $interval->end) {
$next[] = new self($block->end, $interval->end);
}
}
$result = $next;
}
return $result;
}
}