diff --git a/resources/master_data/scripts/master_copy.py b/resources/master_data/scripts/master_copy.py index 355164f4..cf9148ed 100644 --- a/resources/master_data/scripts/master_copy.py +++ b/resources/master_data/scripts/master_copy.py @@ -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: diff --git a/resources/master_data/scripts/master_text.py b/resources/master_data/scripts/master_text.py index 13ac5665..d20ce613 100644 --- a/resources/master_data/scripts/master_text.py +++ b/resources/master_data/scripts/master_text.py @@ -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), diff --git a/resources/tester/test_m01_copy.py b/resources/tester/test_m01_copy.py index edc026c9..05ee068d 100644 --- a/resources/tester/test_m01_copy.py +++ b/resources/tester/test_m01_copy.py @@ -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", diff --git a/resources/tester/test_m01_text.py b/resources/tester/test_m01_text.py index 3550758e..e6bc33ef 100644 --- a/resources/tester/test_m01_text.py +++ b/resources/tester/test_m01_text.py @@ -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"(? 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 — 조각(알약) ─────────────────────────────────────────