feat(holidays): one official calendar, inherited everywhere

The holiday model was already right — national holidays global, a per-tenant
override in both directions, per-doctor and per-resource exceptions — but
nothing could create a national holiday. The only writer was an import
command, so the calendar the whole product inherits from had no owner.

Three admin-only routes give it one. POST upserts, because `date` is unique
and re-sending a day should rename it rather than surface a raw database
error; PATCH takes only the title, because moving a date means a different
holiday. The system admin has no work environment, so the list endpoint now
returns the calendar with an empty `overrides` for that role instead of the
403 `pair()` would raise — the person who maintains the calendar has to be
able to read it.

Both holiday tabs — the doctor's and the resource's — now open with the
official calendar above their own exceptions, from one shared card rather
than two copies that would drift. Each row can be opted out of with a single
click, which is the existing holiday-override endpoint.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
hamed
2026-08-02 15:39:48 +03:30
co-authored by Claude Opus 5
parent dd284ec622
commit f144401dd3
11 changed files with 505 additions and 9 deletions
@@ -0,0 +1,71 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { renderWithProviders } from '../../test/utils';
vi.mock('sonner', () => ({ toast: { success: vi.fn(), error: vi.fn() } }));
const setOverride = { mutate: vi.fn(), isPending: false };
const removeOverride = { mutate: vi.fn(), isPending: false };
let holidays: Array<{ uuid: string; date: number; title: string }> = [];
let overrides: Array<{ uuid: string; date: number; is_working: boolean }> = [];
vi.mock('../../hooks/useResourceCalendar', () => ({
useHolidays: () => ({ holidays, overrides, loading: false, setOverride, removeOverride }),
}));
import NationalHolidaysCard from './NationalHolidaysCard';
const NOWRUZ = { uuid: 'h-1', date: 1774040400, title: 'نوروز' };
describe('NationalHolidaysCard', () => {
beforeEach(() => {
holidays = [NOWRUZ];
overrides = [];
vi.clearAllMocks();
});
it('تعطیلات رسمی سال را نشان می‌دهد', () => {
renderWithProviders(<NationalHolidaysCard canUpdate year={1405} />);
expect(screen.getByText(/نوروز/)).toBeInTheDocument();
expect(screen.getByText('تعطیل')).toBeInTheDocument();
});
/** محیطی که آن روز کار می‌کند، تعطیلیِ سراسری را برای خودش خنثی می‌کند. */
it('با یک کلیک روز را برای این محیط باز می‌کند', async () => {
const user = userEvent.setup();
renderWithProviders(<NationalHolidaysCard canUpdate year={1405} />);
await user.click(screen.getByRole('button', { name: 'این روز باز است' }));
expect(setOverride.mutate).toHaveBeenCalledWith({ date: NOWRUZ.date, is_working: true });
});
it('روزی که استثنا خورده، «باز» است و می‌شود دوباره تعطیلش کرد', async () => {
overrides = [{ uuid: 'o-1', date: NOWRUZ.date, is_working: true }];
const user = userEvent.setup();
renderWithProviders(<NationalHolidaysCard canUpdate year={1405} />);
expect(screen.getByText('باز')).toBeInTheDocument();
await user.click(screen.getByRole('button', { name: 'تعطیل کن' }));
expect(removeOverride.mutate).toHaveBeenCalledWith('o-1');
});
/** ساخت و حذفِ خودِ تعطیلی کارِ مدیر سیستم است؛ اینجا فقط استثنا زده می‌شود. */
it('بدون مجوز ویرایش، هیچ دکمه‌ای نمی‌دهد', () => {
renderWithProviders(<NationalHolidaysCard canUpdate={false} year={1405} />);
expect(screen.queryByRole('button')).not.toBeInTheDocument();
expect(screen.getByText('تنظیمات ← تعطیلات رسمی')).toHaveAttribute('href', '/admin/holidays');
});
it('سالِ بدون تعطیلی را صریح می‌گوید', async () => {
holidays = [];
renderWithProviders(<NationalHolidaysCard canUpdate year={1405} />);
await waitFor(() => expect(screen.getByText(/تعطیلی رسمی ثبت نشده است/)).toBeInTheDocument());
});
});
@@ -0,0 +1,84 @@
import React, { useMemo } from 'react';
import { Link } from 'react-router-dom';
import { useHolidays } from '../../hooks/useResourceCalendar';
import { formatDate, currentJalaliYear } from '../../lib/utils';
/**
* تعطیلات رسمیِ سال، همان‌جایی که تعطیلی اختصاصی تعریف می‌شود.
*
* تقویم رسمی یک بار مرکزی ثبت می‌شود و هر پزشک و هر منبع از آن ارث می‌برد؛ اینجا فقط
* دیده می‌شود و — اگر این محیط آن روز باز باشد — با یک سوییچ خنثی می‌شود. ساخت و حذفِ
* خودِ تعطیلی کارِ مدیر سیستم است، نه کلینیک.
*
* یک کامپوننت برای دو مصرف‌کننده: تب تعطیلات پزشک و تب تعطیلات منبع. دو نسخه یعنی دو
* رفتار که با هم واگرا می‌شوند.
*/
export default function NationalHolidaysCard({ canUpdate, year = currentJalaliYear() }: {
canUpdate: boolean;
year?: number;
}) {
const { holidays, overrides, loading, setOverride, removeOverride } = useHolidays(year);
const overrideByDate = useMemo(
() => new Map(overrides.map((o) => [o.date, o])),
[overrides],
);
return (
<div className="card" style={{ padding: 14 }}>
<h2 className="section-title" style={{ margin: '0 0 4px' }}>تعطیلات رسمی {year}</h2>
<p style={{ fontSize: 12, color: 'var(--text-3)', margin: '0 0 10px', lineHeight: 1.9 }}>
اینها برای همهٔ پزشکان و منابع اعمال میشوند. اگر این محیط روزی را باز است،
همینجا استثنا بزنید مدیریت کاملشان در{' '}
<Link to="/admin/holidays" style={{ color: 'var(--primary)' }}>تنظیمات تعطیلات رسمی</Link>.
</p>
{loading ? (
<span style={{ fontSize: 13, color: 'var(--text-3)' }}>در حال بارگذاری...</span>
) : holidays.length === 0 ? (
<p style={{ fontSize: 13, color: 'var(--text-3)', margin: 0 }}>
برای سال {year} تعطیلی رسمی ثبت نشده است.
</p>
) : (
<div style={{ display: 'grid', gap: 8 }}>
{holidays.map((h) => {
const override = overrideByDate.get(h.date);
const open = override?.is_working === true;
return (
<div key={h.uuid} style={{ display: 'flex', alignItems: 'center', gap: 8, fontSize: 13 }}>
<span className={`badge ${open ? 'gray' : 'red'}`} style={{ fontSize: 11 }}>
{open ? 'باز' : 'تعطیل'}
</span>
<span style={{ flex: 1, color: 'var(--text-2)' }}>
{formatDate(h.date * 1000)} · {h.title}
</span>
{canUpdate && (
open ? (
<button
type="button"
className="btn secondary sm"
disabled={removeOverride.isPending}
onClick={() => override && removeOverride.mutate(override.uuid)}
>
تعطیل کن
</button>
) : (
<button
type="button"
className="btn secondary sm"
disabled={setOverride.isPending}
onClick={() => setOverride.mutate({ date: h.date, is_working: true })}
>
این روز باز است
</button>
)
)}
</div>
);
})}
</div>
)}
</div>
);
}
@@ -5,6 +5,7 @@ import PersianDateInput from '../ui/PersianDateInput';
import { useResourceAvailability, useResourceExceptions } from '../../hooks/useResourceCalendar';
import { formatDate } from '../../lib/utils';
import { DAY_LABELS } from './ResourceWorkingHoursPanel';
import NationalHolidaysCard from '../holidays/NationalHolidaysCard';
import type { ResourceException } from '../../types';
/** چرا یک روز خالی است — بدون ترجمه، پاسخ خام سرور به کاربر نشان داده می‌شد. */
@@ -50,6 +51,8 @@ export default function ResourceExceptionsPanel({ resourceUuid, canUpdate }: {
return (
<div style={{ display: 'grid', gap: 16, gridTemplateColumns: 'repeat(auto-fit, minmax(320px, 1fr))', alignItems: 'start' }}>
<NationalHolidaysCard canUpdate={canUpdate} />
<ExceptionsCard
exceptions={exceptions}
canUpdate={canUpdate}
@@ -14,6 +14,7 @@ import { formatNumber, digitsOnly, todayIso, unixToIso } from '../../lib/utils';
import Modal from '../ui/Modal';
import ConfirmDialog from '../ui/ConfirmDialog';
import GlobalSearchableSelect from '../ui/SearchableSelect';
import NationalHolidaysCard from '../holidays/NationalHolidaysCard';
/**
* ساختار واحد تنظیمات نوبت‌دهی یک پزشک — برنامه هفتگی، استثناهای تاریخ و تعطیلات.
@@ -1363,6 +1364,10 @@ function HolidaysTab({ doctorUuid, clinicUuid, readOnly = false }: { doctorUuid:
return (
<div className="space-y-3">
{/* تقویم رسمی بالای تعطیلی اختصاصی: پزشک اول می‌بیند چه چیزی از قبل بسته است،
بعد تعطیلی خودش را رویش اضافه می‌کند. */}
<NationalHolidaysCard canUpdate={!readOnly} />
{!readOnly && (
<div className="flex justify-end">
<button type="button" onClick={() => { setEditing(null); setModalOpen(true); }}
+11
View File
@@ -209,3 +209,14 @@ export const iranNationalCodeOptionalSchema = z
.string()
.transform((v) => digitsOnly(v, 10))
.refine((v) => v === '' || IRAN_NATIONAL_CODE_RE.test(v), 'کد ملی باید ۱۰ رقم باشد');
/**
* سالِ شمسی جاری از فرمت‌کنندهٔ شمسیِ مرورگر، نه با محاسبهٔ دستی.
*
* دو مصرف‌کننده دارد (صفحهٔ تعطیلات و کارت تعطیلات رسمی)؛ دو کپی یعنی دو رفتار در
* مرز سال.
*/
export function currentJalaliYear(): number {
const formatted = new Intl.DateTimeFormat('en-u-ca-persian', { year: 'numeric' }).format(new Date());
return Number(formatted.replace(/\D/g, '')) || 1405;
}
+1 -7
View File
@@ -5,16 +5,10 @@ import SearchableSelect from '../components/ui/SearchableSelect';
import { useUrlState } from '../hooks/useUrlState';
import { usePermissions } from '../hooks/usePermissions';
import { useHolidays } from '../hooks/useResourceCalendar';
import { formatDate } from '../lib/utils';
import { formatDate, currentJalaliYear } from '../lib/utils';
import type { NationalHoliday } from '../types';
import SettingsLayout from '../components/layout/SettingsLayout';
/** سالِ شمسی جاری از همان فرمت‌کنندهٔ شمسیِ مرورگر گرفته می‌شود، نه با محاسبهٔ دستی. */
function currentJalaliYear(): number {
const formatted = new Intl.DateTimeFormat('en-u-ca-persian', { year: 'numeric' }).format(new Date());
return Number(formatted.replace(/\D/g, '')) || 1405;
}
/**
* تعطیلات رسمی و استثناهای این محیط.
*
+33
View File
@@ -179,6 +179,39 @@
`overridden_working` کنار خودِ تعطیلی می‌آید تا UI مجبور نباشد دو فهرست را تطبیق دهد:
`null` یعنی این محیط استثنایی ندارد، `true` یعنی آن روز باز است.
### مدیر سیستم — ساخت و ویرایش تقویم رسمی
`ROLE_ADMIN` فقط. تعطیل رسمی به هیچ محیطی تعلق ندارد، پس ساختنش هم کارِ هیچ کلینیکی
نیست؛ کلینیکی که آن روز باز است با `holiday-overrides` استثنا می‌زند.
| متد | مسیر | بدنه |
|---|---|---|
| POST | `/api/v1/admin/national-holidays` | `{ "jalali_date": "1405-01-13", "title": "سیزده‌بدر" }` |
| PATCH | `/api/v1/admin/national-holiday/{uuid}` | `{ "title": "…" }` |
| DELETE | `/api/v1/admin/national-holiday/{uuid}` | — |
**۲۰۱** (خروجی واقعی تست):
```json
{
"success": true,
"data": {
"uuid": "…", "date": 1774472400, "jalali_date": "1405-01-13",
"jalali_year": 1405, "title": "سیزده‌بدر"
}
}
```
`POST` روی روزی که از قبل هست **upsert** است و عنوان را عوض می‌کند — `date` کلید یکتا
دارد و خطای خام دیتابیس رفتار درستی نیست. `PATCH` فقط عنوان را می‌گیرد: جابه‌جا کردن
تاریخ یعنی یک تعطیلِ دیگر، پس حذف و ثبت دوباره.
**۴۰۳:** هر نقشی جز ادمین (تأیید زنده: کاربر کلینیک روی همین مسیر ۴۰۳ گرفت).
**۴۲۲:** `jalali_date` بدقالب · نبودِ `title`. **۴۰۴:** uuid ناشناخته.
> `GET /api/v1/national-holidays` برای ادمین هم کار می‌کند: او محیط کاری ندارد، پس
> بخش `overrides` برایش خالی برمی‌گردد و فقط تقویم را می‌بیند.
### `POST /api/v1/holiday-overrides`
| فیلد | نوع | توضیح |
+72 -2
View File
@@ -5,6 +5,7 @@ namespace App\Resource\Controller;
use App\Auth\Entity\User;
use App\Resource\Entity\NationalHoliday;
use App\Resource\Entity\TenantHolidayOverride;
use App\Resource\Repository\NationalHolidayRepository;
use App\Resource\Repository\TenantHolidayOverrideRepository;
use App\Resource\Service\HolidayService;
use App\Resource\Service\ResourceContext;
@@ -29,6 +30,7 @@ class HolidayController extends BaseController
private readonly ResourceContext $context,
private readonly TenantHolidayOverrideRepository $overrides,
private readonly TenantOwnershipChecker $ownership,
private readonly NationalHolidayRepository $nationalHolidays,
) {}
/**
@@ -42,8 +44,11 @@ class HolidayController extends BaseController
$year = $this->holidays->assertJalaliYear($request->query->get('year'));
[$entityType, $entityId] = $this->context->pair($user);
$overrides = $this->holidays->overridesForYear($entityType, $entityId, $year);
// مدیر سیستم محیط کاری ندارد و `pair()` برایش ۴۰۳ می‌دهد؛ ولی تقویم رسمی را او
// نگه می‌دارد، پس باید ببیندش. استثنا فقط استثناهاست: کسی که محیط ندارد، ندارد.
$overrides = $user->hasRole('ROLE_ADMIN')
? []
: $this->holidays->overridesForYear(...[...$this->context->pair($user), $year]);
$overrideByDate = [];
foreach ($overrides as $override) {
@@ -112,6 +117,71 @@ class HolidayController extends BaseController
$this->holidays->deleteOverride($override);
return $this->success(null);
}
// ── مدیر سیستم: تقویم تعطیلات رسمی ─────────────────────────────────────
//
// تعطیل رسمی به هیچ محیطی تعلق ندارد، پس ساختنش هم کارِ هیچ کلینیکی نیست: یک بار
// مرکزی ثبت می‌شود و همهٔ محیط‌ها از آن ارث می‌برند. کلینیکی که خلافش کار می‌کند
// با `POST /holiday-overrides` استثنا می‌زند، نه با دست‌کاری خودِ تقویم.
#[Route('/api/v1/admin/national-holidays', name: 'admin_national_holiday_create', methods: ['POST'])]
#[IsGranted('ROLE_ADMIN')]
public function createNationalHoliday(Request $request): JsonResponse
{
$data = json_decode($request->getContent(), true);
if (!is_array($data) || !is_string($data['jalali_date'] ?? null)
|| preg_match('/^(\d{4})-(\d{2})-(\d{2})$/', $data['jalali_date'], $m) !== 1
) {
return $this->error(ErrorCodes::ERR_VALIDATION_002, 'jalali_date باید به شکل ۱۴۰۵-۰۱-۱۳ باشد', 422, 'jalali_date');
}
if (!is_string($data['title'] ?? null) || trim($data['title']) === '') {
return $this->error(ErrorCodes::ERR_VALIDATION_002, 'فیلد title الزامی است', 422, 'title');
}
// upsert است نه insert: ثبت روی `date` کلید یکتا دارد و دوباره‌فرستادن همان
// روز باید عنوان را عوض کند، نه با خطای خام دیتابیس بشکند.
$holiday = $this->holidays->upsertNational((int) $m[1], (int) $m[2], (int) $m[3], trim($data['title']));
return $this->success($holiday->toArray(), 201);
}
#[Route('/api/v1/admin/national-holiday/{uuid}', name: 'admin_national_holiday_update', methods: ['PATCH'])]
#[IsGranted('ROLE_ADMIN')]
public function updateNationalHoliday(string $uuid, Request $request): JsonResponse
{
$holiday = $this->nationalHolidays->findByUuid($uuid);
if ($holiday === null) {
return $this->error(ErrorCodes::ERR_NOT_FOUND_001, 'تعطیلی یافت نشد', 404);
}
$data = json_decode($request->getContent(), true);
if (!is_array($data) || !is_string($data['title'] ?? null) || trim($data['title']) === '') {
return $this->error(ErrorCodes::ERR_VALIDATION_002, 'فیلد title الزامی است', 422, 'title');
}
// فقط عنوان: تاریخ کلید یکتاست و «جابه‌جایی» یعنی یک تعطیلِ دیگر — حذف و ثبت دوباره.
$this->holidays->renameNational($holiday, trim($data['title']));
return $this->success($holiday->toArray());
}
#[Route('/api/v1/admin/national-holiday/{uuid}', name: 'admin_national_holiday_delete', methods: ['DELETE'])]
#[IsGranted('ROLE_ADMIN')]
public function deleteNationalHoliday(string $uuid): JsonResponse
{
$holiday = $this->nationalHolidays->findByUuid($uuid);
if ($holiday === null) {
return $this->error(ErrorCodes::ERR_NOT_FOUND_001, 'تعطیلی یافت نشد', 404);
}
$this->holidays->deleteNational($holiday);
return $this->success(null);
}
}
@@ -48,6 +48,11 @@ class NationalHolidayRepository extends ServiceEntityRepository
return $map;
}
public function findByUuid(string $uuid): ?NationalHoliday
{
return $this->findOneBy(['uuid' => $uuid]);
}
public function findByDate(int $date): ?NationalHoliday
{
return $this->findOneBy(['date' => $date]);
+14
View File
@@ -76,6 +76,20 @@ final class HolidayService
return $holiday;
}
/** تغییر عنوان یک تعطیل رسمی؛ تاریخ کلید یکتاست و اینجا دست نمی‌خورد. */
public function renameNational(NationalHoliday $holiday, string $title): void
{
$holiday->setTitle($title);
$this->em->flush();
}
/** حذف یک تعطیل رسمی — مسیر پنل مدیر سیستم، در برابر `purgeYear` که کل سال را می‌برد. */
public function deleteNational(NationalHoliday $holiday): void
{
$this->em->remove($holiday);
$this->em->flush();
}
/**
* همهٔ تعطیلات یک سال را پاک می‌کند — تنها راه تصحیحِ ردیفی که با تاریخ غلط
* نوشته شده، چون upsert روی `date` کلید می‌خورد و ردیف غلط را نمی‌شناسد.
+206
View File
@@ -0,0 +1,206 @@
<?php
namespace App\Tests\Resource;
use App\Auth\Entity\User;
use App\Clinic\Entity\Clinic;
use App\Doctor\Entity\DoctorAddress;
use App\Resource\Entity\ClinicResource;
use App\Resource\Entity\ResourceCalendar;
use App\Resource\Entity\ResourceType;
use App\Tests\ApiTestCase;
/**
* تقویم تعطیلات رسمی، از دید مدیر سیستم.
*
* تعطیل رسمی به هیچ محیطی تعلق ندارد، پس ساختنش کارِ هیچ کلینیکی نیست: یک بار مرکزی
* ثبت می‌شود و همهٔ محیط‌ها — پزشک و منبع — از همان ارث می‌برند. کلینیکی که آن روز باز
* است با `holiday-overrides` استثنا می‌زند، نه با دست‌کاری خودِ تقویم.
*/
class NationalHolidayAdminTest extends ApiTestCase
{
/** @return array{0: User, 1: ClinicResource} */
private function clinicWithResource(): array
{
$user = $this->createUser(['ROLE_USER', 'ROLE_CLINIC']);
$clinic = new Clinic($user);
$clinic->setName('کلینیک تعطیلات');
$this->em->persist($clinic);
$this->em->flush();
$address = DoctorAddress::forClinic($clinic->getId());
$address->setName('شعبهٔ مرکزی');
$this->em->persist($address);
$this->em->flush();
$type = new ResourceType('clinic', (int) $clinic->getId(), 'device', 'دستگاه');
$this->em->persist($type);
$this->em->flush();
$resource = new ClinicResource($address, $type, 'لیزر دایود');
$this->em->persist($resource);
$this->em->flush();
return [$user, $resource];
}
/** یک تعطیلِ رسمی روی روزی که منبع شیفت دارد، تا اثرش در دسترس‌پذیری دیده شود. */
private function shiftOn(ClinicResource $resource, int $midnight): void
{
$dayOfWeek = (int) (((int) date('w', $midnight) + 1) % 7);
$this->em->persist(new ResourceCalendar($resource, $dayOfWeek, 540, 1020));
$this->em->flush();
}
private function admin(): User
{
return $this->createUser(['ROLE_USER', 'ROLE_ADMIN']);
}
// ── ✅ موفق ──────────────────────────────────────────────────────────────
public function testAnAdminRegistersAnOfficialHoliday(): void
{
$body = $this->authJson('POST', '/api/v1/admin/national-holidays', $this->admin(), [
'jalali_date' => '1405-01-13',
'title' => 'سیزده‌بدر',
]);
self::assertSame(201, $this->responseCode(), json_encode($body, JSON_UNESCAPED_UNICODE));
self::assertSame('1405-01-13', $body['data']['jalali_date']);
self::assertSame('سیزده‌بدر', $body['data']['title']);
self::assertSame(1405, $body['data']['jalali_year']);
}
/** ثبت روی `date` کلید یکتا دارد؛ دوباره فرستادن باید عنوان را عوض کند نه بشکند. */
public function testTheSameDayTwiceUpdatesTheTitle(): void
{
$admin = $this->admin();
$this->authJson('POST', '/api/v1/admin/national-holidays', $admin, [
'jalali_date' => '1405-01-13', 'title' => 'سیزده بدر',
]);
$body = $this->authJson('POST', '/api/v1/admin/national-holidays', $admin, [
'jalali_date' => '1405-01-13', 'title' => 'روز طبیعت',
]);
self::assertSame('روز طبیعت', $body['data']['title']);
self::assertCount(
1,
array_filter(
$this->authJson('GET', '/api/v1/national-holidays?year=1405', $admin)['data']['holidays'],
static fn (array $h): bool => $h['jalali_date'] === '1405-01-13',
),
);
}
public function testAHolidayCanBeRenamedAndRemoved(): void
{
$admin = $this->admin();
$uuid = $this->authJson('POST', '/api/v1/admin/national-holidays', $admin, [
'jalali_date' => '1405-02-03', 'title' => 'مناسبت',
])['data']['uuid'];
$renamed = $this->authJson('PATCH', "/api/v1/admin/national-holiday/{$uuid}", $admin, ['title' => 'مناسبت درست']);
self::assertSame('مناسبت درست', $renamed['data']['title']);
$this->authJson('DELETE', "/api/v1/admin/national-holiday/{$uuid}", $admin);
self::assertSame(200, $this->responseCode());
// فهرست سال را کامل نمی‌سنجیم: `national_holidays` جدول سراسری است و بین
// تست‌ها پاک نمی‌شود. آنچه مهم است، نبودنِ همین uuid است.
$uuids = array_column(
$this->authJson('GET', '/api/v1/national-holidays?year=1405', $admin)['data']['holidays'],
'uuid',
);
self::assertNotContains($uuid, $uuids);
}
/** تعطیلِ ثبت‌شدهٔ مرکزی باید بی‌واسطه روز منبعِ هر محیطی را ببندد. */
public function testTheHolidayClosesThatDayForEveryResource(): void
{
[$user, $resource] = $this->clinicWithResource();
// ۱۴۰۵-۰۱-۱۳ به میلادی: ۲۰۲۶-۰۴-۰۲.
$midnight = (int) strtotime('2026-04-02 00:00:00');
$this->shiftOn($resource, $midnight);
$this->authJson('POST', '/api/v1/admin/national-holidays', $this->admin(), [
'jalali_date' => '1405-01-13', 'title' => 'سیزده‌بدر',
]);
$days = $this->authJson(
'GET',
sprintf('/api/v1/resource/%s/availability?from=%d&to=%d', $resource->getUuid(), $midnight, $midnight),
$user,
)['data']['days'];
self::assertSame([], $days[0]['intervals']);
self::assertContains('national_holiday', $days[0]['reasons']);
}
// ── ⚠️ مرزی ─────────────────────────────────────────────────────────────
/** محیطی که آن روز باز است، با استثنای خودش تعطیلیِ سراسری را خنثی می‌کند. */
public function testAnEnvironmentCanOptOutOfTheOfficialHoliday(): void
{
[$user, $resource] = $this->clinicWithResource();
$midnight = (int) strtotime('2026-04-02 00:00:00');
$this->shiftOn($resource, $midnight);
$this->authJson('POST', '/api/v1/admin/national-holidays', $this->admin(), [
'jalali_date' => '1405-01-13', 'title' => 'سیزده‌بدر',
]);
$this->authJson('POST', '/api/v1/holiday-overrides', $user, [
'date' => $midnight, 'is_working' => true, 'note' => 'شیفت اورژانس',
]);
$days = $this->authJson(
'GET',
sprintf('/api/v1/resource/%s/availability?from=%d&to=%d', $resource->getUuid(), $midnight, $midnight),
$user,
)['data']['days'];
self::assertNotSame([], $days[0]['intervals'], 'استثنای محیط بر تقویم رسمی مقدم است');
self::assertSame(480, $days[0]['total_minutes']);
}
// ── ❌ خطا ───────────────────────────────────────────────────────────────
public function testADoctorCannotRegisterANationalHoliday(): void
{
[$user] = $this->clinicWithResource();
$this->authJson('POST', '/api/v1/admin/national-holidays', $user, [
'jalali_date' => '1405-01-13', 'title' => 'سیزده‌بدر',
]);
self::assertSame(403, $this->responseCode());
}
public function testAMalformedJalaliDateIsRefused(): void
{
$this->authJson('POST', '/api/v1/admin/national-holidays', $this->admin(), [
'jalali_date' => '1405/1/13', 'title' => 'سیزده‌بدر',
]);
self::assertSame(422, $this->responseCode());
}
public function testTitleIsRequired(): void
{
$this->authJson('POST', '/api/v1/admin/national-holidays', $this->admin(), ['jalali_date' => '1405-01-13']);
self::assertSame(422, $this->responseCode());
}
public function testAnUnknownHolidayIs404(): void
{
$this->authJson('DELETE', '/api/v1/admin/national-holiday/00000000-0000-4000-8000-000000000000', $this->admin());
self::assertSame(404, $this->responseCode());
}
}