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
← All changes | includes/Services/Products/UpdateSingleProductService.php +225 -4 1.10.3 → 1.10.28 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,34 +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 103 $maxDescriptionRetries = 3;
35 104 $descriptionRetry = 0;
105 + $skuRetry = false;
36 106
37 107 while (true) {
38 108 try {
39 109 $request = $this->apiservice->patch($url, $productData);
40 - break;
41 110 } catch (RetryableException $e) {
111 + if ($this->retryWithoutDuplicateSku($productData, $e, $skuRetry)) {
112 + continue;
113 + }
114 +
42 115 throw $e;
43 116 } catch (NonRetryableException $e) {
117 + if ($this->retryWithoutDuplicateSku($productData, $e, $skuRetry)) {
118 + continue;
119 + }
120 +
44 121 if ($descriptionRetry < $maxDescriptionRetries && $this->stripForbiddenDescription($e, $productData, $productId, $descriptionRetry)) {
45 122 $descriptionRetry++;
46 123 continue;
47 124 }
125 +
48 126 throw $e;
49 127 } catch (\Exception $e) {
128 + if ($this->retryWithoutDuplicateSku($productData, $e, $skuRetry)) {
129 + continue;
130 + }
131 +
50 132 throw new \Exception(esc_html('خطا در ارتباط با API باسلام: ' . $e->getMessage()));
51 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;
52 141 }
53 142
54 143 $body = $request['body'] ?? '';
55 144
@@ -75,8 +164,17 @@
75 164 }
76 165
77 166 if (is_wp_error($request)) throw NonRetryableException::permanent('خطایی در ارتباط با سرور رخ داد.');
78 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 +
79 177 $product = \wc_get_product($productId);
80 178 if ($product && $product->is_type('variable')) {
81 179 $variations = $product->get_children();
82 180 if (isset($body['variants'])) {
@@ -136,16 +234,32 @@
136 234 if (isset($syncBasalamVariations[$key])) {
137 235 update_post_meta($wcVarId, 'sync_basalam_variation_id', $syncBasalamVariations[$key]);
138 236 }
139 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 + }
245 +
246 + if ($product instanceof \WC_Product_Variable) {
247 + (new VariantService())->syncExpandedVariationIds($product, $body['variants']);
248 + }
140 249 }
141 250 }
142 251
252 + return $this->finishUpdate($productId, $body, 'فرایند بروزرسانی محصول با موفقیت انجام شد.');
253 + }
254 +
255 + private function finishUpdate($productId, $body, string $message): array
256 + {
143 257 update_post_meta($productId, ProductMetaKey::basalamProductSyncStatus(), 'synced');
144 258
145 259 $result = [
146 260 'success' => true,
147 - 'message' => 'فرایند بروزرسانی محصول با موفقیت انجام شد.',
261 + 'message' => $message,
148 262 'status_code' => 200,
149 263 ];
150 264
151 265 do_action('sync_basalam_after_update_product_api', $productId, $body, $result);
@@ -152,8 +266,77 @@
152 266
153 267 return $result;
154 268 }
155 269
270 + private function shouldUpdateVariationsSeparately($productId, array $productData): bool
271 + {
272 + if (empty($productData['variants']) || !is_array($productData['variants'])) return false;
273 +
274 + $product = \wc_get_product($productId);
275 + if (!$product || !$product->is_type('variable')) return false;
276 +
277 + $vendorSyncPolicy = syncBasalamContainer()->get(VendorSyncPolicy::class);
278 +
279 + // A limited inactive vendor still uses the product endpoint. Its variants
280 + // payload contains price/stock plus the unchanged properties needed to
281 + // identify each variant; it must not fall back to one request per stored
282 + // variation id because those ids can be recreated by Basalam.
283 + if ($vendorSyncPolicy->shouldRestrictUpdateFields(false)) return false;
284 +
285 + // Only the "custom" mode with the variation price/stock fields ticked uses the
286 + // dedicated variation endpoint. "All fields" and "price & stock" always send
287 + // the complete product payload, exactly like earlier major versions.
288 + $syncFields = SettingsManager::getSettings(SettingsConfig::SYNC_PRODUCT_FIELDS);
289 + if ($syncFields !== 'custom') return false;
290 +
291 + $syncVariantPrice = SettingsManager::getSettings(SettingsConfig::SYNC_PRODUCT_FIELD_VARIANT_PRICE);
292 + $syncVariantStock = SettingsManager::getSettings(SettingsConfig::SYNC_PRODUCT_FIELD_VARIANT_STOCK);
293 + if ($syncVariantPrice != 1 && $syncVariantStock != 1) return false;
294 +
295 + // A variation that is not connected to Basalam yet must be created through
296 + // the product payload, not the variation endpoint.
297 + return UpdateProductVariationsService::allVariantsHaveBasalamId($productData['variants']);
298 + }
299 +
300 + private function prepareVariationRemapPayload(int $productId, array $productData): array
301 + {
302 + $product = \wc_get_product($productId);
303 + if (!$product || !$product->is_type('variable')) {
304 + throw NonRetryableException::invalidData('محصول متغیر برای بازسازی نگاشت‌ها یافت نشد.');
305 + }
306 +
307 + $variants = $this->variantDataService->getVariants($product);
308 + if (empty($variants)) {
309 + throw NonRetryableException::invalidData('اطلاعات متغیرهای محصول برای بازسازی نگاشت‌ها کامل نیست.');
310 + }
311 +
312 + foreach ($variants as &$variant) {
313 + unset($variant['id']);
314 + }
315 + unset($variant);
316 +
317 + // Clear every old id only after the complete replacement payload has
318 + // been built. If the following API request fails, the next job retries
319 + // the safe full-product remapping path instead of the stale endpoint.
320 + foreach ($product->get_children() as $variationId) {
321 + delete_post_meta($variationId, 'sync_basalam_variation_id');
322 + if (get_post_meta($variationId, 'sync_basalam_variation_map', true)) {
323 + delete_post_meta($variationId, 'sync_basalam_variation_map');
324 + }
325 + }
326 +
327 + $productData['variants'] = $variants;
328 +
329 + return $productData;
330 + }
331 +
332 + private function hasProductFieldsToUpdate(array $productData): bool
333 + {
334 + $identifiers = ['id' => true, 'type' => true];
335 +
336 + return !empty(array_diff_key($productData, $identifiers));
337 + }
338 +
156 339 private function stripForbiddenDescription(NonRetryableException $e, array &$productData, int $productId, int $attempt): bool
157 340 {
158 341 if (!isset($productData['description']) || !is_string($productData['description'])) return false;
159 342
@@ -167,11 +350,30 @@
167 350
168 351 return true;
169 352 }
170 353
354 + private function retryWithoutDuplicateSku(array &$productData, $error, bool &$retried): bool
355 + {
356 + if ($retried || !ProductSkuRetry::hasSku($productData)) return false;
357 + if (!ProductSkuRetry::isDuplicateSkuError($error)) return false;
358 +
359 + $productData = ProductSkuRetry::withoutSkus($productData);
360 + $retried = true;
361 +
362 + return true;
363 + }
364 +
171 365 public function updateProductStatus($productId, $status)
172 366 {
367 + $vendorSyncPolicy = syncBasalamContainer()->get(VendorSyncPolicy::class);
368 + if (!$vendorSyncPolicy->canUpdateProductStatus()) {
369 + throw NonRetryableException::invalidData($vendorSyncPolicy->getRestrictionMessage(false));
370 + }
371 +
173 372 $syncBasalamProductId = get_post_meta($productId, ProductMetaKey::basalamProductId(), true);
373 +
374 + ProductConnection::assertUnique($productId, $syncBasalamProductId);
375 +
174 376 $url = sprintf(Endpoints::PRODUCT_UPDATE, $syncBasalamProductId);
175 377
176 378 $data = ["status" => $status];
177 379
@@ -204,6 +406,25 @@
204 406 return $result;
205 407 }
206 408
207 409 throw NonRetryableException::permanent("تغییر وضعیت محصول در باسلام ناموفق بود.");
410 + }
411 +
412 + private function ensureProductSkuUpdated(string $url, array $productData, $responseBody): void
413 + {
414 + if (!array_key_exists('sku', $productData) || $productData['sku'] === null) return;
415 +
416 + $requestedSku = (string) $productData['sku'];
417 + $responseSku = null;
418 +
419 + if (is_array($responseBody) && array_key_exists('sku', $responseBody)) {
420 + $responseSku = $responseBody['sku'];
421 + }
422 +
423 + if ($responseSku !== null && (string) $responseSku === $requestedSku) return;
424 +
425 + $skuRequest = $this->apiservice->patch($url, ['sku' => $productData['sku']]);
426 + if (!is_array($skuRequest) || (int) ($skuRequest['status_code'] ?? 0) !== 200) {
427 + throw NonRetryableException::permanent('بروزرسانی شناسه محصول (SKU) در باسلام ناموفق بود.');
428 + }
208 429 }
209 430 }