PluginProbe
ووسلام – همگام سازی ووکامرس و باسلام / 1.10.21
ووسلام – همگام سازی ووکامرس و باسلام v1.10.21
1.10.21 1.10.19 1.10.20 1.10.18 1.10.17 1.10.15 1.10.14 1.10.13 1.10.12 1.10.10 1.10.9 1.10.8 1.10.7 1.10.6 1.10.5 1.10.4 1.10.3 1.10.2 1.10.1 1.10.0 1.9.2 1.9.1 1.9.0 1.8.8 1.8.5 All 54 releases
sync-basalam / includes / Services / Products / UpdateSingleProductService.php

UpdateSingleProductService.php in ووسلام – همگام سازی ووکامرس و باسلام 1.10.21, at includes/Services/Products/UpdateSingleProductService.php

454 lines 18.6 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 namespace SyncBasalam\Services\Products;
4
5 use SyncBasalam\Admin\Product\Data\Services\VariantService;
6 use SyncBasalam\Admin\Settings\SettingsConfig;
7 use SyncBasalam\Admin\Settings\SettingsManager;
8 use SyncBasalam\Config\Endpoints;
9 use SyncBasalam\Services\ApiServiceManager;
10 use SyncBasalam\Jobs\Exceptions\RetryableException;
11 use SyncBasalam\Jobs\Exceptions\NonRetryableException;
12 use SyncBasalam\Jobs\Exceptions\StaleVariationException;
13 use SyncBasalam\Logger\Logger;
14 use SyncBasalam\Utilities\ProductMetaKey;
15 use SyncBasalam\Services\VendorSyncPolicy;
16
17 defined('ABSPATH') || exit;
18
19 class UpdateSingleProductService
20 {
21 private $apiservice;
22 private $variationsService;
23 private $variantDataService;
24
25 public function __construct(
26 $apiservice = null,
27 $variationsService = null,
28 $variantDataService = null
29 )
30 {
31 $this->apiservice = $apiservice ?: syncBasalamContainer()->get(ApiServiceManager::class);
32 $this->variationsService = $variationsService ?: syncBasalamContainer()->get(UpdateProductVariationsService::class);
33 $this->variantDataService = $variantDataService ?: new VariantService();
34 }
35
36 public function updateProductInBasalam($productData, $productId)
37 {
38 if (!get_post_type($productId) === 'product') throw NonRetryableException::invalidData('نوع post �
39 حصول نیست.');
40
41 $vendorSyncPolicy = syncBasalamContainer()->get(VendorSyncPolicy::class);
42 if (!$vendorSyncPolicy->canUpdate()) {
43 throw NonRetryableException::invalidData($vendorSyncPolicy->getRestrictionMessage(false));
44 }
45
46 $productData = apply_filters('sync_basalam_product_data_before_update', $productData, $productId);
47 $productData = $vendorSyncPolicy->restrictUpdatePayload($productData, false);
48
49 do_action('sync_basalam_before_update_product_api', $productId, $productData);
50
51 $syncBasalamProductId = get_post_meta($productId, ProductMetaKey::basalamProductId(), true);
52 ProductConnection::assertUnique($productId, $syncBasalamProductId);
53
54 // The dedicated variation endpoint is only used in the "custom" update mode with the
55 // variation price/stock settings ticked. Every other mode (and every not-yet-connected
56 // variation) sends the complete variants section inside one product PATCH, exactly like
57 // earlier major versions: no Basalam variation ids in the payload, and the response
58 // mapping stores the current ids.
59 if ($this->shouldUpdateVariationsSeparately($productId, $productData)) {
60 $variationMappingRecovered = false;
61
62 try {
63 $this->variationsService->updateVariations($syncBasalamProductId, $productData['variants'], $productId);
64 } catch (StaleVariationException $e) {
65 // Core v4 returns 404 when Basalam has recreated/replaced a
66 // variation but WooCommerce still holds its old id. This is not
67 // an error for the user: rebuild a complete variants payload and
68 // let the normal product PATCH return the current ids, so the
69 // next custom-field update finds fresh variation ids again.
70 $productData = $this->prepareVariationRemapPayload($productId, $productData);
71 $variationMappingRecovered = true;
72
73 Logger::warning('شناسه‌های قدی�
74 ی �
75 تغیرهای باسلا�
76 شناسایی شد؛ نگاشت �
77 تغیرها به‌صورت خودکار بازسازی �
78 ی‌شود.', [
79 'product_id' => $productId,
80 'basalam_product_id' => $e->getBasalamProductId(),
81 'stale_basalam_variation_id' => $e->getBasalamVariationId(),
82 ]);
83 }
84
85 // A stale mapping keeps the rebuilt variants in the product PATCH.
86 // The ordinary successful path has already updated each variation,
87 // so duplicate price/stock fields must still be removed.
88 if (!$variationMappingRecovered) {
89 unset($productData['variants'], $productData['primary_price'], $productData['stock']);
90 }
91
92 if (!$this->hasProductFieldsToUpdate($productData)) {
93 return $this->finishUpdate($productId, [], '�
94 تغیرهای �
95 حصول با �
96 وفقیت بروزرسانی شدند.');
97 }
98 }
99
100 // The complete variants section must not carry Basalam variation ids: the API
101 // matches variations by their properties and returns the current ids, which
102 // are stored after the request. This is exactly the pre-1.10.4 behaviour.
103 if (isset($productData['variants']) && is_array($productData['variants'])) {
104 foreach ($productData['variants'] as &$variant) {
105 if (is_array($variant)) unset($variant['id']);
106 }
107 unset($variant);
108 }
109
110 $url = sprintf(Endpoints::PRODUCT_UPDATE, $syncBasalamProductId);
111
112 $maxDescriptionRetries = 3;
113 $descriptionRetry = 0;
114 $skuRetry = false;
115
116 while (true) {
117 try {
118 $request = $this->apiservice->patch($url, $productData);
119 } catch (RetryableException $e) {
120 if ($this->retryWithoutDuplicateSku($productData, $e, $skuRetry)) {
121 continue;
122 }
123
124 throw $e;
125 } catch (NonRetryableException $e) {
126 if ($this->retryWithoutDuplicateSku($productData, $e, $skuRetry)) {
127 continue;
128 }
129
130 if ($descriptionRetry < $maxDescriptionRetries && $this->stripForbiddenDescription($e, $productData, $productId, $descriptionRetry)) {
131 $descriptionRetry++;
132 continue;
133 }
134
135 throw $e;
136 } catch (\Exception $e) {
137 if ($this->retryWithoutDuplicateSku($productData, $e, $skuRetry)) {
138 continue;
139 }
140
141 throw new \Exception(esc_html('خطا در ارتباط با API باسلا�
142 : ' . $e->getMessage()));
143 }
144
145 // Some API adapters return a non-2xx response instead of throwing it.
146 if ($this->retryWithoutDuplicateSku($productData, $request, $skuRetry)) {
147 continue;
148 }
149
150 break;
151 }
152
153 $body = $request['body'] ?? '';
154
155 if (is_string($body)) $body = json_decode($body, true);
156
157 if ($request['status_code'] != 200) {
158 if ($request['status_code'] == 403) throw NonRetryableException::unauthorized("این �
159 حصول �
160 تعلق به غرفه فعلی نیست.");
161
162 if (!is_array($body)) $body = [];
163
164 if (isset($body['messages'][0]['message'])) $message = $body['messages'][0]['message'];
165 elseif (isset($body[0]['message'])) $message = $body[0]['message'];
166 else $message = '';
167
168 if (isset($body['messages'][0]['fields'][0])) $field = $body['messages'][0]['fields'][0];
169 elseif (isset($body[0]['fields'][0])) $field = $body[0]['fields'][0];
170 else $field = '';
171
172 $errorMessage = $message ?: 'درخواست با خطا �
173 واجه شد.';
174 if ($field) $errorMessage .= ' (فیلد: ' . $field . ')';
175
176 throw NonRetryableException::permanent(esc_html($errorMessage));
177 }
178
179 if (is_wp_error($request)) throw NonRetryableException::permanent('خطایی در ارتباط با سرور رخ داد.');
180
181 // Basalam may return a successful response with a stale/null product SKU
182 // when another product field (most commonly a long description) is
183 // present in the same patch. Send the SKU on its own when the response
184 // does not contain the value we requested, so the product-level SKU is
185 // not lost for variable products.
186 // When the duplicate-SKU fallback was used, deliberately keep the
187 // second request SKU-free; do not issue a follow-up SKU-only patch.
188 if (!$skuRetry) $this->ensureProductSkuUpdated($url, $productData, $body);
189
190 $product = \wc_get_product($productId);
191 if ($product && $product->is_type('variable')) {
192 $variations = $product->get_children();
193 if (isset($body['variants'])) {
194 $wcVariations = [];
195 $attributes = $product->get_attributes();
196
197 foreach ($variations as $variationId) {
198 $variation = \wc_get_product($variationId);
199 $attributeValues = [];
200
201 foreach ($attributes as $attributeName => $attribute) {
202 if ($attribute->get_variation()) {
203 $cleanAttributeName = str_replace('attribute_', '', $attributeName);
204 $value = $variation->get_attribute($cleanAttributeName);
205
206 $value = urldecode($value);
207 $value = trim($value);
208 $value = mb_strtolower($value, 'UTF-8');
209 $value = str_replace(['ي', 'ك'], ['ی', 'ک'], $value);
210 $value = str_replace(['-', '_', '–', '—'], ' ', $value);
211 $value = preg_replace('/\s+/', ' ', $value);
212
213 if (!empty($value)) $attributeValues[] = $value;
214 }
215 }
216
217 if (!empty($attributeValues)) {
218 $key = implode("_", $attributeValues);
219 $wcVariations[$key] = $variationId;
220 }
221 }
222
223 $syncBasalamVariations = [];
224 foreach ($body['variants'] as $variant) {
225 $attributeValues = [];
226 if (!empty($variant['properties'])) {
227 foreach ($variant['properties'] as $property) {
228 $val = $property['value']['title'];
229
230 $val = trim($val);
231 $val = mb_strtolower($val, 'UTF-8');
232 $val = str_replace(['ي', 'ك'], ['ی', 'ک'], $val);
233 $val = str_replace(['-', '_', '–', '—'], ' ', $val);
234 $val = preg_replace('/\s+/', ' ', $val);
235
236 if (!empty($val)) $attributeValues[] = $val;
237 }
238 }
239
240 if (!empty($attributeValues)) {
241 $key = implode("_", $attributeValues);
242 $syncBasalamVariations[$key] = $variant['id'];
243 }
244 }
245
246 foreach ($wcVariations as $key => $wcVarId) {
247 if (isset($syncBasalamVariations[$key])) {
248 update_post_meta($wcVarId, 'sync_basalam_variation_id', $syncBasalamVariations[$key]);
249 }
250 }
251
252 // Some legacy products have a single empty Basalam property
253 // value, so neither side produces a usable property key. A
254 // one-to-one mapping is unambiguous and safe in that case.
255 if (count($variations) === 1 && count($body['variants']) === 1 && !empty($body['variants'][0]['id'])) {
256 update_post_meta($variations[0], 'sync_basalam_variation_id', $body['variants'][0]['id']);
257 }
258 }
259 }
260
261 return $this->finishUpdate($productId, $body, 'فرایند بروزرسانی �
262 حصول با �
263 وفقیت انجا�
264 شد.');
265 }
266
267 private function finishUpdate($productId, $body, string $message): array
268 {
269 update_post_meta($productId, ProductMetaKey::basalamProductSyncStatus(), 'synced');
270
271 $result = [
272 'success' => true,
273 'message' => $message,
274 'status_code' => 200,
275 ];
276
277 do_action('sync_basalam_after_update_product_api', $productId, $body, $result);
278
279 return $result;
280 }
281
282 private function shouldUpdateVariationsSeparately($productId, array $productData): bool
283 {
284 if (empty($productData['variants']) || !is_array($productData['variants'])) return false;
285
286 $product = \wc_get_product($productId);
287 if (!$product || !$product->is_type('variable')) return false;
288
289 $vendorSyncPolicy = syncBasalamContainer()->get(VendorSyncPolicy::class);
290
291 // A limited inactive vendor still uses the product endpoint. Its variants
292 // payload contains price/stock plus the unchanged properties needed to
293 // identify each variant; it must not fall back to one request per stored
294 // variation id because those ids can be recreated by Basalam.
295 if ($vendorSyncPolicy->shouldRestrictUpdateFields(false)) return false;
296
297 // Only the "custom" mode with the variation price/stock fields ticked uses the
298 // dedicated variation endpoint. "All fields" and "price & stock" always send
299 // the complete product payload, exactly like earlier major versions.
300 $syncFields = SettingsManager::getSettings(SettingsConfig::SYNC_PRODUCT_FIELDS);
301 if ($syncFields !== 'custom') return false;
302
303 $syncVariantPrice = SettingsManager::getSettings(SettingsConfig::SYNC_PRODUCT_FIELD_VARIANT_PRICE);
304 $syncVariantStock = SettingsManager::getSettings(SettingsConfig::SYNC_PRODUCT_FIELD_VARIANT_STOCK);
305 if ($syncVariantPrice != 1 && $syncVariantStock != 1) return false;
306
307 // A variation that is not connected to Basalam yet must be created through
308 // the product payload, not the variation endpoint.
309 return UpdateProductVariationsService::allVariantsHaveBasalamId($productData['variants']);
310 }
311
312 private function prepareVariationRemapPayload(int $productId, array $productData): array
313 {
314 $product = \wc_get_product($productId);
315 if (!$product || !$product->is_type('variable')) {
316 throw NonRetryableException::invalidData('�
317 حصول �
318 تغیر برای بازسازی نگاشت‌ها یافت نشد.');
319 }
320
321 $variants = $this->variantDataService->getVariants($product);
322 if (empty($variants)) {
323 throw NonRetryableException::invalidData('اطلاعات �
324 تغیرهای �
325 حصول برای بازسازی نگاشت‌ها کا�
326 ل نیست.');
327 }
328
329 foreach ($variants as &$variant) {
330 unset($variant['id']);
331 }
332 unset($variant);
333
334 // Clear every old id only after the complete replacement payload has
335 // been built. If the following API request fails, the next job retries
336 // the safe full-product remapping path instead of the stale endpoint.
337 foreach ($product->get_children() as $variationId) {
338 delete_post_meta($variationId, 'sync_basalam_variation_id');
339 }
340
341 $productData['variants'] = $variants;
342
343 return $productData;
344 }
345
346 private function hasProductFieldsToUpdate(array $productData): bool
347 {
348 $identifiers = ['id' => true, 'type' => true];
349
350 return !empty(array_diff_key($productData, $identifiers));
351 }
352
353 private function stripForbiddenDescription(NonRetryableException $e, array &$productData, int $productId, int $attempt): bool
354 {
355 if (!isset($productData['description']) || !is_string($productData['description'])) return false;
356
357 $values = DescriptionErrorSanitizer::extractDescriptionValues($e->getResponseData());
358 if (empty($values)) return false;
359
360 $cleaned = DescriptionErrorSanitizer::sanitize($productData['description'], $values);
361 if ($cleaned === $productData['description']) return false;
362
363 $productData['description'] = $cleaned;
364
365 return true;
366 }
367
368 private function retryWithoutDuplicateSku(array &$productData, $error, bool &$retried): bool
369 {
370 if ($retried || !ProductSkuRetry::hasSku($productData)) return false;
371 if (!ProductSkuRetry::isDuplicateSkuError($error)) return false;
372
373 $productData = ProductSkuRetry::withoutSkus($productData);
374 $retried = true;
375
376 return true;
377 }
378
379 public function updateProductStatus($productId, $status)
380 {
381 $vendorSyncPolicy = syncBasalamContainer()->get(VendorSyncPolicy::class);
382 if (!$vendorSyncPolicy->canUpdateProductStatus()) {
383 throw NonRetryableException::invalidData($vendorSyncPolicy->getRestrictionMessage(false));
384 }
385
386 $syncBasalamProductId = get_post_meta($productId, ProductMetaKey::basalamProductId(), true);
387
388 ProductConnection::assertUnique($productId, $syncBasalamProductId);
389
390 $url = sprintf(Endpoints::PRODUCT_UPDATE, $syncBasalamProductId);
391
392 $data = ["status" => $status];
393
394 $data = apply_filters('sync_basalam_product_status_data_before_update', $data, $productId, $status);
395
396 do_action('sync_basalam_before_update_product_status', $productId, $status, $data);
397
398 try {
399 $request = $this->apiservice->patch($url, $data);
400 } catch (RetryableException $e) {
401 throw $e;
402 } catch (NonRetryableException $e) {
403 throw $e;
404 } catch (\Exception $e) {
405 throw NonRetryableException::permanent(esc_html($e->getMessage()));
406 }
407
408 if (!is_wp_error($request)) {
409 update_post_meta($productId, ProductMetaKey::basalamProductSyncStatus(), 'synced');
410 update_post_meta($productId, ProductMetaKey::basalamProductStatus(), $status);
411
412 $result = [
413 'success' => true,
414 'message' => 'وضعیت �
415 حصول با �
416 وفقیت در باسلا�
417 تغییر کرد.',
418 'status_code' => 200,
419 ];
420
421 do_action('sync_basalam_after_update_product_status', $productId, $status, $result);
422
423 return $result;
424 }
425
426 throw NonRetryableException::permanent("تغییر وضعیت �
427 حصول در باسلا�
428 نا�
429 وفق بود.");
430 }
431
432 private function ensureProductSkuUpdated(string $url, array $productData, $responseBody): void
433 {
434 if (!array_key_exists('sku', $productData) || $productData['sku'] === null) return;
435
436 $requestedSku = (string) $productData['sku'];
437 $responseSku = null;
438
439 if (is_array($responseBody) && array_key_exists('sku', $responseBody)) {
440 $responseSku = $responseBody['sku'];
441 }
442
443 if ($responseSku !== null && (string) $responseSku === $requestedSku) return;
444
445 $skuRequest = $this->apiservice->patch($url, ['sku' => $productData['sku']]);
446 if (!is_array($skuRequest) || (int) ($skuRequest['status_code'] ?? 0) !== 200) {
447 throw NonRetryableException::permanent('بروزرسانی شناسه �
448 حصول (SKU) در باسلا�
449 نا�
450 وفق بود.');
451 }
452 }
453 }
454