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:
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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'] ?? '');
|
||||
|
||||
Reference in New Issue
Block a user