From 05f443e3169efe2c887fe9ffa0da566daaf8c528 Mon Sep 17 00:00:00 2001 From: romanetar Date: Mon, 26 Oct 2026 14:47:45 +0100 Subject: [PATCH] fix(registration-stats): count badge features inherited from the badge type The tickets per badge feature type queries joined SummitBadgeFeatureType with the same column on both sides of an OR and never referenced the SummitBadgeType_BadgeFeatures join, so features a badge inherits from its badge type were not counted in sold_qty and checkin_qty. Rewrite both queries as a UNION ALL of the badge level and the badge type level branches, counting distinct tickets per feature name, which matches SummitAttendeeBadge::getAllFeatures. The date filter is applied to each branch. Drop the try/catch that swallowed SQL errors and returned an empty result, so a failure no longer renders as zero sold. --- .../Summit/SummitRegistrationStats.php | 89 ++++++------ .../RegistrationStatsBadgeFeatureTest.php | 137 ++++++++++++++++++ 2 files changed, 178 insertions(+), 48 deletions(-) create mode 100644 tests/Repositories/RegistrationStatsBadgeFeatureTest.php diff --git a/app/Models/Foundation/Summit/SummitRegistrationStats.php b/app/Models/Foundation/Summit/SummitRegistrationStats.php index 37aad51aac..a49246b8a6 100644 --- a/app/Models/Foundation/Summit/SummitRegistrationStats.php +++ b/app/Models/Foundation/Summit/SummitRegistrationStats.php @@ -599,77 +599,70 @@ public function getVirtualNonCheckedInAttendeesCount(?DateTime $startDate = nul return 0; } /** + * Counts tickets per badge feature type, including the features a badge inherits from its badge type + * (same semantics as SummitAttendeeBadge::getAllFeatures). + * @param bool $checked_in_only * @param DateTime|null $startDate * @param DateTime|null $endDate * @return array + * @throws \Exception */ - public function getActiveTicketsPerBadgeFeatureType(?DateTime $startDate = null, ?DateTime $endDate = null): array + private function getTicketsPerBadgeFeatureType(bool $checked_in_only, ?DateTime $startDate = null, ?DateTime $endDate = null): array { - try { + $checkin_join = $checked_in_only ? 'INNER JOIN SummitAttendee ON SummitAttendee.ID = SummitAttendeeTicket.OwnerID' : ''; + $checkin_filter = $checked_in_only ? ' AND SummitAttendee.SummitHallCheckedIn = 1' : ''; + + // one branch per level: the date filter is string concatenation on the WHERE clause, + // so it has to be applied to each branch and not to the combined query + $branch = function (string $feature_join) use ($checkin_join, $checkin_filter, $startDate, $endDate): string { $sql = <<prepareRawSQL($sql, ['summit_id' => $this->id]); - $res = $stmt->executeQuery(); - $res = $res->fetchAllAssociative(); - $res = count($res) > 0 ? $res : []; - return $res; - } catch (\Exception $ex) { + $badge_level = $branch(<<prepareRawSQL($sql, ['summit_id' => $this->id]); + return $stmt->executeQuery()->fetchAllAssociative(); + } - } - return []; + /** + * @param DateTime|null $startDate + * @param DateTime|null $endDate + * @return array + * @throws \Exception + */ + public function getActiveTicketsPerBadgeFeatureType(?DateTime $startDate = null, ?DateTime $endDate = null): array + { + return $this->getTicketsPerBadgeFeatureType(false, $startDate, $endDate); } /** * @param DateTime|null $startDate * @param DateTime|null $endDate * @return array + * @throws \Exception */ public function getAttendeesCheckinPerBadgeFeatureType(?DateTime $startDate = null, ?DateTime $endDate = null): array { - try { - $sql = <<prepareRawSQL($sql, ['summit_id' => $this->id]); - $res = $stmt->executeQuery(); - $res = $res->fetchAllAssociative(); - $res = count($res) > 0 ? $res : []; - return $res; - } catch (\Exception $ex) { - - } - return []; + return $this->getTicketsPerBadgeFeatureType(true, $startDate, $endDate); } /** diff --git a/tests/Repositories/RegistrationStatsBadgeFeatureTest.php b/tests/Repositories/RegistrationStatsBadgeFeatureTest.php new file mode 100644 index 0000000000..21451f518d --- /dev/null +++ b/tests/Repositories/RegistrationStatsBadgeFeatureTest.php @@ -0,0 +1,137 @@ +current_group = IGroup::TrackChairs; + parent::setUp(); + self::insertSummitTestData(); + self::InsertOrdersTestData(); + + [$f1, $f2, $f3] = self::$badge_features; + self::$badge_type_2->addBadgeFeatureType($f2); + self::$badge_type_2->addBadgeFeatureType($f3); + + $layout = [ + [self::$default_badge_type, [$f1]], + [self::$badge_type_2, []], + [self::$badge_type_2, [$f3]], + [self::$badge_type_2, [$f1]], + ]; + + $checked_in = []; + foreach ($layout as $i => [$type, $features]) { + $ticket = self::$summit_orders[$i]->getTickets()->first(); + $badge = new SummitAttendeeBadge(); + $badge->setType($type); + $badge->setTicket($ticket); + foreach ($features as $f) { + $badge->addFeature($f); + } + $ticket->setBadge($badge); + + if ($i === 0) continue; + $attendee = new SummitAttendee(); + $attendee->setEmail(sprintf("badge-feature+%s@test.com", $i)); + $attendee->setFirstName("Attendee"); + $attendee->setSurname(sprintf("%s", $i)); + $attendee->addTicket($ticket); + self::$summit->addAttendee($attendee); + self::$em->persist($attendee); + self::$em->flush(); + if ($i !== 3) $checked_in[] = $attendee->getId(); + } + self::$em->flush(); + + DB::table('SummitAttendee')->whereIn('ID', $checked_in)->update(['SummitHallCheckedIn' => 1]); + + $id = self::$summit->getId(); + self::$em->clear(); + $this->stats_summit = self::$em->find(Summit::class, $id); + } + + protected function tearDown(): void + { + self::clearOrdersTestData(); + self::clearSummitTestData(); + parent::tearDown(); + } + + private static function byType(array $rows): array + { + $res = array_column($rows, 'qty', 'type'); + $res = array_map('intval', $res); + ksort($res); + return $res; + } + + public function testSoldQtyIncludesFeaturesInheritedFromTheBadgeType(): void + { + $this->assertSame( + ['FEATURE 1' => 2, 'FEATURE 2' => 3, 'FEATURE 3' => 3], + self::byType($this->stats_summit->getActiveTicketsPerBadgeFeatureType()) + ); + } + + public function testCheckinQtyIncludesFeaturesInheritedFromTheBadgeType(): void + { + $this->assertSame( + ['FEATURE 2' => 2, 'FEATURE 3' => 2], + self::byType($this->stats_summit->getAttendeesCheckinPerBadgeFeatureType()) + ); + } + + public function testDateFilterAppliesToBothBranches(): void + { + $future = new DateTime('+1 year', new DateTimeZone('UTC')); + $this->assertSame([], $this->stats_summit->getActiveTicketsPerBadgeFeatureType($future)); + $this->assertSame([], $this->stats_summit->getAttendeesCheckinPerBadgeFeatureType($future)); + + $past = new DateTime('-1 year', new DateTimeZone('UTC')); + $this->assertSame( + ['FEATURE 1' => 2, 'FEATURE 2' => 3, 'FEATURE 3' => 3], + self::byType($this->stats_summit->getActiveTicketsPerBadgeFeatureType($past)) + ); + } +}