из import.xml → товар WooCommerce. */ final class Products { private const ZERO_GUID = '00000000-0000-0000-0000-000000000000'; /** Галочка в карточке товара «Не обновлять из 1С» (цены и остатки обновляются всё равно). */ public const LOCK_META = '_gersync_lock'; /** Хеш данных товара из 1С на момент последней обработки — чтобы пропускать неизменённые. */ public const HASH_META = '_gersync_hash'; /** @var array */ private array $terms = []; private ?string $fingerprint = null; public function __construct(private readonly Catalog $catalog, private readonly Images $images) { } public function import(SimpleXMLElement $item): void { $guid = Xml::text($item, 'Ид'); if ($guid === '' || str_contains($guid, '#')) { return; // характеристики («guid#guid») создаются как вариации из offers.xml } $sku = Xml::text($item, 'Артикул'); $product = $this->find($guid, $sku); if (Xml::isDeleted($item)) { if ($product) { Lifecycle::hideWithTranslations($product, $guid); } return; } if ($product && Lifecycle::trashedByUser($product)) { Log::debug('Товар удалён вручную, пропущен: ' . $guid); return; } // Данные в 1С не менялись с прошлого обмена — товар не трогаем (главное ускорение повторных обменов). $hash = $this->hash($item, $product && $product->get_meta(self::LOCK_META) === 'yes'); if ($product && $this->unchanged($product, $hash)) { update_post_meta($product->get_id(), Session::SEEN_META, Session::id()); return; } $name = Xml::text($item, 'Наименование'); $isNew = $product === null; if ($isNew && $name === '') { Log::warning('Товар без наименования пропущен: ' . $guid); return; } if ($isNew) { $product = new WC_Product_Simple(); $product->set_status(Settings::str('new_status')); } else { $product = Lifecycle::restore($product); Lifecycle::restoreTranslations($product, $guid); } // Новый товар заполняем целиком; у существующего — только то, что разрешено // в настройках и не запрещено галочкой в карточке товара. $locked = !$isNew && $product->get_meta(self::LOCK_META) === 'yes'; $update = static fn(string $option): bool => $isNew || (!$locked && Settings::bool($option)); if ($name !== '' && $update('update_name')) { $product->set_name($name); } if ($update('update_sku')) { $this->applySku($product, $sku, $guid); } $this->applyRequisites($product, $item, $update('update_description'), $update('update_weight')); if ($update('update_categories')) { $this->applyCategories($product, $item); } $attributesMode = match (true) { !Settings::bool('import_attributes') => 'none', $isNew => 'sync', $locked => 'none', default => Settings::str('attributes_mode'), }; if ($attributesMode !== 'none') { $this->applyAttributes($product, $item, $attributesMode === 'merge'); } $product->update_meta_data(Repo::META, $guid); $product->update_meta_data(Session::SEEN_META, Session::id()); $product->update_meta_data(self::HASH_META, $hash); $id = $product->save(); Repo::rememberProduct($guid, $id); if ($isNew) { Lang::assignDefaultPost($id); } // Картинкам нужен ID товара, поэтому — после первого сохранения. if ($update('update_images')) { $changed = $this->images->apply($product, Xml::nodes($item, 'Картинка')); if (!$this->images->complete()) { // Картинка не загрузилась — в следующий обмен товар обработаем заново. $product->delete_meta_data(self::HASH_META); $changed = true; } if ($changed) { $product->save(); } } } /** * Можно ли пропустить товар: хеш совпал, обмен не принудительный, товар не скрыт обменом. */ private function unchanged(WC_Product $product, string $hash): bool { return Settings::bool('skip_unchanged') && !Session::isForced() && !Lifecycle::isHidden($product) && $product->get_meta(self::HASH_META) === $hash; } /** * Хеш = XML товара + размеры файлов картинок + настройки, влияющие на результат, + справочник свойств. * Если поменялось что-то из этого — товар обрабатывается заново. */ private function hash(SimpleXMLElement $item, bool $locked): string { $pictures = []; foreach (Xml::nodes($item, 'Картинка') as $picture) { try { $file = Storage::path(trim((string) $picture)); $pictures[] = is_file($file) ? (string) filesize($file) : '-'; } catch (ExchangeException) { $pictures[] = '!'; } } $this->fingerprint ??= md5((string) wp_json_encode([ GERSYNC_VERSION, array_intersect_key(Settings::all(), array_flip([ 'update_name', 'update_description', 'update_categories', 'update_images', 'update_sku', 'update_weight', 'attributes_mode', 'import_attributes', ])), $this->catalog->signature(), ])); return md5($item->asXML() . '|' . implode(',', $pictures) . '|' . $this->fingerprint . '|' . (int) $locked); } 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; // Товар из import.xml не может быть вариацией (совпал артикул вариации) — считаем, что не найден. return $product instanceof WC_Product && !$product instanceof WC_Product_Variation ? $product : null; } private function applySku(WC_Product $product, string $sku, string $guid): void { if ($sku === '' || $product->get_sku() === $sku) { return; } try { $product->set_sku($sku); } catch (\WC_Data_Exception) { Log::warning(sprintf('Артикул «%s» уже занят, товар %s сохранён без него', $sku, $guid)); } } private function applyRequisites(WC_Product $product, SimpleXMLElement $item, bool $updateDescription, bool $updateWeight): void { $description = isset($item->Описание) ? Xml::text($item, 'Описание') : null; foreach (Xml::nodes($item, 'ЗначенияРеквизитов', 'ЗначениеРеквизита') as $requisite) { $key = mb_strtolower(Xml::text($requisite, 'Наименование')); $value = Xml::text($requisite, 'Значение'); if ($key === 'вес' && $updateWeight && ($weight = Xml::number($value)) !== null && $weight > 0) { $product->set_weight((string) wc_format_decimal($weight)); } elseif ($key === 'описаниевформатеhtml' && $value !== '') { $description = $value; } } if ($description !== null && $updateDescription) { $product->set_description(wp_kses_post($description)); } } private function applyCategories(WC_Product $product, SimpleXMLElement $item): void { $ids = []; foreach (Xml::nodes($item, 'Группы', 'Ид') as $groupId) { $termId = Repo::termId(trim((string) $groupId), 'product_cat'); if ($termId > 0) { $ids[] = $termId; } } if ($ids) { $product->set_category_ids($ids); } } /** * Свойства 1С → глобальные атрибуты. * * Атрибуты, добавленные вручную (которых нет среди свойств 1С), и атрибуты вариаций не трогаются никогда. * $merge = true: значения из 1С только добавляются, ничего не удаляется (ручные правки сохраняются). */ private function applyAttributes(WC_Product $product, SimpleXMLElement $item, bool $merge): void { $attributes = $product->get_attributes(); $position = count($attributes); $received = []; foreach (Xml::nodes($item, 'ЗначенияСвойств', 'ЗначенияСвойства') as $propertyValue) { $prop = $this->catalog->property(Xml::text($propertyValue, 'Ид')); $taxonomy = $prop && $prop['attr'] ? wc_attribute_taxonomy_name_by_id($prop['attr']) : ''; if ($taxonomy === '') { continue; } Catalog::ensureTaxonomy($taxonomy); $termIds = []; foreach (Xml::nodes($propertyValue, 'Значение') as $raw) { $value = self::propertyValue($prop['values'], trim((string) $raw)); if ($value !== null && ($termId = $this->term($taxonomy, $value)) > 0) { $termIds[] = $termId; } } if (!$termIds) { continue; } $existing = $attributes[$taxonomy] ?? null; if ($existing instanceof WC_Product_Attribute) { // Клонируем: тот же объект WooCommerce не считает изменённым. $attribute = clone $existing; $options = $merge ? [...$attribute->get_options(), ...$termIds] : $termIds; } else { $attribute = new WC_Product_Attribute(); $attribute->set_id($prop['attr']); $attribute->set_name($taxonomy); $attribute->set_visible(true); $attribute->set_variation(false); $attribute->set_position($position++); $options = $termIds; } // Видимость и порядок, настроенные вручную, сохраняются — меняются только значения. $attribute->set_options(array_values(array_unique(array_map('intval', $options)))); $attributes[$taxonomy] = $attribute; $received[$taxonomy] = true; } if (!$merge) { // Синхронизация: атрибут из 1С, которого больше нет у товара в 1С, убираем. $managed = $this->catalog->managedAttributeIds(); foreach ($attributes as $key => $attribute) { if ($attribute instanceof WC_Product_Attribute && !isset($received[$key]) && !$attribute->get_variation() && in_array($attribute->get_id(), $managed, true)) { unset($attributes[$key]); } } } $product->set_attributes($attributes); } /** * @param array $dictionary значения справочника свойства */ private static function propertyValue(array $dictionary, string $raw): ?string { if ($raw === '' || $raw === self::ZERO_GUID) { return null; } if (isset($dictionary[$raw])) { return $dictionary[$raw]; } if (Xml::isGuid($raw)) { return null; // значение справочника, которого нет в классификаторе } return match ($raw) { 'true' => 'Да', 'false' => 'Нет', default => mb_substr($raw, 0, 190), }; } private function term(string $taxonomy, string $name): int { $key = $taxonomy . '|' . $name; if (isset($this->terms[$key])) { return $this->terms[$key]; } $existing = term_exists($name, $taxonomy); if (is_array($existing)) { return $this->terms[$key] = (int) $existing['term_id']; } $result = wp_insert_term($name, $taxonomy); return $this->terms[$key] = is_wp_error($result) ? (int) $result->get_error_data('term_exists') : (int) $result['term_id']; } }