createUser(['ROLE_DOCTOR']); $doctor = new Doctor($owner, 'دکتر تست'); $this->em->persist($doctor); $this->em->flush(); return [$owner, $doctor]; } // national_code is UNIQUE and db_test is never reset, so randomise it per // patient (like mobile) to avoid cross-run collisions; read it back from the // record's user when a test needs to filter by it. private function patientRecord(Doctor $doctor, string $realName): PatientRecord { $patient = $this->createUser(['ROLE_USER']); $this->setField($patient, 'realName', $realName); $this->setField($patient, 'nationalCode', str_pad((string) random_int(0, 9_999_999_999), 10, '0', STR_PAD_LEFT)); $record = new PatientRecord('doctor', $doctor->getId(), $patient, 'doctor', $doctor->getId()); $this->em->persist($record); $this->em->flush(); return $record; } private function nationalCodeOf(PatientRecord $record): string { return $record->getUser()->getNationalCode(); } /** * `$status` چرخه‌ی حیات صورتحساب است (draft/finalized/void). وضعیت پرداختِ * نمایش‌داده‌شده از `$paidRials` مشتق می‌شود — پرداخت واقعی روی مراجعه. */ private function invoice( Doctor $doctor, PatientRecord $record, string $status, int $patientRials, ?int $issuedAt = null, ?string $itemTitle = null, int $totalRials = 0, int $paidRials = 0, ): Invoice { $invoice = new Invoice('doctor', $doctor->getId()); $invoice->setPatientRecordId($record->getId()); if ($paidRials > 0) { $invoice->setPatientSessionId($this->paidSession($record, $paidRials)->getId()); } if ($itemTitle !== null) { // Item added only for its title (service_title); totals set below by hand. $invoice->addItem(new InvoiceItem($invoice, $itemTitle, $totalRials, 1, new ShareBreakdown($totalRials, 0, 0, $patientRials), null)); } $this->setField($invoice, 'status', $status); $this->setField($invoice, 'patientRials', $patientRials); $this->setField($invoice, 'totalRials', $totalRials); if ($issuedAt !== null) { $this->setField($invoice, 'issuedAt', $issuedAt); } $this->em->persist($invoice); $this->em->flush(); return $invoice; } /** مراجعه‌ای با یک پرداخت نقدی ثبت‌شده — منبع واقعیِ «چقدر وصول شده». */ private function paidSession(PatientRecord $record, int $paidRials): PatientSession { $session = new PatientSession($record); $this->em->persist($session); $this->em->flush(); $payment = new SessionPayment($session, 'cash', $paidRials, time()); $this->em->persist($payment); $this->em->flush(); return $session; } private function setField(object $obj, string $prop, mixed $value): void { $ref = new \ReflectionProperty($obj, $prop); $ref->setAccessible(true); $ref->setValue($obj, $value); } public function testListsFlatInvoicesNewestFirst(): void { [$owner, $doctor] = $this->doctor(); $a = $this->patientRecord($doctor, 'دنیا خلیلی'); $this->invoice($doctor, $a, Invoice::STATUS_FINALIZED, 100000, 2000, null, 0, 100000); // fully paid $this->invoice($doctor, $a, Invoice::STATUS_FINALIZED, 50000, 1000); // nothing paid $this->invoice($doctor, $a, Invoice::STATUS_DRAFT, 999999, 3000); // excluded $this->invoice($doctor, $a, Invoice::STATUS_VOID, 999999, 3000); // excluded $res = $this->authJson('GET', '/api/v1/my/billing/payments', $owner); self::assertSame(200, $this->responseCode()); self::assertSame(2, $res['meta']['totalRecords']); // draft/void excluded // newest (issued_at DESC) first self::assertSame('دنیا خلیلی', $res['data'][0]['patient_name']); self::assertSame($this->nationalCodeOf($a), $res['data'][0]['national_code']); self::assertSame(100000, $res['data'][0]['amount_rials']); self::assertSame('paid', $res['data'][0]['status']); self::assertArrayHasKey('invoice_uuid', $res['data'][0]); self::assertSame($a->getUuid(), $res['data'][0]['patient_uuid']); self::assertSame(50000, $res['data'][1]['amount_rials']); self::assertSame('unsettled', $res['data'][1]['status']); } public function testFiltersByNationalCodeAndStatus(): void { [$owner, $doctor] = $this->doctor(); $paid = $this->patientRecord($doctor, 'بیمار پرداخت'); $unpaid = $this->patientRecord($doctor, 'بیمار بدهکار'); $partial = $this->patientRecord($doctor, 'بیمار نیمه‌پرداخت'); $this->invoice($doctor, $paid, Invoice::STATUS_FINALIZED, 100000, null, null, 0, 100000); $this->invoice($doctor, $unpaid, Invoice::STATUS_FINALIZED, 100000); $this->invoice($doctor, $partial, Invoice::STATUS_FINALIZED, 100000, null, null, 0, 40000); $byCode = $this->authJson('GET', '/api/v1/my/billing/payments?national_code=' . $this->nationalCodeOf($paid), $owner); self::assertSame(1, $byCode['meta']['totalRecords']); self::assertSame($paid->getUuid(), $byCode['data'][0]['patient_uuid']); $onlyPaid = $this->authJson('GET', '/api/v1/my/billing/payments?status=paid', $owner); self::assertSame(1, $onlyPaid['meta']['totalRecords']); self::assertSame('paid', $onlyPaid['data'][0]['status']); $onlyUnsettled = $this->authJson('GET', '/api/v1/my/billing/payments?status=unsettled', $owner); self::assertSame(1, $onlyUnsettled['meta']['totalRecords']); self::assertSame($unpaid->getUuid(), $onlyUnsettled['data'][0]['patient_uuid']); $onlyPartial = $this->authJson('GET', '/api/v1/my/billing/payments?status=partial', $owner); self::assertSame(1, $onlyPartial['meta']['totalRecords']); self::assertSame($partial->getUuid(), $onlyPartial['data'][0]['patient_uuid']); self::assertSame('partial', $onlyPartial['data'][0]['status']); self::assertSame(40000, $onlyPartial['data'][0]['paid_rials']); } /** بیمارِ ساخته‌شده در نوبت‌دهی کد ملی را روی پروفایل دارد نه روی users. */ private function patientRecordWithProfileCode(Doctor $doctor, string $realName, string $nationalCode): PatientRecord { $patient = $this->createUser(['ROLE_USER']); $this->setField($patient, 'realName', $realName); // users.national_code خالی؛ کد ملی فقط روی پروفایل. $profile = new UserProfile($patient); $profile->setNationalCode($nationalCode); $this->em->persist($profile); $record = new PatientRecord('doctor', $doctor->getId(), $patient, 'doctor', $doctor->getId()); $this->em->persist($record); $this->em->flush(); return $record; } public function testNationalCodeFromProfileSurfacesInListAndDetail(): void { [$owner, $doctor] = $this->doctor(); $nc = str_pad((string) random_int(0, 9_999_999_999), 10, '0', STR_PAD_LEFT); $record = $this->patientRecordWithProfileCode($doctor, 'بیمار پروفایلی', $nc); $this->invoice($doctor, $record, Invoice::STATUS_FINALIZED, 100000, 1000, null, 0, 100000); // لیست $list = $this->authJson('GET', '/api/v1/my/billing/payments', $owner); self::assertSame($nc, $list['data'][0]['national_code']); // فیلتر با کد ملیِ پروفایل $filtered = $this->authJson('GET', '/api/v1/my/billing/payments?national_code=' . $nc, $owner); self::assertSame(1, $filtered['meta']['totalRecords']); // هدرِ صفحهٔ جزئیات $detail = $this->authJson('GET', '/api/v1/my/billing/patients/' . $record->getUuid() . '/invoices', $owner); self::assertSame($nc, $detail['data']['patient']['national_code']); } public function testEmptyWhenNoInvoices(): void { [$owner] = $this->doctor(); $res = $this->authJson('GET', '/api/v1/my/billing/payments', $owner); self::assertSame(200, $this->responseCode()); self::assertSame(0, $res['meta']['totalRecords']); self::assertCount(0, $res['data']); } public function testForbiddenWithoutProfile(): void { $orphan = $this->createUser(['ROLE_DOCTOR']); // ROLE_DOCTOR but no Doctor row $this->authJson('GET', '/api/v1/my/billing/payments', $orphan); self::assertSame(403, $this->responseCode()); } // ── Node 2: a patient's recorded invoices ──────────────────────────────── public function testListsPatientInvoicesWithHeaderAndDerivedStatus(): void { [$owner, $doctor] = $this->doctor(); $record = $this->patientRecord($doctor, 'دنیا خلیلی'); $this->invoice($doctor, $record, Invoice::STATUS_FINALIZED, 235000, 1000, 'روکش دندان', 235000, 235000); $this->invoice($doctor, $record, Invoice::STATUS_FINALIZED, 600000, 2000, 'طرح لبخند', 600000); $this->invoice($doctor, $record, Invoice::STATUS_DRAFT, 111, 3000, 'پیش‌نویس', 111); // excluded $res = $this->authJson('GET', '/api/v1/my/billing/patients/' . $record->getUuid() . '/invoices', $owner); self::assertSame(200, $this->responseCode()); // header self::assertSame('دنیا خلیلی', $res['data']['patient']['name']); self::assertSame($this->nationalCodeOf($record), $res['data']['patient']['national_code']); // invoices — draft excluded, newest (issued_at DESC) first self::assertSame(2, $res['data']['meta']['totalRecords']); $rows = $res['data']['data']; self::assertCount(2, $rows); self::assertSame('طرح لبخند', $rows[0]['service_title']); self::assertSame('unsettled', $rows[0]['status']); self::assertSame(600000, $rows[0]['total_rials']); self::assertSame('روکش دندان', $rows[1]['service_title']); self::assertSame('paid', $rows[1]['status']); } public function testPatientInvoiceRowsCarryTheirPaymentMethods(): void { [$owner, $doctor] = $this->doctor(); $record = $this->patientRecord($doctor, 'دنیا خلیلی'); $session = $this->paidSession($record, 60000); $extra = new SessionPayment($session, 'pos', 40000, time() + 60); $this->em->persist($extra); $this->em->flush(); $invoice = $this->invoice($doctor, $record, Invoice::STATUS_FINALIZED, 100000, 1000, 'روکش دندان', 100000); $invoice->setPatientSessionId($session->getId()); $this->em->flush(); $res = $this->authJson('GET', '/api/v1/my/billing/patients/' . $record->getUuid() . '/invoices', $owner); $row = $res['data']['data'][0]; self::assertSame('paid', $row['status']); self::assertSame(100000, $row['paid_rials']); self::assertCount(2, $row['payments']); // oldest payment first self::assertSame('cash', $row['payments'][0]['method']); self::assertSame(60000, $row['payments'][0]['amount_rials']); self::assertSame('pos', $row['payments'][1]['method']); self::assertSame(40000, $row['payments'][1]['amount_rials']); } public function testPatientInvoiceWithoutSessionHasNoPayments(): void { [$owner, $doctor] = $this->doctor(); $record = $this->patientRecord($doctor, 'بدون مراجعه'); $this->invoice($doctor, $record, Invoice::STATUS_FINALIZED, 100000, 1000, 'ویزیت', 100000); $res = $this->authJson('GET', '/api/v1/my/billing/patients/' . $record->getUuid() . '/invoices', $owner); $row = $res['data']['data'][0]; self::assertSame([], $row['payments']); self::assertSame(0, $row['paid_rials']); self::assertSame('unsettled', $row['status']); } public function testPatientInvoicesNotFoundForOtherTenant(): void { [, $doctor] = $this->doctor(); $record = $this->patientRecord($doctor, 'بیمار'); [$other] = $this->doctor(); $this->authJson('GET', '/api/v1/my/billing/patients/' . $record->getUuid() . '/invoices', $other); self::assertSame(404, $this->responseCode()); } public function testPatientInvoicesEmpty(): void { [$owner, $doctor] = $this->doctor(); $record = $this->patientRecord($doctor, 'بدون فاکتور'); $res = $this->authJson('GET', '/api/v1/my/billing/patients/' . $record->getUuid() . '/invoices', $owner); self::assertSame(200, $this->responseCode()); self::assertSame(0, $res['data']['meta']['totalRecords']); self::assertCount(0, $res['data']['data']); self::assertSame('بدون فاکتور', $res['data']['patient']['name']); } }