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>
152 lines
6.5 KiB
PHP
152 lines
6.5 KiB
PHP
<?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(),
|
|
),
|
|
];
|
|
}
|
|
}
|