From ee43e18b252a546e9c0a000e2eef8d3c79b613a2 Mon Sep 17 00:00:00 2001 From: Roberto Guagliardo Date: Sat, 10 Jan 2026 15:19:24 +0000 Subject: [PATCH] feat: Add supplier exports functionality with scheduling and admin management --- includes/class-siti-stock-admin.php | 131 +++- includes/class-siti-stock-plugin.php | 16 +- includes/class-siti-stock-settings.php | 1 + .../class-siti-stock-supplier-exports.php | 671 ++++++++++++++++++ siti-stock-plugin.php | 4 +- 5 files changed, 818 insertions(+), 5 deletions(-) create mode 100644 includes/class-siti-stock-supplier-exports.php diff --git a/includes/class-siti-stock-admin.php b/includes/class-siti-stock-admin.php index 7062afc..4e615a7 100644 --- a/includes/class-siti-stock-admin.php +++ b/includes/class-siti-stock-admin.php @@ -24,6 +24,11 @@ class Siti_Stock_Admin { */ private $notices; + /** + * @var Siti_Stock_Supplier_Exports + */ + private $supplier_exports; + /** * Cached settings snapshot for rendering. * @@ -35,11 +40,13 @@ class Siti_Stock_Admin { * @param Siti_Stock_Settings $settings Settings repository. * @param Siti_Stock_Sync_Controller $sync_controller Sync controller. * @param Siti_Stock_Admin_Notices $notices Notice handler. + * @param Siti_Stock_Supplier_Exports $supplier_exports Supplier exports handler. */ - public function __construct( Siti_Stock_Settings $settings, Siti_Stock_Sync_Controller $sync_controller, Siti_Stock_Admin_Notices $notices ) { + public function __construct( Siti_Stock_Settings $settings, Siti_Stock_Sync_Controller $sync_controller, Siti_Stock_Admin_Notices $notices, Siti_Stock_Supplier_Exports $supplier_exports ) { $this->settings = $settings; $this->sync_controller = $sync_controller; $this->notices = $notices; + $this->supplier_exports = $supplier_exports; } /** @@ -160,6 +167,15 @@ class Siti_Stock_Admin { array( $this, 'render_settings_page' ), 'dashicons-products' ); + + add_submenu_page( + 'siti-stock-plugin', + __( 'Stock updates', 'siti-stock-plugin' ), + __( 'Stock updates', 'siti-stock-plugin' ), + 'manage_options', + 'siti-stock-plugin-stock-updates', + array( $this, 'render_stock_updates_page' ) + ); } /** @@ -168,7 +184,12 @@ class Siti_Stock_Admin { * @param string $hook Hook suffix. */ public function enqueue_admin_assets( $hook ) { - if ( 'toplevel_page_siti-stock-plugin' !== $hook ) { + $pages = array( + 'toplevel_page_siti-stock-plugin', + 'siti-stock-plugin_page_siti-stock-plugin-stock-updates', + ); + + if ( ! in_array( $hook, $pages, true ) ) { return; } @@ -241,6 +262,112 @@ class Siti_Stock_Admin { supplier_exports->get_supplier_configs(); + $admin_email = get_option( 'admin_email' ); + ?> +
+

+

+ + +

+ + +

+ +
+ + + + + + + + + + + + + $config ) : ?> + + + + + + + +
+ + + +
+ + +
+ +

+

+ + + + + + + + + + + $config ) : ?> + + + + + + + +
+
+ + + + +
+
+ +
+ get_all() ); + + $notices = new Siti_Stock_Admin_Notices(); + $supplier_exports = new Siti_Stock_Supplier_Exports( $settings_repo, $notices ); + $supplier_exports->reschedule_all(); } /** @@ -87,18 +97,22 @@ class Siti_Stock_Plugin { */ public static function deactivate() { Siti_Stock_Sync_Controller::clear_schedule( self::CRON_HOOK ); + Siti_Stock_Supplier_Exports::clear_all_schedules(); } private function __construct() { $this->settings = new Siti_Stock_Settings( self::OPTION_KEY ); $this->notices = new Siti_Stock_Admin_Notices(); $this->sync_controller = new Siti_Stock_Sync_Controller( $this->settings, $this->notices, self::CRON_HOOK ); + $this->supplier_exports = new Siti_Stock_Supplier_Exports( $this->settings, $this->notices ); $this->inventory_manager = new Siti_Stock_Inventory_Manager( self::EXTERNAL_STOCK_META_KEY ); - $this->admin = new Siti_Stock_Admin( $this->settings, $this->sync_controller, $this->notices ); + $this->admin = new Siti_Stock_Admin( $this->settings, $this->sync_controller, $this->notices, $this->supplier_exports ); $this->admin->register_hooks(); $this->inventory_manager->register_hooks(); $this->sync_controller->register_hooks(); + $this->supplier_exports->register_hooks(); + $this->supplier_exports->maybe_schedule_all(); add_filter( 'woocommerce_data_stores', array( $this, 'override_product_data_store' ), 20 ); } diff --git a/includes/class-siti-stock-settings.php b/includes/class-siti-stock-settings.php index 4480c05..e5b1148 100644 --- a/includes/class-siti-stock-settings.php +++ b/includes/class-siti-stock-settings.php @@ -99,6 +99,7 @@ class Siti_Stock_Settings { 'default_status' => 'instock', 'enable_auto_sync' => false, 'sync_interval' => 'hourly', + 'supplier_exports' => array(), ) ); } diff --git a/includes/class-siti-stock-supplier-exports.php b/includes/class-siti-stock-supplier-exports.php new file mode 100644 index 0000000..0ee5671 --- /dev/null +++ b/includes/class-siti-stock-supplier-exports.php @@ -0,0 +1,671 @@ +>|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 ); + + $after_timestamp = current_time( 'timestamp' ) - DAY_IN_SECONDS; + $date_filter = array( + 'after' => gmdate( 'Y-m-d H:i:s', $after_timestamp ), + ); + + if ( class_exists( 'WC_DateTime' ) ) { + $after_object = new WC_DateTime( '@' . $after_timestamp ); + $after_object->setTimezone( new DateTimeZone( 'UTC' ) ); + $date_filter = array( 'after' => $after_object ); + } + + $query = new WC_Order_Query( + array( + 'status' => array( 'pending', 'processing', 'on-hold', 'completed' ), + 'limit' => -1, + 'type' => 'shop_order', + 'return' => 'objects', + 'date_created' => $date_filter, + ) + ); + + $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; + } +} diff --git a/siti-stock-plugin.php b/siti-stock-plugin.php index 2c8f67f..f74e718 100644 --- a/siti-stock-plugin.php +++ b/siti-stock-plugin.php @@ -3,7 +3,7 @@ * Plugin Name: Siti Stock Plugin * Plugin URI: https://github.com/SitiWeb/siti-stock-plugin * Description: Synchroniseert WooCommerce voorraad met het externe Siti voorraadplatform. - * Version: 1.2.1 + * Version: 1.2.2 * Author: Siti Web * Author URI: https://www.siti.nl * Requires PHP: 8.1 @@ -16,7 +16,7 @@ if ( ! defined( 'ABSPATH' ) ) { exit; } -define( 'SITI_STOCK_PLUGIN_VERSION', '1.2.0' ); +define( 'SITI_STOCK_PLUGIN_VERSION', '1.2.2' ); define( 'SITI_STOCK_PLUGIN_FILE', __FILE__ ); define( 'SITI_STOCK_PLUGIN_DIR', plugin_dir_path( __FILE__ ) );