Files
Aislo/resources/master_data/scripts/master_copy.py
T
eomsangdonandClaude Opus 5.5 f808b67285 fix(m01): 이름 식 별칭 = 짧은 표 제목 + 찾은 줄 값 · 긴 품명은 앞 핵심만 (PLAN 5장 5바퀴)
- 표 키(QC…) 대신 표 제목을 짧게 — 규격·코드 뗌 · 길면 첫 + 끝 낱말 · 제목 없으면 공종 · 상세구분
- 같은 표 여러 번은 번호 대신 서로 가르는 인자 값만(낙석방지망설치재료_철망 · 합판거푸집_각재)
- 호표 줄 이름도 짧은 품명 · 한 로직 안에서 겹치면 꼬리 _2
- 자동값 7,008줄: 표 키 1,188→0 · 번호 별칭 982→4 · 붙인 20자 넘는 이름 108→0 · 금액 변화 0

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01V1MKKZKpUHTPKb513FneU8
2026-09-24 12:04:39 +09:00

436 lines
19 KiB
Python
Raw 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 -*-
"""정본 로직을 식 줄로 펴 보이고, 고친 식을 되돌려 자체 로직으로 저장 (PLAN 3-4).
규칙은 `resources/master_data/ref/_설계_식_복사.md` 그대로 —
`찾기(…)`·`로직(…)` 부르기 한 덩이를 이름 하나(별칭)로 바꿔 보이고 뒤에 원문을 둠.
별칭은 저장하지 않음(겉치레) — 되돌릴 때 별칭표의 원문으로 갈아 끼워 원래 식이 됨.
식 자르기는 `master_formula` 파서를 그대로 씀 — 정규식으로 괄호를 세지 않음.
"""
from __future__ import annotations
import copy
import re
import master_formula as mf
MAX_NAME = 12 # 짧은 이름의 길이 상한
MAX_WORD = 8 # 별칭 꼬리(찾은 줄 값) 한 낱말의 길이 상한
_DROP = re.compile(r"[^\w]", re.UNICODE) # 별칭에 못 쓰는 글자(빈칸 · 괄호 · 가운뎃점 …)
_SPACE = re.compile(r"\s+")
_PAREN = re.compile(r"\([^()]*\)")
_WORDS = re.compile(r"[\s·・,/:+×*~∼]+")
_QUOTED = re.compile(r"'[^']*'")
RESERVED = frozenset((*mf.FUNCS, *mf.COST_ITEMS, "찾기", "로직", "그리고", "또는", "계", "줄"))
# ── 부르기 자리 자르기 ─────────────────────────────────────────────────
class _Catch(mf._Parser):
"""식을 읽으면서 `찾기(…)`·`로직(…)` 한 덩이의 자리를 적어 둠(겹친 것 포함)."""
def __init__(self, text: str):
super().__init__(text)
self.spots: list[tuple[int, int, str, str]] = []
def primary(self):
self.ws()
at = self.i
node = super().primary()
if node[0] in ("find", "logic"):
self.spots.append((at, self.i, node[0], node[1]))
return node
def spots(text: str) -> list[tuple[int, int, str, str]]:
"""식 글 안 부르기 한 덩이 — (처음, 끝, 종류, 참조).
겹친 것은 바깥만 · 못 읽는 글은 빈 목록.
"""
try:
found = _Catch(str(text))
found.parse()
except (mf.FormulaError, RecursionError):
return []
out = [
one
for one in found.spots
if not any(o[0] <= one[0] and one[1] <= o[1] and o[:2] != one[:2] for o in found.spots)
]
return sorted(out)
def _holes(text: str) -> list[tuple[int, int]]:
"""별칭을 갈아 끼우면 안 되는 자리 — 이미 원문으로 적힌 부르기 안 · 작은따옴표 안."""
return [(s, e) for s, e, _, _ in spots(text)] + [
(m.start(), m.end()) for m in _QUOTED.finditer(text)
]
# ── 식이 든 자리 ───────────────────────────────────────────────────────
def slots(row: dict):
"""(자리표, 갈래, 차례, 칸 이름, 그 칸을 담은 묶음) — 번호 매기는 차례(호표 → 중간 → 덧줄)."""
for i, one in enumerate(row.get("호표") or []):
for col in ("수량", "요소"):
if isinstance(one.get(col), str):
yield f"호표[{i}].{col}", "호표", i, col, one
for i, one in enumerate(row.get("중간") or []):
if isinstance(one.get("식"), str):
yield f"중간[{i}].식", "중간", i, "식", one
for i, one in enumerate(row.get("덧줄") or []):
if isinstance(one.get("식"), str):
yield f"덧줄[{i}].식", "덧줄", i, "식", one
end = row.get("결과")
if isinstance(end, dict) and isinstance(end.get("식"), str):
yield "결과.식", "결과", 0, "식", end
def _taken(row: dict) -> set[str]:
"""이미 식에서 쓰는 이름 — 입력 · 중간 · 덧줄 · 비목 · 함수 이름."""
out = set(RESERVED)
for part in ("입력", "중간", "덧줄"):
for one in row.get(part) or []:
name = str(one.get("이름") or "")
if name:
out.add(name)
return out
# ── 별칭 짓기 ─────────────────────────────────────────────────────────
def plain(text) -> str:
"""이름에 쓸 수 있는 글자만 남김 — 별칭과 변수 이름이 같은 규칙을 씀."""
return _DROP.sub("", str(text or ""))
def _unparen(text: str, keep: bool) -> str:
"""괄호 묶음을 벗김 — `keep` 이면 숫자 없는 묶음(공기식 · 건설)만 글로 남김."""
while True:
got = _PAREN.sub(
lambda m: f" {m.group()[1:-1]} " if keep and not re.search(r"\d", m.group()) else " ",
text,
)
if got == text:
return got
text = got
def _core(text: str) -> list[str]:
"""앞 핵심 낱말 — 머리 번호(1급 · 5-3)는 건너뛰고, 그 뒤 숫자 든 낱말(규격)에서 끊음."""
out: list[str] = []
for word in _WORDS.split(text):
word = plain(word)
if not word:
continue
if re.search(r"\d", word):
if out:
break
continue
out.append(word)
return out
def _fit(words: list[str], cap: int) -> str:
"""낱말들을 이어 이름 하나로 — 길면 첫 낱말 + 끝 낱말 · 그래도 길면 첫 낱말을 자름."""
tries = ["".join(words), *([words[0] + words[-1], words[0][:cap]] if words else [])]
return next((one for one in tries if one and len(one) <= cap), "")
def short(text, cap: int = MAX_NAME) -> str:
"""사람이 읽는 짧은 이름 — 규격·코드를 떼고, 길면 괄호 속 · 가운데 낱말 순으로 버림.
늘 같은 글이면 같은 이름 · 글자 · 숫자 · 밑줄만 · 숫자로 시작하지 않음(못 지으면 빈 글)."""
text = str(text or "")
kept = "".join(_core(_unparen(text, True)))
return kept if kept and len(kept) <= cap else _fit(_core(_unparen(text, False)), cap)
def _base(ref: str, master) -> str:
"""별칭 밑이름 — 표·로직 이름을 짧게 · 이름이 없으면 공종 · 상세구분 · 그래도 없으면 키."""
try:
row = master.get(ref)
except mf.FormulaError:
row = {}
use = row.get("용도") if isinstance(row.get("용도"), dict) else {}
for text in (row.get("이름"), use.get("공종"), row.get("상세구분")):
got = short(text)
if got:
return got
return _DROP.sub("", ref) or "표"
def _words(node, master) -> list[str]:
"""부르기 인자 하나의 낱말 — 글은 그 값(요소 코드면 품명 + 규격) · 수 · 이름은 그대로."""
kind = node[0]
if kind == "name":
return [node[1]]
if kind == "num":
return [plain(node[1])]
if kind != "str": # 만약(…) 따위 — 안에 적힌 글 가운데 처음 읽히는 것
return next(filter(None, (_core(_unparen(one, False)) for one in _texts(node))), [])
for ref in (node[1], f"EQ:{node[1]}"): # 기계 입력은 원문번호(0201-0020)
try:
row = master.get(ref)
except mf.FormulaError:
continue
if row.get("이름"):
return [short(row["이름"]), *filter(None, [plain(row.get("규격"))])]
return _core(_unparen(str(node[1]), False)) or [plain(node[1])]
def _texts(node):
"""마디 안의 글 값(작은따옴표) — 적힌 차례로."""
if isinstance(node, tuple) and node[:1] == ("str",):
yield str(node[1])
elif isinstance(node, (tuple, list)):
for one in node:
yield from _texts(one)
def _columns(signs: list[str], refs: list[str], master) -> list[dict[str, str]]:
"""부르기마다 {칸: 꼬리 낱말} — 다른 표가 섞이면 표 제목도 한 칸(`""`).
한 칸 값들이 모두 가진 낱말(`구체콘크리트`)은 떼어 서로 가르는 낱말만 남김."""
rows: list[dict[str, list[str]]] = [{} for _ in signs]
if len(set(refs)) > 1:
for one, ref in zip(rows, refs):
try:
one[""] = _core(_unparen(str(master.get(ref).get("이름") or ""), False))
except mf.FormulaError:
one[""] = []
for one, sign in zip(rows, signs):
try:
args = mf.parse(sign)[2]
except (mf.FormulaError, IndexError, TypeError):
args = []
one.update({key: _words(value, master) for key, value in args})
out: list[dict[str, str]] = [{} for _ in signs]
for key in dict.fromkeys(key for one in rows for key in one):
lists = [one.get(key, []) for one in rows]
common = set.intersection(*map(set, lists))
if not all(set(words) - common for words in lists):
common = set()
for got, words in zip(out, lists):
got[key] = _fit([w for w in words if w not in common], MAX_WORD)
return out
def _tails(signs: list[str], refs: list[str], master) -> list[str]:
"""같은 밑이름의 부르기들 — 서로 가르는 인자 값만 이어 꼬리로(`철망` · `외업_고급기술자`).
가르는 칸은 적게 — 가장 잘 가르는 칸부터 모두 갈릴 때까지 고르고, 줄마다 없어도 되는 칸은 뺌."""
cols = _columns(signs, refs, master)
order = list(dict.fromkeys(key for one in cols for key in one))
def seen(one: dict, keys: list[str]) -> tuple:
return tuple(one.get(key, "") for key in keys)
picked: list[str] = []
left = list(order)
while len({seen(one, picked) for one in cols}) < len(cols) and left:
best = max(left, key=lambda key: len({seen(one, [*picked, key]) for one in cols}))
if len({seen(one, [*picked, best]) for one in cols}) == len(
{seen(o, picked) for o in cols}
):
break
picked.append(best)
left.remove(best)
out = []
for at, one in enumerate(cols):
mine = list(picked)
for key in reversed(picked): # 늦게 고른(덜 가르는) 칸부터 빼 봄
rest = [k for k in mine if k != key]
others = (seen(o, rest) for j, o in enumerate(cols) if j != at)
if not rest or seen(one, rest) in set(others):
break
mine = rest
out.append("_".join(filter(None, seen(one, sorted(mine, key=order.index)))))
return out
def _free(want: str, taken: set[str]) -> str:
name, at = want, 1
while name in taken:
at += 1
name = f"{want}_{at}"
taken.add(name)
return name
def aliases(row: dict, master) -> list[dict]:
"""부르기마다 별칭 하나 — 완전히 같은 부르기는 합치고, 갈리면 등장 차례로 번호."""
order: list[str] = []
seen: dict[str, tuple[str, str]] = {} # 서명 → (종류, 참조)
raw_of: dict[str, str] = {} # 서명 → 원문(처음 나온 글 그대로)
where_of: dict[str, list[str]] = {}
for where, _, _, col, box in slots(row):
text = box[col]
for start, end, kind, ref in spots(text):
raw = text[start:end]
sign = _SPACE.sub(" ", raw)
if sign not in seen:
order.append(sign)
seen[sign] = (kind, ref)
raw_of[sign] = raw
where_of.setdefault(sign, []).append(where)
bunch: dict[str, list[str]] = {}
for sign in order:
bunch.setdefault(_base(seen[sign][1], master), []).append(sign)
taken, name_of = _taken(row), {}
for kind, ref in seen.values(): # 표의 값 칸 이름과 헷갈리지 않게
if kind == "find":
taken |= set(_about(kind, ref, master)["값칸"])
for base, signs in bunch.items():
gap = "_" if base[-1:].isdigit() else "" # 키를 밑이름으로 쓴 자리 — 번호가 붙지 않게
tails = _tails(signs, [seen[s][1] for s in signs], master) if len(signs) > 1 else [""]
for at, (sign, tail) in enumerate(zip(signs, tails)):
alone = len(signs) == 1 or any(tails) # 꼬리 없는 하나는 밑이름만
want = f"{base}_{tail}" if tail else base if alone else f"{base}{gap}{at + 1}"
name_of[sign] = _free(want, taken)
out = []
for sign in order:
kind, ref = seen[sign]
out.append(
{
"이름": name_of[sign],
"종류": "찾기" if kind == "find" else "로직",
"참조": ref,
"원문": raw_of[sign],
**_about(kind, ref, master),
"자리": where_of[sign],
}
)
return out
def _about(kind: str, ref: str, master) -> dict:
"""모달에 보일 한 마디 — 표는 표 이름과 값 칸 · 로직은 로직 이름과 결과 단위."""
try:
row = master.get(ref)
except mf.FormulaError:
return {"밑이름": "", "값칸": {}, "결과단위": ""}
if kind == "find":
return {"밑이름": row.get("이름") or "", "값칸": row.get("값칸") or {}, "결과단위": ""}
return {"밑이름": row.get("이름") or "", "값칸": {}, "결과단위": row.get("결과단위") or ""}
# ── 펴기 · 되돌리기 ────────────────────────────────────────────────────
def names(marks: list[dict]) -> dict[str, str]:
"""별칭 목록 → {원문 서명: 별칭 이름} — 식 글을 별칭으로 갈아 끼울 때 씀."""
return {_SPACE.sub(" ", str(one["원문"])): str(one["이름"]) for one in marks}
def mask(text, name_of: dict[str, str]) -> str:
"""부르기 한 덩이를 별칭 이름으로 — `spread` 와 같은 규칙(다른 모듈도 씀)."""
return _mask(str(text), name_of)
def _mask(text: str, name_of: dict[str, str]) -> str:
"""부르기 한 덩이를 별칭 이름으로 — 뒤에서부터 갈아 끼움(자리가 밀리지 않게)."""
out = text
for start, end, _, _ in reversed(spots(text)):
name = name_of.get(_SPACE.sub(" ", text[start:end]))
if name:
out = out[:start] + name + out[end:]
return out
def _glued(text: str, m: re.Match) -> bool:
"""앞뒤가 낱말 글자면 다른 이름의 한 토막 · 점 뒤면 표의 값 칸 이름 — 갈아 끼우지 않음."""
side = text[m.start() - 1 : m.start()] + text[m.end() : m.end() + 1]
return any(ch.isalnum() or ch == "_" for ch in side) or text[: m.start()].rstrip().endswith(".")
def _unmask(text: str, raw_of: dict[str, str]) -> str:
"""별칭 이름을 원문 부르기로 — 이미 원문인 부르기 안과 따옴표 안은 안 건드림."""
if not raw_of:
return text
holes = _holes(text)
catch = re.compile("|".join(re.escape(n) for n in sorted(raw_of, key=len, reverse=True)))
out, at = [], 0
for m in catch.finditer(text):
if any(s <= m.start() and m.end() <= e for s, e in holes) or _glued(text, m):
continue
out.append(text[at : m.start()])
out.append(raw_of[m.group()])
at = m.end()
out.append(text[at:])
return "".join(out)
def spread(master, row: dict) -> dict:
"""로직 한 줄 → 별칭 꼴 식 묶음. `로직` = 식만 별칭으로 바뀐 줄 통째 · `별칭` 에 원문."""
marks = aliases(row, master)
name_of = names(marks)
shown = copy.deepcopy(row)
for _, _, _, col, box in slots(shown):
box[col] = _mask(box[col], name_of)
return {"로직": shown, "별칭": marks, "줄": lines(shown)}
def lines(row: dict) -> list[dict]:
"""화면에 쌓을 식 줄 — 읽는 차례(중간 → 호표 → 덧줄 → 결과)."""
out = []
for i, one in enumerate(row.get("중간") or []):
out.append(_line(f"중간[{i}].식", "중간", i, one, one.get("식")))
for i, one in enumerate(row.get("호표") or []):
got = _line(f"호표[{i}].수량", "호표", i, one, one.get("수량"))
out.append({**got, "종류": one.get("종류") or "", "요소": one.get("요소")})
for i, one in enumerate(row.get("덧줄") or []):
out.append(_line(f"덧줄[{i}].식", "덧줄", i, one, one.get("식")))
end = row.get("결과")
if isinstance(end, dict):
out.append(_line("결과.식", "결과", 0, end, end.get("식")))
return out
def _line(where: str, kind: str, at: int, one: dict, text) -> dict:
return {
"자리": where,
"갈래": kind,
"차례": at,
"이름": one.get("이름") or "",
"단위": one.get("단위") or "",
"비목": one.get("비목") or "",
"식": text,
"출처": one.get("출처") or "",
}
def gather(shown: dict, marks: list[dict]) -> dict:
"""별칭 꼴 식 묶음 → 저장할 로직 한 줄. 별칭 원문은 부르기 하나여야 함(아무 글이나 못 넣음).
사용자가 편집 중 별칭과 같은 이름의 입력·중간·덧줄을 새로 만들면(예: `메쌓기인력 = 1`)
그 뒤로는 식 글 속 그 이름이 사용자 것인지 별칭인지 가릴 수 없음(둘 다 같은 토큰) —
자동으로 별칭 쪽만 다시 이름 지어 피하려 하면 사용자가 실제로 쓴 자리를 건드릴 위험이
있어(화면 개입 없이는 안전하게 못 가림) 조용히 바꾸지 않고 저장을 막음.
"""
raw_of: dict[str, str] = {}
taken = _taken(shown) # 지금 입력·중간·덧줄에 실제로 있는 이름(사용자 것이 우선)
for one in marks or []:
name, raw = str(one.get("이름") or ""), str(one.get("원문") or "")
if not name or not raw:
raise mf.FormulaError("별칭에 「이름」 과 「원문」 이 있어야 함")
if mf.parse(raw)[0] not in ("find", "logic"):
raise mf.FormulaError(f"별칭 「{name}」 의 원문이 찾기·로직 부르기가 아님 — 「{raw}」")
if name in raw_of and raw_of[name] != raw:
raise mf.FormulaError(f"별칭 「{name}」 이 두 가지 원문을 가리킴")
if name in taken:
raise mf.FormulaError(
f"「{name}」 을 입력·중간·덧줄 이름으로도 쓰고 별칭으로도 씀 — "
"어느 쪽 식이 그 이름을 가리키는지 가릴 수 없음 · 이름을 바꿀 것"
)
raw_of[name] = raw
out = copy.deepcopy(shown)
for _, _, _, col, box in slots(out):
box[col] = _unmask(box[col], raw_of)
return out
def check(master, row: dict) -> list[str]:
"""펴고 그대로 되돌리면 원래 식으로 돌아오는지 — 어긋난 자리 글 목록(비면 됨)."""
view = spread(master, row)
again = gather(view["로직"], view["별칭"])
out = []
for (where, _, _, col, box), (_, _, _, _, was) in zip(slots(again), slots(row)):
if box[col] != was[col]:
out.append(f"{row.get('키')} {where} — 「{was[col]}」 → 「{box[col]}」")
return out