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
+175
View File
@@ -0,0 +1,175 @@
<?php
namespace App\Tests\Patient;
use App\Doctor\Entity\Doctor;
use App\Inventory\Entity\InventoryItem;
use App\Inventory\Entity\InventoryPackage;
use App\Patient\Entity\PatientRecord;
use App\Tests\ApiTestCase;
/**
* ثبت مراجعه با کالای مصرفی/پکیج/زمان پذیرش (ویزارد ثبت مراجعه):
* POST /patient/{uuid}/session با consumables (سهم بیمار کامل، بدون بیمه)،
* inventory_package_uuid (مرجع) و session_at. آیتم‌های tenant دیگر بی‌صدا رد می‌شوند.
*/
class SessionConsumableTest extends ApiTestCase
{
/** @return array{0: \App\Auth\Entity\User, 1: Doctor, 2: PatientRecord} */
private function recordFor(): array
{
$owner = $this->createUser(['ROLE_DOCTOR']);
$doctor = new Doctor($owner, 'دکتر');
$this->em->persist($doctor);
$this->em->flush();
$patient = $this->createUser(['ROLE_USER']);
$record = new PatientRecord('doctor', $doctor->getId(), $patient, 'doctor', $doctor->getId());
$this->em->persist($record);
$this->em->flush();
return [$owner, $doctor, $record];
}
private function itemFor(Doctor $doctor, string $name, int $price): InventoryItem
{
$item = new InventoryItem('doctor', $doctor->getId(), $name);
$item->setPrice($price)->setStock(100);
$this->em->persist($item);
$this->em->flush();
return $item;
}
// ── موفق ────────────────────────────────────────────────────────────────
public function testCreateSessionWithConsumablesPackageAndSessionAt(): void
{
[$owner, $doctor, $record] = $this->recordFor();
$glasses = $this->itemFor($doctor, 'عینک', 1_200_000);
$pencil = $this->itemFor($doctor, 'مداد سفید', 300_000);
$package = new InventoryPackage('doctor', $doctor->getId(), 'پکیج زیبایی');
$this->em->persist($package);
$this->em->flush();
$res = $this->authJson('POST', '/api/v1/patient/' . $record->getUuid() . '/session', $owner, [
'visit_price_rials' => 500_000,
'session_at' => 1_760_000_000,
'inventory_package_uuid' => $package->getUuid(),
'consumables' => [
['inventory_item_uuid' => $glasses->getUuid(), 'quantity' => 2],
['inventory_item_uuid' => $pencil->getUuid()],
],
]);
self::assertSame(201, $this->responseCode());
self::assertSame(1_760_000_000, $res['data']['session_at']);
self::assertSame($package->getUuid(), $res['data']['inventory_package_uuid']);
self::assertSame('پکیج زیبایی', $res['data']['inventory_package_title']);
self::assertCount(2, $res['data']['consumables']);
self::assertSame(2_700_000, $res['data']['consumables_total_rials']); // 2×1٬200٬000 + 300٬000
$byName = array_column($res['data']['consumables'], null, 'item_name');
self::assertSame(2, $byName['عینک']['quantity']);
self::assertSame(2_400_000, $byName['عینک']['line_total_rials']);
self::assertSame(1, $byName['مداد سفید']['quantity']);
// کالاها بدون پوشش بیمه → کامل روی سهم بیمار
self::assertSame(3_200_000, $res['data']['final_price_rials']); // ویزیت 500٬000 + کالاها 2٬700٬000
}
public function testConsumablesAppearInSessionsList(): void
{
[$owner, $doctor, $record] = $this->recordFor();
$item = $this->itemFor($doctor, 'سرنگ', 50_000);
$this->authJson('POST', '/api/v1/patient/' . $record->getUuid() . '/session', $owner, [
'visit_price_rials' => 0,
'consumables' => [['inventory_item_uuid' => $item->getUuid(), 'quantity' => 3]],
]);
self::assertSame(201, $this->responseCode());
$sessions = $this->authJson('GET', '/api/v1/patient/' . $record->getUuid() . '/sessions', $owner);
self::assertCount(1, $sessions['data'][0]['consumables']);
self::assertSame(150_000, $sessions['data'][0]['consumables_total_rials']);
self::assertSame(150_000, $sessions['data'][0]['patient_debt_rials']);
}
// ── خطا ─────────────────────────────────────────────────────────────────
public function testForeignTenantConsumableAndPackageSilentlySkipped(): void
{
[$owner, , $record] = $this->recordFor();
// tenant دیگر
$otherOwner = $this->createUser(['ROLE_DOCTOR']);
$otherDoctor = new Doctor($otherOwner, 'دکتر دیگر');
$this->em->persist($otherDoctor);
$this->em->flush();
$foreignItem = $this->itemFor($otherDoctor, 'آمپول', 900_000);
$foreignPackage = new InventoryPackage('doctor', $otherDoctor->getId(), 'پکیج دیگری');
$this->em->persist($foreignPackage);
$this->em->flush();
$res = $this->authJson('POST', '/api/v1/patient/' . $record->getUuid() . '/session', $owner, [
'visit_price_rials' => 400_000,
'inventory_package_uuid' => $foreignPackage->getUuid(),
'consumables' => [['inventory_item_uuid' => $foreignItem->getUuid(), 'quantity' => 5]],
]);
self::assertSame(201, $this->responseCode());
self::assertCount(0, $res['data']['consumables']);
self::assertSame(0, $res['data']['consumables_total_rials']);
self::assertNull($res['data']['inventory_package_uuid']);
self::assertSame(400_000, $res['data']['final_price_rials']);
}
public function testUnknownConsumableUuidSkipped(): void
{
[$owner, , $record] = $this->recordFor();
$res = $this->authJson('POST', '/api/v1/patient/' . $record->getUuid() . '/session', $owner, [
'visit_price_rials' => 200_000,
'consumables' => [['inventory_item_uuid' => '00000000-0000-0000-0000-000000000000']],
]);
self::assertSame(201, $this->responseCode());
self::assertCount(0, $res['data']['consumables']);
self::assertSame(200_000, $res['data']['final_price_rials']);
}
// ── مرزی ────────────────────────────────────────────────────────────────
public function testEmptyConsumablesAndNoSessionAtDefaults(): void
{
[$owner, , $record] = $this->recordFor();
$res = $this->authJson('POST', '/api/v1/patient/' . $record->getUuid() . '/session', $owner, [
'visit_price_rials' => 100_000,
'consumables' => [],
]);
self::assertSame(201, $this->responseCode());
self::assertSame([], $res['data']['consumables']);
self::assertSame(0, $res['data']['consumables_total_rials']);
self::assertNull($res['data']['session_at']);
self::assertNull($res['data']['inventory_package_uuid']);
}
public function testZeroQuantityCoercedToOne(): void
{
[$owner, $doctor, $record] = $this->recordFor();
$item = $this->itemFor($doctor, 'گاز استریل', 80_000);
$res = $this->authJson('POST', '/api/v1/patient/' . $record->getUuid() . '/session', $owner, [
'visit_price_rials' => 0,
'consumables' => [['inventory_item_uuid' => $item->getUuid(), 'quantity' => 0]],
]);
self::assertSame(201, $this->responseCode());
self::assertSame(1, $res['data']['consumables'][0]['quantity']);
self::assertSame(80_000, $res['data']['consumables_total_rials']);
}
}