feat(plan): multi-segment appointments with per-segment resource requirements

Section 7 of the design document, and the reason the whole resource layer exists.
A laser session is not one block: numbing cream (5 min, room + operator), waiting for
it to work (30 min, room only), the laser itself (20 min, room + operator + device),
aftercare (5 min, room + operator). Under the single-interval model the operator is
locked for all 60 minutes while actually working 30 — half the capacity thrown away.

AppointmentPlanBuilder turns (service, selected items, branch, patient) into a plan:
segments with offsets, durations and resource requirements. It deliberately assigns
no absolute time and no specific resource — that is the next task. This only produces
the *shape* of the appointment.

Segment duration comes from one of two sources. A fixed segment carries its own
number; an item-driven one gets its duration from task 04's DurationCalculator, so
"the laser itself" grows with two treated areas while "waiting for the cream" does
not. One number could not have expressed that.

Three contracts worth stating:

- A service with no segment templates falls back to a single continuous segment
  requiring the doctor resource — exactly today's behaviour. Without it every
  existing service would have become unplannable overnight.
- A segment with no requirements is valid: "waiting at home" consumes time but
  occupies nothing.
- same_gender_as_patient with an unknown patient gender is a 422, not a silently
  dropped requirement. Dropping it quietly would route the patient to a resource the
  clinic said must not serve them.

When no resource qualifies, the error names the role, the skill and the branch —
"no female operator with the skill «Alexandrite laser» is available at «Central»" —
rather than an empty result the caller has to interpret (section 10).

occupancy_offset carries each requirement's setup/cleanup minutes for the availability
engine. It is taken as the maximum across candidates, because the builder does not yet
know which resource will be picked and under-reserving means the next appointment
lands on top of the cleanup.

11 tests covering the document's reference example (offsets 0/5/35/55, total 60),
item-driven scaling, the no-template fallback, all three gender-constraint outcomes,
merging and both caps. 1186 tests overall. phpstan back at its 14-error baseline;
slot-mode frozen contract green.

