- 금액 줄 못 읽음: 결과 뒤 꼬리 글은 떼고 금액 줄로 읽음(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>
287 lines
14 KiB
Python
287 lines
14 KiB
Python
"""797-1a 지킴 — 엑셀 불러오기 빠진 정보 표지 다섯 · 줄 단위 합격 · 표본 밖 둘 고정(요구 658).
|
||
|
||
표지 다섯(`M02_San_Emit` · `M02_San_Est` · `M02_San_Book` · `M02_Sangeun_FromHopyo`) —
|
||
① 금액 줄 못 읽음(글끝 · 식못읽음 · 결과 뒤 꼬리 글은 떼고 읽음) ② 단가 후보 여럿 · 추정
|
||
③ 당초 / 적용 칸 ④ 조정 비율 · 낙찰률 미지원 ⑤ 읽기 불가 사유 — 작은 견본(엑셀 없이).
|
||
줄 단위 합격 = 블록 합만이 아니라 표지 없는 줄 중 틀린 줄 0(표지 달린 줄은 틀림에서 뺌) ·
|
||
표본 밖(개발에 안 쓴 파일: 2026 수목진료 142 · 울진대흥 공통 46 · 2016 사방 374)을 합격 수로 고정 ·
|
||
EST 단가줄 버림1 일치율 감시(낮아지면 실패). 실무문서 엑셀 없으면 skip(읽기만).
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
from functools import lru_cache
|
||
from pathlib import Path
|
||
|
||
import helper_m02_hopyo_golden as golden
|
||
import pytest
|
||
|
||
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_Expr as ex
|
||
from M02_MasterTemplete import M02_San_Stmate as stm
|
||
from M02_MasterTemplete import M02_Sangeun_FromHopyo as fh
|
||
|
||
NOTE = em.NOTE
|
||
DOCS = golden.DOCS
|
||
|
||
|
||
def notes(out: dict) -> list[str]:
|
||
return [m for m in out["원본"]["표지"] if NOTE in m]
|
||
|
||
|
||
def hard(out: dict) -> list[str]:
|
||
"""못 옮김 표지(참고 표지 뺌)."""
|
||
return [m for m in out["원본"]["표지"] if NOTE not in m]
|
||
|
||
|
||
# ── ① 금액 줄 못 읽음 ──────────────────────────────────────────────────
|
||
TAIL = " 보통인부 : 94,338 ×0.0328인 = 3,094.2 : 채집"
|
||
PRICE_L = {"종류": "노임", "이름": "보통인부", "규격": "", "비목": "L", "값": 94338.0}
|
||
|
||
|
||
def _est_out(text: list[str], prices: list[dict], want: dict):
|
||
rows = list(range(10, 10 + len(text)))
|
||
blk = {"이름": "견본", "단위": "m", "번": 1, "글": text, "글행": rows}
|
||
lines = est.parse_block(text, prices)
|
||
return lines, em.est(blk, lines, want)
|
||
|
||
|
||
def test_tail_text_is_stripped_and_read_as_money() -> None:
|
||
one = est.parse_line(TAIL)
|
||
assert (one["꼴"], one["값"], one["라벨"]) == ("돈", 3094.2, "보통인부")
|
||
want = {"T": 3094, "L": 3094, "M": 0, "S": 0}
|
||
lines, out = _est_out([TAIL, " 노무비 : 3,094"], [PRICE_L], want)
|
||
assert [x["종류"] for x in lines] == ["단가줄", "값줄"]
|
||
assert hard(out) == [] and notes(out) == []
|
||
assert out["로직"]["호표"][0]["이름"] == "보통인부"
|
||
|
||
|
||
def test_unreadable_money_line_is_flagged_with_shown_value() -> None:
|
||
cut = " 절취량 : (0.5×0.6×1.0) / 2 = 0.15㎥/m"
|
||
note = " 야산지 25%, 경사도 중(15~30˚)) 5% => 30% 할증"
|
||
bad = " 산출 : 가나다 = 12.5"
|
||
assert est.parse_line(cut) == {"꼴": "글끝", "라벨": "절취량", "보인": 0.15}
|
||
assert est.parse_line(note) == {"꼴": "글끝"} # 라벨 없는 설명 글 — 표지 안 함
|
||
assert est.parse_line(bad)["꼴"] == "식못읽음"
|
||
zero = {"T": 0, "L": 0, "M": 0, "S": 0}
|
||
_, out = _est_out([cut, note, bad], [], zero)
|
||
got = {g["행"]: g for g in out["원본"]["글줄"]}
|
||
flag = f"{NOTE}금액 줄 못 읽음 · 보인 값 얼림"
|
||
assert (got[10]["표지"], got[10]["값"]) == (flag, 0.15)
|
||
assert "표지" not in got[11] and "값" not in got[11]
|
||
assert (got[12]["표지"], got[12]["값"]) == (flag, 12.5)
|
||
assert len(notes(out)) == 2 and hard(out) == []
|
||
|
||
|
||
# ── ② 단가 후보 여럿 · 추정 ────────────────────────────────────────────
|
||
def test_ambiguous_price_is_flagged_not_silently_picked() -> None:
|
||
prices = [
|
||
{"종류": "자재", "이름": "철근", "규격": "", "비목": "M", "값": 500.0},
|
||
{"종류": "자재", "이름": "시멘트", "규격": "", "비목": "M", "값": 500.0},
|
||
]
|
||
text = [" 철근 : 500 ×2개 = 1,000", " 자재비 : 500 ×3개 = 1,500"]
|
||
want = {"T": 1500, "L": 0, "M": 1500, "S": 0}
|
||
lines, out = _est_out(text, prices, want)
|
||
assert [x["역할"][0]["갈림"] for x in lines] == ["문맥", "문맥"]
|
||
assert [x["역할"][0]["모호"] for x in lines] == [False, True]
|
||
got = {g["행"]: g["표지"] for g in out["원본"]["글줄"]}
|
||
assert got[10] == f"{NOTE}추정 — 단가 정체 근거 문맥"
|
||
assert got[11] == f"{NOTE}단가 후보 여럿(2) · 사람 확인"
|
||
assert hard(out) == []
|
||
assert [it["자원"].get("모호") for it in out["로직"]["호표"]] == [None, True]
|
||
|
||
|
||
# ── ③ 당초 / 적용 칸 ───────────────────────────────────────────────────
|
||
def _row(applied: tuple, first: tuple) -> list:
|
||
r = [None] * 29
|
||
for j, v in zip((5, 6, 7, 8), applied, strict=True):
|
||
r[j] = v
|
||
for j, v in zip((25, 26, 27, 28), first, strict=True):
|
||
r[j] = v
|
||
return r
|
||
|
||
|
||
def test_applied_or_first_column_is_flagged() -> None:
|
||
same = book.applied_how(_row((100, 40, 30, 30), (100, 40, 30, 30)))
|
||
assert same == ({"T": 100, "L": 40, "M": 30, "S": 30}, None)
|
||
diff = book.applied_how(_row((100, 40, 30, 30), (90, 40, 30, 20)))
|
||
assert diff[0]["T"] == 100
|
||
assert diff[1] == "적용 칸 사용 — 당초와 다름 (계 적용 100 · 당초 90 · 경비 적용 30 · 당초 20)"
|
||
empty = book.applied_how(_row((None, None, None, None), (90, 40, 30, 20)))
|
||
assert empty[0] == {"T": 90, "L": 40, "M": 30, "S": 20}
|
||
assert empty[1] == "당초 칸 사용 — 적용 칸 비어 있음"
|
||
sums = book.applied_how(_row((100, 40, 30, 10), (90, 40, 30, 20)))
|
||
assert sums[0]["T"] == 90
|
||
assert (
|
||
sums[1]
|
||
== "당초 칸 사용 — 적용 칸 합이 안 맞음 (계 적용 100 · 당초 90 · 경비 적용 10 · 당초 20)"
|
||
)
|
||
nothing = book.applied_how(_row((None,) * 4, (0, 0, 0, 0)))
|
||
assert nothing[1] is None # 쓸 값이 없는 줄
|
||
assert book.applied(_row((100, 40, 30, 30), (90, 40, 30, 20)))["T"] == 100
|
||
|
||
|
||
def test_first_column_note_reaches_block_flags() -> None:
|
||
head = [[None] * 29 for _ in range(3)]
|
||
sheet = [*head, [None, 1, "견본", ""] + [None] * 25]
|
||
sheet[3][25:29] = [90, 40, 30, 20]
|
||
prices, san = book.est_prices({"단가산출총괄표": sheet})
|
||
assert san[1]["칸"] == "당초 칸 사용 — 적용 칸 비어 있음" and san[1]["T"] == 90
|
||
assert {(p["종류"], p["비목"], p["값"]) for p in prices} >= {("산근", "T", 90.0)}
|
||
_, out = _est_out([" 노무비 : 40"], [], {**san[1]})
|
||
assert f"{NOTE}당초 칸 사용 — 적용 칸 비어 있음" in out["원본"]["표지"]
|
||
|
||
|
||
# ── ④ 조정 비율 · 낙찰률 미지원 ────────────────────────────────────────
|
||
def _stmate_out(meta: dict) -> dict:
|
||
prices = {
|
||
"L00007": {"T": 100, "L": 100, "M": 0, "S": 0, "meta": {}, "이름": "보통인부"},
|
||
"D00001": {"T": 0, "L": 0, "M": 0, "S": 0, "meta": meta, "단위": "식"},
|
||
}
|
||
rows = [{"행": 0, "G": "'보통인부' &L00007& * 1 :=A01"}]
|
||
block = {"코드": "D00001", "이름": "견본", "꼬리": [], "몸": rows}
|
||
block["머리"] = {"A": "", "B": "", "C": 0, "D": 0, "E": 0, "F": 0}
|
||
return em.stmate(block, stm.parse_block(rows), prices, "비목버림")
|
||
|
||
|
||
def test_adjust_ratio_and_bid_rate_are_flagged() -> None:
|
||
base = {"ADJ": "T", "SBM": " ", "ADJL": "1", "ADJM": "1", "ADJS": "1", "UC": "F", "NAG": "0"}
|
||
assert _stmate_out(base)["원본"]["표지"] == [] # 비율 1 · 낙찰률 없음 = 표지 없음
|
||
odd = _stmate_out({**base, "ADJL": "0.9"})["원본"]["표지"]
|
||
assert odd == [f"{NOTE}조정 비율 미지원 · 사람 확인 (노무비 0.9)"]
|
||
sbm = _stmate_out({**base, "SBM": "S", "ADJL": "0.9"})
|
||
assert sbm["로직"]["조정"] == {"옮김": "경비"} and sbm["원본"]["표지"] == []
|
||
bid = _stmate_out({**base, "UC": "F", "NAG": "88.5"})["원본"]["표지"]
|
||
assert bid == [f"{NOTE}낙찰률 88.5 있으나 계약단가 아님(UC) — 낙찰률 미적용 · 사람 확인"]
|
||
used = _stmate_out({**base, "UC": "T", "NAG": "88.5"})
|
||
assert used["원본"]["표지"] == [] and used["원본"]["계약"]["낙찰률"] == 88.5
|
||
|
||
|
||
# ── ⑤ 읽기 불가 사유 ───────────────────────────────────────────────────
|
||
def test_unreadable_reason_is_returned(tmp_path: Path) -> None:
|
||
def keep(name: str, data: bytes) -> str | None:
|
||
return fh.keep(tmp_path, "HP990001", name, data)
|
||
|
||
assert keep("a.xlsx", b"") == "빈 파일"
|
||
assert keep("a.xlsx", b"\x9b DRMONE\x00\x01") == "DRM 암호 파일 — 암호 풀린 사본을 올림"
|
||
assert (keep("a.xlsx", b"PK\x03\x04 not a zip") or "").startswith("엑셀 형식 오류")
|
||
assert (keep("a.xls", b"plain text") or "").startswith("엑셀 형식 오류")
|
||
assert not fh.has(tmp_path, "HP990001") # 못 읽은 파일은 안 둠
|
||
|
||
|
||
# ── 줄 단위 합격 · 표본 밖 고정 · 버림1 감시 ───────────────────────────
|
||
def _oos() -> dict[str, Path]:
|
||
pack = DOCS / "2026_실무일위대가 모음"
|
||
common = DOCS / "2024년 간선임도(기번3-울진.대흥)(공통)/1. 설계원본/01 내역자료"
|
||
found = {
|
||
"2016사방": next(pack.glob("2016*.xls"), None),
|
||
"2026수목": next(pack.glob("2026_상반*.xls"), None),
|
||
"대흥공통": next((p for p in common.glob("*.xlsx") if not p.name.startswith("~$")), None),
|
||
}
|
||
return {k: v for k, v in found.items() if v}
|
||
|
||
|
||
FILES = {**golden.FILES, **_oos()}
|
||
|
||
|
||
@lru_cache(maxsize=16)
|
||
def _load(tag: str) -> dict:
|
||
path = FILES[tag]
|
||
if not path.exists():
|
||
pytest.skip(f"실무 엑셀 없음 {path.name}")
|
||
return book.load(path)
|
||
|
||
|
||
def _stmate_tally(tag: str) -> dict:
|
||
data = _load(tag)
|
||
t = dict.fromkeys(("블록", "맞음", "금액줄", "표지없는틀린줄", "계줄", "계줄맞음"), 0)
|
||
for blk in data["블록"]:
|
||
if not any(ln["G"].strip() for ln in blk["몸"]):
|
||
continue
|
||
nodes = stm.parse_block(blk["몸"])
|
||
rule = fh._rule(blk, nodes, data["단가"])
|
||
chk = ev.stmate_check(blk, ev.stmate_block(blk, nodes, data["단가"], rule))
|
||
out = em.stmate(blk, nodes, data["단가"], rule, False, tag)
|
||
marked = {g["행"] for g in out["원본"]["글줄"] if g.get("표지")}
|
||
t["블록"] += 1
|
||
t["맞음"] += chk["맞음"]
|
||
t["금액줄"] += chk["금액줄"]
|
||
t["계줄"] += chk["계줄"]
|
||
t["계줄맞음"] += chk["계줄맞음"]
|
||
t["표지없는틀린줄"] += sum(1 for b in chk["틀린줄"] if b["행"] not in marked)
|
||
return t
|
||
|
||
|
||
def _est_tally(tag: str) -> dict:
|
||
data = _load(tag)
|
||
t = dict.fromkeys(("블록", "맞음", "줄수", "표지없는틀린줄", "표지줄", "단가줄", "버림1"), 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)
|
||
out = em.est(blk, parsed, want, False, tag)
|
||
rows = blk.get("글행") or []
|
||
marked = {g["행"] for g in out["원본"]["글줄"] if g.get("표지")}
|
||
t["블록"] += 1
|
||
t["맞음"] += chk["맞음"]
|
||
t["줄수"] += chk["줄수"]
|
||
t["표지줄"] += len(marked)
|
||
for b in chk["틀린줄"]:
|
||
k = b["차례"]
|
||
t["표지없는틀린줄"] += (rows[k] if k < len(rows) else k) not in marked
|
||
for ln in parsed:
|
||
if ln.get("종류") == "단가줄" and ln.get("셈") is not None:
|
||
t["단가줄"] += 1
|
||
t["버림1"] += abs(ex.RULE["버림1"](ln["셈"]) - ln["값"]) < ex.EPS
|
||
return t
|
||
|
||
|
||
# 표본 밖 = 개발에 안 쓴 파일(합격 수 고정) · 골든 = 개발에 쓴 파일
|
||
OOS_FIXED = {
|
||
"2026수목": {"블록": 142, "맞음": 142, "줄수": 1654},
|
||
"대흥공통": {"블록": 46, "맞음": 46, "금액줄": 258, "계줄": 127, "계줄맞음": 127},
|
||
"2016사방": {"블록": 374, "맞음": 374, "줄수": 1870},
|
||
}
|
||
# 단가줄 버림1 일치 수 / 단가줄 수(낮아지면 실패 — 다른 규칙으로 갈리는 줄이 늘었다는 뜻)
|
||
FLOOR_1 = {
|
||
"북부청임도": (539, 552),
|
||
"북부청사방": (363, 364),
|
||
"거창": (381, 382),
|
||
"2016사방": (718, 718),
|
||
"2026수목": (653, 653),
|
||
}
|
||
|
||
|
||
@pytest.mark.parametrize("tag", list(FILES))
|
||
def test_no_unmarked_wrong_line(tag: str) -> None:
|
||
"""줄 단위 — 표지 없는 틀린 줄 0 · 블록 다시 셈 = 엑셀 전부."""
|
||
est_form = _load(tag)["꼴"] == "EST"
|
||
t = _est_tally(tag) if est_form else _stmate_tally(tag)
|
||
assert t["블록"] > 0
|
||
assert (t["표지없는틀린줄"], t["맞음"]) == (0, t["블록"]), t
|
||
|
||
|
||
@pytest.mark.parametrize("tag", list(OOS_FIXED))
|
||
def test_out_of_sample_counts_fixed(tag: str) -> None:
|
||
if tag not in FILES:
|
||
pytest.skip("표본 밖 엑셀 없음")
|
||
t = _est_tally(tag) if tag != "대흥공통" else _stmate_tally(tag)
|
||
assert {k: t[k] for k in OOS_FIXED[tag]} == OOS_FIXED[tag]
|
||
|
||
|
||
@pytest.mark.parametrize("tag", list(FLOOR_1))
|
||
def test_unit_price_line_floor1_rate_does_not_drop(tag: str) -> None:
|
||
if tag not in FILES:
|
||
pytest.skip("실무 엑셀 없음")
|
||
t = _est_tally(tag)
|
||
hit, total = FLOOR_1[tag]
|
||
assert t["단가줄"] >= total and t["버림1"] / t["단가줄"] >= hit / total, (
|
||
t["버림1"],
|
||
t["단가줄"],
|
||
)
|