diff --git a/assets/admin/components/session/CreateStep.tsx b/assets/admin/components/session/CreateStep.tsx index d1020623..1664ef9e 100644 --- a/assets/admin/components/session/CreateStep.tsx +++ b/assets/admin/components/session/CreateStep.tsx @@ -1,5 +1,5 @@ import { useEffect, useMemo, useState } from 'react'; -import { useQuery, useMutation } from '@tanstack/react-query'; +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { toast } from 'sonner'; import { PlusIcon, MinusIcon } from '@heroicons/react/24/outline'; import { api } from '../../lib/api'; @@ -57,6 +57,7 @@ interface Props { * موجودِ NewSessionPage (نمایش شرطی: سرویسِ تحت پوشش بیمه یا بیمه در پروفایل بیمار). */ export default function CreateStep({ recordUuid, profile, onCreated, onCancel, editSession }: Props) { + const qc = useQueryClient(); const userName = useAuthStore((s) => s.userName); const isEdit = !!editSession; @@ -307,6 +308,16 @@ export default function CreateStep({ recordUuid, profile, onCreated, onCancel, e ? api.patch(`/api/v1/session/${editSession!.uuid}`, body) : api.post(`/api/v1/patient/${recordUuid}/session`, body), onSuccess: (res: any) => { + // بدون این، صفحهٔ پرونده و فهرست پرداخت‌ها نسخهٔ کش‌شده را نشان می‌دادند و + // وضعیت مراجعه تا رفرشِ دستی عوض نمی‌شد. صورتحساب هم سمت سرور با همین ویرایش + // هم‌تراز می‌شود، پس آمار پرداخت‌ها هم کهنه می‌ماند. + qc.invalidateQueries({ queryKey: ['patient-sessions', recordUuid] }); + qc.invalidateQueries({ queryKey: ['patient-detail', recordUuid] }); + qc.invalidateQueries({ queryKey: ['patient', recordUuid] }); + qc.invalidateQueries({ queryKey: ['patient-invoices', recordUuid] }); + qc.invalidateQueries({ queryKey: ['payments'] }); + qc.invalidateQueries({ queryKey: ['payments-summary'] }); + toast.success(isEdit ? 'مراجعه ویرایش شد' : 'مراجعه ثبت شد'); onCreated((isEdit ? editSession!.uuid : res?.data?.uuid) as string); }, diff --git a/src/Billing/Command/ResyncSessionInvoicesCommand.php b/src/Billing/Command/ResyncSessionInvoicesCommand.php new file mode 100644 index 00000000..650f8eda --- /dev/null +++ b/src/Billing/Command/ResyncSessionInvoicesCommand.php @@ -0,0 +1,108 @@ +addOption('dry-run', null, InputOption::VALUE_NONE, 'فقط گزارش بده، چیزی ننویس'); + } + + protected function execute(InputInterface $input, OutputInterface $output): int + { + $io = new SymfonyStyle($input, $output); + $dryRun = (bool) $input->getOption('dry-run'); + + $drifted = 0; + $rows = []; + + foreach ($this->invoiceRepo->findAll() as $invoice) { + if ($invoice->getStatus() === Invoice::STATUS_VOID || $invoice->getPatientSessionId() === null) { + continue; + } + + $session = $this->sessionRepo->find($invoice->getPatientSessionId()); + if ($session === null) { + continue; + } + + $before = $invoice->getTotalRials(); + // مبلغِ درست از خودِ منطقِ ساخت می‌آید، نه از یک محاسبهٔ موازی در این دستور. + $after = $dryRun + ? $this->previewTotal($session, $invoice) + : $this->invoiceService->syncFromSession($session, $invoice->getEntityType(), $invoice->getEntityId())?->getTotalRials() ?? $before; + + if ($before === $after) { + continue; + } + + $drifted++; + $rows[] = [$invoice->getUuid(), $before, $after]; + } + + if ($rows !== []) { + $io->table(['invoice', 'before (rials)', 'after (rials)'], $rows); + } + + $io->success(sprintf( + $dryRun ? '%d صورتحساب منحرف است (چیزی نوشته نشد)' : '%d صورتحساب هم‌تراز شد', + $drifted, + )); + + return Command::SUCCESS; + } + + /** + * مبلغِ درستِ همین صورتحساب بدون ذخیره — برای `--dry-run`. + * روی همان entity کار می‌کند و در پایان چیزی flush نمی‌شود. + */ + private function previewTotal(\App\Patient\Entity\PatientSession $session, Invoice $invoice): int + { + $this->em->beginTransaction(); + + try { + $synced = $this->invoiceService->syncFromSession($session, $invoice->getEntityType(), $invoice->getEntityId()); + + return $synced?->getTotalRials() ?? $invoice->getTotalRials(); + } finally { + $this->em->rollback(); + } + } +} diff --git a/src/Billing/Entity/Invoice.php b/src/Billing/Entity/Invoice.php index 3ee9c104..d7e29cd9 100644 --- a/src/Billing/Entity/Invoice.php +++ b/src/Billing/Entity/Invoice.php @@ -116,6 +116,18 @@ class Invoice return $this; } + /** + * خالی‌کردن خطوط برای بازسازی از روی مراجعه. + * `orphanRemoval` ردیف‌های جداشده را حذف می‌کند، پس نیازی به حذف دستی نیست. + */ + public function clearItems(): self + { + $this->items->clear(); + $this->updatedAt = time(); + + return $this; + } + public function recalculateTotals(): void { $total = $base = $supp = $patient = 0; diff --git a/src/Billing/Repository/InvoiceRepository.php b/src/Billing/Repository/InvoiceRepository.php index a9fcfc88..79a4f7e4 100644 --- a/src/Billing/Repository/InvoiceRepository.php +++ b/src/Billing/Repository/InvoiceRepository.php @@ -185,34 +185,47 @@ class InvoiceRepository extends ServiceEntityRepository } /** - * Sum `$field` over a prepared invoice query, split by paid vs. still unsettled. + * Sum `$field` over a prepared invoice query, split by collected vs. still owed. + * + * «پرداخت‌شده» **مبلغِ وصول‌شده** است، نه مجموعِ صورتحساب‌های کاملاً تسویه‌شده: + * با شمارشِ صورتحساب‌محور، فاکتوری که نیمی از آن وصول شده بود صفر حساب می‌شد و کلِ + * مبلغش در «تسویه‌نشده» می‌نشست — یعنی پولِ گرفته‌شده نامرئی و بدهی بزرگ‌تر از + * واقعیت نشان داده می‌شد. + * + * جمع per-invoice در PHP بسته می‌شود چون DQL نه `LEAST` دارد و نه اجازهٔ `SUM` + * روی زیرکوئریِ همبسته؛ هر ردیف فقط دو عدد است. * * @return array{total_rials:int,paid_rials:int,unsettled_rials:int,invoices_count:int} */ private function summarize(QueryBuilder $qb, string $field): array { - $row = (clone $qb) + $rows = (clone $qb) ->select( - sprintf('COALESCE(SUM(i.%s), 0) AS total_rials', $field), - 'COUNT(i.id) AS invoices_count', + sprintf('i.%s AS amount_rials', $field), + 'i.patientRials AS due_rials', + sprintf('%s AS paid_rials', self::paidSumDql('sp_sum')), ) ->getQuery() - ->getSingleResult(); + ->getArrayResult(); - // «پرداخت‌شده» = مجموع صورتحساب‌هایی که سهم بیمارشان کامل وصول شده - $paid = (int) (clone $qb) - ->select(sprintf('COALESCE(SUM(i.%s), 0)', $field)) - ->andWhere(sprintf('%s >= i.patientRials', self::paidSumDql('sp_sum'))) - ->getQuery() - ->getSingleScalarResult(); + $total = $paid = $unsettled = 0; + foreach ($rows as $row) { + $total += (int) $row['amount_rials']; - $total = (int) $row['total_rials']; + // بدهی و وصولی همیشه روی **سهم بیمار** سنجیده می‌شوند، حتی وقتی ستونِ + // «مجموع» جمعِ کلِ صورتحساب است: بیمار سهم بیمه را بدهکار نیست. + $due = (int) $row['due_rials']; + $part = min((int) $row['paid_rials'], $due); + + $paid += $part; + $unsettled += $due - $part; + } return [ 'total_rials' => $total, 'paid_rials' => $paid, - 'unsettled_rials' => $total - $paid, - 'invoices_count' => (int) $row['invoices_count'], + 'unsettled_rials' => $unsettled, + 'invoices_count' => count($rows), ]; } diff --git a/src/Billing/Service/InvoiceService.php b/src/Billing/Service/InvoiceService.php index e6e94094..69d0633d 100644 --- a/src/Billing/Service/InvoiceService.php +++ b/src/Billing/Service/InvoiceService.php @@ -53,8 +53,51 @@ class InvoiceService $invoice = new Invoice($entityType, $entityId); $invoice->setPatientSessionId($session->getId()) - ->setPatientRecordId($session->getRecord()->getId()) - ->setBaseInsuranceId($session->getInsuranceBaseId()) + ->setPatientRecordId($session->getRecord()->getId()); + + $this->fillFromSession($invoice, $session, $entityType, $entityId); + $this->invoiceRepo->save($invoice); + + return $invoice; + } + + /** + * هم‌ترازکردن صورتحسابِ موجود با مراجعه‌اش — پس از ویرایش سرویس/کالا/ویزیت/بیمه. + * + * صورتحساب عکسِ لحظهٔ ساخت بود و ویرایشِ بعدیِ مراجعه هرگز به آن نمی‌رسید: مراجعه‌ای + * که بعداً سرویس گرفت، صورتحسابش روی مبلغ قدیمی می‌ماند و چون پرداختی‌ها از همان + * مبلغِ کوچک بیشتر بودند، «پرداخت‌شده» دیده می‌شد. فهرست پرداخت‌ها هم مجموع و + * تسویه‌نشده را از همین ستون‌ها می‌سازد، پس خطا تا آمار بالای صفحه می‌رفت. + * + * `null` یعنی این مراجعه هنوز صورتحسابی ندارد؛ ساختش سیاست جای دیگری است. + */ + public function syncFromSession(PatientSession $session, string $entityType, int $entityId): ?Invoice + { + $invoice = $session->getId() !== null ? $this->invoiceRepo->findBySession($session->getId()) : null; + if ($invoice === null) { + return null; + } + + // صورتحساب باطل‌شده دیگر دنبال مراجعه نمی‌آید؛ بازنویسی‌اش یعنی زنده‌کردن سندی + // که عمداً کنار گذاشته شده. + if ($invoice->getStatus() === Invoice::STATUS_VOID) { + return $invoice; + } + + $invoice->clearItems(); + $this->fillFromSession($invoice, $session, $entityType, $entityId); + $this->invoiceRepo->save($invoice); + + return $invoice; + } + + /** + * خطوطِ صورتحساب از روی مراجعه: ویزیت + هر سرویس، با قاعدهٔ پوشش بیمهٔ همان محیط. + * تنها جای ساختِ خطوط است تا «ساخت» و «هم‌ترازسازی» از هم واگرا نشوند. + */ + private function fillFromSession(Invoice $invoice, PatientSession $session, string $entityType, int $entityId): void + { + $invoice->setBaseInsuranceId($session->getInsuranceBaseId()) ->setSupplementaryInsuranceId($session->getInsuranceSupplementaryId()); $baseId = $session->getInsuranceBaseId(); @@ -87,9 +130,6 @@ class InvoiceService } $invoice->recalculateTotals(); - $this->invoiceRepo->save($invoice); - - return $invoice; } /** نهایی‌سازی، و اعلامش به مصرف‌کننده‌های اثر جانبی (ساخت مطالبهٔ بیمه). */ diff --git a/src/Billing/Service/SessionBillingService.php b/src/Billing/Service/SessionBillingService.php index 1b6614d1..05b9ad76 100644 --- a/src/Billing/Service/SessionBillingService.php +++ b/src/Billing/Service/SessionBillingService.php @@ -24,9 +24,28 @@ class SessionBillingService /** * صورتحساب مراجعه را می‌سازد و نهایی می‌کند. مراجعهٔ بدون بیمه دست‌نخورده می‌ماند. * شکست اینجا نباید ثبت مراجعه یا قطعی‌کردن نوبت را برگرداند. + * + * اگر صورتحسابی از قبل هست — چه بیمه‌دار، چه ساخته‌شده از صفحهٔ پرداخت — با محتوای + * فعلیِ مراجعه هم‌تراز می‌شود؛ وگرنه ویرایشِ سرویس‌ها روی مبلغِ قدیمیِ صورتحساب + * می‌نشست و «تسویه‌شده/تسویه‌نشده» و آمار فهرست پرداخت‌ها را غلط نشان می‌داد. */ public function ensureFinalizedInvoice(PatientSession $session, string $entityType, int $entityId): ?Invoice { + try { + $synced = $this->invoiceService->syncFromSession($session, $entityType, $entityId); + } catch (\Throwable $e) { + $this->logger->error('syncing the session invoice failed', [ + 'session' => $session->getUuid(), + 'error' => $e->getMessage(), + ]); + + return null; + } + + if ($synced !== null) { + return $synced; + } + if ($session->getInsuranceBaseId() === null && $session->getInsuranceSupplementaryId() === null) { return null; } diff --git a/tests/Billing/InvoiceSyncOnSessionEditTest.php b/tests/Billing/InvoiceSyncOnSessionEditTest.php new file mode 100644 index 00000000..5d7d3f7b --- /dev/null +++ b/tests/Billing/InvoiceSyncOnSessionEditTest.php @@ -0,0 +1,151 @@ +createUser(['ROLE_CLINIC']); + $clinic = new Clinic($owner); + $this->em->persist($clinic); + + $doctor = new Doctor($this->createUser(['ROLE_DOCTOR']), 'دکتر تست'); + $this->em->persist($doctor); + $clinic->getDoctors()->add($doctor); + + $this->em->persist(new UserActiveContext($owner, $clinic->getUuid(), 'clinic')); + + $patient = $this->createUser(['ROLE_USER']); + $patient->setRealName('بیمار تست'); + $record = new PatientRecord('clinic', $clinic->getId(), $patient, 'clinic', $clinic->getId()); + $this->em->persist($record); + + $section = new ServiceSection('clinic', $clinic->getId(), 'لیزر'); + $this->em->persist($section); + $this->em->flush(); + + return [$owner, $clinic, $record, $section]; + } + + private function service(ServiceSection $section, string $name, int $priceRials): ServiceItem + { + $item = new ServiceItem($section, $name); + $item->setPriceRials($priceRials); + $this->em->persist($item); + $this->em->flush(); + + return $item; + } + + private function invoiceOf(PatientSession $session): ?Invoice + { + $this->em->clear(); + + return $this->em->getRepository(Invoice::class)->findOneBy(['patientSessionId' => $session->getId()]); + } + + /** ✅ موفق: افزودن سرویس به مراجعه، مبلغ صورتحساب را هم بالا می‌برد. */ + public function testEditingSessionServicesUpdatesTheInvoiceTotal(): void + { + [$owner, , $record, $section] = $this->scenario(); + $first = $this->service($section, 'لیزر ناحیه‌ای', 10_000_000); + $second = $this->service($section, 'لیزر توتال', 13_000_000); + + $created = $this->authJson('POST', '/api/v1/patient/' . $record->getUuid() . '/session', $owner, [ + 'services' => [['service_item_uuid' => $first->getUuid(), 'quantity' => 1]], + ]); + self::assertSame(201, $this->responseCode()); + $sessionUuid = $created['data']['uuid']; + + // صورتحساب از مسیر پرداخت ساخته می‌شود (مراجعهٔ بدون بیمه). + $this->authJson('POST', '/api/v1/billing/invoices', $owner, ['session_uuid' => $sessionUuid]); + self::assertSame(201, $this->responseCode()); + + $session = $this->em->getRepository(PatientSession::class)->findOneBy(['uuid' => $sessionUuid]); + self::assertSame(10_000_000, $this->invoiceOf($session)->getTotalRials()); + + // ویرایش: سرویس دوم اضافه می‌شود. + $this->authJson('PATCH', '/api/v1/session/' . $sessionUuid, $owner, [ + 'services' => [ + ['service_item_uuid' => $first->getUuid(), 'quantity' => 1], + ['service_item_uuid' => $second->getUuid(), 'quantity' => 1], + ], + ]); + self::assertSame(200, $this->responseCode()); + + $session = $this->em->getRepository(PatientSession::class)->findOneBy(['uuid' => $sessionUuid]); + $invoice = $this->invoiceOf($session); + + self::assertSame(23_000_000, $invoice->getTotalRials()); + self::assertSame(23_000_000, $invoice->getPatientRials()); + self::assertCount(2, $invoice->getItems()); + } + + /** ⚠️ مرزی: حذف سرویس هم باید مبلغ را پایین بیاورد، نه اینکه خط یتیم بماند. */ + public function testRemovingAServiceShrinksTheInvoice(): void + { + [$owner, , $record, $section] = $this->scenario(); + $first = $this->service($section, 'سرویس اول', 5_000_000); + $second = $this->service($section, 'سرویس دوم', 7_000_000); + + $created = $this->authJson('POST', '/api/v1/patient/' . $record->getUuid() . '/session', $owner, [ + 'services' => [ + ['service_item_uuid' => $first->getUuid(), 'quantity' => 1], + ['service_item_uuid' => $second->getUuid(), 'quantity' => 1], + ], + ]); + $sessionUuid = $created['data']['uuid']; + $this->authJson('POST', '/api/v1/billing/invoices', $owner, ['session_uuid' => $sessionUuid]); + + $this->authJson('PATCH', '/api/v1/session/' . $sessionUuid, $owner, [ + 'services' => [['service_item_uuid' => $first->getUuid(), 'quantity' => 1]], + ]); + self::assertSame(200, $this->responseCode()); + + $session = $this->em->getRepository(PatientSession::class)->findOneBy(['uuid' => $sessionUuid]); + $invoice = $this->invoiceOf($session); + + self::assertSame(5_000_000, $invoice->getTotalRials()); + self::assertCount(1, $invoice->getItems()); + } + + /** ⚠️ مرزی: مراجعهٔ بدونِ صورتحساب نباید با ویرایش، صورتحساب بگیرد. */ + public function testSessionWithoutInvoiceStaysWithoutOne(): void + { + [$owner, , $record, $section] = $this->scenario(); + $item = $this->service($section, 'سرویس', 4_000_000); + + $created = $this->authJson('POST', '/api/v1/patient/' . $record->getUuid() . '/session', $owner, [ + 'services' => [['service_item_uuid' => $item->getUuid(), 'quantity' => 1]], + ]); + $sessionUuid = $created['data']['uuid']; + + $this->authJson('PATCH', '/api/v1/session/' . $sessionUuid, $owner, [ + 'services' => [['service_item_uuid' => $item->getUuid(), 'quantity' => 2]], + ]); + self::assertSame(200, $this->responseCode()); + + $session = $this->em->getRepository(PatientSession::class)->findOneBy(['uuid' => $sessionUuid]); + self::assertNull($this->invoiceOf($session)); + } +} diff --git a/tests/Billing/PaymentsSummaryTest.php b/tests/Billing/PaymentsSummaryTest.php index 995898b4..315e2ac9 100644 --- a/tests/Billing/PaymentsSummaryTest.php +++ b/tests/Billing/PaymentsSummaryTest.php @@ -108,6 +108,39 @@ class PaymentsSummaryTest extends ApiTestCase self::assertSame(2, $data['invoices_count']); } + /** + * ⚠️ مرزی: فاکتورِ نیمه‌پرداخت. پیش از این، «پرداخت‌شده» فقط فاکتورهای کاملاً + * تسویه‌شده را می‌شمرد، پس پولِ گرفته‌شده نامرئی می‌شد و بدهی بزرگ‌تر از واقعیت. + */ + public function testPartiallyPaidInvoiceCountsItsCollectedAmount(): void + { + [$owner, $doctor] = $this->doctor(); + $record = $this->patientRecord($doctor); + + // سهم بیمار ۱٬۰۰۰٬۰۰۰ و فقط ۴۰۰٬۰۰۰ وصول شده. + $this->invoice($doctor, $record, 1_000_000, Invoice::STATUS_FINALIZED, 1_700_000_000, 400_000); + + $data = $this->authJson('GET', '/api/v1/my/billing/payments/summary', $owner)['data']; + + self::assertSame(1_000_000, $data['total_rials']); + self::assertSame(400_000, $data['paid_rials']); + self::assertSame(600_000, $data['unsettled_rials']); + } + + /** ⚠️ مرزی: اضافه‌پرداخت نباید «تسویه‌نشده» را منفی کند. */ + public function testOverpaymentIsCappedAtTheInvoiceShare(): void + { + [$owner, $doctor] = $this->doctor(); + $record = $this->patientRecord($doctor); + + $this->invoice($doctor, $record, 500_000, Invoice::STATUS_FINALIZED, 1_700_000_000, 900_000); + + $data = $this->authJson('GET', '/api/v1/my/billing/payments/summary', $owner)['data']; + + self::assertSame(500_000, $data['paid_rials']); + self::assertSame(0, $data['unsettled_rials']); + } + public function testSummaryHonoursStatusAndDateFilters(): void { [$owner, $doctor] = $this->doctor(); @@ -168,11 +201,12 @@ class PaymentsSummaryTest extends ApiTestCase $res = $this->authJson('GET', '/api/v1/my/billing/patients/' . $record->getUuid() . '/invoices', $owner); self::assertSame(200, $this->responseCode()); - // the patient view sums total_rials (invoice grand total), not the patient share + // ستون «مجموع» جمعِ کلِ صورتحساب است، ولی وصولی و بدهی روی سهم بیمار سنجیده + // می‌شوند: فاکتور اول (سهم ۱٬۰۰۰٬۰۰۰) تسویه شده و فاکتور دوم (۵۰۰٬۰۰۰) نه. $summary = $res['data']['summary']; self::assertSame(3_000_000, $summary['total_rials']); - self::assertSame(2_000_000, $summary['paid_rials']); - self::assertSame(1_000_000, $summary['unsettled_rials']); + self::assertSame(1_000_000, $summary['paid_rials']); + self::assertSame(500_000, $summary['unsettled_rials']); self::assertSame(2, $summary['invoices_count']); self::assertSame(2, $res['data']['meta']['totalRecords']); }