Files

351 lines
14 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# -*- 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