feat: Implement Siti Stock Plugin for WooCommerce integration

- Added core plugin structure with main class Siti_Stock_Plugin.
- Implemented settings management through Siti_Stock_Settings.
- Developed admin interface for settings configuration via Siti_Stock_Admin.
- Created inventory management with external stock handling in Siti_Stock_Inventory_Manager.
- Integrated synchronization service to fetch and apply stock updates from external API in Siti_Stock_Sync_Service.
- Added custom product data store to manage combined stock values in Siti_Stock_Product_Data_Store.
- Registered hooks for admin menus, settings, and synchronization processes.
- Implemented REST API endpoint for triggering stock sync.
- Added cron scheduling for automatic stock synchronization.
- Included localization support for Dutch language.
This commit is contained in:
2025-12-12 14:58:59 +00:00
commit d6182f589e
17 changed files with 1875 additions and 0 deletions

102
.github/workflows/release.yml vendored Normal file
View File

@@ -0,0 +1,102 @@
name: Build & Release Plugin
on:
workflow_dispatch:
inputs:
release_notes:
description: 'Optionele release-opmerkingen'
required: false
push:
branches:
- main
paths:
- 'siti-stock-plugin.php'
- 'includes/**'
- 'assets/**'
- 'README.md'
- '.github/workflows/release.yml'
jobs:
release:
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
fetch-depth: 0
- 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')
if [ -z "$VERSION" ]; then
echo "::error::Kon pluginversie niet bepalen."
exit 1
fi
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
- name: Check if tag exists
id: tagcheck
run: |
TAG="v${{ steps.meta.outputs.version }}"
if git rev-parse "$TAG" >/dev/null 2>&1; then
echo "exists=true" >> "$GITHUB_OUTPUT"
else
echo "exists=false" >> "$GITHUB_OUTPUT"
fi
- name: Tag bestaat al workflow afronden
if: steps.tagcheck.outputs.exists == 'true'
run: |
echo "Tag v${{ steps.meta.outputs.version }} bestaat al. Release wordt overgeslagen."
- name: Build distributie-zip
if: steps.tagcheck.outputs.exists == 'false'
id: package
run: |
VERSION="${{ steps.meta.outputs.version }}"
SLUG="siti-stock-plugin"
BUILD_ROOT="$RUNNER_TEMP/build"
DEST_DIR="$BUILD_ROOT/$SLUG"
mkdir -p "$DEST_DIR"
rsync -a ./ "$DEST_DIR" \
--exclude '.git/' \
--exclude '.github/' \
--exclude 'docker/' \
--exclude 'docs/' \
--exclude 'dist/' \
--exclude 'docker-compose.yml' \
--exclude 'PLAN.md'
mkdir -p dist
ZIP_PATH="dist/${SLUG}-${VERSION}.zip"
(cd "$BUILD_ROOT" && zip -r "$GITHUB_WORKSPACE/$ZIP_PATH" "$SLUG")
echo "asset_path=$ZIP_PATH" >> "$GITHUB_OUTPUT"
echo "asset_name=${SLUG}-${VERSION}.zip" >> "$GITHUB_OUTPUT"
- name: Stel release-body samen
if: steps.tagcheck.outputs.exists == 'false'
id: releasebody
run: |
if [ -n "$RELEASE_NOTES" ]; then
echo "text=$RELEASE_NOTES" >> "$GITHUB_OUTPUT"
else
echo "text=Automatische release op basis van versie ${{ steps.meta.outputs.version }}." >> "$GITHUB_OUTPUT"
fi
env:
RELEASE_NOTES: ${{ github.event.inputs.release_notes }}
- name: Maak GitHub release
if: steps.tagcheck.outputs.exists == 'false'
uses: softprops/action-gh-release@v2
with:
tag_name: v${{ steps.meta.outputs.version }}
name: Siti Stock Plugin v${{ steps.meta.outputs.version }}
body: ${{ steps.releasebody.outputs.text }}
generate_release_notes: true
files: ${{ steps.package.outputs.asset_path }}

20
.gitignore vendored Normal file
View File

@@ -0,0 +1,20 @@
# OS cruft
.DS_Store
Thumbs.db
# IDE / tooling
.idea/
.vscode/
*.code-workspace
# Dependencies & builds
node_modules/
vendor/
dist/
# Logs / env
*.log
.env*
# Docker artifacts
docker/wordpress/wp-content/

63
README.md Normal file
View File

@@ -0,0 +1,63 @@
# Siti Stock Plugin (WordPress plugin)
Deze repository bevat de WordPress plugin **Siti Stock Plugin**. De plugincode leeft geheel in deze map en volgt dezelfde ontwikkelworkflow als onze andere Siti plugins, zodat je eenvoudig lokaal kunt bouwen, testen en releasen.
## Functionaliteit
- Houd WooCommerce voorraad synchroon via een extern API-endpoint.
- Stel API-sleutel, standaard voorraadstatus en cron-interval in vanuit het beheerscherm **Siti Stock**.
- Start een sync handmatig vanuit de beheerpagina of via de REST-route `siti-stock/v1/sync`.
## Installatie & gebruik
1. Download de nieuwste release (`siti-stock-plugin-x.y.z.zip`) vanaf GitHub Releases of gebruik het zip-bestand uit `dist/`.
2. Upload het zip-bestand in WordPress via **Plugins → Nieuwe plugin → Plugin uploaden** of plaats de map handmatig in `wp-content/plugins/`.
3. Activeer **Siti Stock Plugin** en configureer eventueel de instellingen onder **Instellingen → Siti Stock**.
## Ontwikkelvereisten
- Docker Desktop of Docker Engine + Docker Compose v2
- Een API-key of andere geheimen plaats je in `.env` (wordt genegeerd door git)
## Lokale ontwikkeling met Docker
1. Start de containers:
```bash
docker compose up --build -d
```
2. Doorloop de WordPress installatie op http://localhost:8086.
- Database host: `db`
- Database naam: `wordpress`
- Database gebruiker/wachtwoord: `wordpress`
3. Activeer binnen WordPress de plugin **Siti Stock Plugin** (deze map wordt in de container gemount naar `wp-content/plugins/siti-stock-plugin`).
### Handige commando's
```bash
# Bash in de WordPress container (voor wp-cli of composer)
docker compose exec wordpress bash
# Voorbeeld: lijst plugins met WP-CLI
docker compose exec wordpress wp plugin list
# phpMyAdmin
open http://localhost:8089 (zelfde DB-gegevens als hierboven)
# Containers stoppen
docker compose down
```
## Werken met git
De code blijft op de host staan en wordt alleen als bind-mount gebruikt. Daardoor kun je gewoon lokaal commits maken:
```bash
git status
git add .
git commit -m "Omschrijf je wijziging"
git push origin <branch>
```
## Releasen
De workflow `.github/workflows/release.yml` maakt op basis van de pluginversie automatisch een distributie-zip en GitHub Release aan. Pas vóór een release de `Version` in `siti-stock-plugin.php` aan en zorg dat alle wijzigingen gecommit zijn.

