Введение
Эта статья — история о том, как небольшая команда перевела медицинскую информационную систему с устаревшей платформы 1С 7.7 на 1С 8.3 и выстроила вокруг неё современные практики разработки: контроль версий, код-ревью, автотесты, CI/CD. Дальше — сама история.
В начале двухтысячных классные специалисты разработали систему для клиники на базе 1С 7.7. Функционал включал как административно-хозяйственную часть, так и работу с пациентами. Программный продукт проработал почти два десятилетия, код отладили и протестировали самой жизнью, а идея, что новый код может оказаться лучше старого, казалась совершенно абсурдной. Система при этом технологически устарела: ручное формирование XML отнимало значительное количество времени у программиста, а запросы к БД и обработка данных таблиц для отчётов могли занимать десятки минут. Казалось бы, простые интеграции как XML и REST реализовывали через костыли. Экстренную правку кода приходилось вносить с отключением всех пользователей программы на несколько минут — динамическое обновление для семёрки попросту не разрабатывали.
В 2020 году руководство клиники приняло решение о миграции на новый технологический стек и через год привлекли разработчиков. Всерьёз рассматривался только вариант 1С 8.3: на момент принятия решения других вариантов, сопоставимых по зрелости экосистемы и доступности специалистов на рынке, просто не существовало. Речь шла не о внедрении готового отраслевого решения, а о разработке под одного конкретного заказчика — максимально повторяющей то, к чему сотрудники клиники привыкли за два десятилетия. Устоявшиеся процессы, интерфейсы документов и порядок работы заказчик менять не планировал.
Меня привлекли к задаче в 2023 году — после того как предшествующая команда почти полтора года не могла адаптировать своё решение под клинику. Со слов заказчика, «абсолютно всё» уступало прежнему решению на семёрке, включая скорость работы расписания врачей и записи на приём.
Со стороны заказчика в проекте участвовало заинтересованное руководство: от каждого направления — медицинская часть, финансы, материалы, зарплата и кадры — выделили ответственного сотрудника, который формировал бизнес-требования и участвовал в тестировании. Системный администратор — профессионал своего дела: вопросов и задержек с созданием виртуальных машин, правами доступа и прочим не возникало.
Ресурсы для достижения цели выглядели так: я — руководитель проекта, миддл-разработчик, а также начинающий разработчик после курсов на 0.5 ставки и, в тоже время, бухгалтер по основному месту работы. Помимо разработки, она выполняла роль аналитика отлично закрывала задачи, связанные с учётом. В команду также включили программиста заказчика — впрочем, его участие оказалось скорее номинальным: отдав ему несколько задач, я так и не дождался результата и потерял немало времени. Документация по семёрке отсутствовала — анализ вели по коду и со слов заказчика.
Разработка велась удалённо: в клинику я приезжал лишь несколько раз, и это были скорее ознакомительные визиты, чем рабочие встречи. Обсуждения проходили в Google Meet, а когда сервис начали блокировать, я настроил локальную систему взаимодействия в 1С — это позволило сохранить коммуникацию в полном объёме.
Отдельно стоит сказать, зачем вообще написана эта статья. 1С исторически живёт немного особняком от остальной индустрии разработки. У платформы своя экосистема, свои конференции, свой рынок специалистов. То, что для веб- или бэкенд-разработки давно стало нормой — система контроля версий, код-ревью, автоматизированные тесты, CI/CD, декомпозиция задач через трекер, — в 1С-проектах приживается заметно медленнее. Нередко всё это воспринимается как избыточная роскошь «для энтерпрайза», а не как необходимость для небольших компаний.
Наш опыт показывает, что именно маленькой команде, где каждый человек на счету, автоматизация и общепринятые практики разработки нужны в первую очередь и позволяют выжать максимум из доступных ресурсов.
Дальше — как это выглядело у нас на практике.
1. Анализ и планирование
Первым делом мы разобрали, как работа реально устроена в семёрке. Сущности в целом были разведены аккуратно, например пациенты технически не смешаны с контрагентами. Контролировались договоры системы лояльности как абонементы и сертификаты и многое другое.
Переносить всю архитектуру в 8.3 один в один мы не стали — разные платформы, разная модель метаданных, и, главное, механическое копирование утащило бы в новую систему все компромиссы, накопленные за двадцать лет. Спойлер: кое-что мы всё-таки утащили. Со слов нашего аналитика — в прошлом главного бухгалтера, а на момент проекта джуниор-разработчика, — учёт авансов в семёрке был устроен удобно и просто: усложнение и приведение его к общепринятой модели грозило увеличением сроков, а пользователей вырвало бы из привычной логики работы. Решили взять за основу вариант из семёрки, адаптировать его и уже после старта промышленной эксплуатации что-то менять. Подход оказался верным. Минус в том, что учёт авансов и их зачёта до сих пор считается по логике семёрки, со всеми её ограничениями, а не по более гибкой модели, которую в теории можно было бы заложить с нуля.
Итак, после совещания с командой решено: в основе конфигурации - БСП. Для командной разработки используем хранилище, у каждого разработчика - своя локальная база.
Железо: хост-машина: CPU Intel Xeon E5-2643 (2 физических ядра), 128 ГБ оперативной памяти, СХД. Все наши серверы — виртуальные, на этом же хосте: отдельная машина под прод (12 CPU, 48 ГБ ОЗУ), отдельная под тест (6 ядер, 18 ГБ ОЗУ), обе — под Windows. Каждому разработчику — своя виртуалка: 4 ядра, 8 ГБ ОЗУ. Отдельно, на ещё одной машине, подняли Redmine 5 — как трекер задач и место для пользовательской документации.
Для масштаба: в клинике — порядка 80–100 активных пользователей приложения ежедневно.
2. Почему Redmine?
Небольшие команды — в том числе и в 1С — нередко работают вообще без трекера задач. Таблицы в Excel, договорённости в мессенджере, память тимлида — и какое-то время это действительно работает: меньше бюрократии, быстрее решения, никто не тратит время на заполнение карточек. Обратная сторона проявляется позже: как только что-то идёт не так, оказывается, что никто толком не помнит, кто и когда за что отвечал, а история решений живёт только в головах участников.
На мой взгляд, трекер нужен всегда — и не только для того, чтобы декомпозировать задачи, раздать их и следить за сроками. Есть причина, которая проявляется позже: и при запуске, и через полгода после него, приходится объяснять заказчику, почему сделали именно так, а не иначе. В Redmine мы складывали не только текст задачи, но и видеозаписи созвонов, фото и голосовые из Telegram. Спойлер: это выручило. Когда сроки поплыли, на отчёте я за пару минут поднял скрины и голосовые — и стало видно, из-за каких переделок они сдвинулись.
Мало того, что трекер нужен, так и вести его нужно регулярно и аккуратно. Я просил полного описания задач со всеми файлами внутри. Просматривать каждую и постоянно напоминать об этом утомляло — но со временем привыкли, и дальше это уже не требовало усилий.
Redmine — опен-сорсный трекер, который разворачивается на собственных серверах, а не в чьём-то облаке. После 2022 года это стало условием выживания проекта: облачные SaaS-трекеры зарубежных вендоров одномоментно перестали быть надёжной опорой для бизнеса в России, а Redmine как стоял на своей виртуалке, так и продолжил стоять.
Второй плюс — открытость и API. Трекер можно донастраивать под себя без оглядки на вендора: свои типы задач, свои поля, свои статусы, свои плагины. А через REST API Redmine спокойно встраивается в остальную инфраструктуру — например, чтобы забирать оттуда данные для отчётов или связывать задачи с коммитами.
У нас в проекте это выглядит так: вместо стандартных «Задача/Ошибка» настроено пять типов задач — Задача, Ошибка, Бэклог, Метрики и Релиз (последний — специально под задачи, которые уже проверены пользователями на препроде и готовятся к раскатке). У задач дополнительные поля: ФИО заказчика, отметка и дата, что заказчик проверил решение в препроде, раздел системы, сложность, привязка к вехе, отдельный флаг для KPI-задач. Из железа для Redmine беру: 2 виртуальных ядра, 8 ГБ ОЗУ, диск ~107 ГБ, хост — Hyper-V.
Плагинов теперь на счету семь
Easy Gantt — план-график по вехам
Redmine Checklists (Light) — чек-листы внутри задачи;
Redmine Drawio — диаграммы прямо в вики и задачах;
Image Clipboard Paste — вставка скриншота из буфера обмена в комментарий;
Redmine Dynamic Edit Issue — редактирование полей задачи без перезагрузки страницы, в стиле Jira;
RedmineX Lightbox2 — превью картинок и PDF во вложениях;
Redmine WYSIWYG Editor — визуальный редактор описаний (это и есть модуль visual_editor, который мы видели раньше).
База нашего Redmine работает на MS SQL Server, а не на MySQL/Postgres, которые сами Redmine поддерживает как основные. Почему так: система критичная, в ней живёт и история разработки, и документация, да и на проде уже стоял MS SQL Server под остальные критичные данные клиники — логично было не плодить лишний тип СУБД, а завести Redmine туда же, под общий бэкап и общий контур обслуживания.
Основной гайд по установке : RedmineInstall — redmine.org
Тот же гайд на русском (официальный перевод): RusRedmineInstall — redmine.org Там прямо по шагам: требования (ОС, версия Ruby, СУБД — включая MySQL/PostgreSQL/SQL Server/SQLite), создание пустой базы, bundle install, генерация секретного ключа, миграции, загрузка данных по умолчанию, настройка почты/логов/бэкапов.
3. Тесты
Прототип собрали быстро: мигрировали часть данных из старой системы и показали заказчику. Отзывы были хорошие, к выверке данных подключились сотрудники клиники. Новый функционал и исправления ошибок появлялись каждый день.
Взаимодействие с бизнес-заказчиками строилось так: на первую встречу по новому функционалу выходила вся команда разработки в полном составе. Дальше я делегировал задачу, дробил её на подзадачи, контролировал прогресс и присутствовал на приёмке. А вот внутри самой разработки программист работал автономно — напрямую общался с принимающей стороной, без посредников.
Через пару месяцев случилась крупная неприятность. Разрабатывая новый функционал, внесли изменения в существующую модель, толком не проверив, как это скажется на других блоках. В итоге сломали то, что работало, — и стало ясно: без тестирования на этом этапе уже никак.
Для сценарного тестирования 1С есть готовый опенсорсный стек из трёх частей. В основе — OneScript, среда выполнения скриптов на языке 1С, отдельная от самого 1С:Предприятия (oscript.io). Поверх неё работает vanessa-runner — консольный оркестратор: одной командой собирает конфигурацию из исходников, заливает её в информационную базу и запускает тестирование (github.com/vanessa-opensource/vanessa-runner). А сами тесты крутит Vanessa Automation.
Очевидно, что гонять их руками утомительно, а просить каждого разработчика делать это вручную перед деплоем — так себе идея. Решение на такой случай существует давно — Jenkins, оркестратор, который умеет сам, по расписанию или по событию, собирать проект и прогонять тесты без участия человека. Дженкинс развернули на двух нодах: одна — веб-часть, вторая — на Windows Server 2019, где, собственно, и происходят сборка проекта и тестирование.
При первых прогонах в автоматическом режиме нашлась забавная особенность: чтобы менеджер тестирования конфигурации 1С вообще заработал, пользователь, под которым стартует Jenkins, должен быть залогинен на тестовом сервере — именно залогинен. После перезагрузки я заходил на сервер под учёткой Jenkins по RDP и просто закрывал окно — в диспетчере пользователей сессия помечалась как отключённая, а не завершённая. Только так тесты 1С вообще соглашались запускаться.
Первая версия пайплайна выглядела следующим образом: скрипт забирал конфигурацию из хранилища на Windows-ноду, накатывал cf на отдельную тестовую базу, прогонял тесты — и если всё было зелёным, считалось, что из хранилища можно обновлять рабочую базу. Пайплайн стартовал в 10 вечера, уведомления прилетали на почту, и первое время всё выглядело неплохо.

