Сделки из csv (агент)

с периодичностью запускается агент,
если есть csv файл создает контакты и сделки, удаляет файл

Есть
  • проверка на дубли контактов
  • добавление значений списочных свойств, если их нет
use Bitrix\Crm\FieldMultiTable;
use Bitrix\Main\Loader;

class ParserPatientCsv
{
    private const FILE_PATH = "/upload/import_file.csv";
    private const SEPARATOR = ',';
    private const LIST_PROPS = [
        'UF_CRM_1761210004' => 114, //114
        'UF_CRM_1761210195' => 116, //116
        'UF_CRM_1761210342' => 118, //118
        'UF_CRM_1761210413' => 119, //119
        'UF_CRM_1761743809' => 121, //121
    ];
    private const MAPPING = [
        'Центр' => 'UF_CRM_1761210004',
        'Специальность' => 'UF_CRM_1761210195',
        'Страховая' => 'UF_CRM_1761210342',
        'Услуга' => 'UF_CRM_1761210413',
        'Статус_согласования_услуги' => 'UF_CRM_1761743809',
        'Врач' => 'UF_CRM_1761901380',
        'Код_диагноза' => 'UF_CRM_1761901400',
        'Номер_Истории_болезни' => 'UF_CRM_1761744904419',
        'Пациент' => 'UF_CRM_1761746907',
        'Телефон' => 'UF_CRM_1761746943',
        'Комментарий_к_записи' => 'UF_CRM_1761743834',
        'Результат_взаимодействия_с_пациентом' => 'UF_CRM_1761743868',
        'Дата_создания' => 'UF_CRM_1761743940210',
        'Ссылка' => 'UF_CRM_1761210451',
    ];
    private const DEAL_DEFAULT = [
        'ASSIGNED_BY_ID' => 9,
        'CATEGORY_ID' => 0,
        'STAGE_ID' => 'NEW'
    ];

    private const OPTIONS = [
        'CURRENT_USER' => 1
    ];
    private const BP_ID = 9;

    private array $propertyValues = [];
    private array $contacts = [];
    private array $headers = [];
    private array $lines = [];

    private array $newDealsIds = [];
    private array $newContactIds = [];
    private array $errors = [];

    public function parse(): void
    {
        if (!Loader::includeModule("crm")) {
            self::addToLog('error', 'error include crm module');
            return;
        }

        if (!$this->readFile()) {
            self::addToLog('error', 'error read file');
            return;
        }

        if (!$this->parseHeaders()) {
            self::addToLog('error', 'error parse headers');
            return;
        }

        $this->fillContacts();
        $this->fillListProps(); 
        $this->addDeals();

        self::addToLog('Результат обработки CSV', self::formatFinishMessage());
        $this->deleteFile();
    }

    public function parseTest(): void
    {
        if (!$this->readFile()) {
            self::addToLogTest('error', 'error read file');
            return;
        }
        echo '<pre>';
        print_r($this->lines);
        echo '<pre>';

        if (!$this->parseHeaders()) {
            self::addToLogTest('error', 'error parse headers');
            return;
        }

        $this->addDealsTest();

        self::addToLogTest('Результат обработки CSV', self::formatFinishMessage());
    }

    private function readFile(): bool
    {
        $filePath = $_SERVER["DOCUMENT_ROOT"] . self::FILE_PATH;
        if (!file_exists($filePath)) {
            return false;
        }        
        
        $content = file_get_contents($filePath);

        $this->lines = $this->parseCsvLines($content);
        
        return count($this->lines) > 1;
    }
    private function parseCsvLines(string $content): array
    {
        $lines = [];
        $currentLine = '';
        $inQuotes = false;
        $length = strlen($content);
        
        for ($i = 0; $i < $length; $i++) {
            $char = $content[$i];
            $nextChar = $i + 1 < $length ? $content[$i + 1] : '';
            
            if ($char === '"') {
                // Проверяем, это экранированная кавычка или нет
                if ($inQuotes && $nextChar === '"') {
                    $currentLine .= '"';
                    $i++; // Пропускаем следующую кавычку
                } else {
                    $inQuotes = !$inQuotes;
                    $currentLine .= $char;
                }
            } elseif ($char === "\n" && !$inQuotes) {
                // Перенос строки вне кавычек - конец строки CSV
                $lines[] = $currentLine;
                $currentLine = '';
            } elseif ($char === "\r" && $nextChar === "\n" && !$inQuotes) {
                // Windows перенос строки вне кавычек
                $lines[] = $currentLine;
                $currentLine = '';
                $i++; // Пропускаем \n
            } elseif ($char === "\r" && !$inQuotes) {
                // Старый Mac перенос строки вне кавычек
                $lines[] = $currentLine;
                $currentLine = '';
            } else {
                $currentLine .= $char;
            }
        }
        
        // Добавляем последнюю строку
        if ($currentLine !== '') {
            $lines[] = $currentLine;
        }
        
        return $lines;
    }

