Plugin Directory

Changeset 3790681


Ignore:
Timestamp:
11/08/2026 08:24:24 AM (5 hours ago)
Author:
aribhour
Message:

ew: "Document Timing" and "Document Delay (seconds)"

Location:
linet-erp-woocommerce-integration/trunk
Files:
4 edited

Legend:

Unmodified
Added
Removed
  • linet-erp-woocommerce-integration/trunk/classes/class-wc-li-invoice-manager.php

    r3773444 r3790681  
    88{
    99
     10  // Document Timing setting values
     11  const TIMING_IMMEDIATE = 'immediate';
     12  const TIMING_DELAYED = 'delayed';
     13
     14  const DEFAULT_DELAY = 60;
     15
     16  // Action Scheduler hook and group for the delayed documents
     17  const QUEUE_HOOK = 'linet_auto_send_invoice';
     18  const QUEUE_GROUP = 'linet';
     19
     20  // A queued document still pending this long after its due time is reported as stuck
     21  const STUCK_GRACE = 600;
     22
     23  // How far back failed queued documents are reported
     24  const FAILED_LOOKBACK = WEEK_IN_SECONDS;
     25
     26  // Order meta "_linet_auto_doc_{status}" holds the doc id the queue created for that status
     27  const AUTO_DOC_META = '_linet_auto_doc_';
     28
     29  const NOTICE_TRANSIENT = 'wc_linet_queue_health';
     30
    1031  /**
    1132   * @var WC_LI_Settings
    … …  
    4970        //var_dump($do_doc);
    5071        //var_dump("woocommerce_order_status_$skey");
    51         add_action("woocommerce_order_status_$skey", array($this, 'auto_send_invoice'));
    52       }
     72        add_action("woocommerce_order_status_$skey", array($this, 'on_order_status'), 10, 2);
     73      }
     74    }
     75
     76    // Always registered, so documents queued before the timing was switched back
     77    // to immediate still get created
     78    add_action(self::QUEUE_HOOK, array($this, 'run_queued_invoice'), 10, 3);
     79
     80    if (is_admin()) {
     81      add_action('admin_notices', array($this, 'queue_health_notice'));
    5382    }
    5483
    … …  
    285314
    286315  /**
     316   * The Document Timing setting: immediate (the original behaviour) or delayed.
     317   *
     318   * @return string
     319   */
     320  public static function doc_timing()
     321  {
     322    $timing = get_option('wc_linet_doc_timing', self::TIMING_IMMEDIATE);
     323    return self::TIMING_DELAYED === $timing ? self::TIMING_DELAYED : self::TIMING_IMMEDIATE;
     324  }
     325
     326  /**
     327   * The delay in seconds for delayed documents.
     328   *
     329   * @return int
     330   */
     331  public static function doc_delay()
     332  {
     333    $delay = get_option('wc_linet_doc_delay', '');
     334    if ('' === $delay || !is_numeric($delay)) {
     335      return self::DEFAULT_DELAY;
     336    }
     337    return max(0, (int) $delay);
     338  }
     339
     340  /**
     341   * True when delayed documents can actually be queued.
     342   *
     343   * @return bool
     344   */
     345  public static function queue_available()
     346  {
     347    return function_exists('as_schedule_single_action') && function_exists('as_has_scheduled_action');
     348  }
     349
     350  /**
     351   * Runs on every mapped woocommerce_order_status_{status} hook.
     352   *
     353   * Immediate timing behaves exactly like before. Delayed timing queues the
     354   * document, so other plugins that set the status first and save their meta
     355   * after it are done by the time the document is built.
     356   *
     357   * @param int           $order_id
     358   * @param WC_Order|null $order
     359   *
     360   * @return bool
     361   */
     362  public function on_order_status($order_id, $order = null)
     363  {
     364    if (self::TIMING_DELAYED !== self::doc_timing() || !self::queue_available()) {
     365      return $this->auto_send_invoice($order_id);
     366    }
     367
     368    if (!$order instanceof WC_Order) {
     369      $order = wc_get_order($order_id);
     370    }
     371    if (!$order) {
     372      return false;
     373    }
     374
     375    // The status is captured now: by the time the job runs the order may have moved on
     376    // and the document type must be the one mapped to the status that triggered it.
     377    // prev_doc lets the job notice a document created in the meantime (manually for example).
     378    $args = array(
     379      'order_id' => (int) $order->get_id(),
     380      'status' => $order->get_status(),
     381      'prev_doc' => (string) $order->get_meta('_linet_doc_id', true),
     382    );
     383
     384    if (as_has_scheduled_action(self::QUEUE_HOOK, $args, self::QUEUE_GROUP)) {
     385      return false;
     386    }
     387
     388    $run_at = time() + self::doc_delay();
     389    as_schedule_single_action($run_at, self::QUEUE_HOOK, $args, self::QUEUE_GROUP);
     390
     391    $order->add_order_note(
     392      sprintf(
     393        /* translators: 1: order status, 2: date and time */
     394        __('LINET: Doc. for status %1$s scheduled for %2$s', 'linet-erp-woocommerce-integration'),
     395        $args['status'],
     396        wp_date(get_option('date_format') . ' ' . get_option('time_format'), $run_at)
     397      )
     398    );
     399
     400    return true;
     401  }
     402
     403  /**
     404   * Action Scheduler callback for a delayed document.
     405   *
     406   * @param int    $order_id
     407   * @param string $status   status that triggered the document
     408   * @param string $prev_doc _linet_doc_id of the order when it was queued
     409   *
     410   * @throws Exception when Linet answered with an error, so the action shows as failed
     411   */
     412  public function run_queued_invoice($order_id, $status, $prev_doc = '')
     413  {
     414    $order = wc_get_order($order_id);
     415    if (!$order) {
     416      return;
     417    }
     418
     419    $status = sanitize_key($status);
     420    $prev_doc = (string) $prev_doc;
     421
     422    // Guard: this status already got its document from the queue
     423    if ('' !== (string) $order->get_meta(self::AUTO_DOC_META . $status, true)) {
     424      $order->add_order_note(sprintf(
     425        __('LINET: Skipped scheduled doc. for status %s, it was already created', 'linet-erp-woocommerce-integration'),
     426        $status
     427      ));
     428      return;
     429    }
     430
     431    // Guard: a document was created after this one was queued and not by the queue
     432    // (the manual order action for example)
     433    $current_doc = (string) $order->get_meta('_linet_doc_id', true);
     434    if ('' !== $current_doc && $current_doc !== $prev_doc && !$this->is_queue_doc($order, $current_doc)) {
     435      $order->add_order_note(sprintf(
     436        __('LINET: Skipped scheduled doc. for status %1$s, doc. %2$s was created after it was scheduled', 'linet-erp-woocommerce-integration'),
     437        $status,
     438        $current_doc
     439      ));
     440      return;
     441    }
     442
     443    $doctype = (int) get_option("wc_linet_sync_orders_wc-$status");
     444    if (0 === $doctype) {
     445      $order->add_order_note(sprintf(
     446        __('LINET: Skipped scheduled doc. for status %s, the status is no longer mapped to a doc. type', 'linet-erp-woocommerce-integration'),
     447        $status
     448      ));
     449      return;
     450    }
     451
     452    $result = $this->auto_send_invoice($order_id, $doctype);
     453
     454    // Reload: send_invoice saved its meta on its own copy of the order
     455    $order = wc_get_order($order_id);
     456    $new_doc = (string) $order->get_meta('_linet_doc_id', true);
     457
     458    if ('' !== $new_doc && $new_doc !== $current_doc) {
     459      $order->update_meta_data(self::AUTO_DOC_META . $status, $new_doc);
     460      $order->save();
     461      delete_transient(self::NOTICE_TRANSIENT);
     462      return;
     463    }
     464
     465    // send_invoice returns true after an error answer from Linet (it already mailed and
     466    // noted it). Throwing marks the action failed so it shows up in the queue warning.
     467    if (true === $result) {
     468      throw new Exception(sprintf('Linet did not create a doc. for order %d (status %s), see the order notes', (int) $order_id, $status));
     469    }
     470  }
     471
     472  /**
     473   * True when the doc id was created by the queue for one of the order statuses.
     474   *
     475   * @param WC_Order $order
     476   * @param string   $doc_id
     477   *
     478   * @return bool
     479   */
     480  private function is_queue_doc($order, $doc_id)
     481  {
     482    foreach (array_keys(wc_get_order_statuses()) as $key) {
     483      $skey = str_replace('wc-', '', $key);
     484      if ($doc_id === (string) $order->get_meta(self::AUTO_DOC_META . $skey, true)) {
     485        return true;
     486      }
     487    }
     488    return false;
     489  }
     490
     491  /**
     492   * Counts of stuck (pending well past their time) and recently failed queued documents.
     493   * Cached for a few minutes, this runs on admin pages.
     494   *
     495   * @return array{stuck:int,failed:int}
     496   */
     497  private static function queue_health()
     498  {
     499    $health = get_transient(self::NOTICE_TRANSIENT);
     500    if (is_array($health)) {
     501      return $health;
     502    }
     503
     504    $query = array(
     505      'hook' => self::QUEUE_HOOK,
     506      'group' => self::QUEUE_GROUP,
     507      'per_page' => 100,
     508    );
     509
     510    $health = array(
     511      'stuck' => count(as_get_scheduled_actions($query + array(
     512        'status' => 'pending',
     513        'date' => time() - self::STUCK_GRACE,
     514        'date_compare' => '<=',
     515      ), 'ids')),
     516      'failed' => count(as_get_scheduled_actions($query + array(
     517        'status' => 'failed',
     518        'modified' => time() - self::FAILED_LOOKBACK,
     519        'modified_compare' => '>=',
     520      ), 'ids')),
     521    );
     522
     523    set_transient(self::NOTICE_TRANSIENT, $health, 5 * MINUTE_IN_SECONDS);
     524
     525    return $health;
     526  }
     527
     528  /**
     529   * Admin warning when delayed documents are not getting created.
     530   */
     531  public function queue_health_notice()
     532  {
     533    if (!current_user_can('manage_woocommerce')) {
     534      return;
     535    }
     536
     537    $delayed = self::TIMING_DELAYED === self::doc_timing();
     538
     539    if (!function_exists('as_get_scheduled_actions')) {
     540      if ($delayed) {
     541        echo '<div class="notice notice-warning"><p>' . esc_html__('Linet: Document Timing is set to Delayed but the WooCommerce background queue (Action Scheduler) is not available, documents are created immediately.', 'linet-erp-woocommerce-integration') . '</p></div>';
     542      }
     543      return;
     544    }
     545
     546    $health = self::queue_health();
     547    if (0 === $health['stuck'] && 0 === $health['failed']) {
     548      return;
     549    }
     550
     551    $messages = array();
     552
     553    if ($health['stuck'] > 0) {
     554      $url = admin_url('admin.php?page=wc-status&tab=action-scheduler&status=pending&s=' . self::QUEUE_HOOK);
     555      $messages[] = sprintf(
     556        /* translators: 1: count, 2: minutes */
     557        esc_html__('%1$d Linet documents are waiting more than %2$d minutes past their scheduled time. The WooCommerce background queue is not running, check that WP-Cron (or a server cron) is working.', 'linet-erp-woocommerce-integration'),
     558        $health['stuck'],
     559        self::STUCK_GRACE / MINUTE_IN_SECONDS
     560      ) . ' <a href="' . esc_url($url) . '">' . esc_html__('View pending documents', 'linet-erp-woocommerce-integration') . '</a>';
     561    }
     562
     563    if ($health['failed'] > 0) {
     564      $url = admin_url('admin.php?page=wc-status&tab=action-scheduler&status=failed&s=' . self::QUEUE_HOOK);
     565      $messages[] = sprintf(
     566        /* translators: %d: count */
     567        esc_html__('%d scheduled Linet documents failed in the last 7 days. See the order notes, then use the "Send Doc. to Linet" order action if needed.', 'linet-erp-woocommerce-integration'),
     568        $health['failed']
     569      ) . ' <a href="' . esc_url($url) . '">' . esc_html__('View failed documents', 'linet-erp-woocommerce-integration') . '</a>';
     570    }
     571
     572    echo '<div class="notice notice-warning"><p>' . implode('</p><p>', $messages) . '</p></div>';
     573  }
     574
     575  /**
    287576   * Get invoice by order
    288577   *
  • linet-erp-woocommerce-integration/trunk/classes/class-wc-li-settings.php

    r3773444 r3790681  
    214214        ),
    215215        'description' => __('Send orders with a total of 0 (fully covered by a coupon or gift card) to Linet', 'linet-erp-woocommerce-integration'),
     216      ),
     217
     218      'doc_timing' => array(
     219        'title' => __('Document Timing', 'linet-erp-woocommerce-integration'),
     220        'default' => WC_LI_Invoice_Manager::TIMING_IMMEDIATE,
     221        'type' => 'select',
     222        'options' => array(
     223          WC_LI_Invoice_Manager::TIMING_IMMEDIATE => __('Immediately on status change', 'linet-erp-woocommerce-integration'),
     224          WC_LI_Invoice_Manager::TIMING_DELAYED => __('Delayed (background queue)', 'linet-erp-woocommerce-integration'),
     225        ),
     226        'description' => __('Immediately creates the document the moment the order status changes. Delayed queues it in the WooCommerce background queue (Action Scheduler) and creates it after the delay below, so other plugins (payment gateways for example) can finish saving their order data first', 'linet-erp-woocommerce-integration'),
     227      ),
     228
     229      'doc_delay' => array(
     230        'title' => __('Document Delay (seconds)', 'linet-erp-woocommerce-integration'),
     231        'default' => (string) WC_LI_Invoice_Manager::DEFAULT_DELAY,
     232        'type' => 'text',
     233        'description' => __('Used only when Document Timing is Delayed. Seconds to wait after the status change before creating the document. Default 60', 'linet-erp-woocommerce-integration'),
    216234      ),
    217235
  • linet-erp-woocommerce-integration/trunk/readme.txt

    r3773444 r3790681  
    44Requires at least: 6.0
    55Tested up to: 7.0
    6 Stable tag: 3.6.45
     6Stable tag: 3.6.46
    77License: GPLv2 or later
    88Requires PHP: 7.4
    … …  
    4848
    4949== Changelog ==
     50
     51= 2026.11.08 - version 3.6.46 =
     52
     53* New: "Document Timing" and "Document Delay (seconds)" settings (Order Options). Immediate (the default) creates the Linet doc. on the status change as before. Delayed queues it in the WooCommerce background queue (Action Scheduler) and creates it after the delay (60 seconds by default), so plugins that change the status first and save their order meta after it (payment gateways for example) are done before the doc. is built
     54* New: delayed docs are not created twice for the same status, are skipped when a doc. was created in the meantime (the manual order action for example), and use the doc. type of the status that triggered them
     55* New: admin warning when delayed docs are stuck in the queue (WP-Cron not running) or failed in the last 7 days
    5056
    5157= 2026.10.29 - version 3.6.45 =
  • linet-erp-woocommerce-integration/trunk/woocommerce-linet.php

    r3773444 r3790681  
    88 * License: GPL v2 or later
    99 * License URI: https://www.gnu.org/licenses/gpl-2.0.html
    10  * Version: 3.6.45
     10 * Version: 3.6.46
    1111 * Tested up to: 7.0
    1212 * Requires at least: 6.0
    … …  
    4141class WC_Linet
    4242{
    43   const VERSION = '3.6.45';
     43  const VERSION = '3.6.46';
    4444
    4545
Note: See TracChangeset for help on using the changeset viewer.