# -*- coding: utf-8 -*- """수식 문서 변수 단위 채움 · 단위 셈 (PLAN 845-1 · 요구 880). 보이는 변수 `단위` 칸만 고침 — 셈 · 연결(연결 안 `단위` = 재료 환산) 안 건드림. 빌더(`build_수식문서_802_2.py`)가 변환 뒤 문서마다 `fill` 을 부름 — 빈 단위만 채우고 적힌 단위는 그대로(자리표시 「줄 따름」 류 · 「수」 만 고침). 채움 근거는 원본 · 셈만(지어내지 않음): - 표 칸 = 표 `값칸` 단위 · 「줄 따름」 류 = 고를 수 있는 행이 모두 같은 `단위` 칸일 때 그 단위. - 요소 = 기계 칸 단위(`기계/*.json` 단위) · 노임 · 재료 · 유가 = 「원/요소 단위」. - 단가산출 = 부른 문서 결과단위 · 수 고르기 = 이름 끝에 적힌 단위(「두께mm이하」). - 식 = 식 속 변수 단위로 셈(`infer`) — 2 배 밖 수(× 8 · ÷ 15)가 끼면 단위를 품을 수 있어 안 채움. - 금액 줄 역산 = 줄 단위가 단가 단위와 맞을 때 수량을 더하기로만 이은 변수 = 줄 단위. 무차원(계수 · 비 · 글 고르기)은 빈 단위가 맞음 — `dimensionless` 가 가름. """ from __future__ import annotations import re from fractions import Fraction import master_doc as md import master_unit_text as ut # 행마다 단위가 다른 표 칸 — 행의 `단위` 칸이 참값 ROW_UNIT = ( "줄 따름", "구분 따름", "단위 따름", "단위 칸 따름", "줄 단위", "단위 칸", "수(단위는 줄의 단위 칸 따름)", ) PLAIN_NUMBER = ( "수", "배", "수(또는 원문 비율표기)", "수 또는 글자", "수 또는 글", ) # 원본 「수」 칸 = 무차원 NOT_UNIT = ( "수", "수(또는 원문 비율표기)", "수 또는 글자", "수 또는 글", ) # 단위 칸에 적힌 「수」 = 단위 아님 → 빈 단위 ROW = "줄 따름" # 행마다 다른 단위 한 표기(화면이 안 그림 · `common_util_line_stack_pick`) TEXT = ("글", "글자") # 비교 · 셈 꼴 — 같은 단위의 다른 표기 ALIAS = { "hr": "시간", "h": "시간", "HR": "시간", "Hr": "시간", "min": "분", "sec": "초", "L": "ℓ", "l": "ℓ", "kg": "㎏", "KG": "㎏", "m3": "㎥", "M3": "㎥", "m2": "㎡", "M2": "㎡", "km": "㎞", "mm": "㎜", "cm": "㎝", "t": "ton", "TON": "ton", "Ton": "ton", "톤": "ton", "EA": "개", "ea": "개", "kWh": "㎾h", "kW": "㎾", "SET": "조", "set": "조", "세트": "조", "명": "인", } # fmt: skip SCALE = {"천원": (Fraction(1000), "원"), "만㎡": (Fraction(10000), "㎡")} # 기계 문서 칸 → 기계 자료 단위 칸(`master_machine_crew` 옛 칸 이름) EQ_COLUMN = {"잡재료율": "잡품", "조종원": "인력"} EQ_TEXT = ("조종원직종", "주연료종류", "주연료2종류") CODE = re.compile(r"직종$|^기계$") # 고르는 값 = 노임 · 기계 번호(수 꼴 글) NAMED = re.compile( r"(mm|㎜|cm|㎝|ℓ)(?:이하|이상)?$" ) # 이름 끝에 단위를 적은 수 고르기(「두께mm이하」) _NUM = re.compile(r"^(\d[\d,]*(?:\.\d+)?)(.+)$") _POW = re.compile(r"^×?10\^(-?\d+)(.*)$") def show(unit) -> str: """보이는 꼴 한 벌 — 원문 꼴(`master_unit_text.canon`) + ℓ · ㎾h(원문 8-4 · 13-1 꼴).""" text = ut.canon(unit) text = re.sub(r"(? 2: return None # 「㎥·㎏·본」 = 줄마다 다른 단위 나열 for p, sign in parts: got = _token(p) if got is None: return None scale, dims = got if not scale: return None out_scale *= scale**sign for k, n in dims.items(): out[k] = out.get(k, 0) + n * sign return out_scale, {k: n for k, n in out.items() if n} def text_of(dims: dict) -> str: """차원 → 단위 글(보이는 꼴) · 노임 「원/일」 × 「인」 = 원(1 인 1 일).""" if dims.get("원") and dims.get("인", 0) > 0 and dims.get("일", 0) < 0: n = min(dims["인"], -dims["일"]) dims = {k: v for k, v in {**dims, "인": dims["인"] - n, "일": dims["일"] + n}.items() if v} up = [k if n == 1 else f"{k}^{n}" for k, n in dims.items() if n > 0] down = [k if n == -1 else f"{k}^{-n}" for k, n in dims.items() if n < 0] text = "·".join(up) or ("1" if down else "") return show(text + "".join("/" + d for d in down)) def same(a, b) -> bool: pa, pb = parse(a), parse(b) return pa is not None and pa == pb # ── 식 단위 셈 ─────────────────────────────────────────────────────── class Unknown(Exception): """단위를 못 셈 — 까닭 글.""" _TEXTV = ("글",) _ZERO = ("영",) CAP = 64 # 배 갈래 수 윗끝(만약 갈래가 곱으로 불어남) FACTOR = (Fraction(1, 2), Fraction(2)) # 식 속 수가 곱한 정도 — 이 안 = 보정 계수(무차원) def _val(node, units: dict): """마디 → (배 모음, 차원) · 글 = _TEXTV · 수 0 = _ZERO(아무 단위와 맞음). 배 = 식 값 × 배 → 차원의 기준 값(천원 × 1000 · % ÷ 100 = 1) · 만약 갈래마다 배가 달라 모음.""" kind = node[0] if kind == "num": return _ZERO if node[1] == 0 else (frozenset([1 / Fraction(str(node[1]))]), {}) if kind == "str": return _TEXTV if kind == "var": if node[1] not in units: raise Unknown(f"「{node[1]}」 단위 모름") got = units[node[1]] if got is None: raise Unknown(f"「{node[1]}」 단위가 행마다 다름") return got if got == _TEXTV else (frozenset([got[0]]), got[1]) if kind == "pct": inner = _val(node[1], units) return inner if inner in (_ZERO, _TEXTV) else (_times(inner[0], [Fraction(100)]), inner[1]) if kind == "neg": inner = _val(node[1], units) return inner if inner in (_ZERO, _TEXTV) else (frozenset(-f for f in inner[0]), inner[1]) if kind == "bin": op = node[1] if op not in ("+", "-", "*", "/"): # 비교 · 그리고 · 또는 = 참거짓 return _TEXTV a, b = _val(node[2], units), _val(node[3], units) if op in ("*", "/"): if _TEXTV in (a, b): raise Unknown("글을 곱함") if a == _ZERO or (b == _ZERO and op == "*"): return _ZERO if b == _ZERO: raise Unknown("0 으로 나눔") sign = 1 if op == "*" else -1 dims = dict(a[1]) for k, n in b[1].items(): dims[k] = dims.get(k, 0) + n * sign return _times(a[0], [f**sign for f in b[0]]), {k: n for k, n in dims.items() if n} return _same(a, b, "더하기 · 빼기") if kind == "call": if node[1] == "만약": # 조건은 단위와 무관 return _same(_val(node[2][1], units), _val(node[2][2], units), "만약 두 갈래") args = [_val(x, units) for x in node[2]] if node[1] in ("올림", "버림", "반올림"): return args[0] if node[1] in ("최소", "최대"): out = args[0] for x in args[1:]: out = _same(out, x, node[1]) return out raise Unknown(f"함수 「{node[1]}」") if kind == "field": base = node[1] if base[0] == "var" and base[1] in units: return _val(base, units) raise Unknown("칸 읽기") raise Unknown(f"마디 「{kind}」") def _times(fs, gs) -> frozenset: out = frozenset(f * g for f in fs for g in gs) if len(out) > CAP: raise Unknown("갈래가 너무 많음") return out def _same(a, b, where: str): if a == _ZERO: return b if b == _ZERO: return a if a == _TEXTV and b == _TEXTV: return _TEXTV if _TEXTV in (a, b) or a[1] != b[1]: raise Unknown(f"{where} 단위 다름") if not a[1]: # 무차원끼리(1 - 8 ÷ 100 · 만약(…, 0.85, 1)) — 2 배 안 수는 보정 계수 하나로 a, b = (_small(a[0]), a[1]), (_small(b[0]), b[1]) return a[0] | b[0], a[1] def _small(fs: frozenset) -> frozenset: return frozenset([Fraction(1)]) if all(abs(1 / f) <= FACTOR[1] for f in fs) else fs def infer(text, units: dict, named: dict | None = None) -> tuple[str | None, str]: """식 글 → (단위 글 | None, 까닭) — 「」 = 무차원 · 「글」 = 글 값 · None = 못 셈(까닭).""" try: got = _val(md.parse(text), units) except (Unknown, md.DocError, ArithmeticError) as e: return None, str(e) if got == _TEXTV: return "글", "" if got == _ZERO: return None, "값 0 뿐" fs, dims = got if any(abs(n) > 1 for n in dims.values()): return None, "제곱 단위" if fs == {1}: return text_of(dims), "" if len(fs) == 1: hit = (named or {}).get((next(iter(fs)), tuple(sorted(dims.items())))) if hit: return hit, "" if not dims and fs == {Fraction(1, 100)}: return "%", "" if not any(dims.values()) and all(f != 1 for f in fs): return None, "변수 없이 수만 — 수가 단위를 품을 수 있음" if all(FACTOR[0] <= abs(1 / f) <= FACTOR[1] for f in fs): return text_of(dims), "" # 0.85 · 1.1 · 1.3 = 보정 계수 return None, "단위 없는 수가 단위를 품을 수 있음(× " + " · ".join( sorted({str(1 / f) for f in fs})[:3] ) + ")" # ── 원본 단위 ─────────────────────────────────────────────────────── def _rows_of(master, ref: str, choices: dict | None = None) -> list[dict]: """키 · 「ID:원문번호」 · 「ID:{빈칸}…」 → 맞을 수 있는 원본 줄들(빈칸 = 고를 수 있는 값만).""" if "{" not in ref: try: return [master.get(ref)] except Exception: # noqa: BLE001 — 없는 키 = 근거 없음 return [] tid, _, number = ref.partition(":") def hole(m) -> str: vals = (choices or {}).get(m.group(1)) return "(?:" + "|".join(re.escape(str(v)) for v in vals) + ")" if vals else ".+" parts = re.split(r"(\{[^{}]+\})", number) pat = "".join(md.HOLE.sub(hole, x) if x.startswith("{") else re.escape(x) for x in parts) pat = re.compile("^" + pat + "$") return [r for n, r in (master.numbers.get(tid) or {}).items() if pat.match(n)] def choices_of(master, doc: dict) -> dict: """기호 → 고를 수 있는 값 — 선택 목록 · 기계 글 칸(주연료종류 …) = 고를 수 있는 기계의 그 칸.""" out = { md.var_id(v): {str(x) for x in (v["연결"].get("목록") or [])} for v in doc.get("변수") or [] if (v.get("연결") or {}).get("갈래") == "선택" } for v in doc.get("변수") or []: link = v.get("연결") or {} if link.get("갈래") == "요소" and link.get("칸") in EQ_TEXT: rows = _rows_of(master, str(link.get("키") or ""), out) vals = {str((r.get("값") or {}).get(link["칸"])) for r in rows} if rows and "None" not in vals: out[md.var_id(v)] = vals return out def _one(units) -> str | None: """모두 같은 단위 하나 — 빈 것 · 단위로 못 읽는 글(「1일 1대당」)이 끼면 None.""" got = {show(u) for u in units if u not in (None, "")} if len(got) != 1 or any(u in (None, "") for u in units): return None one = got.pop() return one if parse(one) is not None else None def element_unit(master, link: dict, choices: dict | None = None) -> tuple[str | None, str]: """요소 연결 → (보일 단위 | None, 근거) — 「글」 = 글 값.""" if link.get("재료") is not None: unit = str(link.get("단위") or "") return (f"원/{show(unit)}", "재료 고르기 단위") if unit else (None, "재료 단위 없음") ref, col = str(link.get("키") or ""), link.get("칸") rows = _rows_of(master, ref, choices) if not rows: return None, f"요소 「{ref}」 없음" tid = ref[:2] if tid == "EQ": if col in EQ_TEXT: return "글", f"기계 「{col}」 = 글" if col is None: return None, "기계 칸 없음" units = [(r.get("단위") or {}).get(EQ_COLUMN.get(col, col)) for r in rows] units = [u for u, r in zip(units, rows) if EQ_COLUMN.get(col, col) in (r.get("값") or {})] got = _one(units) return got, f"기계 단위 「{EQ_COLUMN.get(col, col)}」" if col is not None: return None, f"요소 칸 「{col}」" if tid == "MT": got = _one([r.get("단위") for r in rows]) return (f"원/{got}" if got else None), "자재 단위" if tid in ("LB", "MO"): return _one([r.get("단위") for r in rows]), "노임 · 유가 단위" return None, f"요소 「{tid}」" def _row_unit(master, link: dict, choices: dict) -> tuple[str | None, str]: """표 칸 단위 — 값칸 단위 · 자리표시면 고를 수 있는 행이 모두 같은 `단위` 칸.""" rows = _rows_of(master, str(link.get("표") or "")) if len(rows) != 1: return None, "표 하나로 안 정해짐" table, col = rows[0], link.get("칸") head = (table.get("값칸") or {}).get(col) if head is None: return None, f"칸 「{col}」 없음" if head in TEXT: return "글", "값칸 = 글" picked = table.get("줄") or [] if head not in ROW_UNIT and not (parse(head) is None and any("단위" in r for r in picked)): return show(head), "값칸 단위" for k, cond in (link.get("조건") or {}).items(): cond = str(cond).strip() if cond[:1] == "'" and cond[-1:] == "'": picked = [r for r in picked if str(r.get(k)) == cond[1:-1]] elif cond in choices: picked = [r for r in picked if str(r.get(k)) in choices[cond]] got = _one([r.get("단위") for r in picked]) if picked else None return got, ("고를 수 있는 행 단위 한 가지" if got else "행마다 단위 다름") def dimensionless(var: dict) -> bool: """빈 단위가 맞는 변수 — 글 고르기 · 글 값 · 원본이 「수」 로 적은 칸은 `fill` 이 「」 로 둠.""" link = var.get("연결") or {} if CODE.search(str(var.get("이름") or "")): return link.get("갈래") in ("선택", "입력") if link.get("갈래") == "선택": return not all(_is_num(x) for x in link.get("목록") or []) return link.get("갈래") == "입력" and not _is_num(link.get("값")) def _is_num(x) -> bool: try: float(str(x).replace(",", "")) return True except ValueError: return False def source_unit(master, doc: dict, var: dict) -> tuple[str | None, str]: """변수 하나의 원본 단위 — (단위 | 「글」 | 「」 무차원 | None 모름, 근거).""" link = var.get("연결") or {} kind = link.get("갈래") choices = choices_of(master, doc) if kind == "표": return _row_unit(master, link, choices) if kind == "요소": return element_unit(master, link, choices) if kind == "단가산출": try: unit = master.logic(str(link.get("키") or "")).get("결과단위") or "" except Exception: # noqa: BLE001 return None, "부른 문서 없음" return (show(unit), "부른 문서 결과단위") if unit else (None, "부른 문서 결과단위 없음") if dimensionless(var): return "", "글 고르기 · 글 값" return None, "" def decide(master, doc: dict, v: dict) -> tuple[str | None, str]: """변수 하나 — (바꿀 단위 | 「글」 | None 그대로, 근거). 식 변수는 `fill` 이 식 셈으로.""" now = str(v.get("단위") or "") kind = (v.get("연결") or {}).get("갈래") if kind in ("선택", "입력") and not now and not dimensionless(v): hit = NAMED.search(str(v.get("이름") or "")) return (show(hit.group(1)), "이름에 적힌 단위") if hit else (None, "") if kind not in ("표", "요소", "단가산출"): return None, "" src, why = source_unit(master, doc, v) if src == "글": return ("글", why) if not now else (None, why) if kind == "표" and now in NOT_UNIT: return "", "단위 칸 「수」 = 단위 아님" if not now or (kind == "표" and (now in ROW_UNIT or parse(now) is None)): if src and src not in PLAIN_NUMBER and src != now and parse(src) is not None: return src, why if now and now != ROW and why == "행마다 단위 다름": return ROW, "행마다 다른 단위 표기 하나로" return None, why if kind == "표" and src and show(now) != now and same(now, src): return show(now), "표기 한 벌" return None, why def fill(doc: dict, master) -> list[tuple[str, str, str, str]]: """빈 단위(· 자리표시)를 원본 · 식 셈 · 금액 줄 역산으로 채움 — [(기호, 전, 후, 근거)].""" changed, texts = [], set() by_id = {md.var_id(v): v for v in doc.get("변수") or []} for v in doc.get("변수") or []: got, why = decide(master, doc, v) if got == "글": texts.add(md.var_id(v)) elif got is not None: changed.append((md.var_id(v), str(v.get("단위") or ""), got, why)) v["단위"] = got steps = {line["기호"]: line["식"] for line in doc.get("식") or []} _forward(doc, by_id, steps, texts, changed) for vid, (unit, why) in _back(doc, by_id, steps, known(doc, texts)).items(): changed.append((vid, "", unit, why)) by_id[vid]["단위"] = unit _forward(doc, by_id, steps, texts, changed) return changed def _forward(doc, by_id, steps, texts, changed) -> None: for _ in range(len(steps) + 1): # 식이 뒤 식을 부름 — 안 바뀔 때까지 units, more = known(doc, texts), False for sid, text in steps.items(): v = by_id.get(sid) if v is None or v.get("단위") or sid in texts: continue got, _ = infer(text, units, _texts(doc)) if got == "글": texts.add(sid) more = True elif got: changed.append((sid, "", got, f"식 셈 — {text}")) v["단위"], more = got, True if not more: return def line_ok(line: dict, units: dict) -> bool: """금액 줄 단위가 단가 단위(원/X)의 X 와 같음 — 노임 「원/일」 은 「인」(1 인 1 일).""" price = units.get(str(line.get("단가") or "").strip()) mine = parse(line.get("단위")) if price in (None, _TEXTV) or mine is None or price[1].get("원") != 1: return False per = {k: -n for k, n in price[1].items() if k != "원"} return price[0] == 1 and (mine[1] == per or (per == {"일": 1} and mine[1] == {"인": 1})) def _back(doc, by_id, steps, units) -> dict: """금액 줄 역산 — 단가와 맞는 줄 단위 U 의 수량을 더하기(· 보정 계수 곱 · 만약 갈래)로만 이은 표 · 식 변수 = U. 한 변수에 두 단위가 나오면 안 채움.""" got: dict[str, set] = {} def walk(node, unit, line, depth=0): kind = node[0] if kind == "var": v = by_id.get(node[1]) if v is None or v.get("단위"): return link = (v.get("연결") or {}).get("갈래") if link == "표" or link == "식": got.setdefault(node[1], set()).add((unit, line)) if link == "식" and node[1] in steps and depth < 8: walk(md.parse(steps[node[1]]), unit, line, depth + 1) elif kind == "bin" and node[1] in ("+", "-"): walk(node[2], unit, line, depth) walk(node[3], unit, line, depth) elif kind == "bin" and node[1] in ("*", "/"): for me, other in ((node[2], node[3]), (node[3], node[2])): if (node[1] == "*" or other is node[3]) and _factor(other): walk(me, unit, line, depth) elif kind == "call" and node[1] == "만약": for x in node[2][1:]: walk(x, unit, line, depth) elif kind == "neg": walk(node[1], unit, line, depth) for line in doc.get("금액") or []: if line.get("수량") and str(line.get("단위") or "") != "식" and line_ok(line, units): try: walk(md.parse(line["수량"]), show(line["단위"]), str(line.get("이름") or "")) except md.DocError: continue out = {} for vid, hits in got.items(): kinds = {u for u, _ in hits} if len(kinds) == 1: unit = kinds.pop() names = " · ".join(sorted({n for _, n in hits})[:3]) out[vid] = ( unit, f"금액 줄 「{names}」 단위 {unit} 역산(단가 단위와 맞음 · 더하기로 이음)", ) return out def _factor(node) -> bool: """변수 없는 수 · 만약(…, 수, 수) — 2 배 안 보정 계수.""" try: got = _val(node, {}) except (Unknown, ArithmeticError): return False if got in (_ZERO, _TEXTV) or got[1]: return False return all(FACTOR[0] <= abs(1 / f) <= FACTOR[1] for f in got[0]) def _texts(doc: dict) -> dict: """(배, 차원) → 그 단위를 적은 변수 단위 글 — 식이 변수 하나를 옮길 때(「천원」 그대로).""" out = {} for v in doc.get("변수") or []: got = parse(v.get("단위")) if v.get("단위") else None if got is not None: out.setdefault((got[0], tuple(sorted(got[1].items()))), show(v["단위"])) return out def known(doc: dict, texts=()) -> dict: """기호 → (배, 차원) | 글 | None(행마다 다름) — 식 셈 재료. 빈 단위 식 변수 · 수 입력 · 원본 칸이 빈 표는 모름(안 넣음).""" out = {} for v in doc.get("변수") or []: vid, unit = md.var_id(v), str(v.get("단위") or "") if vid in texts or (not unit and dimensionless(v)): out[vid] = _TEXTV elif unit: out[vid] = parse(unit) for k in md.COSTS: out.setdefault(k, None) return out