Rebuild the patient records (پروندهها) area, phase A of the Figma redesign:
- BE: add a clinic-scoped `record_number` and a TenantTag `tags` M2M to
PatientRecord (migration + EAGER-hydrated collection). POST /patient and
PATCH /patient/{uuid} now accept `record_number` and tenant-scoped `tags`
(foreign tag → 422); demographic fields (gender, date_of_birth,
referral_source, description) continue to live on UserProfile via PATCH.
- FE: new PatientsListPage (table + card views, search, pagination, tags
column, "تشکیل پرونده") at /admin/patients, and PatientRecordFormPage
(create/edit) that POSTs the record then PATCHes the demographics. Point
the sidebar "پرونده" entry to the new list.
Phases B–E (tabbed patient file, service stepper, invoice, payments/wallet,
call-center) follow. Backend covered by PHPUnit, FE by Vitest.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
127 lines
4.7 KiB
PHP
127 lines
4.7 KiB
PHP
<?php
|
|
|
|
namespace App\Patient\Entity;
|
|
|
|
use App\Auth\Entity\User;
|
|
use App\Patient\Repository\PatientRecordRepository;
|
|
use App\Tag\Entity\TenantTag;
|
|
use Doctrine\Common\Collections\ArrayCollection;
|
|
use Doctrine\Common\Collections\Collection;
|
|
use Doctrine\ORM\Mapping as ORM;
|
|
use Symfony\Component\Uid\Uuid;
|
|
|
|
#[ORM\Entity(repositoryClass: PatientRecordRepository::class)]
|
|
#[ORM\Table(name: 'patient_records')]
|
|
#[ORM\UniqueConstraint(name: 'uniq_patient_record', columns: ['entity_type', 'entity_id', 'user_id'])]
|
|
class PatientRecord
|
|
{
|
|
#[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(name: 'entity_type', type: 'string', length: 10)]
|
|
private string $entityType;
|
|
|
|
#[ORM\Column(name: 'entity_id', type: 'integer')]
|
|
private int $entityId;
|
|
|
|
#[ORM\ManyToOne(targetEntity: User::class)]
|
|
#[ORM\JoinColumn(nullable: false, onDelete: 'RESTRICT')]
|
|
private User $user;
|
|
|
|
#[ORM\Column(name: 'created_by_type', type: 'string', length: 15)]
|
|
private string $createdByType;
|
|
|
|
#[ORM\Column(name: 'created_by_id', type: 'integer')]
|
|
private int $createdById;
|
|
|
|
#[ORM\Column(name: 'created_at', type: 'integer')]
|
|
private int $createdAt;
|
|
|
|
// Clinic-scoped case-file number. Patient identity/demographics (gender,
|
|
// date_of_birth, referral_source, description, insurance, …) live on the
|
|
// patient's UserProfile and are set via PATCH /patient/{uuid}.
|
|
#[ORM\Column(name: 'record_number', type: 'string', length: 40, nullable: true)]
|
|
private ?string $recordNumber = null;
|
|
|
|
/**
|
|
* @var Collection<int, TenantTag> record labels. EAGER so the typed
|
|
* collection is always hydrated (see ServiceItem for the same pitfall).
|
|
*/
|
|
#[ORM\ManyToMany(targetEntity: TenantTag::class, fetch: 'EAGER')]
|
|
#[ORM\JoinTable(name: 'patient_record_tags')]
|
|
private Collection $tags;
|
|
|
|
#[ORM\OneToMany(targetEntity: PatientSession::class, mappedBy: 'record', cascade: ['remove'])]
|
|
private Collection $sessions;
|
|
|
|
public function __construct(string $entityType, int $entityId, User $user, string $createdByType, int $createdById)
|
|
{
|
|
$this->uuid = Uuid::v4()->toRfc4122();
|
|
$this->entityType = $entityType;
|
|
$this->entityId = $entityId;
|
|
$this->user = $user;
|
|
$this->createdByType = $createdByType;
|
|
$this->createdById = $createdById;
|
|
$this->createdAt = time();
|
|
$this->sessions = new ArrayCollection();
|
|
$this->tags = new ArrayCollection();
|
|
}
|
|
|
|
public function getId(): ?int { return $this->id; }
|
|
public function getUuid(): string { return $this->uuid; }
|
|
public function getEntityType(): string { return $this->entityType; }
|
|
public function getEntityId(): int { return $this->entityId; }
|
|
public function getUser(): User { return $this->user; }
|
|
public function getCreatedByType(): string { return $this->createdByType; }
|
|
public function getCreatedById(): int { return $this->createdById; }
|
|
public function getCreatedAt(): int { return $this->createdAt; }
|
|
|
|
public function getRecordNumber(): ?string { return $this->recordNumber; }
|
|
public function setRecordNumber(?string $v): self { $this->recordNumber = $v; return $this; }
|
|
|
|
/** @return Collection<int, TenantTag> */
|
|
public function getTags(): Collection
|
|
{
|
|
// Doctrine hydrates without the constructor; guard the typed property.
|
|
return $this->tags ??= new ArrayCollection();
|
|
}
|
|
|
|
/** @param TenantTag[] $tags */
|
|
public function setTags(array $tags): self
|
|
{
|
|
$collection = $this->getTags();
|
|
$collection->clear();
|
|
foreach ($tags as $t) {
|
|
if (!$collection->contains($t)) {
|
|
$collection->add($t);
|
|
}
|
|
}
|
|
return $this;
|
|
}
|
|
|
|
public function toArray(): array
|
|
{
|
|
return [
|
|
'uuid' => $this->uuid,
|
|
'entity_type' => $this->entityType,
|
|
'entity_id' => $this->entityId,
|
|
'user_uuid' => $this->user->getUuid(),
|
|
'user_name' => $this->user->getRealName(),
|
|
'user_mobile' => $this->user->getMobileNumber(),
|
|
'user_national_code' => $this->user->getNationalCode(),
|
|
'record_number' => $this->recordNumber,
|
|
'tags' => array_map(
|
|
fn(TenantTag $t) => $t->toArray(),
|
|
array_values($this->getTags()->toArray())
|
|
),
|
|
'created_by_type' => $this->createdByType,
|
|
'created_at' => $this->createdAt,
|
|
];
|
|
}
|
|
}
|