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:
hamed
2026-07-30 12:57:48 +03:30
co-authored by Claude Opus 5
parent 2dae76cac0
commit b589a851d0
3 changed files with 309 additions and 0 deletions
@@ -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;
}
+6
View File
@@ -34,6 +34,10 @@ class ErrorCodes
// Appointment
public const ERR_APPOINTMENT_001 = 'ERR_APPOINTMENT_001';
public const ERR_APPOINTMENT_002 = 'ERR_APPOINTMENT_002';
/** مدت نوبت با مجموع مدت سرویس‌های انتخابی نمی‌خواند (حالت نوبت‌دهی سرویسی). */
public const ERR_APPOINTMENT_003 = 'ERR_APPOINTMENT_003';
/** عملیات با روش نوبت‌دهیِ این محل سازگار نیست (slot در برابر service). */
public const ERR_APPOINTMENT_004 = 'ERR_APPOINTMENT_004';
// File
public const ERR_FILE_001 = 'ERR_FILE_001';
@@ -133,6 +137,8 @@ class ErrorCodes
self::ERR_PAYMENT_004 => 'محیط این پرداخت مشخص نیست',
self::ERR_APPOINTMENT_001 => 'اسلات انتخاب‌شده در دسترس نیست',
self::ERR_APPOINTMENT_002 => 'نوبت قابل لغو نیست',
self::ERR_APPOINTMENT_003 => 'مدت نوبت با مجموع مدت سرویس‌های انتخابی نمی‌خواند',
self::ERR_APPOINTMENT_004 => 'این عملیات با روش نوبت‌دهی این محل سازگار نیست',
self::ERR_FILE_001 => 'فرمت فایل مجاز نیست',
self::ERR_FILE_002 => 'حجم فایل بیش از حد مجاز است (حداکثر 5MB)',
self::ERR_SMS_001 => 'موجودی پیامک کافی نیست',
@@ -0,0 +1,252 @@
<?php
namespace App\Tests\Appointment;
use App\Appointment\Entity\Appointment;
use App\Appointment\Entity\WeeklySchedule;
use App\ClinicService\Entity\ServiceItem;
use App\ClinicService\Entity\ServiceSection;
use App\Doctor\Entity\Doctor;
use App\Shared\Constant\ErrorCodes;
use App\Tests\ApiTestCase;
/**
* `PATCH /api/v1/appointment/{uuid}` در حالت نوبت‌دهی سرویسی: مدت داده است نه ورودی.
*
* پیش از این، مدت دلخواه پذیرفته می‌شد و فقط `service_item_uuid` تکی به‌روز می‌شد در
* حالی که `service_items` دست‌نخورده می‌ماند — یعنی نوبت با سرویس‌های قبلی و مدت جدید.
*
* ⛔ در حالت اسلاتی هیچ‌کدام از این بررسی‌ها اجرا نمی‌شود؛ آخرین تست همین را می‌سنجد.
*/
class PatchServiceDurationTest extends ApiTestCase
{
/** @return array{0:\App\Auth\Entity\User,1:Doctor,2:ServiceSection} */
private function doctorInMode(string $mode, int $buffer = 0): array
{
$owner = $this->createUser(['ROLE_DOCTOR']);
$doctor = new Doctor($owner, 'دکتر ویرایش');
$this->em->persist($doctor);
$this->em->flush();
$date = date('Y-m-d', strtotime('+2 days'));
$dayKey = (string) (((int) date('w', strtotime($date)) + 1) % 7);
$schedule = $this->newWeeklySchedule($doctor, [
$dayKey => ['sessions' => [[
'active' => true, 'start_time' => '09:00', 'end_time' => '18:00',
'duration_per_patient' => 20, 'location_id' => 1,
]]],
]);
$schedule->setMeta(['booking_mode' => $mode, 'buffer_minutes' => $buffer]);
$this->em->persist($schedule);
$section = new ServiceSection('doctor', $doctor->getId(), 'بخش ویرایش');
$this->em->persist($section);
$this->em->flush();
return [$owner, $doctor, $section];
}
private function service(ServiceSection $section, string $name, int $minutes): ServiceItem
{
$item = new ServiceItem($section, $name, 0);
$item->setDurationMinutes($minutes)->setBookable(true);
$this->em->persist($item);
$this->em->flush();
return $item;
}
/** نوبت زمان‌دارِ تأییدشده با مدت دلخواه. */
private function appointment(Doctor $doctor, int $minutes, array $items = []): Appointment
{
$patient = $this->createUser(['ROLE_USER']);
$start = (int) strtotime(date('Y-m-d', strtotime('+2 days')) . ' 10:00');
$appt = $this->newAppointment($doctor, $patient, $start, $start + $minutes * 60);
$appt->transitionTo(Appointment::STATUS_CONFIRMED);
if ($items !== []) {
$appt->replaceServiceItems($items);
}
$this->em->persist($appt);
$this->em->flush();
return $appt;
}
private function patch(Appointment $appt, \App\Auth\Entity\User $actor, array $body): array
{
return $this->authJson('PATCH', '/api/v1/appointment/' . $appt->getUuid(), $actor, $body + [
'version' => $appt->getVersion(),
]);
}
private function at(string $time): int
{
return (int) strtotime(date('Y-m-d', strtotime('+2 days')) . ' ' . $time);
}
// ── ✅ موفق ──────────────────────────────────────────────────────────────
public function testReplacingServicesRecomputesTheStoredDuration(): void
{
[$owner, $doctor, $section] = $this->doctorInMode(WeeklySchedule::MODE_SERVICE, buffer: 10);
$face = $this->service($section, 'لیزر صورت', 20);
$bikini = $this->service($section, 'لیزر بیکینی', 15);
$appt = $this->appointment($doctor, 20, [$face]);
$res = $this->patch($appt, $owner, [
'slot_start' => $this->at('11:00'),
'slot_end' => $this->at('11:35'), // ۳۵ = ۲۰ + ۱۵
'service_item_uuids' => [$face->getUuid(), $bikini->getUuid()],
]);
self::assertSame(200, $this->responseCode());
self::assertSame(35, $res['data']['data']['service_total_minutes']);
self::assertSame(10, $res['data']['data']['service_buffer_minutes']);
self::assertCount(2, $res['data']['data']['service_items']);
}
public function testTheLegacySingleServiceFollowsTheList(): void
{
[$owner, $doctor, $section] = $this->doctorInMode(WeeklySchedule::MODE_SERVICE);
$old = $this->service($section, 'سرویس قدیمی', 20);
$new = $this->service($section, 'سرویس جدید', 30);
$appt = $this->appointment($doctor, 20, [$old]);
$res = $this->patch($appt, $owner, [
'slot_start' => $this->at('11:00'),
'slot_end' => $this->at('11:30'),
'service_item_uuids' => [$new->getUuid()],
]);
self::assertSame(200, $this->responseCode());
self::assertSame($new->getUuid(), $res['data']['data']['service_item']['uuid'],
'ستون تکی باید با فهرست هم‌گام شود، وگرنه لیست‌ها نام سرویس قدیمی را نشان می‌دهند');
}
public function testNoteOnlyPatchSkipsDurationValidation(): void
{
[$owner, $doctor, $section] = $this->doctorInMode(WeeklySchedule::MODE_SERVICE);
$item = $this->service($section, 'مشاوره', 30);
// نوبتی که مدتش با سرویسش نمی‌خواند (دادهٔ قدیمی).
$appt = $this->appointment($doctor, 20, [$item]);
$this->patch($appt, $owner, ['note' => 'یادداشت تازه']);
self::assertSame(200, $this->responseCode(), 'PATCH بدون slot نباید مدت را بسنجد');
}
public function testReserveEntryStoresServicesWithoutDurationCheck(): void
{
[$owner, $doctor, $section] = $this->doctorInMode(WeeklySchedule::MODE_SERVICE);
$item = $this->service($section, 'لیزر', 45);
$appt = $this->appointment($doctor, 30, [$item]);
$midnight = $this->at('00:00');
$res = $this->patch($appt, $owner, [
'slot_start' => $midnight,
'slot_end' => $midnight, // رزرو: start == end
'is_reserve' => true,
'service_item_uuids' => [$item->getUuid()],
]);
self::assertSame(200, $this->responseCode(), 'نوبت رزرو از بررسی مدت معاف است');
self::assertTrue($res['data']['data']['is_reserve']);
self::assertSame(45, $res['data']['data']['service_total_minutes'],
'مدت برای تبدیل بعدیِ رزرو به نوبت زمان‌دار ذخیره می‌شود');
}
public function testReserveConvertsToATimedAppointmentThroughPatch(): void
{
[$owner, $doctor, $section] = $this->doctorInMode(WeeklySchedule::MODE_SERVICE);
$item = $this->service($section, 'لیزر', 30);
$patient = $this->createUser(['ROLE_USER']);
$midnight = $this->at('00:00');
$reserve = $this->newAppointment($doctor, $patient, $midnight, $midnight);
$reserve->rescheduleTo($midnight, $midnight, true);
$reserve->replaceServiceItems([$item]);
$reserve->transitionTo(Appointment::STATUS_CONFIRMED);
$this->em->persist($reserve);
$this->em->flush();
$res = $this->patch($reserve, $owner, [
'slot_start' => $this->at('12:00'),
'slot_end' => $this->at('12:30'),
'is_reserve' => false,
]);
self::assertSame(200, $this->responseCode(), 'تبدیل رزرو با همان PATCH انجام می‌شود — endpoint جدید لازم نیست');
self::assertFalse($res['data']['data']['is_reserve']);
self::assertSame(30, $res['data']['data']['service_total_minutes']);
}
// ── ❌ خطا ───────────────────────────────────────────────────────────────
public function testMismatchedDurationIsRejectedWithTheCorrectMinutes(): void
{
[$owner, $doctor, $section] = $this->doctorInMode(WeeklySchedule::MODE_SERVICE);
$item = $this->service($section, 'لیزر', 45);
$appt = $this->appointment($doctor, 45, [$item]);
$res = $this->patch($appt, $owner, [
'slot_start' => $this->at('11:00'),
'slot_end' => $this->at('11:20'), // ۲۰ دقیقه به‌جای ۴۵
]);
self::assertSame(422, $this->responseCode());
self::assertSame(ErrorCodes::ERR_APPOINTMENT_003, $res['errors'][0]['code']);
self::assertSame('slot_end', $res['errors'][0]['field']);
self::assertStringContainsString('45', $res['errors'][0]['message'], 'پیام باید مدت درست را بگوید');
}
public function testForeignServiceIsRejected(): void
{
[$owner, $doctor, $section] = $this->doctorInMode(WeeklySchedule::MODE_SERVICE);
$mine = $this->service($section, 'سرویس خودم', 30);
$appt = $this->appointment($doctor, 30, [$mine]);
[, $otherDoctor, $otherSection] = $this->doctorInMode(WeeklySchedule::MODE_SERVICE);
$foreign = $this->service($otherSection, 'سرویس بیگانه', 30);
self::assertNotNull($otherDoctor->getId());
$res = $this->patch($appt, $owner, [
'slot_start' => $this->at('11:00'),
'slot_end' => $this->at('11:30'),
'service_item_uuids' => [$foreign->getUuid()],
]);
self::assertSame(422, $this->responseCode());
self::assertSame('سرویس انتخاب‌شده به این محل نوبت‌دهی تعلق ندارد', $res['errors'][0]['message']);
}
// ── ⚠️ مرزی — خط سرخ ─────────────────────────────────────────────────────
public function testSlotModeAppointmentKeepsAcceptingAnyDuration(): void
{
[$owner, $doctor, $section] = $this->doctorInMode(WeeklySchedule::MODE_SLOT);
$item = $this->service($section, 'سرویس اسلاتی', 45);
$appt = $this->appointment($doctor, 20, [$item]);
$res = $this->patch($appt, $owner, [
'slot_start' => $this->at('11:00'),
'slot_end' => $this->at('11:20'), // با مدت سرویس نمی‌خواند — و باید بخورد
]);
self::assertSame(200, $this->responseCode(), 'حالت اسلاتی نباید هیچ اعتبارسنجی سرویسی بگیرد');
self::assertNull($res['data']['data']['service_total_minutes'], 'ستون سرویسی در حالت اسلاتی null می‌ماند');
}
public function testServiceModeAppointmentWithoutAnyServiceIsNotBlocked(): void
{
[$owner, $doctor] = $this->doctorInMode(WeeklySchedule::MODE_SERVICE);
$appt = $this->appointment($doctor, 20); // دادهٔ قدیمی: بدون سرویس
$res = $this->patch($appt, $owner, [
'slot_start' => $this->at('11:00'),
'slot_end' => $this->at('11:20'),
]);
self::assertSame(200, $this->responseCode(), 'نوبت سرویسی بدون سرویس نباید قفل شود');
self::assertNull($res['data']['data']['service_total_minutes']);
}
}