feat(treatment): add treatment protocols, the multi-session course of a service

A protocol says a course of a service runs over several sessions, when each
falls due, which doctor supervises it and which staff may perform it. The row
existing IS the "طول درمان" switch, so there is no separate boolean that could
disagree with the step list.

Each step's offset is measured from the previous session rather than from the
start of the course: laser spacing is a clinical requirement — hair regrows
relative to the last treatment — so a late patient shifts the rest of their
course instead of getting the next session early. That also lets one course use
uneven gaps, which a single min/ideal/max triple cannot express: a botox course
is session 1, then +15 days, then monthly.

Steps and staff are cleared and rewritten in two flushes inside a transaction.
A single flush sends inserts before deletes and the replacement row collides
with the unique (protocol, step_number) index — caught by the replace test.

Removes docs/api/course.md and the task-12 folder. They documented src/Course/,
a module deleted in 65d5831c whose commit message only mentions removing two
test files; that design is superseded by this one.

ServiceItem::$sessionCount is marked deprecated. It never had logic behind it
and session count now comes from the protocol; the column stays in payloads so
existing clients keep working.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
hamed
2026-08-06 16:36:04 +03:30
co-authored by Claude Opus 5
parent 85985b04a0
commit e2e3e6b43b
21 changed files with 1107 additions and 1200 deletions
+7 -1
View File
@@ -90,7 +90,13 @@ class ServiceItem
#[ORM\Column(name: 'additional_duration_minutes', type: 'smallint', nullable: true)]
private ?int $additionalDurationMinutes = null;
/** تعداد جلسات؛ ۱ یعنی تک‌جلسه‌ای. پروتکل کامل دوره در تسک ۱۲. */
/**
* @deprecated تعداد جلسات از `TreatmentProtocol::totalSessions()` می‌آید.
*
* این ستون هیچ‌وقت منطقی پشتش نداشت و فقط در پاسخ‌ها دیده می‌شد. برای نشکستن
* کلاینت‌ها در `toArray()` می‌ماند، ولی هیچ کد جدیدی نباید بخواندش: پروتکل و این
* عدد دو منبع حقیقت برای یک مفهوم‌اند و اولی مرجع است.
*/
#[ORM\Column(name: 'session_count', type: 'smallint', options: ['default' => 1])]
private int $sessionCount = 1;
+6
View File
@@ -111,6 +111,12 @@ final class GlobalTables
\App\ClinicService\Entity\ServiceItemAuditLog::class => \App\ClinicService\Entity\ServiceItem::class,
\App\ClinicService\Entity\ServiceItemConsumable::class => \App\ClinicService\Entity\ServiceItem::class,
\App\ClinicService\Entity\ItemGroupMember::class => \App\ClinicService\Entity\ItemGroup::class,
// «طول درمانِ این سرویس» جزئی از تعریف همان سرویس است و uuid خودش هیچ‌جا از
// درخواست نمی‌آید — تنها راه رسیدن به آن، uuid سرویس است.
\App\Treatment\Entity\TreatmentProtocol::class => \App\ClinicService\Entity\ServiceItem::class,
\App\Treatment\Entity\TreatmentProtocolStep::class => \App\Treatment\Entity\TreatmentProtocol::class,
\App\Treatment\Entity\TreatmentProtocolStaff::class => \App\Treatment\Entity\TreatmentProtocol::class,
// یال «این دسته شامل آن دسته است» جزئی از تعریف دستهٔ والد است؛ هر دو سرِ یال
// در یک محیط‌اند و سازندهٔ یال همین را اجبار می‌کند.
\App\ClinicService\Entity\CatalogCategoryInclude::class => \App\ClinicService\Entity\CatalogCategory::class,
@@ -0,0 +1,85 @@
<?php
namespace App\Treatment\Controller;
use App\Auth\Entity\User;
use App\ClinicService\Entity\ServiceItem;
use App\ClinicService\Repository\ServiceItemRepository;
use App\Doctor\Service\AddressResolver;
use App\Shared\Constant\ErrorCodes;
use App\Shared\Controller\BaseController;
use App\Shared\Exception\AppException;
use App\Treatment\Repository\TreatmentProtocolRepository;
use App\Treatment\Service\TreatmentProtocolWriter;
use Doctrine\ORM\EntityManagerInterface;
use OpenApi\Attributes as OA;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\CurrentUser;
use Symfony\Component\Security\Http\Attribute\IsGranted;
#[OA\Tag(name: 'Treatment')]
#[IsGranted('IS_AUTHENTICATED_FULLY')]
class TreatmentProtocolController extends BaseController
{
public function __construct(
private readonly TreatmentProtocolRepository $protocols,
private readonly ServiceItemRepository $items,
private readonly TreatmentProtocolWriter $writer,
private readonly AddressResolver $branches,
private readonly EntityManagerInterface $em,
) {}
/** `null` یعنی سوییچ «طول درمان» خاموش است، نه اینکه چیزی پیدا نشد. */
#[Route('/api/v1/service-item/{uuid}/treatment-protocol', name: 'treatment_protocol_show', methods: ['GET'])]
public function show(#[CurrentUser] User $user, string $uuid): JsonResponse
{
$protocol = $this->protocols->findForService($this->requireItem($user, $uuid));
return $this->success($protocol?->toArray());
}
#[Route('/api/v1/service-item/{uuid}/treatment-protocol', name: 'treatment_protocol_replace', methods: ['PUT'])]
public function replace(#[CurrentUser] User $user, string $uuid, Request $request): JsonResponse
{
$data = json_decode($request->getContent(), true);
if (!is_array($data)) {
return $this->error(ErrorCodes::ERR_VALIDATION_001, 'بدنهٔ درخواست نامعتبر است', 422);
}
$protocol = $this->writer->replace($user, $this->requireItem($user, $uuid), $data);
return $this->success($protocol->toArray());
}
/** خاموش کردن سوییچ: پروتکل حذف می‌شود و سرویس دوباره تک‌جلسه‌ای می‌گردد. */
#[Route('/api/v1/service-item/{uuid}/treatment-protocol', name: 'treatment_protocol_delete', methods: ['DELETE'])]
public function delete(#[CurrentUser] User $user, string $uuid): JsonResponse
{
$protocol = $this->protocols->findForService($this->requireItem($user, $uuid));
if ($protocol !== null) {
$this->em->remove($protocol);
$this->em->flush();
}
return $this->success(null);
}
private function requireItem(User $user, string $uuid): ServiceItem
{
$item = $this->items->findByUuid($uuid);
[$entityType, $entityId] = $this->branches->pair($user);
if ($item === null
|| $item->getSection()->getEntityType() !== $entityType
|| $item->getSection()->getEntityId() !== $entityId
) {
throw new AppException(ErrorCodes::ERR_NOT_FOUND_001, 'سرویس یافت نشد', 404);
}
return $item;
}
}
+152
View File
@@ -0,0 +1,152 @@
<?php
namespace App\Treatment\Entity;
use App\ClinicService\Entity\ServiceItem;
use App\Doctor\Entity\Doctor;
use App\Staff\Entity\ClinicStaff;
use App\Treatment\Repository\TreatmentProtocolRepository;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Uid\Uuid;
/**
* «طول درمان» یک سرویس — اینکه یک دورهٔ آن چند جلسه است و هر جلسه کِی سررسید دارد.
*
* وجودِ این ردیف خودش سوییچِ «طول درمان» است: نبودنش یعنی سرویس تک‌جلسه‌ای. یک فیلد
* بولینِ جدا فقط حالتی می‌ساخت که روشن باشد ولی گامی نداشته باشد.
*
* محیط ندارد و از `ServiceItem` به ارث می‌برد؛ تنها راه رسیدن به آن uuid همان سرویس
* است، پس در `GlobalTables::AGGREGATE_CHILDREN` ثبت شده.
*/
#[ORM\Entity(repositoryClass: TreatmentProtocolRepository::class)]
#[ORM\Table(name: 'treatment_protocols')]
#[ORM\UniqueConstraint(name: 'uq_treatment_protocol_service', columns: ['service_item_id'])]
class TreatmentProtocol
{
/** دوره‌ای که یک گام دارد همان سرویس تک‌جلسه‌ای است — یعنی سوییچ باید خاموش باشد. */
public const MIN_STEPS = 2;
public const MAX_STEPS = 60;
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column(type: 'integer')]
private ?int $id = null;
#[ORM\Column(type: 'string', length: 36, unique: true)]
private string $uuid;
#[ORM\OneToOne(targetEntity: ServiceItem::class)]
#[ORM\JoinColumn(name: 'service_item_id', referencedColumnName: 'id', nullable: false, onDelete: 'CASCADE')]
private ServiceItem $serviceItem;
/**
* پزشکِ پاسخگوی این دوره. لزوماً انجام‌دهنده نیست — اپراتور کار را می‌کند.
*/
#[ORM\ManyToOne(targetEntity: Doctor::class)]
#[ORM\JoinColumn(name: 'supervisor_doctor_id', referencedColumnName: 'id', nullable: true, onDelete: 'SET NULL')]
private ?Doctor $supervisorDoctor = null;
#[ORM\Column(type: 'boolean', options: ['default' => true])]
private bool $active = true;
#[ORM\Column(name: 'created_at', type: 'integer')]
private int $createdAt;
#[ORM\Column(name: 'updated_at', type: 'integer')]
private int $updatedAt;
#[ORM\OneToMany(targetEntity: TreatmentProtocolStep::class, mappedBy: 'protocol', cascade: ['persist', 'remove'], orphanRemoval: true)]
#[ORM\OrderBy(['stepNumber' => 'ASC'])]
private Collection $steps;
#[ORM\OneToMany(targetEntity: TreatmentProtocolStaff::class, mappedBy: 'protocol', cascade: ['persist', 'remove'], orphanRemoval: true)]
private Collection $allowedStaff;
public function __construct(ServiceItem $serviceItem)
{
$this->uuid = Uuid::v4()->toRfc4122();
$this->serviceItem = $serviceItem;
$this->createdAt = time();
$this->updatedAt = time();
$this->steps = new ArrayCollection();
$this->allowedStaff = new ArrayCollection();
}
public function getId(): ?int { return $this->id; }
public function getUuid(): string { return $this->uuid; }
public function getServiceItem(): ServiceItem { return $this->serviceItem; }
public function getSupervisorDoctor(): ?Doctor { return $this->supervisorDoctor; }
public function isActive(): bool { return $this->active; }
public function getSteps(): Collection { return $this->steps; }
public function getAllowedStaff(): Collection { return $this->allowedStaff; }
public function setSupervisorDoctor(?Doctor $v): self { $this->supervisorDoctor = $v; $this->touch(); return $this; }
public function setActive(bool $v): self { $this->active = $v; $this->touch(); return $this; }
/** تعداد جلسات یک دوره — همیشه از گام‌ها می‌آید، نه از `ServiceItem::$sessionCount`. */
public function totalSessions(): int
{
return $this->steps->count();
}
/** @param TreatmentProtocolStep[] $steps */
public function replaceSteps(array $steps): self
{
$this->steps->clear();
foreach ($steps as $step) {
$this->steps->add($step);
}
$this->touch();
return $this;
}
/** @param TreatmentProtocolStaff[] $members */
public function replaceAllowedStaff(array $members): self
{
$this->allowedStaff->clear();
foreach ($members as $member) {
$this->allowedStaff->add($member);
}
$this->touch();
return $this;
}
public function allows(ClinicStaff $staff): bool
{
foreach ($this->allowedStaff as $member) {
if ($member->getStaff()->getId() === $staff->getId()) {
return true;
}
}
return false;
}
public function toArray(): array
{
return [
'uuid' => $this->uuid,
'service_uuid' => $this->serviceItem->getUuid(),
'active' => $this->active,
'total_sessions' => $this->totalSessions(),
'supervisor' => $this->supervisorDoctor === null ? null : [
'uuid' => $this->supervisorDoctor->getUuid(),
'name' => $this->supervisorDoctor->getName(),
],
'steps' => array_map(
static fn (TreatmentProtocolStep $s): array => $s->toArray(),
$this->steps->toArray(),
),
'staff' => array_map(
static fn (TreatmentProtocolStaff $m): array => $m->toArray(),
$this->allowedStaff->toArray(),
),
];
}
private function touch(): void { $this->updatedAt = time(); }
}
@@ -0,0 +1,50 @@
<?php
namespace App\Treatment\Entity;
use App\Staff\Entity\ClinicStaff;
use App\Treatment\Repository\TreatmentProtocolStaffRepository;
use Doctrine\ORM\Mapping as ORM;
/**
* پرسنلی که اجازه دارد این دوره را انجام دهد.
*
* فهرست مجاز روی پروتکل تعریف می‌شود و منشی هنگام رزرو از میان همین‌ها یکی را
* برمی‌دارد؛ بدون این، هر پرسنلی می‌توانست پای هر دستگاهی بنشیند.
*/
#[ORM\Entity(repositoryClass: TreatmentProtocolStaffRepository::class)]
#[ORM\Table(name: 'treatment_protocol_staff')]
#[ORM\UniqueConstraint(name: 'uq_protocol_staff', columns: ['protocol_id', 'staff_id'])]
class TreatmentProtocolStaff
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column(type: 'integer')]
private ?int $id = null;
#[ORM\ManyToOne(targetEntity: TreatmentProtocol::class, inversedBy: 'allowedStaff')]
#[ORM\JoinColumn(name: 'protocol_id', referencedColumnName: 'id', nullable: false, onDelete: 'CASCADE')]
private TreatmentProtocol $protocol;
#[ORM\ManyToOne(targetEntity: ClinicStaff::class)]
#[ORM\JoinColumn(name: 'staff_id', referencedColumnName: 'id', nullable: false, onDelete: 'CASCADE')]
private ClinicStaff $staff;
public function __construct(TreatmentProtocol $protocol, ClinicStaff $staff)
{
$this->protocol = $protocol;
$this->staff = $staff;
}
public function getId(): ?int { return $this->id; }
public function getProtocol(): TreatmentProtocol { return $this->protocol; }
public function getStaff(): ClinicStaff { return $this->staff; }
public function toArray(): array
{
return [
'uuid' => $this->staff->getUuid(),
'name' => $this->staff->getFullName(),
];
}
}
@@ -0,0 +1,58 @@
<?php
namespace App\Treatment\Entity;
use App\Treatment\Repository\TreatmentProtocolStepRepository;
use Doctrine\ORM\Mapping as ORM;
/**
* یک گام از دوره: جلسهٔ چندم، و چند روز بعد از جلسهٔ **قبلی**.
*
* `offsetDays` عمداً نسبت به جلسهٔ قبل است نه شروع دوره. فاصلهٔ لیزر یک ضرورت پزشکی
* است — مو بعد از درمان قبلی رشد می‌کند، نه بعد از باز شدن پرونده — پس بیمارِ دیرآمده
* باید کل دوره‌اش جابه‌جا شود، نه اینکه جلسهٔ بعدی‌اش زودتر از موعد بیفتد.
*/
#[ORM\Entity(repositoryClass: TreatmentProtocolStepRepository::class)]
#[ORM\Table(name: 'treatment_protocol_steps')]
#[ORM\UniqueConstraint(name: 'uq_protocol_step_number', columns: ['protocol_id', 'step_number'])]
class TreatmentProtocolStep
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column(type: 'integer')]
private ?int $id = null;
#[ORM\ManyToOne(targetEntity: TreatmentProtocol::class, inversedBy: 'steps')]
#[ORM\JoinColumn(name: 'protocol_id', referencedColumnName: 'id', nullable: false, onDelete: 'CASCADE')]
private TreatmentProtocol $protocol;
#[ORM\Column(name: 'step_number', type: 'smallint')]
private int $stepNumber;
#[ORM\Column(name: 'offset_days', type: 'smallint')]
private int $offsetDays;
#[ORM\Column(name: 'created_at', type: 'integer')]
private int $createdAt;
public function __construct(TreatmentProtocol $protocol, int $stepNumber, int $offsetDays)
{
$this->protocol = $protocol;
$this->stepNumber = $stepNumber;
$this->offsetDays = $offsetDays;
$this->createdAt = time();
}
public function getId(): ?int { return $this->id; }
public function getProtocol(): TreatmentProtocol { return $this->protocol; }
public function getStepNumber(): int { return $this->stepNumber; }
public function getOffsetDays(): int { return $this->offsetDays; }
public function toArray(): array
{
return [
'step_number' => $this->stepNumber,
'offset_days' => $this->offsetDays,
];
}
}
@@ -0,0 +1,30 @@
<?php
namespace App\Treatment\Repository;
use App\ClinicService\Entity\ServiceItem;
use App\Treatment\Entity\TreatmentProtocol;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
/**
* @extends ServiceEntityRepository<TreatmentProtocol>
*/
class TreatmentProtocolRepository extends ServiceEntityRepository
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, TreatmentProtocol::class);
}
public function findForService(ServiceItem $service): ?TreatmentProtocol
{
return $this->findOneBy(['serviceItem' => $service]);
}
/** پروتکلِ فعالِ یک سرویس — نقطهٔ تصمیمِ «این نوبت دوره‌ای است یا تک‌جلسه‌ای». */
public function findActiveForService(ServiceItem $service): ?TreatmentProtocol
{
return $this->findOneBy(['serviceItem' => $service, 'active' => true]);
}
}
@@ -0,0 +1,18 @@
<?php
namespace App\Treatment\Repository;
use App\Treatment\Entity\TreatmentProtocolStaff;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
/**
* @extends ServiceEntityRepository<TreatmentProtocolStaff>
*/
class TreatmentProtocolStaffRepository extends ServiceEntityRepository
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, TreatmentProtocolStaff::class);
}
}
@@ -0,0 +1,18 @@
<?php
namespace App\Treatment\Repository;
use App\Treatment\Entity\TreatmentProtocolStep;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
/**
* @extends ServiceEntityRepository<TreatmentProtocolStep>
*/
class TreatmentProtocolStepRepository extends ServiceEntityRepository
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, TreatmentProtocolStep::class);
}
}
@@ -0,0 +1,180 @@
<?php
namespace App\Treatment\Service;
use App\Auth\Entity\User;
use App\ClinicService\Entity\ServiceItem;
use App\Resource\Service\ResourceContext;
use App\Shared\Constant\ErrorCodes;
use App\Shared\Exception\AppException;
use App\Shared\Tenant\TenantOwnershipChecker;
use App\Staff\Repository\ClinicStaffRepository;
use App\Treatment\Entity\TreatmentProtocol;
use App\Treatment\Entity\TreatmentProtocolStaff;
use App\Treatment\Entity\TreatmentProtocolStep;
use App\Treatment\Repository\TreatmentProtocolRepository;
use Doctrine\ORM\EntityManagerInterface;
/**
* جایگزینی کامل پروتکل یک سرویس.
*
* همه‌چیز پیش از هر نوشتنی حل و اعتبارسنجی می‌شود — همان قرارداد بقیهٔ PUT های پروژه:
* گامِ نامعتبر در انتهای فهرست نباید گام‌های درستِ قبلی را پاک کند.
*/
final class TreatmentProtocolWriter
{
public function __construct(
private readonly TreatmentProtocolRepository $protocols,
private readonly ClinicStaffRepository $staff,
private readonly ResourceContext $context,
private readonly TenantOwnershipChecker $ownership,
private readonly EntityManagerInterface $em,
) {}
public function replace(User $user, ServiceItem $service, array $data): TreatmentProtocol
{
$steps = $this->readSteps($data);
$staffUuids = $this->readStaffUuids($data);
$supervisor = is_string($data['supervisor_doctor_uuid'] ?? null) && $data['supervisor_doctor_uuid'] !== ''
? $this->context->supervisor($user, $data['supervisor_doctor_uuid'])
: null;
[$entityType, $entityId] = $this->context->pair($user);
$members = [];
foreach ($staffUuids as $uuid) {
$member = $this->staff->findByUuid($uuid);
// پرسنل با uuid از خودِ درخواست می‌آید و TenantFilter پوششش نمی‌دهد؛ بدون
// این بررسی، پرسنلِ کلینیک دیگری روی پروتکل این کلینیک می‌نشست.
if (!$this->ownership->belongsToPair($entityType, $entityId, $member) || !$member->isActive()) {
throw new AppException(ErrorCodes::ERR_NOT_FOUND_001, 'پرسنل یافت نشد', 404, 'staff_uuids');
}
$members[] = $member;
}
$protocol = $this->protocols->findForService($service) ?? new TreatmentProtocol($service);
return $this->em->wrapInTransaction(function () use ($protocol, $supervisor, $steps, $members): TreatmentProtocol {
$protocol->setSupervisorDoctor($supervisor);
$protocol->setActive(true);
$this->em->persist($protocol);
// خالی‌کردن و پرکردن در **دو** flush: در یک flush واحد، Doctrine درج‌ها را
// پیش از حذف‌ها می‌فرستد و ردیف تازه به قید یکتای (protocol, step_number)
// می‌خورد. تراکنش تضمین می‌کند حالت میانیِ «پروتکلِ بی‌گام» دیده نشود.
$protocol->replaceSteps([]);
$protocol->replaceAllowedStaff([]);
$this->em->flush();
$protocol->replaceSteps(array_map(
static fn (array $step): TreatmentProtocolStep
=> new TreatmentProtocolStep($protocol, $step['step_number'], $step['offset_days']),
$steps,
));
$protocol->replaceAllowedStaff(array_map(
static fn ($member): TreatmentProtocolStaff => new TreatmentProtocolStaff($protocol, $member),
$members,
));
$this->em->flush();
return $protocol;
});
}
/**
* @return list<array{step_number: int, offset_days: int}>
*/
private function readSteps(array $data): array
{
$rows = $data['steps'] ?? null;
if (!is_array($rows)) {
throw new AppException(ErrorCodes::ERR_VALIDATION_002, 'فیلد steps الزامی است', 422, 'steps');
}
if (count($rows) < TreatmentProtocol::MIN_STEPS) {
throw new AppException(
ErrorCodes::ERR_VALIDATION_001,
sprintf('دورهٔ درمان حداقل %d جلسه دارد؛ کمتر از آن یعنی سرویس تک‌جلسه‌ای', TreatmentProtocol::MIN_STEPS),
422,
'steps',
);
}
if (count($rows) > TreatmentProtocol::MAX_STEPS) {
throw new AppException(
ErrorCodes::ERR_VALIDATION_001,
sprintf('دورهٔ درمان حداکثر %d جلسه دارد', TreatmentProtocol::MAX_STEPS),
422,
'steps',
);
}
$steps = [];
foreach (array_values($rows) as $index => $row) {
$expected = $index + 1;
if (!is_array($row) || !is_numeric($row['offset_days'] ?? null)) {
throw new AppException(ErrorCodes::ERR_VALIDATION_002, 'offset_days هر گام الزامی است', 422, 'offset_days');
}
$number = isset($row['step_number']) ? (int) $row['step_number'] : $expected;
$offset = (int) $row['offset_days'];
// شماره‌ها باید پیوسته از ۱ باشند: «جلسهٔ ۳ از ۸» فقط وقتی معنی دارد که
// گامی جا نیفتاده باشد.
if ($number !== $expected) {
throw new AppException(
ErrorCodes::ERR_VALIDATION_001,
sprintf('شمارهٔ گام‌ها باید پیوسته از ۱ باشد؛ گام %d انتظار می‌رفت', $expected),
422,
'step_number',
);
}
// گام اول لنگر دوره است و فاصله‌ای از «جلسهٔ قبل» ندارد.
if ($expected === 1 && $offset !== 0) {
throw new AppException(ErrorCodes::ERR_VALIDATION_001, 'فاصلهٔ گام اول باید صفر باشد', 422, 'offset_days');
}
if ($expected > 1 && $offset <= 0) {
throw new AppException(
ErrorCodes::ERR_VALIDATION_001,
sprintf('فاصلهٔ گام %d باید بزرگ‌تر از صفر باشد', $expected),
422,
'offset_days',
);
}
$steps[] = ['step_number' => $number, 'offset_days' => $offset];
}
return $steps;
}
/** @return list<string> */
private function readStaffUuids(array $data): array
{
$rows = $data['staff_uuids'] ?? null;
if (!is_array($rows) || $rows === []) {
throw new AppException(ErrorCodes::ERR_VALIDATION_002, 'حداقل یک پرسنل مجاز الزامی است', 422, 'staff_uuids');
}
$uuids = [];
foreach ($rows as $uuid) {
if (!is_string($uuid) || trim($uuid) === '') {
throw new AppException(ErrorCodes::ERR_VALIDATION_002, 'uuid پرسنل نامعتبر است', 422, 'staff_uuids');
}
$uuids[trim($uuid)] = true;
}
return array_keys($uuids);
}
}