из offers / prices / rests: цены, остатки и вариации («Ид#Ид»). */ final class Offers { /** @var array Вариативные товары, которые нужно пересчитать в конце шага */ private array $touchedParents = []; public function __construct(private readonly Catalog $catalog) { } public function import(SimpleXMLElement $offer): void { $guid = Xml::text($offer, 'Ид'); if ($guid === '') { return; } $saleType = Settings::str('sale_price_type'); $data = new OfferData( price: $this->price($offer, Settings::str('price_type'), true), sale: $saleType !== '' ? $this->price($offer, $saleType, false) : null, quantity: self::quantity($offer), deleted: Xml::isDeleted($offer), ); [$parentGuid, $characteristic] = array_pad(explode('#', $guid, 2), 2, ''); if ($characteristic !== '') { $this->importVariation($offer, $guid, $parentGuid, $data); return; } $product = $this->find($guid, Xml::text($offer, 'Артикул')); if (!$product || $product->get_status() === 'trash') { Log::debug('Предложение без товара на сайте: ' . $guid); return; } if ($data->deleted || $product->is_type('variable')) { return; // удаление простых товаров приходит в import.xml; цены вариативных — у вариаций } $this->applyPriceStock($product, $data); // Цена и остаток те же — не сохраняем (WooCommerce сам отслеживает реальные изменения). if ($product->get_changes()) { $product->save(); } } /** * Пересчёт цен/остатков вариативных товаров — один раз на шаг, а не на каждую вариацию. */ public function flush(): void { foreach (array_keys($this->touchedParents) as $parentId) { WC_Product_Variable::sync($parentId); wc_delete_product_transients($parentId); } $this->touchedParents = []; } private function find(string $guid, string $sku): ?WC_Product { $id = Repo::productId($guid); if ($id === 0 && $sku !== '' && Settings::bool('match_by_sku')) { $id = (int) wc_get_product_id_by_sku($sku); } $product = $id > 0 ? wc_get_product($id) : null; return $product instanceof WC_Product ? $product : null; } // --- Вариации ----------------------------------------------------------- private function importVariation(SimpleXMLElement $offer, string $guid, string $parentGuid, OfferData $data): void { $parentId = Repo::productId($parentGuid); $parent = $parentId > 0 ? wc_get_product($parentId) : null; if (!$parent instanceof WC_Product || $parent instanceof WC_Product_Variation || $parent->get_status() === 'trash') { Log::debug('Характеристика без товара на сайте: ' . $guid); return; } $variationId = Repo::productId($guid); $variation = $variationId > 0 ? wc_get_product($variationId) : null; $variation = $variation instanceof WC_Product_Variation ? $variation : null; if ($data->deleted) { if ($variation) { Lifecycle::hide($variation); $this->touchedParents[$parentId] = true; } return; } if ($variation && Lifecycle::trashedByUser($variation)) { return; } // Название из скобок берём только для новой вариации: в prices/rests характеристик нет вовсе. $characteristics = self::characteristics($offer, $variation === null); if (!$characteristics && !$variation) { Log::debug('Нет данных для создания вариации: ' . $guid); return; } $parent = $this->makeVariable($parent); if ($characteristics) { $this->addVariationAttributes($parent, $characteristics); } if ($variation) { $variation = Lifecycle::restore($variation); } else { $variation = new WC_Product_Variation(); $variation->set_parent_id($parentId); $variation->set_status('publish'); } if ($characteristics) { $values = []; foreach ($characteristics as $name => $value) { $values[sanitize_title($name)] = $value; } $variation->set_attributes($values); } $this->applyPriceStock($variation, $data); // Сохраняем, только если вариация новая или что-то в ней действительно поменялось. $isNew = $variation->get_id() === 0; $needsGuid = $isNew || get_post_meta($variation->get_id(), Repo::META, true) !== $guid; if ($needsGuid) { $variation->update_meta_data(Repo::META, $guid); } if ($needsGuid || $variation->get_changes()) { Repo::rememberProduct($guid, $variation->save()); $this->touchedParents[$parentId] = true; } } /** * @return array название характеристики => значение */ private static function characteristics(SimpleXMLElement $offer, bool $fromTitle): array { $result = []; foreach (Xml::nodes($offer, 'ХарактеристикиТовара', 'ХарактеристикаТовара') as $c) { $name = Xml::text($c, 'Наименование'); $value = str_replace('|', '/', Xml::text($c, 'Значение')); // «|» — разделитель значений в WooCommerce if ($name !== '' && $value !== '') { $result[$name] = $value; } } if ($result || !$fromTitle) { return $result; } // Нет описания характеристик — берём текст из скобок в наименовании («Футболка (XL, красная)»). $title = Xml::text($offer, 'Наименование'); if ($title === '') { return []; } $label = preg_match('/\(([^()]+)\)\s*$/u', $title, $m) ? trim($m[1]) : $title; return ['Вариант' => str_replace('|', '/', $label)]; } private function makeVariable(WC_Product $product): WC_Product_Variable { if ($product instanceof WC_Product_Variable) { return $product; } $id = $product->get_id(); wp_set_object_terms($id, 'variable', 'product_type'); clean_post_cache($id); wc_delete_product_transients($id); $variable = new WC_Product_Variable($id); $variable->set_manage_stock(false); $variable->save(); return $variable; } /** * @param array $characteristics */ private function addVariationAttributes(WC_Product_Variable $parent, array $characteristics): void { $attributes = $parent->get_attributes(); $changed = false; foreach ($characteristics as $name => $value) { $key = sanitize_title($name); $attribute = $attributes[$key] ?? null; if ($attribute instanceof WC_Product_Attribute && !$attribute->is_taxonomy()) { // Клонируем: тот же объект WooCommerce не считает изменённым и не сохраняет. $attribute = clone $attribute; $options = $attribute->get_options(); if (in_array($value, $options, true) && $attribute->get_variation()) { continue; } if (!in_array($value, $options, true)) { $attribute->set_options([...$options, $value]); } $attribute->set_variation(true); } else { $attribute = new WC_Product_Attribute(); $attribute->set_id(0); $attribute->set_name($name); $attribute->set_options([$value]); $attribute->set_visible(true); $attribute->set_variation(true); $attribute->set_position(count($attributes)); } $attributes[$key] = $attribute; $changed = true; } if ($changed) { $parent->set_attributes($attributes); $parent->save(); } } // --- Цены и остатки ----------------------------------------------------- private function applyPriceStock(WC_Product $product, OfferData $data): void { // Галочки «Цены» / «Остатки» сняты — заполняем только пустые (новый товар), заполненные не трогаем. $hasPrice = $product->get_regular_price() !== ''; $hasStock = $product->get_manage_stock() || $product->get_stock_quantity() !== null; if ($data->price !== null && (!$hasPrice || Settings::bool('update_price'))) { $product->set_regular_price((string) wc_format_decimal($data->price)); if ($data->sale !== null && $data->sale > 0 && $data->sale < $data->price) { $product->set_sale_price((string) wc_format_decimal($data->sale)); } elseif (Settings::str('sale_price_type') !== '' || ($product->get_sale_price() !== '' && (float) $product->get_sale_price() >= $data->price)) { // Скидкой управляет 1С, либо ручная «скидка» стала не меньше обычной цены. $product->set_sale_price(''); } } if ($data->quantity !== null && (!$hasStock || Settings::bool('update_stock'))) { if (Settings::bool('manage_stock')) { $product->set_manage_stock(true); $product->set_stock_quantity(wc_stock_amount($data->quantity)); } else { $product->set_stock_status($data->quantity > 0 ? 'instock' : 'outofstock'); } } } private function price(SimpleXMLElement $offer, string $type, bool $fallbackToFirst): ?float { $prices = Xml::nodes($offer, 'Цены', 'Цена'); if (!$prices) { return null; } if ($type === '') { return $fallbackToFirst ? Xml::number(Xml::text($prices[0], 'ЦенаЗаЕдиницу')) : null; } foreach ($prices as $price) { if ($this->catalog->priceTypeMatches(Xml::text($price, 'ИдТипаЦены'), $type)) { return Xml::number(Xml::text($price, 'ЦенаЗаЕдиницу')); } } return null; } /** * Сумма остатков по всем складам; null — в предложении нет данных об остатках. */ private static function quantity(SimpleXMLElement $offer): ?float { // CommerceML 2.08+: <Остатки><Остаток>[<Склад>]<Количество> $rests = Xml::nodes($offer, 'Остатки', 'Остаток'); if ($rests) { $sum = 0.0; foreach ($rests as $rest) { $sum += Xml::number(Xml::text($rest, 'Количество')) ?? 0.0; foreach (Xml::nodes($rest, 'Склад') as $store) { $sum += Xml::number(Xml::text($store, 'Количество')) ?? 0.0; } } return $sum; } // CommerceML 2.05: <Количество> или <Склад КоличествоНаСкладе="…"/> if (isset($offer->Количество)) { return Xml::number(Xml::text($offer, 'Количество')) ?? 0.0; } $stores = Xml::nodes($offer, 'Склад'); if (!$stores) { return null; } $sum = 0.0; foreach ($stores as $store) { $sum += Xml::number((string) $store['КоличествоНаСкладе']) ?? 0.0; } return $sum; } }