key => item. * * @var array>> */ private array $buffer = array(); private bool $flush_hooked = false; /** * Constructor. * * @param NotificationService $service Delivery. * @param NotificationSettings $settings Settings. * @param ReorderService $reorder Reorder list (digest). * @param PORepository $pos Purchase orders. * @param ProductResolver $resolver Product names and SKUs. * @param Scheduler $scheduler Background jobs. */ public function __construct( NotificationService $service, NotificationSettings $settings, ReorderService $reorder, PORepository $pos, ProductResolver $resolver, Scheduler $scheduler ) { $this->service = $service; $this->settings = $settings; $this->reorder = $reorder; $this->pos = $pos; $this->resolver = $resolver; $this->scheduler = $scheduler; } /** * Register listeners and the hourly job. */ public function register_hooks(): void { add_action( 'profit_engine_forecast_severity_changed', array( $this, 'on_severity_changed' ), 10, 4 ); add_action( 'woocommerce_no_stock', array( $this, 'on_no_stock' ) ); add_action( 'woocommerce_low_stock', array( $this, 'on_low_stock' ) ); add_action( 'profit_engine_po_received', array( $this, 'on_po_received' ), 10, 2 ); add_action( self::HOURLY_HOOK, array( $this, 'run_hourly' ) ); // Action Scheduler's store is only ready from this hook (see ForecastScheduler). add_action( 'action_scheduler_init', array( $this, 'ensure_schedule' ) ); } /** * Keep the hourly job scheduled. */ public function ensure_schedule(): void { $this->scheduler->schedule_recurring( time() + 300, HOUR_IN_SECONDS, self::HOURLY_HOOK ); } /** * A product's reorder urgency changed. * * Only escalations alert: becoming critical, or becoming warning from a * calmer state. Recovering (critical → warning, anything → ok) is not news. * * @param int $product_id Product ID. * @param string|null $old_severity Previous severity. * @param string $severity New severity. * @param array $forecast Key figures. */ public function on_severity_changed( $product_id, $old_severity, $severity, $forecast ): void { if ( 'critical' === $severity ) { $event = 'critical'; } elseif ( 'warning' === $severity && 'critical' !== $old_severity ) { $event = 'warning'; } else { return; } $forecast = is_array( $forecast ) ? $forecast : array(); $this->queue( $event, (int) $product_id, $this->product_item( (int) $product_id, $this->forecast_detail( $forecast ) ) ); } /** * WooCommerce: a product ran out of stock. * * @param \WC_Product|false|null $product Product. */ public function on_no_stock( $product ): void { if ( $product instanceof \WC_Product ) { $this->queue( 'out_of_stock', $product->get_id(), $this->product_item( $product->get_id(), __( 'Out of stock', 'profit-engine' ) ) ); } } /** * WooCommerce: a product reached its low-stock threshold. * * @param \WC_Product|false|null $product Product. */ public function on_low_stock( $product ): void { if ( $product instanceof \WC_Product ) { /* translators: %s: stock quantity. */ $detail = sprintf( __( 'Low stock: %s left', 'profit-engine' ), wc_stock_amount( (float) $product->get_stock_quantity() ) ); $this->queue( 'low_stock', $product->get_id(), $this->product_item( $product->get_id(), $detail ) ); } } /** * Stock was received against a purchase order. * * @param int $po_id PO ID. * @param string $status New status. */ public function on_po_received( $po_id, $status ): void { $po = $this->pos->find( (int) $po_id ); if ( ! $po ) { return; } $detail = 'received' === $status ? __( 'Fully received', 'profit-engine' ) : __( 'Partly received', 'profit-engine' ); // Every receipt is news, so no cool-down: key by PO and time. $this->queue( 'po_received', (int) $po_id, $this->po_item( $po, $detail ), false ); } /** * Hourly job: the daily digest and the overdue-PO check. */ public function run_hourly(): void { $settings = $this->settings->get(); $today = wp_date( 'Y-m-d' ); $state = get_option( self::STATE_OPTION, array() ); $state = is_array( $state ) ? $state : array(); if ( ( $state['overdue_checked'] ?? '' ) !== $today ) { $state['overdue_checked'] = $today; update_option( self::STATE_OPTION, $state, false ); $this->check_overdue( $today ); } if ( ! empty( $settings['digest']['enabled'] ) && (int) wp_date( 'G' ) >= (int) $settings['digest']['hour'] && ( $state['digest_sent'] ?? '' ) !== $today ) { $state['digest_sent'] = $today; update_option( self::STATE_OPTION, $state, false ); $this->send_digest(); } $this->flush(); } /** * Build and send the daily digest. Public so the page can send a preview. * * @return int Channels queued (0 when nothing needs reordering or no channel is on). */ public function send_digest(): int { $list = $this->reorder->list( array() ); $items = $list['items']; if ( ! $items ) { return 0; } $counts = $list['counts']; $total = array_sum( array_map( static fn( $i ) => (float) $i['estimated_cost'], $items ) ); $rows = array(); foreach ( array_slice( $items, 0, self::MAX_ITEMS ) as $item ) { $rows[] = array( 'title' => (string) $item['label'], 'subtitle' => (string) $item['sku'], 'detail' => $this->severity_label( (string) $item['severity'] ) . ' · ' . $this->forecast_detail( $item ) . ( '' !== $item['supplier_name'] ? ' · ' . $item['supplier_name'] : '' ), ); } return $this->service->notify( array( 'event' => 'digest', /* translators: %d: number of products to reorder. */ 'title' => sprintf( _n( 'Daily reorder digest: %d product to reorder', 'Daily reorder digest: %d products to reorder', count( $items ), 'profit-engine' ), count( $items ) ), 'intro' => sprintf( /* translators: 1: critical count, 2: warning count, 3: recommended count, 4: estimated spend. */ __( '%1$d critical, %2$d running low, %3$d recommended. Estimated spend: %4$s.', 'profit-engine' ), (int) ( $counts['critical'] ?? 0 ), (int) ( $counts['warning'] ?? 0 ), (int) ( $counts['recommended'] ?? 0 ), $this->money( $total ) ), 'items' => $rows, 'more' => max( 0, count( $items ) - self::MAX_ITEMS ), ) ); } /** * Send everything collected during this request, one message per alert type. */ public function flush(): void { $buffer = $this->buffer; $this->buffer = array(); foreach ( $buffer as $event => $items ) { $items = array_values( $items ); $count = count( $items ); $this->service->notify( array( 'event' => $event, 'title' => $this->title( $event, $count, $items[0]['title'] ), 'intro' => $this->intro( $event ), 'items' => array_slice( $items, 0, self::MAX_ITEMS ), 'more' => max( 0, $count - self::MAX_ITEMS ), 'url' => in_array( $event, array( 'po_overdue', 'po_received' ), true ) ? admin_url( 'admin.php?page=profit-engine-purchase-orders' ) : admin_url( 'admin.php?page=profit-engine-reorder' ), ) ); } } /** * Raise one alert for each overdue PO not already reported. * * @param string $today Y-m-d. */ private function check_overdue( string $today ): void { foreach ( $this->pos->overdue( $today ) as $po ) { $days = (int) floor( ( strtotime( $today ) - strtotime( (string) $po['expected_date'] ) ) / DAY_IN_SECONDS ); /* translators: 1: expected date, 2: days late. */ $detail = sprintf( _n( 'Expected %1$s, %2$d day late', 'Expected %1$s, %2$d days late', $days, 'profit-engine' ), (string) $po['expected_date'], $days ); $this->queue( 'po_overdue', (int) $po['id'], $this->po_item( $po, $detail ) ); } } /** * Add an alert to the buffer, unless its type is off, no channel is on, or * it is still in cool-down. * * @param string $event Alert type. * @param int $id Product or PO ID. * @param array $item Row. * @param bool $cooldown Whether to apply the cool-down. */ private function queue( string $event, int $id, array $item, bool $cooldown = true ): void { if ( ! $this->settings->event_enabled( $event ) || ! $this->settings->active_channels() ) { return; } if ( $cooldown ) { $key = 'profit_engine_nc_' . $event . '_' . $id; if ( false !== get_transient( $key ) ) { return; } set_transient( $key, 1, (int) $this->settings->get()['cooldown_hours'] * HOUR_IN_SECONDS ); } $this->buffer[ $event ][ (string) $id ] = $item; if ( ! $this->flush_hooked ) { $this->flush_hooked = true; add_action( 'shutdown', array( $this, 'flush' ), 5 ); } } /** * A message row for a product. * * @param int $product_id Product ID. * @param string $detail Detail line. * @return array */ private function product_item( int $product_id, string $detail ): array { return array( 'title' => $this->resolver->unit_label( $product_id ), 'subtitle' => $this->resolver->sku( $product_id ), 'detail' => $detail, ); } /** * A message row for a purchase order. * * @param array $po PO row. * @param string $detail Detail line. * @return array */ private function po_item( array $po, string $detail ): array { return array( 'title' => (string) $po['po_number'], 'subtitle' => (string) ( $po['supplier_name'] ?? '' ), 'detail' => $detail, ); } /** * "Stock 3 · 1.1 sold/day · about 3 days left · suggested order 20". * * @param array $f Forecast figures. */ private function forecast_detail( array $f ): string { $parts = array( /* translators: %s: stock quantity. */ sprintf( __( 'Stock %s', 'profit-engine' ), wc_stock_amount( (float) ( $f['current_stock'] ?? 0 ) ) ), /* translators: %s: average units sold per day. */ sprintf( __( '%s sold/day', 'profit-engine' ), round( (float) ( $f['avg_daily_sales'] ?? 0 ), 2 ) ), ); $days = (float) ( $f['days_remaining'] ?? 0 ); if ( $days < 999999 ) { /* translators: %d: days of stock left. */ $parts[] = sprintf( _n( 'about %d day left', 'about %d days left', (int) round( $days ), 'profit-engine' ), (int) round( $days ) ); } if ( ! empty( $f['suggested_qty'] ) ) { /* translators: %d: suggested order quantity. */ $parts[] = sprintf( __( 'suggested order %d', 'profit-engine' ), (int) $f['suggested_qty'] ); } return implode( ' · ', $parts ); } /** * Headline for a grouped alert. * * @param string $event Alert type. * @param int $count Rows. * @param string $first First row's title (used when there is only one). */ private function title( string $event, int $count, string $first ): string { if ( 1 === $count ) { $single = array( /* translators: %s: product name. */ 'critical' => __( 'Reorder now: %s', 'profit-engine' ), /* translators: %s: product name. */ 'warning' => __( 'Running low: %s', 'profit-engine' ), /* translators: %s: product name. */ 'out_of_stock' => __( 'Out of stock: %s', 'profit-engine' ), /* translators: %s: product name. */ 'low_stock' => __( 'Low stock: %s', 'profit-engine' ), /* translators: %s: purchase order number. */ 'po_overdue' => __( 'Purchase order overdue: %s', 'profit-engine' ), /* translators: %s: purchase order number. */ 'po_received' => __( 'Stock received: %s', 'profit-engine' ), ); return isset( $single[ $event ] ) ? sprintf( $single[ $event ], $first ) : $first; } $many = array( /* translators: %d: number of products. */ 'critical' => __( '%d products need reordering now', 'profit-engine' ), /* translators: %d: number of products. */ 'warning' => __( '%d products are running low', 'profit-engine' ), /* translators: %d: number of products. */ 'out_of_stock' => __( '%d products are out of stock', 'profit-engine' ), /* translators: %d: number of products. */ 'low_stock' => __( '%d products are low on stock', 'profit-engine' ), /* translators: %d: number of purchase orders. */ 'po_overdue' => __( '%d purchase orders are overdue', 'profit-engine' ), /* translators: %d: number of purchase orders. */ 'po_received' => __( 'Stock received on %d purchase orders', 'profit-engine' ), ); return isset( $many[ $event ] ) ? sprintf( $many[ $event ], $count ) : $first; } /** * Explanation under the headline. * * @param string $event Alert type. */ private function intro( string $event ): string { switch ( $event ) { case 'critical': return __( 'These will run out before a new order could arrive.', 'profit-engine' ); case 'warning': return __( 'These are at or below their reorder point. Plan a purchase soon.', 'profit-engine' ); case 'po_overdue': return __( 'These purchase orders are past their expected date and not fully received.', 'profit-engine' ); } return ''; } /** * Severity label. * * @param string $severity Severity. */ private function severity_label( string $severity ): string { $labels = array( 'critical' => __( 'Critical', 'profit-engine' ), 'warning' => __( 'Warning', 'profit-engine' ), 'recommended' => __( 'Recommended', 'profit-engine' ), ); return $labels[ $severity ] ?? $severity; } /** * Plain-text money in the store currency. * * @param float $amount Amount. */ private function money( float $amount ): string { return html_entity_decode( wp_strip_all_tags( wc_price( $amount ) ), ENT_QUOTES, 'UTF-8' ); } }