"""M02 일위대가 실무자료 옵션값 읽기(PLAN 785-2 · 요구 641 · 642 · 설계 642-A 2-5 ④-3) — 우리 기초 하나 + 엑셀 글감 → 입력값. 찾지 않고 읽음 · 지어 넣는 1 · 0 없음. 길(앞이 먼저 · 두 길이 다른 값 = 엇갈림 · 앞 값 둠): B1 부르는 호표 줄 규격 · 품명 괄호(`Match.spec_inputs`) B6 같은 호표 다른 줄 — 레미콘 규격 「a-b-c」 → 굵은골재 · 강도 · 슬럼프 · 채움량 = 그 줄 수량 ÷ 이 줄 수량 B2 선택값 줄(785-1 Formula) · 글에 없는 고르기 = 「아님 · 해당 없음」(글 · 줄에 칸 이름 · 다른 고름이 없을 때) B3 상수 줄(기호표 `SYMBOLS` · 기호 = 입력 이름 · 「(90˚)」 = 선회각도 · 표 칸 이름 = 기호면 그 표 줄 조건값) B4 대입식 — 우리 줄 수량 식을 중간까지 펴 분자 · 분모 자리 + 단위로 짝(하나뿐일 때만) B5 표 거꾸로 — 못 읽은 찾기 조건 칸 = 엑셀 품과 같은 표 줄(`fit` 검산) · 「감률」 칸 = 품 배수 못 읽은 칸 = 비움(지역 · 자재지역 · 조종원직종 = 프로젝트) · 입력 필요(빈 줄을 낳음 · 요소 글에 듦) · 안 씀(그 밖) · 기본 = `master_auto.defaults` 제안(저장 아님). 엔진 몫 `quantities` = 금액 없이 품만. 서버 단독 · 읽기만 · 짝 = `M02_Hopyo_Solve`. """ from __future__ import annotations import re from decimal import Decimal from typing import Any, Callable from M01_MasterData import M01_MasterData_Store as m01 from M02_MasterTemplete import M02_Hopyo_Match as hm mf = m01.cm.mf mp = mf.mp mm = mp.mm import master_auto as ma # noqa: E402 — mf 를 불러 scripts 경로가 깔린 뒤 import master_unit as mu # noqa: E402 SKIP = ("지역", "자재지역", "조종원직종") NEG = ("아님", "해당 없음", "없음", "미적용", "사용 안 함") # 상수 기호 → 입력 이름(앞이 먼저 · 기호 = 입력 이름이면 바로) SYMBOLS = { "q": ("버킷용량",), "E": ("E입력",), "Cm": ("싸이클입력",), "L": ("운반거리", "거리"), "n": ("운반회수",), "T": ("실작업시간",), "경사도": ("경사각도",), } _WORDS = re.compile(r"[\s::,/()※*·\[\]{}]+") _SAME_UNIT = {"sec": "초", "s": "초", "min": "분", "hr": "시간", "h": "시간"} _ANGLE = re.compile(r"\(\s*(\d+)\s*[˚°]\s*\)") _MIX = re.compile(r"(? Decimal | None: try: return None if value in (None, "") else Decimal(str(value)) except ArithmeticError: return None def plain(value: Any) -> Any: """JSON 수 — Decimal → int · float(글은 그대로).""" if isinstance(value, Decimal): value = value.normalize() return int(value) if value == value.to_integral_value() else float(value) return value def specs_of(row: dict) -> list[dict]: return mf.inputs_of(row) if isinstance(row.get("흐름"), dict) else row.get("입력", []) def _pick(spec: dict, value: Any) -> Any: """고르기 칸이면 같은 수 · 같은 글의 고름(「080」 = 80) · 없으면 None · 고르기 아니면 수.""" picks = spec.get("고르기") if not picks: return _num(value) hit = next((o for o in picks if str(o) == str(value)), None) n = _num(value) if hit is None and n is not None: hit = next((o for o in picks if _num(o) == n), None) return hit # ── 엔진 몫(품만) ───────────────────────────────────────────────────── def _env(master: mf.Master, row: dict, given: dict) -> dict: """입력 검사(고르기 · 범위 · 기계 키) — 빈 칸은 자리값으로 검사만 하고 None 으로 되돌림.""" holes = {} for spec in specs_of(row): if given.get(spec["이름"]) is None: span = spec.get("범위") holes[spec["이름"]] = (spec.get("고르기") or [None])[0] or (span[0] if span else 0) env = mf._inputs(row, {**{s["이름"]: None for s in specs_of(row)}, **given, **holes}, master) return {**env, **dict.fromkeys(holes)} def _rate(extra: dict, env: dict, master: mf.Master) -> Decimal | None: """덧줄 율 — 식 꼴 「(기준) × 율 / 100」 의 율 ÷ 100 · 다른 꼴 = None.""" node = mf.parse(extra["식"]) if node[0] == "bin" and node[1] == "/" and node[3] == ("num", Decimal(100)): left = node[2] if left[0] == "bin" and left[1] == "*": return mf.evaluate(left[3], env, master) / 100 return None def quantities(master: mf.Master, key: str, given: dict) -> list[dict]: """로직 줄마다 품 `{이름, 종류, 단위, 요소, 품, 까닭?}` + 덧줄 율(종류 「율」) — 금액 안 셈 · 빈 칸을 쓰는 줄만 품 None + 까닭(`mf.run` 과 같은 길).""" row = master.logic(key) if isinstance(row.get("흐름"), dict): # 흐름 로직 — 엔진 한 번(빈 칸 = 자리값 없음 → 못 셈) try: got = mf.run(master, key, given) except mf.FormulaError as e: return [{"이름": "", "종류": "", "단위": "", "품": None, "까닭": str(e)}] return [{**x, "종류": "", "품": x.get("수량")} for x in got.get("줄", [])] env = _env(master, row, given) for step in row.get("중간", []): try: env[step["이름"]] = mf.evaluate(mf.parse(step["식"]), env, master) except mf.EmptyInput as e: env[step["이름"]] = mf.Blank(str(e)) out = [] for item in row.get("호표", []): one = {k: item.get(k) for k in ("이름", "종류", "단위", "요소", "비목")} try: one["품"] = mf.evaluate(mf.parse(item["수량"]), env, master) except mf.EmptyInput as e: one |= {"품": None, "까닭": str(e)} out.append(one) for extra in row.get("덧줄", []): one = {"이름": extra["이름"], "종류": "율", "단위": "%", "비목": extra.get("비목")} try: one["품"] = _rate(extra, env, master) except mf.EmptyInput as e: one |= {"품": None, "까닭": str(e)} out.append(one) return out # ── 식 풀기 ─────────────────────────────────────────────────────────── def _names(node) -> set: return {p[1] for p in mf.walk(node) if p[0] == "name"} def _mids(row: dict) -> dict: return {s["이름"]: mf.parse(s["식"]) for s in row.get("중간", [])} def deps(row: dict, text: str) -> set: """식이 기대는 입력 이름 — 중간을 거쳐 폄(만약 갈래 모두 · 넉넉히).""" mids, seen, todo = _mids(row), set(), _names(mf.parse(text)) while todo: name = todo.pop() if name in seen: continue seen.add(name) if name in mids: todo |= _names(mids[name]) return seen def _place(node, side: str, mids: dict, out: dict, depth: int = 0) -> None: """이름마다 분자 · 분모 자리 — 「/」 오른쪽이 뒤집힘 · 만약은 두 갈래만 · 찾기 · 함수 안은 안 봄.""" kind = node[0] if kind == "name": if node[1] in mids and depth < 20: _place(mids[node[1]], side, mids, out, depth + 1) else: out.setdefault(node[1], set()).add(side) elif kind == "bin" and node[1] in ("*", "/", "+", "-"): _place(node[2], side, mids, out, depth) flip = {"분자": "분모", "분모": "분자"}[side] if node[1] == "/" else side _place(node[3], flip, mids, out, depth) elif kind == "call" and node[1] == "만약": for arm in node[2][1:]: _place(arm, side, mids, out, depth) def _element_names(row: dict, element: Any) -> set: """요소가 기대는 입력 — 글 = 식 · 묶음 = 「{이름}」 구멍.""" if isinstance(element, str): return deps(row, element) if isinstance(element, dict): return {n for v in element.values() for n in re.findall(r"\{([^{}]+)\}", str(v))} return set() def places(row: dict) -> dict: """입력 이름 → {분자 | 분모} — 호표 줄 수량 식에서.""" out: dict = {} mids = _mids(row) for item in row.get("호표", []): _place(mf.parse(item["수량"]), "분자", mids, out) return out def finds(master: mf.Master, row: dict, depth: int = 0) -> dict: """찾기 조건 칸으로 쓰인 입력 → [(표, 조건)] — 「로직(키, 칸=이름)」 한 겹 안도 이름을 옮겨 봄.""" out = {k: list(v) for k, v in ma._uses(row)[0].items()} if depth > 1: return out for text in ma._texts(row): for part in mf.walk(mf.parse(text)): if part[0] != "logic" or not isinstance(part[1], str) or "{" in part[1]: continue try: inner = finds(master, master.logic(part[1]), depth + 1) except mf.FormulaError: continue passed = {k: v[1] for k, v in part[2] if v[0] == "name"} for name, uses in inner.items(): if name in passed: out.setdefault(passed[name], []).extend(uses) return out # ── 읽기 ───────────────────────────────────────────────────────────── class _Got: """읽은 값 · 출처 · 엇갈림.""" def __init__(self, specs: list[dict]) -> None: self.specs = {s["이름"]: s for s in specs} self.value: dict = {} self.where: dict = {} self.clash: dict = {} def put(self, name: str, value: Any, how: str, at: Any = None, text: Any = "") -> None: spec = self.specs.get(name) value = _pick(spec, value) if spec else None if value is None or name in SKIP: return mine = {"값": plain(value), "길": how, "행": at} if name not in self.value: self.value[name], self.where[name] = value, {"길": how, "행": at, "글": str(text)[:120]} elif self.value[name] != value: first = { "값": plain(self.value[name]), **{k: self.where[name][k] for k in ("길", "행")}, } self.clash.setdefault(name, [first]) if mine not in self.clash[name]: self.clash[name].append(mine) def _b1(got: _Got, line: dict | None, how: str = "B1") -> None: if not line: return values, _ = hm.spec_inputs(line, list(got.specs.values())) text = f"{line.get('이름') or ''} {line.get('규격') or ''}".strip() for name, value in values.items(): got.put(name, value, how, line.get("행"), text) def _b6(got: _Got, line: dict | None, same: list[dict]) -> None: for other in same or []: text = f"{other.get('이름') or ''} {other.get('규격') or ''}" mix = _MIX.search(text) if not mix or not any(w in text for w in CONC): continue for name, value in zip(MIX_NAMES, mix.groups(), strict=True): got.put(name, value, "B6", other.get("행"), text) have, mine = _num(other.get("수량")), _num((line or {}).get("수량")) for name, spec in got.specs.items(): unit = mm.plain_unit(spec.get("단위")) fill = "콘크리트" in name and unit.count("/") == 1 and have and mine if fill and unit.split("/") == [ mm.plain_unit(other.get("단위")), mm.plain_unit(line.get("단위")), ]: got.put( name, (have / mine).quantize(Decimal("0.0001")), "B6", other.get("행"), text ) def _b2(got: _Got, picks: list[dict], text: str, keys: dict) -> None: """선택값 줄 · 글에 없는 켜기 칸 = 아님(표 줄을 고르는 찾기 조건 칸은 B5 몫 · 뺌).""" for one in picks or []: # 고름 글 = 줄의 낱말 하나와 같음(「사용횟수」 ≠ 「사용」) words = {mm.plain_spec(w) for w in _WORDS.split(str(one.get("글") or "")) if w} for name, spec in got.specs.items(): hits = [o for o in spec.get("고르기") or [] if mm.plain_spec(str(o)) in words] if len(hits) == 1: got.put(name, hits[0], "B2", one.get("행"), one.get("글")) flat = mm.plain_spec(text) for name, spec in got.specs.items(): opts = spec.get("고르기") or [] neg = next((o for o in opts if str(o) in NEG), None) if neg is None or name in got.value or name in keys: continue words = [name, *(hm._PAREN.sub("", str(o)) for o in opts if o != neg)] if not any(len(w) > 2 and mm.plain_spec(w) in flat for w in words): got.put(name, neg, "B2", None, f"글에 「{name}」 없음") def _unit_ok(have: Any, want: Any) -> bool: """상수 단위 ↔ 입력 단위 — 한쪽이 없음 · 같음(「회/일」 = 회) · 같은 성격(길이 등).""" a, b = (mm.plain_unit(str(u or "").split("/")[0]) for u in (have, want)) a, b = _SAME_UNIT.get(a, a), _SAME_UNIT.get(b, b) return not a or not b or a == b or mu.same_kind(a, b) def _b3(got: _Got, consts: list[dict], master: mf.Master, uses: dict) -> None: for c in consts or []: sym, value, at, text = str(c.get("기호") or ""), c.get("값"), c.get("행"), c.get("글") or "" angle = _ANGLE.search(f"({c.get('덧') or ''}) {c.get('단위') or ''} {text}") if angle: got.put("선회각도", angle.group(1), "B3", at, text) names = [ n for n in (c.get("이름"), sym, *SYMBOLS.get(sym, ())) if n in got.specs and _unit_ok(c.get("단위"), got.specs[n].get("단위")) ] if names: unit = got.specs[names[0]].get("단위") rate = mu.factor(c.get("단위"), unit) if c.get("단위") and unit else None value = _num(value) * rate if rate and _num(value) is not None else value got.put( names[0], value, "B3", at, f"{sym} = {plain(_num(c.get('값')))}{c.get('단위') or ''}", ) continue for name, refs in uses.items(): # 표 칸 이름 = 기호 → 그 줄 조건값(K = 0.55 → 버킷조건) if name in got.value or name not in got.specs: continue for ref, cond in refs: rows = [r for r in master.get(ref).get("줄", []) if _num(r.get(sym)) == _num(value)] conds = {r.get(cond) for r in rows} if len(conds) == 1 and None not in conds: got.put(name, conds.pop(), "B5", at, f"{sym} = {plain(_num(value))} → {ref}") def _b4(got: _Got, forms: list[dict], row: dict) -> None: where = places(row) for form in forms or []: for side in ("분자", "분모"): for term in form.get(side) or []: unit = mm.plain_unit(str(term.get("단위") or "").split("/")[0]) hits = [ n for n, s in got.specs.items() if side in where.get(n, ()) and unit and mm.plain_unit(s.get("단위")) == unit ] value = term.get("수", term.get("값")) if term.get("기호") is not None: value = (form.get("대입") or {}).get(term["기호"]) if len(hits) == 1 and _num(value) is not None: got.put(hits[0], value, "B4", form.get("행"), form.get("글")) def _ok(fit: Callable, given: dict) -> dict: try: return fit(given) or {} except mf.FormulaError: return {} def _b5(got: _Got, master: mf.Master, row: dict, uses: dict, fit: Callable, need: set) -> None: """못 읽은 찾기 조건 칸 — 표 줄마다 품 검산 · 줄이 갈리면 그 값(같은 값 여럿 = 엇갈림).""" rate = [n for n in need if n.endswith("감률") and n not in got.value] zero = dict.fromkeys(rate, Decimal(0)) tables: dict = {} for name in sorted(need): for ref, cond in uses.get(name, []): if name not in got.value and got.specs.get(name, {}).get("고르기"): tables.setdefault(ref, []).append((name, cond)) for ref, conds in tables.items(): rows, combos = master.get(ref).get("줄", []), [] bound = { c: got.value[n] for n, refs in uses.items() if n in got.value for r, c in refs if r == ref } for r in rows: if all(r.get(c) is None or str(r.get(c)) == str(v) for c, v in bound.items()): combo = {n: r.get(c) for n, c in conds} combos += [combo] if combo not in combos else [] exact, scaled = [], [] for combo in combos: fill = {n: v if v is not None else got.specs[n]["고르기"][0] for n, v in combo.items()} res = _ok(fit, {**got.value, **zero, **fill}) if res.get("맞음"): exact.append(combo) elif rate and (res.get("배") or 0) > 1: scaled.append(combo) live = exact or scaled # 그대로 같은 줄이 감률 배 줄보다 먼저 if not live or (len(live) == len(combos) > 1): continue for name, _ in conds: seen = {str(c[name]) for c in live if c[name] is not None} for v in [str(o) for o in got.specs[name]["고르기"] if str(o) in seen]: got.put(name, v, "B5", None, f"{ref} 줄 = 엑셀 품") if rate: # 감률 = 엑셀 품 ÷ 우리 품(감률 0)이 모든 노무 · 기계 줄에서 같은 배 res = _ok(fit, {**got.value, **zero}) k = res.get("배") if k and k > 1: cut = (Decimal(1) - Decimal(1) / Decimal(str(k))) * 100 cut = ( cut.quantize(Decimal("1")) if abs(cut - cut.quantize(Decimal("1"))) < Decimal("0.5") else cut ) if _ok(fit, {**got.value, rate[0]: cut}).get("맞음"): got.put( rate[0], cut, "B5", None, f"품 × {plain(Decimal(str(k)).quantize(Decimal('0.01')))}", ) def bind(master: mf.Master, key: str, src: dict, fit: Callable | None = None) -> dict: """옵션값 읽기 → `{입력값, 출처, 엇갈림, 안씀, 입력필요, 비움, 기본}` — 설계 642-A 2-5 ④-3.""" row = master.logic(key) specs = specs_of(row) got = _Got(specs) line, block = src.get("줄"), src.get("블록") or {} _b1(got, line) if not line and block: _b1(got, {"이름": block.get("이름"), "규격": "", "행": (block.get("행") or [None])[0]}) _b6(got, line, src.get("같은줄") or []) uses = finds(master, row) if not isinstance(row.get("흐름"), dict) else {} _b3(got, src.get("상수") or [], master, uses) if not isinstance(row.get("흐름"), dict): _b4(got, src.get("식") or src.get("대입식") or [], row) whole = " ".join( [ *(block.get("글") or []), str((line or {}).get("이름") or ""), str((line or {}).get("규격") or ""), ] ) direct = {} if isinstance(row.get("흐름"), dict) else ma._uses(row)[0] _b2(got, src.get("선택값") or [], whole, direct) if fit: _b5(got, master, row, uses, fit, _needed(master, key, row, got.value)) need = _needed(master, key, row, got.value) base = ma.defaults(master, row) unread = [s["이름"] for s in specs if s["이름"] not in got.value] skip = [n for n in unread if n in SKIP] want = [n for n in unread if n in need and n not in SKIP and n not in got.clash] unused = [n for n in unread if n not in skip and n not in want and n not in got.clash] return { "입력값": {k: plain(v) for k, v in got.value.items() if k not in got.clash}, "출처": {k: v for k, v in got.where.items() if k not in got.clash}, "엇갈림": got.clash, "안씀": unused, "입력필요": want, "비움": skip, "기본": {n: plain(base.get(n)) for n in [*want, *unused, *got.clash]}, } def _needed(master: mf.Master, key: str, row: dict, have: dict) -> set: """못 읽은 칸 중 쓰이는 것 — 빈 품 줄이 기대는 칸(기본값으로 0 이 되는 줄 = 꺼진 줄 · 뺌) + 품 있는 줄의 요소 글에 든 칸(기계 · 지역 등 단가 몫).""" if isinstance(row.get("흐름"), dict): return {s["이름"] for s in specs_of(row)} - set(have) lines = quantities(master, key, have) base = {k: v for k, v in ma.defaults(master, row).items() if v is not None and k not in have} probe = ( quantities(master, key, {**base, **have}) if any(x["품"] is None for x in lines) else lines ) items = [*row.get("호표", []), *({"수량": x["식"]} for x in row.get("덧줄", []))] need: set = set() for item, now, then in zip(items, lines, probe, strict=True): if now.get("까닭") and then["품"] != 0: need |= deps(row, item["수량"]) elif now["품"] not in (None, 0): need |= _element_names(row, item.get("요소")) # ponytail: 0 이 되는 줄 = 꺼진 줄로 봄(기본값 하나로 판정) — 값마다 다른 줄이 나오면 칸마다 검산으로 return need - set(have)