feat(catalog): dual durations, item groups, relations and branch overrides

Section 5 of the design document rejects summing service durations. "Face + bikini"
is not 15+12=27 minutes but 15+8=23 — preparation and settling the patient do not
happen twice. Seven wasted minutes times twenty appointments a day is an hour of
capacity lost daily, and AppointmentController was doing exactly that plain sum.

Each item now carries a solo duration and an additional duration. One item counts at
its solo duration and the rest at their additional; the anchor is the item with the
*largest* solo duration rather than the first one selected. Anchoring on selection
order would have let the same basket cost different amounts depending on click order,
so a patient could buy a shorter appointment by reordering. Largest-first is also
conservative: no combination is ever under-estimated, and under-estimating pushes the
next appointment on top of this one.

additional_duration_minutes stays NULL by default and the entity reads NULL as "same
as solo", so every existing service keeps behaving exactly as before — the 236
appointment-domain tests pass unchanged. The old duration_minutes column is kept and
written in step rather than renamed, because other consumers still read it.

ServiceBookingCalculator now delegates to DurationCalculator, which is the one-line
change task 00 predicted when it deliberately preserved the naive sum.

Selection rules are data, not policy: min/max per group is a number, and "bikini does
not combine with full body" is a relation. Putting either in a rules engine means
several rules per service and nobody able to explain a rejection. Validation returns
*all* errors at once rather than the first, since a user with three problems should
not make three round trips. Prerequisite cycles are rejected at write time — storing
both "A requires B" and "B requires A" would make every selection permanently invalid.

Named CatalogCategory, not ServiceCategory: that name is already an insurance enum
(outpatient/inpatient) living on ServiceItem itself, so the two would have collided in
the same file's imports.

Also fixed a defect the tests caught: breakdown() used $overrides[$id]?->… on a key
that may not exist, which warns instead of yielding null.

1175 tests / 3289 assertions. phpstan measured at 14 errors both with and without
this change (verified by stashing). Slot-mode frozen contract green.

