Лист 05 · разбор проекта

← Каталог работ  ·  English

Автоматизация · 1С:ERP

2025

Softvent Automation

Читает многостраничный проект вентиляции в PDF, находит в нём спецификацию по ГОСТ 21.602-2016, вытаскивает воздуховоды и приводит их к номенклатуре 1С:ERP «Софтвент» — метры превращаются в штуки заводской длины.

Роль
единственный разработчик: архитектура, парсеры, правила пересчёта, UI-автоматизация
Стек
Python 3.12 · pdfplumber · PyPDF2 · camelot · pandas · openpyxl · pyautogui · pywin32
Вход
PDF проектной документации, 29 листов А3 в тестовом наборе
Выход
позиции спецификации в JSON и TXT + разрезанный по смыслу PDF
Объём
2 029 строк Python в пяти рабочих модулях
Статус
рабочий прототип: парсер доведён до конца, автозаполнение 1С — нет

01 · Задача

Спецификация приходит как чертёж, а не как данные

Производитель воздуховодов получает от заказчика комплект проектной документации: PDF, собранный из листов формата А3. Внутри — планы этажей, аксонометрические схемы, таблица характеристик отопительно-вентиляционных систем и, где-то в конце, спецификация изделий и материалов по ГОСТ 21.602-2016.

Чтобы посчитать заказ, менеджер должен перенести из этой спецификации в 1С:ERP «Софтвент» каждый типоразмер: материал, толщину металла, ширину, высоту, количество. В типовом проекте это несколько десятков позиций, и каждая — шесть действий мышью и клавиатурой.

Самое неприятное в этой работе не объём, а единицы измерения. Проектировщик пишет «воздуховод 400×300 — 220 м»: ему важна длина трассы. Производство режет металл хлыстами фиксированной длины, и в номенклатуре ERP та же позиция живёт как «220 м → 190 шт». Пересчёт делается в уме, и именно там появляются ошибки, которые всплывают уже на складе.

Задача системы — снять с человека обе рутины сразу: найти спецификацию в чертеже и перевести её в единицы, на которых работает производство.

02 · Как устроено

Конвейер из пяти шагов, каждый со своим запасным вариантом

Пайплайн односторонний: PDF на входе, JSON на выходе. Промежуточные результаты пишутся на диск, чтобы каждый шаг можно было проверить глазами, не запуская остальные.

  1. Классификация страниц Текст каждой страницы вытаскивается через PyPDF2 — это быстро и не требует разбора графики. Страница получает баллы по ключевым словам и относится к одному из типов: specification, hovs, axonometry, technical, other. Баллы вместо первого совпадения — потому что лист аксонометрии почти всегда содержит слово «характеристика», а лист ХОВС — слово «вентилятор».
  2. Достройка диапазона спецификации Спецификация редко занимает один лист: продолжение идёт без заголовка, только с шапкой таблицы. Поэтому от каждой найденной страницы алгоритм идёт вперёд и присоединяет соседние, пока в тексте встречаются единицы измерения (шт, м², кг) либо больше пятидесяти цифр на страницу.
  3. Разрезание PDF по смыслу PdfWriter собирает отдельные файлы specification.pdf, hovs.pdf, axonometry.pdf. Это не украшение: дальше тяжёлый табличный разбор запускается только по нужным страницам, а не по всем двадцати девяти.
  4. Извлечение таблицы Основной путь — pdfplumber.extract_tables(): он лучше держит кириллицу. Если таблицу не нашли, подключается camelot в режиме lattice. Camelot импортируется в try/except и деградирует молча — он тянет за собой OpenCV и Ghostscript, которых на машине менеджера может не быть.
  5. Приведение к номенклатуре Из наименования регулярками достаются габариты, толщина и обозначение системы; количество переводится в штуки заводской длины. Результат — плоский список словарей, готовый для контроллера 1С.

Почему не 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 · Код

Четыре места, где принимались решения

Классификация страницы баллами, а не первым совпадением src/parsers/table_parser.py
# Начальные баллы
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 и перебивает любое слово.

Продолжение спецификации без заголовка src/parsers/table_parser.py
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

Второй и третий лист спецификации не содержат слова «спецификация» — только шапку таблицы. Прямая проверка по ключевым словам их теряет. Здесь используется другое свойство: страница спецификации — это страница, забитая числами и единицами измерения. Проход останавливается о лист другого типа, чтобы не проглотить весь конец альбома.

Бизнес-правило: метры трассы → штуки хлыста src/parsers/table_parser.py · convert_to_softvent_format
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 мм — рабочая длина хлыста воздуховода на этом производстве; она задана константой, и от неё считается вся номенклатура. Округление всегда вверх: половина хлыста на складе всё равно занимает целую позицию. Отдельная ветка на м² нужна, потому что часть проектировщиков задаёт объём площадью развёртки листа — тогда длину приходится восстанавливать через периметр сечения, и без габаритов из наименования позиция считаться не может вовсе.

Автоматизация чужого интерфейса по координатам src/automation/softvent_controller.py
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 · Цифры

Что измерено на реальном проекте

2 029

строк Python в пяти рабочих модулях: табличный парсер, парсер-предшественник, контроллер 1С, запуск и CLI

29

листов А3 в тестовом проекте: планы, аксонометрия, ХОВС и три листа спецификации

2:32

минуты на полный проход одного проекта: классификация 29 страниц плюс табличный разбор

21–23

колонки отдаёт extract_tables() на листе, где по ГОСТ их девять

33

позиции в образце спецификации: 9 типоразмеров воздуховодов и 24 фасонных изделия

547 м

трассы воздуховодов в образце — это 476 штук по 1160 мм после пересчёта

Извлечённая спецификация: 33 позиции — воздуховоды в метрах, врезки, заглушки, отводы и переходы в штуках
Результат разбора: 33 позиции, приведённые к номенклатуре. Воздуховоды в метрах, фасонные изделия в штуках.
Позиция из спецификацииВ проектеХлысты 1160 мм
Воздуховод 400×300220 м190 шт
Воздуховод 600×500142 м123 шт
Воздуховод 500×40077 м67 шт
Воздуховод 700×50032 м28 шт
Воздуховод 200×30025 м22 шт
Воздуховод 900×10005 м5 шт

Шесть строк из девяти. Хорошо видно, зачем нужно округление вверх: 5 м трассы — это пять хлыстов, а не четыре с третью.

06 · Что осталось за кадром

Границы прототипа

Это рабочий прототип, а не сданный продукт. Ниже — то, что я проверил по коду и логам и что честнее назвать вслух, чем спрятать.

Если бы я продолжал этот проект, порядок был бы такой: сначала набор реальных PDF от разных проектных бюро как фикстуры и тесты на них, потом словарь написаний вместо одиночных регулярок (диаметр — это Ø, ø, D, д и слово «диаметр»), и только потом возврат к автоматизации интерфейса. Парсер без корпуса примеров всегда выглядит рабочим на том файле, на котором его писали.