PluginProbe ʕ •ᴥ•ʔ
CommerceBird – AI Command Center, ERP Integrations & B2B for WooCommerce (Zoho, Exact Online). / 2.9.0
CommerceBird – AI Command Center, ERP Integrations & B2B for WooCommerce (Zoho, Exact Online). v2.9.0
3.0.3 3.0.2 3.0.1 trunk 2.2.14 2.2.15 2.2.16 2.2.17 2.2.18 2.2.19 2.3.0 2.3.1 2.3.10 2.3.11 2.3.12 2.3.13 2.3.14 2.3.2 2.3.3 2.3.4 2.3.5 2.3.6 2.3.7 2.3.8 2.3.9 2.4.0 2.4.1 2.4.2 2.4.3 2.4.4 2.4.5 2.4.6 2.5.0 2.5.1 2.5.2 2.6.0 2.6.1 2.6.2 2.6.3 2.6.4 2.6.5 2.7.0 2.7.1 2.7.2 2.7.3 2.7.4 2.7.5 2.7.6 2.7.7 2.7.8 2.7.9 2.7.91 2.7.92 2.7.93 2.8.0 2.8.1 2.8.2 2.8.3 2.8.4 2.8.5 2.9.0 2.9.1 2.9.2 2.9.3 3.0.0
commercebird / admin / includes / Mappings / ZohoCategoryMap.php
commercebird / admin / includes / Mappings Last commit date
ClaimResult.php 2 months ago ReconcileResult.php 2 months ago ReviewResolution.php 2 months ago ZohoCategoryMap.php 2 months ago index.php 2 months ago
ZohoCategoryMap.php
418 lines
1 <?php
2
3 namespace CommerceBird\Admin\Mappings;
4
5 if ( ! defined( 'ABSPATH' ) ) {
6 exit;
7 }
8
9 final class ZohoCategoryMap {
10
11 /**
12 * @var \wpdb
13 */
14 private $db;
15
16 private string $table;
17
18 public function __construct() {
19 global $wpdb;
20 $this->db = $wpdb;
21 $this->table = $wpdb->prefix . 'cmbird_zi_category_map';
22 }
23
24 public function table_name(): string {
25 return $this->table;
26 }
27
28 public function zoho_id_for_term( int $term_id ): ?string {
29 $row = $this->db->get_var(
30 $this->db->prepare(
31 "SELECT zoho_category_id FROM {$this->table} WHERE wc_term_id = %d AND status = 'active' LIMIT 1",
32 $term_id
33 )
34 );
35
36 if ( null !== $row ) {
37 return (string) $row;
38 }
39
40 if ( defined( 'CMBIRD_ZI_CATEGORY_MAP_LEGACY_FALLBACK' ) && CMBIRD_ZI_CATEGORY_MAP_LEGACY_FALLBACK ) {
41 $legacy = get_option( 'cmbird_zoho_id_for_term_id_' . $term_id );
42 if ( ! empty( $legacy ) ) {
43 // Back-fill on hit so subsequent reads avoid the fallback.
44 $this->claim_auto( $term_id, (string) $legacy );
45 return (string) $legacy;
46 }
47 }
48
49 return null;
50 }
51
52 public function term_id_for_zoho_id( string $zoho_id ): ?int {
53 $row = $this->db->get_var(
54 $this->db->prepare(
55 "SELECT wc_term_id FROM {$this->table} WHERE zoho_category_id = %s AND status = 'active' LIMIT 1",
56 $zoho_id
57 )
58 );
59
60 if ( null !== $row ) {
61 return (int) $row;
62 }
63
64 if ( defined( 'CMBIRD_ZI_CATEGORY_MAP_LEGACY_FALLBACK' ) && CMBIRD_ZI_CATEGORY_MAP_LEGACY_FALLBACK ) {
65 $option_name = $this->db->get_var(
66 $this->db->prepare(
67 "SELECT option_name FROM {$this->db->options}
68 WHERE option_name LIKE 'cmbird_zoho_id_for_term_id_%%'
69 AND option_value = %s
70 LIMIT 1",
71 $zoho_id
72 )
73 );
74 if ( ! empty( $option_name ) ) {
75 $term_id = (int) substr( $option_name, strlen( 'cmbird_zoho_id_for_term_id_' ) );
76 if ( $term_id > 0 ) {
77 $this->claim_auto( $term_id, $zoho_id );
78 return $term_id;
79 }
80 }
81 }
82
83 return null;
84 }
85
86 public function claim_auto( int $term_id, string $zoho_id ): ClaimResult {
87 $now = current_time( 'mysql' );
88
89 // Suppress wpdb's automatic error printing — we expect unique-key violations.
90 $previous_show_errors = $this->db->show_errors( false );
91
92 $inserted = $this->db->insert(
93 $this->table,
94 array(
95 'wc_term_id' => $term_id,
96 'zoho_category_id' => $zoho_id,
97 'source' => 'auto',
98 'status' => 'active',
99 'created_at' => $now,
100 'updated_at' => $now,
101 'last_verified_at' => $now,
102 ),
103 array( '%d', '%s', '%s', '%s', '%s', '%s', '%s' )
104 );
105
106 $this->db->show_errors( $previous_show_errors );
107
108 if ( false !== $inserted ) {
109 return new ClaimResult( ClaimResult::STATUS_CREATED, (int) $this->db->insert_id );
110 }
111
112 // Insert failed — must be a unique-violation. Resolve it.
113 return $this->resolve_auto_conflict( $term_id, $zoho_id, $now );
114 }
115
116 private function resolve_auto_conflict( int $term_id, string $zoho_id, string $now ): ClaimResult {
117 // Look up the conflicting row by either constraint.
118 $existing = $this->db->get_row(
119 $this->db->prepare(
120 "SELECT * FROM {$this->table}
121 WHERE status = 'active' AND ( wc_term_id = %d OR zoho_category_id = %s )
122 LIMIT 1",
123 $term_id,
124 $zoho_id
125 )
126 );
127
128 if ( null === $existing ) {
129 // Race: row vanished between failed INSERT and SELECT. Retry once.
130 return $this->claim_auto( $term_id, $zoho_id );
131 }
132
133 // Same auto pair → noop, bump last_verified_at.
134 if (
135 'auto' === $existing->source
136 && (int) $existing->wc_term_id === $term_id
137 && (string) $existing->zoho_category_id === $zoho_id
138 ) {
139 // NOOP is a re-confirmation, not a data change — only bump last_verified_at.
140 // updated_at stays put so it accurately reflects the last time row data changed.
141 $this->db->update(
142 $this->table,
143 array( 'last_verified_at' => $now ),
144 array( 'id' => $existing->id ),
145 array( '%s' ),
146 array( '%d' )
147 );
148 return new ClaimResult( ClaimResult::STATUS_NOOP, (int) $existing->id );
149 }
150
151 // Otherwise it's a genuine conflict — implemented in Task 7.
152 return $this->queue_for_review( $term_id, $zoho_id, $existing, $now );
153 }
154
155 private function queue_for_review( int $term_id, string $zoho_id, object $existing, string $now ): ClaimResult {
156 if ( 'manual' === $existing->source ) {
157 $reason = 'manual_override_protected';
158 } elseif ( (int) $existing->wc_term_id === $term_id ) {
159 $reason = 'term_remap_pending';
160 } else {
161 $reason = 'duplicate_zoho_id';
162 }
163
164 $this->db->insert(
165 $this->table,
166 array(
167 'wc_term_id' => $term_id,
168 'zoho_category_id' => $zoho_id,
169 'source' => 'auto',
170 'status' => 'review',
171 'review_reason' => $reason,
172 'created_at' => $now,
173 'updated_at' => $now,
174 ),
175 array( '%d', '%s', '%s', '%s', '%s', '%s', '%s' )
176 );
177
178 return new ClaimResult(
179 ClaimResult::STATUS_QUEUED_FOR_REVIEW,
180 (int) $this->db->insert_id,
181 $reason
182 );
183 }
184
185 public function set_manual( int $term_id, string $zoho_id ): ClaimResult {
186 $now = current_time( 'mysql' );
187
188 // Delete any active rows that would conflict on either unique key.
189 // This wipes auto rows AND any prior manual row for the same term/zoho id —
190 // operator override is destructive by design.
191 $this->db->query(
192 $this->db->prepare(
193 "DELETE FROM {$this->table}
194 WHERE status = 'active'
195 AND ( wc_term_id = %d OR zoho_category_id = %s )",
196 $term_id,
197 $zoho_id
198 )
199 );
200
201 $rows_deleted = (int) $this->db->rows_affected;
202
203 $this->db->insert(
204 $this->table,
205 array(
206 'wc_term_id' => $term_id,
207 'zoho_category_id' => $zoho_id,
208 'source' => 'manual',
209 'status' => 'active',
210 'created_at' => $now,
211 'updated_at' => $now,
212 'last_verified_at' => $now,
213 ),
214 array( '%d', '%s', '%s', '%s', '%s', '%s', '%s' )
215 );
216
217 return new ClaimResult(
218 $rows_deleted > 0 ? ClaimResult::STATUS_UPDATED : ClaimResult::STATUS_CREATED,
219 (int) $this->db->insert_id
220 );
221 }
222
223 public function forget_term( int $term_id ): void {
224 $this->db->delete(
225 $this->table,
226 array( 'wc_term_id' => $term_id ),
227 array( '%d' )
228 );
229 }
230
231 public function reconcile_zoho_presence( array $current_zoho_ids ): ReconcileResult {
232 if ( empty( $current_zoho_ids ) ) {
233 // No current ids — every active row is orphaned by definition.
234 $hard_deleted = (int) $this->db->query(
235 "DELETE FROM {$this->table} WHERE source = 'auto' AND status = 'active'"
236 );
237 $now = current_time( 'mysql' );
238 $orphaned = (int) $this->db->query(
239 $this->db->prepare(
240 "UPDATE {$this->table}
241 SET status = 'orphan', review_reason = 'zoho_missing', updated_at = %s
242 WHERE source = 'manual' AND status = 'active'",
243 $now
244 )
245 );
246 return new ReconcileResult( $hard_deleted, $orphaned );
247 }
248
249 $placeholders = implode( ',', array_fill( 0, count( $current_zoho_ids ), '%s' ) );
250
251 $hard_deleted = (int) $this->db->query(
252 $this->db->prepare(
253 "DELETE FROM {$this->table}
254 WHERE source = 'auto'
255 AND status = 'active'
256 AND zoho_category_id NOT IN ({$placeholders})",
257 ...array_map( 'strval', $current_zoho_ids )
258 )
259 );
260
261 $now = current_time( 'mysql' );
262 $orphaned = (int) $this->db->query(
263 $this->db->prepare(
264 "UPDATE {$this->table}
265 SET status = 'orphan', review_reason = 'zoho_missing', updated_at = %s
266 WHERE source = 'manual'
267 AND status = 'active'
268 AND zoho_category_id NOT IN ({$placeholders})",
269 $now,
270 ...array_map( 'strval', $current_zoho_ids )
271 )
272 );
273
274 return new ReconcileResult( $hard_deleted, $orphaned );
275 }
276
277 public function list( int $offset = 0, int $limit = 50, string $status = 'active' ): array {
278 $rows = $this->db->get_results(
279 $this->db->prepare(
280 "SELECT id, wc_term_id, zoho_category_id, source, status, review_reason, created_at, updated_at, last_verified_at
281 FROM {$this->table}
282 WHERE status = %s
283 ORDER BY updated_at DESC
284 LIMIT %d OFFSET %d",
285 $status,
286 $limit,
287 $offset
288 ),
289 ARRAY_A
290 );
291
292 return $rows ?: array();
293 }
294
295 public function resolve_review( int $row_id, ReviewResolution $resolution ): void {
296 $review_row = $this->db->get_row(
297 $this->db->prepare(
298 "SELECT * FROM {$this->table} WHERE id = %d AND status = 'review'",
299 $row_id
300 )
301 );
302
303 if ( null === $review_row ) {
304 return; // already resolved by another tab/operator
305 }
306
307 switch ( $resolution ) {
308 case ReviewResolution::KeepExisting:
309 $this->db->delete( $this->table, array( 'id' => $row_id ), array( '%d' ) );
310 return;
311
312 case ReviewResolution::DiscardBoth:
313 $this->db->query(
314 $this->db->prepare(
315 "DELETE FROM {$this->table}
316 WHERE id = %d
317 OR ( status = 'active'
318 AND ( wc_term_id = %d OR zoho_category_id = %s ) )",
319 $row_id,
320 (int) $review_row->wc_term_id,
321 (string) $review_row->zoho_category_id
322 )
323 );
324 return;
325
326 case ReviewResolution::ReplaceWithPending:
327 // Delete the review row and any conflicting active row, then promote pending as manual.
328 $this->db->query(
329 $this->db->prepare(
330 "DELETE FROM {$this->table}
331 WHERE id = %d
332 OR ( status = 'active'
333 AND ( wc_term_id = %d OR zoho_category_id = %s ) )",
334 $row_id,
335 (int) $review_row->wc_term_id,
336 (string) $review_row->zoho_category_id
337 )
338 );
339 $this->set_manual( (int) $review_row->wc_term_id, (string) $review_row->zoho_category_id );
340 return;
341 }
342 }
343
344 public static function migrate_legacy_options( bool $dry_run = false ): array {
345 global $wpdb;
346
347 $rows = $wpdb->get_results(
348 "SELECT option_name, option_value
349 FROM {$wpdb->options}
350 WHERE option_name LIKE 'cmbird_zoho_id_for_term_id_%'"
351 );
352
353 $migrated = 0;
354 $skipped = array();
355
356 if ( empty( $rows ) ) {
357 return array( 'migrated' => 0, 'skipped' => array() );
358 }
359
360 $self = new self();
361 $table = $self->table_name();
362 $now = current_time( 'mysql' );
363
364 foreach ( $rows as $row ) {
365 $term_id = (int) substr( $row->option_name, strlen( 'cmbird_zoho_id_for_term_id_' ) );
366 $zoho_id = (string) $row->option_value;
367
368 if ( $term_id <= 0 || '' === $zoho_id ) {
369 $skipped[] = array(
370 'option_name' => $row->option_name,
371 'reason' => 'invalid_value',
372 );
373 continue;
374 }
375
376 if ( null === term_exists( $term_id, 'product_cat' ) ) {
377 $skipped[] = array(
378 'option_name' => $row->option_name,
379 'reason' => 'orphan_term',
380 );
381 continue;
382 }
383
384 if ( $dry_run ) {
385 ++$migrated;
386 continue;
387 }
388
389 // INSERT IGNORE: unique-violations from already-migrated rows are silent skips.
390 $inserted = $wpdb->query(
391 $wpdb->prepare(
392 "INSERT IGNORE INTO {$table}
393 (wc_term_id, zoho_category_id, source, status, created_at, updated_at)
394 VALUES (%d, %s, 'auto', 'active', %s, %s)",
395 $term_id,
396 $zoho_id,
397 $now,
398 $now
399 )
400 );
401
402 if ( $inserted ) {
403 ++$migrated;
404 } else {
405 $skipped[] = array(
406 'option_name' => $row->option_name,
407 'reason' => 'duplicate_constraint',
408 );
409 }
410 }
411
412 return array(
413 'migrated' => $migrated,
414 'skipped' => $skipped,
415 );
416 }
417 }
418