feat: port tauri create-service page as 3-step new-session wizard

Backend:
- Add session_at and inventory_package_id to patient_sessions, new
  session_consumables table (migration Version20260716102537)
- New SessionConsumable entity/repository mirroring SessionService;
  price snapshot, quantity >= 1, tenant-scoped silent skip
- PatientService::createSession accepts session_at, consumables[] and
  inventory_package_uuid; consumables are fully patient-paid (no
  insurance coverage) and added to final_price_rials
- Functional tests: success, foreign-tenant/unknown skip, empty and
  zero-quantity edges (tests/Patient/SessionConsumableTest.php)
- docs/api/patient.md updated for the new Create Session fields

Frontend (admin):
- NewSessionPage rewritten as the tauri /files/create-service 3-step
  wizard (ایجاد سرویس ← پرداخت ← جزییات) using SessionStepper
- New CreateStep: acceptance date/time (Jalali), section/service/staff,
  consumables with counters, package select, conditional insurance
  block (insured service or insured patient profile), price summary
- PaymentStep/DetailsStep extracted from SessionPaymentPage and shared
  between both pages (behavior unchanged, tests still green)
- UserTick and FilesServiceAddCard icons ported verbatim from tauri
- Vitest coverage for the wizard incl. empty-data states

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
hamed
2026-07-16 14:20:32 +03:30
co-authored by Claude Opus 4.8
parent d7b06108f0
commit 07222826c3
14 changed files with 1462 additions and 550 deletions
+45
View File
@@ -3,6 +3,7 @@
namespace App\Patient\Entity;
use App\Appointment\Entity\Appointment;
use App\Inventory\Entity\InventoryPackage;
use App\Patient\Repository\PatientSessionRepository;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
@@ -30,6 +31,15 @@ class PatientSession
#[ORM\JoinColumn(nullable: true, onDelete: 'SET NULL')]
private ?Appointment $appointment = null;
/** زمان پذیرش (unix)؛ اگر ست نشود همان زمان ثبت است */
#[ORM\Column(name: 'session_at', type: 'integer', nullable: true)]
private ?int $sessionAt = null;
/** پکیج مصرفی انتخاب‌شده برای این مراجعه (اختیاری، فقط مرجع) */
#[ORM\ManyToOne(targetEntity: InventoryPackage::class)]
#[ORM\JoinColumn(name: 'inventory_package_id', nullable: true, onDelete: 'SET NULL')]
private ?InventoryPackage $inventoryPackage = null;
#[ORM\Column(name: 'insurance_base_id', type: 'integer', nullable: true)]
private ?int $insuranceBaseId = null;
@@ -85,6 +95,9 @@ class PatientSession
#[ORM\OneToMany(targetEntity: SessionPayment::class, mappedBy: 'session', cascade: ['remove'])]
private Collection $payments;
#[ORM\OneToMany(targetEntity: SessionConsumable::class, mappedBy: 'session', cascade: ['remove'])]
private Collection $consumables;
public function __construct(PatientRecord $record, ?Appointment $appointment = null)
{
$this->uuid = Uuid::v4()->toRfc4122();
@@ -94,6 +107,7 @@ class PatientSession
$this->updatedAt = time();
$this->services = new ArrayCollection();
$this->payments = new ArrayCollection();
$this->consumables = new ArrayCollection();
}
public function getId(): ?int { return $this->id; }
@@ -146,6 +160,27 @@ class PatientSession
return max(0, $this->finalPriceRials - $this->discountRials - $this->getPaidTotalRials());
}
public function getSessionAt(): ?int { return $this->sessionAt; }
public function getInventoryPackage(): ?InventoryPackage { return $this->inventoryPackage; }
public function getConsumables(): Collection { return $this->consumables; }
public function addConsumable(SessionConsumable $consumable): self
{
if (!$this->consumables->contains($consumable)) {
$this->consumables->add($consumable);
}
return $this;
}
/** مجموع قیمت کالاهای مصرفی این مراجعه (ریال) */
public function getConsumablesTotalRials(): int
{
return array_sum(array_map(
fn(SessionConsumable $c) => $c->getLineTotalRials(),
$this->consumables->toArray(),
));
}
public function getNotes(): ?string { return $this->notes; }
public function getCreatedAt(): int { return $this->createdAt; }
public function getUpdatedAt(): int { return $this->updatedAt; }
@@ -166,6 +201,8 @@ class PatientSession
$this->updatedAt = time();
return $this;
}
public function setSessionAt(?int $v): self { $this->sessionAt = $v; $this->updatedAt = time(); return $this; }
public function setInventoryPackage(?InventoryPackage $v): self { $this->inventoryPackage = $v; $this->updatedAt = time(); return $this; }
public function setPaidAt(?int $v): self { $this->paidAt = $v; $this->updatedAt = time(); return $this; }
public function setNotes(?string $v): self { $this->notes = $v; $this->updatedAt = time(); return $this; }
@@ -199,6 +236,14 @@ class PatientSession
fn(SessionService $s) => $s->toArray(),
$this->services->toArray()
),
'session_at' => $this->sessionAt,
'inventory_package_uuid' => $this->inventoryPackage?->getUuid(),
'inventory_package_title' => $this->inventoryPackage?->getTitle(),
'consumables' => array_map(
fn(SessionConsumable $c) => $c->toArray(),
$this->consumables->toArray()
),
'consumables_total_rials' => $this->getConsumablesTotalRials(),
'notes' => $this->notes,
'created_at' => $this->createdAt,
'updated_at' => $this->updatedAt,
+75
View File
@@ -0,0 +1,75 @@
<?php
namespace App\Patient\Entity;
use App\Inventory\Entity\InventoryItem;
use App\Patient\Repository\SessionConsumableRepository;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Uid\Uuid;
/**
* A consumable (inventory item) used during a patient session. Price is a
* snapshot of the item's unit price at creation time, mirroring {@see SessionService}.
*/
#[ORM\Entity(repositoryClass: SessionConsumableRepository::class)]
#[ORM\Table(name: 'session_consumables')]
class SessionConsumable
{
#[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: PatientSession::class, inversedBy: 'consumables')]
#[ORM\JoinColumn(nullable: false, onDelete: 'CASCADE')]
private PatientSession $session;
#[ORM\ManyToOne(targetEntity: InventoryItem::class)]
#[ORM\JoinColumn(name: 'inventory_item_id', nullable: false, onDelete: 'RESTRICT')]
private InventoryItem $item;
#[ORM\Column(name: 'price_rials', type: 'integer')]
private int $priceRials;
#[ORM\Column(type: 'integer')]
private int $quantity = 1;
#[ORM\Column(name: 'created_at', type: 'integer')]
private int $createdAt;
public function __construct(PatientSession $session, InventoryItem $item, int $quantity = 1)
{
$this->uuid = Uuid::v4()->toRfc4122();
$this->session = $session;
$this->item = $item;
$this->priceRials = $item->getPrice();
$this->quantity = max(1, $quantity);
$this->createdAt = time();
}
public function getId(): ?int { return $this->id; }
public function getUuid(): string { return $this->uuid; }
public function getSession(): PatientSession { return $this->session; }
public function getItem(): InventoryItem { return $this->item; }
public function getPriceRials(): int { return $this->priceRials; }
public function getQuantity(): int { return $this->quantity; }
public function getLineTotalRials(): int { return $this->priceRials * $this->quantity; }
public function getCreatedAt(): int { return $this->createdAt; }
public function toArray(): array
{
return [
'uuid' => $this->uuid,
'inventory_item_uuid' => $this->item->getUuid(),
'item_name' => $this->item->getName(),
'unit' => $this->item->getUnit(),
'price_rials' => $this->priceRials,
'quantity' => $this->quantity,
'line_total_rials' => $this->getLineTotalRials(),
'created_at' => $this->createdAt,
];
}
}