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 +183 -8 1.10.91.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
@@ -13,13 +19,19 @@
13 19 class UpdateSingleProductService
14 20 {
15 21 private $apiservice;
16 22 private $variationsService;
23 + private $variantDataService;
17 24
18 - public function __construct()
25 + public function __construct(
26 + $apiservice = null,
27 + $variationsService = null,
28 + $variantDataService = null
29 + )
19 30 {
20 - $this->apiservice = syncBasalamContainer()->get(ApiServiceManager::class);
21 - $this->variationsService = syncBasalamContainer()->get(UpdateProductVariationsService::class);
31 + $this->apiservice = $apiservice ?: syncBasalamContainer()->get(ApiServiceManager::class);
32 + $this->variationsService = $variationsService ?: syncBasalamContainer()->get(UpdateProductVariationsService::class);
33 + $this->variantDataService = $variantDataService ?: new VariantService();
22 34 }
23 35
24 36 public function updateProductInBasalam($productData, $productId)
25 37 {
@@ -24,9 +36,15 @@
24 36 public function updateProductInBasalam($productData, $productId)
25 37 {
26 38 if (!get_post_type($productId) === 'product') throw NonRetryableException::invalidData('نوع post محصول نیست.');
27 39
40 + $vendorSyncPolicy = syncBasalamContainer()->get(VendorSyncPolicy::class);
41 + if (!$vendorSyncPolicy->canUpdate()) {
42 + throw NonRetryableException::invalidData($vendorSyncPolicy->getRestrictionMessage(false));
43 + }
44 +
28 45 $productData = apply_filters('sync_basalam_product_data_before_update', $productData, $productId);
46 + $productData = $vendorSyncPolicy->restrictUpdatePayload($productData, false);
29 47
30 48 do_action('sync_basalam_before_update_product_api', $productId, $productData);
31 49
32 50 $syncBasalamProductId = get_post_meta($productId, ProductMetaKey::basalamProductId(), true);
@@ -31,39 +49,96 @@
31 49
32 50 $syncBasalamProductId = get_post_meta($productId, ProductMetaKey::basalamProductId(), true);
33 51 ProductConnection::assertUnique($productId, $syncBasalamProductId);
34 52
35 - // Variable products whose variations are all connected to Basalam are updated with one
36 - // request per variation, so price and stock must not be part of the product payload.
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.
37 58 if ($this->shouldUpdateVariationsSeparately($productId, $productData)) {
38 - $this->variationsService->updateVariations($syncBasalamProductId, $productData['variants'], $productId);
59 + $variationMappingRecovered = false;
39 60
40 - unset($productData['variants'], $productData['primary_price'], $productData['stock']);
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;
41 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 +
42 86 if (!$this->hasProductFieldsToUpdate($productData)) {
43 87 return $this->finishUpdate($productId, [], 'متغیرهای محصول با موفقیت بروزرسانی شدند.');
44 88 }
45 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 +
46 101 $url = sprintf(Endpoints::PRODUCT_UPDATE, $syncBasalamProductId);
47 102
48 103 $maxDescriptionRetries = 3;
49 104 $descriptionRetry = 0;
105 + $skuRetry = false;
50 106
51 107 while (true) {
52 108 try {
53 109 $request = $this->apiservice->patch($url, $productData);
54 - break;
55 110 } catch (RetryableException $e) {
111 + if ($this->retryWithoutDuplicateSku($productData, $e, $skuRetry)) {
112 + continue;
113 + }
114 +
56 115 throw $e;
57 116 } catch (NonRetryableException $e) {
117 + if ($this->retryWithoutDuplicateSku($productData, $e, $skuRetry)) {
118 + continue;
119 + }
120 +
58 121 if ($descriptionRetry < $maxDescriptionRetries && $this->stripForbiddenDescription($e, $productData, $productId, $descriptionRetry)) {
59 122 $descriptionRetry++;
60 123 continue;
61 124 }
125 +
62 126 throw $e;
63 127 } catch (\Exception $e) {
128 + if ($this->retryWithoutDuplicateSku($productData, $e, $skuRetry)) {
129 + continue;
130 + }
131 +
64 132 throw new \Exception(esc_html('خطا در ارتباط با API باسلام: ' . $e->getMessage()));
65 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;
66 141 }
67 142
68 143 $body = $request['body'] ?? '';
69 144
@@ -89,8 +164,17 @@
89 164 }
90 165
91 166 if (is_wp_error($request)) throw NonRetryableException::permanent('خطایی در ارتباط با سرور رخ داد.');
92 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 +
93 177 $product = \wc_get_product($productId);
94 178 if ($product && $product->is_type('variable')) {
95 179 $variations = $product->get_children();
96 180 if (isset($body['variants'])) {
@@ -150,8 +234,15 @@
150 234 if (isset($syncBasalamVariations[$key])) {
151 235 update_post_meta($wcVarId, 'sync_basalam_variation_id', $syncBasalamVariations[$key]);
152 236 }
153 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 + }
154 245 }
155 246 }
156 247
157 248 return $this->finishUpdate($productId, $body, 'فرایند بروزرسانی محصول با موفقیت انجام شد.');
@@ -178,11 +269,60 @@
178 269
179 270 $product = \wc_get_product($productId);
180 271 if (!$product || !$product->is_type('variable')) return false;
181 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.
182 293 return UpdateProductVariationsService::allVariantsHaveBasalamId($productData['variants']);
183 294 }
184 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 +
185 325 private function hasProductFieldsToUpdate(array $productData): bool
186 326 {
187 327 $identifiers = ['id' => true, 'type' => true];
188 328
@@ -203,10 +343,26 @@
203 343
204 344 return true;
205 345 }
206 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 +
207 358 public function updateProductStatus($productId, $status)
208 359 {
360 + $vendorSyncPolicy = syncBasalamContainer()->get(VendorSyncPolicy::class);
361 + if (!$vendorSyncPolicy->canUpdateProductStatus()) {
362 + throw NonRetryableException::invalidData($vendorSyncPolicy->getRestrictionMessage(false));
363 + }
364 +
209 365 $syncBasalamProductId = get_post_meta($productId, ProductMetaKey::basalamProductId(), true);
210 366
211 367 ProductConnection::assertUnique($productId, $syncBasalamProductId);
212 368
@@ -243,6 +399,25 @@
243 399 return $result;
244 400 }
245 401
246 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 + }
247 422 }
248 423 }