12 Commits

Author SHA1 Message Date
d5babe6cae fix: Update version to 1.2.6 in plugin header and constant definition 2026-01-10 16:12:18 +00:00
e7e757f0cd fix: Update version to 1.2.5 and correct supplier meta key in supplier exports 2026-01-10 16:08:12 +00:00
08551e0013 fix: Update date filtering in supplier row collection to use DateTimeImmutable and date_query 2026-01-10 15:56:28 +00:00
03fdf89442 fix: Improve version determination by trimming whitespace in release workflow 2026-01-10 15:50:03 +00:00
97ca8c4015 fix: Add git configuration for tagging in release workflow 2026-01-10 15:47:00 +00:00
6ededb139b fix: Update GitHub release action and improve release process 2026-01-10 15:42:07 +00:00
124f7b4e94 fix: Improve tag existence check in release workflow 2026-01-10 15:36:01 +00:00
de87021339 chore: Update plugin version to 1.2.3 and add master branch to release workflow 2026-01-10 15:30:48 +00:00
ee43e18b25 feat: Add supplier exports functionality with scheduling and admin management 2026-01-10 15:19:24 +00:00
d8146457ea chore: Bump version to 1.2.1 2026-01-09 19:37:31 +00:00
3c442eb28b feat: Add order item stock source tracking and styling for admin interface 2026-01-09 19:31:53 +00:00
1b2ff44eb4 fix: Ensure SitiWebUpdater is only loaded if the class does not exist 2025-12-12 15:34:28 +00:00
8 changed files with 1030 additions and 21 deletions

View File

@@ -9,6 +9,7 @@ on:
push:
branches:
- main
- master
paths:
- 'siti-stock-plugin.php'
- 'includes/**'
@@ -27,11 +28,14 @@ jobs:
with:
fetch-depth: 0
- name: Fetch tags
run: git fetch --tags origin
- name: Determine plugin version
id: meta
run: |
VERSION=$(grep -E "^\\s*\\*\\s*Version:" -m 1 siti-stock-plugin.php | sed -E 's/.*Version:\\s*//')
VERSION=$(echo "$VERSION" | tr -d '\\r')
VERSION=$(grep -E "^[[:space:]]*\\*[[:space:]]*Version:" -m 1 siti-stock-plugin.php | sed -E 's/.*Version:[[:space:]]*//')
VERSION=$(echo "$VERSION" | tr -d '\\r' | xargs)
if [ -z "$VERSION" ]; then
echo "::error::Kon pluginversie niet bepalen."
exit 1
@@ -42,12 +46,21 @@ jobs:
id: tagcheck
run: |
TAG="v${{ steps.meta.outputs.version }}"
if git rev-parse "$TAG" >/dev/null 2>&1; then
if git show-ref --tags --verify --quiet "refs/tags/$TAG"; then
echo "exists=true" >> "$GITHUB_OUTPUT"
else
echo "exists=false" >> "$GITHUB_OUTPUT"
fi
- name: Maak of update git-tag
if: steps.tagcheck.outputs.exists == 'false'
run: |
TAG="v${{ steps.meta.outputs.version }}"
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
git tag "$TAG" "$GITHUB_SHA"
git push origin "$TAG"
- name: Tag bestaat al workflow afronden
if: steps.tagcheck.outputs.exists == 'true'
run: |
@@ -93,10 +106,14 @@ jobs:
- name: Maak GitHub release
if: steps.tagcheck.outputs.exists == 'false'
uses: softprops/action-gh-release@v2
uses: ncipollo/release-action@v1
with:
tag_name: v${{ steps.meta.outputs.version }}
tag: v${{ steps.meta.outputs.version }}
commit: ${{ github.sha }}
name: Siti Stock Plugin v${{ steps.meta.outputs.version }}
body: ${{ steps.releasebody.outputs.text }}
generate_release_notes: true
files: ${{ steps.package.outputs.asset_path }}
artifacts: ${{ steps.package.outputs.asset_path }}
generateReleaseNotes: true
token: ${{ secrets.GITHUB_TOKEN }}
allowUpdates: true
updateOnlyUnreleased: true

View File

@@ -0,0 +1,41 @@
.siti-stock-order-item-sources {
display: flex;
flex-wrap: wrap;
gap: 6px;
align-items: center;
margin: 4px 0 6px;
font-size: 12px;
}
.siti-stock-order-item-sources__label {
font-weight: 600;
color: #1d2327;
margin-right: 4px;
}
.siti-stock-order-item-sources__badge {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 2px 8px;
border-radius: 999px;
background: #f1f5f9;
color: #1d2327;
}
.siti-stock-order-item-sources__badge .dashicons {
font-size: 13px;
width: 13px;
height: 13px;
line-height: 13px;
}
.siti-stock-order-item-sources__badge.is-local {
background: #e6f8ef;
color: #17633d;
}
.siti-stock-order-item-sources__badge.is-external {
background: #f1ecff;
color: #3e2a85;
}

