feat: implement specialty hierarchy handling in doctor and representation APIs, add backfill command and tests

This commit is contained in:
hamed
2026-07-19 17:30:46 +03:30
parent 21b67ec075
commit 6496ebf336
12 changed files with 539 additions and 12 deletions
@@ -0,0 +1,100 @@
<?php
namespace App\Doctor\Command;
use App\Doctor\Entity\Doctor;
use App\Specialty\Entity\Specialty;
use App\Specialty\Repository\SpecialtyRepository;
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;
/**
* backfill: پزشکانی که فقط تخصص فرزند دارند (مثلاً «گوارش و کبد» بدون «داخلی»)
* تمام تخصص‌های والد تا ریشهٔ درخت را می‌گیرند. idempotent؛ با --dry-run فقط گزارش می‌دهد.
*
* php bin/console app:doctors:backfill-specialty-parents --dry-run
* php bin/console app:doctors:backfill-specialty-parents
*/
#[AsCommand(
name: 'app:doctors:backfill-specialty-parents',
description: 'Attach every ancestor specialty to doctors that only have the child one (idempotent, supports --dry-run)',
)]
class BackfillDoctorSpecialtyParentsCommand extends Command
{
public function __construct(
private readonly EntityManagerInterface $em,
private readonly SpecialtyRepository $specialtyRepo,
) {
parent::__construct();
}
protected function configure(): void
{
$this->addOption('dry-run', null, InputOption::VALUE_NONE, 'Report only, change nothing');
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$io = new SymfonyStyle($input, $output);
$dryRun = (bool) $input->getOption('dry-run');
/** @var Doctor[] $doctors */
$doctors = $this->em->getRepository(Doctor::class)->findAll();
$touched = 0;
$added = 0;
foreach ($doctors as $doctor) {
$current = array_map(
static fn (Specialty $s) => $s->getId(),
$doctor->getSpecialties()->toArray()
);
if ($current === []) {
continue;
}
$missing = array_diff($this->specialtyRepo->expandWithAncestors($current), $current);
if ($missing === []) {
continue;
}
$io->text(sprintf(
'%s doctor #%d — adding specialty %s',
$dryRun ? '[dry-run]' : '[update]',
$doctor->getId(),
implode(', ', $missing)
));
if (!$dryRun) {
foreach ($missing as $id) {
$s = $this->specialtyRepo->find($id);
if ($s !== null) {
$doctor->getSpecialties()->add($s);
}
}
}
$touched++;
$added += count($missing);
}
if (!$dryRun && $touched > 0) {
$this->em->flush();
}
$io->success(sprintf(
'%d doctor(s) %s, %d specialty link(s) added (of %d scanned)',
$touched,
$dryRun ? 'would be updated' : 'updated',
$added,
count($doctors)
));
return Command::SUCCESS;
}
}
+2 -2
View File
@@ -792,8 +792,8 @@ class DoctorController extends BaseController
// Specialties
if (array_key_exists('specialties', $data) && is_array($data['specialties'])) {
$doctor->getSpecialties()->clear();
foreach ($data['specialties'] as $id) {
$s = $this->specialtyRepo->find((int) $id);
foreach ($this->specialtyRepo->expandWithAncestors(array_map('intval', $data['specialties'])) as $id) {
$s = $this->specialtyRepo->find($id);
if ($s !== null) $doctor->getSpecialties()->add($s);
}
}
+6 -1
View File
@@ -112,7 +112,12 @@ class DoctorImportService
if (array_key_exists('info', $data)) $doctor->setInfo($data['info']);
// روابط بر پایهٔ شناسه‌های مرجع (تخصص/استان/شهر)
$this->syncRefCollection($doctor->getSpecialties(), $data['specialties'] ?? null, Specialty::class);
$specialtyIds = $data['specialties'] ?? null;
if (is_array($specialtyIds)) {
$specialtyIds = $this->em->getRepository(Specialty::class)
->expandWithAncestors(array_map('intval', $specialtyIds));
}
$this->syncRefCollection($doctor->getSpecialties(), $specialtyIds, Specialty::class);
$this->syncRefCollection($doctor->getProvinces(), $data['states'] ?? null, Province::class);
$this->syncRefCollection($doctor->getCities(), $data['cities'] ?? null, City::class);