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,
];
}
}
@@ -0,0 +1,21 @@
<?php
namespace App\Patient\Repository;
use App\Patient\Entity\SessionConsumable;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
class SessionConsumableRepository extends ServiceEntityRepository
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, SessionConsumable::class);
}
public function save(SessionConsumable $consumable): void
{
$this->getEntityManager()->persist($consumable);
$this->getEntityManager()->flush();
}
}
+43 -1
View File
@@ -9,14 +9,18 @@ use App\Billing\ValueObject\Money;
use App\ClinicService\Repository\ServiceItemRepository;
use App\Clinic\Repository\ClinicRepository;
use App\Insurance\Service\TenantInsuranceService;
use App\Inventory\Repository\InventoryItemRepository;
use App\Inventory\Repository\InventoryPackageRepository;
use App\Doctor\Repository\DoctorAddressRepository;
use App\Auth\Entity\User;
use App\Patient\Entity\PatientRecord;
use App\Patient\Entity\PatientSession;
use App\Patient\Entity\SessionConsumable;
use App\Patient\Entity\SessionPayment;
use App\Patient\Entity\SessionService;
use App\Patient\Repository\PatientRecordRepository;
use App\Patient\Repository\PatientSessionRepository;
use App\Patient\Repository\SessionConsumableRepository;
use App\Patient\Repository\SessionPaymentRepository;
use App\Patient\Repository\SessionServiceRepository;
use App\Settlement\Service\WalletService;
@@ -32,7 +36,10 @@ class PatientService
private readonly PatientSessionRepository $sessionRepo,
private readonly SessionServiceRepository $sessionServiceRepo,
private readonly SessionPaymentRepository $sessionPaymentRepo,
private readonly SessionConsumableRepository $sessionConsumableRepo,
private readonly ServiceItemRepository $serviceItemRepo,
private readonly InventoryItemRepository $inventoryItemRepo,
private readonly InventoryPackageRepository $inventoryPackageRepo,
private readonly ClinicStaffRepository $staffRepo,
private readonly UserRepository $userRepo,
private readonly SubscriptionService $subscriptionService,
@@ -145,6 +152,19 @@ class PatientService
$session->setPaymentMethod($data['payment_method'] ?? 'pending');
$session->setNotes($data['notes'] ?? null);
// زمان پذیرش (اختیاری — پیش‌فرض زمان ثبت)
if (!empty($data['session_at'])) {
$session->setSessionAt((int) $data['session_at']);
}
// پکیج مصرفی (اختیاری، فقط مرجع؛ باید متعلق به همین tenant باشد)
if (!empty($data['inventory_package_uuid'])) {
$package = $this->inventoryPackageRepo->findByUuid((string) $data['inventory_package_uuid']);
if ($package !== null && $package->getEntityType() === $entityType && $package->getEntityId() === $entityId) {
$session->setInventoryPackage($package);
}
}
// جمع‌آوری service items (با احتساب تعداد)
$serviceItemsData = [];
foreach (($data['services'] ?? []) as $svc) {
@@ -167,10 +187,32 @@ class PatientService
);
$session->setServicesTotalRials($priceCalc['services_total_rials']);
$session->setFinalPriceRials($priceCalc['final_price_rials']);
// کالاهای مصرفی: بدون پوشش بیمه — تمام مبلغ سهم بیمار است.
// فقط آیتم‌های متعلق به همین tenant پذیرفته می‌شوند؛ بقیه بی‌صدا رد می‌شوند (هم‌رفتار با services).
$consumableRows = [];
$consumablesTotal = 0;
foreach (($data['consumables'] ?? []) as $row) {
$item = $this->inventoryItemRepo->findByUuid((string) ($row['inventory_item_uuid'] ?? ''));
if ($item === null || $item->getEntityType() !== $entityType || $item->getEntityId() !== $entityId) {
continue;
}
$qty = max(1, (int) ($row['quantity'] ?? 1));
$consumableRows[] = ['item' => $item, 'quantity' => $qty];
$consumablesTotal += $item->getPrice() * $qty;
}
$session->setFinalPriceRials($priceCalc['final_price_rials'] + $consumablesTotal);
$this->sessionRepo->save($session);
// ثبت session consumables
foreach ($consumableRows as $row) {
$sc = new SessionConsumable($session, $row['item'], $row['quantity']);
$this->sessionConsumableRepo->save($sc);
$session->addConsumable($sc);
}
// ثبت session services
foreach (($data['services'] ?? []) as $svc) {
$item = $this->serviceItemRepo->findByUuid($svc['service_item_uuid'] ?? '');