202
SitiWebUpdater.php Normal file
View File

@@ -0,0 +1,202 @@
<?php
class SitiWebUpdater {
private $file;
private $plugin;
private $basename;
private $active;
private $username;
private $repository;
private $authorize_token;
private $github_response;
public function __construct( $file ) {
$this->file = $file;
$this->set_plugin_properties();
add_action( 'admin_init', array( $this, 'set_plugin_properties' ) );
return $this;
}
public function set_plugin_properties() {
if ( ! function_exists( 'get_plugin_data' ) ) {
require_once ABSPATH . 'wp-admin/includes/plugin.php';
}
$this->plugin = get_plugin_data( $this->file );
$this->basename = plugin_basename( $this->file );
$this->active = is_plugin_active( $this->basename );
}
public function set_username( $username ) {
$this->username = $username;
}
public function set_repository( $repository ) {
$this->repository = $repository;
}
public function authorize( $token ) {
$this->authorize_token = $token;
}
private function get_repository_info() {
if ( is_null( $this->github_response ) ) { // Do we have a response?
$args = array();
$request_uri = sprintf( 'https://api.github.com/repos/%s/%s/releases', $this->username, $this->repository ); // Build URI
$args = array();
if( $this->authorize_token ) { // Is there an access token?
$args['headers']['Authorization'] = "bearer {$this->authorize_token}"; // Set the headers
}
$response = json_decode( wp_remote_retrieve_body( wp_remote_get( $request_uri, $args ) ), true ); // Get JSON and parse it
if( is_array( $response ) ) { // If it is an array
$response = current( $response ); // Get the first item
}
$this->github_response = $response; // Set it to our property
}
}
private function get_latest_version_from_response() {
if ( empty( $this->github_response['tag_name'] ) ) {
return null;
}
return ltrim( $this->github_response['tag_name'], 'vV' );
}
public function initialize() {
add_filter( 'pre_set_site_transient_update_plugins', array( $this, 'modify_transient' ), 10, 1 );
add_filter( 'plugins_api', array( $this, 'plugin_popup' ), 10, 3);
add_filter( 'upgrader_post_install', array( $this, 'after_install' ), 10, 3 );
// Add Authorization Token to download_package
add_filter( 'upgrader_pre_download',
function() {
add_filter( 'http_request_args', [ $this, 'download_package' ], 15, 2 );
return false; // upgrader_pre_download filter default return value.
}
);
}
public function modify_transient( $transient ) {
if( property_exists( $transient, 'checked') ) { // Check if transient has a checked property
if( $checked = $transient->checked ) { // Did Wordpress check for updates?
$this->get_repository_info(); // Get the repo info
$latest_version = $this->get_latest_version_from_response();
if ( null === $latest_version ) {
return $transient;
}
$out_of_date = version_compare( $latest_version, $checked[ $this->basename ], 'gt' ); // Check if we're out of date
if( $out_of_date ) {
$new_files = $this->github_response['zipball_url']; // Get the ZIP
$slug = current( explode('/', $this->basename ) ); // Create valid slug
$plugin = array( // setup our plugin info
'url' => $this->plugin["PluginURI"],
'slug' => $slug,
'package' => $new_files,
'new_version' => $latest_version
);
$transient->response[$this->basename] = (object) $plugin; // Return it in response
}
}
}
return $transient; // Return filtered transient
}
public function plugin_popup( $result, $action, $args ) {
if( ! empty( $args->slug ) ) { // If there is a slug
if( $args->slug == current( explode( '/' , $this->basename ) ) ) { // And it's our slug
$this->get_repository_info(); // Get our repo info
$latest_version = $this->get_latest_version_from_response();
if ( null === $latest_version ) {
return $result;
}
// Set it to an array
$plugin = array(
'name' => $this->plugin["Name"],
'slug' => $this->basename,
'requires' => '5.1',
'tested' => '6.4.3',
'rating' => '100.0',
'num_ratings' => '10823',
'downloaded' => '14249',
'added' => '2016-01-05',
'version' => $latest_version,
'author' => $this->plugin["AuthorName"],
'author_profile' => $this->plugin["AuthorURI"],
'last_updated' => $this->github_response['published_at'],
'homepage' => $this->plugin["PluginURI"],
'short_description' => $this->plugin["Description"],
'sections' => array(
'Description' => $this->plugin["Description"],
'Updates' => $this->github_response['body'],
),
'download_link' => $this->github_response['zipball_url']
);
return (object) $plugin; // Return the data
}
}
return $result; // Otherwise return default
}
public function download_package( $args, $url ) {
if ( null !== $args['filename'] ) {
if( $this->authorize_token ) {
$args = array_merge( $args, array( "headers" => array( "Authorization" => "token {$this->authorize_token}" ) ) );
}
}
remove_filter( 'http_request_args', [ $this, 'download_package' ] );
return $args;
}
public function after_install( $response, $hook_extra, $result ) {
global $wp_filesystem; // Get global FS object
$install_directory = plugin_dir_path( $this->file ); // Our plugin directory
$wp_filesystem->move( $result['destination'], $install_directory ); // Move files to the plugin dir
$result['destination'] = $install_directory; // Set the destination for the rest of the stack
if ( $this->active ) { // If it was active
activate_plugin( $this->basename ); // Reactivate
}
return $result;
}
}

19
assets/css/admin.css Normal file
View File

@@ -0,0 +1,19 @@
.siti-stock-settings {
max-width: 800px;
}
.siti-stock-settings .description {
color: #555;
}
.siti-stock-cron-info {
margin-top: 20px;
border-left: 4px solid #2271b1;
background: #fff;
padding: 16px;
display: none;
}
.siti-stock-cron-info.is-visible {
display: block;
}

15
assets/js/admin.js Normal file
View File

@@ -0,0 +1,15 @@
(function ($) {
function toggleCronInfo() {
var info = $('.siti-stock-cron-info');
var checkbox = $('[data-siti-stock-toggle="cron"]');
if (!info.length || !checkbox.length) {
return;
}
info.toggleClass('is-visible', checkbox.is(':checked'));
}
$(document).on('change', '[data-siti-stock-toggle="cron"]', toggleCronInfo);
$(toggleCronInfo);
})(jQuery);

49
docker-compose.yml Normal file
View File

