Files
Aislo/resources/tester/test_793_2_san_emit.py
eomsangdonandClaude Sonnet 5.5 0792a17e2c feat(797-1a): 엑셀 불러오기 빠진 정보 표지 다섯 + 줄 단위 합격 시험 · 표본 밖 고정
- 금액 줄 못 읽음: 결과 뒤 꼬리 글은 떼고 금액 줄로 읽음(2016 틀린 줄 2 → 0) · 못 읽는 글끝 · 식못읽음 = 표지 + 보인 값 보존
- 단가 후보 여럿 · 사람 확인 / 추정(문맥 · 기계 세 줄) 표지 — 임의 선택 대신 표지
- 당초 / 적용 칸 표지(Book.applied_how) · 조정 비율 · 낙찰률 미지원 표지
- FromHopyo.keep 이 읽기 불가 사유(빈 파일 · DRM 암호 · 형식 오류)를 돌려줌
- 사람 확인 표지는 「참고 · 」 머리 — 못 옮김 표지와 가름 · test_793_2 는 못 옮김 표지만 셈(낡은 기대 수정)
- test_797_1a 신설: 작은 견본 · 표지 없는 틀린 줄 0 · 표본 밖 수목 142 · 대흥공통 46 · 2016 374 고정 · 버림1 일치율 감시
- 금액 결과 그대로(엔진 · 수식 · Copy 손대지 않음)

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-07 12:07:36 +09:00

