feat(practice-domain): add practice domains and let a clinic select one

A practice domain is the field a clinic operates in — beauty, dentistry —
and unlike Specialty it is configuration, not a label: treatment workflows
will bind to its code, so the code is immutable once created and only a
platform admin can mint one. A clinic that has not chosen a domain keeps
behaving exactly as it does today.

Assignment reuses PATCH /api/v1/clinic/{uuid} rather than adding a second
endpoint. An unknown domain uuid is rejected instead of silently dropped,
because a lost selection would only surface at the first protocol-driven
booking.

Also corrects ADR-0003: resource occupancy does not in fact guard the panel
booking path, which writes appointments.resource_id and no occupancy row at
all, so the doctor slot key cannot simply be dropped.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
hamed
2026-08-06 16:06:29 +03:30
co-authored by Claude Opus 5
parent 1d43475724
commit 85985b04a0
13 changed files with 736 additions and 23 deletions
@@ -0,0 +1,113 @@
<?php
namespace App\PracticeDomain\Controller;
use App\PracticeDomain\Entity\PracticeDomain;
use App\PracticeDomain\Repository\PracticeDomainRepository;
use App\Shared\Constant\ErrorCodes;
use App\Shared\Controller\BaseController;
use Doctrine\ORM\EntityManagerInterface;
use OpenApi\Attributes as OA;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;
#[OA\Tag(name: 'PracticeDomain')]
#[IsGranted('IS_AUTHENTICATED_FULLY')]
class PracticeDomainController extends BaseController
{
public function __construct(
private readonly PracticeDomainRepository $domains,
private readonly EntityManagerInterface $em,
) {}
/**
* فهرست حوزه‌ها برای انتخاب در تنظیمات کلینیک.
*
* غیرفعال‌ها فقط برای ادمین پلتفرم برمی‌گردند؛ مدیر کلینیک نباید حوزه‌ای را
* انتخاب کند که پلتفرم بازنشسته‌اش کرده.
*/
#[Route('/api/v1/practice-domains', name: 'practice_domain_list', methods: ['GET'])]
public function list(): JsonResponse
{
$includeInactive = $this->isGranted('ROLE_ADMIN');
return $this->success(array_map(
static fn (PracticeDomain $d): array => $d->toArray(),
$this->domains->findOrdered($includeInactive),
));
}
#[Route('/api/v1/practice-domains', name: 'practice_domain_create', methods: ['POST'])]
#[IsGranted('ROLE_ADMIN')]
public function create(Request $request): JsonResponse
{
$data = json_decode($request->getContent(), true);
if (!is_array($data)) {
return $this->error(ErrorCodes::ERR_VALIDATION_001, 'بدنهٔ درخواست نامعتبر است', 422);
}
$code = is_string($data['code'] ?? null) ? trim($data['code']) : '';
$name = is_string($data['name'] ?? null) ? trim($data['name']) : '';
if (preg_match(PracticeDomain::CODE_PATTERN, $code) !== 1) {
return $this->error(ErrorCodes::ERR_VALIDATION_001, 'کد فقط حروف کوچک انگلیسی، عدد و زیرخط می‌پذیرد', 422, 'code');
}
if ($name === '') {
return $this->error(ErrorCodes::ERR_VALIDATION_002, 'نام حوزه فعالیت الزامی است', 422, 'name');
}
if ($this->domains->findByCode($code) !== null) {
return $this->error(ErrorCodes::ERR_VALIDATION_001, 'حوزه فعالیتی با این کد از قبل وجود دارد', 422, 'code');
}
$domain = new PracticeDomain($code, $name);
if (isset($data['sort_order'])) {
$domain->setSortOrder((int) $data['sort_order']);
}
$this->em->persist($domain);
$this->em->flush();
return $this->success($domain->toArray(), 201);
}
#[Route('/api/v1/practice-domain/{uuid}', name: 'practice_domain_update', methods: ['PATCH'])]
#[IsGranted('ROLE_ADMIN')]
public function update(string $uuid, Request $request): JsonResponse
{
$data = json_decode($request->getContent(), true);
if (!is_array($data)) {
return $this->error(ErrorCodes::ERR_VALIDATION_001, 'بدنهٔ درخواست نامعتبر است', 422);
}
$domain = $this->domains->findByUuid($uuid);
if ($domain === null) {
return $this->error(ErrorCodes::ERR_VALIDATION_002, 'حوزه فعالیت یافت نشد', 404);
}
// `code` تغییر نمی‌کند: پیاده‌سازی‌های TreatmentWorkflow روی همین کد سوار
// می‌شوند و عوض کردنش workflow را بی‌صدا از کار می‌اندازد.
if (is_string($data['name'] ?? null) && trim($data['name']) !== '') {
$domain->setName(trim($data['name']));
}
if (isset($data['sort_order'])) {
$domain->setSortOrder((int) $data['sort_order']);
}
if (array_key_exists('active', $data)) {
$domain->setActive((bool) $data['active']);
}
$this->em->flush();
return $this->success($domain->toArray());
}
}
@@ -0,0 +1,91 @@
<?php
namespace App\PracticeDomain\Entity;
use App\PracticeDomain\Repository\PracticeDomainRepository;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Uid\Uuid;
/**
* حوزهٔ فعالیت یک محیط درمانی — زیبایی، دندانپزشکی، ارتوپدی.
*
* برخلاف {@see \App\Specialty\Entity\Specialty} که برچسبی توصیفی برای سایت عمومی
* است، این یک کلید پیکربندی است: `TreatmentWorkflow` روی `code` سوار می‌شود، پس کد
* بعد از ساخت تغییر نمی‌کند — تغییرش یعنی گم شدن بی‌صدای workflow.
*/
#[ORM\Entity(repositoryClass: PracticeDomainRepository::class)]
#[ORM\Table(name: 'practice_domains')]
#[ORM\UniqueConstraint(name: 'uq_practice_domains_code', columns: ['code'])]
#[ORM\Index(columns: ['active', 'sort_order'], name: 'idx_practice_domains_active')]
class PracticeDomain
{
public const CODE_PATTERN = '/^[a-z0-9_]{1,40}$/';
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column(type: 'integer')]
private ?int $id = null;
#[ORM\Column(type: 'string', length: 36, unique: true)]
private string $uuid;
#[ORM\Column(type: 'string', length: 40)]
private string $code;
#[ORM\Column(type: 'string', length: 100)]
private string $name;
#[ORM\Column(name: 'sort_order', type: 'smallint', options: ['default' => 0])]
private int $sortOrder = 0;
#[ORM\Column(type: 'boolean', options: ['default' => true])]
private bool $active = true;
#[ORM\Column(name: 'created_at', type: 'integer')]
private int $createdAt;
#[ORM\Column(name: 'updated_at', type: 'integer')]
private int $updatedAt;
public function __construct(string $code, string $name)
{
$this->uuid = Uuid::v4()->toRfc4122();
$this->code = $code;
$this->name = $name;
$this->createdAt = time();
$this->updatedAt = time();
}
public function getId(): ?int { return $this->id; }
public function getUuid(): string { return $this->uuid; }
public function getCode(): string { return $this->code; }
public function getName(): string { return $this->name; }
public function getSortOrder(): int { return $this->sortOrder; }
public function isActive(): bool { return $this->active; }
public function setName(string $v): self { $this->name = $v; $this->touch(); return $this; }
public function setSortOrder(int $v): self { $this->sortOrder = $v; $this->touch(); return $this; }
public function setActive(bool $v): self { $this->active = $v; $this->touch(); return $this; }
/**
* @param bool|null $hasWorkflow آیا پیاده‌سازی workflow برای این کد ثبت شده؛ null یعنی پرسیده نشده
*/
public function toArray(?bool $hasWorkflow = null): array
{
$data = [
'uuid' => $this->uuid,
'code' => $this->code,
'name' => $this->name,
'sort_order' => $this->sortOrder,
'active' => $this->active,
];
if ($hasWorkflow !== null) {
$data['has_workflow'] = $hasWorkflow;
}
return $data;
}
private function touch(): void { $this->updatedAt = time(); }
}
@@ -0,0 +1,48 @@
<?php
namespace App\PracticeDomain\Repository;
use App\PracticeDomain\Entity\PracticeDomain;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
/**
* @extends ServiceEntityRepository<PracticeDomain>
*/
class PracticeDomainRepository extends ServiceEntityRepository
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, PracticeDomain::class);
}
public function findByUuid(string $uuid): ?PracticeDomain
{
return $this->findOneBy(['uuid' => $uuid]);
}
public function findByCode(string $code): ?PracticeDomain
{
return $this->findOneBy(['code' => $code]);
}
/**
* @param bool $includeInactive مدیر پلتفرم غیرفعال‌ها را هم می‌بیند تا بتواند دوباره فعالشان کند
*
* @return PracticeDomain[]
*/
public function findOrdered(bool $includeInactive = false): array
{
$qb = $this->createQueryBuilder('d');
if (!$includeInactive) {
$qb->where('d.active = true');
}
return $qb
->orderBy('d.sortOrder', 'ASC')
->addOrderBy('d.name', 'ASC')
->getQuery()
->getResult();
}
}