refactor(812-1): 옛 엑셀 엔진 걷기 1 — San_Est 글 추정 사슬 · San_Eval EST 셈 · 엑셀 검산 · San_Emit.est · San_Book.est_blocks · load
- 남김: San_Est.tokens · num · NUMRE(NativeEst 가 씀) · San_Eval.run_stmate · stmate_block(STC · STmate) · San_Emit.stmate - 시험 고침: 802_4b1 · 797_1 = 세 줄 → 묶음 한 줄(원파일 길 꼴) · 797_2 = 블록 번은 엑셀 둘 꼴에서 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -12,11 +12,8 @@ from __future__ import annotations
|
||||
import io
|
||||
import re
|
||||
import warnings
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from M02_MasterTemplete import M02_Hopyo_ReadEst as read_est
|
||||
|
||||
STM_SHEETS = (
|
||||
"단가산출근거",
|
||||
"단가산출근거목록표",
|
||||
@@ -233,23 +230,3 @@ def est_prices(book: dict[str, list]) -> tuple[list[dict[str, Any]], dict[int, d
|
||||
one = {"종류": "산근", "이름": f"단산{n}", "규격": ""}
|
||||
out.append({**one, "비목": k, "값": san[n][k]})
|
||||
return out, san
|
||||
|
||||
|
||||
def est_blocks(book: dict[str, list]) -> list[dict[str, Any]]:
|
||||
"""→ [{번, 이름, 단위, 글 [줄], 글행 [행]}]."""
|
||||
blocks = read_est.read(book.get(read_est.SHEET))
|
||||
keep = ("번", "이름", "단위", "글", "글행")
|
||||
return [{k: b[k] for k in keep} for b in blocks.values()]
|
||||
|
||||
|
||||
def load(path: str | Path) -> dict[str, Any]:
|
||||
"""파일 → {꼴, 블록, 단가(STmate dict | EST 목록), 총괄(EST)}."""
|
||||
path = Path(path)
|
||||
book = sheets(path.name, path.read_bytes())
|
||||
kind = kind_of(book)
|
||||
if kind == "STmate":
|
||||
return {"꼴": kind, "블록": stmate_blocks(book), "단가": stmate_prices(book)}
|
||||
if kind == "EST":
|
||||
prices, san = est_prices(book)
|
||||
return {"꼴": kind, "블록": est_blocks(book), "단가": prices, "총괄": san}
|
||||
raise ValueError("STmate · EST 산근 시트 없음")
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"""경쟁사 산근 → 우리 엔진 로직 줄 꼴 내보내기(PLAN 793-2a · 요구서_652 · eval 없음).
|
||||
|
||||
793-1 줄 나무(`M02_San_Stmate` · `M02_San_Est`) → `{로직, 원본}` — 꼴 = 793-2 가져온 식 SK
|
||||
793-1 줄 나무(`M02_San_Stmate`) → `{로직, 원본}`(EST = 원파일 · 엑셀 둘 다 `M02_Doc_NativeEst` · 812-1) — 꼴 = 793-2 가져온 식 SK
|
||||
만들기 몸(`M02_Sangeun_Imported.py` 머리) · 로직 = `_틀.md` 7 장 꼴(셈 = `master_formula.run` →
|
||||
`master_cut.engine` · `요소` 는 SK 서버가 채움).
|
||||
- 로직 = {이름, 결과단위, 입력 [], 중간 [{이름, 식, 표시?}], 호표,
|
||||
@@ -33,7 +33,6 @@ COMP = {"L": "노무비", "M": "재료비", "S": "경비"}
|
||||
_CODE = {v: k for k, v in COMP.items()}
|
||||
_SUM = "@합{}@" # 누계 % 밑 자리(블록 끝에 `finish` 가 채움)
|
||||
KIND = {"L": "인력", "M": "재료", "X": "기계", "D": "단가산출", "B": "단가산출"}
|
||||
KIND_EST = {"노임": "인력", "자재": "재료", "기계": "기계", "견적": "고정", "산근": "단가산출"}
|
||||
RESERVED = {"노무비", "재료비", "경비", "계", "번지", "단계", "줄", "찾기", "로직", "그리고"}
|
||||
RESERVED |= {"또는", "만약", "올림", "버림", "반올림", "최소", "최대"}
|
||||
_KEY = re.compile(r"[A-Z]{2}\d{6}")
|
||||
@@ -527,90 +526,3 @@ def stmate(
|
||||
more["계약"] = {"낙찰률": float(meta["NAG"]), "금액": _cols(block["머리"])}
|
||||
end = "큰나머지" if rule.startswith("합버림") else "비목버림"
|
||||
return o.done(end, adjust, _cols(kye), "단가산출근거", file, **more)
|
||||
|
||||
|
||||
# ── EST ────────────────────────────────────────────────────────────────
|
||||
_VAR = re.compile(r"([A-Za-z][A-Za-z0-9_]*)\s*(?:\([^)]*\))?\s*=")
|
||||
|
||||
|
||||
def _est_res(role: dict, value: float, comp: str | None) -> dict:
|
||||
"""EST 단가 토큰 정체 → 자원 이음 열쇠(옮김 = 성분 비목이 단가 비목과 다를 때)."""
|
||||
p = role["정체"]
|
||||
pc = p["비목"] if p["비목"] in COMP else (comp or "S")
|
||||
res = {"꼴": "EST", "갈래": KIND_EST.get(p["종류"], "고정"), "종류": p["종류"]}
|
||||
res |= {"이름": p["이름"], "규격": p["규격"], "단위": None}
|
||||
res |= {"단가": {pc: value}}
|
||||
res |= {"비목": pc, "옮김": comp if comp and comp != pc else None, "열쇠비목": p["비목"]}
|
||||
return res | ({"모호": True} if role.get("모호") else {})
|
||||
|
||||
|
||||
def est(block: dict, lines: list[dict], want: dict, keys: bool = False, file: str = "") -> dict:
|
||||
"""EST 블록(`M02_San_Book.est_blocks` 한 개) + 복원 줄(`M02_San_Est.parse_block`) +
|
||||
총괄표 성분 want {T, L, M, S} → {로직, 원본} · 성분을 낸 줄만 합(나머지 = 합밖)."""
|
||||
des = ev.designate(lines, want)
|
||||
mine = {k: c for c, k in des.items() if isinstance(k, int)}
|
||||
o = Out(block.get("이름"), block.get("단위"), "EST", block.get("번"))
|
||||
rows = block.get("글행") or []
|
||||
if want.get("칸"): # 당초 칸 사용 · 적용 ≠ 당초(797-1a)
|
||||
o.marks.append(f"{NOTE}{want['칸']}")
|
||||
for c, k in des.items():
|
||||
if k is None:
|
||||
o.marks.append(f"성분 {COMP[c]} {want[c]} 을 낸 줄 없음")
|
||||
val = ev.run_est(lines)
|
||||
ref: dict[int, str] = {} # 차례 → 엔진 부름 글(앞줄 · 숨은 합)
|
||||
for L in lines:
|
||||
k, form, kind = L["차례"], L["꼴"], L.get("종류")
|
||||
at, glyph = rows[k] if k < len(rows) else k, L["원글"]
|
||||
comp = mine.get(k)
|
||||
if form == "빈":
|
||||
continue
|
||||
if form == "변수" and L.get("값") is not None:
|
||||
m = _VAR.search(L["원글"])
|
||||
eng = o.names.line(m.group(1) if m else "변수")
|
||||
o.middle(eng, num(L["값"]), None, at, glyph) # 얼린 값(금액엔 보인 값이 박힘)
|
||||
continue
|
||||
if form not in ("돈", "값"):
|
||||
shown = L.get("보인") if form == "글끝" else L.get("값") if form == "식못읽음" else None
|
||||
if shown is None:
|
||||
o.glyph(at, glyph)
|
||||
else: # 금액 줄 못 읽음 — 보인 값 얼림(글줄 값으로 보존)
|
||||
o.glyph(at, glyph, value=shown, mark=f"{NOTE}금액 줄 못 읽음 · 보인 값 얼림")
|
||||
continue
|
||||
head = {} if comp else {"합밖": True}
|
||||
try:
|
||||
place = _place(L.get("규칙"))
|
||||
if k not in val:
|
||||
raise EmitError("보인 값이 되는 식 · 자리 못 찾음 — 보인 값으로 얼림")
|
||||
if kind == "단가줄":
|
||||
i, role = next((i, r) for i, r in L["역할"].items() if r["갈래"] == "단가")
|
||||
price = next(n[1] for n in ex.numbers(L["식"]) if n[2] == i)
|
||||
res = _est_res(role, price, comp)
|
||||
link, many = _m01(res["갈래"], res) if keys else (None, None)
|
||||
qty = text(L["식"], {}, {i: "1"})
|
||||
one = resource(o.names.item(L.get("라벨") or ""), None, qty, res, link)
|
||||
one |= {**head, **({"자리": place} if place else {})}
|
||||
o.add_line(one, True, at, glyph, L["값"])
|
||||
if many:
|
||||
o.glyph(at, glyph, mark=many)
|
||||
if role.get("모호"): # 임의 선택 아님 — 후보를 사람이 고름
|
||||
o.glyph(at, glyph, mark=f"{NOTE}단가 후보 여럿({role['남은']}) · 사람 확인")
|
||||
elif role.get("갈림") in ("문맥", "기계 세 줄"):
|
||||
o.glyph(at, glyph, mark=f"{NOTE}추정 — 단가 정체 근거 {role['갈림']}")
|
||||
ref[k] = f"줄.'{one['이름']}'"
|
||||
continue
|
||||
if kind == "값줄":
|
||||
parts = [ref.get(j) or num(lines[j]["값"]) for j in L["숨은"]["줄"]]
|
||||
formula = " + ".join(parts)
|
||||
else:
|
||||
swap = {i: ref[r["줄"]] for i, r in L["역할"].items() if r.get("줄") in ref}
|
||||
formula = text(L["식"], {}, swap)
|
||||
except EmitError as error:
|
||||
o.glyph(at, glyph, mark=str(error))
|
||||
formula, place = num(L["값"]), None
|
||||
name = o.names.line(L.get("라벨") or "줄")
|
||||
one = {"이름": name, "식": formula, "비목": COMP[comp or L.get("비목") or "S"]}
|
||||
o.add_line(one | head | ({"자리": place} if place else {}), False, at, glyph, L["값"])
|
||||
if k in val:
|
||||
ref[k] = name
|
||||
check = _money(want) | {"계": want["T"]}
|
||||
return o.done("비목버림", None, check, "단가산출", file)
|
||||
|
||||
@@ -1,42 +1,19 @@
|
||||
"""EST 산근 보이는 글 → 식 복원(PLAN 793-1 · 요구서_652 · 문법 = `_분석_648-A` 3 장 · eval 없음).
|
||||
"""EST 식 글 토큰(PLAN 793-1 · 812-1 · eval 없음) — 원파일 산근 변환(`M02_Doc_NativeEst`)이 쓰는 수 · 토큰 읽기만.
|
||||
|
||||
엑셀엔 식이 없고 인쇄 글뿐 — 「라벨 : 식 = 결과」 로 나눠 식을 되살림. 셈은 `M02_San_Eval.run_est`.
|
||||
|
||||
줄 꼴(`꼴`) — 빈 · 그림(분수 ─) · 글 · 글끝(끝이 결과 아님) · 식못읽음 · 변수 · 값 · 돈.
|
||||
- 돈(`종류` 단가줄 · 참조줄 · 상수줄) — 라벨 · 비목(라벨 끝 노무비 · 재료비 · 경비) ·
|
||||
값(보인 결과) · 식(`M02_San_Expr` 나무) ·
|
||||
역할 {수 토큰 차례: 단가(후보 · 정체 · 모호) | 앞줄(같은 블록 앞 결과 줄 차례) | 상수} ·
|
||||
규칙(보인 결과가 되는 줄 자리 속성 · 기본 버림1)
|
||||
- 값(`종류` 값줄 「소 계 : 1,355.9」) — 숨은 = {갈래 복사 | 합, 줄 [차례], 규칙}(앞 결과 하나 또는
|
||||
바로 앞 돈 · 값 줄 이음 합)
|
||||
- 변수(`종류` 변수식 · 변수값 · 변수기호 · 변수글) — 값 · 규칙 = 얼림(금액엔 반올림된 값이 박힘)
|
||||
옛 엑셀 글 추정 사슬(줄 꼴 · 역할 · 값 역색인 · `parse_block`)은 812-1 엑셀 읽기기(`M02_San_Xlsx`)로 바뀌어 걷음.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import collections
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
from M02_MasterTemplete import M02_San_Expr as ex
|
||||
from M02_MasterTemplete.M02_San_Book import flat
|
||||
|
||||
COMPW = {"노무비": "L", "재료비": "M", "경비": "S"}
|
||||
NUMRE = r"\d{1,3}(?:,\d{3})+(?:\.\d*)?|\d+(?:\.\d*)?|\.\d+"
|
||||
EPS = ex.EPS
|
||||
OPS = {"×": "*", "x": "*", "X": "*", "*": "*", "÷": "/", "/": "/", "+": "+", "-": "-"}
|
||||
OPS |= {"-": "-", "−": "-"}
|
||||
_UNIT = re.compile(r"\s*(?:[A-Za-z가-힣㎡㎥㎏ℓ㎝㎜°˚’'\"Øø]+(?:\s*/\s*[A-Za-z가-힣㎡㎥㎏ℓ]+)?)")
|
||||
VARLINE = re.compile(r"^\s*(?:[○●◦·\-]\s*)?[A-Za-z][A-Za-z0-9_]*\s*(?:\([^)]*\))?\s*=")
|
||||
RES = re.compile(
|
||||
r"^\s*(?:[A-Z]\s+)?("
|
||||
+ NUMRE
|
||||
+ r")\s*\.?\s*(?:원)?\s*(?:[₩\\]?\s*/\s*\S+)?\s*(?:\([^()]*\))?\s*$"
|
||||
)
|
||||
_PLAIN = re.compile(r"[\s\d.,×÷+\-()A-Za-z가-힣㎡㎥]*")
|
||||
_LABEL = re.compile(r"^(?P<l>[^::=]{0,40}?)\s*[::]\s*(?P<r>.*)$")
|
||||
TAIL = re.compile(r"^\s*(" + NUMRE + r")\s*\.?\s*[::][^=]*$") # 결과 뒤 꼬리 글 「3,094.2 : 채집」
|
||||
_HEADING = re.compile(r"^\s*(\d+|[가-하])\s*[.)]")
|
||||
|
||||
|
||||
def num(s: str) -> float:
|
||||
@@ -112,214 +89,3 @@ def tokens(seg: str) -> list[tuple] | None:
|
||||
if depth != 0 or want or not any(t[0] in ("연산", "%") for t in out):
|
||||
return None
|
||||
return out
|
||||
|
||||
|
||||
def calc(node: tuple, sub: dict | None = None) -> float | None:
|
||||
try:
|
||||
return ex.value(node, {"V": {}, "sub": sub})
|
||||
except ex.ExprError:
|
||||
return None
|
||||
|
||||
|
||||
def label_comp(label: str) -> str | None:
|
||||
w = flat(label)
|
||||
return next((v for k, v in COMPW.items() if w.endswith(k)), None)
|
||||
|
||||
|
||||
def parse_line(text: str) -> dict[str, Any]:
|
||||
"""보이는 글 한 줄 → 꼴 · 라벨 · 결과 · 식 나무(역할 전)."""
|
||||
t = text.strip()
|
||||
if not t:
|
||||
return {"꼴": "빈"}
|
||||
if "─" in t or _PLAIN.fullmatch(t) and "=" not in t and ":" not in t:
|
||||
return {"꼴": "그림" if "─" in t else "글"}
|
||||
label, rhs = "", t
|
||||
mc = _LABEL.match(t)
|
||||
if mc and not re.search(r"\d\s*$", mc.group("l")):
|
||||
label, rhs = mc.group("l"), mc.group("r")
|
||||
if VARLINE.match(rhs) or VARLINE.match(t):
|
||||
return {"꼴": "변수", "조각": rhs.split("=")}
|
||||
if "=" not in rhs:
|
||||
m = re.match(r"^\s*(" + NUMRE + r")\s*\.?\s*$", rhs)
|
||||
if label and m:
|
||||
return {"꼴": "값", "라벨": label, "비목": label_comp(label), "값": num(m.group(1))}
|
||||
return {"꼴": "글"}
|
||||
segs = rhs.split("=")
|
||||
mres = RES.match(segs[-1]) or TAIL.match(segs[-1]) # 꼬리 글은 떼고 읽음(797-1a)
|
||||
if not mres:
|
||||
lead = re.match(r"\s*(" + NUMRE + r")", segs[-1])
|
||||
if (
|
||||
label and lead and re.search(r"\d", "=".join(segs[:-1]))
|
||||
): # 금액 줄 못 읽음 — 보인 값 보존
|
||||
return {"꼴": "글끝", "라벨": label, "보인": num(lead.group(1))}
|
||||
return {"꼴": "글끝"}
|
||||
shown = num(mres.group(1))
|
||||
toks = None
|
||||
for seg in reversed(segs[:-1]):
|
||||
toks = tokens(seg)
|
||||
if toks is None: # 라벨 없는 줄 — 왼쪽 글을 깎아 가며
|
||||
starts = {m.start() for m in re.finditer(NUMRE, seg)}
|
||||
starts |= {m.start() for m in re.finditer(r"\(\s*\d", seg)}
|
||||
for p in sorted(starts):
|
||||
if p and (seg[p - 1].isdigit() or seg[p - 1] in ".,"):
|
||||
continue
|
||||
toks = tokens(seg[p:])
|
||||
if toks:
|
||||
label = label or seg[:p]
|
||||
break
|
||||
if toks:
|
||||
break
|
||||
if not toks:
|
||||
return {"꼴": "식못읽음", "라벨": label, "값": shown}
|
||||
return {"꼴": "돈", "라벨": label, "비목": label_comp(label), "값": shown, "식": ex.parse(toks)}
|
||||
|
||||
|
||||
# ── 블록 풀이 ──────────────────────────────────────────────────────────
|
||||
def _ident(p: dict[str, Any]) -> tuple:
|
||||
return (p["종류"], p["이름"], p["규격"])
|
||||
|
||||
|
||||
def _money(L: dict, prices_by_value: dict, results: list) -> None:
|
||||
"""돈 줄 — 수 토큰 역할(단가 · 앞줄 · 상수) · 다시 셈 · 자리 규칙."""
|
||||
comp = L["비목"]
|
||||
nums = [(n[2], n[1]) for n in ex.numbers(L["식"])]
|
||||
roles: dict[int, dict[str, Any]] = {}
|
||||
price_at = None
|
||||
for i, v in nums:
|
||||
if v >= 1 and any(abs(r[1] - v) < EPS for r in results):
|
||||
continue # 앞 결과와 같은 수 = 참조(단가표 우연 일치보다 먼저)
|
||||
cands = [p for p in prices_by_value.get(v, []) if v >= 1 or (v == 0 and comp)]
|
||||
if comp:
|
||||
cands = [p for p in cands if p["비목"] == comp] or cands
|
||||
if v == 0 and comp and not cands:
|
||||
cands = [{"종류": "0", "이름": "-", "규격": "", "비목": comp, "값": 0.0}]
|
||||
first = i == nums[0][0]
|
||||
if cands and (v >= 100 or (first and v > 0)) or (v == 0 and cands):
|
||||
price_at = i
|
||||
roles[i] = {"갈래": "단가", "후보": cands}
|
||||
break
|
||||
for i, v in nums:
|
||||
if i in roles:
|
||||
continue
|
||||
hit = [r for r in reversed(results) if abs(r[1] - v) < EPS]
|
||||
roles[i] = {"갈래": "앞줄", "줄": hit[0][0]} if hit and v >= 1 else {"갈래": "상수"}
|
||||
L["역할"] = roles
|
||||
L["셈"] = calc(L["식"])
|
||||
L["규칙"] = ex.fit(L["셈"], L["값"])
|
||||
if price_at is not None:
|
||||
L["종류"] = "단가줄"
|
||||
pc = roles[price_at]["후보"]
|
||||
L["비목"] = L["비목"] or (pc[0]["비목"] if len({p["비목"] for p in pc}) == 1 else None)
|
||||
L["후보수"] = len({_ident(p) for p in pc})
|
||||
elif any(r["갈래"] == "앞줄" for r in roles.values()):
|
||||
L["종류"] = "참조줄"
|
||||
else:
|
||||
L["종류"] = "상수줄"
|
||||
|
||||
|
||||
def _value(L: dict, lines: list, results: list) -> None:
|
||||
"""값 줄 — 앞 결과 하나(자리 처리) 또는 바로 앞 돈 · 값 줄 이음 합."""
|
||||
v, how = L["값"], None
|
||||
for r in reversed(results):
|
||||
rule = ex.fit(r[1], v)
|
||||
if rule:
|
||||
how = {"갈래": "복사", "줄": [r[0]], "규칙": rule}
|
||||
break
|
||||
if not how and flat(L["라벨"]) in ("계", "합계"):
|
||||
got: dict[str, dict] = {}
|
||||
for x in reversed(lines):
|
||||
c = x.get("비목")
|
||||
if c and x.get("꼴") in ("돈", "값") and c not in got:
|
||||
got[c] = x
|
||||
if len(got) == 3:
|
||||
break
|
||||
if got:
|
||||
rule = ex.fit(sum(x["값"] for x in got.values()), v)
|
||||
if rule:
|
||||
how = {"갈래": "합", "줄": sorted(x["차례"] for x in got.values()), "규칙": rule}
|
||||
if not how:
|
||||
acc, used = 0.0, []
|
||||
for x in reversed([x for x in lines if x.get("꼴") in ("돈", "값")]):
|
||||
if len(used) >= 25:
|
||||
break
|
||||
acc += x["값"]
|
||||
used.append(x["차례"])
|
||||
rule = ex.fit(acc, v)
|
||||
if rule and len(used) > 1:
|
||||
how = {"갈래": "합", "줄": used[::-1], "규칙": rule}
|
||||
break
|
||||
L["종류"] = "값줄"
|
||||
L["숨은"] = how
|
||||
L["규칙"] = how["규칙"] if how else None
|
||||
|
||||
|
||||
def _var(L: dict) -> None:
|
||||
segs = L.pop("조각")
|
||||
m = re.match(r"^\s*(" + NUMRE + r")", segs[-1]) if len(segs) > 1 else None
|
||||
if not m:
|
||||
L["종류"] = "변수글"
|
||||
return
|
||||
shown, toks = num(m.group(1)), None
|
||||
for seg in reversed(segs[1:-1]):
|
||||
toks = tokens(seg)
|
||||
if toks:
|
||||
break
|
||||
L["값"] = shown
|
||||
L["규칙"] = ex.fit(calc(ex.parse(toks)), shown) if toks else None
|
||||
L["종류"] = "변수식" if toks else ("변수값" if len(segs) == 2 else "변수기호")
|
||||
|
||||
|
||||
def parse_block(text: list[str], prices: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||
"""블록 글 줄 → 복원 줄 목록(역할 · 숨은 합 · 자리 규칙 · 단가 정체)."""
|
||||
by_value = collections.defaultdict(list)
|
||||
for p in prices:
|
||||
by_value[p["값"]].append(p)
|
||||
lines: list[dict[str, Any]] = []
|
||||
results: list[tuple] = [] # (차례, 값, 비목)
|
||||
for k, raw in enumerate(text):
|
||||
L = {"차례": k, "원글": raw, **parse_line(raw)}
|
||||
if L["꼴"] == "돈":
|
||||
_money(L, by_value, results)
|
||||
results.append((k, L["값"], L["비목"]))
|
||||
elif L["꼴"] == "값":
|
||||
_value(L, lines, results)
|
||||
results.append((k, L["값"], L["비목"]))
|
||||
elif L["꼴"] == "변수":
|
||||
_var(L)
|
||||
lines.append(L)
|
||||
identify(lines)
|
||||
return lines
|
||||
|
||||
|
||||
def identify(lines: list[dict[str, Any]]) -> None:
|
||||
"""단가 토큰 정체 — 값 하나 · 기계 세 줄 겹침 · 문맥(라벨 · 머리 글 이름) · 남으면 모호 표지."""
|
||||
head = ""
|
||||
for L in lines:
|
||||
if L["꼴"] in ("글", "글끝") or _HEADING.match(L["원글"]):
|
||||
head = L["원글"]
|
||||
if L.get("종류") != "단가줄":
|
||||
continue
|
||||
role = next(r for r in L["역할"].values() if r["갈래"] == "단가")
|
||||
cands = role["후보"]
|
||||
ids = {_ident(p) for p in cands}
|
||||
how = "값"
|
||||
if len(ids) > 1 and cands[0]["종류"] in ("기계", "산근"):
|
||||
k0, inter = L["차례"], set(ids)
|
||||
for M in lines[max(0, k0 - 3) : k0 + 4]:
|
||||
if M is L or M.get("종류") != "단가줄":
|
||||
continue
|
||||
r2 = next(r for r in M["역할"].values() if r["갈래"] == "단가")
|
||||
ids2 = {_ident(p) for p in r2["후보"]}
|
||||
if ids2 & inter and len(ids2 & inter) < len(inter):
|
||||
inter &= ids2
|
||||
ids, how = inter, "기계 세 줄"
|
||||
if len(ids) > 1:
|
||||
ctx = flat(L["원글"]) + "|" + flat(head)
|
||||
hit = {x for x in ids if x[1] and (x[1] in ctx or x[1][:3] in ctx)}
|
||||
ids, how = ({x for x in hit if not x[2] or x[2] in ctx} or hit or ids), "문맥"
|
||||
role["모호"] = len(ids) != 1
|
||||
role["갈림"] = how
|
||||
role["남은"] = len(ids)
|
||||
pick = sorted(ids)[0]
|
||||
p = next(p for p in cands if _ident(p) == pick)
|
||||
role["정체"] = {"종류": p["종류"], "이름": p["이름"], "규격": p["규격"], "비목": p["비목"]}
|
||||
|
||||
@@ -7,9 +7,8 @@ STmate 셈 규칙(`_분석_649-G2` 1 장) — 줄 금액 = 단가 × (계수 + 1
|
||||
T% = 바로 앞 계표시 값 × 수량 · 계표시 = 아래 단 누계 · `(-=)` = 찍되 위 합에서 뺌 ·
|
||||
`->` = 번지엔 쌓되 합에서 뺌 · 블록 끝전 두 갈래(비목 버림 / 계 버림 + 큰 나머지) ·
|
||||
결과금액 조정(SBM = 계를 한 비목으로) · 계약단가 = 비목 × 낙찰률 원 버림.
|
||||
EST 셈 규칙(같은 문서 2 장) — 줄 = 복원 식(단가 · 앞줄 결과 · 상수) → 줄 자리 속성 ·
|
||||
값 줄 = 숨은 복사 · 합 → 자리 속성 · 블록 성분 = 그 값을 낸 마지막 줄.
|
||||
단가 바뀜 전파 — STmate = 단가 dict 를 바꿔 다시 셈 · EST = `run_est(lines, {정체 열쇠: 새 값})`.
|
||||
단가 바뀜 전파 — 단가 dict 를 바꿔 다시 셈. 옛 EST 엑셀 셈 · 엑셀 검산(`run_est` · `est_check` ·
|
||||
`stmate_check`)은 812-1 엑셀 읽기기로 걷음(EST = `M02_Doc_NativeEst`).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -22,7 +21,6 @@ from M02_MasterTemplete import M02_San_Expr as ex
|
||||
|
||||
COMPS = ("L", "M", "S")
|
||||
SPECIAL = ("L%", "M%", "S%", "T%")
|
||||
TOL_LINE = 0.05
|
||||
|
||||
|
||||
def _zero() -> dict[str, float]:
|
||||
@@ -179,115 +177,3 @@ def stmate_block(block: dict, nodes: list[dict], prices: dict, rule: str) -> dic
|
||||
fin = {c: math.floor(round(kye[c] * nag / 100, 6)) for c in COMPS}
|
||||
fin["T"] = fin["L"] + fin["M"] + fin["S"]
|
||||
return {"줄": lines, "합": tot, "계": kye, "금액": fin, "낙찰": nag}
|
||||
|
||||
|
||||
def stmate_check(block: dict, result: dict) -> dict[str, Any]:
|
||||
"""줄마다 · 블록 검산 — 금액 줄(참조 · 율 · 특수) C~F 대조 · 계표시 줄 · 블록 머리 · 꼬리 계."""
|
||||
bad, n_amt, n_tot, ok_tot = [], 0, 0, 0
|
||||
for r, ln in zip(result["줄"], block["몸"], strict=True):
|
||||
want = {"L": ln["D"] or 0, "M": ln["E"] or 0, "S": ln["F"] or 0}
|
||||
if r["종류"] in ("참조", "율", "특수"):
|
||||
n_amt += 1
|
||||
if not r["값"] or any(abs(r["값"][c] - want[c]) >= TOL_LINE for c in COMPS):
|
||||
bad.append({"행": ln["행"], "엑셀": want, "셈": r["값"], "오류": r["오류"]})
|
||||
elif r["종류"] == "계" and ln["C"] is not None:
|
||||
n_tot += 1
|
||||
ok_tot += all(abs(r["값"][c] - want[c]) < 0.15 for c in COMPS)
|
||||
head = block["머리"]
|
||||
want = {"T": head["C"] or 0, "L": head["D"] or 0, "M": head["E"] or 0, "S": head["F"] or 0}
|
||||
kye = next((t for t in block["꼬리"] if t["A"] == "계"), None)
|
||||
want_k = {"T": kye["C"], "L": kye["D"], "M": kye["E"], "S": kye["F"]} if kye else None
|
||||
same = lambda x, y: all(abs((x[k] or 0) - (y[k] or 0)) < 0.5 for k in "TLMS") # noqa: E731
|
||||
ok = same(result["금액"], want) and (want_k is None or same(result["계"], want_k))
|
||||
return {
|
||||
"맞음": ok,
|
||||
"엑셀": want,
|
||||
"엑셀계": want_k,
|
||||
"금액줄": n_amt,
|
||||
"틀린줄": bad,
|
||||
"계줄": n_tot,
|
||||
"계줄맞음": ok_tot,
|
||||
}
|
||||
|
||||
|
||||
# ── EST ────────────────────────────────────────────────────────────────
|
||||
def key_of(ident: dict[str, Any]) -> tuple:
|
||||
"""EST 단가 정체 → 전파 열쇠 (종류, 이름, 규격, 비목)."""
|
||||
return (ident["종류"], ident["이름"], ident["규격"], ident["비목"])
|
||||
|
||||
|
||||
def run_est(lines: list[dict], newp: dict[tuple, float] | None = None) -> dict[int, float | None]:
|
||||
"""복원 줄 → {차례: 다시 셈 값}(줄 자리 속성 그대로) · newp = {정체 열쇠: 새 단가}."""
|
||||
newp = newp or {}
|
||||
val: dict[int, float | None] = {}
|
||||
for L in lines:
|
||||
k = L["차례"]
|
||||
if L.get("꼴") == "돈" and L.get("규칙"):
|
||||
sub = {}
|
||||
for i, r in L["역할"].items():
|
||||
if r["갈래"] == "단가" and key_of(r["정체"]) in newp:
|
||||
sub[i] = newp[key_of(r["정체"])]
|
||||
elif r["갈래"] == "앞줄" and val.get(r["줄"]) is not None:
|
||||
sub[i] = val[r["줄"]]
|
||||
v = _est_calc(L["식"], sub)
|
||||
val[k] = ex.RULE[L["규칙"]](v) if v is not None else None
|
||||
elif L.get("종류") == "값줄" and L.get("숨은"):
|
||||
how = L["숨은"]
|
||||
s = sum(
|
||||
(val.get(j) if val.get(j) is not None else lines[j]["값"]) or 0 for j in how["줄"]
|
||||
)
|
||||
val[k] = ex.RULE[how["규칙"]](s)
|
||||
return val
|
||||
|
||||
|
||||
def _est_calc(node: tuple, sub: dict) -> float | None:
|
||||
try:
|
||||
return ex.value(node, {"V": {}, "sub": sub})
|
||||
except ex.ExprError:
|
||||
return None
|
||||
|
||||
|
||||
def designate(lines: list[dict], want: dict[str, float]) -> dict[str, Any]:
|
||||
"""블록 성분(총괄표) → 그 값을 낸 줄(마지막 · 라벨 비목 우선) · 0 = 「영」 · 없음 = None."""
|
||||
out: dict[str, Any] = {}
|
||||
kinds = ("단가줄", "참조줄", "상수줄", "값줄")
|
||||
for c in COMPS:
|
||||
w = want[c]
|
||||
if not w:
|
||||
out[c] = "영"
|
||||
continue
|
||||
cand = [L for L in lines if L.get("종류") in kinds and abs(L["값"] - w) < ex.EPS]
|
||||
pick = [L for L in cand if L.get("비목") == c] or cand
|
||||
out[c] = pick[-1]["차례"] if pick else None
|
||||
return out
|
||||
|
||||
|
||||
def est_block(lines: list[dict], des: dict[str, Any], newp: dict | None = None) -> dict[str, Any]:
|
||||
"""성분 줄 → 블록 금액(성분 원 버림 · 계 = 합) · 줄 값."""
|
||||
val = run_est(lines, newp)
|
||||
raw = {
|
||||
c: (0.0 if k == "영" else (val.get(k) if k is not None else None)) for c, k in des.items()
|
||||
}
|
||||
if any(v is None for v in raw.values()):
|
||||
return {"줄": val, "성분": raw, "금액": None}
|
||||
fin = {c: math.floor(round(raw[c], 6)) for c in COMPS}
|
||||
fin["T"] = fin["L"] + fin["M"] + fin["S"]
|
||||
return {"줄": val, "성분": raw, "금액": fin}
|
||||
|
||||
|
||||
def est_check(lines: list[dict], want: dict[str, float]) -> dict[str, Any]:
|
||||
"""블록 검산 — 다시 셈 성분 = 총괄표 · 줄마다 보인 값 대조(돈 · 값 줄)."""
|
||||
des = designate(lines, want)
|
||||
got = est_block(lines, des)
|
||||
val = got["줄"]
|
||||
n, bad = 0, []
|
||||
for L in lines:
|
||||
if L.get("꼴") == "돈" or L.get("종류") == "값줄":
|
||||
n += 1
|
||||
v = val.get(L["차례"])
|
||||
if v is None or abs(v - L["값"]) >= ex.EPS:
|
||||
bad.append({"차례": L["차례"], "글": L["원글"].strip(), "보인": L["값"], "셈": v})
|
||||
fin = got["금액"]
|
||||
ok = fin is not None and all(abs(fin[c] - want[c]) < 0.5 for c in COMPS)
|
||||
ok = ok and abs(want["L"] + want["M"] + want["S"] - want["T"]) < 0.5
|
||||
return {"맞음": ok, "성분줄": des, "금액": fin, "줄수": n, "틀린줄": bad}
|
||||
|
||||
@@ -148,14 +148,13 @@ def test_nested_stmate_follows_inner(world) -> None:
|
||||
def test_nested_est_by_part_matches_excel(world) -> None:
|
||||
folder, _ = _hp(world, "북부청임도")
|
||||
with store.at(folder):
|
||||
made = fh.make(folder, KEY, 67) # 단산68 을 노무비 · 재료비 · 경비 세 줄로 부름
|
||||
made = fh.make(folder, KEY, 67) # 단산68 세 줄 = 묶음 한 줄로 부름(812-1 · 원파일 길)
|
||||
doc = _read(made["이름"])
|
||||
inner = [x for x in doc[imp.KEY]["로직"]["호표"] if x.get("참조")]
|
||||
assert [x["참조비목"] for x in inner] == ["노무비", "재료비", "경비"]
|
||||
assert len({x["참조"]["키"] for x in inner}) == 1
|
||||
assert len(inner) == 1 and "참조비목" not in inner[0]
|
||||
assert made["검산"]["같음"] is True and eqm.read(doc)["ok"] is True
|
||||
# 엑셀 단가 그대로 금액 = 굳힌 값 합과 같음
|
||||
frozen = sum(float(x["값묶음"][x["참조비목"]]) * 0.03 for x in inner)
|
||||
frozen = sum(float(v) for v in inner[0]["값묶음"].values())
|
||||
assert float(made["검산"]["셈"]["계"]) == pytest.approx(
|
||||
float(made["검산"]["엑셀"]["계"]), abs=1
|
||||
)
|
||||
|
||||
@@ -271,12 +271,14 @@ def _north(tmp_path):
|
||||
|
||||
|
||||
def test_real_north_block_has_unique_candidate_and_flags(tmp_path) -> None:
|
||||
sheets = _north(tmp_path)
|
||||
_, total = book.est_prices(sheets)
|
||||
nos = [int(b["번"]) for b in book.est_blocks(sheets) if b["번"] in total]
|
||||
_north(tmp_path)
|
||||
excel = json.loads(fh._path(tmp_path, "HP-000001").read_bytes())["엑셀"]
|
||||
found = None
|
||||
for no in nos:
|
||||
out = fh.emit(tmp_path, "HP-000001", no)
|
||||
for no in sorted(fn.numbers(excel)):
|
||||
try:
|
||||
out = fh.emit(tmp_path, "HP-000001", no)
|
||||
except TypeError: # NativeEst 자리 dict 묶음(812-3 몫)
|
||||
continue
|
||||
if "야면석채집및운반(계곡내)" in out["로직"]["이름"]:
|
||||
found = out
|
||||
break
|
||||
|
||||
@@ -166,7 +166,9 @@ def test_stmate_nested_is_sibling_link(world) -> None:
|
||||
def test_est_by_part_and_frozen_vars(world) -> None:
|
||||
folder = _hp(world, "북부청임도")
|
||||
with store.at(folder):
|
||||
made = fh.make(folder, KEY, 67) # 단산68 을 노무비 · 재료비 · 경비 세 줄로 부름
|
||||
made = fh.make(
|
||||
folder, KEY, 67
|
||||
) # 단산68 세 줄(노 · 재 · 경) = 묶음 한 줄(812-1 · 원파일 길)
|
||||
doc = _read(made["이름"])
|
||||
root = dim.derive(doc)
|
||||
parts = [
|
||||
@@ -174,7 +176,7 @@ def test_est_by_part_and_frozen_vars(world) -> None:
|
||||
for x in root["금액"]
|
||||
if (_var(root, x["이름"]).get("연결") or {}).get("키", "").startswith("SK-")
|
||||
]
|
||||
assert parts == [("노무비", "노무비"), ("재료비", "재료비"), ("경비", "경비")]
|
||||
assert parts == [("묶음", None)]
|
||||
assert not any(x["식"].strip().replace(".", "").isdigit() for x in root["식"])
|
||||
excel = dim.run(root, doc)
|
||||
assert float(excel["계"]) == pytest.approx(float(made["검산"]["엑셀"]["계"]), abs=1)
|
||||
@@ -188,7 +190,7 @@ def test_qty_pick_becomes_table_link(world) -> None:
|
||||
for no in range(1, 200):
|
||||
try:
|
||||
out = fh.emit(folder, KEY, no)
|
||||
except store.StoreError:
|
||||
except (store.StoreError, TypeError): # TypeError = NativeEst 자리 dict 묶음(812-3 몫)
|
||||
continue
|
||||
if "야면석채집및운반(계곡내)" in out["로직"]["이름"]:
|
||||
found = out
|
||||
|
||||
Reference in New Issue
Block a user