Изъян вскрылся быстро. Один разработчик протестировал свои изменения на своей базе, счёл код рабочим и поместил в общее хранилище. Следом второй сделал то же самое со своим функционалом. В 22:30 приходит письмо: тестирование упало. Чьи изменения виноваты — непонятно, оба уверены, что у них всё работало. Весь следующий день ушёл на разбор полётов и выборочно коммит одного разработчика из хранилища 1С не убрать.
Стало очевидно, что подход неверный в корне. Проблема не в том, что тесты гоняются автоматически, а в том, что они гоняются постфактум — уже после того, как код оказался в общем хранилище. Нужна была схема, при которой сломанный тестами код физически не может туда попасть: сначала собрать промежуточный результат и прогнать все тесты, и только потом — если всё прошло — пускать изменения в общее хранилище.
4. Путь в GitLab и EDT
Обрисованную в конце прошлого раздела проблему — тестирование постфактум, когда уже поздно разбираться, чьи изменения виноваты, — решил переход на git. Разработчик выделяет для задачи отдельную ветку, коммитит в неё изменения и открывает merge request в GitLab. Дальше всё происходит без моего участия: пайплайн Jenkins прогоняет на отдельной тестовой базе синтаксическую проверку и сценарные тесты по этому мерджу, и только после успешного прогона изменения принимаются в ветку разработки. Отдельно, ещё на этапе MR, GitLab CI прогоняет SonarQube — но не по всей конфигурации, а только по тем строкам, которые изменил разработчик; блокирующими считаются нарушения правил BSL выше определённого уровня критичности. Если раньше падение ночного прогона означало разбор полётов по всем изменениям сразу, то теперь каждый мердж проверяется отдельно — и сразу понятно, чей именно код сломал тесты. Разработчик в течение получаса узнаёт, будет ли принят его код в общую базу, а в случае ошибок видит их и может поправить.
Дальше я бегло просматриваю то, что накопилось в dev, и по готовности переношу в master. По расписанию, несколько раз в неделю, отдельный пайплайн забирает master — если там появились новые коммиты, собирает из него cf, повторяет те же проверки уже на отдельной, предрелизной тестовой базе и заводит в Redmine связанную задачу-релиз: ту самую, о которой шла речь в разделе про Redmine, — со списком вошедших коммитов и задач. Каждую ночь ещё один автоматический процесс подхватывает готовую сборку: бэкапит рабочую базу, накатывает изменения в короткое техническое окно и сам закрывает задачу-релиз, если обновление прошло успешно.

Такой подход потребовал отказаться от хранилища конфигурации в пользу git. Единственным вариантом для этого случая становится 1С:EDT — но как основная среда разработки она у нас не прижилась: непривычно, медленно, а внешнюю обработку отладить в ней сущее мучение. В итоге все остались в конфигураторе, а EDT свели к роли моста между конфигуратором и git. Технически это выглядит так: разработчик берёт задачу в работу, создаёт ответвление от dev, через EDT загружает изменения в свою локальную базу и открывает уже привычный конфигуратор. Реализовав задачу, он возвращается в EDT, выполняет импорт изменений обратно и коммитит их. Конфигурация весит около 210 МБ, на выгрузку с импортом уходит примерно пять-пятнадцать минут — заметно, но не критично.
Для git выбрал GitLab — развернул локально Community Edition, бесплатную версию с открытым кодом для собственного сервера, — и настроил интеграцию с Redmine и Jenkins. При коммите разработчик указывает номер задачи вида «Задача #1234»: Jenkins сам подставляет ссылку на этот коммит в карточку задачи в Redmine, так что по коду однозначно видно, для чего он написан, а по задаче — что именно в неё вошло.
Гладко переход не прошёл. Убеждал, показывал на практике, что значит разветвлённая разработка и почему двадцать минут на выгрузку и импорт изменений — приемлемая цена. Не сразу, но разработчики приняли: если коммит не проходит проверку, нужно разобраться в выводе и влить изменения, устраняющие конфликт, а не спорить, чьи правки виноваты. Люди естественно стремятся работать быстрее и не горят желанием менять привычный процесс, а тут разом навалились и git, и обязательное тестирование: помимо кода разработчик теперь пишет ещё и тесты — пусть всего два-три на задачу.
Плюсов оказалось больше, чем ожидал в начале. Число коммитов первое время снизилось по сравнению с хранилищем, зато качество выросло заметно: ситуации, когда один функционал незаметно ломает другой, практически исчезли. История изменений в GitLab читается быстро — видно, кто, когда и сколько строк поменял, а не просто факт «объект был занят». Комментарии внутри кода перестали писать вовсе — всё, что раньше объясняли прямо в тексте, теперь укладывается в сообщение коммита. Разработчики стали коммитить каждый день, а не раз в несколько дней перед сдачей. Задачи стали длиннее по времени, но вопрос «кто держит объект и когда его отпустит» отпал сам собой — ветки решают его лучше любой блокировки. Появилась возможность держать в работе сразу несколько задач и переключаться между ветками, не сохраняя промежуточные версии cf вручную. К этому стоит добавить то, что верно для git в целом: слияние изменений нескольких разработчиков в общую ветку — штатная операция, а откат одной проблемной правки не требует отменять всё, что успели сделать рядом.
Возросшая прозрачность дала и менее очевидный эффект: мы заметно реже стали возвращаться к уже закрытым задачам, в которых потом находили недочёты. Часть ошибок, которые раньше долетали до общей базы и обнаруживались через недели, теперь ловится ещё на этапе проверки после мерджа — пока автор помнит контекст и стоимость исправления минимальна. Мысль о том, что чем позже находится ошибка, тем дороже её чинить, в разработке ПО звучит не первое десятилетие не просто так.
В итоге на этих трёх системах — GitLab, Jenkins, Redmine — мы построили инфраструктуру, которая делает прозрачной всю историю разработки: от пожелания заказчика до установленного релиза. Контроль качества кода, автоматическое тестирование, сборка и обновление выполняются с минимальным участием человека. При этом по ночам мы спим, а по утрам больше не отвечаем на звонки о том, что база почему-то не работает.
5. Подходы к повышению производительности труда
Свою производительность оценить самому почти невозможно. Работаешь на том максимуме, который сам себе установил, — сравнивать не с чем, кроме вчерашнего себя. Только увидев, как пишет код другой разработчик, начинаешь понимать: то, что казалось потолком, — просто привычка, и есть варианты быстрее.
Несколько лет назад я перешёл на TurboConf — расширение конфигуратора 1С, второе по популярности после Снегопата, если судить по обзору «Помогаторы разработчика 1С» Виталия Онянова. Без него сейчас — как без рук: не в смысле «стало бы медленнее», а в смысле «руки уже не знают, как иначе». Инструмент незаметно стал частью моторики, и его отсутствие ощущается не как неудобство, а как растерянность.
Конкретно на производительность работают три вещи. Автодополнение резко сокращает переключения между раскладками клавиатуры — самую нудную механику в 1С-разработке, где идентификаторы кириллические, а операторы и часть синтаксиса латиница.

