diff --git a/tools/excel2drawio.py b/tools/excel2drawio.py new file mode 100644 index 0000000..d4b48e1 --- /dev/null +++ b/tools/excel2drawio.py @@ -0,0 +1,173 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +excel2drawio.py — 从 Excel 股权关系表生成 drawio 股权架构图 +============================================================= +用法: + python3 excel2drawio.py 输入.xlsx [输出.drawio] + +Excel 格式 (Sheet 名任意, 表头必须为以下列名): + 母公司名称 | 子公司名称 | 股权比例(%) | 备注 + 每行 = 一条股权关系。无母公司的公司 = 根节点(集团/母公司)。 + 例: + 母公司名称 子公司名称 股权比例(%) 备注 + 集团总公司 子公司A 100 全资 + 集团总公司 子公司B 60 控股 + 子公司A 孙公司C 51 控股 + +输出: drawio 格式 XML, 可用 drawio 桌面版/网页版打开, 或嵌入网页。 +""" +import sys, math +import openpyxl + +# ---------- 样式 ---------- +STYLE_ROOT = "rounded=1;whiteSpace=wrap;html=1;fillColor=#1e6fd9;fontColor=#ffffff;strokeColor=#0d47a1;fontSize=13;fontStyle=1;" +STYLE_NODE = "rounded=1;whiteSpace=wrap;html=1;fillColor=#e8f0fe;fontColor=#000000;strokeColor=#5b9bd5;fontSize=11;" +STYLE_LEAF = "rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;fontColor=#000000;strokeColor=#999999;fontSize=11;" +STYLE_EDGE = "edgeStyle=orthogonalEdgeStyle;rounded=1;html=1;strokeColor=#666666;strokeWidth=1.5;fontSize=10;" +COL_W, COL_H = 180, 44 # 节点宽高 +GAP_X, GAP_Y = 30, 80 # 横向/纵向间距 + + +def read_relations(xlsx_path): + """读取 Excel, 返回 (关系列表, 所有公司集合)""" + wb = openpyxl.load_workbook(xlsx_path, data_only=True) + ws = wb.active + rows = list(ws.iter_rows(values_only=True)) + if not rows: + sys.exit("Excel 为空") + header = [str(c).strip() if c else "" for c in rows[0]] + # 兼容列名变体:先精确匹配,再包含匹配(按 names 顺序优先) + def col(*names): + for i, h in enumerate(header): + if h in names: + return i + for n in names: + for i, h in enumerate(header): + if n in h: + return i + return None + i_parent = col("母公司", "父公司", "上级") + i_child = col("子公司", "公司", "被投") + i_ratio = col("股权比例", "比例", "持股") + i_note = col("备注") + if i_parent is None or i_child is None: + sys.exit(f"表头需要包含「母公司名称」「子公司名称」列, 实际: {header}") + relations, companies = [], set() + for r in rows[1:]: + if not r or all(v is None or str(v).strip() == "" for v in r): + continue + parent = str(r[i_parent]).strip() if i_parent is not None and r[i_parent] else "" + child = str(r[i_child]).strip() if r[i_child] else "" + if not child: + continue + ratio = r[i_ratio] if i_ratio is not None and r[i_ratio] is not None else "" + note = str(r[i_note]).strip() if i_note is not None and r[i_note] else "" + relations.append((parent, child, ratio, note)) + companies.add(child) + if parent: + companies.add(parent) + return relations, companies + + +def build_levels(relations, companies): + """计算每家公司层级 (根=0), 返回 {公司: 层级}""" + children = {} + for p, c, _, _ in relations: + children.setdefault(p, []).append(c) + # 没有作为子节点出现的公司 = 根(集团/母公司) + as_child = {c for _, c, _, _ in relations} + roots = [c for c in companies if c not in as_child] + level = {} + def dfs(node, lv): + if node in level and level[node] <= lv: + return + level[node] = lv + for ch in children.get(node, []): + dfs(ch, lv + 1) + for r in roots: + dfs(r, 0) + # 兜底: 未覆盖的孤儿节点 + for c in companies: + if c not in level: + level[c] = 0 + return level, roots + + +def generate_drawio(relations, companies, xlsx_path): + level, roots = build_levels(relations, companies) + # 按层级分组, 保持输入顺序 + order = [] + seen = set() + def add(n): + if n not in seen: + seen.add(n); order.append(n) + for p, c, _, _ in relations: + add(p); add(c) + for c in companies: + add(c) + by_level = {} + for n in order: + by_level.setdefault(level[n], []).append(n) + max_lv = max(level.values()) + # 计算每层宽度 → 画布大小 + positions = {} + for lv, nodes in by_level.items(): + n = len(nodes) + x0 = (n - 1) * (COL_W + GAP_X) / 2 + for i, node in enumerate(nodes): + positions[node] = (x0 - i * (COL_W + GAP_X), lv * (COL_H + GAP_Y)) + canvas_w = max((x + COL_W for x, _ in positions.values()), default=800) + 100 + canvas_h = (max_lv + 1) * (COL_H + GAP_Y) + 60 + + # 节点样式: 根=深蓝, 有子公司=浅蓝, 叶子=灰 + children = {p for p, _, _, _ in relations} + lines = [] + lines.append(f'') + lines.append('') + nid = 1 + for node in order: + nid += 1 + x, y = positions[node] + if node in roots: + style = STYLE_ROOT + elif node in children: + style = STYLE_NODE + else: + style = STYLE_LEAF + lines.append(f'') + positions[node] = (nid, x, y) # 记录 id + for p, c, ratio, note in relations: + nid += 1 + pid, _, _ = positions[p] if p in positions else (None, None, None) + cid, _, _ = positions[c] if c in positions else (None, None, None) + if pid is None or cid is None: + continue + label = "" + if ratio not in (None, ""): + label = f"{ratio}%" + if note: + label += f" ({note})" + lines.append(f'') + lines.append('') + return "\n".join(lines) + + +def main(): + if len(sys.argv) < 2: + sys.exit(__doc__) + xlsx_path = sys.argv[1] + out_path = sys.argv[2] if len(sys.argv) > 2 else xlsx_path.rsplit(".", 1)[0] + ".drawio" + relations, companies = read_relations(xlsx_path) + if not companies: + sys.exit("没有读到任何公司") + xml = generate_drawio(relations, companies, xlsx_path) + with open(out_path, "w", encoding="utf-8") as f: + f.write(xml) + print(f"✅ 生成成功: {out_path}") + print(f" 公司数: {len(companies)}, 关系数: {len(relations)}") + print(" 用 drawio 打开该文件即可查看架构图") + + +if __name__ == "__main__": + main()