diff --git a/dokogen/generator.py b/dokogen/generator.py index 9dd4232..6a1962f 100644 --- a/dokogen/generator.py +++ b/dokogen/generator.py @@ -449,32 +449,58 @@ def expand_loops_in_zip(docx_path: str, loops: Dict[str, List[Dict]]) -> str: # 3. ЗАМЕНА ПЕРЕМЕННЫХ В ЭЛЕМЕНТАХ DOCX # ============================================================ +def _apply_highlight_color(run_el, color: str): + """Добавляет маркер highlight указанного цвета к run.""" + ns = 'http://schemas.openxmlformats.org/wordprocessingml/2006/main' + rpr = run_el.find(f'{{{ns}}}rPr') + if rpr is None: + rpr = OxmlElement('w:rPr') + run_el.insert(0, rpr) + hl = OxmlElement('w:highlight') + hl.set(f'{{{ns}}}val', color) + rpr.append(hl) + + def _replace_text_in_para(para, replacements: Dict[str, str]) -> bool: - """Заменяет {{var}} в параграфе. Возвращает True, если были замены.""" + """Заменяет {{var}} в параграфе. Возвращает True, если были замены. + + Значения «—» (незаполненная информация) подсвечиваются жёлтым маркером. + """ full = para.text - matches = VAR_PATTERN.findall(full) + matches = list(VAR_PATTERN.finditer(full)) if not matches: return False - - new_text = full - for var in matches: + + # Собираем параграф заново: обычный текст + значения переменных + first = para.runs[0] if para.runs else None + para.clear() + pos = 0 + changed = False + for m in matches: + var = m.group(0) val = replacements.get(var) - if val is not None: - new_text = new_text.replace(var, str(val)) - - if new_text == full: - return False - - # Сохраняем форматирование первого run - if para.runs: - first = para.runs[0] - para.clear() - run = para.add_run(new_text) - _copy_run_style(first, run) - else: - para.clear() - para.add_run(new_text) - return True + if val is None: + continue + # текст до переменной + if m.start() > pos: + run = para.add_run(full[pos:m.start()]) + if first is not None: + _copy_run_style(first, run) + # значение переменной + val_str = str(val) + run = para.add_run(val_str) + if first is not None: + _copy_run_style(first, run) + if val_str == '—' or val_str == '': + _apply_highlight_color(run._element, 'yellow') + changed = True + pos = m.end() + # хвост после последней переменной + if pos < len(full): + run = para.add_run(full[pos:]) + if first is not None: + _copy_run_style(first, run) + return changed def _copy_run_style(source, target): diff --git a/dokogen/importer.py b/dokogen/importer.py index f28e5f6..2c44bd5 100644 --- a/dokogen/importer.py +++ b/dokogen/importer.py @@ -71,6 +71,31 @@ def _normalize_caps(text: str) -> str: return ' '.join(result) +_ADDRESS_ABBR = { + # нормальные сокращения для адресов: «Г» → «г.», «Р-Н» → «р-н.» и т.п. + 'Г': 'г.', 'УЛ': 'ул.', 'Д': 'д.', 'КВ': 'кв.', 'ПР': 'пр.', + 'ПЕР': 'пер.', 'ПЛ': 'пл.', 'ОБЛ': 'обл.', 'ПОС': 'пос.', + 'СТ': 'ст.', 'Ш': 'ш.', 'Р-Н': 'р-н.', 'Б-Р': 'б-р.', 'МКР': 'мкр.', + 'НАБ': 'наб.', 'ТУП': 'туп.', 'ПР-Д': 'пр-д.', 'КРП': 'крп.', + 'Г.': 'г.', 'УЛ.': 'ул.', 'Д.': 'д.', 'Р-Н.': 'р-н.', +} + + +def normalize_address(text: str) -> str: + """Приводит адрес к нормальному виду: «Г Крымск» → «г. Крымск», + «Р-Н Крымский» → «р-н. Крымский», «УЛ Карла» → «ул. Карла».""" + if not text: + return '' + parts = re.split(r'(\s+|[,\;])', text) + out = [] + for p in parts: + if p.strip() and p.strip().upper() in _ADDRESS_ABBR: + out.append(_ADDRESS_ABBR[p.strip().upper()]) + else: + out.append(p) + return ''.join(out) + + def clean_value(val): """Очистка и нормализация значения ячейки Excel.""" if val is None: @@ -145,6 +170,11 @@ def import_152fz(filepath: str, log_fn: Optional[Callable] = None, settings_path if data.get(src) and not data.get(dst): data[dst] = data[src] + # Нормализация адресов: «Г Крымск» → «г. Крымск», «Р-Н» → «р-н.» и т.п. + for key in ('addressLegal', 'addressActual'): + if data.get(key): + data[key] = normalize_address(data[key]) + _validate_company_data(data, log_fn) return data @@ -366,7 +396,8 @@ def _import_152_is_list(wb, data, settings, log_fn): matched_label = label break if matched_label and val_b and 'выбрать' not in field_lower: - is_obj['defense_tools'].append(f"{matched_label}: {val_b}") + # Без префикса категории: просто название средства (Kaspersky, КриптоПро) + is_obj['defense_tools'].append(val_b) elif matched_label: pass elif 'куда' in field_lower or 'передаются' in field_lower: diff --git a/dokogen/ui.py b/dokogen/ui.py index c4b55c4..a22dfb7 100644 --- a/dokogen/ui.py +++ b/dokogen/ui.py @@ -595,13 +595,6 @@ class DokoGenApp: self.is_structure_entry = ttk.Entry(fields_frame, width=55) self.is_structure_entry.grid(row=7, column=1, padx=10, sticky=tk.EW) - # Программное обеспечение - ttk.Label(fields_frame, text="ПО:").grid(row=8, column=0, sticky=tk.W, pady=2) - self.is_software_entry = ttk.Entry(fields_frame, width=55) - self.is_software_entry.grid(row=8, column=1, padx=10, sticky=tk.EW) - - - fields_frame.columnconfigure(1, weight=1) # Вложенные списки @@ -659,6 +652,7 @@ class DokoGenApp: self.users_listbox.pack(side=tk.LEFT, fill=tk.BOTH, expand=True) btnf = ttk.Frame(user_tab) btnf.pack(pady=3) + ttk.Button(btnf, text="👥 Выбрать из списка", command=self._select_users_from_employees).pack(side=tk.LEFT, padx=2) ttk.Button(btnf, text="+ Добавить", command=self._add_user_to_is).pack(side=tk.LEFT, padx=2) ttk.Button(btnf, text="- Удалить", command=self._del_user_from_is).pack(side=tk.LEFT, padx=2) @@ -1339,6 +1333,28 @@ class DokoGenApp: if user: self.users_listbox.insert(tk.END, user) + def _select_users_from_employees(self): + """Выбор пользователей ИС из списка сотрудников организации (вкладка «Пользователи»).""" + names = [] + for emp in self.model.employees_access: + fio = (emp.get('fio') or '').strip() + if fio and fio not in names: + names.append(fio) + # Дополнительно: пользователи из других ИС + for isys in self.model.information_systems: + for u in (isys.users_list or []): + u = u.strip() + if u and u not in names: + names.append(u) + if not names: + messagebox.showinfo( + "Выбор пользователей", + "Список пуст.\nЗаполните вкладку «Пользователи» (сотрудники с доступом к ПДн) " + "или добавьте пользователей в другой ИС." + ) + return + self._select_from_dialog("Выбор пользователей ИС", names, self.users_listbox) + def _del_user_from_is(self): sel = self.users_listbox.curselection() if sel: diff --git a/dokogen/variables.py b/dokogen/variables.py index a6e1c8c..c3b08ec 100644 --- a/dokogen/variables.py +++ b/dokogen/variables.py @@ -277,7 +277,7 @@ def _build_loop_data(company: Company, is_list: List) -> Dict[str, List[Dict]]: 'pd_list': ', '.join(isys.personal_data_list or []) or '—', 'pd_count': str(isys.pd_count or '—'), 'users': ', '.join(isys.users_list or []) or '—', - 'defense_tools_list': '; '.join(isys.defense_tools_list or []) or '—', + 'defense_tools_list': ', '.join(isys.defense_tools_list or []) or '—', 'processing_modes': isys.processing_modes or '—', 'pd_subjects': '; '.join(isys.pd_subjects_list or []) or '—', 'is_purpose': isys.purpose or '—', @@ -288,13 +288,47 @@ def _build_loop_data(company: Company, is_list: List) -> Dict[str, List[Dict]]: loops['information_systems'] = items # Бумажные документы + def _storage_with_prep(storage: str) -> str: + """«сейф» → «в сейфе» (предлог + предложный падеж). + Если уже начинается с предлога — оставить как есть.""" + if not storage: + return '' + s = storage.strip() + low = s.lower() + if low.startswith(('в ', 'на ', 'под ', 'за ', 'у ', 'при ', 'из ', 'со ', 'во ', 'над ', 'перед ')): + return s + words = s.split() + out = [] + # Исключения предложного падежа: «в шкафу», «в углу» (не «в шкафе») + loct_exceptions = {'шкаф': 'шкафу', 'угол': 'углу', 'край': 'краю', 'рот': 'рту', 'мост': 'мосту'} + for w in words: + core = w.strip('.,;') + if not core or core.isdigit() or core.startswith('№'): + out.append(w) + continue + if core.lower() in loct_exceptions: + out.append(loct_exceptions[core.lower()]) + continue + try: + p = dc.morph.parse(core)[0] + inf = p.inflect({'loct'}) + if inf: + out.append(inf.word) + continue + except Exception: + pass + out.append(w) + return 'в ' + ' '.join(out) + paper_list = getattr(company, 'paper_documents_list', None) or [] if paper_list: items = [] for p in paper_list: + storage = p.storage or '' items.append({ 'document': p.name or '', - 'storage': p.storage or '', + 'storage': _storage_with_prep(storage), # «в сейфе» + 'storage_raw': storage, # «сейф» (как введено) }) loops['paper_documents'] = items