PluginProbe
StoreEngine — Complete eCommerce Solution with Memberships, Licensing, Affiliates & More / 2.2.0
StoreEngine — Complete eCommerce Solution with Memberships, Licensing, Affiliates & More v2.2.0
2.3.0 2.2.0 2.1.1 2.1.0 2.0.0 1.10.0 1.9.1 1.9.0 1.2.1 1.2.2 1.3.0 1.3.1 1.3.2 1.3.3 1.4.0 1.5.0 1.5.1 1.5.2 1.5.3 1.5.4 1.5.5 1.5.6 1.5.7 1.5.8 1.6.0 All 59 releases
storeengine / includes / classes / sku-generator.php

sku-generator.php in StoreEngine — Complete eCommerce Solution with Memberships, Licensing, Affiliates & More 2.2.0, at includes/classes/sku-generator.php

251 lines 8.1 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 namespace StoreEngine\Classes;
4
5 use StoreEngine\Utils\Helper;
6
7 if ( ! defined( 'ABSPATH' ) ) {
8 exit;
9 }
10
11 /**
12 * SKU + Barcode bulk generation.
13 *
14 * Used by the React variant-matrix UI and by CSV import to produce stable,
15 * human-readable, collision-free codes from product slug + variation
16 * attribute slugs.
17 */
18 class SkuGenerator {
19
20 /**
21 * Build a deterministic SKU from a base + attribute slugs.
22 *
23 * base="tshirt-noir", parts=["black","m"] → "TSHIRT-NOIR-BLACK-M"
24 */
25 public static function build( string $base, array $parts ): string {
26 $parts = array_filter( array_map( [ self::class, 'normalize' ], $parts ), 'strlen' );
27 $base = self::normalize( $base );
28 $pieces = array_filter( array_merge( [ $base ], $parts ), 'strlen' );
29
30 return strtoupper( implode( '-', $pieces ) );
31 }
32
33 /**
34 * Build a SKU and append a numeric suffix until it is unique within
35 * storeengine_product_variations and the post-meta SKU column for simple
36 * products.
37 */
38 public static function build_unique( string $base, array $parts ): string {
39 $candidate = self::build( $base, $parts );
40 $suffix = 1;
41 $final = $candidate;
42
43 while ( self::sku_exists( $final ) ) {
44 $suffix++;
45 $final = $candidate . '-' . $suffix;
46 }
47
48 return $final;
49 }
50
51 public static function sku_exists( string $sku ): bool {
52 if ( '' === $sku ) {
53 return false;
54 }
55
56 global $wpdb;
57
58 $variations_table = $wpdb->prefix . 'storeengine_product_variations';
59
60 // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter -- %i identifier + %s value bound via prepare() on a custom StoreEngine table; uniqueness probe, not cacheable.
61 $found = (int) $wpdb->get_var( $wpdb->prepare(
62 'SELECT COUNT(1) FROM %i WHERE sku = %s',
63 $variations_table,
64 $sku
65 ) );
66 // phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter
67
68 if ( $found > 0 ) {
69 return true;
70 }
71
72 // Simple-product SKU stored in post meta.
73 // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
74 $found_meta = (int) $wpdb->get_var( $wpdb->prepare(
75 "SELECT COUNT(1) FROM {$wpdb->postmeta} WHERE meta_key = %s AND meta_value = %s",
76 '_storeengine_sku',
77 $sku
78 ) );
79 // phpcs:enable
80
81 return $found_meta > 0;
82 }
83
84 /**
85 * Generate a numeric EAN-13 candidate. Not a registered prefix, intended
86 * for in-house labelling only — replace with vendor-supplied codes for
87 * retail distribution.
88 */
89 public static function make_internal_barcode( int $variation_id ): string {
90 $prefix = '200'; // ITF-style "in-store" prefix.
91 $body = str_pad( (string) $variation_id, 9, '0', STR_PAD_LEFT );
92 $base = $prefix . substr( $body, -9 );
93
94 return $base . self::ean13_check_digit( $base );
95 }
96
97 /**
98 * Monotonic per-key counter stored in an option. Used for the {number}
99 * token and for internal barcode bodies so generated codes don't collide.
100 */
101 public static function next_sequence( string $key ): int {
102 $n = (int) get_option( $key, 0 ) + 1;
103 update_option( $key, $n, false );
104 return $n;
105 }
106
107 /**
108 * Generate a SKU from the admin "SKU pattern" setting, ensuring uniqueness.
109 *
110 * Pattern tokens: {prefix is literal text}, {number} (zero-padded running
111 * sequence), {name} (product name slug), {category} (primary category slug),
112 * {year}.
113 *
114 * @param array{name?:string, category?:string} $ctx
115 */
116 public static function generate_sku( array $ctx = [] ): string {
117 $pattern = (string) Helper::get_settings( 'sku_pattern', '{category}-{number}' );
118 if ( '' === trim( $pattern ) ) {
119 $pattern = '{number}';
120 }
121 $padding = (int) Helper::get_settings( 'sku_number_padding', 4 );
122
123 $candidate = self::render_sku_pattern( $pattern, $ctx, $padding );
124 if ( '' === $candidate ) {
125 $candidate = 'SKU-' . str_pad( (string) self::next_sequence( 'storeengine_sku_sequence' ), max( 1, $padding ), '0', STR_PAD_LEFT );
126 }
127
128 $final = $candidate;
129 $suffix = 1;
130 while ( self::sku_exists( $final ) ) {
131 $suffix++;
132 $final = $candidate . '-' . $suffix;
133 }
134
135 return $final;
136 }
137
138 protected static function render_sku_pattern( string $pattern, array $ctx, int $padding ): string {
139 $repl = [
140 '{name}' => strtoupper( self::normalize( (string) ( $ctx['name'] ?? '' ) ) ),
141 '{category}' => strtoupper( self::normalize( (string) ( $ctx['category'] ?? '' ) ) ),
142 '{year}' => gmdate( 'Y' ),
143 ];
144
145 // {number} only consumes a sequence value when the token is present.
146 if ( false !== strpos( $pattern, '{number}' ) ) {
147 $repl['{number}'] = str_pad( (string) self::next_sequence( 'storeengine_sku_sequence' ), max( 1, $padding ), '0', STR_PAD_LEFT );
148 }
149
150 $out = strtr( $pattern, $repl );
151 $out = preg_replace( '/-{2,}/', '-', $out );
152
153 return strtoupper( trim( (string) $out, '-' ) );
154 }
155
156 /**
157 * Generate a unique internal EAN-13 barcode using the configured in-store
158 * prefix and a running sequence.
159 */
160 public static function generate_barcode(): string {
161 $prefix = preg_replace( '/\D/', '', (string) Helper::get_settings( 'barcode_ean_prefix', '20' ) );
162 if ( '' === $prefix ) {
163 $prefix = '20';
164 }
165
166 do {
167 $seq = self::next_sequence( 'storeengine_barcode_sequence' );
168 $code = self::make_internal_barcode_with_prefix( $prefix, $seq );
169 } while ( self::barcode_exists( $code ) );
170
171 return $code;
172 }
173
174 /**
175 * Build an EAN-13 from an arbitrary in-store prefix + a number, padded to
176 * the 12-digit base then suffixed with the check digit.
177 */
178 public static function make_internal_barcode_with_prefix( string $prefix, int $number ): string {
179 $prefix = substr( preg_replace( '/\D/', '', $prefix ), 0, 11 );
180 $body_len = max( 1, 12 - strlen( $prefix ) );
181 $body = substr( str_pad( (string) $number, $body_len, '0', STR_PAD_LEFT ), -$body_len );
182 $base = substr( $prefix . $body, 0, 12 );
183 $base = str_pad( $base, 12, '0', STR_PAD_LEFT );
184
185 return $base . self::ean13_check_digit( $base );
186 }
187
188 public static function barcode_exists( string $code ): bool {
189 if ( '' === $code ) {
190 return false;
191 }
192
193 global $wpdb;
194
195 $variations_table = $wpdb->prefix . 'storeengine_product_variations';
196
197 // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter -- %i identifier + %s value bound via prepare() on a custom StoreEngine table; uniqueness probe, not cacheable.
198 $found = (int) $wpdb->get_var( $wpdb->prepare(
199 'SELECT COUNT(1) FROM %i WHERE barcode = %s',
200 $variations_table,
201 $code
202 ) );
203 // phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter
204
205 if ( $found > 0 ) {
206 return true;
207 }
208
209 // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
210 $found_meta = (int) $wpdb->get_var( $wpdb->prepare(
211 "SELECT COUNT(1) FROM {$wpdb->postmeta} WHERE meta_key = %s AND meta_value = %s",
212 '_storeengine_barcode',
213 $code
214 ) );
215 // phpcs:enable
216
217 return $found_meta > 0;
218 }
219
220 /**
221 * Primary-category slug for a product, for the {category} SKU token.
222 */
223 public static function product_category_slug( int $product_id ): string {
224 if ( $product_id <= 0 ) {
225 return '';
226 }
227 $term_ids = Helper::get_product_term_ids( $product_id, Helper::PRODUCT_CATEGORY_TAXONOMY );
228 $first = $term_ids ? reset( $term_ids ) : 0;
229 if ( ! $first ) {
230 return '';
231 }
232 $term = get_term( (int) $first, Helper::PRODUCT_CATEGORY_TAXONOMY );
233 return ( $term && ! is_wp_error( $term ) ) ? (string) $term->slug : '';
234 }
235
236 protected static function ean13_check_digit( string $base12 ): string {
237 $sum = 0;
238 for ( $i = 0; $i < 12; $i++ ) {
239 $digit = (int) $base12[ $i ];
240 $sum += ( $i % 2 === 0 ) ? $digit : $digit * 3;
241 }
242 return (string) ( ( 10 - ( $sum % 10 ) ) % 10 );
243 }
244
245 protected static function normalize( string $part ): string {
246 $part = remove_accents( $part );
247 $part = preg_replace( '/[^A-Za-z0-9]+/', '-', $part );
248 return trim( (string) $part, '-' );
249 }
250 }
251