@@ -0,0 +1,49 @@
services:
db:
image: mariadb:10.11
command: --default-authentication-plugin=mysql_native_password
restart: unless-stopped
environment:
MYSQL_DATABASE: wordpress
MYSQL_USER: wordpress
MYSQL_PASSWORD: wordpress
MYSQL_ROOT_PASSWORD: supersecret
volumes:
- db_data:/var/lib/mysql
ports:
- "3312:3306"
wordpress:
build:
context: .
dockerfile: docker/wordpress/Dockerfile
depends_on:
- db
ports:
- "8082:80"
environment:
WORDPRESS_DB_HOST: db:3306
WORDPRESS_DB_USER: wordpress
WORDPRESS_DB_PASSWORD: wordpress
WORDPRESS_DB_NAME: wordpress
WORDPRESS_DEBUG: 1
WP_ENVIRONMENT_TYPE: local
volumes:
- wordpress_data:/var/www/html
- ./:/var/www/html/wp-content/plugins/siti-stock-plugin
restart: unless-stopped
phpmyadmin:
image: phpmyadmin/phpmyadmin:5
depends_on:
- db
ports:
- "8010:80"
environment:
PMA_HOST: db
PMA_USER: wordpress
PMA_PASSWORD: wordpress
volumes:
db_data:
wordpress_data:

View File

@@ -0,0 +1,13 @@
FROM wordpress:6.9-php8.2
# Install handy tooling (git, mariadb client, wp-cli) for local development.
RUN apt-get update \
&& apt-get install -y --no-install-recommends git less vim unzip mariadb-client \
&& rm -rf /var/lib/apt/lists/* \
&& curl -o /usr/local/bin/wp https://raw.githubusercontent.com/wp-cli/builds/gh-pages/phar/wp-cli.phar \
&& chmod +x /usr/local/bin/wp
WORKDIR /var/www/html
# Match the default Linux UID/GID to avoid permission headaches with bind mounts.
RUN usermod -u 1000 www-data && groupmod -g 1000 www-data

View File

@@ -0,0 +1,58 @@
<?php
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
/**
* Handles temporary admin notices.
*/
class Siti_Stock_Admin_Notices {
const TRANSIENT_KEY = 'siti_stock_notice';
/**
* Register hooks.
*/
public function register_hooks() {
add_action( 'admin_notices', array( $this, 'render_flash_notice' ) );
}
/**
* Store notice in transient for later output.
*
* @param string $message Message.
* @param string $type Notice type.
*/
public function add_notice( $message, $type = 'success' ) {
set_transient(
self::TRANSIENT_KEY,
array(
'message' => $message,
'type' => $type,
),
30
);
}
/**
* Render notice if stored.
*/
public function render_flash_notice() {
$notice = get_transient( self::TRANSIENT_KEY );
if ( ! $notice ) {
return;
}
delete_transient( self::TRANSIENT_KEY );
$type = in_array( $notice['type'], array( 'error', 'warning', 'success', 'info' ), true ) ? $notice['type'] : 'info';
printf(
'<div class="notice notice-%1$s is-dismissible"><p>%2$s</p></div>',
esc_attr( $type ),
esc_html( $notice['message'] )
);
}
}

View File