    private function detectEncoding(string $content): string
    {
        // Проверяем BOM
        if (strncmp($content, "\xEF\xBB\xBF", 3) === 0) {
            return 'UTF-8';
        }
        if (strncmp($content, "\xFF\xFE", 2) === 0) {
            return 'UTF-16LE';
        }
        if (strncmp($content, "\xFE\xFF", 2) === 0) {
            return 'UTF-16BE';
        }
        
        // Пытаемся определить по содержимому
        $encodings = ['UTF-8', 'Windows-1251', 'CP1251', 'ISO-8859-1', 'KOI8-R'];
        
        foreach ($encodings as $encoding) {
            if (mb_check_encoding($content, $encoding)) {
                return $encoding;
            }
        }
        
        // По умолчанию считаем Windows-1251 (как было раньше)
        return 'Windows-1251';
    }

    public function parseHeaders(): bool
    {
        if (empty($this->lines[0])) {
            return false;
        }
        $columns = self::separateLine($this->lines[0]);
        $this->headers = [];

        foreach ($columns as $index => $header) {
            $header = trim($header);
            if (isset(self::MAPPING[$header])) {
                $this->headers[$index] = $header;
            }
        }
        return !empty($this->headers);
    }

    private function fillContacts(): void
    {
        $contactData = $this->parseContactData();
        $phones = array_keys($contactData);
        if (empty($phones)) {
            return;
        }

        $this->loadExistingContacts($phones);
        $this->createMissingContacts($contactData);
    }

    private function parseContactData(): array
    {
        $contactData = [];
        foreach ($this->lines as $lineNum => $line) {
            if ($lineNum == 0) {
                continue;
            }

            $data = self::separateLine($line);
            $phone = '';
            $patientName = '';

            foreach ($this->headers as $index => $header) {
                if (!isset($data[$index])) {
                    continue;
                }

                $value = trim($data[$index]);
                if ($header === 'Телефон' && !empty($value)) {
                    $phone = $value;
                } elseif ($header === 'Пациент' && !empty($value)) {
                    $patientName = $value;
                }
            }

            if (!empty($phone)) {
                $contactData[$phone] = $patientName;
            }
        }
        return $contactData;
    }

    private function loadExistingContacts(array $phones): void
    {
        $dbPhones = FieldMultiTable::getList([
            'select' => ['ELEMENT_ID', 'VALUE'],
            'filter' => [
                'ENTITY_ID' => 'CONTACT',
                'TYPE_ID' => 'PHONE',
                'VALUE' => $phones
            ],
        ]);
        while ($phone = $dbPhones->fetch()) {
            $this->contacts[$phone['VALUE']] = (int)$phone['ELEMENT_ID'];
        }
    }

    private function createMissingContacts(array $phoneToNameMap): void
    {
        $phonesToCreate = array_diff(array_keys($phoneToNameMap), array_keys($this->contacts));
        if (empty($phonesToCreate)) {
            return;
        }

        foreach ($phonesToCreate as $phone) {
            $name = $phoneToNameMap[$phone];
            $contactFields = [
                'NAME' => $name,
                'OPENED' => 'Y',
                'ASSIGNED_BY_ID' => self::DEAL_DEFAULT['ASSIGNED_BY_ID'],
                'TYPE_ID' => 'CLIENT',
                'SOURCE_ID' => 'OTHER',
                'FM' => [
                    'PHONE' => [
                        ['VALUE' => $phone, 'VALUE_TYPE' => 'WORK']
                    ]
                ]
            ];

            $contact = new CCrmContact(false);
            $contactId = $contact->Add($contactFields, true, ['CURRENT_USER' => self::OPTIONS['CURRENT_USER']]);
            if ($contactId > 0) {
                $this->contacts[$phone] = $contactId;
                $this->newContactIds[] = $contactId;
            } else {
                $this->errors[] = $contact->LAST_ERROR ?? '';
            }
        }
    }