294 lines
13 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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, 349, 184, "비목버림"),
"동부청영월": (69, 411, 230, "합버림_큰나머지"),
"울진대흥": (47, 261, 128, "비목버림"),
"영덕계류": (15, 86, 41, "비목버림"),
"울진소광": (40, 208, 112, "비목버림"),
}
EST = {"북부청임도": (94, 1252), "북부청사방": (67, 849), "거창": (61, 802)} # (블록, 돈 · 값 줄)
# 엑셀 글 식이 보인 값과 안 맞는 줄(793-1 틀린 줄 2) — 보인 값으로 얼림 · 블록 금액은 같음
EST_FROZEN = {"북부청임도": {27: 1, 33: 1}}
K = ("노무비", "재료비", "경비", "계")
LABOR = {"종류": "노임", "이름": "보통인부", "규격": "", "열쇠비목": "L"}
def engine(out: dict) -> dict:
"""내보낸 로직 → 엔진 셈 — 단가 줄은 SK 서버처럼 값을 얹음(요소 대신 값묶음 {비목: 단가})."""
logic = {**out["로직"], "키": "GX990001"}
items = []
for item in logic["호표"]:
one = {k: v for k, v in item.items() if k not in ("이음", "자원", "단가", "규격")}
if "단가" in item:
money = dict.fromkeys(K[:3], 0) | {item["비목"]: item["단가"]}
one |= {"종류": "로직", "값묶음": money}
del one["비목"]
items.append(one | {"요소": None})
logic["호표"] = items
master = mf.Master({"로직.json": {"그룹": "로직", "줄": [logic]}})
return mcut.engine(master, logic["키"], mf.run(master, logic["키"], {}))
def hard(out: dict) -> list[str]:
"""못 옮김 표지만(「참고 · …」 사람 확인 표지 · 797-1a 는 뺌 — test_797_1a 가 지킴)."""
return [m for m in out["원본"]["표지"] if em.NOTE not in m]
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][3])
def _lines_equal(out: dict, got: dict) -> tuple[list, int, int]:
"""원본 글줄마다 엔진 줄 금액(계) = 엑셀 · 단계 = 계표시 줄 → (틀린 것, 줄 수, 단계 수)."""
lines = {x["이름"]: x for x in got["줄"]}
steps = {x["이름"]: x for x in got.get("단계", [])}
bad, n_line, n_step = [], 0, 0
for g in out["원본"]["글줄"]:
if g.get("값") is None or not g.get("자리") or g["자리"].startswith("mid:"):
continue
if g["자리"].startswith("단계:"):
n_step += 1
have = float(steps[g["자리"][3:]]["계"])
tol = 0.15
else:
n_line += 1
have = sum(float(lines[n]["금액"] or 0) for n in g.get("자리들") or [g["자리"]])
tol = 0.05
if abs(have - g["값"]) >= tol:
bad.append((g["행"], g["글"].strip(), have, g["값"]))
return bad, n_line, n_step
@pytest.mark.parametrize("tag", list(STMATE))
def test_stmate_engine_equals_excel(tag: str) -> None:
data = _load(tag)
blocks, n_lines, n_steps, rule = STMATE[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, out = _stmate(tag, blk)
got = engine(out)
ref = ev.stmate_block(blk, nodes, data["단가"], rule)["계"]
want = out["원본"]["엑셀금액"]
same = money(got) == tuple(round(want[k]) for k in K)
same &= money(got) == (ref["L"], ref["M"], ref["S"], ref["T"])
bad, a, b = _lines_equal(out, got)
bad += ms.check(out["로직"], blk["코드"]) + hard(out)
lines, steps = lines + a, steps + b
if deal := out["원본"].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]))
assert (n, wrong) == (blocks, [])
assert (lines, steps) == (n_lines, n_steps)
@pytest.mark.parametrize("tag", list(EST))
def test_est_engine_equals_excel(tag: str) -> None:
data = _load(tag)
n, wrong, lines = 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
n += 1
parsed = est.parse_block(blk["글"], data["단가"])
out = em.est(blk, parsed, want)
got = engine(out)
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"])
bad, a, _ = _lines_equal(out, got)
bad += ms.check(out["로직"], str(blk["번"]))
lines += a
frozen = len(hard(out))
if not same or bad or frozen != EST_FROZEN.get(tag, {}).get(blk["번"], 0):
wrong.append((blk["번"], money(got), bad[:3], hard(out)))
assert (n, wrong, lines) == (EST[tag][0], [], EST[tag][1])
@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)
_, out = _stmate(tag, blk)
assert money(engine(out))[:3] == before
assert em.reprice(out["로직"], {"코드": labor}, {"L": 180000}) >= 1
assert money(engine(out))[: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)
out = em.est(blk, est.parse_block(blk["글"], data["단가"]), data["총괄"][no])
assert em.reprice(out["로직"], LABOR, {"L": 180000.0}) >= 1
assert money(engine(out))[:3] == after
def test_link_keys_m01() -> None:
"""호표 줄 = 793-2 꼴(종류 · 단가 · 이음 = 1차 줄 판정 M01 키) · 덧칸 자원 · 원본."""
tag = "봉화"
blk = next(b for b in _load(tag)["블록"] if b["코드"] == "D00102")
out = em.stmate(blk, stm.parse_block(blk["몸"]), _load(tag)["단가"], "비목버림", True)
labor = next(x for x in out["로직"]["호표"] if x["이름"] == "보통인부")
assert {k: v for k, v in labor.items() if k != "자원"} == {
"이름": "보통인부",
"규격": "",
"단위": "인",
"수량": "(2 / 50)",
"종류": "인력",
"비목": "노무비",
"단가": 165545,
"이음": "LB000002",
"번지": "A02",
}
assert labor["자원"] == {
"꼴": "STmate",
"갈래": "인력",
"코드": "L00007",
"이름": "보통인부",
"규격": None,
"단위": "인",
"단가": {"L": 165545, "M": 0, "S": 0},
"비목": "L",
"옮김": None,
}
src = out["원본"]
assert {k: src[k] for k in ("시트", "갈래", "코드", "행")} == {
"시트": "단가산출근거",
"갈래": "STmate",
"코드": "D00102",
"행": [2912, 2937],
}
assert src["엑셀금액"] == {"노무비": 34119, "재료비": 11192, "경비": 341, "계": 45652}
assert next(g for g in src["글줄"] if g.get("자리") == "보통인부")["값"] == 6621.8
# ── 엑셀 없이 도는 문법 견본(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}
out = em.stmate(block, nodes, prices, "비목버림")
logic = out["로직"]
assert logic["중간"] == [
{"이름": "q", "식": "(2.5 / 3)", "표시": 2},
{"이름": "q_2", "식": "(1 / 3)", "표시": 2}, # 다시 대입 = 새 이름
]
assert ms.check(logic, "견본") == [] and out["원본"]["표지"] == []
got = engine(out)
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}
out = em.est({"번": 1, "이름": "견본", "단위": "㎡"}, lines, want)
logic = out["로직"]
assert [x["식"] for x in logic["덧줄"]] == [
"((줄.'보통인부' * 3) / 100)",
"줄.'보통인부' + 공구손료",
"소계",
]
assert logic["호표"][0]["종류"] == "인력" and logic["호표"][0]["단가"] == 172068
assert logic["덧줄"][2]["자리"] == {"방법": "버림", "자리": 0}
assert [x.get("합밖") for x in (*logic["호표"], *logic["덧줄"])] == [True, True, True, None]
assert money(engine(out)) == (15950, 0, 0, 15950)
em.reprice(logic, LABOR, {"L": 180000.0})
assert money(engine(out)) == (16686, 0, 0, 16686) # 793-1 run_est 전파와 같음