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:
102
.github/workflows/release.yml
vendored
Normal file
102
.github/workflows/release.yml
vendored
Normal 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
20
.gitignore
vendored
Normal 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
63
README.md
Normal 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
202
SitiWebUpdater.php
Normal 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
19
assets/css/admin.css
Normal 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
15
assets/js/admin.js
Normal 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
49
docker-compose.yml
Normal 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:
|
||||||
13
docker/wordpress/Dockerfile
Normal file
13
docker/wordpress/Dockerfile
Normal 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
|
||||||
58
includes/class-siti-stock-admin-notices.php
Normal file
58
includes/class-siti-stock-admin-notices.php
Normal 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'] )
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
349
includes/class-siti-stock-admin.php
Normal file
349
includes/class-siti-stock-admin.php
Normal 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 ] : '';
|
||||||
|
}
|
||||||
|
}
|
||||||
209
includes/class-siti-stock-inventory-manager.php
Normal file
209
includes/class-siti-stock-inventory-manager.php
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
141
includes/class-siti-stock-plugin.php
Normal file
141
includes/class-siti-stock-plugin.php
Normal 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' );
|
||||||
|
}
|
||||||
|
}
|
||||||
51
includes/class-siti-stock-product-data-store.php
Normal file
51
includes/class-siti-stock-product-data-store.php
Normal 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
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
128
includes/class-siti-stock-settings.php
Normal file
128
includes/class-siti-stock-settings.php
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
234
includes/class-siti-stock-sync-controller.php
Normal file
234
includes/class-siti-stock-sync-controller.php
Normal 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 );
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
172
includes/class-siti-stock-sync-service.php
Normal file
172
includes/class-siti-stock-sync-service.php
Normal 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
50
siti-stock-plugin.php
Normal 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();
|
||||||
|
}
|
||||||
|
);
|
||||||
Reference in New Issue
Block a user