View File

@@ -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 {
<?php
}
/**
* Render stock updates admin page.
*/
public function render_stock_updates_page() {
if ( ! current_user_can( 'manage_options' ) ) {
return;
}
$suppliers = $this->supplier_exports->get_supplier_configs();
$admin_email = get_option( 'admin_email' );
?>
<div class="wrap siti-stock-settings">
<h1><?php esc_html_e( 'Stock updates', 'siti-stock-plugin' ); ?></h1>
<p class="description">
<?php esc_html_e( 'Plan automatische e-mails met de bestellingen per leverancier in CSV-formaat.', 'siti-stock-plugin' ); ?>
<?php
if ( $admin_email ) {
printf(
/* translators: %s admin email */
esc_html__( 'E-mails worden verzonden naar %s.', 'siti-stock-plugin' ),
esc_html( $admin_email )
);
}
?>
</p>
<?php if ( empty( $suppliers ) ) : ?>
<p><?php esc_html_e( 'Er zijn momenteel geen leveranciers gevonden.', 'siti-stock-plugin' ); ?></p>
<?php else : ?>
<form method="post" action="<?php echo esc_url( admin_url( 'admin-post.php' ) ); ?>">
<?php wp_nonce_field( 'siti_stock_save_supplier_schedule' ); ?>
<input type="hidden" name="action" value="siti_stock_save_supplier_schedule" />
<table class="widefat fixed striped">
<thead>
<tr>
<th><?php esc_html_e( 'Leverancier', 'siti-stock-plugin' ); ?></th>
<th><?php esc_html_e( 'Tijd (24u)', 'siti-stock-plugin' ); ?></th>
<th><?php esc_html_e( 'Automatisch verzenden', 'siti-stock-plugin' ); ?></th>
</tr>
</thead>
<tbody>
<?php foreach ( $suppliers as $key => $config ) : ?>
<tr>
<td><?php echo esc_html( isset( $config['supplier'] ) ? $config['supplier'] : $key ); ?></td>
<td>
<input
type="time"
name="<?php echo esc_attr( 'supplier_exports[' . $key . '][time]' ); ?>"
value="<?php echo esc_attr( isset( $config['time'] ) ? $config['time'] : '09:00' ); ?>"
step="60"
class="regular-text"
/>
</td>
<td>
<label>
<input
type="checkbox"
name="<?php echo esc_attr( 'supplier_exports[' . $key . '][enabled]' ); ?>"
value="1"
<?php checked( ! empty( $config['enabled'] ) ); ?>
/>
<?php esc_html_e( 'Activeer e-mail', 'siti-stock-plugin' ); ?>
</label>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
<?php submit_button( __( 'Instellingen opslaan', 'siti-stock-plugin' ) ); ?>
</form>
<h2><?php esc_html_e( 'Handmatig versturen', 'siti-stock-plugin' ); ?></h2>
<p class="description"><?php esc_html_e( 'Gebruik deze knoppen om direct een export te versturen, ook als de automatische taak uit staat.', 'siti-stock-plugin' ); ?></p>
<table class="widefat fixed striped">
<thead>
<tr>
<th><?php esc_html_e( 'Leverancier', 'siti-stock-plugin' ); ?></th>
<th><?php esc_html_e( 'Geplande tijd', 'siti-stock-plugin' ); ?></th>
<th><?php esc_html_e( 'Actie', 'siti-stock-plugin' ); ?></th>
</tr>
</thead>
<tbody>
<?php foreach ( $suppliers as $key => $config ) : ?>
<tr>
<td><?php echo esc_html( isset( $config['supplier'] ) ? $config['supplier'] : $key ); ?></td>
<td><?php echo esc_html( isset( $config['time'] ) ? $config['time'] : '00:00' ); ?></td>
<td>
<form method="post" action="<?php echo esc_url( admin_url( 'admin-post.php' ) ); ?>">
<?php wp_nonce_field( 'siti_stock_send_supplier_export' ); ?>
<input type="hidden" name="action" value="siti_stock_send_supplier_export" />
<input type="hidden" name="supplier_key" value="<?php echo esc_attr( $key ); ?>" />
<?php submit_button( __( 'Verstuur nu', 'siti-stock-plugin' ), 'secondary', 'submit', false ); ?>
</form>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
<?php endif; ?>
</div>
<?php
}
/**
* Render a text/password input.
*

View File

@@ -14,11 +14,17 @@ class Siti_Stock_Inventory_Manager {
*/
private $external_stock_key;
/**
* @var string
*/
private $order_item_stock_source_key;
/**
* @param string $meta_key Meta key used to store external stock.
*/
public function __construct( $meta_key ) {
$this->external_stock_key = $meta_key;
$this->order_item_stock_source_key = '_siti_stock_source_breakdown';
}
/**
@@ -34,6 +40,8 @@ class Siti_Stock_Inventory_Manager {
add_filter( 'woocommerce_product_get_stock_status', array( $this, 'filter_stock_status_with_external' ), 10, 2 );
add_filter( 'woocommerce_product_variation_get_stock_status', array( $this, 'filter_stock_status_with_external' ), 10, 2 );
add_action( 'woocommerce_reduce_order_item_stock', array( $this, 'rebalance_stock_after_order_reduction' ), 20, 3 );
add_action( 'woocommerce_after_order_itemmeta', array( $this, 'render_order_item_stock_source' ), 10, 3 );
add_action( 'admin_enqueue_scripts', array( $this, 'enqueue_order_admin_assets' ) );
}
/**
@@ -216,6 +224,71 @@ class Siti_Stock_Inventory_Manager {
return max( 0, (int) $base_stock ) + $external_stock;
}
/**
* Determine the quantity reduced for the order item.
*
* @param WC_Order_Item_Product $item Order line item.
* @param array<string,mixed> $change Change context from WooCommerce.
* @return int
*/
private function determine_reduced_quantity( $item, $change ) {
if ( ! is_array( $change ) ) {
$change = array(
'quantity' => $change,
);
}
$from = isset( $change['from'] ) ? $change['from'] : null;
$to = isset( $change['to'] ) ? $change['to'] : null;
if ( null !== $from && null !== $to ) {
$qty = $from - $to;
} elseif ( isset( $change['quantity'] ) ) {
$qty = $change['quantity'];
} elseif ( $item instanceof WC_Order_Item_Product ) {
$qty = $item->get_quantity();
} else {
$qty = 0;
}
$qty = function_exists( 'wc_stock_amount' ) ? wc_stock_amount( $qty ) : (int) $qty;
return max( 0, (int) $qty );
}
/**
* Record the stock source split for an order item.
*
* @param WC_Order_Item_Product $item Order line item.
* @param WC_Product $stock_holder Product that holds stock.
* @param int $quantity Total reduced quantity.
*/
private function record_order_item_stock_source_breakdown( $item, $stock_holder, $quantity ) {
$previous_local = (int) $stock_holder->get_stock_quantity( 'edit' ) + $quantity;
$local_available = max( 0, $previous_local );
$external_available = (int) $this->get_external_stock( $stock_holder );
$from_local = min( $quantity, $local_available );
$remaining = max( 0, $quantity - $from_local );
$from_external = min( $remaining, max( 0, $external_available ) );
$existing = $item->get_meta( $this->order_item_stock_source_key, true );
$existing = is_array( $existing )
? array(
'local' => isset( $existing['local'] ) ? (int) $existing['local'] : 0,
'external' => isset( $existing['external'] ) ? (int) $existing['external'] : 0,
)
: array(
'local' => 0,
'external' => 0,
);
$existing['local'] += $from_local;
$existing['external'] += $from_external;
$item->update_meta_data( $this->order_item_stock_source_key, $existing );
$item->save();
}
/**
* Ensure local stock only dips below zero when the external stock is depleted.
*
@@ -224,7 +297,7 @@ class Siti_Stock_Inventory_Manager {
* @param WC_Order $order Order instance (unused).
*/
public function rebalance_stock_after_order_reduction( $item, $change, $order ) {
unset( $change, $order );
unset( $order );
if ( ! $item instanceof WC_Order_Item_Product ) {
return;
@@ -243,6 +316,12 @@ class Siti_Stock_Inventory_Manager {
return;
}
$quantity = $this->determine_reduced_quantity( $item, $change );
if ( $quantity > 0 ) {
$this->record_order_item_stock_source_breakdown( $item, $stock_holder, $quantity );
}
$current_local = (int) $stock_holder->get_stock_quantity( 'edit' );
$external_stock = $this->get_external_stock( $stock_holder );
@@ -269,4 +348,91 @@ class Siti_Stock_Inventory_Manager {
$stock_holder->save();
}
}
/**
* Display the recorded stock source on the admin order screen.
*
* @param int $item_id Order item ID.
* @param WC_Order_Item $item Order item instance.
* @param WC_Product|false $product Product (unused).
*/
public function render_order_item_stock_source( $item_id, $item, $product ) {
unset( $item_id, $product );
if ( ! is_admin() || ! $item instanceof WC_Order_Item_Product ) {
return;
}
$data = $item->get_meta( $this->order_item_stock_source_key, true );
if ( ! is_array( $data ) ) {
return;
}
$local = isset( $data['local'] ) ? max( 0, (int) $data['local'] ) : 0;
$external = isset( $data['external'] ) ? max( 0, (int) $data['external'] ) : 0;
if ( $local <= 0 && $external <= 0 ) {
return;
}
echo '<div class="siti-stock-order-item-sources">';
echo '<span class="siti-stock-order-item-sources__label">' . esc_html__( 'Voorraadbron', 'siti-stock-plugin' ) . ':</span>';
if ( $local > 0 ) {
$local_label = sprintf(
/* translators: %d: quantity fulfilled from regular stock. */
_n( '%d uit reguliere voorraad', '%d uit reguliere voorraad', $local, 'siti-stock-plugin' ),
$local
);
printf(
'<span class="siti-stock-order-item-sources__badge is-local"><span class="dashicons dashicons-archive" aria-hidden="true"></span>%s</span>',
esc_html( $local_label )
);
}
if ( $external > 0 ) {
$external_label = sprintf(
/* translators: %d: quantity fulfilled from external stock. */
_n( '%d uit externe voorraad', '%d uit externe voorraad', $external, 'siti-stock-plugin' ),
$external
);
printf(
'<span class="siti-stock-order-item-sources__badge is-external"><span class="dashicons dashicons-cloud" aria-hidden="true"></span>%s</span>',
esc_html( $external_label )
);
}
echo '</div>';
}
/**
* Enqueue lightweight styling on the order edit screen.
*
* @param string $hook Current admin hook.
*/
public function enqueue_order_admin_assets( $hook ) {
if ( 'post.php' !== $hook && 'post-new.php' !== $hook ) {
return;
}
if ( ! function_exists( 'get_current_screen' ) ) {
return;
}
$screen = get_current_screen();
if ( ! $screen || 'shop_order' !== $screen->post_type ) {
return;
}
wp_enqueue_style(
'siti-stock-order-admin',
plugins_url( 'assets/css/order-admin.css', SITI_STOCK_PLUGIN_FILE ),
array(),
SITI_STOCK_PLUGIN_VERSION
);
}
}

