feat(treatment): bind an appointment to a chosen session, and free it on cancel
Two holes in how a course's later appointments were made.
The link from the unbooked queue carried nothing — `/admin/appointments/new`
with no parameters — so the secretary retyped the patient and the service, and
which case the appointment joined was inferred from the service they happened to
pick. A patient with two open courses had no way to say which one they meant,
and picking the wrong service silently opened a third case. (The suggestion link
did pass slot_start and resource_uuid, but the create page never read either.)
POST /api/v1/my/appointment now takes an optional treatment_session_uuid.
SessionBookingLink validates it — same tenant, still unbooked, case open, same
patient — and reserves that session. Confirm-time attachment steps aside when
the appointment already holds a session. The booking form states in words which
session, which course and which patient it is about to book, read from a new
GET /api/v1/treatment-session/{uuid}.
Nothing ever detached a session from its appointment, so a cancelled booking
left the session `booked` forever, and since findNextUnbooked requires
"has no appointment", it could never return to the queue. Cancellation and
no-show now release it back to `planned`. A finished session is history and is
left alone.
The system still never books the next appointment by itself — it only suggests.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -19,7 +19,7 @@ import ServiceSlotPicker from '../components/appointments/ServiceSlotPicker';
|
||||
import SlotPicker, { type PickedSlot } from '../components/appointments/SlotPicker';
|
||||
import { tehranWallClockToUnix, rialToToman, tomanToRial } from '../lib/utils';
|
||||
import BackButton from '../components/ui/BackButton';
|
||||
import { digitsOnly, todayIso } from '../lib/utils';
|
||||
import { digitsOnly, todayIso, formatNumber } from '../lib/utils';
|
||||
import Switch from '../components/ui/Switch';
|
||||
|
||||
/**
|
||||
@@ -104,6 +104,26 @@ export default function AppointmentCreatePage() {
|
||||
// ورود از تب «نوبتها»ی پروندهٔ بیمار: مراجعهکننده از قبل معلوم است، پس بهجای
|
||||
// جستجو، خودِ پرونده خوانده و قفل میشود.
|
||||
const fromRecordUuid = params.get('record') ?? '';
|
||||
|
||||
/**
|
||||
* «ثبت نوبت این جلسه» — بیمار میتواند چند دورهٔ باز داشته باشد، پس فرم باید بگوید
|
||||
* این نوبت به کدام جلسه میچسبد و همان را به سرور بفرستد. بدونش اتصال به حدسِ
|
||||
* سرویس سپرده میشود و سرویسِ اشتباه یک پروندهٔ موازی میسازد.
|
||||
*/
|
||||
const targetSessionUuid = params.get('session') ?? '';
|
||||
const targetSessionQ = useQuery<ApiResponse<{
|
||||
uuid: string;
|
||||
session_number: number;
|
||||
total_sessions: number;
|
||||
case_uuid: string;
|
||||
service: { uuid: string; name: string };
|
||||
patient: { record_uuid: string; name: string | null };
|
||||
}>>({
|
||||
queryKey: ['create-appt-session', targetSessionUuid],
|
||||
queryFn: () => api.get(`/api/v1/treatment-session/${targetSessionUuid}`),
|
||||
enabled: !!targetSessionUuid,
|
||||
});
|
||||
const targetSession = targetSessionQ.data?.data;
|
||||
const recordQ = useQuery<ApiResponse<PatientRow & { user_name?: string }>>({
|
||||
queryKey: ['create-appt-record', fromRecordUuid],
|
||||
queryFn: () => api.get(`/api/v1/patient/${fromRecordUuid}`),
|
||||
@@ -208,6 +228,7 @@ export default function AppointmentCreatePage() {
|
||||
// و در حالت منبع، منبعِ کلینیک اصلاً «متعلق به این محیط» شناخته نمیشود (۴۲۲).
|
||||
...(clinicUuid ? { clinic_uuid: clinicUuid } : {}),
|
||||
...(resourceMode ? { resource_uuid: activeResource!.uuid } : {}),
|
||||
...(targetSessionUuid ? { treatment_session_uuid: targetSessionUuid } : {}),
|
||||
...(serviceMode
|
||||
? { service_item_uuids: servicePick.serviceUuids, duration_from_services: true, service_durations: servicePick.durations }
|
||||
: {
|
||||
@@ -255,6 +276,38 @@ export default function AppointmentCreatePage() {
|
||||
<span style={{ fontSize: 15, fontWeight: 700, color: 'var(--text)' }}>ثبت نوبت جدید</span>
|
||||
</div>
|
||||
|
||||
{/* فرم باید صریح بگوید این نوبت برای کدام دوره است؛ بیمارِ چنددورهای بدون این،
|
||||
اتصال را به حدسِ سرویس میسپارد. */}
|
||||
{targetSessionUuid !== '' && (
|
||||
<div style={{
|
||||
marginBottom: 16, padding: '12px 16px', borderRadius: 'var(--r-sm)',
|
||||
background: 'var(--primary-soft)', display: 'flex', gap: 14, flexWrap: 'wrap', fontSize: 13,
|
||||
}}>
|
||||
{targetSessionQ.isLoading ? (
|
||||
<span style={{ color: 'var(--text-3)' }}>در حال خواندن جلسهٔ درمان…</span>
|
||||
) : targetSession ? (
|
||||
<>
|
||||
<span>
|
||||
<span style={{ color: 'var(--text-3)' }}>این نوبت برای: </span>
|
||||
<b>جلسهٔ {formatNumber(targetSession.session_number)} از {formatNumber(targetSession.total_sessions)}</b>
|
||||
</span>
|
||||
<span>
|
||||
<span style={{ color: 'var(--text-3)' }}>دوره: </span>
|
||||
<b>{targetSession.service.name}</b>
|
||||
</span>
|
||||
<span>
|
||||
<span style={{ color: 'var(--text-3)' }}>بیمار: </span>
|
||||
<b>{targetSession.patient.name || 'بدون نام'}</b>
|
||||
</span>
|
||||
</>
|
||||
) : (
|
||||
<span style={{ color: 'var(--danger)' }}>
|
||||
جلسهٔ درمان یافت نشد — نوبت بدون اتصال به دوره ثبت میشود.
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div style={{ background: 'var(--surface)', border: '1px solid var(--border-2)', borderRadius: 'var(--r-sm)', padding: 28 }}>
|
||||
{/* پزشک — فقط برای admin/clinic */}
|
||||
{!isDoctor && (
|
||||
|
||||
@@ -452,7 +452,11 @@ function SlotSuggestions({ sessionUuid }: { sessionUuid: string }) {
|
||||
<button type="button" className="btn secondary sm" onClick={() => setOpen(true)}>
|
||||
پیشنهاد وقت
|
||||
</button>
|
||||
<Link to="/admin/appointments/new" className="btn primary sm">ثبت نوبت این جلسه</Link>
|
||||
{/* لینک باید جلسه را ببرد، وگرنه منشی بیمار و سرویس را دستی میزند و اتصال
|
||||
به حدسِ سرویس سپرده میشود. */}
|
||||
<Link to={`/admin/appointments/new?session=${sessionUuid}`} className="btn primary sm">
|
||||
ثبت نوبت این جلسه
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -483,7 +487,7 @@ function SlotSuggestions({ sessionUuid }: { sessionUuid: string }) {
|
||||
{day.slots.slice(0, 6).map((slot) => (
|
||||
<Link
|
||||
key={slot.start}
|
||||
to={`/admin/appointments/new?slot_start=${slot.start}&resource_uuid=${data?.data?.resource_uuid ?? ''}`}
|
||||
to={`/admin/appointments/new?session=${sessionUuid}&date=${day.date}`}
|
||||
className="btn secondary sm"
|
||||
title={formatDateTime(slot.start)}
|
||||
>
|
||||
|
||||
@@ -281,6 +281,41 @@ single-session again. Idempotent: deleting a service that has no protocol still
|
||||
|
||||
---
|
||||
|
||||
## GET `/api/v1/treatment-session/{uuid}`
|
||||
|
||||
یک جلسه بهتنهایی، بهعلاوهٔ `case_uuid`، `service` و `patient` — برای فرمِ «ثبت نوبت این
|
||||
جلسه». جلسهٔ محیط دیگر `404` میگیرد.
|
||||
|
||||
---
|
||||
|
||||
## بستنِ نوبت به یک جلسهٔ مشخص
|
||||
|
||||
یک بیمار میتواند چند دورهٔ باز داشته باشد. تا پیش از این، اتصال **ضمنی** بود: هنگام
|
||||
قطعیشدن، از روی سرویسِ نوبت پروندهٔ باز پیدا میشد و نوبت به اولین جلسهٔ بدوننوبتِ آن
|
||||
میچسبید. انتخابِ سرویسِ اشتباه بیصدا یک پروندهٔ موازی میساخت.
|
||||
|
||||
`POST /api/v1/my/appointment` حالا `treatment_session_uuid` اختیاری میگیرد:
|
||||
|
||||
| کد | HTTP | شرط |
|
||||
|---|---|---|
|
||||
| ERR_NOT_FOUND_001 | 404 | جلسه یافت نشد یا مال محیط دیگری است |
|
||||
| ERR_CONFLICT_001 | 409 | جلسه از قبل نوبت دارد |
|
||||
| ERR_CONFLICT_001 | 409 | پروندهٔ جلسه باز نیست |
|
||||
| ERR_CONFLICT_001 | 409 | جلسه مالِ بیمار دیگری است |
|
||||
|
||||
وقتی فرستاده شود، همان جلسه رزرو میشود و منطقِ «اولین جلسهٔ بدون نوبت» هنگام تأیید
|
||||
کنار میرود. نبودنش یعنی همان رفتار قبلی.
|
||||
|
||||
### آزاد شدن جلسه
|
||||
|
||||
`cancelled_by_user` · `cancelled_by_doctor` · `no_show` جلسه را از نوبت جدا میکنند و به
|
||||
`planned` برمیگردانند، پس دوباره در «جلسات بدون نوبت» دیده میشود. غیبت در
|
||||
`CANCEL_STATUSES` نیست ولی برای دوره فرقی ندارد — سابقهٔ غیبت روی خودِ نوبت میماند.
|
||||
|
||||
جلسهٔ `done` استثناست: سابقه است و آزاد نمیشود.
|
||||
|
||||
---
|
||||
|
||||
## GET `/api/v1/treatment-sessions/unbooked`
|
||||
|
||||
صفِ «جلسات بدون نوبت» — جلسهای که سررسیدش رسیده و کسی رزروش نکرده.
|
||||
|
||||
@@ -50,6 +50,7 @@ class AppointmentController extends BaseController
|
||||
private readonly \App\Appointment\Service\ServiceBookingCalculator $serviceCalculator,
|
||||
private readonly \App\Appointment\Service\ServiceRescheduleService $rescheduleService,
|
||||
private readonly \App\Appointment\Availability\Service\ResourceOccupier $occupier,
|
||||
private readonly \App\Treatment\Service\SessionBookingLink $sessionLink,
|
||||
private readonly \Psr\Log\LoggerInterface $logger,
|
||||
) {}
|
||||
|
||||
@@ -1067,6 +1068,18 @@ class AppointmentController extends BaseController
|
||||
if ($appointment->getResource() !== null) {
|
||||
$this->occupier->releaseForAppointment((int) $appointment->getId());
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* جلسهٔ درمان با لغو **و** غیبت آزاد میشود.
|
||||
*
|
||||
* غیبت در `CANCEL_STATUSES` نیست چون نوبت را لغو نمیکند، ولی برای دوره فرقی
|
||||
* ندارد: بیمار نیامده و آن جلسه باید دوباره قابل رزرو باشد. سابقهٔ غیبت روی
|
||||
* خودِ نوبت میماند، پس چیزی از دست نمیرود.
|
||||
*/
|
||||
if (in_array($newStatus, [...self::CANCEL_STATUSES, Appointment::STATUS_NO_SHOW], true)) {
|
||||
$this->sessionLink->release($appointment);
|
||||
}
|
||||
|
||||
if ($newStatus === Appointment::STATUS_COMPLETED) {
|
||||
|
||||
@@ -50,6 +50,7 @@ class MyAppointmentsController extends BaseController
|
||||
private readonly \App\Resource\Repository\ClinicResourceRepository $resourceRepo,
|
||||
private readonly \App\Resource\Service\ResourceBookingSlotService $resourceSlots,
|
||||
private readonly \App\Appointment\Availability\Service\ResourceOccupier $occupier,
|
||||
private readonly \App\Treatment\Service\SessionBookingLink $sessionLink,
|
||||
) {}
|
||||
|
||||
/**
|
||||
@@ -355,6 +356,20 @@ class MyAppointmentsController extends BaseController
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* جلسهٔ درمانِ صریح.
|
||||
*
|
||||
* یک بیمار میتواند چند دورهٔ باز داشته باشد؛ بدون این، اتصال از روی سرویس
|
||||
* حدس زده میشود و سرویسِ اشتباه بیصدا یک پروندهٔ موازی میسازد. بعد از
|
||||
* `bookAtomically` میآید چون شناسهٔ نوبت لازم است.
|
||||
*/
|
||||
$sessionUuid = trim((string) ($data['treatment_session_uuid'] ?? ''));
|
||||
|
||||
if ($sessionUuid !== '') {
|
||||
$this->sessionLink->bind($appointment, $sessionUuid, ...$this->branches->pair($user));
|
||||
$this->appointmentRepo->save($appointment);
|
||||
}
|
||||
|
||||
return $this->success([
|
||||
'uuid' => $appointment->getUuid(),
|
||||
'slot_start' => $slotStart,
|
||||
|
||||
@@ -140,6 +140,28 @@ class TreatmentCaseController extends BaseController
|
||||
));
|
||||
}
|
||||
|
||||
/**
|
||||
* یک جلسه بهتنهایی — برای فرمِ «ثبت نوبت این جلسه».
|
||||
*
|
||||
* فرم باید بگوید نوبت برای کدام دوره و کدام بیمار ثبت میشود؛ بیمارِ چنددورهای
|
||||
* بدون این، اتصال را به حدسِ سرویس میسپارد.
|
||||
*/
|
||||
#[Route('/api/v1/treatment-session/{uuid}', name: 'treatment_session_show', methods: ['GET'])]
|
||||
public function showSession(#[CurrentUser] User $user, string $uuid): JsonResponse
|
||||
{
|
||||
$session = $this->requireSession($user, $uuid);
|
||||
$case = $session->getTreatmentCase();
|
||||
|
||||
return $this->success($session->toArray() + [
|
||||
'case_uuid' => $case->getUuid(),
|
||||
'service' => ['uuid' => $case->getServiceItem()->getUuid(), 'name' => $case->getServiceItem()->getName()],
|
||||
'patient' => [
|
||||
'record_uuid' => $case->getPatientRecord()->getUuid(),
|
||||
'name' => $case->getPatientRecord()->getUser()->getRealName(),
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* اسلاتهای پیشنهادی برای این جلسه.
|
||||
*
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
<?php
|
||||
|
||||
namespace App\Treatment\Service;
|
||||
|
||||
use App\Appointment\Entity\Appointment;
|
||||
use App\Shared\Constant\ErrorCodes;
|
||||
use App\Shared\Exception\AppException;
|
||||
use App\Treatment\Entity\TreatmentCase;
|
||||
use App\Treatment\Entity\TreatmentSession;
|
||||
use App\Treatment\Repository\TreatmentSessionRepository;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
|
||||
/**
|
||||
* پیوندِ «این نوبت مالِ کدام جلسهٔ درمان است».
|
||||
*
|
||||
* یک بیمار میتواند چند دورهٔ باز داشته باشد. تا امروز اتصال **ضمنی** بود — از روی
|
||||
* سرویسِ نوبت، پروندهٔ باز پیدا میشد — و انتخابِ سرویسِ اشتباه بیسروصدا یک پروندهٔ
|
||||
* موازی میساخت. حالا منشی میتواند صریح بگوید کدام جلسه، و همان جلسه رزرو میشود.
|
||||
*
|
||||
* جدا شدن هم اینجاست: نوبتِ لغوشده یا غیبتخورده باید جلسه را آزاد کند، وگرنه جلسه تا
|
||||
* ابد `booked` میماند و چون `findNextUnbooked` شرطش «نوبت ندارد» است، هرگز به صف
|
||||
* برنمیگردد.
|
||||
*/
|
||||
final class SessionBookingLink
|
||||
{
|
||||
public function __construct(
|
||||
private readonly TreatmentSessionRepository $sessions,
|
||||
private readonly EntityManagerInterface $em,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* رزروِ صریحِ یک جلسه با این نوبت.
|
||||
*
|
||||
* فقط اعتبارسنجی و اتصال؛ فراخوان مسئول flush است تا با تراکنشِ ساختِ نوبت یکی بماند.
|
||||
*/
|
||||
public function bind(Appointment $appointment, string $sessionUuid, string $entityType, int $entityId): TreatmentSession
|
||||
{
|
||||
$session = $this->sessions->findByUuid($sessionUuid);
|
||||
|
||||
if ($session === null
|
||||
|| $session->getEntityType() !== $entityType
|
||||
|| $session->getEntityId() !== $entityId
|
||||
) {
|
||||
throw new AppException(
|
||||
ErrorCodes::ERR_NOT_FOUND_001,
|
||||
'جلسهٔ درمان یافت نشد',
|
||||
404,
|
||||
'treatment_session_uuid',
|
||||
);
|
||||
}
|
||||
|
||||
if ($session->getAppointment() !== null) {
|
||||
throw new AppException(
|
||||
ErrorCodes::ERR_CONFLICT_001,
|
||||
'این جلسه از قبل نوبت دارد',
|
||||
409,
|
||||
'treatment_session_uuid',
|
||||
);
|
||||
}
|
||||
|
||||
if ($session->getTreatmentCase()->getStatus() !== TreatmentCase::STATUS_ACTIVE) {
|
||||
throw new AppException(
|
||||
ErrorCodes::ERR_CONFLICT_001,
|
||||
'پروندهٔ این جلسه باز نیست',
|
||||
409,
|
||||
'treatment_session_uuid',
|
||||
);
|
||||
}
|
||||
|
||||
// بیمارِ نوبت باید همان بیمارِ پرونده باشد، وگرنه جلسهٔ یک نفر به نوبت
|
||||
// دیگری وصل میشود و سابقهٔ درمان دروغ میگوید.
|
||||
$recordUser = $session->getTreatmentCase()->getPatientRecord()->getUser();
|
||||
|
||||
if ($recordUser->getId() !== $appointment->getUser()->getId()) {
|
||||
throw new AppException(
|
||||
ErrorCodes::ERR_CONFLICT_001,
|
||||
'این جلسه مالِ بیمار دیگری است',
|
||||
409,
|
||||
'treatment_session_uuid',
|
||||
);
|
||||
}
|
||||
|
||||
$session->attachAppointment($appointment);
|
||||
$session->setDueAt($appointment->getSlotStart());
|
||||
|
||||
return $session;
|
||||
}
|
||||
|
||||
/**
|
||||
* آزاد کردن جلسهای که نوبتش لغو شد یا بیمار نیامد.
|
||||
*
|
||||
* غیبت و لغو یک رفتار دارند: جلسه دوباره قابل رزرو میشود. سابقهٔ غیبت روی خودِ
|
||||
* نوبت میماند، پس چیزی از دست نمیرود.
|
||||
*/
|
||||
public function release(Appointment $appointment): ?TreatmentSession
|
||||
{
|
||||
$session = $this->sessions->findOneBy(['appointment' => $appointment]);
|
||||
|
||||
if ($session === null || $session->getStatus() === TreatmentSession::STATUS_DONE) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$session->attachAppointment(null);
|
||||
$this->em->flush();
|
||||
|
||||
return $session;
|
||||
}
|
||||
}
|
||||
@@ -69,9 +69,18 @@ class DefaultTreatmentWorkflow implements TreatmentWorkflow
|
||||
$this->em->flush();
|
||||
}
|
||||
|
||||
/** نوبت به اولین جلسهٔ بدونِ نوبت وصل میشود، نه لزوماً به جلسهٔ اول. */
|
||||
/**
|
||||
* نوبت به اولین جلسهٔ بدونِ نوبت وصل میشود، نه لزوماً به جلسهٔ اول.
|
||||
*
|
||||
* مگر اینکه منشی هنگام ثبت، جلسه را صریح انتخاب کرده باشد؛ آنوقت نوبت از قبل
|
||||
* وصل است و حدسِ «اولین بدون نوبت» فقط جلسهٔ دیگری را اشتباهی رزرو میکند.
|
||||
*/
|
||||
protected function attachToNextOpenSession(TreatmentCase $case, Appointment $appointment): void
|
||||
{
|
||||
if ($this->sessions->findOneBy(['appointment' => $appointment]) !== null) {
|
||||
return;
|
||||
}
|
||||
|
||||
$session = $this->sessions->findNextUnbooked($case);
|
||||
|
||||
if ($session === null) {
|
||||
|
||||
@@ -179,6 +179,51 @@ class OpenCaseOnConfirmTest extends ApiTestCase
|
||||
self::assertNull($sessions[1]->getDueAt());
|
||||
}
|
||||
|
||||
/**
|
||||
* نوبتِ لغوشده باید جلسه را آزاد کند.
|
||||
*
|
||||
* بدون این، جلسه تا ابد `booked` میماند و چون `findNextUnbooked` شرطش «نوبت
|
||||
* ندارد» است، هرگز به صفِ «جلسات بدون نوبت» برنمیگردد.
|
||||
*/
|
||||
public function testCancellingAnAppointmentReleasesItsSession(): void
|
||||
{
|
||||
[$appointment, $service] = $this->scenario(withProtocol: true);
|
||||
|
||||
$this->confirmation()->onConfirmed($appointment);
|
||||
|
||||
$sessions = $this->casesFor($service)[0]->getSessions()->toArray();
|
||||
usort($sessions, static fn (TreatmentSession $a, TreatmentSession $b): int
|
||||
=> $a->getSessionNumber() <=> $b->getSessionNumber());
|
||||
|
||||
self::assertSame(TreatmentSession::STATUS_BOOKED, $sessions[0]->getStatus());
|
||||
|
||||
static::getContainer()->get(\App\Treatment\Service\SessionBookingLink::class)->release($appointment);
|
||||
|
||||
self::assertNull($sessions[0]->getAppointment());
|
||||
self::assertSame(TreatmentSession::STATUS_PLANNED, $sessions[0]->getStatus());
|
||||
}
|
||||
|
||||
/** جلسهٔ انجامشده سابقه است؛ لغوِ بعدیِ نوبت نباید آن را باز کند. */
|
||||
public function testReleasingDoesNotTouchAFinishedSession(): void
|
||||
{
|
||||
[$appointment, $service] = $this->scenario(withProtocol: true);
|
||||
|
||||
$this->confirmation()->onConfirmed($appointment);
|
||||
|
||||
$sessions = $this->casesFor($service)[0]->getSessions()->toArray();
|
||||
usort($sessions, static fn (TreatmentSession $a, TreatmentSession $b): int
|
||||
=> $a->getSessionNumber() <=> $b->getSessionNumber());
|
||||
|
||||
$sessions[0]->start();
|
||||
$sessions[0]->finish();
|
||||
$this->em->flush();
|
||||
|
||||
static::getContainer()->get(\App\Treatment\Service\SessionBookingLink::class)->release($appointment);
|
||||
|
||||
self::assertNotNull($sessions[0]->getAppointment());
|
||||
self::assertSame(TreatmentSession::STATUS_DONE, $sessions[0]->getStatus());
|
||||
}
|
||||
|
||||
/** بیمار وسط دوره نوبت دیگری میگیرد — باید جلسهٔ همان دوره باشد نه دورهٔ موازی. */
|
||||
public function testASecondAppointmentDoesNotOpenASecondCase(): void
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user