From 810e9351a94d3cf3c62a5379d0cd354531586bb2 Mon Sep 17 00:00:00 2001 From: hamed <15238-genius.ha@users.noreply.drupalcode.org> Date: Tue, 4 Aug 2026 11:19:07 +0330 Subject: [PATCH] feat(invoice): implement recorder identity resolution for payments and update related tests --- .../components/InvoiceSummaryModal.test.tsx | 56 +++++++++++++ .../admin/components/InvoiceSummaryModal.tsx | 56 ++++++++++++- docs/api/billing.md | 25 +++++- src/Billing/Service/InvoiceService.php | 34 ++++++++ src/Settlement/Service/WalletService.php | 18 ++--- src/Shared/Service/ActorIdentityResolver.php | 79 +++++++++++++++++++ tests/Billing/InvoiceShowSessionTest.php | 66 ++++++++++++++++ 7 files changed, 321 insertions(+), 13 deletions(-) create mode 100644 src/Shared/Service/ActorIdentityResolver.php diff --git a/assets/admin/components/InvoiceSummaryModal.test.tsx b/assets/admin/components/InvoiceSummaryModal.test.tsx index 2f838bf6..06520c74 100644 --- a/assets/admin/components/InvoiceSummaryModal.test.tsx +++ b/assets/admin/components/InvoiceSummaryModal.test.tsx @@ -8,6 +8,7 @@ vi.mock('../lib/api', () => ({ })); import { api } from '../lib/api'; +import { useAuthStore } from '../stores/authStore'; import InvoiceSummaryModal from './InvoiceSummaryModal'; const get = api.get as ReturnType; @@ -121,6 +122,61 @@ describe('InvoiceSummaryModal', () => { expect(screen.getByText('بیمه ایران')).toBeInTheDocument(); }); + /** + * ستون «ثبت‌کننده»: نامِ حل‌شدهٔ سرور، و لینک فقط وقتی بیننده صفحهٔ آن پروفایل را + * می‌تواند باز کند. عکسِ لحظهٔ ثبت (`created_by_name`) فقط fallback است. + */ + describe('ثبت‌کنندهٔ پرداخت', () => { + const withRecorder = (recorder: object | null) => ({ + ...baseInvoice, + session: { + ...fullSession, + payments: [{ + uuid: 'p1', method: 'wallet', amount_rials: 1_500_000, paid_at: 1700100000, + created_by_name: '09120000000', created_by: recorder, + }], + }, + }); + + it('پزشکِ ثبت‌کننده به پروفایل پزشک لینک می‌شود', async () => { + useAuthStore.setState({ primaryRole: 'clinic' } as any); + mockInvoice(withRecorder({ user_uuid: 'u1', name: 'دکتر رضایی', role: 'doctor', doctor_uuid: 'doc-9' })); + renderWithProviders( {}} />); + + const link = await screen.findByRole('link', { name: 'دکتر رضایی' }); + expect(link).toHaveAttribute('href', '/admin/doctors/doc-9'); + // نامِ زنده جای شمارهٔ ذخیره‌شده می‌نشیند. + expect(screen.queryByText('09120000000')).toBeNull(); + }); + + it('منشیِ ثبت‌کننده برای کلینیک به فهرست منشی‌های خودش لینک می‌شود', async () => { + useAuthStore.setState({ primaryRole: 'clinic' } as any); + mockInvoice(withRecorder({ user_uuid: 'u2', name: 'منشی مدیسا', role: 'secretary', doctor_uuid: null })); + renderWithProviders( {}} />); + + const link = await screen.findByRole('link', { name: 'منشی مدیسا' }); + expect(link).toHaveAttribute('href', '/admin/my-secretaries'); + }); + + it('بیننده‌ای که آن صفحه را ندارد، فقط نام می‌بیند نه لینک', async () => { + useAuthStore.setState({ primaryRole: 'secretary' } as any); + mockInvoice(withRecorder({ user_uuid: 'u2', name: 'منشی مدیسا', role: 'secretary', doctor_uuid: null })); + renderWithProviders( {}} />); + + expect(await screen.findByText('منشی مدیسا')).toBeInTheDocument(); + expect(screen.queryByRole('link', { name: 'منشی مدیسا' })).toBeNull(); + }); + + it('بدون created_by (کاربر حذف‌شده): همان عکسِ ذخیره‌شده، بدون لینک', async () => { + useAuthStore.setState({ primaryRole: 'clinic' } as any); + mockInvoice(withRecorder(null)); + renderWithProviders( {}} />); + + expect(await screen.findByText('09120000000')).toBeInTheDocument(); + expect(screen.queryByRole('link', { name: '09120000000' })).toBeNull(); + }); + }); + it('فاکتور بدون بیمه، جدول بیمه ندارد', async () => { mockInvoice(baseInvoice); renderWithProviders( {}} />); diff --git a/assets/admin/components/InvoiceSummaryModal.tsx b/assets/admin/components/InvoiceSummaryModal.tsx index 985c25c4..fe2d8d4a 100644 --- a/assets/admin/components/InvoiceSummaryModal.tsx +++ b/assets/admin/components/InvoiceSummaryModal.tsx @@ -1,12 +1,25 @@ import { useQuery } from '@tanstack/react-query'; +import { Link } from 'react-router-dom'; import { api } from '../lib/api'; import type { ApiResponse } from '../lib/api'; +import { useAuthStore } from '../stores/authStore'; import Modal from './ui/Modal'; import { formatDate, formatDateTime, formatRial } from '../lib/utils'; import { METHOD_LABELS } from './session/PaymentStep'; interface InvoiceItem { uuid: string; title: string; quantity: number; total_rials: number; patient_rials: number } -interface SessionPayment { uuid: string; method: string; amount_rials: number; paid_at: number; created_by_name: string | null } +/** ثبت‌کنندهٔ پرداخت — نامش زنده از خودِ کاربر حل می‌شود، نه از عکسِ لحظهٔ ثبت. */ +interface PaymentRecorder { + user_uuid: string; + name: string | null; + role: 'admin' | 'clinic' | 'doctor' | 'secretary' | 'staff' | 'representation' | 'user'; + doctor_uuid: string | null; +} +interface SessionPayment { + uuid: string; method: string; amount_rials: number; paid_at: number; + created_by_name: string | null; + created_by?: PaymentRecorder | null; +} interface SessionConsumable { uuid: string; item_name: string; quantity: number; line_total_rials: number } interface SessionData { session_at: number | null; paid_at: number | null; @@ -60,8 +73,47 @@ function SectionTable({ title, cols, rows }: { title: string; cols: string[]; ro ); } +/** + * پروفایلِ ثبت‌کننده در پنلِ همین بیننده — یا `null` وقتی صفحه‌ای برایش وجود ندارد. + * + * فقط پزشک صفحهٔ پروفایلِ مستقل دارد؛ منشی و پرسنل صفحهٔ فهرستِ مدیریتشان را دارند + * و ادمین فهرستِ خودش را. مسیری که نقشِ بیننده اجازه‌اش را ندارد لینک نمی‌شود، وگرنه + * کلیک به داشبورد پرت می‌کرد. + */ +function recorderProfilePath(recorder: PaymentRecorder, viewerRole: string | null): string | null { + if (recorder.role === 'doctor' && recorder.doctor_uuid) { + return ['admin', 'doctor', 'clinic', 'representation'].includes(viewerRole ?? '') + ? `/admin/doctors/${recorder.doctor_uuid}` + : null; + } + if (recorder.role === 'secretary') { + if (viewerRole === 'admin') return '/admin/secretaries'; + return viewerRole === 'clinic' || viewerRole === 'doctor' ? '/admin/my-secretaries' : null; + } + if (recorder.role === 'staff') { + return ['clinic', 'doctor', 'secretary'].includes(viewerRole ?? '') ? '/admin/staff' : null; + } + if (viewerRole === 'admin') return `/admin/users/${recorder.user_uuid}`; + + return null; +} + +/** سلولِ «ثبت‌کننده»: نام، و اگر پروفایلی در دسترسِ بیننده باشد، لینکش. */ +function RecorderCell({ payment, viewerRole }: { payment: SessionPayment; viewerRole: string | null }) { + const recorder = payment.created_by ?? null; + const name = recorder?.name ?? payment.created_by_name; + if (!name) return <>-; + + const path = recorder ? recorderProfilePath(recorder, viewerRole) : null; + + return path + ? {name} + : <>{name}; +} + /** خلاصه فاکتور — invoice summary, ported pixel-for-pixel from tauri InvoiceSummary. */ export default function InvoiceSummaryModal({ invoiceUuid, onClose }: { invoiceUuid: string | null; onClose: () => void }) { + const viewerRole = useAuthStore(s => s.primaryRole); const { data, isLoading } = useQuery>({ queryKey: ['invoice', invoiceUuid], queryFn: () => api.get(`/api/v1/billing/invoices/${invoiceUuid}`), @@ -156,7 +208,7 @@ export default function InvoiceSummaryModal({ invoiceUuid, onClose }: { invoiceU METHOD_LABELS[p.method] ?? p.method, formatRial(p.amount_rials), p.paid_at ? formatDateTime(p.paid_at) : '-', - p.created_by_name ?? '-', + , ]), ['', مجموع پرداختی‌ها, {formatRial(session.paid_total_rials)}, '', ''], ] diff --git a/docs/api/billing.md b/docs/api/billing.md index e1a9944e..c8f5177b 100644 --- a/docs/api/billing.md +++ b/docs/api/billing.md @@ -130,7 +130,12 @@ ddev exec php bin/console app:billing:backfill-claims # ساخت م "final_price_rials": 2240000, "paid_total_rials": 1500000, "payments": [ - { "uuid": "…", "method": "wallet", "amount_rials": 1500000, "paid_at": 1718990000, "created_by_name": "منشی تست", "created_at": 1718990000 } + { + "uuid": "…", "method": "wallet", "amount_rials": 1500000, + "paid_at": 1718990000, "created_at": 1718990000, + "created_by_name": "منشی تست", + "created_by": { "user_uuid": "…", "name": "منشی تست", "role": "secretary", "doctor_uuid": null } + } ], "consumables": [ { "uuid": "…", "item_name": "عینک", "quantity": 2, "price_rials": 20000, "line_total_rials": 40000 } @@ -141,6 +146,24 @@ ddev exec php bin/console app:billing:backfill-claims # ساخت م } ``` +### ثبت‌کنندهٔ هر پرداخت (2026-08) + +`created_by` هویتِ کاربری است که پرداخت را ثبت کرده، برای نمایش نام و لینک‌دادن به +پروفایلش: + +| فیلد | توضیح | +|---|---| +| `user_uuid` | uuid کاربر | +| `name` | نامِ نمایشی — پروفایل ← `real_name` ← شمارهٔ موبایل | +| `role` | نقشِ پرتوان کاربر: `admin\|clinic\|doctor\|secretary\|staff\|representation\|user` | +| `doctor_uuid` | فقط برای نقش `doctor` — تنها نقشی که در پنل صفحهٔ پروفایل مستقل دارد | + +- `created_by_name` روی ردیف **عکسِ لحظهٔ ثبت** است و برای کاربری که آن‌وقت پروفایل + نداشته ممکن است شمارهٔ موبایل باشد؛ در این پاسخ با نامِ زندهٔ همان کاربر بازنویسی + می‌شود و فقط وقتی دست‌نخورده می‌ماند که `created_by` تهی باشد (کاربر حذف شده). +- مسیرِ پروفایل عمداً در پاسخ نیست: سرور مسیرهای پنل را نمی‌شناسد و دسترسیِ هر نقش + به هر صفحه کارِ کلاینت است. + **Errors:** `404 ERR_NOT_FOUND_001`. --- diff --git a/src/Billing/Service/InvoiceService.php b/src/Billing/Service/InvoiceService.php index fcf8f787..e6e94094 100644 --- a/src/Billing/Service/InvoiceService.php +++ b/src/Billing/Service/InvoiceService.php @@ -12,6 +12,7 @@ use App\Insurance\Repository\InsuranceRepository; use App\Insurance\Service\TenantInsuranceService; use App\Patient\Entity\PatientSession; use App\Patient\Repository\PatientSessionRepository; +use App\Shared\Service\ActorIdentityResolver; use Psr\EventDispatcher\EventDispatcherInterface; class InvoiceService @@ -23,6 +24,7 @@ class InvoiceService private readonly PatientSessionRepository $sessionRepo, private readonly InsuranceRepository $insuranceRepo, private readonly EventDispatcherInterface $events, + private readonly ActorIdentityResolver $actorIdentity, ) {} /** @var array نام بیمه‌ها، یک‌بار در هر درخواست. */ @@ -112,6 +114,10 @@ class InvoiceService $session = $sessionId !== null ? $this->sessionRepo->find($sessionId) : null; $data['session'] = $session?->toArray(); + if ($session !== null) { + $data['session']['payments'] = $this->withRecorderIdentity($session, $data['session']['payments'] ?? []); + } + // نام بیمه‌ها برای چاپ روی فاکتور؛ فاکتور فقط شناسه را نگه می‌دارد. $data['base_insurance_name'] = $this->insuranceName($invoice->getBaseInsuranceId()); $data['supplementary_insurance_name'] = $this->insuranceName($invoice->getSupplementaryInsuranceId()); @@ -119,6 +125,34 @@ class InvoiceService return $data; } + /** + * ستون «ثبت‌کننده»ی خلاصهٔ فاکتور: هویتِ کاربرِ ثبت‌کننده کنار هر پرداخت. + * + * `created_by_name` روی ردیف، عکسِ لحظهٔ ثبت است و برای کاربری که آن‌وقت پروفایل + * نداشته شمارهٔ موبایل ذخیره شده؛ پس نام از خودِ کاربر دوباره حل می‌شود و عکس فقط + * برای کاربرِ حذف‌شده می‌ماند. `created_by` هم شناسه‌ها را می‌دهد تا کلاینت بتواند + * به پروفایلش لینک کند. + * + * @param list> $rows + * @return list> + */ + private function withRecorderIdentity(PatientSession $session, array $rows): array + { + $actorByUuid = []; + foreach ($session->getPayments() as $payment) { + $actorByUuid[$payment->getUuid()] = $payment->getCreatedBy(); + } + + return array_map(function (array $row) use ($actorByUuid): array { + $identity = $this->actorIdentity->identity($actorByUuid[$row['uuid'] ?? ''] ?? null); + + $row['created_by'] = $identity; + $row['created_by_name'] = $identity['name'] ?? ($row['created_by_name'] ?? null); + + return $row; + }, $rows); + } + /** * Paginated flat list of a tenant's recorded (finalized/paid) invoices for * the payments list (node 1). Rows arrive ready-shaped from the repository; diff --git a/src/Settlement/Service/WalletService.php b/src/Settlement/Service/WalletService.php index b7ed6978..2db4c152 100644 --- a/src/Settlement/Service/WalletService.php +++ b/src/Settlement/Service/WalletService.php @@ -8,7 +8,7 @@ use App\Settlement\Repository\SettlementRepository; use App\Settlement\Repository\WalletTransactionRepository; use App\Shared\Constant\ErrorCodes; use App\Shared\Exception\AppException; -use App\UserProfile\Repository\UserProfileRepository; +use App\Shared\Service\ActorIdentityResolver; /** * منطق کیف پولِ بیمار: موجودی، شارژ (credit)، برداشت (debit) و تسویهٔ سرویس از @@ -24,7 +24,7 @@ class WalletService public function __construct( private readonly WalletTransactionRepository $walletRepo, private readonly SettlementRepository $settlementRepo, - private readonly UserProfileRepository $profileRepo, + private readonly ActorIdentityResolver $actorIdentity, ) {} public function balance(User $patient): int @@ -32,16 +32,14 @@ class WalletService return $this->settlementRepo->getWalletBalance($patient); } - /** نامِ نمایشیِ کاربرِ عامل (برای ستون «ثبت‌کننده»)؛ در نبودِ پروفایل، شماره موبایل. */ + /** + * نامِ نمایشیِ کاربرِ عامل (برای ستون «ثبت‌کننده»). + * پیاده‌سازی در {@see ActorIdentityResolver} است تا نامِ ذخیره‌شده و نامِ + * نمایش‌داده‌شده از یک قاعده بیایند. + */ public function resolveActorName(?User $actor): ?string { - if ($actor === null) { - return null; - } - $profile = $this->profileRepo->findByUser($actor); - $name = trim(($profile?->getLabel() ?? '') . ' ' . ($profile?->getFamily() ?? '')); - - return $name !== '' ? $name : $actor->getMobileNumber(); + return $this->actorIdentity->name($actor); } /** شارژِ کیف پول (credit). فرض بر مثبت بودنِ مبلغ است (اعتبارسنجی در Controller). */ diff --git a/src/Shared/Service/ActorIdentityResolver.php b/src/Shared/Service/ActorIdentityResolver.php new file mode 100644 index 00000000..8a224b6e --- /dev/null +++ b/src/Shared/Service/ActorIdentityResolver.php @@ -0,0 +1,79 @@ +profileRepo->findByUser($actor); + $name = trim(($profile?->getLabel() ?? '') . ' ' . ($profile?->getFamily() ?? '')); + if ($name !== '') { + return $name; + } + + $realName = trim((string) $actor->getRealName()); + + return $realName !== '' ? $realName : $actor->getMobileNumber(); + } + + /** + * هویتِ کاملِ عامل برای نمایش و لینک‌دادن به پروفایلش. + * + * `doctor_uuid` فقط برای پزشک پر می‌شود — تنها نقشی که در پنل صفحهٔ پروفایلِ + * مستقل دارد. مسیرِ لینک کارِ کلاینت است، نه سرور: سرور مسیرهای پنل را نمی‌داند. + * + * @return array{user_uuid:string,name:?string,role:string,doctor_uuid:?string}|null + */ + public function identity(?User $actor): ?array + { + if ($actor === null) { + return null; + } + + $role = $this->primaryRole($actor); + + return [ + 'user_uuid' => $actor->getUuid(), + 'name' => $this->name($actor), + 'role' => $role, + 'doctor_uuid' => $role === 'doctor' ? $this->doctorRepo->findByUser($actor)?->getUuid() : null, + ]; + } + + /** همان اولویتِ نقشِ `oauth/userinfo` — پرتوان‌ترین نقش برنده است. */ + private function primaryRole(User $actor): string + { + $roles = $actor->getRoles(); + if (in_array('ROLE_ADMIN', $roles, true)) return 'admin'; + if (in_array('ROLE_CLINIC', $roles, true)) return 'clinic'; + if (in_array('ROLE_DOCTOR', $roles, true)) return 'doctor'; + if (in_array('ROLE_SECRETARY', $roles, true)) return 'secretary'; + if (in_array('ROLE_STAFF', $roles, true)) return 'staff'; + if (in_array('ROLE_REPRESENTATION', $roles, true)) return 'representation'; + + return 'user'; + } +} diff --git a/tests/Billing/InvoiceShowSessionTest.php b/tests/Billing/InvoiceShowSessionTest.php index 6df00322..8b2ab3fd 100644 --- a/tests/Billing/InvoiceShowSessionTest.php +++ b/tests/Billing/InvoiceShowSessionTest.php @@ -111,6 +111,72 @@ class InvoiceShowSessionTest extends ApiTestCase self::assertSame(40_000, $s['consumables_total_rials']); } + /** + * ستون «ثبت‌کننده» باید نامِ زندهٔ کاربر و شناسه‌های لینکش را بدهد؛ عکسِ + * `created_by_name` ممکن است شمارهٔ موبایلِ زمانی باشد که کاربر پروفایل نداشته. + */ + public function testShowResolvesRecorderIdentityFromUser(): void + { + [$owner, $doctor] = $this->doctor(); + $record = $this->patientRecord($doctor); + + $recorderDoctor = new Doctor($this->createUser(['ROLE_DOCTOR']), 'دکتر ثبت‌کننده'); + $this->em->persist($recorderDoctor); + $recorder = $recorderDoctor->getUser(); + $recorder->setRealName('دکتر ثبت‌کننده'); + + $session = new PatientSession($record); + $this->em->persist($session); + $this->em->flush(); + + $payment = new SessionPayment($session, 'cash', 500_000, 1_700_100_000); + // عکسِ کهنه: همان چیزی که هنگام ثبت ذخیره شده بود. + $payment->setCreatedByName($recorder->getMobileNumber())->setCreatedBy($recorder); + $this->em->persist($payment); + $session->addPayment($payment); + + $invoice = new Invoice('doctor', $doctor->getId()); + $invoice->setPatientSessionId($session->getId()); + $this->em->persist($invoice); + $this->em->flush(); + + $res = $this->authJson('GET', '/api/v1/billing/invoices/' . $invoice->getUuid(), $owner); + self::assertSame(200, $this->responseCode()); + + $row = $res['data']['data']['session']['payments'][0]; + self::assertSame('دکتر ثبت‌کننده', $row['created_by_name']); + self::assertSame($recorder->getUuid(), $row['created_by']['user_uuid']); + self::assertSame('doctor', $row['created_by']['role']); + self::assertSame($recorderDoctor->getUuid(), $row['created_by']['doctor_uuid']); + } + + /** کاربرِ حذف‌شده: هویتی نیست، پس همان عکسِ ذخیره‌شده می‌ماند. */ + public function testShowKeepsSnapshotNameWhenRecorderUnknown(): void + { + [$owner, $doctor] = $this->doctor(); + $record = $this->patientRecord($doctor); + + $session = new PatientSession($record); + $this->em->persist($session); + $this->em->flush(); + + $payment = new SessionPayment($session, 'cash', 100_000, 1_700_100_000); + $payment->setCreatedByName('منشی قدیمی'); + $this->em->persist($payment); + $session->addPayment($payment); + + $invoice = new Invoice('doctor', $doctor->getId()); + $invoice->setPatientSessionId($session->getId()); + $this->em->persist($invoice); + $this->em->flush(); + + $res = $this->authJson('GET', '/api/v1/billing/invoices/' . $invoice->getUuid(), $owner); + + $row = $res['data']['data']['session']['payments'][0]; + self::assertSame('منشی قدیمی', $row['created_by_name']); + self::assertNull($row['created_by']); + } + public function testShowSessionNullWhenInvoiceHasNoSession(): void { [$owner, $doctor] = $this->doctor();