    private function fillListProps(): void
    {
        // 1. Сначала загружаем существующие значения списков
        foreach (self::LIST_PROPS as $propertyCode => $propId) {
            $this->propertyValues[$propertyCode] = [];
            $obEnum = new \CUserFieldEnum;
            $rsEnum = $obEnum->GetList([], ["USER_FIELD_ID" => $propId]);

            while ($arEnum = $rsEnum->Fetch()) {
                $this->propertyValues[$propertyCode][$arEnum["ID"]] = $arEnum["VALUE"];
            }
        }

        // 2. Собираем ВСЕ уникальные значения из CSV файла для каждого списка
        $valuesFromCsv = [];
        foreach (self::LIST_PROPS as $propertyCode => $propId) {
            $valuesFromCsv[$propertyCode] = [];
        }

        foreach ($this->lines as $lineNum => $line) {
            if ($lineNum == 0) {
                continue; // Пропускаем заголовок
            }

            $data = self::separateLine($line);

            foreach ($this->headers as $index => $header) {
                if (!isset($data[$index])) {
                    continue;
                }

                $value = trim($data[$index]);
                if (empty($value)) {
                    continue;
                }

                $fieldCode = self::MAPPING[$header];

                // Если это поле списка
                if (isset(self::LIST_PROPS[$fieldCode])) {
                    $valuesFromCsv[$fieldCode][] = $value;
                }
            }
        }

        // 3. Убираем дубликаты
        foreach ($valuesFromCsv as $propertyCode => $csvValues) {
            $valuesFromCsv[$propertyCode] = array_unique($csvValues);
        }

        // 4. Для каждого списка добавляем отсутствующие значения
        foreach ($valuesFromCsv as $propertyCode => $csvValues) {
            $propId = self::LIST_PROPS[$propertyCode];
            $existingValues = array_map('trim', $this->propertyValues[$propertyCode]);

            // Находим значения, которых нет в системе
            $valuesToAdd = array_diff($csvValues, $existingValues);

            if (!empty($valuesToAdd)) {
                $this->addEnumValues($propId, $valuesToAdd, $propertyCode);
            }
        }
    }


    private function addEnumValues(int $fieldId, array $values, string $propertyCode): void
    {
        $obEnum = new \CUserFieldEnum;

        
        $enumValues = [];
        $i = 0;
        foreach ($values as $value) {
            $enumValues["n{$i}"] = ["VALUE" => $value];
            $i++;
        }

       
        $result = $obEnum->SetEnumValues($fieldId, $enumValues);

        if ($result) {
            // Обновляем кэш, загружая добавленные значения
            $rsEnum = $obEnum->GetList([], ["USER_FIELD_ID" => $fieldId]);
            while ($arEnum = $rsEnum->Fetch()) {
                if (!isset($this->propertyValues[$propertyCode][$arEnum["ID"]])) {
                    $this->propertyValues[$propertyCode][$arEnum["ID"]] = $arEnum["VALUE"];
                }
            }

            self::addToLog('Добавлены значения списка',
                "В список {$propertyCode} (ID: {$fieldId}) добавлены значения: " . implode(', ', $values));
        } else {
            $this->errors[] = "Не удалось добавить значения в список {$propertyCode}: " . implode(', ', $values);
        }
    }

    private function getListValue(string $propertyCode, string $value): ?int
    {
        $value = trim($value);
        if (empty($value)) {
            return null;
        }

        foreach ($this->propertyValues[$propertyCode] as $valueId => $propertyValue) {
            if (trim($propertyValue) == $value) {
                return $valueId;
            }
        }

        return null;
    }

