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:
hamed
2026-08-01 14:22:22 +03:30
co-authored by Claude Opus 5
parent 92edd175cc
commit b3c331f0cb
20 changed files with 624 additions and 30 deletions
@@ -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;
}
}