events = $events ?? new EventQuery(); } /** * One line for an event: time, change, why. * * Example: "28/10/2026 03:12 · Stock: 5 → 0. Written straight to the * product's data by softone-bridge, bypassing WooCommerce. 14 minutes * after order #4812, which had changed the stock from 6 to 5." * * @param array $event Event row (context decoded). * @param bool $time Whether to start with the time. */ public function event( array $event, bool $time = true ): string { $parts = array(); if ( $time ) { $parts[] = Labels::time( (string) $event['created_at'] ) . ' ·'; } $parts[] = $this->change( $event ) . '.'; $parts[] = $this->why( $event ); $after = $this->after_order( $event ); if ( '' !== $after ) { $parts[] = $after; } return implode( ' ', $parts ); } /** * The change itself: "Stock: 9 → 8". * * @param array $event Event row. */ public function change( array $event ): string { $field = (string) $event['field']; $label = Labels::field( $field ); $new = Labels::value( $field, $event['new_value'] ); if ( ! empty( $event['context']['initial'] ) ) { /* translators: 1: field name, 2: value. */ return sprintf( __( '%1$s: %2$s (when the product was created)', 'stock-alibi' ), $label, $new ); } /* translators: 1: field name, 2: old value, 3: new value. */ return sprintf( __( '%1$s: %2$s → %3$s', 'stock-alibi' ), $label, Labels::value( $field, $event['old_value'] ), $new ); } /** * Why it changed: one or two sentences about the source. * * @param array $event Event row. */ public function why( array $event ): string { $source = (string) $event['source']; $ref = (string) $event['source_ref']; $context = is_array( $event['context'] ?? null ) ? $event['context'] : array(); $user = Labels::user( isset( $event['user_id'] ) ? (int) $event['user_id'] : null ); switch ( $source ) { case Context::SOURCE_ORDER: /* translators: %s: order number. */ $text = sprintf( __( 'Order #%s was placed or paid.', 'stock-alibi' ), $ref ); break; case Context::SOURCE_ORDER_RESTORE: $status = isset( $context['order_status'] ) && function_exists( 'wc_get_order_status_name' ) ? wc_get_order_status_name( (string) $context['order_status'] ) : ''; $text = '' !== $status /* translators: 1: order number, 2: order status, e.g. Cancelled. */ ? sprintf( __( 'Order #%1$s changed to %2$s and its items went back to stock.', 'stock-alibi' ), $ref, $status ) /* translators: %s: order number. */ : sprintf( __( 'The items of order #%s went back to stock.', 'stock-alibi' ), $ref ); break; case Context::SOURCE_REFUND_RESTOCK: $text = ! empty( $context['refund_id'] ) /* translators: 1: refund number, 2: order number. */ ? sprintf( __( 'Refund #%1$s of order #%2$s returned the items to stock.', 'stock-alibi' ), (int) $context['refund_id'], $ref ) /* translators: %s: order number. */ : sprintf( __( 'A refund of order #%s returned the items to stock.', 'stock-alibi' ), $ref ); break; case Context::SOURCE_ORDER_EDIT: $text = '' !== $user /* translators: 1: user name, 2: order number. */ ? sprintf( __( '%1$s edited the items of order #%2$s.', 'stock-alibi' ), $user, $ref ) /* translators: %s: order number. */ : sprintf( __( 'The items of order #%s were edited.', 'stock-alibi' ), $ref ); break; case Context::SOURCE_ADMIN: $text = '' !== $user /* translators: %s: user name. */ ? sprintf( __( '%s saved the product on the product screen.', 'stock-alibi' ), $user ) : __( 'The product was saved on the product screen.', 'stock-alibi' ); break; case Context::SOURCE_QUICK_EDIT: $text = '' !== $user /* translators: %s: user name. */ ? sprintf( __( '%s used Quick Edit in the product list.', 'stock-alibi' ), $user ) : __( 'Quick Edit in the product list.', 'stock-alibi' ); break; case Context::SOURCE_BULK_EDIT: if ( 'variations' === $ref ) { $action = (string) ( $context['bulk_action'] ?? '' ); $text = '' !== $user /* translators: 1: user name, 2: name of the bulk action, e.g. variable_stock. */ ? sprintf( __( '%1$s used a bulk action on the variations (%2$s).', 'stock-alibi' ), $user, $action ) /* translators: %s: name of the bulk action. */ : sprintf( __( 'A bulk action on the variations (%s).', 'stock-alibi' ), $action ); } else { $text = '' !== $user /* translators: %s: user name. */ ? sprintf( __( '%s used Bulk Edit in the product list.', 'stock-alibi' ), $user ) : __( 'Bulk Edit in the product list.', 'stock-alibi' ); } break; case Context::SOURCE_REST: $text = '' !== $ref /* translators: %s: REST route, e.g. PUT /wc/v3/products/12. */ ? sprintf( __( 'REST API request %s.', 'stock-alibi' ), $ref ) : __( 'REST API request.', 'stock-alibi' ); $text = $this->with_credentials( $text, $context, $user ); break; case Context::SOURCE_IMPORT: $text = '' !== $ref /* translators: %s: file name. */ ? sprintf( __( 'Product CSV import (%s).', 'stock-alibi' ), $ref ) : __( 'Product CSV import.', 'stock-alibi' ); if ( '' !== $user ) { /* translators: %s: user name. */ $text .= ' ' . sprintf( __( 'Run by %s.', 'stock-alibi' ), $user ); } break; case Context::SOURCE_CLI: $text = '' !== $ref /* translators: %s: WP-CLI command, e.g. wc product update 12. */ ? sprintf( __( 'WP-CLI command "wp %s".', 'stock-alibi' ), $ref ) : __( 'WP-CLI command.', 'stock-alibi' ); if ( ! empty( $context['rest_route'] ) ) { /* translators: %s: REST route. */ $text .= ' ' . sprintf( __( 'It ran the REST request %s.', 'stock-alibi' ), (string) $context['rest_route'] ); } break; case Context::SOURCE_CRON: $text = '' !== $ref /* translators: %s: name of the WP-Cron hook. */ ? sprintf( __( 'WP-Cron task %s.', 'stock-alibi' ), $ref ) : __( 'WP-Cron task.', 'stock-alibi' ); break; case Context::SOURCE_SCHEDULER: if ( '' === $ref ) { $text = __( 'Scheduled action.', 'stock-alibi' ); } else { $text = ! empty( $context['action_id'] ) /* translators: 1: hook name, 2: action ID. */ ? sprintf( __( 'Scheduled action %1$s (#%2$d).', 'stock-alibi' ), $ref, (int) $context['action_id'] ) /* translators: %s: hook name. */ : sprintf( __( 'Scheduled action %s.', 'stock-alibi' ), $ref ); } break; case Context::SOURCE_SCHEDULED_SALE: $text = 'end' === $ref ? __( 'The scheduled sale ended.', 'stock-alibi' ) : __( 'The scheduled sale started.', 'stock-alibi' ); break; case Context::SOURCE_PLUGIN: $text = '' !== $ref /* translators: %s: plugin or theme, e.g. my-erp-bridge or themes/storefront. */ ? sprintf( __( 'Code of %s changed it through WooCommerce.', 'stock-alibi' ), $ref ) : __( 'Code of another plugin or theme changed it through WooCommerce.', 'stock-alibi' ); break; case Context::SOURCE_META_DIRECT: $text = '' !== $ref /* translators: %s: plugin or theme. */ ? sprintf( __( '%s wrote it straight to the product data, bypassing WooCommerce.', 'stock-alibi' ), $ref ) : __( 'A script wrote it straight to the product data, bypassing WooCommerce.', 'stock-alibi' ); break; case Context::SOURCE_EXTERNAL: $text = $this->external( $context ); break; case Context::SOURCE_UNKNOWN: $text = __( 'The source could not be determined.', 'stock-alibi' ); break; default: // A name set by an integration through `stock_alibi_resolve_source`. $text = '' !== $ref /* translators: 1: source name set by an integration, e.g. softone, 2: its reference, e.g. nightly sync. */ ? sprintf( __( 'Reported by %1$s: %2$s.', 'stock-alibi' ), $source, $ref ) /* translators: %s: source name set by an integration. */ : sprintf( __( 'Reported by %s.', 'stock-alibi' ), $source ); break; }//end switch return $this->with_entry_point( $text, $source, $context ); } /** * How long after an order an automated stock change came, when that is * the case: "14 minutes after order #4812, which had changed the stock * from 6 to 5." * * @param array $event Event row. */ public function after_order( array $event ): string { if ( Fields::STOCK_QUANTITY !== $event['field'] || in_array( $event['source'], EventQuery::ORDER_SOURCES, true ) || ! empty( $event['context']['initial'] ) || empty( $event['id'] ) ) { return ''; } $previous = $this->events->previous_stock_event( $event ); if ( null === $previous || Context::SOURCE_ORDER !== $previous['source'] ) { return ''; } $then = (int) strtotime( $previous['created_at'] . ' UTC' ); $now = (int) strtotime( $event['created_at'] . ' UTC' ); if ( $now - $then > self::ORDER_LINK_WINDOW ) { return ''; } return sprintf( /* translators: 1: time span, e.g. 14 mins, 2: order number, 3: old stock, 4: new stock. */ __( '%1$s after order #%2$s, which had changed the stock from %3$s to %4$s.', 'stock-alibi' ), human_time_diff( $then, max( $then + 1, $now ) ), $previous['source_ref'], Labels::value( Fields::STOCK_QUANTITY, $previous['old_value'] ), Labels::value( Fields::STOCK_QUANTITY, $previous['new_value'] ) ); } /** * Explanation of a product's recent history: what its stock and price are, * the last change of each and why, and where the changes came from. * * @param int $product_id Product or variable product (with its variations). * @param int $days Period for the summary by source. * @return string[] Sentences. */ public function product( int $product_id, int $days = 30 ): array { $lines = array(); $product = function_exists( 'wc_get_product' ) ? wc_get_product( $product_id ) : null; if ( $product && $product->managing_stock() && ! $product->is_type( 'variable' ) ) { $lines[] = sprintf( /* translators: 1: stock quantity, 2: stock status. */ __( 'Stock now: %1$s (%2$s).', 'stock-alibi' ), wc_stock_amount( $product->get_stock_quantity( 'edit' ) ), Labels::value( Fields::STOCK_STATUS, $product->get_stock_status( 'edit' ) ) ); } $last_stock = $this->events->find( array( 'product' => $product_id, 'fields' => array( Fields::STOCK_QUANTITY ), ), 1 ); if ( $last_stock ) { /* translators: %s: explanation of a change. */ $lines[] = sprintf( __( 'Last stock change: %s', 'stock-alibi' ), $this->event( $last_stock[0] ) ); } $last_price = $this->events->find( array( 'product' => $product_id, 'fields' => array_merge( Fields::PRICES, array( Fields::PRICE ) ), ), 1 ); if ( $last_price ) { /* translators: %s: explanation of a change. */ $lines[] = sprintf( __( 'Last price change: %s', 'stock-alibi' ), $this->event( $last_price[0] ) ); } $since = gmdate( 'Y-m-d H:i:s', time() - $days * DAY_IN_SECONDS ); $recent = $this->events->find( array( 'product' => $product_id, 'fields' => array( Fields::STOCK_QUANTITY ), 'from' => $since, ), EventQuery::MAX_LIMIT ); if ( $recent ) { $counts = array(); foreach ( $recent as $event ) { $label = Labels::source( (string) $event['source'] ); $counts[ $label ] = ( $counts[ $label ] ?? 0 ) + 1; } arsort( $counts ); $parts = array(); foreach ( $counts as $label => $count ) { $parts[] = $label . ' ' . $count; } $lines[] = sprintf( /* translators: 1: number of days, 2: number of changes, 3: list of sources with counts, e.g. Order 8, REST API 4. */ _n( 'In the last %1$d day: %2$d stock changes (%3$s).', 'In the last %1$d days: %2$d stock changes (%3$s).', $days, 'stock-alibi' ), $days, count( $recent ), implode( ', ', $parts ) ); } if ( ! $lines ) { $lines[] = __( 'No stock or price changes have been recorded for this product yet.', 'stock-alibi' ); } return $lines; } /** * A sentence for an anomaly. * * @param array $anomaly Anomaly from `Anomalies::find()`. */ public function anomaly( array $anomaly ): string { $details = is_array( $anomaly['details'] ?? null ) ? $anomaly['details'] : array(); switch ( $anomaly['type'] ) { case Anomalies::TYPE_NEGATIVE_STOCK: return sprintf( /* translators: 1: old stock, 2: new stock, 3: source. */ __( 'Stock went from %1$s to %2$s although backorders were not allowed. Source: %3$s.', 'stock-alibi' ), Labels::value( Fields::STOCK_QUANTITY, $details['previous'] ?? null ), Labels::value( Fields::STOCK_QUANTITY, $details['stock'] ?? null ), $this->source_with_ref( (string) ( $details['source'] ?? '' ), (string) ( $details['source_ref'] ?? '' ) ) ); case Anomalies::TYPE_DOUBLE_REDUCTION: $reduced = array_map( static fn( $delta ): string => Fields::number( abs( (float) $delta ) ) ?? '', (array) ( $details['reduced'] ?? array() ) ); return sprintf( /* translators: 1: order number, 2: first quantity, 3: second quantity. */ __( 'Order #%1$s reduced the stock of the same line twice (by %2$s and by %3$s), without the stock being restored in between.', 'stock-alibi' ), (int) ( $details['order_id'] ?? 0 ), $reduced[0] ?? '?', $reduced[1] ?? '?' ); case Anomalies::TYPE_SYNC_OVERWRITE: $orders = array_map( static fn( $id ): string => '#' . (int) $id, (array) ( $details['orders'] ?? array() ) ); return sprintf( /* translators: 1: source, 2: old stock, 3: new stock, 4: time span, e.g. 3 mins, 5: order numbers, e.g. #12, #13. */ __( '%1$s set the stock from %2$s back to %3$s, %4$s after order %5$s had reduced it. The sale may have been overwritten by a sync that did not know about it.', 'stock-alibi' ), $this->source_with_ref( (string) ( $details['source'] ?? '' ), (string) ( $details['source_ref'] ?? '' ) ), Labels::value( Fields::STOCK_QUANTITY, $details['old_value'] ?? null ), Labels::value( Fields::STOCK_QUANTITY, $details['new_value'] ?? null ), human_time_diff( 0, max( 1, (int) ( $details['seconds_after'] ?? 0 ) ) ), implode( ', ', $orders ) ); case Anomalies::TYPE_PRICE_OUTSIDE: $field = (string) ( $details['field'] ?? Fields::PRICE ); $text = 'sql' === ( $details['path'] ?? '' ) /* translators: 1: field name, 2: old value, 3: new value. */ ? __( '%1$s changed from %2$s to %3$s directly in the database, around WooCommerce.', 'stock-alibi' ) /* translators: 1: field name, 2: old value, 3: new value. */ : __( '%1$s changed from %2$s to %3$s by code that wrote the product data directly, around WooCommerce.', 'stock-alibi' ); $text = sprintf( $text, Labels::field( $field ), Labels::value( $field, $details['old_value'] ?? null ), Labels::value( $field, $details['new_value'] ?? null ) ); if ( 'sql' !== ( $details['path'] ?? '' ) ) { /* translators: %s: source. */ $text .= ' ' . sprintf( __( 'Source: %s.', 'stock-alibi' ), $this->source_with_ref( (string) ( $details['source'] ?? '' ), (string) ( $details['source_ref'] ?? '' ) ) ); } return $text; }//end switch return Labels::anomaly( (string) $anomaly['type'] ); } /** * "REST API (PUT /wc/v3/products/12)", "softone (nightly sync)". * * @param string $source Source key. * @param string $ref Source reference. */ public function source_with_ref( string $source, string $ref ): string { $label = Labels::source( $source ); if ( '' === $ref || Context::SOURCE_SCHEDULED_SALE === $source ) { return $label; } if ( in_array( $source, EventQuery::ORDER_SOURCES, true ) ) { return $label . ' #' . $ref; } return $label . ' (' . $ref . ')'; } /** * A direct database change found by reconciliation. * * @param array $context Event context. */ private function external( array $context ): string { $window = is_array( $context['window'] ?? null ) ? $context['window'] : array(); if ( ! empty( $window['from'] ) && ! empty( $window['to'] ) && Labels::time( (string) $window['from'] ) === Labels::time( (string) $window['to'] ) ) { $text = sprintf( /* translators: %s: time. */ __( 'Changed directly in the database, without WordPress or WooCommerce, at about %s.', 'stock-alibi' ), Labels::time( (string) $window['to'] ) ); } elseif ( ! empty( $window['from'] ) && ! empty( $window['to'] ) ) { $same_day = Labels::time( (string) $window['from'], 'Y-m-d' ) === Labels::time( (string) $window['to'], 'Y-m-d' ); $text = sprintf( /* translators: 1: earliest time, 2: latest time. */ __( 'Changed directly in the database, without WordPress or WooCommerce, sometime between %1$s and %2$s.', 'stock-alibi' ), Labels::time( (string) $window['from'] ), $same_day ? Labels::time( (string) $window['to'], (string) get_option( 'time_format' ) ) : Labels::time( (string) $window['to'] ) ); } else { $text = __( 'Changed directly in the database, without WordPress or WooCommerce.', 'stock-alibi' ); } $text .= ' ' . __( 'Found by the database check.', 'stock-alibi' ); if ( array_key_exists( 'lookup', $context ) && null !== $context['lookup'] ) { /* translators: %s: value. */ $text .= ' ' . sprintf( __( "WooCommerce's lookup table still showed %s, which confirms that WooCommerce did not make the change.", 'stock-alibi' ), (string) $context['lookup'] ); } return $text; } /** * Adds the API key, application password or user of a REST request. * * @param string $text Sentence so far. * @param array $context Event context. * @param string $user User name. */ private function with_credentials( string $text, array $context, string $user ): string { if ( is_array( $context['api_key'] ?? null ) ) { $key = $context['api_key']; $name = (string) ( $key['description'] ?? '' ); if ( '' === $name ) { /* translators: %d: API key ID. */ $name = sprintf( __( 'key #%d', 'stock-alibi' ), (int) ( $key['id'] ?? 0 ) ); } $text .= ' ' . ( ! empty( $key['truncated_key'] ) /* translators: 1: API key description, 2: last characters of the key. */ ? sprintf( __( 'API key "%1$s" (…%2$s).', 'stock-alibi' ), $name, (string) $key['truncated_key'] ) /* translators: %s: API key description. */ : sprintf( __( 'API key "%s".', 'stock-alibi' ), $name ) ); } elseif ( is_array( $context['app_password'] ?? null ) && ! empty( $context['app_password']['name'] ) ) { /* translators: %s: application password name. */ $text .= ' ' . sprintf( __( 'Application password "%s".', 'stock-alibi' ), (string) $context['app_password']['name'] ); } if ( '' !== $user ) { /* translators: %s: user name. */ $text .= ' ' . sprintf( __( 'User: %s.', 'stock-alibi' ), $user ); } return $text; } /** * Adds where a change entered WordPress (for `meta_direct`) and which * plugin was running (for entry-point sources). * * @param string $text Sentence so far. * @param string $source Source key. * @param array $context Event context. */ private function with_entry_point( string $text, string $source, array $context ): string { if ( is_array( $context['via'] ?? null ) && ! empty( $context['via']['source'] ) ) { /* translators: %s: source, e.g. REST API (POST /erp/v1/stock). */ $text .= ' ' . sprintf( __( 'Through: %s.', 'stock-alibi' ), $this->source_with_ref( (string) $context['via']['source'], (string) ( $context['via']['ref'] ?? '' ) ) ); } if ( Context::SOURCE_PLUGIN !== $source && is_array( $context['caller'] ?? null ) && ! empty( $context['caller']['slug'] ) ) { $caller = Context::code_ref( array( 'type' => (string) ( $context['caller']['type'] ?? '' ), 'slug' => (string) $context['caller']['slug'], ) ); /* translators: %s: plugin or theme. */ $text .= ' ' . sprintf( __( 'Code running: %s.', 'stock-alibi' ), '' !== $caller ? $caller : (string) $context['caller']['slug'] ); } if ( ! empty( $context['synced_from'] ) ) { /* translators: %s: variation name. */ $text .= ' ' . sprintf( __( 'WooCommerce synced the variable product from the change of %s.', 'stock-alibi' ), Labels::product( (int) $context['synced_from'] ) ); } if ( ! empty( $context['resolved_source'] ) && is_string( $context['resolved_source'] ) ) { /* translators: %s: source as Stock Alibi saw it, e.g. REST API. */ $text .= ' ' . sprintf( __( 'Seen by Stock Alibi as: %s.', 'stock-alibi' ), Labels::source( $context['resolved_source'] ) ); } return $text; } }