PluginProbe
MLSImport: IDX Plugin & MLS Plugin for Real Estate Listings / 7.1.1
MLSImport: IDX Plugin & MLS Plugin for Real Estate Listings v7.1.1
7.2.1 7.2 7.1.2 7.1.1 7.1 7.0.4 7.0.6 7.0.7 6.3.8 6.3.7 6.3.6 6.3.5 6.3.4 6.3.3 6.3.1 trunk 5.7.3 5.7.5 5.8.1 5.8.2 5.8.3 5.8.4 5.8.6 6.0.4 6.0.5 All 36 releases
mlsimport / includes / class-mlsimport-reconciliation.php

class-mlsimport-reconciliation.php in MLSImport: IDX Plugin & MLS Plugin for Real Estate Listings 7.1.1, at includes/class-mlsimport-reconciliation.php

237 lines 7.7 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Deep listing reconciliation module.
4 *
5 * This file owns the keep/delete decision sequence behind one public operation:
6 * Mlsimport_Reconciliation::reconcile_current_listings(). WordPress, the SaaS
7 * snapshot endpoint, scheduling, and destructive storage are accessed through
8 * the environment interface so policy is testable without exposing helpers.
9 *
10 * Reconciliation is deliberately plan-first. It reads and validates the whole
11 * Managed Listing set, creates every decision, and only then starts deletion.
12 * This prevents a late read or policy failure from leaving a half-evaluated run.
13 *
14 * @package MLSImport
15 */
16
17 if ( ! defined( 'ABSPATH' ) ) {
18 exit;
19 }
20
21 require_once __DIR__ . '/interface-mlsimport-reconciliation-environment.php';
22
23 /**
24 * Coordinates one complete Reconciliation Run through the confirmed seam.
25 */
26 final class Mlsimport_Reconciliation {
27 /** One-hour delay agreed for postponed and partial run retries. */
28 private const RETRY_DELAY_SECONDS = 3600;
29
30 /**
31 * External operations used during the run.
32 *
33 * @var Mlsimport_Reconciliation_Environment
34 */
35 private $environment;
36
37 /**
38 * Receive the concrete WordPress environment or a system-boundary test double.
39 *
40 * @param Mlsimport_Reconciliation_Environment $environment External operations.
41 */
42 public function __construct( Mlsimport_Reconciliation_Environment $environment ) {
43 $this->environment = $environment;
44 }
45
46 /**
47 * Reconcile every Managed Listing against one authoritative snapshot.
48 *
49 * Run sequence:
50 * 1. Acquire the singleton lock.
51 * 2. Postpone when import or sync is active.
52 * 3. Fetch and structurally validate the snapshot before local reads.
53 * 4. Read the complete inventory and apply the independent 80% guard.
54 * 5. Build the complete keep/delete plan without side effects.
55 * 6. Apply planned deletions and return observable counts.
56 * 7. Always release the lock.
57 *
58 * @return array<string, int|string> Structured Reconciliation Outcome.
59 */
60 public function reconcile_current_listings(): array {
61 if ( ! $this->environment->acquire_lock() ) {
62 return $this->outcome( 'already_running', 'reconciliation_already_running' );
63 }
64
65 try {
66 if ( $this->environment->import_or_sync_is_active() ) {
67 // Reconciliation and imports mutate the same listings. Postpone
68 // before fetching or planning, and let the environment deduplicate
69 // the single retry event at the scheduling boundary.
70 $this->environment->schedule_retry( self::RETRY_DELAY_SECONDS );
71 return $this->outcome( 'postponed', 'import_or_sync_active' );
72 }
73
74 try {
75 $snapshot = $this->environment->fetch_snapshot();
76 } catch ( Throwable $exception ) {
77 // Endpoint and transport failures are safety aborts. They wait for
78 // the next daily run instead of creating an hourly retry loop.
79 return $this->outcome( 'aborted', 'snapshot_fetch_failed' );
80 }
81 $keys = isset( $snapshot['all_data'] ) && is_array( $snapshot['all_data'] )
82 ? $snapshot['all_data']
83 : array();
84 $keys_are_valid = ! empty( $keys );
85 foreach ( $keys as $key ) {
86 if ( ! is_string( $key ) || '' === trim( $key ) ) {
87 $keys_are_valid = false;
88 break;
89 }
90 }
91
92 if (
93 true !== ( $snapshot['complete'] ?? false )
94 || ! $keys_are_valid
95 || ! isset( $snapshot['count'] )
96 || ! is_int( $snapshot['count'] )
97 || count( $keys ) !== $snapshot['count']
98 || count( $keys ) !== count( array_unique( $keys, SORT_STRING ) )
99 ) {
100 return $this->outcome( 'aborted', 'snapshot_not_authoritative' );
101 }
102
103 try {
104 $listings = $this->environment->read_managed_listings();
105 } catch ( Throwable $exception ) {
106 // A complete plan cannot be proven when any Managed Listing read
107 // fails, so return an abort before the deletion phase begins.
108 return $this->outcome( 'aborted', 'managed_listing_read_failed' );
109 }
110
111 if ( ! mlsimport_reconciliation_feed_is_plausible( count( $keys ), count( $listings ) ) ) {
112 return $this->outcome( 'aborted', 'snapshot_not_authoritative' );
113 }
114
115 $snapshot_keys = array_fill_keys( $keys, true );
116 $plan = array();
117
118 foreach ( $listings as $listing ) {
119 // Every row must contain the values needed to make one reliable
120 // policy decision. Any invalid row aborts the whole plan before
121 // the later deletion loop can perform a destructive side effect.
122 if (
123 ! is_array( $listing )
124 || empty( $listing['id'] )
125 || ! isset( $listing['listing_key'] )
126 || ! is_string( $listing['listing_key'] )
127 || '' === trim( $listing['listing_key'] )
128 || ! array_key_exists( 'import_task_exists', $listing )
129 || ! isset( $listing['protected_statuses'] )
130 || ! is_array( $listing['protected_statuses'] )
131 || ! array_key_exists( 'status_readable', $listing )
132 || ! array_key_exists( 'status', $listing )
133 ) {
134 return $this->outcome( 'aborted', 'reconciliation_plan_invalid' );
135 }
136
137 if ( isset( $snapshot_keys[ $listing['listing_key'] ] ) ) {
138 $plan[] = array(
139 'decision' => 'keep',
140 'listing' => $listing,
141 );
142 continue;
143 }
144
145 $protected_statuses = isset( $listing['protected_statuses'] ) && is_array( $listing['protected_statuses'] )
146 ? $listing['protected_statuses']
147 : array();
148
149 // Protection belongs to the creating Import Task. When that task
150 // has a protection policy, keep either a confirmed matching status
151 // or an unreadable status whose protection cannot be ruled out.
152 if (
153 ! empty( $listing['import_task_exists'] )
154 && ! empty( $protected_statuses )
155 && (
156 empty( $listing['status_readable'] )
157 || in_array( $listing['status'], $protected_statuses, true )
158 )
159 ) {
160 $plan[] = array(
161 'decision' => 'keep',
162 'listing' => $listing,
163 );
164 continue;
165 }
166
167 $plan[] = array(
168 'decision' => 'delete',
169 'listing' => $listing,
170 'reason' => empty( $listing['import_task_exists'] )
171 ? 'absent_import_task_missing'
172 : 'absent_unprotected',
173 );
174 }
175
176 $kept = 0;
177 $deleted = 0;
178 $failed = 0;
179 foreach ( $plan as $item ) {
180 if ( 'keep' === $item['decision'] ) {
181 ++$kept;
182 continue;
183 }
184
185 try {
186 $was_deleted = $this->environment->delete_managed_listing(
187 (int) $item['listing']['id'],
188 (string) $item['listing']['listing_key'],
189 (string) $item['reason']
190 );
191 } catch ( Throwable $exception ) {
192 // Treat storage exceptions like a false delete result. The plan
193 // is already complete, so later independent listings still run.
194 $was_deleted = false;
195 }
196
197 if ( $was_deleted ) {
198 ++$deleted;
199 } else {
200 // A failed destructive operation is isolated to this listing;
201 // continue applying the already-validated remainder of the plan.
202 ++$failed;
203 }
204 }
205
206 if ( $failed > 0 ) {
207 $this->environment->schedule_retry( self::RETRY_DELAY_SECONDS );
208 return $this->outcome( 'partial', 'deletion_failed', $kept, $deleted, $failed );
209 }
210
211 return $this->outcome( 'completed', 'reconciliation_completed', $kept, $deleted );
212 } finally {
213 $this->environment->release_lock();
214 }
215 }
216
217 /**
218 * Build the stable public result shape returned for every run status.
219 *
220 * @param string $status Completed, partial, aborted, postponed, or already_running.
221 * @param string $reason Stable machine-readable outcome reason.
222 * @param int $kept Number of planned keeps.
223 * @param int $deleted Number of successful deletions.
224 * @param int $failed Number of failed planned deletions.
225 * @return array<string, int|string>
226 */
227 private function outcome( string $status, string $reason, int $kept = 0, int $deleted = 0, int $failed = 0 ): array {
228 return array(
229 'status' => $status,
230 'reason' => $reason,
231 'kept' => $kept,
232 'deleted' => $deleted,
233 'failed' => $failed,
234 );
235 }
236 }
237