# -*- coding: utf-8 -*- """기계 줄 시간당 작업량 (PLAN 846-1 · 요구 881 · 882 · 883) — 기계 줄 수량을 「1 ÷ 시간당 작업량」 으로 읽고 단가산출 덮음으로 그 기계의 작업량 식(시간당 방식)으로 바꿔 셈. 읽기(`sites`) — 기계 줄 수량 식의 시간당 작업량 자리(저장 식 글은 안 바꿈 · 만약 갈래 안도 찾음): 일당 = 곱 · 나눔 줄에 8 과 나눔 하나(시공량 D) → 시간당 작업량 = D ÷ (8 × 대수) (대수 = 곱의 다른 변수 — 단위 % · `r_` 율은 곱에 남김) 식 = 나눔으로만 쓰는 변수 · 단위 「…/hr」(작업량 식 부품 · 표 값) 기계 값 칸 `작업량` = [{식: 작업량 틀 키, 단위, 변수: {기계 규격에서 정해지는 칸}}](빌더 `build_machine_work.py`). 덮음 = `기계덮음` 기계 칸 `작업량: {방식: '시간당', 식?, 변수?}` — 그 기계 줄의 작업량 자리를 `1 ÷ Q` 로 바꿈 (Q = 덮음 식 › 단위 맞는 첫 식을 식 문서 기본 › 기계 변수 › 덮음 변수로 셈) · 다른 곱(율 등) 그대로 · 기계에 작업량 칸 없음 · 단위 맞는 식 없음 · Q 못 셈 = 그 줄 멈춤(까닭). 셈 답 기계 줄 칸 `작업량` = {기계, 꼴(일당 · 식 · 시간당), 율, 값, 단위, 식들, 변수?, 까닭?}(산출근거 자료). 파이썬 · TS 짝 — 짝 = `common_util/common_util_formula_doc_work.ts` · 거울 시험 `resources/tester/test_846_1_machine_work.py`. """ from __future__ import annotations import re from decimal import Decimal import master_doc as md WORK = "작업량" HOURLY, DAILY = "시간당", "일당" Q_NEW = "Q_시간당작업량" # 셈 범위 이름(문서 기호 아님) EIGHT = Decimal(8) def norm_unit(u) -> str: t = str(u or "").strip().replace("m3", "㎥").replace("m2", "㎡") return re.sub(r"/(시간|h|Hr|HR)$", "/hr", t) def _var(doc: dict, name: str) -> dict: return next((v for v in doc.get("변수") or [] if md.var_id(v) == name), {}) def _per_hour(doc: dict) -> str: """문서 결과 단위(원/X · 원/100㎡) → X/hr · 모름 = "".""" t = str(doc.get("결과단위") or "") low = re.sub(r"^[\d.,]+", "", t.split("/", 1)[1]).strip() if "/" in t else "" return f"{norm_unit(low)}/hr" if low else "" def unit_of(doc: dict, name: str, depth: int = 0) -> str: """변수 단위 — 빈 칸이면 정의 식에서 짐작(…/hr 변수를 씀 · 3600 이 든 식 = 문서 결과 단위/hr).""" u = norm_unit(_var(doc, name).get("단위")) line = next((x for x in doc.get("식") or [] if x.get("기호") == name), None) if u or depth > 3 or line is None: return u node = _parse(line.get("식")) if node is None: return "" for n in md.names(node): if (got := unit_of(doc, n, depth + 1)).endswith("/hr"): return got return _per_hour(doc) if any(x[0] == "num" and x[1] == 3600 for x in md.walk(node)) else "" def _held(doc: dict, name: str) -> bool: """곱에 남기는 변수(율) — 대수가 아님.""" return name.startswith("r_") or unit_of(doc, name) == "%" # ── 식 나무 ─────────────────────────────────────────────────────────── def _chain(node) -> bool: return node[0] == "bin" and node[1] in ("*", "/") def _factors(node, sign: int = 1, out: list | None = None) -> list: out = [] if out is None else out if _chain(node): _factors(node[2], sign, out) _factors(node[3], sign if node[1] == "*" else -sign, out) else: out.append((sign, node)) return out def _build(fs: list): """(부호, 노드) 목록 → 곱 · 나눔 나무(차례 그대로 · 곱 먼저).""" tops = [n for s, n in fs if s > 0] or [("num", Decimal(1))] node = tops[0] for n in tops[1:]: node = ("bin", "*", node, n) for s, n in fs: if s < 0: node = ("bin", "/", node, n) return node def _kids(node) -> list: if node[0] == "call": return list(node[2]) return [x for x in node[1:] if isinstance(x, tuple)] def _uses(node, sign: int, out: dict) -> None: """변수 쓰임 — {이름: [나눔으로만?]} (곱 · 나눔 줄 잎 = 부호 · 그 밖 = 0).""" if _chain(node): for s, n in _factors(node): if n[0] == "var": out.setdefault(n[1], []).append(s * sign) else: _uses(n, 0, out) return if node[0] == "var": out.setdefault(node[1], []).append(0) return for k in _kids(node): _uses(k, 0, out) def _rates(doc: dict, node) -> set: uses: dict = {} _uses(node, 1, uses) return { n for n, signs in uses.items() if all(s < 0 for s in signs) and unit_of(doc, n).endswith("/hr") } def _daily(doc: dict, node, rates: set): """곱 · 나눔 줄 하나 → 일당 자리 | None.""" fs = _factors(node) eight = [i for i, (s, n) in enumerate(fs) if s > 0 and n[0] == "num" and n[1] == EIGHT] den = [ i for i, (s, n) in enumerate(fs) if s < 0 and n[0] != "num" ] # 수 나눔(÷ 100) = 곱에 남김 if not eight or len(den) != 1 or (fs[den[0]][1][0] == "var" and fs[den[0]][1][1] in rates): return None crew = [i for i, (s, n) in enumerate(fs) if s > 0 and n[0] == "var" and not _held(doc, n[1])] d = fs[den[0]][1] per = ("num", EIGHT) for i in crew: per = ("bin", "*", per, fs[i][1]) rate = ("bin", "/", d, per) names = md.names(d) u = unit_of(doc, names[0]) if len(names) == 1 else "" unit = f"{u}/hr" if u else _per_hour(doc) # 시공량 단위 없음 = 문서 결과 단위 return {"꼴": DAILY, "노드": rate, "단위": unit, "뺌": {eight[0], den[0], *crew}} def _find(doc: dict, node, rates: set, test, out: list) -> None: if node[0] == "call" and node[1] == "만약" and test is not None and len(node[2]) == 3: t = test(node[2][0]) if t is not None: _find(doc, node[2][1] if t else node[2][2], rates, test, out) return if _chain(node): site = _daily(doc, node, rates) if site: out.append(site) return for s, n in _factors(node): if s < 0 and n[0] == "var" and n[1] in rates: out.append({"꼴": "식", "노드": n, "단위": unit_of(doc, n[1])}) elif n[0] != "var": _find(doc, n, rates, test, out) return for k in _kids(node): _find(doc, k, rates, test, out) def _parse(text): try: return md.parse("1" if text in (None, "") else text) except md.DocError: return None def sites(doc: dict, text, test=None) -> list[dict]: """수량 글 → 시간당 작업량 자리 [{꼴, 노드, 율(글), 단위}] · test(조건 노드) → 참 · 거짓 · None(두 갈래 다).""" node = _parse(text) if node is None: return [] out: list = [] _find(doc, node, _rates(doc, node), test, out) for s in out: s["율"] = md.text_of(s["노드"]) return out def _swap(doc: dict, node, rates: set): if _chain(node): site = _daily(doc, node, rates) fs = _factors(node) if site: keep = [f for i, f in enumerate(fs) if i not in site["뺌"]] return _build([*keep, (-1, ("var", Q_NEW))]) if any(s < 0 and n[0] == "var" and n[1] in rates for s, n in fs): return _build( [ (s, ("var", Q_NEW) if s < 0 and n[0] == "var" and n[1] in rates else n) for s, n in fs ] # fmt: skip ) kids = [(s, n if n[0] == "var" else _swap(doc, n, rates)) for s, n in fs] return node if all(a[1] is b[1] for a, b in zip(fs, kids)) else _build(kids) kids = [_swap(doc, k, rates) for k in _kids(node)] if all(a is b for a, b in zip(_kids(node), kids)): return node # 자리 없음 = 그대로(글 차례 안 바뀜) if node[0] == "call": return ("call", node[1], kids) if node[0] == "field": return ("field", kids[0], node[2]) return (node[0], *node[1:2], *kids) if node[0] == "bin" else (node[0], kids[0]) def swap(doc: dict, text) -> str: """수량 글의 작업량 자리(모든 갈래)를 `1 ÷ Q_NEW` 로 바꾼 글.""" node = _parse(text) return md.text_of(_swap(doc, node, _rates(doc, node))) if node is not None else str(text) # ── 기계 작업량 · 덮음 ────────────────────────────────────────────────── def specs_of(got) -> list[dict]: """기계 값 칸 `작업량` → [{식, 단위, 변수}] (꼴 틀림 = 뺌).""" return [ x for x in (got if isinstance(got, list) else []) if isinstance(x, dict) and isinstance(x.get("식"), str) and x["식"] ] def fits(specs: list[dict], unit: str) -> list[str]: """시간당 작업량 단위에 맞는 식 키(단위 모름 = 전부).""" return [s["식"] for s in specs if not unit or norm_unit(s.get("단위")) == unit] def defaults(tpl: dict | None, spec: dict | None) -> dict: """식 문서 입력 · 선택 기본 › 기계 변수.""" out = {} for v in (tpl or {}).get("변수") or []: link = v.get("연결") or {} if link.get("갈래") in ("입력", "선택", "숫자") and v.get("열쇠"): out[str(v["열쇠"])] = link.get("값") return {**out, **((spec or {}).get("변수") or {})} def _same(a, b) -> bool: try: return abs(Decimal(str(a)) - Decimal(str(b))) <= Decimal("1e-9") * max( Decimal(1), abs(Decimal(str(a))) ) except Exception: return str(a) == str(b) def prune_work(want, base: dict | None = None) -> dict | None: """작업량 덮음 정리 — 방식 시간당만 · 기본(`defaults`)과 같은 변수 뺌 · 남는 것 없음 = None.""" if not isinstance(want, dict) or want.get("방식") != HOURLY: return None base = base or {} got = { k: v for k, v in (want.get("변수") or {}).items() if v not in (None, "") and not (k in base and _same(v, base[k])) } out: dict = {"방식": HOURLY} if want.get("식"): out["식"] = str(want["식"]) if got: out["변수"] = got return out def clean_work(want, where: str) -> dict: """몸 검사 — 틀리면 ValueError.""" if not isinstance(want, dict) or want.get("방식") not in (DAILY, HOURLY): raise ValueError(f"{where} 작업량 — 방식 일당 · 시간당") out: dict = {"방식": want["방식"]} if want.get("식") not in (None, ""): if not isinstance(want["식"], str): raise ValueError(f"{where} 작업량 식 — 글") out["식"] = want["식"] elif want["방식"] == HOURLY: # 사본 부름 묶음(`cover_calls`)에 그 식을 담으려면 필요 raise ValueError(f"{where} 작업량 — 시간당은 식 키 필요") vals = want.get("변수") if vals is not None: if not isinstance(vals, dict) or any( isinstance(v, (dict, list, bool)) for v in vals.values() ): raise ValueError(f"{where} 작업량 변수 — {{열쇠: 수 · 글}}") out["변수"] = {str(k): v for k, v in vals.items() if v not in (None, "")} return out def cover_calls(doc: dict) -> list[str]: """문서 `기계덮음` 의 시간당 작업량 식 키 — 사본 부름 닫힌 묶음에 더함(`M02_Doc_Copy.calls`).""" got = doc.get("기계덮음") if isinstance(doc, dict) else None return [ str(w["식"]) for e in (got.values() if isinstance(got, dict) else []) if isinstance(e, dict) and isinstance(w := e.get(WORK), dict) and w.get("방식") == HOURLY and w.get("식") ] # ── 셈(계산기 `_money` 가 기계 줄마다 부름) ───────────────────────────── NOT_MACHINE = ("지역", "보정작업", "조종원직종", "계약종별") # 기계 식 입력 가운데 기계 아닌 것 def _machine_of(run, line: dict): """금액 줄 단가가 부르는 기계 식 변수 → (작업 기계 입력 식, 변수 이름) · 기계 줄 아님 = None · 부착 기계(브레이커 · 집게 입력) 줄 = 그 부착 기계(일하는 기계 — 굴착기 줄과 따로).""" node = _parse(line.get("단가")) for name in md.names(node) if node is not None else []: link = (run.vars.get(name) or {}).get("연결") or {} given = link.get("입력") or {} if link.get("갈래") == "단가산출" and "기계" in given: tool = [v for k, v in given.items() if k != "기계" and k not in NOT_MACHINE] return str(tool[-1] if tool else given["기계"]), name return None def line_work(run, line: dict, scope: dict): """기계 줄 하나 → (바꾼 수량 글 | 멈춤(표지는 계산기가 담) | None, 범위 더함 {}, 답 칸 | None) — 계산기 `_money` 가 부름.""" import master_doc_calc as mc import master_doc_cover as mcv got = _machine_of(run, line) if not got: return None, {}, None where = str(line.get("이름") or "") def quiet(fn): """흔적 없는 셈 — 표지 · 기억한 값을 되돌림(진짜 셈이 표지를 다시 냄).""" n, seen = len(run.marks), set(run.values) try: return fn() finally: del run.marks[n:] for k in set(run.values) - seen: del run.values[k] key_val = quiet(lambda: run.expr(got[0], where, scope)) if mc._bad(key_val): return None, {}, None key = mcv.machine_key({"기계": mc.plain_text(key_val)}) def test(cond): v = quiet(lambda: run.ev(cond, scope, where)) return None if mc._bad(v) else bool(v) found = sites(run.doc, line.get("수량"), test) want = ((run.cover or {}).get(key) or {}).get(WORK) hourly = isinstance(want, dict) and want.get("방식") == HOURLY if not found and not hourly: return None, {}, None try: specs = specs_of(run.links.element({"키": f"EQ:{key}", "칸": WORK})) except (mc.Gap, mc.Broken): specs = [] unit = found[0]["단위"] if found else "" info = {"기계": key, "꼴": found[0]["꼴"] if found else "", "율": found[0]["율"] if found else "", "값": None, "단위": unit, "식들": fits(specs, unit)} # fmt: skip if not hourly: v = quiet(lambda: run.ev(found[0]["노드"], scope, where)) info["값"] = v if isinstance(v, Decimal) else None return None, {}, info pick = str(want.get("식") or "") or next(iter(info["식들"]), "") spec = next((s for s in specs if s["식"] == pick), None) why = ( "시간당 작업량 식 없는 기계" if not specs else "시간당 작업량 자리 없는 줄" if not found else f"단위 {unit} 맞는 작업량 식 없음" if not spec or pick not in info["식들"] else "" ) info |= {"꼴": HOURLY, "율": pick} if why: info["까닭"] = why return mc.Stop(f"{where} 시간당 거절 — {why}", "시간당 거절", where), {}, info given = {**(spec.get("변수") or {}), **(want.get("변수") or {})} tpl = None try: q = run.sub({"키": pick, "입력": given}, where) tpl = run.links.doc(pick) except (mc.Gap, mc.Broken) as e: q = mc.Stop(str(e)) info["변수"] = {**defaults(tpl, spec), **(want.get("변수") or {})} if not isinstance(q, Decimal) or q <= 0: info["까닭"] = f"작업량 식 {pick} 못 셈" + (f" — {q.why}" if mc._bad(q) else "") return mc.Stop(f"{where} {info['까닭']}", "시간당 거절", where), {}, info info["값"] = q return swap(run.doc, line.get("수량")), {Q_NEW: q}, info