The admin UI tab for groups and relations is not built; the checklist records it as
outstanding with a target. The backend is complete and
POST /service-selection/validate is consumable without it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
hamed
2026-07-30 18:44:02 +03:30
co-authored by Claude Opus 5
parent 0f4db93fb8
commit b1b06c1b36
21 changed files with 2388 additions and 72 deletions
@@ -0,0 +1,52 @@
<?php
namespace App\ClinicService\Repository;
use App\ClinicService\Entity\CatalogCategory;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
/**
* @extends ServiceEntityRepository<CatalogCategory>
*/
class CatalogCategoryRepository extends ServiceEntityRepository
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, CatalogCategory::class);
}
public function findByUuid(string $uuid): ?CatalogCategory
{
return $this->findOneBy(['uuid' => $uuid]);
}
/**
* کل درخت یک محیط با **یک** کوئری؛ ساختار درختی در PHP بسته می‌شود، نه با یک
* کوئری per گره.
*
* @return CatalogCategory[]
*/
public function findForPair(string $entityType, int $entityId): array
{
return $this->createQueryBuilder('c')
->where('c.entityType = :type')
->andWhere('c.entityId = :id')
->setParameter('type', $entityType)
->setParameter('id', $entityId)
->orderBy('c.sortOrder', 'ASC')
->addOrderBy('c.name', 'ASC')
->getQuery()
->getResult();
}
public function countChildren(CatalogCategory $category): int
{
return (int) $this->createQueryBuilder('c')
->select('COUNT(c.id)')
->where('c.parent = :parent')
->setParameter('parent', $category)
->getQuery()
->getSingleScalarResult();
}
}
@@ -0,0 +1,50 @@
<?php
namespace App\ClinicService\Repository;
use App\ClinicService\Entity\ItemGroup;
use App\ClinicService\Entity\ItemGroupMember;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
/**
* @extends ServiceEntityRepository<ItemGroupMember>
*/
class ItemGroupMemberRepository extends ServiceEntityRepository
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, ItemGroupMember::class);
}
public function deleteForGroup(ItemGroup $group): int
{
return (int) $this->createQueryBuilder('m')
->delete()
->where('m.group = :group')
->setParameter('group', $group)
->getQuery()
->execute();
}
/**
* گروه‌هایی که این آیتم‌ها در آن‌ها عضوند — برای اعتبارسنجی انتخاب، با یک کوئری.
*
* @param int[] $itemIds
* @return ItemGroupMember[]
*/
public function findByItemIds(array $itemIds): array
{
if ($itemIds === []) {
return [];
}
return $this->createQueryBuilder('m')
->addSelect('g')
->join('m.group', 'g')
->where('m.item IN (:ids)')
->setParameter('ids', $itemIds)
->getQuery()
->getResult();
}
}
@@ -0,0 +1,36 @@
<?php
namespace App\ClinicService\Repository;
use App\ClinicService\Entity\ItemGroup;
use App\ClinicService\Entity\ServiceItem;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
/**
* @extends ServiceEntityRepository<ItemGroup>
*/
class ItemGroupRepository extends ServiceEntityRepository
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, ItemGroup::class);
}
public function findByUuid(string $uuid): ?ItemGroup
{
return $this->findOneBy(['uuid' => $uuid]);
}
/** @return ItemGroup[] */
public function findForService(ServiceItem $service): array
{
return $this->createQueryBuilder('g')
->where('g.service = :service')
->setParameter('service', $service)
->orderBy('g.sortOrder', 'ASC')
->addOrderBy('g.name', 'ASC')
->getQuery()
->getResult();
}
}
@@ -0,0 +1,68 @@
<?php
namespace App\ClinicService\Repository;
use App\ClinicService\Entity\ServiceBranchOverride;
use App\ClinicService\Entity\ServiceItem;
use App\Doctor\Entity\DoctorAddress;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
/**
* @extends ServiceEntityRepository<ServiceBranchOverride>
*/
class ServiceBranchOverrideRepository extends ServiceEntityRepository
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, ServiceBranchOverride::class);
}
/** @return ServiceBranchOverride[] */
public function findForItem(ServiceItem $item): array
{
return $this->createQueryBuilder('o')
->where('o.item = :item')
->setParameter('item', $item)
->getQuery()
->getResult();
}
/**
* override های یک شعبه برای مجموعه‌ای از آیتم‌ها — یک کوئری، نه یکی per آیتم.
*
* @param int[] $itemIds
* @return array<int, ServiceBranchOverride> کلید = شناسهٔ آیتم
*/
public function mapForAddress(array $itemIds, DoctorAddress $address): array
{
if ($itemIds === []) {
return [];
}
$rows = $this->createQueryBuilder('o')
->where('o.item IN (:ids)')
->andWhere('o.address = :address')
->setParameter('ids', $itemIds)
->setParameter('address', $address)
->getQuery()
->getResult();
$map = [];
foreach ($rows as $override) {
$map[(int) $override->getItem()->getId()] = $override;
}
return $map;
}
public function deleteForItem(ServiceItem $item): int
{
return (int) $this->createQueryBuilder('o')
->delete()
->where('o.item = :item')
->setParameter('item', $item)
->getQuery()
->execute();
}
}
@@ -0,0 +1,58 @@
<?php
namespace App\ClinicService\Repository;
use App\ClinicService\Entity\ServiceItem;
use App\ClinicService\Entity\ServiceItemRelation;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
/**
* @extends ServiceEntityRepository<ServiceItemRelation>
*/
class ServiceItemRelationRepository extends ServiceEntityRepository
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, ServiceItemRelation::class);
}
/** @return ServiceItemRelation[] */
public function findForItem(ServiceItem $item): array
{
return $this->createQueryBuilder('r')
->where('r.item = :item')
->setParameter('item', $item)
->getQuery()
->getResult();
}
/**
* همهٔ روابطی که یک سرِ آن‌ها در این مجموعه است — یک کوئری برای کل اعتبارسنجی.
*
* @param int[] $itemIds
* @return ServiceItemRelation[]
*/
public function findTouching(array $itemIds): array
{
if ($itemIds === []) {
return [];
}
return $this->createQueryBuilder('r')
->where('r.item IN (:ids) OR r.relatedItem IN (:ids)')
->setParameter('ids', $itemIds)
->getQuery()
->getResult();
}
public function deleteForItem(ServiceItem $item): int
{
return (int) $this->createQueryBuilder('r')
->delete()
->where('r.item = :item')
->setParameter('item', $item)
->getQuery()
->execute();
}
}