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:
hamed
2026-07-16 10:10:47 +03:30
co-authored by Claude Opus 4.8
parent d76fd73529
commit 4642506c0f
9 changed files with 315 additions and 65 deletions
+68 -45
View File
@@ -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>
+4
View File
@@ -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
+7 -5
View File
@@ -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).
+35
View File
@@ -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);
+28 -1
View File
@@ -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