Автогенерация описаний процедур и функций — TurboConf в связке со встроенным ИИ-помощником сам формирует заготовку описания по коду метода, а не оставляет это на разработчика.

Автопроверка кода — CodeInspector в связке с BSL Language Server находит скрытые ошибки и опечатки ещё до того, как код попадёт на ревью.
Неожиданный побочный эффект — совместная работа. У TurboConf есть свой форум, куда пользователи приносят пожелания по доработкам, и нумерацию строк в редакторе кода в своё время предложил туда я. К моему удивлению, разработчики его реализовали. Это поменяло разговор с джуном: он не всегда сразу находит нужное условие и фокусируется на проблеме, а когда можно сказать «смотри строку 214» — обсуждение идёт предметно, а не «где-то тут, чуть выше».

Ничего из этого не экзотика. В IntelliJ IDEA то же самое — норма жизни уже второй десяток лет: подсказки по мере набора, документация по наведению курсора, быстрые исправления, живые шаблоны кода. 1С-разработчик просит того же самого, просто на своей платформе.
Есть и другая группа привычек — они не про то, как быстро лично я пишу код, а про то, сколько времени команда потратит на этот код потом, когда его придётся читать, расширять или чинить не мне.
Первая: текст запроса — всегда в отдельной функции. Когда запрос на десяток строк вставлен прямо посреди обработки его же результата, чтение разрывается: глаза сначала продираются через текст на языке запросов, потом возвращаются к тому, что вообще делает процедура. Вынесенный в отдельную функцию с понятным именем запрос читается как одна строка вызова, а логика до и после него остаётся неразрывной. По сути это классический приём Extract Function из «Рефакторинга» Мартина Фаулера, просто применённый к специфике 1С: текст запроса живёт на отдельном языке внутри языка, и чем меньше он перемешан с обычным кодом, тем легче читать и то, и другое.
Вторая — у процедуры или функции всегда один параметр, и это структура, а не три-четыре отдельных аргумента. Проблема давно описана в том же «Рефакторинге» — Long Parameter List, длинный список параметров как запах кода, — а лечится через Introduce Parameter Object. Практическая польза конкретна: через полгода понадобится передать в функцию ещё одно значение — можно просто добавить поле в структуру, и ни одна из существующих точек вызова не сломается. Добавь вместо этого четвёртый позиционный параметр — и придётся руками находить и править каждый вызов по всему проекту.
Третья, по той же причине: лучше возвращать из функции структуру, а не голое значение (исключение — функции, возвращающие Булево, там оборачивать незачем). Голое значение можно вернуть только одно, и если через полгода понадобится вернуть что-то ещё — признак ошибки, диагностику, — придётся менять сигнатуру и переделывать всех, кто уже на эту функцию завязался. Структура расширяется бесплатно: новое поле не трогает ни один существующий вызов.
Четвёртая и пятая касаются работы с базой и связаны между собой. Получать данные лучше языком запросов, а не цепочкой обращений через точку к объектам конфигурации — и, что важнее, лучше один раз забрать из СУБД всё нужное одним запросом, чем дёргать базу по разу на каждую запись в цикле.
Разница с TurboConf принципиальная. Личный инструмент ускоряет меня здесь и сейчас. Эти несколько привычек не ускоряют написание кода вообще — структуру собрать и запрос вынести в отдельную функцию иногда даже дольше, чем написать в лоб. Но код, написанный так, дешевле читать любому, кто откроет его после меня, дешевле расширять, не опасаясь что-то сломать, и не создаёт тех самых лишних обращений к базе, из-за которых потом всей командой ищут, почему форма открывается пятнадцать секунд. Личная производительность разработчика измеряется тем, сколько кода он написал сегодня. Производительность команды — тем, сколько времени все остальные не потеряют на этом коде завтра.
У платформы есть официальный ответ на этот запрос — EDT, среда на базе Eclipse, в которую 1С годами вкладывается как в будущее разработки. Только по отзывам самих разработчиков ровно те же две вещи там реализованы не очень: генерация описаний процедур вызывает нескончаемые проблемы и требует ручной доработки, а с крупными конфигурациями среда откровенно не дружит.
Мнение спорное, но я его придерживаюсь: развивать плагинную экосистему вокруг обычного конфигуратора — с открытым доступом к разработкам сторонних авторов вроде TurboConf, с нормальной интеграцией в git — перспективнее, чем и дальше вкладываться в надстройку над Eclipse. Есть старая программистская шутка: как ускорить Eclipse? Сменить IDE.
Источники:
6. Старт промышленной эксплуатации
Переход на новую систему откладывали несколько раз. Каждый раз находилась причина: то не готов очередной блок функциональности, то руководство просило подождать до конца отчётного периода, то нужно было доучить ключевых пользователей. За это время команда успела обрасти инструментами — тесты, пайплайны, конвенции. Но одно дело проверять систему автоматикой, и совсем другое — выпускать её на живых людей, которые каждый день ведут пациентов и совершенно не обязаны разбираться, почему что-то в новом интерфейсе работает иначе, чем в привычном.
Первые недели после запуска показали то, что обычно и показывают: сухие цифры вовлечённости расходятся с тем, что происходит на местах. Формально систему включили, но по факту часть отделений продолжала работать на старый лад, где это было возможно, и обращалась к новому интерфейсу по минимуму. Стало ясно, что без понимания, кто и что делает в системе, любые решения о её доработке будут приниматься вслепую.
Поэтому логирование действий пользователей расширили заранее — ещё до того, как стало понятно, что оно понадобится в таком объёме. Фиксировалось не только то, какие документы создаются и проводятся, но и переходы между разделами, выбор значений в списках, изменение полей — практически весь путь пользователя внутри системы. На этапе внедрения это выглядело как побочная предосторожность, но именно она впоследствии позволила увидеть, что происходит на самом деле, а не то, что казалось со стороны. Без них никак нельзя стартовать: без такой детализации любая гипотеза о причинах низкой вовлечённости так и осталась бы гипотезой.
Идея из области управления рисками простая: прежде чем что-то пойдёт не так, полезно заранее представить, что именно может пойти не так, и подстраховаться. В проектном управлении для этого есть отдельная техника — «премортем», предложенная психологом Гэри Кляйном: команда заранее представляет, что проект провалился, и разбирает возможные причины провала, чтобы устранить их до старта, а не после. Более глубокое логирование в этом смысле сыграло роль такой подстраховки, хотя изначально задумывалось скорее для контроля качества данных.
Инструкции для пользователей готовили заранее: собрали основные сценарии работы, описали их в вики и с помощью ChatGPT привели текст к понятному виду, без лишних технических терминов. Расчёт был на то, что подробная инструкция снимет большую часть вопросов ещё до того, как они возникнут. На практике вышло иначе. Пользователям неудобно было абсолютно всё, и это всё отнимало больше времени, чем неделей раньше. Инструкция отвечала на вопрос «как сделать», но не снимала раздражения от того, что делать теперь приходится не так, как раньше.
Первые дни промышленной эксплуатации прошли относительно спокойно: обращений было немного, и на выручке клиники не сказывалось. На следующий день, тон сотрудников изменился радикально. Количество обращений выросло, а формулировки стали заметно жёстче. На встрече с руководством и линейными сотрудниками разговор шёл уже не о частностях, а о системе в целом: почему так неудобно, почему так долго, почему никто не предупредил, что будет настолько сложно.
После того как линейные сотрудники объяснили, в чём именно неудобство, я и сам его увидел. Разве в такой ситуации я бы действовал иначе? И вопрос, почему они раньше молчали, тоже стоило задать не им, а себе — обратной связи от пользователей на этапе тестирования было явно недостаточно.
Стало понятно, что часть проблем можно снять только точечными доработками интерфейса под конкретные рабочие места. Такой путём, до интерфейса добавили несколько сокращений и подсказок именно там, где чаще всего спотыкались пользователи — не по общей логике, а по факту обращений, которые теперь было легко увидеть благодаря логам. Это сняло часть напряжения, хотя и не решило проблему целиком: к новой системе всё равно нужно было привыкнуть.
Из этого запуска можно сделать простой вывод: использование уже известных практик — логирования, премортема, обратной связи от пользователей — не изобретение чего-то нового, а способ достичь результата с меньшими потерями. Работать заранее с рисками дешевле, чем разбирать последствия по факту, всё это давно известно.
7. Потери и приобретения
Проект лишился миддл-разработчика. Однако, руководитель проекта — это не только человек, который формулирует задачи на собраниях и распределяет их между исполнителями. Временами приходится засучить рукава и сесть за код самому. Так и вышло. Я стал больше кодить лично и чаще подключаться к консультациям. Увеличившийся объём работы нужно было как-то переварить, и первым делом — искать резервы для повышения производительности. Первым, от чего практически отказались, стала документация, и техническая, и пользовательская: обновлять её по всем правилам вслед за каждой закрытой задачей стало некогда. Следом пострадало ведение вики — страницы стали правиться реже, по остаточному принципу, только когда без этого совсем нельзя обойтись. Короче стало и ревью MR: придирчивый разбор мелких стилистических огрехов уступил место проверке по существу — работает ли, не ломает ли остальное.
Стало ясно, что нужно искать новые решения, и в первую очередь — среди AI-агентов. К тому моменту я уже активно пользовался DeepSeek, но этого было недостаточно: не хватало инструмента, который подключается напрямую к тикет-системе и к GitLab, умеет писать документацию и находить ошибки в коде — универсального помощника разработчика, а не просто чата для вопросов.
Пробовал Cursor. Для 1С не подошёл: качество кода оказалось слабым, инструмент мог придумывать несуществующие методы платформы, а полноценно подключить его к Redmine у меня так и не получилось.
Остановился на Claude. У него есть и десктопная версия, и браузерная, но для такой связки нужна именно десктопная — только в ней можно подключить внешние MCP-серверы, через которые агент получает доступ к GitLab и Redmine. Если совсем просто: MCP, Model Context Protocol — это открытый протокол, по которому агент обращается к внешним системам как к набору инструментов, а не читает их через веб-страницу (modelcontextprotocol.io). Anthropic описывает эту идею как способ дать модели общий, стандартный способ работать с внешними источниками данных и сервисами (Introducing the Model Context Protocol). На практике это означает: на компьютере поднимается отдельный MCP-сервер для каждой системы, свой для GitLab, свой для Redmine, а десктопное приложение подключается к ним. Всю настройку — завести токены доступа, прописать конфигурацию сервера — мне помог сделать сам Claude. Подписки на Claude около 20 долларов в месяц хватает на закрытие всех текущих потребностей проекта. Оплату и активацию подписки сделал через МТС.
Минусы тоже проявились быстро. Агент может коммитить сразу в gitlab, но только если модуль небольшой. В Библиотеке стандартных подсистем (БСП), модули огромные, токенов на них не хватает, и коммит просто не проходит и такие вещи лучше делать руками. С картинками похожая история: добавить скриншот в задачу Redmine через агента — зависает и проще прикрепить вручную. А вот с текстом и небольшими файлами и кодом всё наоборот: это ровно то, для чего инструмент подходит лучше всего.
Приведу пример работы с ошибками нашего приложения. Описан следующий баг: при смене врача в форме подбора даты и времени приёма продолжительность приёма обнулялась и затирала уже введённое значение. Причина скрывалась в паре форм, которые редко смотрят вместе, — форма записи и форма подбора слотов расписания. Агент, работая напрямую с репозиторием через GitLab MCP-сервер, разобрал код, нашёл конкретную процедуру, пересчитывающую итоги по пустой табличной части, и предложил два варианта исправления, оставив выбор бизнес-логики за мной. Раньше на такой разбор ушло бы больше времени просто на воспроизведение бага.
Границы у инструмента тоже видны отчётливо: агент отлично пишет и читает код, находит ошибки по описанию и логам, разбирается в связке форм. Но плохо работает с большими файлами из-за ограничения на объём токенов, не заменяет ручную работу с вложениями и время от времени всё же ошибается — предлагает метод, которого в реальности не существует, и это нужно проверять самому, а не принимать на веру.
Уход сильного разработчика в итоге не застопорил и не обрушил проект — освободившееся место отчасти закрыл сам инструмент. Сейчас в большинстве случаев именно Claude первым разбирается в баге: при каких действиях он проявляется и каким способом его можно исправить. Это не вся работа разработчика, но существенная её часть. Проект пережил уход человека, увеличив производительность за счёт AI-агента.
Заключение
Небольшой команде дисциплина и инструменты нужны сильнее, чем большой. В команде на десять человек слабое место прикрывает кто-то ещё — второй тестировщик, отдельный DevOps-инженер, дублирующий эксперт. В команде на два-три человека прикрывать некому.
За два года систему перевели с 1С 7.7 на 8.3, обросли git, тестами и пайплайнами. Решения на каждом шаге были практически одинаковыми: искать готовую практику вместо своей и платить временем за настройку инструмента один раз, вместо того чтобы платить им же каждый день заново.
Впереди работы не меньше. Интеграция с MAX должна снять часть нагрузки с регистратуры — напоминания и подтверждение записи в мессенджере вместо звонка. С ЕГИСЗ сейчас идёт тестирование обмена СЭМД, электронными медицинскими документами, — сама интеграция уже на подходе. Интерфейс, несмотря на доработки после запуска, всё ещё нуждается в основательной переработке — с оглядкой на то, как люди реально работают за ним.
Приложение. Скрипты пайплайна
В тексте несколько раз шла речь о том, что делают конкретные пайплайны — без самого кода, чтобы не перегружать историю техническими деталями. Для ясности рассказа проверка MR была описана как гейт перед самим мерджем. На практике тесты запускаются сразу после того, как изменения уже попали в dev: если прогон падает, разработчик получает обратную связь и правит следующим коммитом, а не видит заблокированную кнопку слияния в GitLab. Смысл для команды тот же — сломанный код не живёт в общей ветке долго, — но механика чуть более приземлённая, чем в упрощённом описании. Заодно в реальном пайплайне есть шаг, которого в тексте не было: GitLab CI отдельно проверяет само сообщение коммита — номер задачи и минимальную длину, — прежде чем вообще пускать код дальше. К удобству гитлаба я могу исключить отдельные коммиты из ветки dev в течение нескольких минут. При использовании хранилища у нас это было практически невозможно.
gitlab-ci.yml Выполняется при каждом MR
Срабатывает на каждый merge request в dev и на каждый push в саму ветку dev. Первый шаг — validate_commit_message — проверяет последний коммит: есть ли в сообщении номер задачи и не короче ли оно тридцати символов; если нет, MR получает комментарий с описанием проблемы, и пайплайн падает ещё до того, как код доедет до Jenkins. Второй шаг — sonarqube-check — прогоняет статический анализ: на MR только по изменённому диффу, на push в dev — по всей ветке. Оба шага дешёвые и быстрые, поэтому логично ловить простые проблемы здесь, а не тратить время тяжёлого билда в Jenkins на то, что видно за секунды.
# .gitlab-ci.yml stages: - validate - test variables: DOCKER_PULL_POLICY: if-not-present validate_commit_message: stage: validate image: alpine:latest before_script: - apk add --no-cache git curl rules: - if: '$CI_MERGE_REQUEST_IID && $CI_MERGE_REQUEST_TARGET_BRANCH_NAME == "dev"' script: - | echo "=== Проверка последнего коммита в MR !${CI_MERGE_REQUEST_IID} ===" COMMIT=$CI_COMMIT_SHA if [ -z "$COMMIT" ]; then echo "Не удалось определить SHA последнего коммита, проверка пропущена" exit 0 fi MSG=$(git log -1 --pretty=%B $COMMIT) SHORT=$(git log -1 --pretty=%h $COMMIT) echo "Последний коммит: $SHORT" echo "Сообщение: $MSG" FAILED=0 ERROR_MSG="" if ! echo "$MSG" | grep -qE '#[0-9]+'; then ERROR_MSG="${ERROR_MSG}Коммит $SHORT не содержит номера задачи (формат #1234)\n" FAILED=1 fi LEN=$(echo -n "$MSG" | wc -c) if [ "$LEN" -lt 30 ]; then ERROR_MSG="${ERROR_MSG}Коммит $SHORT: длина $LEN символов (нужно ≥30)\n" FAILED=1 fi if [ $FAILED -eq 1 ]; then echo -e "$ERROR_MSG" if [ -n "$GITLAB_API_TOKEN" ]; then curl --request POST --header "PRIVATE-TOKEN: $GITLAB_API_TOKEN" \ "$CI_API_V4_URL/projects/$CI_PROJECT_ID/merge_requests/$CI_MERGE_REQUEST_IID/notes" \ --data "body=**Проверка коммитов не пройдена**\n\n$ERROR_MSG" \ --silent --output /dev/null || true fi exit 1 fi echo "Последний коммит прошёл проверку" sonarqube-check: stage: test image: sonarsource/sonar-scanner-cli:latest rules: - if: '$CI_MERGE_REQUEST_IID && $CI_MERGE_REQUEST_TARGET_BRANCH_NAME == "dev"' - if: '$CI_COMMIT_BRANCH == "dev" && $CI_PIPELINE_SOURCE == "push"' before_script: - git config --global --add safe.directory /builds/pulsedevelopers/pulse - git fetch --unshallow || true - git fetch origin dev:refs/remotes/origin/dev --depth=1 script: - ulimit -v unlimited - ulimit -s 8192 - | if [ -n "$CI_MERGE_REQUEST_IID" ]; then echo "=== Анализ MR #${CI_MERGE_REQUEST_IID}: ${CI_MERGE_REQUEST_SOURCE_BRANCH_NAME} → ${CI_MERGE_REQUEST_TARGET_BRANCH_NAME} ===" sonar-scanner \ -Dsonar.host.url=http://192.168.0.47:9000 \ -Dsonar.token=${SONAR_TOKEN} \ -Dsonar.projectKey=pulse \ -Dsonar.sources=. \ -Dsonar.pullrequest.key=${CI_MERGE_REQUEST_IID} \ -Dsonar.pullrequest.branch=${CI_MERGE_REQUEST_SOURCE_BRANCH_NAME} \ -Dsonar.pullrequest.base=${CI_MERGE_REQUEST_TARGET_BRANCH_NAME} \ -Dsonar.scm.revision=${CI_COMMIT_SHA} \ -Dsonar.qualitygate.wait=true \ -Dsonar.qualitygate.timeout=600 else echo "=== Анализ ветки ${CI_COMMIT_BRANCH} ===" sonar-scanner \ -Dsonar.host.url=http://192.168.0.47:9000 \ -Dsonar.token=${SONAR_TOKEN} \ -Dsonar.projectKey=pulse \ -Dsonar.sources=. \ -Dsonar.branch.name=${CI_COMMIT_BRANCH} fi after_script: - cat sonar.log 2>/dev/null | tail -200 > sonar-error.log || true artifacts: paths: - sonar.log - sonar-error.log expire_in: 1 week
Jenkinsfile.dev при успешном мердже в DEV
Триггерится вебхуком из GitLab на каждый push в dev — то есть ровно тогда, когда мердж уже случился. Пайплайн подтягивает актуальный dev, находит в последнем коммите номера задач и дописывает в Redmine ссылку на коммит прямо в карточку задачи, дальше выгружает конфигурацию через EDT, собирает cf, накатывает его на отдельную тестовую базу и прогоняет синтаксис-проверку и сценарные тесты Vanessa Automation по двум направлениям — Финансы и Медицина. Результаты уходят в Allure, а при падении на любом шаге — письмо на почту. Смысл в том, чтобы узнать о сломанном мердже в течение получаса, а не после того как на нём успеют написать ещё три коммита.

