feat(billing): add command and tests for backfilling missing invoices for paid sessions

This commit is contained in:
hamed
2026-08-10 12:08:58 +03:30
parent 3365a0427e
commit 7073377122
2 changed files with 257 additions and 0 deletions
@@ -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];
}
}