feat(846-1): 기계 줄 시간당 작업량 — 기계 값 칸 작업량 · 덮음 일당 | 시간당 · 계산기 짝(금액 불변)

- 기계 자료: M01 기계 221 대 값 칸 `작업량` = [{식 · 단위 · 변수(규격에서 정해지는 칸)}] (건설 공통 8-2 장 작업량 틀 · 빌더 build_기계_작업량_846_1.py · --check 0 · 다른 칸 · 시간당 비용 그대로)
- 계산기 짝 master_doc_work.py ↔ common_util_formula_doc_work.ts: 기계 줄 수량에서 시간당 작업량 자리 읽기(일당 = 시공량 ÷ (8 × 대수) · 식 = …/hr 나눔 변수) · 셈 답 줄 칸 `작업량` · 덮음 `작업량: {방식 시간당, 식, 변수?}` = 그 자리를 1 ÷ Q(기계 작업량 식)로 바꿔 셈 · 부착 기계(브레이커 · 집게) 줄 따로 · 식 없는 기계 · 단위 안 맞음 = 시간당 거절
- 덮음 짝(master_doc_cover · common_util_formula_doc_cover): 작업량은 기계 식 문서에 안 얹음 · prune 이 작업량 정리(일당 · 기본과 같은 변수 = 지움) · 몸 검사 · CoverEntry · CoverBase 꼴
- 저장 길: 사본 부름 묶음에 시간당 식 키(M02_Doc_Copy.calls) · 내 기계 clean 이 작업량 받음 · 연결 읽기 작업량 칸(master_doc_link)
- 기계 보기: 작업량 칸 = 제어 구역 몫(ownedCols — 바탕 칸 숫자 입력으로 안 그림 · 그리기는 846-2)
- 지킴: 1,520 문서 · 기계 655 대 · 견본 SK 금액 전후 다름 0 · 빌더 802-2 --check 1,520 같음(옵션 표본 18,873) · 둘 다 가능 줄 전수 시간당 셈 멈춤 0
- 시험: test_846_1_machine_work(일당 · 시간당 손셈 · E · K 덮음 · 정리 · 거절 · 부착 · 거울 · 저장 길 · 내 기계) 신설 · test_816_1 기대값 고침(까닭 — 4륜 덤프 1톤 기계 줄에 작업량 칸이 생겨 값 칸 비교에서 뺌)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-11-16 00:00:06 +09:00
co-authored by Claude Opus 5.5
parent 54d49c1a82
commit a53fae747c
235 changed files with 5073 additions and 556 deletions
@@ -0,0 +1,236 @@
# -*- coding: utf-8 -*-
"""846-1 자료 — M01 기계 줄 값 칸 `작업량`(시간당 작업량 = 기계의 능력 · 요구 882 · 883).
꼴 = [{식: 작업량 틀 키(건설 공통 8-2 장 GC001144 ~ 1190), 단위: 식 결과 단위, 변수: {기계 규격에서 정해지는 칸}}]
· 그 기계 상세구분이 식 문서 `부품.기종` 에 든 건설품셈 작업량 틀 전부(키 차례) · 산림품셈 GF 틀(문서 전용 표 값) 뺌
· 변수 = 규격 수(버킷용량 · 적재용량 …) · 규격 고르기 칸(급수 · 규격구분 …)만 — 현장 조건(K · f · E · cm …) = 식 문서 기본값
· 틀마다 그 변수로 셈해 Q > 0 인지 봄 — 못 셈 = 규격 칸 빼고 다시 · 그래도 못 셈 = 그 틀 뺌(목록)
· 식 없는 기계 = 칸 없음 · 단위 「」 · 출처 = 식 출처(건설품셈 공통 8-2-x)
다시 돌려도 같음 · 다른 칸 · 기계 시간당 비용 셈은 안 바뀜.
실행 = `./venv/Scripts/python.exe resources/master_data/scripts/build_기계_작업량_846_1.py [--dry | --check]`
"""
from __future__ import annotations
import re
import sys
from collections import Counter
from decimal import Decimal
from pathlib import Path
HERE = Path(__file__).resolve().parent
ROOT = HERE.parents[2]
sys.path[:0] = [str(HERE), str(ROOT)]
import master_doc_calc as mc # noqa: E402
import master_doc_link as ml # noqa: E402
import master_doc_work as mw # noqa: E402
import master_machine_files as mmf # noqa: E402
FOLDER = HERE.parent
ROLL = {"머캐덤 롤러": "머캐덤롤러", "탠덤롤러": "탠덤롤러", "타이어 롤러": "타이어롤러",
"양족식 롤러": "자주식, 양족식 롤러", "진동롤러": "진동롤러(자주식)"} # fmt: skip
ROLL_E = {"머캐덤 롤러": "머캐덤롤러", "탠덤롤러": "탠덤롤러", "타이어 롤러": "타이어롤러",
"양족식 롤러": "양족식롤러(자주식)", "진동롤러": "진동롤러"} # fmt: skip
def _num(text, after: str = ""):
t = str(text or "").replace(",", "")
if after:
t = t.split(after, 1)[1] if after in t else ""
m = re.search(r"\d+(?:\.\d+)?", t)
return Decimal(m.group()) if m else None
def _opts(tpl: dict, key: str) -> list:
v = next((x for x in tpl.get("변수") or [] if x.get("열쇠") == key), None)
return list(((v or {}).get("연결") or {}).get("목록") or [])
def _pick(tpl: dict, key: str, ok) -> object | None:
return next((o for o in _opts(tpl, key) if ok(o)), None)
def _eq(n):
def ok(o):
try:
return n is not None and Decimal(str(o).split("(")[0].strip()) == n
except ArithmeticError:
return False
except ValueError:
return False
return ok
def _within(n):
def ok(o):
parts = [p for p in str(o).split("∼")]
try:
low, high = Decimal(parts[0]), Decimal(parts[-1])
except ArithmeticError:
return False
return n is not None and low <= n <= high
return ok
def _jaw(spec: str) -> str | None:
"""「25.4㎝×40.64㎝ …」 → 「025040」(크러셔 규격 칸)."""
m = re.match(r"\s*([\d.]+)㎝×([\d.]+)㎝", str(spec))
return f"{int(float(m.group(1))):03d}{int(float(m.group(2))):03d}" if m else None
TABLES: dict = {} # 롤러 표(CC000062 규격 · CC000064 작업효율) — main 이 채움
LAYER = {"기층, 보조기층": "보조기층, 기층"} # E 공종 → 다짐층 글
def _mid(text) -> Decimal | None:
parts = [_num(p) for p in str(text).split("∼")]
return None if None in parts else sum(parts) / len(parts)
def _roller(tpl: dict, kind: str, spec: str, n) -> dict:
"""롤러 — 다짐기계 · 그 기계 표 줄의 규격(같은 글 › 범위 안 › 가까운 규격) · 작업효율 표에 있는 공종."""
mach, emach = ROLL[kind], ROLL_E[kind]
sizes = [r["규격"] for r in TABLES["CC000062"] if r.get("다짐기계") == mach]
size = next((s for s in sizes if s == spec), None) or next(
(s for s in sizes if _within(n)(s) or _eq(n)(s)), None
)
if size is None and n is not None and sizes:
size = min(sizes, key=lambda s: abs((_mid(s) or 0) - n)) # 표에 없는 규격 = 가까운 줄
works = [r["공종"] for r in TABLES["CC000064"] if r.get("다짐기계") == emach]
work = "노체, 축제, 노상" if "노체, 축제, 노상" in works else (works[0] if works else None)
out = {"다짐기계": mach, "E기계": emach, "규격": size}
if work and work != "노체, 축제, 노상":
out |= {"다짐층": LAYER.get(work, work), "E공종": work}
return {k: v for k, v in out.items() if v is not None}
def spec_vars(key: str, tpl: dict, row: dict) -> dict:
"""작업량 틀 하나 × 기계 → 기계 규격에서 정해지는 변수."""
name, spec, kind = str(row.get("이름") or ""), str(row.get("규격") or ""), str(row.get("상세구분") or "") # fmt: skip
n = _num(spec)
out: dict = {}
def put(k, v):
if v is not None:
out[k] = v
size = (tpl.get("부품") or {}).get("규격")
if size and n is not None and size in ("버킷용량", "적재용량", "믹서용량"):
put(size, n)
if key == "GC001144":
put("종별", "타이어" if "타이어" in name else "무한궤도")
put("급수", _pick(tpl, "급수", _eq(n)))
elif key == "GC001147":
put("규격구분", _pick(tpl, "규격구분", _within(n)))
elif key == "GC001150":
put("기종", "무한궤도식" if "무한궤도" in name else "타이어식")
elif key == "GC001152":
put("적재함용적", n)
elif key in ("GC001157", "GC001158") and kind in ROLL:
out.update(_roller(tpl, kind, spec, n))
elif key in ("GC001160", "GC001164"):
put("플랜트규격" if key == "GC001160" else "규격", _pick(tpl, "플랜트규격" if key == "GC001160" else "규격", _eq(n))) # fmt: skip
elif key == "GC001161":
put("로터폭", n)
elif key in ("GC001162", "GC001163"):
put("규격", _pick(tpl, "규격", lambda o: str(o) == _jaw(spec)))
elif key == "GC001171":
put(
"규격",
_pick(tpl, "규격", lambda o: n is not None and str(o).startswith(f"{int(n)}㎥/h")),
)
elif key == "GC001173":
put("규격", _pick(tpl, "규격", _eq(n)))
elif key == "GC001174":
put("기관차", _pick(tpl, "기관차", lambda o: n is not None and str(o) == f"{int(n)}t"))
elif key in ("GC001176", "GC001177"):
put("해머", _pick(tpl, "해머", lambda o: _num(o) == n))
elif key == "GC001181":
put("해머", _pick(tpl, "해머", _eq(n)))
elif key == "GC001183":
put("규격", _pick(tpl, "규격", lambda o: str(o).startswith(spec)))
elif key == "GC001185":
put("굴착직경", n)
elif key == "GC001187":
put("버킷용량", _pick(tpl, "버킷용량", _eq(_num(spec, "형식"))))
elif key == "GC001189":
put("규격", _pick(tpl, "규격", lambda o: str(o) == spec))
elif key == "GC001190":
put("출력", _num(spec, "출력"))
return out # 수 = Decimal(기계 파일 읽기 꼴과 같음 — 다시 돌려도 같음)
def _q(links, key: str, tpl: dict, given: dict):
got = mc.calc(tpl, links, given)
q = got.get("결과")
return q if isinstance(q, Decimal) and q > 0 else None
def work_of(row: dict, links, docs: dict, seen: Counter) -> list[dict]:
kind = str(row.get("상세구분") or "")
out = []
for key, tpl in sorted(docs.items()):
part = tpl.get("부품") or {}
if not key.startswith("GC") or part.get("갈래") != "작업량 틀" or kind not in (part.get("기종") or []): # fmt: skip
continue
given = spec_vars(key, tpl, row)
while given and _q(links, key, tpl, given) is None: # 표에 없는 규격 = 뒤 칸부터 뺌
gone = list(given)[-1]
seen[f"규격 칸 뺌 {key} {gone}"] += 1
given = {k: v for k, v in given.items() if k != gone}
if _q(links, key, tpl, given) is None:
seen[f"못 셈 뺌 {key}"] += 1
continue
out.append({"식": key, "단위": mw.norm_unit(tpl.get("결과단위")), "변수": given})
return out
def build(row: dict, links, docs: dict, seen: Counter) -> dict:
work = work_of(row, links, docs, seen)
vals = {k: v for k, v in (row.get("값") or {}).items() if k != mw.WORK}
units = {k: v for k, v in (row.get("단위") or {}).items() if k != mw.WORK}
srcs = {k: v for k, v in (row.get("출처") or {}).items() if k != mw.WORK}
out = {**row, "값": vals, "단위": units, "출처": srcs}
if work:
vals[mw.WORK] = work
units[mw.WORK] = ""
srcs[mw.WORK] = " · ".join(
dict.fromkeys(str(docs[w["식"]].get("출처") or "") for w in work)
)
return out
def main(mode: str = "") -> Counter:
from M01_MasterData import M01_MasterData_Store as m01
links = ml.Links(m01.loaded()[1])
docs = links.docs
for ref in ("CC000062", "CC000064"):
TABLES[ref] = links.master.get(ref)["줄"]
data = mmf.read(FOLDER, mmf.NAME)
old = {**data, "줄": list(data["줄"])}
seen: Counter = Counter()
rows = []
for r in data["줄"]:
new = build(r, links, docs, seen)
same = new == r
rows.append(r if same else new)
seen["대"] += 1
seen["바뀜"] += not same
if work := (new.get("값") or {}).get(mw.WORK):
seen["작업량 칸 있음"] += 1
seen["식 여럿"] += len(work) > 1
seen["규격 변수 있음"] += any(w["변수"] for w in work)
if mode == "--check":
seen["어긋남(--check)"] = seen["바뀜"]
elif mode != "--dry":
mmf.write(FOLDER, {**data, "줄": rows}, old)
return seen
if __name__ == "__main__":
for k, v in sorted(main(sys.argv[1] if len(sys.argv) > 1 else "").items()):
print(f"{k}\t{v}")
@@ -17,6 +17,7 @@ from decimal import ROUND_DOWN, ROUND_HALF_UP, ROUND_UP, Decimal, InvalidOperati
import master_doc as md
import master_doc_cover as mcv
import master_doc_work as mw
COSTS, BUNDLE = md.COSTS, md.BUNDLE
EPS = 1e-7 # B09_Estimation_Calc_Base.EPS 와 같음
@@ -404,9 +405,13 @@ def _money(run: _Run, doc: dict, rules: dict):
for i, line in enumerate(lines):
scope = _scope(doc, outs, dict(running), cover, syms)
where = str(line.get("이름") or f"금액 {i + 1}")
swapped, more, work = mw.line_work(run, line, scope) # 846-1 시간당 작업량
text = line.get("수량") if swapped is None else swapped
qty = (
run.expr(line["수량"], where, scope)
if line.get("수량") not in (None, "")
run.mark(swapped)
if isinstance(swapped, Stop)
else run.expr(text, where, {**scope, **more})
if text not in (None, "")
else Decimal(1)
)
price = run.expr(line.get("단가"), where, scope)
@@ -464,6 +469,7 @@ def _money(run: _Run, doc: dict, rules: dict):
**({"까닭": why} if why else {}),
**({"합밖": True} if i in skip else {}),
**({"묶음": line["묶음"]} if line.get("묶음") else {}),
**({mw.WORK: work} if work else {}),
}
)
return outs, running, stopped, cover
@@ -19,6 +19,7 @@ import re
from decimal import Decimal
import master_doc as md
import master_doc_work as mw
import master_machine_crew as mcrew
COVER = "기계덮음"
@@ -204,6 +205,8 @@ def prune(entry: dict, base: dict) -> dict | None:
for part in ("조종원", "잡품"):
if entry.get(part) is not None and _canon(entry[part]) != _canon(base[part]):
out[part] = entry[part]
if work := mw.prune_work(entry.get(mw.WORK), base.get(mw.WORK)): # 846-1 시간당 작업량
out[mw.WORK] = work
return out or None
@@ -269,7 +272,8 @@ def cover_doc(doc: dict, given, cover, base: dict | None = None) -> dict:
"""기계 식 문서에 그 기계 덮음을 얹음(새 문서) — `base` = 기계 줄 바탕 덮음(`row_entry`) ·
덮음 없음 = 그대로(같은 물건)."""
key = machine_key(given)
entry = merge(base, (cover or {}).get(key) if key else None)
top = {k: v for k, v in ((cover or {}).get(key) or {}).items() if k != mw.WORK} if key else None
entry = merge(base, top or None) # 작업량(846-1) = 부른 쪽 기계 줄 수량 몫 — 식 문서는 그대로
if not entry:
return doc
out = cp.deepcopy(doc)
@@ -421,6 +425,8 @@ def clean(raw) -> dict:
if part == "잡품" and not (r.get("줄") or r.get("키")):
raise ValueError(f"{COVER} 「{key}」 잡품 줄 — 재료(키) 또는 줄 필요")
one[part] = rows
if entry.get(mw.WORK) is not None:
one[mw.WORK] = mw.clean_work(entry[mw.WORK], f"{COVER} 「{key}」")
if one:
out[str(key)] = one
return out
@@ -436,12 +442,38 @@ def prune_doc(doc: dict, links) -> dict:
if not cover:
return rest
memo: dict = {}
mc.calc(rest, links, None, (), memo)
top = 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
base = row_entry(sub, lists_of(links, dict(given))) if sub else None
if sub and (one := prune(cover[m], defaults_of(sub, ans.get("값") or {}, base))):
if not sub or not is_machine(sub):
continue
base = defaults_of(sub, ans.get("값") or {}, row_entry(sub, lists_of(links, dict(given))))
if one := prune({k: v for k, v in cover[m].items() if k != mw.WORK}, base):
kept[m] = one
works = { # 작업량(846-1) = 기계 줄 답이 읽은 작업 기계(부착 기계 포함)
w["기계"] for a in (top, *memo.values()) for x in a.get("줄") or [] if (w := x.get(mw.WORK))
}
for m in sorted(works & set(cover)):
want = cover[m].get(mw.WORK)
if want and (one := mw.prune_work(want, _work_base(links, m, want))):
kept[m] = {**kept.get(m, {}), mw.WORK: one}
return {**rest, COVER: kept} if kept else rest
def _work_base(links, machine: str, want: dict) -> dict:
"""시간당 작업량 덮음 기본 변수(식 문서 기본 › 기계 변수) · 기계에 그 식 없음 = ValueError(거절)."""
import master_doc_calc as mc
if want.get("방식") != mw.HOURLY:
return {}
try:
specs = mw.specs_of(links.element({"키": f"EQ:{machine}", "칸": mw.WORK}))
except (mc.Gap, mc.Broken):
specs = []
spec = next((s for s in specs if s["식"] == want.get("식")), None)
if spec is None:
what = f"작업량 식 「{want.get('식')}」 없음" if specs else "작업량 식 없는 기계"
raise ValueError(f"{COVER} 「{machine}」 시간당 — {what}")
return mw.defaults(links.doc(spec["식"]), spec)
@@ -54,7 +54,10 @@ def _arith(text: str):
def _cell(value, col: str):
if value is None:
raise mc.Gap(f"칸 「{col}」 값 없음 — 관리자가 채울 값")
if isinstance(value, list) and col in mcrew.LISTS: # 기계 인력 · 잡품 줄(842-3)
if isinstance(value, list) and col in (
*mcrew.LISTS,
"작업량",
): # 기계 인력 · 잡품 줄(842-3) · 작업량(846-1)
return value
if isinstance(value, list):
raise mc.Broken(f"칸 「{col}」 값이 범위 — 입력으로 받아야 함")
@@ -0,0 +1,395 @@
# -*- 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