PluginProbe
ووسلام – همگام سازی ووکامرس و باسلام / 1.10.28
ووسلام – همگام سازی ووکامرس و باسلام v1.10.28
1.10.28 1.1.26 1.10.27 1.10.24 1.10.25 1.10.23 1.10.22 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 All 61 releases
sync-basalam / includes / Services / Products / UpdateSingleProductService.php

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

461 lines 18.9 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 if ($product instanceof \WC_Product_Variable) {
260 (new VariantService())->syncExpandedVariationIds($product, $body['variants']);
261 }
262 }
263 }
264
265 return $this->finishUpdate($productId, $body, 'فرایند بروزرسانی �
266 حصول با �
267 وفقیت انجا�
268 شد.');
269 }
270
271 private function finishUpdate($productId, $body, string $message): array
272 {
273 update_post_meta($productId, ProductMetaKey::basalamProductSyncStatus(), 'synced');
274
275 $result = [
276 'success' => true,
277 'message' => $message,
278 'status_code' => 200,
279 ];
280
281 do_action('sync_basalam_after_update_product_api', $productId, $body, $result);
282
283 return $result;
284 }
285
286 private function shouldUpdateVariationsSeparately($productId, array $productData): bool
287 {
288 if (empty($productData['variants']) || !is_array($productData['variants'])) return false;
289
290 $product = \wc_get_product($productId);
291 if (!$product || !$product->is_type('variable')) return false;
292
293 $vendorSyncPolicy = syncBasalamContainer()->get(VendorSyncPolicy::class);
294
295 // A limited inactive vendor still uses the product endpoint. Its variants
296 // payload contains price/stock plus the unchanged properties needed to
297 // identify each variant; it must not fall back to one request per stored
298 // variation id because those ids can be recreated by Basalam.
299 if ($vendorSyncPolicy->shouldRestrictUpdateFields(false)) return false;
300
301 // Only the "custom" mode with the variation price/stock fields ticked uses the
302 // dedicated variation endpoint. "All fields" and "price & stock" always send
303 // the complete product payload, exactly like earlier major versions.
304 $syncFields = SettingsManager::getSettings(SettingsConfig::SYNC_PRODUCT_FIELDS);
305 if ($syncFields !== 'custom') return false;
306
307 $syncVariantPrice = SettingsManager::getSettings(SettingsConfig::SYNC_PRODUCT_FIELD_VARIANT_PRICE);
308 $syncVariantStock = SettingsManager::getSettings(SettingsConfig::SYNC_PRODUCT_FIELD_VARIANT_STOCK);
309 if ($syncVariantPrice != 1 && $syncVariantStock != 1) return false;
310
311 // A variation that is not connected to Basalam yet must be created through
312 // the product payload, not the variation endpoint.
313 return UpdateProductVariationsService::allVariantsHaveBasalamId($productData['variants']);
314 }
315
316 private function prepareVariationRemapPayload(int $productId, array $productData): array
317 {
318 $product = \wc_get_product($productId);
319 if (!$product || !$product->is_type('variable')) {
320 throw NonRetryableException::invalidData('�
321 حصول �
322 تغیر برای بازسازی نگاشت‌ها یافت نشد.');
323 }
324
325 $variants = $this->variantDataService->getVariants($product);
326 if (empty($variants)) {
327 throw NonRetryableException::invalidData('اطلاعات �
328 تغیرهای �
329 حصول برای بازسازی نگاشت‌ها کا�
330 ل نیست.');
331 }
332
333 foreach ($variants as &$variant) {
334 unset($variant['id']);
335 }
336 unset($variant);
337
338 // Clear every old id only after the complete replacement payload has
339 // been built. If the following API request fails, the next job retries
340 // the safe full-product remapping path instead of the stale endpoint.
341 foreach ($product->get_children() as $variationId) {
342 delete_post_meta($variationId, 'sync_basalam_variation_id');
343 if (get_post_meta($variationId, 'sync_basalam_variation_map', true)) {
344 delete_post_meta($variationId, 'sync_basalam_variation_map');
345 }
346 }
347
348 $productData['variants'] = $variants;
349
350 return $productData;
351 }
352
353 private function hasProductFieldsToUpdate(array $productData): bool
354 {
355 $identifiers = ['id' => true, 'type' => true];
356
357 return !empty(array_diff_key($productData, $identifiers));
358 }
359
360 private function stripForbiddenDescription(NonRetryableException $e, array &$productData, int $productId, int $attempt): bool
361 {
362 if (!isset($productData['description']) || !is_string($productData['description'])) return false;
363
364 $values = DescriptionErrorSanitizer::extractDescriptionValues($e->getResponseData());
365 if (empty($values)) return false;
366
367 $cleaned = DescriptionErrorSanitizer::sanitize($productData['description'], $values);
368 if ($cleaned === $productData['description']) return false;
369
370 $productData['description'] = $cleaned;
371
372 return true;
373 }
374
375 private function retryWithoutDuplicateSku(array &$productData, $error, bool &$retried): bool
376 {
377 if ($retried || !ProductSkuRetry::hasSku($productData)) return false;
378 if (!ProductSkuRetry::isDuplicateSkuError($error)) return false;
379
380 $productData = ProductSkuRetry::withoutSkus($productData);
381 $retried = true;
382
383 return true;
384 }
385
386 public function updateProductStatus($productId, $status)
387 {
388 $vendorSyncPolicy = syncBasalamContainer()->get(VendorSyncPolicy::class);
389 if (!$vendorSyncPolicy->canUpdateProductStatus()) {
390 throw NonRetryableException::invalidData($vendorSyncPolicy->getRestrictionMessage(false));
391 }
392
393 $syncBasalamProductId = get_post_meta($productId, ProductMetaKey::basalamProductId(), true);
394
395 ProductConnection::assertUnique($productId, $syncBasalamProductId);
396
397 $url = sprintf(Endpoints::PRODUCT_UPDATE, $syncBasalamProductId);
398
399 $data = ["status" => $status];
400
401 $data = apply_filters('sync_basalam_product_status_data_before_update', $data, $productId, $status);
402
403 do_action('sync_basalam_before_update_product_status', $productId, $status, $data);
404
405 try {
406 $request = $this->apiservice->patch($url, $data);
407 } catch (RetryableException $e) {
408 throw $e;
409 } catch (NonRetryableException $e) {
410 throw $e;
411 } catch (\Exception $e) {
412 throw NonRetryableException::permanent(esc_html($e->getMessage()));
413 }
414
415 if (!is_wp_error($request)) {
416 update_post_meta($productId, ProductMetaKey::basalamProductSyncStatus(), 'synced');
417 update_post_meta($productId, ProductMetaKey::basalamProductStatus(), $status);
418
419 $result = [
420 'success' => true,
421 'message' => 'وضعیت �
422 حصول با �
423 وفقیت در باسلا�
424 تغییر کرد.',
425 'status_code' => 200,
426 ];
427
428 do_action('sync_basalam_after_update_product_status', $productId, $status, $result);
429
430 return $result;
431 }
432
433 throw NonRetryableException::permanent("تغییر وضعیت �
434 حصول در باسلا�
435 نا�
436 وفق بود.");
437 }
438
439 private function ensureProductSkuUpdated(string $url, array $productData, $responseBody): void
440 {
441 if (!array_key_exists('sku', $productData) || $productData['sku'] === null) return;
442
443 $requestedSku = (string) $productData['sku'];
444 $responseSku = null;
445
446 if (is_array($responseBody) && array_key_exists('sku', $responseBody)) {
447 $responseSku = $responseBody['sku'];
448 }
449
450 if ($responseSku !== null && (string) $responseSku === $requestedSku) return;
451
452 $skuRequest = $this->apiservice->patch($url, ['sku' => $productData['sku']]);
453 if (!is_array($skuRequest) || (int) ($skuRequest['status_code'] ?? 0) !== 200) {
454 throw NonRetryableException::permanent('بروزرسانی شناسه �
455 حصول (SKU) در باسلا�
456 نا�
457 وفق بود.');
458 }
459 }
460 }
461