Автоматизация · 1С:ERP
2025
Читает многостраничный проект вентиляции в PDF, находит в нём спецификацию по ГОСТ 21.602-2016, вытаскивает воздуховоды и приводит их к номенклатуре 1С:ERP «Софтвент» — метры превращаются в штуки заводской длины.
01 · Задача
Производитель воздуховодов получает от заказчика комплект проектной документации: PDF, собранный из листов формата А3. Внутри — планы этажей, аксонометрические схемы, таблица характеристик отопительно-вентиляционных систем и, где-то в конце, спецификация изделий и материалов по ГОСТ 21.602-2016.
Чтобы посчитать заказ, менеджер должен перенести из этой спецификации в 1С:ERP «Софтвент» каждый типоразмер: материал, толщину металла, ширину, высоту, количество. В типовом проекте это несколько десятков позиций, и каждая — шесть действий мышью и клавиатурой.
Самое неприятное в этой работе не объём, а единицы измерения. Проектировщик пишет «воздуховод 400×300 — 220 м»: ему важна длина трассы. Производство режет металл хлыстами фиксированной длины, и в номенклатуре ERP та же позиция живёт как «220 м → 190 шт». Пересчёт делается в уме, и именно там появляются ошибки, которые всплывают уже на складе.
Задача системы — снять с человека обе рутины сразу: найти спецификацию в чертеже и перевести её в единицы, на которых работает производство.
02 · Как устроено
Пайплайн односторонний: PDF на входе, JSON на выходе. Промежуточные результаты пишутся на диск, чтобы каждый шаг можно было проверить глазами, не запуская остальные.
PyPDF2 — это быстро и не требует
разбора графики. Страница получает баллы по ключевым словам и относится к одному из
типов: specification, hovs, axonometry,
technical, other. Баллы вместо первого совпадения — потому что
лист аксонометрии почти всегда содержит слово «характеристика», а лист ХОВС — слово
«вентилятор».
шт, м²,
кг) либо больше пятидесяти цифр на страницу.
PdfWriter собирает отдельные файлы specification.pdf,
hovs.pdf, axonometry.pdf. Это не украшение: дальше тяжёлый
табличный разбор запускается только по нужным страницам, а не по всем двадцати девяти.
pdfplumber.extract_tables(): он лучше держит кириллицу.
Если таблицу не нашли, подключается camelot в режиме lattice.
Camelot импортируется в try/except и деградирует молча — он тянет за собой
OpenCV и Ghostscript, которых на машине менеджера может не быть.
Почему не OCR. Все листы проекта — векторные, текст в них есть. OCR добавил бы минуты на страницу и новый класс ошибок (7 против 1, «х» против «x») там, где текст уже лежит в файле. OCR остался в планах только для сканов.
03 · Трудная часть
ГОСТ 21.602-2016 задаёт спецификации девять колонок: поз., наименование и техническая характеристика, тип и марка, код изделия, завод-изготовитель, единица измерения, количество, масса единицы, примечание. В PDF от этой структуры не остаётся почти ничего — лист выгружен из CAD как чертёж, а не как документ.
На листе А3 (1191×842 pt) кроме спецификации есть штамп, графа изменений, поля подшивки.
Все они нарисованы линиями, и extract_tables() честно склеивает их в одну
сетку. На реальных листах получалось 21 и 23 колонки вместо девяти;
полезные данные занимали столбцы 6–20, остальные приходили пустыми.
Подписи боковых полей набраны вертикально. В извлечённой таблице они появляются
как обычные значения, только задом наперёд: онавосалгоС,
атади.пдоП, №.вни.мазВ. Формально это валидные строки, и
отличить их можно только по тому, что они не совпадают ни с одним ожидаемым полем.
Каждая надпись в CAD — отдельный текстовый объект, и при извлечении между ними не
возникает пробела: Кодизделия, Металлдлякрепления,
Наименование итехническая характеристика. Заголовок «Единица измерения»
приходит переносами в четыре строки: Ед./изме-/ре-/ния.
Название клапана переносится на две строки, и хвост второй строки срастается со
значением колонки «тип, марка»: …предел огнестойкости 90 минКПУ-1Н-О-100.
Если разбирать страницу как плоский текст, такая строка не парсится ничем. Именно
поэтому разбор идёт по ячейкам таблицы, а не по строкам текста.
«Арматура воздуховодов», «Воздухораспределители», «Материалы изоляции воздуховодов», «Оборудование» приходят как строки, у которых заполнена одна ячейка из двадцати одной. Их надо не парсить, а пропускать — иначе они становятся позициями с нулевым количеством.
Из-за переносов и потерянных пробелов сопоставление колонок сделано по подстроке в
нижнем регистре («кол», «ед», «наимен»), а чтение
значения строки — с цепочкой запасных ключей. Точное совпадение заголовка не работает
ни на одном реальном листе.
04 · Код
# Начальные баллы scores = {'specification': 0, 'hovs': 0, 'axonometry': 0, 'technical': 0, 'other': 0} if 'СПЕЦИФИКА' in text_upper or 'ВЕДОМОСТ' in text_upper: scores['specification'] += 3 if any(kw in text_upper for kw in ['ПОЗ.', 'НАИМЕНОВАНИЕ', 'ЕДИНИЦА ИЗМЕРЕНИЯ', 'КОД ИЗДЕЛИЯ']): scores['specification'] += 2 if 'ХАРАКТЕРИСТИКА' in text_upper and any(kw in text_upper for kw in ['ВЕНТИЛ', 'ХОВС']): scores['hovs'] += 4 # Найденная таблица весит больше любого слова if page_tables: for table in page_tables: if self.parser._is_specification_table(table): scores['specification'] += 5 break # Разрешение конфликтов: спецификация > ХОВС > аксонометрия > ... ordered = ['specification', 'hovs', 'axonometry', 'technical', 'other'] best = max(ordered, key=lambda k: (scores[k], -ordered.index(k))) return best if scores[best] > 0 else 'other'
Первая версия возвращала тип по первому совпавшему слову — и относила спецификацию к ХОВС, потому что в ней есть слово «характеристика». Баллы плюс явный порядок приоритетов решают конфликт предсказуемо; наличие настоящей таблицы весит 5 и перебивает любое слово.
for start in sorted(spec_pages): i = start + 1 while i <= max_page: # упёрлись в лист другого раздела — стоп if i in pages_by_type['hovs'] or i in pages_by_type['axonometry'] \ or i in pages_by_type['technical']: break t = (page_texts.get(i) or '').upper() # признак продолжения: единицы измерения или плотность цифр if any(u in t for u in [' ШТ', ' М²', ' М2', ' КГ']) \ or sum(ch.isdigit() for ch in t) > 50: spec_pages.add(i) i += 1 continue break
Второй и третий лист спецификации не содержат слова «спецификация» — только шапку таблицы. Прямая проверка по ключевым словам их теряет. Здесь используется другое свойство: страница спецификации — это страница, забитая числами и единицами измерения. Проход останавливается о лист другого типа, чтобы не проглотить весь конец альбома.
if item.unit in ['м', 'м.п.', 'мп']: # метры трассы → миллиметры → штуки по 1160 мм, вверх total_length = item.quantity * 1000 our_quantity = int(total_length / 1160 + 0.99) elif item.unit in ['м2', 'м²']: # площадь развёртки → длина через периметр сечения if item.width and item.height: perimeter = (item.width + item.height) * 2 / 1000 total_length = item.quantity / perimeter * 1000 our_quantity = int(total_length / 1160 + 0.99) else: our_quantity = 0 else: our_quantity = int(item.quantity)
Это тот случай, когда правило важнее кода. 1160 мм — рабочая длина хлыста воздуховода на этом производстве; она задана константой, и от неё считается вся номенклатура. Округление всегда вверх: половина хлыста на складе всё равно занимает целую позицию. Отдельная ветка на м² нужна, потому что часть проектировщиков задаёт объём площадью развёртки листа — тогда длину приходится восстанавливать через периметр сечения, и без габаритов из наименования позиция считаться не может вовсе.
pyautogui.FAILSAFE = True # мышь в угол экрана — аварийный выход pyautogui.PAUSE = 0.5 # 1С успевает перерисовать форму keyboard.add_hotkey('ctrl+shift+p', self.pause_resume) keyboard.add_hotkey('ctrl+shift+s', self.emergency_stop) # окно ищется по заголовку и поднимается наверх win32gui.ShowWindow(self.current_window, win32con.SW_RESTORE) win32gui.SetForegroundWindow(self.current_window) FIELDS = { 'material': {'x': 300, 'y': 250}, 'thickness': {'x': 300, 'y': 290}, 'width_a': {'x': 300, 'y': 330}, 'height_b': {'x': 300, 'y': 370}, 'quantity_n': {'x': 300, 'y': 450}, }
У «Софтвента» нет открытого API, поэтому единственный доступный интерфейс — экранный. Это честно плохой способ, и код построен вокруг признания этого: аварийный выход мышью в угол, две глобальные горячие клавиши, пауза между действиями, диалог «продолжить / пропустить / остановить» на каждой ошибке ввода. Координаты не пишутся руками — есть режим калибровки: оператор наводит курсор на семь элементов формы, программа снимает позиции и сохраняет их в JSON.
05 · Цифры
строк Python в пяти рабочих модулях: табличный парсер, парсер-предшественник, контроллер 1С, запуск и CLI
листов А3 в тестовом проекте: планы, аксонометрия, ХОВС и три листа спецификации
минуты на полный проход одного проекта: классификация 29 страниц плюс табличный разбор
колонки отдаёт extract_tables() на листе, где по ГОСТ их девять
позиции в образце спецификации: 9 типоразмеров воздуховодов и 24 фасонных изделия
трассы воздуховодов в образце — это 476 штук по 1160 мм после пересчёта
| Позиция из спецификации | В проекте | Хлысты 1160 мм |
|---|---|---|
| Воздуховод 400×300 | 220 м | 190 шт |
| Воздуховод 600×500 | 142 м | 123 шт |
| Воздуховод 500×400 | 77 м | 67 шт |
| Воздуховод 700×500 | 32 м | 28 шт |
| Воздуховод 200×300 | 25 м | 22 шт |
| Воздуховод 900×1000 | 5 м | 5 шт |
Шесть строк из девяти. Хорошо видно, зачем нужно округление вверх: 5 м трассы — это пять хлыстов, а не четыре с третью.
06 · Что осталось за кадром
Это рабочий прототип, а не сданный продукт. Ниже — то, что я проверил по коду и логам и что честнее назвать вслух, чем спрятать.
ø160 (U+00F8), а регулярка ищет
[ØDд] с прописной Ø (U+00D8) — совпадения нет. Вторая: толщина
написана словами «толщиной 0,5 мм», а паттерн ждёт δ=0,5. Позиции находятся,
габариты — нет, и в ERP такую строку отдавать нельзя.
int(x / 1160 + 0.99) — не то же самое,
что math.ceil: при дробной части меньше 0,01 оно даёт на штуку меньше.
На встреченных объёмах расхождения не возникает, но правильнее написать ceil.
convert_to_softvent_format
пропускает всё, в наименовании чего нет слова «воздуховод». Отводы, врезки, заглушки
и переходы — это две трети позиций образца.
excel_parser.py, ui_mapper.py,
converter.py, utils/logger.py и config.yaml —
нулевого размера. Разбор Excel описан в README, но не написан: образец
test_spec.xlsx я разбирал вручную. Конфигурация координат из README тоже
не подключена — они лежат константами в классе SoftventElements.
Если бы я продолжал этот проект, порядок был бы такой: сначала набор реальных PDF
от разных проектных бюро как фикстуры и тесты на них, потом словарь написаний вместо
одиночных регулярок (диаметр — это Ø, ø, D,
д и слово «диаметр»), и только потом возврат к автоматизации интерфейса.
Парсер без корпуса примеров всегда выглядит рабочим на том файле, на котором его писали.