"""M02 일위대가 실무자료 블록 풀이 검산 · 판정(PLAN 785-2 · 요구 642 · 설계 642-A 2-5 ④-4 · ④-5) — 후보 기초마다 옵션값 읽기(`M02_Hopyo_Bind`) → 엔진 한 번(품 · 금액 참고) → 엑셀 단위 줄과 짝 · 판정. 짝(같은 묶음끼리): L 노무 = 직종 이름 · X 기계 = 시간 단위 로직 줄 · M 자재 = 뜻 낱말(남은 하나끼리) · R 율 = 덧줄 율(같은 율 › 남은 하나끼리) · E 값 · S 하위 = 엑셀만. 품 같음(`same`) = 엑셀 자릿수 반올림 폭 안(0.67 ↔ 0.6667) 또는 0.5 % · 금액은 참고(노임 · 단가 해가 다름). 줄 판정 = 품 같음 · 품 다름 · 입력 필요 · 엑셀만 · 우리만(우리 줄 품 0 = 꺼진 줄 · 안 보임). 절 판정 = 기초 풂(품 다름 없는 후보 하나) · 후보 여럿 · 기초 없음 / 블록 판정 = 기초로 풂 · 기초+덧(엑셀만 줄 → `Units` 잇기) · 줄 조합(기초 없음뿐) · 고를 것 n. 서버 단독 · 읽기만 · 자동 고름 없음(제안 · 사용자 확인). """ from __future__ import annotations from decimal import Decimal from statistics import median from typing import Any, Callable from M02_MasterTemplete import M02_Hopyo_Bind as hb from M02_MasterTemplete import M02_Hopyo_Match as hm mf, mm, ma = hb.mf, hb.mm, hb.ma SAME, DIFF, NEED, EXCEL, OURS = "품 같음", "품 다름", "입력 필요", "엑셀만", "우리만" SOLVED, SOME, NONE = "기초 풂", "후보 여럿", "기초 없음" BY_BASE, BASE_PLUS, BY_LINES, PICK = "기초로 풂", "기초+덧", "줄 조합", "고를 것" SHARE = Decimal("0.005") HOURS = {"시간", "hr", "h", "시"} def _d(value: Any) -> Decimal | None: try: return None if value is None else Decimal(str(round(float(value), 10))) except (ArithmeticError, TypeError, ValueError): return None def same(excel: Any, ours: Any) -> bool: """품 같음 — 엑셀 적힌 자릿수의 반올림 폭 안 또는 0.5 % 안.""" e, o = _d(excel), _d(ours) if e is None or o is None: return False gap = abs(e - o) places = -e.normalize().as_tuple().exponent width = Decimal(5) * Decimal(10) ** -(places + 1) if 0 <= places <= 4 else Decimal(0) return gap <= width or gap <= abs(e) * SHARE def _kind(line: dict) -> str | None: kind = line.get("종류") if kind in ("로직", "기계"): return "X" if str(line.get("단위") or "").strip() in HOURS else "S" return {"인력": "L", "재료": "M", "율": "R"}.get(kind) def _strict(item: dict, line: dict) -> bool: kind = item.get("묶음") if kind == "L": return hm.tidy(line.get("이름")) == hm.tidy(item.get("직종") or item.get("이름")) if kind == "M": return hm.near(hm.words(item.get("이름")), hm.words(line.get("이름"))) if kind == "R": return same(item.get("품"), line.get("품")) return kind == "X" def _excel(item: dict, share: Decimal) -> dict: q = _d(item.get("품")) return { "묶음": item.get("묶음"), "이름": item.get("이름"), "품": hb.plain(q / share if q is not None and item.get("묶음") != "R" else q), "행": item.get("행"), **({"별도": True} if item.get("어떻게") == "별도" else {}), } def _row(item: dict | None, line: dict | None, share: Decimal) -> dict: ex = _excel(item, share) if item else None us = {k: hb.plain(line.get(k)) for k in ("이름", "종류", "단위", "품")} if line else None if line and line.get("까닭"): us["까닭"] = line["까닭"] if ex is None: verdict = NEED if line.get("까닭") else OURS elif us is None: verdict = EXCEL elif us["품"] is None: verdict = NEED if line.get("까닭") else DIFF else: verdict = SAME if same(ex["품"], us["품"]) else DIFF out = {"엑셀": ex, "우리": us, "판정": verdict} names = f"{(ex or {}).get('이름') or ''}|{(us or {}).get('이름') or ''}" if item and line and item.get("묶음") == "X" and "타이어" in names and "무한궤도" in names: out["표지"] = "기계 갈래 엇갈림" return out def pair(lines: list[dict], items: list[dict], share: Any = 1) -> list[dict]: """엑셀 단위 줄 ↔ 우리 줄 짝 → 줄 판정(엑셀 차례 · 남은 우리 줄 뒤).""" share = _d(share) or Decimal(1) used: set = set() got: dict = {} late = [] for i, item in enumerate(items): pool = [j for j, ln in enumerate(lines) if j not in used and _kind(ln) == item.get("묶음")] hits = [j for j in pool if _strict(item, lines[j])] exact = [j for j in hits if lines[j].get("이름") == (item.get("직종") or item.get("이름"))] if hits: got[i] = (exact or hits)[0] used.add(got[i]) else: late.append(i) for i in late: # 남은 하나끼리(결속선 ↔ 어닐링철선) kind = items[i].get("묶음") pool = [j for j, ln in enumerate(lines) if j not in used and _kind(ln) == kind] rest = [k for k in late if items[k].get("묶음") == kind and k not in got] if len(pool) == 1 and len(rest) == 1 and kind in ("M", "R"): got[i] = pool[0] used.add(pool[0]) rows = [_row(item, lines[got[i]] if i in got else None, share) for i, item in enumerate(items)] for j, line in enumerate(lines): if ( j not in used and line.get("품") != 0 and (line.get("품") is not None or line.get("까닭")) ): rows.append(_row(None, line, share)) return rows def _usable(items: list[dict]) -> list[dict]: return [x for x in items if not x.get("밖")] def fitter(master: mf.Master, key: str, items: list[dict], share: Any = 1) -> Callable: """입력값 → `{맞음, 배}` — 맞음 = 짝 하나 이상 · 모두 품 같음 / 배 = 노무 · 기계 줄이 모두 같은 배 (자재 · 율 줄은 같음) — 표 거꾸로 · 감률 읽기(B5)가 씀.""" items = _usable(items) def fit(given: dict) -> dict: rows = pair(hb.quantities(master, key, given), items, share) both = [r for r in rows if r["엑셀"] and r["우리"] and r["엑셀"]["품"] and r["우리"]["품"]] ok = bool(both) and all(r["판정"] == SAME for r in both) labor = [r for r in both if r["엑셀"]["묶음"] in ("L", "X")] k = None if labor: k = median(_d(r["엑셀"]["품"]) / _d(r["우리"]["품"]) for r in labor) flat = all(same(r["엑셀"]["품"], _d(r["우리"]["품"]) * k) for r in labor) rest = all(r["판정"] == SAME for r in both if r not in labor) k = float(k) if flat and rest else None return {"맞음": ok, "배": k} return fit def _money(master: mf.Master, key: str, bound: dict, given: dict) -> dict: """금액 참고 — 입력 필요가 없을 때 엔진 한 번(안 씀 · 비움 = 기본 제안 · 저장 안 함).""" if bound["입력필요"]: return {"계": None, "까닭": f"입력 필요 {len(bound['입력필요'])}"} row = master.logic(key) base = ma.defaults(master, row) fill = {n: base.get(n) for n in [*bound["안씀"], *bound["비움"]]} try: got = mf.run(master, key, mf.fill_empty(row, {**fill, **given})) except mf.FormulaError as e: return {"계": None, "까닭": str(e)} if "결과" in got: return {"결과": hb.plain(got["결과"])} return {k: hb.plain(got.get(k)) for k in ("노무비", "재료비", "경비", "계")} def candidate(master: mf.Master, key: str, src: dict, items: list[dict], share: Any = 1) -> dict: """후보 기초 하나 — 바인딩 · 줄 대조 · 판정 · 금액 참고.""" row = master.logic(key) bound = hb.bind(master, key, src, fitter(master, key, items, share)) given = {**bound["입력값"], **{n: v[0]["값"] for n, v in bound["엇갈림"].items()}} idle = {n: bound["기본"][n] for n in bound["안씀"] if bound["기본"].get(n) is not None} rows = pair(hb.quantities(master, key, {**idle, **given}), _usable(items), share) count = {v: sum(r["판정"] == v for r in rows) for v in (SAME, DIFF, NEED, EXCEL, OURS)} if count[DIFF]: verdict = DIFF elif bound["입력필요"] or count[NEED] or bound["엇갈림"]: verdict = NEED else: verdict = SAME if count[SAME] else EXCEL return { "키": key, "이름": row.get("이름"), "바인딩": bound, "줄": rows, "셈": count, "판정": verdict, "금액": _money(master, key, bound, given), } def _section(master: mf.Master, block: dict, part: dict, src: dict) -> dict: items = part.get("항목") items = _usable(block.get("항목") or []) if items is None else items mine = {**src, **{k: part[k] for k in ("선택값", "상수", "식", "대입식") if k in part}} share = part.get("몫") share = (share.get("값") if isinstance(share, dict) else share) or 1 cands = [candidate(master, k, mine, items, share) for k in part.get("후보") or []] good = [c for c in cands if c["셈"][DIFF] == 0 and (c["셈"][SAME] or len(cands) == 1)] verdict = NONE if not cands else SOLVED if len(good) == 1 else SOME pick = good[0]["키"] if verdict == SOLVED else None return { "제목": part.get("제목", ""), "판정": verdict, "고름": pick, "후보": cands, "항목": items, } def block( master: mf.Master, blk: dict, parts: list[dict], src: dict | None = None, units: Any = None ) -> dict: """블록 풀이 — 절마다 후보 검산 · 절 판정 · 덧 줄(엑셀만 → `Units`) · 블록 판정.""" src = {**(src or {}), "블록": blk} secs = [_section(master, blk, p, src) for p in parts] extra, n = [], 0 for sec in secs: chosen = next((c for c in sec["후보"] if c["키"] == sec["고름"]), None) if sec["판정"] == SOME: n += 1 if chosen: b = chosen["바인딩"] n += len(b["입력필요"]) + len(b["엇갈림"]) + chosen["셈"][DIFF] alone = [r for r in chosen["줄"] if r["판정"] == EXCEL] lone = [x for x in sec["항목"] if any(r["엑셀"]["행"] == x.get("행") for r in alone)] else: lone = sec["항목"] if sec["판정"] == NONE else [] for item in lone: if units is None: from M02_MasterTemplete import M02_Hopyo_Units as hu units = hu.Units() link = units.line(item) extra.append( {"절": sec["제목"], "행": item.get("행"), "이름": item.get("이름"), **link} ) n += link.get("상태") != "이어짐" for sec in secs: sec.pop("항목") if secs and all(s["판정"] == NONE for s in secs): verdict = BY_LINES elif n: verdict = PICK else: verdict = BASE_PLUS if extra else BY_BASE return {"번": blk.get("번"), "판정": verdict, "고를수": n, "절": secs, "덧": extra} def _gather(nodes: list[dict], key: str) -> list: return [x for one in nodes for x in [*one.get(key, []), *_gather(one.get("아래", []), key)]] def parts(blk: dict, cands: list[Any]) -> list[dict]: """절 나무(785-1 `Formula.works`) + 후보(키 · `Clause` 후보 {키, 절}) → 풂 절 — 절 표 있는 후보가 있으면 일 절마다 그 절 후보 + 절 표 없는 후보 · 없으면 블록 통째 한 절 · 단위 줄 · 상수 · 식 · 선택값(머리 것 더함) · 몫.""" from M02_MasterTemplete import M02_Hopyo_Formula as fo nodes = blk.get("절") or fo.tree(blk.get("글") or [], blk.get("글행"), blk.get("항목") or []) top = fo.head(blk.get("글") or [], blk.get("글행")) cands = [c if isinstance(c, dict) else {"키": c} for c in cands] items = blk.get("항목") or [] work = fo.works(nodes) if any(c.get("절") for c in cands) else [] if not work: # 블록 통째 한 일(절 표 있는 후보가 없으면 한 기초가 블록 전부) whole = {"제목": blk.get("이름", ""), "후보": [c["키"] for c in cands], "항목": None} return [whole | {k: top[k] + _gather(nodes, k) for k in ("상수", "식", "선택값")}] out = [] for one in work: mine = [c["키"] for c in cands if c.get("절") in (None, one["번"])] at = [i for i in _gather([one], "항목") if not items[i].get("밖")] part = {"제목": one["제목"], "번": one["번"], "후보": mine, "항목": [items[i] for i in at]} part |= {k: top[k] + _gather([one], k) for k in ("상수", "식", "선택값")} out.append(part | {"몫": one.get("몫")}) return out