diff --git a/migrations/Version20260730143130.php b/migrations/Version20260730143130.php new file mode 100644 index 00000000..3d89b95b --- /dev/null +++ b/migrations/Version20260730143130.php @@ -0,0 +1,43 @@ +addSql('CREATE TABLE national_holidays (id INT AUTO_INCREMENT NOT NULL, uuid VARCHAR(36) NOT NULL, date INT NOT NULL, jalali_date VARCHAR(10) NOT NULL, jalali_year SMALLINT NOT NULL, title VARCHAR(200) NOT NULL, created_at INT NOT NULL, UNIQUE INDEX UNIQ_8262602CD17F50A6 (uuid), INDEX idx_holiday_year (jalali_year), UNIQUE INDEX uniq_holiday_date (date), PRIMARY KEY (id)) DEFAULT CHARACTER SET utf8mb4'); + $this->addSql('CREATE TABLE resource_calendars (id INT AUTO_INCREMENT NOT NULL, day_of_week SMALLINT NOT NULL, sequence SMALLINT DEFAULT 0 NOT NULL, start_minute SMALLINT NOT NULL, end_minute SMALLINT NOT NULL, active TINYINT DEFAULT 1 NOT NULL, resource_id INT NOT NULL, INDEX IDX_2C16D19089329D25 (resource_id), INDEX idx_rc_resource_day (resource_id, day_of_week, active), UNIQUE INDEX uniq_rc_resource_day_seq (resource_id, day_of_week, sequence), PRIMARY KEY (id)) DEFAULT CHARACTER SET utf8mb4'); + $this->addSql('CREATE TABLE resource_exceptions (id INT AUTO_INCREMENT NOT NULL, uuid VARCHAR(36) NOT NULL, type VARCHAR(20) NOT NULL, starts_at INT NOT NULL, ends_at INT NOT NULL, reason VARCHAR(200) DEFAULT NULL, created_at INT NOT NULL, updated_at INT NOT NULL, entity_type VARCHAR(10) NOT NULL, entity_id INT NOT NULL, resource_id INT NOT NULL, UNIQUE INDEX UNIQ_486A5CE6D17F50A6 (uuid), INDEX IDX_486A5CE689329D25 (resource_id), INDEX idx_rex_tenant (entity_type, entity_id), INDEX idx_rex_resource_range (resource_id, starts_at, ends_at), PRIMARY KEY (id)) DEFAULT CHARACTER SET utf8mb4'); + $this->addSql('CREATE TABLE tenant_holiday_overrides (id INT AUTO_INCREMENT NOT NULL, uuid VARCHAR(36) NOT NULL, date INT NOT NULL, is_working TINYINT NOT NULL, note VARCHAR(200) DEFAULT NULL, created_at INT NOT NULL, entity_type VARCHAR(10) NOT NULL, entity_id INT NOT NULL, UNIQUE INDEX UNIQ_DB0B49E4D17F50A6 (uuid), UNIQUE INDEX uniq_tho_tenant_date (entity_type, entity_id, date), PRIMARY KEY (id)) DEFAULT CHARACTER SET utf8mb4'); + $this->addSql('ALTER TABLE resource_calendars ADD CONSTRAINT FK_2C16D19089329D25 FOREIGN KEY (resource_id) REFERENCES clinic_resources (id) ON DELETE CASCADE'); + $this->addSql('ALTER TABLE resource_exceptions ADD CONSTRAINT FK_486A5CE689329D25 FOREIGN KEY (resource_id) REFERENCES clinic_resources (id) ON DELETE CASCADE'); + } + + public function down(Schema $schema): void + { + $this->addSql('ALTER TABLE resource_calendars DROP FOREIGN KEY FK_2C16D19089329D25'); + $this->addSql('ALTER TABLE resource_exceptions DROP FOREIGN KEY FK_486A5CE689329D25'); + $this->addSql('DROP TABLE national_holidays'); + $this->addSql('DROP TABLE resource_calendars'); + $this->addSql('DROP TABLE resource_exceptions'); + $this->addSql('DROP TABLE tenant_holiday_overrides'); + } +} diff --git a/src/Representation/Service/JalaliDateService.php b/src/Representation/Service/JalaliDateService.php index cd8640b8..8e49e46b 100644 --- a/src/Representation/Service/JalaliDateService.php +++ b/src/Representation/Service/JalaliDateService.php @@ -7,38 +7,32 @@ namespace App\Representation\Service; */ class JalaliDateService { + public const TIMEZONE = 'Asia/Tehran'; + public function toJalali(\DateTimeInterface $date): array { [$gy, $gm, $gd] = [(int)$date->format('Y'), (int)$date->format('m'), (int)$date->format('d')]; return $this->gregorianToJalali($gy, $gm, $gd); } - /** Returns [year, month, day] in Jalali */ + /** + * @return array{0: int, 1: int, 2: int} [سال، ماه، روز] شمسی + * + * پیاده‌سازی دستیِ قبلی غلط بود: `gregorianToJalali(2026, 7, 30)` مقدار + * `[3006, 7, 3]` می‌داد به‌جای `[1405, 5, 8]` (مبدأ روزشمار ~۱۶۰۱ سال جابه‌جا + * بود). چون `formatDateTime()` همین کلاس از قبل با `IntlDateFormatter` درست کار + * می‌کرد، هر دو تبدیل هم به همان منتقل شدند تا یک منبع حقیقت بماند. + */ public function gregorianToJalali(int $gy, int $gm, int $gd): array { - $g_d_no = 365 * $gy + (int)(($gy + 3) / 4) - (int)(($gy + 99) / 100) + (int)(($gy + 399) / 400); - $g_days = [0, 31, 59, 90, 120, 151, 181, 212, 243, 273, 304, 334]; - for ($i = 1; $i < $gm; $i++) $g_d_no += $g_days[$i]; - if ($gm > 2 && (($gy % 4 === 0 && $gy % 100 !== 0) || ($gy % 400 === 0))) $g_d_no++; + // ساعت ۱۲ ظهر عمدی است: نیمه‌شب در روزهای تغییر ساعت می‌تواند یک روز عقب/جلو بیفتد. + $date = (new \DateTimeImmutable( + sprintf('%04d-%02d-%02d 12:00:00', $gy, $gm, $gd), + new \DateTimeZone(self::TIMEZONE), + ))->getTimestamp(); - $j_d_no = $g_d_no - 79; - $j_np = (int)($j_d_no / 12053); - $j_d_no %= 12053; - $jy = 979 + 33 * $j_np + 4 * (int)($j_d_no / 1461); - $j_d_no %= 1461; - - if ($j_d_no >= 366) { - $jy += (int)(($j_d_no - 1) / 365); - $j_d_no = ($j_d_no - 1) % 365; - } - - $j_days = [0, 31, 31, 31, 31, 31, 31, 30, 30, 30, 30, 30, 29]; - $jm = 0; - for ($i = 1; $i <= 12; $i++) { - if ($j_d_no < $j_days[$i]) { $jm = $i; break; } - $j_d_no -= $j_days[$i]; - } - $jd = $j_d_no + 1; + $formatted = $this->persianFormatter('yyyy-MM-dd')->format($date); + [$jy, $jm, $jd] = array_map('intval', explode('-', $formatted)); return [$jy, $jm, $jd]; } @@ -51,7 +45,7 @@ class JalaliDateService 'fa_IR@calendar=persian', \IntlDateFormatter::NONE, \IntlDateFormatter::NONE, - 'Asia/Tehran', + self::TIMEZONE, \IntlDateFormatter::TRADITIONAL, $pattern, ); @@ -101,27 +95,31 @@ class JalaliDateService ]; } + /** @return array{0: int, 1: int, 2: int} [سال، ماه، روز] میلادی */ public function jalaliToGregorian(int $jy, int $jm, int $jd): array { - $jy += 1595; - $days = -355779 + 365 * $jy + (int)($jy / 33) * 8 + (int)((($jy % 33) + 3) / 4) + $jd; - $jm_days = [0, 31, 62, 93, 124, 155, 186, 216, 246, 276, 306, 336]; - $days += $jm_days[$jm - 1]; + $calendar = \IntlCalendar::createInstance( + new \DateTimeZone(self::TIMEZONE), + 'fa_IR@calendar=persian', + ); + $calendar->clear(); + $calendar->set($jy, $jm - 1, $jd, 12, 0, 0); - $gy = 400 * (int)($days / 146097); - $days %= 146097; - if ($days > 36524) { $gy += 100 * (int)(--$days / 36524); $days %= 36524; if ($days >= 365) $days++; } - $gy += 4 * (int)($days / 1461); - $days %= 1461; - if ($days > 365) { $gy += (int)(($days - 1) / 365); $days = ($days - 1) % 365; } - $gd = $days + 1; - $gm_days = [0, 31, 29, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]; - $gm = 0; - for ($i = 1; $i <= 12; $i++) { - if ($gd <= $gm_days[$i]) { $gm = $i; break; } - $gd -= $gm_days[$i]; - } + $date = (new \DateTimeImmutable('@' . intdiv((int) $calendar->getTime(), 1000))) + ->setTimezone(new \DateTimeZone(self::TIMEZONE)); - return [$gy, $gm, $gd]; + return [(int) $date->format('Y'), (int) $date->format('n'), (int) $date->format('j')]; + } + + private function persianFormatter(string $pattern): \IntlDateFormatter + { + return new \IntlDateFormatter( + 'en_US@calendar=persian', + \IntlDateFormatter::NONE, + \IntlDateFormatter::NONE, + self::TIMEZONE, + \IntlDateFormatter::TRADITIONAL, + $pattern, + ); } } diff --git a/src/Resource/Command/ImportHolidayCommand.php b/src/Resource/Command/ImportHolidayCommand.php new file mode 100644 index 00000000..fb3ccdd6 --- /dev/null +++ b/src/Resource/Command/ImportHolidayCommand.php @@ -0,0 +1,106 @@ +addOption('year', null, InputOption::VALUE_REQUIRED, 'Jalali year, e.g. 1405'); + $this->addOption('force', null, InputOption::VALUE_NONE, 'Actually write; without it the command only reports'); + $this->addOption( + 'extra', + null, + InputOption::VALUE_REQUIRED | InputOption::VALUE_IS_ARRAY, + 'Extra holiday as month/day/title, e.g. --extra=10/12/عید فطر', + ); + } + + protected function execute(InputInterface $input, OutputInterface $output): int + { + $io = new SymfonyStyle($input, $output); + $force = (bool) $input->getOption('force'); + + $year = $input->getOption('year'); + + if (!is_numeric($year)) { + $io->error('گزینهٔ --year الزامی است، مثلاً --year=1405'); + + return Command::INVALID; + } + + $year = (int) $year; + $entries = self::FIXED; + + foreach ((array) $input->getOption('extra') as $extra) { + $parts = explode('/', (string) $extra, 3); + + if (count($parts) !== 3 || !is_numeric($parts[0]) || !is_numeric($parts[1])) { + $io->error(sprintf('قالب --extra باید ماه/روز/عنوان باشد؛ «%s» خوانده نشد.', $extra)); + + return Command::INVALID; + } + + $entries[] = [(int) $parts[0], (int) $parts[1], $parts[2]]; + } + + if (!$force) { + $io->note('Dry run — nothing will be written. Re-run with --force to apply.'); + } + + $rows = []; + + foreach ($entries as [$month, $day, $title]) { + $rows[] = [sprintf('%04d-%02d-%02d', $year, $month, $day), $title]; + + if ($force) { + $this->holidays->upsertNational($year, $month, $day, $title); + } + } + + $io->table(['تاریخ شمسی', 'مناسبت'], $rows); + $io->success(sprintf('%s — %d تعطیل رسمی سال %d', $force ? 'ثبت شد' : 'ثبت می‌شود', count($rows), $year)); + + return Command::SUCCESS; + } +} diff --git a/src/Resource/Controller/HolidayController.php b/src/Resource/Controller/HolidayController.php new file mode 100644 index 00000000..9049dc1d --- /dev/null +++ b/src/Resource/Controller/HolidayController.php @@ -0,0 +1,117 @@ +denyUnlessGranted($user, 'view'); + + $year = $this->holidays->assertJalaliYear($request->query->get('year')); + + [$entityType, $entityId] = $this->context->pair($user); + $overrides = $this->holidays->overridesForYear($entityType, $entityId, $year); + + $overrideByDate = []; + foreach ($overrides as $override) { + $overrideByDate[$override->getDate()] = $override; + } + + return $this->success([ + 'year' => $year, + 'holidays' => array_map( + static function (NationalHoliday $h) use ($overrideByDate): array { + $row = $h->toArray(); + // «این محیط آن روز باز است» کنار خودِ تعطیلی می‌آید تا UI مجبور + // نباشد دو فهرست را خودش تطبیق دهد. + $row['overridden_working'] = ($overrideByDate[$h->getDate()] ?? null)?->isWorking(); + + return $row; + }, + $this->holidays->forYear($year), + ), + 'overrides' => array_map( + static fn (TenantHolidayOverride $o): array => $o->toArray(), + $overrides, + ), + ]); + } + + #[Route('/api/v1/holiday-overrides', name: 'holiday_override_create', methods: ['POST'])] + public function createOverride(#[CurrentUser] User $user, Request $request): JsonResponse + { + $this->denyUnlessGranted($user, 'update'); + + $data = json_decode($request->getContent(), true); + + if (!is_array($data) || !is_numeric($data['date'] ?? null)) { + return $this->error(ErrorCodes::ERR_VALIDATION_002, 'فیلد date الزامی است', 422, 'date'); + } + + if (!array_key_exists('is_working', $data)) { + return $this->error(ErrorCodes::ERR_VALIDATION_002, 'فیلد is_working الزامی است', 422, 'is_working'); + } + + [$entityType, $entityId] = $this->context->pair($user); + + $override = $this->holidays->setOverride( + $entityType, + $entityId, + (int) $data['date'], + (bool) $data['is_working'], + is_string($data['note'] ?? null) && trim($data['note']) !== '' ? trim($data['note']) : null, + ); + + return $this->success($override->toArray(), 201); + } + + #[Route('/api/v1/holiday-override/{uuid}', name: 'holiday_override_delete', methods: ['DELETE'])] + public function deleteOverride(#[CurrentUser] User $user, string $uuid): JsonResponse + { + $this->denyUnlessGranted($user, 'update'); + + $override = $this->overrides->findByUuid($uuid); + [$entityType, $entityId] = $this->context->pair($user); + + if ($override === null || !$this->ownership->belongsToPair($entityType, $entityId, $override)) { + return $this->error(ErrorCodes::ERR_NOT_FOUND_001, 'استثنای تعطیلی یافت نشد', 404); + } + + $this->holidays->deleteOverride($override); + + return $this->success(null); + } +} diff --git a/src/Resource/Controller/ResourceCalendarController.php b/src/Resource/Controller/ResourceCalendarController.php new file mode 100644 index 00000000..778c7b78 --- /dev/null +++ b/src/Resource/Controller/ResourceCalendarController.php @@ -0,0 +1,192 @@ +denyUnlessGranted($user, 'view'); + + $resource = $this->context->resource($user, $uuid); + + return $this->success([ + 'resource_uuid' => $resource->getUuid(), + 'timezone' => $resource->getAddress()->getTimezone(), + 'defined' => $this->calendar->isDefined($resource), + 'days' => (object) $this->calendar->read($resource), + ]); + } + + /** جایگزینی کامل هفت روز؛ آرایهٔ خالی یعنی منبع هیچ شیفتی ندارد. */ + #[Route('/api/v1/resource/{uuid}/calendar', name: 'resource_calendar_replace', methods: ['PUT'])] + public function replace(#[CurrentUser] User $user, string $uuid, Request $request): JsonResponse + { + $this->denyUnlessGranted($user, 'update'); + + $data = json_decode($request->getContent(), true); + + if (!is_array($data) || !is_array($data['days'] ?? null)) { + return $this->error(ErrorCodes::ERR_VALIDATION_002, 'فیلد days الزامی است', 422, 'days'); + } + + $resource = $this->context->resource($user, $uuid); + $days = $this->calendar->replace($resource, $data['days']); + + return $this->success([ + 'resource_uuid' => $resource->getUuid(), + 'timezone' => $resource->getAddress()->getTimezone(), + 'defined' => $days !== array_fill_keys(ResourceCalendarService::DAYS, []), + 'days' => (object) $days, + ]); + } + + #[Route('/api/v1/resource/{uuid}/exceptions', name: 'resource_exceptions_list', methods: ['GET'])] + public function listExceptions(#[CurrentUser] User $user, string $uuid, Request $request): JsonResponse + { + $this->denyUnlessGranted($user, 'view'); + + $resource = $this->context->resource($user, $uuid); + $from = $request->query->get('from'); + $to = $request->query->get('to'); + + // بدون بازه، همهٔ استثناها از ابتدای زمان تا انتهای آن — عمداً محدود نمی‌شود + // چون فهرست مرخصیِ یک منبع کوچک است و صفحه‌بندی‌اش سود ندارد. + $rows = $this->exceptions->findOverlapping( + $resource, + is_numeric($from) ? (int) $from : 0, + is_numeric($to) ? (int) $to : PHP_INT_MAX, + ); + + return $this->success(array_map( + static fn (ResourceException $e): array => $e->toArray(), + $rows, + )); + } + + #[Route('/api/v1/resource/{uuid}/exception', name: 'resource_exception_create', methods: ['POST'])] + public function createException(#[CurrentUser] User $user, string $uuid, Request $request): JsonResponse + { + $this->denyUnlessGranted($user, 'update'); + + $data = json_decode($request->getContent(), true); + + if (!is_array($data)) { + return $this->error(ErrorCodes::ERR_VALIDATION_001, 'بدنهٔ درخواست نامعتبر است', 422); + } + + $resource = $this->context->resource($user, $uuid); + $exception = $this->calendar->createException($resource, $data); + + return $this->success($exception->toArray(), 201); + } + + #[Route('/api/v1/resource-exception/{uuid}', name: 'resource_exception_update', methods: ['PATCH'])] + public function updateException(#[CurrentUser] User $user, string $uuid, Request $request): JsonResponse + { + $this->denyUnlessGranted($user, 'update'); + + $data = json_decode($request->getContent(), true); + + if (!is_array($data)) { + return $this->error(ErrorCodes::ERR_VALIDATION_001, 'بدنهٔ درخواست نامعتبر است', 422); + } + + return $this->success($this->calendar->updateException($this->requireException($user, $uuid), $data)->toArray()); + } + + #[Route('/api/v1/resource-exception/{uuid}', name: 'resource_exception_delete', methods: ['DELETE'])] + public function deleteException(#[CurrentUser] User $user, string $uuid): JsonResponse + { + $this->denyUnlessGranted($user, 'update'); + + $this->calendar->deleteException($this->requireException($user, $uuid)); + + return $this->success(null); + } + + /** + * ساعت آزاد **خام**: ساعت شعبه ∩ شیفت منبع − تعطیلات − استثناها. + * نوبت‌های ثبت‌شده اینجا کسر نمی‌شوند — آن کارِ تسک ۰۶/۰۷ است. + */ + #[Route('/api/v1/resource/{uuid}/availability', name: 'resource_availability', methods: ['GET'])] + public function availabilityFor(#[CurrentUser] User $user, string $uuid, Request $request): JsonResponse + { + $this->denyUnlessGranted($user, 'view'); + + $resource = $this->context->resource($user, $uuid); + $from = $request->query->get('from'); + $to = $request->query->get('to'); + + if (!is_numeric($from) || !is_numeric($to)) { + return $this->error(ErrorCodes::ERR_VALIDATION_002, 'پارامترهای from و to الزامی‌اند', 422, 'from'); + } + + if ((int) $to < (int) $from) { + return $this->error(ErrorCodes::ERR_VALIDATION_001, 'to باید بعد از from باشد', 422, 'to'); + } + + $days = intdiv((int) $to - (int) $from, ResourceAvailabilityService::DAY_SECONDS) + 1; + + if ($days > ResourceAvailabilityService::MAX_DAYS) { + return $this->error( + ErrorCodes::ERR_VALIDATION_001, + sprintf('بازهٔ درخواستی حداکثر %d روز است', ResourceAvailabilityService::MAX_DAYS), + 422, + 'to', + ); + } + + return $this->success([ + 'resource_uuid' => $resource->getUuid(), + 'timezone' => $resource->getAddress()->getTimezone(), + 'days' => array_map( + static fn (DayAvailability $d): array => $d->toArray(), + $this->availability->rawAvailability($resource, (int) $from, (int) $to), + ), + ]); + } + + private function requireException(User $user, string $uuid): ResourceException + { + $exception = $this->exceptions->findByUuid($uuid); + [$entityType, $entityId] = $this->context->pair($user); + + if ($exception === null || !$this->ownership->belongsToPair($entityType, $entityId, $exception)) { + throw new AppException(ErrorCodes::ERR_NOT_FOUND_001, 'استثنا یافت نشد', 404); + } + + return $exception; + } +} diff --git a/src/Resource/Entity/NationalHoliday.php b/src/Resource/Entity/NationalHoliday.php new file mode 100644 index 00000000..a4875c8f --- /dev/null +++ b/src/Resource/Entity/NationalHoliday.php @@ -0,0 +1,78 @@ +uuid = Uuid::v4()->toRfc4122(); + $this->date = $date; + $this->jalaliDate = $jalaliDate; + $this->jalaliYear = $jalaliYear; + $this->title = $title; + $this->createdAt = time(); + } + + public function getId(): ?int { return $this->id; } + public function getUuid(): string { return $this->uuid; } + public function getDate(): int { return $this->date; } + public function getJalaliDate(): string { return $this->jalaliDate; } + public function getJalaliYear(): int { return $this->jalaliYear; } + public function getTitle(): string { return $this->title; } + + public function setTitle(string $v): self { $this->title = $v; return $this; } + + public function toArray(): array + { + return [ + 'uuid' => $this->uuid, + 'date' => $this->date, + 'jalali_date' => $this->jalaliDate, + 'jalali_year' => $this->jalaliYear, + 'title' => $this->title, + ]; + } +} diff --git a/src/Resource/Entity/ResourceCalendar.php b/src/Resource/Entity/ResourceCalendar.php new file mode 100644 index 00000000..a6494452 --- /dev/null +++ b/src/Resource/Entity/ResourceCalendar.php @@ -0,0 +1,92 @@ + 0])] + private int $sequence = 0; + + #[ORM\Column(name: 'start_minute', type: 'smallint')] + private int $startMinute; + + #[ORM\Column(name: 'end_minute', type: 'smallint')] + private int $endMinute; + + #[ORM\Column(type: 'boolean', options: ['default' => true])] + private bool $active = true; + + public function __construct( + ClinicResource $resource, + int $dayOfWeek, + int $startMinute, + int $endMinute, + int $sequence = 0, + ) { + $this->resource = $resource; + $this->dayOfWeek = $dayOfWeek; + $this->startMinute = $startMinute; + $this->endMinute = $endMinute; + $this->sequence = $sequence; + } + + public function getId(): ?int { return $this->id; } + public function getResource(): ClinicResource { return $this->resource; } + public function getDayOfWeek(): int { return $this->dayOfWeek; } + public function getSequence(): int { return $this->sequence; } + public function getStartMinute(): int { return $this->startMinute; } + public function getEndMinute(): int { return $this->endMinute; } + public function isActive(): bool { return $this->active; } + + public function setActive(bool $v): self { $this->active = $v; return $this; } + + public function toArray(): array + { + return [ + 'sequence' => $this->sequence, + 'start_minute' => $this->startMinute, + 'end_minute' => $this->endMinute, + 'start_time' => self::formatMinute($this->startMinute), + 'end_time' => self::formatMinute($this->endMinute), + 'active' => $this->active, + ]; + } + + /** ۱۴۴۰ به «۲۴:۰۰» تبدیل می‌شود، نه «۰۰:۰۰» — پایان روز است نه آغازش. */ + public static function formatMinute(int $minute): string + { + return sprintf('%02d:%02d', intdiv($minute, 60), $minute % 60); + } +} diff --git a/src/Resource/Entity/ResourceException.php b/src/Resource/Entity/ResourceException.php new file mode 100644 index 00000000..2ac555b6 --- /dev/null +++ b/src/Resource/Entity/ResourceException.php @@ -0,0 +1,135 @@ + 'مرخصی', + self::TYPE_ABSENCE => 'غیبت', + self::TYPE_MAINTENANCE => 'سرویس دوره‌ای', + self::TYPE_CLOSURE => 'تعطیلی موردی', + ]; + + #[ORM\Id] + #[ORM\GeneratedValue] + #[ORM\Column(type: 'integer')] + private ?int $id = null; + + #[ORM\Column(type: 'string', length: 36, unique: true)] + private string $uuid; + + #[ORM\ManyToOne(targetEntity: ClinicResource::class)] + #[ORM\JoinColumn(name: 'resource_id', referencedColumnName: 'id', nullable: false, onDelete: 'CASCADE')] + private ClinicResource $resource; + + #[ORM\Column(type: 'string', length: 20)] + private string $type; + + #[ORM\Column(name: 'starts_at', type: 'integer')] + private int $startsAt; + + #[ORM\Column(name: 'ends_at', type: 'integer')] + private int $endsAt; + + #[ORM\Column(type: 'string', length: 200, nullable: true)] + private ?string $reason = null; + + #[ORM\Column(name: 'created_at', type: 'integer')] + private int $createdAt; + + #[ORM\Column(name: 'updated_at', type: 'integer')] + private int $updatedAt; + + public function __construct(ClinicResource $resource, string $type, int $startsAt, int $endsAt) + { + if (!array_key_exists($type, self::TYPES)) { + throw new \InvalidArgumentException(sprintf('Unknown resource exception type "%s".', $type)); + } + + if ($endsAt <= $startsAt) { + throw new \InvalidArgumentException('Exception end must be after its start.'); + } + + $this->uuid = Uuid::v4()->toRfc4122(); + $this->resource = $resource; + $this->type = $type; + $this->startsAt = $startsAt; + $this->endsAt = $endsAt; + $this->createdAt = time(); + $this->updatedAt = time(); + + $this->assignTenantPair($resource->getEntityType(), $resource->getEntityId()); + } + + public function getId(): ?int { return $this->id; } + public function getUuid(): string { return $this->uuid; } + public function getResource(): ClinicResource { return $this->resource; } + public function getType(): string { return $this->type; } + public function getStartsAt(): int { return $this->startsAt; } + public function getEndsAt(): int { return $this->endsAt; } + public function getReason(): ?string { return $this->reason; } + public function getCreatedAt(): int { return $this->createdAt; } + public function getUpdatedAt(): int { return $this->updatedAt; } + + public function setReason(?string $v): self { $this->reason = $v; $this->touch(); return $this; } + + /** @throws \InvalidArgumentException روی بازهٔ وارونه */ + public function reschedule(int $startsAt, int $endsAt): self + { + if ($endsAt <= $startsAt) { + throw new \InvalidArgumentException('Exception end must be after its start.'); + } + + $this->startsAt = $startsAt; + $this->endsAt = $endsAt; + $this->touch(); + + return $this; + } + + private function touch(): void { $this->updatedAt = time(); } + + public function toArray(): array + { + return [ + 'uuid' => $this->uuid, + 'resource_uuid' => $this->resource->getUuid(), + 'resource_name' => $this->resource->getName(), + 'type' => $this->type, + 'type_label' => self::TYPES[$this->type], + 'starts_at' => $this->startsAt, + 'ends_at' => $this->endsAt, + 'reason' => $this->reason, + 'created_at' => $this->createdAt, + 'updated_at' => $this->updatedAt, + ]; + } +} diff --git a/src/Resource/Entity/TenantHolidayOverride.php b/src/Resource/Entity/TenantHolidayOverride.php new file mode 100644 index 00000000..06cde82a --- /dev/null +++ b/src/Resource/Entity/TenantHolidayOverride.php @@ -0,0 +1,78 @@ +uuid = Uuid::v4()->toRfc4122(); + $this->date = $date; + $this->isWorking = $isWorking; + $this->createdAt = time(); + + $this->assignTenantPair($entityType, $entityId); + } + + public function getId(): ?int { return $this->id; } + public function getUuid(): string { return $this->uuid; } + public function getDate(): int { return $this->date; } + public function isWorking(): bool { return $this->isWorking; } + public function getNote(): ?string { return $this->note; } + + public function setNote(?string $v): self { $this->note = $v; return $this; } + public function setWorking(bool $v): self { $this->isWorking = $v; return $this; } + + public function toArray(): array + { + return [ + 'uuid' => $this->uuid, + 'date' => $this->date, + 'is_working' => $this->isWorking, + 'note' => $this->note, + 'created_at' => $this->createdAt, + ]; + } +} diff --git a/src/Resource/Repository/NationalHolidayRepository.php b/src/Resource/Repository/NationalHolidayRepository.php new file mode 100644 index 00000000..be600d03 --- /dev/null +++ b/src/Resource/Repository/NationalHolidayRepository.php @@ -0,0 +1,55 @@ + + */ +class NationalHolidayRepository extends ServiceEntityRepository +{ + public function __construct(ManagerRegistry $registry) + { + parent::__construct($registry, NationalHoliday::class); + } + + /** @return NationalHoliday[] */ + public function findForYear(int $jalaliYear): array + { + return $this->createQueryBuilder('h') + ->where('h.jalaliYear = :year') + ->setParameter('year', $jalaliYear) + ->orderBy('h.date', 'ASC') + ->getQuery() + ->getResult(); + } + + /** + * @return array کلید = نیمه‌شب همان روز + */ + public function mapForRange(int $from, int $to): array + { + $rows = $this->createQueryBuilder('h') + ->where('h.date >= :from') + ->andWhere('h.date <= :to') + ->setParameter('from', $from) + ->setParameter('to', $to) + ->getQuery() + ->getResult(); + + $map = []; + foreach ($rows as $holiday) { + $map[$holiday->getDate()] = $holiday; + } + + return $map; + } + + public function findByDate(int $date): ?NationalHoliday + { + return $this->findOneBy(['date' => $date]); + } +} diff --git a/src/Resource/Repository/ResourceCalendarRepository.php b/src/Resource/Repository/ResourceCalendarRepository.php new file mode 100644 index 00000000..b438197c --- /dev/null +++ b/src/Resource/Repository/ResourceCalendarRepository.php @@ -0,0 +1,67 @@ + + */ +class ResourceCalendarRepository extends ServiceEntityRepository +{ + public function __construct(ManagerRegistry $registry) + { + parent::__construct($registry, ResourceCalendar::class); + } + + /** @return ResourceCalendar[] */ + public function findForResource(ClinicResource $resource): array + { + return $this->createQueryBuilder('c') + ->where('c.resource = :resource') + ->setParameter('resource', $resource) + ->orderBy('c.dayOfWeek', 'ASC') + ->addOrderBy('c.sequence', 'ASC') + ->getQuery() + ->getResult(); + } + + public function deleteForResource(ClinicResource $resource): int + { + return (int) $this->createQueryBuilder('c') + ->delete() + ->where('c.resource = :resource') + ->setParameter('resource', $resource) + ->getQuery() + ->execute(); + } + + /** + * @param int[] $resourceIds + * @return array شناسهٔ منبع => تعداد شیفت + */ + public function countByResourceIds(array $resourceIds): array + { + if ($resourceIds === []) { + return []; + } + + $rows = $this->createQueryBuilder('c') + ->select('IDENTITY(c.resource) AS resource_id, COUNT(c.id) AS total') + ->where('c.resource IN (:ids)') + ->setParameter('ids', $resourceIds) + ->groupBy('c.resource') + ->getQuery() + ->getArrayResult(); + + $counts = []; + foreach ($rows as $row) { + $counts[(int) $row['resource_id']] = (int) $row['total']; + } + + return $counts; + } +} diff --git a/src/Resource/Repository/ResourceExceptionRepository.php b/src/Resource/Repository/ResourceExceptionRepository.php new file mode 100644 index 00000000..386f6cea --- /dev/null +++ b/src/Resource/Repository/ResourceExceptionRepository.php @@ -0,0 +1,44 @@ + + */ +class ResourceExceptionRepository extends ServiceEntityRepository +{ + public function __construct(ManagerRegistry $registry) + { + parent::__construct($registry, ResourceException::class); + } + + public function findByUuid(string $uuid): ?ResourceException + { + return $this->findOneBy(['uuid' => $uuid]); + } + + /** + * استثناهایی که با بازهٔ [from, to) **تداخل** دارند — نه فقط آن‌هایی که کاملاً + * درونش‌اند. مرخصیِ سه‌روزه‌ای که وسطش این بازه است باید برگردد. + * + * @return ResourceException[] + */ + public function findOverlapping(ClinicResource $resource, int $from, int $to): array + { + return $this->createQueryBuilder('e') + ->where('e.resource = :resource') + ->andWhere('e.startsAt < :to') + ->andWhere('e.endsAt > :from') + ->setParameter('resource', $resource) + ->setParameter('from', $from) + ->setParameter('to', $to) + ->orderBy('e.startsAt', 'ASC') + ->getQuery() + ->getResult(); + } +} diff --git a/src/Resource/Repository/TenantHolidayOverrideRepository.php b/src/Resource/Repository/TenantHolidayOverrideRepository.php new file mode 100644 index 00000000..f83e3121 --- /dev/null +++ b/src/Resource/Repository/TenantHolidayOverrideRepository.php @@ -0,0 +1,53 @@ + + */ +class TenantHolidayOverrideRepository extends ServiceEntityRepository +{ + public function __construct(ManagerRegistry $registry) + { + parent::__construct($registry, TenantHolidayOverride::class); + } + + public function findByUuid(string $uuid): ?TenantHolidayOverride + { + return $this->findOneBy(['uuid' => $uuid]); + } + + public function findForDate(string $entityType, int $entityId, int $date): ?TenantHolidayOverride + { + return $this->findOneBy(['entityType' => $entityType, 'entityId' => $entityId, 'date' => $date]); + } + + /** + * @return array کلید = نیمه‌شب همان روز + */ + public function mapForRange(string $entityType, int $entityId, int $from, int $to): array + { + $rows = $this->createQueryBuilder('o') + ->where('o.entityType = :type') + ->andWhere('o.entityId = :id') + ->andWhere('o.date >= :from') + ->andWhere('o.date <= :to') + ->setParameter('type', $entityType) + ->setParameter('id', $entityId) + ->setParameter('from', $from) + ->setParameter('to', $to) + ->getQuery() + ->getResult(); + + $map = []; + foreach ($rows as $override) { + $map[$override->getDate()] = $override; + } + + return $map; + } +} diff --git a/src/Resource/Service/HolidayService.php b/src/Resource/Service/HolidayService.php new file mode 100644 index 00000000..37a3a625 --- /dev/null +++ b/src/Resource/Service/HolidayService.php @@ -0,0 +1,141 @@ +jalali->jalaliToGregorian($jy, $jm, $jd); + + return (new \DateTimeImmutable( + sprintf('%04d-%02d-%02d 00:00:00', $gy, $gm, $gd), + new \DateTimeZone(self::NATIONAL_TIMEZONE), + ))->getTimestamp(); + } + + /** + * یک تعطیل رسمی را ثبت یا به‌روز می‌کند. idempotent: تکیه‌گاهش `date` است که + * یکتاست، پس import دوباره ردیف تکراری نمی‌سازد. + * + * خودش flush می‌کند و این عمدی است: سپردنِ flush به فراخوان یعنی اگر او + * EntityManager دیگری در دست داشته باشد، persist روی یکی و flush روی دیگری + * می‌افتد و **هیچ ردیفی نوشته نمی‌شود، بی‌هیچ خطایی**. همین در تست‌ها اتفاق افتاد، + * چون هر درخواست HTTP کرنل را از نو می‌سازد و سرویس از کانتینر تازه می‌آید. + */ + public function upsertNational(int $jy, int $jm, int $jd, string $title): NationalHoliday + { + $date = $this->jalaliToMidnight($jy, $jm, $jd); + $existing = $this->holidays->findByDate($date); + + if ($existing !== null) { + $existing->setTitle($title); + $this->em->flush(); + + return $existing; + } + + $holiday = new NationalHoliday( + $date, + sprintf('%04d-%02d-%02d', $jy, $jm, $jd), + $jy, + $title, + ); + + $this->em->persist($holiday); + $this->em->flush(); + + return $holiday; + } + + /** @return NationalHoliday[] */ + public function forYear(int $jalaliYear): array + { + return $this->holidays->findForYear($jalaliYear); + } + + /** + * استثنای محیط. روی همان تاریخ دوباره فرستادن، همان ردیف را عوض می‌کند — وگرنه + * کلید یکتا با خطای خام دیتابیس می‌شکست. + */ + public function setOverride(string $entityType, int $entityId, int $date, bool $isWorking, ?string $note): TenantHolidayOverride + { + $midnight = $this->midnightTehran($date); + $existing = $this->overrides->findForDate($entityType, $entityId, $midnight); + + if ($existing !== null) { + $existing->setWorking($isWorking)->setNote($note); + $this->em->flush(); + + return $existing; + } + + $override = new TenantHolidayOverride($entityType, $entityId, $midnight, $isWorking); + $override->setNote($note); + + $this->em->persist($override); + $this->em->flush(); + + return $override; + } + + public function deleteOverride(TenantHolidayOverride $override): void + { + $this->em->remove($override); + $this->em->flush(); + } + + /** @return TenantHolidayOverride[] */ + public function overridesForYear(string $entityType, int $entityId, int $jalaliYear): array + { + $from = $this->jalaliToMidnight($jalaliYear, 1, 1); + $to = $this->jalaliToMidnight($jalaliYear + 1, 1, 1); + + return array_values($this->overrides->mapForRange($entityType, $entityId, $from, $to)); + } + + public function assertJalaliYear(mixed $value): int + { + if (!is_numeric($value) || (int) $value < 1300 || (int) $value > 1500) { + throw new AppException(ErrorCodes::ERR_VALIDATION_001, 'سال شمسی نامعتبر است', 422, 'year'); + } + + return (int) $value; + } + + private function midnightTehran(int $timestamp): int + { + return (new \DateTimeImmutable('@' . $timestamp)) + ->setTimezone(new \DateTimeZone(self::NATIONAL_TIMEZONE)) + ->setTime(0, 0) + ->getTimestamp(); + } +} diff --git a/src/Resource/Service/ResourceAvailabilityService.php b/src/Resource/Service/ResourceAvailabilityService.php new file mode 100644 index 00000000..c08eb13e --- /dev/null +++ b/src/Resource/Service/ResourceAvailabilityService.php @@ -0,0 +1,256 @@ +getAddress()->getTimezone()); + $startDay = $this->midnight($from, $timezone); + $endDay = $this->midnight($to, $timezone); + + // شیفت‌ها و ساعت شعبه یک بار خوانده می‌شوند، نه per روز. + $shiftsByDay = $this->shiftsByDay($resource); + $branchByDay = $this->branchHoursByDay($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, + $branchByDay, + $holidayMap, + $overrideMap, + $exceptions, + ); + } + + return $days; + } + + /** + * @param array> $shiftsByDay + * @param array>|null $branchByDay + * @param array $holidayMap + * @param array $overrideMap + * @param ResourceException[] $exceptions + */ + private function buildDay( + ClinicResource $resource, + int $midnight, + \DateTimeZone $timezone, + array $shiftsByDay, + ?array $branchByDay, + array $holidayMap, + array $overrideMap, + array $exceptions, + ): DayAvailability { + $reasons = []; + $dayOfWeek = $this->dayOfWeek($midnight, $timezone); + + if (!$resource->isActive()) { + return new DayAvailability($midnight, $dayOfWeek, [], ['resource_inactive']); + } + + if (!$resource->getAddress()->isActive()) { + return new DayAvailability($midnight, $dayOfWeek, [], ['branch_inactive']); + } + + $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']); + } + + // شعبهٔ بدون ساعت کاری = «تعریف‌نشده»، نه «بسته»: شیفت منبع بی‌قید اعمال + // می‌شود تا دادهٔ موجود دقیقاً مثل امروز کار کند (قرارداد تسک ۰۱). + if ($branchByDay !== null) { + $branchWindows = $branchByDay[$dayOfWeek] ?? []; + + if ($branchWindows === []) { + return new DayAvailability($midnight, $dayOfWeek, [], ['branch_closed']); + } + + $intersected = TimeInterval::intersectAll($shifts, $branchWindows); + + // شیفت هست ولی تقاطعش با ساعت شعبه خالی شد — این با «شیفتی نیست» فرق دارد + // و بدون دلیل صریح، پاسخِ خالی از یک باگ قابل تشخیص نیست. + if ($intersected === []) { + $reasons[] = 'outside_branch_hours'; + } + + $shifts = $intersected; + } + + $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> روز هفته => بازه‌های دقیقه‌ای */ + 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); + } + + /** + * `null` یعنی این شعبه اصلاً ساعت کاری تعریف‌شده ندارد — که با «همهٔ روزها بسته» + * فرق دارد و نباید با آن یکی گرفته شود. + * + * @return array>|null + */ + private function branchHoursByDay(ClinicResource $resource): ?array + { + $rows = $this->branchHours->findForAddress($resource->getAddress()); + + if ($rows === []) { + return null; + } + + $byDay = []; + foreach ($rows as $row) { + if (!$row->isActive()) { + continue; + } + + $byDay[$row->getDayOfWeek()][] = new TimeInterval($row->getStartMinute(), $row->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); + } +} diff --git a/src/Resource/Service/ResourceCalendarService.php b/src/Resource/Service/ResourceCalendarService.php new file mode 100644 index 00000000..9737c5a4 --- /dev/null +++ b/src/Resource/Service/ResourceCalendarService.php @@ -0,0 +1,235 @@ +>> کلیدهای ۰..۶ همیشه هر هفت روز */ + public function read(ClinicResource $resource): array + { + $result = array_fill_keys(self::DAYS, []); + + foreach ($this->calendars->findForResource($resource) as $shift) { + $result[$shift->getDayOfWeek()][] = $shift->toArray(); + } + + return $result; + } + + public function isDefined(ClinicResource $resource): bool + { + return $this->calendars->findForResource($resource) !== []; + } + + /** + * جایگزینی کامل هفت روز — همان قرارداد ساعت کاری شعبه، و به همان دلیل: + * merge تفاضلی روی هفت روز و چند بازه، دو کلاینت هم‌زمان را ناسازگار می‌کند. + * + * @param array $days + * @return array>> + */ + public function replace(ClinicResource $resource, array $days): array + { + $normalized = $this->validate($days); + + // اعتبارسنجی کامل پیش از هر حذفی — بازهٔ نامعتبر در روز ششم نباید شش روز + // درستِ قبلی را پاک کند و بعد ۴۲۲ برگرداند. + $this->calendars->deleteForResource($resource); + + foreach ($normalized as $dayOfWeek => $ranges) { + foreach ($ranges as $sequence => $range) { + $this->em->persist(new ResourceCalendar( + $resource, + $dayOfWeek, + $range['start_minute'], + $range['end_minute'], + $sequence, + )); + } + } + + $this->em->flush(); + + return $this->read($resource); + } + + /** + * @param array $days + * @return array> + */ + private function validate(array $days): array + { + $normalized = array_fill_keys(self::DAYS, []); + + foreach ($days as $rawDay => $ranges) { + if (!is_numeric($rawDay) || !in_array((int) $rawDay, self::DAYS, true)) { + throw new AppException( + ErrorCodes::ERR_VALIDATION_001, + 'روز هفته باید عددی بین ۰ (شنبه) و ۶ (جمعه) باشد', + 422, + 'day_of_week', + ); + } + + if (!is_array($ranges)) { + throw new AppException(ErrorCodes::ERR_VALIDATION_001, 'بازه‌های هر روز باید آرایه باشد', 422, 'shifts'); + } + + $normalized[(int) $rawDay] = $this->assertRanges((int) $rawDay, $ranges); + } + + return $normalized; + } + + /** + * @param array $ranges + * @return list + */ + private function assertRanges(int $day, array $ranges): array + { + $parsed = []; + + foreach ($ranges as $range) { + if (!is_array($range)) { + throw new AppException(ErrorCodes::ERR_VALIDATION_001, 'ساختار بازه درست نیست', 422, 'start_minute'); + } + + $start = $this->assertMinute($range['start_minute'] ?? null, $day, 'start_minute'); + $end = $this->assertMinute($range['end_minute'] ?? null, $day, 'end_minute'); + + if ($end <= $start) { + throw new AppException( + ErrorCodes::ERR_VALIDATION_001, + sprintf('در روز %d، پایان شیفت باید بعد از شروع آن باشد', $day), + 422, + 'end_minute', + ); + } + + $parsed[] = ['start_minute' => $start, 'end_minute' => $end]; + } + + usort($parsed, static fn (array $a, array $b): int => $a['start_minute'] <=> $b['start_minute']); + + foreach ($parsed as $i => $range) { + if ($i > 0 && $range['start_minute'] < $parsed[$i - 1]['end_minute']) { + throw new AppException( + ErrorCodes::ERR_VALIDATION_001, + sprintf('شیفت‌های روز %d با هم هم‌پوشانی دارند', $day), + 422, + 'start_minute', + ); + } + } + + return $parsed; + } + + private function assertMinute(mixed $value, int $day, string $field): int + { + if (!is_numeric($value)) { + throw new AppException( + ErrorCodes::ERR_VALIDATION_002, + sprintf('در روز %d مقدار %s الزامی است', $day, $field), + 422, + $field, + ); + } + + $minute = (int) $value; + + if ($minute < 0 || $minute > ResourceCalendar::MINUTES_IN_DAY) { + throw new AppException( + ErrorCodes::ERR_VALIDATION_001, + sprintf('در روز %d مقدار %s باید بین ۰ و ۱۴۴۰ باشد', $day, $field), + 422, + $field, + ); + } + + return $minute; + } + + /** @param array $data */ + public function createException(ClinicResource $resource, array $data): ResourceException + { + $type = is_string($data['type'] ?? null) ? $data['type'] : ''; + $start = $data['starts_at'] ?? null; + $end = $data['ends_at'] ?? null; + + if (!array_key_exists($type, ResourceException::TYPES)) { + throw new AppException( + ErrorCodes::ERR_VALIDATION_001, + 'نوع استثنا باید یکی از leave/absence/maintenance/closure باشد', + 422, + 'type', + ); + } + + if (!is_numeric($start) || !is_numeric($end)) { + throw new AppException(ErrorCodes::ERR_VALIDATION_002, 'starts_at و ends_at الزامی‌اند', 422, 'starts_at'); + } + + try { + $exception = new ResourceException($resource, $type, (int) $start, (int) $end); + } catch (\InvalidArgumentException) { + throw new AppException(ErrorCodes::ERR_VALIDATION_001, 'پایان استثنا باید بعد از شروع آن باشد', 422, 'ends_at'); + } + + if (isset($data['reason']) && is_string($data['reason'])) { + $exception->setReason(trim($data['reason']) === '' ? null : trim($data['reason'])); + } + + $this->em->persist($exception); + $this->em->flush(); + + return $exception; + } + + /** @param array $data */ + public function updateException(ResourceException $exception, array $data): ResourceException + { + $start = $data['starts_at'] ?? $exception->getStartsAt(); + $end = $data['ends_at'] ?? $exception->getEndsAt(); + + if (!is_numeric($start) || !is_numeric($end)) { + throw new AppException(ErrorCodes::ERR_VALIDATION_001, 'starts_at و ends_at باید عدد باشند', 422, 'starts_at'); + } + + try { + $exception->reschedule((int) $start, (int) $end); + } catch (\InvalidArgumentException) { + throw new AppException(ErrorCodes::ERR_VALIDATION_001, 'پایان استثنا باید بعد از شروع آن باشد', 422, 'ends_at'); + } + + if (array_key_exists('reason', $data)) { + $reason = is_string($data['reason']) ? trim($data['reason']) : ''; + $exception->setReason($reason === '' ? null : $reason); + } + + $this->em->flush(); + + return $exception; + } + + public function deleteException(ResourceException $exception): void + { + $this->em->remove($exception); + $this->em->flush(); + } +} diff --git a/src/Resource/ValueObject/DayAvailability.php b/src/Resource/ValueObject/DayAvailability.php new file mode 100644 index 00000000..ee2ca230 --- /dev/null +++ b/src/Resource/ValueObject/DayAvailability.php @@ -0,0 +1,55 @@ + $intervals بازه‌های آزاد، به‌صورت timestamp مطلق + * @param list $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, + ]; + } +} diff --git a/src/Resource/ValueObject/TimeInterval.php b/src/Resource/ValueObject/TimeInterval.php new file mode 100644 index 00000000..a01c07c0 --- /dev/null +++ b/src/Resource/ValueObject/TimeInterval.php @@ -0,0 +1,131 @@ +start * 60, $midnight + $this->end * 60); + } + + public function toArray(): array + { + return ['start' => $this->start, 'end' => $this->end]; + } + + /** + * بازه‌های هم‌پوشان یا چسبیده را یکی می‌کند. خروجی مرتب است. + * + * @param list $intervals + * @return list + */ + 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 $left + * @param list $right + * @return list + */ + 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 $from + * @param list $blocks + * @return list + */ + 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; + } +} diff --git a/src/Shared/Tenant/GlobalTables.php b/src/Shared/Tenant/GlobalTables.php index 7b8dccf9..586cb611 100644 --- a/src/Shared/Tenant/GlobalTables.php +++ b/src/Shared/Tenant/GlobalTables.php @@ -38,6 +38,7 @@ final class GlobalTables \App\Sms\Entity\SmsLog::class => 'لاگ ارسال؛ فقط شماره و قالب دارد، مالک ندارد', \App\Shared\Logging\AppLog::class => 'لاگ سراسری برنامه', \App\Blog\Entity\Blog::class => 'محتوای عمومی مارکت‌پلیس', + \App\Resource\Entity\NationalHoliday::class => 'تعطیلات رسمی کشور؛ per محیط کردنش معنای «کشوری» را از بین می‌برد — محیطی که خلافش کار می‌کند TenantHolidayOverride می‌زند', // هویت — یک شخص می‌تواند در چند محیط حضور داشته باشد \App\Auth\Entity\User::class => 'هویت سراسری؛ رابطهٔ بیمار با محیط از patient_records می‌آید', @@ -93,6 +94,7 @@ final class GlobalTables // تسک ۰۱)، پس ارث‌بری اینجا واقعی است. هیچ‌کدام uuid از request نمی‌گیرند: // تنها راهشان PUT روی /resource/{uuid}/skills و /resource-pool/{uuid}/members است. \App\Resource\Entity\ResourceSkill::class => \App\Resource\Entity\ClinicResource::class, + \App\Resource\Entity\ResourceCalendar::class => \App\Resource\Entity\ClinicResource::class, \App\Resource\Entity\ResourcePoolMember::class => \App\Resource\Entity\ResourcePool::class, \App\Patient\Entity\SessionAuditLog::class => \App\Patient\Entity\PatientSession::class, diff --git a/tests/Representation/JalaliDateServiceTest.php b/tests/Representation/JalaliDateServiceTest.php new file mode 100644 index 00000000..dbe9ee7d --- /dev/null +++ b/tests/Representation/JalaliDateServiceTest.php @@ -0,0 +1,77 @@ +service = new JalaliDateService(); + } + + /** نوروز لنگرِ همه‌چیز است: ۱ فروردین ۱۴۰۵ = ۲۱ مارس ۲۰۲۶. */ + public function testNowruzAnchor(): void + { + self::assertSame([2026, 3, 21], $this->service->jalaliToGregorian(1405, 1, 1)); + self::assertSame([1405, 1, 1], $this->service->gregorianToJalali(2026, 3, 21)); + } + + public function testRoundTripAcrossTheYear(): void + { + foreach ([[1405, 1, 1], [1405, 5, 8], [1405, 6, 31], [1405, 7, 1], [1405, 12, 29]] as [$jy, $jm, $jd]) { + [$gy, $gm, $gd] = $this->service->jalaliToGregorian($jy, $jm, $jd); + + self::assertSame( + [$jy, $jm, $jd], + $this->service->gregorianToJalali($gy, $gm, $gd), + sprintf('رفت‌وبرگشت %d/%d/%d', $jy, $jm, $jd), + ); + } + } + + /** مهر اول نیم‌سال دوم است و ماه‌هایش ۳۰ روزه — مرز ۶/۳۱ به ۷/۱. */ + public function testSixthMonthHasThirtyOneDaysAndSeventhStartsNextDay(): void + { + [$gy, $gm, $gd] = $this->service->jalaliToGregorian(1405, 6, 31); + $end = (new \DateTimeImmutable(sprintf('%04d-%02d-%02d', $gy, $gm, $gd)))->modify('+1 day'); + + self::assertSame( + [1405, 7, 1], + $this->service->gregorianToJalali( + (int) $end->format('Y'), + (int) $end->format('n'), + (int) $end->format('j'), + ), + ); + } + + public function testJalaliYearOfAKnownTimestamp(): void + { + $timestamp = (new \DateTimeImmutable('2026-07-30 12:00:00', new \DateTimeZone('Asia/Tehran')))->getTimestamp(); + + self::assertSame(1405, $this->service->jalaliYear($timestamp)); + self::assertSame(5, $this->service->jalaliMonth($timestamp)); + } + + /** بازهٔ سال باید نوروز تا آخر اسفند باشد، نه چیزی در قرن سی‌ویکم. */ + public function testYearRangeStartsAtNowruz(): void + { + [$start, $end] = $this->service->jalaliYearRange(1405); + + self::assertSame('2026-03-21', date('Y-m-d', $start)); + self::assertGreaterThan($start, $end); + self::assertSame(1405, $this->service->jalaliYear($start)); + } +} diff --git a/tests/Resource/ResourceAvailabilityTest.php b/tests/Resource/ResourceAvailabilityTest.php new file mode 100644 index 00000000..af4326e0 --- /dev/null +++ b/tests/Resource/ResourceAvailabilityTest.php @@ -0,0 +1,333 @@ +setTime(0, 0) + ->getTimestamp(); + } + + /** + * شنبه‌ای دور، مخصوصِ تست‌های تعطیلات رسمی. + * + * `national_holidays` عمداً سراسری است و `db_test` هرگز ریست نمی‌شود، پس ردیفی که + * اینجا ساخته شود روی **همهٔ** تست‌های دیگری که همان روز را می‌سنجند اثر می‌گذارد. + * فاصله گرفتن از پنجرهٔ یک‌هفته‌ایِ بقیه + پاک کردن در tearDown، هر دو لازم‌اند. + */ + private function farSaturday(): int + { + return $this->dayAfter($this->nextSaturday(), 210); + } + + protected function tearDown(): void + { + // ردیف سراسری را همان تستی که ساخته پاک می‌کند؛ وگرنه بدهی‌اش را تست بعدی می‌دهد. + $this->em->createQuery('DELETE FROM App\Resource\Entity\NationalHoliday h')->execute(); + + // بدون clear()، همان ردیفِ حذف‌شده در identity map می‌ماند و `findByDate()` تستِ + // بعدی آن را برمی‌گرداند؛ آن‌وقت upsert فکر می‌کند رکورد هست و چیزی نمی‌نویسد. + // این تست‌ها تنها وقتی جدا اجرا می‌شدند سبز بودند — دقیقاً نشانهٔ همین. + $this->em->clear(); + + parent::tearDown(); + } + + private function dayAfter(int $midnight, int $days): int + { + return (new \DateTimeImmutable('@' . $midnight)) + ->setTimezone(new \DateTimeZone(self::TEHRAN)) + ->modify("+$days day") + ->setTime(0, 0) + ->getTimestamp(); + } + + /** @return array{0: User, 1: DoctorAddress, 2: string} کاربر، شعبه، uuid منبع */ + private function resourceWithShifts(array $days = [0, 1, 2, 3, 4]): array + { + [$user, , $address] = $this->clinicWithAddress(); + $type = $this->resourceType($address, 'operator', 'اپراتور'); + $created = $this->createResource($user, $address, $type, ['name' => 'اپراتور مریم']); + $uuid = $created['data']['uuid']; + + $shifts = []; + foreach ($days as $day) { + $shifts[$day] = [['start_minute' => 540, 'end_minute' => 1020]]; // 09:00-17:00 + } + + $this->authJson('PUT', "/api/v1/resource/$uuid/calendar", $user, ['days' => $shifts]); + self::assertSame(200, $this->responseCode()); + + return [$user, $address, $uuid]; + } + + private function availability(User $user, string $uuid, int $from, int $to): array + { + $body = $this->authJson('GET', "/api/v1/resource/$uuid/availability?from=$from&to=$to", $user); + self::assertSame(200, $this->responseCode(), json_encode($body, JSON_UNESCAPED_UNICODE)); + + return $body['data']['days']; + } + + public function testFiveWorkingDaysAndAnEmptyFriday(): void + { + [$user, , $uuid] = $this->resourceWithShifts(); + + $saturday = $this->nextSaturday(); + $days = $this->availability($user, $uuid, $saturday, $this->dayAfter($saturday, 6)); + + self::assertCount(7, $days); + + $withHours = array_values(array_filter($days, static fn (array $d): bool => $d['intervals'] !== [])); + self::assertCount(5, $withHours, 'شنبه تا چهارشنبه'); + + // ۶ = جمعه در قرارداد ۰=شنبه + $friday = $days[6]; + self::assertSame(6, $friday['day_of_week']); + self::assertSame([], $friday['intervals']); + self::assertContains('no_shift', $friday['reasons']); + + self::assertSame(480, $days[0]['total_minutes'], '۹ تا ۱۷ یعنی ۴۸۰ دقیقه'); + } + + public function testAnExceptionEmptiesThatDay(): void + { + [$user, , $uuid] = $this->resourceWithShifts(); + + $saturday = $this->nextSaturday(); + $tuesday = $this->dayAfter($saturday, 3); + + $this->authJson('POST', "/api/v1/resource/$uuid/exception", $user, [ + 'type' => ResourceException::TYPE_LEAVE, + 'starts_at' => $tuesday, + 'ends_at' => $this->dayAfter($tuesday, 1), + 'reason' => 'مرخصی استحقاقی', + ]); + self::assertSame(201, $this->responseCode()); + + $days = $this->availability($user, $uuid, $saturday, $this->dayAfter($saturday, 6)); + + self::assertSame([], $days[3]['intervals']); + self::assertContains('exception', $days[3]['reasons']); + self::assertNotSame([], $days[2]['intervals'], 'روزهای دیگر دست‌نخورده‌اند'); + } + + /** استثنای نیم‌روزه فقط همان تکه را می‌بُرد، نه کل روز. */ + public function testHalfDayExceptionCutsOnlyItsOwnWindow(): void + { + [$user, , $uuid] = $this->resourceWithShifts(); + + $saturday = $this->nextSaturday(); + $monday = $this->dayAfter($saturday, 2); + + $this->authJson('POST', "/api/v1/resource/$uuid/exception", $user, [ + 'type' => ResourceException::TYPE_MAINTENANCE, + 'starts_at' => $monday + 14 * 3600, + 'ends_at' => $monday + 18 * 3600, + ]); + self::assertSame(201, $this->responseCode()); + + $days = $this->availability($user, $uuid, $monday, $monday); + + self::assertCount(1, $days[0]['intervals']); + self::assertSame($monday + 9 * 3600, $days[0]['intervals'][0]['start']); + self::assertSame($monday + 14 * 3600, $days[0]['intervals'][0]['end']); + self::assertSame(300, $days[0]['total_minutes'], '۹ تا ۱۴ یعنی ۳۰۰ دقیقه'); + } + + /** دو استثنای هم‌پوشان مجازند و اتحاد گرفته می‌شود، نه خطا. */ + public function testOverlappingExceptionsAreUnioned(): void + { + [$user, , $uuid] = $this->resourceWithShifts(); + + $saturday = $this->nextSaturday(); + + foreach ([[10, 13], [12, 16]] as [$startHour, $endHour]) { + $this->authJson('POST', "/api/v1/resource/$uuid/exception", $user, [ + 'type' => ResourceException::TYPE_ABSENCE, + 'starts_at' => $saturday + $startHour * 3600, + 'ends_at' => $saturday + $endHour * 3600, + ]); + self::assertSame(201, $this->responseCode()); + } + + $days = $this->availability($user, $uuid, $saturday, $saturday); + + // ۹-۱۰ و ۱۶-۱۷ باقی می‌ماند؛ ۱۰ تا ۱۶ یکجا بریده می‌شود. + self::assertCount(2, $days[0]['intervals']); + self::assertSame(120, $days[0]['total_minutes']); + } + + /** تعطیل رسمی بدون هیچ ثبت دستی، روز را برای همهٔ محیط‌ها می‌بندد. */ + public function testNationalHolidayClosesTheDayForEveryone(): void + { + [$user, , $uuid] = $this->resourceWithShifts(); + + $saturday = $this->farSaturday(); + $holidays = static::getContainer()->get(HolidayService::class); + + $jalali = static::getContainer()->get(\App\Representation\Service\JalaliDateService::class); + [$jy, $jm, $jd] = $jalali->toJalali( + (new \DateTimeImmutable('@' . $saturday))->setTimezone(new \DateTimeZone(self::TEHRAN)), + ); + + $holidays->upsertNational($jy, $jm, $jd, 'تعطیل آزمایشی'); + + $days = $this->availability($user, $uuid, $saturday, $saturday); + + self::assertSame([], $days[0]['intervals']); + self::assertContains('national_holiday', $days[0]['reasons']); + } + + /** محیطی که آن روز کار می‌کند با override باز می‌شود — فقط منابع همان محیط. */ + public function testTenantOverrideReopensANationalHoliday(): void + { + [$user, , $uuid] = $this->resourceWithShifts(); + [$otherUser, , $otherUuid] = $this->resourceWithShifts(); + + $saturday = $this->farSaturday(); + $holidays = static::getContainer()->get(HolidayService::class); + $jalali = static::getContainer()->get(\App\Representation\Service\JalaliDateService::class); + + [$jy, $jm, $jd] = $jalali->toJalali( + (new \DateTimeImmutable('@' . $saturday))->setTimezone(new \DateTimeZone(self::TEHRAN)), + ); + $holidays->upsertNational($jy, $jm, $jd, 'تعطیل آزمایشی'); + + $this->authJson('POST', '/api/v1/holiday-overrides', $user, [ + 'date' => $saturday, + 'is_working' => true, + 'note' => 'کلینیک ما این روز باز است', + ]); + self::assertSame(201, $this->responseCode()); + + $mine = $this->availability($user, $uuid, $saturday, $saturday); + self::assertNotSame([], $mine[0]['intervals'], 'محیطِ دارای override باز می‌شود'); + + $theirs = $this->availability($otherUser, $otherUuid, $saturday, $saturday); + self::assertSame([], $theirs[0]['intervals'], 'محیط دیگر همچنان بسته است'); + } + + /** جهت دوم: روزی که رسمی نیست ولی این محیط تعطیل است. */ + public function testTenantOverrideCanCloseANormalDay(): void + { + [$user, , $uuid] = $this->resourceWithShifts(); + + $saturday = $this->nextSaturday(); + + $this->authJson('POST', '/api/v1/holiday-overrides', $user, [ + 'date' => $saturday, + 'is_working' => false, + ]); + self::assertSame(201, $this->responseCode()); + + $days = $this->availability($user, $uuid, $saturday, $saturday); + + self::assertSame([], $days[0]['intervals']); + self::assertContains('tenant_holiday', $days[0]['reasons']); + } + + /** شیفت بیرون از ساعت شعبه رد نمی‌شود — تقاطع گرفته می‌شود. */ + public function testShiftIsIntersectedWithBranchHours(): void + { + [$user, $address, $uuid] = $this->resourceWithShifts([0]); + + // شعبه فقط ۱۰ تا ۱۲ باز است؛ شیفت منبع ۹ تا ۱۷. + $this->authJson('PUT', "/api/v1/branch/{$address->getUuid()}/working-hours", $user, [ + 'days' => [0 => [['start_minute' => 600, 'end_minute' => 720]]], + ]); + self::assertSame(200, $this->responseCode()); + + $saturday = $this->nextSaturday(); + $days = $this->availability($user, $uuid, $saturday, $saturday); + + self::assertCount(1, $days[0]['intervals']); + self::assertSame(120, $days[0]['total_minutes'], 'تقاطع ۱۰ تا ۱۲'); + } + + /** تقاطع خالی → روز خالی، با دلیل صریح تا از یک باگ قابل تشخیص باشد. */ + public function testEmptyIntersectionReportsOutsideBranchHours(): void + { + [$user, $address, $uuid] = $this->resourceWithShifts([0]); + + $this->authJson('PUT', "/api/v1/branch/{$address->getUuid()}/working-hours", $user, [ + 'days' => [0 => [['start_minute' => 1080, 'end_minute' => 1200]]], // 18:00-20:00 + ]); + + $saturday = $this->nextSaturday(); + $days = $this->availability($user, $uuid, $saturday, $saturday); + + self::assertSame([], $days[0]['intervals']); + self::assertContains('outside_branch_hours', $days[0]['reasons']); + } + + /** شعبهٔ بدون ساعت کاری = «تعریف‌نشده»، پس شیفت منبع بی‌قید اعمال می‌شود. */ + public function testBranchWithoutHoursDoesNotConstrainTheShift(): void + { + [$user, , $uuid] = $this->resourceWithShifts([0]); + + $saturday = $this->nextSaturday(); + $days = $this->availability($user, $uuid, $saturday, $saturday); + + self::assertSame(480, $days[0]['total_minutes']); + } + + /** روزی که شعبه بسته است با «تعریف‌نشده» یکی نیست. */ + public function testBranchClosedDayIsDistinctFromUndefined(): void + { + [$user, $address, $uuid] = $this->resourceWithShifts([0, 1]); + + $this->authJson('PUT', "/api/v1/branch/{$address->getUuid()}/working-hours", $user, [ + 'days' => [0 => [['start_minute' => 540, 'end_minute' => 1020]]], + ]); + + $saturday = $this->nextSaturday(); + $days = $this->availability($user, $uuid, $saturday, $this->dayAfter($saturday, 1)); + + self::assertNotSame([], $days[0]['intervals']); + self::assertSame([], $days[1]['intervals']); + self::assertContains('branch_closed', $days[1]['reasons']); + } + + public function testInactiveResourceHasNoAvailability(): void + { + [$user, , $uuid] = $this->resourceWithShifts(); + + $this->authJson('PATCH', "/api/v1/resource/$uuid", $user, ['active' => false]); + + $saturday = $this->nextSaturday(); + $days = $this->availability($user, $uuid, $saturday, $saturday); + + self::assertSame([], $days[0]['intervals']); + self::assertContains('resource_inactive', $days[0]['reasons']); + } + + public function testRangeLongerThanTheCapIsRejected(): void + { + [$user, , $uuid] = $this->resourceWithShifts(); + + $saturday = $this->nextSaturday(); + $tooFar = $this->dayAfter($saturday, 200); + + $this->authJson('GET', "/api/v1/resource/$uuid/availability?from=$saturday&to=$tooFar", $user); + + self::assertSame(422, $this->responseCode()); + } +}