Merge remote-tracking branch 'origin/sub_laptop_4' into main_laptop_1
This commit is contained in:
@@ -0,0 +1,238 @@
|
||||
"""경쟁사 산근 엑셀 읽기 — 블록 · 같은 책 단가표(PLAN 793-1 · 읽기만).
|
||||
|
||||
STmate(.xlsx) = 단가산출근거 시트 G 열 「BDCOD|D…」 머리 · POSS~POSE 몸(숨은 G 열 원문) ·
|
||||
꼬리(계 · 계약단가 · 총계) + 목록표 단가(노무비 · 재료비 · 경비 = E 열 · 숨은 G 열 `COD` ·
|
||||
중기 · 단가산출근거 = E~H 열 계 · 노 · 재 · 경 · 숨은 J 열 메타).
|
||||
EST(.xls · .xlsx) = 단가산출 시트 블록 글(`M02_Hopyo_ReadEst.read`) + 노임단가 ·
|
||||
자재단가(적용단가) · 기계경비총괄표 · 견적단가 · 단가산출총괄표(적용 칸 · 합이 안 맞으면 당초 칸).
|
||||
"""
|
||||
|
||||
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 = (
|
||||
"단가산출근거",
|
||||
"단가산출근거목록표",
|
||||
"노무비목록표",
|
||||
"재료비목록표",
|
||||
"경비목록표",
|
||||
"중기목록표",
|
||||
)
|
||||
EST_SHEETS = ("단가산출총괄표", "단가산출", "기계경비총괄표", "자재단가", "노임단가", "견적단가")
|
||||
_X7, _X5 = "_x0007_", "_x0005_"
|
||||
_HEAD = re.compile(r"BDCOD\|([A-Z]\d+).*?POSS\|(\d+).*?POSE\|(\d+)")
|
||||
|
||||
|
||||
def sheets(name: str, data: bytes) -> dict[str, list[list[Any]]]:
|
||||
"""책 바이트 → {시트: 줄} (두 꼴 시트만)."""
|
||||
keep = STM_SHEETS + EST_SHEETS
|
||||
if data[:4] == b"PK\x03\x04" or name.lower().endswith(".xlsx"):
|
||||
import openpyxl
|
||||
|
||||
with warnings.catch_warnings(): # 인쇄 영역 이름 경고
|
||||
warnings.simplefilter("ignore")
|
||||
wb = openpyxl.load_workbook(io.BytesIO(data), read_only=True, data_only=True)
|
||||
try:
|
||||
return {
|
||||
ws.title.strip(): [list(r) for r in ws.iter_rows(values_only=True)]
|
||||
for ws in wb.worksheets
|
||||
if ws.title.strip() in keep
|
||||
}
|
||||
finally:
|
||||
wb.close()
|
||||
import xlrd
|
||||
|
||||
wb = xlrd.open_workbook(file_contents=data, logfile=io.StringIO())
|
||||
return {
|
||||
sh.name.strip(): [sh.row_values(r) for r in range(sh.nrows)]
|
||||
for sh in wb.sheets()
|
||||
if sh.name.strip() in keep
|
||||
}
|
||||
|
||||
|
||||
def kind_of(book: dict[str, list]) -> str | None:
|
||||
if "단가산출근거" in book:
|
||||
return "STmate"
|
||||
return "EST" if "단가산출" in book else None
|
||||
|
||||
|
||||
# ── STmate ─────────────────────────────────────────────────────────────
|
||||
def _col(row: list[Any], letter: str) -> Any:
|
||||
i = ord(letter) - 65
|
||||
return row[i] if i < len(row) else None
|
||||
|
||||
|
||||
def meta(text: Any) -> dict[str, str]:
|
||||
"""숨은 메타 칸 「COD|L00003 ␅ UNT|L% …」 → dict."""
|
||||
s = str(text or "").replace(_X7, "").replace(_X5, "\x05")
|
||||
out = {}
|
||||
for part in s.split("\x05"):
|
||||
part = part.strip("`")
|
||||
if "|" in part:
|
||||
k, v = part.split("|", 1)
|
||||
out[k] = v
|
||||
return out
|
||||
|
||||
|
||||
_BODY = "BCDEFGHIJKLMNO"
|
||||
|
||||
|
||||
def stmate_blocks(book: dict[str, list]) -> list[dict[str, Any]]:
|
||||
"""→ [{코드, 이름, 행, 머리 {A..F}, 몸 [{행, B, G, C~F …}], 꼬리 [{행, A, C~F, H}]}]."""
|
||||
rows = book["단가산출근거"]
|
||||
out = []
|
||||
for i, r in enumerate(rows):
|
||||
gv = _col(r, "G")
|
||||
if not (isinstance(gv, str) and "BDCOD|" in gv):
|
||||
continue
|
||||
m = _HEAD.search(gv)
|
||||
code, ps, pe = m.group(1), int(m.group(2)), int(m.group(3))
|
||||
body = []
|
||||
for rn in range(ps, pe + 1):
|
||||
rr = rows[rn - 1]
|
||||
one = {k: _col(rr, k) for k in _BODY}
|
||||
one["G"] = str(one["G"] or "").replace(_X7, "")
|
||||
body.append({"행": rn, **one})
|
||||
tail = []
|
||||
for rn in range(pe + 1, min(pe + 40, len(rows) + 1)):
|
||||
rr = rows[rn - 1]
|
||||
g2 = _col(rr, "G")
|
||||
if isinstance(g2, str) and "BDCOD|" in g2:
|
||||
break
|
||||
a = str(_col(rr, "A") or "").replace(" ", "")
|
||||
if a and isinstance(_col(rr, "C"), (int, float)):
|
||||
tail.append({"행": rn, "A": a, **{k: _col(rr, k) for k in "CDEFH"}})
|
||||
head = {k: _col(r, k) for k in "ABCDEF"}
|
||||
name = _col(rows[i + 1], "B") if i + 1 < len(rows) else None
|
||||
out.append(
|
||||
{"코드": code, "이름": name, "행": i + 1, "머리": head, "몸": body, "꼬리": tail}
|
||||
)
|
||||
return out
|
||||
|
||||
|
||||
def stmate_prices(book: dict[str, list]) -> dict[str, dict[str, Any]]:
|
||||
"""코드 → {T, L, M, S, meta, 이름, 규격, 단위}."""
|
||||
prices: dict[str, dict[str, Any]] = {}
|
||||
for sheet, comp in (("노무비목록표", "L"), ("재료비목록표", "M"), ("경비목록표", "S")):
|
||||
for r in book.get(sheet, [])[3:]:
|
||||
m = meta(_col(r, "G")) if isinstance(_col(r, "G"), str) else {}
|
||||
v = _col(r, "E")
|
||||
if m.get("COD") and isinstance(v, (int, float)):
|
||||
one = {c: (v if c == comp else 0) for c in "LMS"}
|
||||
prices[m["COD"]] = {"T": v, **one, "meta": m} | _names(r)
|
||||
for sheet in ("중기목록표", "단가산출근거목록표"):
|
||||
for r in book.get(sheet, [])[3:]:
|
||||
m = meta(_col(r, "J")) if isinstance(_col(r, "J"), str) else {}
|
||||
if m.get("COD") and isinstance(_col(r, "E"), (int, float)):
|
||||
one = {"T": _col(r, "E"), "L": _col(r, "F") or 0, "M": _col(r, "G") or 0}
|
||||
prices[m["COD"]] = {**one, "S": _col(r, "H") or 0, "meta": m} | _names(r)
|
||||
return prices
|
||||
|
||||
|
||||
def _names(r: list[Any]) -> dict[str, Any]:
|
||||
return {"이름": _col(r, "B"), "규격": _col(r, "C"), "단위": _col(r, "D")}
|
||||
|
||||
|
||||
# ── EST ────────────────────────────────────────────────────────────────
|
||||
def flat(s: Any) -> str:
|
||||
return re.sub(r"\s+", "", str(s or ""))
|
||||
|
||||
|
||||
def _hcol(rows: list[list[Any]], word: str, start: int = 2) -> int | None:
|
||||
for r in rows[start : start + 2]:
|
||||
for j, v in enumerate(r):
|
||||
if flat(v) == word:
|
||||
return j
|
||||
return None
|
||||
|
||||
|
||||
def _n(v: Any) -> float | None:
|
||||
if isinstance(v, (int, float)):
|
||||
return float(v)
|
||||
try:
|
||||
return float(str(v).replace(",", "").strip())
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
|
||||
def applied(r: list[Any]) -> dict[str, float]:
|
||||
"""총괄표 줄 → {T, L, M, S} — 적용 칸(5~8) 합이 맞으면 적용 · 아니면 당초(25~28)."""
|
||||
a = {k: _n(r[j]) for k, j in (("T", 5), ("L", 6), ("M", 7), ("S", 8))}
|
||||
if (
|
||||
a["T"]
|
||||
and all(a[k] is not None for k in "LMS")
|
||||
and abs(a["L"] + a["M"] + a["S"] - a["T"]) < 0.5
|
||||
and (a["L"] or a["M"] or a["S"])
|
||||
):
|
||||
return a
|
||||
return {k: (_n(r[j]) or 0.0) for k, j in (("T", 25), ("L", 26), ("M", 27), ("S", 28))}
|
||||
|
||||
|
||||
def est_prices(book: dict[str, list]) -> tuple[list[dict[str, Any]], dict[int, dict[str, Any]]]:
|
||||
"""→ (단가 목록 [{종류, 이름, 규격, 비목, 값}], 산근 총괄 {번: {T, L, M, S, 이름, 규격}})."""
|
||||
out: list[dict[str, Any]] = []
|
||||
lab = book.get("노임단가", [])
|
||||
c = _hcol(lab, "적용단가")
|
||||
for r in lab[3:]:
|
||||
if c is not None and len(r) > c and r[2] and isinstance(r[c], (int, float)):
|
||||
out.append(
|
||||
{"종류": "노임", "이름": flat(r[2]), "규격": "", "비목": "L", "값": float(r[c])}
|
||||
)
|
||||
mat = book.get("자재단가", [])
|
||||
c = _hcol(mat, "적용단가")
|
||||
for r in mat[4:]:
|
||||
if c is not None and len(r) > c and r[2] and isinstance(r[c], (int, float)):
|
||||
out.append(
|
||||
{
|
||||
"종류": "자재",
|
||||
"이름": flat(r[2]),
|
||||
"규격": flat(r[3]),
|
||||
"비목": "M",
|
||||
"값": float(r[c]),
|
||||
}
|
||||
)
|
||||
for sheet, kind in (("기계경비총괄표", "기계"), ("견적단가", "견적")):
|
||||
for r in book.get(sheet, [])[3:]:
|
||||
if len(r) > 28 and r[2]:
|
||||
for k, v in applied(r).items():
|
||||
if isinstance(v, (int, float)) and (v or k != "T"):
|
||||
one = {"종류": kind, "이름": flat(r[2]), "규격": flat(r[3])}
|
||||
out.append({**one, "비목": k, "값": float(v or 0)})
|
||||
san: dict[int, dict[str, Any]] = {}
|
||||
for r in book.get("단가산출총괄표", [])[3:]:
|
||||
if len(r) > 28 and str(r[1]).strip().isdigit():
|
||||
n = int(str(r[1]).strip())
|
||||
san[n] = {k: float(v or 0) for k, v in applied(r).items()}
|
||||
san[n] |= {"이름": flat(r[2]), "규격": flat(r[3])}
|
||||
for k in "TLMS":
|
||||
if san[n][k] or k != "T":
|
||||
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 산근 시트 없음")
|
||||
@@ -0,0 +1,318 @@
|
||||
"""EST 산근 보이는 글 → 식 복원(PLAN 793-1 · 요구서_652 · 문법 = `_분석_648-A` 3 장 · eval 없음).
|
||||
|
||||
엑셀엔 식이 없고 인쇄 글뿐 — 「라벨 : 식 = 결과」 로 나눠 식을 되살림. 셈은 `M02_San_Eval.run_est`.
|
||||
|
||||
줄 꼴(`꼴`) — 빈 · 그림(분수 ─) · 글 · 글끝(끝이 결과 아님) · 식못읽음 · 변수 · 값 · 돈.
|
||||
- 돈(`종류` 단가줄 · 참조줄 · 상수줄) — 라벨 · 비목(라벨 끝 노무비 · 재료비 · 경비) ·
|
||||
값(보인 결과) · 식(`M02_San_Expr` 나무) ·
|
||||
역할 {수 토큰 차례: 단가(후보 · 정체 · 모호) | 앞줄(같은 블록 앞 결과 줄 차례) | 상수} ·
|
||||
규칙(보인 결과가 되는 줄 자리 속성 · 기본 버림1)
|
||||
- 값(`종류` 값줄 「소 계 : 1,355.9」) — 숨은 = {갈래 복사 | 합, 줄 [차례], 규칙}(앞 결과 하나 또는
|
||||
바로 앞 돈 · 값 줄 이음 합)
|
||||
- 변수(`종류` 변수식 · 변수값 · 변수기호 · 변수글) — 값 · 규칙 = 얼림(금액엔 반올림된 값이 박힘)
|
||||
"""
|
||||
|
||||
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>.*)$")
|
||||
_HEADING = re.compile(r"^\s*(\d+|[가-하])\s*[.)]")
|
||||
|
||||
|
||||
def num(s: str) -> float:
|
||||
return float(s.replace(",", "").rstrip(".") or 0)
|
||||
|
||||
|
||||
def tokens(seg: str) -> list[tuple] | None:
|
||||
"""식 조각 → Expr 토큰 · 숫자 식이 아니면(기호 · 수 둘 잇달아 · 연산 없음) None."""
|
||||
s = seg.replace("(", "(").replace(")", ")").replace("x", "x")
|
||||
prev = None
|
||||
while prev != s: # 수 없는 괄호 글 「(품할증)」
|
||||
prev = s
|
||||
s = re.sub(r"\([^()\d]*\)", " ", s)
|
||||
out: list[tuple] = []
|
||||
i, want, depth = 0, True, 0
|
||||
while i < len(s):
|
||||
ch = s[i]
|
||||
if ch.isspace():
|
||||
i += 1
|
||||
continue
|
||||
m = re.match(NUMRE, s[i:])
|
||||
if m and (i == 0 or not (s[i - 1].isalpha() and s[i - 1].isascii())):
|
||||
if not want:
|
||||
return None
|
||||
out.append(("수", num(m.group(0))))
|
||||
i += len(m.group(0))
|
||||
want = False
|
||||
while i < len(s): # 단위 글 「인」 「㎡」 「본/일」
|
||||
m2 = _UNIT.match(s[i:])
|
||||
if m2 and m2.group(0).strip() and not re.fullmatch(r"\s*[xX]", m2.group(0)):
|
||||
i += len(m2.group(0))
|
||||
continue
|
||||
break
|
||||
continue
|
||||
if ch == "%":
|
||||
if want:
|
||||
return None
|
||||
out.append(("%",))
|
||||
i += 1
|
||||
continue
|
||||
if ch in OPS:
|
||||
if ch in "xX" and want:
|
||||
return None
|
||||
if ch == "/" and not re.match(r"\s*[\d(.]", s[i + 1 :]):
|
||||
return None
|
||||
if ch in "--−" and want:
|
||||
out.append(("연산", "-"))
|
||||
i += 1
|
||||
continue
|
||||
if want:
|
||||
return None
|
||||
out.append(("연산", OPS[ch]))
|
||||
i += 1
|
||||
want = True
|
||||
continue
|
||||
if ch == "(":
|
||||
if not want:
|
||||
return None
|
||||
depth += 1
|
||||
out.append(("(",))
|
||||
i += 1
|
||||
continue
|
||||
if ch == ")":
|
||||
depth -= 1
|
||||
if depth < 0 or want:
|
||||
return None
|
||||
out.append((")",))
|
||||
i += 1
|
||||
continue
|
||||
if ch.isalpha() or ch == "_":
|
||||
return None
|
||||
i += 1 # 장식(₩ 등)
|
||||
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])
|
||||
if not mres:
|
||||
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
|
||||
pick = sorted(ids)[0]
|
||||
p = next(p for p in cands if _ident(p) == pick)
|
||||
role["정체"] = {"종류": p["종류"], "이름": p["이름"], "규격": p["규격"], "비목": p["비목"]}
|
||||
@@ -0,0 +1,293 @@
|
||||
"""경쟁사 산근 참조 평가기(PLAN 793-1 · 요구서_652) — 줄 나무 + 같은 책 단가 →
|
||||
줄 · 블록 금액 · 검산.
|
||||
|
||||
STmate 셈 규칙(`_분석_649-G2` 1 장) — 줄 금액 = 단가 × (계수 + 1e-13) → 소수 1 버림 ·
|
||||
변수 `X = 식 =` = 소수 2 반올림 · 율 `&P..&` = 범위 비목 금액 × n % → 소수 1 버림 ·
|
||||
`&R..&` = × n · 특수 % L% · M% = 처음부터 그 비목 누계 × n % ·
|
||||
T% = 바로 앞 계표시 값 × 수량 · 계표시 = 아래 단 누계 · `(-=)` = 찍되 위 합에서 뺌 ·
|
||||
`->` = 번지엔 쌓되 합에서 뺌 · 블록 끝전 두 갈래(비목 버림 / 계 버림 + 큰 나머지) ·
|
||||
결과금액 조정(SBM = 계를 한 비목으로) · 계약단가 = 비목 × 낙찰률 원 버림.
|
||||
EST 셈 규칙(같은 문서 2 장) — 줄 = 복원 식(단가 · 앞줄 결과 · 상수) → 줄 자리 속성 ·
|
||||
값 줄 = 숨은 복사 · 합 → 자리 속성 · 블록 성분 = 그 값을 낸 마지막 줄.
|
||||
단가 바뀜 전파 — STmate = 단가 dict 를 바꿔 다시 셈 · EST = `run_est(lines, {정체 열쇠: 새 값})`.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import collections
|
||||
import math
|
||||
from typing import Any
|
||||
|
||||
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]:
|
||||
return dict.fromkeys(COMPS, 0.0)
|
||||
|
||||
|
||||
# ── STmate ─────────────────────────────────────────────────────────────
|
||||
def _rate_amount(node: dict, coef: float, A: dict, T: dict, since: dict, total: dict) -> dict:
|
||||
rate = node["율"]
|
||||
area = rate["범위"]
|
||||
if area == "A":
|
||||
base = _zero()
|
||||
for spot in rate["번지"]:
|
||||
for c in COMPS:
|
||||
base[c] += A[spot][c]
|
||||
elif area == "T":
|
||||
base = T.get(rate["번지"][0]) or _zero()
|
||||
else:
|
||||
base = since if area == "C" else total
|
||||
pct = coef if rate["곱"] else coef / 100
|
||||
src, dst = rate["비목"], rate["옮김"]
|
||||
amt = _zero()
|
||||
if src:
|
||||
amt[dst or src] = ex.fl1(base[src] * pct)
|
||||
elif dst:
|
||||
amt[dst] = ex.fl1(sum(base.values()) * pct)
|
||||
else:
|
||||
for c in COMPS:
|
||||
amt[c] = ex.fl1(base[c] * pct)
|
||||
return amt
|
||||
|
||||
|
||||
def _ref_amount(
|
||||
node: dict, coef: float, prices: dict, total: dict, last_tot: Any, res: dict
|
||||
) -> dict:
|
||||
src_ = node["자원"]
|
||||
code = src_["코드"]
|
||||
if code not in prices:
|
||||
raise ex.ExprError(f"단가 없음 {code}")
|
||||
pr = prices[code]
|
||||
src, dst = src_["비목"], src_["옮김"]
|
||||
amt = _zero()
|
||||
unt = (pr.get("meta") or {}).get("UNT")
|
||||
res["단가"] = {c: pr[c] for c in COMPS}
|
||||
if unt in SPECIAL:
|
||||
dst2 = dst or {"L": "L", "M": "M", "S": "S"}.get(code[0])
|
||||
if unt == "T%":
|
||||
b0 = last_tot if last_tot is not None else sum(total.values())
|
||||
amt[dst2] = ex.fl1(b0 * coef)
|
||||
else:
|
||||
b0 = total[unt[0]]
|
||||
amt[dst2] = ex.fl1(b0 * coef / 100)
|
||||
res["기준"] = b0
|
||||
res["종류"] = "특수"
|
||||
return amt
|
||||
c_eps = coef + 1e-13
|
||||
if src:
|
||||
amt[dst or src] = ex.fl1(pr[src] * c_eps)
|
||||
else:
|
||||
for c in COMPS:
|
||||
if pr[c]:
|
||||
amt[c] = ex.fl1(pr[c] * c_eps)
|
||||
return amt
|
||||
|
||||
|
||||
def run_stmate(nodes: list[dict], prices: dict, dec: int = 2) -> tuple[list[dict], dict]:
|
||||
"""줄 나무 → (줄 결과 [{종류, 값 {L, M, S}, 계수, 오류 …}], 블록 소수 합 {L, M, S})."""
|
||||
V: dict[str, float] = {}
|
||||
A: dict[str, dict] = collections.defaultdict(_zero) # 금액번지
|
||||
T: dict[str, dict] = {} # 누계번지
|
||||
acc = [_zero() for _ in range(6)] # 계 단 1~5 · 0 = 전체
|
||||
since = _zero() # 마지막 계표시 뒤
|
||||
last_tot = None
|
||||
out = []
|
||||
for node in nodes:
|
||||
kind = node["종류"]
|
||||
res: dict[str, Any] = {"행": node.get("행"), "종류": kind, "값": None, "오류": None}
|
||||
try:
|
||||
if kind == "계":
|
||||
lvl = node["단"]
|
||||
val = dict(acc[lvl])
|
||||
res |= {"단": lvl, "제외": node["제외"], "값": val}
|
||||
for k in range(1, lvl + 1):
|
||||
acc[k] = _zero()
|
||||
if node["제외"]:
|
||||
for k in [*range(lvl + 1, 6), 0]:
|
||||
for c in COMPS:
|
||||
acc[k][c] -= val[c]
|
||||
if node["누계번지"]:
|
||||
T[node["누계번지"]] = val
|
||||
last_tot = sum(val.values())
|
||||
since = _zero()
|
||||
elif kind in ("참조", "율"):
|
||||
if node.get("오류"):
|
||||
raise ex.ExprError(node["오류"])
|
||||
coef = ex.value(node["식"], {"V": V, "R": 1.0})
|
||||
if kind == "율":
|
||||
amt = _rate_amount(node, coef, A, T, since, acc[0])
|
||||
else:
|
||||
amt = _ref_amount(node, coef, prices, acc[0], last_tot, res)
|
||||
res |= {"값": amt, "계수": coef, "번지": node["번지"], "빼기": node["빼기"]}
|
||||
addr = node["번지"]
|
||||
if addr and addr.startswith("A"):
|
||||
for c in COMPS:
|
||||
A[addr][c] += amt[c]
|
||||
if not node["빼기"]:
|
||||
for k in range(6):
|
||||
for c in COMPS:
|
||||
acc[k][c] += amt[c]
|
||||
for c in COMPS:
|
||||
since[c] += amt[c]
|
||||
elif kind == "대입":
|
||||
for part in node["대입"]:
|
||||
if part["이름"] is None:
|
||||
try:
|
||||
ex.value(part["식"], {"V": V, "R": 1.0})
|
||||
except ex.ExprError:
|
||||
pass
|
||||
continue
|
||||
v = ex.value(part["식"], {"V": V, "R": 1.0})
|
||||
V[part["이름"]] = ex.rnd(v, dec) if part["찍기"] else v
|
||||
except ex.ExprError as error:
|
||||
res["오류"] = str(error)
|
||||
out.append(res)
|
||||
return out, acc[0]
|
||||
|
||||
|
||||
def block_total(tot: dict, rule: str) -> dict[str, float]:
|
||||
"""블록 끝전 — 비목버림(비목 원 버림 · 계 = 합) / 합버림_큰나머지(계 원 버림 · 나머지를 소수 큰
|
||||
차례 경 › 재 › 노 로 1 원씩)."""
|
||||
c = {k: math.floor(round(tot[k], 6)) for k in COMPS}
|
||||
if rule == "비목버림":
|
||||
c["T"] = c["L"] + c["M"] + c["S"]
|
||||
return c
|
||||
total = math.floor(round(sum(tot.values()), 6))
|
||||
order = sorted(COMPS, key=lambda k: (-round(tot[k] - c[k], 6), "SML".index(k)))
|
||||
for k in order[: total - sum(c.values())]:
|
||||
c[k] += 1
|
||||
c["T"] = total
|
||||
return c
|
||||
|
||||
|
||||
def stmate_block(block: dict, nodes: list[dict], prices: dict, rule: str) -> dict[str, Any]:
|
||||
"""블록 → {줄, 계(끝전 뒤 · 계약 전), 금액(조정 · 계약 뒤), 낙찰}."""
|
||||
lines, tot = run_stmate(nodes, prices)
|
||||
meta = (prices.get(block["코드"]) or {}).get("meta", {})
|
||||
kye = block_total(tot, rule)
|
||||
sbm = (meta.get("SBM") or "").strip()
|
||||
if meta.get("ADJ") == "T" and sbm in COMPS:
|
||||
kye = {"L": 0, "M": 0, "S": 0, "T": kye["T"], sbm: kye["T"]}
|
||||
nag = float(meta["NAG"]) if meta.get("UC") == "T" and meta.get("NAG") else None
|
||||
fin = dict(kye)
|
||||
if nag:
|
||||
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}
|
||||
@@ -0,0 +1,260 @@
|
||||
"""경쟁사 산근 글 식 — 셈 식 나무 · 평가 · 자리 규칙(PLAN 793-1 · 요구서_652 · eval 없음).
|
||||
|
||||
토큰 = `("수", 값)` · `("이름", 글)` · `("참조",)` · `("연산", + - * / ^)` · `("(",)` · `(")",)` ·
|
||||
`(",",)` · `("%",)` · `("끼움", 이름 | None, 토큰 목록)`.
|
||||
나무 마디 = `("수", 값, 토큰 차례)` · `("이름", 소문자)` · `("참조",)` ·
|
||||
`("끼움", 이름 | None, 마디)` · `("함수", round | int, [마디])` · `("음", 마디)` ·
|
||||
`("셈", 연산, 왼, 오른)` · `("오류", 까닭)`(읽기 실패 — 셀 때 오류).
|
||||
우선순위 = 파이썬 식과 같음(`^` = 거듭제곱 · 오른쪽 결합 · 단항 `-` 보다 셈 · `%` = 「/ 100」 을
|
||||
곱셈 단에서 왼쪽부터) — 측정 도구(`tmp/brain_649/G2`)의 파이썬 식 평가와 같은 부동소수 결과.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
from typing import Any
|
||||
|
||||
FUNCS = ("round", "int")
|
||||
MAX_DEPTH = 64
|
||||
|
||||
|
||||
class ExprError(Exception):
|
||||
"""식 읽기 · 셈 실패 — 줄 표지로 남김."""
|
||||
|
||||
|
||||
# ── 자리 규칙 ──────────────────────────────────────────────────────────
|
||||
def fl1(x: float) -> float:
|
||||
"""소수 1 자리 버림(STmate 줄 금액)."""
|
||||
return math.floor(round(x * 10, 6)) / 10
|
||||
|
||||
|
||||
def rnd(x: float, n: int) -> float:
|
||||
"""반올림(half up) — STmate 변수 표시 자리."""
|
||||
q = 10**n
|
||||
return math.floor(x * q + 0.5 + 1e-9) / q
|
||||
|
||||
|
||||
def _cut(q: float) -> Any:
|
||||
return lambda v: math.floor(round(v * q, 6)) / q
|
||||
|
||||
|
||||
def _half(q: float) -> Any:
|
||||
return lambda v: math.floor(v * q + 0.5 + 1e-9) / q
|
||||
|
||||
|
||||
def _up(q: float) -> Any:
|
||||
return lambda v: math.ceil(round(v * q, 6)) / q
|
||||
|
||||
|
||||
# 줄 자리 속성 — 차례 = 되찾기 우선(EST 기본 = 버림1)
|
||||
RULES: list[tuple[str, Any]] = [
|
||||
("버림1", _cut(10)),
|
||||
("버림0", _cut(1)),
|
||||
("반올림1", _half(10)),
|
||||
("반올림0", _half(1)),
|
||||
("버림2", _cut(100)),
|
||||
("반올림2", _half(100)),
|
||||
("버림3", _cut(1000)),
|
||||
("반올림3", _half(1000)),
|
||||
("올림0", _up(1)),
|
||||
("올림1", _up(10)),
|
||||
("올림2", _up(100)),
|
||||
("그대로", lambda v: v),
|
||||
]
|
||||
RULE = dict(RULES)
|
||||
EPS = 1e-6
|
||||
|
||||
|
||||
def fit(value: float | None, shown: float) -> str | None:
|
||||
"""셈 값 → 보인 값이 되는 첫 자리 규칙 이름(없으면 None)."""
|
||||
if value is None:
|
||||
return None
|
||||
return next((name for name, f in RULES if abs(f(value) - shown) < EPS), None)
|
||||
|
||||
|
||||
# ── 읽기(재귀 하강) ────────────────────────────────────────────────────
|
||||
class _Parser:
|
||||
def __init__(self, toks: list[tuple]) -> None:
|
||||
self.toks, self.at, self.depth = toks, 0, 0
|
||||
|
||||
def peek(self) -> tuple | None:
|
||||
return self.toks[self.at] if self.at < len(self.toks) else None
|
||||
|
||||
def take(self) -> tuple:
|
||||
tok = self.peek()
|
||||
if tok is None:
|
||||
raise ExprError("식이 덜 끝남")
|
||||
self.at += 1
|
||||
return tok
|
||||
|
||||
def is_op(self, *ops: str) -> bool:
|
||||
tok = self.peek()
|
||||
return tok is not None and tok[0] == "연산" and tok[1] in ops
|
||||
|
||||
def expr(self) -> tuple:
|
||||
self.depth += 1
|
||||
if self.depth > MAX_DEPTH:
|
||||
raise ExprError("괄호가 너무 깊음")
|
||||
node = self.term()
|
||||
while self.is_op("+", "-"):
|
||||
op = self.take()[1]
|
||||
node = ("셈", op, node, self.term())
|
||||
self.depth -= 1
|
||||
return node
|
||||
|
||||
def term(self) -> tuple:
|
||||
node = self.unary()
|
||||
while True:
|
||||
if self.is_op("*", "/"):
|
||||
op = self.take()[1]
|
||||
node = ("셈", op, node, self.unary())
|
||||
elif (tok := self.peek()) is not None and tok[0] == "%":
|
||||
self.take()
|
||||
node = ("셈", "/", node, ("수", 100.0, None))
|
||||
else:
|
||||
return node
|
||||
|
||||
def unary(self) -> tuple:
|
||||
if self.is_op("-"):
|
||||
self.take()
|
||||
return ("음", self.unary())
|
||||
if self.is_op("+"):
|
||||
self.take()
|
||||
return self.unary()
|
||||
return self.power()
|
||||
|
||||
def power(self) -> tuple:
|
||||
node = self.atom()
|
||||
if self.is_op("^"):
|
||||
self.take()
|
||||
return ("셈", "^", node, self.unary())
|
||||
return node
|
||||
|
||||
def atom(self) -> tuple:
|
||||
at = self.at
|
||||
tok = self.take()
|
||||
kind = tok[0]
|
||||
if kind == "수":
|
||||
return ("수", tok[1], at)
|
||||
if kind == "참조":
|
||||
return ("참조",)
|
||||
if kind == "끼움":
|
||||
return ("끼움", tok[1], parse(tok[2]))
|
||||
if kind == "(":
|
||||
node = self.expr()
|
||||
if self.take()[0] != ")":
|
||||
raise ExprError("괄호 짝 없음")
|
||||
return node
|
||||
if kind == "이름":
|
||||
name = tok[1].lower()
|
||||
if name in FUNCS and (nxt := self.peek()) is not None and nxt[0] == "(":
|
||||
self.take()
|
||||
args = [self.expr()]
|
||||
while (nxt := self.peek()) is not None and nxt[0] == ",":
|
||||
self.take()
|
||||
args.append(self.expr())
|
||||
if self.take()[0] != ")":
|
||||
raise ExprError("함수 괄호 짝 없음")
|
||||
return ("함수", name, args)
|
||||
return ("이름", name)
|
||||
raise ExprError(f"식 자리 아닌 토큰 {kind}")
|
||||
|
||||
|
||||
def parse(toks: list[tuple]) -> tuple:
|
||||
"""토큰 → 나무(남는 토큰 · 빈 식 = 오류)."""
|
||||
if not toks:
|
||||
raise ExprError("빈 식")
|
||||
p = _Parser(toks)
|
||||
node = p.expr()
|
||||
if p.peek() is not None:
|
||||
raise ExprError(f"식 뒤 남은 토큰 {p.peek()[0]}")
|
||||
return node
|
||||
|
||||
|
||||
# ── 셈 ─────────────────────────────────────────────────────────────────
|
||||
def _brackets(node: tuple, env: dict, seen: dict) -> None:
|
||||
"""끼움 `[A]` · `[Q=0.208]` 먼저(왼쪽부터 · 원문 치환 차례) — 값은 seen[id]."""
|
||||
kind = node[0]
|
||||
if kind == "끼움":
|
||||
_brackets(node[2], env, seen)
|
||||
value = calc(node[2], env, seen)
|
||||
if node[1]:
|
||||
env["V"][node[1].lower()] = value
|
||||
seen[id(node)] = value
|
||||
elif kind == "셈":
|
||||
_brackets(node[2], env, seen)
|
||||
_brackets(node[3], env, seen)
|
||||
elif kind == "음":
|
||||
_brackets(node[1], env, seen)
|
||||
elif kind == "함수":
|
||||
for arg in node[2]:
|
||||
_brackets(arg, env, seen)
|
||||
|
||||
|
||||
def calc(node: tuple, env: dict, seen: dict | None = None) -> float:
|
||||
"""나무 → 값 · env = {"V": 변수(소문자), "R": 참조 값, "sub": {토큰 차례: 새 값}}."""
|
||||
kind = node[0]
|
||||
if kind == "오류":
|
||||
raise ExprError(node[1])
|
||||
if kind == "수":
|
||||
sub = env.get("sub")
|
||||
return sub[node[2]] if sub and node[2] in sub else node[1]
|
||||
if kind == "이름":
|
||||
if node[1] not in env["V"]:
|
||||
raise ExprError(f"미정의 변수 {node[1]}")
|
||||
return env["V"][node[1]]
|
||||
if kind == "참조":
|
||||
return env.get("R", 1.0)
|
||||
if kind == "끼움":
|
||||
if seen is not None and id(node) in seen:
|
||||
return seen[id(node)]
|
||||
return calc(node[2], env, seen)
|
||||
if kind == "음":
|
||||
return -calc(node[1], env, seen)
|
||||
if kind == "함수":
|
||||
args = [calc(a, env, seen) for a in node[2]]
|
||||
if node[1] == "int":
|
||||
return float(int(args[0]))
|
||||
return float(round(args[0], int(args[1])) if len(args) > 1 else round(args[0]))
|
||||
a, b = calc(node[2], env, seen), calc(node[3], env, seen)
|
||||
op = node[1]
|
||||
if op == "+":
|
||||
return a + b
|
||||
if op == "-":
|
||||
return a - b
|
||||
if op == "*":
|
||||
return a * b
|
||||
if op == "^":
|
||||
try:
|
||||
return float(a**b)
|
||||
except (OverflowError, ZeroDivisionError) as error:
|
||||
raise ExprError("거듭제곱 오류") from error
|
||||
if b == 0:
|
||||
raise ExprError("0 나누기")
|
||||
return a / b
|
||||
|
||||
|
||||
def value(node: tuple, env: dict) -> float:
|
||||
"""끼움 먼저 · 그 다음 셈."""
|
||||
seen: dict = {}
|
||||
_brackets(node, env, seen)
|
||||
out = calc(node, env, seen)
|
||||
if isinstance(out, complex):
|
||||
raise ExprError("복소수")
|
||||
return out
|
||||
|
||||
|
||||
def numbers(node: tuple) -> list[tuple]:
|
||||
"""나무 속 수 마디(왼쪽부터)."""
|
||||
kind = node[0]
|
||||
if kind == "수":
|
||||
return [node] if node[2] is not None else []
|
||||
if kind == "셈":
|
||||
return numbers(node[2]) + numbers(node[3])
|
||||
if kind in ("음",):
|
||||
return numbers(node[1])
|
||||
if kind == "끼움":
|
||||
return numbers(node[2])
|
||||
if kind == "함수":
|
||||
return [n for a in node[2] for n in numbers(a)]
|
||||
return []
|
||||
@@ -0,0 +1,191 @@
|
||||
"""STmate 산근 글 식 파서(PLAN 793-1 · 요구서_652 · 문법 = `_분석_648-B` 3 장 · eval 없음).
|
||||
|
||||
단가산출근거 시트 숨은 G 열 원문 한 줄 → 줄 나무(dict). 셈은 `M02_San_Eval.run_stmate`.
|
||||
|
||||
줄 꼴(`종류`):
|
||||
- 빈 · 글 — 셈 없음(`'글'` · 단위 조각 · `// 덧글` 뺀 뒤 빈 것)
|
||||
- 계 — `(=)`~`(=====)` 단 1~5 · `(-=)` 제외(위 합에서 뺌) · `(=):T01` 누계번지
|
||||
- 대입 — `X = 식` 전 자리 · `X = 식 =` 소수 2 자리 반올림 · 한 줄 여러 대입(`,`) ·
|
||||
맨 식(끼움 `[A]` 만 먹음)
|
||||
- 참조 — `&L..&` · `&M..&` · `&S..&` · `&X..L/M/S(:L/M/S)&` · `&D..&` · `&B..&` 단가 × 계수 식
|
||||
(특수 % 코드 L% · M% · T% 는 목록표 UNT 로 셀 때 가림)
|
||||
- 율 — `&P{A01|A0102..|T01|C_|S_}{L|M|S|}:{L|M|S|}& × n'%'` 범위 금액 × n % · `&R..&` × n
|
||||
- 공통 칸 — 숨김(`;;`) · 번지(`:=A01` · `:=T01`) · 빼기(`->` = 번지엔 쌓되 합에서 뺌) ·
|
||||
찍기(끝 `=` 또는 번지) · 식(계수 나무 · `("참조",)` = 단가 자리 1) · 오류(읽기 실패 까닭)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
from M02_MasterTemplete import M02_San_Expr as ex
|
||||
|
||||
REF = re.compile(r"&([A-Z]+)([^&]*)&")
|
||||
_TOK = re.compile(r"\s*(?:(\d+\.?\d*|\.\d+)|([A-Za-z][A-Za-z0-9_]*)|(@R)|(.))")
|
||||
_TOTAL = re.compile(r"^\((-?)(=+)\)(?::(T\d+))?")
|
||||
_NAME_EQ = re.compile(r"^\s*[A-Za-z][A-Za-z0-9_]*\s*=(?!=)")
|
||||
_ASSIGN = re.compile(r"^([A-Za-z][A-Za-z0-9_]*)\s*=(?!=)(.*)$")
|
||||
_ADDR = re.compile(r":=\s*([AT]\d+)\s*$")
|
||||
_RATE_TAIL = re.compile(r"^(\d*|_)([LMS]?)(?::([LMS]?))?$")
|
||||
_CODE_TAIL = re.compile(r"^([LMS]?)(?::([LMS]))?$")
|
||||
RATE_KINDS = ("PA", "PT", "PC", "PS", "RA", "RT", "RC", "RS")
|
||||
OPS = "+-*/^"
|
||||
|
||||
|
||||
def tokens(text: str) -> list[tuple]:
|
||||
"""STmate 식 글 → 토큰(끼움 `[..]` 안은 다시 토큰) · 모르는 기호 = ExprError."""
|
||||
out: list[tuple] = []
|
||||
at = 0
|
||||
while at < len(text):
|
||||
if text[at].isspace():
|
||||
at += 1
|
||||
continue
|
||||
if text[at] == "[":
|
||||
end = text.find("]", at)
|
||||
if end < 0:
|
||||
raise ex.ExprError("끼움 괄호 짝 없음")
|
||||
inner = text[at + 1 : end]
|
||||
if "=" in inner:
|
||||
name, body = inner.split("=", 1)
|
||||
out.append(("끼움", name.strip(), tokens(body)))
|
||||
else:
|
||||
out.append(("끼움", None, tokens(inner)))
|
||||
at = end + 1
|
||||
continue
|
||||
m = _TOK.match(text, at)
|
||||
if not m or m.end() == at:
|
||||
break
|
||||
at = m.end()
|
||||
num, name, ref, other = m.groups()
|
||||
if num:
|
||||
out.append(("수", float(num)))
|
||||
elif name:
|
||||
out.append(("이름", name))
|
||||
elif ref:
|
||||
out.append(("참조",))
|
||||
elif other and other.strip():
|
||||
if other in OPS:
|
||||
out.append(("연산", other))
|
||||
elif other in "(),":
|
||||
out.append((other,))
|
||||
else:
|
||||
raise ex.ExprError(f"모르는 기호 {other!r}")
|
||||
return out
|
||||
|
||||
|
||||
def expr(text: str) -> tuple:
|
||||
"""식 글 → 나무 · 실패 = `("오류", 까닭)`(셀 때 터짐 — 측정 도구와 같은 차례)."""
|
||||
try:
|
||||
return ex.parse(tokens(text))
|
||||
except ex.ExprError as error:
|
||||
return ("오류", str(error))
|
||||
|
||||
|
||||
def split_top(text: str, sep: str = ",") -> list[str]:
|
||||
"""괄호 밖 `sep` 로 나눔."""
|
||||
out, depth, cur = [], 0, ""
|
||||
for ch in text:
|
||||
if ch in "([":
|
||||
depth += 1
|
||||
elif ch in ")]":
|
||||
depth -= 1
|
||||
if ch == sep and depth == 0:
|
||||
out.append(cur)
|
||||
cur = ""
|
||||
else:
|
||||
cur += ch
|
||||
out.append(cur)
|
||||
return out
|
||||
|
||||
|
||||
def _clean(g: str) -> tuple[str, bool, str | None]:
|
||||
"""원문 → (셈 글, 숨김, 덧글) — `//` 뒤 덧글 · `;;` 숨은 줄 · `'글'` 지움."""
|
||||
raw, _, note = g.partition("//")
|
||||
hidden = raw.lstrip().startswith(";;")
|
||||
s = raw.lstrip()[2:] if hidden else raw
|
||||
s = re.sub(r"'[^']*'", " ", s).replace("'", " ")
|
||||
return s.strip(), hidden, (note.strip() or None) if _ else None
|
||||
|
||||
|
||||
def _ref_line(st: str, m: re.Match) -> dict[str, Any]:
|
||||
kind, tail = m.group(1), m.group(2)
|
||||
pre = _NAME_EQ.sub(" ", st[: m.start()])
|
||||
body = pre + " @R " + st[m.end() :]
|
||||
out: dict[str, Any] = {"빼기": "->" in body}
|
||||
body = body.replace("->", " ")
|
||||
addr = _ADDR.search(body)
|
||||
out["번지"] = addr.group(1) if addr else None
|
||||
if addr:
|
||||
body = body[: addr.start()]
|
||||
out["찍기"] = body.rstrip().endswith("=") or bool(addr)
|
||||
out["식"] = expr(body.rstrip().rstrip("="))
|
||||
if kind in RATE_KINDS:
|
||||
mt = _RATE_TAIL.match(tail)
|
||||
if not mt:
|
||||
return {**out, "종류": "율", "오류": f"율 꼬리 {tail}"}
|
||||
num = mt.group(1)
|
||||
area = kind[1]
|
||||
if area == "A":
|
||||
spots = ["A" + num[i : i + 2] for i in range(0, len(num), 2)]
|
||||
elif area == "T":
|
||||
spots = ["T" + num]
|
||||
else:
|
||||
spots = []
|
||||
rate = {"곱": kind[0] == "R", "범위": area, "번지": spots}
|
||||
rate |= {"비목": mt.group(2) or None, "옮김": mt.group(3) or None}
|
||||
return {**out, "종류": "율", "율": rate}
|
||||
digits = re.match(r"^\d*", tail).group(0)
|
||||
mc = _CODE_TAIL.match(tail[len(digits) :])
|
||||
if not mc:
|
||||
return {**out, "종류": "참조", "오류": f"참조 꼬리 {tail[len(digits) :]}"}
|
||||
src = {"코드": kind + digits, "비목": mc.group(1) or None, "옮김": mc.group(2)}
|
||||
return {**out, "종류": "참조", "자원": src}
|
||||
|
||||
|
||||
def _assign_line(st: str) -> dict[str, Any]:
|
||||
parts: list[dict[str, Any]] = []
|
||||
for part in split_top(st):
|
||||
p = part.strip()
|
||||
if not p:
|
||||
continue
|
||||
ma = _ASSIGN.match(p)
|
||||
if ma:
|
||||
body = ma.group(2).strip()
|
||||
show = body.endswith("=")
|
||||
body = body.rstrip("=").strip()
|
||||
if body:
|
||||
parts.append({"이름": ma.group(1).lower(), "식": expr(body), "찍기": show})
|
||||
else:
|
||||
node = expr(p.rstrip("=").strip())
|
||||
if node[0] not in ("오류", "이름"): # 맨 낱말 · 못 읽는 글 = 셈 없음
|
||||
parts.append({"이름": None, "식": node, "찍기": False})
|
||||
if not parts:
|
||||
return {"종류": "글"}
|
||||
return {"종류": "대입", "대입": parts}
|
||||
|
||||
|
||||
def parse_line(g: str | None) -> dict[str, Any]:
|
||||
"""G 열 원문 한 줄 → 줄 나무."""
|
||||
st, hidden, note = _clean(g or "")
|
||||
head = {"숨김": hidden, "덧글": note}
|
||||
tot = _TOTAL.match(st.replace(" ", ""))
|
||||
if tot:
|
||||
return {**head, "종류": "계", "단": len(tot.group(2)), "제외": tot.group(1) == "-"} | {
|
||||
"누계번지": tot.group(3)
|
||||
}
|
||||
if not st:
|
||||
return {**head, "종류": "빈"}
|
||||
refs = list(REF.finditer(st))
|
||||
if len(refs) > 1:
|
||||
return {**head, "종류": "참조", "오류": "참조 둘 이상", "식": ("오류", "참조 둘 이상")}
|
||||
if refs:
|
||||
return {**head, **_ref_line(st, refs[0])}
|
||||
return {**head, **_assign_line(st)}
|
||||
|
||||
|
||||
def parse_block(lines: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||
"""[{행, G}] → 줄 나무 목록(행 · 원문 붙임)."""
|
||||
return [
|
||||
{"행": ln.get("행"), "글": ln.get("G") or "", **parse_line(ln.get("G"))} for ln in lines
|
||||
]
|
||||
@@ -0,0 +1,171 @@
|
||||
"""793-1 지킴 — 경쟁사 산근 글 식 파서 · 참조 평가기(`M02_San_*`) · 실무 여덟 파일 금액 그대로.
|
||||
|
||||
STmate = 숨은 G 열 원문 식 → 233/233 블록 · 금액 줄 1,315 · 계표시 줄 695 다 맞음.
|
||||
EST = 보이는 글 → 식 복원 → 222/222 블록 · 돈 · 값 줄 2,901/2,903 · 모호 단가 토큰 9.
|
||||
단가 바뀜 전파 견본 = `_분석_649-G2` 4 장(보통인부 → 180,000).
|
||||
엑셀 = `helper_m02_hopyo_golden.FILES`(읽기만) · 없으면 skip(다른 PC).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from functools import lru_cache
|
||||
|
||||
import helper_m02_hopyo_golden as golden
|
||||
import pytest
|
||||
|
||||
from M02_MasterTemplete import M02_San_Book as book
|
||||
from M02_MasterTemplete import M02_San_Est as est
|
||||
from M02_MasterTemplete import M02_San_Eval as ev
|
||||
from M02_MasterTemplete import M02_San_Stmate as stm
|
||||
|
||||
# 파일: (블록, 금액 줄, 계표시 줄, 끝전)
|
||||
STMATE = {
|
||||
"봉화": (62, 349, 184, "비목버림"),
|
||||
"동부청영월": (69, 411, 230, "합버림_큰나머지"),
|
||||
"울진대흥": (47, 261, 128, "비목버림"),
|
||||
"영덕계류": (15, 86, 41, "비목버림"),
|
||||
"울진소광": (40, 208, 112, "비목버림"),
|
||||
}
|
||||
# 파일: (블록, 돈 · 값 줄, 틀린 줄 상한, 모호 토큰)
|
||||
EST = {"북부청임도": (94, 1252, 2, 0), "북부청사방": (67, 849, 0, 7), "거창": (61, 802, 0, 2)}
|
||||
LABOR = ("노임", "보통인부", "", "L")
|
||||
|
||||
|
||||
@lru_cache(maxsize=8)
|
||||
def _load(tag: str) -> dict:
|
||||
path = golden.FILES[tag]
|
||||
if not path.exists():
|
||||
pytest.skip(f"실무 엑셀 없음 {path.name}")
|
||||
return book.load(path)
|
||||
|
||||
|
||||
def _stmate_runs(tag: str) -> list[tuple]:
|
||||
data = _load(tag)
|
||||
out = []
|
||||
for blk in data["블록"]:
|
||||
if not any(ln["G"].strip() for ln in blk["몸"]):
|
||||
continue
|
||||
res = ev.stmate_block(blk, stm.parse_block(blk["몸"]), data["단가"], STMATE[tag][3])
|
||||
out.append((blk, res, ev.stmate_check(blk, res)))
|
||||
return out
|
||||
|
||||
|
||||
@pytest.mark.parametrize("tag", list(STMATE))
|
||||
def test_stmate_blocks_equal_excel(tag: str) -> None:
|
||||
blocks, amount_lines, total_lines, _ = STMATE[tag]
|
||||
runs = _stmate_runs(tag)
|
||||
assert len(runs) == blocks
|
||||
wrong = [b["코드"] for b, _, c in runs if not c["맞음"]]
|
||||
assert not wrong, wrong
|
||||
assert sum(c["금액줄"] for _, _, c in runs) == amount_lines
|
||||
assert [x for _, _, c in runs for x in c["틀린줄"]] == []
|
||||
assert sum(c["계줄"] for _, _, c in runs) == total_lines
|
||||
assert sum(c["계줄맞음"] for _, _, c in runs) == total_lines
|
||||
|
||||
|
||||
@pytest.mark.parametrize("tag", list(EST))
|
||||
def test_est_blocks_equal_excel(tag: str) -> None:
|
||||
blocks, lines, bad_max, vague = EST[tag]
|
||||
data = _load(tag)
|
||||
n, wrong, got_lines, got_bad, got_vague = 0, [], 0, 0, 0
|
||||
for blk in data["블록"]:
|
||||
want = data["총괄"].get(blk["번"])
|
||||
if not want or not (want["T"] or any(want[c] for c in "LMS")):
|
||||
continue
|
||||
parsed = est.parse_block(blk["글"], data["단가"])
|
||||
chk = ev.est_check(parsed, want)
|
||||
n += 1
|
||||
if not chk["맞음"]:
|
||||
wrong.append(blk["번"])
|
||||
got_lines += chk["줄수"]
|
||||
got_bad += len(chk["틀린줄"])
|
||||
got_vague += sum(1 for L in parsed for r in (L.get("역할") or {}).values() if r.get("모호"))
|
||||
assert (n, wrong) == (blocks, [])
|
||||
assert got_lines == lines
|
||||
assert got_bad <= bad_max
|
||||
assert got_vague == vague
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"tag, code, labor, before, after",
|
||||
[
|
||||
("봉화", "D00102", "L00007", (34119, 11192, 341), (34697, 11192, 346)),
|
||||
("동부청영월", "D00003", "L00002", (4764, 310, 397), (5097, 309, 397)),
|
||||
("울진소광", "D01489", "L00016", (182223, 230359, 8152), (185067, 230359, 8209)),
|
||||
],
|
||||
)
|
||||
def test_stmate_price_propagates(tag, code, labor, before, after) -> None:
|
||||
data = _load(tag)
|
||||
blk = next(b for b in data["블록"] if b["코드"] == code)
|
||||
nodes = stm.parse_block(blk["몸"])
|
||||
rule = STMATE[tag][3]
|
||||
prices = dict(data["단가"])
|
||||
prices[labor] = {**prices[labor], "T": 180000, "L": 180000}
|
||||
for p, want in ((data["단가"], before), (prices, after)):
|
||||
got = ev.stmate_block(blk, nodes, p, rule)["계"]
|
||||
assert (got["L"], got["M"], got["S"]) == want
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"tag, no, after",
|
||||
[
|
||||
("북부청사방", 31, (14877, 5491, 0)),
|
||||
("북부청임도", 2, (22774, 3235, 3830)),
|
||||
("거창", 1, (3485, 832, 1049)),
|
||||
("북부청사방", 3, (20147, 391, 0)),
|
||||
],
|
||||
)
|
||||
def test_est_price_propagates(tag, no, after) -> None:
|
||||
data = _load(tag)
|
||||
blk = next(b for b in data["블록"] if b["번"] == no)
|
||||
parsed = est.parse_block(blk["글"], data["단가"])
|
||||
des = ev.designate(parsed, data["총괄"][no])
|
||||
got = ev.est_block(parsed, des, {LABOR: 180000.0})["금액"]
|
||||
assert (got["L"], got["M"], got["S"]) == after
|
||||
|
||||
|
||||
# ── 엑셀 없이 도는 문법 견본(`_분석_648-B` 3-1 봉화 D00102) ──
|
||||
def test_stmate_grammar_sample() -> None:
|
||||
prices = {
|
||||
"M00081": {"T": 11468, "L": 0, "M": 11468, "S": 0, "meta": {}},
|
||||
"L00007": {"T": 165545, "L": 165545, "M": 0, "S": 0, "meta": {}},
|
||||
"L00099": {"T": 2, "L": 2, "M": 0, "S": 0, "meta": {"UNT": "L%"}},
|
||||
}
|
||||
text = [
|
||||
"'합판(내수) :'&M00081& * 1.03 * 0.327 :=A01",
|
||||
";;Q = 2.5 / 3 =",
|
||||
"'보통인부' &L00007& * [Q] / 4 :=A02",
|
||||
"&PA01M:M& * 11'%' =",
|
||||
"&PA02L:S& * 1'%' = // 공구손료",
|
||||
"(=)",
|
||||
"&L00099& * 2 =",
|
||||
"(-=)",
|
||||
]
|
||||
nodes = stm.parse_block([{"행": i, "G": g} for i, g in enumerate(text)])
|
||||
assert [n["종류"] for n in nodes] == ["참조", "대입", "참조", "율", "율", "계", "참조", "계"]
|
||||
assert nodes[1]["숨김"] and nodes[4]["덧글"] == "공구손료"
|
||||
assert nodes[3]["율"] == {"곱": False, "범위": "A", "번지": ["A01"], "비목": "M", "옮김": "M"}
|
||||
lines, tot = ev.run_stmate(nodes, prices)
|
||||
assert lines[0]["값"]["M"] == 3862.5
|
||||
assert lines[2]["값"]["L"] == 34350.5 # 165,545 × 0.83(Q 반올림) / 4 → 0.1 버림
|
||||
assert lines[3]["값"]["M"] == 424.8 # A01 재료 3,862.5 × 11 %
|
||||
assert lines[4]["값"]["S"] == 343.5 # A02 노무 34,350.5 × 1 % → 경비
|
||||
assert lines[5]["값"] == pytest.approx({"L": 34350.5, "M": 4287.3, "S": 343.5})
|
||||
assert lines[6]["종류"] == "특수" and lines[6]["값"]["L"] == 687.0 # 노무 누계 × 2 %
|
||||
assert lines[7]["값"]["L"] == 687.0 # (-=) 찍되 합에서 뺌
|
||||
assert tot == pytest.approx({"L": 34350.5, "M": 4287.3, "S": 343.5})
|
||||
|
||||
|
||||
def test_est_grammar_sample() -> None:
|
||||
prices = [{"종류": "노임", "이름": "보통인부", "규격": "", "비목": "L", "값": 172068.0}]
|
||||
text = [
|
||||
"보통인부 : 172,068 × 0.09 인 = 15,486.1",
|
||||
"공구손료 : 15,486.1 × 3 % = 464.5",
|
||||
"소 계 : 15,950.6",
|
||||
"노무비 : 15,950.6 × 1 = 15,950",
|
||||
]
|
||||
lines = est.parse_block(text, prices)
|
||||
assert [L.get("종류") for L in lines] == ["단가줄", "참조줄", "값줄", "참조줄"]
|
||||
assert lines[3]["규칙"] == "버림0" and lines[2]["숨은"]["갈래"] == "합"
|
||||
val = ev.run_est(lines, {LABOR: 180000.0})
|
||||
assert val[0] == 16200.0 and val[1] == 486.0 and val[3] == 16686.0
|
||||
Reference in New Issue
Block a user