* * @throws DomainException When the range is invalid. */ public function summarise( AccessScope $scope, DateTimeImmutable $from_utc, DateTimeImmutable $to_utc, string $timezone ): array { $this->named_features()->assert_allows( NamedFeature::Reports ); $zone = $this->zone( $timezone ); $from_utc = $from_utc->setTimezone( new DateTimeZone( 'UTC' ) ); $to_utc = $to_utc->setTimezone( new DateTimeZone( 'UTC' ) ); $this->assert_range( $from_utc, $to_utc ); $bookings = $this->windows->handle( $scope, $from_utc, $to_utc ); $by_status = []; $by_day = []; $revenue = []; $no_show = 0; foreach ( $bookings as $booking ) { $status = $booking->status()->value; $by_status[ $status ] = ( $by_status[ $status ] ?? 0 ) + 1; if ( BookingStatus::NoShow === $booking->status() ) { ++$no_show; } $day = $booking->start_utc()->setTimezone( $zone )->format( 'Y-m-d' ); if ( ! isset( $by_day[ $day ] ) ) { $by_day[ $day ] = [ 'date' => $day, 'bookings' => 0, 'revenue' => [], ]; } ++$by_day[ $day ]['bookings']; $currency = $booking->price()->currency(); $minor = $booking->price()->amount_minor(); $revenue[ $currency ] = ( $revenue[ $currency ] ?? 0 ) + $minor; $day_rev = $by_day[ $day ]['revenue']; $day_rev[ $currency ] = ( $day_rev[ $currency ] ?? 0 ) + $minor; $by_day[ $day ]['revenue'] = $day_rev; } ksort( $by_day ); $total = count( $bookings ); $rev_list = []; foreach ( $revenue as $code => $amount ) { $rev_list[] = [ 'currency' => $code, 'amount_minor' => $amount, ]; } return [ 'from' => $from_utc->format( 'c' ), 'to' => $to_utc->format( 'c' ), 'timezone' => $zone->getName(), 'booking_count' => $total, 'no_show_count' => $no_show, 'no_show_rate' => 0 === $total ? 0 : (int) round( ( $no_show / $total ) * 10000 ), 'revenue' => $rev_list, 'by_status' => $this->status_rows( $by_status ), 'by_day' => array_values( $by_day ), ]; } /** * CSV extract of the same window (one row per booking). * * @param AccessScope $scope Access scope. * @param DateTimeImmutable $from_utc Inclusive start (UTC). * @param DateTimeImmutable $to_utc Exclusive end (UTC). * @param string $timezone IANA zone for the date column. * @return array{filename: string, csv: string} * * @throws DomainException When the range is invalid. */ public function csv( AccessScope $scope, DateTimeImmutable $from_utc, DateTimeImmutable $to_utc, string $timezone ): array { $this->named_features()->assert_allows( NamedFeature::Reports ); $zone = $this->zone( $timezone ); $from_utc = $from_utc->setTimezone( new DateTimeZone( 'UTC' ) ); $to_utc = $to_utc->setTimezone( new DateTimeZone( 'UTC' ) ); $this->assert_range( $from_utc, $to_utc ); $bookings = $this->windows->handle( $scope, $from_utc, $to_utc ); $lines = [ 'date,booking_id,status,amount_minor,currency' ]; foreach ( $bookings as $booking ) { $lines[] = implode( ',', [ self::csv_field( $booking->start_utc()->setTimezone( $zone )->format( 'Y-m-d' ) ), self::csv_field( (string) ( $booking->id() ?? 0 ) ), self::csv_field( $booking->status()->value ), self::csv_field( (string) $booking->price()->amount_minor() ), self::csv_field( $booking->price()->currency() ), ] ); } $from_day = $from_utc->setTimezone( $zone )->format( 'Y-m-d' ); $to_day = $to_utc->sub( new DateInterval( 'PT1S' ) )->setTimezone( $zone )->format( 'Y-m-d' ); return [ 'filename' => 'buildler-report-' . $from_day . '-to-' . $to_day . '.csv', 'csv' => implode( "\n", $lines ) . "\n", ]; } /** * Plan map, or the open directory gate. */ private function named_features(): NamedFeatureGate { return $this->named ?? new OpenNamedFeatureGate(); } /** * Refuse inverted or over-long windows. * * @param DateTimeImmutable $from Start. * @param DateTimeImmutable $to End. * * @throws DomainException When inverted or longer than a year. */ private function assert_range( DateTimeImmutable $from, DateTimeImmutable $to ): void { if ( $to <= $from ) { throw new DomainException( 'reports.range', 'Report end must be after start.' ); } $days = (int) $from->diff( $to )->days; if ( $days > self::MAX_RANGE_DAYS ) { throw new DomainException( 'reports.range_too_long', 'Report range must be 366 days or fewer.' ); } } /** * Parse an IANA timezone, defaulting to UTC. * * @param string $timezone IANA id. * * @throws DomainException When the zone is unknown. */ private function zone( string $timezone ): DateTimeZone { $tz = '' !== trim( $timezone ) ? trim( $timezone ) : 'UTC'; try { return new DateTimeZone( $tz ); } catch ( \Exception ) { throw new DomainException( 'reports.timezone', 'Unknown timezone.' ); } } /** * Status mix rows. * * @param array $counts Status tallies. * @return list */ private function status_rows( array $counts ): array { $rows = []; foreach ( $counts as $status => $count ) { $rows[] = [ 'status' => $status, 'count' => $count, ]; } return $rows; } /** * RFC 4180 field. * * @param string $value Value. */ private static function csv_field( string $value ): string { if ( ! str_contains( $value, ',' ) && ! str_contains( $value, '"' ) && ! str_contains( $value, "\n" ) ) { return $value; } return '"' . str_replace( '"', '""', $value ) . '"'; } }