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
+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` کلید می‌خورد و ردیف غلط را نمی‌شناسد.