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
This commit is contained in:
2026-09-24 12:04:39 +09:00
co-authored by Claude Opus 5.5
parent 68daa24259
commit f808b67285
4 changed files with 230 additions and 32 deletions
+146 -9
View File
@@ -14,9 +14,12 @@ import re
import master_formula as mf
MAX_NAME = 12 # 이 글자 수를 넘는 표 이름은 억지로 줄이지 않고 표 키를 그대로 보임
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, "찾기", "로직", "그리고", "또는", "", ""))
@@ -98,15 +101,146 @@ 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:
name = plain(master.get(ref).get("이름"))
row = master.get(ref)
except mf.FormulaError:
name = ""
if not name or len(name) > MAX_NAME or name[0].isdigit():
return _DROP.sub("", ref) or ""
return name
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:
@@ -144,8 +278,11 @@ def aliases(row: dict, master) -> list[dict]:
taken |= set(_about(kind, ref, master)["값칸"])
for base, signs in bunch.items():
gap = "_" if base[-1:].isdigit() else "" # 키를 밑이름으로 쓴 자리 — 번호가 붙지 않게
for at, sign in enumerate(signs):
name_of[sign] = _free(f"{base}{gap}{at + 1}" if len(signs) > 1 else base, taken)
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:
+25 -12
View File
@@ -118,7 +118,7 @@ def _elem_name(master, ref: str) -> str:
): # 별칭 이름이 표 열쇠 그대로인 것(QC…) — 밑이름을 풀어쓰지 않음
return ref
try:
name = mc.plain(master.get(ref).get("이름"))
name = mc.short(master.get(ref).get("이름"))
except mf.FormulaError:
name = ""
return f"{name}{ELEM_TAIL.get(str(ref)[:2], '단가')}" if name else ref
@@ -389,12 +389,24 @@ def _line_text(item: dict, line: dict, env: dict, master, part: str | None) -> s
return f"{fmt_price(price)} * {qty} = {fmt_money(money)}"
def _line_names(item: dict, line: dict, env: dict, master, part: str | None, name_of: dict) -> str:
def _item_names(items: list) -> list[tuple[str, str]]:
"""호표 줄마다 (짧은 품명(규격·코드 뗌), 겹침 꼬리) — 한 로직 안에서 겹치면 꼬리 `_2`."""
taken: set[str] = set()
out = []
for item in items:
name = mc.short(item.get("이름")) or ""
out.append((name, mc._free(name, taken)[len(name) :]))
return out
def _line_names(
item: dict, env: dict, master, part: str | None, name_of: dict, label: tuple[str, str]
) -> str:
"""같은 줄을 변수 이름으로 — 「석공노임 * 메쌓기인력.석공 * (1 + 증가율) = 석공비」."""
name = mc.plain(item.get("이름") or line.get("이름")) or ""
name, dup = label
tail = PRICE_TAIL.get(str(item.get("종류") or ""), "단가")
price = f"{name}{part or ''}{tail}"
money = f"{name}{part}" if part else f"{name}"
price = f"{name}{part or ''}{tail}{dup}"
money = f"{name}{part}{dup}" if part else f"{name}{dup}"
qty = _times(_names(item["수량"], env, master, name_of), item, env, master)
return f"{price} * {qty} = {money}"
@@ -406,7 +418,7 @@ def _extra_text(extra: dict, line: dict, env: dict, master) -> str:
def _extra_names(extra: dict, env: dict, master, name_of: dict) -> str:
got = mc.plain(extra.get("이름")) or "덧줄"
got = mc.short(extra.get("이름")) or "덧줄"
return f"{_names(extra[''], env, master, name_of)} = {got}"
@@ -437,7 +449,7 @@ def lines(files: dict, key: str, given: dict) -> dict:
if "결과" in row:
조각 = _formula_pieces(row["결과"][""], env, master, name_of, alias_by_name)
got = mc.plain(row.get("이름")) or "결과"
got = mc.short(row.get("이름")) or "결과"
shown = (
fmt_money if str(row.get("결과단위") or "").startswith("") else fmt
) # 돈 결과만 정수
@@ -472,10 +484,11 @@ def lines(files: dict, key: str, given: dict) -> dict:
made: dict[str, list] = {name: [] for name in mf.COST_ITEMS}
items = row.get("호표") or []
labels = _item_names(items)
if result is not None:
shown = result[""]
env[""] = {r["이름"]: r["금액"] for r in shown if r["이름"]}
for item, line in zip(items, shown):
for item, line, label in zip(items, shown, labels):
조각 = _formula_pieces(item["수량"], env, master, name_of, alias_by_name)
요소 = _item_pieces(item, env, master)
for part, money in _shares(line):
@@ -483,7 +496,7 @@ def lines(files: dict, key: str, given: dict) -> dict:
one = {
"이름": _label(item, line),
"": _line_text(item, line, env, master, split),
"이름글": _line_names(item, line, env, master, split, name_of),
"이름글": _line_names(item, env, master, split, name_of, label),
"금액": money,
"조각": 조각,
"요소조각": 요소,
@@ -495,7 +508,7 @@ def lines(files: dict, key: str, given: dict) -> dict:
끝수 = _cut_note(row)
= result[""]
else:
for item in items:
for item, label in zip(items, labels):
parts = (
list(mf.COST_ITEMS)
if item.get("종류") == "로직" and "비목" not in item
@@ -511,7 +524,7 @@ def lines(files: dict, key: str, given: dict) -> dict:
{
"이름": _label(item, {}),
"": "",
"이름글": _line_names(item, {}, env, master, split, name_of),
"이름글": _line_names(item, env, master, split, name_of, label),
"금액": None,
"조각": 조각,
"요소조각": 요소,
@@ -524,7 +537,7 @@ def lines(files: dict, key: str, given: dict) -> dict:
"": "",
"이름글": (
f"{_names(extra[''], env, master, name_of)} = "
f"{mc.plain(extra.get('이름')) or '덧줄'}"
f"{mc.short(extra.get('이름')) or '덧줄'}"
),
"금액": None,
"조각": _formula_pieces(extra[""], env, master, name_of, alias_by_name),
+8 -6
View File
@@ -3,7 +3,7 @@
계약 `resources/master_data/_화면_계약.md` 7 · 규칙 `resources/master_data/ref/_설계_식_복사.md`.
못박는
- 13-4-1 식으로 아무것도 고치고 되돌려 저장하면 원본과 계산 결과가 같음.
- 12-4 처럼 같은 표를 다른 인자로 쓰는 로직은 별칭이 번호 갈림.
- 12-4 처럼 같은 표를 다른 인자로 쓰는 로직은 별칭이 찾은 값으 갈림.
- 로직 전부(1,354) 펴고 되돌리면 원래 글자 그대로 돌아옴.
"""
@@ -83,10 +83,12 @@ def test_별칭이_식에서_이름_하나로_보이고_원문은_뒤에_남는
assert "높이에대한증가율표.증가율" in 가로["중간[0].식"]
def test_같은_표를_다른_인자로_쓰면_별칭이_번호로_갈린다(client: TestClient) -> None:
def test_같은_표를_다른_인자로_쓰면_별칭이_찾은_줄_값으로_갈린다(client: TestClient) -> None:
got = _get(client, "/api/m01/logic/formula", key=FORM)
형틀 = [one for one in got["별칭"] if one["참조"] == "QF000357"]
assert [one["이름"] for one in 형틀] == [f"합판거푸집{n}" for n in range(1, 8)]
assert [one["이름"] for one in 형틀] == [
f"합판거푸집_{n}" for n in ("합판", "각재", "철선", "", "박리제", "형틀목공", "보통인부")
]
assert [one["원문"] for one in 형틀][:2] == [
"찾기(QF000357, 구분='합판')",
"찾기(QF000357, 구분='각재')",
@@ -96,8 +98,8 @@ def test_같은_표를_다른_인자로_쓰면_별칭이_번호로_갈린다(cli
assert len(한벌) == 1 and 한벌[0]["자리"] == ["중간[0].식", "중간[1].식"]
가로 = {one["자리"]: one[""] for one in got[""]}
assert 가로["호표[0].수량"] == "합판거푸집1.기준수량_1회사용 * 재료율"
assert 가로["호표[1].수량"] == "합판거푸집2.기준수량_1회사용 * 재료율"
assert 가로["호표[0].수량"] == "합판거푸집_합판.기준수량_1회사용 * 재료율"
assert 가로["호표[1].수량"] == "합판거푸집_각재.기준수량_1회사용 * 재료율"
def test_로직_부르기도_별칭으로_보인다(client: TestClient) -> None:
@@ -155,7 +157,7 @@ def test_식을_고쳐_저장하면_그_값으로_계산된다(client: TestClien
view = _get(client, "/api/m01/logic/formula", key=FORM)
row = view["로직"]
row["호표"][6]["수량"] = "합판거푸집6.기준수량_1회사용 * 인력율 * 2" # 형틀목공 품 두 배
row["호표"][6]["수량"] = "합판거푸집_형틀목공.기준수량_1회사용 * 인력율 * 2" # 형틀목공 품 두 배
made = _post(
client,
"/api/m01/logic/formula/save",
+51 -5
View File
@@ -5,6 +5,7 @@
from __future__ import annotations
import re
import shutil
import sys
from decimal import Decimal
@@ -167,13 +168,58 @@ def test_끝수는_줄이_아니라_소계에_붙는다(client: TestClient) -> N
assert not any("버림" in one[""] for one in group[""])
def test_이름_꼴_찾기_별칭_표_열쇠_그대로면_밑이름으로_풀지_않는다(client: TestClient) -> None:
"""GC001008 덧줄 — 별칭 이름이 QC001882 뿐이라 이름 식도 별칭 목록의 그 이름을 씀."""
def test_이름_꼴_찾기_별칭_표_열쇠_대신_짧은_표_제목(client: TestClient) -> None:
"""GC001008 덧줄 — 긴 표 제목(잔디깎기 공구손료·경장비 기계경비 요율)은 첫 + 끝 낱말."""
got, _ = _both(client, "GC001008")
extra = next(one for one in _all_lines(got) if one["이름"].startswith("공구손료"))
assert "QC001882.요율 %" in extra["이름글"]
assert "잔디깎기공구손료" not in extra["이름글"]
assert any(one["이름"] == "QC001882" for one in got["별칭"])
assert "잔디깎기요율.요율 %" in extra["이름글"]
assert "QC001882" not in extra["이름글"]
assert any(one["이름"] == "잔디깎기요율" for one in got["별칭"])
def test_짧은_이름은_규격_코드를_떼고_길면_줄인다() -> None:
short = mt.mc.short
assert short("바퀴숫돌 평면용 A24 QWV1호 A205×25×25 KSL 6501") == "바퀴숫돌평면용"
assert short("크롤러드릴(공기식)(17㎥/min)") == "크롤러드릴공기식"
assert short("콘크리트 피니셔(중앙분리대용) 105.9㎾") == "콘크리트피니셔"
assert short("1급 기준점 측량") == "기준점측량" # 숫자로 시작하지 않음
assert short("잔디깎기 연간 시공횟수별 시공량 할증율") == "잔디깎기할증율"
def test_이름글_별칭은_찾은_줄_값으로_갈리고_표_키_번호가_없다() -> None:
"""번호(가스압접1) · 표 키(QC000568) 대신 표 제목 + 찾은 줄 값 · 되돌리면 원래 부르기."""
files = store.cm.load(folder=REAL)
master = store.cm.master(REAL)
want = {
"GC000337": "낙석방지망설치재료_철망.수량",
"GC000360": "가스압접_용접공.수량",
"GC000350": "터널바닥암반청소_굴착기_02.",
"GF000185": "구체콘크리트펌프카타설_레미콘.",
}
for key, part in want.items():
got = mt.lines(files, key, dict(store.cm.시험입력(master.logic(key))))
names = [one["이름글"] for one in _all_lines(got)]
assert any(part in one for one in names), (key, names)
assert not any(re.search(r"\b[A-Z]{2}\d{6}", one) for one in names), (key, names)
raw_of = {m["이름"]: m["원문"] for m in got["별칭"]}
for one in names: # 이름글 속 별칭은 모두 원문 부르기로 되돌려짐
back = mt.mc._unmask(one, raw_of)
left = [n for n in raw_of if re.search(rf"(?<![\w.]){re.escape(n)}(?!\w)", back)]
assert not left, (key, back)
assert any("찾기(" in mt.mc._unmask(one, raw_of) for one in names)
def test_긴_품명은_앞_핵심만_겹치면_꼬리_2() -> None:
files = store.cm.load(folder=REAL)
master = store.cm.master(REAL)
for key in ("GC000325", "GC000360"):
got = mt.lines(files, key, dict(store.cm.시험입력(master.logic(key))))
names = [one["이름글"] for one in _all_lines(got)]
assert not any(len(w) > 20 for one in names for w in re.findall(r"[^\W\d]\w*", one))
if key == "GC000325":
assert any(one.startswith("콘크리트피니셔노무비단가 * ") for one in names)
else:
assert "보통인부노임_2 * 0 = 보통인부비_2" in names
# ── PLAN 2-1 · 2-2 — 조각(알약) ─────────────────────────────────────────