- Introduced `consumables` field in `ServiceItem` to allow multiple individual items alongside inventory packages. - Created `ServiceItemConsumable` entity to manage individual consumable items linked to a service. - Updated `ServiceItemController` to handle CRUD operations for consumables. - Enhanced `ServiceDetailPage` and `ServiceItemFormModal` to display and manage consumables. - Added tests to ensure functionality for adding, updating, and validating consumables. - Updated API documentation to reflect changes in service item structure and consumables.
235 lines
9.9 KiB
PHP
235 lines
9.9 KiB
PHP
<?php
|
|
|
|
namespace App\ClinicService\Entity;
|
|
|
|
use App\ClinicService\Repository\ServiceItemRepository;
|
|
use App\Staff\Entity\ClinicStaff;
|
|
use Doctrine\Common\Collections\ArrayCollection;
|
|
use Doctrine\Common\Collections\Collection;
|
|
use Doctrine\ORM\Mapping as ORM;
|
|
use Symfony\Component\Uid\Uuid;
|
|
|
|
#[ORM\Entity(repositoryClass: ServiceItemRepository::class)]
|
|
#[ORM\Table(name: 'service_items')]
|
|
class ServiceItem
|
|
{
|
|
#[ORM\Id]
|
|
#[ORM\GeneratedValue]
|
|
#[ORM\Column(type: 'integer')]
|
|
private ?int $id = null;
|
|
|
|
#[ORM\Column(type: 'string', length: 36, unique: true)]
|
|
private string $uuid;
|
|
|
|
#[ORM\ManyToOne(targetEntity: ServiceSection::class, inversedBy: 'items')]
|
|
#[ORM\JoinColumn(nullable: false, onDelete: 'CASCADE')]
|
|
private ServiceSection $section;
|
|
|
|
// Legacy single-staff column, kept for backward compatibility with existing
|
|
// consumers (reception/session). Mirrors the first entry of $staffMembers.
|
|
#[ORM\ManyToOne(targetEntity: ClinicStaff::class)]
|
|
#[ORM\JoinColumn(nullable: true, onDelete: 'SET NULL')]
|
|
private ?ClinicStaff $staff = null;
|
|
|
|
/**
|
|
* @var Collection<int, ClinicStaff> personnel assigned to this service.
|
|
* EAGER so hydration always populates the typed property (avoids the
|
|
* "accessed before initialization" pitfall on lazy typed collections).
|
|
*/
|
|
#[ORM\ManyToMany(targetEntity: ClinicStaff::class, fetch: 'EAGER')]
|
|
#[ORM\JoinTable(name: 'service_item_staff')]
|
|
private Collection $staffMembers;
|
|
|
|
#[ORM\Column(type: 'string', length: 200)]
|
|
private string $name;
|
|
|
|
#[ORM\Column(name: 'price_rials', type: 'integer')]
|
|
private int $priceRials = 0;
|
|
|
|
#[ORM\Column(type: 'boolean')]
|
|
private bool $active = true;
|
|
|
|
#[ORM\Column(name: 'insurance_covered', type: 'boolean')]
|
|
private bool $insuranceCovered = false;
|
|
|
|
#[ORM\Column(name: 'insurance_price_rials', type: 'integer', nullable: true)]
|
|
private ?int $insurancePriceRials = null;
|
|
|
|
#[ORM\Column(name: 'duration_minutes', type: 'integer', nullable: true)]
|
|
private ?int $durationMinutes = null;
|
|
|
|
/** نمایش این سرویس در نوبتدهی (پزشک ممکن است همهٔ سرویسها را ارائه ندهد). */
|
|
#[ORM\Column(type: 'boolean', options: ['default' => false])]
|
|
private bool $bookable = false;
|
|
|
|
/**
|
|
* پکیج کالای مصرفی این خدمت ({@see \App\Inventory\Entity\InventoryPackage}).
|
|
* ارجاع خام int بدون FK — همان الگوی Tariff و TenantServiceCoverage — تا دامنهٔ
|
|
* ClinicService به Inventory وابسته نشود.
|
|
*/
|
|
#[ORM\Column(name: 'inventory_package_id', type: 'integer', nullable: true)]
|
|
private ?int $inventoryPackageId = null;
|
|
|
|
/**
|
|
* اقلام کالای تکیِ این خدمت — مستقل از پکیج و قابل استفاده همزمان با آن.
|
|
*
|
|
* @var Collection<int, ServiceItemConsumable>
|
|
*/
|
|
#[ORM\OneToMany(mappedBy: 'serviceItem', targetEntity: ServiceItemConsumable::class, cascade: ['persist', 'remove'], orphanRemoval: true)]
|
|
private Collection $consumables;
|
|
|
|
#[ORM\Column(name: 'created_at', type: 'integer')]
|
|
private int $createdAt;
|
|
|
|
#[ORM\Column(name: 'updated_at', type: 'integer')]
|
|
private int $updatedAt;
|
|
|
|
public function __construct(ServiceSection $section, string $name, int $priceRials = 0)
|
|
{
|
|
$this->uuid = Uuid::v4()->toRfc4122();
|
|
$this->section = $section;
|
|
$this->name = $name;
|
|
$this->priceRials = $priceRials;
|
|
$this->createdAt = time();
|
|
$this->updatedAt = time();
|
|
$this->staffMembers = new ArrayCollection();
|
|
$this->consumables = new ArrayCollection();
|
|
}
|
|
|
|
/** @return Collection<int, ServiceItemConsumable> */
|
|
public function getConsumables(): Collection
|
|
{
|
|
// Doctrine بدون constructor هیدریت میکند؛ از property تایپشده محافظت کن.
|
|
return $this->consumables ??= new ArrayCollection();
|
|
}
|
|
|
|
/**
|
|
* جایگزینی کامل اقلام تکی. کلید تطبیق، خودِ InventoryItem است تا ردیف بدون تغییر
|
|
* حذف و دوباره ساخته نشود.
|
|
*
|
|
* @param array<int, array{item: \App\Inventory\Entity\InventoryItem, amount: int}> $lines
|
|
*/
|
|
public function replaceConsumables(array $lines): self
|
|
{
|
|
$existing = [];
|
|
foreach ($this->getConsumables() as $consumable) {
|
|
$existing[$consumable->getItem()->getId()] = $consumable;
|
|
}
|
|
|
|
$keep = [];
|
|
foreach ($lines as $line) {
|
|
$itemId = $line['item']->getId();
|
|
$keep[] = $itemId;
|
|
|
|
if (isset($existing[$itemId])) {
|
|
$existing[$itemId]->setAmount($line['amount']);
|
|
continue;
|
|
}
|
|
|
|
$this->getConsumables()->add((new ServiceItemConsumable($line['item'], $line['amount']))->setServiceItem($this));
|
|
}
|
|
|
|
foreach ($existing as $itemId => $consumable) {
|
|
if (!in_array($itemId, $keep, true)) {
|
|
$this->getConsumables()->removeElement($consumable);
|
|
}
|
|
}
|
|
|
|
$this->updatedAt = time();
|
|
|
|
return $this;
|
|
}
|
|
|
|
public function getId(): ?int { return $this->id; }
|
|
public function getUuid(): string { return $this->uuid; }
|
|
public function getSection(): ServiceSection { return $this->section; }
|
|
public function getStaff(): ?ClinicStaff { return $this->staff; }
|
|
public function getName(): string { return $this->name; }
|
|
public function getPriceRials(): int { return $this->priceRials; }
|
|
public function isActive(): bool { return $this->active; }
|
|
public function isInsuranceCovered(): bool { return $this->insuranceCovered; }
|
|
public function getInsurancePriceRials(): ?int { return $this->insurancePriceRials; }
|
|
public function getDurationMinutes(): ?int { return $this->durationMinutes; }
|
|
public function isBookable(): bool { return $this->bookable; }
|
|
public function getInventoryPackageId(): ?int { return $this->inventoryPackageId; }
|
|
public function getCreatedAt(): int { return $this->createdAt; }
|
|
public function getUpdatedAt(): int { return $this->updatedAt; }
|
|
|
|
public function setStaff(?ClinicStaff $staff): self { $this->staff = $staff; $this->updatedAt = time(); return $this; }
|
|
|
|
/** @return Collection<int, ClinicStaff> */
|
|
public function getStaffMembers(): Collection
|
|
{
|
|
// Doctrine hydrates without the constructor; guard the typed property.
|
|
return $this->staffMembers ??= new ArrayCollection();
|
|
}
|
|
|
|
/**
|
|
* Replace the assigned personnel. Also mirrors the first member into the
|
|
* legacy single {@see $staff} column so back-compat consumers keep working.
|
|
*
|
|
* @param ClinicStaff[] $members
|
|
*/
|
|
public function setStaffMembers(array $members): self
|
|
{
|
|
$collection = $this->getStaffMembers();
|
|
$collection->clear();
|
|
foreach ($members as $m) {
|
|
if (!$collection->contains($m)) {
|
|
$collection->add($m);
|
|
}
|
|
}
|
|
$this->staff = $members[0] ?? null;
|
|
$this->updatedAt = time();
|
|
return $this;
|
|
}
|
|
public function setName(string $name): self { $this->name = $name; $this->updatedAt = time(); return $this; }
|
|
public function setPriceRials(int $price): self { $this->priceRials = $price; $this->updatedAt = time(); return $this; }
|
|
public function setActive(bool $active): self { $this->active = $active; $this->updatedAt = time(); return $this; }
|
|
public function setInsuranceCovered(bool $v): self { $this->insuranceCovered = $v; $this->updatedAt = time(); return $this; }
|
|
public function setInsurancePriceRials(?int $v): self { $this->insurancePriceRials = $v; $this->updatedAt = time(); return $this; }
|
|
public function setDurationMinutes(?int $v): self { $this->durationMinutes = $v; $this->updatedAt = time(); return $this; }
|
|
public function setBookable(bool $v): self { $this->bookable = $v; $this->updatedAt = time(); return $this; }
|
|
public function setInventoryPackageId(?int $v): self { $this->inventoryPackageId = $v; $this->updatedAt = time(); return $this; }
|
|
|
|
public function toArray(): array
|
|
{
|
|
// Prefer the multi-staff collection; fall back to the legacy single
|
|
// staff so rows created before the migration still expose personnel.
|
|
$members = array_values($this->getStaffMembers()->toArray());
|
|
if (empty($members) && $this->staff !== null) {
|
|
$members = [$this->staff];
|
|
}
|
|
$primary = $members[0] ?? null;
|
|
|
|
return [
|
|
'uuid' => $this->uuid,
|
|
'section_uuid' => $this->section->getUuid(),
|
|
'section_name' => $this->section->getName(),
|
|
'staff_uuid' => $primary?->getUuid(),
|
|
'staff_name' => $primary?->getFullName(),
|
|
'staff' => $primary !== null
|
|
? ['uuid' => $primary->getUuid(), 'full_name' => $primary->getFullName()]
|
|
: null,
|
|
'staff_members' => array_map(
|
|
fn(ClinicStaff $s) => ['uuid' => $s->getUuid(), 'full_name' => $s->getFullName()],
|
|
$members
|
|
),
|
|
'name' => $this->name,
|
|
'price_rials' => $this->priceRials,
|
|
'active' => $this->active,
|
|
'insurance_covered' => $this->insuranceCovered,
|
|
'insurance_price_rials' => $this->insurancePriceRials,
|
|
'duration_minutes' => $this->durationMinutes,
|
|
'bookable' => $this->bookable,
|
|
'inventory_package_id' => $this->inventoryPackageId,
|
|
'consumables' => array_map(
|
|
fn(ServiceItemConsumable $c) => $c->toArray(),
|
|
$this->getConsumables()->toArray()
|
|
),
|
|
'created_at' => $this->createdAt,
|
|
'updated_at' => $this->updatedAt,
|
|
];
|
|
}
|
|
}
|