feat(785-2): 옵션값 읽기 Bind · 검산 판정 Solve · master_auto.defaults — 1차
- 새 M02_Hopyo_Bind: B1 규격 · 품명 괄호 · B6 같은 호표 레미콘 a-b-c · B2 선택값 줄 + 글에 없는 켜기 칸 = 아님 · B3 상수(기호표 · 이름 · 90˚ · 표 칸 = 기호) · B4 대입식(분자 · 분모 자리 + 단위) · B5 표 거꾸로 + 감률 배수 · 출처 꼬리표 · 엇갈림 · 안씀 · 입력필요 · 비움 · 기본 제안 · 엔진 몫 quantities(품만) - 새 M02_Hopyo_Solve: 후보마다 바인딩 → 줄 짝(L · X · M · R) → 품 같음(자릿수 반올림 폭 · 0.5 %) · 절 · 블록 판정 · 덧 줄 Units · 금액 참고(입력 필요 없을 때 엔진 한 번) - master_auto.defaults: 기본 칸 › 고르기 첫째 › 가드 0 › 0 을 품은 범위 0 › 표 첫 줄 › None(새 맞춤만 부름 · values · run_line 그대로) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,446 @@
|
||||
"""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 경로가 깔린 뒤
|
||||
|
||||
SKIP = ("지역", "자재지역", "조종원직종")
|
||||
NEG = ("아님", "해당 없음", "없음", "미적용", "사용 안 함")
|
||||
# 상수 기호 → 입력 이름(앞이 먼저 · 기호 = 입력 이름이면 바로)
|
||||
SYMBOLS = {
|
||||
"q": ("버킷용량",),
|
||||
"E": ("E입력",),
|
||||
"Cm": ("싸이클입력",),
|
||||
"L": ("운반거리", "거리"),
|
||||
"n": ("운반회수",),
|
||||
"T": ("실작업시간",),
|
||||
}
|
||||
_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 []:
|
||||
_b1(got, {"이름": "", "규격": one.get("글"), "행": one.get("행")}, "B2")
|
||||
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 _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 and n in got.specs]
|
||||
if names:
|
||||
got.put(names[0], value, "B3", at, f"{sym} = {plain(_num(value))}")
|
||||
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)
|
||||
@@ -0,0 +1,251 @@
|
||||
"""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}
|
||||
@@ -88,6 +88,49 @@ def _lowest(master: mf.Master, uses: list):
|
||||
return (both or next((s for s in sets if s), [None]))[0]
|
||||
|
||||
|
||||
def _first_row(master: mf.Master, uses: list):
|
||||
"""찾기 조건 칸의 표 첫 줄 값 — 범위 줄이면 아래 끝(열린 아래 끝 = 위 끝) · 빈 칸 줄은 건넘."""
|
||||
for ref, cond in uses:
|
||||
try:
|
||||
rows = master.get(ref).get("줄", [])
|
||||
except mf.FormulaError:
|
||||
continue
|
||||
for r in rows:
|
||||
got = r.get(cond)
|
||||
if isinstance(got, list):
|
||||
got = next((v for v in got if v is not None), None)
|
||||
if got is not None:
|
||||
return got
|
||||
return None
|
||||
|
||||
|
||||
def _holds_zero(span) -> bool:
|
||||
low, high = span[0], span[1]
|
||||
return (low is None or Decimal(str(low)) <= 0) and (high is None or Decimal(str(high)) >= 0)
|
||||
|
||||
|
||||
def defaults(master: mf.Master, row: dict) -> dict:
|
||||
"""「기본」 제안(PLAN 785-2 · 639-1a) — 「기본」 칸 › 고르기 첫째 › 가드 0 › 0 을 품은 범위 0 ›
|
||||
찾기 조건 표 첫 줄 › None(못 정함 = 사용자 입력). 1 을 지어 넣지 않음 · `values` 와 따로."""
|
||||
specs = mf.inputs_of(row) if isinstance(row.get("흐름"), dict) else row.get("입력", [])
|
||||
finds = {} if isinstance(row.get("흐름"), dict) else _uses(row)[0]
|
||||
guard = guards(row)
|
||||
out = {}
|
||||
for spec in specs:
|
||||
name, span = spec["이름"], spec.get("범위")
|
||||
if spec.get("기본") not in (None, ""):
|
||||
out[name] = spec["기본"]
|
||||
elif spec.get("고르기"):
|
||||
out[name] = spec["고르기"][0]
|
||||
elif name in guard:
|
||||
out[name] = Decimal(0)
|
||||
elif span and _holds_zero(span):
|
||||
out[name] = Decimal(0)
|
||||
else:
|
||||
out[name] = _first_row(master, finds.get(name, []))
|
||||
return out
|
||||
|
||||
|
||||
def values(master: mf.Master, row: dict) -> dict:
|
||||
"""로직 줄 하나의 입력마다 자동값 · 흐름 로직은 「설계 때 정할 것」 예시값."""
|
||||
if isinstance(row.get("흐름"), dict):
|
||||
|
||||
Reference in New Issue
Block a user