commit d6182f589e0d8e5a1e79c2a2a818c41427869fdb Author: Roberto Guagliardo Date: Fri Dec 12 14:58:59 2025 +0000 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. diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..8890e42 --- /dev/null +++ b/.github/workflows/release.yml @@ -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 }} diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..14a050a --- /dev/null +++ b/.gitignore @@ -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/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..c9e3206 --- /dev/null +++ b/README.md @@ -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 +``` + +## 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. diff --git a/SitiWebUpdater.php b/SitiWebUpdater.php new file mode 100644 index 0000000..e070288 --- /dev/null +++ b/SitiWebUpdater.php @@ -0,0 +1,202 @@ +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; + } +} diff --git a/assets/css/admin.css b/assets/css/admin.css new file mode 100644 index 0000000..f4a0c1c --- /dev/null +++ b/assets/css/admin.css @@ -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; +} diff --git a/assets/js/admin.js b/assets/js/admin.js new file mode 100644 index 0000000..341e31a --- /dev/null +++ b/assets/js/admin.js @@ -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); diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..9331a45 --- /dev/null +++ b/docker-compose.yml @@ -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: diff --git a/docker/wordpress/Dockerfile b/docker/wordpress/Dockerfile new file mode 100644 index 0000000..6bc4b01 --- /dev/null +++ b/docker/wordpress/Dockerfile @@ -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 diff --git a/includes/class-siti-stock-admin-notices.php b/includes/class-siti-stock-admin-notices.php new file mode 100644 index 0000000..3e06e46 --- /dev/null +++ b/includes/class-siti-stock-admin-notices.php @@ -0,0 +1,58 @@ + $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( + '

%2$s

', + esc_attr( $type ), + esc_html( $notice['message'] ) + ); + } +} diff --git a/includes/class-siti-stock-admin.php b/includes/class-siti-stock-admin.php new file mode 100644 index 0000000..7062afc --- /dev/null +++ b/includes/class-siti-stock-admin.php @@ -0,0 +1,349 @@ +|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 '

' . esc_html__( 'Configureer het endpoint dat de actuele voorraad teruggeeft.', 'siti-stock-plugin' ) . '

'; + }, + '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 '

' . esc_html__( 'Stel in hoe en wanneer de voorraad automatisch bijgewerkt moet worden.', 'siti-stock-plugin' ) . '

'; + }, + '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(); + ?> +
+

+

+ +
+ settings->get_settings_group() ); + do_settings_sections( 'siti-stock-plugin' ); + submit_button(); + ?> +
+ +
+ +

+

+
+ + + +
+ +
+

+

+ +

+
+
+ $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 ); + ?> + + +

+ + $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(); + ?> + + $args Arguments. + */ + public function render_checkbox_field( $args ) { + $key = $args['key']; + $value = (bool) $this->get_settings_value( $key ); + ?> + + +

+ +

' . esc_html__( 'WooCommerce is vereist voor de Siti Stock Plugin.', 'siti-stock-plugin' ) . '

'; + } + + /** + * Get cached settings snapshot for admin rendering. + * + * @return array + */ + 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 ] : ''; + } +} diff --git a/includes/class-siti-stock-inventory-manager.php b/includes/class-siti-stock-inventory-manager.php new file mode 100644 index 0000000..ab624c4 --- /dev/null +++ b/includes/class-siti-stock-inventory-manager.php @@ -0,0 +1,209 @@ +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(); + } + } +} diff --git a/includes/class-siti-stock-plugin.php b/includes/class-siti-stock-plugin.php new file mode 100644 index 0000000..2b28241 --- /dev/null +++ b/includes/class-siti-stock-plugin.php @@ -0,0 +1,141 @@ + 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 $stores Registered data store map. + * @return array + */ + 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' ); + } + } diff --git a/includes/class-siti-stock-product-data-store.php b/includes/class-siti-stock-product-data-store.php new file mode 100644 index 0000000..47a107d --- /dev/null +++ b/includes/class-siti-stock-product-data-store.php @@ -0,0 +1,51 @@ +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 + ); + } +} diff --git a/includes/class-siti-stock-settings.php b/includes/class-siti-stock-settings.php new file mode 100644 index 0000000..4480c05 --- /dev/null +++ b/includes/class-siti-stock-settings.php @@ -0,0 +1,128 @@ +|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 $input Raw input. + * @return array + */ + 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 + */ + 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; + } +} diff --git a/includes/class-siti-stock-sync-controller.php b/includes/class-siti-stock-sync-controller.php new file mode 100644 index 0000000..dc06ec0 --- /dev/null +++ b/includes/class-siti-stock-sync-controller.php @@ -0,0 +1,234 @@ +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> $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|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 $old_value Old settings. + * @param array $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 $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 ); + } + } +} diff --git a/includes/class-siti-stock-sync-service.php b/includes/class-siti-stock-sync-service.php new file mode 100644 index 0000000..d01994c --- /dev/null +++ b/includes/class-siti-stock-sync-service.php @@ -0,0 +1,172 @@ + + */ + private $settings; + + /** + * @param array $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|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>|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> $records Array of stock records. + * @return array|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; + } +} diff --git a/siti-stock-plugin.php b/siti-stock-plugin.php new file mode 100644 index 0000000..0795f4e --- /dev/null +++ b/siti-stock-plugin.php @@ -0,0 +1,50 @@ +set_username( 'SitiWeb' ); + $updater->set_repository( 'siti-stock-plugin' ); + $updater->initialize(); + } +);