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();
|
||||
});
|
||||
|
||||
// بخش و سرویس هر دو باید برچسب واقعی نشان دهند، نه 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,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>
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user