feat(appointment): derive service section from service item when missing in appointment
This commit is contained in:
@@ -50,6 +50,29 @@ describe('AppointmentEditPage (ویرایش نوبت)', () => {
|
|||||||
expect(screen.getByDisplayValue('یادداشت')).toBeInTheDocument();
|
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 () => {
|
it('patches the general update endpoint with the edited values', async () => {
|
||||||
renderEdit();
|
renderEdit();
|
||||||
await screen.findByText('مشخصات سرویس:');
|
await screen.findByText('مشخصات سرویس:');
|
||||||
|
|||||||
@@ -6,9 +6,7 @@ import { toast } from 'sonner';
|
|||||||
import { api } from '../lib/api';
|
import { api } from '../lib/api';
|
||||||
import type { ApiResponse } from '../lib/api';
|
import type { ApiResponse } from '../lib/api';
|
||||||
import PersianDateInput from '../components/ui/PersianDateInput';
|
import PersianDateInput from '../components/ui/PersianDateInput';
|
||||||
import PriceInput from '../components/ui/PriceInput';
|
|
||||||
import SearchableSelect from '../components/ui/SearchableSelect';
|
import SearchableSelect from '../components/ui/SearchableSelect';
|
||||||
import { WalletChargeLink } from '../components/AppointmentActions';
|
|
||||||
import { formatRial, rialToToman, tomanToRial } from '../lib/utils';
|
import { formatRial, rialToToman, tomanToRial } from '../lib/utils';
|
||||||
import { DEFAULT_SERVICE_CATEGORY } from '../lib/insuranceShares';
|
import { DEFAULT_SERVICE_CATEGORY } from '../lib/insuranceShares';
|
||||||
import BackButton from '../components/ui/BackButton';
|
import BackButton from '../components/ui/BackButton';
|
||||||
@@ -16,7 +14,6 @@ import { useAppointmentInsurance } from '../hooks/useAppointmentInsurance';
|
|||||||
import { useDoctorBookingServices } from '../hooks/useDoctorBookingServices';
|
import { useDoctorBookingServices } from '../hooks/useDoctorBookingServices';
|
||||||
import ServiceSlotPicker from '../components/appointments/ServiceSlotPicker';
|
import ServiceSlotPicker from '../components/appointments/ServiceSlotPicker';
|
||||||
import type { PickedService, ServicePick } 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 }
|
interface Option { uuid: string; name?: string; full_name?: string }
|
||||||
|
|
||||||
@@ -357,24 +354,12 @@ export default function AppointmentEditPage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div style={{ fontSize: 14, fontWeight: 700, marginBottom: 12 }}>بیعانه:</div>
|
{/*
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: 16, flexWrap: 'wrap', marginBottom: 18 }}>
|
بخش «بیعانه» فعلاً از فرم برداشته شد (درخواست ۲۰۲۶-۰۸-۰۸).
|
||||||
<Switch
|
state و ارسالش عمداً سر جایشان ماندند: مقدارِ فعلیِ نوبت هنگام هیدریت
|
||||||
inline
|
خوانده و همان هنگام ذخیره پس فرستاده میشود، پس ویرایشِ بقیهٔ فیلدها
|
||||||
checked={depositRequired}
|
بیعانهٔ ثبتشده را پاک نمیکند. برگرداندنِ UI یعنی همین بلوک، نه بیشتر.
|
||||||
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>
|
|
||||||
|
|
||||||
<div style={{ maxWidth: 320, marginBottom: 18 }}>
|
<div style={{ maxWidth: 320, marginBottom: 18 }}>
|
||||||
<label style={label}>انتخاب وضعیت</label>
|
<label style={label}>انتخاب وضعیت</label>
|
||||||
|
|||||||
@@ -390,6 +390,13 @@ Get appointment detail.
|
|||||||
|
|
||||||
**Permission:** `AUTH` — see [Single-appointment access model](#single-appointment-access-model)
|
**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
|
### Path Parameters
|
||||||
| Param | Type | Description |
|
| Param | Type | Description |
|
||||||
|-------|------|-------------|
|
|-------|------|-------------|
|
||||||
|
|||||||
@@ -704,7 +704,27 @@ class AppointmentController extends BaseController
|
|||||||
return $this->error(ErrorCodes::ERR_ACCESS_DENIED, 'دسترسی ممنوع', 403);
|
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(
|
#[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());
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user