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:
@@ -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); }}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
* تعطیلات رسمی و استثناهای این محیط.
|
||||
*
|
||||
|
||||
@@ -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`
|
||||
|
||||
| فیلد | نوع | توضیح |
|
||||
|
||||
@@ -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]);
|
||||
|
||||
@@ -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` کلید میخورد و ردیف غلط را نمیشناسد.
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user