@@ -0,0 +1,349 @@
<?php
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
/**
* Registers admin UI, menus and asset loading.
*/
class Siti_Stock_Admin {
/**
* @var Siti_Stock_Settings
*/
private $settings;
/**
* @var Siti_Stock_Sync_Controller
*/
private $sync_controller;
/**
* @var Siti_Stock_Admin_Notices
*/
private $notices;
/**
* Cached settings snapshot for rendering.
*
* @var array<string,mixed>|null
*/
private $settings_cache = null;
/**
* @param Siti_Stock_Settings $settings Settings repository.
* @param Siti_Stock_Sync_Controller $sync_controller Sync controller.
* @param Siti_Stock_Admin_Notices $notices Notice handler.
*/
public function __construct( Siti_Stock_Settings $settings, Siti_Stock_Sync_Controller $sync_controller, Siti_Stock_Admin_Notices $notices ) {
$this->settings = $settings;
$this->sync_controller = $sync_controller;
$this->notices = $notices;
}
/**
* Register all admin-related hooks.
*/
public function register_hooks() {
add_action( 'admin_init', array( $this, 'register_settings' ) );
add_action( 'admin_menu', array( $this, 'register_menu' ) );
add_action( 'admin_enqueue_scripts', array( $this, 'enqueue_admin_assets' ) );
add_action( 'admin_notices', array( $this, 'maybe_show_missing_dependencies' ) );
$this->notices->register_hooks();
}
/**
* Register settings sections/fields.
*/
public function register_settings() {
$this->settings->register();
add_settings_section(
'siti_stock_api',
__( 'API-instellingen', 'siti-stock-plugin' ),
function () {
echo '<p>' . esc_html__( 'Configureer het endpoint dat de actuele voorraad teruggeeft.', 'siti-stock-plugin' ) . '</p>';
},
'siti-stock-plugin'
);
add_settings_field(
'api_endpoint',
__( 'API-endpoint', 'siti-stock-plugin' ),
array( $this, 'render_text_field' ),
'siti-stock-plugin',
'siti_stock_api',
array(
'key' => 'api_endpoint',
'placeholder' => 'https://example.com/api/stock',
)
);
add_settings_field(
'api_key',
__( 'API-sleutel', 'siti-stock-plugin' ),
array( $this, 'render_text_field' ),
'siti-stock-plugin',
'siti_stock_api',
array(
'key' => 'api_key',
'type' => 'password',
'description' => __( 'Wordt als Bearer-token meegestuurd.', 'siti-stock-plugin' ),
)
);
add_settings_section(
'siti_stock_sync',
__( 'Synchronisatie', 'siti-stock-plugin' ),
function () {
echo '<p>' . esc_html__( 'Stel in hoe en wanneer de voorraad automatisch bijgewerkt moet worden.', 'siti-stock-plugin' ) . '</p>';
},
'siti-stock-plugin'
);
add_settings_field(
'default_status',
__( 'Standaardstatus', 'siti-stock-plugin' ),
array( $this, 'render_select_field' ),
'siti-stock-plugin',
'siti_stock_sync',
array(
'key' => 'default_status',
'options' => array(
'instock' => __( 'Op voorraad', 'siti-stock-plugin' ),
'outofstock' => __( 'Niet op voorraad', 'siti-stock-plugin' ),
'onbackorder'=> __( 'In nabestelling', 'siti-stock-plugin' ),
),
)
);
add_settings_field(
'enable_auto_sync',
__( 'Automatisch synchroniseren', 'siti-stock-plugin' ),
array( $this, 'render_checkbox_field' ),
'siti-stock-plugin',
'siti_stock_sync',
array(
'key' => 'enable_auto_sync',
'description' => __( 'Voer de synchronisatie periodiek uit via WP-Cron.', 'siti-stock-plugin' ),
)
);
add_settings_field(
'sync_interval',
__( 'Interval', 'siti-stock-plugin' ),
array( $this, 'render_select_field' ),
'siti-stock-plugin',
'siti_stock_sync',
array(
'key' => 'sync_interval',
'options' => array(
'siti_stock_quarter_hour' => __( 'Elke 15 minuten', 'siti-stock-plugin' ),
'hourly' => __( 'Elk uur', 'siti-stock-plugin' ),
'twicedaily' => __( 'Twee keer per dag', 'siti-stock-plugin' ),
'daily' => __( 'Dagelijks', 'siti-stock-plugin' ),
),
)
);
}
/**
* Register admin menu page.
*/
public function register_menu() {
add_menu_page(
__( 'Siti Stock', 'siti-stock-plugin' ),
__( 'Siti Stock', 'siti-stock-plugin' ),
'manage_options',
'siti-stock-plugin',
array( $this, 'render_settings_page' ),
'dashicons-products'
);
}
/**
* Enqueue admin assets only on our page.
*
* @param string $hook Hook suffix.
*/
public function enqueue_admin_assets( $hook ) {
if ( 'toplevel_page_siti-stock-plugin' !== $hook ) {
return;
}
wp_enqueue_style(
'siti-stock-admin',
plugins_url( 'assets/css/admin.css', SITI_STOCK_PLUGIN_FILE ),
array(),
SITI_STOCK_PLUGIN_VERSION
);
wp_enqueue_script(
'siti-stock-admin',
plugins_url( 'assets/js/admin.js', SITI_STOCK_PLUGIN_FILE ),
array( 'jquery' ),
SITI_STOCK_PLUGIN_VERSION,
true
);
}
/**
* Display main admin page.
*/
public function render_settings_page() {
if ( ! current_user_can( 'manage_options' ) ) {
return;
}
$settings = $this->get_settings_snapshot();
$next_run = $this->sync_controller->get_next_scheduled_run();
?>
<div class="wrap siti-stock-settings">
<h1><?php esc_html_e( 'Siti Stock Plugin', 'siti-stock-plugin' ); ?></h1>
<p class="description"><?php esc_html_e( 'Beheer API-sleutels, synchronisatie-instellingen en voer handmatige voorraadupdates uit.', 'siti-stock-plugin' ); ?></p>
<form method="post" action="options.php">
<?php
settings_fields( $this->settings->get_settings_group() );
do_settings_sections( 'siti-stock-plugin' );
submit_button();
?>
</form>
<hr />
<h2><?php esc_html_e( 'Handmatige synchronisatie', 'siti-stock-plugin' ); ?></h2>
<p><?php esc_html_e( 'Voer direct een synchronisatie uit wanneer je net voorraad hebt bijgewerkt in het bronsysteem.', 'siti-stock-plugin' ); ?></p>
<form method="post" action="<?php echo esc_url( admin_url( 'admin-post.php' ) ); ?>">
<?php wp_nonce_field( 'siti_stock_manual_sync' ); ?>
<input type="hidden" name="action" value="siti_stock_manual_sync" />
<?php submit_button( __( 'Start handmatige sync', 'siti-stock-plugin' ), 'secondary', 'siti_stock_manual_sync', false ); ?>
</form>
<div class="siti-stock-cron-info<?php echo ! empty( $settings['enable_auto_sync'] ) ? ' is-visible' : ''; ?>" data-visible="<?php echo esc_attr( ! empty( $settings['enable_auto_sync'] ) ? '1' : '0' ); ?>">
<h3><?php esc_html_e( 'Cron status', 'siti-stock-plugin' ); ?></h3>
<p>
<?php
if ( $next_run ) {
printf(
/* translators: %s = formatted datetime */
esc_html__( 'Volgende geplande run: %s', 'siti-stock-plugin' ),
esc_html( wp_date( get_option( 'date_format' ) . ' ' . get_option( 'time_format' ), $next_run ) )
);
} else {
esc_html_e( 'Momenteel staat er geen geplande sync klaar.', 'siti-stock-plugin' );
}
?>
</p>
</div>
</div>
<?php
}
/**
* Render a text/password input.
*
* @param array<string,string> $args Field args.
*/
public function render_text_field( $args ) {
$key = $args['key'];
$type = isset( $args['type'] ) ? $args['type'] : 'text';
$placeholder = isset( $args['placeholder'] ) ? $args['placeholder'] : '';
$value = $this->get_settings_value( $key );
?>
<input
type="<?php echo esc_attr( $type ); ?>"
name="<?php echo esc_attr( $this->settings->get_option_key() . '[' . $key . ']' ); ?>"
id="<?php echo esc_attr( $key ); ?>"
class="regular-text"
value="<?php echo esc_attr( $value ); ?>"
placeholder="<?php echo esc_attr( $placeholder ); ?>"
/>
<?php if ( ! empty( $args['description'] ) ) : ?>
<p class="description"><?php echo esc_html( $args['description'] ); ?></p>
<?php endif; ?>
<?php
}
/**
* Render select field.
*
* @param array<string,mixed> $args Field args.
*/
public function render_select_field( $args ) {
$key = $args['key'];
$value = $this->get_settings_value( $key );
$options = isset( $args['options'] ) ? $args['options'] : array();
?>
<select name="<?php echo esc_attr( $this->settings->get_option_key() . '[' . $key . ']' ); ?>" id="<?php echo esc_attr( $key ); ?>">
<?php foreach ( $options as $option_key => $label ) : ?>
<option value="<?php echo esc_attr( $option_key ); ?>" <?php selected( $value, $option_key ); ?>>
<?php echo esc_html( $label ); ?>
</option>
<?php endforeach; ?>
</select>
<?php
}
/**
* Render checkbox.
*
* @param array<string,mixed> $args Arguments.
*/
public function render_checkbox_field( $args ) {
$key = $args['key'];
$value = (bool) $this->get_settings_value( $key );
?>
<label>
<input
type="checkbox"
name="<?php echo esc_attr( $this->settings->get_option_key() . '[' . $key . ']' ); ?>"
value="1"
<?php checked( $value ); ?>
data-siti-stock-toggle="cron"
/>
<?php esc_html_e( 'Inschakelen', 'siti-stock-plugin' ); ?>
</label>
<?php if ( ! empty( $args['description'] ) ) : ?>
<p class="description"><?php echo esc_html( $args['description'] ); ?></p>
<?php endif; ?>
<?php
}
/**
* Show dependencies notice.
*/
public function maybe_show_missing_dependencies() {
if ( class_exists( 'WooCommerce' ) ) {
return;
}
echo '<div class="notice notice-error"><p>' . esc_html__( 'WooCommerce is vereist voor de Siti Stock Plugin.', 'siti-stock-plugin' ) . '</p></div>';
}
/**
* Get cached settings snapshot for admin rendering.
*
* @return array<string,mixed>
*/
private function get_settings_snapshot() {
if ( null === $this->settings_cache ) {
$this->settings_cache = $this->settings->get_all();
}
return $this->settings_cache;
}
/**
* Helper for retrieving a specific setting.
*
* @param string $key Array key.
* @return mixed
*/
private function get_settings_value( $key ) {
$settings = $this->get_settings_snapshot();
return isset( $settings[ $key ] ) ? $settings[ $key ] : '';
}
}

