feat(appointment): derive service section from service item when missing in appointment

This commit is contained in:
hamed
2026-08-08 07:14:40 +03:30
parent da07e3ad9c
commit b699476305
5 changed files with 144 additions and 22 deletions
@@ -50,6 +50,29 @@ describe('AppointmentEditPage (ویرایش نوبت)', () => {
expect(screen.getByDisplayValue('یادداشت')).toBeInTheDocument();
});
// بخش و سرویس هر دو باید برچسب واقعی نشان دهند، نه placeholder. فهرست سرویس‌ها
// از بخش می‌آید، پس بخشِ خالی یعنی سرویسِ ذخیره‌شده هم انتخاب‌نشده دیده می‌شود —
// همان باگی که در ۲۰۲۶-۰۸-۰۸ گزارش شد.
it('shows the stored section and service, not their placeholders', async () => {
renderEdit();
await screen.findByText('مشخصات سرویس:');
expect(await screen.findByText('زیبایی')).toBeInTheDocument();
expect(await screen.findByText('لیزر')).toBeInTheDocument();
expect(screen.queryByText('انتخاب بخش')).not.toBeInTheDocument();
expect(screen.queryByText('انتخاب سرویس')).not.toBeInTheDocument();
});
// بلوک بیعانه فعلاً از فرم برداشته شده، ولی مقدارش همچنان ذخیره می‌شود
// (اثباتش در تست بعدی: deposit_required در payload هست).
it('no longer renders the deposit block', async () => {
renderEdit();
await screen.findByText('مشخصات سرویس:');
expect(screen.queryByText('بیعانه:')).not.toBeInTheDocument();
expect(screen.queryByText('بیعانه مورد نیاز است.')).not.toBeInTheDocument();
});
it('patches the general update endpoint with the edited values', async () => {
renderEdit();
await screen.findByText('مشخصات سرویس:');
+6 -21
View File
@@ -6,9 +6,7 @@ import { toast } from 'sonner';
import { api } from '../lib/api';
import type { ApiResponse } from '../lib/api';
import PersianDateInput from '../components/ui/PersianDateInput';
import PriceInput from '../components/ui/PriceInput';
import SearchableSelect from '../components/ui/SearchableSelect';
import { WalletChargeLink } from '../components/AppointmentActions';
import { formatRial, rialToToman, tomanToRial } from '../lib/utils';
import { DEFAULT_SERVICE_CATEGORY } from '../lib/insuranceShares';
import BackButton from '../components/ui/BackButton';
@@ -16,7 +14,6 @@ import { useAppointmentInsurance } from '../hooks/useAppointmentInsurance';
import { useDoctorBookingServices } from '../hooks/useDoctorBookingServices';
import ServiceSlotPicker from '../components/appointments/ServiceSlotPicker';
import type { PickedService, ServicePick } from '../components/appointments/ServiceSlotPicker';
import Switch from '../components/ui/Switch';
interface Option { uuid: string; name?: string; full_name?: string }
@@ -357,24 +354,12 @@ export default function AppointmentEditPage() {
</div>
)}
<div style={{ fontSize: 14, fontWeight: 700, marginBottom: 12 }}>بیعانه:</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 16, flexWrap: 'wrap', marginBottom: 18 }}>
<Switch
inline
checked={depositRequired}
onChange={setDepositRequired}
label="بیعانه مورد نیاز است."
/>
{depositRequired && (
<>
<div style={{ minWidth: 220 }}>
<label style={label}>مبلغ بیعانه (تومان)</label>
<div style={{ marginTop: 6 }}><PriceInput value={depositToman} onChange={setDepositToman} /></div>
</div>
<WalletChargeLink mobile={a.patient_mobile || a.user?.mobile || ''} />
</>
)}
</div>
{/*
بخش «بیعانه» فعلاً از فرم برداشته شد (درخواست ۲۰۲۶-۰۸-۰۸).
state و ارسالش عمداً سر جایشان ماندند: مقدارِ فعلیِ نوبت هنگام هیدریت
خوانده و همان هنگام ذخیره پس فرستاده می‌شود، پس ویرایشِ بقیهٔ فیلدها
بیعانهٔ ثبت‌شده را پاک نمی‌کند. برگرداندنِ UI یعنی همین بلوک، نه بیشتر.
*/}
<div style={{ maxWidth: 320, marginBottom: 18 }}>
<label style={label}>انتخاب وضعیت</label>
+7
View File
@@ -390,6 +390,13 @@ Get appointment detail.
**Permission:** `AUTH` — see [Single-appointment access model](#single-appointment-access-model)
> **`service_section` اینجا استنتاجی است.** نوبتِ سرویسیِ کلینیک بخش را روی خودِ نوبت
> ذخیره نمی‌کند و فقط سرویس را نگه می‌دارد. این اندپوینت — و **فقط** این اندپوینت — وقتی
> `service_section` خالی باشد آن را از `service_item` درمی‌آورد. حدس نیست:
> `service_items.section_id` ستونی `NOT NULL` است، پس هر سرویسی دقیقاً یک بخش دارد.
> نوبتِ بدون هیچ سرویسی همچنان `null` می‌گیرد. فهرست‌ها (`GET /api/v1/appointments…`)
> عمداً این استنتاج را ندارند و رفتارشان تغییر نکرده.
### Path Parameters
| Param | Type | Description |
|-------|------|-------------|
@@ -704,7 +704,27 @@ class AppointmentController extends BaseController
return $this->error(ErrorCodes::ERR_ACCESS_DENIED, 'دسترسی ممنوع', 403);
}
return $this->success(['data' => $appointment->toArray()]);
$payload = $appointment->toArray();
/**
* نوبتِ سرویسیِ کلینیک `service_section_id` را روی خودِ نوبت نمی‌نویسد؛ فقط
* سرویس را نگه می‌دارد. ولی فرم ویرایش فهرستِ سرویس‌ها را از **بخش**
* می‌گیرد، پس بخشِ خالی یعنی فهرست سرویس هم خالی می‌ماند و سرویسِ
* ذخیره‌شده — با اینکه مقدارش درست است — بی‌برچسب و انگار انتخاب‌نشده
* دیده می‌شود.
*
* بخش از خودِ سرویس درمی‌آید و حدس نیست: `service_items.section_id` ستونِ
* NOT NULL است، پس هر سرویسی دقیقاً یک بخش دارد.
*
* عمداً اینجاست و نه در `Appointment::toArray()`: آنجا فهرستِ نوبت‌ها هم
* از همین متد می‌خواند و نباید ناگهان بخشی نشان دهد که تا امروز نداشت.
*/
if ($payload['service_section'] === null && ($item = $appointment->getServiceItem()) !== null) {
$section = $item->getSection();
$payload['service_section'] = ['uuid' => $section->getUuid(), 'name' => $section->getName()];
}
return $this->success(['data' => $payload]);
}
#[OA\Get(
@@ -0,0 +1,87 @@
<?php
namespace App\Tests\Appointment;
use App\ClinicService\Entity\ServiceItem;
use App\ClinicService\Entity\ServiceSection;
use App\Doctor\Entity\Doctor;
use App\Tests\ApiTestCase;
/**
* `GET /api/v1/appointment/{uuid}` وقتی نوبت بخش ندارد، بخش را از سرویسش درمی‌آورد.
*
* گزارش ۲۰۲۶-۰۸-۰۸: در فرم ویرایش نوبت، «بخش» و «سرویس» هر دو خالی دیده می‌شدند
* با اینکه نوبت سرویس داشت. علت: نوبتِ سرویسیِ کلینیک `service_section_id` را
* ذخیره نمی‌کند، و فرم فهرست سرویس‌ها را از بخش می‌گیرد — پس بخشِ خالی یعنی
* فهرست خالی و سرویسِ بی‌برچسب.
*/
class AppointmentDetailSectionFallbackTest extends ApiTestCase
{
/** @return array{0: Doctor, 1: ServiceSection, 2: ServiceItem} */
private function doctorWithOneService(): array
{
$owner = $this->createUser(['ROLE_DOCTOR']);
$doctor = new Doctor($owner, 'دکتر تست بخش');
$this->em->persist($doctor);
// بخش به شناسهٔ پزشک نیاز دارد، پس پزشک باید پیش از ساختش flush شود.
$this->em->flush();
$section = new ServiceSection('doctor', $doctor->getId(), 'بخش لیزر');
$this->em->persist($section);
$item = new ServiceItem($section, 'لیزر توتال', 0);
$item->setDurationMinutes(40)->setBookable(true);
$this->em->persist($item);
$this->em->flush();
return [$doctor, $section, $item];
}
/** ✅ موفق — بخشِ ذخیره‌نشده از روی سرویس پر می‌شود. */
public function testSectionIsDerivedFromTheServiceItemWhenMissing(): void
{
[$doctor, $section, $item] = $this->doctorWithOneService();
$patient = $this->createUser(['ROLE_USER']);
$start = strtotime('+5 days 10:00');
$appt = $this->newAppointment($doctor, $patient, $start, $start + 2400);
$appt->replaceServiceItems([$item]);
self::assertNull($appt->getServiceSection(), 'پیش‌فرضِ این سناریو: نوبت بخش ندارد');
$this->em->persist($appt);
$this->em->flush();
$res = $this->authJson('GET', '/api/v1/appointment/' . $appt->getUuid(), $doctor->getUser());
self::assertSame(200, $this->responseCode());
$payload = $res['data']['data'];
self::assertSame($section->getUuid(), $payload['service_section']['uuid']);
self::assertSame('بخش لیزر', $payload['service_section']['name']);
self::assertSame($item->getUuid(), $payload['service_item']['uuid']);
}
/** ⚠️ مرزی — نوبتِ بدون هیچ سرویسی چیزی برای استنتاج ندارد و `null` می‌ماند. */
public function testSectionStaysNullWhenTheAppointmentHasNoService(): void
{
[$doctor] = $this->doctorWithOneService();
$patient = $this->createUser(['ROLE_USER']);
$start = strtotime('+6 days 10:00');
$appt = $this->newAppointment($doctor, $patient, $start, $start + 1800);
$this->em->persist($appt);
$this->em->flush();
$res = $this->authJson('GET', '/api/v1/appointment/' . $appt->getUuid(), $doctor->getUser());
self::assertSame(200, $this->responseCode());
self::assertNull($res['data']['data']['service_section']);
}
/** ❌ خطا — نوبتِ ناموجود همچنان ۴۰۴ است؛ fallback مسیر خطا را عوض نمی‌کند. */
public function testUnknownAppointmentStillReturns404(): void
{
[$doctor] = $this->doctorWithOneService();
$this->authJson('GET', '/api/v1/appointment/00000000-0000-0000-0000-000000000000', $doctor->getUser());
self::assertSame(404, $this->responseCode());
}
}