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:
@@ -16,8 +16,10 @@ use App\Insurance\Repository\InsuranceRepository;
|
||||
use App\Location\Repository\CityRepository;
|
||||
use App\Location\Repository\ProvinceRepository;
|
||||
use App\Specialty\Repository\SpecialtyRepository;
|
||||
use App\PracticeDomain\Repository\PracticeDomainRepository;
|
||||
use App\Shared\Constant\ErrorCodes;
|
||||
use App\Shared\Controller\BaseController;
|
||||
use App\Shared\Exception\AppException;
|
||||
use App\Shared\Service\FileValidatorService;
|
||||
use OpenApi\Attributes as OA;
|
||||
use Symfony\Component\HttpFoundation\JsonResponse;
|
||||
@@ -43,6 +45,7 @@ class ClinicController extends BaseController
|
||||
private readonly CityRepository $cityRepo,
|
||||
private readonly UserRepository $userRepo,
|
||||
private readonly WeeklyScheduleRepository $scheduleRepo,
|
||||
private readonly PracticeDomainRepository $practiceDomains,
|
||||
private readonly \App\Clinic\Repository\ClinicDoctorPermissionRepository $permRepo,
|
||||
private readonly \App\Clinic\Security\ClinicDoctorPermissionChecker $permChecker,
|
||||
private readonly FileValidatorService $fileValidator,
|
||||
@@ -532,6 +535,20 @@ class ClinicController extends BaseController
|
||||
if (array_key_exists('latitude', $data)) $clinic->setLatitude((float) $data['latitude']);
|
||||
if (array_key_exists('longitude', $data)) $clinic->setLongitude((float) $data['longitude']);
|
||||
|
||||
// حوزهٔ فعالیت: رشتهٔ خالی یا null یعنی «پاک کن»، کلید نبودن یعنی «دست نزن».
|
||||
// uuid ناشناس بیصدا رد نمیشود چون انتخابِ نادرست تا اولین نوبتِ پروتکلدار
|
||||
// پیدا نمیشد — و آنوقت مدیر فکر میکرد تنظیمش ذخیره شده.
|
||||
if (array_key_exists('practice_domain_uuid', $data)) {
|
||||
$domainUuid = is_string($data['practice_domain_uuid']) ? trim($data['practice_domain_uuid']) : '';
|
||||
$domain = $domainUuid === '' ? null : $this->practiceDomains->findByUuid($domainUuid);
|
||||
|
||||
if ($domainUuid !== '' && $domain === null) {
|
||||
throw new AppException(ErrorCodes::ERR_VALIDATION_002, 'حوزه فعالیت یافت نشد', 422, 'practice_domain_uuid');
|
||||
}
|
||||
|
||||
$clinic->setPracticeDomain($domain);
|
||||
}
|
||||
|
||||
// Location
|
||||
if (!empty($data['state']) && is_array($data['state'])) {
|
||||
$clinic->setProvinceId((int) $data['state'][0]);
|
||||
|
||||
@@ -36,6 +36,16 @@ class Clinic
|
||||
#[ORM\Column(type: 'string', length: 255, nullable: true)]
|
||||
private ?string $name = null;
|
||||
|
||||
/**
|
||||
* حوزهٔ فعالیت کلینیک — تعیین میکند کدام `TreatmentWorkflow` صدا زده شود.
|
||||
*
|
||||
* `null` یعنی تنظیمنشده و رفتار پیشفرض، نه خطا: کلینیکهای موجود بدون انتخاب
|
||||
* حوزه باید دقیقاً مثل امروز کار کنند.
|
||||
*/
|
||||
#[ORM\ManyToOne(targetEntity: \App\PracticeDomain\Entity\PracticeDomain::class)]
|
||||
#[ORM\JoinColumn(name: 'practice_domain_id', referencedColumnName: 'id', nullable: true, onDelete: 'SET NULL')]
|
||||
private ?\App\PracticeDomain\Entity\PracticeDomain $practiceDomain = null;
|
||||
|
||||
#[ORM\Column(type: 'text', nullable: true)]
|
||||
private ?string $info = null;
|
||||
|
||||
@@ -153,6 +163,7 @@ class Clinic
|
||||
public function getCreatedAt(): int { return $this->createdAt; }
|
||||
public function getUpdatedAt(): int { return $this->updatedAt; }
|
||||
public function getDoctors(): Collection { return $this->doctors; }
|
||||
public function getPracticeDomain(): ?\App\PracticeDomain\Entity\PracticeDomain { return $this->practiceDomain; }
|
||||
|
||||
public function hasDoctor(Doctor $doctor): bool { return $this->doctors->contains($doctor); }
|
||||
|
||||
@@ -184,6 +195,7 @@ class Clinic
|
||||
public function setSocialMedia(?array $v): self { $this->socialMedia = $v; $this->touch(); return $this; }
|
||||
public function setClinicLogo(?string $v): self { $this->clinicLogo = $v; $this->touch(); return $this; }
|
||||
public function setNotificationMobile(?string $v): self { $this->notificationMobile = $v; $this->touch(); return $this; }
|
||||
public function setPracticeDomain(?\App\PracticeDomain\Entity\PracticeDomain $v): self { $this->practiceDomain = $v; $this->touch(); return $this; }
|
||||
|
||||
private function touch(): void { $this->updatedAt = time(); }
|
||||
|
||||
@@ -231,6 +243,7 @@ class Clinic
|
||||
], $this->specialties->toArray()),
|
||||
'doctors' => $this->doctors->count(),
|
||||
'doctor_list' => null,
|
||||
'practice_domain' => $this->practiceDomain?->toArray(),
|
||||
'city' => $cityData ? [$cityData] : [],
|
||||
'state' => $provinceData ? [$provinceData] : [],
|
||||
'location' => $address,
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -25,6 +25,7 @@ final class GlobalTables
|
||||
\App\Location\Entity\Province::class => 'تقسیمات کشوری',
|
||||
\App\Location\Entity\City::class => 'تقسیمات کشوری',
|
||||
\App\Specialty\Entity\Specialty::class => 'تاکسونومی سراسری تخصصها',
|
||||
\App\PracticeDomain\Entity\PracticeDomain::class => 'تاکسونومی سراسری حوزهٔ فعالیت؛ محیط آن را انتخاب میکند نه مالکش، و کدش لنگرِ پیادهسازیهای TreatmentWorkflow است',
|
||||
\App\DoctorService\Entity\DoctorService::class => 'تاکسونومی سراسری خدمات، وابسته به تخصص نه به محیط',
|
||||
\App\Insurance\Entity\Insurance::class => 'فهرست بیمههای کشور',
|
||||
\App\Insurance\Entity\InsuranceCoverageDefault::class => 'پیشفرض پوشش بیمه در سطح کشور؛ هر محیط با TenantInsurance بازنویسیاش میکند',
|
||||
|
||||
Reference in New Issue
Block a user