- 표 키(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
436 lines
19 KiB
Python
436 lines
19 KiB
Python
# -*- 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
|