Files
eomsangdonandClaude Opus 5.5 d516d09fd9 feat(785-2): Bind B2 · B3 · B4 785-1 꼴 맞춤 · Solve 풂 절 parts · 지킴 시험 test_642_2
- Bind: B2 선택값 줄 = 낱말 같음 고름(「사용횟수」 ≠ 「사용」) · B3 상수 이름 · 덧(90˚) · 단위 맞춤(sec = 초 · km → m) · 경사도 → 경사각도 · B4 식 분자 · 분모 {수|기호} + 대입
- Solve.parts: Formula.works 절 + Clause 후보 {키, 절} → 풂 절(절 표 없는 후보뿐이면 블록 통째)
- 새 test_642_2_bind_solve: 예시 1 · 2 · 3 + 단산46 · 39 입력값 · 출처 꼬리표 · 입력 필요 · 엇갈림 · 지어 넣은 값 0 · 품 같음 · defaults 규칙

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 22:23:07 +09:00

475 lines
21 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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"(?<!\d)(\d{2})-(\d{2,3})-(\d{2,3})(?!\d)")
MIX_NAMES = ("굵은골재최대치수", "강도", "슬럼프")
CONC = ("레미콘", "콘크리트")
def _num(value: Any) -> 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)