    private function addDeals(): void
    {
        $deal = new CCrmDeal(false);

        foreach ($this->lines as $lineNum => $line) {
            if ($lineNum == 0) {
                continue;
            }

            try {
                $dealFields = $this->fillDealFields($line);
                if (empty($dealFields[self::MAPPING['Дата_создания']])) { 
                    //защита от пустой строки
                    continue;
                }
                $dealId = $deal->Add($dealFields, true, self::OPTIONS);

                if ($dealId > 0) {
                    $updateFields = ['TITLE' => 'Сделка №' . $dealId . ' ' . ($dealFields['UF_CRM_1761746907'] ?? '')];
                    $deal->Update($dealId, $updateFields, true, self::OPTIONS);

                    $this->newDealsIds[] = $dealId;
                    if (CModule::IncludeModule('bizproc')) {
                        $arErrors = [];
                        CBPDocument::StartWorkflow(
                            self::BP_ID,
                            ["crm", "CCrmDocumentDeal", "DEAL_" . $dealId],
                            ["TargetUser" => "user_" . self::OPTIONS['CURRENT_USER']],
                            $arErrors
                        );
                    }
                } else {
                    $errorMsg = $deal->LAST_ERROR ?: 'Неизвестная ошибка';
                    $this->errors[] = 'Не добавили строчку ' . $lineNum . ': ' . $errorMsg;
                }
            } catch (Exception $e) {
                $this->errors[] = 'Строка ' . $lineNum . ': ' . $e->getMessage();
            }
        }
    }

    private function addDealsTest(): void
    {
        foreach ($this->lines as $lineNum => $line) {
            if ($lineNum == 0) {
                continue;
            }

            try {
                $dealFields = $this->fillDealFields($line);
                echo '<pre>';
                print_r(self::separateLine($line));
                print_r($dealFields);
                echo '</pre>';
            } catch (Exception $e) {
                $this->errors[] = 'Строка ' . $lineNum . ': ' . $e->getMessage();
            }
        }
    }

    private function fillDealFields(string $line): array
    {
        $arLine = self::separateLine($line);        
        $arDeal = self::DEAL_DEFAULT;
        $arDeal['TITLE'] = 'Сделка № (ID будет добавлен)';

        foreach ($this->headers as $index => $header) {
            if (!isset($arLine[$index])) {
                continue;
            }

            $value = trim($arLine[$index]);
            if (empty($value)) {
                continue;
            }

            $fieldCode = self::MAPPING[$header];

            if (isset(self::LIST_PROPS[$fieldCode])) {
                $listValueId = $this->getListValue($fieldCode, $value);
                if ($listValueId) {
                    $arDeal[$fieldCode] = $listValueId;
                } else {
                    //$arDeal[$fieldCode] = 'test: '.$value;
                }
            } elseif ($header === 'Дата_создания') {
                $timestamp = strtotime($value);
                if ($timestamp !== false) {
                    $arDeal[$fieldCode] = ConvertTimeStamp($timestamp, 'FULL');
                }            
            } elseif ($header === 'Телефон') {
                $arDeal['CONTACT_ID'] = $this->contacts[$value] ?? null;
                $arDeal[$fieldCode] = $value;
            } else {
                $arDeal[$fieldCode] = $value;
            }
        }

        return $arDeal;
    }

    private function deleteFile(): void
    {
        $filePath = $_SERVER["DOCUMENT_ROOT"] . self::FILE_PATH;
        if (file_exists($filePath)) {
            unlink($filePath);
        }
    }

    private function formatFinishMessage(): string
    {
        $message = "Обработка CSV завершена.\n\n";
        $message .= "Успешно создано сделок: " . count($this->newDealsIds) . "\n";
        $message .= "Успешно создано контактов: " . count($this->newContactIds) . "\n";

        if (!empty($this->errors)) {
            $message .= "Ошибок: " . count($this->errors) . "\n";
            $message .= "Список ошибок:\n" . implode("\n", $this->errors);
        }

        return $message;
    }

    private static function separateLine(string $line): array
    {
        $line = trim($line, "\r\n");
        
        return str_getcsv($line, self::SEPARATOR, '"', '\\');
    }

    private static function addToLog(string $subject, string $message): void
    {
        mail('hello@nikaverro.ru', 'ParserPatientCsv: ' . $subject, $message);
        
    }

    private static function addToLogTest(string $subject, string $message): void
    {
        echo $subject.', '.$message;
    }
}


агент в init.php
function parsePatientCsv(): string
{
    try{
        require($_SERVER['DOCUMENT_ROOT'] .'/bitrix/php_interface/include/parserpatientcsv.php');
        $parser = new ParserPatientCsv();
        $parser->parse();
    } catch (\Exception $e) {
        mail('hello@nikaverro.ru', 'error ParserPatientCsv in init ', $e->getMessage());
        return '';
    }
    
    return 'parsePatientCsv();';
}