diff --git a/docs/api/representation.md b/docs/api/representation.md index 90f91863..c9444a37 100644 --- a/docs/api/representation.md +++ b/docs/api/representation.md @@ -284,6 +284,15 @@ Get yearly earnings dashboard for a 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 ورودی برای تعیین مالکیت پذیرفته نمی‌شود. > **Permission (همه‌ی این بخش):** `ROLE_REPRESENTATION` diff --git a/src/Payment/Entity/Payment.php b/src/Payment/Entity/Payment.php index 0dcd55a1..11989cdf 100644 --- a/src/Payment/Entity/Payment.php +++ b/src/Payment/Entity/Payment.php @@ -112,6 +112,9 @@ class Payment public function getFrontendAddress(): ?string { return $this->frontendAddress; } public function getCallbackIp(): ?string { return $this->callbackIp; } 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 setMetadata(?array $metadata): self { $this->metadata = $metadata; $this->touch(); return $this; } diff --git a/src/Representation/Command/BackfillOnlineCommissionCommand.php b/src/Representation/Command/BackfillOnlineCommissionCommand.php new file mode 100644 index 00000000..a151414d --- /dev/null +++ b/src/Representation/Command/BackfillOnlineCommissionCommand.php @@ -0,0 +1,192 @@ +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(); + } +} diff --git a/src/Representation/Controller/RepresentationActionController.php b/src/Representation/Controller/RepresentationActionController.php index f31610c4..951d3161 100644 --- a/src/Representation/Controller/RepresentationActionController.php +++ b/src/Representation/Controller/RepresentationActionController.php @@ -670,9 +670,14 @@ class RepresentationActionController extends BaseController $week = $now - 7 * 86400; $month = $now - 30 * 86400; + // فقط نوبت‌های **آنلاینِ** همین نماینده. نوبتی که منشی در پنل ثبت می‌کند از + // سایت نماینده نیامده و سهمی هم نمی‌سازد، پس در آمار او هم نباید بیاید. + // `bookingRepresentationId` تنها در مسیر رزرو سایت عمومی پر می‌شود. $apptCount = fn(?int $start): int => (int) $this->em->createQuery( '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]) ->getSingleScalarResult(); @@ -739,18 +744,25 @@ class RepresentationActionController extends BaseController $week = $now - 7 * 86400; $month = $now - 30 * 86400; + // همان قاعدهٔ خلاصهٔ داشبورد: فقط نوبت‌های آنلاینِ همین نماینده شمرده می‌شوند، + // وگرنه ستون «نوبت» با ستون «درآمد نماینده» در یک جدول ناسازگار می‌شد. $apptCount = fn(int $doctorId, ?int $start): int => (int) $this->em->createQuery( 'SELECT COUNT(a.id) FROM App\Appointment\Entity\Appointment a - WHERE a.doctor = :d' . ($start !== null ? ' AND a.createdAt >= :start' : '') - )->setParameters($start !== null ? ['d' => $doctorId, 'start' => $start] : ['d' => $doctorId]) + WHERE a.doctor = :d AND a.bookingRepresentationId = :repId' + . ($start !== null ? ' AND a.createdAt >= :start' : '') + )->setParameters($start !== null + ? ['d' => $doctorId, 'repId' => $repId, 'start' => $start] + : ['d' => $doctorId, 'repId' => $repId]) ->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']; + // سهم همین نماینده از این پزشک، نه سهم هر نماینده‌ای: پزشک می‌تواند در + // گذشته زیر نمایندهٔ دیگری بوده باشد و آن درآمد سهم این یکی نیست. $income = (int) ($this->em->createQuery( 'SELECT SUM(b.representationShareRials) FROM App\Settlement\Entity\FinancialBreakdown b - WHERE b.doctorId = :d' - )->setParameter('d', $doctorId)->getSingleScalarResult() ?? 0); + WHERE b.doctorId = :d AND b.representationId = :repId' + )->setParameters(['d' => $doctorId, 'repId' => $repId])->getSingleScalarResult() ?? 0); $sub = $this->subscriptionService->getActiveSubscription('doctor', $doctorId); $status = $sub === null ? 'none' : 'active'; diff --git a/tests/Representation/OnlineAppointmentCommissionTest.php b/tests/Representation/OnlineAppointmentCommissionTest.php index bcdcf9f0..b4d0877e 100644 --- a/tests/Representation/OnlineAppointmentCommissionTest.php +++ b/tests/Representation/OnlineAppointmentCommissionTest.php @@ -134,6 +134,28 @@ class OnlineAppointmentCommissionTest extends ApiTestCase 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 { [$rep, , $doctor] = $this->makeRepWithCityAndDoctor();