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