Files
Aislo/resources/tester/test_797_1a_import_flags.py
T
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

287 lines
14 KiB
Python
Raw 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.
"""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["단가줄"],
)