feat: support multiple services per appointment (checkbox selection)
Appointments could only reference a single service (ManyToOne). Add an appointment_service_items join table (ManyToMany) so an appointment can carry several services; the first stays the primary service_item for backward compatibility, and toArray now also returns service_items[]. Both create endpoints (my/appointment, admin/appointment) accept service_item_uuids[] and attach all of them. A new duration_from_services flag gates the slot_end recompute: service-booking mode sends it true (slot_end = start + Σ durations); slot mode omits it so the manual end time is preserved. The admin endpoint previously ignored services entirely. Frontend: in slot mode the single service dropdown becomes a checkbox list filtered by the selected section (multi-select); service mode sends the duration flag. Migration + backend/entity tests + docs updated. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -69,8 +69,10 @@ export default function AppointmentCreatePage() {
|
||||
|
||||
// ── مشخصات سرویس
|
||||
const [sectionUuid, setSectionUuid] = useState('');
|
||||
const [itemUuid, setItemUuid] = useState('');
|
||||
const [serviceItemUuids, setServiceItemUuids] = useState<string[]>([]); // چند سرویس در حالت اسلاتی
|
||||
const [staffUuid, setStaffUuid] = useState('');
|
||||
const toggleServiceItem = (uuid: string) =>
|
||||
setServiceItemUuids(prev => prev.includes(uuid) ? prev.filter(u => u !== uuid) : [...prev, uuid]);
|
||||
const sectionsQ = useQuery<ApiResponse<Option[]>>({ queryKey: ['service-sections'], queryFn: () => api.get('/api/v1/service-sections') });
|
||||
const itemsQ = useQuery<ApiResponse<Option[]>>({
|
||||
queryKey: ['service-items', sectionUuid],
|
||||
@@ -117,10 +119,10 @@ export default function AppointmentCreatePage() {
|
||||
patient_mobile: effectiveMobile,
|
||||
patient_national_code: effectiveNationalCode,
|
||||
...(serviceMode
|
||||
? { service_item_uuids: servicePick.serviceUuids }
|
||||
? { service_item_uuids: servicePick.serviceUuids, duration_from_services: true }
|
||||
: {
|
||||
...(sectionUuid ? { service_section_uuid: sectionUuid } : {}),
|
||||
...(itemUuid ? { service_item_uuid: itemUuid } : {}),
|
||||
...(serviceItemUuids.length ? { service_item_uuids: serviceItemUuids } : {}),
|
||||
}),
|
||||
...(staffUuid ? { staff_uuid: staffUuid } : {}),
|
||||
...(depositRequired ? { deposit_required: true, deposit_amount_rials: depositRials } : {}),
|
||||
@@ -273,51 +275,72 @@ export default function AppointmentCreatePage() {
|
||||
{/* مشخصات سرویس */}
|
||||
<div style={sectionTitle}>مشخصات سرویس</div>
|
||||
{!serviceMode ? (
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(3, 1fr)', gap: 16, marginBottom: 10 }}>
|
||||
<div>
|
||||
<label style={label}>بخش</label>
|
||||
<div style={{ marginTop: 6 }}>
|
||||
<SearchableSelect
|
||||
options={(sectionsQ.data?.data ?? []).map(o => ({ value: o.uuid, label: o.name ?? '' }))}
|
||||
value={sectionUuid || null}
|
||||
onChange={v => { setSectionUuid(v ? String(v) : ''); setItemUuid(''); }}
|
||||
placeholder="انتخاب بخش"
|
||||
isLoading={sectionsQ.isLoading}
|
||||
isClearable
|
||||
height={44}
|
||||
/>
|
||||
<>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(2, 1fr)', gap: 16, marginBottom: 10 }}>
|
||||
<div>
|
||||
<label style={label}>بخش</label>
|
||||
<div style={{ marginTop: 6 }}>
|
||||
<SearchableSelect
|
||||
options={(sectionsQ.data?.data ?? []).map(o => ({ value: o.uuid, label: o.name ?? '' }))}
|
||||
value={sectionUuid || null}
|
||||
onChange={v => { setSectionUuid(v ? String(v) : ''); setServiceItemUuids([]); }}
|
||||
placeholder="انتخاب بخش"
|
||||
isLoading={sectionsQ.isLoading}
|
||||
isClearable
|
||||
height={44}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label style={label}>پرسنل</label>
|
||||
<div style={{ marginTop: 6 }}>
|
||||
<SearchableSelect
|
||||
options={(staffQ.data?.data ?? []).map(o => ({ value: o.uuid, label: o.full_name ?? '' }))}
|
||||
value={staffUuid || null}
|
||||
onChange={v => setStaffUuid(v ? String(v) : '')}
|
||||
placeholder="انتخاب پرسنل"
|
||||
isLoading={staffQ.isLoading}
|
||||
isClearable
|
||||
height={44}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label style={label}>سرویس</label>
|
||||
<div style={{ marginTop: 6 }}>
|
||||
<SearchableSelect
|
||||
options={(itemsQ.data?.data ?? []).map(o => ({ value: o.uuid, label: o.name ?? '' }))}
|
||||
value={itemUuid || null}
|
||||
onChange={v => setItemUuid(v ? String(v) : '')}
|
||||
placeholder="انتخاب سرویس"
|
||||
isDisabled={!sectionUuid}
|
||||
isLoading={itemsQ.isLoading}
|
||||
isClearable
|
||||
height={44}
|
||||
/>
|
||||
|
||||
{/* سرویسها — چند انتخابی، بر اساس بخشِ انتخابشده */}
|
||||
<label style={label}>سرویس (یک یا چند)</label>
|
||||
{!sectionUuid ? (
|
||||
<div style={{ fontSize: 12.5, color: 'var(--text-3)', margin: '6px 0 10px' }}>ابتدا بخش را انتخاب کنید.</div>
|
||||
) : itemsQ.isLoading ? (
|
||||
<div style={{ fontSize: 12.5, color: 'var(--text-3)', margin: '6px 0 10px' }}>در حال بارگذاری...</div>
|
||||
) : (itemsQ.data?.data ?? []).length === 0 ? (
|
||||
<div style={{ fontSize: 12.5, color: 'var(--text-3)', margin: '6px 0 10px' }}>سرویسی در این بخش تعریف نشده است.</div>
|
||||
) : (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 6, margin: '6px 0 12px' }}>
|
||||
{(itemsQ.data?.data ?? []).map(o => {
|
||||
const active = serviceItemUuids.includes(o.uuid);
|
||||
return (
|
||||
<button key={o.uuid} type="button" onClick={() => toggleServiceItem(o.uuid)}
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', gap: 8, padding: '9px 12px', borderRadius: 'var(--r-sm)',
|
||||
cursor: 'pointer', textAlign: 'right', fontFamily: 'inherit', fontSize: 13,
|
||||
border: active ? '1px solid var(--primary)' : '1px solid var(--border)',
|
||||
background: active ? 'var(--primary-soft)' : 'var(--surface)',
|
||||
}}>
|
||||
<span style={{
|
||||
width: 16, height: 16, borderRadius: 4, display: 'grid', placeItems: 'center', flexShrink: 0,
|
||||
border: active ? '1px solid var(--primary)' : '1px solid var(--border-2)',
|
||||
background: active ? 'var(--primary)' : 'transparent',
|
||||
}}>
|
||||
{active && <span style={{ width: 8, height: 8, background: '#fff', borderRadius: 2 }} />}
|
||||
</span>
|
||||
{o.name}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label style={label}>پرسنل</label>
|
||||
<div style={{ marginTop: 6 }}>
|
||||
<SearchableSelect
|
||||
options={(staffQ.data?.data ?? []).map(o => ({ value: o.uuid, label: o.full_name ?? '' }))}
|
||||
value={staffUuid || null}
|
||||
onChange={v => setStaffUuid(v ? String(v) : '')}
|
||||
placeholder="انتخاب پرسنل"
|
||||
isLoading={staffQ.isLoading}
|
||||
isClearable
|
||||
height={44}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<div style={{ maxWidth: 400, marginBottom: 10 }}>
|
||||
<label style={label}>پرسنل</label>
|
||||
|
||||
@@ -599,11 +599,15 @@ Create a new appointment for a patient. If no user exists with the given mobile,
|
||||
"patient_mobile": "09123456789",
|
||||
"patient_name": "علی محمدی",
|
||||
"patient_national_code": "0012345678",
|
||||
"service_item_uuids": ["service-uuid-1", "service-uuid-2"],
|
||||
"duration_from_services": false,
|
||||
"note": "optional note"
|
||||
}
|
||||
```
|
||||
|
||||
> `patient_mobile`، `patient_name` و `patient_national_code` هر سه اجباری هستند. کد ملی باید ۱۰ رقم معتبر باشد و روی **پروفایل** بیمار ذخیره میشود (`profiles.national_code`، یکتا). بیمار **اول با کد ملیِ پروفایل** و سپس با موبایل resolve میشود، تا پرونده برای هر کد ملی یکتا بماند (یک شخص میتواند چند موبایل داشته باشد). اگر بیماری یافت نشود، کاربر جدید (`ROLE_USER`) بههمراه پروفایلِ حاملِ همان کد ملی ساخته میشود.
|
||||
>
|
||||
> `service_item_uuids[]` (اختیاری): یک یا چند سرویس که به نوبت پیوست میشوند؛ اولین سرویس = سرویسِ اصلی و همه در `service_items` پاسخ برمیگردند. UUID ناموجود ⇒ `422`. با `duration_from_services: true` مدت نوبت از مجموع `duration_minutes` سرویسها محاسبه و `slot_end` بازنویسی میشود (سرویسِ غیرbookable/بدون مدت ⇒ `422`)؛ بدون آن ساعت پایانِ دستی حفظ میماند.
|
||||
|
||||
### Response `201`
|
||||
```json
|
||||
|
||||
@@ -206,8 +206,9 @@ Book an appointment slot.
|
||||
|-------|------|----------|-------------|
|
||||
| `doctor_uuid` | string (UUID) | ✅ | Doctor UUID |
|
||||
| `slot_start` | integer | ✅ | Slot start (Unix timestamp) |
|
||||
| `slot_end` | integer | ⚠️ | Slot end (Unix timestamp). در حالت سرویسی که `service_item_uuids` ارسال شود، سرور آن را از `slot_start + Σ duration_minutes` بازمحاسبه میکند و مقدار کلاینت نادیده گرفته میشود |
|
||||
| `service_item_uuids` | string[] | ❌ | حالت نوبتدهی سرویسی: یک/چند UUID سرویسِ `bookable`. مدت نوبت = مجموع `duration_minutes` آنها؛ اولین سرویس روی نوبت ثبت میشود. سرویسِ غیرbookable یا بدون مدت ⇒ `422` |
|
||||
| `slot_end` | integer | ⚠️ | Slot end (Unix timestamp). فقط وقتی `duration_from_services=true` باشد سرور آن را از `slot_start + Σ duration_minutes` بازمحاسبه میکند؛ در غیر این صورت مقدار کلاینت حفظ میشود |
|
||||
| `service_item_uuids` | string[] | ❌ | یک یا چند UUID سرویس که به نوبت **پیوست** میشوند (چند سرویس). اولین سرویس بهعنوان سرویسِ اصلی (`service_item`) ثبت و همه در `service_items` برمیگردند. UUID ناموجود ⇒ `422` |
|
||||
| `duration_from_services` | boolean | ❌ | `true` = حالت نوبتدهی سرویسی: مدت نوبت از مجموع `duration_minutes` سرویسها محاسبه و `slot_end` بازنویسی میشود؛ در این حالت سرویسِ غیرbookable یا بدون مدت ⇒ `422`. پیشفرض `false` (حالت اسلاتی: فقط پیوست، ساعت پایانِ دستی حفظ میشود) |
|
||||
| `for_self` | boolean | ❌ | `true` (default) = patient is the logged-in payer; `false` = booking for someone else |
|
||||
| `patient_name` | string | ⚠️ | Required when `for_self=false`; otherwise filled from the payer's profile |
|
||||
| `patient_mobile` | string | ⚠️ | Required when `for_self=false`; otherwise the payer's mobile |
|
||||
@@ -594,7 +595,7 @@ Role-aware paginated list of appointments. Returns only what the authenticated u
|
||||
|
||||
## Clinic workflow extensions (نوبتها — Figma)
|
||||
|
||||
New optional fields on `Appointment` (all backward-compatible): `service_section` (بخش), `service_item` (سرویس), `staff` (پرسنل), `deposit_required` / `deposit_amount_rials` (بیعانه), `is_reserve` (نوبت رزرو — day-level, never occupies a slot).
|
||||
New optional fields on `Appointment` (all backward-compatible): `service_section` (بخش), `service_item` (سرویسِ اصلی/اول), `service_items` (آرایهٔ همهٔ سرویسهای نوبت — چند سرویس، هر عضو `{uuid, name}`), `staff` (پرسنل), `deposit_required` / `deposit_amount_rials` (بیعانه), `is_reserve` (نوبت رزرو — day-level, never occupies a slot).
|
||||
|
||||
New statuses: `following_up` (در حال پیگیری), `salon` (سالن). Transitions:
|
||||
`pending → confirmed|following_up|cancelled_*|expired` · `confirmed → completed|following_up|salon|cancelled_*|no_show` · `following_up → confirmed|salon|completed|cancelled_*|no_show` · `salon → completed|following_up|cancelled_*|no_show`
|
||||
@@ -628,9 +629,10 @@ Response `200`: `{ success, data: { data: <appointment.toArray()> } }`
|
||||
| 409 | slot taken or version conflict |
|
||||
|
||||
### POST `/api/v1/my/appointment` (extended)
|
||||
Extra optional body fields: `service_section_uuid`, `service_item_uuid`, `staff_uuid`, `deposit_required`, `deposit_amount_rials`, `is_reserve`, `service_item_uuids[]`.
|
||||
Extra optional body fields: `service_section_uuid`, `service_item_uuid`, `staff_uuid`, `deposit_required`, `deposit_amount_rials`, `is_reserve`, `service_item_uuids[]`, `duration_from_services`.
|
||||
`is_reserve: true` → day-level reserve entry: `slot_end` may equal `slot_start`, the past-slot rule is skipped, and the entry never occupies a slot (several reserves may share a day). Response `201` now also returns `is_reserve`.
|
||||
`service_item_uuids[]` (حالت نوبتدهی سرویسی، غیرِ رزرو): یک/چند سرویسِ `bookable`؛ `slot_end` سمت سرور از `slot_start + Σ duration_minutes` محاسبه میشود و اولین سرویس روی نوبت ثبت میگردد. سرویسِ غیرbookable یا بدون مدت ⇒ `422`.
|
||||
`service_item_uuids[]` (غیرِ رزرو): یک یا چند سرویس که به نوبت **پیوست** میشوند (چند سرویس)؛ اولین سرویس = سرویسِ اصلی و همه در `service_items` برمیگردند. UUID ناموجود ⇒ `422`.
|
||||
`duration_from_services: true` (حالت نوبتدهی سرویسی): مدت نوبت از مجموع `duration_minutes` سرویسها محاسبه و `slot_end` بازنویسی میشود؛ در این حالت سرویسِ غیرbookable یا بدون مدت ⇒ `422`. بدون این پرچم (حالت اسلاتی)، ساعت پایانِ دستی حفظ میشود.
|
||||
|
||||
### GET `/api/v1/my/appointments` (extended)
|
||||
New query param `reserve=1` → returns only reserve-list entries; without it only regular slot bookings are returned. Each row now also includes: `patient_uuid`, `is_reserve`, `deposit_required`, `deposit_amount_rials`, `note`, `service_section`, `service_item`, `staff` (each `{uuid, name|full_name}` or null).
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace DoctrineMigrations;
|
||||
|
||||
use Doctrine\DBAL\Schema\Schema;
|
||||
use Doctrine\Migrations\AbstractMigration;
|
||||
|
||||
/**
|
||||
* Auto-generated Migration: Please modify to your needs!
|
||||
*/
|
||||
final class Version20260716063302 extends AbstractMigration
|
||||
{
|
||||
public function getDescription(): string
|
||||
{
|
||||
return 'Add appointment_service_items join table for multi-service appointments';
|
||||
}
|
||||
|
||||
public function up(Schema $schema): void
|
||||
{
|
||||
// this up() migration is auto-generated, please modify it to your needs
|
||||
$this->addSql('CREATE TABLE appointment_service_items (appointment_id INT NOT NULL, service_item_id INT NOT NULL, INDEX IDX_3624BA00E5B533F9 (appointment_id), INDEX IDX_3624BA00DDEB00C2 (service_item_id), PRIMARY KEY (appointment_id, service_item_id)) DEFAULT CHARACTER SET utf8mb4');
|
||||
$this->addSql('ALTER TABLE appointment_service_items ADD CONSTRAINT FK_3624BA00E5B533F9 FOREIGN KEY (appointment_id) REFERENCES appointments (id) ON DELETE CASCADE');
|
||||
$this->addSql('ALTER TABLE appointment_service_items ADD CONSTRAINT FK_3624BA00DDEB00C2 FOREIGN KEY (service_item_id) REFERENCES service_items (id) ON DELETE CASCADE');
|
||||
}
|
||||
|
||||
public function down(Schema $schema): void
|
||||
{
|
||||
// this down() migration is auto-generated, please modify it to your needs
|
||||
$this->addSql('ALTER TABLE appointment_service_items DROP FOREIGN KEY FK_3624BA00E5B533F9');
|
||||
$this->addSql('ALTER TABLE appointment_service_items DROP FOREIGN KEY FK_3624BA00DDEB00C2');
|
||||
$this->addSql('DROP TABLE appointment_service_items');
|
||||
}
|
||||
}
|
||||
@@ -862,6 +862,36 @@ class AdminApiController extends BaseController
|
||||
$patientName = trim($data['patient_name'] ?? '');
|
||||
$nationalCode = InputValidator::toEnglishDigits(trim((string) ($data['patient_national_code'] ?? '')));
|
||||
|
||||
// سرویسهای نوبت (چند سرویس). `duration_from_services` فقط در نوبتدهی سرویسی
|
||||
// true است و آنگاه slot_end از مجموع مدت سرویسها محاسبه میشود؛ در حالت اسلاتی
|
||||
// سرویسها صرفاً پیوست میشوند و ساعت پایانِ دستی حفظ میماند.
|
||||
$serviceUuids = array_values(array_filter(array_map('trim', (array) ($data['service_item_uuids'] ?? []))));
|
||||
$computeDuration = (bool) ($data['duration_from_services'] ?? false);
|
||||
$serviceItems = [];
|
||||
if (!empty($serviceUuids)) {
|
||||
$itemRepo = $this->em->getRepository(\App\ClinicService\Entity\ServiceItem::class);
|
||||
$totalMinutes = 0;
|
||||
foreach ($serviceUuids as $u) {
|
||||
$item = $itemRepo->findOneBy(['uuid' => $u]);
|
||||
if ($item === null) {
|
||||
return $this->error(ErrorCodes::VALIDATION, 'سرویس یافت نشد', 422, 'service_item_uuids');
|
||||
}
|
||||
if ($computeDuration) {
|
||||
if (!$item->isBookable()) {
|
||||
return $this->error(ErrorCodes::VALIDATION, 'این سرویس برای نوبتدهی فعال نیست', 422, 'service_item_uuids');
|
||||
}
|
||||
if (($item->getDurationMinutes() ?? 0) <= 0) {
|
||||
return $this->error(ErrorCodes::VALIDATION, 'مدت سرویس تعریف نشده است', 422, 'service_item_uuids');
|
||||
}
|
||||
$totalMinutes += (int) $item->getDurationMinutes();
|
||||
}
|
||||
$serviceItems[] = $item;
|
||||
}
|
||||
if ($computeDuration) {
|
||||
$slotEnd = $slotStart + $totalMinutes * 60;
|
||||
}
|
||||
}
|
||||
|
||||
if (empty($doctorUuid) || $slotStart <= 0 || $slotEnd <= $slotStart || empty($mobile) || empty($patientName)) {
|
||||
return $this->error(ErrorCodes::VALIDATION, 'doctor_uuid، slot_start، slot_end، patient_mobile و patient_name الزامی است', 422);
|
||||
}
|
||||
@@ -885,6 +915,9 @@ class AdminApiController extends BaseController
|
||||
$appointment->setPatientName($patientName);
|
||||
$appointment->setPatientMobile($mobile);
|
||||
if (!empty($data['note'])) $appointment->setNote($data['note']);
|
||||
foreach ($serviceItems as $si) {
|
||||
$appointment->addServiceItem($si);
|
||||
}
|
||||
$locationId = $this->slotCalculator->resolveSlotLocationId($doctor, $slotStart);
|
||||
if ($locationId !== null) $appointment->setAddressId($locationId);
|
||||
|
||||
|
||||
@@ -71,8 +71,12 @@ class MyAppointmentsController extends BaseController
|
||||
|
||||
// حالت نوبتدهی سرویسی: مدت نوبت از مجموعِ مدت سرویسهای انتخابشده تعیین
|
||||
// و slot_end سمت سرور محاسبه میشود (به مقدار کلاینت اعتماد نمیشود).
|
||||
$serviceUuids = array_values(array_filter(array_map('trim', (array) ($data['service_item_uuids'] ?? []))));
|
||||
$serviceItems = [];
|
||||
// `duration_from_services` فقط در نوبتدهی سرویسی true است: آنجا مدت نوبت از
|
||||
// مجموع سرویسها محاسبه و slot_end بازنویسی میشود. در حالت اسلاتی، سرویسها
|
||||
// فقط به نوبت پیوست میشوند و ساعت پایانِ دستی حفظ میشود.
|
||||
$serviceUuids = array_values(array_filter(array_map('trim', (array) ($data['service_item_uuids'] ?? []))));
|
||||
$computeDuration = (bool) ($data['duration_from_services'] ?? false);
|
||||
$serviceItems = [];
|
||||
if (!empty($serviceUuids) && !$isReserve) {
|
||||
$totalMinutes = 0;
|
||||
foreach ($serviceUuids as $u) {
|
||||
@@ -80,16 +84,20 @@ class MyAppointmentsController extends BaseController
|
||||
if ($item === null) {
|
||||
return $this->error(ErrorCodes::VALIDATION, 'سرویس یافت نشد', 422, 'service_item_uuids');
|
||||
}
|
||||
if (!$item->isBookable()) {
|
||||
return $this->error(ErrorCodes::VALIDATION, 'این سرویس برای نوبتدهی فعال نیست', 422, 'service_item_uuids');
|
||||
if ($computeDuration) {
|
||||
if (!$item->isBookable()) {
|
||||
return $this->error(ErrorCodes::VALIDATION, 'این سرویس برای نوبتدهی فعال نیست', 422, 'service_item_uuids');
|
||||
}
|
||||
if (($item->getDurationMinutes() ?? 0) <= 0) {
|
||||
return $this->error(ErrorCodes::VALIDATION, 'مدت سرویس تعریف نشده است', 422, 'service_item_uuids');
|
||||
}
|
||||
$totalMinutes += (int) $item->getDurationMinutes();
|
||||
}
|
||||
if (($item->getDurationMinutes() ?? 0) <= 0) {
|
||||
return $this->error(ErrorCodes::VALIDATION, 'مدت سرویس تعریف نشده است', 422, 'service_item_uuids');
|
||||
}
|
||||
$totalMinutes += (int) $item->getDurationMinutes();
|
||||
$serviceItems[] = $item;
|
||||
}
|
||||
$slotEnd = $slotStart + $totalMinutes * 60;
|
||||
if ($computeDuration) {
|
||||
$slotEnd = $slotStart + $totalMinutes * 60;
|
||||
}
|
||||
}
|
||||
|
||||
if (empty($doctorUuid) || $slotStart <= 0 || (!$isReserve && $slotEnd <= $slotStart) || empty($mobile) || empty($patientName)) {
|
||||
@@ -140,9 +148,9 @@ class MyAppointmentsController extends BaseController
|
||||
}
|
||||
$appointment->$setter($entity);
|
||||
}
|
||||
// در حالت سرویسی، سرویسِ اصلیِ نوبت = اولین سرویسِ انتخابشده.
|
||||
if (!empty($serviceItems)) {
|
||||
$appointment->setServiceItem($serviceItems[0]);
|
||||
// پیوستِ همهٔ سرویسهای انتخابشده؛ سرویسِ اصلی = اولین سرویس (addServiceItem).
|
||||
foreach ($serviceItems as $si) {
|
||||
$appointment->addServiceItem($si);
|
||||
}
|
||||
if (!empty($data['deposit_required'])) {
|
||||
$appointment->setDepositRequired(true);
|
||||
|
||||
@@ -4,6 +4,8 @@ namespace App\Appointment\Entity;
|
||||
|
||||
use App\Auth\Entity\User;
|
||||
use App\Doctor\Entity\Doctor;
|
||||
use Doctrine\Common\Collections\ArrayCollection;
|
||||
use Doctrine\Common\Collections\Collection;
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
use App\Appointment\Repository\AppointmentRepository;
|
||||
use Symfony\Component\Uid\Uuid;
|
||||
@@ -135,11 +137,16 @@ class Appointment
|
||||
#[ORM\JoinColumn(name: 'service_section_id', nullable: true, onDelete: 'SET NULL')]
|
||||
private ?\App\ClinicService\Entity\ServiceSection $serviceSection = null;
|
||||
|
||||
/** سرویس — concrete service item to perform. */
|
||||
/** سرویس — سرویس اصلی/اولِ نوبت (برای سازگاری با مصرفکنندههای موجود). */
|
||||
#[ORM\ManyToOne(targetEntity: \App\ClinicService\Entity\ServiceItem::class)]
|
||||
#[ORM\JoinColumn(name: 'service_item_id', nullable: true, onDelete: 'SET NULL')]
|
||||
private ?\App\ClinicService\Entity\ServiceItem $serviceItem = null;
|
||||
|
||||
/** سرویسهای نوبت — امکان انتخاب چند سرویس. serviceItem بالا همان سرویسِ اول است. */
|
||||
#[ORM\ManyToMany(targetEntity: \App\ClinicService\Entity\ServiceItem::class)]
|
||||
#[ORM\JoinTable(name: 'appointment_service_items')]
|
||||
private Collection $serviceItems;
|
||||
|
||||
/** پرسنل — staff member assigned to the appointment. */
|
||||
#[ORM\ManyToOne(targetEntity: \App\Staff\Entity\ClinicStaff::class)]
|
||||
#[ORM\JoinColumn(name: 'staff_id', nullable: true, onDelete: 'SET NULL')]
|
||||
@@ -174,6 +181,7 @@ class Appointment
|
||||
$this->slotEnd = $slotEnd;
|
||||
$this->createdAt = time();
|
||||
$this->updatedAt = time();
|
||||
$this->serviceItems = new ArrayCollection();
|
||||
$this->refreshActiveSlotKey();
|
||||
}
|
||||
|
||||
@@ -219,6 +227,21 @@ class Appointment
|
||||
|
||||
public function getServiceSection(): ?\App\ClinicService\Entity\ServiceSection { return $this->serviceSection; }
|
||||
public function getServiceItem(): ?\App\ClinicService\Entity\ServiceItem { return $this->serviceItem; }
|
||||
|
||||
/** @return Collection<int,\App\ClinicService\Entity\ServiceItem> */
|
||||
public function getServiceItems(): Collection { return $this->serviceItems; }
|
||||
|
||||
public function addServiceItem(\App\ClinicService\Entity\ServiceItem $item): self
|
||||
{
|
||||
if (!$this->serviceItems->contains($item)) {
|
||||
$this->serviceItems->add($item);
|
||||
}
|
||||
// سرویسِ اصلی = اولین سرویس، تا مصرفکنندههای موجود کار کنند.
|
||||
if ($this->serviceItem === null) {
|
||||
$this->serviceItem = $item;
|
||||
}
|
||||
return $this;
|
||||
}
|
||||
public function getStaff(): ?\App\Staff\Entity\ClinicStaff { return $this->staff; }
|
||||
public function isDepositRequired(): bool { return $this->depositRequired; }
|
||||
public function getDepositAmountRials(): ?int { return $this->depositAmountRials; }
|
||||
@@ -317,6 +340,10 @@ class Appointment
|
||||
'patient_reason' => $this->patientReason,
|
||||
'service_section' => $this->serviceSection ? ['uuid' => $this->serviceSection->getUuid(), 'name' => $this->serviceSection->getName()] : null,
|
||||
'service_item' => $this->serviceItem ? ['uuid' => $this->serviceItem->getUuid(), 'name' => $this->serviceItem->getName()] : null,
|
||||
'service_items' => array_map(
|
||||
fn(\App\ClinicService\Entity\ServiceItem $i) => ['uuid' => $i->getUuid(), 'name' => $i->getName()],
|
||||
$this->serviceItems->toArray()
|
||||
),
|
||||
'staff' => $this->staff ? ['uuid' => $this->staff->getUuid(), 'full_name' => $this->staff->getFullName()] : null,
|
||||
'deposit_required' => $this->depositRequired,
|
||||
'deposit_amount_rials' => $this->depositAmountRials,
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
<?php
|
||||
|
||||
namespace App\Tests\Appointment;
|
||||
|
||||
use App\Appointment\Entity\Appointment;
|
||||
use App\ClinicService\Entity\ServiceItem;
|
||||
use App\ClinicService\Entity\ServiceSection;
|
||||
use App\Doctor\Entity\Doctor;
|
||||
use App\Tests\ApiTestCase;
|
||||
|
||||
/**
|
||||
* چند سرویس روی یک نوبت: در حالت اسلاتی سرویسها فقط پیوست میشوند و ساعت پایانِ
|
||||
* دستی حفظ میماند؛ در حالت سرویسی (duration_from_services) slot_end از مجموع مدت
|
||||
* سرویسها بازمحاسبه میشود.
|
||||
*/
|
||||
class AppointmentMultiServiceTest extends ApiTestCase
|
||||
{
|
||||
private function doctor(): array
|
||||
{
|
||||
$owner = $this->createUser(['ROLE_DOCTOR']);
|
||||
$doctor = new Doctor($owner, 'دکتر تست');
|
||||
$this->em->persist($doctor);
|
||||
$this->em->flush();
|
||||
|
||||
return [$owner, $doctor];
|
||||
}
|
||||
|
||||
private function serviceItem(Doctor $doctor, string $name, int $minutes): ServiceItem
|
||||
{
|
||||
$section = new ServiceSection('doctor', $doctor->getId(), 'بخش ' . $name);
|
||||
$this->em->persist($section);
|
||||
$item = new ServiceItem($section, $name, 500_000);
|
||||
$item->setDurationMinutes($minutes)->setBookable(true);
|
||||
$this->em->persist($item);
|
||||
$this->em->flush();
|
||||
|
||||
return $item;
|
||||
}
|
||||
|
||||
private function nationalCode(): string
|
||||
{
|
||||
return '00' . str_pad((string) random_int(0, 99_999_999), 8, '0', STR_PAD_LEFT);
|
||||
}
|
||||
|
||||
private function body(Doctor $doctor, int $start, int $end, array $extra): array
|
||||
{
|
||||
return array_merge([
|
||||
'doctor_uuid' => $doctor->getUuid(),
|
||||
'slot_start' => $start,
|
||||
'slot_end' => $end,
|
||||
'patient_mobile' => '09' . str_pad((string) random_int(0, 999_999_999), 9, '0', STR_PAD_LEFT),
|
||||
'patient_name' => 'بیمار تست',
|
||||
'patient_national_code' => $this->nationalCode(),
|
||||
], $extra);
|
||||
}
|
||||
|
||||
private function reload(string $uuid): Appointment
|
||||
{
|
||||
$this->em->clear();
|
||||
return $this->em->getRepository(Appointment::class)->findOneBy(['uuid' => $uuid]);
|
||||
}
|
||||
|
||||
public function testSlotModeAttachesMultipleServicesAndKeepsManualEnd(): void
|
||||
{
|
||||
[$owner, $doctor] = $this->doctor();
|
||||
$a = $this->serviceItem($doctor, 'تزریق ژل', 30);
|
||||
$b = $this->serviceItem($doctor, 'کندلا', 20);
|
||||
|
||||
$start = time() + 86_400 + random_int(0, 3_600) * 100;
|
||||
$end = $start + 3_600; // ساعت پایانِ دستی: ۶۰ دقیقه
|
||||
|
||||
$res = $this->authJson('POST', '/api/v1/my/appointment', $owner, $this->body($doctor, $start, $end, [
|
||||
'service_item_uuids' => [$a->getUuid(), $b->getUuid()],
|
||||
]));
|
||||
self::assertSame(201, $this->responseCode());
|
||||
|
||||
$appt = $this->reload($res['data']['uuid']);
|
||||
self::assertCount(2, $appt->getServiceItems());
|
||||
// ساعت پایان دستنخورده (بدون بازمحاسبه از مدت سرویسها)
|
||||
self::assertSame($end, $appt->getSlotEnd());
|
||||
// سرویسِ اصلی = اولین سرویس
|
||||
self::assertSame($a->getUuid(), $appt->getServiceItem()?->getUuid());
|
||||
}
|
||||
|
||||
public function testServiceModeRecomputesEndFromDurations(): void
|
||||
{
|
||||
[$owner, $doctor] = $this->doctor();
|
||||
$a = $this->serviceItem($doctor, 'تزریق ژل', 30);
|
||||
$b = $this->serviceItem($doctor, 'کندلا', 20);
|
||||
|
||||
$start = time() + 86_400 + random_int(0, 3_600) * 100;
|
||||
|
||||
$res = $this->authJson('POST', '/api/v1/my/appointment', $owner, $this->body($doctor, $start, $start + 60, [
|
||||
'service_item_uuids' => [$a->getUuid(), $b->getUuid()],
|
||||
'duration_from_services' => true,
|
||||
]));
|
||||
self::assertSame(201, $this->responseCode());
|
||||
|
||||
$appt = $this->reload($res['data']['uuid']);
|
||||
self::assertCount(2, $appt->getServiceItems());
|
||||
// مجموع مدت ۳۰+۲۰=۵۰ دقیقه → slot_end بازمحاسبهشده
|
||||
self::assertSame($start + 50 * 60, $appt->getSlotEnd());
|
||||
}
|
||||
|
||||
public function testUnknownServiceUuidIsRejected(): void
|
||||
{
|
||||
[$owner, $doctor] = $this->doctor();
|
||||
$start = time() + 86_400 + random_int(0, 3_600) * 100;
|
||||
|
||||
$this->authJson('POST', '/api/v1/my/appointment', $owner, $this->body($doctor, $start, $start + 1_800, [
|
||||
'service_item_uuids' => ['no-such-uuid'],
|
||||
]));
|
||||
self::assertSame(422, $this->responseCode());
|
||||
}
|
||||
}
|
||||
@@ -27,10 +27,13 @@ class PatientListNationalCodeTest extends ApiTestCase
|
||||
{
|
||||
[$owner, $doctor] = $this->doctor();
|
||||
|
||||
// db_test ریست نمیشود؛ کد ملیِ یکتا رندوم تا با اجراهای قبلی تصادم نکند.
|
||||
$nc = '00' . str_pad((string) random_int(0, 99_999_999), 8, '0', STR_PAD_LEFT);
|
||||
|
||||
$patient = $this->createUser(['ROLE_USER']);
|
||||
// کد ملی فقط روی پروفایل، نه روی خود کاربر
|
||||
$profile = new UserProfile($patient);
|
||||
$profile->setNationalCode('0012345675');
|
||||
$profile->setNationalCode($nc);
|
||||
$this->em->persist($profile);
|
||||
|
||||
$record = new PatientRecord('doctor', $doctor->getId(), $patient, 'doctor', $doctor->getId());
|
||||
@@ -40,7 +43,7 @@ class PatientListNationalCodeTest extends ApiTestCase
|
||||
$res = $this->authJson('GET', '/api/v1/patients', $owner);
|
||||
self::assertSame(200, $this->responseCode());
|
||||
self::assertSame($record->getUuid(), $res['data'][0]['uuid']);
|
||||
self::assertSame('0012345675', $res['data'][0]['user_national_code']);
|
||||
self::assertSame($nc, $res['data'][0]['user_national_code']);
|
||||
}
|
||||
|
||||
public function testNullWhenNoNationalCodeAnywhere(): void
|
||||
|
||||
Reference in New Issue
Block a user