View File

@@ -0,0 +1,209 @@
<?php
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
/**
* Adds the external stock field and ensures WooCommerce exposes combined stock values.
*/
class Siti_Stock_Inventory_Manager {
/**
* @var string
*/
private $external_stock_key;
/**
* @param string $meta_key Meta key used to store external stock.
*/
public function __construct( $meta_key ) {
$this->external_stock_key = $meta_key;
}
/**
* Register relevant hooks.
*/
public function register_hooks() {
add_action( 'woocommerce_product_options_stock_fields', array( $this, 'render_external_stock_field' ) );
add_action( 'woocommerce_admin_process_product_object', array( $this, 'save_external_stock_value' ) );
add_filter( 'woocommerce_product_get_stock_quantity', array( $this, 'filter_stock_quantity_with_external' ), 10, 2 );
add_filter( 'woocommerce_product_variation_get_stock_quantity', array( $this, 'filter_stock_quantity_with_external' ), 10, 2 );
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 );
}
/**
* Render external stock field within the inventory tab.
*/
public function render_external_stock_field() {
if ( ! function_exists( 'woocommerce_wp_text_input' ) ) {
return;
}
global $product_object;
if ( ! $product_object instanceof WC_Product ) {
return;
}
$external_stock = $this->get_external_stock( $product_object );
woocommerce_wp_text_input(
array(
'id' => $this->external_stock_key,
'value' => $external_stock,
'label' => __( 'Externe voorraad', 'siti-stock-plugin' ),
'desc_tip' => true,
'description' => __( 'Voorraad beschikbaar op externe locatie(s).', 'siti-stock-plugin' ),
'type' => 'number',
'custom_attributes' => array(
'step' => '1',
'min' => '0',
),
)
);
}
/**
* Persist external stock coming from the product edit screen.
*
* @param WC_Product $product Product being saved.
*/
public function save_external_stock_value( $product ) {
if ( ! $product instanceof WC_Product ) {
return;
}
if ( ! isset( $_POST[ $this->external_stock_key ] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Missing
return;
}
$raw_value = wp_unslash( $_POST[ $this->external_stock_key ] ); // phpcs:ignore WordPress.Security.NonceVerification.Missing
$value = function_exists( 'wc_stock_amount' ) ? wc_stock_amount( $raw_value ) : (int) $raw_value;
$value = max( 0, (int) $value );
$product->update_meta_data( $this->external_stock_key, $value );
}
/**
* Ensure WooCommerce exposes combined stock (local + external) on the frontend.
*
* @param int|null $stock Stock reported by WooCommerce.
* @param WC_Product $product Product instance.
* @return int|null
*/
public function filter_stock_quantity_with_external( $stock, $product ) {
if ( ! $product instanceof WC_Product || ! $product->managing_stock() ) {
return $stock;
}
return $this->calculate_combined_stock( $product );
}
/**
* Mirror combined stock towards frontend stock status without touching edit context.
*
* @param string $status Current status.
* @param WC_Product $product Product instance.
* @return string
*/
public function filter_stock_status_with_external( $status, $product ) {
if ( ! $product instanceof WC_Product || ! $product->managing_stock() ) {
return $status;
}
$combined = $this->calculate_combined_stock( $product );
if ( 'outofstock' === $status && $combined > 0 ) {
return 'instock';
}
if ( 'instock' === $status && $combined <= 0 ) {
return 'outofstock';
}
return $status;
}
/**
* Retrieve stored external stock value.
*
* @param WC_Product $product Product instance.
* @return int
*/
private function get_external_stock( $product ) {
$raw = (int) $product->get_meta( $this->external_stock_key, true );
return max( 0, $raw );
}
/**
* Calculate combined stock without triggering recursion.
*
* @param WC_Product $product Product instance.
* @return int
*/
private function calculate_combined_stock( $product ) {
$raw_stock = $product->get_stock_quantity( 'edit' );
$base_stock = function_exists( 'wc_stock_amount' ) ? wc_stock_amount( $raw_stock ) : (int) $raw_stock;
$external_stock = $this->get_external_stock( $product );
return max( 0, (int) $base_stock ) + $external_stock;
}
/**
* Ensure local stock only dips below zero when the external stock is depleted.
*
* @param WC_Order_Item_Product $item Order line item that triggered the reduction.
* @param array $change Change context provided by WooCommerce.
* @param WC_Order $order Order instance (unused).
*/
public function rebalance_stock_after_order_reduction( $item, $change, $order ) {
unset( $change, $order );
if ( ! $item instanceof WC_Order_Item_Product ) {
return;
}
$product = $item->get_product();
if ( ! $product instanceof WC_Product || ! $product->managing_stock() ) {
return;
}
$managed_id = $product->get_stock_managed_by_id();
$stock_holder = ( $managed_id === $product->get_id() ) ? $product : wc_get_product( $managed_id );
if ( ! $stock_holder instanceof WC_Product ) {
return;
}
$current_local = (int) $stock_holder->get_stock_quantity( 'edit' );
$external_stock = $this->get_external_stock( $stock_holder );
if ( $current_local >= 0 || $external_stock <= 0 ) {
return;
}
$shortage = min( abs( $current_local ), $external_stock );
$new_local = $current_local + $shortage;
$new_external = $external_stock - $shortage;
$needs_save = false;
if ( $new_local !== $current_local ) {
$stock_holder->set_stock_quantity( $new_local );
$needs_save = true;
}
if ( $new_external !== $external_stock ) {
$stock_holder->update_meta_data( $this->external_stock_key, $new_external );
$needs_save = true;
}
if ( $needs_save ) {
$stock_holder->save();
}
}
}

View File

