fontdelbosc-combustible/includes/class-fontdelbosc-combustible-data-manager.php
2026-10-12 13:25:09 +00:00

407 lines
12 KiB
PHP

<?php
/**
* Orchestration: caching, filtering and sorting of station data.
*
* @package FontdelboscCombustible
*/
declare( strict_types=1 );
defined( 'ABSPATH' ) || exit;
/**
* Caches API payloads and builds the final station selection.
*/
final class Fontdelbosc_Combustible_Data_Manager {
/**
* Transient holding the live province payload.
*/
public const PROVINCE_TRANSIENT = 'fontdelbosc_combustible_province_08';
/**
* Transient holding the stale-fallback copy of the province payload.
*/
public const STALE_TRANSIENT = 'fontdelbosc_combustible_province_08_stale';
/**
* Transient holding the product catalog.
*/
public const PRODUCTS_TRANSIENT = 'fontdelbosc_combustible_products';
/**
* Option holding fetch status for the admin screen.
*/
public const STATUS_OPTION = 'fontdelbosc_combustible_status';
/**
* Option holding plugin settings.
*/
public const OPTIONS_OPTION = 'fontdelbosc_combustible_options';
/**
* Stale copy lifetime: 25 hours.
*/
private const STALE_TTL = 90000;
/**
* Product catalog lifetime: 7 days.
*/
private const PRODUCTS_TTL = 604800;
/**
* Default settings.
*
* @return array<string,mixed>
*/
public static function defaults(): array {
return array(
'search_radius_km' => 15,
'max_stations' => 10,
'fuel_types' => Fontdelbosc_Combustible_Fuels::DEFAULT_IDS,
'show_only_public' => true,
'cache_ttl' => 3600,
'show_distance' => true,
'show_schedule' => true,
'sort_by' => 'distance',
'delete_data_on_uninstall' => false,
);
}
/**
* Returns the merged settings.
*
* @return array<string,mixed>
*/
public static function options(): array {
$clean = array();
foreach ( Fontdelbosc_Combustible_Payload::arr( get_option( self::OPTIONS_OPTION, array() ) ) as $key => $value ) {
if ( is_string( $key ) ) {
$clean[ $key ] = $value;
}
}
return array_merge( self::defaults(), $clean );
}
/**
* Returns the cached province payload, refreshing it when stale.
*
* @param bool $force_refresh Skip the live cache.
* @return array{fecha:string,stations:array<int,array<mixed>>,fetched_at:int,duration:float,stale?:bool}|null
*/
public static function get_payload( bool $force_refresh = false ): ?array {
if ( ! $force_refresh ) {
$cached = get_transient( self::PROVINCE_TRANSIENT );
if ( is_array( $cached ) ) {
return self::shape( $cached );
}
}
$payload = Fontdelbosc_Combustible_Api::fetch_province();
if ( null === $payload ) {
$stale = get_transient( self::STALE_TRANSIENT );
if ( is_array( $stale ) ) {
$shaped = self::shape( $stale );
$shaped['stale'] = true;
return $shaped;
}
return null;
}
set_transient( self::PROVINCE_TRANSIENT, $payload, max( 60, Fontdelbosc_Combustible_Payload::int( self::options()['cache_ttl'] ?? null ) ?? 3600 ) );
set_transient( self::STALE_TRANSIENT, $payload, self::STALE_TTL );
self::record_status( $payload );
return $payload;
}
/**
* Returns the cached product catalog, possibly empty.
*
* @return array<int,array{id:int,name:string,abbr:string}>
*/
public static function get_products(): array {
$cached = get_transient( self::PRODUCTS_TRANSIENT );
if ( is_array( $cached ) ) {
return self::shape_products( $cached );
}
$products = Fontdelbosc_Combustible_Api::fetch_products();
if ( null === $products ) {
return array();
}
set_transient( self::PRODUCTS_TRANSIENT, $products, self::PRODUCTS_TTL );
return $products;
}
/**
* Builds the final station selection.
*
* @param array<string,mixed> $args Overrides: radius, max, fuel, sort, only_public.
* @return array{stations:array<int,array<mixed>>,fecha:string,stale:bool,radius:int,fuels:array<int>,fields:array<int,string>}
*/
public static function get_stations( array $args = array() ): array {
$options = self::options();
$opt_radius = Fontdelbosc_Combustible_Payload::int( $options['search_radius_km'] ?? null ) ?? 15;
$opt_max = Fontdelbosc_Combustible_Payload::int( $options['max_stations'] ?? null ) ?? 10;
$opt_sort = Fontdelbosc_Combustible_Payload::str( $options['sort_by'] ?? null ) ?? 'distance';
$opt_public = ! empty( $options['show_only_public'] );
$only_public = $opt_public && ( $args['only_public'] ?? true );
$radius = max( 5, min( 50, Fontdelbosc_Combustible_Payload::int( $args['radius'] ?? null ) ?? $opt_radius ) );
$max = max( 1, min( 20, Fontdelbosc_Combustible_Payload::int( $args['max'] ?? null ) ?? $opt_max ) );
$fuels = self::fuel_ids( Fontdelbosc_Combustible_Payload::str( $args['fuel'] ?? null ) ?? '', $options );
$sort_att = Fontdelbosc_Combustible_Payload::str( $args['sort'] ?? null );
$sort = ( $sort_att ?? $opt_sort ) === 'price_asc' ? 'price_asc' : 'distance';
$stations = array();
$fields = array();
$stale = false;
$fecha = '';
$payload = self::get_payload();
if ( null !== $payload ) {
$stale = ! empty( $payload['stale'] );
$fecha = $payload['fecha'];
$selected = self::select( $payload['stations'], $radius, $fuels, $sort, $only_public );
$stations = $selected['stations'];
$fields = $selected['fields'];
}
return array(
'stations' => array_slice( $stations, 0, $max ),
'fecha' => $fecha,
'stale' => $stale,
'radius' => $radius,
'fuels' => $fuels,
'fields' => $fields,
);
}
/**
* Deletes every cache entry.
*/
public static function flush_cache(): void {
delete_transient( self::PROVINCE_TRANSIENT );
delete_transient( self::STALE_TRANSIENT );
delete_transient( self::PRODUCTS_TRANSIENT );
}
/**
* Filters, sorts and dedupes the normalized station list.
*
* @param array<int,array<mixed>> $stations Stations.
* @param int $radius Radius in km.
* @param array<int> $fuels Product IDs.
* @param string $sort distance|price_asc.
* @param bool $only_public Keep Tipo Venta "P" only.
* @return array{stations:array<int,array<mixed>>,fields:array<int,string>}
*/
private static function select( array $stations, int $radius, array $fuels, string $sort, bool $only_public ): array {
$labels = array();
foreach ( $stations as $station ) {
foreach ( Fontdelbosc_Combustible_Payload::arr( Fontdelbosc_Combustible_Payload::get( $station, 'prices' ) ) as $label => $_unused ) {
if ( is_string( $label ) ) {
$labels[ $label ] = true;
}
}
}
$fields = self::fuel_fields( $fuels, array_keys( $labels ) );
$keep = array();
foreach ( $stations as $station ) {
$tipo = Fontdelbosc_Combustible_Payload::str( Fontdelbosc_Combustible_Payload::get( $station, 'tipo_venta' ) ) ?? '';
if ( $only_public && 'P' !== $tipo ) {
continue;
}
$distance = Fontdelbosc_Combustible_Payload::float( Fontdelbosc_Combustible_Payload::get( $station, 'distance' ) );
if ( null === $distance || $distance > (float) $radius ) {
continue;
}
$prices = array();
foreach ( $fields as $field ) {
$price = Fontdelbosc_Combustible_Payload::float(
Fontdelbosc_Combustible_Payload::get( Fontdelbosc_Combustible_Payload::arr( Fontdelbosc_Combustible_Payload::get( $station, 'prices' ) ), $field )
);
if ( null !== $price ) {
$prices[ $field ] = $price;
}
}
if ( array() === $prices ) {
continue;
}
$station['selected_prices'] = $prices;
$station['best_price'] = min( $prices );
$keep[] = $station;
}
usort(
$keep,
static function ( array $a, array $b ) use ( $sort ): int {
$a_price = Fontdelbosc_Combustible_Payload::float( Fontdelbosc_Combustible_Payload::get( $a, 'best_price' ) );
$b_price = Fontdelbosc_Combustible_Payload::float( Fontdelbosc_Combustible_Payload::get( $b, 'best_price' ) );
$a_key = 'price_asc' === $sort ? ( $a_price ?? INF ) : Fontdelbosc_Combustible_Payload::float( Fontdelbosc_Combustible_Payload::get( $a, 'distance' ) ) ?? INF;
$b_key = 'price_asc' === $sort ? ( $b_price ?? INF ) : Fontdelbosc_Combustible_Payload::float( Fontdelbosc_Combustible_Payload::get( $b, 'distance' ) ) ?? INF;
return $a_key <=> $b_key;
}
);
return array(
'stations' => $keep,
'fields' => $fields,
);
}
/**
* Resolves the fuel selection from a shortcode attribute or the options.
*
* @param string $att Raw "1,4" style attribute.
* @param array<string,mixed> $options Settings.
* @return array<int>
*/
private static function fuel_ids( string $att, array $options ): array {
if ( '' !== $att ) {
$ids = array();
foreach ( explode( ',', $att ) as $piece ) {
$id = Fontdelbosc_Combustible_Payload::int( trim( $piece ) );
if ( null !== $id && $id > 0 ) {
$ids[] = $id;
}
}
if ( array() !== $ids ) {
return array_values( array_unique( $ids ) );
}
}
$saved = array();
foreach ( Fontdelbosc_Combustible_Payload::arr( $options['fuel_types'] ?? null ) as $id ) {
$int = Fontdelbosc_Combustible_Payload::int( $id );
if ( null !== $int && $int > 0 ) {
$saved[] = $int;
}
}
return array() === $saved ? Fontdelbosc_Combustible_Fuels::DEFAULT_IDS : array_values( array_unique( $saved ) );
}
/**
* Maps product IDs to station price fields using the catalog.
*
* @param array<int> $ids Product IDs.
* @param array<string> $labels Price field labels present in the payload.
* @return array<int,string>
*/
private static function fuel_fields( array $ids, array $labels ): array {
$names = array();
foreach ( self::get_products() as $product ) {
$names[ $product['id'] ] = $product['name'];
}
$fields = array();
foreach ( $ids as $id ) {
$name = $names[ (int) $id ] ?? null;
$field = null === $name ? null : Fontdelbosc_Combustible_Fuels::station_field( $name, $labels );
if ( null !== $field ) {
$fields[ (int) $id ] = $field;
}
}
return $fields;
}
/**
* Coerces a cached payload back into its typed shape.
*
* @param array<mixed> $raw Cached value.
* @return array{fecha:string,stations:array<int,array<mixed>>,fetched_at:int,duration:float,stale?:bool}
*/
private static function shape( array $raw ): array {
$stations = array();
foreach ( Fontdelbosc_Combustible_Payload::arr( Fontdelbosc_Combustible_Payload::get( $raw, 'stations' ) ) as $station ) {
if ( is_array( $station ) ) {
$stations[] = $station;
}
}
return array(
'fecha' => Fontdelbosc_Combustible_Payload::str( Fontdelbosc_Combustible_Payload::get( $raw, 'fecha' ) ) ?? '',
'stations' => $stations,
'fetched_at' => Fontdelbosc_Combustible_Payload::int( Fontdelbosc_Combustible_Payload::get( $raw, 'fetched_at' ) ) ?? time(),
'duration' => Fontdelbosc_Combustible_Payload::float( Fontdelbosc_Combustible_Payload::get( $raw, 'duration' ) ) ?? 0.0,
);
}
/**
* Coerces a cached product catalog back into its typed shape.
*
* @param array<mixed> $raw Cached value.
* @return array<int,array{id:int,name:string,abbr:string}>
*/
private static function shape_products( array $raw ): array {
$products = array();
foreach ( $raw as $entry ) {
$id = Fontdelbosc_Combustible_Payload::int( Fontdelbosc_Combustible_Payload::get( $entry, 'IDProducto' ) )
?? Fontdelbosc_Combustible_Payload::int( Fontdelbosc_Combustible_Payload::get( $entry, 'id' ) );
if ( null === $id ) {
continue;
}
$products[] = array(
'id' => $id,
'name' => Fontdelbosc_Combustible_Payload::str( Fontdelbosc_Combustible_Payload::get( $entry, 'name' ) ) ?? (string) $id,
'abbr' => Fontdelbosc_Combustible_Payload::str( Fontdelbosc_Combustible_Payload::get( $entry, 'abbr' ) ) ?? '',
);
}
return $products;
}
/**
* Persists fetch status for the admin screen.
*
* @param array<string,mixed> $payload Fresh payload.
*/
private static function record_status( array $payload ): void {
update_option(
self::STATUS_OPTION,
array(
'last_success' => Fontdelbosc_Combustible_Payload::int( Fontdelbosc_Combustible_Payload::get( $payload, 'fetched_at' ) ) ?? time(),
'last_duration' => Fontdelbosc_Combustible_Payload::float( Fontdelbosc_Combustible_Payload::get( $payload, 'duration' ) ) ?? 0.0,
'last_data_date' => Fontdelbosc_Combustible_Payload::str( Fontdelbosc_Combustible_Payload::get( $payload, 'fecha' ) ) ?? '',
),
false
);
}
}