From 0792a17e2c5fa0735e04d0475fbda76e6e6a089b Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sat, 7 Nov 2026 12:07:36 +0900 Subject: [PATCH] =?UTF-8?q?feat(797-1a):=20=EC=97=91=EC=85=80=20=EB=B6=88?= =?UTF-8?q?=EB=9F=AC=EC=98=A4=EA=B8=B0=20=EB=B9=A0=EC=A7=84=20=EC=A0=95?= =?UTF-8?q?=EB=B3=B4=20=ED=91=9C=EC=A7=80=20=EB=8B=A4=EC=84=AF=20+=20?= =?UTF-8?q?=EC=A4=84=20=EB=8B=A8=EC=9C=84=20=ED=95=A9=EA=B2=A9=20=EC=8B=9C?= =?UTF-8?q?=ED=97=98=20=C2=B7=20=ED=91=9C=EB=B3=B8=20=EB=B0=96=20=EA=B3=A0?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 금액 줄 못 읽음: 결과 뒤 꼬리 글은 떼고 금액 줄로 읽음(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 --- M02_MasterTemplete/M02_San_Book.py | 29 +- M02_MasterTemplete/M02_San_Emit.py | 36 ++- M02_MasterTemplete/M02_San_Est.py | 9 +- M02_MasterTemplete/M02_Sangeun_FromHopyo.py | 25 +- resources/tester/test_793_2_san_emit.py | 11 +- resources/tester/test_797_1a_import_flags.py | 286 +++++++++++++++++++ 6 files changed, 378 insertions(+), 18 deletions(-) create mode 100644 resources/tester/test_797_1a_import_flags.py diff --git a/M02_MasterTemplete/M02_San_Book.py b/M02_MasterTemplete/M02_San_Book.py index f02e2b789..676685b1a 100644 --- a/M02_MasterTemplete/M02_San_Book.py +++ b/M02_MasterTemplete/M02_San_Book.py @@ -162,17 +162,33 @@ def _n(v: Any) -> float | None: return None -def applied(r: list[Any]) -> dict[str, float]: - """총괄표 줄 → {T, L, M, S} — 적용 칸(5~8) 합이 맞으면 적용 · 아니면 당초(25~28).""" +def applied_how(r: list[Any]) -> tuple[dict[str, float], str | None]: + """총괄표 줄 → ({T, L, M, S}, 쓴 칸 표지) — 적용 칸(5~8) 합이 맞으면 적용 · 아니면 당초(25~28). + 표지 = 당초를 쓴 경우(까닭) · 적용 ≠ 당초인 경우(차이) · 그 밖 None(PLAN 797-1a).""" a = {k: _n(r[j]) for k, j in (("T", 5), ("L", 6), ("M", 7), ("S", 8))} + first = {k: _n(r[j]) for k, j in (("T", 25), ("L", 26), ("M", 27), ("S", 28))} + names = {"T": "계", "L": "노무비", "M": "재료비", "S": "경비"} + gap = " · ".join( + f"{names[k]} 적용 {a[k] or 0:g} · 당초 {first[k]:g}" + for k in "TLMS" + if a["T"] and first[k] is not None and abs((a[k] or 0) - first[k]) >= 0.5 + ) 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))} + return a, (f"적용 칸 사용 — 당초와 다름 ({gap})" if gap else None) + got = {k: (v or 0.0) for k, v in first.items()} + if not got["T"]: + return got, None # 쓸 값이 없음 — 표지할 것 없음 + why = "적용 칸 합이 안 맞음" if a["T"] else "적용 칸 비어 있음" + return got, f"당초 칸 사용 — {why}" + (f" ({gap})" if gap else "") + + +def applied(r: list[Any]) -> dict[str, float]: + return applied_how(r)[0] def est_prices(book: dict[str, list]) -> tuple[list[dict[str, Any]], dict[int, dict[str, Any]]]: @@ -209,8 +225,9 @@ def est_prices(book: dict[str, list]) -> tuple[list[dict[str, Any]], dict[int, d 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])} + got, note = applied_how(r) + san[n] = {k: float(v or 0) for k, v in got.items()} + san[n] |= {"이름": flat(r[2]), "규격": flat(r[3]), "칸": note} for k in "TLMS": if san[n][k] or k != "T": one = {"종류": "산근", "이름": f"단산{n}", "규격": ""} diff --git a/M02_MasterTemplete/M02_San_Emit.py b/M02_MasterTemplete/M02_San_Emit.py index 1177dd9a0..b8652e517 100644 --- a/M02_MasterTemplete/M02_San_Emit.py +++ b/M02_MasterTemplete/M02_San_Emit.py @@ -16,6 +16,8 @@ 계약 전), 글줄 [{행, 글, 자리?, 자리들?, 값?, 표지?}], 코드, 표지 [..], 계약? {낙찰률, 금액}} · 글줄 자리 = 셈 줄 이름 | `mid:이름` | `단계:이름` · 값 = 그 줄 엑셀 금액(계) 표지 = 못 옮긴 까닭(0 이 목표) · STmate 계약단가(낙찰률)는 B09 몫. + 「참고 · …」 표지(`NOTE`) = 엑셀에 없는 정보를 채우거나 건너뛴 자리(797-1a · 요구 658) — + 금액 줄 못 읽음 · 단가 후보 여럿 · 단가 추정 · 당초 칸 사용 · 조정 비율 미지원 · 낙찰률 미적용. """ from __future__ import annotations @@ -36,6 +38,7 @@ RESERVED = {"노무비", "재료비", "경비", "계", "번지", "단계", "줄" RESERVED |= {"또는", "만약", "올림", "버림", "반올림", "최소", "최대"} _KEY = re.compile(r"[A-Z]{2}\d{6}") _LABEL = re.compile(r"'([^']*)'") +NOTE = "참고 · " # 사람 확인 표지 머리(못 옮김 표지와 가름) class EmitError(Exception): @@ -212,7 +215,7 @@ class Out: if value is not None: one["값"] = value if mark: - one["표지"] = mark + one["표지"] = f"{one['표지']} / {mark}" if one.get("표지") else mark self.marks.append(f"{at}: {mark}") def middle(self, name: str, formula: str, show: int | None, at: Any, glyph: str) -> None: @@ -435,6 +438,19 @@ def _cols(row: dict) -> dict[str, Any]: return _money({"L": row["D"], "M": row["E"], "S": row["F"]}) | {"계": row["C"] or 0} +def _num(v: Any) -> float: + try: + return float(str(v).strip()) + except ValueError: + return 0.0 + + +def _ratios(meta: dict) -> str: + """결과금액 조정 비율 ADJL · ADJM · ADJS 중 1 아닌 것 → 「노무비 0.9 · …」 · 모두 1 = 빈 글.""" + pairs = [(COMP[c], meta.get(f"ADJ{c}")) for c in COMP] + return " · ".join(f"{n} {v}" for n, v in pairs if v is not None and abs(_num(v) - 1) > 1e-9) + + def stmate( block: dict, nodes: list[dict], prices: dict, rule: str, keys: bool = False, file: str = "" ) -> dict: @@ -473,6 +489,12 @@ def stmate( adjust, sbm = None, (meta.get("SBM") or "").strip() if meta.get("ADJ") == "T" and sbm in COMP: adjust = {"옮김": COMP[sbm]} + elif meta.get("ADJ") == "T" and _ratios(meta): # 비율 조정 — 엔진에 없음(몰래 건너뛰지 않음) + o.marks.append(f"{NOTE}조정 비율 미지원 · 사람 확인 ({_ratios(meta)})") + if meta.get("UC") != "T" and _num(meta.get("NAG")): + o.marks.append( + f"{NOTE}낙찰률 {meta['NAG']} 있으나 계약단가 아님(UC) — 낙찰률 미적용 · 사람 확인" + ) more = {} if meta.get("UC") == "T" and meta.get("NAG"): # 계약단가 = B09 몫(엔진 밖) more["계약"] = {"낙찰률": float(meta["NAG"]), "금액": _cols(block["머리"])} @@ -502,6 +524,8 @@ def est(block: dict, lines: list[dict], want: dict, keys: bool = False, file: st mine = {k: c for c, k in des.items() if isinstance(k, int)} o = Out(block.get("이름"), block.get("단위"), "EST", block.get("번")) rows = block.get("글행") or [] + if want.get("칸"): # 당초 칸 사용 · 적용 ≠ 당초(797-1a) + o.marks.append(f"{NOTE}{want['칸']}") for c, k in des.items(): if k is None: o.marks.append(f"성분 {COMP[c]} {want[c]} 을 낸 줄 없음") @@ -519,7 +543,11 @@ def est(block: dict, lines: list[dict], want: dict, keys: bool = False, file: st o.middle(eng, num(L["값"]), None, at, glyph) # 얼린 값(금액엔 보인 값이 박힘) continue if form not in ("돈", "값"): - o.glyph(at, glyph) + shown = L.get("보인") if form == "글끝" else L.get("값") if form == "식못읽음" else None + if shown is None: + o.glyph(at, glyph) + else: # 금액 줄 못 읽음 — 보인 값 얼림(글줄 값으로 보존) + o.glyph(at, glyph, value=shown, mark=f"{NOTE}금액 줄 못 읽음 · 보인 값 얼림") continue head = {} if comp else {"합밖": True} try: @@ -535,6 +563,10 @@ def est(block: dict, lines: list[dict], want: dict, keys: bool = False, file: st one = resource(o.names.item(L.get("라벨") or ""), None, qty, res, link) one |= {**head, **({"자리": place} if place else {})} o.add_line(one, True, at, glyph, L["값"]) + if role.get("모호"): # 임의 선택 아님 — 후보를 사람이 고름 + o.glyph(at, glyph, mark=f"{NOTE}단가 후보 여럿({role['남은']}) · 사람 확인") + elif role.get("갈림") in ("문맥", "기계 세 줄"): + o.glyph(at, glyph, mark=f"{NOTE}추정 — 단가 정체 근거 {role['갈림']}") ref[k] = f"줄.'{one['이름']}'" continue if kind == "값줄": diff --git a/M02_MasterTemplete/M02_San_Est.py b/M02_MasterTemplete/M02_San_Est.py index ba2f20eae..d78d56639 100644 --- a/M02_MasterTemplete/M02_San_Est.py +++ b/M02_MasterTemplete/M02_San_Est.py @@ -35,6 +35,7 @@ RES = re.compile( ) _PLAIN = re.compile(r"[\s\d.,×÷+\-()A-Za-z가-힣㎡㎥]*") _LABEL = re.compile(r"^(?P[^::=]{0,40}?)\s*[::]\s*(?P.*)$") +TAIL = re.compile(r"^\s*(" + NUMRE + r")\s*\.?\s*[::][^=]*$") # 결과 뒤 꼬리 글 「3,094.2 : 채집」 _HEADING = re.compile(r"^\s*(\d+|[가-하])\s*[.)]") @@ -144,8 +145,13 @@ def parse_line(text: str) -> dict[str, Any]: return {"꼴": "값", "라벨": label, "비목": label_comp(label), "값": num(m.group(1))} return {"꼴": "글"} segs = rhs.split("=") - mres = RES.match(segs[-1]) + mres = RES.match(segs[-1]) or TAIL.match(segs[-1]) # 꼬리 글은 떼고 읽음(797-1a) if not mres: + lead = re.match(r"\s*(" + NUMRE + r")", segs[-1]) + if ( + label and lead and re.search(r"\d", "=".join(segs[:-1])) + ): # 금액 줄 못 읽음 — 보인 값 보존 + return {"꼴": "글끝", "라벨": label, "보인": num(lead.group(1))} return {"꼴": "글끝"} shown = num(mres.group(1)) toks = None @@ -313,6 +319,7 @@ def identify(lines: list[dict[str, Any]]) -> None: 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 + role["남은"] = len(ids) pick = sorted(ids)[0] p = next(p for p in cands if _ident(p) == pick) role["정체"] = {"종류": p["종류"], "이름": p["이름"], "규격": p["규격"], "비목": p["비목"]} diff --git a/M02_MasterTemplete/M02_Sangeun_FromHopyo.py b/M02_MasterTemplete/M02_Sangeun_FromHopyo.py index 8ae88c45d..3697db2f5 100644 --- a/M02_MasterTemplete/M02_Sangeun_FromHopyo.py +++ b/M02_MasterTemplete/M02_Sangeun_FromHopyo.py @@ -30,19 +30,32 @@ def _path(folder: str | Path, key: str) -> Path: return hopyo_store._dir(folder) / DIRNAME / f"{key}.json" -def keep(folder: str | Path, key: str, name: str, data: bytes) -> None: - """올린 엑셀 → 산근 시트 둠(STmate · EST 꼴만 · 못 읽으면 안 둠 — 올리기는 됨).""" +def unreadable(data: bytes) -> str | None: + """읽기 불가 사유(797-1a) — 빈 파일 · DRM 암호(파일 머리 `DRMONE`) · 아니면 None.""" + if not data: + return "빈 파일" + if b"DRMONE" in data[:16]: + return "DRM 암호 파일 — 암호 풀린 사본을 올림" + return None + + +def keep(folder: str | Path, key: str, name: str, data: bytes) -> str | None: + """올린 엑셀 → 산근 시트 둠(STmate · EST 꼴만 · 못 읽으면 안 둠 — 올리기는 됨). + 돌려줌 = 못 읽은 사유(빈 파일 · DRM 암호 · 형식 오류) · 둠 · 산근 꼴 아님 = None.""" from M02_MasterTemplete import M02_San_Book as book + if why := unreadable(data): + return why try: sheets = book.sheets(name, data) - if not book.kind_of(sheets): - return - except Exception: # noqa: BLE001 — 산근은 덤(가져온 식 열기만 막힘) - return + except Exception as e: # noqa: BLE001 — 산근은 덤(가져온 식 열기만 막힘) · 사유는 돌려줌 + return f"엑셀 형식 오류 — {type(e).__name__}: {str(e)[:80]}" + if not book.kind_of(sheets): + return None path = _path(folder, key) path.parent.mkdir(parents=True, exist_ok=True) atomic_write_json(path, {"파일": name, "시트": sheets}) + return None def has(folder: str | Path, key: str) -> bool: diff --git a/resources/tester/test_793_2_san_emit.py b/resources/tester/test_793_2_san_emit.py index 98462c758..55983f12e 100644 --- a/resources/tester/test_793_2_san_emit.py +++ b/resources/tester/test_793_2_san_emit.py @@ -57,6 +57,11 @@ def engine(out: dict) -> dict: 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) @@ -111,7 +116,7 @@ def test_stmate_engine_equals_excel(tag: str) -> None: 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["코드"]) + out["원본"]["표지"] + 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]] @@ -140,9 +145,9 @@ def test_est_engine_equals_excel(tag: str) -> None: bad, a, _ = _lines_equal(out, got) bad += ms.check(out["로직"], str(blk["번"])) lines += a - frozen = len(out["원본"]["표지"]) + 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], out["원본"]["표지"])) + wrong.append((blk["번"], money(got), bad[:3], hard(out))) assert (n, wrong, lines) == (EST[tag][0], [], EST[tag][1]) diff --git a/resources/tester/test_797_1a_import_flags.py b/resources/tester/test_797_1a_import_flags.py new file mode 100644 index 000000000..34073714a --- /dev/null +++ b/resources/tester/test_797_1a_import_flags.py @@ -0,0 +1,286 @@ +"""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["단가줄"], + )