feat(billing): add command and tests for backfilling missing invoices for paid sessions
This commit is contained in:
@@ -0,0 +1,116 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Billing\Command;
|
||||||
|
|
||||||
|
use App\Billing\Service\SessionBillingService;
|
||||||
|
use App\Patient\Entity\PatientSession;
|
||||||
|
use Doctrine\ORM\EntityManagerInterface;
|
||||||
|
use Symfony\Component\Console\Attribute\AsCommand;
|
||||||
|
use Symfony\Component\Console\Command\Command;
|
||||||
|
use Symfony\Component\Console\Input\InputInterface;
|
||||||
|
use Symfony\Component\Console\Input\InputOption;
|
||||||
|
use Symfony\Component\Console\Output\OutputInterface;
|
||||||
|
use Symfony\Component\Console\Style\SymfonyStyle;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* صورتحسابِ جاافتادهٔ مراجعههایی که پول گرفتهاند را میسازد.
|
||||||
|
*
|
||||||
|
* تا پیش از این، صورتحساب فقط برای مراجعهٔ بیمهدار ساخته میشد؛ نوبتِ بدون بیمه که
|
||||||
|
* از پنل قطعی و پرداخت میشد، `session_payments` میگرفت ولی هیچ صورتحسابی نداشت و
|
||||||
|
* چون فهرست «پرداختها» صورتحسابمحور است، آن پول هیچجا دیده نمیشد. قاعده اصلاح
|
||||||
|
* شده؛ این دستور فقط دادهٔ گذشته را جبران میکند.
|
||||||
|
*
|
||||||
|
* ddev exec php bin/console app:billing:backfill-paid-invoices --dry-run
|
||||||
|
*/
|
||||||
|
#[AsCommand(
|
||||||
|
name: 'app:billing:backfill-paid-invoices',
|
||||||
|
description: 'Create the missing invoice for sessions that already recorded a payment',
|
||||||
|
)]
|
||||||
|
class BackfillPaidSessionInvoicesCommand extends Command
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly SessionBillingService $sessionBilling,
|
||||||
|
private readonly EntityManagerInterface $em,
|
||||||
|
) {
|
||||||
|
parent::__construct();
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function configure(): void
|
||||||
|
{
|
||||||
|
$this->addOption('dry-run', null, InputOption::VALUE_NONE, 'فقط گزارش بده، چیزی ننویس');
|
||||||
|
// محیطها مستقلاند و جبرانِ داده معمولاً برای یک مشتری لازم میشود، نه همه.
|
||||||
|
$this->addOption('tenant', null, InputOption::VALUE_REQUIRED, 'فقط یک محیط، به شکل type:id — مثلاً clinic:5');
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function execute(InputInterface $input, OutputInterface $output): int
|
||||||
|
{
|
||||||
|
$io = new SymfonyStyle($input, $output);
|
||||||
|
$dryRun = (bool) $input->getOption('dry-run');
|
||||||
|
|
||||||
|
$dql = 'SELECT s FROM ' . PatientSession::class . ' s
|
||||||
|
JOIN s.record r
|
||||||
|
WHERE EXISTS (SELECT 1 FROM App\Patient\Entity\SessionPayment p WHERE IDENTITY(p.session) = s.id)
|
||||||
|
AND NOT EXISTS (SELECT 1 FROM App\Billing\Entity\Invoice i WHERE i.patientSessionId = s.id)';
|
||||||
|
|
||||||
|
$tenant = $this->parseTenant($input->getOption('tenant'));
|
||||||
|
if ($tenant !== null) {
|
||||||
|
$dql .= ' AND r.entityType = :type AND r.entityId = :id';
|
||||||
|
}
|
||||||
|
|
||||||
|
$query = $this->em->createQuery($dql);
|
||||||
|
if ($tenant !== null) {
|
||||||
|
$query->setParameter('type', $tenant[0])->setParameter('id', $tenant[1]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @var list<PatientSession> $sessions */
|
||||||
|
$sessions = $query->getResult();
|
||||||
|
|
||||||
|
if ($sessions === []) {
|
||||||
|
$io->success('همهٔ مراجعههای پرداختشده صورتحساب دارند.');
|
||||||
|
|
||||||
|
return Command::SUCCESS;
|
||||||
|
}
|
||||||
|
|
||||||
|
$rows = [];
|
||||||
|
foreach ($sessions as $session) {
|
||||||
|
$record = $session->getRecord();
|
||||||
|
$rows[] = [
|
||||||
|
$session->getId(),
|
||||||
|
$record->getEntityType() . ':' . $record->getEntityId(),
|
||||||
|
$session->getPaidTotalRials(),
|
||||||
|
];
|
||||||
|
|
||||||
|
if (!$dryRun) {
|
||||||
|
$this->sessionBilling->ensureFinalizedInvoice(
|
||||||
|
$session,
|
||||||
|
$record->getEntityType(),
|
||||||
|
$record->getEntityId(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$io->table(['session', 'tenant', 'paid_rials'], $rows);
|
||||||
|
$io->success(sprintf(
|
||||||
|
'%d مراجعه %s.',
|
||||||
|
count($rows),
|
||||||
|
$dryRun ? 'صورتحساب ندارند (dry-run)' : 'صورتحساب گرفتند',
|
||||||
|
));
|
||||||
|
|
||||||
|
return Command::SUCCESS;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return array{0: string, 1: int}|null */
|
||||||
|
private function parseTenant(mixed $raw): ?array
|
||||||
|
{
|
||||||
|
if (!is_string($raw) || $raw === '') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
[$type, $id] = array_pad(explode(':', $raw, 2), 2, null);
|
||||||
|
if ($type === null || $id === null || !ctype_digit((string) $id)) {
|
||||||
|
throw new \InvalidArgumentException('قالب tenant باید type:id باشد — مثلاً clinic:5');
|
||||||
|
}
|
||||||
|
|
||||||
|
return [$type, (int) $id];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,141 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Tests\Billing;
|
||||||
|
|
||||||
|
use App\Billing\Entity\Invoice;
|
||||||
|
use App\Billing\Service\SessionBillingService;
|
||||||
|
use App\Doctor\Entity\Doctor;
|
||||||
|
use App\Patient\Entity\PatientRecord;
|
||||||
|
use App\Patient\Entity\PatientSession;
|
||||||
|
use App\Patient\Service\PatientService;
|
||||||
|
use App\Tests\ApiTestCase;
|
||||||
|
use Symfony\Bundle\FrameworkBundle\Console\Application;
|
||||||
|
use Symfony\Component\Console\Tester\CommandTester;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* جبران دادهٔ گذشته: مراجعههایی که پیش از اصلاحِ قاعده پول گرفتند ولی صورتحساب
|
||||||
|
* نداشتند و برای همین در فهرست «پرداختها» دیده نمیشدند.
|
||||||
|
*/
|
||||||
|
class BackfillPaidSessionInvoicesTest extends ApiTestCase
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* همیشه با --tenant اجرا میشود: db_test بین اجراها پاک نمیشود و بدون محدودکردن
|
||||||
|
* محیط، هر تست کلِ دادهٔ قدیمیِ پایگاه را هم بکفیل میکرد.
|
||||||
|
*/
|
||||||
|
private function runCommand(int $doctorId, bool $dryRun = false): string
|
||||||
|
{
|
||||||
|
$application = new Application(static::$kernel);
|
||||||
|
$tester = new CommandTester($application->find('app:billing:backfill-paid-invoices'));
|
||||||
|
|
||||||
|
$args = ['--tenant' => 'doctor:' . $doctorId];
|
||||||
|
if ($dryRun) {
|
||||||
|
$args['--dry-run'] = true;
|
||||||
|
}
|
||||||
|
$tester->execute($args);
|
||||||
|
|
||||||
|
return $tester->getDisplay();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return array{0: PatientSession, 1: int} مراجعهٔ پرداختشدهٔ بدون صورتحساب و شناسهٔ پزشکش */
|
||||||
|
private function makePaidSessionWithoutInvoice(): array
|
||||||
|
{
|
||||||
|
$user = $this->createUser(['ROLE_USER', 'ROLE_DOCTOR']);
|
||||||
|
$doctor = new Doctor($user, 'دکتر بکفیل');
|
||||||
|
$doctor->setMobileNumber($user->getMobileNumber());
|
||||||
|
$this->em->persist($doctor);
|
||||||
|
$this->em->flush();
|
||||||
|
|
||||||
|
$record = new PatientRecord('doctor', $doctor->getId(), $this->createUser(), 'doctor', $doctor->getId());
|
||||||
|
$this->em->persist($record);
|
||||||
|
|
||||||
|
$session = new PatientSession($record);
|
||||||
|
$session->setVisitPriceRials(3_000_000);
|
||||||
|
$session->applyShares(3_000_000, 0, 0, 3_000_000);
|
||||||
|
$this->em->persist($session);
|
||||||
|
$this->em->flush();
|
||||||
|
|
||||||
|
// مسیر واقعی ثبت پول، ولی صورتحسابی که همان مسیر میسازد عمداً حذف میشود
|
||||||
|
// تا وضعیتِ پیش از اصلاح بازسازی شود.
|
||||||
|
static::getContainer()->get(PatientService::class)
|
||||||
|
->addSessionPayment($session, 'cash', 1_000_000);
|
||||||
|
|
||||||
|
foreach ($this->em->getRepository(Invoice::class)->findBy(['patientSessionId' => $session->getId()]) as $invoice) {
|
||||||
|
$this->em->remove($invoice);
|
||||||
|
}
|
||||||
|
$this->em->flush();
|
||||||
|
|
||||||
|
return [$session, $doctor->getId()];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testBackfillCreatesTheMissingFinalizedInvoice(): void
|
||||||
|
{
|
||||||
|
[$session, $doctorId] = $this->makePaidSessionWithoutInvoice();
|
||||||
|
|
||||||
|
$this->runCommand($doctorId);
|
||||||
|
$this->em->clear();
|
||||||
|
|
||||||
|
$invoices = $this->em->getRepository(Invoice::class)->findBy(['patientSessionId' => $session->getId()]);
|
||||||
|
self::assertCount(1, $invoices);
|
||||||
|
self::assertSame(Invoice::STATUS_FINALIZED, $invoices[0]->getStatus());
|
||||||
|
self::assertSame(3_000_000, $invoices[0]->getTotalRials());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testDryRunWritesNothing(): void
|
||||||
|
{
|
||||||
|
[$session, $doctorId] = $this->makePaidSessionWithoutInvoice();
|
||||||
|
|
||||||
|
$display = $this->runCommand($doctorId, dryRun: true);
|
||||||
|
$this->em->clear();
|
||||||
|
|
||||||
|
self::assertStringContainsString((string) $session->getId(), $display);
|
||||||
|
self::assertSame([], $this->em->getRepository(Invoice::class)->findBy(['patientSessionId' => $session->getId()]));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testRunningTwiceDoesNotDuplicateInvoices(): void
|
||||||
|
{
|
||||||
|
[$session, $doctorId] = $this->makePaidSessionWithoutInvoice();
|
||||||
|
|
||||||
|
$this->runCommand($doctorId);
|
||||||
|
$this->runCommand($doctorId);
|
||||||
|
$this->em->clear();
|
||||||
|
|
||||||
|
self::assertCount(1, $this->em->getRepository(Invoice::class)->findBy(['patientSessionId' => $session->getId()]));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** مرزی: مراجعهٔ بدون پرداخت نباید صورتحساب بگیرد. */
|
||||||
|
public function testUnpaidSessionIsLeftAlone(): void
|
||||||
|
{
|
||||||
|
$user = $this->createUser(['ROLE_USER', 'ROLE_DOCTOR']);
|
||||||
|
$doctor = new Doctor($user, 'دکتر بدون پرداخت');
|
||||||
|
$doctor->setMobileNumber($user->getMobileNumber());
|
||||||
|
$this->em->persist($doctor);
|
||||||
|
$this->em->flush();
|
||||||
|
|
||||||
|
$record = new PatientRecord('doctor', $doctor->getId(), $this->createUser(), 'doctor', $doctor->getId());
|
||||||
|
$this->em->persist($record);
|
||||||
|
|
||||||
|
$session = new PatientSession($record);
|
||||||
|
$session->setVisitPriceRials(1_000_000);
|
||||||
|
$session->applyShares(1_000_000, 0, 0, 1_000_000);
|
||||||
|
$this->em->persist($session);
|
||||||
|
$this->em->flush();
|
||||||
|
|
||||||
|
$this->runCommand($doctor->getId());
|
||||||
|
$this->em->clear();
|
||||||
|
|
||||||
|
self::assertSame([], $this->em->getRepository(Invoice::class)->findBy(['patientSessionId' => $session->getId()]));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** رفتار سرویس، مستقل از کامند: پرداختدار یعنی صورتحساب لازم. */
|
||||||
|
public function testServiceCreatesInvoiceOnlyWhenMoneyOrInsuranceExists(): void
|
||||||
|
{
|
||||||
|
$billing = static::getContainer()->get(SessionBillingService::class);
|
||||||
|
[$session] = $this->makePaidSessionWithoutInvoice();
|
||||||
|
$record = $session->getRecord();
|
||||||
|
|
||||||
|
$invoice = $billing->ensureFinalizedInvoice($session, $record->getEntityType(), $record->getEntityId());
|
||||||
|
|
||||||
|
self::assertNotNull($invoice);
|
||||||
|
self::assertSame(Invoice::STATUS_FINALIZED, $invoice->getStatus());
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user