@@ -0,0 +1,141 @@
<?php
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
require_once __DIR__ . '/class-siti-stock-settings.php';
require_once __DIR__ . '/class-siti-stock-admin-notices.php';
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';
/**
* Core plugin bootstrap that wires together all sub-components.
*/
class Siti_Stock_Plugin {
const OPTION_KEY = 'siti_stock_settings';
const CRON_HOOK = 'siti_stock_plugin_sync_inventory';
const EXTERNAL_STOCK_META_KEY = '_siti_external_stock';
/**
* @var Siti_Stock_Plugin|null
*/
private static $instance = null;
/**
* @var Siti_Stock_Settings
*/
private $settings;
/**
* @var Siti_Stock_Admin_Notices
*/
private $notices;
/**
* @var Siti_Stock_Admin
*/
private $admin;
/**
* @var Siti_Stock_Inventory_Manager
*/
private $inventory_manager;
/**
* @var Siti_Stock_Sync_Controller
*/
private $sync_controller;
/**
* Get singleton instance.
*
* @return Siti_Stock_Plugin
*/
public static function instance() {
if ( ! self::$instance ) {
self::$instance = new self();
}
return self::$instance;
}
/**
* Plugin activation callback.
*/
public static function activate() {
if ( ! class_exists( 'WooCommerce' ) ) {
if ( defined( 'SITI_STOCK_PLUGIN_FILE' ) ) {
deactivate_plugins( plugin_basename( SITI_STOCK_PLUGIN_FILE ) );
}
wp_die(
__( 'WooCommerce is vereist voor de Siti Stock Plugin.', 'siti-stock-plugin' ),
__( 'Siti Stock Plugin', 'siti-stock-plugin' ),
array( 'back_link' => true )
);
}
$settings_repo = new Siti_Stock_Settings( self::OPTION_KEY );
Siti_Stock_Sync_Controller::maybe_schedule_from_settings( self::CRON_HOOK, $settings_repo->get_all() );
}
/**
* Plugin deactivation callback.
*/
public static function deactivate() {
Siti_Stock_Sync_Controller::clear_schedule( self::CRON_HOOK );
}
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->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->register_hooks();
$this->inventory_manager->register_hooks();
$this->sync_controller->register_hooks();
add_filter( 'woocommerce_data_stores', array( $this, 'override_product_data_store' ), 20 );
}
/**
* Ensure WooCommerce uses the custom data store so reservations see combined stock.
*
* @param array<string,string> $stores Registered data store map.
* @return array<string,string>
*/
public function override_product_data_store( $stores ) {
if ( isset( $stores['product'] ) && $this->ensure_product_data_store_loaded() ) {
$is_cpt_store = is_a( $stores['product'], 'WC_Product_Data_Store_CPT', true );
if ( $is_cpt_store ) {
$stores['product'] = 'Siti_Stock_Product_Data_Store';
}
}
return $stores;
}
/**
* Load the custom product data store once WooCommerce base classes exist.
*
* @return bool
*/
private function ensure_product_data_store_loaded() {
if ( class_exists( 'Siti_Stock_Product_Data_Store' ) ) {
return true;
}
if ( ! class_exists( 'WC_Product_Data_Store_CPT' ) ) {
return false;
}
require_once __DIR__ . '/class-siti-stock-product-data-store.php';
return class_exists( 'Siti_Stock_Product_Data_Store' );
}
}

View File

@@ -0,0 +1,51 @@
<?php
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
/**
* Custom product data store that exposes combined (local + external) stock in SQL queries.
*
* WooCommerce's stock reservation system queries the raw `_stock` meta directly and therefore
* ignores any runtime filters. By decorating the default CPT data store we make sure that the
* reservation queries see the same combined stock value that the storefront shows.
*/
class Siti_Stock_Product_Data_Store extends WC_Product_Data_Store_CPT {
/**
* Build a SQL snippet that adds the external stock meta value to the native `_stock`.
*
* {@inheritDoc}
*
* @param int $product_id Product ID that the stock query should represent.
* @return string
*/
public function get_query_for_stock( $product_id ) {
global $wpdb;
return $wpdb->prepare(
"
SELECT
GREATEST(
0,
CAST( COALESCE( stock_meta.meta_value, 0 ) AS SIGNED )
) +
GREATEST(
0,
CAST( COALESCE( external_meta.meta_value, 0 ) AS SIGNED )
)
FROM {$wpdb->posts} AS posts
LEFT JOIN {$wpdb->postmeta} AS stock_meta
ON stock_meta.post_id = posts.ID AND stock_meta.meta_key = %s
LEFT JOIN {$wpdb->postmeta} AS external_meta
ON external_meta.post_id = posts.ID AND external_meta.meta_key = %s
WHERE posts.ID = %d
LIMIT 1
",
'_stock',
Siti_Stock_Plugin::EXTERNAL_STOCK_META_KEY,
$product_id
);
}
}

View File

@@ -0,0 +1,128 @@
<?php
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
/**
* Settings repository with caching/sanitization helpers.
*/
class Siti_Stock_Settings {
/**
* Cached settings.
*
* @var array<string,mixed>|null
*/
private $cache = null;
/**
* @var string
*/
private $option_key;
/**
* @var string
*/
private $settings_group;
/**
* @param string $option_key Option key.
* @param string|null $settings_group Settings group (defaults to option key).
*/
public function __construct( $option_key, $settings_group = null ) {
$this->option_key = $option_key;
$this->settings_group = $settings_group ? $settings_group : $option_key;
}
/**
* Register option storage with WordPress.
*/
public function register() {
register_setting(
$this->settings_group,
$this->option_key,
array( $this, 'sanitize' )
);
}
/**
* Sanitize input before saving.
*
* @param array<string,mixed> $input Raw input.
* @return array<string,mixed>
*/
public function sanitize( $input ) {
$output = $this->get_all();
$output['api_endpoint'] = isset( $input['api_endpoint'] ) ? esc_url_raw( $input['api_endpoint'] ) : '';
$output['api_key'] = isset( $input['api_key'] ) ? sanitize_text_field( $input['api_key'] ) : '';
$output['default_status'] = isset( $input['default_status'] ) ? sanitize_key( $input['default_status'] ) : 'instock';
$output['enable_auto_sync'] = ! empty( $input['enable_auto_sync'] );
$output['sync_interval'] = isset( $input['sync_interval'] ) ? sanitize_key( $input['sync_interval'] ) : 'hourly';
$this->cache = $output;
return $output;
}
/**
* Get option key.
*
* @return string
*/
public function get_option_key() {
return $this->option_key;
}
/**
* Get settings group name.
*
* @return string
*/
public function get_settings_group() {
return $this->settings_group;
}
/**
* Retrieve all settings (cached).
*
* @return array<string,mixed>
*/
public function get_all() {
if ( null === $this->cache ) {
$this->cache = wp_parse_args(
get_option( $this->option_key, array() ),
array(
'api_endpoint' => '',
'api_key' => '',
'default_status' => 'instock',
'enable_auto_sync' => false,
'sync_interval' => 'hourly',
)
);
}
return $this->cache;
}
/**
* Retrieve single setting.
*
* @param string $key Setting key.
* @param mixed $default Default value.
* @return mixed
*/
public function get( $key, $default = '' ) {
$settings = $this->get_all();
return isset( $settings[ $key ] ) ? $settings[ $key ] : $default;
}
/**
* Drop cache so latest values get read.
*/
public function reset_cache() {
$this->cache = null;
}
}

