feat(booking): persist service duration and allow full service replacement

Appointment gains:
  - replaceServiceItems(): full replacement that unconditionally syncs the
    legacy single serviceItem column. addServiceItem() only fills it when null,
    which would leave stale service names in the four consumers that read
    service_item (admin lists, public site, desktop app).
  - currentServiceUuids(): input-order uuids, falling back to the single column
    for appointments created before multi-service support.
  - service_total_minutes / service_buffer_minutes (both nullable, NULL in slot
    mode). slot_end - slot_start carries the number but cannot say whether it
    was intentional, and a reserve entry has slot_start == slot_end so its
    duration had nowhere to live.

Existing columns untouched: slot_start, slot_end, active_slot_key, is_reserve
verified unchanged via SHOW COLUMNS.

Task: docs/new_feture/taskes/task-00-service-mode-completion/
Slot-mode contract: unchanged (--group=slot-mode-frozen green)

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
hamed
2026-07-30 12:47:48 +03:30
co-authored by Claude Opus 5
parent aaa864f408
commit 6c2e075eea
4 changed files with 296 additions and 9 deletions
+79
View File
@@ -206,6 +206,25 @@ class Appointment
#[ORM\Column(name: 'is_reserve', type: 'boolean', options: ['default' => false])]
private bool $isReserve = false;
/**
* مدتِ محاسبه‌شدهٔ ترکیب سرویس‌ها در لحظهٔ ثبت — فقط در حالت نوبت‌دهی سرویسی.
*
* `slot_end - slot_start` همین عدد را دارد ولی نمی‌گوید عمدی بود یا دستی؛ و برای نوبت
* رزرو (که `slot_start == slot_end` است) هیچ‌جای دیگری مدت نگه‌داشته نمی‌شود، پس تبدیل
* رزرو به نوبت زمان‌دار بدون این ستون مدت را از دست می‌دهد.
*
* در حالت اسلاتی همیشه NULL می‌ماند.
*/
#[ORM\Column(name: 'service_total_minutes', type: 'smallint', nullable: true)]
private ?int $serviceTotalMinutes = null;
/**
* `buffer_minutes` مؤثر در لحظهٔ ثبت. تغییر بافر در تنظیمات نباید معنای نوبت‌های
* ثبت‌شده را عوض کند (قانون پنجم مستند: هر چیزی که ثبت شد همان‌طور می‌ماند).
*/
#[ORM\Column(name: 'service_buffer_minutes', type: 'smallint', nullable: true)]
private ?int $serviceBufferMinutes = null;
#[ORM\Column(name: 'created_at', type: 'integer')]
private int $createdAt;
@@ -289,6 +308,51 @@ class Appointment
}
return $this;
}
/**
* جایگزینی کامل سرویس‌های نوبت (برای ویرایش و جابه‌جایی سرویس‌آگاه).
*
* برخلاف {@see addServiceItem()}، ستون تکیِ `serviceItem` را **بی‌قید** با اولین عضو
* هم‌گام می‌کند: چهار مصرف‌کننده روی `service_item` تکی خوانده‌اند (`AppointmentsPage`،
* `ReserveAppointmentsPage`، `nobat724_front/services/response.js`،
* `clinic-pro-tauri/src/service/response.js`) و رهاکردنش یعنی نوبت با سرویس‌های جدید
* ولی نامِ سرویس قدیمی در فهرست‌ها. همان الگوی `ServiceItem::setStaffMembers()`.
*
* @param \App\ClinicService\Entity\ServiceItem[] $items
*/
public function replaceServiceItems(array $items): self
{
$this->serviceItems->clear();
foreach ($items as $item) {
if (!$this->serviceItems->contains($item)) {
$this->serviceItems->add($item);
}
}
$this->serviceItem = $items[0] ?? null;
$this->updatedAt = time();
return $this;
}
/**
* uuid سرویس‌های فعلی، به ترتیب. نوبت‌های پیش از چند-سرویسی‌شدن فقط `serviceItem`
* تکی دارند، پس آن هم پوشش داده می‌شود.
*
* @return string[]
*/
public function currentServiceUuids(): array
{
$uuids = array_map(
fn(\App\ClinicService\Entity\ServiceItem $i) => $i->getUuid(),
$this->serviceItems->toArray(),
);
if ($uuids === [] && $this->serviceItem !== null) {
$uuids = [$this->serviceItem->getUuid()];
}
return array_values($uuids);
}
public function getStaff(): ?\App\Staff\Entity\ClinicStaff { return $this->staff; }
public function isDepositRequired(): bool { return $this->depositRequired; }
public function getDepositAmountRials(): ?int { return $this->depositAmountRials; }
@@ -297,6 +361,18 @@ class Appointment
public function getInsuranceBaseId(): ?int { return $this->insuranceBaseId; }
public function getInsuranceSupplementaryId(): ?int { return $this->insuranceSupplementaryId; }
public function isReserve(): bool { return $this->isReserve; }
public function getServiceTotalMinutes(): ?int { return $this->serviceTotalMinutes; }
public function getServiceBufferMinutes(): ?int { return $this->serviceBufferMinutes; }
/** هر دو با هم ست می‌شوند: مدت بی‌بافر و بافر بی‌مدت هیچ‌کدام معنا ندارند. */
public function setServiceDuration(?int $totalMinutes, ?int $bufferMinutes): self
{
$this->serviceTotalMinutes = $totalMinutes;
$this->serviceBufferMinutes = $totalMinutes === null ? null : $bufferMinutes;
$this->updatedAt = time();
return $this;
}
public function setServiceSection(?\App\ClinicService\Entity\ServiceSection $v): self { $this->serviceSection = $v; return $this; }
public function setServiceItem(?\App\ClinicService\Entity\ServiceItem $v): self { $this->serviceItem = $v; return $this; }
@@ -430,6 +506,9 @@ class Appointment
'insurance_base_id' => $this->insuranceBaseId,
'insurance_supplementary_id' => $this->insuranceSupplementaryId,
'is_reserve' => $this->isReserve,
// فقط در حالت نوبت‌دهی سرویسی پر می‌شوند؛ در حالت اسلاتی null.
'service_total_minutes' => $this->serviceTotalMinutes,
'service_buffer_minutes' => $this->serviceBufferMinutes,
'version' => $this->version,
'created_at' => $this->createdAt,
'updated_at' => $this->updatedAt,