- 새 M02_San_Emit.py — 793-1 줄 나무 → 로직 JSON(중간 · 호표 값묶음 · 덧줄 · 번지 · 단계 · 합밖 · 자리 · 끝전 · 조정 · 글줄 · 가져옴 검산 · 계약) - 자원 이음 열쇠(품명 · 규격 · 단위 · 경쟁사 코드 · 1차 줄 판정 M01 키) · reprice 단가 전파 - STmate 누계 % 밑 = 그 줄 앞까지(뒤 호표 빼고 · 안 빠진 제외 단계 더함) · 율 범위 C = 앞 줄 부름 합 - 지킴 시험 test_793_2 — STmate 233 · EST 222 블록 엔진 셈 = 엑셀 = 참조 평가기 · 줄 4,218 · 단계 695 · 전파 견본 7 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
250 lines
11 KiB
Python
250 lines
11 KiB
Python
"""793-2a 지킴 — 경쟁사 산근 → 우리 엔진 로직 줄 꼴(`M02_San_Emit`) · 엔진 셈 = 엑셀.
|
||
|
||
여덟 파일 STmate 233 · EST 222 블록을 로직 JSON 으로 내보내 `master_formula.run` →
|
||
`master_cut.engine` 으로 셈 — 노 · 재 · 경 · 계 = 엑셀(STmate 계 꼬리 · EST 총괄표) =
|
||
참조 평가기(`M02_San_Eval`) · 줄마다(STmate 금액 줄 · EST 돈 · 값 줄) · 계표시(단계) 같음.
|
||
단가 전파 견본 = `_분석_649-G2` 4 장(보통인부 → 180,000) · 계약단가(낙찰률)는 B09 몫 — 엔진 계 ×
|
||
낙찰률 원 버림 = 엑셀 머리만 확인. 엑셀 = `helper_m02_hopyo_golden.FILES`(읽기만 · 없으면 skip).
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import importlib
|
||
import math
|
||
from functools import lru_cache
|
||
|
||
import helper_m02_hopyo_golden as golden
|
||
import pytest
|
||
|
||
from M01_MasterData import M01_MasterData_Store as m01 # noqa: F401 — scripts 길을 둠
|
||
from M02_MasterTemplete import M02_San_Book as book
|
||
from M02_MasterTemplete import M02_San_Emit as em
|
||
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
|
||
|
||
mf = importlib.import_module("master_formula")
|
||
mcut = importlib.import_module("master_cut")
|
||
ms = importlib.import_module("master_formula_sum")
|
||
|
||
STMATE = {
|
||
"봉화": (62, "비목버림"),
|
||
"동부청영월": (69, "합버림_큰나머지"),
|
||
"울진대흥": (47, "비목버림"),
|
||
"영덕계류": (15, "비목버림"),
|
||
"울진소광": (40, "비목버림"),
|
||
}
|
||
EST = {"북부청임도": 94, "북부청사방": 67, "거창": 61}
|
||
# 엑셀 글 식이 보인 값과 안 맞는 줄(793-1 틀린 줄 2) — 보인 값으로 얼림 · 블록 금액은 같음
|
||
EST_FROZEN = {"북부청임도": {27: ["37"], 33: ["51"]}}
|
||
K = ("노무비", "재료비", "경비", "계")
|
||
LABOR = {"종류": "노임", "이름": "보통인부", "규격": "", "열쇠비목": "L"}
|
||
|
||
|
||
def engine(logic: dict) -> dict:
|
||
master = mf.Master({"로직.json": {"그룹": "로직", "줄": [logic]}})
|
||
return mcut.engine(master, logic["키"], mf.run(master, logic["키"], {}))
|
||
|
||
|
||
def money(got: dict) -> tuple:
|
||
return tuple(int(got[k]) for k in K)
|
||
|
||
|
||
@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(tag: str, blk: dict) -> tuple[list, dict]:
|
||
nodes = stm.parse_block(blk["몸"])
|
||
return nodes, em.stmate(blk, nodes, _load(tag)["단가"], STMATE[tag][1], "GX990001")
|
||
|
||
|
||
def _lines_equal(logic: dict, got: dict) -> list:
|
||
"""줄마다 엔진 비목 금액 = 엑셀 · 단계 = 계표시 줄."""
|
||
bad = []
|
||
for item, line in zip([*logic["호표"], *logic["덧줄"]], got["줄"], strict=True):
|
||
for k, want in item["검산"].items():
|
||
if abs(float(line["비목"].get(k) or 0) - want) >= 0.05:
|
||
bad.append((item["이름"], k, line["비목"].get(k), want))
|
||
for step in got.get("단계", []):
|
||
want = logic["가져옴"]["단계"][step["이름"]]["검산"]
|
||
if any(abs(float(step[k]) - want[k]) >= 0.15 for k in want):
|
||
bad.append((step["이름"], want))
|
||
return bad
|
||
|
||
|
||
@pytest.mark.parametrize("tag", list(STMATE))
|
||
def test_stmate_engine_equals_excel(tag: str) -> None:
|
||
data = _load(tag)
|
||
n, wrong, lines, steps = 0, [], 0, 0
|
||
for blk in data["블록"]:
|
||
if not any(ln["G"].strip() for ln in blk["몸"]):
|
||
continue
|
||
n += 1
|
||
nodes, logic = _stmate(tag, blk)
|
||
got = engine(logic)
|
||
ref = ev.stmate_block(blk, nodes, data["단가"], STMATE[tag][1])["계"]
|
||
want = logic["가져옴"]["검산"]
|
||
same = money(got) == tuple(round(want[k]) for k in K)
|
||
same &= money(got) == (ref["L"], ref["M"], ref["S"], ref["T"])
|
||
bad = _lines_equal(logic, got) + ms.check(logic, logic["키"]) + logic["가져옴"]["표지"]
|
||
if deal := logic["가져옴"].get("계약"): # B09 몫 — 엔진 계 × 낙찰률 원 버림 = 엑셀 머리
|
||
cut = [math.floor(round(int(got[k]) * deal["낙찰률"] / 100, 6)) for k in K[:3]]
|
||
same &= cut == [deal["금액"][k] for k in K[:3]]
|
||
if not same or bad:
|
||
wrong.append((blk["코드"], money(got), bad[:3]))
|
||
lines += len(logic["호표"]) + len(logic["덧줄"])
|
||
steps += len(got.get("단계", []))
|
||
assert (n, wrong) == (STMATE[tag][0], [])
|
||
assert lines >= 1 and steps >= 1
|
||
|
||
|
||
@pytest.mark.parametrize("tag", list(EST))
|
||
def test_est_engine_equals_excel(tag: str) -> None:
|
||
data = _load(tag)
|
||
n, wrong = 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
|
||
n += 1
|
||
parsed = est.parse_block(blk["글"], data["단가"])
|
||
logic = em.est(blk, parsed, want, "GX990002")
|
||
got = engine(logic)
|
||
ref = ev.est_block(parsed, ev.designate(parsed, want))["금액"]
|
||
same = money(got) == (want["L"], want["M"], want["S"], want["T"])
|
||
same &= money(got) == (ref["L"], ref["M"], ref["S"], ref["T"])
|
||
frozen = [m.split(":")[0] for m in logic["가져옴"]["표지"]]
|
||
bad = _lines_equal(logic, got) + ms.check(logic, logic["키"])
|
||
if not same or bad or frozen != EST_FROZEN.get(tag, {}).get(blk["번"], []):
|
||
wrong.append((blk["번"], money(got), bad[:3], frozen))
|
||
assert (n, wrong) == (EST[tag], [])
|
||
|
||
|
||
@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:
|
||
blk = next(b for b in _load(tag)["블록"] if b["코드"] == code)
|
||
_, logic = _stmate(tag, blk)
|
||
assert money(engine(logic))[:3] == before
|
||
assert em.reprice(logic, {"코드": labor}, {"L": 180000}) >= 1
|
||
assert money(engine(logic))[:3] == after
|
||
|
||
|
||
@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)
|
||
logic = em.est(blk, est.parse_block(blk["글"], data["단가"]), data["총괄"][no], "GX990002")
|
||
assert em.reprice(logic, LABOR, {"L": 180000.0}) >= 1
|
||
assert money(engine(logic))[:3] == after
|
||
|
||
|
||
def test_link_keys_m01() -> None:
|
||
"""자원 이음 열쇠 — 엑셀 품명 · 규격 · 단위 · 경쟁사 코드 · 1차 줄 판정 M01 키."""
|
||
tag = "봉화"
|
||
blk = next(b for b in _load(tag)["블록"] if b["코드"] == "D00102")
|
||
logic = em.stmate(
|
||
blk, stm.parse_block(blk["몸"]), _load(tag)["단가"], "비목버림", "GX990001", True
|
||
)
|
||
labor = next(x for x in logic["호표"] if x["이름"] == "보통인부")
|
||
assert labor["자원"] | {"M01": labor["자원"].get("M01")} == {
|
||
"꼴": "STmate",
|
||
"갈래": "인력",
|
||
"코드": "L00007",
|
||
"이름": "보통인부",
|
||
"규격": None,
|
||
"단위": "인",
|
||
"단가": {"L": 165545, "M": 0, "S": 0},
|
||
"M01": "LB000002",
|
||
}
|
||
assert labor["값묶음"] == {"노무비": 165545, "재료비": 0, "경비": 0}
|
||
|
||
|
||
# ── 엑셀 없이 도는 문법 견본(793-1 `test_stmate_grammar_sample` 그대로) ──
|
||
def test_stmate_grammar_sample_engine() -> 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 =",
|
||
"(-=)",
|
||
"'별도' &M00081& * 2 =",
|
||
"Q = 1 / 3 =",
|
||
"&L00099& * [Q] =",
|
||
"'뒤 인부' &L00007& * 0.01 =",
|
||
]
|
||
rows = [{"행": i, "G": g} for i, g in enumerate(text)]
|
||
nodes = stm.parse_block(rows)
|
||
block = {"코드": "D00001", "이름": "견본", "머리": {}, "꼬리": [], "몸": rows}
|
||
block["머리"] = {"A": "", "B": "", "C": 0, "D": 0, "E": 0, "F": 0}
|
||
logic = em.stmate(block, nodes, prices, "비목버림", "GX990003")
|
||
assert [x["이름"] for x in logic["중간"]] == ["q", "q_2"] # 다시 대입 = 새 이름
|
||
assert logic["중간"][0] == {
|
||
"이름": "q",
|
||
"식": "(2.5 / 3)",
|
||
"표시": 2,
|
||
"원문": {"행": 1, "글": text[1]},
|
||
}
|
||
assert ms.check(logic, "견본") == [] and logic["가져옴"]["표지"] == []
|
||
got = engine(logic)
|
||
lines, tot = ev.run_stmate(nodes, prices)
|
||
want = ev.block_total(tot, "비목버림")
|
||
assert money(got) == (want["L"], want["M"], want["S"], want["T"])
|
||
by = {x["이름"]: x for x in got["줄"]}
|
||
assert by["보통인부"]["비목"]["노무비"] == 34350.5 # 165,545 × 0.83 / 4
|
||
assert by["공구"]["비목"]["노무비"] == 687 # 노무 누계 × 2 % · (-=) 로 합에서 뺌
|
||
assert logic["덧줄"][-1]["식"] == "(노무비 - 줄.'뒤 인부') * ((q_2)) / 100" # 뒤 줄을 뺀 누계
|
||
assert (
|
||
float(by["공구_2"]["비목"]["노무비"]) == lines[10]["값"]["L"] == 113.3
|
||
) # 34,350.5 × 0.33 %
|
||
|
||
|
||
def test_est_grammar_sample_engine() -> 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)
|
||
want = {"T": 15950.0, "L": 15950.0, "M": 0.0, "S": 0.0}
|
||
logic = em.est({"번": 1, "이름": "견본", "단위": "㎡"}, lines, want, "GX990004")
|
||
assert [x["식"] for x in logic["덧줄"]] == [
|
||
"((줄.'보통인부' * 3) / 100)",
|
||
"줄.'보통인부' + 공구손료",
|
||
"소계",
|
||
]
|
||
assert logic["덧줄"][2]["자리"] == {"방법": "버림", "자리": 0}
|
||
assert [x.get("합밖") for x in (*logic["호표"], *logic["덧줄"])] == [True, True, True, None]
|
||
assert money(engine(logic)) == (15950, 0, 0, 15950)
|
||
em.reprice(logic, LABOR, {"L": 180000.0})
|
||
assert money(engine(logic)) == (16686, 0, 0, 16686) # 793-1 run_est 전파와 같음
|