The admin segments page is not built; the checklist records it with a target. The
backend and preview endpoint are complete and consumable without it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
hamed
2026-07-30 21:07:30 +03:30
co-authored by Claude Opus 5
parent 04526542c9
commit 22c89fbae4
16 changed files with 1528 additions and 12 deletions
@@ -0,0 +1,208 @@
<?php
namespace App\Appointment\Plan\Controller;
use App\Appointment\Plan\Entity\SegmentRequirement;
use App\Appointment\Plan\Entity\SegmentTemplate;
use App\Appointment\Plan\Repository\SegmentTemplateRepository;
use App\Appointment\Plan\Service\AppointmentPlanBuilder;
use App\Auth\Entity\User;
use App\Branch\Service\BranchResolver;
use App\ClinicService\Entity\ServiceItem;
use App\ClinicService\Repository\ServiceItemRepository;
use App\Resource\Service\ResourceContext;
use App\Shared\Constant\ErrorCodes;
use App\Shared\Controller\BaseController;
use App\Shared\Exception\AppException;
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: 'Appointment Plan')]
#[IsGranted('IS_AUTHENTICATED_FULLY')]
class AppointmentPlanController extends BaseController
{
public function __construct(
private readonly SegmentTemplateRepository $templates,
private readonly ServiceItemRepository $items,
private readonly AppointmentPlanBuilder $builder,
private readonly BranchResolver $branches,
private readonly ResourceContext $resourceContext,
private readonly EntityManagerInterface $em,
) {}
#[Route('/api/v1/service-item/{uuid}/segments', name: 'service_segments_show', methods: ['GET'])]
public function show(#[CurrentUser] User $user, string $uuid): JsonResponse
{
$service = $this->requireItem($user, $uuid);
return $this->success(array_map(
static fn (SegmentTemplate $s): array => $s->toArray(),
$this->templates->findForService($service),
));
}
/**
* جایگزینی کامل بخش‌های یک سرویس، به‌همراه نیازمندی‌هایشان.
*
* همه‌چیز پیش از هر حذفی حل و اعتبارسنجی می‌شود — همان قرارداد بقیهٔ PUT های
* پروژه: بخش نامعتبر در انتهای فهرست نباید بخش‌های درستِ قبلی را پاک کند.
*/
#[Route('/api/v1/service-item/{uuid}/segments', name: 'service_segments_replace', methods: ['PUT'])]
public function replace(#[CurrentUser] User $user, string $uuid, Request $request): JsonResponse
{
$data = json_decode($request->getContent(), true);
if (!is_array($data) || !is_array($data['segments'] ?? null)) {
return $this->error(ErrorCodes::ERR_VALIDATION_002, 'فیلد segments الزامی است', 422, 'segments');
}
$service = $this->requireItem($user, $uuid);
$planned = [];
$total = 0;
foreach ($data['segments'] as $index => $row) {
if (!is_array($row) || !is_string($row['name'] ?? null) || trim($row['name']) === '') {
return $this->error(ErrorCodes::ERR_VALIDATION_002, 'نام هر بخش الزامی است', 422, 'name');
}
$source = is_string($row['duration_source'] ?? null) ? $row['duration_source'] : SegmentTemplate::DURATION_FIXED;
$minutes = is_numeric($row['duration_minutes'] ?? null) ? (int) $row['duration_minutes'] : 0;
if (!in_array($source, SegmentTemplate::DURATION_SOURCES, true)) {
return $this->error(ErrorCodes::ERR_VALIDATION_001, 'منبع مدت بخش نامعتبر است', 422, 'duration_source');
}
if ($source === SegmentTemplate::DURATION_FIXED && $minutes <= 0) {
return $this->error(
ErrorCodes::ERR_VALIDATION_001,
sprintf('بخش «%s» مدت مثبت لازم دارد', trim($row['name'])),
422,
'duration_minutes',
);
}
$total += $minutes;
$requirements = [];
foreach (($row['requirements'] ?? []) as $req) {
if (!is_array($req) || !is_string($req['type_uuid'] ?? null)) {
return $this->error(ErrorCodes::ERR_VALIDATION_002, 'type_uuid هر نیازمندی الزامی است', 422, 'requirements');
}
$requirements[] = [
'type' => $this->resourceContext->type($user, $req['type_uuid']),
'skill' => is_string($req['skill_uuid'] ?? null) ? $this->resourceContext->skill($user, $req['skill_uuid']) : null,
'count' => is_numeric($req['count'] ?? null) ? max(1, (int) $req['count']) : 1,
'occupancy' => is_string($req['occupancy'] ?? null) ? $req['occupancy'] : SegmentRequirement::OCCUPANCY_EXCLUSIVE,
'constraints' => is_array($req['constraints'] ?? null) ? $req['constraints'] : [],
];
}
$planned[] = [
'sequence' => is_numeric($row['sequence'] ?? null) ? (int) $row['sequence'] : $index + 1,
'name' => trim($row['name']),
'source' => $source,
'minutes' => $minutes,
'patient' => (bool) ($row['patient_present'] ?? true),
'mergeable' => (bool) ($row['mergeable'] ?? false),
'requirements' => $requirements,
];
}
if ($total > SegmentTemplate::MAX_TOTAL_MINUTES) {
return $this->error(
ErrorCodes::ERR_VALIDATION_001,
sprintf('مجموع مدت بخش‌ها از سقف %d دقیقه بیشتر است', SegmentTemplate::MAX_TOTAL_MINUTES),
422,
'segments',
);
}
$this->templates->deleteForService($service);
foreach ($planned as $row) {
$template = new SegmentTemplate($service, $row['sequence'], $row['name']);
try {
$template->setDuration($row['source'], $row['minutes']);
} catch (\InvalidArgumentException $e) {
throw new AppException(ErrorCodes::ERR_VALIDATION_001, 'مدت بخش نامعتبر است', 422, 'duration_minutes');
}
$template->setPatientPresent($row['patient'])->setMergeable($row['mergeable']);
$this->em->persist($template);
foreach ($row['requirements'] as $req) {
$requirement = new SegmentRequirement($template, $req['type'], $req['count']);
$requirement->setSkill($req['skill']);
try {
$requirement->setOccupancy($req['occupancy'])->setConstraints($req['constraints']);
} catch (\InvalidArgumentException $e) {
throw new AppException(ErrorCodes::ERR_VALIDATION_001, $e->getMessage(), 422, 'requirements');
}
$this->em->persist($requirement);
$template->getRequirements()->add($requirement);
}
}
$this->em->flush();
return $this->success(array_map(
static fn (SegmentTemplate $s): array => $s->toArray(),
$this->templates->findForService($service),
));
}
/** برنامهٔ نوبت، بدون هیچ ثبتی — پیش از رفتن به جستجوی وقت. */
#[Route('/api/v1/appointment-plan/preview', name: 'appointment_plan_preview', methods: ['POST'])]
public function preview(#[CurrentUser] User $user, Request $request): JsonResponse
{
$data = json_decode($request->getContent(), true);
if (!is_array($data) || !is_string($data['service_uuid'] ?? null)) {
return $this->error(ErrorCodes::ERR_VALIDATION_002, 'فیلد service_uuid الزامی است', 422, 'service_uuid');
}
if (!is_string($data['branch_uuid'] ?? null)) {
return $this->error(ErrorCodes::ERR_VALIDATION_002, 'فیلد branch_uuid الزامی است', 422, 'branch_uuid');
}
$service = $this->requireItem($user, $data['service_uuid']);
$address = $this->branches->resolve($user, $data['branch_uuid']);
$selected = [];
foreach (($data['item_uuids'] ?? []) as $itemUuid) {
if (!is_string($itemUuid)) {
return $this->error(ErrorCodes::ERR_VALIDATION_001, 'item_uuids باید فهرستی از uuid باشد', 422, 'item_uuids');
}
$selected[] = $this->requireItem($user, $itemUuid);
}
$gender = is_string($data['patient_gender'] ?? null) ? $data['patient_gender'] : null;
return $this->success($this->builder->build($service, $selected, $address, $gender)->toArray());
}
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;
}
}
@@ -0,0 +1,127 @@
<?php
namespace App\Appointment\Plan\Entity;
use App\Appointment\Plan\Repository\SegmentRequirementRepository;
use App\Resource\Entity\ResourceType;
use App\Resource\Entity\Skill;
use Doctrine\ORM\Mapping as ORM;
/**
* «این بخش چه منبعی می‌خواهد» — نقش، تعداد، مهارت لازم، قید، و نوع اشغال.
*
* فرزند aggregate با ریشهٔ {@see SegmentTemplate} که خودش جفت محیط دارد؛ uuid از
* request نمی‌گیرد و فقط از `PUT /segment-template/{uuid}/requirements` نوشته می‌شود.
*/
#[ORM\Entity(repositoryClass: SegmentRequirementRepository::class)]
#[ORM\Table(name: 'segment_requirements')]
#[ORM\Index(columns: ['segment_id'], name: 'idx_requirement_segment')]
class SegmentRequirement
{
/** منبع در تمام بخش قفل است. */
public const OCCUPANCY_EXCLUSIVE = 'exclusive';
/** منبع لازم است ولی می‌تواند هم‌زمان جای دیگری هم باشد (ظرفیتش می‌شمارد). */
public const OCCUPANCY_SHARED = 'shared';
public const OCCUPANCIES = [self::OCCUPANCY_EXCLUSIVE, self::OCCUPANCY_SHARED];
/** جنسیت منبع باید با بیمار یکی باشد — بند ۱۰ مستند. */
public const CONSTRAINT_SAME_GENDER = 'same_gender_as_patient';
public const CONSTRAINTS = [self::CONSTRAINT_SAME_GENDER];
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column(type: 'integer')]
private ?int $id = null;
#[ORM\ManyToOne(targetEntity: SegmentTemplate::class, inversedBy: 'requirements')]
#[ORM\JoinColumn(name: 'segment_id', referencedColumnName: 'id', nullable: false, onDelete: 'CASCADE')]
private SegmentTemplate $segment;
#[ORM\ManyToOne(targetEntity: ResourceType::class)]
#[ORM\JoinColumn(name: 'resource_type_id', referencedColumnName: 'id', nullable: false, onDelete: 'RESTRICT')]
private ResourceType $resourceType;
#[ORM\ManyToOne(targetEntity: Skill::class)]
#[ORM\JoinColumn(name: 'skill_id', referencedColumnName: 'id', nullable: true, onDelete: 'RESTRICT')]
private ?Skill $skill = null;
#[ORM\Column(type: 'smallint', options: ['default' => 1])]
private int $count = 1;
#[ORM\Column(type: 'string', length: 12, options: ['default' => self::OCCUPANCY_EXCLUSIVE])]
private string $occupancy = self::OCCUPANCY_EXCLUSIVE;
/** @var string[] */
#[ORM\Column(type: 'json', nullable: true)]
private ?array $constraints = null;
public function __construct(SegmentTemplate $segment, ResourceType $resourceType, int $count = 1)
{
if ($count < 1) {
throw new \InvalidArgumentException('A requirement needs at least one resource.');
}
$this->segment = $segment;
$this->resourceType = $resourceType;
$this->count = $count;
}
public function getId(): ?int { return $this->id; }
public function getSegment(): SegmentTemplate { return $this->segment; }
public function getResourceType(): ResourceType { return $this->resourceType; }
public function getSkill(): ?Skill { return $this->skill; }
public function getCount(): int { return $this->count; }
public function getOccupancy(): string { return $this->occupancy; }
/** @return string[] */
public function getConstraints(): array { return $this->constraints ?? []; }
public function setSkill(?Skill $v): self { $this->skill = $v; return $this; }
public function setOccupancy(string $v): self
{
if (!in_array($v, self::OCCUPANCIES, true)) {
throw new \InvalidArgumentException(sprintf('Unknown occupancy "%s".', $v));
}
$this->occupancy = $v;
return $this;
}
/** @param string[] $constraints */
public function setConstraints(array $constraints): self
{
foreach ($constraints as $constraint) {
if (!in_array($constraint, self::CONSTRAINTS, true)) {
throw new \InvalidArgumentException(sprintf('Unknown constraint "%s".', $constraint));
}
}
$this->constraints = $constraints === [] ? null : array_values(array_unique($constraints));
return $this;
}
public function requiresSameGender(): bool
{
return in_array(self::CONSTRAINT_SAME_GENDER, $this->getConstraints(), true);
}
public function toArray(): array
{
return [
'role' => $this->resourceType->getCode(),
'role_name' => $this->resourceType->getName(),
'type_uuid' => $this->resourceType->getUuid(),
'skill_uuid' => $this->skill?->getUuid(),
'skill_name' => $this->skill?->getName(),
'count' => $this->count,
'occupancy' => $this->occupancy,
'constraints' => $this->getConstraints(),
];
}
}
@@ -0,0 +1,151 @@
<?php
namespace App\Appointment\Plan\Entity;
use App\Appointment\Plan\Repository\SegmentTemplateRepository;
use App\ClinicService\Entity\ServiceItem;
use App\Shared\Tenant\TenantOwnedTrait;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Uid\Uuid;
/**
* یک بخش از نوبت — بند ۷ مستند.
*
* نوبت یک تکه زمان پیوسته نیست. «مالیدن کرم بی‌حسی (۵ دقیقه، اتاق+اپراتور)» و
* «انتظار اثر کرم (۳۰ دقیقه، فقط اتاق)» دو بخش‌اند؛ با مدل تک‌بازه‌ای اپراتور ۶۰
* دقیقه قفل می‌شود در حالی که ۳۰ دقیقه کار می‌کند — نصف ظرفیت هدر می‌رود.
*/
#[ORM\Entity(repositoryClass: SegmentTemplateRepository::class)]
#[ORM\Table(name: 'segment_templates')]
#[ORM\Index(columns: ['entity_type', 'entity_id'], name: 'idx_segment_tenant')]
#[ORM\Index(columns: ['service_id', 'sequence'], name: 'idx_segment_service_seq')]
class SegmentTemplate
{
use TenantOwnedTrait;
/** مدت ثابت است. */
public const DURATION_FIXED = 'fixed';
/**
* مدت از آیتم‌های انتخاب‌شده می‌آید ({@see \App\ClinicService\Service\DurationCalculator}).
* بخش «خود لیزر» با دو ناحیه طولانی‌تر می‌شود، ولی «انتظار اثر کرم» نه.
*/
public const DURATION_FROM_ITEMS = 'items';
public const DURATION_SOURCES = [self::DURATION_FIXED, self::DURATION_FROM_ITEMS];
/** سقف مجموع مدت یک نوبت — حفاظت از جستجوی وقت در تسک ۰۶. */
public const MAX_TOTAL_MINUTES = 480;
#[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: ServiceItem::class)]
#[ORM\JoinColumn(name: 'service_id', referencedColumnName: 'id', nullable: false, onDelete: 'CASCADE')]
private ServiceItem $service;
#[ORM\Column(type: 'smallint')]
private int $sequence;
#[ORM\Column(type: 'string', length: 150)]
private string $name;
#[ORM\Column(name: 'duration_source', type: 'string', length: 10, options: ['default' => self::DURATION_FIXED])]
private string $durationSource = self::DURATION_FIXED;
#[ORM\Column(name: 'duration_minutes', type: 'smallint', options: ['default' => 0])]
private int $durationMinutes = 0;
/** بیمار در این بخش حاضر است؟ «انتظار در خانه» نمونهٔ خلافش است. */
#[ORM\Column(name: 'patient_present', type: 'boolean', options: ['default' => true])]
private bool $patientPresent = true;
/**
* با انتخاب چند آیتم، این بخش **یک بار** می‌آید نه چند بار.
* «آماده‌سازی» یک بار انجام می‌شود؛ «خود لیزر» به‌ازای هر ناحیه.
*/
#[ORM\Column(type: 'boolean', options: ['default' => false])]
private bool $mergeable = false;
#[ORM\Column(name: 'created_at', type: 'integer')]
private int $createdAt;
/** @var Collection<int, SegmentRequirement> */
#[ORM\OneToMany(targetEntity: SegmentRequirement::class, mappedBy: 'segment', cascade: ['persist', 'remove'], orphanRemoval: true)]
private Collection $requirements;
public function __construct(ServiceItem $service, int $sequence, string $name)
{
$this->uuid = Uuid::v4()->toRfc4122();
$this->service = $service;
$this->sequence = $sequence;
$this->name = $name;
$this->createdAt = time();
$this->requirements = new ArrayCollection();
$this->assignTenantPair($service->getSection()->getEntityType(), $service->getSection()->getEntityId());
}
public function getId(): ?int { return $this->id; }
public function getUuid(): string { return $this->uuid; }
public function getService(): ServiceItem { return $this->service; }
public function getSequence(): int { return $this->sequence; }
public function getName(): string { return $this->name; }
public function getDurationSource(): string { return $this->durationSource; }
public function getDurationMinutes(): int { return $this->durationMinutes; }
public function isPatientPresent(): bool { return $this->patientPresent; }
public function isMergeable(): bool { return $this->mergeable; }
/** @return Collection<int, SegmentRequirement> */
public function getRequirements(): Collection { return $this->requirements; }
public function setPatientPresent(bool $v): self { $this->patientPresent = $v; return $this; }
public function setMergeable(bool $v): self { $this->mergeable = $v; return $this; }
/** @throws \InvalidArgumentException روی منبع مدت ناشناخته یا مدت ناممکن */
public function setDuration(string $source, int $minutes): self
{
if (!in_array($source, self::DURATION_SOURCES, true)) {
throw new \InvalidArgumentException(sprintf('Unknown duration source "%s".', $source));
}
// بخشی که مدتش از آیتم‌ها می‌آید، عدد ثابت لازم ندارد؛ بخش ثابت حتماً دارد.
if ($source === self::DURATION_FIXED && $minutes <= 0) {
throw new \InvalidArgumentException('A fixed segment needs a positive duration.');
}
if ($minutes > self::MAX_TOTAL_MINUTES) {
throw new \InvalidArgumentException('Segment duration exceeds the daily cap.');
}
$this->durationSource = $source;
$this->durationMinutes = max(0, $minutes);
return $this;
}
public function toArray(): array
{
return [
'uuid' => $this->uuid,
'service_uuid' => $this->service->getUuid(),
'sequence' => $this->sequence,
'name' => $this->name,
'duration_source' => $this->durationSource,
'duration_minutes' => $this->durationMinutes,
'patient_present' => $this->patientPresent,
'mergeable' => $this->mergeable,
'requirements' => array_map(
static fn (SegmentRequirement $r): array => $r->toArray(),
$this->requirements->toArray(),
),
];
}
}
@@ -0,0 +1,29 @@
<?php
namespace App\Appointment\Plan\Repository;
use App\Appointment\Plan\Entity\SegmentRequirement;
use App\Appointment\Plan\Entity\SegmentTemplate;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
/**
* @extends ServiceEntityRepository<SegmentRequirement>
*/
class SegmentRequirementRepository extends ServiceEntityRepository
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, SegmentRequirement::class);
}
public function deleteForSegment(SegmentTemplate $segment): int
{
return (int) $this->createQueryBuilder('r')
->delete()
->where('r.segment = :segment')
->setParameter('segment', $segment)
->getQuery()
->execute();
}
}
@@ -0,0 +1,83 @@
<?php
namespace App\Appointment\Plan\Repository;
use App\Appointment\Plan\Entity\SegmentTemplate;
use App\ClinicService\Entity\ServiceItem;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
/**
* @extends ServiceEntityRepository<SegmentTemplate>
*/
class SegmentTemplateRepository extends ServiceEntityRepository
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, SegmentTemplate::class);
}
public function findByUuid(string $uuid): ?SegmentTemplate
{
return $this->findOneBy(['uuid' => $uuid]);
}
/**
* الگوی بخش‌های یک سرویس، به‌همراه نیازمندی‌ها و نوع منبع و مهارتشان — یک کوئری،
* نه یکی per بخش. سازندهٔ برنامه همهٔ این‌ها را لازم دارد.
*
* @return SegmentTemplate[]
*/
public function findForService(ServiceItem $service): array
{
return $this->createQueryBuilder('s')
->addSelect('r', 'rt', 'sk')
->leftJoin('s.requirements', 'r')
->leftJoin('r.resourceType', 'rt')
->leftJoin('r.skill', 'sk')
->where('s.service = :service')
->setParameter('service', $service)
->orderBy('s.sequence', 'ASC')
->getQuery()
->getResult();
}
/**
* @param int[] $serviceIds
* @return array<int, SegmentTemplate[]> شناسهٔ سرویس => بخش‌ها
*/
public function findForServices(array $serviceIds): array
{
if ($serviceIds === []) {
return [];
}
$rows = $this->createQueryBuilder('s')
->addSelect('r', 'rt', 'sk')
->leftJoin('s.requirements', 'r')
->leftJoin('r.resourceType', 'rt')
->leftJoin('r.skill', 'sk')
->where('IDENTITY(s.service) IN (:ids)')
->setParameter('ids', $serviceIds)
->orderBy('s.sequence', 'ASC')
->getQuery()
->getResult();
$byService = [];
foreach ($rows as $segment) {
$byService[(int) $segment->getService()->getId()][] = $segment;
}
return $byService;
}
public function deleteForService(ServiceItem $service): int
{
return (int) $this->createQueryBuilder('s')
->delete()
->where('s.service = :service')
->setParameter('service', $service)
->getQuery()
->execute();
}
}
@@ -0,0 +1,279 @@
<?php
namespace App\Appointment\Plan\Service;
use App\Appointment\Plan\Entity\SegmentRequirement;
use App\Appointment\Plan\Entity\SegmentTemplate;
use App\Appointment\Plan\Repository\SegmentTemplateRepository;
use App\Appointment\Plan\ValueObject\AppointmentPlan;
use App\Appointment\Plan\ValueObject\PlannedRequirement;
use App\Appointment\Plan\ValueObject\PlannedSegment;
use App\ClinicService\Entity\ServiceItem;
use App\ClinicService\Service\DurationCalculator;
use App\Doctor\Entity\DoctorAddress;
use App\Resource\Entity\ClinicResource;
use App\Resource\Entity\ResourceType;
use App\Resource\Repository\ClinicResourceRepository;
use App\Resource\Repository\ResourceTypeRepository;
use App\Shared\Constant\ErrorCodes;
use App\Shared\Exception\AppException;
/**
* از (سرویس، آیتم‌های انتخاب‌شده، شعبه، بیمار) یک **برنامهٔ نوبت** می‌سازد.
*
* هیچ زمان مطلقی و هیچ منبع مشخصی تعیین نمی‌کند — آن کارِ تسک ۰۶ است. اینجا فقط
* شکل نوبت ساخته می‌شود: چند بخش، هرکدام چقدر، و هرکدام چه منبعی می‌خواهد.
*/
final class AppointmentPlanBuilder
{
public function __construct(
private readonly SegmentTemplateRepository $templates,
private readonly ClinicResourceRepository $resources,
private readonly ResourceTypeRepository $types,
private readonly DurationCalculator $durations,
) {}
/**
* @param ServiceItem[] $selectedItems آیتم‌هایی که بیمار انتخاب کرده
* @param string|null $patientGender `male` | `female` | null
*/
public function build(
ServiceItem $service,
array $selectedItems,
DoctorAddress $address,
?string $patientGender = null,
): AppointmentPlan {
$itemMinutes = $this->durations->totalMinutes($selectedItems !== [] ? $selectedItems : [$service]);
$templates = $this->templates->findForService($service);
// سرویسی که الگوی بخش ندارد، همان رفتار امروز را می‌گیرد: یک بخش پیوسته که
// پزشک را می‌گیرد. بدون این، هر سرویس موجود بی‌برنامه می‌شد.
if ($templates === []) {
return $this->singleSegmentPlan($service, $address, $itemMinutes, $patientGender);
}
$segments = [];
$offset = 0;
foreach ($this->orderedTemplates($templates) as $template) {
$duration = $template->getDurationSource() === SegmentTemplate::DURATION_FROM_ITEMS
? $itemMinutes
: $template->getDurationMinutes();
if ($duration <= 0) {
throw new AppException(
ErrorCodes::ERR_VALIDATION_001,
sprintf('مدت بخش «%s» تعیین نشده است', $template->getName()),
422,
'duration_minutes',
);
}
$segments[] = new PlannedSegment(
sequence: $template->getSequence(),
name: $template->getName(),
offsetMinutes: $offset,
durationMinutes: $duration,
patientPresent: $template->isPatientPresent(),
mergeable: $template->isMergeable(),
requirements: $this->planRequirements($template, $address, $patientGender),
);
$offset += $duration;
}
if ($offset > SegmentTemplate::MAX_TOTAL_MINUTES) {
throw new AppException(
ErrorCodes::ERR_VALIDATION_001,
sprintf('مجموع مدت بخش‌ها (%d دقیقه) از سقف %d دقیقه بیشتر است', $offset, SegmentTemplate::MAX_TOTAL_MINUTES),
422,
'segments',
);
}
return new AppointmentPlan($segments, $offset);
}
/**
* بخش‌های `mergeable` هم‌نام یک بار می‌آیند: «آماده‌سازی» با دو ناحیه یک بار انجام
* می‌شود، ولی «خود لیزر» به‌ازای هر ناحیه طولانی‌تر می‌شود (و آن با
* `DURATION_FROM_ITEMS` بیان شده، نه با تکرار بخش).
*
* @param SegmentTemplate[] $templates
* @return SegmentTemplate[]
*/
private function orderedTemplates(array $templates): array
{
$seenMergeable = [];
$ordered = [];
foreach ($templates as $template) {
if ($template->isMergeable()) {
if (isset($seenMergeable[$template->getName()])) {
continue;
}
$seenMergeable[$template->getName()] = true;
}
$ordered[] = $template;
}
usort(
$ordered,
static fn (SegmentTemplate $a, SegmentTemplate $b): int => $a->getSequence() <=> $b->getSequence(),
);
return $ordered;
}
/** @return list<PlannedRequirement> */
private function planRequirements(
SegmentTemplate $template,
DoctorAddress $address,
?string $patientGender,
): array {
$planned = [];
foreach ($template->getRequirements() as $requirement) {
$eligible = $this->eligibleFor($requirement, $address, $patientGender, $template);
if (count($eligible) < $requirement->getCount()) {
throw new AppException(
ErrorCodes::ERR_NO_ELIGIBLE_RESOURCE,
$this->explainMissing($requirement, $address, $patientGender),
422,
'requirements',
);
}
// setup/cleanup محافظه‌کارانه از بیشترین مقدارِ کاندیدها گرفته می‌شود:
// تسک ۰۶ هنوز نمی‌داند کدام منبع انتخاب می‌شود، و کم گرفتنش یعنی نوبت
// بعدی روی زمان تمیزکاری بیفتد.
$planned[] = new PlannedRequirement(
role: $requirement->getResourceType()->getCode(),
roleName: $requirement->getResourceType()->getName(),
count: $requirement->getCount(),
occupancy: $requirement->getOccupancy(),
constraints: $requirement->getConstraints(),
eligible: $eligible,
skillName: $requirement->getSkill()?->getName(),
setupMinutes: $this->maxOf($eligible, static fn (ClinicResource $r): int => $r->getSetupMinutes()),
cleanupMinutes: $this->maxOf($eligible, static fn (ClinicResource $r): int => $r->getCleanupMinutes()),
);
}
return $planned;
}
/** @return list<ClinicResource> */
private function eligibleFor(
SegmentRequirement $requirement,
DoctorAddress $address,
?string $patientGender,
SegmentTemplate $template,
): array {
$skillIds = $requirement->getSkill() === null ? [] : [(int) $requirement->getSkill()->getId()];
$eligible = $this->resources->findEligible($address, $requirement->getResourceType(), $skillIds);
if (!$requirement->requiresSameGender()) {
return array_values($eligible);
}
// قید جنسیت وقتی جنسیت بیمار نامشخص است **نادیده گرفته نمی‌شود**: رد کردن
// بی‌صدا یعنی بیمار به منبعی می‌رسد که قرار نبود.
if ($patientGender === null || $patientGender === '') {
throw new AppException(
ErrorCodes::ERR_VALIDATION_002,
sprintf('برای بخش «%s» ثبت جنسیت بیمار الزامی است', $template->getName()),
422,
'patient_gender',
);
}
return array_values(array_filter(
$eligible,
static fn (ClinicResource $r): bool => ($r->getAttributes()['gender'] ?? null) === $patientGender,
));
}
private function explainMissing(
SegmentRequirement $requirement,
DoctorAddress $address,
?string $patientGender,
): string {
$parts = [sprintf('هیچ %s', $requirement->getResourceType()->getName())];
if ($requirement->requiresSameGender() && $patientGender !== null) {
$parts[] = $patientGender === 'female' ? 'خانمی' : 'آقایی';
}
if ($requirement->getSkill() !== null) {
$parts[] = sprintf('با مهارت «%s»', $requirement->getSkill()->getName());
}
$parts[] = sprintf('در شعبهٔ «%s» موجود نیست', $address->getName() ?? '—');
return implode(' ', $parts);
}
/**
* رفتار امروز، بیان‌شده به زبان برنامه: یک بخش پیوسته که پزشک را می‌گیرد.
*/
private function singleSegmentPlan(
ServiceItem $service,
DoctorAddress $address,
int $minutes,
?string $patientGender,
): AppointmentPlan {
if ($minutes <= 0) {
throw new AppException(
ErrorCodes::ERR_VALIDATION_001,
sprintf('مدت سرویس «%s» تعریف نشده است', $service->getName()),
422,
'duration_minutes',
);
}
$doctorType = $this->types->findByCode(
$address->tenantEntityType(),
$address->tenantEntityId(),
ResourceType::CODE_DOCTOR,
);
$eligible = $doctorType === null
? []
: $this->resources->findEligible($address, $doctorType);
$requirements = $doctorType === null ? [] : [new PlannedRequirement(
role: ResourceType::CODE_DOCTOR,
roleName: $doctorType->getName(),
count: 1,
occupancy: SegmentRequirement::OCCUPANCY_EXCLUSIVE,
constraints: [],
eligible: $eligible,
setupMinutes: $this->maxOf($eligible, static fn (ClinicResource $r): int => $r->getSetupMinutes()),
cleanupMinutes: $this->maxOf($eligible, static fn (ClinicResource $r): int => $r->getCleanupMinutes()),
)];
return new AppointmentPlan(
[new PlannedSegment(
sequence: 1,
name: $service->getName(),
offsetMinutes: 0,
durationMinutes: $minutes,
patientPresent: true,
mergeable: false,
requirements: $requirements,
)],
$minutes,
);
}
/** @param ClinicResource[] $resources */
private function maxOf(array $resources, callable $pick): int
{
$values = array_map($pick, $resources);
return $values === [] ? 0 : max($values);
}
}
@@ -0,0 +1,29 @@
<?php
namespace App\Appointment\Plan\ValueObject;
/**
* برنامهٔ یک نوبت: بخش‌های پشت‌سرهم با آفست، مدت و نیازمندی منبع.
*
* هنوز هیچ زمان مطلق و هیچ منبع مشخصی ندارد — آن کارِ تسک ۰۶ است. این فقط «شکل»
* نوبت است.
*/
final readonly class AppointmentPlan
{
/** @param list<PlannedSegment> $segments */
public function __construct(
public array $segments,
public int $totalMinutes,
) {}
public function toArray(): array
{
return [
'total_minutes' => $this->totalMinutes,
'segments' => array_map(
static fn (PlannedSegment $s): array => $s->toArray(),
$this->segments,
),
];
}
}
@@ -0,0 +1,54 @@
<?php
namespace App\Appointment\Plan\ValueObject;
use App\Resource\Entity\ClinicResource;
/**
* یک نیازمندیِ حل‌شده: چه نقشی، چند تا، با چه اشغالی، و **چند منبع واجد شرایط**.
*
* `candidates` عدد است نه فهرست: تسک ۰۶ خودش منابع را دوباره و با در نظر گرفتن زمان
* پیدا می‌کند؛ اینجا فقط برای پاسخ انسانیِ «هیچ اپراتور خانمی با مهارت لیزر در این
* شعبه نیست» لازم است (بند ۱۰ مستند).
*/
final readonly class PlannedRequirement
{
/**
* @param list<string> $constraints
* @param list<ClinicResource> $eligible منابعی که همین حالا شرایط را دارند
*/
public function __construct(
public string $role,
public string $roleName,
public int $count,
public string $occupancy,
public array $constraints,
public array $eligible,
public ?string $skillName = null,
public int $setupMinutes = 0,
public int $cleanupMinutes = 0,
) {}
public function candidates(): int
{
return count($this->eligible);
}
public function toArray(): array
{
return [
'role' => $this->role,
'role_name' => $this->roleName,
'skill_name' => $this->skillName,
'count' => $this->count,
'occupancy' => $this->occupancy,
'constraints' => $this->constraints,
'candidates' => $this->candidates(),
// تسک ۰۶ بازهٔ اشغال را از همین می‌سازد؛ در نمای کاربر نشان داده نمی‌شود.
'occupancy_offset' => [
'setup_minutes' => $this->setupMinutes,
'cleanup_minutes' => $this->cleanupMinutes,
],
];
}
}
@@ -0,0 +1,33 @@
<?php
namespace App\Appointment\Plan\ValueObject;
final readonly class PlannedSegment
{
/** @param list<PlannedRequirement> $requirements */
public function __construct(
public int $sequence,
public string $name,
public int $offsetMinutes,
public int $durationMinutes,
public bool $patientPresent,
public bool $mergeable,
public array $requirements,
) {}
public function toArray(): array
{
return [
'sequence' => $this->sequence,
'name' => $this->name,
'offset_minutes' => $this->offsetMinutes,
'duration_minutes' => $this->durationMinutes,
'patient_present' => $this->patientPresent,
'mergeable' => $this->mergeable,
'requirements' => array_map(
static fn (PlannedRequirement $r): array => $r->toArray(),
$this->requirements,
),
];
}
}
+4
View File
@@ -19,6 +19,9 @@ class ErrorCodes
// Not Found
public const ERR_NOT_FOUND_001 = 'ERR_NOT_FOUND_001';
/** هیچ منبعی شرایط یک بخش از نوبت را ندارد — بند ۱۰ مستند. */
public const ERR_NO_ELIGIBLE_RESOURCE = 'ERR_NO_ELIGIBLE_RESOURCE';
// Conflict
public const ERR_CONFLICT_001 = 'ERR_CONFLICT_001';
@@ -130,6 +133,7 @@ class ErrorCodes
self::ERR_VALIDATION_001 => 'ورودی نامعتبر است',
self::ERR_VALIDATION_002 => 'فیلد الزامی وارد نشده است',
self::ERR_NOT_FOUND_001 => 'منبع درخواستی یافت نشد',
self::ERR_NO_ELIGIBLE_RESOURCE => 'برای این خدمت منبع واجد شرایطی در این شعبه نیست',
self::ERR_FORBIDDEN_001 => 'دسترسی به این منبع مجاز نیست',
self::ERR_PAYMENT_001 => 'درگاه پرداخت در دسترس نیست',
self::ERR_PAYMENT_002 => 'مبلغ پرداخت نامعتبر است',
+1
View File
@@ -89,6 +89,7 @@ final class GlobalTables
*/
public const AGGREGATE_CHILDREN = [
\App\Appointment\Entity\AppointmentEvent::class => \App\Appointment\Entity\Appointment::class,
\App\Appointment\Plan\Entity\SegmentRequirement::class => \App\Appointment\Plan\Entity\SegmentTemplate::class,
// ریشه‌هاشان خودشان جفت محیط دارند (برخلاف پروندهٔ branch_working_hours در
// تسک ۰۱)، پس ارث‌بری اینجا واقعی است. هیچ‌کدام uuid از request نمی‌گیرند: