feat(booking): make PATCH derive appointment duration from its services
In service mode PATCH accepted any duration and only updated the single service_item column while the service_items collection stayed untouched, so an edit could leave an appointment with old services and a new length. A 45-minute service could be shortened to 20 and the next patient would sit on top of it. Services are now resolved before the time block (duration depends on them) and the stored end must equal start + total minutes. Reserve entries are exempt: they carry slot_start == slot_end and occupy no interval, but they do store the computed duration so a later conversion does not lose it. No convert-reserve endpoint was added: PATCH already converts a reserve to a timed appointment via rescheduleTo($start, $end, $isReserve), which refreshes active_slot_key itself. Project rule 8 — a new endpoint needs an existing one to be insufficient even after extension. Slot mode is untouched: with booking_mode = slot the duration stays null and not one of the new branches runs. Covered by an explicit test. New error codes are ERR_APPOINTMENT_003/004 (the file only had 001/002). Task: docs/new_feture/taskes/task-00-service-mode-completion/ Slot-mode contract: unchanged (--group=slot-mode-frozen green) Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1077,6 +1077,31 @@ class AppointmentController extends BaseController
|
||||
return $this->error(ErrorCodes::ERR_ACCESS_DENIED, 'دسترسی ممنوع', 403);
|
||||
}
|
||||
|
||||
// ── حالت نوبتدهی سرویسی: مدت، داده است نه ورودی ──────────────────────────
|
||||
// سرویسها باید پیش از بلوک زمان حل شوند، چون مدتِ مجاز به آنها وابسته است.
|
||||
// در حالت اسلاتی هیچکدام از این خطوط اجرا نمیشود و رفتار بیتبهبیت همان میماند.
|
||||
$serviceMode = $this->serviceCalculator->isServiceMode($appointment->getDoctor(), $appointment->getClinic());
|
||||
$hasServiceSet = array_key_exists('service_item_uuids', $data);
|
||||
$duration = null;
|
||||
|
||||
if ($serviceMode) {
|
||||
$requestedUuids = $hasServiceSet
|
||||
? array_values(array_filter(array_map('trim', (array) $data['service_item_uuids'])))
|
||||
: $appointment->currentServiceUuids();
|
||||
|
||||
if ($requestedUuids !== []) {
|
||||
$duration = $this->serviceCalculator->calculate(
|
||||
$appointment->getDoctor(),
|
||||
$appointment->getClinic(),
|
||||
$requestedUuids,
|
||||
(array) ($data['durations'] ?? []),
|
||||
// سرویسِ غیرفعالِ نوبتِ موجود نباید نوبت را برای همیشه قفل کند؛ ولی
|
||||
// افزودن سرویس غیرفعالِ تازه رد میشود.
|
||||
allowInactive: !$hasServiceSet,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Slot move / reserve toggle — both times together, or neither.
|
||||
$hasStart = array_key_exists('slot_start', $data);
|
||||
$hasEnd = array_key_exists('slot_end', $data);
|
||||
@@ -1091,19 +1116,45 @@ class AppointmentController extends BaseController
|
||||
}
|
||||
$isReserve = array_key_exists('is_reserve', $data) ? (bool) $data['is_reserve'] : null;
|
||||
$movingToLiveSlot = ($isReserve ?? $appointment->isReserve()) === false;
|
||||
|
||||
// نوبتِ زماندارِ سرویسی نمیتواند مدت دلخواه بگیرد. نوبت رزرو معاف است:
|
||||
// slot_start == slot_end دارد و بازهای اشغال نمیکند.
|
||||
if ($movingToLiveSlot && $duration !== null && $newEnd !== $duration->endFor($newStart)) {
|
||||
return $this->error(
|
||||
ErrorCodes::ERR_APPOINTMENT_003,
|
||||
sprintf('مدت این نوبت باید %d دقیقه باشد', $duration->totalMinutes),
|
||||
422,
|
||||
'slot_end',
|
||||
);
|
||||
}
|
||||
|
||||
if ($movingToLiveSlot && ($newStart !== $appointment->getSlotStart() || $newEnd !== $appointment->getSlotEnd())
|
||||
&& $this->appointmentRepo->isSlotTaken($appointment->getDoctor(), $newStart, $newEnd, $appointment->getId())) {
|
||||
return $this->error(ErrorCodes::ERR_CONFLICT_001, 'این بازه زمانی قبلاً رزرو شده است', 409, 'slot_start');
|
||||
}
|
||||
// rescheduleTo() خودش refreshActiveSlotKey() را صدا میزند، پس تبدیل رزرو به
|
||||
// نوبت زماندار (is_reserve: false) کلید یکتایی را بازتولید میکند.
|
||||
$appointment->rescheduleTo($newStart, $newEnd, $isReserve);
|
||||
}
|
||||
|
||||
if ($duration !== null) {
|
||||
if ($hasServiceSet) {
|
||||
$appointment->replaceServiceItems($duration->serviceItems);
|
||||
}
|
||||
$appointment->setServiceDuration($duration->totalMinutes, $duration->bufferMinutes);
|
||||
}
|
||||
|
||||
// Workflow relations — empty string clears, uuid assigns, unknown → 422.
|
||||
// `service_item_uuid` تکی وقتی نادیده گرفته میشود که فهرست کامل آمده باشد،
|
||||
// وگرنه دو منبع برای یک چیز به نوبتِ ناسازگار میرسد.
|
||||
foreach ([
|
||||
'service_section_uuid' => [$this->sectionRepo, 'setServiceSection', 'بخش'],
|
||||
'service_item_uuid' => [$this->itemRepo, 'setServiceItem', 'سرویس'],
|
||||
'staff_uuid' => [$this->staffRepo, 'setStaff', 'پرسنل'],
|
||||
] as $key => [$repo, $setter, $label]) {
|
||||
if ($key === 'service_item_uuid' && $hasServiceSet) {
|
||||
continue;
|
||||
}
|
||||
if (!array_key_exists($key, $data)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user