# -*- coding: utf-8 -*- """규칙 B 재귀판: 탭 레벨(G→H→I…)에서 '자식이 전부 페이지'면 1행으로 합치고 M=합. 메뉴 카테고리(D·E·F)는 보존 — 합치기는 lc>=7(G 이하 탭 레벨)에서만 수행. 각 레벨 lc 에서: 부모(D..lc-1) 동일 + lc 비어있지 않음 으로 형제 묶음 → 그 묶음이 · 전부 L=페이지, · lc보다 깊은 카테고리열 모두 비어있음(잎) 이면 1행으로 합침: lc 이하 카테고리열 비움, M=합, K=첫 행 URL, 공공누리 O=유형 합집합(오름차순,콤마,공백없음)/P=게시판우선/Q=하나라도 Y. 깊은→얕은 순으로 반복(안정될 때까지) → G→H 합친 뒤 F→G까지 자연 연쇄. 평탄화/재병합(D/E/F)/순번/하이퍼링크는 _dedup_all.write_back 재사용. 사용: python -X utf8 _collapse_recursive.py dry <엑셀경로 | 기관명...> python -X utf8 _collapse_recursive.py run <엑셀경로 | 기관명...> (기관명 없이 경로 1개만 줘도 됨) """ import os, re, sys, shutil, importlib.util, warnings import openpyxl warnings.filterwarnings('ignore') spec = importlib.util.spec_from_file_location('dd', __file__.replace('_collapse_recursive.py', '_dedup_all.py')) dd = importlib.util.module_from_spec(spec); spec.loader.exec_module(dd) CAT = [4, 5, 6, 7, 8, 9, 10] # D E F G H I J COLLAPSE_LEVELS = [10, 9, 8, 7] # 탭 레벨만(깊은→얕은). E(6)·D(5)는 제외=카테고리 보존 TYPE_PAT = re.compile(r'(\d)\s*유형') EXCLUDE = {'계룡시'} # 검수완료 def mval(x): try: return int(x) except Exception: return 1 def merge_kogl(grp): types = set() for g in grp: o = g['vals'].get(15) if isinstance(o, str): for m in TYPE_PAT.finditer(o): types.add(int(m.group(1))) if types: O = ','.join(f'{n}유형' for n in sorted(types)) else: O = '미부착' Ps = [g['vals'].get(16) for g in grp] P = '게시판' if '게시판' in Ps else ('게시물' if '게시물' in Ps else None) Qs = [g['vals'].get(17) for g in grp] Q = 'Y' if 'Y' in Qs else ('N' if 'N' in Qs else None) return O, (P if O != '미부착' else None), (Q if O != '미부착' else None) def collapse_level(rows, lc): parent = [c for c in CAT if c < lc] deeper = [c for c in CAT if c > lc] out, groups = [], [] i, n = 0, len(rows) while i < n: v = rows[i]['vals'] if v.get(lc) not in (None, ''): j = i while (j < n and all(rows[j]['vals'].get(c) == v.get(c) for c in parent) and rows[j]['vals'].get(lc) not in (None, '')): j += 1 grp = rows[i:j] all_page = all(g['vals'].get(12) == '페이지' for g in grp) leaf = all(all(g['vals'].get(c) in (None, '') for c in deeper) for g in grp) has_attach = any(g['vals'].get(15) not in (None, '', '미부착') for g in grp) if len(grp) >= 2 and all_page and leaf: msum = sum(mval(g['vals'].get(13)) for g in grp) O, P, Q = merge_kogl(grp) first = dict(grp[0]); nv = dict(first['vals']) for c in [lc] + deeper: nv[c] = None nv[13] = msum; nv[15] = O; nv[16] = P; nv[17] = Q first['vals'] = nv out.append(first) groups.append({'lc': lc, 'path': [v.get(c) for c in parent], 'label': v.get(lc), 'n': len(grp), 'ms': [mval(g['vals'].get(13)) for g in grp], 'sum': msum, 'O': O, 'attach': has_attach, 'names': [g['vals'].get(lc) for g in grp]}) i = j; continue out.extend(grp); i = j; continue out.append(rows[i]); i += 1 return out, groups def collapse_all_levels(rows): all_groups = [] changed = True while changed: changed = False for lc in COLLAPSE_LEVELS: rows, groups = collapse_level(rows, lc) if groups: changed = True all_groups.extend(groups) return rows, all_groups def resolve_paths(args): """경로 또는 기관명 목록 → [(name, xlsx경로)]""" paths = [] for a in args: if a.lower().endswith('.xlsx') or os.path.sep in a: paths.append((os.path.splitext(os.path.basename(a))[0], a)) names = [a for a in args if not (a.lower().endswith('.xlsx') or os.path.sep in a)] for prov, idx, name in dd.SITES: if names and name not in names: continue if not names: continue paths.append((name, dd.xpath(prov, idx, name))) return paths LV = {7: 'F→G', 8: 'G→H', 9: 'H→I', 10: 'I→J'} def main(): mode = sys.argv[1] if len(sys.argv) > 1 else 'dry' rest = [a for a in sys.argv[2:] if not a.startswith('--')] targets = resolve_paths(rest) if not targets: print('대상 없음. 경로 또는 기관명을 지정하세요.'); return for name, xp in targets: if name in EXCLUDE: print(f'{name}: 검수완료 제외'); continue if not os.path.exists(xp): print(f'{name}: 엑셀 없음 ({xp})'); continue wb = openpyxl.load_workbook(xp); ws = wb.active rows = dd.load_flat(ws) out, groups = collapse_all_levels(rows) removed = len(rows) - len(out) bylv = {} for g in groups: bylv.setdefault(g['lc'], 0) bylv[g['lc']] += 1 lvstr = ' '.join(f"{LV.get(k,k)}:{v}" for k, v in sorted(bylv.items())) print(f'\n=== {name} === {len(rows)}→{len(out)} (제거 {removed}) [{lvstr}]') for g in groups: ms = '+'.join(str(m) for m in g['ms']) star = ' ★부착' if g['attach'] else '' print(f" [{LV.get(g['lc'],g['lc'])}] {' > '.join(str(x) for x in g['path'] if x)} > {g['label']}" f" ({g['n']}개) → M={ms}={g['sum']} O={g['O']}{star}") if mode == 'run' and groups: bak = xp.replace('.xlsx', '_backup_재귀합치기전.xlsx') shutil.copy(xp, bak) dd.write_back(ws, out) try: wb.save(xp) except PermissionError: wb.save(xp.replace('.xlsx', '_LP.xlsx')); print(f' !! {name} 잠김→_LP') else: print(f' 저장 완료. 백업: {os.path.basename(bak)}') elif mode != 'run': print(' (DRY)') if __name__ == '__main__': main()