feat: вложенный цикл users_loop — все пользователи ИС включая администратора

- <!-- loop:users_loop --> внутри цикла ИС: {{user_fio}}, {{user_position}}
- администратор НЕ исключается (как просил Костя), убираются только
  дубли по ФИО (нормализованное сравнение)
This commit is contained in:
2026-08-03 15:20:11 +04:00
parent 208e449953
commit 7c88351252
2 changed files with 78 additions and 0 deletions
+61
View File
@@ -282,6 +282,62 @@ def _cleanup_marker_paras(doc_xml: str) -> str:
return etree.tostring(root, xml_declaration=True, encoding='UTF-8', standalone=True).decode('utf-8')
def _expand_nested_loops(template: str, item: Dict) -> str:
"""Разворачивает вложенные циклы вида <!-- loop:users_loop --> ... <!-- loop_end -->
данными из item (список словарей). Используется для циклов внутри цикла ИС,
например пользователи ИС. Переменные внутри: {{user_fio}}, {{user_position}}."""
result = template
for _ in range(20):
m_start = LOOP_START.search(result)
if not m_start:
break
key = m_start.group(1)
data = item.get(key)
if not isinstance(data, list):
# Нет данных для вложенного цикла — удаляем блок целиком
depth = 1
pos = m_start.end()
m_end = None
for mm in LOOP_END.finditer(result, pos):
depth -= 1
if depth == 0:
m_end = mm
break
if m_end:
result = result[:m_start.start()] + result[m_end.end():]
continue
# Ищем соответствующий loop_end с учётом вложенности
depth = 1
pos = m_start.end()
m_end = None
markers = sorted(
[('s', x) for x in LOOP_START.finditer(result, pos)] +
[('e', x) for x in LOOP_END.finditer(result, pos)],
key=lambda t: t[1].start()
)
for kind, mm in markers:
if kind == 's':
depth += 1
else:
depth -= 1
if depth == 0:
m_end = mm
break
if m_end is None:
break
inner = result[m_start.end():m_end.start()]
parts = []
for sub in data:
clone = inner
for f, v in sub.items():
val = str(v) if v else ''
clone = clone.replace('{{user_%s}}' % f, val)
clone = clone.replace('{{item.%s}}' % f, val)
parts.append(clone)
result = result[:m_start.start()] + ''.join(parts) + result[m_end.end():]
return result
def expand_loops_in_zip(docx_path: str, loops: Dict[str, List[Dict]]) -> str:
"""Обрабатывает циклы напрямую в ZIP/DOCX на уровне XML.
Работает до загрузки python-docx — гарантирует корректную вложенность.
@@ -392,7 +448,12 @@ def expand_loops_in_zip(docx_path: str, loops: Dict[str, List[Dict]]) -> str:
expanded_parts = []
for idx, item in enumerate(items, 1):
clone = template_clean
# Вложенные циклы (users_loop и т.п.) — разворачиваем ДО подстановки
# обычных полей, чтобы {{user_fio}} внутри них не затёрлись алиасами
clone = _expand_nested_loops(clone, item)
for field, value in item.items():
if isinstance(value, list):
continue # списки — данные вложенных циклов, не подставляем
val_str = str(value) if value else ('' if value is None else '')
# С префиксом item.
clone = clone.replace('{{item.%s}}' % field, val_str)