perf(reports): read every resource's calendar in one batch, and close the owed tests
Writing the query-count test that task 14 owed showed the growth was real: one resource cost 10 queries, six cost 33 — about five per resource, because the available-minutes figure walked each resource's calendar on its own. Holidays, tenant overrides and branch hours are identical for every resource in a report, so they now load once outside the loop; shifts and exceptions load for all resources in one query each. The batched path is a new method rather than a change to rawAvailability, which the booking engine also calls. The test pins the shape of the growth, not an exact count. Also landed: - app:segment:seed-templates with beauty, dental and physio presets. Building four segments and their requirements by hand is the first thing a new clinic must do and the most tedious; this gives them something to edit instead of an empty page. It refuses to touch a service that already has segments unless --force, and it will not invent resource types the tenant never defined. - book-all is all-or-nothing, proven rather than asserted: with a calendar open one day a week and a 1-2 day protocol gap, session one finds a slot and session two cannot, and every session must come back planned. - credit_refundable: false takes the credit back with a negative adjustment and deletes nothing — the ledger stays append-only. - the segments editor has frontend tests, including that it sends back what the user sees and renders read-only without the permission. useBranches now returns [] for a non-array payload instead of throwing "branches.map is not a function" and taking the page down with it. BookingLocationsScanTest built a Clinic around a Doctor loaded from a different manager, which Doctrine treats as a new entity; it flushed fine most runs and failed on cascade in others. It now loads the doctor from the same manager. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,151 @@
|
||||
<?php
|
||||
|
||||
namespace App\Appointment\Plan\Command;
|
||||
|
||||
use App\Appointment\Plan\Entity\SegmentRequirement;
|
||||
use App\Appointment\Plan\Entity\SegmentTemplate;
|
||||
use App\Appointment\Plan\Repository\SegmentTemplateRepository;
|
||||
use App\ClinicService\Repository\ServiceItemRepository;
|
||||
use App\Resource\Repository\ResourceTypeRepository;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Symfony\Component\Console\Attribute\AsCommand;
|
||||
use Symfony\Component\Console\Command\Command;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
use Symfony\Component\Console\Input\InputOption;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
use Symfony\Component\Console\Style\SymfonyStyle;
|
||||
|
||||
/**
|
||||
* الگوی نمونهٔ بخشها برای یک سرویس — نقطهٔ شروع، نه پیکربندی نهایی.
|
||||
*
|
||||
* ساختنِ چهار بخش با نیازمندیهایشان از صفر، اولین کاری است که کلینیک تازه باید بکند و
|
||||
* بیحوصلهکنندهترینش. این دستور همان را در یک خط میسازد تا کلینیک از روی چیزی که
|
||||
* میبیند ویرایش کند، نه از روی صفحهٔ خالی.
|
||||
*
|
||||
* روی سرویسی که از قبل بخش دارد **کاری نمیکند** مگر `--force`: بازنویسی خاموشِ چیزی که
|
||||
* کلینیک خودش ساخته، بدترین رفتار ممکن است.
|
||||
*/
|
||||
#[AsCommand(
|
||||
name: 'app:segment:seed-templates',
|
||||
description: 'Create a starter set of appointment segments for a service.',
|
||||
)]
|
||||
class SeedSegmentTemplatesCommand extends Command
|
||||
{
|
||||
/**
|
||||
* سه الگو از سه حوزهٔ واقعی. `items` یعنی مدت از خودِ سرویسهای انتخابشده میآید.
|
||||
*
|
||||
* @var array<string, list<array{name: string, minutes: int, source: string, present: bool, mergeable: bool, roles: list<string>}>>
|
||||
*/
|
||||
private const PRESETS = [
|
||||
'beauty' => [
|
||||
['name' => 'آمادهسازی', 'minutes' => 10, 'source' => 'fixed', 'present' => true, 'mergeable' => true, 'roles' => ['room', 'operator']],
|
||||
['name' => 'بیحسی موضعی', 'minutes' => 15, 'source' => 'fixed', 'present' => true, 'mergeable' => true, 'roles' => ['room']],
|
||||
['name' => 'انتظار اثر', 'minutes' => 20, 'source' => 'fixed', 'present' => true, 'mergeable' => false, 'roles' => []],
|
||||
['name' => 'کار اصلی', 'minutes' => 0, 'source' => 'items', 'present' => true, 'mergeable' => false, 'roles' => ['room', 'operator']],
|
||||
['name' => 'تمیزکاری', 'minutes' => 10, 'source' => 'fixed', 'present' => false, 'mergeable' => false, 'roles' => ['room']],
|
||||
],
|
||||
'dental' => [
|
||||
['name' => 'معاینه', 'minutes' => 10, 'source' => 'fixed', 'present' => true, 'mergeable' => true, 'roles' => ['room', 'doctor']],
|
||||
['name' => 'درمان', 'minutes' => 0, 'source' => 'items', 'present' => true, 'mergeable' => false, 'roles' => ['room', 'doctor']],
|
||||
['name' => 'ضدعفونی یونیت', 'minutes' => 15, 'source' => 'fixed', 'present' => false, 'mergeable' => false, 'roles' => ['room']],
|
||||
],
|
||||
'physio' => [
|
||||
['name' => 'ارزیابی', 'minutes' => 15, 'source' => 'fixed', 'present' => true, 'mergeable' => true, 'roles' => ['doctor']],
|
||||
['name' => 'جلسهٔ درمان', 'minutes' => 0, 'source' => 'items', 'present' => true, 'mergeable' => false, 'roles' => ['room', 'operator']],
|
||||
['name' => 'استراحت', 'minutes' => 10, 'source' => 'fixed', 'present' => true, 'mergeable' => false, 'roles' => []],
|
||||
],
|
||||
];
|
||||
|
||||
public function __construct(
|
||||
private readonly ServiceItemRepository $services,
|
||||
private readonly SegmentTemplateRepository $templates,
|
||||
private readonly ResourceTypeRepository $types,
|
||||
private readonly EntityManagerInterface $em,
|
||||
) {
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
protected function configure(): void
|
||||
{
|
||||
$this
|
||||
->addOption('service', null, InputOption::VALUE_REQUIRED, 'Service item uuid')
|
||||
->addOption('preset', null, InputOption::VALUE_REQUIRED, 'beauty | dental | physio', 'beauty')
|
||||
->addOption('force', null, InputOption::VALUE_NONE, 'Replace segments the service already has');
|
||||
}
|
||||
|
||||
protected function execute(InputInterface $input, OutputInterface $output): int
|
||||
{
|
||||
$io = new SymfonyStyle($input, $output);
|
||||
$preset = (string) $input->getOption('preset');
|
||||
|
||||
if (!isset(self::PRESETS[$preset])) {
|
||||
$io->error(sprintf('الگوی «%s» وجود ندارد. یکی از: %s', $preset, implode('، ', array_keys(self::PRESETS))));
|
||||
|
||||
return Command::INVALID;
|
||||
}
|
||||
|
||||
$uuid = (string) $input->getOption('service');
|
||||
$service = $uuid === '' ? null : $this->services->findOneBy(['uuid' => $uuid]);
|
||||
|
||||
if ($service === null) {
|
||||
$io->error('سرویس یافت نشد؛ `--service=<uuid>` را بدهید.');
|
||||
|
||||
return Command::INVALID;
|
||||
}
|
||||
|
||||
$existing = $this->templates->findForService($service);
|
||||
|
||||
if ($existing !== [] && !$input->getOption('force')) {
|
||||
$io->warning(sprintf(
|
||||
'این سرویس از قبل %d بخش دارد. برای جایگزینی `--force` بدهید.',
|
||||
count($existing),
|
||||
));
|
||||
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
|
||||
foreach ($existing as $template) {
|
||||
$this->em->remove($template);
|
||||
}
|
||||
|
||||
$entityType = $service->getSection()->getEntityType();
|
||||
$entityId = $service->getSection()->getEntityId();
|
||||
$missing = [];
|
||||
$sequence = 0;
|
||||
|
||||
foreach (self::PRESETS[$preset] as $row) {
|
||||
$template = new SegmentTemplate($service, ++$sequence, $row['name']);
|
||||
$template->setDuration($row['source'], $row['minutes']);
|
||||
$template->setPatientPresent($row['present']);
|
||||
$template->setMergeable($row['mergeable']);
|
||||
|
||||
$this->em->persist($template);
|
||||
|
||||
foreach ($row['roles'] as $code) {
|
||||
$type = $this->types->findOneBy(['entityType' => $entityType, 'entityId' => $entityId, 'code' => $code]);
|
||||
|
||||
// نقشی که این محیط ندارد **ساخته نمیشود**: نوع منبع تصمیم کلینیک است و
|
||||
// ساختن خاموشش یعنی فهرست نوعها پر شود از چیزهایی که کسی نخواسته.
|
||||
if ($type === null) {
|
||||
$missing[$code] = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
$this->em->persist(new SegmentRequirement($template, $type));
|
||||
}
|
||||
}
|
||||
|
||||
$this->em->flush();
|
||||
|
||||
$io->success(sprintf('%d بخش برای «%s» ساخته شد.', $sequence, $service->getName()));
|
||||
|
||||
if ($missing !== []) {
|
||||
$io->note(sprintf(
|
||||
'این نقشها در این محیط تعریف نشدهاند و نیازمندیشان ساخته نشد: %s',
|
||||
implode('، ', array_keys($missing)),
|
||||
));
|
||||
}
|
||||
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
}
|
||||
@@ -42,11 +42,15 @@ final class ResourceUtilizationReporter
|
||||
$occupied = $this->occupiedMinutes($resources, $from, $to);
|
||||
$active = $this->activeMinutes($resources, $from, $to);
|
||||
|
||||
// تقویم همهٔ منابع هم دستهای خوانده میشود؛ وگرنه هر منبع پنج کوئری اضافه
|
||||
// میآورد و گزارشِ یک کلینیک متوسط دویست کوئری میشد.
|
||||
$availability = $this->calendars->rawAvailabilityForAll($resources, $from, $to);
|
||||
|
||||
$rows = [];
|
||||
|
||||
foreach ($resources as $resource) {
|
||||
$id = (int) $resource->getId();
|
||||
$available = $this->availableMinutes($resource, $from, $to);
|
||||
$available = $this->availableMinutes($resource, $availability[$id] ?? []);
|
||||
|
||||
$rows[] = $this->row(
|
||||
$resource,
|
||||
@@ -80,11 +84,9 @@ final class ResourceUtilizationReporter
|
||||
];
|
||||
}
|
||||
|
||||
private function availableMinutes(ClinicResource $resource, int $from, int $to): int
|
||||
/** @param list<\App\Resource\ValueObject\DayAvailability> $days */
|
||||
private function availableMinutes(ClinicResource $resource, array $days): int
|
||||
{
|
||||
// شعبه از خودِ منبع میآید؛ منبع بدون شعبه وجود ندارد.
|
||||
$days = $this->calendars->rawAvailability($resource, $from, $to);
|
||||
|
||||
$minutes = 0;
|
||||
|
||||
foreach ($days as $day) {
|
||||
|
||||
@@ -29,6 +29,34 @@ class ResourceCalendarRepository extends ServiceEntityRepository
|
||||
->getResult();
|
||||
}
|
||||
|
||||
/**
|
||||
* شیفتهای چند منبع با **یک** کوئری — گزارش بهرهوری روی چهل منبع، چهل کوئری نمیخواهد.
|
||||
*
|
||||
* @param int[] $resourceIds
|
||||
* @return array<int, list<\App\Resource\Entity\ResourceCalendar>> کلید: شناسهٔ منبع
|
||||
*/
|
||||
public function findForResources(array $resourceIds): array
|
||||
{
|
||||
if ($resourceIds === []) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$rows = $this->createQueryBuilder('c')
|
||||
->where('IDENTITY(c.resource) IN (:ids)')
|
||||
->setParameter('ids', $resourceIds)
|
||||
->orderBy('c.dayOfWeek', 'ASC')
|
||||
->addOrderBy('c.sequence', 'ASC')
|
||||
->getQuery()
|
||||
->getResult();
|
||||
|
||||
$byResource = [];
|
||||
foreach ($rows as $row) {
|
||||
$byResource[(int) $row->getResource()->getId()][] = $row;
|
||||
}
|
||||
|
||||
return $byResource;
|
||||
}
|
||||
|
||||
public function deleteForResource(ClinicResource $resource): int
|
||||
{
|
||||
return (int) $this->createQueryBuilder('c')
|
||||
|
||||
@@ -41,4 +41,35 @@ class ResourceExceptionRepository extends ServiceEntityRepository
|
||||
->getQuery()
|
||||
->getResult();
|
||||
}
|
||||
|
||||
/**
|
||||
* استثناهای چند منبع با یک کوئری.
|
||||
*
|
||||
* @param int[] $resourceIds
|
||||
* @return array<int, list<\App\Resource\Entity\ResourceException>>
|
||||
*/
|
||||
public function findOverlappingForResources(array $resourceIds, int $from, int $to): array
|
||||
{
|
||||
if ($resourceIds === []) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$rows = $this->createQueryBuilder('e')
|
||||
->where('IDENTITY(e.resource) IN (:ids)')
|
||||
->andWhere('e.startsAt < :to')
|
||||
->andWhere('e.endsAt > :from')
|
||||
->setParameter('ids', $resourceIds)
|
||||
->setParameter('from', $from)
|
||||
->setParameter('to', $to)
|
||||
->orderBy('e.startsAt', 'ASC')
|
||||
->getQuery()
|
||||
->getResult();
|
||||
|
||||
$byResource = [];
|
||||
foreach ($rows as $row) {
|
||||
$byResource[(int) $row->getResource()->getId()][] = $row;
|
||||
}
|
||||
|
||||
return $byResource;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -86,6 +86,75 @@ final class ResourceAvailabilityService
|
||||
return $days;
|
||||
}
|
||||
|
||||
/**
|
||||
* همان `rawAvailability` برای چند منبع، ولی با خواندنِ دستهای.
|
||||
*
|
||||
* تعطیلات و استثناهای محیط و ساعت شعبه برای همهٔ منابع یکیاند و بیرون حلقه خوانده
|
||||
* میشوند؛ شیفت و استثنای هر منبع هم با یک کوئری برای همه میآید. بدون این، گزارشِ
|
||||
* چهل منبع دویست کوئری میزد.
|
||||
*
|
||||
* @param ClinicResource[] $resources همهٔ آنها باید یک شعبه داشته باشند
|
||||
* @return array<int, list<DayAvailability>> کلید: شناسهٔ منبع
|
||||
*/
|
||||
public function rawAvailabilityForAll(array $resources, int $from, int $to): array
|
||||
{
|
||||
if ($resources === []) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$first = $resources[array_key_first($resources)];
|
||||
$timezone = new \DateTimeZone($first->getAddress()->getTimezone());
|
||||
$startDay = $this->midnight($from, $timezone);
|
||||
$endDay = $this->midnight($to, $timezone);
|
||||
|
||||
$holidayMap = $this->holidays->mapForRange($startDay, $endDay);
|
||||
$overrideMap = $this->overrides->mapForRange(
|
||||
$first->getEntityType(),
|
||||
$first->getEntityId(),
|
||||
$startDay,
|
||||
$endDay,
|
||||
);
|
||||
|
||||
$branchByDay = $this->branchHoursByDay($first);
|
||||
|
||||
$ids = array_map(static fn (ClinicResource $r): int => (int) $r->getId(), $resources);
|
||||
$shiftsById = $this->calendars->findForResources($ids);
|
||||
$exceptionsById = $this->exceptions->findOverlappingForResources($ids, $startDay, $endDay + self::DAY_SECONDS);
|
||||
|
||||
$out = [];
|
||||
|
||||
foreach ($resources as $resource) {
|
||||
$id = (int) $resource->getId();
|
||||
$byDay = [];
|
||||
|
||||
foreach ($shiftsById[$id] ?? [] as $shift) {
|
||||
if ($shift->isActive()) {
|
||||
$byDay[$shift->getDayOfWeek()][] = new TimeInterval($shift->getStartMinute(), $shift->getEndMinute());
|
||||
}
|
||||
}
|
||||
|
||||
$shiftsByDay = array_map(TimeInterval::mergeAll(...), $byDay);
|
||||
$days = [];
|
||||
|
||||
for ($day = $startDay; $day <= $endDay; $day = $this->nextMidnight($day, $timezone)) {
|
||||
$days[] = $this->buildDay(
|
||||
$resource,
|
||||
$day,
|
||||
$timezone,
|
||||
$shiftsByDay,
|
||||
$branchByDay,
|
||||
$holidayMap,
|
||||
$overrideMap,
|
||||
$exceptionsById[$id] ?? [],
|
||||
);
|
||||
}
|
||||
|
||||
$out[$id] = $days;
|
||||
}
|
||||
|
||||
return $out;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, list<TimeInterval>> $shiftsByDay
|
||||
* @param array<int, list<TimeInterval>>|null $branchByDay
|
||||
|
||||
Reference in New Issue
Block a user