feat(plan): build the plan from the selected items too, so mergeable finally means something
The mergeable flag was stored, returned by the API and rendered in the editor while changing nothing. The reason was upstream: the builder only ever read the primary service's templates, and within one service two segments with the same name do not occur — so the dedupe it already had could never fire. Templates now come from the primary service plus every selected item, and same-named mergeable segments collapse to one. Rules, with their reasons: - the longest of the same-named segments survives — prepping two areas is not shorter than prepping the longer one alone - a duration_source: "items" segment also appears once even when it is not marked mergeable, because DurationCalculator has already summed every item and repeating the segment counts that time twice - the merged requirement count is the maximum, not the sum and not the first one seen: two areas do not need two rooms, but if one of them needed two operators, merging must not quietly demote that to one Also pins that the plan is deterministic: two previews of the same input are compared byte for byte. A plan that shifts between preview and booking means the user confirmed something that was not what got booked. Unrelated but found by running the suite on a Saturday: testPastStartsAreExcluded searched "last week's Saturday", which is today when today is Saturday, so this afternoon's slots were legitimately not in the past. It now searches two weeks back, which is unambiguous on every weekday. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -48,7 +48,7 @@ final class AppointmentPlanBuilder
|
||||
?string $patientGender = null,
|
||||
): AppointmentPlan {
|
||||
$itemMinutes = $this->durations->totalMinutes($selectedItems !== [] ? $selectedItems : [$service]);
|
||||
$templates = $this->templates->findForService($service);
|
||||
$templates = $this->gatherTemplates($service, $selectedItems);
|
||||
|
||||
// سرویسی که الگوی بخش ندارد، همان رفتار امروز را میگیرد: یک بخش پیوسته که
|
||||
// پزشک را میگیرد. بدون این، هر سرویس موجود بیبرنامه میشد.
|
||||
@@ -61,6 +61,7 @@ final class AppointmentPlanBuilder
|
||||
|
||||
$segments = [];
|
||||
$offset = 0;
|
||||
$counts = $this->mergedCounts($templates);
|
||||
|
||||
foreach ($this->orderedTemplates($templates) as $template) {
|
||||
$duration = $template->getDurationSource() === SegmentTemplate::DURATION_FROM_ITEMS
|
||||
@@ -83,7 +84,7 @@ final class AppointmentPlanBuilder
|
||||
durationMinutes: $duration,
|
||||
patientPresent: $template->isPatientPresent(),
|
||||
mergeable: $template->isMergeable(),
|
||||
requirements: $this->planRequirements($template, $address, $patientGender),
|
||||
requirements: $this->planRequirements($template, $address, $patientGender, $counts[$template->getName()] ?? []),
|
||||
);
|
||||
|
||||
$offset += $duration;
|
||||
@@ -184,30 +185,77 @@ final class AppointmentPlanBuilder
|
||||
return $target;
|
||||
}
|
||||
|
||||
/**
|
||||
* الگوهای سرویس اصلی **بهعلاوهٔ** الگوهای آیتمهای انتخابشده.
|
||||
*
|
||||
* بدون این، بخشهایی که کلینیک روی خودِ ناحیه تعریف کرده بیصدا نادیده میماندند و
|
||||
* پرچم `mergeable` هرگز کاری نمیکرد — چون در یک سرویس، دو بخشِ همنام معنا ندارد.
|
||||
*
|
||||
* @param ServiceItem[] $selectedItems
|
||||
* @return SegmentTemplate[]
|
||||
*/
|
||||
private function gatherTemplates(ServiceItem $service, array $selectedItems): array
|
||||
{
|
||||
$ids = [(int) $service->getId()];
|
||||
|
||||
foreach ($selectedItems as $item) {
|
||||
$ids[] = (int) $item->getId();
|
||||
}
|
||||
|
||||
$ids = array_values(array_unique(array_filter($ids)));
|
||||
|
||||
if (count($ids) === 1) {
|
||||
return $this->templates->findForService($service);
|
||||
}
|
||||
|
||||
$gathered = [];
|
||||
|
||||
foreach ($this->templates->findForServices($ids) as $forService) {
|
||||
foreach ($forService as $template) {
|
||||
$gathered[(int) $template->getId()] = $template;
|
||||
}
|
||||
}
|
||||
|
||||
return array_values($gathered);
|
||||
}
|
||||
|
||||
/**
|
||||
* بخشهای `mergeable` همنام یک بار میآیند: «آمادهسازی» با دو ناحیه یک بار انجام
|
||||
* میشود، ولی «خود لیزر» بهازای هر ناحیه طولانیتر میشود (و آن با
|
||||
* `DURATION_FROM_ITEMS` بیان شده، نه با تکرار بخش).
|
||||
*
|
||||
* از میان همنامها **طولانیترین** میماند: آمادهسازیِ دو ناحیه کوتاهتر از
|
||||
* طولانیترینشان نیست. بخشی که مدتش از آیتمها میآید هم یک بار میآید حتی اگر
|
||||
* `mergeable` نباشد، چون `DurationCalculator` از قبل مجموع همهٔ آیتمها را داده و
|
||||
* تکرارش یعنی دوبار شمردن همان زمان.
|
||||
*
|
||||
* @param SegmentTemplate[] $templates
|
||||
* @return SegmentTemplate[]
|
||||
*/
|
||||
private function orderedTemplates(array $templates): array
|
||||
{
|
||||
$seenMergeable = [];
|
||||
$ordered = [];
|
||||
$best = [];
|
||||
$rest = [];
|
||||
|
||||
foreach ($templates as $template) {
|
||||
if ($template->isMergeable()) {
|
||||
if (isset($seenMergeable[$template->getName()])) {
|
||||
continue;
|
||||
}
|
||||
$seenMergeable[$template->getName()] = true;
|
||||
$collapses = $template->isMergeable()
|
||||
|| $template->getDurationSource() === SegmentTemplate::DURATION_FROM_ITEMS;
|
||||
|
||||
if (!$collapses) {
|
||||
$rest[] = $template;
|
||||
continue;
|
||||
}
|
||||
|
||||
$ordered[] = $template;
|
||||
$key = $template->getName();
|
||||
$current = $best[$key] ?? null;
|
||||
|
||||
if ($current === null || $template->getDurationMinutes() > $current->getDurationMinutes()) {
|
||||
$best[$key] = $template;
|
||||
}
|
||||
}
|
||||
|
||||
$ordered = array_merge(array_values($best), $rest);
|
||||
|
||||
usort(
|
||||
$ordered,
|
||||
static fn (SegmentTemplate $a, SegmentTemplate $b): int => $a->getSequence() <=> $b->getSequence(),
|
||||
@@ -216,6 +264,32 @@ final class AppointmentPlanBuilder
|
||||
return $ordered;
|
||||
}
|
||||
|
||||
/**
|
||||
* تعداد منبعِ هر نقش پس از ادغام: **بیشینه**، نه جمع و نه اولی.
|
||||
*
|
||||
* دو ناحیهای که هرکدام یک اتاق میخواهند، با هم دو اتاق نمیخواهند — همان یک اتاق
|
||||
* است. ولی اگر یکی دو اپراتور لازم داشت، ادغام نباید آن را به یک تنزل بدهد؛
|
||||
* کم گرفتنش یعنی نوبتی که سرِ وقتش نیروی کافی ندارد.
|
||||
*
|
||||
* @param SegmentTemplate[] $templates
|
||||
* @return array<string, array<string, int>> `[نامِ بخش => [کدِ نقش => تعداد]]`
|
||||
*/
|
||||
private function mergedCounts(array $templates): array
|
||||
{
|
||||
$counts = [];
|
||||
|
||||
foreach ($templates as $template) {
|
||||
foreach ($template->getRequirements() as $requirement) {
|
||||
$name = $template->getName();
|
||||
$role = $requirement->getResourceType()->getCode();
|
||||
|
||||
$counts[$name][$role] = max($counts[$name][$role] ?? 0, $requirement->getCount());
|
||||
}
|
||||
}
|
||||
|
||||
return $counts;
|
||||
}
|
||||
|
||||
/**
|
||||
* قوانین «منبع»: نقشی که قانون لازم میداند، اگر الگو نداشته باشد، اضافه میشود.
|
||||
*
|
||||
@@ -363,13 +437,18 @@ final class AppointmentPlanBuilder
|
||||
SegmentTemplate $template,
|
||||
DoctorAddress $address,
|
||||
?string $patientGender,
|
||||
array $mergedCounts = [],
|
||||
): array {
|
||||
$planned = [];
|
||||
|
||||
foreach ($template->getRequirements() as $requirement) {
|
||||
$eligible = $this->eligibleFor($requirement, $address, $patientGender, $template);
|
||||
$count = max(
|
||||
$requirement->getCount(),
|
||||
$mergedCounts[$requirement->getResourceType()->getCode()] ?? 0,
|
||||
);
|
||||
|
||||
if (count($eligible) < $requirement->getCount()) {
|
||||
if (count($eligible) < $count) {
|
||||
throw new AppException(
|
||||
ErrorCodes::ERR_NO_ELIGIBLE_RESOURCE,
|
||||
$this->explainMissing($requirement, $address, $patientGender),
|
||||
@@ -384,7 +463,7 @@ final class AppointmentPlanBuilder
|
||||
$planned[] = new PlannedRequirement(
|
||||
role: $requirement->getResourceType()->getCode(),
|
||||
roleName: $requirement->getResourceType()->getName(),
|
||||
count: $requirement->getCount(),
|
||||
count: $count,
|
||||
occupancy: $requirement->getOccupancy(),
|
||||
constraints: $requirement->getConstraints(),
|
||||
eligible: $eligible,
|
||||
|
||||
Reference in New Issue
Block a user