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>
176 lines
7.8 KiB
PHP
176 lines
7.8 KiB
PHP
<?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']);
|
||
}
|
||
}
|