PluginProbe ʕ •ᴥ•ʔ
CommerceBird – AI Command Center, ERP Integrations & B2B for WooCommerce (Zoho, Exact Online). / trunk
CommerceBird – AI Command Center, ERP Integrations & B2B for WooCommerce (Zoho, Exact Online). vtrunk
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 weeks ago index.php 2 months ago
ZohoCategoryMap.php
495 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 /**
87 * Resolve a Zoho category id to a WC term id, preferring active rows but
88 * gracefully falling back to the newest row in any status.
89 */
90 public function term_id_for_zoho_id_any_status( string $zoho_id ): ?int {
91 $active = $this->term_id_for_zoho_id( $zoho_id );
92 if ( null !== $active ) {
93 return $active;
94 }
95
96 $row = $this->db->get_var(
97 $this->db->prepare(
98 "SELECT wc_term_id
99 FROM {$this->table}
100 WHERE zoho_category_id = %s
101 ORDER BY updated_at DESC, id DESC
102 LIMIT 1",
103 $zoho_id
104 )
105 );
106
107 return null !== $row ? (int) $row : null;
108 }
109
110 /**
111 * Resolve a WC term id to a Zoho category id, preferring active rows but
112 * gracefully falling back to the newest row in any status.
113 */
114 public function zoho_id_for_term_any_status( int $term_id ): ?string {
115 $active = $this->zoho_id_for_term( $term_id );
116 if ( null !== $active ) {
117 return $active;
118 }
119
120 $row = $this->db->get_var(
121 $this->db->prepare(
122 "SELECT zoho_category_id
123 FROM {$this->table}
124 WHERE wc_term_id = %d
125 ORDER BY updated_at DESC, id DESC
126 LIMIT 1",
127 $term_id
128 )
129 );
130
131 return null !== $row ? (string) $row : null;
132 }
133
134 public function claim_auto( int $term_id, string $zoho_id ): ClaimResult {
135 $now = current_time( 'mysql' );
136
137 // Suppress wpdb's automatic error printing — we expect unique-key violations.
138 $previous_show_errors = $this->db->show_errors( false );
139
140 $inserted = $this->db->insert(
141 $this->table,
142 array(
143 'wc_term_id' => $term_id,
144 'zoho_category_id' => $zoho_id,
145 'source' => 'auto',
146 'status' => 'active',
147 'created_at' => $now,
148 'updated_at' => $now,
149 'last_verified_at' => $now,
150 ),
151 array( '%d', '%s', '%s', '%s', '%s', '%s', '%s' )
152 );
153
154 $this->db->show_errors( $previous_show_errors );
155
156 if ( false !== $inserted ) {
157 return new ClaimResult( ClaimResult::STATUS_CREATED, (int) $this->db->insert_id );
158 }
159
160 // Insert failed — must be a unique-violation. Resolve it.
161 return $this->resolve_auto_conflict( $term_id, $zoho_id, $now );
162 }
163
164 private function resolve_auto_conflict( int $term_id, string $zoho_id, string $now ): ClaimResult {
165 // Look up the conflicting row by either constraint.
166 $existing = $this->db->get_row(
167 $this->db->prepare(
168 "SELECT * FROM {$this->table}
169 WHERE status = 'active' AND ( wc_term_id = %d OR zoho_category_id = %s )
170 LIMIT 1",
171 $term_id,
172 $zoho_id
173 )
174 );
175
176 if ( null === $existing ) {
177 // Race: row vanished between failed INSERT and SELECT. Retry once.
178 return $this->claim_auto( $term_id, $zoho_id );
179 }
180
181 // Same auto pair → noop, bump last_verified_at.
182 if (
183 'auto' === $existing->source
184 && (int) $existing->wc_term_id === $term_id
185 && (string) $existing->zoho_category_id === $zoho_id
186 ) {
187 // NOOP is a re-confirmation, not a data change — only bump last_verified_at.
188 // updated_at stays put so it accurately reflects the last time row data changed.
189 $this->db->update(
190 $this->table,
191 array( 'last_verified_at' => $now ),
192 array( 'id' => $existing->id ),
193 array( '%s' ),
194 array( '%d' )
195 );
196 return new ClaimResult( ClaimResult::STATUS_NOOP, (int) $existing->id );
197 }
198
199 // Otherwise it's a genuine conflict — implemented in Task 7.
200 return $this->queue_for_review( $term_id, $zoho_id, $existing, $now );
201 }
202
203 private function queue_for_review( int $term_id, string $zoho_id, object $existing, string $now ): ClaimResult {
204 if ( 'manual' === $existing->source ) {
205 $reason = 'manual_override_protected';
206 } elseif ( (int) $existing->wc_term_id === $term_id ) {
207 $reason = 'term_remap_pending';
208 } else {
209 $reason = 'duplicate_zoho_id';
210 }
211
212 $this->db->insert(
213 $this->table,
214 array(
215 'wc_term_id' => $term_id,
216 'zoho_category_id' => $zoho_id,
217 'source' => 'auto',
218 'status' => 'review',
219 'review_reason' => $reason,
220 'created_at' => $now,
221 'updated_at' => $now,
222 ),
223 array( '%d', '%s', '%s', '%s', '%s', '%s', '%s' )
224 );
225
226 return new ClaimResult(
227 ClaimResult::STATUS_QUEUED_FOR_REVIEW,
228 (int) $this->db->insert_id,
229 $reason
230 );
231 }
232
233 public function set_manual( int $term_id, string $zoho_id ): ClaimResult {
234 $now = current_time( 'mysql' );
235
236 // Delete any active rows that would conflict on either unique key.
237 // This wipes auto rows AND any prior manual row for the same term/zoho id —
238 // operator override is destructive by design.
239 $this->db->query(
240 $this->db->prepare(
241 "DELETE FROM {$this->table}
242 WHERE status = 'active'
243 AND ( wc_term_id = %d OR zoho_category_id = %s )",
244 $term_id,
245 $zoho_id
246 )
247 );
248
249 $rows_deleted = (int) $this->db->rows_affected;
250
251 $this->db->insert(
252 $this->table,
253 array(
254 'wc_term_id' => $term_id,
255 'zoho_category_id' => $zoho_id,
256 'source' => 'manual',
257 'status' => 'active',
258 'created_at' => $now,
259 'updated_at' => $now,
260 'last_verified_at' => $now,
261 ),
262 array( '%d', '%s', '%s', '%s', '%s', '%s', '%s' )
263 );
264
265 return new ClaimResult(
266 $rows_deleted > 0 ? ClaimResult::STATUS_UPDATED : ClaimResult::STATUS_CREATED,
267 (int) $this->db->insert_id
268 );
269 }
270
271 public function forget_term( int $term_id ): void {
272 $this->db->delete(
273 $this->table,
274 array( 'wc_term_id' => $term_id ),
275 array( '%d' )
276 );
277 }
278
279 public function delete_by_id( int $row_id ): bool {
280 $deleted = $this->db->delete(
281 $this->table,
282 array( 'id' => $row_id ),
283 array( '%d' )
284 );
285 return $deleted !== false && $deleted > 0;
286 }
287
288 public function reconcile_zoho_presence( array $current_zoho_ids ): ReconcileResult {
289 if ( empty( $current_zoho_ids ) ) {
290 // No current ids — every active row is orphaned by definition.
291 $hard_deleted = (int) $this->db->query(
292 "DELETE FROM {$this->table} WHERE source = 'auto' AND status = 'active'"
293 );
294 $now = current_time( 'mysql' );
295 $orphaned = (int) $this->db->query(
296 $this->db->prepare(
297 "UPDATE {$this->table}
298 SET status = 'orphan', review_reason = 'zoho_missing', updated_at = %s
299 WHERE source = 'manual' AND status = 'active'",
300 $now
301 )
302 );
303 return new ReconcileResult( $hard_deleted, $orphaned );
304 }
305
306 $placeholders = implode( ',', array_fill( 0, count( $current_zoho_ids ), '%s' ) );
307
308 $hard_deleted = (int) $this->db->query(
309 $this->db->prepare(
310 "DELETE FROM {$this->table}
311 WHERE source = 'auto'
312 AND status = 'active'
313 AND zoho_category_id NOT IN ({$placeholders})",
314 ...array_map( 'strval', $current_zoho_ids )
315 )
316 );
317
318 $now = current_time( 'mysql' );
319 $orphaned = (int) $this->db->query(
320 $this->db->prepare(
321 "UPDATE {$this->table}
322 SET status = 'orphan', review_reason = 'zoho_missing', updated_at = %s
323 WHERE source = 'manual'
324 AND status = 'active'
325 AND zoho_category_id NOT IN ({$placeholders})",
326 $now,
327 ...array_map( 'strval', $current_zoho_ids )
328 )
329 );
330
331 return new ReconcileResult( $hard_deleted, $orphaned );
332 }
333
334 public function list( int $offset = 0, int $limit = 50, string $status = 'active' ): array {
335 if ( 'all' === $status ) {
336 $query = $this->db->prepare(
337 "SELECT id, wc_term_id, zoho_category_id, source, status, review_reason, created_at, updated_at, last_verified_at
338 FROM {$this->table}
339 ORDER BY updated_at DESC
340 LIMIT %d OFFSET %d",
341 $limit,
342 $offset
343 );
344 } else {
345 $query = $this->db->prepare(
346 "SELECT id, wc_term_id, zoho_category_id, source, status, review_reason, created_at, updated_at, last_verified_at
347 FROM {$this->table}
348 WHERE status = %s
349 ORDER BY updated_at DESC
350 LIMIT %d OFFSET %d",
351 $status,
352 $limit,
353 $offset
354 );
355 }
356
357 $rows = $this->db->get_results(
358 $query,
359 ARRAY_A
360 );
361
362 return $rows ?: array();
363 }
364
365 public function resolve_review( int $row_id, ReviewResolution $resolution ): void {
366 // The review queue surfaces both 'review' rows (genuine pending conflicts) and
367 // 'orphan' rows (manual mappings whose Zoho category no longer exists,
368 // review_reason = 'zoho_missing'). Both must be resolvable, otherwise an orphan
369 // row can never be cleared and the warning reappears on every page reload.
370 $review_row = $this->db->get_row(
371 $this->db->prepare(
372 "SELECT * FROM {$this->table} WHERE id = %d AND status IN ( 'review', 'orphan' )",
373 $row_id
374 )
375 );
376
377 if ( null === $review_row ) {
378 return; // already resolved by another tab/operator
379 }
380
381 switch ( $resolution ) {
382 case ReviewResolution::KeepExisting:
383 $this->db->delete( $this->table, array( 'id' => $row_id ), array( '%d' ) );
384 return;
385
386 case ReviewResolution::DiscardBoth:
387 $this->db->query(
388 $this->db->prepare(
389 "DELETE FROM {$this->table}
390 WHERE id = %d
391 OR ( status = 'active'
392 AND ( wc_term_id = %d OR zoho_category_id = %s ) )",
393 $row_id,
394 (int) $review_row->wc_term_id,
395 (string) $review_row->zoho_category_id
396 )
397 );
398 return;
399
400 case ReviewResolution::ReplaceWithPending:
401 // Delete the review row and any conflicting active row, then promote pending as manual.
402 $this->db->query(
403 $this->db->prepare(
404 "DELETE FROM {$this->table}
405 WHERE id = %d
406 OR ( status = 'active'
407 AND ( wc_term_id = %d OR zoho_category_id = %s ) )",
408 $row_id,
409 (int) $review_row->wc_term_id,
410 (string) $review_row->zoho_category_id
411 )
412 );
413 $this->set_manual( (int) $review_row->wc_term_id, (string) $review_row->zoho_category_id );
414 return;
415 }
416 }
417
418 public static function migrate_legacy_options( bool $dry_run = false ): array {
419 global $wpdb;
420
421 $rows = $wpdb->get_results(
422 "SELECT option_name, option_value
423 FROM {$wpdb->options}
424 WHERE option_name LIKE 'cmbird_zoho_id_for_term_id_%'"
425 );
426
427 $migrated = 0;
428 $skipped = array();
429
430 if ( empty( $rows ) ) {
431 return array(
432 'migrated' => 0,
433 'skipped' => array(),
434 );
435 }
436
437 $self = new self();
438 $table = $self->table_name();
439 $now = current_time( 'mysql' );
440
441 foreach ( $rows as $row ) {
442 $term_id = (int) substr( $row->option_name, strlen( 'cmbird_zoho_id_for_term_id_' ) );
443 $zoho_id = (string) $row->option_value;
444
445 if ( $term_id <= 0 || '' === $zoho_id ) {
446 $skipped[] = array(
447 'option_name' => $row->option_name,
448 'reason' => 'invalid_value',
449 );
450 continue;
451 }
452
453 if ( null === term_exists( $term_id, 'product_cat' ) ) {
454 $skipped[] = array(
455 'option_name' => $row->option_name,
456 'reason' => 'orphan_term',
457 );
458 continue;
459 }
460
461 if ( $dry_run ) {
462 ++$migrated;
463 continue;
464 }
465
466 // INSERT IGNORE: unique-violations from already-migrated rows are silent skips.
467 $inserted = $wpdb->query(
468 $wpdb->prepare(
469 "INSERT IGNORE INTO {$table}
470 (wc_term_id, zoho_category_id, source, status, created_at, updated_at)
471 VALUES (%d, %s, 'auto', 'active', %s, %s)",
472 $term_id,
473 $zoho_id,
474 $now,
475 $now
476 )
477 );
478
479 if ( $inserted ) {
480 ++$migrated;
481 } else {
482 $skipped[] = array(
483 'option_name' => $row->option_name,
484 'reason' => 'duplicate_constraint',
485 );
486 }
487 }
488
489 return array(
490 'migrated' => $migrated,
491 'skipped' => $skipped,
492 );
493 }
494 }
495