View File

@@ -0,0 +1,234 @@
<?php
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
/**
* Coordinates sync triggers (cron, REST, manual).
*/
class Siti_Stock_Sync_Controller {
/**
* @var Siti_Stock_Settings
*/
private $settings;
/**
* @var Siti_Stock_Admin_Notices
*/
private $notices;
/**
* @var string
*/
private $cron_hook;
/**
* @param Siti_Stock_Settings $settings Settings repository.
* @param Siti_Stock_Admin_Notices $notices Notice handler.
* @param string $cron_hook Cron hook identifier.
*/
public function __construct( Siti_Stock_Settings $settings, Siti_Stock_Admin_Notices $notices, $cron_hook ) {
$this->settings = $settings;
$this->notices = $notices;
$this->cron_hook = $cron_hook;
}
/**
* Register hooks.
*/
public function register_hooks() {
add_filter( 'cron_schedules', array( $this, 'register_custom_schedules' ) );
add_action( $this->cron_hook, array( $this, 'run_scheduled_sync' ) );
add_action( 'rest_api_init', array( $this, 'register_rest_routes' ) );
add_action( 'admin_post_siti_stock_manual_sync', array( $this, 'handle_manual_sync' ) );
add_action( 'update_option_' . $this->settings->get_option_key(), array( $this, 'handle_settings_update' ), 10, 2 );
}
/**
* Register cron intervals.
*
* @param array<string,array<string,int|string>> $schedules Schedules.
* @return array
*/
public function register_custom_schedules( $schedules ) {
$label = 'Elke 15 minuten (Siti Stock)';
if ( did_action( 'init' ) ) {
$label = __( 'Elke 15 minuten (Siti Stock)', 'siti-stock-plugin' );
}
$schedules['siti_stock_quarter_hour'] = array(
'interval' => 15 * MINUTE_IN_SECONDS,
'display' => $label,
);
return $schedules;
}
/**
* Handle manual sync submissions.
*/
public function handle_manual_sync() {
if ( ! current_user_can( 'manage_options' ) ) {
wp_die( esc_html__( 'Onvoldoende rechten.', 'siti-stock-plugin' ) );
}
check_admin_referer( 'siti_stock_manual_sync' );
$result = $this->run_sync();
if ( is_wp_error( $result ) ) {
$this->notices->add_notice( $result->get_error_message(), 'error' );
} else {
$this->notices->add_notice(
sprintf(
/* translators: 1: updated count, 2: skipped count */
__( 'Sync voltooid. %1$d producten bijgewerkt, %2$d overgeslagen.', 'siti-stock-plugin' ),
(int) $result['updated'],
(int) $result['skipped']
),
'success'
);
}
wp_safe_redirect( wp_get_referer() ? wp_get_referer() : admin_url( 'admin.php?page=siti-stock-plugin' ) );
exit;
}
/**
* Handle scheduled sync.
*/
public function run_scheduled_sync() {
$this->run_sync();
}
/**
* Execute actual sync.
*
* @return array<string,mixed>|WP_Error
*/
private function run_sync() {
$settings = $this->settings->get_all();
if ( empty( $settings['api_endpoint'] ) ) {
return new WP_Error( 'siti_stock_missing_endpoint', __( 'Stel eerst een API-endpoint in.', 'siti-stock-plugin' ) );
}
$service = new Siti_Stock_Sync_Service( $settings );
return $service->sync();
}
/**
* Register REST endpoint for remote triggers.
*/
public function register_rest_routes() {
register_rest_route(
'siti-stock/v1',
'/sync',
array(
'methods' => WP_REST_Server::CREATABLE,
'callback' => array( $this, 'rest_trigger_sync' ),
'permission_callback' => function () {
return current_user_can( 'manage_options' );
},
)
);
}
/**
* REST callback to trigger sync.
*
* @param WP_REST_Request $request Request.
* @return WP_REST_Response
*/
public function rest_trigger_sync( WP_REST_Request $request ) {
$result = $this->run_sync();
if ( is_wp_error( $result ) ) {
return new WP_REST_Response(
array(
'error' => $result->get_error_code(),
'message' => $result->get_error_message(),
),
400
);
}
return new WP_REST_Response(
array(
'updated' => (int) $result['updated'],
'skipped' => (int) $result['skipped'],
'errors' => $result['errors'],
),
200
);
}
/**
* React when settings change to schedule/unschedule cron.
*
* @param array<string,mixed> $old_value Old settings.
* @param array<string,mixed> $value New value.
*/
public function handle_settings_update( $old_value, $value ) {
if ( empty( $value['enable_auto_sync'] ) ) {
self::clear_schedule( $this->cron_hook );
return;
}
$interval = isset( $value['sync_interval'] ) ? $value['sync_interval'] : 'hourly';
$this->schedule_sync( $interval );
}
/**
* Schedule cron hook.
*
* @param string $interval WP interval.
*/
private function schedule_sync( $interval ) {
self::clear_schedule( $this->cron_hook );
wp_schedule_event( time() + MINUTE_IN_SECONDS, $interval, $this->cron_hook );
}
/**
* Retrieve next scheduled timestamp.
*
* @return int|false
*/
public function get_next_scheduled_run() {
return wp_next_scheduled( $this->cron_hook );
}
/**
* Schedule cron during plugin activation if needed.
*
* @param string $cron_hook Cron hook.
* @param array<string,mixed> $settings Settings snapshot.
*/
public static function maybe_schedule_from_settings( $cron_hook, $settings ) {
if ( empty( $settings['enable_auto_sync'] ) ) {
return;
}
self::clear_schedule( $cron_hook );
$interval = isset( $settings['sync_interval'] ) ? sanitize_key( $settings['sync_interval'] ) : 'hourly';
wp_schedule_event( time() + MINUTE_IN_SECONDS, $interval, $cron_hook );
}
/**
* Clear scheduled events (used on deactivation/settings change).
*
* @param string $cron_hook Cron hook identifier.
*/
public static function clear_schedule( $cron_hook ) {
$timestamp = wp_next_scheduled( $cron_hook );
while ( $timestamp ) {
wp_unschedule_event( $timestamp, $cron_hook );
$timestamp = wp_next_scheduled( $cron_hook );
}
}
}

