>|null */ private $supplier_cache = null; /** * @param Siti_Stock_Settings $settings Settings repository. * @param Siti_Stock_Admin_Notices $notices Notice handler. */ public function __construct( Siti_Stock_Settings $settings, Siti_Stock_Admin_Notices $notices ) { $this->settings = $settings; $this->notices = $notices; } /** * Register hooks for cron and admin actions. */ public function register_hooks() { add_action( self::CRON_HOOK, array( $this, 'run_scheduled_export' ), 10, 1 ); add_action( 'admin_post_siti_stock_send_supplier_export', array( $this, 'handle_manual_export' ) ); add_action( 'admin_post_siti_stock_save_supplier_schedule', array( $this, 'handle_schedule_save' ) ); add_action( 'update_option_' . $this->settings->get_option_key(), array( $this, 'handle_settings_update' ), 10, 2 ); } /** * Trigger export when cron fires. * * @param string $supplier_key Supplier identifier. */ public function run_scheduled_export( $supplier_key ) { $this->send_export( $supplier_key ); } /** * Ensure all configured suppliers are scheduled. */ public function maybe_schedule_all() { $configs = $this->get_supplier_configs(); foreach ( $configs as $key => $config ) { if ( empty( $config['enabled'] ) || empty( $config['time'] ) ) { $this->clear_schedule_for_supplier( $key ); continue; } if ( ! wp_next_scheduled( self::CRON_HOOK, array( $key ) ) ) { $this->schedule_supplier( $key, $config ); } } } /** * Clear and reschedule cron hooks based on current settings. */ public function reschedule_all() { wp_clear_scheduled_hook( self::CRON_HOOK ); $configs = $this->get_supplier_configs(); foreach ( $configs as $key => $config ) { $this->schedule_supplier( $key, $config ); } } /** * Remove scheduled events and cache. */ public function reset_cache() { $this->supplier_cache = null; } /** * Clear all cron events for this feature. */ public static function clear_all_schedules() { wp_clear_scheduled_hook( self::CRON_HOOK ); } /** * Handle manual export submission. */ public function handle_manual_export() { if ( ! current_user_can( 'manage_options' ) ) { wp_die( esc_html__( 'Onvoldoende rechten.', 'siti-stock-plugin' ) ); } check_admin_referer( 'siti_stock_send_supplier_export' ); $supplier_key = isset( $_POST['supplier_key'] ) ? sanitize_text_field( wp_unslash( $_POST['supplier_key'] ) ) : ''; $result = $this->send_export( $supplier_key ); if ( is_wp_error( $result ) ) { $this->notices->add_notice( $result->get_error_message(), 'error' ); } else { $this->notices->add_notice( sprintf( /* translators: %s supplier label */ __( 'Export voor %s verzonden.', 'siti-stock-plugin' ), isset( $result['label'] ) ? $result['label'] : $supplier_key ), 'success' ); } wp_safe_redirect( wp_get_referer() ? wp_get_referer() : admin_url( 'admin.php?page=siti-stock-plugin-stock-updates' ) ); exit; } /** * Handle saving of schedule settings. */ public function handle_schedule_save() { if ( ! current_user_can( 'manage_options' ) ) { wp_die( esc_html__( 'Onvoldoende rechten.', 'siti-stock-plugin' ) ); } check_admin_referer( 'siti_stock_save_supplier_schedule' ); $raw_input = isset( $_POST['supplier_exports'] ) ? (array) wp_unslash( $_POST['supplier_exports'] ) : array(); $this->persist_supplier_settings( $raw_input ); $this->notices->add_notice( __( 'Voorkeuren opgeslagen.', 'siti-stock-plugin' ) ); wp_safe_redirect( wp_get_referer() ? wp_get_referer() : admin_url( 'admin.php?page=siti-stock-plugin-stock-updates' ) ); exit; } /** * React to option updates by refreshing cron. * * @param array $old_value Old settings. * @param array $value New settings. */ public function handle_settings_update( $old_value, $value ) { unset( $old_value ); // Unused. unset( $value ); $this->reset_cache(); $this->reschedule_all(); } /** * Retrieve supplier configs for display/scheduling. * * @return array> */ public function get_supplier_configs() { if ( null !== $this->supplier_cache ) { return $this->supplier_cache; } $settings = $this->settings->get_all(); $configured = isset( $settings['supplier_exports'] ) && is_array( $settings['supplier_exports'] ) ? $settings['supplier_exports'] : array(); $configured = $this->ensure_defaults( $configured ); $configured = $this->ensure_distinct_meta_suppliers( $configured ); uasort( $configured, function ( $a, $b ) { return strcmp( strtolower( isset( $a['supplier'] ) ? $a['supplier'] : '' ), strtolower( isset( $b['supplier'] ) ? $b['supplier'] : '' ) ); } ); $this->supplier_cache = $configured; return $configured; } /** * Retrieve config for specific supplier key. * * @param string $supplier_key Supplier key. * @return array|null */ public function get_supplier_config( $supplier_key ) { $configs = $this->get_supplier_configs(); return isset( $configs[ $supplier_key ] ) ? $configs[ $supplier_key ] : null; } /** * Send the mail export for supplier key. * * @param string $supplier_key Supplier key. * @return array|WP_Error */ public function send_export( $supplier_key ) { $supplier_key = sanitize_title( $supplier_key ); $config = $this->get_supplier_config( $supplier_key ); if ( empty( $config ) || empty( $config['supplier'] ) ) { return new WP_Error( 'siti_stock_unknown_supplier', __( 'Onbekende leverancier.', 'siti-stock-plugin' ) ); } if ( ! function_exists( 'wc_get_orders' ) ) { return new WP_Error( 'siti_stock_missing_wc', __( 'WooCommerce is vereist voor de exports.', 'siti-stock-plugin' ) ); } $data = $this->collect_rows_for_supplier( $config['supplier'] ); $admin_email = get_option( 'admin_email' ); if ( ! $admin_email || ! is_email( $admin_email ) ) { return new WP_Error( 'siti_stock_missing_email', __( 'Admin e-mailadres kon niet worden opgehaald.', 'siti-stock-plugin' ) ); } $csv = $this->render_csv( $data['rows'] ); $subject = sprintf( /* translators: %s supplier name */ __( 'Bestellingen %s (24 uur)', 'siti-stock-plugin' ), $config['supplier'] ); $body_lines = array(); $body_lines[] = sprintf( /* translators: %s supplier name */ __( 'Overzicht voor leverancier %s.', 'siti-stock-plugin' ), $config['supplier'] ); $body_lines[] = sprintf( /* translators: %d total items */ __( 'Totaal aantal regels: %d.', 'siti-stock-plugin' ), count( $data['rows'] ) ); if ( empty( $data['rows'] ) ) { $body_lines[] = __( 'Er zijn geen bestellingen gevonden in de afgelopen 24 uur.', 'siti-stock-plugin' ); } $body_lines[] = ''; $body_lines[] = $csv; $headers = array( 'Content-Type: text/plain; charset=UTF-8' ); $sent = wp_mail( $admin_email, $subject, implode( "\n", $body_lines ), $headers ); if ( ! $sent ) { return new WP_Error( 'siti_stock_mail_failed', __( 'E-mail verzenden is mislukt.', 'siti-stock-plugin' ) ); } return array( 'label' => $config['supplier'], 'rows' => count( $data['rows'] ), ); } /** * Persist sanitized supplier settings. * * @param array $input Input from form. */ private function persist_supplier_settings( array $input ) { $current = $this->get_supplier_configs(); $sanitized = array(); foreach ( $current as $key => $config ) { $row = isset( $input[ $key ] ) ? (array) $input[ $key ] : array(); $time_raw = isset( $row['time'] ) ? sanitize_text_field( $row['time'] ) : ( isset( $config['time'] ) ? $config['time'] : '' ); $time = $this->normalize_time( $time_raw ); $sanitized[ $key ] = array( 'supplier' => isset( $config['supplier'] ) ? $config['supplier'] : '', 'time' => $time, 'enabled' => ! empty( $row['enabled'] ), ); } $settings = $this->settings->get_all(); $settings['supplier_exports'] = $sanitized; $this->settings->reset_cache(); update_option( $this->settings->get_option_key(), $settings ); $this->reset_cache(); } /** * Collect CSV rows for supplier meta value. * * @param string $supplier Supplier label. * @return array */ private function collect_rows_for_supplier( $supplier ) { $key = $this->normalize_supplier_key( $supplier ); $site_timezone = function_exists( 'wp_timezone' ) ? wp_timezone() : new DateTimeZone( wp_timezone_string() ); $after_date = new DateTimeImmutable( 'now', $site_timezone ); $after_date = $after_date->sub( new DateInterval( 'P1D' ) ); $after_utc = $after_date->setTimezone( new DateTimeZone( 'UTC' ) ); $date_query = array( array( 'column' => 'date_created_gmt', 'after' => $after_utc->format( 'Y-m-d H:i:s' ), 'inclusive' => true, ), ); $query = new WC_Order_Query( array( 'status' => array( 'pending', 'processing', 'on-hold', 'completed' ), 'limit' => -1, 'type' => 'shop_order', 'return' => 'objects', 'date_query' => $date_query, ) ); $orders = $query->get_orders(); $results = array(); foreach ( $orders as $order ) { foreach ( $order->get_items( 'line_item' ) as $item ) { $product = $item->get_product(); if ( ! $product ) { continue; } $item_supplier = $this->get_product_supplier( $product ); if ( '' === $item_supplier || $this->normalize_supplier_key( $item_supplier ) !== $key ) { continue; } $sku = $this->get_product_sku( $product ); if ( '' === $sku ) { $sku = sprintf( 'product-%d', $product->get_id() ); } $index = strtolower( $sku ); if ( ! isset( $results[ $index ] ) ) { $results[ $index ] = array( 'sku' => $sku, 'quantity' => 0, ); } $results[ $index ]['quantity'] += (int) $item->get_quantity(); } } return array( 'rows' => array_values( $results ), ); } /** * Retrieve supplier meta for product (falls back to parent). * * @param WC_Product $product Product. * @return string */ private function get_product_supplier( $product ) { $product_id = $product->get_id(); $value = get_post_meta( $product_id, self::SUPPLIER_META_KEY, true ); if ( '' !== $value ) { return trim( (string) $value ); } $parent_id = $product->get_parent_id(); if ( $parent_id ) { $parent_value = get_post_meta( $parent_id, self::SUPPLIER_META_KEY, true ); if ( '' !== $parent_value ) { return trim( (string) $parent_value ); } } return ''; } /** * Retrieve SKU, falling back to parent product SKU. * * @param WC_Product $product Product. * @return string */ private function get_product_sku( $product ) { $sku = $product->get_sku(); if ( '' !== $sku ) { return $sku; } $parent_id = $product->get_parent_id(); if ( $parent_id ) { $parent = wc_get_product( $parent_id ); if ( $parent && '' !== $parent->get_sku() ) { return $parent->get_sku(); } } return ''; } /** * Convert an array of SKU rows to CSV text separated with semicolons. * * @param array> $rows Rows. * @return string */ private function render_csv( array $rows ) { $lines = array( 'sku;aantal' ); foreach ( $rows as $row ) { $sku = isset( $row['sku'] ) ? $row['sku'] : ''; $quantity = isset( $row['quantity'] ) ? (int) $row['quantity'] : 0; $lines[] = sprintf( '%s;%d', $sku, $quantity ); } return implode( "\n", $lines ); } /** * Schedule supplier if enabled. * * @param string $supplier_key Key. * @param array|null $config Config. */ private function schedule_supplier( $supplier_key, $config ) { if ( empty( $config ) || empty( $config['enabled'] ) || empty( $config['time'] ) ) { return; } $timestamp = $this->calculate_next_timestamp( $config['time'] ); wp_schedule_event( $timestamp, 'daily', self::CRON_HOOK, array( $supplier_key ) ); } /** * Remove scheduled event for supplier. * * @param string $supplier_key Key. */ private function clear_schedule_for_supplier( $supplier_key ) { $timestamp = wp_next_scheduled( self::CRON_HOOK, array( $supplier_key ) ); while ( $timestamp ) { wp_unschedule_event( $timestamp, self::CRON_HOOK, array( $supplier_key ) ); $timestamp = wp_next_scheduled( self::CRON_HOOK, array( $supplier_key ) ); } } /** * Ensure default suppliers exist in config. * * @param array $configured Configured values. * @return array> */ private function ensure_defaults( array $configured ) { foreach ( $this->get_default_suppliers() as $key => $data ) { if ( isset( $configured[ $key ] ) ) { if ( empty( $configured[ $key ]['supplier'] ) ) { $configured[ $key ]['supplier'] = $data['label']; } $configured[ $key ]['time'] = $this->normalize_time( isset( $configured[ $key ]['time'] ) ? $configured[ $key ]['time'] : $data['time'] ); if ( ! isset( $configured[ $key ]['enabled'] ) ) { $configured[ $key ]['enabled'] = $data['enabled']; } continue; } $configured[ $key ] = array( 'supplier' => $data['label'], 'time' => $data['time'], 'enabled' => $data['enabled'], ); } return $configured; } /** * Add entries for suppliers found in product meta. * * @param array $configured Configured values. * @return array */ private function ensure_distinct_meta_suppliers( array $configured ) { foreach ( $this->get_distinct_suppliers() as $supplier ) { $key = $this->normalize_supplier_key( $supplier ); if ( isset( $configured[ $key ] ) ) { $configured[ $key ]['supplier'] = $supplier; continue; } $configured[ $key ] = array( 'supplier' => $supplier, 'time' => $this->get_default_time_for_key( $key ), 'enabled' => $this->is_default_enabled( $key ), ); } return $configured; } /** * Retrieve suppliers from product meta. * * @return array */ private function get_distinct_suppliers() { global $wpdb; $meta_table = $wpdb->postmeta; $post_table = $wpdb->posts; $sql = " SELECT DISTINCT pm.meta_value FROM {$meta_table} pm INNER JOIN {$post_table} p ON p.ID = pm.post_id WHERE pm.meta_key = %s AND pm.meta_value <> '' AND p.post_type IN ('product','product_variation') AND p.post_status NOT IN ('trash','auto-draft') "; $results = $wpdb->get_col( $wpdb->prepare( $sql, self::SUPPLIER_META_KEY ) ); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared if ( empty( $results ) ) { return array(); } return array_map( function ( $value ) { return trim( (string) $value ); }, $results ); } /** * Normalize supplier key to slug. * * @param string $value Value. * @return string */ private function normalize_supplier_key( $value ) { return sanitize_title( (string) $value ); } /** * Normalize HH:MM strings, fallback to midnight. * * @param string $time Time string. * @return string */ private function normalize_time( $time ) { $time = trim( (string) $time ); if ( ! preg_match( '/^\d{1,2}:\d{2}$/', $time ) ) { return '00:00'; } list( $hour, $minute ) = array_map( 'intval', explode( ':', $time, 2 ) ); $hour = max( 0, min( 23, $hour ) ); $minute = max( 0, min( 59, $minute ) ); return sprintf( '%02d:%02d', $hour, $minute ); } /** * Calculate the next timestamp for a given HH:MM. * * @param string $time Time in HH:MM. * @return int */ private function calculate_next_timestamp( $time ) { $time = $this->normalize_time( $time ); list( $hour, $minute ) = array_map( 'intval', explode( ':', $time ) ); $timezone = wp_timezone(); $now = new DateTimeImmutable( 'now', $timezone ); $target = $now->setTime( $hour, $minute ); if ( $target <= $now ) { $target = $target->modify( '+1 day' ); } return $target->getTimestamp(); } /** * Default suppliers (label + default time/settings). * * @return array> */ private function get_default_suppliers() { return array( 'orion' => array( 'label' => 'Orion', 'time' => '09:00', 'enabled' => true, ), 'shots' => array( 'label' => 'Shots', 'time' => '10:00', 'enabled' => true, ), 'stots' => array( 'label' => 'Stots', 'time' => '10:00', 'enabled' => true, ), 'leg-avenue' => array( 'label' => 'Leg Avenue', 'time' => '14:00', 'enabled' => true, ), 'oproducts' => array( 'label' => 'Oproducts', 'time' => '13:00', 'enabled' => true, ), ); } /** * Retrieve default time for supplier key. * * @param string $key Supplier key. * @return string */ private function get_default_time_for_key( $key ) { $defaults = $this->get_default_suppliers(); return isset( $defaults[ $key ] ) ? $defaults[ $key ]['time'] : '09:00'; } /** * Determine default enabled state for supplier key. * * @param string $key Supplier key. * @return bool */ private function is_default_enabled( $key ) { $defaults = $this->get_default_suppliers(); return isset( $defaults[ $key ] ) ? (bool) $defaults[ $key ]['enabled'] : false; } }