feat(treatment): open a treatment case with snapshotted areas and its sessions

Opening a case copies what must not move afterwards — the session count and the
list of body areas, each with its category name — because a treatment record is
a medical document and editing settings tomorrow must not rewrite what was done
yesterday. The areas are the leaf categories under the service's own category:
"توتال" contains bikini, leg and hand, and treatment happens on those three, not
on the grouping node above them. A category with no children is its own single
area, so "لیزر دست" gets one area rather than none.

Every session in the course is created up front so that "session 5 of 8" has
somewhere to live, but none of them is booked: creating eight real appointments
would lock eight months of slots for a patient who may not attend session three.

CategoryClosureResolver gains leaves(); the graph walk it already does is what
tells a leaf from a grouping node, so this belongs next to descendants() rather
than in a second traversal elsewhere.

TreatmentCase and TreatmentSession carry no money field, and must not: billing
lives on PatientSession, which is created when an appointment is confirmed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
hamed
2026-08-06 16:53:50 +03:30
co-authored by Claude Opus 5
parent e2e3e6b43b
commit 6847a473d4
13 changed files with 1278 additions and 0 deletions
@@ -33,6 +33,32 @@ final class CategoryClosureResolver
return $this->walk((int) $category->getId(), $map);
}
/**
* برگ‌های زیرمجموعه — دسته‌هایی که خودشان زیرمجموعه‌ای ندارند.
*
* نواحیِ درمانِ یک سرویس همین‌هایند: «توتال» شامل «بیکینی» و «پا» و «دست» است و
* درمان روی آن سه انجام می‌شود، نه روی خودِ «توتال». دستهٔ میانی فقط گروه‌بندی است.
*
* دسته‌ای که هیچ زیرمجموعه‌ای ندارد، خودش تنها ناحیه است — «لیزر دست» یک سرویس
* واقعی است و باید یک ناحیه داشته باشد، نه صفر.
*
* @return list<int>
*/
public function leaves(CatalogCategory $category): array
{
$map = $this->includes->edgeMapFor($category->getEntityType(), $category->getEntityId());
$descendants = $this->walk((int) $category->getId(), $map);
if ($descendants === []) {
return [(int) $category->getId()];
}
return array_values(array_filter(
$descendants,
static fn (int $id): bool => ($map[$id] ?? []) === [],
));
}
/** آیا یکی از این دو، دیگری را در بر می‌گیرد؟ (رابطه متقارن نیست، ولی تعارض هست) */
public function overlaps(CatalogCategory $a, CatalogCategory $b): bool
{