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:
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user