Files
siti-stock-plugin/includes/class-siti-stock-supplier-exports.php

641 lines
17 KiB
PHP

<?php
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
/**
* Handles scheduled supplier exports and related admin actions.
*/
class Siti_Stock_Supplier_Exports {
const CRON_HOOK = 'siti_stock_plugin_supplier_export';
const SUPPLIER_META_KEY = '_wpci_supplier';
/**
* @var Siti_Stock_Settings
*/
private $settings;
/**
* @var Siti_Stock_Admin_Notices
*/
private $notices;
/**
* Cached supplier configs.
*
* @var array<string,array<string,mixed>>|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<string,mixed> $old_value Old settings.
* @param array<string,mixed> $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<string,array<string,mixed>>
*/
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_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<string,mixed>|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<string,mixed>|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( "\r\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<string,mixed> $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<string,mixed>
*/
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<int,array<string,int|string>> $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( "\r\n", $lines );
}
/**
* Schedule supplier if enabled.
*
* @param string $supplier_key Key.
* @param array<string,mixed>|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 ) );
}
}
/**
* Add entries for suppliers found in product meta.
*
* @param array<string,mixed> $configured Configured values.
* @return array<string,mixed>
*/
private function ensure_distinct_meta_suppliers( array $configured ) {
$result = array();
foreach ( $this->get_distinct_suppliers() as $supplier ) {
$key = $this->normalize_supplier_key( $supplier );
$existing = isset( $configured[ $key ] ) ? $configured[ $key ] : array();
$result[ $key ] = array(
'supplier' => $supplier,
'time' => $this->normalize_time(
isset( $existing['time'] ) ? $existing['time'] : $this->get_default_time_for_key( $key )
),
'enabled' => isset( $existing['enabled'] ) ? (bool) $existing['enabled'] : $this->is_default_enabled( $key ),
);
}
return $result;
}
/**
* Retrieve suppliers from product meta.
*
* @return array<int,string>
*/
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<string,array<string,mixed>>
*/
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;
}
}