View File

@@ -0,0 +1,172 @@
<?php
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
/**
* Handles communication with the remote stock API and applies updates to WooCommerce products.
*/
class Siti_Stock_Sync_Service {
/**
* @var array<string,mixed>
*/
private $settings;
/**
* @param array<string,mixed> $settings
*/
public function __construct( array $settings ) {
$this->settings = wp_parse_args(
$settings,
array(
'api_endpoint' => '',
'api_key' => '',
'default_status' => 'instock',
'enable_auto_sync' => false,
)
);
}
/**
* Trigger a sync run.
*
* @return array<string,mixed>|WP_Error
*/
public function sync() {
$data = $this->fetch_remote_stock();
if ( is_wp_error( $data ) ) {
return $data;
}
return $this->apply_stock_updates( $data );
}
/**
* Fetch stock payload from the configured API.
*
* @return array<int,array<string,mixed>>|WP_Error
*/
private function fetch_remote_stock() {
$endpoint = trim( (string) $this->settings['api_endpoint'] );
if ( empty( $endpoint ) ) {
return new WP_Error( 'siti_stock_missing_endpoint', __( 'Geen API-endpoint ingesteld.', 'siti-stock-plugin' ) );
}
$args = array(
'timeout' => 15,
'headers' => array(
'Accept' => 'application/json',
),
);
if ( ! empty( $this->settings['api_key'] ) ) {
$args['headers']['Authorization'] = 'Bearer ' . trim( (string) $this->settings['api_key'] );
}
$response = wp_remote_get( $endpoint, $args );
if ( is_wp_error( $response ) ) {
return $response;
}
$code = wp_remote_retrieve_response_code( $response );
if ( $code < 200 || $code >= 300 ) {
return new WP_Error(
'siti_stock_bad_response',
sprintf(
/* translators: %d = HTTP status code */
__( 'API gaf een onverwachte status terug (%d).', 'siti-stock-plugin' ),
$code
)
);
}
$body = wp_remote_retrieve_body( $response );
$data = json_decode( $body, true );
if ( json_last_error() !== JSON_ERROR_NONE || ! is_array( $data ) ) {
return new WP_Error( 'siti_stock_bad_json', __( 'Kan de API-respons niet parseren.', 'siti-stock-plugin' ) );
}
return $data;
}
/**
* Apply stock changes to WooCommerce products.
*
* Expected payload: [{ \"sku\": \"123\", \"stock_quantity\": 12, \"external_stock\": 5, \"status\": \"instock\" }]
*
* @param array<int,array<string,mixed>> $records Array of stock records.
* @return array<string,mixed>|WP_Error
*/
private function apply_stock_updates( array $records ) {
if ( ! function_exists( 'wc_get_product_id_by_sku' ) ) {
return new WP_Error( 'siti_stock_missing_wc', __( 'WooCommerce is vereist voor de Siti Stock Plugin.', 'siti-stock-plugin' ) );
}
$summary = array(
'updated' => 0,
'skipped' => 0,
'errors' => array(),
);
$default_status = in_array( $this->settings['default_status'], array( 'instock', 'outofstock' ), true )
? $this->settings['default_status']
: 'instock';
foreach ( $records as $record ) {
$sku = isset( $record['sku'] ) ? sanitize_text_field( (string) $record['sku'] ) : '';
if ( empty( $sku ) ) {
$summary['skipped']++;
continue;
}
$product_id = wc_get_product_id_by_sku( $sku );
if ( ! $product_id ) {
$summary['skipped']++;
$summary['errors'][] = sprintf(
/* translators: %s = product SKU */
__( 'Geen product gevonden met SKU %s.', 'siti-stock-plugin' ),
$sku
);
continue;
}
$product = wc_get_product( $product_id );
if ( ! $product ) {
$summary['skipped']++;
continue;
}
$qty = isset( $record['stock_quantity'] ) ? (int) $record['stock_quantity'] : null;
$status = isset( $record['status'] ) ? sanitize_key( (string) $record['status'] ) : $default_status;
$status = in_array( $status, array( 'instock', 'outofstock', 'onbackorder' ), true ) ? $status : $default_status;
$external_stock = array_key_exists( 'external_stock', $record ) ? (int) $record['external_stock'] : null;
$external_stock = null !== $external_stock ? max( 0, $external_stock ) : null;
if ( null !== $qty ) {
$product->set_manage_stock( true );
$product->set_stock_quantity( $qty );
}
if ( null !== $external_stock ) {
$product->update_meta_data( Siti_Stock_Plugin::EXTERNAL_STOCK_META_KEY, $external_stock );
}
$product->set_stock_status( $status );
$product->save();
$summary['updated']++;
}
return $summary;
}
}

50
siti-stock-plugin.php Normal file
View File

@@ -0,0 +1,50 @@
<?php
/**
* Plugin Name: Siti Stock Plugin
* Plugin URI: https://github.com/SitiWeb/siti-stock-plugin
* Description: Synchroniseert WooCommerce voorraad met het externe Siti voorraadplatform.
* Version: 0.1.0
* Author: Siti Web
* Author URI: https://www.siti.nl
* Requires PHP: 8.1
* Requires at least: 6.4
* Text Domain: siti-stock-plugin
* Domain Path: /languages
*/
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
define( 'SITI_STOCK_PLUGIN_VERSION', '0.1.0' );
define( 'SITI_STOCK_PLUGIN_FILE', __FILE__ );
define( 'SITI_STOCK_PLUGIN_DIR', plugin_dir_path( __FILE__ ) );
require_once __DIR__ . '/includes/class-siti-stock-plugin.php';
require_once __DIR__ . '/SitiWebUpdater.php';
register_activation_hook( __FILE__, array( 'Siti_Stock_Plugin', 'activate' ) );
register_deactivation_hook( __FILE__, array( 'Siti_Stock_Plugin', 'deactivate' ) );
add_action(
'init',
function () {
load_plugin_textdomain( 'siti-stock-plugin', false, dirname( plugin_basename( __FILE__ ) ) . '/languages' );
}
);
Siti_Stock_Plugin::instance();
add_action(
'admin_init',
function () {
if ( ! class_exists( 'SitiWebUpdater' ) ) {
return;
}
$updater = new SitiWebUpdater( __FILE__ );
$updater->set_username( 'SitiWeb' );
$updater->set_repository( 'siti-stock-plugin' );
$updater->initialize();
}
);