feat: enhance payment status handling and summary in MyPaymentsPage

- Added support for 'partial' payment status in MyPaymentsPage and related components.
- Updated API responses to include 'paid_rials' and 'summary' for invoices.
- Introduced InvoicePaymentStatus service to derive payment status based on actual payments.
- Enhanced tests to cover new payment scenarios including partial payments and payment methods.
- Updated documentation to reflect changes in payment status and API responses.
This commit is contained in:
hamed
2026-07-19 10:38:29 +03:30
parent 5c8fe8ece4
commit 357adb1d14
18 changed files with 620 additions and 188 deletions
+3 -2
View File
@@ -217,7 +217,7 @@ class BillingController extends BaseController
/**
* پرداخت‌های ثبت‌شده‌ی یک بیمار — سربرگ بیمار + فهرست صفحه‌بندی‌شده‌ی صورتحساب‌ها.
* فقط مالک رکورد (همان tenant) اجازه دارد؛ در غیر این صورت ۴۰۴.
* پاسخ: { patient:{uuid,name,national_code}, data:[صورتحساب‌ها], meta:{...} }.
* پاسخ: { patient:{uuid,name,national_code}, data:[صورتحساب‌ها], summary:{...}, meta:{...} }.
*/
#[Route('/api/v1/my/billing/patients/{patientUuid}/invoices', methods: ['GET'])]
public function listPatientInvoices(string $patientUuid, Request $request, #[CurrentUser] User $user): JsonResponse
@@ -244,7 +244,8 @@ class BillingController extends BaseController
'name' => $patient->getRealName(),
'national_code' => $patient->getNationalCode(),
],
'data' => $result['items'],
'data' => $result['items'],
'summary' => $result['summary'],
'meta' => [
'totalRecords' => $result['total'],
'totalPages' => (int) ceil($result['total'] / $limit),
+1
View File
@@ -93,6 +93,7 @@ class Invoice
public function getTotalRials(): int { return $this->totalRials; }
public function getPatientRials(): int { return $this->patientRials; }
public function getIssuedAt(): int { return $this->issuedAt; }
public function getPatientSessionId(): ?int { return $this->patientSessionId; }
/** @return Collection<int, InvoiceItem> */
public function getItems(): Collection { return $this->items; }
+114 -20
View File
@@ -3,7 +3,9 @@
namespace App\Billing\Repository;
use App\Billing\Entity\Invoice;
use App\Billing\Service\InvoicePaymentStatus;
use App\Patient\Entity\PatientRecord;
use App\Patient\Entity\SessionPayment;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\ORM\Query\Expr\Join;
use Doctrine\ORM\QueryBuilder;
@@ -40,7 +42,8 @@ class InvoiceRepository extends ServiceEntityRepository
->select(
'i.uuid AS invoice_uuid', 'r.uuid AS patient_uuid', 'u.realName AS patient_name',
'u.nationalCode AS national_code', 'i.issuedAt AS issued_at',
'i.patientRials AS amount_rials', 'i.status AS status',
'i.patientRials AS amount_rials',
sprintf('%s AS paid_rials', self::paidSumDql('sp_row')),
)
->orderBy('i.issuedAt', 'DESC')
->setFirstResult(($page - 1) * $limit)
@@ -55,10 +58,78 @@ class InvoiceRepository extends ServiceEntityRepository
'national_code' => $r['national_code'],
'issued_at' => (int) $r['issued_at'],
'amount_rials' => (int) $r['amount_rials'],
'status' => $r['status'] === Invoice::STATUS_PAID ? 'paid' : 'unsettled',
'paid_rials' => (int) $r['paid_rials'],
'status' => InvoicePaymentStatus::resolve((int) $r['amount_rials'], (int) $r['paid_rials']),
], $rows);
}
/**
* زیرپرس‌وجوی مجموع پرداخت‌های مراجعه‌ی همان صورتحساب. صورتحساب بدون مراجعه
* هیچ پرداختی ندارد، پس `COALESCE` صفر می‌دهد و «تسویه‌نشده» می‌ماند.
*
* @param string $alias نام یکتا؛ استفاده‌ی دوبار از یک alias در یک query خطای semantical می‌دهد.
*/
private static function paidSumDql(string $alias): string
{
return sprintf(
'(SELECT COALESCE(SUM(%1$s.amountRials), 0) FROM %2$s %1$s WHERE IDENTITY(%1$s.session) = i.patientSessionId)',
$alias,
SessionPayment::class,
);
}
/**
* پرداخت‌های ثبت‌شده‌ی هر صورتحساب (از راه مراجعه‌اش)، در یک کوئری برای کل صفحه.
* مبلغ وصول‌شده هم از همین ردیف‌ها جمع می‌شود تا کوئری دوم لازم نباشد.
*
* @param list<Invoice> $invoices
* @return array<int, list<array{method:string,amount_rials:int,paid_at:int,created_by_name:?string}>>
* کلید = شناسه‌ی صورتحساب؛ قدیمی‌ترین پرداخت اول.
*/
public function paymentsForInvoices(array $invoices): array
{
$sessionIds = [];
foreach ($invoices as $invoice) {
$sessionId = $invoice->getPatientSessionId();
if ($sessionId !== null) {
$sessionIds[$invoice->getId()] = $sessionId;
}
}
if ($sessionIds === []) {
return [];
}
$rows = $this->getEntityManager()->createQueryBuilder()
->select(
'IDENTITY(sp.session) AS session_id', 'sp.method AS method',
'sp.amountRials AS amount_rials', 'sp.paidAt AS paid_at',
'sp.createdByName AS created_by_name',
)
->from(SessionPayment::class, 'sp')
->where('IDENTITY(sp.session) IN (:sessions)')
->setParameter('sessions', array_values($sessionIds))
->orderBy('sp.paidAt', 'ASC')
->getQuery()
->getArrayResult();
$bySession = [];
foreach ($rows as $row) {
$bySession[(int) $row['session_id']][] = [
'method' => $row['method'],
'amount_rials' => (int) $row['amount_rials'],
'paid_at' => (int) $row['paid_at'],
'created_by_name' => $row['created_by_name'],
];
}
$byInvoice = [];
foreach ($sessionIds as $invoiceId => $sessionId) {
$byInvoice[$invoiceId] = $bySession[$sessionId] ?? [];
}
return $byInvoice;
}
public function countTenantInvoices(string $entityType, int $entityId, array $filters): int
{
return (int) $this->tenantInvoicesQuery($entityType, $entityId, $filters)
@@ -76,18 +147,43 @@ class InvoiceRepository extends ServiceEntityRepository
*/
public function tenantInvoiceSummary(string $entityType, int $entityId, array $filters): array
{
$row = $this->tenantInvoicesQuery($entityType, $entityId, $filters)
return $this->summarize($this->tenantInvoicesQuery($entityType, $entityId, $filters), 'patientRials');
}
/**
* Aggregate totals over one patient's invoices — the same set {@see invoicesForPatient}
* pages through, so the detail page's cards match its table.
*
* @return array{total_rials:int,paid_rials:int,unsettled_rials:int,invoices_count:int}
*/
public function patientInvoiceSummary(string $entityType, int $entityId, int $recordId): array
{
return $this->summarize($this->patientInvoicesQuery($entityType, $entityId, $recordId), 'totalRials');
}
/**
* Sum `$field` over a prepared invoice query, split by paid vs. still unsettled.
*
* @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)
->select(
'COALESCE(SUM(i.patientRials), 0) AS total_rials',
'COALESCE(SUM(CASE WHEN i.status = :paidStatus THEN i.patientRials ELSE 0 END), 0) AS paid_rials',
sprintf('COALESCE(SUM(i.%s), 0) AS total_rials', $field),
'COUNT(i.id) AS invoices_count',
)
->setParameter('paidStatus', Invoice::STATUS_PAID)
->getQuery()
->getSingleResult();
// «پرداخت‌شده» = مجموع صورتحساب‌هایی که سهم بیمارشان کامل وصول شده
$paid = (int) (clone $qb)
->select(sprintf('COALESCE(SUM(i.%s), 0)', $field))
->andWhere(sprintf('%s >= i.patientRials', self::paidSumDql('sp_sum')))
->getQuery()
->getSingleScalarResult();
$total = (int) $row['total_rials'];
$paid = (int) $row['paid_rials'];
return [
'total_rials' => $total,
@@ -118,11 +214,17 @@ class InvoiceRepository extends ServiceEntityRepository
if (!empty($filters['to'])) {
$qb->andWhere('i.issuedAt <= :to')->setParameter('to', (int) $filters['to']);
}
if (($filters['status'] ?? null) === 'paid') {
$qb->andWhere('i.status = :st')->setParameter('st', Invoice::STATUS_PAID);
} elseif (($filters['status'] ?? null) === 'unsettled') {
$qb->andWhere('i.status = :st')->setParameter('st', Invoice::STATUS_FINALIZED);
}
// وضعیت پرداخت مشتق است (پرداخت‌های مراجعه در برابر سهم بیمار)، نه ستون status
match ($filters['status'] ?? null) {
InvoicePaymentStatus::PAID => $qb->andWhere(sprintf('%s >= i.patientRials', self::paidSumDql('sp_f1'))),
InvoicePaymentStatus::PARTIAL => $qb->andWhere(sprintf(
'%s > 0 AND %s < i.patientRials',
self::paidSumDql('sp_f1'),
self::paidSumDql('sp_f2'),
)),
InvoicePaymentStatus::UNSETTLED => $qb->andWhere(sprintf('%s <= 0 AND i.patientRials > 0', self::paidSumDql('sp_f1'))),
default => null,
};
return $qb;
}
@@ -141,14 +243,6 @@ class InvoiceRepository extends ServiceEntityRepository
->getResult();
}
public function countInvoicesForPatient(string $entityType, int $entityId, int $recordId): int
{
return (int) $this->patientInvoicesQuery($entityType, $entityId, $recordId)
->select('COUNT(i.id)')
->getQuery()
->getSingleScalarResult();
}
private function patientInvoicesQuery(string $entityType, int $entityId, int $recordId): QueryBuilder
{
return $this->createQueryBuilder('i')
@@ -0,0 +1,28 @@
<?php
namespace App\Billing\Service;
/**
* وضعیت پرداخت یک صورتحساب.
*
* ستون `invoices.status` چرخه‌ی حیات صورتحساب را نگه می‌دارد (draft → finalized → void)
* و هرگز به `paid` نمی‌رود؛ پول واقعی در `session_payments` ثبت می‌شود. بنابراین وضعیت
* پرداخت **مشتق** است: مجموع پرداخت‌های مراجعه در برابر سهم بیمار (`patient_rials`).
* همین‌جا تنها مرجع این قاعده است تا نماها از هم واگرا نشوند.
*/
final class InvoicePaymentStatus
{
public const PAID = 'paid';
public const PARTIAL = 'partial';
public const UNSETTLED = 'unsettled';
/** @return self::PAID|self::PARTIAL|self::UNSETTLED */
public static function resolve(int $dueRials, int $paidRials): string
{
if ($paidRials >= $dueRials) {
return self::PAID;
}
return $paidRials > 0 ? self::PARTIAL : self::UNSETTLED;
}
}
+18 -6
View File
@@ -126,11 +126,14 @@ class InvoiceService
* time, a single service title (first item, "+ more" when several), total,
* a two-state status (paid|unsettled), and the full item breakdown.
*
* @return array{items: list<array<string, mixed>>, total: int}
* @return array{items: list<array<string, mixed>>, total: int, summary: array{total_rials:int,paid_rials:int,unsettled_rials:int,invoices_count:int}}
*/
public function patientInvoiceList(string $entityType, int $entityId, int $recordId, int $page, int $limit): array
{
$items = array_map(function (Invoice $invoice): array {
$invoices = $this->invoiceRepo->invoicesForPatient($entityType, $entityId, $recordId, $page, $limit);
$paymentsById = $this->invoiceRepo->paymentsForInvoices($invoices);
$items = array_map(function (Invoice $invoice) use ($paymentsById): array {
$lineItems = array_map(static fn(InvoiceItem $i) => $i->toArray(), $invoice->getItems()->toArray());
$title = match (count($lineItems)) {
0 => null,
@@ -138,20 +141,29 @@ class InvoiceService
default => $lineItems[0]['title'] . ' و موارد دیگر',
};
$payments = $paymentsById[$invoice->getId()] ?? [];
$paid = array_sum(array_column($payments, 'amount_rials'));
return [
'uuid' => $invoice->getUuid(),
'number' => $invoice->getId(),
'issued_at' => $invoice->getIssuedAt(),
'total_rials' => $invoice->getTotalRials(),
'status' => $invoice->getStatus() === Invoice::STATUS_PAID ? 'paid' : 'unsettled',
'patient_rials' => $invoice->getPatientRials(),
'paid_rials' => $paid,
'status' => InvoicePaymentStatus::resolve($invoice->getPatientRials(), $paid),
'service_title' => $title,
'items' => $lineItems,
'payments' => $payments,
];
}, $this->invoiceRepo->invoicesForPatient($entityType, $entityId, $recordId, $page, $limit));
}, $invoices);
$summary = $this->invoiceRepo->patientInvoiceSummary($entityType, $entityId, $recordId);
return [
'items' => $items,
'total' => $this->invoiceRepo->countInvoicesForPatient($entityType, $entityId, $recordId),
'items' => $items,
'total' => $summary['invoices_count'],
'summary' => $summary,
];
}
}