fix(representation): count only online bookings in the agent panel

The agent's own dashboard (dashboard/summary and doctors/performance) counted
every appointment belonging to their doctors, so the bookings a secretary types
into the panel — which earn the agent nothing — sat next to a commission column
that ignored them. Both now count only bookings that came from the agent's own
site, matching the monthly/yearly report. The per-doctor income column is also
scoped to this agent, since a doctor may have been under another one before.

Adds a backfill for the bookings paid before city domains resolved to an agent:
they carry neither booking_representation_id nor a FinancialBreakdown, and
neither can be recovered by replaying the request. Both are derived from
payments.frontend_address, the address the payment was started from. The
recovered breakdown is dated to the payment, not to the run, or a year of
commission would land in "today". Dry-run by default; re-running is a no-op,
and a payment whose domain does not match the doctor's owner is reported and
skipped rather than retried forever.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
hamed
2026-08-19 23:24:48 +03:30
co-authored by Claude Opus 5
parent be12502873
commit f863b39a74
5 changed files with 244 additions and 6 deletions
+9
View File
@@ -284,6 +284,15 @@ Get yearly earnings dashboard for a representation.
## پنل نماینده (ROLE_REPRESENTATION) ## پنل نماینده (ROLE_REPRESENTATION)
> **شمارش نوبت:** `dashboard/summary` و `doctors/performance` هم فقط نوبت‌های آنلاینِ همین نماینده را می‌شمارند (`appointments.booking_representation_id`). نوبتی که منشی در پنل ثبت می‌کند شمرده نمی‌شود. ستون درآمدِ هر پزشک هم فقط سهم همین نماینده است، نه سهم نمایندگان قبلیِ آن پزشک.
>
> **بازسازی گذشته:** نوبت‌های آنلاینی که پیش از نگاشت دامنه‌ی شهری پرداخت شده‌اند نه `booking_representation_id` دارند و نه ردیف `FinancialBreakdown`. دستور زیر هر دو را از روی `payments.frontend_address` می‌سازد؛ بدون `--force` فقط گزارش می‌دهد و تاریخ ردیف مالی روی لحظه‌ی پرداخت می‌نشیند، نه لحظه‌ی اجرا:
>
> ```
> php bin/console app:representation:backfill-online-commission [--force]
> ```
این endpointها برای کاربرِ دارای نقش `ROLE_REPRESENTATION` در پنل ادمین (`/admin`) هستند. مالکیت همیشه از کاربر جاری (`#[CurrentUser]` + `findByUser`) تعیین می‌شود؛ هیچ uuid/id ورودی برای تعیین مالکیت پذیرفته نمی‌شود. این endpointها برای کاربرِ دارای نقش `ROLE_REPRESENTATION` در پنل ادمین (`/admin`) هستند. مالکیت همیشه از کاربر جاری (`#[CurrentUser]` + `findByUser`) تعیین می‌شود؛ هیچ uuid/id ورودی برای تعیین مالکیت پذیرفته نمی‌شود.
> **Permission (همه‌ی این بخش):** `ROLE_REPRESENTATION` > **Permission (همه‌ی این بخش):** `ROLE_REPRESENTATION`
+3
View File
@@ -112,6 +112,9 @@ class Payment
public function getFrontendAddress(): ?string { return $this->frontendAddress; } public function getFrontendAddress(): ?string { return $this->frontendAddress; }
public function getCallbackIp(): ?string { return $this->callbackIp; } public function getCallbackIp(): ?string { return $this->callbackIp; }
public function getMetadata(): ?array { return $this->metadata; } public function getMetadata(): ?array { return $this->metadata; }
public function getCreatedAt(): int { return $this->createdAt; }
/** برای پرداختِ موفق یعنی لحظهٔ تأیید درگاه — همان چیزی که پنل «تاریخ پرداخت» می‌نامد. */
public function getUpdatedAt(): int { return $this->updatedAt; }
public function setAppointment(?Appointment $a): self { $this->appointment = $a; return $this; } public function setAppointment(?Appointment $a): self { $this->appointment = $a; return $this; }
public function setMetadata(?array $metadata): self { $this->metadata = $metadata; $this->touch(); return $this; } public function setMetadata(?array $metadata): self { $this->metadata = $metadata; $this->touch(); return $this; }
@@ -0,0 +1,192 @@
<?php
namespace App\Representation\Command;
use App\Appointment\Entity\Appointment;
use App\Payment\Entity\Payment;
use App\Payment\Repository\PaymentRepository;
use App\Representation\Service\DomainContextResolver;
use App\Settlement\Repository\FinancialBreakdownRepository;
use App\Settlement\Service\CommissionService;
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;
/**
* Online bookings paid before city domains could be mapped to a representative.
*
* Two things were missed at the time and neither can be recovered by replaying
* the request: the appointment never got `booking_representation_id`, so it is
* invisible in the representative's stats, and no FinancialBreakdown was ever
* written, so the commission was never owed to anyone.
*
* Both are derived from `payments.frontend_address`, the address the payment was
* actually started from — the same source the live path uses. Nothing is
* invented: a payment whose domain still maps to no representative is skipped.
*
* Dry-run by default; `--force` writes. Re-running writes nothing twice —
* CommissionService bails on a payment that already has a breakdown.
*/
#[AsCommand(
name: 'app:representation:backfill-online-commission',
description: 'Attribute past online-paid appointments to their representative and record the missing commission',
)]
class BackfillOnlineCommissionCommand extends Command
{
public function __construct(
private readonly EntityManagerInterface $em,
private readonly PaymentRepository $paymentRepo,
private readonly DomainContextResolver $domainResolver,
private readonly CommissionService $commissionService,
private readonly FinancialBreakdownRepository $breakdownRepo,
) {
parent::__construct();
}
protected function configure(): void
{
$this->addOption('force', null, InputOption::VALUE_NONE, 'Write the changes instead of only reporting them');
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$io = new SymfonyStyle($input, $output);
$force = (bool) $input->getOption('force');
/** @var Payment[] $payments */
$payments = $this->em->createQueryBuilder()
->select('p')
->from(Payment::class, 'p')
->where('p.type = :type')
->andWhere('p.status = :status')
->andWhere('p.appointment IS NOT NULL')
->setParameter('type', Payment::TYPE_APPOINTMENT)
->setParameter('status', Payment::STATUS_SUCCESS)
->orderBy('p.id', 'ASC')
->getQuery()
->getResult();
$rows = [];
$stamped = 0;
$settled = 0;
$mismatched = 0;
foreach ($payments as $payment) {
$appointment = $payment->getAppointment();
if (!$appointment instanceof Appointment) {
continue;
}
$repId = $this->domainResolver->resolve($payment->getFrontendAddress())->representationId();
if ($repId === null) {
continue;
}
// نماینده‌ی دامنه و نماینده‌ی مالکِ پزشک یکی نیستند: گاردِ زندهٔ پورسانت هم
// همین را رد می‌کند، پس ردیفی ساخته نمی‌شود و گزارش نباید آن را «قابل
// بازسازی» نشان دهد.
if ($appointment->getDoctor()->getRepresentationId() !== $repId) {
$mismatched++;
continue;
}
$needsStamp = $appointment->getBookingRepresentationId() !== $repId;
$needsSettle = !$this->breakdownRepo->existsForPayment($payment);
// ردیفی که یک اجرای قبلیِ همین دستور ساخته، تاریخِ آن اجرا را دارد نه
// تاریخ پرداخت. مسیر زنده همیشه هم‌تاریخ می‌سازد، پس اختلاف یعنی بازسازی.
$needsBackdate = !$needsSettle && $this->breakdownDateOf($payment) !== $payment->getUpdatedAt();
if (!$needsStamp && !$needsSettle && !$needsBackdate) {
continue;
}
$rows[] = [
$payment->getOrderId(),
$appointment->getDoctor()->getName(),
$repId,
$needsStamp ? 'yes' : '—',
$needsSettle ? 'yes' : ($needsBackdate ? 'date' : '—'),
];
if (!$force) {
continue;
}
if ($needsStamp) {
$appointment->setBookingRepresentationId($repId);
$this->em->persist($appointment);
$stamped++;
}
if ($needsSettle) {
// همان مسیر زندهٔ پرداخت؛ گاردِ دامنه و درصدها اینجا دوباره نوشته نمی‌شوند.
$this->commissionService->processAppointment(
$payment,
$appointment->getDoctor()->getRepresentationId(),
$repId,
$appointment->getDoctor()->getId(),
);
if ($this->breakdownRepo->existsForPayment($payment)) {
$this->backdateToPayment($payment);
$settled++;
}
} elseif ($needsBackdate) {
$this->backdateToPayment($payment);
}
}
if ($mismatched > 0) {
$io->note(sprintf('%d پرداخت رد شد: نمایندهٔ دامنه با نمایندهٔ مالکِ پزشک یکی نیست.', $mismatched));
}
if ($rows === []) {
$io->success('چیزی برای بازسازی نیست.');
return Command::SUCCESS;
}
$io->table(['order_id', 'doctor', 'rep_id', 'stamp', 'commission'], $rows);
if (!$force) {
$io->note(sprintf('%d پرداخت قابل بازسازی است. برای اعمال، دوباره با --force اجرا کنید.', count($rows)));
return Command::SUCCESS;
}
$this->em->flush();
$io->success(sprintf('%d نوبت نشانه‌گذاری شد، %d پورسانت ثبت شد.', $stamped, $settled));
return Command::SUCCESS;
}
/**
* تاریخ ثبتِ ردیف بازسازی‌شده را روی لحظهٔ پرداخت می‌گذارد، نه لحظهٔ اجرای این
* دستور. وگرنه پورسانتِ ماه‌های گذشته یک‌جا در «درآمد امروز» می‌نشیند و گزارش
* دوره‌ای نماینده بی‌معنا می‌شود.
*
* `FinancialBreakdown::$createdAt` عمداً setter ندارد — ردیف مالی پس از ثبت
* تغییر نمی‌کند — پس این استثنا همین‌جا و فقط برای بازسازی می‌ماند.
*/
private function breakdownDateOf(Payment $payment): ?int
{
$row = $this->em->createQuery(
'SELECT b.createdAt FROM App\\Settlement\\Entity\\FinancialBreakdown b WHERE b.payment = :payment'
)->setParameter('payment', $payment)->setMaxResults(1)->getScalarResult();
return $row === [] ? null : (int) $row[0]['createdAt'];
}
private function backdateToPayment(Payment $payment): void
{
$this->em->createQuery(
'UPDATE App\\Settlement\\Entity\\FinancialBreakdown b
SET b.createdAt = :at WHERE b.payment = :payment'
)->setParameters(['at' => $payment->getUpdatedAt(), 'payment' => $payment])
->execute();
}
}
@@ -670,9 +670,14 @@ class RepresentationActionController extends BaseController
$week = $now - 7 * 86400; $week = $now - 7 * 86400;
$month = $now - 30 * 86400; $month = $now - 30 * 86400;
// فقط نوبت‌های **آنلاینِ** همین نماینده. نوبتی که منشی در پنل ثبت می‌کند از
// سایت نماینده نیامده و سهمی هم نمی‌سازد، پس در آمار او هم نباید بیاید.
// `bookingRepresentationId` تنها در مسیر رزرو سایت عمومی پر می‌شود.
$apptCount = fn(?int $start): int => (int) $this->em->createQuery( $apptCount = fn(?int $start): int => (int) $this->em->createQuery(
'SELECT COUNT(a.id) FROM App\Appointment\Entity\Appointment a 'SELECT COUNT(a.id) FROM App\Appointment\Entity\Appointment a
JOIN a.doctor d WHERE d.representationId = :repId' . ($start !== null ? ' AND a.createdAt >= :start' : '') JOIN a.doctor d
WHERE d.representationId = :repId AND a.bookingRepresentationId = :repId'
. ($start !== null ? ' AND a.createdAt >= :start' : '')
)->setParameters($start !== null ? ['repId' => $repId, 'start' => $start] : ['repId' => $repId]) )->setParameters($start !== null ? ['repId' => $repId, 'start' => $start] : ['repId' => $repId])
->getSingleScalarResult(); ->getSingleScalarResult();
@@ -739,18 +744,25 @@ class RepresentationActionController extends BaseController
$week = $now - 7 * 86400; $week = $now - 7 * 86400;
$month = $now - 30 * 86400; $month = $now - 30 * 86400;
// همان قاعدهٔ خلاصهٔ داشبورد: فقط نوبت‌های آنلاینِ همین نماینده شمرده می‌شوند،
// وگرنه ستون «نوبت» با ستون «درآمد نماینده» در یک جدول ناسازگار می‌شد.
$apptCount = fn(int $doctorId, ?int $start): int => (int) $this->em->createQuery( $apptCount = fn(int $doctorId, ?int $start): int => (int) $this->em->createQuery(
'SELECT COUNT(a.id) FROM App\Appointment\Entity\Appointment a 'SELECT COUNT(a.id) FROM App\Appointment\Entity\Appointment a
WHERE a.doctor = :d' . ($start !== null ? ' AND a.createdAt >= :start' : '') WHERE a.doctor = :d AND a.bookingRepresentationId = :repId'
)->setParameters($start !== null ? ['d' => $doctorId, 'start' => $start] : ['d' => $doctorId]) . ($start !== null ? ' AND a.createdAt >= :start' : '')
)->setParameters($start !== null
? ['d' => $doctorId, 'repId' => $repId, 'start' => $start]
: ['d' => $doctorId, 'repId' => $repId])
->getSingleScalarResult(); ->getSingleScalarResult();
$items = array_map(function (array $d) use ($apptCount, $today, $week, $month): array { $items = array_map(function (array $d) use ($apptCount, $repId, $today, $week, $month): array {
$doctorId = (int) $d['id']; $doctorId = (int) $d['id'];
// سهم همین نماینده از این پزشک، نه سهم هر نماینده‌ای: پزشک می‌تواند در
// گذشته زیر نمایندهٔ دیگری بوده باشد و آن درآمد سهم این یکی نیست.
$income = (int) ($this->em->createQuery( $income = (int) ($this->em->createQuery(
'SELECT SUM(b.representationShareRials) FROM App\Settlement\Entity\FinancialBreakdown b 'SELECT SUM(b.representationShareRials) FROM App\Settlement\Entity\FinancialBreakdown b
WHERE b.doctorId = :d' WHERE b.doctorId = :d AND b.representationId = :repId'
)->setParameter('d', $doctorId)->getSingleScalarResult() ?? 0); )->setParameters(['d' => $doctorId, 'repId' => $repId])->getSingleScalarResult() ?? 0);
$sub = $this->subscriptionService->getActiveSubscription('doctor', $doctorId); $sub = $this->subscriptionService->getActiveSubscription('doctor', $doctorId);
$status = $sub === null ? 'none' : 'active'; $status = $sub === null ? 'none' : 'active';
@@ -134,6 +134,28 @@ class OnlineAppointmentCommissionTest extends ApiTestCase
self::assertCount(1, $rows, 'ثبت باید idempotent بماند'); self::assertCount(1, $rows, 'ثبت باید idempotent بماند');
} }
/** پنل خودِ نماینده هم همان قاعده را دارد، نه فقط گزارش ماهانه/سالانه. */
public function testRepresentationPanelSummaryCountsOnlyOnlineAppointments(): void
{
[$rep, , $doctor] = $this->makeRepWithCityAndDoctor();
$start = time() + 86400;
$online = $this->newAppointment($doctor, $this->createUser(), $start, $start + 900);
$online->setBookingRepresentationId($rep->getId());
$this->em->persist($online);
$manual = $this->newAppointment($doctor, $this->createUser(), $start + 3600, $start + 4500);
$this->em->persist($manual);
$this->em->flush();
$body = $this->authJson('GET', '/api/v1/representation/dashboard/summary', $rep->getUser());
self::assertSame(1, $body['data']['appointments']['total']);
$perf = $this->authJson('GET', '/api/v1/representation/doctors/performance?limit=100', $rep->getUser());
$row = current(array_filter($perf['data'], fn(array $r) => $r['uuid'] === $doctor->getUuid()));
self::assertSame(1, $row['appointments']['total']);
}
public function testDashboardCountsOnlyOnlineAppointments(): void public function testDashboardCountsOnlyOnlineAppointments(): void
{ {
[$rep, , $doctor] = $this->makeRepWithCityAndDoctor(); [$rep, , $doctor] = $this->makeRepWithCityAndDoctor();