absint( $data['meter_id'] ), 'parcel_id' => absint( $data['parcel_id'] ), 'reading_value' => (float) $data['reading_value'], 'reading_date' => $data['reading_date'], 'note' => $data['note'], 'submitted_by' => absint( $data['submitted_by'] ), 'is_self_reading' => ! empty( $data['is_self_reading'] ) ? 1 : 0, 'created_at' => $this->now(), ); $this->wpdb->insert( $this->table, $payload, array( '%d', '%d', '%f', '%s', '%s', '%d', '%d', '%s' ) ); return $this->wpdb->insert_id; } /** * Get the latest reading for one meter. * * @param int $meter_id Meter ID. * @return object|null */ public function get_latest_for_meter( $meter_id ) { $sql = "SELECT * FROM {$this->table} WHERE meter_id = %d ORDER BY reading_date DESC, id DESC LIMIT 1"; return $this->wpdb->get_row( $this->wpdb->prepare( $sql, $meter_id ) ); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared } /** * Get recent readings for a parcel. * * @param int $parcel_id Parcel ID. * @param int $limit Number of rows. * @return array */ public function get_recent_for_parcel( $parcel_id, $limit = 10 ) { $meters = Schema::table( 'meters' ); $sql = "SELECT r.*, m.type, m.meter_number FROM {$this->table} r LEFT JOIN {$meters} m ON m.id = r.meter_id WHERE r.parcel_id = %d ORDER BY r.reading_date DESC, r.id DESC LIMIT %d"; return $this->wpdb->get_results( $this->wpdb->prepare( $sql, $parcel_id, absint( $limit ) ) ); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared } /** * Get a filtered consumption report including deltas since the previous reading. * * @param int $section_id Optional section filter. * @param string $date_from Optional start date. * @param string $date_to Optional end date. * @param string $order ASC|DESC by reading date. * @return array */ public function get_consumption_report( $section_id = 0, $date_from = '', $date_to = '', $order = 'DESC' ) { $meters = Schema::table( 'meters' ); $sections = Schema::table( 'sections' ); $parcels = Schema::table( 'parcels' ); $sql = "SELECT r.*, m.type, m.meter_number, m.section_id, s.name AS section_name, p.label AS parcel_label FROM {$this->table} r LEFT JOIN {$meters} m ON m.id = r.meter_id LEFT JOIN {$sections} s ON s.id = m.section_id LEFT JOIN {$parcels} p ON p.id = r.parcel_id WHERE 1=1"; $params = array(); $order = 'ASC' === strtoupper( (string) $order ) ? 'ASC' : 'DESC'; if ( $section_id > 0 ) { $sql .= ' AND m.section_id = %d'; $params[] = $section_id; } if ( '' !== $date_to ) { $sql .= ' AND r.reading_date <= %s'; $params[] = $date_to; } $sql .= ' ORDER BY r.meter_id ASC, r.reading_date ASC, r.id ASC'; $rows = ! empty( $params ) ? $this->wpdb->get_results( $this->wpdb->prepare( $sql, $params ) ) // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared : $this->wpdb->get_results( $sql ); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared,WordPress.DB.DirectDatabaseQuery.DirectQuery $report = array(); $last_by_meter = array(); foreach ( $rows as $row ) { $previous = isset( $last_by_meter[ $row->meter_id ] ) ? $last_by_meter[ $row->meter_id ] : null; $row->previous_value = $previous ? (float) $previous->reading_value : null; $row->consumption = $previous && (float) $row->reading_value >= (float) $previous->reading_value ? (float) $row->reading_value - (float) $previous->reading_value : null; $last_by_meter[$row->meter_id] = $row; if ( '' !== $date_from && $row->reading_date < $date_from ) { continue; } $report[] = $row; } usort( $report, function( $left, $right ) use ( $order ) { $left_key = $left->reading_date . ' ' . str_pad( (string) $left->id, 10, '0', STR_PAD_LEFT ); $right_key = $right->reading_date . ' ' . str_pad( (string) $right->id, 10, '0', STR_PAD_LEFT ); if ( $left_key === $right_key ) { return 0; } if ( 'ASC' === $order ) { return $left_key < $right_key ? -1 : 1; } return $left_key > $right_key ? -1 : 1; } ); return $report; } /** * Get all readings for a parcel, suitable for export. * * @param int $parcel_id Parcel ID. * @return array */ public function get_all_for_parcel( $parcel_id ) { $meters = Schema::table( 'meters' ); $sql = "SELECT r.*, m.type, m.meter_number FROM {$this->table} r LEFT JOIN {$meters} m ON m.id = r.meter_id WHERE r.parcel_id = %d ORDER BY r.reading_date DESC, r.id DESC"; return $this->wpdb->get_results( $this->wpdb->prepare( $sql, $parcel_id ) ); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared } /** * Get reading history for a specific meter. * * @param int $meter_id Meter ID. * @param int $limit Max rows. * @return array */ public function get_all_for_meter( $meter_id, $limit = 50 ) { $parcels = Schema::table( 'parcels' ); $sql = "SELECT r.*, p.label AS parcel_label FROM {$this->table} r LEFT JOIN {$parcels} p ON p.id = r.parcel_id WHERE r.meter_id = %d ORDER BY r.reading_date DESC, r.id DESC LIMIT %d"; return $this->wpdb->get_results( $this->wpdb->prepare( $sql, $meter_id, absint( $limit ) ) ); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared } /** * Build a monthly summary for one meter. * * @param int $meter_id Meter ID. * @return array */ public function get_monthly_summary_for_meter( $meter_id ) { $rows = $this->wpdb->get_results( $this->wpdb->prepare( "SELECT * FROM {$this->table} WHERE meter_id = %d ORDER BY reading_date ASC, id ASC", $meter_id ) ); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared if ( empty( $rows ) ) { return array(); } $monthly = array(); $last = null; foreach ( $rows as $row ) { $key = gmdate( 'Y-m', strtotime( $row->reading_date ) ); if ( ! isset( $monthly[ $key ] ) ) { $monthly[ $key ] = array( 'month' => $key, 'from_value' => null, 'to_value' => (float) $row->reading_value, 'consumption' => 0.0, 'readings' => 0, 'last_date' => $row->reading_date, ); } $monthly[ $key ]['to_value'] = (float) $row->reading_value; $monthly[ $key ]['last_date'] = $row->reading_date; $monthly[ $key ]['readings']++; if ( $last && (float) $row->reading_value >= (float) $last->reading_value ) { if ( null === $monthly[ $key ]['from_value'] ) { $monthly[ $key ]['from_value'] = (float) $last->reading_value; } $monthly[ $key ]['consumption'] += (float) $row->reading_value - (float) $last->reading_value; } $last = $row; } return array_reverse( array_values( $monthly ) ); } }