View File

@@ -10,6 +10,7 @@ require_once __DIR__ . '/class-siti-stock-admin.php';
require_once __DIR__ . '/class-siti-stock-inventory-manager.php';
require_once __DIR__ . '/class-siti-stock-sync-service.php';
require_once __DIR__ . '/class-siti-stock-sync-controller.php';
require_once __DIR__ . '/class-siti-stock-supplier-exports.php';
/**
* Core plugin bootstrap that wires together all sub-components.
@@ -50,6 +51,11 @@ class Siti_Stock_Plugin {
*/
private $sync_controller;
/**
* @var Siti_Stock_Supplier_Exports
*/
private $supplier_exports;
/**
* Get singleton instance.
*
@@ -80,6 +86,10 @@ class Siti_Stock_Plugin {
$settings_repo = new Siti_Stock_Settings( self::OPTION_KEY );
Siti_Stock_Sync_Controller::maybe_schedule_from_settings( self::CRON_HOOK, $settings_repo->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 );
}

View File

@@ -99,6 +99,7 @@ class Siti_Stock_Settings {
'default_status' => 'instock',
'enable_auto_sync' => false,
'sync_interval' => 'hourly',
'supplier_exports' => array(),
)
);
}

View File

@@ -0,0 +1,640 @@
<?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;
}
}

View File

@@ -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.0.0
* Version: 1.2.6
* Author: Siti Web
* Author URI: https://www.siti.nl
* Requires PHP: 8.1
@@ -16,12 +16,15 @@ if ( ! defined( 'ABSPATH' ) ) {
exit;
}
define( 'SITI_STOCK_PLUGIN_VERSION', '0.1.0' );
define( 'SITI_STOCK_PLUGIN_VERSION', '1.2.6' );
define( 'SITI_STOCK_PLUGIN_FILE', __FILE__ );
define( 'SITI_STOCK_PLUGIN_DIR', plugin_dir_path( __FILE__ ) );
require_once __DIR__ . '/includes/class-siti-stock-plugin.php';
if ( ! class_exists( 'SitiWebUpdater' ) ) {
require_once __DIR__ . '/SitiWebUpdater.php';
}
register_activation_hook( __FILE__, array( 'Siti_Stock_Plugin', 'activate' ) );
register_deactivation_hook( __FILE__, array( 'Siti_Stock_Plugin', 'deactivate' ) );