# -*- coding: utf-8 -*- """기계 덮음 (PLAN 836-1 · 요구 846 · 849 · 850 · 853) — 단가산출 안 기계 내부 값 고침. 자리 = 문서 칸 `기계덮음` {기계 입력 글: {값?: {칸: 값}, 조종원?: [{키, 인}], 잡품?: [{줄? | 키?, 이름?, 수량?, 율}]}} 기계 입력 글 = 부름 입력 `기계` 값(따옴표 뗌) — 같은 기계 줄 여럿 = 한 벌 · 원본과 같은 칸 = 없음(`prune`). 셈 규칙(`cover_doc`) = 계산기(`master_doc_calc.calc`)가 기계 식을 부를 때 그 기계 덮음을 얹은 문서로 셈 — 덮음 없음 = 문서 그대로(금액 전후 같음). 파이썬 · TS 짝 — 짝 = `common_util/common_util_formula_doc_cover.ts` · 거울 시험 `resources/tester/test_836_1_machine_cover.py`. """ from __future__ import annotations import copy as cp import json import re from decimal import Decimal import master_doc as md COVER = "기계덮음" NOT_VALUE = ( "조종원", "조종원직종", "잡재료율", ) # 값 칸에서 빼는 기계 칸 — 줄(조종원 · 잡품)로 다룸 HOUR = "Q_시간환산" _HOUR_VARS = [ {"열쇠": "Q_분자^표", "기호": "Q", "아래": "분자", "위": "표", "이름": "건설기계 조종원 노임 시간 환산 계수.분자", "단위": "", "연결": {"갈래": "표", "표": "CC000336", "조건": {"구분": "'시간환산'"}, "칸": "분자"}}, {"열쇠": "Q_분모^표", "기호": "Q", "아래": "분모", "위": "표", "이름": "건설기계 조종원 노임 시간 환산 계수.분모", "단위": "", "연결": {"갈래": "표", "표": "CC000336", "조건": {"구분": "'시간환산'"}, "칸": "분모"}}, {"열쇠": "시간환산", "기호": "Q", "아래": "시간환산", "위": "", "이름": "시간환산", "단위": "", "연결": {"갈래": "식"}}, ] # fmt: skip _HOUR_LINE = {"기호": HOUR, "식": "Q_분자^표 ÷ Q_분모^표"} MAX_ROWS = 30 def _plain(n) -> str: d = Decimal(str(n)).normalize() return format(d, "f") def cover_of(doc) -> dict: got = doc.get(COVER) if isinstance(doc, dict) else None return got if isinstance(got, dict) else {} def machine_key(given) -> str: """부름 입력 → 기계 열쇠(따옴표 뗌) · 기계 입력 없음 = "".""" v = (given or {}).get("기계") if v is None: return "" t = str(v).strip() return t[1:-1] if len(t) >= 2 and t[0] == "'" and t[-1] == "'" else t def _names(text) -> list[str]: if text is None or str(text).strip() == "": return [] try: return md.names(md.parse(text)) except md.DocError: return [] def parts_of(doc: dict) -> dict: """기계 식 짜임 — {fields [{칸, ids}], crew [줄 차례], crew_qty {차례: 기호}, misc, rate, base}.""" eq: dict[str, str] = {} for v in doc.get("변수") or []: link = v.get("연결") or {} key = str(link.get("키") or "") if ( link.get("갈래") == "요소" and key.startswith("EQ:") and "{S_기계}" in key and link.get("칸") ): eq[md.var_id(v)] = link["칸"] fields: list[dict] = [] for id_, col in eq.items(): if col in NOT_VALUE: continue at = next((f for f in fields if f["칸"] == col), None) if at: at["ids"].append(id_) else: fields.append({"칸": col, "ids": [id_]}) lines = doc.get("금액") or [] crew, crew_qty, misc, rate = [], {}, None, None for i, line in enumerate(lines): q = next((n for n in _names(line.get("수량")) if eq.get(n) == "조종원"), None) if line.get("비목") == "노무비" and q: crew.append(i) crew_qty[i] = q p = _names(line.get("단가")) other = [n for n in p if n != "재료비"] if misc is None and line.get("비목") == "재료비" and "재료비" in p and len(other) == 1: misc, rate = i, other[0] base = ( [] if misc is None else [ i for i, x in enumerate(lines) if i < misc and x.get("비목") == "재료비" and not x.get("합밖") ] ) return {"fields": fields, "crew": crew, "crew_qty": crew_qty, "misc": misc, "rate": rate, "base": base} # fmt: skip def _num(v): if isinstance(v, bool) or not isinstance(v, (int, float, Decimal)): return None return v def _fill_key(text: str, vals: dict) -> str: def one(m): v = vals.get(m.group(1)) return _plain(v) if _num(v) is not None else str("" if v is None else v) return re.sub(r"\{([^{}]+)\}", one, text) def defaults_of(doc: dict, vals: dict) -> dict: """원본(덮음 없음) 기본값 — `vals` = 그 기계로 셈한 원본 답 `값`.""" parts = parts_of(doc) lines = doc.get("금액") or [] vars_ = {md.var_id(v): v for v in doc.get("변수") or []} values = {} for f in parts["fields"]: v = vals.get(f["ids"][0]) if _num(v) is not None or isinstance(v, str): values[f["칸"]] = v crew = [] for i in parts["crew"]: n = _num(vals.get(parts["crew_qty"][i])) sym = (_names(lines[i].get("단가")) or [""])[0] key = str(((vars_.get(sym) or {}).get("연결") or {}).get("키") or "") if n and key: crew.append({"키": _fill_key(key, vals), "인": n}) r = _num(vals.get(parts["rate"])) if parts["rate"] else None misc = [{"줄": str(lines[i].get("이름") or ""), "율": r} for i in parts["base"]] if r else [] return {"값": values, "조종원": crew, "잡품": misc} def _canon(v) -> str: def norm(x): if isinstance(x, dict): return {k: norm(y) for k, y in sorted(x.items()) if y not in (None, "")} if isinstance(x, list): return [norm(y) for y in x] if _num(x) is not None: return float(f"{float(x):.12g}") return x return json.dumps(norm(v), ensure_ascii=False, sort_keys=True) def _same(a, b) -> bool: if _num(a) is not None and _num(b) is not None: a, b = float(a), float(b) return abs(a - b) <= 1e-9 * max(1.0, abs(a), abs(b)) return str(a) == str(b) def prune(entry: dict, base: dict) -> dict | None: """원본과 같은 칸 · 줄을 뺀 덮음(바꿨다 되돌림 = 안 고침) · 다 빠지면 None.""" out: dict = {} vals = { k: v for k, v in (entry.get("값") or {}).items() if v not in (None, "") and not (k in base["값"] and _same(v, base["값"][k])) } if vals: out["값"] = vals for part in ("조종원", "잡품"): if entry.get(part) is not None and _canon(entry[part]) != _canon(base[part]): out[part] = entry[part] return out or None # ── 셈 규칙 ─────────────────────────────────────────────────────────── def _add_var(doc: dict, sign: str, low: str, name: str, key: str) -> str: have = {md.var_id(v) for v in doc.get("변수") or []} | { str(x.get("기호") or "") for x in doc.get("식") or [] } n = 1 while md.sym_id(sign, f"{low}{n}") in have: n += 1 under = f"{low}{n}" id_ = md.sym_id(sign, under) doc.setdefault("변수", []).append( {"열쇠": id_, "기호": sign, "아래": under, "위": "", "이름": name, "단위": "", "연결": {"갈래": "요소", "키": key}} ) # fmt: skip return id_ def _crew_lines(doc: dict, rows: list) -> list[dict]: have = any(md.var_id(v) == HOUR for v in doc.get("변수") or []) or any( x.get("기호") == HOUR for x in doc.get("식") or [] ) if rows and not have: ids = {md.var_id(v) for v in doc.get("변수") or []} doc.setdefault("변수", []).extend( cp.deepcopy(v) for v in _HOUR_VARS if md.var_id(v) not in ids ) doc.setdefault("식", []).append(dict(_HOUR_LINE)) return [ {"이름": "조종원", "단위": "인", "비목": "노무비", "수량": f"{_plain(r['인'])} × {HOUR}", "단가": _add_var(doc, "w", "조종원", "조종원 노임", r["키"])} for r in rows ] # fmt: skip def _misc_lines(doc: dict, rows: list, lines: list) -> list[dict]: out = [] for r in rows: label = r.get("이름") or "" if r.get("키"): price = _add_var(doc, "P", "잡품", label or r["키"], r["키"]) qty = _plain(r["수량"] if r.get("수량") is not None else 1) else: b = next((x for x in lines if str(x.get("이름") or "") == r.get("줄")), None) if not b or not b.get("단가"): continue price = str(b["단가"]) qty = _plain(r["수량"]) if r.get("수량") is not None else f"({b.get('수량') or 1})" label = label or str(b.get("이름") or "") out.append( {"이름": "잡품", "규격": label, "단위": "", "비목": "재료비", "수량": f"{qty} × {_plain(r['율'])} ÷ 100", "단가": price} ) # fmt: skip return out def cover_doc(doc: dict, given, cover) -> dict: """기계 식 문서에 그 기계 덮음을 얹음(새 문서) — 덮음 없음 = 그대로(같은 물건).""" key = machine_key(given) entry = (cover or {}).get(key) if key else None if not entry: return doc out = cp.deepcopy(doc) out.pop("쌓기", None) # 금액 차례가 바뀜 — 기본 차림 parts = parts_of(out) values = entry.get("값") or {} for f in parts["fields"]: if f["칸"] in values: for v in out.get("변수") or []: if md.var_id(v) in f["ids"]: v["연결"] = {"갈래": "숫자", "값": values[f["칸"]]} lines = out.get("금액") or [] crew = _crew_lines(out, entry["조종원"]) if entry.get("조종원") is not None else None want = entry.get("잡품") misc = ( _misc_lines(out, want, lines) if want is not None and (parts["misc"] is not None or want) else None ) gone = set(parts["crew"] if crew is not None else []) | ( {parts["misc"]} if misc is not None and parts["misc"] is not None else set() ) tail = next((i for i, x in enumerate(lines) if x.get("비목") == "경비"), len(lines)) crew_at = parts["crew"][0] if parts["crew"] else tail misc_at = parts["misc"] if parts["misc"] is not None else tail new = [] for i in range(len(lines) + 1): if crew is not None and i == crew_at: new += crew if misc is not None and i == misc_at: new += misc if i < len(lines) and i not in gone: new.append(lines[i]) out["금액"] = new return out # ── 서버 검사 · 정리 ────────────────────────────────────────────────── def _nonneg(v, where: str): if isinstance(v, bool) or not isinstance(v, (int, float, Decimal)) or v < 0: raise ValueError(f"{where} — 0 이상 수") return v def clean(raw) -> dict: """몸 검사 — 틀리면 ValueError(까닭) · 빈 칸 · 빈 기계 = 뺌.""" if raw is None: return {} if not isinstance(raw, dict): raise ValueError(f"{COVER} — 묶음 아님") out: dict = {} for key, entry in raw.items(): if not isinstance(entry, dict): raise ValueError(f"{COVER} 「{key}」 — 묶음 아님") one: dict = {} values = entry.get("값") if values is not None: if not isinstance(values, dict): raise ValueError(f"{COVER} 「{key}」 값 — 묶음 아님") got = {} for col, v in values.items(): if v in (None, ""): continue if col in NOT_VALUE: raise ValueError(f"{COVER} 「{key}」 값 「{col}」 — 줄로 고침") got[col] = v.strip() if isinstance(v, str) else _nonneg(v, f"「{key}」 {col}") if got: one["값"] = got for part, need in (("조종원", ("키", "인")), ("잡품", ("율",))): rows = entry.get(part) if rows is None: continue if not isinstance(rows, list) or len(rows) > MAX_ROWS: raise ValueError(f"{COVER} 「{key}」 {part} — 목록({MAX_ROWS} 줄까지)") for r in rows: if not isinstance(r, dict) or any(r.get(k) in (None, "") for k in need): raise ValueError(f"{COVER} 「{key}」 {part} 줄 — {' · '.join(need)} 필요") for k in ("인", "율", "수량"): if r.get(k) is not None: _nonneg(r[k], f"「{key}」 {part} {k}") if part == "잡품" and not (r.get("줄") or r.get("키")): raise ValueError(f"{COVER} 「{key}」 잡품 줄 — 재료(키) 또는 줄 필요") one[part] = rows if one: out[str(key)] = one return out def prune_doc(doc: dict, links) -> dict: """서버 정리 — 덮음을 검사하고 원본과 같은 칸 · 줄 · 기계를 뺀 문서(새 문서 · 덮음 없음 = 그대로). 원본 = 덮음 뺀 문서 셈의 부름 메모(그 기계 식 · 입력 · 원본 답).""" import master_doc_calc as mc cover = clean(doc.get(COVER)) rest = {k: v for k, v in doc.items() if k != COVER} if not cover: return rest memo: dict = {} mc.calc(rest, links, None, (), memo) kept: dict = {} # 문서가 안 부르는 기계 덮음 = 뺌 for (key, given), ans in memo.items(): m = machine_key(dict(given)) sub = links.doc(key) if m in cover and m not in kept else None if sub and (one := prune(cover[m], defaults_of(sub, ans.get("값") or {}))): kept[m] = one return {**rest, COVER: kept} if kept else rest