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:
@@ -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();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user