feat: ImportMapper — универсальный редактор импорта из Excel-опросников

- Профили: 152-ФЗ, 187-ФЗ, 117-ФЗ, МУ, КЗИ, ПЗИ
- Сканирование Excel: что импортируется и куда
- Галочки: включить/выключить поля, добавить свои привязки
- Сохранение настроек по типу опросника
- Выгрузка данных в JSON (export/export_<тип>.json)
- Тесты: отключение полей, пользовательские привязки, секции, ИС
This commit is contained in:
2026-08-03 09:19:16 +04:00
commit 7886edc0af
13 changed files with 966 additions and 0 deletions
+350
View File
@@ -0,0 +1,350 @@
# -*- coding: utf-8 -*-
"""ImportMapper — ядро: профили опросников, сканирование Excel, извлечение данных."""
import os
import re
import json
from datetime import datetime, timedelta
try:
import openpyxl
except ImportError:
openpyxl = None
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
PROFILES_DIR = os.path.join(BASE_DIR, 'profiles')
SETTINGS_DIR = os.path.join(BASE_DIR, 'settings')
EXPORT_DIR = os.path.join(BASE_DIR, 'export')
# ============================================================
# НОРМАЛИЗАЦИЯ ЗНАЧЕНИЙ
# ============================================================
def clean_value(val):
"""Очистка и нормализация значения ячейки Excel."""
if val is None:
return ''
if isinstance(val, (int, float)):
# Попытка распознать дату (серийный номер Excel)
if 10000 < val < 80000:
serial = int(val)
if serial > 60:
serial -= 1
dt = datetime(1899, 12, 30) + timedelta(days=serial)
return dt.strftime('%d.%m.%Y')
if isinstance(val, float) and val == int(val):
return str(int(val))
return str(val)
text = str(val).strip()
text = text.replace('\n', ' ').replace('\r', ' ')
text = ' '.join(text.split())
# Приведение регистра: если текст только из ЗАГЛАВНЫХ
if len(text) > 4 and any('А' <= c <= 'Я' or c == 'Ё' for c in text):
has_lower = any('а' <= c <= 'я' or c == 'ё' for c in text)
if not has_lower:
words = text.split()
text = text.title() if len(words) >= 3 else text.capitalize()
return text
def is_noise_label(val: str) -> bool:
"""Служебные строки опросника, которые импортировать не нужно."""
noise = {'№ п/п', '', 'номер', 'п/п', '№пп', '№ пп', 'значение', 'примечание',
'вопрос', 'ответ', 'выбрать', 'да', 'нет', 'нет данных', 'поле'}
if val.strip() in noise:
return True
if re.fullmatch(r'\d+', val.strip()):
return True
return False
# ============================================================
# ПРОФИЛИ ОПРОСНИКОВ
# ============================================================
def list_profiles():
"""Список доступных профилей опросников."""
result = []
if not os.path.isdir(PROFILES_DIR):
return result
for fn in sorted(os.listdir(PROFILES_DIR)):
if fn.endswith('.json'):
try:
with open(os.path.join(PROFILES_DIR, fn), encoding='utf-8') as f:
profile = json.load(f)
result.append(profile)
except Exception:
pass
return result
def load_profile(profile_id):
for p in list_profiles():
if p.get('id') == profile_id:
return p
return None
def settings_path(profile_id):
os.makedirs(SETTINGS_DIR, exist_ok=True)
return os.path.join(SETTINGS_DIR, f'settings_{profile_id}.json')
def load_settings(profile_id):
try:
with open(settings_path(profile_id), encoding='utf-8') as f:
return json.load(f)
except Exception:
return {'disabled': [], 'custom': []}
def save_settings(profile_id, settings):
with open(settings_path(profile_id), 'w', encoding='utf-8') as f:
json.dump(settings, f, ensure_ascii=False, indent=2)
def is_disabled(settings, key):
return key in settings.get('disabled', [])
# ============================================================
# СКАНИРОВАНИЕ EXCEL
# ============================================================
def scan_excel(filepath, profile):
"""Сканирует Excel по правилам профиля.
Возвращает (detected, unmapped, error):
detected — пары (заголовок → поле), которые импортируются
unmapped — найденные заголовки без привязки
"""
if not openpyxl:
return [], [], 'openpyxl не установлен. Установите: pip install openpyxl'
rules = profile.get('rules', [])
common_sheet = profile.get('common_sheet', 'общие')
employee_sheet = profile.get('employee_sheet', 'сотруд')
detected = []
unmapped = []
try:
wb = openpyxl.load_workbook(filepath, data_only=True, read_only=False)
except Exception as e:
return [], [], f'Не удалось открыть файл: {e}'
try:
for sheet_name in wb.sheetnames:
sheet_lower = sheet_name.lower()
if common_sheet and common_sheet.lower() in sheet_lower:
sheet_type = 'common'
elif employee_sheet and employee_sheet.lower() in sheet_lower:
sheet_type = 'employees'
else:
sheet_type = 'is'
ws = wb[sheet_name]
for row in ws.iter_rows(min_row=1, values_only=True):
label = str(row[0]).strip() if row and row[0] is not None else ''
value = str(row[1]).strip() if len(row) > 1 and row[1] is not None else ''
if not label:
continue
label_lower = label.lower()
mapped = False
for rule in rules:
pattern = rule.get('pattern', '')
if pattern and re.search(pattern, label_lower):
detected.append({
'field': rule.get('field', ''),
'excel_text': label,
'value': value[:60],
'sheet': sheet_name,
'desc': rule.get('desc', rule.get('field', '')),
})
mapped = True
break
if not mapped:
if is_noise_label(label_lower):
continue
unmapped.append({
'excel_text': label,
'sheet': sheet_name,
'suggested': _suggest_field(label, sheet_type, profile),
})
finally:
try:
wb.close()
except Exception:
pass
return detected, unmapped, ''
def _suggest_field(value, sheet_type, profile):
"""Пытается предположить, какому полю соответствует заголовок."""
val = value.lower().strip()
if is_noise_label(val):
return ''
for rule in profile.get('rules', []):
pattern = rule.get('pattern', '')
if pattern and re.search(pattern, val):
return rule.get('field', '')
keywords = {
'fullName': ['полное наименование', 'наименование организации', 'полное название'],
'shortName': ['сокращенное наименование', 'краткое наименование', 'сокращ'],
'addressLegal': ['адрес', 'место нахождения'],
'chiefFio': ['руководител', 'глава', 'директор'],
'inn': ['инн', 'идентификационный номер'],
'ogrn': ['огрн', 'основной государствен'],
'kpp': ['кпп'],
'phone': ['телефон', 'контактный'],
'email': ['почта', 'email', 'e-mail'],
'is_name': ['наименование ис', 'название ис', 'наименование информационной'],
'is_software': ['программное обеспечение', 'программный'],
}
for field, kws in keywords.items():
for kw in kws:
if kw in val:
return field
return 'unknown_' + value[:20].replace(' ', '_').lower()
# ============================================================
# ИЗВЛЕЧЕНИЕ ДАННЫХ (ПО НАСТРОЙКАМ РЕДАКТОРА)
# ============================================================
def extract_data(filepath, profile, settings):
"""Извлекает данные из опросника с учётом настроек редактора.
Возвращает dict: {'fields': {...}, 'sections': {...}, 'is_list': [...]}
"""
if not openpyxl:
return {'error': 'openpyxl не установлен'}
disabled = set(settings.get('disabled', []))
custom = settings.get('custom', [])
rules = profile.get('rules', [])
common_sheet = profile.get('common_sheet', 'общие')
employee_sheet = profile.get('employee_sheet', 'сотруд')
result = {'fields': {}, 'sections': {}, 'is_list': []}
# Все секции профиля всегда присутствуют в выгрузке (пустые, если отключены)
for sec_id in profile.get('sections', {}):
result['sections'][sec_id] = []
try:
wb = openpyxl.load_workbook(filepath, data_only=True, read_only=False)
except Exception as e:
return {'error': str(e)}
try:
# --- Общие сведения ---
common_sn = None
for sn in wb.sheetnames:
if common_sheet and common_sheet.lower() in sn.lower():
common_sn = sn
break
if common_sn:
ws = wb[common_sn]
for row in ws.iter_rows(min_row=1, values_only=True):
label = clean_value(row[0]) if row and row[0] is not None else ''
value = clean_value(row[1]) if len(row) > 1 else ''
if not label:
continue
label_lower = label.lower()
for rule in rules:
pattern = rule.get('pattern', '')
field = rule.get('field', '')
if pattern and re.search(pattern, label_lower):
if not is_disabled(settings, field) and value:
result['fields'][field] = value
break
# --- Секции (комиссия, сотрудники) ---
sections = profile.get('sections', {})
emp_sn = None
for sn in wb.sheetnames:
if employee_sheet and employee_sheet.lower() in sn.lower():
emp_sn = sn
break
if emp_sn and sections:
ws = wb[emp_sn]
current = None
for row in ws.iter_rows(min_row=1, values_only=True):
col_a = clean_value(row[0]) if row and row[0] is not None else ''
a_lower = col_a.lower()
header_hit = None
for sec_id, sec_cfg in sections.items():
if is_disabled(settings, sec_id):
continue
if re.search(sec_cfg.get('header', ''), a_lower):
header_hit = sec_id
break
if header_hit:
current = header_hit
result['sections'].setdefault(current, [])
continue
if current:
# Заголовок таблицы внутри секции — пропускаем
if a_lower in ('№ п/п', '', 'номер', 'п/п', '№пп', '№ пп'):
continue
row_data = [clean_value(c) if c is not None else ''
for c in row[:6]]
if any(row_data):
# Номер строки в первой колонке не нужен
if re.fullmatch(r'\d+', row_data[0].strip()):
row_data[0] = ''
result['sections'][current].append(row_data)
# --- Информационные системы (листы, отличные от общих/сотрудников) ---
if profile.get('is_enabled'):
for sn in wb.sheetnames:
if common_sheet and common_sheet.lower() in sn.lower():
continue
if employee_sheet and employee_sheet.lower() in sn.lower():
continue
ws = wb[sn]
is_obj = {'name': '', 'rows': []}
for row in ws.iter_rows(min_row=1, values_only=True):
vals = [clean_value(c) for c in row]
label = (vals[0] or '').strip() if vals else ''
if not label:
continue
is_obj['rows'].append(vals)
label_lower = label.lower()
if re.search(r'название ис|наименование ис|наименование информационной',
label_lower) and not is_disabled(settings, 'is_name'):
if len(vals) > 1:
is_obj['name'] = vals[1]
if is_obj['rows']:
if not is_obj['name']:
is_obj['name'] = sn.strip()
result['is_list'].append(is_obj)
# --- Пользовательские привязки ---
for cm in custom:
excel_text = (cm.get('excel_text') or '').strip().lower()
sheet = (cm.get('sheet') or '').strip()
field = (cm.get('field') or '').strip()
if not excel_text or not field:
continue
target_sheets = [sheet] if sheet in wb.sheetnames else wb.sheetnames
for sn in target_sheets:
ws = wb[sn]
for row in ws.iter_rows(min_row=1, values_only=True):
label = clean_value(row[0]) if row and row[0] is not None else ''
if label.lower().strip() == excel_text:
value = clean_value(row[1]) if len(row) > 1 else ''
if value:
result['fields'][field] = value
break
finally:
try:
wb.close()
except Exception:
pass
return result