// Jenkinsfile.dev import groovy.json.JsonSlurper import groovy.json.JsonOutput pipeline { agent { label 'windows' } options { disableConcurrentBuilds() } environment { // ------------------ Общие параметры ------------------ SONAR_URL = "http://192.168.0.47:9000" SONAR_PROJECT_KEY = "pulse" SONAR_COMPONENT = 'pulse' SONAR_SEVERITIES = "MAJOR,CRITICAL,BLOCKER" SONAR_DASH_URL = "${SONAR_URL}/dashboard?id=${SONAR_PROJECT_KEY}" WORKSPACE_ROOT = "D:/" WORKSPACE_VANESSA = "D:/vanessa" ALLURE_RESULTS = "allure\\Pulse" GITLAB_REPO_URL = "http://192.168.0.47/pulsedevelopers/pulse.git" GITLAB_BRANCH = "dev" GITLAB_HOST = "http://192.168.0.47" GITLAB_PROJECT_ID = 1 GITLAB_REF_NAME = 'dev' // ------------------ GitLab credentials ------------------ GITLAB_CREDENTIALS_ID = 'gitlab_credentials_id' GITLAB_TOKEN = 'gitlab_token' // ------------------ Redmine ------------------ REDMINE_URL = "http://192.168.0.42" REDMINE_API_KEY = 'redmine_token' REDMINE_PROJECT_ID = 1 REDMINE_TRACKER_ID = 5 REDMINE_TRACKER_METRICS_ID = 9 REDMINE_SUBTRACKER_ID = 5 REDMINE_SUBTASK_TRACKER_ID = 5 REDMINE_FIX_STATUS_ID = 8 REDMINE_CLOSED_STATUS_ID = 5 REDMINE_STATUS_ID1 = 1 REDMINE_PRORITY_ID = 2 DEFAULT_AUTHOR_EMAIL = 'u2mj3gajf@mozmail.com' } post { always { dir('D:/vanessa') { allure includeProperties: false, jdk: '', results: [[path: 'allure\\Pulse']] } dir('D:/') { archiveArtifacts artifacts: '/Jenkins/out/cf/edt/*.cf', onlyIfSuccessful: true } } failure { cmd("echo failure") mail to: 'u2mj3gajf@mozmail.com', subject: "Jenkins Build FAILURE: ${currentBuild.fullDisplayName}", body: """\ Статус: ${currentBuild.currentResult} Номер сборки: ${env.BUILD_NUMBER} Ссылка на билд: ${env.BUILD_URL} """ } success { cmd("echo success") } } stages { stage("Получение ветки с GitLab") { steps { timestamps { git branch: 'dev', credentialsId: 'gitlab_credentials_id', url: 'http://192.168.0.47/pulsedevelopers/pulse.git' } } } stage('Update Redmine Commits Field') { steps { script { echo "Поиск задач для обновления из коммитов..." def commitMessage = bat( script: 'git log -1 --pretty=format:"%%s"', returnStdout: true ).split('\n')[-1].trim() def commitHash = bat( script: 'git rev-parse HEAD', returnStdout: true ).split('\n')[-1].trim() def author = bat( script: 'git log -1 --pretty=format:"%%an"', returnStdout: true ).split('\n')[-1].trim() def datetime = bat( script: 'git log -1 --pretty=format:"%%ad" --date=iso', returnStdout: true ).split('\n')[-1].trim() def taskIds = (commitMessage =~ /(?<!\w)#(\d+)/).collect { it[1] } if (taskIds.isEmpty()) { echo "Нет задач в сообщении коммита. Stage завершён." return } taskIds.each { taskId -> def response try { response = httpRequest( httpMode: 'GET', url: "${env.REDMINE_URL}/issues/${taskId}.json", customHeaders: [[name: 'X-Redmine-API-Key', value: env.REDMINE_API_KEY]], validResponseCodes: '200', consoleLogResponseBody: true ) } catch (err) { echo "Ошибка при получении задачи #${taskId}: ${err}" return } if (!response?.content) { echo "Пустой ответ от Redmine для задачи #${taskId}" return } def issueData = readJSON text: response.content def customField = issueData.issue?.custom_fields?.find { it.id == 3 } if (!customField) { echo "Поле с id=3 не найдено в задаче #${taskId}" return } def currentCommits = (customField.value?.toString() ?: '') def newCommitUrl = "http://192.168.0.47/pulsedevelopers/pulse/-/commit/${commitHash}" def newCommitTextile = "*Commit* \"${commitHash}\":${newCommitUrl}\n*Author*: ${author}\n*Date*: ${datetime}" def alreadyHas = currentCommits.contains(commitHash) def updatedValue = alreadyHas ? currentCommits : (currentCommits ? "${currentCommits}\n\n${newCommitTextile}" : newCommitTextile) def updateBody = [ issue: [ custom_fields: [[ id: 3, value: updatedValue ]] ] ] def jsonPayload = writeJSON returnText: true, json: updateBody try { httpRequest( httpMode: 'PUT', contentType: 'APPLICATION_JSON', requestBody: jsonPayload, url: "${env.REDMINE_URL}/issues/${taskId}.json", customHeaders: [[name: 'X-Redmine-API-Key', value: env.REDMINE_API_KEY]], validResponseCodes: '200,204', consoleLogResponseBody: true ) } catch (err) { echo "Ошибка при обновлении задачи #${taskId}: ${err}" } } } } } stage("Подготовка проекта") { steps { timestamps { cmd("\"C:\\Program Files\\1C\\1CE\\components\\1c-edt-2025.1.4+15-x86_64\\1cedtcli.exe\" -data D:/edt/timofeev1 -command export --project ${WORKSPACE}\\Pulse --configuration-files ${WORKSPACE}"+"\\xml") cmd("vrunner compile --src ${WORKSPACE}\\xml --out D:/Jenkins/out/cf/edt/1Cv8.cf") cmd("runner session kill --ras 192.168.0.46:1545 --cluster-admin admin --cluster-pwd cluster_password --db test_stage_mis --db-user jenkins --db-pwd db_password") cmd("runner session unlock --ras 192.168.0.46:1545 --cluster-admin admin --cluster-pwd cluster_password --db test_stage_mis --db-user jenkins --db-pwd db_password") cmd("vrunner load --src D:/Jenkins/out/cf/edt/1Cv8.cf --ibconnection //S192.168.0.46\\test_stage_mis --db-user jenkins --db-pwd db_password") cmd("vrunner updatedb --ibconnection //S192.168.0.46\\test_stage_mis --db-user jenkins --db-pwd db_password") } } } stage('Синтаксис') { steps { timestamps { cmd("vrunner syntax-check --junitpath D:/Jenkins/out/junit/syntaxCheck.xml --ibconnection //S192.168.0.46\\test_stage_mis --db-user jenkins --db-pwd db_password") } } } stage('Тесты Финанс') { steps { timestamps { cmd("runner vanessa --settings D:/vanessa/features/finance.json --ibconnection //S192.168.0.46\\test_stage_mis --db-user jenkins --db-pwd db_password") } } } stage('Тесты Медицина') { steps { timestamps { cmd("runner vanessa --settings D:/vanessa/features/medical.json --ibconnection //S192.168.0.46\\test_stage_mis --db-user jenkins --db-pwd db_password") } } } } } def cmd(command) { bat "chcp 65001\n ${command}"
Jenkinsfile.master (сборка и релиз мастер-ветки)
Триггер — расписание в Jenkins, вс–чт в 22:00 (по пятницам и субботам сборку не запускают — чтобы не выпускать релиз перед выходными, когда его некому будет откатывать). Пайплайн подтягивает master, сравнивает текущий HEAD с последним успешно собранным коммитом (точка отсчёта хранится в отдельном файле вне workspace — раньше он лежал прямо в workspace и терялся при каждой его очистке) и, если новых коммитов нет, останавливается. Если есть — собирает cf через EDT и vrunner, накатывает его на отдельную предрелизную базу test-main-branch (не на ту же test_stage_mis, что использует dev-пайплайн), гоняет синтаксис-контроль и те же наборы автотестов. Затем по журналу коммитов ищет номера задач и создаёт в Redmine задачу-релиз с перечнем изменений и ссылками на коммиты, привязывая к ней исходные задачи. В конце копирует собранный cf в общую папку, откуда его на следующем шаге заберёт пайплайн раскатки на прод.
Отдельно стоит сказать, что отслеживание «новых коммитов» устроено с запасом прочности: если точка отсчёта почему-то не находится в истории текущего HEAD (например, после принудительного push или переписывания истории), пайплайн не пытается угадать список изменений — код всё равно собирается и тестируется, но релиз в Redmine в этом прогоне не создаётся, а точка отсчёта тихо сбрасывается на текущий коммит, чтобы со следующей сборки отслеживание снова работало само.
// Jenkinsfile.master pipeline { agent { label 'windows' } options { disableConcurrentBuilds() } environment { JAVA_TOOL_OPTIONS = '-Dfile.encoding=UTF-8' HAS_NEW_COMMITS = 'true' // ------------------ Общие параметры ------------------ SONAR_URL = "http://192.168.0.47:9000" SONAR_PROJECT_KEY = "pulse" SONAR_COMPONENT = 'pulse' SONAR_SEVERITIES = "MAJOR,CRITICAL,BLOCKER" SONAR_DASH_URL = "${SONAR_URL}/dashboard?id=${SONAR_PROJECT_KEY}" WORKSPACE_ROOT = "D:/" WORKSPACE_VANESSA = "D:/vanessa" RELEASE_STATE_DIR = "D:/Jenkins/pulse-release-state" ALLURE_RESULTS = "allure\\Pulse" GITLAB_REPO_URL = "http://192.168.0.47/pulsedevelopers/pulse.git" GITLAB_BRANCH = "master" GITLAB_HOST = "http://192.168.0.47" GITLAB_PROJECT_ID = 1 GITLAB_REF_NAME = 'master' // ------------------ GitLab credentials ------------------ GITLAB_CREDENTIALS_ID = 'gitlab_credentials_id' GITLAB_TOKEN = 'gitlab_token' // ------------------ Redmine ------------------ REDMINE_URL = "http://192.168.0.42" REDMINE_API_KEY = 'redmine_token' REDMINE_PROJECT_ID = 1 REDMINE_TRACKER_ID = 5 REDMINE_TRACKER_METRICS_ID = 9 REDMINE_SUBTRACKER_ID = 5 REDMINE_SUBTASK_TRACKER_ID = 5 REDMINE_FIX_STATUS_ID = 8 REDMINE_CLOSED_STATUS_ID = 5 REDMINE_STATUS_ID1 = 1 REDMINE_PRORITY_ID = 2 DEFAULT_AUTHOR_EMAIL = 'se.timofeev@icloud.com' } stages { stage("Получение ветки master с GitLab") { steps { timestamps { git branch: 'master', credentialsId: 'gitlab_credentials_id', url: 'http://192.168.0.47/pulsedevelopers/pulse.git' script { bat(script: 'if exist new_commits.log del /f /q new_commits.log', returnStatus: true) // FIX: папка с состоянием живёт вне workspace и переживает его удаление — // создаём её, если её ещё нет (например, самый первый запуск после этого фикса). bat(script: "if not exist \"${env.RELEASE_STATE_DIR}\" mkdir \"${env.RELEASE_STATE_DIR}\"", returnStatus: true) def commitFile = "${env.RELEASE_STATE_DIR}/last_success_commit.txt" def lastSuccessCommit = null if (fileExists(commitFile)) { def content = readFile(commitFile).trim() def matcher = (content =~ /[a-f0-9]{40}/) if (matcher) { lastSuccessCommit = matcher[0] echo "Last successful commit: ${lastSuccessCommit}" } else { echo "Could not parse commit hash from file, ignoring" } } else { echo "No previous success commit file found" } def rangeArg = null def baselineReset = false if (lastSuccessCommit) { bat(script: 'git fetch --unshallow', returnStatus: true) def isAncestor = bat( script: "git merge-base --is-ancestor ${lastSuccessCommit} HEAD", returnStatus: true ) == 0 if (isAncestor) { rangeArg = "${lastSuccessCommit}..HEAD" echo "Using range: ${rangeArg}" } else { echo "WARNING: Saved commit ${lastSuccessCommit} is not an ancestor of HEAD " + "(history rewritten / force-push / branch reset?). Cannot reliably compute " + "the list of new commits for this build." baselineReset = true } } else { rangeArg = "HEAD" echo "No previous commit. Will take all commits up to HEAD." } if (baselineReset) { writeFile file: 'new_commits.log', text: '' archiveArtifacts artifacts: 'new_commits.log', fingerprint: true def currentCommit = powershell(script: 'git rev-parse HEAD', returnStdout: true).trim() writeFile file: commitFile, text: currentCommit writeFile file: 'last_success_commit.txt', text: currentCommit archiveArtifacts artifacts: 'last_success_commit.txt', fingerprint: true env.HAS_NEW_COMMITS = 'true' env.CAN_CREATE_RELEASE = 'false' echo "Baseline reset to ${currentCommit}. Build/tests will still run, but no " + "Redmine release will be created for this build. Next build resumes normal " + "incremental commit tracking from this point." } else { def psScript = """ \$range = '${rangeArg}' if (\$range -eq 'HEAD') { \$hashes = git rev-list --no-merges HEAD } else { \$hashes = git rev-list --no-merges \$range } if (\$hashes) { \$result = @() foreach (\$hash in \$hashes) { \$msg = git log -1 --pretty=format:%s \$hash \$result += "\$hash|\$msg" } \$result -join "`n" | Out-File -FilePath new_commits.log -Encoding UTF8 Write-Host "Found \$(\$result.Count) new commits" } else { Write-Host "No new commits found" New-Item new_commits.log -Force | Out-Null } """ powershell(psScript) archiveArtifacts artifacts: 'new_commits.log', fingerprint: true def commitsContent = readFile('new_commits.log') echo "new_commits.log content:\n${commitsContent}" env.HAS_NEW_COMMITS = 'false' env.CAN_CREATE_RELEASE = 'false' currentBuild.result = 'ABORTED' error("No new commits since last successful build. Stopping pipeline.") } else { env.HAS_NEW_COMMITS = 'true' env.CAN_CREATE_RELEASE = 'true' } } } } } } stage("Подготовка проекта") { when { expression { env.HAS_NEW_COMMITS == 'true' } } steps { timestamps { cmd("\"C:\\Program Files\\1C\\1CE\\components\\1c-edt-2025.1.4+15-x86_64\\1cedtcli.exe\" -data D:\\edt\\master -command export --project \"${WORKSPACE}\\Pulse\" --configuration-files \"${WORKSPACE}\\xml\"") cmd("vrunner compile --src ${WORKSPACE}\\xml --out ${WORKSPACE}/1Cv8.cf") cmd("runner session kill --ras 192.168.0.46:1545 --cluster-admin admin --cluster-pwd cluster_password --db test-main-branch --db-user jenkins --db-pwd db_password") cmd("runner session unlock --ras 192.168.0.46:1545 --cluster-admin admin --cluster-pwd cluster_password --db test-main-branch --db-user jenkins --db-pwd db_password") cmd("vrunner load --src D:/Jenkins/workspace/master-build/1Cv8.cf --ibconnection //S192.168.0.46\\test-main-branch --db-user jenkins --db-pwd db_password") cmd("vrunner updatedb --ibconnection //S192.168.0.46\\test-main-branch --db-user jenkins --db-pwd db_password") } } } stage('Синтаксис') { // FIX: раньше эта стадия не проверяла HAS_NEW_COMMITS вообще when { expression { env.HAS_NEW_COMMITS == 'true' } } steps { timestamps { cmd("vrunner syntax-check --junitpath D:/Jenkins/out/junit/syntaxCheck.xml --ibconnection //S192.168.0.46\\test-main-branch --db-user jenkins --db-pwd db_password") } } } stage('Тесты Финанс') { when { expression { env.HAS_NEW_COMMITS == 'true' } } steps { timestamps { cmd("runner vanessa --settings D:/vanessa/features/finance.json --ibconnection //S192.168.0.46\\test_stage_mis --db-user jenkins --db-pwd db_password") } } } stage('Тесты Медицина') { when { expression { env.HAS_NEW_COMMITS == 'true' } } steps { timestamps { cmd("runner vanessa --settings D:/vanessa/features/medical.json --ibconnection //S192.168.0.46\\test_stage_mis --db-user jenkins --db-pwd db_password") } } } stage('Create Release in Redmine') { when { allOf { expression { env.CAN_CREATE_RELEASE == 'true' } expression { fileExists('D:/Jenkins/out/cf/edt/1Cv8.cf') } } } steps { script { if (!fileExists('new_commits.log')) { echo "No new_commits.log found, skipping release creation." return } def commitsLog = readFile('new_commits.log').trim().split('\\r?\\n').findAll { it && !it.isEmpty() } if (commitsLog.isEmpty()) { echo "No new commits, skipping release." return } echo "Processing ${commitsLog.size()} commits." def issueIds = [] as Set def commitsByIssue = [:] for (line in commitsLog) { def parts = line.split('\\|', 2) if (parts.size() < 2) continue def hash = parts[0] def matcher = (msg =~ /(?i)(?:задача|task|issue|ошибка|bug)\s*[№#]?\s*(\d{2,6})|#(\d{2,6})\b/) if (matcher.find()) { def issueId = matcher.group(1) ?: matcher.group(2) if (issueId) { issueIds.add(issueId) if (!commitsByIssue.containsKey(issueId)) commitsByIssue[issueId] = [] commitsByIssue[issueId].add("- ${hash.take(7)}: ${msg}") } } } if (issueIds.isEmpty()) { echo "No Redmine issues referenced in commits, release will be created without linked tasks." } else { echo "Found issue IDs: ${issueIds}" } def description = "Новый релиз\n\n## Изменения:\n" for (issueId in issueIds) { def issueSubject = "Задача #${issueId}" try { def issueResp = httpRequest( url: "${env.REDMINE_URL}/issues/${issueId}.json", customHeaders: [[name: 'X-Redmine-API-Key', value: env.REDMINE_API_KEY]], validResponseCodes: '200' ) def issueJson = readJSON text: issueResp.content issueSubject = "${issueJson.issue.subject} (#${issueId})" } catch (err) { echo "Could not fetch issue #${issueId}: ${err}" } description += "* ${issueSubject}\n" def commitLines = commitsByIssue[issueId] if (commitLines) { for (commitLine in commitLines) { description += " ${commitLine}\n" } } description += "\n" } description += "\n---\n**Сборка Jenkins:** ${env.BUILD_NUMBER}\n**Дата:** ${new Date()}" def releasePayload = [ issue: [ project_id : env.REDMINE_PROJECT_ID.toInteger(), tracker_id : 10, subject : "Новый релиз", description: description, status_id : 1, priority_id: 2 ] ] def jsonBody = groovy.json.JsonOutput.toJson(releasePayload) def createResponse = httpRequest( url: "${env.REDMINE_URL}/issues.json", httpMode: 'POST', contentType: 'APPLICATION_JSON', customHeaders: [[name: 'X-Redmine-API-Key', value: env.REDMINE_API_KEY]], requestBody: jsonBody, validResponseCodes: '201' ) def createdIssue = readJSON text: createResponse.content def releaseId = createdIssue.issue.id echo "Release issue created: #${releaseId}" writeFile file: 'release_info.txt', text: "RELEASE_ISSUE_ID=${releaseId}\nBUILD_NUMBER=${env.BUILD_NUMBER}" archiveArtifacts artifacts: 'release_info.txt', fingerprint: true // FIX: .each { } -> for (та же причина, см. комментарий выше) for (issueId in issueIds) { def relationPayload = [ relation: [ issue_to_id: issueId.toInteger(), relation_type: "relates" ] ] def relationBody = groovy.json.JsonOutput.toJson(relationPayload) try { httpRequest( url: "${env.REDMINE_URL}/issues/${releaseId}/relations.json", httpMode: 'POST', contentType: 'APPLICATION_JSON', customHeaders: [[name: 'X-Redmine-API-Key', value: env.REDMINE_API_KEY]], requestBody: relationBody, validResponseCodes: '201' ) echo "Linked issue #${issueId} to release #${releaseId}" } catch (err) { echo "Failed to link issue #${issueId}: ${err}" } } def currentCommit = powershell(script: 'git rev-parse HEAD', returnStdout: true).trim() bat(script: "if not exist \"${env.RELEASE_STATE_DIR}\" mkdir \"${env.RELEASE_STATE_DIR}\"", returnStatus: true) writeFile file: "${env.RELEASE_STATE_DIR}/last_success_commit.txt", text: currentCommit writeFile file: 'last_success_commit.txt', text: currentCommit archiveArtifacts artifacts: 'last_success_commit.txt', fingerprint: true echo "Release process completed successfully." } } } stage('Copy *.cf for prod') { // FIX: тоже стоит не копировать сборку в прод, если новых коммитов не было when { expression { env.HAS_NEW_COMMITS == 'true' } } steps { script { def sourcePath = "D:/Jenkins/workspace/master-build/1cv8.cf" def destinationPath = "D:/repo1c/master-cf/master.cf" bat """ echo Copying file from ${sourcePath} to ${destinationPath} copy /Y "${sourcePath}" "${destinationPath}" """ } } } } } def cmd(String command) { bat """ chcp 65001 >nul ${command} """ }
Jenkinsfile.deploy (раскатка на прод)
Триггер — тоже расписание, ночью, уже после того как master-build успел собрать и оставить master.cf. Пайплайн сначала проверяет, что файл вообще существует и датирован сегодняшним или вчерашним днём (простая защита от накатки протухшей сборки), затем делает полный бэкап MSSQL-базы mis через sqlcmd. Дальше — собственно накатка: рассчитывается короткое окно блокировки (начало через 30 секунд, конец через 20 минут по московскому времени), сессии на проде принудительно завершаются и блокируются на этот интервал, накатывается новый cf, прогоняется обновление конфигурации, после чего блокировка снимается. Применённый файл переименовывается с меткой времени, чтобы его нельзя было случайно накатить повторно. В конце пайплайн находит последнюю открытую задачу-релиз в Redmine и закрывает её, а на почту уходит короткое уведомление об успешном релизе.
// Jenkinsfile.deploy pipeline { agent { label 'windows' } options { disableConcurrentBuilds() } environment { JAVA_TOOL_OPTIONS = '-Dfile.encoding=UTF-8' // ------------------ Общие параметры ------------------ SONAR_URL = "http://192.168.0.47:9000" SONAR_PROJECT_KEY = "pulse" SONAR_COMPONENT = 'pulse' SONAR_SEVERITIES = "MAJOR,CRITICAL,BLOCKER" SONAR_DASH_URL = "${SONAR_URL}/dashboard?id=${SONAR_PROJECT_KEY}" WORKSPACE_ROOT = "D:/" WORKSPACE_VANESSA = "D:/vanessa" ALLURE_RESULTS = "allure\\Pulse" GITLAB_REPO_URL = "http://192.168.0.47/pulsedevelopers/pulse.git" GITLAB_BRANCH = "master" GITLAB_HOST = "http://192.168.0.47" GITLAB_PROJECT_ID = 1 GITLAB_REF_NAME = 'master' // ------------------ GitLab credentials ------------------ GITLAB_CREDENTIALS_ID = 'gitlab_credentials_id' GITLAB_TOKEN = 'gitlab_token' // ------------------ Redmine ------------------ REDMINE_URL = "http://192.168.0.42" REDMINE_API_KEY = 'redmine_token' REDMINE_PROJECT_ID = 1 REDMINE_TRACKER_ID = 5 REDMINE_TRACKER_METRICS_ID = 9 REDMINE_SUBTRACKER_ID = 5 REDMINE_SUBTASK_TRACKER_ID = 5 REDMINE_FIX_STATUS_ID = 8 REDMINE_CLOSED_STATUS_ID = 5 REDMINE_STATUS_ID1 = 1 REDMINE_PRORITY_ID = 2 DEFAULT_AUTHOR_EMAIL = 'se.timofeev@icloud.com' } stages { stage("Проверка master.cf") { agent { label 'windows' } steps { timestamps { cmd(""" if not exist "D:\\repo1c\\master-cf\\master.cf" ( echo Файл master.cf не найден exit /b 1 ) """) cmd(""" for %%f in ("D:\\repo1c\\master-cf\\master.cf") do set FILEDATE=%%~tf rem FILEDATE в формате: 01.12.2025 14:23 for /f "tokens=1 delims= " %%a in ("%FILEDATE%") do set FDATE=%%a rem Получить сегодняшнюю дату for /f "tokens=1 delims= " %%a in ('date /t') do set TODAY=%%a rem Получить дату вчера powershell -command "(Get-Date).AddDays(-1).ToString('dd.MM.yyyy')" > %WORKSPACE%\\yesterday.txt set /p YESTERDAY=<%WORKSPACE%\\yesterday.txt if not "%FDATE%"=="%TODAY%" if not "%FDATE%"=="%YESTERDAY%" ( echo Некорректная дата master.cf. Дата файла: %FDATE%. Ожидалось: сегодня или вчера. exit /b 1 ) echo Файл найден. Дата корректная: %FDATE% """) } } } stage('Бэкап MSSQL mis перед обновлением') { agent { label 'windows' } steps { timestamps { cmd(""" set SERVER=192.168.0.45 set DB=mis set DEST=D:\\mis-bkp-before-update set USER=jenkins set PWD=backup_password if not exist "%DEST%" mkdir "%DEST%" for /f "delims=" %%i in ('powershell -command "(Get-Date).ToString('yyyyMMdd_HHmmss')"') do set DT=%%i set BKPFILE=%DEST%\\mis_full_%DT%.bak echo Старт бэкапа MSSQL %DB% с сервера %SERVER% sqlcmd -S %SERVER% -U %USER% -P %PWD% -Q "BACKUP DATABASE [%DB%] TO DISK='%BKPFILE%' WITH INIT" if %errorlevel% neq 0 ( echo Ошибка при создании бэкапа базы exit /b 1 ) echo Бэкап успешно создан: %BKPFILE% """) } } } stage("Import *.cf to 1C") { agent { label 'windows' } steps { timestamps { script { def now = new Date() def calStart = Calendar.getInstance() calStart.setTime(now) calStart.add(Calendar.SECOND, 30) def calEnd = Calendar.getInstance() calEnd.setTime(now) calEnd.add(Calendar.MINUTE, 20) def sdf = new java.text.SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss") sdf.setTimeZone(TimeZone.getTimeZone("Europe/Moscow")) def startLock = sdf.format(calStart.getTime()) def endLock = sdf.format(calEnd.getTime()) echo "Lock start date (Moscow): ${startLock}" echo "Lock end date (Moscow): ${endLock}" cmd("runner session kill --ras 192.168.0.45:1545 --cluster-admin admin --cluster-pwd cluster_password --db mis --db-user jenkins --db-pwd db_password --uccode 21 --lockstart ${startLock} --lockend ${endLock}") cmd("vrunner load --src D:/repo1c/master-cf/master.cf --ibconnection //S192.168.0.45\\mis --db-user jenkins --db-pwd db_password --uccode 21") cmd("vrunner updatedb --ibconnection //S192.168.0.45\\mis --db-user jenkins --db-pwd db_password --uccode 21") cmd("runner session unlock --ras 192.168.0.45:1545 --cluster-admin admin --cluster-pwd cluster_password --db mis --db-user jenkins --db-pwd db_password") } } } } stage("Переименование master.cf") { agent { label 'windows' } steps { timestamps { cmd(""" rem Генерируем метку времени for /f "delims=" %%i in ('powershell -command "(Get-Date).ToString('yyyyMMdd_HHmmss')"') do set DT=%%i set SRC=D:\\repo1c\\master-cf\\master.cf set NEWNAME=master_applied_%DT%.cf echo Переименовываем файл: %SRC% -> %NEWNAME% ren "%SRC%" "%NEWNAME%" if %errorlevel% neq 0 ( echo Ошибка при переименовании файла exit /b 1 ) echo Готово """) } } } stage("Close Release in Redmine") { steps { script { def projectId = 1 def trackerId = 10 def closedStatusId = 10 def searchUrl = "${env.REDMINE_URL}/issues.json?project_id=${projectId}&tracker_id=${trackerId}&status_id=open&sort=id:desc" echo "Searching for release issues: ${searchUrl}" def response = httpRequest( url: searchUrl, customHeaders: [[name: 'X-Redmine-API-Key', value: env.REDMINE_API_KEY]], validResponseCodes: '200' ) def json = readJSON text: response.content def issues = json.issues if (issues.isEmpty()) { echo "No open release issues found. Nothing to close." return } def releaseIssue = issues[0] def releaseId = releaseIssue.id def releaseSubject = releaseIssue.subject echo "Found release issue #${releaseId}: ${releaseSubject}" def updatePayload = [ issue: [ status_id: closedStatusId ] ] def jsonBody = groovy.json.JsonOutput.toJson(updatePayload) def updateResponse = httpRequest( url: "${env.REDMINE_URL}/issues/${releaseId}.json", httpMode: 'PUT', contentType: 'APPLICATION_JSON', customHeaders: [[name: 'X-Redmine-API-Key', value: env.REDMINE_API_KEY]], requestBody: jsonBody, validResponseCodes: '200,204' ) echo "Release issue #${releaseId} closed (status updated to ${closedStatusId})." } } } stage("Уведомление об успешном релизе") { steps { mail to: 'u2mj3gajf@mozmail.com', subject: 'Релиз установлен', body: 'Релиз успешно установлен.' } } } } def cmd(String command) { bat """ chcp 65001 >nul ${command} """ }

