diff --git a/docs/api/billing.md b/docs/api/billing.md index cb1cd5fb..bc4dc068 100644 --- a/docs/api/billing.md +++ b/docs/api/billing.md @@ -120,6 +120,10 @@ | `from` | int (Unix) | مطالبات با `created_at >= from` (شروع بازه) | | `to` | int (Unix) | مطالبات با `created_at <= to` (پایان بازه) | | `q` | string | جستجوی بیمار: موبایل، کدملی یا نام (LIKE) | +| `page` | int | شماره صفحه (پیش‌فرض ۱) | +| `limit` | int | تعداد در هر صفحه (پیش‌فرض ۵۰، حداکثر ۱۰۰) | + +> **صفحه‌بندی:** پاسخ علاوه بر `data.data` (آرایه‌ی مطالبات) یک `data.meta` با `totalRecords`/`totalPages`/`currentPage` دارد. پاکت قبلی (`data.data`) دست‌نخورده است؛ کلاینت‌های موجود بدون تغییر کار می‌کنند. > بازه‌ی تاریخ شمسی (سال/ماه/روز خاص) در سمت کلاینت به `from`/`to` یونیکس تبدیل می‌شود؛ backend فقط بازه‌ی یونیکس می‌گیرد. فیلتر `q` از طریق زنجیره `claim → claim_item → invoice_item → invoice → patient_record → user` با `EXISTS` اعمال می‌شود. diff --git a/docs/api/settlement.md b/docs/api/settlement.md index 1d8eff38..a3769913 100644 --- a/docs/api/settlement.md +++ b/docs/api/settlement.md @@ -39,32 +39,41 @@ Get authenticated user's wallet balance. ## GET `/api/v1/wallet/transactions` -Get wallet transaction history for the authenticated user. +Get wallet transaction history for the authenticated user (paginated, newest first). **Permission:** `AUTH` +### Query Parameters +| پارامتر | نوع | توضیح | +|---------|-----|-------| +| `page` | int | شماره صفحه (پیش‌فرض ۱) | +| `limit` | int | تعداد در هر صفحه (پیش‌فرض ۵۰، حداکثر ۱۰۰) | + ### Response `200` ```json { "success": true, - "data": [ - { - "uuid": "...", - "type": "credit", - "amount_rials": 500000, - "balance_after": 2500000, - "description": "دریافت از نوبت", - "created_at": 1717000000 - }, - { - "uuid": "...", - "type": "debit", - "amount_rials": 200000, - "balance_after": 2300000, - "description": "تسویه‌حساب", - "created_at": 1716900000 - } - ] + "data": { + "data": [ + { + "uuid": "...", + "type": "credit", + "amount_rials": 500000, + "balance_after": 2500000, + "description": "دریافت از نوبت", + "created_at": 1717000000 + }, + { + "uuid": "...", + "type": "debit", + "amount_rials": 200000, + "balance_after": 2300000, + "description": "تسویه‌حساب", + "created_at": 1716900000 + } + ], + "meta": { "totalRecords": 124, "totalPages": 3, "currentPage": 1 } + } } ``` diff --git a/docs/audit-backlog.md b/docs/audit-backlog.md index b98feb7d..4ea9dc5b 100644 --- a/docs/audit-backlog.md +++ b/docs/audit-backlog.md @@ -45,8 +45,8 @@ _None outstanding._ | ✅H6 | N+1: clinic doctors list lazy-loads `specialties` per doctor | src/Doctor/Repository/DoctorRepository.php (findByClinicWithFilters) | perf-nplus1 | **DONE** — `addSelect('s')` + `Paginator(fetchJoinCollection:true)`. Added `ApiTestCase::countQueries()` helper. `tests/Doctor/ClinicDoctorListNPlusOneTest` (query count constant vs doctor count; verified 4→10 without fix). | | ✅H7 | N+1: comments list lazy-loads `likes`→`user`, `replies` (recursive), author realName | src/Rating/Repository/CommentRepository.php (findApprovedRootsByDoctor) | perf-nplus1 | **DONE** — two fetch-join passes (roots+user+likes; replies+their user+likes+one more reply level) hydrate everything `toArray()` touches → bounded queries for a 2-level thread. Functional correctness test `tests/Rating/CommentListNPlusOneTest` (like counts, approved-only replies). Query-count assertion unreliable through HTTP here, so correctness-tested. | | ✅H8 | N+1: insurance service-coverage `serviceItemRepo->find()` per row in array_map | src/Insurance/Controller/InsuranceController.php:406-420 | perf-nplus1 | **DONE** — batch `findBy(['id' => $ids])` + uuid map. Verified by functional correctness test (`tests/Insurance/ServiceCoverageNPlusOneTest`); query-count assertion was unreliable for this endpoint (identity-map), so correctness-tested instead. | -| H9 | Unbounded list: `listClaims` loads ALL tenant claims, no LIMIT/pagination | src/Billing/Controller/BillingController.php:173 · ClaimRepository.php:64 | perf-pagination | GET claims high volume → must paginate | -| H10 | Unbounded list: `wallet/transactions` loads ALL user transactions, no LIMIT | src/Settlement/Controller/SettlementController.php:89-94 | perf-pagination | GET wallet transactions → must paginate | +| ✅H9 | Unbounded list: `listClaims` loads ALL tenant claims, no LIMIT/pagination | src/Billing/Controller/BillingController.php · ClaimRepository.php | perf-pagination | **DONE** — `findByTenant(page,limit)` + `countByTenant` (shared QB), default limit 50/max 100. Additive `data.meta` (envelope unchanged → backward compatible). `tests/Billing/ClaimsListPaginationTest`. | +| ✅H10 | Unbounded list: `wallet/transactions` ~~loads ALL~~ user transactions | src/Settlement/Controller/SettlementController.php · WalletTransactionRepository.php | perf-pagination | **DONE** — **finding overstated**: `findByUser` already defaulted to `limit=50` (bounded, just page-less). Added `page`/`offset` + `countByUser` + `data.meta`. `tests/Settlement/WalletTransactionsPaginationTest`. | --- diff --git a/src/Billing/Controller/BillingController.php b/src/Billing/Controller/BillingController.php index 1a970317..350b66ab 100644 --- a/src/Billing/Controller/BillingController.php +++ b/src/Billing/Controller/BillingController.php @@ -184,9 +184,23 @@ class BillingController extends BaseController 'to' => $request->query->get('to') ?: null, 'q' => $request->query->get('q') ?: null, ]; - $claims = $this->claimRepo->findByTenant($entityType, $entityId, $filters); - return $this->success(['data' => $this->enrichClaims($claims)]); + $page = max(1, (int) $request->query->get('page', 1)); + $limit = min(100, max(1, (int) $request->query->get('limit', 50))); + + $claims = $this->claimRepo->findByTenant($entityType, $entityId, $filters, $page, $limit); + $total = $this->claimRepo->countByTenant($entityType, $entityId, $filters); + + // meta added inside the existing { data: { data: [...] } } envelope so + // existing clients keep reading data.data unchanged. + return $this->success([ + 'data' => $this->enrichClaims($claims), + 'meta' => [ + 'totalRecords' => $total, + 'totalPages' => (int) ceil($total / $limit), + 'currentPage' => $page, + ], + ]); } #[Route('/api/v1/billing/claims/{uuid}/{action}', methods: ['POST'], requirements: ['action' => 'submit|approve|reject|pay'])] diff --git a/src/Billing/Repository/ClaimRepository.php b/src/Billing/Repository/ClaimRepository.php index 4493deca..e5db7988 100644 --- a/src/Billing/Repository/ClaimRepository.php +++ b/src/Billing/Repository/ClaimRepository.php @@ -22,14 +22,31 @@ class ClaimRepository extends ServiceEntityRepository * @param array{status?:?string, insurance_id?:?int, from?:?int, to?:?int, q?:?string} $filters * @return Claim[] */ - public function findByTenant(string $entityType, int $entityId, array $filters = []): array + public function findByTenant(string $entityType, int $entityId, array $filters = [], int $page = 1, int $limit = 50): array + { + return $this->tenantQb($entityType, $entityId, $filters) + ->orderBy('c.id', 'DESC') + ->setFirstResult(($page - 1) * $limit) + ->setMaxResults($limit) + ->getQuery() + ->getResult(); + } + + public function countByTenant(string $entityType, int $entityId, array $filters = []): int + { + return (int) $this->tenantQb($entityType, $entityId, $filters) + ->select('COUNT(c.id)') + ->getQuery() + ->getSingleScalarResult(); + } + + private function tenantQb(string $entityType, int $entityId, array $filters): \Doctrine\ORM\QueryBuilder { $qb = $this->createQueryBuilder('c') ->where('c.entityType = :type') ->andWhere('c.entityId = :id') ->setParameter('type', $entityType) - ->setParameter('id', $entityId) - ->orderBy('c.id', 'DESC'); + ->setParameter('id', $entityId); if (!empty($filters['status'])) { $qb->andWhere('c.status = :status')->setParameter('status', $filters['status']); @@ -61,7 +78,7 @@ class ClaimRepository extends ServiceEntityRepository )->setParameter('q', '%' . $q . '%'); } - return $qb->getQuery()->getResult(); + return $qb; } /** diff --git a/src/Settlement/Controller/SettlementController.php b/src/Settlement/Controller/SettlementController.php index 31241375..4a75ce0f 100644 --- a/src/Settlement/Controller/SettlementController.php +++ b/src/Settlement/Controller/SettlementController.php @@ -86,14 +86,27 @@ class SettlementController extends BaseController ] )] #[Route('/api/v1/wallet/transactions', methods: ['GET'])] - public function transactions(#[CurrentUser] User $user): JsonResponse + public function transactions(Request $request, #[CurrentUser] User $user): JsonResponse { + $page = max(1, (int) $request->query->get('page', 1)); + $limit = min(100, max(1, (int) $request->query->get('limit', 50))); + $transactions = array_map( fn(WalletTransaction $t) => $t->toArray(), - $this->walletRepo->findByUser($user) + $this->walletRepo->findByUser($user, $limit, ($page - 1) * $limit) ); + $total = $this->walletRepo->countByUser($user); - return $this->success(['data' => $transactions]); + // meta added inside the existing { data: { data: [...] } } envelope so + // existing clients keep reading data.data unchanged. + return $this->success([ + 'data' => $transactions, + 'meta' => [ + 'totalRecords' => $total, + 'totalPages' => (int) ceil($total / $limit), + 'currentPage' => $page, + ], + ]); } // ── Settlement Requests ─────────────────────────────────────────────────── diff --git a/src/Settlement/Repository/WalletTransactionRepository.php b/src/Settlement/Repository/WalletTransactionRepository.php index 177e27c0..1a99219d 100644 --- a/src/Settlement/Repository/WalletTransactionRepository.php +++ b/src/Settlement/Repository/WalletTransactionRepository.php @@ -15,9 +15,14 @@ class WalletTransactionRepository extends ServiceEntityRepository } /** @return WalletTransaction[] */ - public function findByUser(User $user, int $limit = 50): array + public function findByUser(User $user, int $limit = 50, int $offset = 0): array { - return $this->findBy(['user' => $user], ['createdAt' => 'DESC'], $limit); + return $this->findBy(['user' => $user], ['createdAt' => 'DESC'], $limit, $offset); + } + + public function countByUser(User $user): int + { + return $this->count(['user' => $user]); } public function save(WalletTransaction $entity, bool $flush = true): void diff --git a/tests/Billing/ClaimsListPaginationTest.php b/tests/Billing/ClaimsListPaginationTest.php new file mode 100644 index 00000000..9ac2f5fe --- /dev/null +++ b/tests/Billing/ClaimsListPaginationTest.php @@ -0,0 +1,36 @@ +createUser(['ROLE_DOCTOR']); + $doctor = new Doctor($owner, 'دکتر تست'); + $this->em->persist($doctor); + $this->em->flush(); + + for ($i = 0; $i < 7; $i++) { + $this->em->persist(new Claim('doctor', $doctor->getId(), 1, 'base')); + } + $this->em->flush(); + + $body = $this->authJson('GET', '/api/v1/billing/claims?limit=5', $owner); + $this->assertSame(200, $this->responseCode()); + $this->assertCount(5, $body['data']['data']); + $this->assertSame(7, $body['data']['meta']['totalRecords']); + $this->assertSame(2, $body['data']['meta']['totalPages']); + + $page2 = $this->authJson('GET', '/api/v1/billing/claims?limit=5&page=2', $owner); + $this->assertCount(2, $page2['data']['data']); + } +} diff --git a/tests/Settlement/WalletTransactionsPaginationTest.php b/tests/Settlement/WalletTransactionsPaginationTest.php new file mode 100644 index 00000000..0cc2cdeb --- /dev/null +++ b/tests/Settlement/WalletTransactionsPaginationTest.php @@ -0,0 +1,31 @@ +createUser(); + for ($i = 0; $i < 7; $i++) { + $this->em->persist(new WalletTransaction($user, 1000, 'credit', 1000)); + } + $this->em->flush(); + + $body = $this->authJson('GET', '/api/v1/wallet/transactions?limit=5', $user); + $this->assertSame(200, $this->responseCode()); + $this->assertCount(5, $body['data']['data']); + $this->assertSame(7, $body['data']['meta']['totalRecords']); + $this->assertSame(2, $body['data']['meta']['totalPages']); + + $page2 = $this->authJson('GET', '/api/v1/wallet/transactions?limit=5&page=2', $user); + $this->assertCount(2, $page2['data']['data']); + } +}